diff --git a/config/automation/.vscode/settings.json b/config/automation/.vscode/settings.json new file mode 100644 index 0000000..a04b218 --- /dev/null +++ b/config/automation/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "files.associations": { + "*.yaml": "home-assistant" + } +} \ No newline at end of file diff --git a/config/automation/broadlink.yaml.old b/config/automation/broadlink.yaml.old new file mode 100644 index 0000000..e1d4ea4 --- /dev/null +++ b/config/automation/broadlink.yaml.old @@ -0,0 +1,13 @@ +- id: '1584370618810' + alias: learn IR code + description: '' + trigger: + - entity_id: input_boolean.learn_ir_code + from: 'off' + platform: state + to: 'on' + condition: [] + action: + - data: + host: 10.0.0.106 + service: broadlink.learn \ No newline at end of file diff --git a/config/automation/chauff_sdb.yaml b/config/automation/chauff_sdb.yaml new file mode 100644 index 0000000..e69de29 diff --git a/config/automation/climate.yaml b/config/automation/climate.yaml new file mode 100644 index 0000000..e69de29 diff --git a/config/automation/telegram.yaml b/config/automation/telegram.yaml new file mode 100644 index 0000000..01ad2f9 --- /dev/null +++ b/config/automation/telegram.yaml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/config/automation/telegram_camera.yaml b/config/automation/telegram_camera.yaml new file mode 100644 index 0000000..e69de29 diff --git a/config/automation/telegram_chauff.yaml b/config/automation/telegram_chauff.yaml new file mode 100644 index 0000000..e69de29 diff --git a/config/automation/telegram_volet.yaml b/config/automation/telegram_volet.yaml new file mode 100644 index 0000000..e69de29 diff --git a/config/automation/test.yaml b/config/automation/test.yaml new file mode 100644 index 0000000..e69de29 diff --git a/config/automation/time.yaml b/config/automation/time.yaml new file mode 100644 index 0000000..e69de29 diff --git a/config/automation/vmc.yaml b/config/automation/vmc.yaml new file mode 100644 index 0000000..e69de29 diff --git a/config/automation/volet.yaml b/config/automation/volet.yaml new file mode 100644 index 0000000..e69de29 diff --git a/config/automations.yaml b/config/automations.yaml index 0637a08..a1941e5 100644 --- a/config/automations.yaml +++ b/config/automations.yaml @@ -1 +1,2305 @@ -[] \ No newline at end of file +- id: motion_sensor_light_on + alias: Motion Sensor Lights On + trigger: + - platform: state + entity_id: + - binary_sensor.pir_sensor_2 + from: 'off' + to: 'on' + condition: + - condition: sun + before: sunrise + after: sunset + enabled: true + action: + - service: switch.turn_on + data: {} + target: + entity_id: switch.sonoff_basic_relay_escalier + enabled: false + - service: light.turn_on + data: {} + target: + entity_id: light.sonoff_couloir + enabled: false + - delay: + hours: 0 + minutes: 0 + seconds: 10 + milliseconds: 0 + enabled: false + - service: light.turn_on + data: + color_temp: 488 + target: + entity_id: light.lumieres_couloir + - service: light.turn_on + data: + color_temp: 493 + target: + entity_id: light.lumieres_escaliers + - delay: + hours: 0 + minutes: 3 + seconds: 0 + milliseconds: 0 + enabled: true + - service: light.turn_off + data: {} + target: + entity_id: + - light.sonoff_couloir + enabled: false + - service: switch.turn_off + data: {} + target: + entity_id: switch.sonoff_basic_relay_escalier + enabled: false + - service: light.turn_off + data: {} + enabled: true + target: + entity_id: light.lumieres_escaliers + - service: light.turn_off + data: {} + target: + entity_id: light.lumieres_couloir +- id: 935b2285deb8492e9f3ed53233e8e72b + alias: Lux Sensor Lights Off + trigger: + - platform: numeric_state + entity_id: sensor.kc868_a8_d758d0_d758d0_bh1750_illuminance + above: 100 + condition: + - condition: or + conditions: + - condition: sun + after: sunrise + - condition: sun + before: sunset + action: + - service: light.turn_off + entity_id: light.plafond_cuisine + - service: light.turn_off + entity_id: light.applique_cuisine + - service: light.turn_off + entity_id: light.lumieres_plafond + - service: light.turn_off + entity_id: light.applique_salon +- id: porte_garage_light_on_1 + alias: Porte garage Lights On + trigger: + - platform: state + entity_id: binary_sensor.esp32_4_relays_garage_5a10c8_porte_garage_01 + to: 'on' + condition: + - condition: state + entity_id: sun.sun + state: below_horizon + action: + - service: light.toggle + data: {} + target: + entity_id: + - light.eclairage_bois + - delay: 00:10:00 + - service: light.turn_off + data: {} + target: + entity_id: + - light.eclairage_bois +- id: porte_cave_light_on_1 + alias: Porte cave Lights On + trigger: + - platform: state + entity_id: + - binary_sensor.wemos_cave_porte + to: 'on' + action: + - service: switch.turn_on + data: {} + target: + entity_id: switch.sonoff_4ch_relay_1 +- id: porte_cave_light_off_1 + alias: Porte cave Lights Off + trigger: + - platform: state + entity_id: binary_sensor.wemos_cave_porte + from: 'on' + to: 'off' + action: + - service: switch.turn_off + data: {} + target: + entity_id: switch.sonoff_4ch_relay_1 + - service: light.turn_on + data: {} + target: + entity_id: light.lumieres_couloir + - service: light.turn_on + data: {} + target: + entity_id: light.lumieres_escaliers + - delay: 00:02 + - service: light.turn_off + data: {} + target: + entity_id: light.lumieres_couloir + - service: light.turn_off + data: {} + target: + entity_id: light.lumieres_escaliers +- id: porte_bureau_light_on_1 + alias: Porte bureau Lights On + trigger: + - platform: state + entity_id: binary_sensor.wemos_bureau0_porte + to: 'on' + condition: + - condition: or + conditions: + - condition: sun + after: sunset + - condition: sun + before: sunrise + action: + - service: light.turn_on + data: {} + target: + entity_id: light.bureau_rdc +- id: porte_bureau_light_off_1 + alias: Porte bureau Lights Off + trigger: + - platform: state + entity_id: + - binary_sensor.wemos_bureau0_porte + to: 'off' + action: + - service: light.turn_off + data: {} + target: + entity_id: light.bureau_rdc + - service: light.turn_on + data: {} + target: + entity_id: light.lumieres_couloir + - delay: 00:02:00 +- id: SDB_chauff_OFF_1 + alias: Chauffage OFF + trigger: + - platform: numeric_state + entity_id: sensor.t_salle_de_bain + above: 19 + action: + service: switch.turn_off + entity_id: switch.sonoff_pow_relay +- id: '1584210783162' + alias: reglage du thermostat1 + description: '' + trigger: + - at: '22:40:00' + platform: time + condition: [] + action: + - data: + temperature: 17 + service: climate.set_temperature + target: + entity_id: + - climate.salon_3 +- id: '1584210783163' + alias: reglage du thermostat2 + description: '' + trigger: + - at: 00:40:00 + platform: time + condition: [] + action: + - data: + temperature: 16 + service: climate.set_temperature + target: + entity_id: + - climate.salon_3 +- id: '1584210783164' + alias: reglage du thermostat3 + description: '' + trigger: + - at: '13:40:00' + platform: time + condition: [] + action: + - data: + temperature: 17 + service: climate.set_temperature + target: + entity_id: climate.salon_3 +- id: '1584210783165' + alias: reglage du thermostat4 + description: '' + trigger: + - at: '16:40:00' + platform: time + condition: [] + action: + - data: + temperature: 18 + service: climate.set_temperature + target: + entity_id: climate.salon_3 +- id: light_on_le_matin + alias: Lights On le matin + trigger: + - platform: time + at: 06:15:00 + condition: + - condition: state + entity_id: binary_sensor.workday_sensor + state: 'on' + action: + - service: light.turn_on + entity_id: light.plafond_cuisine + - delay: 00:15:00 + - service: light.turn_on + data: {} + target: + entity_id: light.lumieres_couloir + - delay: 00:50:00 + - service: light.turn_off + data: {} + target: + entity_id: + - light.lumieres_couloir + - light.plafond_cuisine +- id: chauffage_SDB + alias: chauffage SDB + trigger: + platform: time + at: 05:30:00 + action: + - service: switch.turn_off + entity_id: switch.zb_30a_relay_chauffe_eau + - delay: 00:00:10 + - service: switch.turn_on + entity_id: switch.sonoff_pow_relay + - delay: 00:45:00 + - service: switch.turn_off + entity_id: switch.sonoff_pow_relay +- id: chauffage_ECS + alias: chauffage ECS + trigger: + - platform: time + at: '14:30:00' + condition: + - condition: time + weekday: + - mon + - wed + - fri + action: + - service: switch.turn_off + entity_id: switch.sonoff_pow_relay + - delay: 00:00:10 + - service: switch.turn_on + data: {} + target: + entity_id: switch.zb_30a_relay_chauffe_eau + - delay: + hours: 3 + minutes: 30 + seconds: 0 + milliseconds: 0 + - service: switch.turn_off + data: {} + target: + entity_id: switch.zb_30a_relay_chauffe_eau +- id: '1584208402327' + alias: descendre volet porte + description: '' + trigger: + - platform: sun + event: sunset + offset: 00:35:00 + condition: + condition: or + conditions: + - condition: state + entity_id: sensor.season + state: winter + - condition: state + entity_id: sensor.season + state: spring + - condition: state + entity_id: sensor.season + state: autumn + action: + - data: + entity_id: cover.volet_porte + position: 35 + service: cover.set_cover_position +- id: '1584209402327' + alias: descendre volet porte + description: '' + trigger: + - at: '22:00:00' + platform: time + condition: + - condition: or + conditions: + - condition: state + entity_id: sensor.season + state: winter + - condition: state + entity_id: sensor.season + state: spring + - condition: state + entity_id: sensor.season + state: autumn + action: + - data: + position: 28 + service: cover.set_cover_position + target: + entity_id: cover.volet_porte_volet_porte +- id: '1584208402328' + alias: descendre volet salon 1 + description: '' + trigger: + - platform: sun + event: sunset + offset: 00:37:00 + condition: + condition: or + conditions: + - condition: state + entity_id: sensor.season + state: winter + - condition: state + entity_id: sensor.season + state: spring + - condition: state + entity_id: sensor.season + state: autumn + action: + - data: + entity_id: cover.volet_salon1 + position: 0 + service: cover.set_cover_position +- id: '1584208402329' + alias: descendre volet salon 2 + description: '' + trigger: + - platform: sun + event: sunset + offset: 00:38:00 + condition: + - condition: or + conditions: + - condition: state + entity_id: sensor.season + state: winter + - condition: state + entity_id: sensor.season + state: spring + - condition: state + entity_id: sensor.season + state: autumn + action: + - data: + position: 0 + service: cover.set_cover_position + target: + entity_id: cover.volet_salon_2_volet_salon_2 +- id: '1584208402339' + alias: descendre volet arriere + description: '' + trigger: + - platform: sun + event: sunset + offset: 00:35:00 + condition: + - condition: or + conditions: + - condition: state + entity_id: sensor.season + state: winter + - condition: state + entity_id: sensor.season + state: spring + - condition: state + entity_id: sensor.season + state: autumn + action: + - data: + position: 8 + service: cover.set_cover_position + target: + entity_id: cover.volet_arriere_volet_arriere +- id: '1584208402330' + alias: descendre volet chambre 1 + description: '' + trigger: + - platform: sun + event: sunset + offset: 00:36:00 + condition: + condition: or + conditions: + - condition: state + entity_id: sensor.season + state: winter + - condition: state + entity_id: sensor.season + state: spring + - condition: state + entity_id: sensor.season + state: autumn + action: + - data: + entity_id: cover.chambre_1 + position: 0 + service: cover.set_cover_position +- id: '1584208402331' + alias: descendre volet chambre 2 + description: '' + trigger: + - platform: sun + event: sunset + offset: 00:37:00 + condition: + condition: or + conditions: + - condition: state + entity_id: sensor.season + state: winter + - condition: state + entity_id: sensor.season + state: spring + - condition: state + entity_id: sensor.season + state: autumn + action: + - data: + entity_id: cover.chambre_2 + position: 0 + service: cover.set_cover_position +- id: '1584208412327' + alias: descendre volet porte + description: '' + trigger: + - platform: sun + event: sunrise + offset: -00:05:00 + condition: + condition: or + conditions: + - condition: state + entity_id: sensor.season + state: winter + - condition: state + entity_id: sensor.season + state: spring + - condition: state + entity_id: sensor.season + state: autumn + action: + - data: + entity_id: cover.volet_porte + position: 100 + service: cover.set_cover_position +- id: '1584208412328' + alias: descendre volet salon 1 + description: '' + trigger: + - platform: sun + event: sunrise + offset: -00:04:00 + condition: + condition: or + conditions: + - condition: state + entity_id: sensor.season + state: winter + - condition: state + entity_id: sensor.season + state: spring + - condition: state + entity_id: sensor.season + state: autumn + action: + - data: + entity_id: cover.volet_salon1 + position: 100 + service: cover.set_cover_position +- id: '1584208412329' + alias: descendre volet salon 2 + description: '' + trigger: + - platform: sun + event: sunrise + offset: -00:06:00 + condition: + condition: or + conditions: + - condition: state + entity_id: sensor.season + state: winter + - condition: state + entity_id: sensor.season + state: spring + - condition: state + entity_id: sensor.season + state: autumn + action: + - data: + entity_id: cover.volet_salon2 + position: 100 + service: cover.set_cover_position +- id: '1584208412339' + alias: descendre volet arriere + description: '' + trigger: + - platform: sun + event: sunrise + offset: -00:04:00 + condition: + condition: or + conditions: + - condition: state + entity_id: sensor.season + state: winter + - condition: state + entity_id: sensor.season + state: spring + - condition: state + entity_id: sensor.season + state: autumn + action: + - data: + entity_id: cover.arriere + position: 100 + service: cover.set_cover_position +- id: '1584208412330' + alias: descendre volet chambre 1 + description: '' + trigger: + - platform: sun + event: sunrise + offset: -00:07:00 + condition: + condition: or + conditions: + - condition: state + entity_id: sensor.season + state: winter + - condition: state + entity_id: sensor.season + state: spring + - condition: state + entity_id: sensor.season + state: autumn + action: + - data: + entity_id: cover.chambre_1 + position: 100 + service: cover.set_cover_position +- id: '1584208412331' + alias: descendre volet chambre 2 + description: '' + trigger: + - platform: sun + event: sunrise + offset: -00:08:00 + condition: + condition: or + conditions: + - condition: state + entity_id: sensor.season + state: winter + - condition: state + entity_id: sensor.season + state: spring + - condition: state + entity_id: sensor.season + state: autumn + action: + - data: + entity_id: cover.chambre_2 + position: 100 + service: cover.set_cover_position +- id: vmc_auto + alias: VMC_auto + trigger: + - platform: time_pattern + hours: /1 + action: + - service: switch.turn_on + data: {} + target: + entity_id: switch.sonoff_r2_vmc_relay2 + - delay: + hours: 0 + minutes: 10 + seconds: 0 + milliseconds: 0 + - service: switch.turn_off + data: {} + target: + entity_id: switch.sonoff_r2_vmc_relay2 +- id: vmc_hygro + alias: VMC_hygro + trigger: + - platform: numeric_state + entity_id: sensor.h_salle_de_bain + above: 83 + for: + minutes: 2 + action: + - service: switch.turn_on + data: {} + target: + entity_id: switch.sonoff_r2_vmc_relay + - delay: + hours: 0 + minutes: 10 + seconds: 0 + milliseconds: 0 + - service: switch.turn_on + data: {} + target: + entity_id: switch.sonoff_r2_vmc_relay +- id: porte_escalier_light_on_1 + alias: Porte escalier Lights On + trigger: + - platform: state + entity_id: binary_sensor.esp8266_tampon_temp_4d756f_esp8266_tampon_temp_porte_escalier + to: 'on' + condition: + - condition: or + conditions: + - condition: sun + after: sunset + - condition: sun + before: sunrise + action: + - service: light.turn_on + data: {} + target: + entity_id: light.lumieres_escaliers + - delay: 00:10:00 + - service: light.turn_off + data: {} + target: + entity_id: light.lumieres_escaliers +- id: alarm21584208412331 + alias: Déclencher une alarme lorsque vous êtes armé + trigger: + - platform: state + entity_id: binary_sensor.esp32_4_relays_garage_5a10c8_porte_garage_01 + to: 'on' + - platform: state + entity_id: binary_sensor.esp8266_tampon_temp_4d756f_esp8266_tampon_temp_porte_escalier + to: 'on' + condition: + - condition: state + entity_id: alarm_control_panel.home_alarm + state: armed_away + action: + - service: alarm_control_panel.alarm_trigger + target: + entity_id: alarm_control_panel.home_alarm +- alias: Envoyer une notification lorsque l alarme est déclenchée + id: alarm1584208412331 + trigger: + - platform: state + entity_id: alarm_control_panel.home_alarm + to: triggered + action: + - service: notify.notify + data: + message: ALARME! L'alarme a été déclenchée +- id: '1647212391302' + alias: Allumage automatique dashboard + description: '' + trigger: + - platform: time_pattern + hours: '06' + minutes: '00' + seconds: '00' + condition: + - condition: state + entity_id: binary_sensor.workday_sensor + state: 'on' + action: + - service: switch.turn_on + data: {} + target: + entity_id: switch.yoga + mode: single +- id: '1647212976229' + alias: Extinction dashboard + description: '' + trigger: + - platform: time_pattern + hours: '07' + minutes: '15' + seconds: '00' + condition: + - condition: state + entity_id: binary_sensor.workday_sensor + state: 'on' + action: + - service: switch.turn_off + data: {} + target: + entity_id: switch.yoga + mode: single +- id: '1647213126018' + alias: Allumage automatique dashboard2 + description: '' + trigger: + - platform: time_pattern + hours: '17' + minutes: '15' + seconds: '00' + condition: + - condition: state + entity_id: binary_sensor.workday_sensor + state: 'on' + action: + - service: switch.turn_on + data: {} + target: + entity_id: switch.yoga + mode: single +- id: '1661115407918' + alias: arret auto ballon 2h + description: '' + trigger: + - platform: state + entity_id: + - switch.zb_30a_relay_chauffe_eau + from: 'off' + to: 'on' + condition: [] + action: + - delay: + hours: 2 + minutes: 0 + seconds: 0 + milliseconds: 0 + - type: turn_off + device_id: 2d23b7cb9abcf02cfb24d3c138c29f28 + entity_id: 0f3c3b69a354e1c462e6176b973ac783 + domain: switch + mode: single +- id: '1663647864481' + alias: ouverture volet + description: '' + trigger: + - platform: time + at: '14:00:00' + condition: [] + action: + - service: cover.set_cover_position + data: + position: 83 + target: + entity_id: cover.volet_salon1 + - service: cover.set_cover_position + data: + position: 85 + target: + entity_id: cover.volet_salon2 + - service: cover.set_cover_position + data: + position: 81 + target: + entity_id: cover.volet_chambre1 + - service: cover.set_cover_position + data: + position: 87 + target: + entity_id: cover.volet_chambre2 + - service: cover.set_cover_position + data: + position: 24 + target: + entity_id: cover.volet_porte_2 + enabled: false + mode: single +- id: '1669575255730' + alias: Allume bureau RDC + description: '' + trigger: + - platform: state + entity_id: + - binary_sensor.wemos_bureau0_porte + from: '0' + to: '1' + condition: [] + action: + - type: turn_on + device_id: 8f339d247995a56da0e236c585fbf5c3 + entity_id: 6e588a63d704d35f4961ccfde37c4c24 + domain: light + mode: single +- id: '1673966882487' + alias: allume_dressing + description: '' + trigger: + - type: opened + platform: device + device_id: e42dfa2a334f4e7ff6fcdf0b3bad679a + entity_id: cef1f7040328449cb4fbb8ce80e937a0 + domain: binary_sensor + condition: [] + action: + - service: light.turn_on + data: {} + target: + entity_id: light.dressing + mode: single +- id: '1673966958993' + alias: Eteint dressing + description: '' + trigger: + - type: not_opened + platform: device + device_id: e42dfa2a334f4e7ff6fcdf0b3bad679a + entity_id: cef1f7040328449cb4fbb8ce80e937a0 + domain: binary_sensor + condition: [] + action: + - service: light.turn_off + data: {} + target: + entity_id: light.dressing + mode: single +- id: '1676819975278' + alias: notification courant compteur + description: '' + trigger: + - type: power + platform: device + device_id: a313df7677d0b61f7bc877c5842ac7fb + entity_id: 3a414ae30ce17744b790fae0155dbf78 + domain: sensor + above: 3300 + condition: [] + action: + - service: tts.google_say + data: + entity_id: media_player.hp_salon + message: Atttention le compteur va sauter + language: fr + - service: switch.turn_off + data: {} + target: + entity_id: switch.zb_30a_relay_chauffe_eau + mode: single +- id: '1677344177654' + alias: Radiation + description: '' + trigger: + - type: unsafe + platform: device + device_id: b07e1486aa2eaf8dd3812ebde6e8b2ec + entity_id: 45c5cc0f18c2fa50be487f01ff12f743 + domain: binary_sensor + for: + hours: 0 + minutes: 0 + seconds: 10 + condition: [] + action: + - service: notify.persistent_notification + data: + message: radiarion warning dangereux + title: radiation + mode: single +- id: '1677916892812' + alias: hasp2 + description: openHASP Day mode + trigger: + - platform: numeric_state + entity_id: sun.sun + attribute: elevation + above: 1 + condition: [] + action: + - service: mqtt.publish + data: + qos: 0 + retain: false + topic: hasp/plate1/command + payload: 'backlight {"state": 1, "brightness": 20}' + mode: single +- id: '1677924144659' + alias: Allume Hasp WT + description: '' + trigger: + - platform: state + entity_id: binary_sensor.pir_sensor_2 + from: 'off' + to: 'on' + condition: + - condition: sun + before: sunrise + after: sunset + enabled: false + action: + - service: script.wt32_sc01_wake_up + data: {} + - delay: + hours: 0 + minutes: 3 + seconds: 0 + milliseconds: 0 + - service: script.wt32_sc01_sleep + data: {} + mode: single +- id: '1678205941146' + alias: enteindre toute les lumieres + description: '' + trigger: + - platform: time + at: 08:20:00 + condition: [] + action: + - service: light.turn_off + data: {} + target: + entity_id: + - light.applique_cuisine + - light.bureau_rdc + - light.lumieres_couloir + - light.dressing + - light.eclairage_bois + - light.eclairage_cave + - light.light_bureau_z + - light.lumieres_escaliers + - light.lumieres_plafond + - light.plafond_cuisine + mode: single +- id: '1678205963063' + alias: allume toute les lumieres + description: '' + trigger: + - platform: time + at: 08:20:00 + condition: [] + action: + - service: light.turn_on + data: {} + target: + entity_id: + - light.applique_cuisine + - light.bureau_rdc + - light.lumieres_couloir + - light.dressing + - light.eclairage_bois + - light.eclairage_cave + - light.light_bureau_z + - light.lumieres_escaliers + - light.lumieres_plafond + - light.plafond_cuisine + mode: single +- id: '1681964652926' + alias: easun battery low + description: '' + trigger: + - type: battery_level + platform: device + device_id: 477cc04b209cbec1076e213a0a0a1bdd + entity_id: sensor.battery_soc + domain: sensor + below: 23 + condition: [] + action: + - device_id: 039b70e127d5dadf9db2ff249d45be5c + domain: select + entity_id: select.charger_source_priority_2 + type: select_option + option: Solar and utility simultaneously + - delay: + hours: 0 + minutes: 0 + seconds: 30 + milliseconds: 0 + - device_id: 039b70e127d5dadf9db2ff249d45be5c + domain: select + entity_id: select.output_source_priority_2 + type: select_option + option: Utility first + mode: single +- id: '1682268105598' + alias: 'easun passgeen PV ' + description: '' + trigger: + - type: irradiance + platform: device + device_id: ece62aa07124a7fa58b7d8e490850dcc + entity_id: sensor.ecowitt_solarradiation + domain: sensor + above: 50 + condition: + - condition: numeric_state + entity_id: sensor.battery_soc + above: 28 + action: + - device_id: 039b70e127d5dadf9db2ff249d45be5c + domain: select + entity_id: select.charger_source_priority_2 + type: select_option + option: Solar first + - delay: + hours: 0 + minutes: 0 + seconds: 30 + milliseconds: 0 + - device_id: 039b70e127d5dadf9db2ff249d45be5c + domain: select + entity_id: select.output_source_priority_2 + type: select_option + option: Solar first + mode: single +- id: '1685394964755' + alias: toggle chambre + description: '' + trigger: + - type: turned_on + platform: device + device_id: e42dfa2a334f4e7ff6fcdf0b3bad679a + entity_id: binary_sensor.kc868_a8_d758d0_d758d0_interrupteur_chambre1 + domain: binary_sensor + condition: [] + action: + - service: light.toggle + data: {} + target: + entity_id: light.plafond_chambre_z + mode: single +- id: '1686068928189' + alias: easun conso load power eleve + description: '' + trigger: + - type: power + platform: device + device_id: 039b70e127d5dadf9db2ff249d45be5c + entity_id: sensor.load_power_3 + domain: sensor + above: 2800 + condition: [] + action: + - device_id: 039b70e127d5dadf9db2ff249d45be5c + domain: select + entity_id: select.output_source_priority_2 + type: select_option + option: Utility first + - delay: + hours: 0 + minutes: 0 + seconds: 10 + milliseconds: 0 + - device_id: 039b70e127d5dadf9db2ff249d45be5c + domain: select + entity_id: select.output_source_priority_2 + type: select_option + option: Utility first + mode: single +- id: '1686071509982' + alias: easun conso load power retour normal + description: '' + trigger: + - type: power + platform: device + device_id: 039b70e127d5dadf9db2ff249d45be5c + entity_id: sensor.load_power_3 + domain: sensor + below: 1450 + condition: [] + action: + - device_id: 039b70e127d5dadf9db2ff249d45be5c + domain: select + entity_id: select.output_source_priority_2 + type: select_option + option: Solar first + mode: single +- id: '1686196586553' + alias: monter volet arriere + description: '' + trigger: + - platform: sun + event: sunrise + offset: -0:00:25 + condition: + - condition: or + conditions: + - condition: state + entity_id: sensor.season + state: winter + - condition: state + entity_id: sensor.season + state: spring + - condition: state + entity_id: sensor.season + state: autumn + - condition: state + entity_id: binary_sensor.workday_sensor + state: 'off' + action: + - data: + position: 100 + service: cover.set_cover_position + target: + entity_id: + - cover.volet_porte_volet_porte + mode: single +- id: '1686196753195' + alias: monter volet salon 2 + description: '' + trigger: + - platform: sun + event: sunrise + condition: + - condition: or + conditions: + - condition: state + entity_id: sensor.season + state: winter + - condition: state + entity_id: sensor.season + state: spring + - condition: state + entity_id: sensor.season + state: autumn + action: + - data: + position: 100 + service: cover.set_cover_position + target: + entity_id: cover.volet_salon_2_volet_salon_2 + mode: single +- id: '1686196833346' + alias: descendre volet salon 1 + description: '' + trigger: + - platform: sun + event: sunset + offset: 00:37:00 + condition: + - condition: or + conditions: + - condition: state + entity_id: sensor.season + state: winter + - condition: state + entity_id: sensor.season + state: spring + - condition: state + entity_id: sensor.season + state: autumn + action: + - data: + position: 0 + service: cover.set_cover_position + target: + entity_id: + - cover.volet_salon_1_volet_salon_1 + mode: single +- id: '1686196921286' + alias: monter volet salon 1 + description: '' + trigger: + - platform: sun + event: sunrise + condition: + - condition: or + conditions: + - condition: state + entity_id: sensor.season + state: winter + - condition: state + entity_id: sensor.season + state: spring + - condition: state + entity_id: sensor.season + state: autumn + action: + - data: + position: 100 + service: cover.set_cover_position + target: + entity_id: + - cover.volet_salon_1_volet_salon_1 + mode: single +- id: '1686375508963' + alias: allume aorus + description: '' + trigger: + - platform: device + domain: mqtt + device_id: 157ec5a089ec7c168bdf5dee18a3c068 + type: action + subtype: shake + discovery_id: 0x00158d0005d3f635 action_shake + condition: [] + action: + - service: switch.turn_on + data: {} + target: + entity_id: switch.tasmota + - delay: + hours: 0 + minutes: 0 + seconds: 5 + milliseconds: 0 + - service: switch.toggle + data: {} + target: + entity_id: switch.ryzen + mode: single +- id: '1688194990099' + alias: pas d arrosage + description: '' + trigger: + - platform: time + at: 06:00:00 + - platform: time + at: '18:00:00' + condition: + - condition: or + conditions: + - condition: numeric_state + entity_id: sensor.ecowitt_dailyrain + above: 5 + - condition: numeric_state + entity_id: sensor.ecowitt_eventrain + above: 5 + action: + - service: irrigation_unlimited.disable + data: + entity_id: binary_sensor.irrigation_unlimited_c1_m + mode: single +- id: '1688195035893' + alias: arrosage auto + description: '' + trigger: + - platform: time + at: 06:00:00 + - platform: time + at: '18:00:00' + condition: + - condition: and + conditions: + - condition: numeric_state + entity_id: sensor.ecowitt_dailyrain + below: 4 + - condition: numeric_state + entity_id: sensor.ecowitt_eventrain + below: 4 + action: + - service: irrigation_unlimited.enable + data: + entity_id: binary_sensor.irrigation_unlimited_c1_m + mode: single +- id: '1688737708801' + alias: moes 4 switch1 + description: '' + trigger: + - platform: device + domain: mqtt + device_id: 8a5fa172a17acae2954d20ec7188775b + type: action + subtype: 1_single + discovery_id: 0x70ac08fffee6d168 action_1_single + condition: [] + action: + - service: light.toggle + data: {} + target: + entity_id: light.lumieres_couloir + mode: single +- id: '1688737792542' + alias: moes 4 switch2 + description: '' + trigger: + - platform: device + domain: mqtt + device_id: 8a5fa172a17acae2954d20ec7188775b + type: action + subtype: 2_single + discovery_id: 0x70ac08fffee6d168 action_2_single + condition: [] + action: + - service: light.toggle + data: {} + target: + entity_id: light.plafond_chambre_z + mode: single +- id: '1688737807420' + alias: moes 4 switch3 + description: '' + trigger: + - platform: device + domain: mqtt + device_id: 8a5fa172a17acae2954d20ec7188775b + type: action + subtype: 3_single + discovery_id: 0x70ac08fffee6d168 action_3_single + condition: [] + action: + - service: switch.toggle + data: {} + target: + entity_id: switch.sonoff_basic_relay_escalier + mode: single +- id: '1688737822775' + alias: moes 4 switch4 + description: '' + trigger: + - platform: device + domain: mqtt + device_id: 8a5fa172a17acae2954d20ec7188775b + type: action + subtype: 4_single + discovery_id: 0x70ac08fffee6d168 action_4_single + condition: [] + action: + - service: light.toggle + data: {} + target: + entity_id: light.plafond_cuisine + mode: single +- id: '1688980093210' + alias: je pars + description: '' + trigger: + - platform: zone + entity_id: device_tracker.iphonex + zone: zone.home + event: leave + condition: [] + action: + - service: cover.close_cover + data: {} + target: + entity_id: cover.volet_porte_volet_porte + - service: notify.mobile_app_iphonex_3 + data: + message: le volet de la porte se ferme + title: volet porte + mode: single +- id: '1688980923500' + alias: J arrive + description: '' + trigger: + - platform: zone + entity_id: device_tracker.iphonex + zone: zone.home + event: enter + condition: [] + action: + - service: cover.set_cover_position + data: + position: 70 + mode: single +- id: '1690523777219' + alias: allume aorus2 + description: '' + trigger: + - platform: state + entity_id: + - switch.ryzen + from: 'off' + to: 'on' + condition: [] + action: + - service: switch.turn_on + data: {} + target: + entity_id: switch.tasmota + - delay: + hours: 0 + minutes: 0 + seconds: 5 + milliseconds: 0 + - service: switch.turn_on + data: {} + target: + entity_id: switch.ryzen + mode: single +- id: '1690637372276' + alias: apsystem beaucoup soleil + description: '' + trigger: + - type: irradiance + platform: device + device_id: ece62aa07124a7fa58b7d8e490850dcc + entity_id: 82a2c21dc63253a4a7512559a8234d92 + domain: sensor + above: 300 + condition: + - type: is_power + condition: device + device_id: a313df7677d0b61f7bc877c5842ac7fb + entity_id: 3a414ae30ce17744b790fae0155dbf78 + domain: sensor + below: 300 + - type: is_battery_level + condition: device + device_id: 3012934cdfcc3dc76324be73eab48e9e + entity_id: 6883b5c05e197fe7aa465ad129ad30ed + domain: sensor + below: 98 + action: + - type: turn_on + device_id: e0bc07770566e349f57fb6c01a589ed4 + entity_id: 16d753c211622421f689756eecfa2427 + domain: switch + - delay: + hours: 0 + minutes: 0 + seconds: 30 + milliseconds: 0 + - type: turn_on + device_id: 3012934cdfcc3dc76324be73eab48e9e + entity_id: fde76fb92fef7074e814f22e8828443c + domain: switch + mode: single +- id: '1690639616100' + alias: easun battery full + description: '' + trigger: + - type: battery_level + platform: device + device_id: 477cc04b209cbec1076e213a0a0a1bdd + entity_id: sensor.battery_soc + domain: sensor + above: 90 + condition: + - type: is_irradiance + condition: device + device_id: ece62aa07124a7fa58b7d8e490850dcc + entity_id: 82a2c21dc63253a4a7512559a8234d92 + domain: sensor + above: 5 + action: + - delay: + hours: 1 + minutes: 0 + seconds: 0 + milliseconds: 0 + - device_id: 039b70e127d5dadf9db2ff249d45be5c + domain: select + entity_id: select.charger_source_priority_2 + type: select_option + option: Solar only + - delay: + hours: 0 + minutes: 0 + seconds: 10 + milliseconds: 0 + - device_id: 039b70e127d5dadf9db2ff249d45be5c + domain: select + entity_id: select.output_source_priority_2 + type: select_option + option: Solar/Battery/Utility + mode: single +- id: '1690896885121' + alias: off aorus + description: '' + trigger: + - platform: device + domain: mqtt + device_id: 157ec5a089ec7c168bdf5dee18a3c068 + type: action + subtype: slide + discovery_id: 0x00158d0005d3f635 action_slide + condition: [] + action: [] + mode: single +- id: '1691318394973' + alias: cycle UV elegoo + description: '' + trigger: + - platform: device + type: turned_on + device_id: 12296fcf4051cff3e333257039c1d27d + entity_id: bd06db8121a920cc1bb72c1d16d4f58a + domain: switch + condition: [] + action: + - delay: + hours: 0 + minutes: 6 + seconds: 0 + milliseconds: 0 + - service: switch.turn_off + data: {} + target: + entity_id: switch.prise_salon_uv_z + - service: tts.google_say + data: + cache: false + entity_id: media_player.hp_salon + message: la séance d'uv est terminée + language: fr + mode: single +- id: '1691485011308' + alias: J arrive + description: '' + trigger: + - platform: zone + entity_id: device_tracker.iphonex + zone: zone.home + event: enter + condition: [] + action: + - service: cover.open_cover + data: {} + target: + entity_id: cover.volet_porte_volet_porte + mode: single +- id: '1692001008329' + alias: impression elegoo terminée + description: '' + trigger: + - platform: state + entity_id: + - binary_sensor.impression_sla_en_cours + from: 'on' + to: 'off' + condition: [] + action: + - service: tts.google_say + data: + cache: false + entity_id: media_player.hp_salon + message: l'impression sur l'elegoo est terminée + language: fr + mode: single +- id: '1692014714379' + alias: Eclairage hotte cuisine + description: '' + trigger: + - platform: device + domain: mqtt + device_id: a6d6e5b68e9ed69f82f6bccb330a430f + type: action + subtype: single + discovery_id: 0xa4c1386e85dbf484 action_single + condition: [] + action: + - service: switch.toggle + data: {} + target: + entity_id: switch.prise_eclairage_hotte_cuisine + mode: single +- id: '1692313211284' + alias: restart esphome's + description: '' + trigger: + - platform: state + entity_id: + - input_button.restart_esphome_s + condition: [] + action: + - service: switch.turn_on + data: {} + target: + entity_id: + - switch.h801light_6f9188_h801_restart + - switch.sonoff_4ch_restart_2 + - switch.kc868_a8_d758d0_d758d0_kc868_a8_restart + - switch.nmcuvoletporte_volet_porte_restart + - switch.esp32_4_relays_garage_5a10c8_esp32_4_relays_garage_restart + - switch.esp32_4_relays_garage_5a10c8_esp32_4_relays_garage_restart + - switch.sonoff_4ch_garage_restart + - switch.sonoff_r2_vmc_restart + - switch.nmcuvoletarriere1_volet_arriere_restart + - switch.nmcuvoletsalon1_volet_salon_1_restart + - switch.nmcuvoletsalon2_volet_salon_2_restart + - switch.eclairage_bois_restart + - switch.sonoff_dressing_restart_2 + - switch.sonoff_escalier_restart_2 + - switch.nmcuvoletchambre1_volet_chambre_1_restart + - switch.volet_chambre_2_restart_nmcuvoletchambre2 + - switch.nmcuvoletcuisine1_volet_cuisine_1_restart + - switch.nmcuvoletcuisine2_volet_cuisine_2_restart + - switch.meuble_dashboard_restart + - switch.wemos_pir_comble1_restart_2 + - switch.geiger_wemos_geiger_restart + mode: single +- id: '1697147323532' + alias: 4switchz_1_cuisine + description: '' + trigger: + - platform: device + domain: mqtt + device_id: a0783be10b7e41c8758160cb3f3332a2 + type: action + subtype: 1_single + discovery_id: 0xb43a31fffe2667d8 action_1_single + condition: [] + action: + - service: light.toggle + data: + brightness_pct: 67 + target: + entity_id: light.applique_cuisine + mode: single +- id: '1697518688800' + description: '' + trigger: + - platform: device + domain: mqtt + device_id: a0783be10b7e41c8758160cb3f3332a2 + type: action + subtype: 3_single + discovery_id: 0xb43a31fffe2667d8 action_3_single + condition: [] + action: + - service: light.toggle + data: {} + target: + entity_id: light.plafond_cuisine + mode: single +- id: '1697518854405' + alias: zi_switch3_togglecuisine + description: '' + trigger: + - platform: device + domain: mqtt + device_id: a0783be10b7e41c8758160cb3f3332a2 + type: action + subtype: 3_single + discovery_id: 0xb43a31fffe2667d8 action_3_single + condition: [] + action: + - service: light.toggle + data: {} + target: + entity_id: light.plafond_cuisine + mode: single +- id: '1699291265877' + description: '' + trigger: + - platform: zone + entity_id: device_tracker.iphonex + zone: zone.home + event: enter + condition: [] + action: + - service: cover.open_cover + data: {} + target: + entity_id: cover.volet_porte_volet_porte + mode: single +- id: '1699291405935' + alias: j arrive lumiere + description: '' + trigger: + - platform: zone + entity_id: device_tracker.iphonex + zone: zone.home + event: enter + condition: + - condition: sun + after: sunset + action: + - service: light.turn_on + data: {} + target: + entity_id: light.eclairage_bois + - delay: + hours: 0 + minutes: 10 + seconds: 0 + milliseconds: 0 + - service: light.turn_off + data: {} + target: + entity_id: light.eclairage_bois + mode: single +- id: '1700066940215' + alias: chaudiere fermer porte + description: '' + trigger: + - type: temperature + platform: device + device_id: 26137125cd7042f7127ecf3eed68c699 + entity_id: 544f7121b81fc415b1870af9b35b9fd8 + domain: sensor + above: 130 + condition: + - type: is_open + condition: device + device_id: 26137125cd7042f7127ecf3eed68c699 + entity_id: 9114f3f450c4a9992a4ac39bc2370377 + domain: binary_sensor + action: + - service: tts.speak + data: + cache: true + media_player_entity_id: media_player.hp_salon + message: fermer la porte + language: fr + target: + entity_id: tts.google_say + mode: single +- id: '1700067438541' + alias: rajouter du bois + description: '' + trigger: + - type: temperature + platform: device + device_id: 26137125cd7042f7127ecf3eed68c699 + entity_id: 544f7121b81fc415b1870af9b35b9fd8 + domain: sensor + below: 110 + condition: + - condition: state + entity_id: sensor.froling_s3_etat_chaudiere + state: '3' + - type: is_temperature + condition: device + device_id: 26137125cd7042f7127ecf3eed68c699 + entity_id: 9ea08059d271eff6c93061b320c0e6ae + domain: sensor + below: 50 + action: + - service: tts.speak + data: + cache: true + media_player_entity_id: media_player.hp_salon + message: ajouter du bois + target: + entity_id: tts.google_say + mode: single +- id: '1700428678105' + alias: ouverture porte garage + description: '' + trigger: + - type: opened + platform: device + device_id: a1197e1c85c0f91b5007c97f2fb044d8 + entity_id: a51ee606044b1a471c3389cae7feb66f + domain: binary_sensor + condition: + - condition: sun + after: sunset + before: sunrise + before_offset: 00:30:00 + after_offset: -00:30:00 + action: + - service: light.turn_on + data: {} + target: + entity_id: light.eclairage_bois + - delay: + hours: 0 + minutes: 5 + seconds: 0 + milliseconds: 0 + - service: light.turn_off + data: {} + target: + entity_id: light.eclairage_bois + mode: single +- id: '1701323663651' + alias: 4switch salon + description: '' + trigger: + - platform: device + domain: mqtt + device_id: a0783be10b7e41c8758160cb3f3332a2 + type: action + subtype: 4_single + discovery_id: 0xb43a31fffe2667d8 action_4_single + condition: [] + action: + - service: light.toggle + data: + brightness_pct: 67 + target: + entity_id: + - light.applique_salon + mode: single +- id: '1701539536870' + alias: purge_db + description: '' + trigger: + - platform: state + entity_id: + - input_button.purge_db + condition: [] + action: + - service: script.purge_database + data: {} + mode: single +- id: '1701586721944' + alias: Notification - Alertes météo + description: '' + trigger: + - platform: state + entity_id: + - sensor.alerte_meteo + from: + to: Jaune + - platform: state + entity_id: + - sensor.alerte_meteo + from: + to: Orange + - platform: state + entity_id: + - sensor.alerte_meteo + from: + to: Rouge + condition: [] + action: + - device_id: 4924071cde65b291a4b96864ae6e3d82 + domain: mobile_app + type: notify + message: 'Orages : {{ state_attr(''sensor.alerte_meteo'', ''Orages'') }} + + + Vent Violent : {{ state_attr(''sensor.alerte_meteo'', ''Vent Violent'') }} + + + Pluie Inondation : {{ state_attr(''sensor.alerte_meteo'', ''Pluie Inondation'') + }} + + + Inondation : {{ state_attr(''sensor.alerte_meteo'', ''Inondation'') }} + + + Canicule : {{ state_attr(''sensor.alerte_meteo'', ''Canicule'') }} + + + Grand Froid : {{ state_attr(''sensor.alerte_meteo'', ''Grand Froid'') }} + + + Neige Verglas : {{ state_attr(''sensor.alerte_meteo'', ''Neige Verglas'') }} + + ' + title: 'Alerte météo : {{ states(''sensor.alerte_meteo'') }} ' + mode: single +- id: '1701634998616' + alias: 'Frigate Notifications ' + description: '' + use_blueprint: + path: SgtBatten/Stable.yaml + input: + camera: camera.terrasse + notify_device: 4924071cde65b291a4b96864ae6e3d82 + base_url: http://maison43.duckdns.org:8123 + title: frigate + tap_action: '{{base_url}}/api/frigate/notifications/{{id}}/snapshot.jpg' + alert_once: true + ios_live_view: true + labels: + - person + - dog + - car + state_filter: false + cooldown: 76 + color: red + attachment: snapshot + url_1: '{{base_url}}/api/camera_proxy_stream/camera.{{trigger.payload_json[''after''][''camera''].lower()}}?token={{state_attr( + ''camera.'' ~ camera, ''access_token'')}}' + loiter_timer: 114 + silence_timer: 92 +- id: '1701725588574' + alias: ajouter du bois + description: '' + trigger: + - type: temperature + platform: device + device_id: 26137125cd7042f7127ecf3eed68c699 + entity_id: 544f7121b81fc415b1870af9b35b9fd8 + domain: sensor + below: 120 + for: + hours: 0 + minutes: 1 + seconds: 0 + condition: + - type: is_not_open + condition: device + device_id: 26137125cd7042f7127ecf3eed68c699 + entity_id: 9114f3f450c4a9992a4ac39bc2370377 + domain: binary_sensor + - type: is_temperature + condition: device + device_id: 26137125cd7042f7127ecf3eed68c699 + entity_id: 9ea08059d271eff6c93061b320c0e6ae + domain: sensor + below: 45 + action: + - service: tts.google_say + data: + cache: false + entity_id: media_player.hp_salon + message: ajouter du bois + language: fr + mode: single +- id: '1702411697026' + alias: matin allume garage + description: '' + trigger: + - type: opened + platform: device + device_id: db5c6c1763d37f1890a2f0b7ef1e0a71 + entity_id: 423657dccf57d3710e3afd3531039621 + domain: binary_sensor + for: + hours: 0 + minutes: 0 + seconds: 2 + condition: + - condition: time + after: 06:30:00 + before: 07:15:00 + weekday: + - mon + - tue + - wed + - thu + - fri + action: + - service: light.turn_on + target: + entity_id: light.lumiere_garage1 + data: {} + mode: single +- id: '1704002151378' + alias: presence cuisine + description: '' + trigger: + - type: present + platform: device + device_id: 5f4a2468d39c0264d04f5d7349dd2b34 + entity_id: bc10f7d7ea63a6e94a3db8e18b0ec6fd + domain: binary_sensor + condition: + - condition: sun + before: sunrise + after: sunset + enabled: true + action: + - service: switch.turn_on + target: + entity_id: switch.prise_eclairage_hotte_cuisine + data: {} + mode: single +- id: '1704002531297' + alias: fin detection cuisine + description: '' + trigger: + - type: not_present + platform: device + device_id: 5f4a2468d39c0264d04f5d7349dd2b34 + entity_id: bc10f7d7ea63a6e94a3db8e18b0ec6fd + domain: binary_sensor + condition: [] + action: + - service: switch.turn_off + target: + entity_id: + - switch.prise_eclairage_hotte_cuisine + device_id: [] + area_id: [] + data: {} + mode: single +- id: '1704354369737' + alias: recharge_toggle + description: '' + trigger: + - platform: device + domain: mqtt + device_id: b7bcb1edd4478de219bc35a8b10bb1d5 + type: action + subtype: 1_single + discovery_id: 0x943469fffe5f6024 action_1_single + condition: [] + action: + - type: toggle + device_id: b6be148a36401980ca193fe79da740f6 + entity_id: 5b3c12dea9525a50ae8632b5c2c0200f + domain: switch + mode: single +- id: '1705469874811' + alias: zb-btton- couloir + description: '' + trigger: + - platform: device + domain: mqtt + device_id: a0783be10b7e41c8758160cb3f3332a2 + type: action + subtype: 2_single + discovery_id: 0xb43a31fffe2667d8 action_2_single + condition: [] + action: + - type: toggle + device_id: f5a1b4424948da936c7d75e14f18afa3 + entity_id: 582588be67c51be8b765a15eb5ffd4aa + domain: light + mode: single +- id: '1707798145001' + alias: pir couloir + description: '' + trigger: + - type: motion + platform: device + device_id: b3754c8ee053109a4a37d3673b1f43cb + entity_id: c167b5b31415f51df78e137d49222981 + domain: binary_sensor + condition: + - condition: numeric_state + entity_id: sensor.kc868_a8_d758d0_d758d0_bh1750_illuminance + below: 10 + action: + - type: turn_on + device_id: dd8b56ae02d022db8d8a388fa7ac29c3 + entity_id: 959475d99663bf1db611fb71a6037334 + domain: light + - delay: + hours: 0 + minutes: 2 + seconds: 0 + milliseconds: 0 + - type: turn_off + device_id: dd8b56ae02d022db8d8a388fa7ac29c3 + entity_id: 959475d99663bf1db611fb71a6037334 + domain: light + mode: single +- id: '1710354523724' + alias: monter volet porte hiver + description: '' + trigger: + - platform: numeric_state + entity_id: + - sensor.ecowitt_tempin + below: sensor.ecowitt_temp + condition: + - condition: not + conditions: + - condition: state + entity_id: sensor.season + state: summer + action: + - device_id: 559dcd81bebb2fb7defc092d667462fc + domain: cover + entity_id: 85118468c05ea4d3c6ea63c604205fd0 + type: open + - type: turn_on + device_id: 559dcd81bebb2fb7defc092d667462fc + entity_id: 7d2cfae545ef09a61958f96ae1994a93 + domain: switch + mode: single +- id: '1710827908641' + alias: je part au boulot + description: '' + trigger: + - type: not_opened + platform: device + device_id: a1197e1c85c0f91b5007c97f2fb044d8 + entity_id: a51ee606044b1a471c3389cae7feb66f + domain: binary_sensor + condition: + - condition: time + after: 06:50:00 + before: 07:10:00 + action: + - service: light.turn_off + metadata: {} + data: {} + target: + entity_id: + - light.eclairage_bois + - light.lumieres_escaliers + - light.lumiere_garage1 + mode: single +- id: '1711426426777' + alias: easun y a plus de soleil + description: '' + trigger: + - platform: sun + event: sunset + offset: 0 + condition: [] + action: + - device_id: 039b70e127d5dadf9db2ff249d45be5c + domain: select + entity_id: select.charger_source_priority_2 + type: select_option + option: Utility first + - delay: + hours: 0 + minutes: 0 + seconds: 30 + milliseconds: 0 + - device_id: 039b70e127d5dadf9db2ff249d45be5c + domain: select + entity_id: select.output_source_priority_2 + type: select_option + option: Utility first + mode: single +- id: '1712403053393' + alias: si batterye vide + description: '' + trigger: + - platform: device + type: turned_on + device_id: 7210a849c3fa92342ae96167da2a106e + entity_id: 146669babe5d4bbb1ebe5fd7f9c3d2d5 + domain: switch + condition: + - type: is_battery_level + condition: device + device_id: 039b70e127d5dadf9db2ff249d45be5c + entity_id: edd574741ee09a1bfa19ad5efa86c744 + domain: sensor + above: 20 + action: + - type: turn_off + device_id: e0bc07770566e349f57fb6c01a589ed4 + entity_id: 16d753c211622421f689756eecfa2427 + domain: switch + mode: single +- id: '1712784173503' + alias: ecoflow batterie pleine + description: '' + trigger: + - type: battery_level + platform: device + device_id: 3012934cdfcc3dc76324be73eab48e9e + entity_id: 6883b5c05e197fe7aa465ad129ad30ed + domain: sensor + above: 95 + condition: + - condition: sun + after: sunset + action: + - delay: + hours: 0 + minutes: 30 + seconds: 0 + milliseconds: 0 + - type: turn_off + device_id: e0bc07770566e349f57fb6c01a589ed4 + entity_id: 16d753c211622421f689756eecfa2427 + domain: switch + mode: single +- id: '1714481820637' + alias: test bouton rotatif + description: '' + trigger: + - platform: device + domain: mqtt + device_id: 0f74f46551b0b58e87565e4ed32cd035 + type: action + subtype: rotate_left + condition: [] + action: + - type: turn_on + device_id: dd8b56ae02d022db8d8a388fa7ac29c3 + entity_id: 959475d99663bf1db611fb71a6037334 + domain: light + mode: single +- id: '1714481860932' + alias: test2 bouton rotatif + description: '' + trigger: + - platform: device + domain: mqtt + device_id: 0f74f46551b0b58e87565e4ed32cd035 + type: action + subtype: rotate_right + condition: [] + action: + - type: turn_off + device_id: dd8b56ae02d022db8d8a388fa7ac29c3 + entity_id: 959475d99663bf1db611fb71a6037334 + domain: light + mode: single +- id: '1714787013186' + alias: automatique purge db + description: '' + trigger: + - platform: time + at: 01:00:00 + condition: [] + action: + - service: automation.trigger + target: + entity_id: automation.purge_db + data: + skip_condition: true + mode: single +- id: '1714980226749' + alias: clear_log + description: '' + trigger: + - platform: state + entity_id: + - input_button.clear_log + condition: [] + action: + - service: script.1714980028797 + data: {} + mode: single +- id: '1715171546682' + alias: apsystem pas beaucoup soleil + description: '' + trigger: + - type: irradiance + platform: device + device_id: ece62aa07124a7fa58b7d8e490850dcc + entity_id: 82a2c21dc63253a4a7512559a8234d92 + domain: sensor + below: 290 + condition: [] + action: + - type: turn_off + device_id: 3012934cdfcc3dc76324be73eab48e9e + entity_id: fde76fb92fef7074e814f22e8828443c + domain: switch + - delay: + hours: 0 + minutes: 0 + seconds: 10 + milliseconds: 0 + - type: turn_off + device_id: e0bc07770566e349f57fb6c01a589ed4 + entity_id: 16d753c211622421f689756eecfa2427 + domain: switch + mode: single +- id: '1715350137885' + alias: cycle cuisson pilon + description: '' + trigger: + - platform: state + entity_id: + - input_button.cycle_prise_ronde + from: unknown + to: + condition: [] + action: + - type: turn_on + device_id: ea907b7803133511649ecb269be786ff + entity_id: a1c5260977db4e2f04bc3182d6effe2e + domain: switch + - delay: + hours: 0 + minutes: 15 + seconds: 15 + milliseconds: 0 + - type: turn_off + device_id: ea907b7803133511649ecb269be786ff + entity_id: a1c5260977db4e2f04bc3182d6effe2e + domain: switch + mode: single diff --git a/config/configuration.yaml b/config/configuration.yaml index cd26c66..5a9ff39 100644 --- a/config/configuration.yaml +++ b/config/configuration.yaml @@ -96,3 +96,41 @@ alarm_control_panel: api: wake_on_lan: + +command_line: + - sensor: + name: Météo France alertes 43 + unique_id: meteo_france_alertes_43 + scan_interval: 10800 + command: > + curl -X GET "https://public-api.meteofrance.fr/public/DPVigilance/v1/cartevigilance/encours' \ -H 'accept: */*' \ -H 'apikey: eyJ4NXQiOiJZV0kxTTJZNE1qWTNOemsyTkRZeU5XTTRPV014TXpjek1UVmhNbU14T1RSa09ETXlOVEE0Tnc9PSIsImtpZCI6ImdhdGV3YXlfY2VydGlmaWNhdGVfYWxpYXMiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJiaWxvYmEyMDA1QGNhcmJvbi5zdXBlciIsImFwcGxpY2F0aW9uIjp7Im93bmVyIjoiYmlsb2JhMjAwNSIsInRpZXJRdW90YVR5cGUiOm51bGwsInRpZXIiOiJVbmxpbWl0ZWQiLCJuYW1lIjoiRGVmYXVsdEFwcGxpY2F0aW9uIiwiaWQiOjEyODM3LCJ1dWlkIjoiZTczNDYwODctMjAzYS00NzFlLTg2MDMtOWVhZjkyYzQ5YTJiIn0sImlzcyI6Imh0dHBzOlwvXC9wb3J0YWlsLWFwaS5tZXRlb2ZyYW5jZS5mcjo0NDNcL29hdXRoMlwvdG9rZW4iLCJ0aWVySW5mbyI6eyI2MFJlcVBhck1pbiI6eyJ0aWVyUXVvdGFUeXBlIjoicmVxdWVzdENvdW50IiwiZ3JhcGhRTE1heENvbXBsZXhpdHkiOjAsImdyYXBoUUxNYXhEZXB0aCI6MCwic3RvcE9uUXVvdGFSZWFjaCI6dHJ1ZSwic3Bpa2VBcnJlc3RMaW1pdCI6MCwic3Bpa2VBcnJlc3RVbml0Ijoic2VjIn19LCJrZXl0eXBlIjoiUFJPRFVDVElPTiIsInN1YnNjcmliZWRBUElzIjpbeyJzdWJzY3JpYmVyVGVuYW50RG9tYWluIjoiY2FyYm9uLnN1cGVyIiwibmFtZSI6IkRvbm5lZXNQdWJsaXF1ZXNWaWdpbGFuY2UiLCJjb250ZXh0IjoiXC9wdWJsaWNcL0RQVmlnaWxhbmNlXC92MSIsInB1Ymxpc2hlciI6ImFkbWluIiwidmVyc2lvbiI6InYxIiwic3Vic2NyaXB0aW9uVGllciI6IjYwUmVxUGFyTWluIn1dLCJleHAiOjE3NDY1MjcyOTcsInRva2VuX3R5cGUiOiJhcGlLZXkiLCJpYXQiOjE3MTQ5OTEyOTcsImp0aSI6ImQ3OTQwZmMxLTYzZTgtNGQ1ZC1iZTBiLWZkZjdlN2RlNjkyMSJ9.JUskxCK07-Acn_bn_SRXjRFqTK7Azj-MGLVa7BP4xLeHbhXS5o2SX0Wn85XH55Il8JPnDZ47Pye4Zp_U5sqqFWWUkqN_B23ztc7YHJ5nXwDIg1sxNBT8fJLhIWD9s0NtqdmdDqU5OlqXkgSsh0nN9o0zLT9auj1-eSuZje8Ua84q1sDBrdc6wChbMRFPAX8OqDbQTErqpLhA5VtQNWPpORlArvTqj2t0XPX_Bi8YtaV0HNom57C3LDY1kzDLClejjfSDf80F7vByRpLHGl8qnokyw_aciLJaFubsRobuTcx_IcWJa3YXy7LVyLax_MZnFXr6jabR9t4XU0_28ax48w==" | jq '{details: {"domain_max_color_id_today": .product.periods[0].timelaps.domain_ids[78].max_color_id,"domain_max_color_id_tomorrow": .product.periods[1].timelaps.domain_ids[78].max_color_id, "update_time": .product.update_time}, "today": .product.periods[0].timelaps.domain_ids[78].phenomenon_items | sort_by(.phenomenon_id), "tomorrow": .product.periods[1].timelaps.domain_ids[78].phenomenon_items | sort_by(.phenomenon_id)}' + value_template: " {{ value_json.details.domain_max_color_id_today }} " + json_attributes: + - details + - today + - tomorrow + - sensor: + name: Météo France alertes image today + unique_id: meteo_france_alertes_image_today + scan_interval: 14400 + command: > + curl -X GET "https://public-api.meteofrance.fr/public/DPVigilance/v1/vignettenationale-J/encours" -H "accept: */*" -H "apikey: eyJ4NXQiOiJZV0kxTTJZNE1qWTNOemsyTkRZeU5XTTRPV014TXpjek1UVmhNbU14T1RSa09ETXlOVEE0Tnc9PSIsImtpZCI6ImdhdGV3YXlfY2VydGlmaWNhdGVfYWxpYXMiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJiaWxvYmEyMDA1QGNhcmJvbi5zdXBlciIsImFwcGxpY2F0aW9uIjp7Im93bmVyIjoiYmlsb2JhMjAwNSIsInRpZXJRdW90YVR5cGUiOm51bGwsInRpZXIiOiJVbmxpbWl0ZWQiLCJuYW1lIjoiRGVmYXVsdEFwcGxpY2F0aW9uIiwiaWQiOjEyODM3LCJ1dWlkIjoiZTczNDYwODctMjAzYS00NzFlLTg2MDMtOWVhZjkyYzQ5YTJiIn0sImlzcyI6Imh0dHBzOlwvXC9wb3J0YWlsLWFwaS5tZXRlb2ZyYW5jZS5mcjo0NDNcL29hdXRoMlwvdG9rZW4iLCJ0aWVySW5mbyI6eyI2MFJlcVBhck1pbiI6eyJ0aWVyUXVvdGFUeXBlIjoicmVxdWVzdENvdW50IiwiZ3JhcGhRTE1heENvbXBsZXhpdHkiOjAsImdyYXBoUUxNYXhEZXB0aCI6MCwic3RvcE9uUXVvdGFSZWFjaCI6dHJ1ZSwic3Bpa2VBcnJlc3RMaW1pdCI6MCwic3Bpa2VBcnJlc3RVbml0Ijoic2VjIn19LCJrZXl0eXBlIjoiUFJPRFVDVElPTiIsInN1YnNjcmliZWRBUElzIjpbeyJzdWJzY3JpYmVyVGVuYW50RG9tYWluIjoiY2FyYm9uLnN1cGVyIiwibmFtZSI6IkRvbm5lZXNQdWJsaXF1ZXNWaWdpbGFuY2UiLCJjb250ZXh0IjoiXC9wdWJsaWNcL0RQVmlnaWxhbmNlXC92MSIsInB1Ymxpc2hlciI6ImFkbWluIiwidmVyc2lvbiI6InYxIiwic3Vic2NyaXB0aW9uVGllciI6IjYwUmVxUGFyTWluIn1dLCJleHAiOjE3NDY1MjcyOTcsInRva2VuX3R5cGUiOiJhcGlLZXkiLCJpYXQiOjE3MTQ5OTEyOTcsImp0aSI6ImQ3OTQwZmMxLTYzZTgtNGQ1ZC1iZTBiLWZkZjdlN2RlNjkyMSJ9.JUskxCK07-Acn_bn_SRXjRFqTK7Azj-MGLVa7BP4xLeHbhXS5o2SX0Wn85XH55Il8JPnDZ47Pye4Zp_U5sqqFWWUkqN_B23ztc7YHJ5nXwDIg1sxNBT8fJLhIWD9s0NtqdmdDqU5OlqXkgSsh0nN9o0zLT9auj1-eSuZje8Ua84q1sDBrdc6wChbMRFPAX8OqDbQTErqpLhA5VtQNWPpORlArvTqj2t0XPX_Bi8YtaV0HNom57C3LDY1kzDLClejjfSDf80F7vByRpLHGl8qnokyw_aciLJaFubsRobuTcx_IcWJa3YXy7LVyLax_MZnFXr6jabR9t4XU0_28ax48w==" > ./www/weather/meteo_france_alerte_today.jpg + value_template: "mf_alerte_today" + + - sensor: + name: Météo France alertes image tomorrow + unique_id: meteo_france_alertes_image_tomorrow + scan_interval: 14400 + command: > + curl -X GET "https://public-api.meteofrance.fr/public/DPVigilance/v1/vignettenationale-J1/encours" -H "accept: */*" -H "apikey: eyJ4NXQiOiJZV0kxTTJZNE1qWTNOemsyTkRZeU5XTTRPV014TXpjek1UVmhNbU14T1RSa09ETXlOVEE0Tnc9PSIsImtpZCI6ImdhdGV3YXlfY2VydGlmaWNhdGVfYWxpYXMiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJiaWxvYmEyMDA1QGNhcmJvbi5zdXBlciIsImFwcGxpY2F0aW9uIjp7Im93bmVyIjoiYmlsb2JhMjAwNSIsInRpZXJRdW90YVR5cGUiOm51bGwsInRpZXIiOiJVbmxpbWl0ZWQiLCJuYW1lIjoiRGVmYXVsdEFwcGxpY2F0aW9uIiwiaWQiOjEyODM3LCJ1dWlkIjoiZTczNDYwODctMjAzYS00NzFlLTg2MDMtOWVhZjkyYzQ5YTJiIn0sImlzcyI6Imh0dHBzOlwvXC9wb3J0YWlsLWFwaS5tZXRlb2ZyYW5jZS5mcjo0NDNcL29hdXRoMlwvdG9rZW4iLCJ0aWVySW5mbyI6eyI2MFJlcVBhck1pbiI6eyJ0aWVyUXVvdGFUeXBlIjoicmVxdWVzdENvdW50IiwiZ3JhcGhRTE1heENvbXBsZXhpdHkiOjAsImdyYXBoUUxNYXhEZXB0aCI6MCwic3RvcE9uUXVvdGFSZWFjaCI6dHJ1ZSwic3Bpa2VBcnJlc3RMaW1pdCI6MCwic3Bpa2VBcnJlc3RVbml0Ijoic2VjIn19LCJrZXl0eXBlIjoiUFJPRFVDVElPTiIsInN1YnNjcmliZWRBUElzIjpbeyJzdWJzY3JpYmVyVGVuYW50RG9tYWluIjoiY2FyYm9uLnN1cGVyIiwibmFtZSI6IkRvbm5lZXNQdWJsaXF1ZXNWaWdpbGFuY2UiLCJjb250ZXh0IjoiXC9wdWJsaWNcL0RQVmlnaWxhbmNlXC92MSIsInB1Ymxpc2hlciI6ImFkbWluIiwidmVyc2lvbiI6InYxIiwic3Vic2NyaXB0aW9uVGllciI6IjYwUmVxUGFyTWluIn1dLCJleHAiOjE3NDY1MjcyOTcsInRva2VuX3R5cGUiOiJhcGlLZXkiLCJpYXQiOjE3MTQ5OTEyOTcsImp0aSI6ImQ3OTQwZmMxLTYzZTgtNGQ1ZC1iZTBiLWZkZjdlN2RlNjkyMSJ9.JUskxCK07-Acn_bn_SRXjRFqTK7Azj-MGLVa7BP4xLeHbhXS5o2SX0Wn85XH55Il8JPnDZ47Pye4Zp_U5sqqFWWUkqN_B23ztc7YHJ5nXwDIg1sxNBT8fJLhIWD9s0NtqdmdDqU5OlqXkgSsh0nN9o0zLT9auj1-eSuZje8Ua84q1sDBrdc6wChbMRFPAX8OqDbQTErqpLhA5VtQNWPpORlArvTqj2t0XPX_Bi8YtaV0HNom57C3LDY1kzDLClejjfSDf80F7vByRpLHGl8qnokyw_aciLJaFubsRobuTcx_IcWJa3YXy7LVyLax_MZnFXr6jabR9t4XU0_28ax48w==" > ./www/weather/meteo_france_alerte_tomorrow.jpg + value_template: "mf_alerte_tomorrow" + +camera: + - platform: local_file + name: MF_alerte_today + file_path: /config/www/weather/meteo_france_alerte_today.png + + - platform: local_file + name: MF_alerte_tomorrow + file_path: /config/www/weather/meteo_france_alerte_tomorrow.png + diff --git a/config/custom_components/blitzortung/__init__.py b/config/custom_components/blitzortung/__init__.py new file mode 100644 index 0000000..e069388 --- /dev/null +++ b/config/custom_components/blitzortung/__init__.py @@ -0,0 +1,330 @@ +"""The blitzortung integration.""" +import asyncio +import json +import logging +import math +import time + +import voluptuous as vol + +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, UnitOfLength +from homeassistant.core import callback, HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.event import async_track_time_interval + +from homeassistant.util.unit_system import IMPERIAL_SYSTEM +from homeassistant.util.unit_conversion import DistanceConverter +from . import const +from .const import ( + CONF_IDLE_RESET_TIMEOUT, + CONF_MAX_TRACKED_LIGHTNINGS, + CONF_RADIUS, + CONF_TIME_WINDOW, + DEFAULT_IDLE_RESET_TIMEOUT, + DEFAULT_MAX_TRACKED_LIGHTNINGS, + DEFAULT_RADIUS, + DEFAULT_TIME_WINDOW, + DEFAULT_UPDATE_INTERVAL, + DOMAIN, + PLATFORMS, +) +from .geohash_utils import geohash_overlap +from .mqtt import MQTT, MQTT_CONNECTED, MQTT_DISCONNECTED +from .version import __version__ + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.Schema({vol.Optional(const.SERVER_STATS, default=False): bool})}, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass: HomeAssistant, config: dict): + """Initialize basic config of blitzortung component.""" + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN]["config"] = config.get(DOMAIN) or {} + return True + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): + """Set up blitzortung from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + config = hass.data[DOMAIN].get("config") or {} + + latitude = config_entry.options.get(CONF_LATITUDE, hass.config.latitude) + longitude = config_entry.options.get(CONF_LONGITUDE, hass.config.longitude) + radius = config_entry.options.get(CONF_RADIUS, DEFAULT_RADIUS) + max_tracked_lightnings = config_entry.options.get( + CONF_MAX_TRACKED_LIGHTNINGS, DEFAULT_MAX_TRACKED_LIGHTNINGS + ) + time_window_seconds = ( + config_entry.options.get(CONF_TIME_WINDOW, DEFAULT_TIME_WINDOW) * 60 + ) + if max_tracked_lightnings >= 500: + _LOGGER.warning( + "Large number of tracked lightnings: %s, it may lead to" + "bigger memory usage / unstable frontend", + max_tracked_lightnings, + ) + + if hass.config.units == IMPERIAL_SYSTEM: + radius_mi = radius + radius = DistanceConverter.convert(radius, UnitOfLength.MILES, UnitOfLength.KILOMETERS) + _LOGGER.info("imperial system, %s mi -> %s km", radius_mi, radius) + + coordinator = BlitzortungCoordinator( + hass, + latitude, + longitude, + radius, + max_tracked_lightnings, + time_window_seconds, + DEFAULT_UPDATE_INTERVAL, + server_stats=config.get(const.SERVER_STATS), + ) + + hass.data[DOMAIN][config_entry.entry_id] = coordinator + + async def start_platforms(): + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_setup(config_entry, component) + for component in PLATFORMS + ] + ) + await coordinator.connect() + + hass.async_create_task(start_platforms()) + + if not config_entry.update_listeners: + config_entry.add_update_listener(async_update_options) + + return True + + +async def async_update_options(hass, config_entry): + """Update options.""" + _LOGGER.info("async_update_options") + await hass.config_entries.async_reload(config_entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry): + """Unload a config entry.""" + coordinator = hass.data[DOMAIN].pop(config_entry.entry_id) + await coordinator.disconnect() + _LOGGER.info("disconnected") + + # cleanup platforms + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(config_entry, component) + for component in PLATFORMS + ] + ) + ) + return unload_ok + + +async def async_migrate_entry(hass, entry): + _LOGGER.debug("Migrating Blitzortung entry from Version %s", entry.version) + if entry.version == 1: + latitude = entry.data[CONF_LATITUDE] + longitude = entry.data[CONF_LONGITUDE] + radius = entry.data[CONF_RADIUS] + name = entry.data[CONF_NAME] + + entry.unique_id = f"{latitude}-{longitude}-{name}-lightning" + entry.data = {CONF_NAME: name} + entry.options = { + CONF_LATITUDE: latitude, + CONF_LONGITUDE: longitude, + CONF_RADIUS: radius, + } + entry.version = 2 + if entry.version == 2: + entry.options = dict(entry.options) + entry.options[CONF_IDLE_RESET_TIMEOUT] = DEFAULT_IDLE_RESET_TIMEOUT + entry.version = 3 + if entry.version == 3: + entry.options = dict(entry.options) + entry.options[CONF_TIME_WINDOW] = entry.options.pop( + CONF_IDLE_RESET_TIMEOUT, DEFAULT_TIME_WINDOW + ) + entry.version = 4 + + return True + + +class BlitzortungCoordinator: + def __init__( + self, + hass, + latitude, + longitude, + radius, # unit: km + max_tracked_lightnings, + time_window_seconds, + update_interval, + server_stats=False, + ): + """Initialize.""" + self.hass = hass + self.latitude = latitude + self.longitude = longitude + self.radius = radius + self.max_tracked_lightnings = max_tracked_lightnings + self.time_window_seconds = time_window_seconds + self.server_stats = server_stats + self.last_time = 0 + self.sensors = [] + self.callbacks = [] + self.lightning_callbacks = [] + self.on_tick_callbacks = [] + self.geohash_overlap = geohash_overlap( + self.latitude, self.longitude, self.radius + ) + self._disconnect_callbacks = [] + self.unloading = False + + _LOGGER.info( + "lat: %s, lon: %s, radius: %skm, geohashes: %s", + self.latitude, + self.longitude, + self.radius, + self.geohash_overlap, + ) + + self.mqtt_client = MQTT( + hass, + "blitzortung.ha.sed.pl", + 1883, + ) + + self._disconnect_callbacks.append( + async_dispatcher_connect( + self.hass, MQTT_CONNECTED, self._on_connection_change + ) + ) + self._disconnect_callbacks.append( + async_dispatcher_connect( + self.hass, MQTT_DISCONNECTED, self._on_connection_change + ) + ) + + @callback + def _on_connection_change(self, *args, **kwargs): + if self.unloading: + return + for sensor in self.sensors: + sensor.async_write_ha_state() + + def compute_polar_coords(self, lightning): + dy = (lightning["lat"] - self.latitude) * math.pi / 180 + dx = ( + (lightning["lon"] - self.longitude) + * math.pi + / 180 + * math.cos(self.latitude * math.pi / 180) + ) + distance = round(math.sqrt(dx * dx + dy * dy) * 6371, 1) + azimuth = round(math.atan2(dx, dy) * 180 / math.pi) % 360 + + lightning[SensorDeviceClass.DISTANCE] = distance + lightning[const.ATTR_LIGHTNING_AZIMUTH] = azimuth + + async def connect(self): + await self.mqtt_client.async_connect() + _LOGGER.info("Connected to Blitzortung proxy mqtt server") + for geohash_code in self.geohash_overlap: + geohash_part = "/".join(geohash_code) + await self.mqtt_client.async_subscribe( + "blitzortung/1.1/{}/#".format(geohash_part), self.on_mqtt_message, qos=0 + ) + if self.server_stats: + await self.mqtt_client.async_subscribe( + "$SYS/broker/#", self.on_mqtt_message, qos=0 + ) + await self.mqtt_client.async_subscribe( + "component/hello", self.on_hello_message, qos=0 + ) + + self._disconnect_callbacks.append( + async_track_time_interval( + self.hass, self._tick, const.DEFAULT_UPDATE_INTERVAL + ) + ) + + async def disconnect(self): + self.unloading = True + await self.mqtt_client.async_disconnect() + for cb in self._disconnect_callbacks: + cb() + + def on_hello_message(self, message, *args): + def parse_version(version_str): + return tuple(map(int, version_str.split("."))) + + data = json.loads(message.payload) + latest_version_str = data.get("latest_version") + if latest_version_str: + default_message = ( + f"New version {latest_version_str} is available. " + f"[Check it out](https://github.com/mrk-its/homeassistant-blitzortung)" + ) + latest_version_message = data.get("latest_version_message", default_message) + latest_version_title = data.get("latest_version_title", "Blitzortung") + latest_version = parse_version(latest_version_str) + current_version = parse_version(__version__) + if latest_version > current_version: + _LOGGER.info("new version is available: %s", latest_version_str) + self.hass.components.persistent_notification.async_create( + title=latest_version_title, + message=latest_version_message, + notification_id="blitzortung_new_version_available", + ) + + async def on_mqtt_message(self, message, *args): + for callback in self.callbacks: + callback(message) + if message.topic.startswith("blitzortung/1.1"): + lightning = json.loads(message.payload) + self.compute_polar_coords(lightning) + if lightning[SensorDeviceClass.DISTANCE] < self.radius: + _LOGGER.debug("lightning data: %s", lightning) + self.last_time = time.time() + for callback in self.lightning_callbacks: + await callback(lightning) + for sensor in self.sensors: + sensor.update_lightning(lightning) + + def register_sensor(self, sensor): + self.sensors.append(sensor) + self.register_on_tick(sensor.tick) + + def register_message_receiver(self, message_cb): + self.callbacks.append(message_cb) + + def register_lightning_receiver(self, lightning_cb): + self.lightning_callbacks.append(lightning_cb) + + def register_on_tick(self, on_tick_cb): + self.on_tick_callbacks.append(on_tick_cb) + + @property + def is_inactive(self): + return bool( + self.time_window_seconds + and (time.time() - self.last_time) >= self.time_window_seconds + ) + + @property + def is_connected(self): + return self.mqtt_client.connected + + async def _tick(self, *args): + for cb in self.on_tick_callbacks: + cb() diff --git a/config/custom_components/blitzortung/config_flow.py b/config/custom_components/blitzortung/config_flow.py new file mode 100644 index 0000000..ee89c7a --- /dev/null +++ b/config/custom_components/blitzortung/config_flow.py @@ -0,0 +1,91 @@ +"""Config flow for blitzortung integration.""" +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant import config_entries +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME + +from .const import ( + CONF_MAX_TRACKED_LIGHTNINGS, + CONF_RADIUS, + CONF_TIME_WINDOW, + DEFAULT_MAX_TRACKED_LIGHTNINGS, + DEFAULT_RADIUS, + DEFAULT_TIME_WINDOW, + DOMAIN, +) + +DEFAULT_CONF_NAME = "Blitzortung" + + +class DomainConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for blitzortung.""" + + VERSION = 4 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + if user_input is not None: + await self.async_set_unique_id(user_input[CONF_NAME]) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=user_input[CONF_NAME], data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_NAME, default=DEFAULT_CONF_NAME): str} + ), + ) + + @staticmethod + def async_get_options_flow(config_entry): + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + def __init__(self, config_entry: config_entries.ConfigEntry): + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required( + CONF_LATITUDE, + default=self.config_entry.options.get( + CONF_LATITUDE, self.hass.config.latitude + ), + ): cv.latitude, + vol.Required( + CONF_LONGITUDE, + default=self.config_entry.options.get( + CONF_LONGITUDE, self.hass.config.longitude + ), + ): cv.longitude, + vol.Required( + CONF_RADIUS, + default=self.config_entry.options.get( + CONF_RADIUS, DEFAULT_RADIUS + ), + ): int, + vol.Optional( + CONF_TIME_WINDOW, + default=self.config_entry.options.get( + CONF_TIME_WINDOW, DEFAULT_TIME_WINDOW, + ), + ): int, + vol.Optional( + CONF_MAX_TRACKED_LIGHTNINGS, + default=self.config_entry.options.get( + CONF_MAX_TRACKED_LIGHTNINGS, DEFAULT_MAX_TRACKED_LIGHTNINGS, + ), + ): int, + } + ), + ) diff --git a/config/custom_components/blitzortung/const.py b/config/custom_components/blitzortung/const.py new file mode 100644 index 0000000..a07f332 --- /dev/null +++ b/config/custom_components/blitzortung/const.py @@ -0,0 +1,33 @@ +import datetime + +SW_VERSION = "1.3.1" + +PLATFORMS = ["sensor", "geo_location"] + +DOMAIN = "blitzortung" +DATA_UNSUBSCRIBE = "unsubscribe" +ATTR_LIGHTNING_AZIMUTH = "azimuth" +ATTR_LIGHTNING_COUNTER = "counter" + +SERVER_STATS = "server_stats" + +BASE_URL_TEMPLATE = ( + "http://data{data_host_nr}.blitzortung.org/Data/Protected/last_strikes.php" +) + +CONF_RADIUS = "radius" +CONF_IDLE_RESET_TIMEOUT = "idle_reset_timeout" +CONF_TIME_WINDOW = "time_window" +CONF_MAX_TRACKED_LIGHTNINGS = "max_tracked_lightnings" + +DEFAULT_IDLE_RESET_TIMEOUT = 120 +DEFAULT_RADIUS = 100 +DEFAULT_MAX_TRACKED_LIGHTNINGS = 100 +DEFAULT_TIME_WINDOW = 120 +DEFAULT_UPDATE_INTERVAL = datetime.timedelta(seconds=60) + +ATTR_LAT = "lat" +ATTR_LON = "lon" +ATTRIBUTION = "Data provided by blitzortung.org" +ATTR_EXTERNAL_ID = "external_id" +ATTR_PUBLICATION_DATE = "publication_date" diff --git a/config/custom_components/blitzortung/geo_location.py b/config/custom_components/blitzortung/geo_location.py new file mode 100644 index 0000000..14bc645 --- /dev/null +++ b/config/custom_components/blitzortung/geo_location.py @@ -0,0 +1,217 @@ +"""Support for Blitzortung geo location events.""" +import bisect +import logging +import time +import uuid + +from homeassistant.components.geo_location import GeolocationEvent +from homeassistant.const import ( + ATTR_ATTRIBUTION, + UnitOfLength +) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.util.dt import utc_from_timestamp +from homeassistant.util.unit_system import IMPERIAL_SYSTEM + +from .const import ATTR_EXTERNAL_ID, ATTR_PUBLICATION_DATE, ATTRIBUTION, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +DEFAULT_EVENT_NAME_TEMPLATE = "Lightning Strike" +DEFAULT_ICON = "mdi:flash" + +SIGNAL_DELETE_ENTITY = "blitzortung_delete_entity_{0}" + + +async def async_setup_entry(hass, config_entry, async_add_entities): + coordinator = hass.data[DOMAIN][config_entry.entry_id] + if not coordinator.max_tracked_lightnings: + return + + manager = BlitzortungEventManager( + hass, + async_add_entities, + coordinator.max_tracked_lightnings, + coordinator.time_window_seconds, + ) + + coordinator.register_lightning_receiver(manager.lightning_cb) + coordinator.register_on_tick(manager.tick) + + +class Strikes(list): + def __init__(self, capacity): + self._keys = [] + self._key_fn = lambda strike: strike._publication_date + self._max_key = 0 + self._capacity = capacity + super().__init__() + + def insort(self, item): + k = self._key_fn(item) + if k > self._max_key: + self._max_key = k + self._keys.append(k) + self.append(item) + else: + i = bisect.bisect_right(self._keys, k) + self._keys.insert(i, k) + self.insert(i, item) + n = len(self) - self._capacity + if n > 0: + del self._keys[0:n] + to_delete = self[0:n] + self[0:n] = [] + return to_delete + return () + + def cleanup(self, k): + if not self._keys or self._keys[0] > k: + return () + + i = bisect.bisect_right(self._keys, k) + if not i: + return () + + del self._keys[0:i] + to_delete = self[0:i] + self[0:i] = [] + return to_delete + + +class BlitzortungEventManager: + """Define a class to handle Blitzortung events.""" + + def __init__( + self, hass, async_add_entities, max_tracked_lightnings, window_seconds, + ): + """Initialize.""" + self._async_add_entities = async_add_entities + self._hass = hass + self._strikes = Strikes(max_tracked_lightnings) + self._window_seconds = window_seconds + + if hass.config.units == IMPERIAL_SYSTEM: + self._unit = UnitOfLength.MILES + else: + self._unit = UnitOfLength.KILOMETERS + + async def lightning_cb(self, lightning): + _LOGGER.debug("geo_location lightning: %s", lightning) + event = BlitzortungEvent( + lightning["distance"], + lightning["lat"], + lightning["lon"], + self._unit, + lightning["time"], + lightning["status"], + lightning["region"], + ) + to_delete = self._strikes.insort(event) + self._async_add_entities([event]) + if to_delete: + self._remove_events(to_delete) + _LOGGER.debug("tracked lightnings: %s", len(self._strikes)) + + @callback + def _remove_events(self, events): + """Remove old geo location events.""" + _LOGGER.debug("Going to remove %s", events) + for event in events: + async_dispatcher_send( + self._hass, SIGNAL_DELETE_ENTITY.format(event._strike_id) + ) + + def tick(self): + to_delete = self._strikes.cleanup(time.time() - self._window_seconds) + if to_delete: + self._remove_events(to_delete) + + +class BlitzortungEvent(GeolocationEvent): + """Define a lightning strike event.""" + + def __init__(self, distance, latitude, longitude, unit, time, status, region): + """Initialize entity with data provided.""" + self._distance = distance + self._latitude = latitude + self._longitude = longitude + self._time = time + self._status = status + self._region = region + self._publication_date = time / 1e9 + self._remove_signal_delete = None + self._strike_id = str(uuid.uuid4()).replace("-", "") + self._unit_of_measurement = unit + self.entity_id = "geo_location.lightning_strike_{0}".format(self._strike_id) + + @property + def extra_state_attributes(self): + """Return the device state attributes.""" + attributes = {} + for key, value in ( + (ATTR_EXTERNAL_ID, self._strike_id), + (ATTR_ATTRIBUTION, ATTRIBUTION), + (ATTR_PUBLICATION_DATE, utc_from_timestamp(self._publication_date)), + ): + attributes[key] = value + return attributes + + @property + def distance(self): + """Return distance value of this external event.""" + return self._distance + + @property + def icon(self): + """Return the icon to use in the front-end.""" + return DEFAULT_ICON + + @property + def latitude(self): + """Return latitude value of this external event.""" + return self._latitude + + @property + def longitude(self): + """Return longitude value of this external event.""" + return self._longitude + + @property + def name(self): + """Return the name of the event.""" + return DEFAULT_EVENT_NAME_TEMPLATE.format(self._publication_date) + + @property + def source(self) -> str: + """Return source value of this external event.""" + return DOMAIN + + @property + def should_poll(self): + """Disable polling.""" + return False + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + @callback + def _delete_callback(self): + """Remove this entity.""" + self._remove_signal_delete() + self.hass.async_create_task(self.async_remove()) + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self._remove_signal_delete = async_dispatcher_connect( + self.hass, + SIGNAL_DELETE_ENTITY.format(self._strike_id), + self._delete_callback, + ) diff --git a/config/custom_components/blitzortung/geohash.py b/config/custom_components/blitzortung/geohash.py new file mode 100644 index 0000000..c2e9859 --- /dev/null +++ b/config/custom_components/blitzortung/geohash.py @@ -0,0 +1,466 @@ +# coding: UTF-8 +# flake8: noqa +""" +Copyright (C) 2009 Hiroaki Kawai +""" +try: + import _geohash +except ImportError: + _geohash = None + +__version__ = "0.8.5" +__all__ = ['encode','decode','decode_exactly','bbox', 'neighbors', 'expand'] + +_base32 = '0123456789bcdefghjkmnpqrstuvwxyz' +_base32_map = {} +for i in range(len(_base32)): + _base32_map[_base32[i]] = i +del i + +LONG_ZERO = 0 +import sys +if sys.version_info[0] < 3: + LONG_ZERO = long(0) + +def _float_hex_to_int(f): + if f<-1.0 or f>=1.0: + return None + + if f==0.0: + return 1,1 + + h = f.hex() + x = h.find("0x1.") + assert(x>=0) + p = h.find("p") + assert(p>0) + + half_len = len(h[x+4:p])*4-int(h[p+1:]) + if x==0: + r = (1<= half: + i = i-half + return float.fromhex(("0x0.%0"+str(s)+"xp1") % (i<<(s*4-l),)) + else: + i = half-i + return float.fromhex(("-0x0.%0"+str(s)+"xp1") % (i<<(s*4-l),)) + +def _encode_i2c(lat,lon,lat_length,lon_length): + precision = int((lat_length+lon_length)/5) + if lat_length < lon_length: + a = lon + b = lat + else: + a = lat + b = lon + + boost = (0,1,4,5,16,17,20,21) + ret = '' + for i in range(precision): + ret+=_base32[(boost[a&7]+(boost[b&3]<<1))&0x1F] + t = a>>3 + a = b>>2 + b = t + + return ret[::-1] + +def encode(latitude, longitude, precision=12): + if latitude >= 90.0 or latitude < -90.0: + raise Exception("invalid latitude.") + while longitude < -180.0: + longitude += 360.0 + while longitude >= 180.0: + longitude -= 360.0 + + if _geohash: + basecode=_geohash.encode(latitude,longitude) + if len(basecode)>precision: + return basecode[0:precision] + return basecode+'0'*(precision-len(basecode)) + + xprecision=precision+1 + lat_length = lon_length = int(xprecision*5/2) + if xprecision%2==1: + lon_length+=1 + + if hasattr(float, "fromhex"): + a = _float_hex_to_int(latitude/90.0) + o = _float_hex_to_int(longitude/180.0) + if a[1] > lat_length: + ai = a[0]>>(a[1]-lat_length) + else: + ai = a[0]<<(lat_length-a[1]) + + if o[1] > lon_length: + oi = o[0]>>(o[1]-lon_length) + else: + oi = o[0]<<(lon_length-o[1]) + + return _encode_i2c(ai, oi, lat_length, lon_length)[:precision] + + lat = latitude/180.0 + lon = longitude/360.0 + + if lat>0: + lat = int((1<0: + lon = int((1<>2)&4 + lat += (t>>2)&2 + lon += (t>>1)&2 + lat += (t>>1)&1 + lon += t&1 + lon_length+=3 + lat_length+=2 + else: + lon = lon<<2 + lat = lat<<3 + lat += (t>>2)&4 + lon += (t>>2)&2 + lat += (t>>1)&2 + lon += (t>>1)&1 + lat += t&1 + lon_length+=2 + lat_length+=3 + + bit_length+=5 + + return (lat,lon,lat_length,lon_length) + +def decode(hashcode, delta=False): + ''' + decode a hashcode and get center coordinate, and distance between center and outer border + ''' + if _geohash: + (lat,lon,lat_bits,lon_bits) = _geohash.decode(hashcode) + latitude_delta = 90.0/(1<> lat_length: + for tlon in (lon-1, lon, lon+1): + ret.append(_encode_i2c(tlat,tlon,lat_length,lon_length)) + + tlat = lat-1 + if tlat >= 0: + for tlon in (lon-1, lon, lon+1): + ret.append(_encode_i2c(tlat,tlon,lat_length,lon_length)) + + return ret + +def expand(hashcode): + ret = neighbors(hashcode) + ret.append(hashcode) + return ret + +def _uint64_interleave(lat32, lon32): + intr = 0 + boost = (0,1,4,5,16,17,20,21,64,65,68,69,80,81,84,85) + for i in range(8): + intr = (intr<<8) + (boost[(lon32>>(28-i*4))%16]<<1) + boost[(lat32>>(28-i*4))%16] + + return intr + +def _uint64_deinterleave(ui64): + lat = lon = 0 + boost = ((0,0),(0,1),(1,0),(1,1),(0,2),(0,3),(1,2),(1,3), + (2,0),(2,1),(3,0),(3,1),(2,2),(2,3),(3,2),(3,3)) + for i in range(16): + p = boost[(ui64>>(60-i*4))%16] + lon = (lon<<2) + p[0] + lat = (lat<<2) + p[1] + + return (lat, lon) + +def encode_uint64(latitude, longitude): + if latitude >= 90.0 or latitude < -90.0: + raise ValueError("Latitude must be in the range of (-90.0, 90.0)") + while longitude < -180.0: + longitude += 360.0 + while longitude >= 180.0: + longitude -= 360.0 + + if _geohash: + ui128 = _geohash.encode_int(latitude,longitude) + if _geohash.intunit == 64: + return ui128[0] + elif _geohash.intunit == 32: + return (ui128[0]<<32) + ui128[1] + elif _geohash.intunit == 16: + return (ui128[0]<<48) + (ui128[1]<<32) + (ui128[2]<<16) + ui128[3] + + lat = int(((latitude + 90.0)/180.0)*(1<<32)) + lon = int(((longitude+180.0)/360.0)*(1<<32)) + return _uint64_interleave(lat, lon) + +def decode_uint64(ui64): + if _geohash: + latlon = _geohash.decode_int(ui64 % 0xFFFFFFFFFFFFFFFF, LONG_ZERO) + if latlon: + return latlon + + lat,lon = _uint64_deinterleave(ui64) + return (180.0*lat/(1<<32) - 90.0, 360.0*lon/(1<<32) - 180.0) + +def expand_uint64(ui64, precision=50): + ui64 = ui64 & (0xFFFFFFFFFFFFFFFF << (64-precision)) + lat,lon = _uint64_deinterleave(ui64) + lat_grid = 1<<(32-int(precision/2)) + lon_grid = lat_grid>>(precision%2) + + if precision<=2: # expand becomes to the whole range + return [] + + ranges = [] + if lat & lat_grid: + if lon & lon_grid: + ui64 = _uint64_interleave(lat-lat_grid, lon-lon_grid) + ranges.append((ui64, ui64 + (1<<(64-precision+2)))) + if precision%2==0: + # lat,lon = (1, 1) and even precision + ui64 = _uint64_interleave(lat-lat_grid, lon+lon_grid) + ranges.append((ui64, ui64 + (1<<(64-precision+1)))) + + if lat + lat_grid < 0xFFFFFFFF: + ui64 = _uint64_interleave(lat+lat_grid, lon-lon_grid) + ranges.append((ui64, ui64 + (1<<(64-precision)))) + ui64 = _uint64_interleave(lat+lat_grid, lon) + ranges.append((ui64, ui64 + (1<<(64-precision)))) + ui64 = _uint64_interleave(lat+lat_grid, lon+lon_grid) + ranges.append((ui64, ui64 + (1<<(64-precision)))) + else: + # lat,lon = (1, 1) and odd precision + if lat + lat_grid < 0xFFFFFFFF: + ui64 = _uint64_interleave(lat+lat_grid, lon-lon_grid) + ranges.append((ui64, ui64 + (1<<(64-precision+1)))) + + ui64 = _uint64_interleave(lat+lat_grid, lon+lon_grid) + ranges.append((ui64, ui64 + (1<<(64-precision)))) + + ui64 = _uint64_interleave(lat, lon+lon_grid) + ranges.append((ui64, ui64 + (1<<(64-precision)))) + ui64 = _uint64_interleave(lat-lat_grid, lon+lon_grid) + ranges.append((ui64, ui64 + (1<<(64-precision)))) + else: + ui64 = _uint64_interleave(lat-lat_grid, lon) + ranges.append((ui64, ui64 + (1<<(64-precision+2)))) + if precision%2==0: + # lat,lon = (1, 0) and odd precision + ui64 = _uint64_interleave(lat-lat_grid, lon-lon_grid) + ranges.append((ui64, ui64 + (1<<(64-precision+1)))) + + if lat + lat_grid < 0xFFFFFFFF: + ui64 = _uint64_interleave(lat+lat_grid, lon-lon_grid) + ranges.append((ui64, ui64 + (1<<(64-precision)))) + ui64 = _uint64_interleave(lat+lat_grid, lon) + ranges.append((ui64, ui64 + (1<<(64-precision)))) + ui64 = _uint64_interleave(lat+lat_grid, lon+lon_grid) + ranges.append((ui64, ui64 + (1<<(64-precision)))) + else: + # lat,lon = (1, 0) and odd precision + if lat + lat_grid < 0xFFFFFFFF: + ui64 = _uint64_interleave(lat+lat_grid, lon) + ranges.append((ui64, ui64 + (1<<(64-precision+1)))) + + ui64 = _uint64_interleave(lat+lat_grid, lon-lon_grid) + ranges.append((ui64, ui64 + (1<<(64-precision)))) + ui64 = _uint64_interleave(lat, lon-lon_grid) + ranges.append((ui64, ui64 + (1<<(64-precision)))) + ui64 = _uint64_interleave(lat-lat_grid, lon-lon_grid) + ranges.append((ui64, ui64 + (1<<(64-precision)))) + else: + if lon & lon_grid: + ui64 = _uint64_interleave(lat, lon-lon_grid) + ranges.append((ui64, ui64 + (1<<(64-precision+2)))) + if precision%2==0: + # lat,lon = (0, 1) and even precision + ui64 = _uint64_interleave(lat, lon+lon_grid) + ranges.append((ui64, ui64 + (1<<(64-precision+1)))) + + if lat > 0: + ui64 = _uint64_interleave(lat-lat_grid, lon-lon_grid) + ranges.append((ui64, ui64 + (1<<(64-precision)))) + ui64 = _uint64_interleave(lat-lat_grid, lon) + ranges.append((ui64, ui64 + (1<<(64-precision)))) + ui64 = _uint64_interleave(lat-lat_grid, lon+lon_grid) + ranges.append((ui64, ui64 + (1<<(64-precision)))) + else: + # lat,lon = (0, 1) and odd precision + if lat > 0: + ui64 = _uint64_interleave(lat-lat_grid, lon-lon_grid) + ranges.append((ui64, ui64 + (1<<(64-precision+1)))) + + ui64 = _uint64_interleave(lat-lat_grid, lon+lon_grid) + ranges.append((ui64, ui64 + (1<<(64-precision)))) + ui64 = _uint64_interleave(lat, lon+lon_grid) + ranges.append((ui64, ui64 + (1<<(64-precision)))) + ui64 = _uint64_interleave(lat+lat_grid, lon+lon_grid) + ranges.append((ui64, ui64 + (1<<(64-precision)))) + else: + ui64 = _uint64_interleave(lat, lon) + ranges.append((ui64, ui64 + (1<<(64-precision+2)))) + if precision%2==0: + # lat,lon = (0, 0) and even precision + ui64 = _uint64_interleave(lat, lon-lon_grid) + ranges.append((ui64, ui64 + (1<<(64-precision+1)))) + + if lat > 0: + ui64 = _uint64_interleave(lat-lat_grid, lon-lon_grid) + ranges.append((ui64, ui64 + (1<<(64-precision)))) + ui64 = _uint64_interleave(lat-lat_grid, lon) + ranges.append((ui64, ui64 + (1<<(64-precision)))) + ui64 = _uint64_interleave(lat-lat_grid, lon+lon_grid) + ranges.append((ui64, ui64 + (1<<(64-precision)))) + else: + # lat,lon = (0, 0) and odd precision + if lat > 0: + ui64 = _uint64_interleave(lat-lat_grid, lon) + ranges.append((ui64, ui64 + (1<<(64-precision+1)))) + + ui64 = _uint64_interleave(lat-lat_grid, lon-lon_grid) + ranges.append((ui64, ui64 + (1<<(64-precision)))) + ui64 = _uint64_interleave(lat, lon-lon_grid) + ranges.append((ui64, ui64 + (1<<(64-precision)))) + ui64 = _uint64_interleave(lat+lat_grid, lon-lon_grid) + ranges.append((ui64, ui64 + (1<<(64-precision)))) + + ranges.sort() + + # merge the conditions + shrink = [] + prev = None + for i in ranges: + if prev: + if prev[1] != i[0]: + shrink.append(prev) + prev = i + else: + prev = (prev[0], i[1]) + else: + prev = i + + shrink.append(prev) + + ranges = [] + for i in shrink: + a,b=i + if a == 0: + a = None # we can remove the condition because it is the lowest value + if b == 0x10000000000000000: + b = None # we can remove the condition because it is the highest value + + ranges.append((a,b)) + + return ranges diff --git a/config/custom_components/blitzortung/geohash_utils.py b/config/custom_components/blitzortung/geohash_utils.py new file mode 100644 index 0000000..4886640 --- /dev/null +++ b/config/custom_components/blitzortung/geohash_utils.py @@ -0,0 +1,58 @@ +import math +from collections import namedtuple + +from . import geohash + +Box = namedtuple("Box", ["s", "w", "n", "e"]) + + +def geohash_bbox(gh): + ret = geohash.bbox(gh) + return Box(ret["s"], ret["w"], ret["n"], ret["e"]) + + +def bbox(lat, lon, radius): + lat_delta = radius * 360 / 40000 + lon_delta = lat_delta / math.cos(lat * math.pi / 180.0) + return Box(lat - lat_delta, lon - lon_delta, lat + lat_delta, lon + lon_delta) + + +def overlap(a1, a2, b1, b2): + return a1 < b2 and a2 > b1 + + +def box_overlap(box1: Box, box2: Box): + return overlap(box1.s, box1.n, box2.s, box2.n) and overlap( + box1.w, box1.e, box2.w, box2.e + ) + + +def compute_geohash_tiles(lat, lon, radius, precision): + bounds = bbox(lat, lon, radius) + center = geohash.encode(lat, lon, precision) + + stack = set() + checked = set() + + stack.add(center) + checked.add(center) + + while stack: + current = stack.pop() + for neighbor in geohash.neighbors(current): + if neighbor not in checked and box_overlap(geohash_bbox(neighbor), bounds): + stack.add(neighbor) + checked.add(neighbor) + return checked + + +def geohash_overlap(lat, lon, radius, max_tiles=9): + result = [] + for precision in range(1, 13): + tiles = compute_geohash_tiles(lat, lon, radius, precision) + if len(tiles) <= 9: + result = tiles + precision += 1 + else: + break + return result diff --git a/config/custom_components/blitzortung/manifest.json b/config/custom_components/blitzortung/manifest.json new file mode 100644 index 0000000..2c6647e --- /dev/null +++ b/config/custom_components/blitzortung/manifest.json @@ -0,0 +1,17 @@ +{ + "domain": "blitzortung", + "name": "Blitzortung", + "after_dependencies": [], + "codeowners": [ + "@mrk-its" + ], + "config_flow": true, + "dependencies": [ + "persistent_notification" + ], + "documentation": "https://github.com/mrk-its/homeassistant-blitzortung", + "iot_class": "cloud_push", + "issue_tracker": "https://github.com/mrk-its/homeassistant-blitzortung/issues", + "requirements": ["paho-mqtt>=1.5.0"], + "version": "1.0.1" +} diff --git a/config/custom_components/blitzortung/mqtt.py b/config/custom_components/blitzortung/mqtt.py new file mode 100644 index 0000000..2d51c19 --- /dev/null +++ b/config/custom_components/blitzortung/mqtt.py @@ -0,0 +1,305 @@ +"""Support for MQTT message handling.""" +import asyncio +import datetime as dt +import logging +from itertools import groupby +from operator import attrgetter +from typing import Callable, List, Optional, Union + +import attr + +from homeassistant.core import callback, HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.util import dt as dt_util + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_PORT = 1883 +DEFAULT_KEEPALIVE = 60 +PROTOCOL_311 = "3.1.1" +DEFAULT_PROTOCOL = PROTOCOL_311 +MQTT_CONNECTED = "blitzortung_mqtt_connected" +MQTT_DISCONNECTED = "blitzortung_mqtt_disconnected" + + +MAX_RECONNECT_WAIT = 300 # seconds + + +def _raise_on_error(result_code: int) -> None: + """Raise error if error result.""" + # pylint: disable=import-outside-toplevel + import paho.mqtt.client as mqtt + + if result_code != 0: + raise HomeAssistantError( + f"Error talking to MQTT: {mqtt.error_string(result_code)}" + ) + + +def _match_topic(subscription: str, topic: str) -> bool: + """Test if topic matches subscription.""" + # pylint: disable=import-outside-toplevel + from paho.mqtt.matcher import MQTTMatcher + + matcher = MQTTMatcher() + matcher[subscription] = True + try: + next(matcher.iter_match(topic)) + return True + except StopIteration: + return False + + +PublishPayloadType = Union[str, bytes, int, float, None] + + +@attr.s(slots=True, frozen=True) +class Message: + """MQTT Message.""" + + topic = attr.ib(type=str) + payload = attr.ib(type=PublishPayloadType) + qos = attr.ib(type=int) + retain = attr.ib(type=bool) + subscribed_topic = attr.ib(type=str, default=None) + timestamp = attr.ib(type=dt.datetime, default=None) + + +MessageCallbackType = Callable[[Message], None] + + +@attr.s(slots=True, frozen=True) +class Subscription: + """Class to hold data about an active subscription.""" + + topic = attr.ib(type=str) + callback = attr.ib(type=MessageCallbackType) + qos = attr.ib(type=int, default=0) + encoding = attr.ib(type=str, default="utf-8") + + +SubscribePayloadType = Union[str, bytes] # Only bytes if encoding is None + + +class MQTT: + """Home Assistant MQTT client.""" + + def __init__( + self, + hass: HomeAssistant, + host, + port=DEFAULT_PORT, + keepalive=DEFAULT_KEEPALIVE, + ) -> None: + """Initialize Home Assistant MQTT client.""" + # We don't import on the top because some integrations + # should be able to optionally rely on MQTT. + import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel + + self.hass = hass + self.host = host + self.port = port + self.keepalive = keepalive + self.subscriptions: List[Subscription] = [] + self.connected = False + self._mqttc: mqtt.Client = None + self._paho_lock = asyncio.Lock() + + self.init_client() + + def init_client(self): + """Initialize paho client.""" + # We don't import on the top because some integrations + # should be able to optionally rely on MQTT. + import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel + + proto = mqtt.MQTTv311 + self._mqttc = mqtt.Client(protocol=proto) + + self._mqttc.on_connect = self._mqtt_on_connect + self._mqttc.on_disconnect = self._mqtt_on_disconnect + self._mqttc.on_message = self._mqtt_on_message + + async def async_publish( + self, topic: str, payload: PublishPayloadType, qos: int, retain: bool + ) -> None: + """Publish a MQTT message.""" + async with self._paho_lock: + _LOGGER.debug("Transmitting message on %s: %s", topic, payload) + await self.hass.async_add_executor_job( + self._mqttc.publish, topic, payload, qos, retain + ) + + async def async_connect(self) -> str: + """Connect to the host. Does not process messages yet.""" + # pylint: disable=import-outside-toplevel + import paho.mqtt.client as mqtt + + result: int = None + try: + result = await self.hass.async_add_executor_job( + self._mqttc.connect, self.host, self.port, self.keepalive, + ) + except OSError as err: + _LOGGER.error("Failed to connect to MQTT server due to exception: %s", err) + + if result is not None and result != 0: + _LOGGER.error( + "Failed to connect to MQTT server: %s", mqtt.error_string(result) + ) + + self._mqttc.loop_start() + + async def async_disconnect(self): + """Stop the MQTT client.""" + + def stop(): + """Stop the MQTT client.""" + self._mqttc.disconnect() + self._mqttc.loop_stop() + + await self.hass.async_add_executor_job(stop) + + async def async_subscribe( + self, topic: str, msg_callback, qos: int, encoding: Optional[str] = None, + ) -> Callable[[], None]: + """Set up a subscription to a topic with the provided qos. + + This method is a coroutine. + """ + if not isinstance(topic, str): + raise HomeAssistantError("Topic needs to be a string!") + + subscription = Subscription(topic, msg_callback, qos, encoding) + self.subscriptions.append(subscription) + + # Only subscribe if currently connected. + if self.connected: + await self._async_perform_subscription(topic, qos) + + @callback + def async_remove() -> None: + """Remove subscription.""" + if subscription not in self.subscriptions: + raise HomeAssistantError("Can't remove subscription twice") + self.subscriptions.remove(subscription) + + if any(other.topic == topic for other in self.subscriptions): + # Other subscriptions on topic remaining - don't unsubscribe. + return + + # Only unsubscribe if currently connected. + if self.connected: + self.hass.async_create_task(self._async_unsubscribe(topic)) + + return async_remove + + async def _async_unsubscribe(self, topic: str) -> None: + """Unsubscribe from a topic. + + This method is a coroutine. + """ + _LOGGER.debug("Unsubscribing from %s", topic) + async with self._paho_lock: + result: int = None + result, _ = await self.hass.async_add_executor_job( + self._mqttc.unsubscribe, topic + ) + _raise_on_error(result) + + async def _async_perform_subscription(self, topic: str, qos: int) -> None: + """Perform a paho-mqtt subscription.""" + _LOGGER.debug("Subscribing to %s", topic) + + async with self._paho_lock: + result: int = None + result, _ = await self.hass.async_add_executor_job( + self._mqttc.subscribe, topic, qos + ) + _raise_on_error(result) + + def _mqtt_on_connect(self, _mqttc, _userdata, _flags, result_code: int) -> None: + """On connect callback. + + Resubscribe to all topics we were subscribed to and publish birth + message. + """ + # pylint: disable=import-outside-toplevel + import paho.mqtt.client as mqtt + + if result_code != mqtt.CONNACK_ACCEPTED: + _LOGGER.error( + "Unable to connect to the MQTT broker: %s", + mqtt.connack_string(result_code), + ) + return + + self.connected = True + dispatcher_send(self.hass, MQTT_CONNECTED) + _LOGGER.info( + "Connected to MQTT server %s:%s (%s)", self.host, self.port, result_code, + ) + + # Group subscriptions to only re-subscribe once for each topic. + keyfunc = attrgetter("topic") + for topic, subs in groupby(sorted(self.subscriptions, key=keyfunc), keyfunc): + # Re-subscribe with the highest requested qos + max_qos = max(subscription.qos for subscription in subs) + self.hass.add_job(self._async_perform_subscription, topic, max_qos) + + def _mqtt_on_message(self, _mqttc, _userdata, msg) -> None: + """Message received callback.""" + self.hass.add_job(self._mqtt_handle_message, msg) + + @callback + def _mqtt_handle_message(self, msg) -> None: + _LOGGER.debug( + "Received message on %s%s: %s", + msg.topic, + " (retained)" if msg.retain else "", + msg.payload, + ) + timestamp = dt_util.utcnow() + + for subscription in self.subscriptions: + if not _match_topic(subscription.topic, msg.topic): + continue + + payload: SubscribePayloadType = msg.payload + if subscription.encoding is not None: + try: + payload = msg.payload.decode(subscription.encoding) + except (AttributeError, UnicodeDecodeError): + _LOGGER.warning( + "Can't decode payload %s on %s with encoding %s (for %s)", + msg.payload, + msg.topic, + subscription.encoding, + subscription.callback, + ) + continue + + self.hass.async_create_task( + subscription.callback( + Message( + msg.topic, + payload, + msg.qos, + msg.retain, + subscription.topic, + timestamp, + ) + ) + ) + + def _mqtt_on_disconnect(self, _mqttc, _userdata, result_code: int) -> None: + """Disconnected callback.""" + self.connected = False + dispatcher_send(self.hass, MQTT_DISCONNECTED) + _LOGGER.info( + "Disconnected from MQTT server %s:%s (%s)", + self.host, + self.port, + result_code, + ) diff --git a/config/custom_components/blitzortung/sensor.py b/config/custom_components/blitzortung/sensor.py new file mode 100644 index 0000000..061a0a7 --- /dev/null +++ b/config/custom_components/blitzortung/sensor.py @@ -0,0 +1,225 @@ +import logging + +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, DEGREE, UnitOfLength +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass +from homeassistant.helpers.device_registry import DeviceEntryType + +from .const import ( + ATTR_LAT, + ATTR_LIGHTNING_AZIMUTH, + ATTR_LIGHTNING_COUNTER, + ATTR_LON, + ATTRIBUTION, + DOMAIN, + SERVER_STATS, + SW_VERSION, +) + +ATTR_ICON = "icon" +ATTR_LABEL = "label" +ATTR_UNIT = "unit" +ATTR_LIGHTNING_PROPERTY = "lightning_prop" + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + integration_name = config_entry.data[CONF_NAME] + + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + unique_prefix = config_entry.unique_id + + sensors = [ + klass(coordinator, integration_name, unique_prefix) + for klass in (DistanceSensor, AzimuthSensor, CounterSensor) + ] + + async_add_entities(sensors, False) + + config = hass.data[DOMAIN].get("config") or {} + if config.get(SERVER_STATS): + server_stat_sensors = {} + + def on_message(message): + if not message.topic.startswith("$SYS/broker/"): + return + topic = message.topic.replace("$SYS/broker/", "") + if topic.startswith("load") and not topic.endswith("/1min"): + return + if topic.startswith("clients") and topic != "clients/connected": + return + sensor = server_stat_sensors.get(topic) + if not sensor: + sensor = ServerStatSensor( + topic, coordinator, integration_name, unique_prefix + ) + server_stat_sensors[topic] = sensor + async_add_entities([sensor], False) + sensor.on_message(topic, message) + + coordinator.register_message_receiver(on_message) + + +class BlitzortungSensor(SensorEntity): + """Define a Blitzortung sensor.""" + + def __init__(self, coordinator, integration_name, unique_prefix): + """Initialize.""" + self.coordinator = coordinator + self._integration_name = integration_name + self.entity_id = f"sensor.{integration_name}-{self.name}" + self._unique_id = f"{unique_prefix}-{self.kind}" + self._device_class = None + self._attrs = {ATTR_ATTRIBUTION: ATTRIBUTION} + + should_poll = False + icon = "mdi:flash" + device_class = None + + @property + def available(self): + return self.coordinator.is_connected + + @property + def label(self): + return self.kind.capitalize() + + @property + def name(self): + """Return the name.""" + return f"Lightning {self.label}" + + @property + def extra_state_attributes(self): + """Return the state attributes.""" + return self._attrs + + @property + def unique_id(self): + """Return a unique_id for this entity.""" + return self._unique_id + + async def async_added_to_hass(self): + """Connect to dispatcher listening for entity data notifications.""" + # self.async_on_remove(self.coordinator.async_add_listener(self._update_sensor)) + self.coordinator.register_sensor(self) + + async def async_update(self): + await self.coordinator.async_request_refresh() + + @property + def device_info(self): + return { + "name": f"{self._integration_name} Lightning Detector", + "identifiers": {(DOMAIN, self._integration_name)}, + "model": "Lightning Detector", + "sw_version": SW_VERSION, + "entry_type": DeviceEntryType.SERVICE, + } + + def update_lightning(self, lightning): + pass + + def on_message(self, message): + pass + + def tick(self): + pass + + +class LightningSensor(BlitzortungSensor): + INITIAL_STATE = None + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._attr_native_value = self.INITIAL_STATE + + def tick(self): + if self._attr_native_value != self.INITIAL_STATE and self.coordinator.is_inactive: + self._attr_native_value = self.INITIAL_STATE + self.async_write_ha_state() + + +class DistanceSensor(LightningSensor): + kind = SensorDeviceClass.DISTANCE + device_class = SensorDeviceClass.DISTANCE + state_class = SensorStateClass.MEASUREMENT + _attr_native_unit_of_measurement = UnitOfLength.KILOMETERS + + def update_lightning(self, lightning): + self._attr_native_value = lightning["distance"] + self._attrs[ATTR_LAT] = lightning[ATTR_LAT] + self._attrs[ATTR_LON] = lightning[ATTR_LON] + self.async_write_ha_state() + + +class AzimuthSensor(LightningSensor): + kind = ATTR_LIGHTNING_AZIMUTH + _attr_native_unit_of_measurement = DEGREE + + def update_lightning(self, lightning): + self._attr_native_value = lightning["azimuth"] + self._attrs[ATTR_LAT] = lightning[ATTR_LAT] + self._attrs[ATTR_LON] = lightning[ATTR_LON] + self.async_write_ha_state() + + +class CounterSensor(LightningSensor): + kind = ATTR_LIGHTNING_COUNTER + _attr_native_unit_of_measurement = "↯" + INITIAL_STATE = 0 + + def update_lightning(self, lightning): + self._attr_native_value = self._attr_native_value + 1 + self.async_write_ha_state() + + +class ServerStatSensor(BlitzortungSensor): + def __init__(self, topic, coordinator, integration_name, unique_prefix): + self._topic = topic + + topic_parts = topic.replace("$SYS/broker/", "").split("/") + self.kind = "_".join(topic_parts) + if self.kind.startswith("load"): + self.data_type = float + elif self.kind in ("uptime", "version"): + self.data_type = str + else: + self.data_type = int + + if self.kind == "clients_connected": + self.kind = "server_stats" + + self._name = " ".join(part.capitalize() for part in topic_parts) + + super().__init__(coordinator, integration_name, unique_prefix) + + @property + def unit_of_measurement(self): + if self.data_type in (int, float): + return "." if self.kind == "server_stats" else " " + else: + return None + + @classmethod + def for_topic(cls, topic, coordinator, integration_name, unique_prefix): + return cls(topic, coordinator, integration_name, unique_prefix) + + def on_message(self, topic, message): + if topic == self._topic: + payload = message.payload.decode("utf-8") + try: + self._attr_native_value = self.data_type(payload) + except ValueError: + self._attr_native_value = str(payload) + if self.hass: + self.async_write_ha_state() + + @property + def label(self): + return self._name + + @property + def name(self): + return self._name diff --git a/config/custom_components/blitzortung/strings.json b/config/custom_components/blitzortung/strings.json new file mode 100644 index 0000000..1ee08f9 --- /dev/null +++ b/config/custom_components/blitzortung/strings.json @@ -0,0 +1,29 @@ +{ + "config": { + "step": { + "user": { + "description": "Set up Blitzortung lightning detection", + "data": { + "name": "Name of the integration instance" + } + } + }, + "error": {}, + "abort": {} + }, + "options": { + "step": { + "init": { + "title": "Blitzortung Options", + "description": "Set up Blitzortung lightning detection options", + "data": { + "radius": "Lightning detection radius (km / mi)", + "time_window": "Time window (minutes, 0 - disabled)", + "max_tracked_lightnings": "Max number of tracked lightnings", + "latitude": "Latitude", + "longitude": "Longitude" + } + } + } + } +} \ No newline at end of file diff --git a/config/custom_components/blitzortung/translations/en.json b/config/custom_components/blitzortung/translations/en.json new file mode 100644 index 0000000..1ee08f9 --- /dev/null +++ b/config/custom_components/blitzortung/translations/en.json @@ -0,0 +1,29 @@ +{ + "config": { + "step": { + "user": { + "description": "Set up Blitzortung lightning detection", + "data": { + "name": "Name of the integration instance" + } + } + }, + "error": {}, + "abort": {} + }, + "options": { + "step": { + "init": { + "title": "Blitzortung Options", + "description": "Set up Blitzortung lightning detection options", + "data": { + "radius": "Lightning detection radius (km / mi)", + "time_window": "Time window (minutes, 0 - disabled)", + "max_tracked_lightnings": "Max number of tracked lightnings", + "latitude": "Latitude", + "longitude": "Longitude" + } + } + } + } +} \ No newline at end of file diff --git a/config/custom_components/blitzortung/translations/fi.json b/config/custom_components/blitzortung/translations/fi.json new file mode 100644 index 0000000..a960158 --- /dev/null +++ b/config/custom_components/blitzortung/translations/fi.json @@ -0,0 +1,29 @@ +{ + "config": { + "step": { + "user": { + "description": "Määritä Blitzortung ukkostutka", + "data": { + "name": "Integraation instanssin nimi" + } + } + }, + "error": {}, + "abort": {} + }, + "options": { + "step": { + "init": { + "title": "Blitzortung Asetukset", + "description": "Määritä Blitzortung ukkostutkan asetukset", + "data": { + "radius": "Salamoiden seuranta-alue (km / mi)", + "time_window": "Aikaikkuna (minuuttia, 0 - ei käytössä)", + "max_tracked_lightnings": "Seurattavien salamoiden enimmäismäärä", + "latitude": "Leveysaste", + "longitude": "Pituusaste" + } + } + } + } +} \ No newline at end of file diff --git a/config/custom_components/blitzortung/translations/fr.json b/config/custom_components/blitzortung/translations/fr.json new file mode 100644 index 0000000..513eb1e --- /dev/null +++ b/config/custom_components/blitzortung/translations/fr.json @@ -0,0 +1,29 @@ +{ + "config": { + "step": { + "user": { + "description": "Configurer Blitzortung détection de fourdre", + "data": { + "name": "Nom de l'instance de cette intégration" + } + } + }, + "error": {}, + "abort": {} + }, + "options": { + "step": { + "init": { + "title": "Options Blitzortung ", + "description": "Configurer les options de Blitzortung détection de foudre", + "data": { + "radius": "Rayon de detection de la foudre (km / mi)", + "time_window": "Fenêtre de temps (minutes, 0 - désactivé)", + "max_tracked_lightnings": "Nombre maximum d'éclairs suivis", + "latitude": "Latitude", + "longitude": "Longitude" + } + } + } + } +} diff --git a/config/custom_components/blitzortung/translations/hr.json b/config/custom_components/blitzortung/translations/hr.json new file mode 100644 index 0000000..f805908 --- /dev/null +++ b/config/custom_components/blitzortung/translations/hr.json @@ -0,0 +1,29 @@ +{ + "config": { + "step": { + "user": { + "description": "Postavke Blitzortung senzora groma", + "data": { + "name": "Naziv instance integracije" + } + } + }, + "error": {}, + "abort": {} + }, + "options": { + "step": { + "init": { + "title": "Blitzortung opcije", + "description": "Podesite Blitzortung opcije otkrivanja groma", + "data": { + "radius": "Radijus prepoznavanja groma (km / mi)", + "time_window": "Vremenski period (minuta, 0 - onemogućeno)", + "max_tracked_lightnings": "Maksimalan broj praćenih gromova", + "latitude": "Zemljopisna širina", + "longitude": "Zemljopisna dužina" + } + } + } + } + } diff --git a/config/custom_components/blitzortung/translations/nb.json b/config/custom_components/blitzortung/translations/nb.json new file mode 100644 index 0000000..2aa580a --- /dev/null +++ b/config/custom_components/blitzortung/translations/nb.json @@ -0,0 +1,29 @@ +{ + "config": { + "step": { + "user": { + "description": "Sett opp lynregistrering av Blitzortung", + "data": { + "name": "Navnet på integrasjonsforekomsten" + } + } + }, + "error": {}, + "abort": {} + }, + "options": { + "step": { + "init": { + "title": "Blitzortung-alternativer", + "description": "Sett opp Blitzortung lyndeteksjonsalternativer", + "data": { + "radius": "Lyndeteksjonsradius (km / mi)", + "time_window": "Tidsvindu (minutter, 0 - deaktivert)", + "max_tracked_lightnings": "Maks antall sporede lyn", + "latitude": "Breddegrad", + "longitude": "Lengdegrad" + } + } + } + } +} diff --git a/config/custom_components/blitzortung/translations/nl.json b/config/custom_components/blitzortung/translations/nl.json new file mode 100644 index 0000000..88fe8be --- /dev/null +++ b/config/custom_components/blitzortung/translations/nl.json @@ -0,0 +1,29 @@ +{ + "config": { + "step": { + "user": { + "description": "Blitzortung bliksemdetectie instellen", + "data": { + "name": "Naam van de integratie-instantie" + } + } + }, + "error": {}, + "abort": {} + }, + "options": { + "step": { + "init": { + "title": "Blitzortung Opties", + "description": "Blitzortung bliksemdetectie opties instellen", + "data": { + "radius": "Bliksemdetectie-radius (km / mi)", + "time_window": "Tijdvenster (minuten, 0 - uitgeschakeld)", + "max_tracked_lightnings": "Max aantal gevolgde bliksems", + "latitude": "Breedtegraad", + "longitude": "Lengtegraad" + } + } + } + } +} \ No newline at end of file diff --git a/config/custom_components/blitzortung/translations/pl.json b/config/custom_components/blitzortung/translations/pl.json new file mode 100644 index 0000000..e301fff --- /dev/null +++ b/config/custom_components/blitzortung/translations/pl.json @@ -0,0 +1,29 @@ +{ + "config": { + "step": { + "user": { + "description": "Konfiguracja wykrywania błyskawic Blitzortung", + "data": { + "name": "Nazwa instancji integracji" + } + } + }, + "error": {}, + "abort": {} + }, + "options": { + "step": { + "init": { + "title": "Opcje Blitzortung", + "description": "Konfiguracja wykrywania błyskawic Blitzortung", + "data": { + "radius": "Promień wykrywania błyskawic (km / mi)", + "time_window": "Okno czasowe (minuty, 0 - wyłączony)", + "max_tracked_lightnings": "Maksymalna ilość śledzonych błyskawic", + "latitude": "Szerokość geograficzna", + "longitude": "Długość geograficzna" + } + } + } + } +} diff --git a/config/custom_components/blitzortung/translations/sl.json b/config/custom_components/blitzortung/translations/sl.json new file mode 100644 index 0000000..925e94d --- /dev/null +++ b/config/custom_components/blitzortung/translations/sl.json @@ -0,0 +1,29 @@ +{ + "config": { + "step": { + "user": { + "description": "Postavke zaznavanja strel Blitzortung", + "data": { + "name": "Ime integracije" + } + } + }, + "error": {}, + "abort": {} + }, + "options": { + "step": { + "init": { + "title": "Možnosti Blitzortung", + "description": "Nastavite možnosti zaznavanja strel Blitzortung", + "data": { + "radius": "Doseg zaznavanja strel (km / mi)", + "time_window": "Časovni okvir (v minutah, 0 - disabled)", + "max_tracked_lightnings": "Maksimalno število zaznanih strel", + "latitude": "Zemljepisna širina središča zaznavanja", + "longitude": "Zemljepisna dolžina središča zaznavanja" + } + } + } + } +} \ No newline at end of file diff --git a/config/custom_components/blitzortung/version.py b/config/custom_components/blitzortung/version.py new file mode 100644 index 0000000..6849410 --- /dev/null +++ b/config/custom_components/blitzortung/version.py @@ -0,0 +1 @@ +__version__ = "1.1.0" diff --git a/config/custom_components/ecoflow_cloud/__init__.py b/config/custom_components/ecoflow_cloud/__init__.py new file mode 100644 index 0000000..be6404c --- /dev/null +++ b/config/custom_components/ecoflow_cloud/__init__.py @@ -0,0 +1,102 @@ +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + + +from .config.const import CONF_DEVICE_TYPE, CONF_USERNAME, CONF_PASSWORD, OPTS_POWER_STEP, OPTS_REFRESH_PERIOD_SEC, \ + DEFAULT_REFRESH_PERIOD_SEC, CONF_DEVICE_ID +from .mqtt.ecoflow_mqtt import EcoflowMQTTClient, EcoflowAuthentication + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "ecoflow_cloud" +CONFIG_VERSION = 3 + +_PLATFORMS = { + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + Platform.BUTTON, + +} + +ATTR_STATUS_SN = "SN" +ATTR_STATUS_UPDATES = "status_request_count" +ATTR_STATUS_LAST_UPDATE = "status_last_update" +ATTR_STATUS_DATA_LAST_UPDATE = "data_last_update" +ATTR_STATUS_RECONNECTS = "reconnects" +ATTR_STATUS_PHASE = "status_phase" + + +async def async_migrate_entry(hass, config_entry: ConfigEntry): + """Migrate old entry.""" + if config_entry.version == 1: + from .devices.registry import devices as device_registry + device = device_registry[config_entry.data[CONF_DEVICE_TYPE]] + + new_data = {**config_entry.data} + new_options = {OPTS_POWER_STEP: device.charging_power_step(), + OPTS_REFRESH_PERIOD_SEC: DEFAULT_REFRESH_PERIOD_SEC} + + config_entry.version = 2 + hass.config_entries.async_update_entry(config_entry, data=new_data, options=new_options) + _LOGGER.info("Migration to version %s successful", config_entry.version) + + if config_entry.version < CONFIG_VERSION: + from .devices.registry import devices as device_registry + from .entities import EcoFlowAbstractEntity + from .devices import EntityMigration, MigrationAction + + device = device_registry[config_entry.data[CONF_DEVICE_TYPE]] + device_sn = config_entry.data[CONF_DEVICE_ID] + entity_registry = er.async_get(hass) + + for v in (config_entry.version, CONFIG_VERSION): + migrations: list[EntityMigration] = device.migrate(v) + for m in migrations: + if m.action == MigrationAction.REMOVE: + entity_id = entity_registry.async_get_entity_id( + domain=m.domain, + platform=DOMAIN, + unique_id=EcoFlowAbstractEntity.gen_unique_id(sn=device_sn, key=m.key)) + + if entity_id: + _LOGGER.info(".... removing entity_id = %s", entity_id) + entity_registry.async_remove(entity_id) + + config_entry.version = CONFIG_VERSION + hass.config_entries.async_update_entry(config_entry) + _LOGGER.info("Migration to version %s successful", config_entry.version) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {} + + auth = EcoflowAuthentication(entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD]) + await hass.async_add_executor_job(auth.authorize) + client = EcoflowMQTTClient(hass, entry, auth) + + hass.data[DOMAIN][entry.entry_id] = client + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + if not await hass.config_entries.async_unload_platforms(entry, _PLATFORMS): + return False + + client: EcoflowMQTTClient = hass.data[DOMAIN].pop(entry.entry_id) + client.stop() + return True + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + await hass.config_entries.async_reload(entry.entry_id) diff --git a/config/custom_components/ecoflow_cloud/button.py b/config/custom_components/ecoflow_cloud/button.py new file mode 100644 index 0000000..762641a --- /dev/null +++ b/config/custom_components/ecoflow_cloud/button.py @@ -0,0 +1,34 @@ +import logging +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import DOMAIN +from .entities import BaseButtonEntity +from .mqtt.ecoflow_mqtt import EcoflowMQTTClient + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback): + client: EcoflowMQTTClient = hass.data[DOMAIN][entry.entry_id] + + from .devices.registry import devices + async_add_entities(devices[client.device_type].buttons(client)) + + +class EnabledButtonEntity(BaseButtonEntity): + + def press(self, **kwargs: Any) -> None: + if self._command: + self.send_set_message(0, self.command_dict(0)) + +class DisabledButtonEntity(BaseButtonEntity): + + async def async_press(self, **kwargs: Any) -> None: + if self._command: + self.send_set_message(0, self.command_dict(0)) + diff --git a/config/custom_components/ecoflow_cloud/config/__init__.py b/config/custom_components/ecoflow_cloud/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/custom_components/ecoflow_cloud/config/const.py b/config/custom_components/ecoflow_cloud/config/const.py new file mode 100644 index 0000000..ce4638c --- /dev/null +++ b/config/custom_components/ecoflow_cloud/config/const.py @@ -0,0 +1,35 @@ +from enum import Enum +from typing import Final + +from homeassistant import const + +CONF_USERNAME: Final = const.CONF_USERNAME +CONF_PASSWORD: Final = const.CONF_PASSWORD +CONF_DEVICE_TYPE: Final = const.CONF_TYPE +CONF_DEVICE_NAME: Final = const.CONF_NAME +CONF_DEVICE_ID: Final = const.CONF_DEVICE_ID +OPTS_POWER_STEP: Final = "power_step" +OPTS_REFRESH_PERIOD_SEC: Final = "refresh_period_sec" + +DEFAULT_REFRESH_PERIOD_SEC: Final = 5 + + +class EcoflowModel(Enum): + DELTA_2 = 1, + RIVER_2 = 2, + RIVER_2_MAX = 3, + RIVER_2_PRO = 4, + DELTA_PRO = 5, + RIVER_MAX = 6, + RIVER_PRO = 7, + DELTA_MAX = 8, # productType = 13 + DELTA_2_MAX = 9, # productType = 81 + DELTA_MINI = 15, # productType = 15 + POWERSTREAM = 51, + GLACIER = 46, + WAVE_2 = 45, # productType = 45 + DIAGNOSTIC = 99 + + @classmethod + def list(cls) -> list[str]: + return [e.name for e in EcoflowModel] diff --git a/config/custom_components/ecoflow_cloud/config_flow.py b/config/custom_components/ecoflow_cloud/config_flow.py new file mode 100644 index 0000000..9e8cb54 --- /dev/null +++ b/config/custom_components/ecoflow_cloud/config_flow.py @@ -0,0 +1,72 @@ +import logging +from typing import Dict, Any + +import voluptuous as vol +from homeassistant import const +from homeassistant.config_entries import ConfigFlow, ConfigEntry, OptionsFlow +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import selector + +from . import DOMAIN, CONFIG_VERSION +from .config.const import EcoflowModel, CONF_USERNAME, CONF_PASSWORD, CONF_DEVICE_TYPE, CONF_DEVICE_NAME, \ + CONF_DEVICE_ID, OPTS_POWER_STEP, OPTS_REFRESH_PERIOD_SEC, DEFAULT_REFRESH_PERIOD_SEC + +_LOGGER = logging.getLogger(__name__) + + +class EcoflowConfigFlow(ConfigFlow, domain=DOMAIN): + VERSION = CONFIG_VERSION + + async def async_step_user(self, user_input: dict[str, Any] | None = None): + errors: Dict[str, str] = {} + if user_input is not None and not errors: + from .devices.registry import devices + device = devices[user_input[CONF_DEVICE_TYPE]] + + options = {OPTS_POWER_STEP: device.charging_power_step(), OPTS_REFRESH_PERIOD_SEC: DEFAULT_REFRESH_PERIOD_SEC} + + return self.async_create_entry(title=user_input[const.CONF_NAME], data=user_input, options=options) + + return self.async_show_form( + step_id="user", + last_step=True, + data_schema=vol.Schema({ + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_DEVICE_TYPE): selector.SelectSelector( + selector.SelectSelectorConfig(options=EcoflowModel.list(), + mode=selector.SelectSelectorMode.DROPDOWN), + ), + vol.Required(CONF_DEVICE_NAME): str, + vol.Required(CONF_DEVICE_ID): str, + }) + + ) + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + return EcoflowOptionsFlow(config_entry) + + +class EcoflowOptionsFlow(OptionsFlow): + def __init__(self, config_entry: ConfigEntry) -> None: + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + last_step=True, + data_schema=vol.Schema({ + vol.Optional(OPTS_POWER_STEP, + default=self.config_entry.options[OPTS_POWER_STEP]): int, + vol.Optional(OPTS_REFRESH_PERIOD_SEC, + default=self.config_entry.options[OPTS_REFRESH_PERIOD_SEC]): int, + }) + ) diff --git a/config/custom_components/ecoflow_cloud/devices/__init__.py b/config/custom_components/ecoflow_cloud/devices/__init__.py new file mode 100644 index 0000000..0a13ddf --- /dev/null +++ b/config/custom_components/ecoflow_cloud/devices/__init__.py @@ -0,0 +1,70 @@ +from abc import ABC, abstractmethod +from enum import StrEnum + +from homeassistant.components.number import NumberEntity +from homeassistant.components.select import SelectEntity +from homeassistant.components.sensor import SensorEntity +from homeassistant.components.switch import SwitchEntity +from homeassistant.components.button import ButtonEntity +from homeassistant.const import Platform + +from ..mqtt.ecoflow_mqtt import EcoflowMQTTClient + + +class MigrationAction(StrEnum): + REMOVE = "remove" + + +class EntityMigration: + + def __init__(self, key: str, domain: Platform, action: MigrationAction, **kwargs): + self.key = key + self.domain = domain + self.action = action + self.args = kwargs + + +class BaseDevice(ABC): + + def charging_power_step(self) -> int: + return 100 + + @abstractmethod + def sensors(self, client: EcoflowMQTTClient) -> list[SensorEntity]: + pass + + @abstractmethod + def numbers(self, client: EcoflowMQTTClient) -> list[NumberEntity]: + pass + + @abstractmethod + def switches(self, client: EcoflowMQTTClient) -> list[SwitchEntity]: + pass + + @abstractmethod + def selects(self, client: EcoflowMQTTClient) -> list[SelectEntity]: + pass + + def buttons(self, client: EcoflowMQTTClient) -> list[ButtonEntity]: + return [] + + def migrate(self, version) -> list[EntityMigration]: + return [] + + +class DiagnosticDevice(BaseDevice): + + def sensors(self, client: EcoflowMQTTClient) -> list[SensorEntity]: + return [] + + def numbers(self, client: EcoflowMQTTClient) -> list[NumberEntity]: + return [] + + def switches(self, client: EcoflowMQTTClient) -> list[SwitchEntity]: + return [] + + def buttons(self, client: EcoflowMQTTClient) -> list[ButtonEntity]: + return [] + + def selects(self, client: EcoflowMQTTClient) -> list[SelectEntity]: + return [] diff --git a/config/custom_components/ecoflow_cloud/devices/const.py b/config/custom_components/ecoflow_cloud/devices/const.py new file mode 100644 index 0000000..60ca56b --- /dev/null +++ b/config/custom_components/ecoflow_cloud/devices/const.py @@ -0,0 +1,255 @@ +DC_MODE_OPTIONS = { + "Auto": 0, + "Solar Recharging": 1, + "Car Recharging": 2, +} + +DC_ICONS = { + "Auto": None, + "MPPT": "mdi:solar-power", + "DC": "mdi:current-dc", +} + +SCREEN_TIMEOUT_OPTIONS = { + "Never": 0, + "10 sec": 10, + "30 sec": 30, + "1 min": 60, + "5 min": 300, + "30 min": 1800, +} + +UNIT_TIMEOUT_OPTIONS = { + "Never": 0, + "30 min": 30, + "1 hr": 60, + "2 hr": 120, + "4 hr": 240, + "6 hr": 360, + "12 hr": 720, + "24 hr": 1440 +} + +UNIT_TIMEOUT_OPTIONS_LIMITED = { + "Never": 0, + "30 min": 30, + "1 hr": 60, + "2 hr": 120, + "6 hr": 360, + "12 hr": 720 +} + +AC_TIMEOUT_OPTIONS = { + "Never": 0, + "30 min": 30, + "1 hr": 60, + "2 hr": 120, + "4 hr": 240, + "6 hr": 360, + "12 hr": 720, + "24 hr": 1440, +} + +AC_TIMEOUT_OPTIONS_LIMITED = { + "Never": 0, + "2 hr": 120, + "4 hr": 240, + "6 hr": 360, + "12 hr": 720, + "24 hr": 1440, +} + +DC_TIMEOUT_OPTIONS = { + "Never": 0, + "30 min": 30, + "1 hr": 60, + "2 hr": 120, + "4 hr": 240, + "6 hr": 360, + "12 hr": 720, + "24 hr": 1440, +} + +DC_TIMEOUT_OPTIONS_LIMITED = { + "Never": 0, + "2 hr": 120, + "4 hr": 240, + "6 hr": 360, + "12 hr": 720, + "24 hr": 1440, +} + +DC_CHARGE_CURRENT_OPTIONS = { + "4A": 4000, + "6A": 6000, + "8A": 8000 +} + +MAIN_MODE_OPTIONS = { + "Cool": 0, + "Heat": 1, + "Fan": 2 +} + +FAN_MODE_OPTIONS = { + "Low": 0, + "Medium": 1, + "High": 2 +} + +REMOTE_MODE_OPTIONS = { + "Startup": 1, + "Standby": 2, + "Shutdown": 3 +} + +POWER_SUB_MODE_OPTIONS = { + "Max": 0, + "Sleep": 1, + "Eco": 2, + "Manual": 3 +} + +COMBINED_BATTERY_LEVEL = "Battery Level" +COMBINED_BATTERY_LEVEL_F32 = "Battery Level (Precise)" +BATTERY_CHARGING_STATE = "Battery Charging State" + +ATTR_DESIGN_CAPACITY = "Design Capacity (mAh)" +ATTR_FULL_CAPACITY = "Full Capacity (mAh)" +ATTR_REMAIN_CAPACITY = "Remain Capacity (mAh)" +MAIN_DESIGN_CAPACITY = "Main Design Capacity" +MAIN_FULL_CAPACITY = "Main Full Capacity" +MAIN_REMAIN_CAPACITY = "Main Remain Capacity" +SLAVE_DESIGN_CAPACITY = "Slave Design Capacity" +SLAVE_FULL_CAPACITY = "Slave Full Capacity" +SLAVE_REMAIN_CAPACITY = "Slave Remain Capacity" +SLAVE_N_DESIGN_CAPACITY = "Slave %i Design Capacity" +SLAVE_N_FULL_CAPACITY = "Slave %i Full Capacity" +SLAVE_N_REMAIN_CAPACITY = "Slave %i Remain Capacity" + +MAIN_BATTERY_LEVEL = "Main Battery Level" +MAIN_BATTERY_LEVEL_F32 = "Main Battery Level (Precise)" +MAIN_BATTERY_CURRENT = "Main Battery Current" +TOTAL_IN_POWER = "Total In Power" +SOLAR_IN_POWER = "Solar In Power" +SOLAR_1_IN_POWER = "Solar (1) In Power" +SOLAR_2_IN_POWER = "Solar (2) In Power" +AC_IN_POWER = "AC In Power" +AC_IN_VOLT = "AC In Volts" +AC_OUT_VOLT = "AC Out Volts" + +TYPE_C_IN_POWER = "Type-C In Power" +SOLAR_IN_CURRENT = "Solar In Current" +SOLAR_IN_VOLTAGE = "Solar In Voltage" +SOLAR_IN_ENERGY = "Solar In Energy" +CHARGE_AC_ENERGY = "Battery Charge Energy from AC" +CHARGE_DC_ENERGY = "Battery Charge Energy from DC" +DISCHARGE_AC_ENERGY = "Battery Discharge Energy to AC" +DISCHARGE_DC_ENERGY = "Battery Discharge Energy to DC" + +TOTAL_OUT_POWER = "Total Out Power" +AC_OUT_POWER = "AC Out Power" +DC_OUT_POWER = "DC Out Power" +DC_OUT_VOLTAGE = "DC Out Voltage" +DC_CAR_OUT_POWER = "DC Car Out Power" +DC_ANDERSON_OUT_POWER = "DC Anderson Out Power" + +TYPEC_OUT_POWER = "Type-C Out Power" +TYPEC_1_OUT_POWER = "Type-C (1) Out Power" +TYPEC_2_OUT_POWER = "Type-C (2) Out Power" +USB_OUT_POWER = "USB Out Power" +USB_1_OUT_POWER = "USB (1) Out Power" +USB_2_OUT_POWER = "USB (2) Out Power" +USB_3_OUT_POWER = "USB (3) Out Power" + +USB_QC_1_OUT_POWER = "USB QC (1) Out Power" +USB_QC_2_OUT_POWER = "USB QC (2) Out Power" + +REMAINING_TIME = "Remaining Time" +CHARGE_REMAINING_TIME = "Charge Remaining Time" +DISCHARGE_REMAINING_TIME = "Discharge Remaining Time" + +CYCLES = "Cycles" +SOH = "State of Health" + +SLAVE_BATTERY_LEVEL = "Slave Battery Level" +SLAVE_N_BATTERY_LEVEL = "Slave %i Battery Level" +SLAVE_N_BATTERY_LEVEL_F32 = "Slave %i Battery Level (Precise)" + +SLAVE_BATTERY_TEMP = "Slave Battery Temperature" +SLAVE_N_BATTERY_TEMP = "Slave %i Battery Temperature" + +SLAVE_MIN_CELL_TEMP = "Slave Min Cell Temperature" +SLAVE_MAX_CELL_TEMP = "Slave Max Cell Temperature" + +SLAVE_N_MIN_CELL_TEMP = "Slave %i Min Cell Temperature" +SLAVE_N_MAX_CELL_TEMP = "Slave %i Max Cell Temperature" + +SLAVE_CYCLES = "Slave Cycles" +SLAVE_N_CYCLES = "Slave %i Cycles" +SLAVE_SOH = "Slave State of Health" +SLAVE_N_SOH = "Slave %i State of Health" + +SLAVE_IN_POWER = "Slave In Power" +SLAVE_N_IN_POWER = "Slave %i In Power" + +SLAVE_OUT_POWER = "Slave Out Power" +SLAVE_N_OUT_POWER = "Slave %i Out Power" + +SLAVE_BATTERY_VOLT = "Slave Battery Volts" +SLAVE_MIN_CELL_VOLT = "Slave Min Cell Volts" +SLAVE_MAX_CELL_VOLT = "Slave Max Cell Volts" + +SLAVE_N_BATTERY_VOLT = "Slave %i Battery Volts" +SLAVE_N_MIN_CELL_VOLT = "Slave %i Min Cell Volts" +SLAVE_N_MAX_CELL_VOLT = "Slave %i Max Cell Volts" +SLAVE_N_BATTERY_CURRENT = "Slave %i Battery Current" + +MAX_CHARGE_LEVEL = "Max Charge Level" +MIN_DISCHARGE_LEVEL = "Min Discharge Level" +BACKUP_RESERVE_LEVEL = "Backup Reserve Level" +AC_CHARGING_POWER = "AC Charging Power" +SCREEN_TIMEOUT = "Screen Timeout" +UNIT_TIMEOUT = "Unit Timeout" +AC_TIMEOUT = "AC Timeout" +DC_TIMEOUT = "DC (12V) Timeout" +DC_CHARGE_CURRENT = "DC (12V) Charge Current" +GEN_AUTO_START_LEVEL = "Generator Auto Start Level" +GEN_AUTO_STOP_LEVEL = "Generator Auto Stop Level" + +BEEPER = "Beeper" +USB_ENABLED = "USB Enabled" +AC_ENABLED = "AC Enabled" +DC_ENABLED = "DC (12V) Enabled" +XBOOST_ENABLED = "X-Boost Enabled" +AC_ALWAYS_ENABLED = "AC Always On" +PV_PRIO = "Prio Solar Charging" +BP_ENABLED = "Backup Reserve Enabled" +AUTO_FAN_SPEED = "Auto Fan Speed" +AC_SLOW_CHARGE = "AC Slow Charging" + +DC_MODE = "DC Mode" + +BATTERY_TEMP = "Battery Temperature" +MIN_CELL_TEMP = "Min Cell Temperature" +MAX_CELL_TEMP = "Max Cell Temperature" +INV_IN_TEMP = "Inverter Inside Temperature" +INV_OUT_TEMP = "Inverter Outside Temperature" +DC_CAR_OUT_TEMP = "DC Temperature" +USB_C_TEMP = "USB C Temperature" +ATTR_MIN_CELL_TEMP = MIN_CELL_TEMP +ATTR_MAX_CELL_TEMP = MAX_CELL_TEMP + +BATTERY_VOLT = "Battery Volts" +MIN_CELL_VOLT = "Min Cell Volts" +MAX_CELL_VOLT = "Max Cell Volts" +ATTR_MIN_CELL_VOLT = MIN_CELL_VOLT +ATTR_MAX_CELL_VOLT = MAX_CELL_VOLT + +BATTERY_AMP = "Battery Current" +SLAVE_BATTERY_AMP = "Slave Battery Current" + +FAN_MODE = "Wind speed" +MAIN_MODE = "Main mode" +REMOTE_MODE = "Remote startup/shutdown" +POWER_SUB_MODE = "Sub-mode" diff --git a/config/custom_components/ecoflow_cloud/devices/delta2.py b/config/custom_components/ecoflow_cloud/devices/delta2.py new file mode 100644 index 0000000..bef42c0 --- /dev/null +++ b/config/custom_components/ecoflow_cloud/devices/delta2.py @@ -0,0 +1,200 @@ +from homeassistant.const import Platform + +from . import const, BaseDevice, EntityMigration, MigrationAction +from .. import EcoflowMQTTClient +from ..entities import BaseSensorEntity, BaseNumberEntity, BaseSwitchEntity, BaseSelectEntity +from ..number import ChargingPowerEntity, MinBatteryLevelEntity, MaxBatteryLevelEntity, \ + MaxGenStopLevelEntity, MinGenStartLevelEntity, BatteryBackupLevel +from ..select import DictSelectEntity, TimeoutDictSelectEntity +from ..sensor import LevelSensorEntity, RemainSensorEntity, TempSensorEntity, CyclesSensorEntity, \ + InWattsSensorEntity, OutWattsSensorEntity, QuotasStatusSensorEntity, MilliVoltSensorEntity, InMilliVoltSensorEntity, \ + OutMilliVoltSensorEntity, CapacitySensorEntity +from ..switch import BeeperEntity, EnabledEntity + + +class Delta2(BaseDevice): + def sensors(self, client: EcoflowMQTTClient) -> list[BaseSensorEntity]: + return [ + LevelSensorEntity(client, "bms_bmsStatus.soc", const.MAIN_BATTERY_LEVEL) + .attr("bms_bmsStatus.designCap", const.ATTR_DESIGN_CAPACITY, 0) + .attr("bms_bmsStatus.fullCap", const.ATTR_FULL_CAPACITY, 0) + .attr("bms_bmsStatus.remainCap", const.ATTR_REMAIN_CAPACITY, 0), + CapacitySensorEntity(client, "bms_bmsStatus.designCap", const.MAIN_DESIGN_CAPACITY, False), + CapacitySensorEntity(client, "bms_bmsStatus.fullCap", const.MAIN_FULL_CAPACITY, False), + CapacitySensorEntity(client, "bms_bmsStatus.remainCap", const.MAIN_REMAIN_CAPACITY, False), + + LevelSensorEntity(client, "bms_bmsStatus.soh", const.SOH), + + LevelSensorEntity(client, "bms_emsStatus.lcdShowSoc", const.COMBINED_BATTERY_LEVEL), + InWattsSensorEntity(client, "pd.wattsInSum", const.TOTAL_IN_POWER), + OutWattsSensorEntity(client, "pd.wattsOutSum", const.TOTAL_OUT_POWER), + + InWattsSensorEntity(client, "inv.inputWatts", const.AC_IN_POWER), + OutWattsSensorEntity(client, "inv.outputWatts", const.AC_OUT_POWER), + + InMilliVoltSensorEntity(client, "inv.acInVol", const.AC_IN_VOLT), + OutMilliVoltSensorEntity(client, "inv.invOutVol", const.AC_OUT_VOLT), + + InWattsSensorEntity(client, "mppt.inWatts", const.SOLAR_IN_POWER), + + + + # OutWattsSensorEntity(client, "pd.carWatts", const.DC_OUT_POWER), + # the same value as pd.carWatts + OutWattsSensorEntity(client, "mppt.outWatts", const.DC_OUT_POWER), + + OutWattsSensorEntity(client, "pd.typec1Watts", const.TYPEC_1_OUT_POWER), + OutWattsSensorEntity(client, "pd.typec2Watts", const.TYPEC_2_OUT_POWER), + + OutWattsSensorEntity(client, "pd.usb1Watts", const.USB_1_OUT_POWER), + OutWattsSensorEntity(client, "pd.usb2Watts", const.USB_2_OUT_POWER), + + OutWattsSensorEntity(client, "pd.qcUsb1Watts", const.USB_QC_1_OUT_POWER), + OutWattsSensorEntity(client, "pd.qcUsb2Watts", const.USB_QC_2_OUT_POWER), + + RemainSensorEntity(client, "bms_emsStatus.chgRemainTime", const.CHARGE_REMAINING_TIME), + RemainSensorEntity(client, "bms_emsStatus.dsgRemainTime", const.DISCHARGE_REMAINING_TIME), + + TempSensorEntity(client, "inv.outTemp", "Inv Out Temperature"), + CyclesSensorEntity(client, "bms_bmsStatus.cycles", const.CYCLES), + + TempSensorEntity(client, "bms_bmsStatus.temp", const.BATTERY_TEMP) + .attr("bms_bmsStatus.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0) + .attr("bms_bmsStatus.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0), + TempSensorEntity(client, "bms_bmsStatus.minCellTemp", const.MIN_CELL_TEMP, False), + TempSensorEntity(client, "bms_bmsStatus.maxCellTemp", const.MAX_CELL_TEMP, False), + + MilliVoltSensorEntity(client, "bms_bmsStatus.vol", const.BATTERY_VOLT, False) + .attr("bms_bmsStatus.minCellVol", const.ATTR_MIN_CELL_VOLT, 0) + .attr("bms_bmsStatus.maxCellVol", const.ATTR_MAX_CELL_VOLT, 0), + MilliVoltSensorEntity(client, "bms_bmsStatus.minCellVol", const.MIN_CELL_VOLT, False), + MilliVoltSensorEntity(client, "bms_bmsStatus.maxCellVol", const.MAX_CELL_VOLT, False), + + # Optional Slave Battery + LevelSensorEntity(client, "bms_slave.soc", const.SLAVE_BATTERY_LEVEL, False, True) + .attr("bms_slave.designCap", const.ATTR_DESIGN_CAPACITY, 0) + .attr("bms_slave.fullCap", const.ATTR_FULL_CAPACITY, 0) + .attr("bms_slave.remainCap", const.ATTR_REMAIN_CAPACITY, 0), + CapacitySensorEntity(client, "bms_slave.designCap", const.SLAVE_DESIGN_CAPACITY, False), + CapacitySensorEntity(client, "bms_slave.fullCap", const.SLAVE_FULL_CAPACITY, False), + CapacitySensorEntity(client, "bms_slave.remainCap", const.SLAVE_REMAIN_CAPACITY, False), + + LevelSensorEntity(client, "bms_slave.soh", const.SLAVE_SOH), + TempSensorEntity(client, "bms_slave.temp", const.SLAVE_BATTERY_TEMP, False, True) + .attr("bms_slave.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0) + .attr("bms_slave.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0), + TempSensorEntity(client, "bms_slave.minCellTemp", const.SLAVE_MIN_CELL_TEMP, False), + TempSensorEntity(client, "bms_slave.maxCellTemp", const.SLAVE_MAX_CELL_TEMP, False), + + MilliVoltSensorEntity(client, "bms_slave.vol", const.SLAVE_BATTERY_VOLT, False) + .attr("bms_slave.minCellVol", const.ATTR_MIN_CELL_VOLT, 0) + .attr("bms_slave.maxCellVol", const.ATTR_MAX_CELL_VOLT, 0), + MilliVoltSensorEntity(client, "bms_slave.minCellVol", const.SLAVE_MIN_CELL_VOLT, False), + MilliVoltSensorEntity(client, "bms_slave.maxCellVol", const.SLAVE_MAX_CELL_VOLT, False), + + CyclesSensorEntity(client, "bms_slave.cycles", const.SLAVE_CYCLES, False, True), + InWattsSensorEntity(client, "bms_slave.inputWatts", const.SLAVE_IN_POWER, False, True), + OutWattsSensorEntity(client, "bms_slave.outputWatts", const.SLAVE_OUT_POWER, False, True), + QuotasStatusSensorEntity(client), + + ] + + def numbers(self, client: EcoflowMQTTClient) -> list[BaseNumberEntity]: + return [ + MaxBatteryLevelEntity(client, "bms_emsStatus.maxChargeSoc", const.MAX_CHARGE_LEVEL, 50, 100, + lambda value: {"moduleType": 2, "operateType": "upsConfig", + "params": {"maxChgSoc": int(value)}}), + + MinBatteryLevelEntity(client, "bms_emsStatus.minDsgSoc", const.MIN_DISCHARGE_LEVEL, 0, 30, + lambda value: {"moduleType": 2, "operateType": "dsgCfg", + "params": {"minDsgSoc": int(value)}}), + + BatteryBackupLevel(client, "pd.bpPowerSoc", const.BACKUP_RESERVE_LEVEL, 5, 100, + "bms_emsStatus.minDsgSoc", "bms_emsStatus.maxChargeSoc", + lambda value: {"moduleType": 1, "operateType": "watthConfig", + "params": {"isConfig": 1, "bpPowerSoc": int(value), "minDsgSoc": 0, + "minChgSoc": 0}}), + + MinGenStartLevelEntity(client, "bms_emsStatus.minOpenOilEb", const.GEN_AUTO_START_LEVEL, 0, 30, + lambda value: {"moduleType": 2, "operateType": "openOilSoc", + "params": {"openOilSoc": value}}), + + MaxGenStopLevelEntity(client, "bms_emsStatus.maxCloseOilEb", const.GEN_AUTO_STOP_LEVEL, 50, 100, + lambda value: {"moduleType": 2, "operateType": "closeOilSoc", + "params": {"closeOilSoc": value}}), + + ChargingPowerEntity(client, "mppt.cfgChgWatts", const.AC_CHARGING_POWER, 200, 1200, + lambda value: {"moduleType": 5, "operateType": "acChgCfg", + "params": {"chgWatts": int(value), "chgPauseFlag": 255}}) + + ] + + def switches(self, client: EcoflowMQTTClient) -> list[BaseSwitchEntity]: + return [ + BeeperEntity(client, "mppt.beepState", const.BEEPER, + lambda value: {"moduleType": 5, "operateType": "quietMode", "params": {"enabled": value}}), + + EnabledEntity(client, "pd.dcOutState", const.USB_ENABLED, + lambda value: {"moduleType": 1, "operateType": "dcOutCfg", "params": {"enabled": value}}), + + EnabledEntity(client, "pd.acAutoOutConfig", const.AC_ALWAYS_ENABLED, + lambda value, params: {"moduleType": 1, "operateType": "acAutoOutConfig", + "params": {"acAutoOutConfig": value, + "minAcOutSoc": int(params.get("bms_emsStatus.minDsgSoc", 0)) + 5}}), + + EnabledEntity(client, "pd.pvChgPrioSet", const.PV_PRIO, + lambda value: {"moduleType": 1, "operateType": "pvChangePrio", + "params": {"pvChangeSet": value}}), + + EnabledEntity(client, "mppt.cfgAcEnabled", const.AC_ENABLED, + lambda value: {"moduleType": 5, "operateType": "acOutCfg", + "params": {"enabled": value, "out_voltage": -1, "out_freq": 255, + "xboost": 255}}), + + EnabledEntity(client, "mppt.cfgAcXboost", const.XBOOST_ENABLED, + lambda value: {"moduleType": 5, "operateType": "acOutCfg", + "params": {"enabled": 255, "out_voltage": -1, "out_freq": 255, + "xboost": value}}), + + EnabledEntity(client, "pd.carState", const.DC_ENABLED, + lambda value: {"moduleType": 5, "operateType": "mpptCar", "params": {"enabled": value}}), + + EnabledEntity(client, "pd.bpPowerSoc", const.BP_ENABLED, + lambda value: {"moduleType": 1, + "operateType": "watthConfig", + "params": {"bpPowerSoc": value, + "minChgSoc": 0, + "isConfig": value, + "minDsgSoc": 0}}), + ] + + def selects(self, client: EcoflowMQTTClient) -> list[BaseSelectEntity]: + return [ + DictSelectEntity(client, "mppt.dcChgCurrent", const.DC_CHARGE_CURRENT, const.DC_CHARGE_CURRENT_OPTIONS, + lambda value: {"moduleType": 5, "operateType": "dcChgCfg", + "params": {"dcChgCfg": value}}), + + TimeoutDictSelectEntity(client, "pd.lcdOffSec", const.SCREEN_TIMEOUT, const.SCREEN_TIMEOUT_OPTIONS, + lambda value: {"moduleType": 1, "operateType": "lcdCfg", + "params": {"brighLevel": 255, "delayOff": value}}), + + TimeoutDictSelectEntity(client, "pd.standbyMin", const.UNIT_TIMEOUT, const.UNIT_TIMEOUT_OPTIONS, + lambda value: {"moduleType": 1, "operateType": "standbyTime", + "params": {"standbyMin": value}}), + + TimeoutDictSelectEntity(client, "mppt.acStandbyMins", const.AC_TIMEOUT, const.AC_TIMEOUT_OPTIONS, + lambda value: {"moduleType": 5, "operateType": "standbyTime", + "params": {"standbyMins": value}}), + + TimeoutDictSelectEntity(client, "mppt.carStandbyMin", const.DC_TIMEOUT, const.DC_TIMEOUT_OPTIONS, + lambda value: {"moduleType": 5, "operateType": "carStandby", + "params": {"standbyMins": value}}) + + ] + + def migrate(self, version) -> list[EntityMigration]: + if version == 2: + return [ + EntityMigration("pd.soc", Platform.SENSOR, MigrationAction.REMOVE) + ] + return [] diff --git a/config/custom_components/ecoflow_cloud/devices/delta2_max.py b/config/custom_components/ecoflow_cloud/devices/delta2_max.py new file mode 100644 index 0000000..2d27ec1 --- /dev/null +++ b/config/custom_components/ecoflow_cloud/devices/delta2_max.py @@ -0,0 +1,229 @@ +from homeassistant.const import Platform + +from . import const, BaseDevice, EntityMigration, MigrationAction +from .. import EcoflowMQTTClient +from ..entities import BaseSensorEntity, BaseNumberEntity, BaseSwitchEntity, BaseSelectEntity +from ..number import ChargingPowerEntity, MinBatteryLevelEntity, MaxBatteryLevelEntity, \ + MaxGenStopLevelEntity, MinGenStartLevelEntity, BatteryBackupLevel +from ..select import TimeoutDictSelectEntity +from ..sensor import LevelSensorEntity, RemainSensorEntity, TempSensorEntity, CyclesSensorEntity, \ + InWattsSensorEntity, OutWattsSensorEntity, StatusSensorEntity, MilliVoltSensorEntity, \ + InMilliVoltSensorEntity, OutMilliVoltSensorEntity, CapacitySensorEntity +from ..switch import BeeperEntity, EnabledEntity + + +class Delta2Max(BaseDevice): + def sensors(self, client: EcoflowMQTTClient) -> list[BaseSensorEntity]: + return [ + LevelSensorEntity(client, "bms_bmsStatus.soc", const.MAIN_BATTERY_LEVEL) + .attr("bms_bmsStatus.designCap", const.ATTR_DESIGN_CAPACITY, 0) + .attr("bms_bmsStatus.fullCap", const.ATTR_FULL_CAPACITY, 0) + .attr("bms_bmsStatus.remainCap", const.ATTR_REMAIN_CAPACITY, 0), + CapacitySensorEntity(client, "bms_bmsStatus.designCap", const.MAIN_DESIGN_CAPACITY, False), + CapacitySensorEntity(client, "bms_bmsStatus.fullCap", const.MAIN_FULL_CAPACITY, False), + CapacitySensorEntity(client, "bms_bmsStatus.remainCap", const.MAIN_REMAIN_CAPACITY, False), + + LevelSensorEntity(client, "bms_bmsStatus.soh", const.SOH), + + LevelSensorEntity(client, "bms_emsStatus.lcdShowSoc", const.COMBINED_BATTERY_LEVEL), + + InWattsSensorEntity(client, "pd.wattsInSum", const.TOTAL_IN_POWER), + OutWattsSensorEntity(client, "pd.wattsOutSum", const.TOTAL_OUT_POWER), + + InWattsSensorEntity(client, "inv.inputWatts", const.AC_IN_POWER), + OutWattsSensorEntity(client, "inv.outputWatts", const.AC_OUT_POWER), + + InMilliVoltSensorEntity(client, "inv.acInVol", const.AC_IN_VOLT), + OutMilliVoltSensorEntity(client, "inv.invOutVol", const.AC_OUT_VOLT), + + InWattsSensorEntity(client, "mppt.inWatts", const.SOLAR_1_IN_POWER), + InWattsSensorEntity(client, "mppt.pv2InWatts", const.SOLAR_2_IN_POWER), + + # OutWattsSensorEntity(client, "pd.carWatts", const.DC_OUT_POWER), + # the same value as pd.carWatts + OutWattsSensorEntity(client, "mppt.outWatts", const.DC_OUT_POWER), + + OutWattsSensorEntity(client, "pd.typec1Watts", const.TYPEC_1_OUT_POWER), + OutWattsSensorEntity(client, "pd.typec2Watts", const.TYPEC_2_OUT_POWER), + + OutWattsSensorEntity(client, "pd.usb1Watts", const.USB_1_OUT_POWER), + OutWattsSensorEntity(client, "pd.usb2Watts", const.USB_2_OUT_POWER), + + OutWattsSensorEntity(client, "pd.qcUsb1Watts", const.USB_QC_1_OUT_POWER), + OutWattsSensorEntity(client, "pd.qcUsb2Watts", const.USB_QC_2_OUT_POWER), + + RemainSensorEntity(client, "bms_emsStatus.chgRemainTime", const.CHARGE_REMAINING_TIME), + RemainSensorEntity(client, "bms_emsStatus.dsgRemainTime", const.DISCHARGE_REMAINING_TIME), + + TempSensorEntity(client, "inv.outTemp", "Inv Out Temperature"), + CyclesSensorEntity(client, "bms_bmsStatus.cycles", const.CYCLES), + + TempSensorEntity(client, "bms_bmsStatus.temp", const.BATTERY_TEMP) + .attr("bms_bmsStatus.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0) + .attr("bms_bmsStatus.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0), + TempSensorEntity(client, "bms_bmsStatus.minCellTemp", const.MIN_CELL_TEMP, False), + TempSensorEntity(client, "bms_bmsStatus.maxCellTemp", const.MAX_CELL_TEMP, False), + + MilliVoltSensorEntity(client, "bms_bmsStatus.vol", const.BATTERY_VOLT, False) + .attr("bms_bmsStatus.minCellVol", const.ATTR_MIN_CELL_VOLT, 0) + .attr("bms_bmsStatus.maxCellVol", const.ATTR_MAX_CELL_VOLT, 0), + MilliVoltSensorEntity(client, "bms_bmsStatus.minCellVol", const.MIN_CELL_VOLT, False), + MilliVoltSensorEntity(client, "bms_bmsStatus.maxCellVol", const.MAX_CELL_VOLT, False), + + # Optional Slave 1 Battery + LevelSensorEntity(client, "bms_slave_bmsSlaveStatus_1.soc", const.SLAVE_N_BATTERY_LEVEL % 1, False, True) + .attr("bms_slave_bmsSlaveStatus_1.designCap", const.ATTR_DESIGN_CAPACITY, 0) + .attr("bms_slave_bmsSlaveStatus_1.fullCap", const.ATTR_FULL_CAPACITY, 0) + .attr("bms_slave_bmsSlaveStatus_1.remainCap", const.ATTR_REMAIN_CAPACITY, 0), + CapacitySensorEntity(client, "bms_slave_bmsSlaveStatus_1.designCap", const.SLAVE_N_DESIGN_CAPACITY % 1, False), + CapacitySensorEntity(client, "bms_slave_bmsSlaveStatus_1.fullCap", const.SLAVE_N_FULL_CAPACITY % 1, False), + CapacitySensorEntity(client, "bms_slave_bmsSlaveStatus_1.remainCap", const.SLAVE_N_REMAIN_CAPACITY % 1, False), + + TempSensorEntity(client, "bms_slave_bmsSlaveStatus_1.temp", const.SLAVE_N_BATTERY_TEMP % 1, False, True) + .attr("bms_slave_bmsSlaveStatus_1.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0) + .attr("bms_slave_bmsSlaveStatus_1.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0), + TempSensorEntity(client, "bms_slave_bmsSlaveStatus_1.minCellTemp", const.SLAVE_N_MIN_CELL_TEMP % 1, False), + TempSensorEntity(client, "bms_slave_bmsSlaveStatus_1.maxCellTemp", const.SLAVE_N_MAX_CELL_TEMP % 1, False), + + MilliVoltSensorEntity(client, "bms_slave_bmsSlaveStatus_1.vol", const.SLAVE_N_BATTERY_VOLT % 1, False) + .attr("bms_slave_bmsSlaveStatus_1.minCellVol", const.ATTR_MIN_CELL_VOLT, 0) + .attr("bms_slave_bmsSlaveStatus_1.maxCellVol", const.ATTR_MAX_CELL_VOLT, 0), + MilliVoltSensorEntity(client, "bms_slave_bmsSlaveStatus_1.minCellVol", const.SLAVE_N_MIN_CELL_VOLT % 1, False), + MilliVoltSensorEntity(client, "bms_slave_bmsSlaveStatus_1.maxCellVol", const.SLAVE_N_MAX_CELL_VOLT % 1, False), + + CyclesSensorEntity(client, "bms_slave_bmsSlaveStatus_1.cycles", const.SLAVE_N_CYCLES % 1, False, True), + LevelSensorEntity(client, "bms_slave_bmsSlaveStatus_1.soh", const.SLAVE_N_SOH % 1, False,True), + InWattsSensorEntity(client, "bms_slave_bmsSlaveStatus_1.inputWatts", const.SLAVE_N_IN_POWER % 1, False, True), + OutWattsSensorEntity(client, "bms_slave_bmsSlaveStatus_1.outputWatts", const.SLAVE_N_OUT_POWER % 1, False, True), + + # Optional Slave 2 Battery + LevelSensorEntity(client, "bms_slave_bmsSlaveStatus_2.soc", const.SLAVE_N_BATTERY_LEVEL % 2, False, True) + .attr("bms_slave_bmsSlaveStatus_2.designCap", const.ATTR_DESIGN_CAPACITY, 0) + .attr("bms_slave_bmsSlaveStatus_2.fullCap", const.ATTR_FULL_CAPACITY, 0) + .attr("bms_slave_bmsSlaveStatus_2.remainCap", const.ATTR_REMAIN_CAPACITY, 0), + CapacitySensorEntity(client, "bms_slave_bmsSlaveStatus_2.designCap", const.SLAVE_N_DESIGN_CAPACITY % 2, False), + CapacitySensorEntity(client, "bms_slave_bmsSlaveStatus_2.fullCap", const.SLAVE_N_FULL_CAPACITY % 2, False), + CapacitySensorEntity(client, "bms_slave_bmsSlaveStatus_2.remainCap", const.SLAVE_N_REMAIN_CAPACITY % 2, False), + + TempSensorEntity(client, "bms_slave_bmsSlaveStatus_2.temp", const.SLAVE_N_BATTERY_TEMP % 2, False, True) + .attr("bms_slave_bmsSlaveStatus_2.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0) + .attr("bms_slave_bmsSlaveStatus_2.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0), + TempSensorEntity(client, "bms_slave_bmsSlaveStatus_2.minCellTemp", const.SLAVE_N_MIN_CELL_TEMP % 2, False), + TempSensorEntity(client, "bms_slave_bmsSlaveStatus_2.maxCellTemp", const.SLAVE_N_MAX_CELL_TEMP % 2, False), + + MilliVoltSensorEntity(client, "bms_slave_bmsSlaveStatus_2.vol", const.SLAVE_N_BATTERY_VOLT % 2, False) + .attr("bms_slave_bmsSlaveStatus_2.minCellVol", const.ATTR_MIN_CELL_VOLT, 0) + .attr("bms_slave_bmsSlaveStatus_2.maxCellVol", const.ATTR_MAX_CELL_VOLT, 0), + MilliVoltSensorEntity(client, "bms_slave_bmsSlaveStatus_2.minCellVol", const.SLAVE_N_MIN_CELL_VOLT % 2, False), + MilliVoltSensorEntity(client, "bms_slave_bmsSlaveStatus_2.maxCellVol", const.SLAVE_N_MAX_CELL_VOLT % 2, False), + + CyclesSensorEntity(client, "bms_slave_bmsSlaveStatus_2.cycles", const.SLAVE_N_CYCLES % 2, False, True), + LevelSensorEntity(client, "bms_slave_bmsSlaveStatus_2.soh", const.SLAVE_N_SOH % 2, False, True), + InWattsSensorEntity(client, "bms_slave_bmsSlaveStatus_2.inputWatts", const.SLAVE_N_IN_POWER % 2, False, True), + OutWattsSensorEntity(client, "bms_slave_bmsSlaveStatus_2.outputWatts", const.SLAVE_N_OUT_POWER % 2, False, True), + + StatusSensorEntity(client), + + ] + + def numbers(self, client: EcoflowMQTTClient) -> list[BaseNumberEntity]: + return [ + MaxBatteryLevelEntity(client, "bms_emsStatus.maxChargeSoc", const.MAX_CHARGE_LEVEL, 50, 100, + lambda value: {"moduleType": 2, "operateType": "upsConfig", + "moduleSn": client.device_sn, + "params": {"maxChgSoc": int(value)}}), + + MinBatteryLevelEntity(client, "bms_emsStatus.minDsgSoc", const.MIN_DISCHARGE_LEVEL, 0, 30, + lambda value: {"moduleType": 2, "operateType": "dsgCfg", + "moduleSn": client.device_sn, + "params": {"minDsgSoc": int(value)}}), + + BatteryBackupLevel(client, "pd.bpPowerSoc", const.BACKUP_RESERVE_LEVEL, 5, 100, + "bms_emsStatus.minDsgSoc", "bms_emsStatus.maxChargeSoc", + lambda value: {"moduleType": 1, "operateType": "watthConfig", + "params": {"isConfig": 1, "bpPowerSoc": int(value), "minDsgSoc": 0, + "minChgSoc": 0}}), + + MinGenStartLevelEntity(client, "bms_emsStatus.minOpenOilEbSoc", const.GEN_AUTO_START_LEVEL, 0, 30, + lambda value: {"moduleType": 2, "operateType": "openOilSoc", + "moduleSn": client.device_sn, + "params": {"openOilSoc": value}}), + + MaxGenStopLevelEntity(client, "bms_emsStatus.maxCloseOilEbSoc", const.GEN_AUTO_STOP_LEVEL, 50, 100, + lambda value: {"moduleType": 2, "operateType": "closeOilSoc", + "moduleSn": client.device_sn, + "params": {"closeOilSoc": value}}), + + ChargingPowerEntity(client, "inv.SlowChgWatts", const.AC_CHARGING_POWER, 200, 2400, + lambda value: {"moduleType": 3, "operateType": "acChgCfg", + "moduleSn": client.device_sn, + "params": {"slowChgWatts": int(value), "fastChgWatts": 255, + "chgPauseFlag": 0}}) + + ] + + def switches(self, client: EcoflowMQTTClient) -> list[BaseSwitchEntity]: + return [ + BeeperEntity(client, "pd.beepMode", const.BEEPER, + lambda value: {"moduleType": 1, "operateType": "quietCfg", + "moduleSn": client.device_sn, + "params": {"enabled": value}}), + + EnabledEntity(client, "pd.dcOutState", const.USB_ENABLED, + lambda value: {"moduleType": 1, "operateType": "dcOutCfg", + "moduleSn": client.device_sn, + "params": {"enabled": value}}), + + EnabledEntity(client, "pd.newAcAutoOnCfg", const.AC_ALWAYS_ENABLED, + lambda value: {"moduleType": 1, "operateType": "newAcAutoOnCfg", + "moduleSn": client.device_sn, + "params": {"enabled": value, "minAcSoc": 5}}), + + EnabledEntity(client, "inv.cfgAcEnabled", const.AC_ENABLED, + lambda value: {"moduleType": 3, "operateType": "acOutCfg", + "moduleSn": client.device_sn, + "params": {"enabled": value, "out_voltage": -1, + "out_freq": 255, "xboost": 255}}), + + EnabledEntity(client, "inv.cfgAcXboost", const.XBOOST_ENABLED, + lambda value: {"moduleType": 3, "operateType": "acOutCfg", + "moduleSn": client.device_sn, + "params": {"xboost": value}}), + EnabledEntity(client, "pd.carState", const.DC_ENABLED, + lambda value: {"moduleType": 5, "operateType": "mpptCar", + "params": {"enabled": value}}), + + EnabledEntity(client, "pd.bpPowerSoc", const.BP_ENABLED, + lambda value: {"moduleType": 1, + "operateType": "watthConfig", + "params": {"bpPowerSoc": value, + "minChgSoc": 0, + "isConfig": value, + "minDsgSoc": 0}}), + ] + + def selects(self, client: EcoflowMQTTClient) -> list[BaseSelectEntity]: + return [ + TimeoutDictSelectEntity(client, "pd.lcdOffSec", const.SCREEN_TIMEOUT, const.SCREEN_TIMEOUT_OPTIONS, + lambda value: {"moduleType": 1, "operateType": "lcdCfg", + "moduleSn": client.device_sn, + "params": {"brighLevel": 255, "delayOff": value}}), + + TimeoutDictSelectEntity(client, "inv.standbyMin", const.UNIT_TIMEOUT, const.UNIT_TIMEOUT_OPTIONS, + lambda value: {"moduleType": 1, "operateType": "standbyTime", + "moduleSn": client.device_sn, + "params": {"standbyMin": value}}), + + TimeoutDictSelectEntity(client, "mppt.carStandbyMin", const.AC_TIMEOUT, const.AC_TIMEOUT_OPTIONS, + lambda value: {"moduleType": 5, "operateType": "standbyTime", + "moduleSn": client.device_sn, + "params": {"standbyMins": value}}), + ] + + def migrate(self, version) -> list[EntityMigration]: + if version == 2: + return [ + EntityMigration("pd.soc", Platform.SENSOR, MigrationAction.REMOVE), + EntityMigration("bms_emsStatus.f32LcdShowSoc", Platform.SENSOR, MigrationAction.REMOVE) + ] + return [] diff --git a/config/custom_components/ecoflow_cloud/devices/delta_max.py b/config/custom_components/ecoflow_cloud/devices/delta_max.py new file mode 100644 index 0000000..69c3f02 --- /dev/null +++ b/config/custom_components/ecoflow_cloud/devices/delta_max.py @@ -0,0 +1,160 @@ +from homeassistant.const import Platform + +from . import const, BaseDevice, EntityMigration, MigrationAction +from .. import EcoflowMQTTClient +from ..entities import BaseSensorEntity, BaseNumberEntity, BaseSwitchEntity, BaseSelectEntity +from ..number import ChargingPowerEntity, MinBatteryLevelEntity, MaxBatteryLevelEntity, \ + MaxGenStopLevelEntity, MinGenStartLevelEntity +from ..sensor import LevelSensorEntity, RemainSensorEntity, TempSensorEntity, CyclesSensorEntity, \ + InWattsSensorEntity, OutWattsSensorEntity, StatusSensorEntity, MilliVoltSensorEntity, \ + InMilliVoltSensorEntity, OutMilliVoltSensorEntity, CapacitySensorEntity, InWattsSolarSensorEntity, \ + OutWattsDcSensorEntity +from ..switch import BeeperEntity, EnabledEntity + + +class DeltaMax(BaseDevice): + def sensors(self, client: EcoflowMQTTClient) -> list[BaseSensorEntity]: + return [ + LevelSensorEntity(client, "bmsMaster.soc", const.MAIN_BATTERY_LEVEL) + .attr("bmsMaster.designCap", const.ATTR_DESIGN_CAPACITY, 0) + .attr("bmsMaster.fullCap", const.ATTR_FULL_CAPACITY, 0) + .attr("bmsMaster.remainCap", const.ATTR_REMAIN_CAPACITY, 0), + CapacitySensorEntity(client, "bmsMaster.designCap", const.MAIN_DESIGN_CAPACITY, False), + CapacitySensorEntity(client, "bmsMaster.fullCap", const.MAIN_FULL_CAPACITY, False), + CapacitySensorEntity(client, "bmsMaster.remainCap", const.MAIN_REMAIN_CAPACITY, False), + + LevelSensorEntity(client, "ems.lcdShowSoc", const.COMBINED_BATTERY_LEVEL), + InWattsSensorEntity(client, "pd.wattsInSum", const.TOTAL_IN_POWER), + OutWattsSensorEntity(client, "pd.wattsOutSum", const.TOTAL_OUT_POWER), + + InWattsSensorEntity(client, "inv.inputWatts", const.AC_IN_POWER), + OutWattsSensorEntity(client, "inv.outputWatts", const.AC_OUT_POWER), + + InMilliVoltSensorEntity(client, "inv.acInVol", const.AC_IN_VOLT), + OutMilliVoltSensorEntity(client, "inv.invOutVol", const.AC_OUT_VOLT), + + InWattsSolarSensorEntity(client, "mppt.inWatts", const.SOLAR_IN_POWER), + OutWattsDcSensorEntity(client, "mppt.outWatts", const.DC_OUT_POWER), + + OutWattsSensorEntity(client, "pd.typec1Watts", const.TYPEC_1_OUT_POWER), + OutWattsSensorEntity(client, "pd.typec2Watts", const.TYPEC_2_OUT_POWER), + + OutWattsSensorEntity(client, "pd.usb1Watts", const.USB_1_OUT_POWER), + OutWattsSensorEntity(client, "pd.usb2Watts", const.USB_2_OUT_POWER), + + OutWattsSensorEntity(client, "pd.qcUsb1Watts", const.USB_QC_1_OUT_POWER), + OutWattsSensorEntity(client, "pd.qcUsb2Watts", const.USB_QC_2_OUT_POWER), + + RemainSensorEntity(client, "ems.chgRemainTime", const.CHARGE_REMAINING_TIME), + RemainSensorEntity(client, "ems.dsgRemainTime", const.DISCHARGE_REMAINING_TIME), + + TempSensorEntity(client, "inv.outTemp", "Inv Out Temperature"), + CyclesSensorEntity(client, "bmsMaster.cycles", const.CYCLES), + + TempSensorEntity(client, "bmsMaster.temp", const.BATTERY_TEMP) + .attr("bmsMaster.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0) + .attr("bmsMaster.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0), + TempSensorEntity(client, "bmsMaster.minCellTemp", const.MIN_CELL_TEMP, False), + TempSensorEntity(client, "bmsMaster.maxCellTemp", const.MAX_CELL_TEMP, False), + + MilliVoltSensorEntity(client, "bmsMaster.vol", const.BATTERY_VOLT, False) + .attr("bmsMaster.minCellVol", const.ATTR_MIN_CELL_VOLT, 0) + .attr("bmsMaster.maxCellVol", const.ATTR_MAX_CELL_VOLT, 0), + MilliVoltSensorEntity(client, "bmsMaster.minCellVol", const.MIN_CELL_VOLT, False), + MilliVoltSensorEntity(client, "bmsMaster.maxCellVol", const.MAX_CELL_VOLT, False), + + # Optional Slave Battery + #LevelSensorEntity(client, "bms_slave.soc", const.SLAVE_BATTERY_LEVEL, False, True), + #TempSensorEntity(client, "bms_slave.temp", const.SLAVE_BATTERY_TEMP, False, True), + #TempSensorEntity(client, "bms_slave.minCellTemp", const.SLAVE_MIN_CELL_TEMP, False), + #TempSensorEntity(client, "bms_slave.maxCellTemp", const.SLAVE_MAX_CELL_TEMP, False), + + #VoltSensorEntity(client, "bms_slave.vol", const.SLAVE_BATTERY_VOLT, False), + #VoltSensorEntity(client, "bms_slave.minCellVol", const.SLAVE_MIN_CELL_VOLT, False), + #VoltSensorEntity(client, "bms_slave.maxCellVol", const.SLAVE_MAX_CELL_VOLT, False), + + #CyclesSensorEntity(client, "bms_slave.cycles", const.SLAVE_CYCLES, False, True), + #InWattsSensorEntity(client, "bms_slave.inputWatts", const.SLAVE_IN_POWER, False, True), + #OutWattsSensorEntity(client, "bms_slave.outputWatts", const.SLAVE_OUT_POWER, False, True) + StatusSensorEntity(client), + ] + + def numbers(self, client: EcoflowMQTTClient) -> list[BaseNumberEntity]: + return [ + MaxBatteryLevelEntity(client, "ems.maxChargeSoc", const.MAX_CHARGE_LEVEL, 50, 100, + lambda value: {"moduleType": 2, "operateType": "TCP", + "params": {"id": 49, "maxChgSoc": value}}), + + MinBatteryLevelEntity(client, "ems.minDsgSoc", const.MIN_DISCHARGE_LEVEL, 0, 30, + lambda value: {"moduleType": 2, "operateType": "TCP", + "params": {"id": 51, "minDsgSoc": value}}), + + MinGenStartLevelEntity(client, "ems.minOpenOilEbSoc", const.GEN_AUTO_START_LEVEL, 0, 30, + lambda value: {"moduleType": 2, "operateType": "TCP", + "params": {"id": 52, "openOilSoc": value}}), + + MaxGenStopLevelEntity(client, "ems.maxCloseOilEbSoc", const.GEN_AUTO_STOP_LEVEL, 50, 100, + lambda value: {"moduleType": 2, "operateType": "TCP", + "params": {"id": 53, "closeOilSoc": value}}), + + ChargingPowerEntity(client, "inv.cfgFastChgWatt", const.AC_CHARGING_POWER, 200, 2000, + lambda value: {"moduleType": 0, "operateType": "TCP", + "params": {"slowChgPower": value, "id": 69}}), + + ] + + def switches(self, client: EcoflowMQTTClient) -> list[BaseSwitchEntity]: + return [ + BeeperEntity(client, "pd.beepState", const.BEEPER, + lambda value: {"moduleType": 5, "operateType": "TCP", "params": {"id": 38, "enabled": value}}), + + EnabledEntity(client, "pd.dcOutState", const.USB_ENABLED, + lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"enabled": value, "id": 34 }}), + + EnabledEntity(client, "pd.acAutoOnCfg", const.AC_ALWAYS_ENABLED, + lambda value: {"moduleType": 1, "operateType": "acAutoOn", "params": {"cfg": value}}), + + EnabledEntity(client, "pd.pvChgPrioSet", const.PV_PRIO, + lambda value: {"moduleType": 1, "operateType": "pvChangePrio", "params": {"pvChangeSet": value}}), + + EnabledEntity(client, "inv.cfgAcEnabled", const.AC_ENABLED, + lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"enabled": value, "id": 66 }}), + + EnabledEntity(client, "inv.cfgAcXboost", const.XBOOST_ENABLED, + lambda value: {"moduleType": 5, "operateType": "TCP", "params": {"id": 66, "xboost": value}}), + + EnabledEntity(client, "mppt.carState", const.DC_ENABLED, + lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"enabled": value, "id": 81 }}), + + ] + + def selects(self, client: EcoflowMQTTClient) -> list[BaseSelectEntity]: + return [ + #DictSelectEntity(client, "mppt.cfgDcChgCurrent", const.DC_CHARGE_CURRENT, const.DC_CHARGE_CURRENT_OPTIONS, + # lambda value: {"moduleType": 5, "operateType": "dcChgCfg", + # "params": {"dcChgCfg": value}}), + + #TimeoutDictSelectEntity(client, "pd.lcdOffSec", const.SCREEN_TIMEOUT, const.SCREEN_TIMEOUT_OPTIONS, + # lambda value: {"moduleType": 1, "operateType": "lcdCfg", + # "params": {"brighLevel": 255, "delayOff": value}}), + + #TimeoutDictSelectEntity(client, "inv.cfgStandbyMin", const.UNIT_TIMEOUT, const.UNIT_TIMEOUT_OPTIONS, + # lambda value: {"moduleType": 1, "operateType": "standbyTime", + # "params": {"standbyMin": value}}), + + #TimeoutDictSelectEntity(client, "mppt.acStandbyMins", const.AC_TIMEOUT, const.AC_TIMEOUT_OPTIONS, + # lambda value: {"moduleType": 5, "operateType": "standbyTime", + # "params": {"standbyMins": value}}), + + #TimeoutDictSelectEntity(client, "mppt.carStandbyMin", const.DC_TIMEOUT, const.DC_TIMEOUT_OPTIONS, + # lambda value: {"moduleType": 5, "operateType": "carStandby", + # "params": {"standbyMins": value}}) + + ] + + def migrate(self, version) -> list[EntityMigration]: + if version == 2: + return [ + EntityMigration("pd.soc", Platform.SENSOR, MigrationAction.REMOVE), + ] + return [] diff --git a/config/custom_components/ecoflow_cloud/devices/delta_mini.py b/config/custom_components/ecoflow_cloud/devices/delta_mini.py new file mode 100644 index 0000000..af022fa --- /dev/null +++ b/config/custom_components/ecoflow_cloud/devices/delta_mini.py @@ -0,0 +1,147 @@ +from homeassistant.const import Platform + +from . import const, BaseDevice, EntityMigration, MigrationAction +from ..entities import BaseSensorEntity, BaseNumberEntity, BaseSwitchEntity, BaseSelectEntity +from ..mqtt.ecoflow_mqtt import EcoflowMQTTClient +from ..number import ChargingPowerEntity, MaxBatteryLevelEntity, MinBatteryLevelEntity +from ..select import DictSelectEntity, TimeoutDictSelectEntity +from ..sensor import LevelSensorEntity, WattsSensorEntity, RemainSensorEntity, TempSensorEntity, \ + CyclesSensorEntity, InWattsSensorEntity, OutWattsSensorEntity, OutWattsDcSensorEntity, InWattsSolarSensorEntity, \ + StatusSensorEntity, InEnergySensorEntity, OutEnergySensorEntity, MilliVoltSensorEntity, InMilliVoltSensorEntity, \ + OutMilliVoltSensorEntity, CapacitySensorEntity +from ..switch import BeeperEntity, EnabledEntity + + +class DeltaMini(BaseDevice): + def sensors(self, client: EcoflowMQTTClient) -> list[BaseSensorEntity]: + return [ + LevelSensorEntity(client, "bmsMaster.soc", const.MAIN_BATTERY_LEVEL) + .attr("bmsMaster.designCap", const.ATTR_DESIGN_CAPACITY, 0) + .attr("bmsMaster.fullCap", const.ATTR_FULL_CAPACITY, 0) + .attr("bmsMaster.remainCap", const.ATTR_REMAIN_CAPACITY, 0), + CapacitySensorEntity(client, "bmsMaster.designCap", const.MAIN_DESIGN_CAPACITY, False), + CapacitySensorEntity(client, "bmsMaster.fullCap", const.MAIN_FULL_CAPACITY, False), + CapacitySensorEntity(client, "bmsMaster.remainCap", const.MAIN_REMAIN_CAPACITY, False), + + LevelSensorEntity(client, "bmsMaster.soh", const.SOH), + + LevelSensorEntity(client, "ems.lcdShowSoc", const.COMBINED_BATTERY_LEVEL), + + WattsSensorEntity(client, "pd.wattsInSum", const.TOTAL_IN_POWER), + WattsSensorEntity(client, "pd.wattsOutSum", const.TOTAL_OUT_POWER), + + InWattsSensorEntity(client, "inv.inputWatts", const.AC_IN_POWER), + OutWattsSensorEntity(client, "inv.outputWatts", const.AC_OUT_POWER), + + InMilliVoltSensorEntity(client, "inv.acInVol", const.AC_IN_VOLT), + OutMilliVoltSensorEntity(client, "inv.invOutVol", const.AC_OUT_VOLT), + + InWattsSolarSensorEntity(client, "mppt.inWatts", const.SOLAR_IN_POWER), + + OutWattsDcSensorEntity(client, "mppt.outWatts", const.DC_OUT_POWER), + + OutWattsDcSensorEntity(client, "mppt.carOutWatts", const.DC_CAR_OUT_POWER), + OutWattsSensorEntity(client, "mppt.dcdc12vWatts", const.DC_ANDERSON_OUT_POWER), + + OutWattsSensorEntity(client, "pd.typec1Watts", const.TYPEC_1_OUT_POWER), + OutWattsSensorEntity(client, "pd.typec2Watts", const.TYPEC_2_OUT_POWER), + + OutWattsSensorEntity(client, "pd.usb1Watts", const.USB_1_OUT_POWER), + OutWattsSensorEntity(client, "pd.usb2Watts", const.USB_2_OUT_POWER), + + OutWattsSensorEntity(client, "pd.qcUsb1Watts", const.USB_QC_1_OUT_POWER), + OutWattsSensorEntity(client, "pd.qcUsb2Watts", const.USB_QC_2_OUT_POWER), + + RemainSensorEntity(client, "ems.chgRemainTime", const.CHARGE_REMAINING_TIME), + RemainSensorEntity(client, "ems.dsgRemainTime", const.DISCHARGE_REMAINING_TIME), + CyclesSensorEntity(client, "bmsMaster.cycles", const.CYCLES), + + TempSensorEntity(client, "bmsMaster.temp", const.BATTERY_TEMP, False) + .attr("bmsMaster.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0) + .attr("bmsMaster.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0), + + MilliVoltSensorEntity(client, "bmsMaster.vol", const.BATTERY_VOLT, False) + .attr("bmsMaster.minCellVol", const.ATTR_MIN_CELL_VOLT, 0) + .attr("bmsMaster.maxCellVol", const.ATTR_MAX_CELL_VOLT, 0), + + # https://github.com/tolwi/hassio-ecoflow-cloud/discussions/87 + InEnergySensorEntity(client, "pd.chgSunPower", const.SOLAR_IN_ENERGY), + InEnergySensorEntity(client, "pd.chgPowerAc", const.CHARGE_AC_ENERGY), + InEnergySensorEntity(client, "pd.chgPowerDc", const.CHARGE_DC_ENERGY), + OutEnergySensorEntity(client, "pd.dsgPowerAc", const.DISCHARGE_AC_ENERGY), + OutEnergySensorEntity(client, "pd.dsgPowerDc", const.DISCHARGE_DC_ENERGY), + + StatusSensorEntity(client), + ] + + def numbers(self, client: EcoflowMQTTClient) -> list[BaseNumberEntity]: + return [ + MaxBatteryLevelEntity(client, "ems.maxChargeSoc", const.MAX_CHARGE_LEVEL, 50, 100, + lambda value: {"moduleType": 0, "operateType": "TCP", + "params": {"id": 49, "maxChgSoc": value}}), + MinBatteryLevelEntity(client, "ems.minDsgSoc", const.MIN_DISCHARGE_LEVEL, 0, 30, + lambda value: {"moduleType": 0, "operateType": "TCP", + "params": {"id": 51, "minDsgSoc": value}}), + # MaxBatteryLevelEntity(client, "pd.bpPowerSoc", const.BACKUP_RESERVE_LEVEL, 5, 100, + # lambda value: {"moduleType": 0, "operateType": "TCP", + # "params": {"isConfig": 1, "bpPowerSoc": int(value), "minDsgSoc": 0, "maxChgSoc": 0, "id": 94}}), + # MinGenStartLevelEntity(client, "ems.minOpenOilEbSoc", const.GEN_AUTO_START_LEVEL, 0, 30, + # lambda value: {"moduleType": 0, "operateType": "TCP", + # "params": {"openOilSoc": value, "id": 52}}), + # + # MaxGenStopLevelEntity(client, "ems.maxCloseOilEbSoc", const.GEN_AUTO_STOP_LEVEL, 50, 100, + # lambda value: {"moduleType": 0, "operateType": "TCP", + # "params": {"closeOilSoc": value, "id": 53}}), + + ChargingPowerEntity(client, "inv.cfgSlowChgWatts", const.AC_CHARGING_POWER, 200, 900, + lambda value: {"moduleType": 0, "operateType": "TCP", + "params": {"slowChgPower": value, "id": 69}}), + + ] + + def switches(self, client: EcoflowMQTTClient) -> list[BaseSwitchEntity]: + return [ + BeeperEntity(client, "mppt.beepState", const.BEEPER, + lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 38, "enabled": value}}), + EnabledEntity(client, "mppt.carState", const.DC_ENABLED, + lambda value: {"moduleType": 0, "operateType": "TCP", + "params": {"id": 81, "enabled": value}}), + EnabledEntity(client, "inv.cfgAcEnabled", const.AC_ENABLED, + lambda value: {"moduleType": 0, "operateType": "TCP", + "params": {"id": 66, "enabled": value}}), + + EnabledEntity(client, "inv.cfgAcXboost", const.XBOOST_ENABLED, + lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 66, "xboost": value}}), + + # EnabledEntity(client, "inv.acPassByAutoEn", const.AC_ALWAYS_ENABLED, + # lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 84, "enabled": value}}), + # EnabledEntity(client, "pd.bpPowerSoc", const.BP_ENABLED, + # lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"isConfig": value}}), + ] + + def selects(self, client: EcoflowMQTTClient) -> list[BaseSelectEntity]: + return [ + DictSelectEntity(client, "mppt.cfgDcChgCurrent", const.DC_CHARGE_CURRENT, const.DC_CHARGE_CURRENT_OPTIONS, + lambda value: {"moduleType": 0, "operateType": "TCP", + "params": {"currMa": value, "id": 71}}), + + TimeoutDictSelectEntity(client, "pd.lcdOffSec", const.SCREEN_TIMEOUT, const.SCREEN_TIMEOUT_OPTIONS, + lambda value: {"moduleType": 0, "operateType": "TCP", + "params": {"lcdTime": value, "id": 39}}), + + TimeoutDictSelectEntity(client, "pd.standByMode", const.UNIT_TIMEOUT, const.UNIT_TIMEOUT_OPTIONS_LIMITED, + lambda value: {"moduleType": 0, "operateType": "TCP", + "params": {"standByMode": value, "id": 33}}), + + TimeoutDictSelectEntity(client, "inv.cfgStandbyMin", const.AC_TIMEOUT, const.AC_TIMEOUT_OPTIONS, + lambda value: {"moduleType": 0, "operateType": "TCP", + "params": {"standByMins": value, "id": 153}}), + + ] + + def migrate(self, version) -> list[EntityMigration]: + if version == 2: + return [ + EntityMigration("pd.soc", Platform.SENSOR, MigrationAction.REMOVE), + ] + return [] diff --git a/config/custom_components/ecoflow_cloud/devices/delta_pro.py b/config/custom_components/ecoflow_cloud/devices/delta_pro.py new file mode 100644 index 0000000..306a254 --- /dev/null +++ b/config/custom_components/ecoflow_cloud/devices/delta_pro.py @@ -0,0 +1,212 @@ +from homeassistant.const import Platform + +from . import const, BaseDevice, EntityMigration, MigrationAction +from ..entities import BaseSensorEntity, BaseNumberEntity, BaseSwitchEntity, BaseSelectEntity +from ..mqtt.ecoflow_mqtt import EcoflowMQTTClient +from ..number import ChargingPowerEntity, MaxBatteryLevelEntity, MinBatteryLevelEntity, MinGenStartLevelEntity, \ + MaxGenStopLevelEntity +from ..select import DictSelectEntity, TimeoutDictSelectEntity +from ..sensor import LevelSensorEntity, WattsSensorEntity, RemainSensorEntity, TempSensorEntity, \ + CyclesSensorEntity, InWattsSensorEntity, OutWattsSensorEntity, OutWattsDcSensorEntity, VoltSensorEntity, \ + InWattsSolarSensorEntity, InVoltSolarSensorEntity, InAmpSolarSensorEntity, OutVoltDcSensorEntity, \ + StatusSensorEntity, InEnergySensorEntity, OutEnergySensorEntity, MilliVoltSensorEntity, InMilliVoltSensorEntity, \ + OutMilliVoltSensorEntity, AmpSensorEntity, CapacitySensorEntity +from ..switch import BeeperEntity, EnabledEntity + + +class DeltaPro(BaseDevice): + def sensors(self, client: EcoflowMQTTClient) -> list[BaseSensorEntity]: + return [ + LevelSensorEntity(client, "bmsMaster.soc", const.MAIN_BATTERY_LEVEL) + .attr("bmsMaster.designCap", const.ATTR_DESIGN_CAPACITY, 0) + .attr("bmsMaster.fullCap", const.ATTR_FULL_CAPACITY, 0) + .attr("bmsMaster.remainCap", const.ATTR_REMAIN_CAPACITY, 0), + LevelSensorEntity(client, "bmsMaster.f32ShowSoc", const.MAIN_BATTERY_LEVEL_F32, False) + .attr("bmsMaster.designCap", const.ATTR_DESIGN_CAPACITY, 0) + .attr("bmsMaster.fullCap", const.ATTR_FULL_CAPACITY, 0) + .attr("bmsMaster.remainCap", const.ATTR_REMAIN_CAPACITY, 0), + CapacitySensorEntity(client, "bmsMaster.designCap", const.MAIN_DESIGN_CAPACITY, False), + CapacitySensorEntity(client, "bmsMaster.fullCap", const.MAIN_FULL_CAPACITY, False), + CapacitySensorEntity(client, "bmsMaster.remainCap", const.MAIN_REMAIN_CAPACITY, False), + LevelSensorEntity(client, "bmsMaster.soh", const.SOH), + + LevelSensorEntity(client, "ems.lcdShowSoc", const.COMBINED_BATTERY_LEVEL), + LevelSensorEntity(client, "ems.f32LcdShowSoc", const.COMBINED_BATTERY_LEVEL_F32, False), + WattsSensorEntity(client, "pd.wattsInSum", const.TOTAL_IN_POWER), + WattsSensorEntity(client, "pd.wattsOutSum", const.TOTAL_OUT_POWER), + AmpSensorEntity(client, "bmsMaster.amp", const.MAIN_BATTERY_CURRENT), + + InWattsSensorEntity(client, "inv.inputWatts", const.AC_IN_POWER), + OutWattsSensorEntity(client, "inv.outputWatts", const.AC_OUT_POWER), + + InMilliVoltSensorEntity(client, "inv.acInVol", const.AC_IN_VOLT), + OutMilliVoltSensorEntity(client, "inv.invOutVol", const.AC_OUT_VOLT), + + InWattsSolarSensorEntity(client, "mppt.inWatts", const.SOLAR_IN_POWER), + InVoltSolarSensorEntity(client, "mppt.inVol", const.SOLAR_IN_VOLTAGE), + InAmpSolarSensorEntity(client, "mppt.inAmp", const.SOLAR_IN_CURRENT), + + OutWattsDcSensorEntity(client, "mppt.outWatts", const.DC_OUT_POWER), + OutVoltDcSensorEntity(client, "mppt.outVol", const.DC_OUT_VOLTAGE), + + OutWattsSensorEntity(client, "mppt.carOutWatts", const.DC_CAR_OUT_POWER), + OutWattsSensorEntity(client, "mppt.dcdc12vWatts", const.DC_ANDERSON_OUT_POWER), + + OutWattsSensorEntity(client, "pd.typec1Watts", const.TYPEC_1_OUT_POWER), + OutWattsSensorEntity(client, "pd.typec2Watts", const.TYPEC_2_OUT_POWER), + + OutWattsSensorEntity(client, "pd.usb1Watts", const.USB_1_OUT_POWER), + OutWattsSensorEntity(client, "pd.usb2Watts", const.USB_2_OUT_POWER), + + OutWattsSensorEntity(client, "pd.qcUsb1Watts", const.USB_QC_1_OUT_POWER), + OutWattsSensorEntity(client, "pd.qcUsb2Watts", const.USB_QC_2_OUT_POWER), + + RemainSensorEntity(client, "ems.chgRemainTime", const.CHARGE_REMAINING_TIME), + RemainSensorEntity(client, "ems.dsgRemainTime", const.DISCHARGE_REMAINING_TIME), + CyclesSensorEntity(client, "bmsMaster.cycles", const.CYCLES), + + TempSensorEntity(client, "bmsMaster.temp", const.BATTERY_TEMP) + .attr("bmsMaster.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0) + .attr("bmsMaster.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0), + TempSensorEntity(client, "bmsMaster.minCellTemp", const.MIN_CELL_TEMP, False), + TempSensorEntity(client, "bmsMaster.maxCellTemp", const.MAX_CELL_TEMP, False), + + MilliVoltSensorEntity(client, "bmsMaster.vol", const.BATTERY_VOLT, False) + .attr("bmsMaster.minCellVol", const.ATTR_MIN_CELL_VOLT, 0) + .attr("bmsMaster.maxCellVol", const.ATTR_MAX_CELL_VOLT, 0), + MilliVoltSensorEntity(client, "bmsMaster.minCellVol", const.MIN_CELL_VOLT, False), + MilliVoltSensorEntity(client, "bmsMaster.maxCellVol", const.MAX_CELL_VOLT, False), + + # https://github.com/tolwi/hassio-ecoflow-cloud/discussions/87 + InEnergySensorEntity(client, "pd.chgSunPower", const.SOLAR_IN_ENERGY), + InEnergySensorEntity(client, "pd.chgPowerAc", const.CHARGE_AC_ENERGY), + InEnergySensorEntity(client, "pd.chgPowerDc", const.CHARGE_DC_ENERGY), + OutEnergySensorEntity(client, "pd.dsgPowerAc", const.DISCHARGE_AC_ENERGY), + OutEnergySensorEntity(client, "pd.dsgPowerDc", const.DISCHARGE_DC_ENERGY), + + # Optional Slave Batteries + LevelSensorEntity(client, "bmsSlave1.soc", const.SLAVE_N_BATTERY_LEVEL % 1, False, True) + .attr("bmsSlave1.designCap", const.ATTR_DESIGN_CAPACITY, 0) + .attr("bmsSlave1.fullCap", const.ATTR_FULL_CAPACITY, 0) + .attr("bmsSlave1.remainCap", const.ATTR_REMAIN_CAPACITY, 0), + LevelSensorEntity(client, "bmsSlave1.f32ShowSoc", const.SLAVE_N_BATTERY_LEVEL_F32 % 1, False, False) + .attr("bmsSlave1.designCap", const.ATTR_DESIGN_CAPACITY, 0) + .attr("bmsSlave1.fullCap", const.ATTR_FULL_CAPACITY, 0) + .attr("bmsSlave1.remainCap", const.ATTR_REMAIN_CAPACITY, 0), + CapacitySensorEntity(client, "bmsSlave1.designCap", const.SLAVE_N_DESIGN_CAPACITY % 1, False), + CapacitySensorEntity(client, "bmsSlave1.fullCap", const.SLAVE_N_FULL_CAPACITY % 1, False), + CapacitySensorEntity(client, "bmsSlave1.remainCap", const.SLAVE_N_REMAIN_CAPACITY % 1, False), + LevelSensorEntity(client, "bmsSlave1.soh", const.SLAVE_N_SOH % 1), + + TempSensorEntity(client, "bmsSlave1.temp", const.SLAVE_N_BATTERY_TEMP % 1, False, True) + .attr("bmsSlave1.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0) + .attr("bmsSlave1.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0), + WattsSensorEntity(client, "bmsSlave1.inputWatts", const.SLAVE_N_IN_POWER % 1, False, True), + WattsSensorEntity(client, "bmsSlave1.outputWatts", const.SLAVE_N_OUT_POWER % 1, False, True), + + LevelSensorEntity(client, "bmsSlave2.soc", const.SLAVE_N_BATTERY_LEVEL % 2, False, True) + .attr("bmsSlave2.designCap", const.ATTR_DESIGN_CAPACITY, 0) + .attr("bmsSlave2.fullCap", const.ATTR_FULL_CAPACITY, 0) + .attr("bmsSlave2.remainCap", const.ATTR_REMAIN_CAPACITY, 0), + LevelSensorEntity(client, "bmsSlave2.f32ShowSoc", const.SLAVE_N_BATTERY_LEVEL_F32 % 2, False, False) + .attr("bmsSlave2.designCap", const.ATTR_DESIGN_CAPACITY, 0) + .attr("bmsSlave2.fullCap", const.ATTR_FULL_CAPACITY, 0) + .attr("bmsSlave2.remainCap", const.ATTR_REMAIN_CAPACITY, 0), + CapacitySensorEntity(client, "bmsSlave2.designCap", const.SLAVE_N_DESIGN_CAPACITY % 2, False), + CapacitySensorEntity(client, "bmsSlave2.fullCap", const.SLAVE_N_FULL_CAPACITY % 2, False), + CapacitySensorEntity(client, "bmsSlave2.remainCap", const.SLAVE_N_REMAIN_CAPACITY % 2, False), + LevelSensorEntity(client, "bmsSlave2.soh", const.SLAVE_N_SOH % 2), + MilliVoltSensorEntity(client, "bmsSlave1.vol", const.SLAVE_N_BATTERY_VOLT % 1, False), + MilliVoltSensorEntity(client, "bmsSlave1.minCellVol", const.SLAVE_N_MIN_CELL_VOLT % 1, False), + MilliVoltSensorEntity(client, "bmsSlave1.maxCellVol", const.SLAVE_N_MAX_CELL_VOLT % 1, False), + AmpSensorEntity(client, "bmsSlave1.amp", const.SLAVE_N_BATTERY_CURRENT % 1, False), + MilliVoltSensorEntity(client, "bmsSlave2.vol", const.SLAVE_N_BATTERY_VOLT % 2, False), + MilliVoltSensorEntity(client, "bmsSlave2.minCellVol", const.SLAVE_N_MIN_CELL_VOLT % 2, False), + MilliVoltSensorEntity(client, "bmsSlave2.maxCellVol", const.SLAVE_N_MAX_CELL_VOLT % 2, False), + AmpSensorEntity(client, "bmsSlave2.amp", const.SLAVE_N_BATTERY_CURRENT % 2, False), + TempSensorEntity(client, "bmsSlave2.temp", const.SLAVE_N_BATTERY_TEMP % 2, False, True) + .attr("bmsSlave2.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0) + .attr("bmsSlave2.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0), + WattsSensorEntity(client, "bmsSlave2.inputWatts", const.SLAVE_N_IN_POWER % 2, False, True), + WattsSensorEntity(client, "bmsSlave2.outputWatts", const.SLAVE_N_OUT_POWER % 2, False, True), + CyclesSensorEntity(client, "bmsSlave1.cycles", const.SLAVE_N_CYCLES % 1, False), + CyclesSensorEntity(client, "bmsSlave2.cycles", const.SLAVE_N_CYCLES % 2, False), + StatusSensorEntity(client), + ] + + def numbers(self, client: EcoflowMQTTClient) -> list[BaseNumberEntity]: + return [ + MaxBatteryLevelEntity(client, "ems.maxChargeSoc", const.MAX_CHARGE_LEVEL, 50, 100, + lambda value: {"moduleType": 0, "operateType": "TCP", + "params": {"id": 49, "maxChgSoc": value}}), + MinBatteryLevelEntity(client, "ems.minDsgSoc", const.MIN_DISCHARGE_LEVEL, 0, 30, + lambda value: {"moduleType": 0, "operateType": "TCP", + "params": {"id": 51, "minDsgSoc": value}}), + MaxBatteryLevelEntity(client, "pd.bpPowerSoc", const.BACKUP_RESERVE_LEVEL, 5, 100, + lambda value: {"moduleType": 0, "operateType": "TCP", + "params": {"isConfig": 1, "bpPowerSoc": int(value), "minDsgSoc": 0, + "maxChgSoc": 0, "id": 94}}), + MinGenStartLevelEntity(client, "ems.minOpenOilEbSoc", const.GEN_AUTO_START_LEVEL, 0, 30, + lambda value: {"moduleType": 0, "operateType": "TCP", + "params": {"openOilSoc": value, "id": 52}}), + + MaxGenStopLevelEntity(client, "ems.maxCloseOilEbSoc", const.GEN_AUTO_STOP_LEVEL, 50, 100, + lambda value: {"moduleType": 0, "operateType": "TCP", + "params": {"closeOilSoc": value, "id": 53}}), + + ChargingPowerEntity(client, "inv.cfgSlowChgWatts", const.AC_CHARGING_POWER, 200, 2900, + lambda value: {"moduleType": 0, "operateType": "TCP", + "params": {"slowChgPower": value, "id": 69}}), + + ] + + def switches(self, client: EcoflowMQTTClient) -> list[BaseSwitchEntity]: + return [ + BeeperEntity(client, "pd.beepState", const.BEEPER, + lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 38, "enabled": value}}), + EnabledEntity(client, "mppt.carState", const.DC_ENABLED, + lambda value: {"moduleType": 0, "operateType": "TCP", + "params": {"id": 81, "enabled": value}}), + EnabledEntity(client, "inv.cfgAcEnabled", const.AC_ENABLED, + lambda value: {"moduleType": 0, "operateType": "TCP", + "params": {"id": 66, "enabled": value}}), + + EnabledEntity(client, "inv.cfgAcXboost", const.XBOOST_ENABLED, + lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 66, "xboost": value}}), + EnabledEntity(client, "pd.acautooutConfig", const.AC_ALWAYS_ENABLED, + lambda value: {"moduleType": 0, "operateType": "TCP", + "params": {"id": 95, "acautooutConfig": value}}), + EnabledEntity(client, "pd.bppowerSoc", const.BP_ENABLED, + lambda value, params: {"moduleType": 0, "operateType": "TCP", + "params": {"id": 94, "isConfig": value, + "bpPowerSoc": int(params.get("pd.bppowerSoc", 0)), + "minDsgSoc": 0, + "maxChgSoc": 0}}), + ] + + def selects(self, client: EcoflowMQTTClient) -> list[BaseSelectEntity]: + return [ + DictSelectEntity(client, "mppt.cfgDcChgCurrent", const.DC_CHARGE_CURRENT, const.DC_CHARGE_CURRENT_OPTIONS, + lambda value: {"moduleType": 0, "operateType": "TCP", + "params": {"currMa": value, "id": 71}}), + + TimeoutDictSelectEntity(client, "pd.lcdOffSec", const.SCREEN_TIMEOUT, const.SCREEN_TIMEOUT_OPTIONS, + lambda value: {"moduleType": 0, "operateType": "TCP", + "params": {"lcdTime": value, "id": 39}}), + + TimeoutDictSelectEntity(client, "pd.standByMode", const.UNIT_TIMEOUT, const.UNIT_TIMEOUT_OPTIONS_LIMITED, + lambda value: {"moduleType": 0, "operateType": "TCP", + "params": {"standByMode": value, "id": 33}}), + + TimeoutDictSelectEntity(client, "inv.cfgStandbyMin", const.AC_TIMEOUT, const.AC_TIMEOUT_OPTIONS, + lambda value: {"moduleType": 0, "operateType": "TCP", + "params": {"standByMins": value, "id": 153}}), + + ] + + def migrate(self, version) -> list[EntityMigration]: + if version == 2: + return [ + EntityMigration("pd.soc", Platform.SENSOR, MigrationAction.REMOVE), + ] + return [] diff --git a/config/custom_components/ecoflow_cloud/devices/glacier.py b/config/custom_components/ecoflow_cloud/devices/glacier.py new file mode 100644 index 0000000..1c906c5 --- /dev/null +++ b/config/custom_components/ecoflow_cloud/devices/glacier.py @@ -0,0 +1,129 @@ +from . import const, BaseDevice +from ..button import EnabledButtonEntity +from ..entities import BaseSensorEntity, BaseNumberEntity, BaseSwitchEntity, BaseSelectEntity, BaseButtonEntity +from ..mqtt.ecoflow_mqtt import EcoflowMQTTClient +from ..number import SetTempEntity +from ..sensor import LevelSensorEntity, RemainSensorEntity, SecondsRemainSensorEntity, TempSensorEntity, \ + CyclesSensorEntity, InWattsSensorEntity, OutWattsSensorEntity, VoltSensorEntity, QuotasStatusSensorEntity, \ + MilliVoltSensorEntity, ChargingStateSensorEntity, \ + FanSensorEntity, MiscBinarySensorEntity, DecicelsiusSensorEntity, MiscSensorEntity, CapacitySensorEntity +from ..switch import EnabledEntity, InvertedBeeperEntity + + +class Glacier(BaseDevice): + def charging_power_step(self) -> int: + return 50 + + def sensors(self, client: EcoflowMQTTClient) -> list[BaseSensorEntity]: + return [ + # Power and Battery Entities + LevelSensorEntity(client, "bms_bmsStatus.soc", const.MAIN_BATTERY_LEVEL) + .attr("bms_bmsStatus.designCap", const.ATTR_DESIGN_CAPACITY, 0) + .attr("bms_bmsStatus.fullCap", const.ATTR_FULL_CAPACITY, 0) + .attr("bms_bmsStatus.remainCap", const.ATTR_REMAIN_CAPACITY, 0), + CapacitySensorEntity(client, "bms_bmsStatus.designCap", const.MAIN_DESIGN_CAPACITY, False), + CapacitySensorEntity(client, "bms_bmsStatus.fullCap", const.MAIN_FULL_CAPACITY, False), + CapacitySensorEntity(client, "bms_bmsStatus.remainCap", const.MAIN_REMAIN_CAPACITY, False), + + LevelSensorEntity(client, "bms_emsStatus.f32LcdSoc", const.COMBINED_BATTERY_LEVEL), + + ChargingStateSensorEntity(client, "bms_emsStatus.chgState", const.BATTERY_CHARGING_STATE), + + InWattsSensorEntity(client, "bms_bmsStatus.inWatts", const.TOTAL_IN_POWER), + OutWattsSensorEntity(client, "bms_bmsStatus.outWatts", const.TOTAL_OUT_POWER), + + OutWattsSensorEntity(client, "pd.motorWat", "Motor Power"), + + RemainSensorEntity(client, "bms_emsStatus.chgRemain", const.CHARGE_REMAINING_TIME), + RemainSensorEntity(client, "bms_emsStatus.dsgRemain", const.DISCHARGE_REMAINING_TIME), + + CyclesSensorEntity(client, "bms_bmsStatus.cycles", const.CYCLES), + + TempSensorEntity(client, "bms_bmsStatus.tmp", const.BATTERY_TEMP) + .attr("bms_bmsStatus.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0) + .attr("bms_bmsStatus.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0), + TempSensorEntity(client, "bms_bmsStatus.minCellTmp", const.MIN_CELL_TEMP, False), + TempSensorEntity(client, "bms_bmsStatus.maxCellTmp", const.MAX_CELL_TEMP, False), + + VoltSensorEntity(client, "bms_bmsStatus.vol", const.BATTERY_VOLT, False) + .attr("bms_bmsStatus.minCellVol", const.ATTR_MIN_CELL_VOLT, 0) + .attr("bms_bmsStatus.maxCellVol", const.ATTR_MAX_CELL_VOLT, 0), + MilliVoltSensorEntity(client, "bms_bmsStatus.minCellVol", const.MIN_CELL_VOLT, False), + MilliVoltSensorEntity(client, "bms_bmsStatus.maxCellVol", const.MAX_CELL_VOLT, False), + + MiscBinarySensorEntity(client,"pd.batFlag", "Battery Present"), + + MiscSensorEntity(client, "pd.xt60InState", "XT60 State"), + + #Fridge Entities + FanSensorEntity(client, "bms_emsStatus.fanLvl", "Fan Level"), + + DecicelsiusSensorEntity(client, "pd.ambientTmp", "Ambient Temperature"), + DecicelsiusSensorEntity(client, "pd.exhaustTmp", "Exhaust Temperature"), + DecicelsiusSensorEntity(client, "pd.tempWater", "Water Temperature"), + DecicelsiusSensorEntity(client, "pd.tmpL", "Left Temperature"), + DecicelsiusSensorEntity(client, "pd.tmpR", "Right Temperature"), + + MiscBinarySensorEntity(client,"pd.flagTwoZone","Dual Zone Mode"), + + SecondsRemainSensorEntity(client, "pd.iceTm", "Ice Time Remain"), + LevelSensorEntity(client, "pd.icePercent", "Ice Percentage"), + + MiscSensorEntity(client, "pd.iceMkMode", "Ice Make Mode"), + + MiscBinarySensorEntity(client,"pd.iceAlert","Ice Alert"), + MiscBinarySensorEntity(client,"pd.waterLine","Ice Water Level OK"), + + QuotasStatusSensorEntity(client) + + ] + + def numbers(self, client: EcoflowMQTTClient) -> list[BaseNumberEntity]: + return [ + SetTempEntity(client,"pd.tmpLSet", "Left Set Temperature",-25, 10, + lambda value, params: {"moduleType": 1, "operateType": "temp", + "params": {"tmpM": int(params.get("pd.tmpMSet", 0)), + "tmpL": int(value), + "tmpR": int(params.get("pd.tmpRSet", 0))}}), + + SetTempEntity(client,"pd.tmpMSet", "Combined Set Temperature",-25, 10, + lambda value, params: {"moduleType": 1, "operateType": "temp", + "params": {"tmpM": int(value), + "tmpL": int(params.get("pd.tmpLSet", 0)), + "tmpR": int(params.get("pd.tmpRSet", 0))}}), + + SetTempEntity(client,"pd.tmpRSet", "Right Set Temperature",-25, 10, + lambda value, params: {"moduleType": 1, "operateType": "temp", + "params": {"tmpM": int(params.get("pd.tmpMSet", 0)), + "tmpL": int(params.get("pd.tmpLSet", 0)), + "tmpR": int(value)}}), + + ] + + def switches(self, client: EcoflowMQTTClient) -> list[BaseSwitchEntity]: + return [ + InvertedBeeperEntity(client, "pd.beepEn", const.BEEPER, + lambda value: {"moduleType": 1, "operateType": "beepEn", "params": {"flag": value}}), + + EnabledEntity(client, "pd.coolMode", "Eco Mode", + lambda value: {"moduleType": 1, "operateType": "ecoMode", "params": {"mode": value}}), + + #power parameter is inverted for some reason + EnabledEntity(client, "pd.pwrState", "Power", + lambda value: {"moduleType": 1, "operateType": "powerOff", "params": {"enable": value}}), + + ] + + def buttons(self, client: EcoflowMQTTClient) -> list[BaseButtonEntity]: + return [ + EnabledButtonEntity(client, "smlice", "Make Small Ice", lambda value: {"moduleType": 1, "operateType": "iceMake", "params": {"enable": 1, "iceShape": 0}}), + EnabledButtonEntity(client, "lrgice", "Make Large Ice", lambda value: {"moduleType": 1, "operateType": "iceMake", "params": {"enable": 1, "iceShape": 1}}), + EnabledButtonEntity(client, "deice", "Detach Ice", lambda value: {"moduleType": 1, "operateType": "deIce", "params": {"enable": 1}}) + + ] + + + def selects(self, client: EcoflowMQTTClient) -> list[BaseSelectEntity]: + return [ + + ] diff --git a/config/custom_components/ecoflow_cloud/devices/powerstream.py b/config/custom_components/ecoflow_cloud/devices/powerstream.py new file mode 100644 index 0000000..583cb95 --- /dev/null +++ b/config/custom_components/ecoflow_cloud/devices/powerstream.py @@ -0,0 +1,104 @@ +from . import BaseDevice +from .. import EcoflowMQTTClient +from ..entities import ( + BaseSensorEntity, BaseNumberEntity, BaseSelectEntity, BaseSwitchEntity +) +from ..sensor import ( + AmpSensorEntity, CentivoltSensorEntity, DeciampSensorEntity, + DecicelsiusSensorEntity, DecihertzSensorEntity, DeciwattsSensorEntity, + DecivoltSensorEntity, InWattsSolarSensorEntity, LevelSensorEntity, + MiscSensorEntity, RemainSensorEntity, StatusSensorEntity, +) +# from ..number import MinBatteryLevelEntity, MaxBatteryLevelEntity +# from ..select import DictSelectEntity + +class PowerStream(BaseDevice): + def sensors(self, client: EcoflowMQTTClient) -> list[BaseSensorEntity]: + return [ + InWattsSolarSensorEntity(client, "pv1_input_watts", "Solar 1 Watts"), + DecivoltSensorEntity(client, "pv1_input_volt", "Solar 1 Input Potential"), + CentivoltSensorEntity(client, "pv1_op_volt", "Solar 1 Op Potential"), + DeciampSensorEntity(client, "pv1_input_cur", "Solar 1 Currrent"), + DecicelsiusSensorEntity(client, "pv1_temp", "Solar 1 Temperature"), + MiscSensorEntity(client, "pv1_relay_status", "Solar 1 Relay Status"), + MiscSensorEntity(client, "pv1_error_code", "Solar 1 Error Code", False), + MiscSensorEntity(client, "pv1_warning_code", "Solar 1 Warning Code", False), + MiscSensorEntity(client, "pv1_status", "Solar 1 Status", False), + + InWattsSolarSensorEntity(client, "pv2_input_watts", "Solar 2 Watts"), + DecivoltSensorEntity(client, "pv2_input_volt", "Solar 2 Input Potential"), + CentivoltSensorEntity(client, "pv2_op_volt", "Solar 2 Op Potential"), + DeciampSensorEntity(client, "pv2_input_cur", "Solar 2 Current"), + DecicelsiusSensorEntity(client, "pv2_temp", "Solar 2 Temperature"), + MiscSensorEntity(client, "pv2_relay_status", "Solar 2 Relay Status"), + MiscSensorEntity(client, "pv2_error_code", "Solar 2 Error Code", False), + MiscSensorEntity(client, "pv2_warning_code", "Solar 2 Warning Code", False), + MiscSensorEntity(client, "pv2_status", "Solar 2 Status", False), + + MiscSensorEntity(client, "bp_type", "Battery Type", False), + LevelSensorEntity(client, "bat_soc", "Battery Charge"), + DeciwattsSensorEntity(client, "bat_input_watts", "Battery Input Watts"), + DecivoltSensorEntity(client, "bat_input_volt", "Battery Input Potential"), + DecivoltSensorEntity(client, "bat_op_volt", "Battery Op Potential"), + AmpSensorEntity(client, "bat_input_cur", "Battery Input Current"), + DecicelsiusSensorEntity(client, "bat_temp", "Battery Temperature"), + RemainSensorEntity(client, "battery_charge_remain", "Charge Time"), + RemainSensorEntity(client, "battery_discharge_remain", "Discharge Time"), + MiscSensorEntity(client, "bat_error_code", "Battery Error Code", False), + MiscSensorEntity(client, "bat_warning_code", "Battery Warning Code", False), + MiscSensorEntity(client, "bat_status", "Battery Status", False), + + DecivoltSensorEntity(client, "llc_input_volt", "LLC Input Potential", False), + DecivoltSensorEntity(client, "llc_op_volt", "LLC Op Potential", False), + MiscSensorEntity(client, "llc_error_code", "LLC Error Code", False), + MiscSensorEntity(client, "llc_warning_code", "LLC Warning Code", False), + MiscSensorEntity(client, "llc_status", "LLC Status", False), + + MiscSensorEntity(client, "inv_on_off", "Inverter On/Off Status"), + DeciwattsSensorEntity(client, "inv_output_watts", "Inverter Output Watts"), + DecivoltSensorEntity(client, "inv_input_volt", "Inverter Output Potential", False), + DecivoltSensorEntity(client, "inv_op_volt", "Inverter Op Potential"), + AmpSensorEntity(client, "inv_output_cur", "Inverter Output Current"), + AmpSensorEntity(client, "inv_dc_cur", "Inverter DC Current"), + DecihertzSensorEntity(client, "inv_freq", "Inverter Frequency"), + DecicelsiusSensorEntity(client, "inv_temp", "Inverter Temperature"), + MiscSensorEntity(client, "inv_relay_status", "Inverter Relay Status"), + MiscSensorEntity(client, "inv_error_code", "Inverter Error Code", False), + MiscSensorEntity(client, "inv_warning_code", "Inverter Warning Code", False), + MiscSensorEntity(client, "inv_status", "Inverter Status", False), + + DeciwattsSensorEntity(client, "permanent_watts", "Other Loads"), + DeciwattsSensorEntity(client, "dynamic_watts", "Smart Plug Loads"), + DeciwattsSensorEntity(client, "rated_power", "Rated Power"), + + MiscSensorEntity(client, "lower_limit", "Lower Battery Limit", False), + MiscSensorEntity(client, "upper_limit", "Upper Battery Limit", False), + MiscSensorEntity(client, "wireless_error_code", "Wireless Error Code", False), + MiscSensorEntity(client, "wireless_warning_code", "Wireless Warning Code", False), + MiscSensorEntity(client, "inv_brightness", "LED Brightness", False), + MiscSensorEntity(client, "heartbeat_frequency", "Heartbeat Frequency", False), + + StatusSensorEntity(client) + ] + + + def numbers(self, client: EcoflowMQTTClient) -> list[BaseNumberEntity]: + return [ + # These will likely be some form of serialised data rather than JSON will look into it later + # MinBatteryLevelEntity(client, "lowerLimit", "Min Disharge Level", 50, 100, + # lambda value: {"moduleType": 0, "operateType": "TCP", + # "params": {"id": 00, "lowerLimit": value}}), + # MaxBatteryLevelEntity(client, "upperLimit", "Max Charge Level", 0, 30, + # lambda value: {"moduleType": 0, "operateType": "TCP", + # "params": {"id": 00, "upperLimit": value}}), + ] + + def switches(self, client: EcoflowMQTTClient) -> list[BaseSwitchEntity]: + return [] + + def selects(self, client: EcoflowMQTTClient) -> list[BaseSelectEntity]: + return [ + # DictSelectEntity(client, "supplyPriority", "Power supply mode", {"Prioritize power supply", "Prioritize power storage"}, + # lambda value: {"moduleType": 00, "operateType": "supplyPriority", + # "params": {"supplyPriority": value}}), + ] diff --git a/config/custom_components/ecoflow_cloud/devices/registry.py b/config/custom_components/ecoflow_cloud/devices/registry.py new file mode 100644 index 0000000..1ff9491 --- /dev/null +++ b/config/custom_components/ecoflow_cloud/devices/registry.py @@ -0,0 +1,32 @@ +from custom_components.ecoflow_cloud.config_flow import EcoflowModel +from custom_components.ecoflow_cloud.devices import BaseDevice, DiagnosticDevice +from custom_components.ecoflow_cloud.devices.delta2 import Delta2 +from custom_components.ecoflow_cloud.devices.delta_mini import DeltaMini +from custom_components.ecoflow_cloud.devices.delta_pro import DeltaPro +from custom_components.ecoflow_cloud.devices.river2 import River2 +from custom_components.ecoflow_cloud.devices.river2_max import River2Max +from custom_components.ecoflow_cloud.devices.river2_pro import River2Pro +from custom_components.ecoflow_cloud.devices.river_max import RiverMax +from custom_components.ecoflow_cloud.devices.river_pro import RiverPro +from custom_components.ecoflow_cloud.devices.delta_max import DeltaMax +from custom_components.ecoflow_cloud.devices.delta2_max import Delta2Max +from custom_components.ecoflow_cloud.devices.powerstream import PowerStream +from custom_components.ecoflow_cloud.devices.glacier import Glacier +from custom_components.ecoflow_cloud.devices.wave2 import Wave2 + +devices: dict[str, BaseDevice] = { + EcoflowModel.DELTA_2.name: Delta2(), + EcoflowModel.RIVER_2.name: River2(), + EcoflowModel.RIVER_2_MAX.name: River2Max(), + EcoflowModel.RIVER_2_PRO.name: River2Pro(), + EcoflowModel.DELTA_PRO.name: DeltaPro(), + EcoflowModel.RIVER_MAX.name: RiverMax(), + EcoflowModel.RIVER_PRO.name: RiverPro(), + EcoflowModel.DELTA_MINI.name: DeltaMini(), + EcoflowModel.DELTA_MAX.name: DeltaMax(), + EcoflowModel.DELTA_2_MAX.name: Delta2Max(), + EcoflowModel.POWERSTREAM.name: PowerStream(), + EcoflowModel.GLACIER.name: Glacier(), + EcoflowModel.WAVE_2.name: Wave2(), + EcoflowModel.DIAGNOSTIC.name: DiagnosticDevice() +} diff --git a/config/custom_components/ecoflow_cloud/devices/river2.py b/config/custom_components/ecoflow_cloud/devices/river2.py new file mode 100644 index 0000000..314248b --- /dev/null +++ b/config/custom_components/ecoflow_cloud/devices/river2.py @@ -0,0 +1,135 @@ +from homeassistant.const import Platform + +from . import const, BaseDevice, EntityMigration, MigrationAction +from ..entities import BaseSensorEntity, BaseNumberEntity, BaseSwitchEntity, BaseSelectEntity +from ..mqtt.ecoflow_mqtt import EcoflowMQTTClient +from ..number import ChargingPowerEntity, MaxBatteryLevelEntity, MinBatteryLevelEntity +from ..select import DictSelectEntity, TimeoutDictSelectEntity +from ..sensor import LevelSensorEntity, RemainSensorEntity, TempSensorEntity, \ + CyclesSensorEntity, InWattsSensorEntity, OutWattsSensorEntity, VoltSensorEntity, StatusSensorEntity, \ + MilliVoltSensorEntity, InMilliVoltSensorEntity, OutMilliVoltSensorEntity, ChargingStateSensorEntity, \ + CapacitySensorEntity +from ..switch import EnabledEntity + + +class River2(BaseDevice): + def charging_power_step(self) -> int: + return 50 + + def sensors(self, client: EcoflowMQTTClient) -> list[BaseSensorEntity]: + return [ + LevelSensorEntity(client, "bms_bmsStatus.soc", const.MAIN_BATTERY_LEVEL) + .attr("bms_bmsStatus.designCap", const.ATTR_DESIGN_CAPACITY, 0) + .attr("bms_bmsStatus.fullCap", const.ATTR_FULL_CAPACITY, 0) + .attr("bms_bmsStatus.remainCap", const.ATTR_REMAIN_CAPACITY, 0), + CapacitySensorEntity(client, "bms_bmsStatus.designCap", const.MAIN_DESIGN_CAPACITY, False), + CapacitySensorEntity(client, "bms_bmsStatus.fullCap", const.MAIN_FULL_CAPACITY, False), + CapacitySensorEntity(client, "bms_bmsStatus.remainCap", const.MAIN_REMAIN_CAPACITY, False), + + LevelSensorEntity(client, "bms_bmsStatus.soh", const.SOH), + + LevelSensorEntity(client, "bms_emsStatus.lcdShowSoc", const.COMBINED_BATTERY_LEVEL), + + ChargingStateSensorEntity(client, "bms_emsStatus.chgState", const.BATTERY_CHARGING_STATE), + + InWattsSensorEntity(client, "pd.wattsInSum", const.TOTAL_IN_POWER), + OutWattsSensorEntity(client, "pd.wattsOutSum", const.TOTAL_OUT_POWER), + + InWattsSensorEntity(client, "inv.inputWatts", const.AC_IN_POWER), + OutWattsSensorEntity(client, "inv.outputWatts", const.AC_OUT_POWER), + + InMilliVoltSensorEntity(client, "inv.acInVol", const.AC_IN_VOLT), + OutMilliVoltSensorEntity(client, "inv.invOutVol", const.AC_OUT_VOLT), + + InWattsSensorEntity(client, "pd.typecChaWatts", const.TYPE_C_IN_POWER), + InWattsSensorEntity(client, "mppt.inWatts", const.SOLAR_IN_POWER), + + OutWattsSensorEntity(client, "pd.carWatts", const.DC_OUT_POWER), + OutWattsSensorEntity(client, "pd.typec1Watts", const.TYPEC_1_OUT_POWER), + + # both USB-A Ports (the small RIVER 2 has only two) are being summarized under "pd.usb1Watts" - https://github.com/tolwi/hassio-ecoflow-cloud/issues/12#issuecomment-1432837393 + OutWattsSensorEntity(client, "pd.usb1Watts", const.USB_OUT_POWER), + + RemainSensorEntity(client, "bms_emsStatus.chgRemainTime", const.CHARGE_REMAINING_TIME), + RemainSensorEntity(client, "bms_emsStatus.dsgRemainTime", const.DISCHARGE_REMAINING_TIME), + + TempSensorEntity(client, "inv.outTemp", "Inv Out Temperature"), + CyclesSensorEntity(client, "bms_bmsStatus.cycles", const.CYCLES), + + TempSensorEntity(client, "bms_bmsStatus.temp", const.BATTERY_TEMP) + .attr("bms_bmsStatus.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0) + .attr("bms_bmsStatus.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0), + TempSensorEntity(client, "bms_bmsStatus.minCellTemp", const.MIN_CELL_TEMP, False), + TempSensorEntity(client, "bms_bmsStatus.maxCellTemp", const.MAX_CELL_TEMP, False), + + VoltSensorEntity(client, "bms_bmsStatus.vol", const.BATTERY_VOLT, False) + .attr("bms_bmsStatus.minCellVol", const.ATTR_MIN_CELL_VOLT, 0) + .attr("bms_bmsStatus.maxCellVol", const.ATTR_MAX_CELL_VOLT, 0), + MilliVoltSensorEntity(client, "bms_bmsStatus.minCellVol", const.MIN_CELL_VOLT, False), + MilliVoltSensorEntity(client, "bms_bmsStatus.maxCellVol", const.MAX_CELL_VOLT, False), + + # FanSensorEntity(client, "bms_emsStatus.fanLevel", "Fan Level"), + StatusSensorEntity(client), + + ] + + def numbers(self, client: EcoflowMQTTClient) -> list[BaseNumberEntity]: + return [ + MaxBatteryLevelEntity(client, "bms_emsStatus.maxChargeSoc", const.MAX_CHARGE_LEVEL, 50, 100, + lambda value: {"moduleType": 2, "operateType": "upsConfig", + "params": {"maxChgSoc": int(value)}}), + + MinBatteryLevelEntity(client, "bms_emsStatus.minDsgSoc", const.MIN_DISCHARGE_LEVEL, 0, 30, + lambda value: {"moduleType": 2, "operateType": "dsgCfg", + "params": {"minDsgSoc": int(value)}}), + + ChargingPowerEntity(client, "mppt.cfgChgWatts", const.AC_CHARGING_POWER, 100, 360, + lambda value: {"moduleType": 5, "operateType": "acChgCfg", + "params": {"chgWatts": int(value), "chgPauseFlag": 255}}), + ] + + def switches(self, client: EcoflowMQTTClient) -> list[BaseSwitchEntity]: + return [ + EnabledEntity(client, "mppt.cfgAcEnabled", const.AC_ENABLED, + lambda value: {"moduleType": 5, "operateType": "acOutCfg", + "params": {"enabled": value, "out_voltage": -1, "out_freq": 255, + "xboost": 255}}), + + EnabledEntity(client, "mppt.cfgAcXboost", const.XBOOST_ENABLED, + lambda value: {"moduleType": 5, "operateType": "acOutCfg", + "params": {"enabled": 255, "out_voltage": -1, "out_freq": 255, + "xboost": value}}), + + EnabledEntity(client, "pd.carState", const.DC_ENABLED, + lambda value: {"moduleType": 5, "operateType": "mpptCar", "params": {"enabled": value}}) + ] + + def selects(self, client: EcoflowMQTTClient) -> list[BaseSelectEntity]: + return [ + DictSelectEntity(client, "mppt.dcChgCurrent", const.DC_CHARGE_CURRENT, const.DC_CHARGE_CURRENT_OPTIONS, + lambda value: {"moduleType": 5, "operateType": "dcChgCfg", + "params": {"dcChgCfg": value}}), + + DictSelectEntity(client, "mppt.cfgChgType", const.DC_MODE, const.DC_MODE_OPTIONS, + lambda value: {"moduleType": 5, "operateType": "chaType", + "params": {"chaType": value}}), + + TimeoutDictSelectEntity(client, "mppt.scrStandbyMin", const.SCREEN_TIMEOUT, const.SCREEN_TIMEOUT_OPTIONS, + lambda value: {"moduleType": 5, "operateType": "lcdCfg", + "params": {"brighLevel": 255, "delayOff": value}}), + + TimeoutDictSelectEntity(client, "mppt.powStandbyMin", const.UNIT_TIMEOUT, const.UNIT_TIMEOUT_OPTIONS, + lambda value: {"moduleType": 5, "operateType": "standby", + "params": {"standbyMins": value}}), + + TimeoutDictSelectEntity(client, "mppt.acStandbyMins", const.AC_TIMEOUT, const.AC_TIMEOUT_OPTIONS, + lambda value: {"moduleType": 5, "operateType": "acStandby", + "params": {"standbyMins": value}}) + ] + + def migrate(self, version) -> list[EntityMigration]: + if version == 2: + return [ + EntityMigration("pd.soc", Platform.SENSOR, MigrationAction.REMOVE), + ] + return [] diff --git a/config/custom_components/ecoflow_cloud/devices/river2_max.py b/config/custom_components/ecoflow_cloud/devices/river2_max.py new file mode 100644 index 0000000..3841b6b --- /dev/null +++ b/config/custom_components/ecoflow_cloud/devices/river2_max.py @@ -0,0 +1,163 @@ +from homeassistant.const import Platform + +from . import const, BaseDevice, EntityMigration, MigrationAction +from .const import ATTR_DESIGN_CAPACITY, ATTR_FULL_CAPACITY, ATTR_REMAIN_CAPACITY, BATTERY_CHARGING_STATE, \ + MAIN_DESIGN_CAPACITY, MAIN_FULL_CAPACITY, MAIN_REMAIN_CAPACITY +from ..entities import BaseSensorEntity, BaseNumberEntity, BaseSwitchEntity, BaseSelectEntity +from ..mqtt.ecoflow_mqtt import EcoflowMQTTClient +from ..number import ChargingPowerEntity, MaxBatteryLevelEntity, MinBatteryLevelEntity, BatteryBackupLevel +from ..select import DictSelectEntity, TimeoutDictSelectEntity +from ..sensor import LevelSensorEntity, RemainSensorEntity, TempSensorEntity, \ + CyclesSensorEntity, InWattsSensorEntity, OutWattsSensorEntity, VoltSensorEntity, InAmpSensorEntity, \ + InVoltSensorEntity, QuotasStatusSensorEntity, MilliVoltSensorEntity, InMilliVoltSensorEntity, \ + OutMilliVoltSensorEntity, ChargingStateSensorEntity, CapacitySensorEntity +from ..switch import EnabledEntity + + +class River2Max(BaseDevice): + + def charging_power_step(self) -> int: + return 50 + + def sensors(self, client: EcoflowMQTTClient) -> list[BaseSensorEntity]: + return [ + LevelSensorEntity(client, "bms_bmsStatus.soc", const.MAIN_BATTERY_LEVEL) + .attr("bms_bmsStatus.designCap", ATTR_DESIGN_CAPACITY, 0) + .attr("bms_bmsStatus.fullCap", ATTR_FULL_CAPACITY, 0) + .attr("bms_bmsStatus.remainCap", ATTR_REMAIN_CAPACITY, 0), + CapacitySensorEntity(client, "bms_bmsStatus.designCap", MAIN_DESIGN_CAPACITY, False), + CapacitySensorEntity(client, "bms_bmsStatus.fullCap", MAIN_FULL_CAPACITY, False), + CapacitySensorEntity(client, "bms_bmsStatus.remainCap", MAIN_REMAIN_CAPACITY, False), + + LevelSensorEntity(client, "bms_bmsStatus.soh", const.SOH), + + LevelSensorEntity(client, "bms_emsStatus.lcdShowSoc", const.COMBINED_BATTERY_LEVEL), + + ChargingStateSensorEntity(client, "bms_emsStatus.chgState", BATTERY_CHARGING_STATE), + + InWattsSensorEntity(client, "pd.wattsInSum", const.TOTAL_IN_POWER), + OutWattsSensorEntity(client, "pd.wattsOutSum", const.TOTAL_OUT_POWER), + + InAmpSensorEntity(client, "inv.dcInAmp", const.SOLAR_IN_CURRENT), + InVoltSensorEntity(client, "inv.dcInVol", const.SOLAR_IN_VOLTAGE), + + InWattsSensorEntity(client, "inv.inputWatts", const.AC_IN_POWER), + OutWattsSensorEntity(client, "inv.outputWatts", const.AC_OUT_POWER), + + InMilliVoltSensorEntity(client, "inv.acInVol", const.AC_IN_VOLT), + OutMilliVoltSensorEntity(client, "inv.invOutVol", const.AC_OUT_VOLT), + + InWattsSensorEntity(client, "pd.typecChaWatts", const.TYPE_C_IN_POWER), + InWattsSensorEntity(client, "mppt.inWatts", const.SOLAR_IN_POWER), + + + OutWattsSensorEntity(client, "pd.carWatts", const.DC_OUT_POWER), + OutWattsSensorEntity(client, "pd.typec1Watts", const.TYPEC_OUT_POWER), + OutWattsSensorEntity(client, "pd.usb1Watts", const.USB_OUT_POWER), + # OutWattsSensorEntity(client, "pd.usb2Watts", const.USB_2_OUT_POWER), + + RemainSensorEntity(client, "bms_emsStatus.chgRemainTime", const.CHARGE_REMAINING_TIME), + RemainSensorEntity(client, "bms_emsStatus.dsgRemainTime", const.DISCHARGE_REMAINING_TIME), + RemainSensorEntity(client, "pd.remainTime", const.REMAINING_TIME), + + TempSensorEntity(client, "inv.outTemp", "Inv Out Temperature"), + CyclesSensorEntity(client, "bms_bmsStatus.cycles", const.CYCLES), + + TempSensorEntity(client, "bms_bmsStatus.temp", const.BATTERY_TEMP) + .attr("bms_bmsStatus.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0) + .attr("bms_bmsStatus.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0), + TempSensorEntity(client, "bms_bmsStatus.minCellTemp", const.MIN_CELL_TEMP, False), + TempSensorEntity(client, "bms_bmsStatus.maxCellTemp", const.MAX_CELL_TEMP, False), + + VoltSensorEntity(client, "bms_bmsStatus.vol", const.BATTERY_VOLT, False) + .attr("bms_bmsStatus.minCellVol", const.ATTR_MIN_CELL_VOLT, 0) + .attr("bms_bmsStatus.maxCellVol", const.ATTR_MAX_CELL_VOLT, 0), + MilliVoltSensorEntity(client, "bms_bmsStatus.minCellVol", const.MIN_CELL_VOLT, False), + MilliVoltSensorEntity(client, "bms_bmsStatus.maxCellVol", const.MAX_CELL_VOLT, False), + + QuotasStatusSensorEntity(client), + # FanSensorEntity(client, "bms_emsStatus.fanLevel", "Fan Level"), + + ] + + def numbers(self, client: EcoflowMQTTClient) -> list[BaseNumberEntity]: + return [ + MaxBatteryLevelEntity(client, "bms_emsStatus.maxChargeSoc", const.MAX_CHARGE_LEVEL, 50, 100, + lambda value: {"moduleType": 2, "operateType": "upsConfig", + "params": {"maxChgSoc": int(value)}}), + + MinBatteryLevelEntity(client, "bms_emsStatus.minDsgSoc", const.MIN_DISCHARGE_LEVEL, 0, 30, + lambda value: {"moduleType": 2, "operateType": "dsgCfg", + "params": {"minDsgSoc": int(value)}}), + + ChargingPowerEntity(client, "mppt.cfgChgWatts", const.AC_CHARGING_POWER, 50, 660, + lambda value: {"moduleType": 5, "operateType": "acChgCfg", + "params": {"chgWatts": int(value), "chgPauseFlag": 255}}), + + BatteryBackupLevel(client, "pd.bpPowerSoc", const.BACKUP_RESERVE_LEVEL, 5, 100, + "bms_emsStatus.minDsgSoc", "bms_emsStatus.maxChargeSoc", + lambda value: {"moduleType": 1, "operateType": "watthConfig", + "params": {"isConfig": 1, + "bpPowerSoc": int(value), + "minDsgSoc": 0, + "minChgSoc": 0}}), + ] + + def switches(self, client: EcoflowMQTTClient) -> list[BaseSwitchEntity]: + return [ + EnabledEntity(client, "mppt.cfgAcEnabled", const.AC_ENABLED, + lambda value: {"moduleType": 5, "operateType": "acOutCfg", + "params": {"enabled": value, "out_voltage": -1, "out_freq": 255, + "xboost": 255}}), + + EnabledEntity(client, "pd.acAutoOutConfig", const.AC_ALWAYS_ENABLED, + lambda value, params: {"moduleType": 1, "operateType": "acAutoOutConfig", + "params": {"acAutoOutConfig": value, + "minAcOutSoc": int(params.get("bms_emsStatus.minDsgSoc", 0)) + 5}} + ), + + EnabledEntity(client, "mppt.cfgAcXboost", const.XBOOST_ENABLED, + lambda value: {"moduleType": 5, "operateType": "acOutCfg", + "params": {"enabled": 255, "out_voltage": -1, "out_freq": 255, + "xboost": value}}), + + EnabledEntity(client, "pd.carState", const.DC_ENABLED, + lambda value: {"moduleType": 5, "operateType": "mpptCar", "params": {"enabled": value}}), + + EnabledEntity(client, "pd.bpPowerSoc", const.BP_ENABLED, + lambda value, params: {"moduleType": 1, "operateType": "watthConfig", + "params": {"isConfig": value, + "bpPowerSoc": value, + "minDsgSoc": 0, + "minChgSoc": 0}}) + ] + + def selects(self, client: EcoflowMQTTClient) -> list[BaseSelectEntity]: + return [ + DictSelectEntity(client, "mppt.dcChgCurrent", const.DC_CHARGE_CURRENT, const.DC_CHARGE_CURRENT_OPTIONS, + lambda value: {"moduleType": 5, "operateType": "dcChgCfg", + "params": {"dcChgCfg": value}}), + + DictSelectEntity(client, "mppt.cfgChgType", const.DC_MODE, const.DC_MODE_OPTIONS, + lambda value: {"moduleType": 5, "operateType": "chaType", + "params": {"chaType": value}}), + + TimeoutDictSelectEntity(client, "mppt.scrStandbyMin", const.SCREEN_TIMEOUT, const.SCREEN_TIMEOUT_OPTIONS, + lambda value: {"moduleType": 5, "operateType": "lcdCfg", + "params": {"brighLevel": 255, "delayOff": value}}), + + TimeoutDictSelectEntity(client, "mppt.powStandbyMin", const.UNIT_TIMEOUT, const.UNIT_TIMEOUT_OPTIONS, + lambda value: {"moduleType": 5, "operateType": "standby", + "params": {"standbyMins": value}}), + + TimeoutDictSelectEntity(client, "mppt.acStandbyMins", const.AC_TIMEOUT, const.AC_TIMEOUT_OPTIONS, + lambda value: {"moduleType": 5, "operateType": "acStandby", + "params": {"standbyMins": value}}) + ] + + def migrate(self, version) -> list[EntityMigration]: + if version == 2: + return [ + EntityMigration("pd.soc", Platform.SENSOR, MigrationAction.REMOVE), + ] + return [] diff --git a/config/custom_components/ecoflow_cloud/devices/river2_pro.py b/config/custom_components/ecoflow_cloud/devices/river2_pro.py new file mode 100644 index 0000000..edab011 --- /dev/null +++ b/config/custom_components/ecoflow_cloud/devices/river2_pro.py @@ -0,0 +1,136 @@ +from homeassistant.const import Platform + +from . import const, BaseDevice, EntityMigration, MigrationAction +from ..entities import BaseSensorEntity, BaseNumberEntity, BaseSwitchEntity, BaseSelectEntity +from ..mqtt.ecoflow_mqtt import EcoflowMQTTClient +from ..number import ChargingPowerEntity, MaxBatteryLevelEntity, MinBatteryLevelEntity +from ..select import DictSelectEntity, TimeoutDictSelectEntity +from ..sensor import LevelSensorEntity, RemainSensorEntity, TempSensorEntity, \ + CyclesSensorEntity, InWattsSensorEntity, OutWattsSensorEntity, VoltSensorEntity, QuotasStatusSensorEntity, \ + MilliVoltSensorEntity, InMilliVoltSensorEntity, OutMilliVoltSensorEntity, ChargingStateSensorEntity, \ + CapacitySensorEntity +from ..switch import EnabledEntity + + +class River2Pro(BaseDevice): + + def charging_power_step(self) -> int: + return 50 + + def sensors(self, client: EcoflowMQTTClient) -> list[BaseSensorEntity]: + return [ + LevelSensorEntity(client, "bms_bmsStatus.soc", const.MAIN_BATTERY_LEVEL) + .attr("bms_bmsStatus.designCap", const.ATTR_DESIGN_CAPACITY, 0) + .attr("bms_bmsStatus.fullCap", const.ATTR_FULL_CAPACITY, 0) + .attr("bms_bmsStatus.remainCap", const.ATTR_REMAIN_CAPACITY, 0), + CapacitySensorEntity(client, "bms_bmsStatus.designCap", const.MAIN_DESIGN_CAPACITY, False), + CapacitySensorEntity(client, "bms_bmsStatus.fullCap", const.MAIN_FULL_CAPACITY, False), + CapacitySensorEntity(client, "bms_bmsStatus.remainCap", const.MAIN_REMAIN_CAPACITY, False), + + LevelSensorEntity(client, "bms_bmsStatus.soh", const.SOH), + LevelSensorEntity(client, "bms_emsStatus.lcdShowSoc", const.COMBINED_BATTERY_LEVEL), + + ChargingStateSensorEntity(client, "bms_emsStatus.chgState", const.BATTERY_CHARGING_STATE), + + InWattsSensorEntity(client, "pd.wattsInSum", const.TOTAL_IN_POWER), + OutWattsSensorEntity(client, "pd.wattsOutSum", const.TOTAL_OUT_POWER), + + InWattsSensorEntity(client, "inv.inputWatts", const.AC_IN_POWER), + OutWattsSensorEntity(client, "inv.outputWatts", const.AC_OUT_POWER), + + InMilliVoltSensorEntity(client, "inv.acInVol", const.AC_IN_VOLT), + OutMilliVoltSensorEntity(client, "inv.invOutVol", const.AC_OUT_VOLT), + + InWattsSensorEntity(client, "pd.typecChaWatts", const.TYPE_C_IN_POWER), + InWattsSensorEntity(client, "mppt.inWatts", const.SOLAR_IN_POWER), + + OutWattsSensorEntity(client, "pd.carWatts", const.DC_OUT_POWER), + OutWattsSensorEntity(client, "pd.typec1Watts", const.TYPEC_OUT_POWER), + OutWattsSensorEntity(client, "pd.usb1Watts", const.USB_OUT_POWER), + # OutWattsSensorEntity(client, "pd.usb2Watts", const.USB_2_OUT_POWER), + + RemainSensorEntity(client, "bms_emsStatus.chgRemainTime", const.CHARGE_REMAINING_TIME), + RemainSensorEntity(client, "bms_emsStatus.dsgRemainTime", const.DISCHARGE_REMAINING_TIME), + RemainSensorEntity(client, "pd.remainTime", const.REMAINING_TIME), + + + TempSensorEntity(client, "inv.outTemp", "Inv Out Temperature"), + CyclesSensorEntity(client, "bms_bmsStatus.cycles", const.CYCLES), + + TempSensorEntity(client, "bms_bmsStatus.temp", const.BATTERY_TEMP) + .attr("bms_bmsStatus.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0) + .attr("bms_bmsStatus.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0), + TempSensorEntity(client, "bms_bmsStatus.minCellTemp", const.MIN_CELL_TEMP, False), + TempSensorEntity(client, "bms_bmsStatus.maxCellTemp", const.MAX_CELL_TEMP, False), + + VoltSensorEntity(client, "bms_bmsStatus.vol", const.BATTERY_VOLT, False) + .attr("bms_bmsStatus.minCellVol", const.ATTR_MIN_CELL_VOLT, 0) + .attr("bms_bmsStatus.maxCellVol", const.ATTR_MAX_CELL_VOLT, 0), + MilliVoltSensorEntity(client, "bms_bmsStatus.minCellVol", const.MIN_CELL_VOLT, False), + MilliVoltSensorEntity(client, "bms_bmsStatus.maxCellVol", const.MAX_CELL_VOLT, False), + # FanSensorEntity(client, "bms_emsStatus.fanLevel", "Fan Level"), + + QuotasStatusSensorEntity(client), + + ] + + def numbers(self, client: EcoflowMQTTClient) -> list[BaseNumberEntity]: + return [ + MaxBatteryLevelEntity(client, "bms_emsStatus.maxChargeSoc", const.MAX_CHARGE_LEVEL, 50, 100, + lambda value: {"moduleType": 2, "operateType": "upsConfig", + "params": {"maxChgSoc": int(value)}}), + + MinBatteryLevelEntity(client, "bms_emsStatus.minDsgSoc", const.MIN_DISCHARGE_LEVEL, 0, 30, + lambda value: {"moduleType": 2, "operateType": "dsgCfg", + "params": {"minDsgSoc": int(value)}}), + + ChargingPowerEntity(client, "mppt.cfgChgWatts", const.AC_CHARGING_POWER, 100, 950, + lambda value: {"moduleType": 5, "operateType": "acChgCfg", + "params": {"chgWatts": int(value), "chgPauseFlag": 255}}), + ] + + def switches(self, client: EcoflowMQTTClient) -> list[BaseSwitchEntity]: + return [ + EnabledEntity(client, "mppt.cfgAcEnabled", const.AC_ENABLED, + lambda value: {"moduleType": 5, "operateType": "acOutCfg", + "params": {"enabled": value, "out_voltage": -1, "out_freq": 255, + "xboost": 255}}), + + EnabledEntity(client, "mppt.cfgAcXboost", const.XBOOST_ENABLED, + lambda value: {"moduleType": 5, "operateType": "acOutCfg", + "params": {"enabled": 255, "out_voltage": -1, "out_freq": 255, + "xboost": value}}), + + EnabledEntity(client, "pd.carState", const.DC_ENABLED, + lambda value: {"moduleType": 5, "operateType": "mpptCar", "params": {"enabled": value}}) + ] + + def selects(self, client: EcoflowMQTTClient) -> list[BaseSelectEntity]: + return [ + DictSelectEntity(client, "mppt.dcChgCurrent", const.DC_CHARGE_CURRENT, const.DC_CHARGE_CURRENT_OPTIONS, + lambda value: {"moduleType": 5, "operateType": "dcChgCfg", + "params": {"dcChgCfg": value}}), + + DictSelectEntity(client, "mppt.cfgChgType", const.DC_MODE, const.DC_MODE_OPTIONS, + lambda value: {"moduleType": 5, "operateType": "chaType", + "params": {"chaType": value}}), + + TimeoutDictSelectEntity(client, "mppt.scrStandbyMin", const.SCREEN_TIMEOUT, const.SCREEN_TIMEOUT_OPTIONS, + lambda value: {"moduleType": 5, "operateType": "lcdCfg", + "params": {"brighLevel": 255, "delayOff": value}}), + + TimeoutDictSelectEntity(client, "mppt.powStandbyMin", const.UNIT_TIMEOUT, const.UNIT_TIMEOUT_OPTIONS, + lambda value: {"moduleType": 5, "operateType": "standby", + "params": {"standbyMins": value}}), + + TimeoutDictSelectEntity(client, "mppt.acStandbyMins", const.AC_TIMEOUT, const.AC_TIMEOUT_OPTIONS, + lambda value: {"moduleType": 5, "operateType": "acStandby", + "params": {"standbyMins": value}}) + ] + + def migrate(self, version) -> list[EntityMigration]: + if version == 2: + return [ + EntityMigration("pd.soc", Platform.SENSOR, MigrationAction.REMOVE), + ] + return [] diff --git a/config/custom_components/ecoflow_cloud/devices/river_max.py b/config/custom_components/ecoflow_cloud/devices/river_max.py new file mode 100644 index 0000000..aa6a779 --- /dev/null +++ b/config/custom_components/ecoflow_cloud/devices/river_max.py @@ -0,0 +1,117 @@ +from homeassistant.const import Platform + +from . import const, BaseDevice, MigrationAction, EntityMigration +from ..entities import BaseSensorEntity, BaseNumberEntity, BaseSwitchEntity, BaseSelectEntity +from ..mqtt.ecoflow_mqtt import EcoflowMQTTClient +from ..number import MaxBatteryLevelEntity +from ..select import DictSelectEntity +from ..sensor import LevelSensorEntity, WattsSensorEntity, RemainSensorEntity, TempSensorEntity, \ + CyclesSensorEntity, InWattsSensorEntity, OutWattsSensorEntity, StatusSensorEntity, \ + InEnergySensorEntity, OutEnergySensorEntity, MilliVoltSensorEntity, InMilliVoltSensorEntity, \ + OutMilliVoltSensorEntity, CapacitySensorEntity +from ..switch import EnabledEntity, BeeperEntity + + +class RiverMax(BaseDevice): + def sensors(self, client: EcoflowMQTTClient) -> list[BaseSensorEntity]: + return [ + LevelSensorEntity(client, "bmsMaster.soc", const.MAIN_BATTERY_LEVEL) + .attr("bmsMaster.designCap", const.ATTR_DESIGN_CAPACITY, 0) + .attr("bmsMaster.fullCap", const.ATTR_FULL_CAPACITY, 0) + .attr("bmsMaster.remainCap", const.ATTR_REMAIN_CAPACITY, 0), + CapacitySensorEntity(client, "bmsMaster.designCap", const.MAIN_DESIGN_CAPACITY, False), + CapacitySensorEntity(client, "bmsMaster.fullCap", const.MAIN_FULL_CAPACITY, False), + CapacitySensorEntity(client, "bmsMaster.remainCap", const.MAIN_REMAIN_CAPACITY, False), + + WattsSensorEntity(client, "pd.wattsInSum", const.TOTAL_IN_POWER), + WattsSensorEntity(client, "pd.wattsOutSum", const.TOTAL_OUT_POWER), + + InWattsSensorEntity(client, "inv.inputWatts", const.AC_IN_POWER), + OutWattsSensorEntity(client, "inv.outputWatts", const.AC_OUT_POWER), + + InMilliVoltSensorEntity(client, "inv.acInVol", const.AC_IN_VOLT), + OutMilliVoltSensorEntity(client, "inv.invOutVol", const.AC_OUT_VOLT), + + OutWattsSensorEntity(client, "pd.carWatts", const.DC_OUT_POWER), + + OutWattsSensorEntity(client, "pd.typecWatts", const.TYPEC_OUT_POWER), + + OutWattsSensorEntity(client, "pd.usb1Watts", const.USB_1_OUT_POWER), + OutWattsSensorEntity(client, "pd.usb2Watts", const.USB_2_OUT_POWER), + OutWattsSensorEntity(client, "pd.usb3Watts", const.USB_3_OUT_POWER), + + RemainSensorEntity(client, "pd.remainTime", const.REMAINING_TIME), + CyclesSensorEntity(client, "bmsMaster.cycles", const.CYCLES), + + TempSensorEntity(client, "bmsMaster.temp", const.BATTERY_TEMP) + .attr("bmsMaster.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0) + .attr("bmsMaster.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0), + TempSensorEntity(client, "bmsMaster.minCellTemp", const.MIN_CELL_TEMP, False), + TempSensorEntity(client, "bmsMaster.maxCellTemp", const.MAX_CELL_TEMP, False), + + MilliVoltSensorEntity(client, "bmsMaster.vol", const.BATTERY_VOLT, False) + .attr("bmsMaster.minCellVol", const.ATTR_MIN_CELL_VOLT, 0) + .attr("bmsMaster.maxCellVol", const.ATTR_MAX_CELL_VOLT, 0), + MilliVoltSensorEntity(client, "bmsMaster.minCellVol", const.MIN_CELL_VOLT, False), + MilliVoltSensorEntity(client, "bmsMaster.maxCellVol", const.MAX_CELL_VOLT, False), + + # https://github.com/tolwi/hassio-ecoflow-cloud/discussions/87 + InEnergySensorEntity(client, "pd.chgSunPower", const.SOLAR_IN_ENERGY), + InEnergySensorEntity(client, "pd.chgPowerAC", const.CHARGE_AC_ENERGY), + InEnergySensorEntity(client, "pd.chgPowerDC", const.CHARGE_DC_ENERGY), + OutEnergySensorEntity(client, "pd.dsgPowerAC", const.DISCHARGE_AC_ENERGY), + OutEnergySensorEntity(client, "pd.dsgPowerDC", const.DISCHARGE_DC_ENERGY), + + LevelSensorEntity(client, "bmsSlave1.soc", const.SLAVE_BATTERY_LEVEL, False, True) + .attr("bmsSlave1.designCap", const.ATTR_DESIGN_CAPACITY, 0) + .attr("bmsSlave1.fullCap", const.ATTR_FULL_CAPACITY, 0) + .attr("bmsSlave1.remainCap", const.ATTR_REMAIN_CAPACITY, 0), + CapacitySensorEntity(client, "bmsSlave1.designCap", const.SLAVE_DESIGN_CAPACITY, False), + CapacitySensorEntity(client, "bmsSlave1.fullCap", const.SLAVE_FULL_CAPACITY, False), + CapacitySensorEntity(client, "bmsSlave1.remainCap", const.SLAVE_REMAIN_CAPACITY, False), + + TempSensorEntity(client, "bmsSlave1.temp", const.SLAVE_BATTERY_TEMP, False, True) + .attr("bmsSlave1.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0) + .attr("bmsSlave1.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0), + TempSensorEntity(client, "bmsSlave1.minCellTemp", const.SLAVE_MIN_CELL_TEMP, False), + TempSensorEntity(client, "bmsSlave1.maxCellTemp", const.SLAVE_MAX_CELL_TEMP, False), + + MilliVoltSensorEntity(client, "bmsSlave1.vol", const.BATTERY_VOLT, False) + .attr("bmsSlave1.minCellVol", const.ATTR_MIN_CELL_VOLT, 0) + .attr("bmsSlave1.maxCellVol", const.ATTR_MAX_CELL_VOLT, 0), + MilliVoltSensorEntity(client, "bmsSlave1.minCellVol", const.MIN_CELL_VOLT, False), + MilliVoltSensorEntity(client, "bmsSlave1.maxCellVol", const.MAX_CELL_VOLT, False), + + CyclesSensorEntity(client, "bmsSlave1.cycles", const.SLAVE_CYCLES, False, True), + StatusSensorEntity(client), + ] + + def numbers(self, client: EcoflowMQTTClient) -> list[BaseNumberEntity]: + return [ + MaxBatteryLevelEntity(client, "bmsMaster.maxChargeSoc", const.MAX_CHARGE_LEVEL, 30, 100, None), + # MinBatteryLevelEntity(client, "bmsMaster.minDsgSoc", const.MIN_DISCHARGE_LEVEL, 0, 30, None), + ] + + def switches(self, client: EcoflowMQTTClient) -> list[BaseSwitchEntity]: + return [ + BeeperEntity(client, "pd.beepState", const.BEEPER, lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 38, "enabled": value}}), + EnabledEntity(client, "inv.cfgAcEnabled", const.AC_ENABLED, lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 66, "enabled": value}}), + EnabledEntity(client, "pd.carSwitch", const.DC_ENABLED, lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 34, "enabled": value}}), + EnabledEntity(client, "inv.cfgAcXboost", const.XBOOST_ENABLED, lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 66, "xboost": value}}) + + ] + + def selects(self, client: EcoflowMQTTClient) -> list[BaseSelectEntity]: + return [ + + DictSelectEntity(client, "pd.standByMode", const.UNIT_TIMEOUT, const.UNIT_TIMEOUT_OPTIONS, lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 33, "standByMode": value}}), + DictSelectEntity(client, "inv.cfgStandbyMin", const.AC_TIMEOUT, const.AC_TIMEOUT_OPTIONS, lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 153, "standByMins": value}}), + + ] + + def migrate(self, version) -> list[EntityMigration]: + if version == 2: + return [ + EntityMigration("pd.soc", Platform.SENSOR, MigrationAction.REMOVE), + ] + return [] diff --git a/config/custom_components/ecoflow_cloud/devices/river_pro.py b/config/custom_components/ecoflow_cloud/devices/river_pro.py new file mode 100644 index 0000000..65c8d89 --- /dev/null +++ b/config/custom_components/ecoflow_cloud/devices/river_pro.py @@ -0,0 +1,138 @@ +from homeassistant.const import Platform + +from . import const, BaseDevice, EntityMigration, MigrationAction +from ..entities import BaseSensorEntity, BaseNumberEntity, BaseSwitchEntity, BaseSelectEntity +from ..mqtt.ecoflow_mqtt import EcoflowMQTTClient +from ..number import MaxBatteryLevelEntity +from ..select import TimeoutDictSelectEntity +from ..sensor import LevelSensorEntity, WattsSensorEntity, RemainSensorEntity, TempSensorEntity, \ + CyclesSensorEntity, InEnergySensorEntity, InWattsSensorEntity, OutEnergySensorEntity, OutWattsSensorEntity, VoltSensorEntity, InVoltSensorEntity, \ + InAmpSensorEntity, AmpSensorEntity, StatusSensorEntity, MilliVoltSensorEntity, InMilliVoltSensorEntity, \ + OutMilliVoltSensorEntity, CapacitySensorEntity +from ..switch import EnabledEntity, BeeperEntity + + +class RiverPro(BaseDevice): + def sensors(self, client: EcoflowMQTTClient) -> list[BaseSensorEntity]: + return [ + LevelSensorEntity(client, "bmsMaster.soc", const.MAIN_BATTERY_LEVEL) + .attr("bmsMaster.designCap", const.ATTR_DESIGN_CAPACITY, 0) + .attr("bmsMaster.fullCap", const.ATTR_FULL_CAPACITY, 0) + .attr("bmsMaster.remainCap", const.ATTR_REMAIN_CAPACITY, 0), + CapacitySensorEntity(client, "bmsMaster.designCap", const.MAIN_DESIGN_CAPACITY, False), + CapacitySensorEntity(client, "bmsMaster.fullCap", const.MAIN_FULL_CAPACITY, False), + CapacitySensorEntity(client, "bmsMaster.remainCap", const.MAIN_REMAIN_CAPACITY, False), + + WattsSensorEntity(client, "pd.wattsInSum", const.TOTAL_IN_POWER), + WattsSensorEntity(client, "pd.wattsOutSum", const.TOTAL_OUT_POWER), + + InAmpSensorEntity(client, "inv.dcInAmp", const.SOLAR_IN_CURRENT), + InVoltSensorEntity(client, "inv.dcInVol", const.SOLAR_IN_VOLTAGE), + + InWattsSensorEntity(client, "inv.inputWatts", const.AC_IN_POWER), + OutWattsSensorEntity(client, "inv.outputWatts", const.AC_OUT_POWER), + + InMilliVoltSensorEntity(client, "inv.acInVol", const.AC_IN_VOLT), + OutMilliVoltSensorEntity(client, "inv.invOutVol", const.AC_OUT_VOLT), + + OutWattsSensorEntity(client, "pd.carWatts", const.DC_OUT_POWER), + OutWattsSensorEntity(client, "pd.typecWatts", const.TYPEC_OUT_POWER), + # disabled by default because they aren't terribly useful + TempSensorEntity(client, "pd.carTemp", const.DC_CAR_OUT_TEMP, False), + TempSensorEntity(client, "pd.typecTemp", const.USB_C_TEMP, False), + + OutWattsSensorEntity(client, "pd.usb1Watts", const.USB_1_OUT_POWER), + OutWattsSensorEntity(client, "pd.usb2Watts", const.USB_2_OUT_POWER), + OutWattsSensorEntity(client, "pd.usb3Watts", const.USB_3_OUT_POWER), + + RemainSensorEntity(client, "pd.remainTime", const.REMAINING_TIME), + TempSensorEntity(client, "bmsMaster.temp", const.BATTERY_TEMP) + .attr("bmsMaster.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0) + .attr("bmsMaster.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0), + + TempSensorEntity(client, "bmsMaster.minCellTemp", const.MIN_CELL_TEMP, False), + TempSensorEntity(client, "bmsMaster.maxCellTemp", const.MAX_CELL_TEMP, False), + TempSensorEntity(client, "inv.inTemp", const.INV_IN_TEMP), + TempSensorEntity(client, "inv.outTemp", const.INV_OUT_TEMP), + + # https://github.com/tolwi/hassio-ecoflow-cloud/discussions/87 + InEnergySensorEntity(client, "pd.chgSunPower", const.SOLAR_IN_ENERGY), + InEnergySensorEntity(client, "pd.chgPowerAC", const.CHARGE_AC_ENERGY), + InEnergySensorEntity(client, "pd.chgPowerDC", const.CHARGE_DC_ENERGY), + OutEnergySensorEntity(client, "pd.dsgPowerAC", const.DISCHARGE_AC_ENERGY), + OutEnergySensorEntity(client, "pd.dsgPowerDC", const.DISCHARGE_DC_ENERGY), + + AmpSensorEntity(client, "bmsMaster.amp", const.BATTERY_AMP, False), + MilliVoltSensorEntity(client, "bmsMaster.vol", const.BATTERY_VOLT, False) + .attr("bmsMaster.minCellVol", const.ATTR_MIN_CELL_VOLT, 0) + .attr("bmsMaster.maxCellVol", const.ATTR_MAX_CELL_VOLT, 0), + MilliVoltSensorEntity(client, "bmsMaster.minCellVol", const.MIN_CELL_VOLT, False), + MilliVoltSensorEntity(client, "bmsMaster.maxCellVol", const.MAX_CELL_VOLT, False), + + CyclesSensorEntity(client, "bmsMaster.cycles", const.CYCLES), + + + # Optional Slave Batteries + LevelSensorEntity(client, "bmsSlave1.soc", const.SLAVE_BATTERY_LEVEL, False, True) + .attr("bmsSlave1.designCap", const.ATTR_DESIGN_CAPACITY, 0) + .attr("bmsSlave1.fullCap", const.ATTR_FULL_CAPACITY, 0) + .attr("bmsSlave1.remainCap", const.ATTR_REMAIN_CAPACITY, 0), + CapacitySensorEntity(client, "bmsSlave1.designCap", const.SLAVE_DESIGN_CAPACITY, False), + CapacitySensorEntity(client, "bmsSlave1.fullCap", const.SLAVE_FULL_CAPACITY, False), + CapacitySensorEntity(client, "bmsSlave1.remainCap", const.SLAVE_REMAIN_CAPACITY, False), + + CyclesSensorEntity(client, "bmsSlave1.cycles", const.SLAVE_CYCLES, False, True), + TempSensorEntity(client, "bmsSlave1.temp", const.SLAVE_BATTERY_TEMP, False, True) + .attr("bmsSlave1.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0) + .attr("bmsSlave1.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0), + + AmpSensorEntity(client, "bmsSlave1.amp", const.SLAVE_BATTERY_AMP, False), + MilliVoltSensorEntity(client, "bmsSlave1.vol", const.SLAVE_BATTERY_VOLT, False) + .attr("bmsSlave1.minCellVol", const.ATTR_MIN_CELL_VOLT, 0) + .attr("bmsSlave1.maxCellVol", const.ATTR_MAX_CELL_VOLT, 0), + MilliVoltSensorEntity(client, "bmsSlave1.minCellVol", const.SLAVE_MIN_CELL_VOLT, False), + MilliVoltSensorEntity(client, "bmsSlave1.maxCellVol", const.SLAVE_MAX_CELL_VOLT, False), + + + StatusSensorEntity(client), + + ] + + def numbers(self, client: EcoflowMQTTClient) -> list[BaseNumberEntity]: + return [ + MaxBatteryLevelEntity(client, "bmsMaster.maxChargeSoc", const.MAX_CHARGE_LEVEL, 30, 100, + lambda value: {"moduleType": 0, "operateType": "TCP", + "params": {"id": 49, "maxChgSoc": value}}), + # MinBatteryLevelEntity(client, "bmsMaster.minDsgSoc", const.MIN_DISCHARGE_LEVEL, 0, 30, None), + ] + + def switches(self, client: EcoflowMQTTClient) -> list[BaseSwitchEntity]: + return [ + BeeperEntity(client, "pd.beepState", const.BEEPER, lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 38, "enabled": value}}), + EnabledEntity(client, "inv.acAutoOutConfig", const.AC_ALWAYS_ENABLED, + lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 95, "acautooutConfig": value, "minAcoutSoc": 255}}), + EnabledEntity(client, "pd.carSwitch", const.DC_ENABLED, lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 34, "enabled": value}}), + EnabledEntity(client, "inv.cfgAcEnabled", const.AC_ENABLED, lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 66, "enabled": value}}), + EnabledEntity(client, "inv.cfgAcXboost", const.XBOOST_ENABLED, lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 66, "xboost": value}}), + EnabledEntity(client, "inv.cfgAcChgModeFlg", const.AC_SLOW_CHARGE, lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 65, "workMode": value}}), + EnabledEntity(client, "inv.cfgFanMode", const.AUTO_FAN_SPEED, lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 73, "fanMode": value}}) + ] + + def selects(self, client: EcoflowMQTTClient) -> list[BaseSelectEntity]: + return [ + TimeoutDictSelectEntity(client, "pd.standByMode", const.UNIT_TIMEOUT, const.UNIT_TIMEOUT_OPTIONS_LIMITED, lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 33, "standByMode": value}}), + TimeoutDictSelectEntity(client, "pd.carDelayOffMin", const.DC_TIMEOUT, const.DC_TIMEOUT_OPTIONS_LIMITED, lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"cmdSet": 32, "id": 84, "carDelayOffMin": value}}), + TimeoutDictSelectEntity(client, "inv.cfgStandbyMin", const.AC_TIMEOUT, const.AC_TIMEOUT_OPTIONS_LIMITED, lambda value: {"moduleType": 0, "operateType": "TCP", "params": {"id": 153, "standByMins": value}}) + + # lambda is confirmed correct, but pd.lcdOffSec is missing from status + # TimeoutDictSelectEntity(client, "pd.lcdOffSec", const.SCREEN_TIMEOUT, const.SCREEN_TIMEOUT_OPTIONS, + # lambda value: {"moduleType": 0, "operateType": "TCP", + # "params": {"lcdTime": value, "id": 39}}) + ] + + def migrate(self, version) -> list[EntityMigration]: + if version == 2: + return [ + EntityMigration("pd.soc", Platform.SENSOR, MigrationAction.REMOVE), + ] + return [] diff --git a/config/custom_components/ecoflow_cloud/devices/wave2.py b/config/custom_components/ecoflow_cloud/devices/wave2.py new file mode 100644 index 0000000..f5a0e01 --- /dev/null +++ b/config/custom_components/ecoflow_cloud/devices/wave2.py @@ -0,0 +1,91 @@ +from homeassistant.components.switch import SwitchEntity + +from . import const, BaseDevice +from .. import EcoflowMQTTClient +from ..entities import BaseSensorEntity, BaseNumberEntity, BaseSelectEntity +from ..number import SetTempEntity +from ..select import DictSelectEntity +from ..sensor import LevelSensorEntity, RemainSensorEntity, TempSensorEntity, \ + WattsSensorEntity, QuotasStatusSensorEntity, \ + MilliCelsiusSensorEntity, CapacitySensorEntity + + +class Wave2(BaseDevice): + def sensors(self, client: EcoflowMQTTClient) -> list[BaseSensorEntity]: + return [ + # Power and Battery Entities + LevelSensorEntity(client, "bms.soc", const.MAIN_BATTERY_LEVEL) + .attr("bms.remainCap", const.ATTR_REMAIN_CAPACITY, 0), + CapacitySensorEntity(client, "bms.remainCap", const.MAIN_REMAIN_CAPACITY, False), + + TempSensorEntity(client, "bms.tmp", const.BATTERY_TEMP) + .attr("bms.minCellTemp", const.ATTR_MIN_CELL_TEMP, 0) + .attr("bms.maxCellTemp", const.ATTR_MAX_CELL_TEMP, 0), + TempSensorEntity(client, "bms.minCellTmp", const.MIN_CELL_TEMP, False), + TempSensorEntity(client, "bms.maxCellTmp", const.MAX_CELL_TEMP, False), + + RemainSensorEntity(client, "pd.batChgRemain", const.CHARGE_REMAINING_TIME), + RemainSensorEntity(client, "pd.batDsgRemain", const.DISCHARGE_REMAINING_TIME), + + # heat pump + MilliCelsiusSensorEntity(client, "pd.condTemp", "Condensation temperature", False), + MilliCelsiusSensorEntity(client, "pd.heatEnv", "Return air temperature in condensation zone", False), + MilliCelsiusSensorEntity(client, "pd.coolEnv", "Air outlet temperature", False), + MilliCelsiusSensorEntity(client, "pd.evapTemp", "Evaporation temperature", False), + MilliCelsiusSensorEntity(client, "pd.motorOutTemp", "Exhaust temperature", False), + MilliCelsiusSensorEntity(client, "pd.airInTemp", "Evaporation zone return air temperature", False), + + TempSensorEntity(client, "pd.coolTemp", "Air outlet temperature", False), + TempSensorEntity(client, "pd.envTemp", "Ambient temperature", False), + + # power (pd) + WattsSensorEntity(client, "pd.mpptPwr", "PV input power"), + WattsSensorEntity(client, "pd.batPwrOut", "Battery output power"), + WattsSensorEntity(client, "pd.pvPower", "PV charging power"), + WattsSensorEntity(client, "pd.acPwrIn", "AC input power"), + WattsSensorEntity(client, "pd.psdrPower ", "Power supply power"), + WattsSensorEntity(client, "pd.sysPowerWatts", "System power"), + WattsSensorEntity(client, "pd.batPower ", "Battery power"), + + # power (motor) + WattsSensorEntity(client, "motor.power", "Motor operating power"), + + # power (power) + WattsSensorEntity(client, "power.batPwrOut", "Battery output power"), + WattsSensorEntity(client, "power.acPwrI", "AC input power"), + WattsSensorEntity(client, "power.mpptPwr ", "PV input power"), + + QuotasStatusSensorEntity(client) + + ] + + def numbers(self, client: EcoflowMQTTClient) -> list[BaseNumberEntity]: + return [ + SetTempEntity(client, "pd.setTemp", "Set Temperature", 0, 40, + lambda value: {"moduleType": 1, "operateType": "setTemp", + "sn": client.device_sn, + "params": {"setTemp": int(value)}}), + ] + + def selects(self, client: EcoflowMQTTClient) -> list[BaseSelectEntity]: + return [ + DictSelectEntity(client, "pd.fanValue", const.FAN_MODE, const.FAN_MODE_OPTIONS, + lambda value: {"moduleType": 1, "operateType": "fanValue", + "sn": client.device_sn, + "params": {"fanValue": value}}), + DictSelectEntity(client, "pd.mainMode", const.MAIN_MODE, const.MAIN_MODE_OPTIONS, + lambda value: {"moduleType": 1, "operateType": "mainMode", + "sn": client.device_sn, + "params": {"mainMode": value}}), + DictSelectEntity(client, "pd.powerMode", const.REMOTE_MODE, const.REMOTE_MODE_OPTIONS, + lambda value: {"moduleType": 1, "operateType": "powerMode", + "sn": client.device_sn, + "params": {"powerMode": value}}), + DictSelectEntity(client, "pd.subMode", const.POWER_SUB_MODE, const.POWER_SUB_MODE_OPTIONS, + lambda value: {"moduleType": 1, "operateType": "subMode", + "sn": client.device_sn, + "params": {"subMode": value}}), + ] + + def switches(self, client: EcoflowMQTTClient) -> list[SwitchEntity]: + return [] diff --git a/config/custom_components/ecoflow_cloud/diagnostics.py b/config/custom_components/ecoflow_cloud/diagnostics.py new file mode 100644 index 0000000..4afaaf0 --- /dev/null +++ b/config/custom_components/ecoflow_cloud/diagnostics.py @@ -0,0 +1,30 @@ +from datetime import timedelta + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from . import DOMAIN +from .mqtt.ecoflow_mqtt import EcoflowMQTTClient + + +def _to_serializable(x): + t = type(x) + if t is dict: + x = {y: _to_serializable(x[y]) for y in x} + if t is timedelta: + x = x.__str__() + return x + + +async def async_get_config_entry_diagnostics(hass: HomeAssistant, entry: ConfigEntry): + client: EcoflowMQTTClient = hass.data[DOMAIN][entry.entry_id] + values = { + 'device': client.device_type, + 'params': dict(sorted(client.data.params.items())), + 'set': [dict(sorted(k.items())) for k in client.data.set], + 'set_reply': [dict(sorted(k.items())) for k in client.data.set_reply], + 'get': [dict(sorted(k.items())) for k in client.data.get], + 'get_reply': [dict(sorted(k.items())) for k in client.data.get_reply], + 'raw_data': client.data.raw_data, + } + return values diff --git a/config/custom_components/ecoflow_cloud/entities/__init__.py b/config/custom_components/ecoflow_cloud/entities/__init__.py new file mode 100644 index 0000000..ab3da1a --- /dev/null +++ b/config/custom_components/ecoflow_cloud/entities/__init__.py @@ -0,0 +1,151 @@ +from __future__ import annotations +import inspect +from typing import Any, Callable, Optional, OrderedDict, Mapping + +from homeassistant.components.number import NumberEntity +from homeassistant.components.select import SelectEntity +from homeassistant.components.sensor import SensorEntity +from homeassistant.components.switch import SwitchEntity +from homeassistant.components.button import ButtonEntity +from homeassistant.helpers.entity import Entity, EntityCategory + +from ..mqtt.ecoflow_mqtt import EcoflowMQTTClient + + +class EcoFlowAbstractEntity(Entity): + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, client: EcoflowMQTTClient, title: str, key: str): + self._client = client + self._attr_name = title + self._attr_device_info = client.device_info_main + self._attr_unique_id = self.gen_unique_id(client.device_sn, key) + + def send_get_message(self, command: dict): + self._client.send_get_message(command) + + def send_set_message(self, target_dict: dict[str, Any] | None, command: dict): + self._client.send_set_message(target_dict, command) + + @staticmethod + def gen_unique_id(sn: str, key: str): + return 'ecoflow-' + sn + '-' + key.replace('.', '-').replace('_', '-') + + +class EcoFlowDictEntity(EcoFlowAbstractEntity): + + def __init__(self, client: EcoflowMQTTClient, mqtt_key: str, title: str, enabled: bool = True, + auto_enable: bool = False) -> object: + super().__init__(client, title, mqtt_key) + self._mqtt_key = mqtt_key + self._auto_enable = auto_enable + self._attr_entity_registry_enabled_default = enabled + self.__attributes_mapping: dict[str, str] = {} + self.__attrs = OrderedDict[str, Any]() + + def attr(self, mqtt_key: str, title: str, default: Any) -> EcoFlowDictEntity: + self.__attributes_mapping[mqtt_key] = title + self.__attrs[title] = default + return self + + @property + def mqtt_key(self): + return self._mqtt_key + + @property + def auto_enable(self): + return self._auto_enable + + def send_set_message(self, target_value: Any, command: dict): + super().send_set_message({self._mqtt_key: target_value}, command) + + @property + def enabled_default(self): + return self._attr_entity_registry_enabled_default + + async def async_added_to_hass(self): + await super().async_added_to_hass() + d = self._client.data.params_observable().subscribe(self._updated) + self.async_on_remove(d.dispose) + + def _updated(self, data: dict[str, Any]): + # update attributes + for key, title in self.__attributes_mapping.items(): + if key in data: + self.__attrs[title] = data[key] + + # update value + if self._mqtt_key in data: + self._attr_available = True + if self._auto_enable: + self._attr_entity_registry_enabled_default = True + + if self._update_value(data[self._mqtt_key]): + self.schedule_update_ha_state() + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + return self.__attrs + + def _update_value(self, val: Any) -> bool: + return False + + +class EcoFlowBaseCommandEntity(EcoFlowDictEntity): + def __init__(self, client: EcoflowMQTTClient, mqtt_key: str, title: str, + command: Callable[[int, Optional[dict[str, Any]]], dict[str, Any]] | None, + enabled: bool = True, auto_enable: bool = False): + super().__init__(client, mqtt_key, title, enabled, auto_enable) + self._command = command + + def command_dict(self, value: int) -> dict[str, any] | None: + if self._command: + p_count = len(inspect.signature(self._command).parameters) + if p_count == 1: + return self._command(value) + elif p_count == 2: + return self._command(value, self._client.data.params) + else: + return None + + +class BaseNumberEntity(NumberEntity, EcoFlowBaseCommandEntity): + _attr_entity_category = EntityCategory.CONFIG + + def __init__(self, client: EcoflowMQTTClient, mqtt_key: str, title: str, min_value: int, max_value: int, + command: Callable[[int], dict[str, any]] | None, enabled: bool = True, + auto_enable: bool = False): + super().__init__(client, mqtt_key, title, command, enabled, auto_enable) + self._attr_native_max_value = max_value + self._attr_native_min_value = min_value + + def _update_value(self, val: Any) -> bool: + if self._attr_native_value != val: + self._attr_native_value = val + return True + else: + return False + + +class BaseSensorEntity(SensorEntity, EcoFlowDictEntity): + + def _update_value(self, val: Any) -> bool: + if self._attr_native_value != val: + self._attr_native_value = val + return True + else: + return False + + +class BaseSwitchEntity(SwitchEntity, EcoFlowBaseCommandEntity): + pass + + +class BaseSelectEntity(SelectEntity, EcoFlowBaseCommandEntity): + pass + + +class BaseButtonEntity(ButtonEntity, EcoFlowBaseCommandEntity): + pass + diff --git a/config/custom_components/ecoflow_cloud/manifest.json b/config/custom_components/ecoflow_cloud/manifest.json new file mode 100644 index 0000000..3bc99a6 --- /dev/null +++ b/config/custom_components/ecoflow_cloud/manifest.json @@ -0,0 +1,17 @@ +{ + "domain": "ecoflow_cloud", + "name": "Ecoflow-Cloud", + "codeowners": [ + "@tolwi" + ], + "config_flow": true, + "documentation": "https://github.com/tolwi/hassio-ecoflow-cloud", + "iot_class": "cloud_push", + "issue_tracker": "https://github.com/tolwi/hassio-ecoflow-cloud/issues", + "requirements": [ + "paho-mqtt==1.6.1", + "reactivex==4.0.4", + "protobuf>=4.23.0" + ], + "version": "0.13.3" +} \ No newline at end of file diff --git a/config/custom_components/ecoflow_cloud/mqtt/__init__.py b/config/custom_components/ecoflow_cloud/mqtt/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/custom_components/ecoflow_cloud/mqtt/ecoflow_mqtt.py b/config/custom_components/ecoflow_cloud/mqtt/ecoflow_mqtt.py new file mode 100644 index 0000000..b7c7eaf --- /dev/null +++ b/config/custom_components/ecoflow_cloud/mqtt/ecoflow_mqtt.py @@ -0,0 +1,340 @@ +import base64 +import json +import logging +import random +import ssl +import time +import uuid +from datetime import datetime +from typing import Any + +import paho.mqtt.client as mqtt_client +import requests +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, DOMAIN +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.util import utcnow +from reactivex import Subject, Observable + +from .proto import powerstream_pb2 as powerstream, ecopacket_pb2 as ecopacket +from .utils import BoundFifoList +from ..config.const import CONF_DEVICE_TYPE, CONF_DEVICE_ID, OPTS_REFRESH_PERIOD_SEC, EcoflowModel + +_LOGGER = logging.getLogger(__name__) + + +class EcoflowException(Exception): + def __init__(self, *args, **kwargs): + super().__init__(args, kwargs) + + +class EcoflowAuthentication: + def __init__(self, ecoflow_username, ecoflow_password): + self.ecoflow_username = ecoflow_username + self.ecoflow_password = ecoflow_password + self.user_id = None + self.token = None + self.mqtt_url = "mqtt.mqtt.com" + self.mqtt_port = 8883 + self.mqtt_username = None + self.mqtt_password = None + + def authorize(self): + url = "https://api.ecoflow.com/auth/login" + headers = {"lang": "en_US", "content-type": "application/json"} + data = {"email": self.ecoflow_username, + "password": base64.b64encode(self.ecoflow_password.encode()).decode(), + "scene": "IOT_APP", + "userType": "ECOFLOW"} + + _LOGGER.info(f"Login to EcoFlow API {url}") + request = requests.post(url, json=data, headers=headers) + response = self.get_json_response(request) + + try: + self.token = response["data"]["token"] + self.user_id = response["data"]["user"]["userId"] + user_name = response["data"]["user"].get("name", "") + except KeyError as key: + raise EcoflowException(f"Failed to extract key {key} from response: {response}") + + _LOGGER.info(f"Successfully logged in: {user_name}") + + url = "https://api.ecoflow.com/iot-auth/app/certification" + headers = {"lang": "en_US", "authorization": f"Bearer {self.token}"} + data = {"userId": self.user_id} + + _LOGGER.info(f"Requesting IoT MQTT credentials {url}") + request = requests.get(url, data=data, headers=headers) + response = self.get_json_response(request) + + try: + self.mqtt_url = response["data"]["url"] + self.mqtt_port = int(response["data"]["port"]) + self.mqtt_username = response["data"]["certificateAccount"] + self.mqtt_password = response["data"]["certificatePassword"] + except KeyError as key: + raise EcoflowException(f"Failed to extract key {key} from {response}") + + _LOGGER.info(f"Successfully extracted account: {self.mqtt_username}") + + def get_json_response(self, request): + if request.status_code != 200: + raise EcoflowException(f"Got HTTP status code {request.status_code}: {request.text}") + + try: + response = json.loads(request.text) + response_message = response["message"] + except KeyError as key: + raise EcoflowException(f"Failed to extract key {key} from {response}") + except Exception as error: + raise EcoflowException(f"Failed to parse response: {request.text} Error: {error}") + + if response_message.lower() != "success": + raise EcoflowException(f"{response_message}") + + return response + + +class EcoflowDataHolder: + def __init__(self, update_period_sec: int, collect_raw: bool = False): + self.__update_period_sec = update_period_sec + self.__collect_raw = collect_raw + self.set = BoundFifoList[dict[str, Any]]() + self.set_reply = BoundFifoList[dict[str, Any]]() + self.get = BoundFifoList[dict[str, Any]]() + self.get_reply = BoundFifoList[dict[str, Any]]() + self.params = dict[str, Any]() + + self.raw_data = BoundFifoList[dict[str, Any]]() + + self.__params_time = utcnow().replace(year=2000, month=1, day=1, hour=0, minute=0, second=0) + self.__params_broadcast_time = utcnow().replace(year=2000, month=1, day=1, hour=0, minute=0, second=0) + self.__params_observable = Subject[dict[str, Any]]() + + self.__set_reply_observable = Subject[list[dict[str, Any]]]() + self.__get_reply_observable = Subject[list[dict[str, Any]]]() + + def params_observable(self) -> Observable[dict[str, Any]]: + return self.__params_observable + + def get_reply_observable(self) -> Observable[list[dict[str, Any]]]: + return self.__get_reply_observable + + def set_reply_observable(self) -> Observable[list[dict[str, Any]]]: + return self.__set_reply_observable + + def add_set_message(self, msg: dict[str, Any]): + self.set.append(msg) + + def add_set_reply_message(self, msg: dict[str, Any]): + self.set_reply.append(msg) + self.__set_reply_observable.on_next(self.set_reply) + + def add_get_message(self, msg: dict[str, Any]): + self.get.append(msg) + + def add_get_reply_message(self, msg: dict[str, Any]): + self.get_reply.append(msg) + self.__get_reply_observable.on_next(self.get_reply) + + def update_to_target_state(self, target_state: dict[str, Any]): + self.params.update(target_state) + self.__broadcast() + + def update_data(self, raw: dict[str, Any]): + self.__add_raw_data(raw) + # self.__params_time = datetime.fromtimestamp(raw['timestamp'], UTC) + self.__params_time = utcnow() + self.params['timestamp'] = raw['timestamp'] + self.params.update(raw['params']) + + if (utcnow() - self.__params_broadcast_time).total_seconds() > self.__update_period_sec: + self.__broadcast() + + def __broadcast(self): + self.__params_broadcast_time = utcnow() + self.__params_observable.on_next(self.params) + + def __add_raw_data(self, raw: dict[str, Any]): + if self.__collect_raw: + self.raw_data.append(raw) + + def params_time(self) -> datetime: + return self.__params_time + + +class EcoflowMQTTClient: + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry, auth: EcoflowAuthentication): + + self.auth = auth + self.config_entry = entry + self.device_type = entry.data[CONF_DEVICE_TYPE] + self.device_sn = entry.data[CONF_DEVICE_ID] + + self._data_topic = f"/app/device/property/{self.device_sn}" + self._set_topic = f"/app/{auth.user_id}/{self.device_sn}/thing/property/set" + self._set_reply_topic = f"/app/{auth.user_id}/{self.device_sn}/thing/property/set_reply" + self._get_topic = f"/app/{auth.user_id}/{self.device_sn}/thing/property/get" + self._get_reply_topic = f"/app/{auth.user_id}/{self.device_sn}/thing/property/get_reply" + + self.data = EcoflowDataHolder(entry.options.get(OPTS_REFRESH_PERIOD_SEC), self.device_type == "DIAGNOSTIC") + + self.device_info_main = DeviceInfo( + identifiers={(DOMAIN, self.device_sn)}, + manufacturer="EcoFlow", + name=entry.title, + model=self.device_type, + ) + + self.client = mqtt_client.Client(client_id=f'ANDROID_-{str(uuid.uuid4()).upper()}_{auth.user_id}', + clean_session=True, reconnect_on_failure=True) + self.client.username_pw_set(self.auth.mqtt_username, self.auth.mqtt_password) + self.client.tls_set(certfile=None, keyfile=None, cert_reqs=ssl.CERT_REQUIRED) + self.client.tls_insecure_set(False) + self.client.on_connect = self.on_connect + self.client.on_disconnect = self.on_disconnect + if self.device_type == EcoflowModel.POWERSTREAM.name: + self.client.on_message = self.on_bytes_message + else: + self.client.on_message = self.on_json_message + + _LOGGER.info(f"Connecting to MQTT Broker {self.auth.mqtt_url}:{self.auth.mqtt_port}") + self.client.connect(self.auth.mqtt_url, self.auth.mqtt_port, 30) + self.client.loop_start() + + def is_connected(self): + return self.client.is_connected() + + def reconnect(self) -> bool: + try: + _LOGGER.info(f"Re-connecting to MQTT Broker {self.auth.mqtt_url}:{self.auth.mqtt_port}") + self.client.loop_stop(True) + self.client.reconnect() + self.client.loop_start() + return True + except Exception as e: + _LOGGER.error(e) + return False + + def on_connect(self, client, userdata, flags, rc): + match rc: + case 0: + self.client.subscribe([(self._data_topic, 1), + (self._set_topic, 1), (self._set_reply_topic, 1), + (self._get_topic, 1), (self._get_reply_topic, 1)]) + _LOGGER.info(f"Subscribed to MQTT topic {self._data_topic}") + case -1: + _LOGGER.error("Failed to connect to MQTT: connection timed out") + case 1: + _LOGGER.error("Failed to connect to MQTT: incorrect protocol version") + case 2: + _LOGGER.error("Failed to connect to MQTT: invalid client identifier") + case 3: + _LOGGER.error("Failed to connect to MQTT: server unavailable") + case 4: + _LOGGER.error("Failed to connect to MQTT: bad username or password") + case 5: + _LOGGER.error("Failed to connect to MQTT: not authorised") + case _: + _LOGGER.error(f"Failed to connect to MQTT: another error occured: {rc}") + + return client + + def on_disconnect(self, client, userdata, rc): + if rc != 0: + _LOGGER.error(f"Unexpected MQTT disconnection: {rc}. Will auto-reconnect") + time.sleep(5) + # self.client.reconnect() ?? + + def on_json_message(self, client, userdata, message): + try: + payload = message.payload.decode("utf-8", errors='ignore') + raw = json.loads(payload) + + if message.topic == self._data_topic: + self.data.update_data(raw) + elif message.topic == self._set_topic: + self.data.add_set_message(raw) + elif message.topic == self._set_reply_topic: + self.data.add_set_reply_message(raw) + elif message.topic == self._get_topic: + self.data.add_get_message(raw) + elif message.topic == self._get_reply_topic: + self.data.add_get_reply_message(raw) + except UnicodeDecodeError as error: + _LOGGER.error(f"UnicodeDecodeError: {error}. Ignoring message and waiting for the next one.") + + def on_bytes_message(self, client, userdata, message): + try: + payload = message.payload + + while True: + packet = ecopacket.SendHeaderMsg() + packet.ParseFromString(payload) + + _LOGGER.debug("cmd id %u payload \"%s\"", packet.msg.cmd_id, payload.hex()) + + if packet.msg.cmd_id != 1: + _LOGGER.info("Unsupported EcoPacket cmd id %u", packet.msg.cmd_id) + + else: + heartbeat = powerstream.InverterHeartbeat() + heartbeat.ParseFromString(packet.msg.pdata) + + raw = {"params": {}} + + for descriptor in heartbeat.DESCRIPTOR.fields: + if not heartbeat.HasField(descriptor.name): + continue + + raw["params"][descriptor.name] = getattr(heartbeat, descriptor.name) + + _LOGGER.info("Found %u fields", len(raw["params"])) + + raw["timestamp"] = utcnow() + + self.data.update_data(raw) + + if packet.ByteSize() >= len(payload): + break + + _LOGGER.info("Found another frame in payload") + + packetLength = len(payload) - packet.ByteSize() + payload = payload[:packetLength] + + except Exception as error: + _LOGGER.error(error) + _LOGGER.info(message.payload.hex()) + + message_id = 999900000 + random.randint(10000, 99999) + + def __prepare_payload(self, command: dict): + self.message_id += 1 + payload = {"from": "HomeAssistant", + "id": f"{self.message_id}", + "version": "1.0"} + payload.update(command) + return payload + + def __send(self, topic: str, message: str): + try: + info = self.client.publish(topic, message, 1) + _LOGGER.debug("Sending " + message + " :" + str(info) + "(" + str(info.is_published()) + ")") + except RuntimeError as error: + _LOGGER.error(error) + + def send_get_message(self, command: dict): + payload = self.__prepare_payload(command) + self.__send(self._get_topic, json.dumps(payload)) + + def send_set_message(self, mqtt_state: dict[str, Any], command: dict): + self.data.update_to_target_state(mqtt_state) + payload = self.__prepare_payload(command) + self.__send(self._set_topic, json.dumps(payload)) + + def stop(self): + self.client.loop_stop() + self.client.disconnect() diff --git a/config/custom_components/ecoflow_cloud/mqtt/proto/__init__.py b/config/custom_components/ecoflow_cloud/mqtt/proto/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/custom_components/ecoflow_cloud/mqtt/proto/ecopacket.proto b/config/custom_components/ecoflow_cloud/mqtt/proto/ecopacket.proto new file mode 100644 index 0000000..b7f6074 --- /dev/null +++ b/config/custom_components/ecoflow_cloud/mqtt/proto/ecopacket.proto @@ -0,0 +1,57 @@ +syntax = "proto3"; + +message Header +{ + optional bytes pdata = 1; + optional int32 src = 2; + optional int32 dest = 3; + optional int32 d_src= 4; + optional int32 d_dest = 5; + optional int32 enc_type = 6; + optional int32 check_type = 7; + optional int32 cmd_func = 8; + optional int32 cmd_id = 9; + optional int32 data_len = 10; + optional int32 need_ack = 11; + optional int32 is_ack = 12; + optional int32 seq = 14; + optional int32 product_id = 15; + optional int32 version = 16; + optional int32 payload_ver = 17; + optional int32 time_snap = 18; + optional int32 is_rw_cmd = 19; + optional int32 is_queue = 20; + optional int32 ack_type= 21; + optional string code = 22; + optional string from = 23; + optional string module_sn = 24; + optional string device_sn = 25; +} + +message SendHeaderMsg +{ + optional Header msg = 1; +} + +message SendMsgHart +{ + optional int32 link_id = 1; + optional int32 src = 2; + optional int32 dest = 3; + optional int32 d_src = 4; + optional int32 d_dest = 5; + optional int32 enc_type = 6; + optional int32 check_type = 7; + optional int32 cmd_func = 8; + optional int32 cmd_id = 9; + optional int32 data_len = 10; + optional int32 need_ack = 11; + optional int32 is_ack = 12; + optional int32 ack_type = 13; + optional int32 seq = 14; + optional int32 time_snap = 15; + optional int32 is_rw_cmd = 16; + optional int32 is_queue = 17; + optional int32 product_id = 18; + optional int32 version = 19; +} diff --git a/config/custom_components/ecoflow_cloud/mqtt/proto/ecopacket_pb2.py b/config/custom_components/ecoflow_cloud/mqtt/proto/ecopacket_pb2.py new file mode 100644 index 0000000..db72f73 --- /dev/null +++ b/config/custom_components/ecoflow_cloud/mqtt/proto/ecopacket_pb2.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: ecopacket.proto +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0f\x65\x63opacket.proto\"\xb8\x06\n\x06Header\x12\x12\n\x05pdata\x18\x01 \x01(\x0cH\x00\x88\x01\x01\x12\x10\n\x03src\x18\x02 \x01(\x05H\x01\x88\x01\x01\x12\x11\n\x04\x64\x65st\x18\x03 \x01(\x05H\x02\x88\x01\x01\x12\x12\n\x05\x64_src\x18\x04 \x01(\x05H\x03\x88\x01\x01\x12\x13\n\x06\x64_dest\x18\x05 \x01(\x05H\x04\x88\x01\x01\x12\x15\n\x08\x65nc_type\x18\x06 \x01(\x05H\x05\x88\x01\x01\x12\x17\n\ncheck_type\x18\x07 \x01(\x05H\x06\x88\x01\x01\x12\x15\n\x08\x63md_func\x18\x08 \x01(\x05H\x07\x88\x01\x01\x12\x13\n\x06\x63md_id\x18\t \x01(\x05H\x08\x88\x01\x01\x12\x15\n\x08\x64\x61ta_len\x18\n \x01(\x05H\t\x88\x01\x01\x12\x15\n\x08need_ack\x18\x0b \x01(\x05H\n\x88\x01\x01\x12\x13\n\x06is_ack\x18\x0c \x01(\x05H\x0b\x88\x01\x01\x12\x10\n\x03seq\x18\x0e \x01(\x05H\x0c\x88\x01\x01\x12\x17\n\nproduct_id\x18\x0f \x01(\x05H\r\x88\x01\x01\x12\x14\n\x07version\x18\x10 \x01(\x05H\x0e\x88\x01\x01\x12\x18\n\x0bpayload_ver\x18\x11 \x01(\x05H\x0f\x88\x01\x01\x12\x16\n\ttime_snap\x18\x12 \x01(\x05H\x10\x88\x01\x01\x12\x16\n\tis_rw_cmd\x18\x13 \x01(\x05H\x11\x88\x01\x01\x12\x15\n\x08is_queue\x18\x14 \x01(\x05H\x12\x88\x01\x01\x12\x15\n\x08\x61\x63k_type\x18\x15 \x01(\x05H\x13\x88\x01\x01\x12\x11\n\x04\x63ode\x18\x16 \x01(\tH\x14\x88\x01\x01\x12\x11\n\x04\x66rom\x18\x17 \x01(\tH\x15\x88\x01\x01\x12\x16\n\tmodule_sn\x18\x18 \x01(\tH\x16\x88\x01\x01\x12\x16\n\tdevice_sn\x18\x19 \x01(\tH\x17\x88\x01\x01\x42\x08\n\x06_pdataB\x06\n\x04_srcB\x07\n\x05_destB\x08\n\x06_d_srcB\t\n\x07_d_destB\x0b\n\t_enc_typeB\r\n\x0b_check_typeB\x0b\n\t_cmd_funcB\t\n\x07_cmd_idB\x0b\n\t_data_lenB\x0b\n\t_need_ackB\t\n\x07_is_ackB\x06\n\x04_seqB\r\n\x0b_product_idB\n\n\x08_versionB\x0e\n\x0c_payload_verB\x0c\n\n_time_snapB\x0c\n\n_is_rw_cmdB\x0b\n\t_is_queueB\x0b\n\t_ack_typeB\x07\n\x05_codeB\x07\n\x05_fromB\x0c\n\n_module_snB\x0c\n\n_device_sn\"2\n\rSendHeaderMsg\x12\x19\n\x03msg\x18\x01 \x01(\x0b\x32\x07.HeaderH\x00\x88\x01\x01\x42\x06\n\x04_msg\"\x93\x05\n\x0bSendMsgHart\x12\x14\n\x07link_id\x18\x01 \x01(\x05H\x00\x88\x01\x01\x12\x10\n\x03src\x18\x02 \x01(\x05H\x01\x88\x01\x01\x12\x11\n\x04\x64\x65st\x18\x03 \x01(\x05H\x02\x88\x01\x01\x12\x12\n\x05\x64_src\x18\x04 \x01(\x05H\x03\x88\x01\x01\x12\x13\n\x06\x64_dest\x18\x05 \x01(\x05H\x04\x88\x01\x01\x12\x15\n\x08\x65nc_type\x18\x06 \x01(\x05H\x05\x88\x01\x01\x12\x17\n\ncheck_type\x18\x07 \x01(\x05H\x06\x88\x01\x01\x12\x15\n\x08\x63md_func\x18\x08 \x01(\x05H\x07\x88\x01\x01\x12\x13\n\x06\x63md_id\x18\t \x01(\x05H\x08\x88\x01\x01\x12\x15\n\x08\x64\x61ta_len\x18\n \x01(\x05H\t\x88\x01\x01\x12\x15\n\x08need_ack\x18\x0b \x01(\x05H\n\x88\x01\x01\x12\x13\n\x06is_ack\x18\x0c \x01(\x05H\x0b\x88\x01\x01\x12\x15\n\x08\x61\x63k_type\x18\r \x01(\x05H\x0c\x88\x01\x01\x12\x10\n\x03seq\x18\x0e \x01(\x05H\r\x88\x01\x01\x12\x16\n\ttime_snap\x18\x0f \x01(\x05H\x0e\x88\x01\x01\x12\x16\n\tis_rw_cmd\x18\x10 \x01(\x05H\x0f\x88\x01\x01\x12\x15\n\x08is_queue\x18\x11 \x01(\x05H\x10\x88\x01\x01\x12\x17\n\nproduct_id\x18\x12 \x01(\x05H\x11\x88\x01\x01\x12\x14\n\x07version\x18\x13 \x01(\x05H\x12\x88\x01\x01\x42\n\n\x08_link_idB\x06\n\x04_srcB\x07\n\x05_destB\x08\n\x06_d_srcB\t\n\x07_d_destB\x0b\n\t_enc_typeB\r\n\x0b_check_typeB\x0b\n\t_cmd_funcB\t\n\x07_cmd_idB\x0b\n\t_data_lenB\x0b\n\t_need_ackB\t\n\x07_is_ackB\x0b\n\t_ack_typeB\x06\n\x04_seqB\x0c\n\n_time_snapB\x0c\n\n_is_rw_cmdB\x0b\n\t_is_queueB\r\n\x0b_product_idB\n\n\x08_versionb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'ecopacket_pb2', _globals) +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + _globals['_HEADER']._serialized_start=20 + _globals['_HEADER']._serialized_end=844 + _globals['_SENDHEADERMSG']._serialized_start=846 + _globals['_SENDHEADERMSG']._serialized_end=896 + _globals['_SENDMSGHART']._serialized_start=899 + _globals['_SENDMSGHART']._serialized_end=1558 +# @@protoc_insertion_point(module_scope) diff --git a/config/custom_components/ecoflow_cloud/mqtt/proto/platform.proto b/config/custom_components/ecoflow_cloud/mqtt/proto/platform.proto new file mode 100644 index 0000000..da23c9b --- /dev/null +++ b/config/custom_components/ecoflow_cloud/mqtt/proto/platform.proto @@ -0,0 +1,105 @@ +syntax = "proto3"; + +message EnergyItem +{ + optional uint32 timestamp = 1; + optional uint32 watth_type = 2; + repeated uint32 watth = 3; +} + +message EnergyTotalReport +{ + optional uint32 watth_seq = 1; + optional EnergyItem watth_item = 2; +} + +message BatchEnergyTotalReport +{ + optional uint32 watth_seq = 1; + repeated EnergyItem watth_item = 2; +} + +message EnergyTotalReportAck +{ + optional uint32 result = 1; + optional uint32 watth_seq = 2; + optional uint32 watth_type = 3; +} + +message EventRecordItem +{ + optional uint32 timestamp = 1; + optional uint32 sys_ms = 2; + optional uint32 event_no = 3; + repeated float event_detail = 4; +} + +message EventRecordReport +{ + optional uint32 event_ver = 1; + optional uint32 event_seq = 2; + repeated EventRecordItem event_item = 3; +} + +message EventInfoReportAck +{ + optional uint32 result = 1; + optional uint32 event_seq = 2; + optional uint32 event_item_num =3; +} + +message ProductNameSet +{ + optional string name = 1; +} + +message ProductNameSetAck +{ + optional uint32 result = 1; +} + +message ProductNameGet { } + +message ProductNameGetAck +{ + optional string name = 3; +} + +message RTCTimeGet { } + +message RTCTimeGetAck +{ + optional uint32 timestamp = 1; + optional int32 timezone = 2; +} + +message RTCTimeSet +{ + optional uint32 timestamp = 1; + optional int32 timezone = 2; +} + +message RTCTimeSetAck +{ + optional uint32 result = 1; +} + +message country_town_message +{ + optional uint32 country = 1; + optional uint32 town = 2; +} + +enum PlCmdSets +{ + PL_NONE_CMD_SETS = 0; + PL_BASIC_CMD_SETS = 1; + PL_EXT_CMD_SETS = 254; +} + +enum PlCmdId +{ + PL_CMD_ID_NONE = 0; + PL_CMD_ID_XLOG = 16; + PL_CMD_ID_WATTH = 32; +} diff --git a/config/custom_components/ecoflow_cloud/mqtt/proto/platform_pb2.py b/config/custom_components/ecoflow_cloud/mqtt/proto/platform_pb2.py new file mode 100644 index 0000000..bea1db9 --- /dev/null +++ b/config/custom_components/ecoflow_cloud/mqtt/proto/platform_pb2.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: platform.proto +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0eplatform.proto\"i\n\nEnergyItem\x12\x16\n\ttimestamp\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x17\n\nwatth_type\x18\x02 \x01(\rH\x01\x88\x01\x01\x12\r\n\x05watth\x18\x03 \x03(\rB\x0c\n\n_timestampB\r\n\x0b_watth_type\"n\n\x11\x45nergyTotalReport\x12\x16\n\twatth_seq\x18\x01 \x01(\rH\x00\x88\x01\x01\x12$\n\nwatth_item\x18\x02 \x01(\x0b\x32\x0b.EnergyItemH\x01\x88\x01\x01\x42\x0c\n\n_watth_seqB\r\n\x0b_watth_item\"_\n\x16\x42\x61tchEnergyTotalReport\x12\x16\n\twatth_seq\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x1f\n\nwatth_item\x18\x02 \x03(\x0b\x32\x0b.EnergyItemB\x0c\n\n_watth_seq\"\x84\x01\n\x14\x45nergyTotalReportAck\x12\x13\n\x06result\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x16\n\twatth_seq\x18\x02 \x01(\rH\x01\x88\x01\x01\x12\x17\n\nwatth_type\x18\x03 \x01(\rH\x02\x88\x01\x01\x42\t\n\x07_resultB\x0c\n\n_watth_seqB\r\n\x0b_watth_type\"\x91\x01\n\x0f\x45ventRecordItem\x12\x16\n\ttimestamp\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x13\n\x06sys_ms\x18\x02 \x01(\rH\x01\x88\x01\x01\x12\x15\n\x08\x65vent_no\x18\x03 \x01(\rH\x02\x88\x01\x01\x12\x14\n\x0c\x65vent_detail\x18\x04 \x03(\x02\x42\x0c\n\n_timestampB\t\n\x07_sys_msB\x0b\n\t_event_no\"\x85\x01\n\x11\x45ventRecordReport\x12\x16\n\tevent_ver\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x16\n\tevent_seq\x18\x02 \x01(\rH\x01\x88\x01\x01\x12$\n\nevent_item\x18\x03 \x03(\x0b\x32\x10.EventRecordItemB\x0c\n\n_event_verB\x0c\n\n_event_seq\"\x8a\x01\n\x12\x45ventInfoReportAck\x12\x13\n\x06result\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x16\n\tevent_seq\x18\x02 \x01(\rH\x01\x88\x01\x01\x12\x1b\n\x0e\x65vent_item_num\x18\x03 \x01(\rH\x02\x88\x01\x01\x42\t\n\x07_resultB\x0c\n\n_event_seqB\x11\n\x0f_event_item_num\",\n\x0eProductNameSet\x12\x11\n\x04name\x18\x01 \x01(\tH\x00\x88\x01\x01\x42\x07\n\x05_name\"3\n\x11ProductNameSetAck\x12\x13\n\x06result\x18\x01 \x01(\rH\x00\x88\x01\x01\x42\t\n\x07_result\"\x10\n\x0eProductNameGet\"/\n\x11ProductNameGetAck\x12\x11\n\x04name\x18\x03 \x01(\tH\x00\x88\x01\x01\x42\x07\n\x05_name\"\x0c\n\nRTCTimeGet\"Y\n\rRTCTimeGetAck\x12\x16\n\ttimestamp\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x15\n\x08timezone\x18\x02 \x01(\x05H\x01\x88\x01\x01\x42\x0c\n\n_timestampB\x0b\n\t_timezone\"V\n\nRTCTimeSet\x12\x16\n\ttimestamp\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x15\n\x08timezone\x18\x02 \x01(\x05H\x01\x88\x01\x01\x42\x0c\n\n_timestampB\x0b\n\t_timezone\"/\n\rRTCTimeSetAck\x12\x13\n\x06result\x18\x01 \x01(\rH\x00\x88\x01\x01\x42\t\n\x07_result\"T\n\x14\x63ountry_town_message\x12\x14\n\x07\x63ountry\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x11\n\x04town\x18\x02 \x01(\rH\x01\x88\x01\x01\x42\n\n\x08_countryB\x07\n\x05_town*N\n\tPlCmdSets\x12\x14\n\x10PL_NONE_CMD_SETS\x10\x00\x12\x15\n\x11PL_BASIC_CMD_SETS\x10\x01\x12\x14\n\x0fPL_EXT_CMD_SETS\x10\xfe\x01*F\n\x07PlCmdId\x12\x12\n\x0ePL_CMD_ID_NONE\x10\x00\x12\x12\n\x0ePL_CMD_ID_XLOG\x10\x10\x12\x13\n\x0fPL_CMD_ID_WATTH\x10 b\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'platform_pb2', _globals) +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + _globals['_PLCMDSETS']._serialized_start=1388 + _globals['_PLCMDSETS']._serialized_end=1466 + _globals['_PLCMDID']._serialized_start=1468 + _globals['_PLCMDID']._serialized_end=1538 + _globals['_ENERGYITEM']._serialized_start=18 + _globals['_ENERGYITEM']._serialized_end=123 + _globals['_ENERGYTOTALREPORT']._serialized_start=125 + _globals['_ENERGYTOTALREPORT']._serialized_end=235 + _globals['_BATCHENERGYTOTALREPORT']._serialized_start=237 + _globals['_BATCHENERGYTOTALREPORT']._serialized_end=332 + _globals['_ENERGYTOTALREPORTACK']._serialized_start=335 + _globals['_ENERGYTOTALREPORTACK']._serialized_end=467 + _globals['_EVENTRECORDITEM']._serialized_start=470 + _globals['_EVENTRECORDITEM']._serialized_end=615 + _globals['_EVENTRECORDREPORT']._serialized_start=618 + _globals['_EVENTRECORDREPORT']._serialized_end=751 + _globals['_EVENTINFOREPORTACK']._serialized_start=754 + _globals['_EVENTINFOREPORTACK']._serialized_end=892 + _globals['_PRODUCTNAMESET']._serialized_start=894 + _globals['_PRODUCTNAMESET']._serialized_end=938 + _globals['_PRODUCTNAMESETACK']._serialized_start=940 + _globals['_PRODUCTNAMESETACK']._serialized_end=991 + _globals['_PRODUCTNAMEGET']._serialized_start=993 + _globals['_PRODUCTNAMEGET']._serialized_end=1009 + _globals['_PRODUCTNAMEGETACK']._serialized_start=1011 + _globals['_PRODUCTNAMEGETACK']._serialized_end=1058 + _globals['_RTCTIMEGET']._serialized_start=1060 + _globals['_RTCTIMEGET']._serialized_end=1072 + _globals['_RTCTIMEGETACK']._serialized_start=1074 + _globals['_RTCTIMEGETACK']._serialized_end=1163 + _globals['_RTCTIMESET']._serialized_start=1165 + _globals['_RTCTIMESET']._serialized_end=1251 + _globals['_RTCTIMESETACK']._serialized_start=1253 + _globals['_RTCTIMESETACK']._serialized_end=1300 + _globals['_COUNTRY_TOWN_MESSAGE']._serialized_start=1302 + _globals['_COUNTRY_TOWN_MESSAGE']._serialized_end=1386 +# @@protoc_insertion_point(module_scope) diff --git a/config/custom_components/ecoflow_cloud/mqtt/proto/powerstream.proto b/config/custom_components/ecoflow_cloud/mqtt/proto/powerstream.proto new file mode 100644 index 0000000..aa3703f --- /dev/null +++ b/config/custom_components/ecoflow_cloud/mqtt/proto/powerstream.proto @@ -0,0 +1,127 @@ +syntax = "proto3"; + +message InverterHeartbeat { + optional uint32 inv_error_code = 1; + optional uint32 inv_warning_code = 3; + optional uint32 pv1_error_code = 2; + optional uint32 pv1_warning_code = 4; + optional uint32 pv2_error_code = 5; + optional uint32 pv2_warning_code = 6; + optional uint32 bat_error_code = 7; + optional uint32 bat_warning_code = 8; + optional uint32 llc_error_code = 9; + optional uint32 llc_warning_code = 10; + optional uint32 pv1_status = 11; + optional uint32 pv2_status = 12; + optional uint32 bat_status = 13; + optional uint32 llc_status = 14; + optional uint32 inv_status = 15; + optional int32 pv1_input_volt = 16; + optional int32 pv1_op_volt = 17; + optional int32 pv1_input_cur = 18; + optional int32 pv1_input_watts = 19; + optional int32 pv1_temp = 20; + optional int32 pv2_input_volt = 21; + optional int32 pv2_op_volt = 22; + optional int32 pv2_input_cur = 23; + optional int32 pv2_input_watts = 24; + optional int32 pv2_temp = 25; + optional int32 bat_input_volt = 26; + optional int32 bat_op_volt = 27; + optional int32 bat_input_cur = 28; + optional int32 bat_input_watts = 29; + optional int32 bat_temp = 30; + optional uint32 bat_soc = 31; + optional int32 llc_input_volt = 32; + optional int32 llc_op_volt = 33; + optional int32 llc_temp = 34; + optional int32 inv_input_volt = 35; + optional int32 inv_op_volt = 36; + optional int32 inv_output_cur = 37; + optional int32 inv_output_watts = 38; + optional int32 inv_temp = 39; + optional int32 inv_freq = 40; + optional int32 inv_dc_cur = 41; + optional int32 bp_type = 42; + optional int32 inv_relay_status = 43; + optional int32 pv1_relay_status = 44; + optional int32 pv2_relay_status = 45; + optional uint32 install_country = 46; + optional uint32 install_town = 47; + optional uint32 permanent_watts = 48; + optional uint32 dynamic_watts = 49; + optional uint32 supply_priority = 50; + optional uint32 lower_limit = 51; + optional uint32 upper_limit = 52; + optional uint32 inv_on_off = 53; + optional uint32 wireless_error_code = 54; + optional uint32 wireless_warning_code = 55; + optional uint32 inv_brightness = 56; + optional uint32 heartbeat_frequency = 57; + optional uint32 rated_power = 58; + optional uint32 battery_charge_remain = 59; + optional uint32 battery_discharge_remain = 60; +} + +message PermanentWattsPack +{ + optional uint32 permanent_watts = 1; +} + +message SupplyPriorityPack +{ + optional uint32 supply_priority = 1; +} + +message BatLowerPack +{ + optional int32 lower_limit = 1; +} + +message BatUpperPack +{ + optional int32 upper_limit = 1; +} + +message BrightnessPack +{ + optional int32 brightness = 1; +} + +message PowerItem +{ + optional uint32 timestamp = 1; + optional sint32 timezone = 2; + optional uint32 inv_to_grid_power = 3; + optional uint32 inv_to_plug_power = 4; + optional int32 battery_power = 5; + optional uint32 pv1_output_power = 6; + optional uint32 pv2_output_power = 7; +} + +message PowerPack +{ + optional uint32 sys_seq = 1; + repeated PowerItem sys_power_stream = 2; +} + +message PowerAckPack +{ + optional uint32 sys_seq = 1; +} + +message NodeMassage +{ + optional string sn = 1; + optional bytes mac = 2; +} + +message MeshChildNodeInfo +{ + optional uint32 topology_type = 1; + optional uint32 mesh_protocol = 2; + optional uint32 max_sub_device_num = 3; + optional bytes parent_mac_id = 4; + optional bytes mesh_id = 5; + repeated NodeMassage sub_device_list = 6; +} diff --git a/config/custom_components/ecoflow_cloud/mqtt/proto/powerstream_pb2.py b/config/custom_components/ecoflow_cloud/mqtt/proto/powerstream_pb2.py new file mode 100644 index 0000000..cc8fecf --- /dev/null +++ b/config/custom_components/ecoflow_cloud/mqtt/proto/powerstream_pb2.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: powerstream.proto +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x11powerstream.proto\"\xef\x15\n\x11InverterHeartbeat\x12\x1b\n\x0einv_error_code\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x1d\n\x10inv_warning_code\x18\x03 \x01(\rH\x01\x88\x01\x01\x12\x1b\n\x0epv1_error_code\x18\x02 \x01(\rH\x02\x88\x01\x01\x12\x1d\n\x10pv1_warning_code\x18\x04 \x01(\rH\x03\x88\x01\x01\x12\x1b\n\x0epv2_error_code\x18\x05 \x01(\rH\x04\x88\x01\x01\x12\x1d\n\x10pv2_warning_code\x18\x06 \x01(\rH\x05\x88\x01\x01\x12\x1b\n\x0e\x62\x61t_error_code\x18\x07 \x01(\rH\x06\x88\x01\x01\x12\x1d\n\x10\x62\x61t_warning_code\x18\x08 \x01(\rH\x07\x88\x01\x01\x12\x1b\n\x0ellc_error_code\x18\t \x01(\rH\x08\x88\x01\x01\x12\x1d\n\x10llc_warning_code\x18\n \x01(\rH\t\x88\x01\x01\x12\x17\n\npv1_status\x18\x0b \x01(\rH\n\x88\x01\x01\x12\x17\n\npv2_status\x18\x0c \x01(\rH\x0b\x88\x01\x01\x12\x17\n\nbat_status\x18\r \x01(\rH\x0c\x88\x01\x01\x12\x17\n\nllc_status\x18\x0e \x01(\rH\r\x88\x01\x01\x12\x17\n\ninv_status\x18\x0f \x01(\rH\x0e\x88\x01\x01\x12\x1b\n\x0epv1_input_volt\x18\x10 \x01(\x05H\x0f\x88\x01\x01\x12\x18\n\x0bpv1_op_volt\x18\x11 \x01(\x05H\x10\x88\x01\x01\x12\x1a\n\rpv1_input_cur\x18\x12 \x01(\x05H\x11\x88\x01\x01\x12\x1c\n\x0fpv1_input_watts\x18\x13 \x01(\x05H\x12\x88\x01\x01\x12\x15\n\x08pv1_temp\x18\x14 \x01(\x05H\x13\x88\x01\x01\x12\x1b\n\x0epv2_input_volt\x18\x15 \x01(\x05H\x14\x88\x01\x01\x12\x18\n\x0bpv2_op_volt\x18\x16 \x01(\x05H\x15\x88\x01\x01\x12\x1a\n\rpv2_input_cur\x18\x17 \x01(\x05H\x16\x88\x01\x01\x12\x1c\n\x0fpv2_input_watts\x18\x18 \x01(\x05H\x17\x88\x01\x01\x12\x15\n\x08pv2_temp\x18\x19 \x01(\x05H\x18\x88\x01\x01\x12\x1b\n\x0e\x62\x61t_input_volt\x18\x1a \x01(\x05H\x19\x88\x01\x01\x12\x18\n\x0b\x62\x61t_op_volt\x18\x1b \x01(\x05H\x1a\x88\x01\x01\x12\x1a\n\rbat_input_cur\x18\x1c \x01(\x05H\x1b\x88\x01\x01\x12\x1c\n\x0f\x62\x61t_input_watts\x18\x1d \x01(\x05H\x1c\x88\x01\x01\x12\x15\n\x08\x62\x61t_temp\x18\x1e \x01(\x05H\x1d\x88\x01\x01\x12\x14\n\x07\x62\x61t_soc\x18\x1f \x01(\rH\x1e\x88\x01\x01\x12\x1b\n\x0ellc_input_volt\x18 \x01(\x05H\x1f\x88\x01\x01\x12\x18\n\x0bllc_op_volt\x18! \x01(\x05H \x88\x01\x01\x12\x15\n\x08llc_temp\x18\" \x01(\x05H!\x88\x01\x01\x12\x1b\n\x0einv_input_volt\x18# \x01(\x05H\"\x88\x01\x01\x12\x18\n\x0binv_op_volt\x18$ \x01(\x05H#\x88\x01\x01\x12\x1b\n\x0einv_output_cur\x18% \x01(\x05H$\x88\x01\x01\x12\x1d\n\x10inv_output_watts\x18& \x01(\x05H%\x88\x01\x01\x12\x15\n\x08inv_temp\x18\' \x01(\x05H&\x88\x01\x01\x12\x15\n\x08inv_freq\x18( \x01(\x05H\'\x88\x01\x01\x12\x17\n\ninv_dc_cur\x18) \x01(\x05H(\x88\x01\x01\x12\x14\n\x07\x62p_type\x18* \x01(\x05H)\x88\x01\x01\x12\x1d\n\x10inv_relay_status\x18+ \x01(\x05H*\x88\x01\x01\x12\x1d\n\x10pv1_relay_status\x18, \x01(\x05H+\x88\x01\x01\x12\x1d\n\x10pv2_relay_status\x18- \x01(\x05H,\x88\x01\x01\x12\x1c\n\x0finstall_country\x18. \x01(\rH-\x88\x01\x01\x12\x19\n\x0cinstall_town\x18/ \x01(\rH.\x88\x01\x01\x12\x1c\n\x0fpermanent_watts\x18\x30 \x01(\rH/\x88\x01\x01\x12\x1a\n\rdynamic_watts\x18\x31 \x01(\rH0\x88\x01\x01\x12\x1c\n\x0fsupply_priority\x18\x32 \x01(\rH1\x88\x01\x01\x12\x18\n\x0blower_limit\x18\x33 \x01(\rH2\x88\x01\x01\x12\x18\n\x0bupper_limit\x18\x34 \x01(\rH3\x88\x01\x01\x12\x17\n\ninv_on_off\x18\x35 \x01(\rH4\x88\x01\x01\x12 \n\x13wireless_error_code\x18\x36 \x01(\rH5\x88\x01\x01\x12\"\n\x15wireless_warning_code\x18\x37 \x01(\rH6\x88\x01\x01\x12\x1b\n\x0einv_brightness\x18\x38 \x01(\rH7\x88\x01\x01\x12 \n\x13heartbeat_frequency\x18\x39 \x01(\rH8\x88\x01\x01\x12\x18\n\x0brated_power\x18: \x01(\rH9\x88\x01\x01\x12\"\n\x15\x62\x61ttery_charge_remain\x18; \x01(\rH:\x88\x01\x01\x12%\n\x18\x62\x61ttery_discharge_remain\x18< \x01(\rH;\x88\x01\x01\x42\x11\n\x0f_inv_error_codeB\x13\n\x11_inv_warning_codeB\x11\n\x0f_pv1_error_codeB\x13\n\x11_pv1_warning_codeB\x11\n\x0f_pv2_error_codeB\x13\n\x11_pv2_warning_codeB\x11\n\x0f_bat_error_codeB\x13\n\x11_bat_warning_codeB\x11\n\x0f_llc_error_codeB\x13\n\x11_llc_warning_codeB\r\n\x0b_pv1_statusB\r\n\x0b_pv2_statusB\r\n\x0b_bat_statusB\r\n\x0b_llc_statusB\r\n\x0b_inv_statusB\x11\n\x0f_pv1_input_voltB\x0e\n\x0c_pv1_op_voltB\x10\n\x0e_pv1_input_curB\x12\n\x10_pv1_input_wattsB\x0b\n\t_pv1_tempB\x11\n\x0f_pv2_input_voltB\x0e\n\x0c_pv2_op_voltB\x10\n\x0e_pv2_input_curB\x12\n\x10_pv2_input_wattsB\x0b\n\t_pv2_tempB\x11\n\x0f_bat_input_voltB\x0e\n\x0c_bat_op_voltB\x10\n\x0e_bat_input_curB\x12\n\x10_bat_input_wattsB\x0b\n\t_bat_tempB\n\n\x08_bat_socB\x11\n\x0f_llc_input_voltB\x0e\n\x0c_llc_op_voltB\x0b\n\t_llc_tempB\x11\n\x0f_inv_input_voltB\x0e\n\x0c_inv_op_voltB\x11\n\x0f_inv_output_curB\x13\n\x11_inv_output_wattsB\x0b\n\t_inv_tempB\x0b\n\t_inv_freqB\r\n\x0b_inv_dc_curB\n\n\x08_bp_typeB\x13\n\x11_inv_relay_statusB\x13\n\x11_pv1_relay_statusB\x13\n\x11_pv2_relay_statusB\x12\n\x10_install_countryB\x0f\n\r_install_townB\x12\n\x10_permanent_wattsB\x10\n\x0e_dynamic_wattsB\x12\n\x10_supply_priorityB\x0e\n\x0c_lower_limitB\x0e\n\x0c_upper_limitB\r\n\x0b_inv_on_offB\x16\n\x14_wireless_error_codeB\x18\n\x16_wireless_warning_codeB\x11\n\x0f_inv_brightnessB\x16\n\x14_heartbeat_frequencyB\x0e\n\x0c_rated_powerB\x18\n\x16_battery_charge_remainB\x1b\n\x19_battery_discharge_remain\"F\n\x12PermanentWattsPack\x12\x1c\n\x0fpermanent_watts\x18\x01 \x01(\rH\x00\x88\x01\x01\x42\x12\n\x10_permanent_watts\"F\n\x12SupplyPriorityPack\x12\x1c\n\x0fsupply_priority\x18\x01 \x01(\rH\x00\x88\x01\x01\x42\x12\n\x10_supply_priority\"8\n\x0c\x42\x61tLowerPack\x12\x18\n\x0blower_limit\x18\x01 \x01(\x05H\x00\x88\x01\x01\x42\x0e\n\x0c_lower_limit\"8\n\x0c\x42\x61tUpperPack\x12\x18\n\x0bupper_limit\x18\x01 \x01(\x05H\x00\x88\x01\x01\x42\x0e\n\x0c_upper_limit\"8\n\x0e\x42rightnessPack\x12\x17\n\nbrightness\x18\x01 \x01(\x05H\x00\x88\x01\x01\x42\r\n\x0b_brightness\"\xd7\x02\n\tPowerItem\x12\x16\n\ttimestamp\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x15\n\x08timezone\x18\x02 \x01(\x11H\x01\x88\x01\x01\x12\x1e\n\x11inv_to_grid_power\x18\x03 \x01(\rH\x02\x88\x01\x01\x12\x1e\n\x11inv_to_plug_power\x18\x04 \x01(\rH\x03\x88\x01\x01\x12\x1a\n\rbattery_power\x18\x05 \x01(\x05H\x04\x88\x01\x01\x12\x1d\n\x10pv1_output_power\x18\x06 \x01(\rH\x05\x88\x01\x01\x12\x1d\n\x10pv2_output_power\x18\x07 \x01(\rH\x06\x88\x01\x01\x42\x0c\n\n_timestampB\x0b\n\t_timezoneB\x14\n\x12_inv_to_grid_powerB\x14\n\x12_inv_to_plug_powerB\x10\n\x0e_battery_powerB\x13\n\x11_pv1_output_powerB\x13\n\x11_pv2_output_power\"S\n\tPowerPack\x12\x14\n\x07sys_seq\x18\x01 \x01(\rH\x00\x88\x01\x01\x12$\n\x10sys_power_stream\x18\x02 \x03(\x0b\x32\n.PowerItemB\n\n\x08_sys_seq\"0\n\x0cPowerAckPack\x12\x14\n\x07sys_seq\x18\x01 \x01(\rH\x00\x88\x01\x01\x42\n\n\x08_sys_seq\"?\n\x0bNodeMassage\x12\x0f\n\x02sn\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x10\n\x03mac\x18\x02 \x01(\x0cH\x01\x88\x01\x01\x42\x05\n\x03_snB\x06\n\x04_mac\"\x9e\x02\n\x11MeshChildNodeInfo\x12\x1a\n\rtopology_type\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x1a\n\rmesh_protocol\x18\x02 \x01(\rH\x01\x88\x01\x01\x12\x1f\n\x12max_sub_device_num\x18\x03 \x01(\rH\x02\x88\x01\x01\x12\x1a\n\rparent_mac_id\x18\x04 \x01(\x0cH\x03\x88\x01\x01\x12\x14\n\x07mesh_id\x18\x05 \x01(\x0cH\x04\x88\x01\x01\x12%\n\x0fsub_device_list\x18\x06 \x03(\x0b\x32\x0c.NodeMassageB\x10\n\x0e_topology_typeB\x10\n\x0e_mesh_protocolB\x15\n\x13_max_sub_device_numB\x10\n\x0e_parent_mac_idB\n\n\x08_mesh_idb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'powerstream_pb2', _globals) +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + _globals['_INVERTERHEARTBEAT']._serialized_start=22 + _globals['_INVERTERHEARTBEAT']._serialized_end=2821 + _globals['_PERMANENTWATTSPACK']._serialized_start=2823 + _globals['_PERMANENTWATTSPACK']._serialized_end=2893 + _globals['_SUPPLYPRIORITYPACK']._serialized_start=2895 + _globals['_SUPPLYPRIORITYPACK']._serialized_end=2965 + _globals['_BATLOWERPACK']._serialized_start=2967 + _globals['_BATLOWERPACK']._serialized_end=3023 + _globals['_BATUPPERPACK']._serialized_start=3025 + _globals['_BATUPPERPACK']._serialized_end=3081 + _globals['_BRIGHTNESSPACK']._serialized_start=3083 + _globals['_BRIGHTNESSPACK']._serialized_end=3139 + _globals['_POWERITEM']._serialized_start=3142 + _globals['_POWERITEM']._serialized_end=3485 + _globals['_POWERPACK']._serialized_start=3487 + _globals['_POWERPACK']._serialized_end=3570 + _globals['_POWERACKPACK']._serialized_start=3572 + _globals['_POWERACKPACK']._serialized_end=3620 + _globals['_NODEMASSAGE']._serialized_start=3622 + _globals['_NODEMASSAGE']._serialized_end=3685 + _globals['_MESHCHILDNODEINFO']._serialized_start=3688 + _globals['_MESHCHILDNODEINFO']._serialized_end=3974 +# @@protoc_insertion_point(module_scope) diff --git a/config/custom_components/ecoflow_cloud/mqtt/utils.py b/config/custom_components/ecoflow_cloud/mqtt/utils.py new file mode 100644 index 0000000..75141d8 --- /dev/null +++ b/config/custom_components/ecoflow_cloud/mqtt/utils.py @@ -0,0 +1,34 @@ +from collections import OrderedDict +from typing import Callable, List, TypeVar + + +class LimitedSizeOrderedDict(OrderedDict): + def __init__(self, maxlen=20): + """Initialize a new DedupStore.""" + super().__init__() + self.maxlen = maxlen + + def append(self, key, val, on_delete: Callable = None): + self[key] = val + self.move_to_end(key) + if len(self) > self.maxlen: + # Removes the first record which should also be the oldest + itm = self.popitem(last=False) + if on_delete: + on_delete(itm) + + +_T = TypeVar("_T") + + +class BoundFifoList(List): + + def __init__(self, maxlen=20) -> None: + super().__init__() + self.maxlen = maxlen + + def append(self, __object: _T) -> None: + super().insert(0, __object) + while len(self) >= self.maxlen: + self.pop() + diff --git a/config/custom_components/ecoflow_cloud/number.py b/config/custom_components/ecoflow_cloud/number.py new file mode 100644 index 0000000..ac4432b --- /dev/null +++ b/config/custom_components/ecoflow_cloud/number.py @@ -0,0 +1,86 @@ +from typing import Callable, Any + +from homeassistant.components.number import NumberMode +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, UnitOfPower, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import DOMAIN, OPTS_POWER_STEP +from .entities import BaseNumberEntity +from .mqtt.ecoflow_mqtt import EcoflowMQTTClient + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback): + client: EcoflowMQTTClient = hass.data[DOMAIN][entry.entry_id] + + from .devices.registry import devices + async_add_entities(devices[client.device_type].numbers(client)) + + +class ValueUpdateEntity(BaseNumberEntity): + _attr_native_step = 1 + _attr_mode = NumberMode.SLIDER + + async def async_set_native_value(self, value: float): + if self._command: + ival = int(value) + self.send_set_message(ival, self.command_dict(ival)) + + +class ChargingPowerEntity(ValueUpdateEntity): + _attr_icon = "mdi:transmission-tower-import" + _attr_native_unit_of_measurement = UnitOfPower.WATT + _attr_device_class = SensorDeviceClass.POWER + + def __init__(self, client: EcoflowMQTTClient, mqtt_key: str, title: str, min_value: int, max_value: int, + command: Callable[[int], dict[str, any]] | None, enabled: bool = True, auto_enable: bool = False): + super().__init__(client, mqtt_key, title, min_value, max_value, command, enabled, auto_enable) + + self._attr_native_step = client.config_entry.options[OPTS_POWER_STEP] + + +class BatteryBackupLevel(ValueUpdateEntity): + _attr_icon = "mdi:battery-charging-90" + _attr_native_unit_of_measurement = PERCENTAGE + + def __init__(self, client: EcoflowMQTTClient, mqtt_key: str, title: str, + min_value: int, max_value: int, + min_key: str, max_key: str, + command: Callable[[int], dict[str, any]] | None): + super().__init__(client, mqtt_key, title, min_value, max_value, command, True, False) + self.__min_key = min_key + self.__max_key = max_key + + def _updated(self, data: dict[str, Any]): + if self.__min_key in data: + self._attr_native_min_value = int(data[self.__min_key]) + 5 # min + 5% + if self.__max_key in data: + self._attr_native_max_value = int(data[self.__max_key]) + super()._updated(data) + + +class LevelEntity(ValueUpdateEntity): + _attr_native_unit_of_measurement = PERCENTAGE + + +class MinBatteryLevelEntity(LevelEntity): + _attr_icon = "mdi:battery-charging-10" + + +class MaxBatteryLevelEntity(LevelEntity): + _attr_icon = "mdi:battery-charging-90" + + +class MinGenStartLevelEntity(LevelEntity): + _attr_icon = "mdi:engine" + + +class MaxGenStopLevelEntity(LevelEntity): + _attr_icon = "mdi:engine-off" + + +class SetTempEntity(ValueUpdateEntity): + _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + diff --git a/config/custom_components/ecoflow_cloud/recorder.py b/config/custom_components/ecoflow_cloud/recorder.py new file mode 100644 index 0000000..92d46b1 --- /dev/null +++ b/config/custom_components/ecoflow_cloud/recorder.py @@ -0,0 +1,9 @@ +from homeassistant.core import callback, HomeAssistant + +from custom_components.ecoflow_cloud import ATTR_STATUS_UPDATES, ATTR_STATUS_DATA_LAST_UPDATE, \ + ATTR_STATUS_LAST_UPDATE, ATTR_STATUS_PHASE + + +@callback +def exclude_attributes(hass: HomeAssistant) -> set[str]: + return {ATTR_STATUS_UPDATES, ATTR_STATUS_DATA_LAST_UPDATE, ATTR_STATUS_LAST_UPDATE, ATTR_STATUS_PHASE} diff --git a/config/custom_components/ecoflow_cloud/select.py b/config/custom_components/ecoflow_cloud/select.py new file mode 100644 index 0000000..fe6e5cc --- /dev/null +++ b/config/custom_components/ecoflow_cloud/select.py @@ -0,0 +1,48 @@ +from typing import Callable, Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from custom_components.ecoflow_cloud import EcoflowMQTTClient, DOMAIN +from custom_components.ecoflow_cloud.entities import BaseSelectEntity + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback): + client: EcoflowMQTTClient = hass.data[DOMAIN][entry.entry_id] + + from .devices.registry import devices + async_add_entities(devices[client.device_type].selects(client)) + + +class DictSelectEntity(BaseSelectEntity): + _attr_entity_category = EntityCategory.CONFIG + _attr_available = False + + def __init__(self, client: EcoflowMQTTClient, mqtt_key: str, title: str, options: dict[str, int], + command: Callable[[int], dict[str, any]] | None, enabled: bool = True, auto_enable: bool = False): + super().__init__(client, mqtt_key, title, command, enabled, auto_enable) + self.__options_dict = options + self._attr_options = list(options.keys()) + + def options_dict(self) -> dict[str, int]: + return self.__options_dict + + def _update_value(self, val: Any) -> bool: + ival = int(val) + lval = [k for k, v in self.__options_dict.items() if v == ival] + if len(lval) == 1: + self._attr_current_option = lval[0] + return True + else: + return False + + async def async_select_option(self, option: str): + if self._command: + val = self.__options_dict[option] + self.send_set_message(val, self.command_dict(val)) + + +class TimeoutDictSelectEntity(DictSelectEntity): + _attr_icon = "mdi:timer-outline" diff --git a/config/custom_components/ecoflow_cloud/sensor.py b/config/custom_components/ecoflow_cloud/sensor.py new file mode 100644 index 0000000..7c73c1d --- /dev/null +++ b/config/custom_components/ecoflow_cloud/sensor.py @@ -0,0 +1,393 @@ +import math +import logging +from datetime import timedelta, datetime +from typing import Any, Mapping, OrderedDict + +from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorDeviceClass +from homeassistant.components.sensor import (SensorDeviceClass, SensorStateClass, SensorEntity) +from homeassistant.config_entries import ConfigEntry + +from homeassistant.const import (PERCENTAGE, + UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, UnitOfFrequency, + UnitOfPower, UnitOfTemperature, UnitOfTime) + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.util import utcnow +from homeassistant.util.dt import UTC + +from . import DOMAIN, ATTR_STATUS_SN, ATTR_STATUS_DATA_LAST_UPDATE, ATTR_STATUS_UPDATES, \ + ATTR_STATUS_LAST_UPDATE, ATTR_STATUS_RECONNECTS, ATTR_STATUS_PHASE +from .entities import BaseSensorEntity, EcoFlowAbstractEntity, EcoFlowDictEntity +from .mqtt.ecoflow_mqtt import EcoflowMQTTClient + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback): + client: EcoflowMQTTClient = hass.data[DOMAIN][entry.entry_id] + + from .devices.registry import devices + async_add_entities(devices[client.device_type].sensors(client)) + + +class MiscBinarySensorEntity(BinarySensorEntity, EcoFlowDictEntity): + + def _update_value(self, val: Any) -> bool: + self._attr_is_on = bool(val) + return True + + +class ChargingStateSensorEntity(BaseSensorEntity): + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_icon = "mdi:battery-charging" + _attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING + + def _update_value(self, val: Any) -> bool: + if val == 0: + return super()._update_value("unused") + elif val == 1: + return super()._update_value("charging") + elif val == 2: + return super()._update_value("discharging") + else: + return False + + +class CyclesSensorEntity(BaseSensorEntity): + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_icon = "mdi:battery-heart-variant" + _attr_state_class = SensorStateClass.TOTAL_INCREASING + + +class FanSensorEntity(BaseSensorEntity): + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_icon = "mdi:fan" + + +class MiscSensorEntity(BaseSensorEntity): + _attr_entity_category = EntityCategory.DIAGNOSTIC + + +class LevelSensorEntity(BaseSensorEntity): + _attr_device_class = SensorDeviceClass.BATTERY + _attr_native_unit_of_measurement = PERCENTAGE + _attr_state_class = SensorStateClass.MEASUREMENT + + +class RemainSensorEntity(BaseSensorEntity): + _attr_device_class = SensorDeviceClass.DURATION + _attr_native_unit_of_measurement = UnitOfTime.MINUTES + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_native_value = 0 + + def _update_value(self, val: Any) -> Any: + ival = int(val) + if ival < 0 or ival > 5000: + ival = 0 + + return super()._update_value(ival) + + +class SecondsRemainSensorEntity(BaseSensorEntity): + _attr_device_class = SensorDeviceClass.DURATION + _attr_native_unit_of_measurement = UnitOfTime.SECONDS + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_native_value = 0 + + def _update_value(self, val: Any) -> Any: + ival = int(val) + if ival < 0 or ival > 5000: + ival = 0 + + return super()._update_value(ival) + + +class TempSensorEntity(BaseSensorEntity): + _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_native_value = -1 + + +class DecicelsiusSensorEntity(TempSensorEntity): + def _update_value(self, val: Any) -> bool: + return super()._update_value(int(val) / 10) + +class MilliCelsiusSensorEntity(TempSensorEntity): + def _update_value(self, val: Any) -> bool: + return super()._update_value(int(val) / 100) + +class VoltSensorEntity(BaseSensorEntity): + _attr_device_class = SensorDeviceClass.VOLTAGE + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_native_value = 0 + + +class MilliVoltSensorEntity(BaseSensorEntity): + _attr_device_class = SensorDeviceClass.VOLTAGE + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_native_unit_of_measurement = UnitOfElectricPotential.MILLIVOLT + _attr_suggested_unit_of_measurement = UnitOfElectricPotential.VOLT + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_native_value = 3 + + +class InMilliVoltSensorEntity(MilliVoltSensorEntity): + _attr_icon = "mdi:transmission-tower-import" + _attr_suggested_display_precision = 0 + + +class OutMilliVoltSensorEntity(MilliVoltSensorEntity): + _attr_icon = "mdi:transmission-tower-export" + _attr_suggested_display_precision = 0 + + +class DecivoltSensorEntity(BaseSensorEntity): + _attr_device_class = SensorDeviceClass.VOLTAGE + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_native_value = 0 + + def _update_value(self, val: Any) -> bool: + return super()._update_value(int(val) / 10) + + +class CentivoltSensorEntity(DecivoltSensorEntity): + def _update_value(self, val: Any) -> bool: + return super()._update_value(int(val) / 10) + + +class AmpSensorEntity(BaseSensorEntity): + _attr_device_class = SensorDeviceClass.CURRENT + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_native_unit_of_measurement = UnitOfElectricCurrent.MILLIAMPERE + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_native_value = 0 + + +class DeciampSensorEntity(BaseSensorEntity): + _attr_device_class = SensorDeviceClass.CURRENT + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_native_value = 0 + + def _update_value(self, val: Any) -> bool: + return super()._update_value(int(val) / 10) + + +class WattsSensorEntity(BaseSensorEntity): + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_device_class = SensorDeviceClass.POWER + _attr_native_unit_of_measurement = UnitOfPower.WATT + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_native_value = 0 + + +class EnergySensorEntity(BaseSensorEntity): + _attr_device_class = SensorDeviceClass.ENERGY + _attr_native_unit_of_measurement = UnitOfEnergy.WATT_HOUR + _attr_state_class = SensorStateClass.TOTAL_INCREASING + + def _update_value(self, val: Any) -> bool: + ival = int(val) + if ival > 0: + return super()._update_value(ival) + else: + return False + + +class CapacitySensorEntity(BaseSensorEntity): + _attr_device_class = SensorDeviceClass.CURRENT + _attr_native_unit_of_measurement = "mAh" + _attr_state_class = SensorStateClass.MEASUREMENT + + +class DeciwattsSensorEntity(WattsSensorEntity): + def _update_value(self, val: Any) -> bool: + return super()._update_value(int(val) / 10) + + +class InWattsSensorEntity(WattsSensorEntity): + _attr_icon = "mdi:transmission-tower-import" + + +class InWattsSolarSensorEntity(InWattsSensorEntity): + _attr_icon = "mdi:solar-power" + + def _update_value(self, val: Any) -> bool: + return super()._update_value(int(val) / 10) + + +class OutWattsSensorEntity(WattsSensorEntity): + _attr_icon = "mdi:transmission-tower-export" + + +class OutWattsDcSensorEntity(WattsSensorEntity): + _attr_icon = "mdi:transmission-tower-export" + + def _update_value(self, val: Any) -> bool: + return super()._update_value(int(val) / 10) + + +class InVoltSensorEntity(VoltSensorEntity): + _attr_icon = "mdi:transmission-tower-import" + +class InVoltSolarSensorEntity(VoltSensorEntity): + _attr_icon = "mdi:solar-power" + + def _update_value(self, val: Any) -> bool: + return super()._update_value(int(val) / 10) + +class OutVoltDcSensorEntity(VoltSensorEntity): + _attr_icon = "mdi:transmission-tower-export" + + def _update_value(self, val: Any) -> bool: + return super()._update_value(int(val) / 10) + +class InAmpSensorEntity(AmpSensorEntity): + _attr_icon = "mdi:transmission-tower-import" + +class InAmpSolarSensorEntity(AmpSensorEntity): + _attr_icon = "mdi:solar-power" + + def _update_value(self, val: Any) -> bool: + return super()._update_value(int(val) * 10) + +class InEnergySensorEntity(EnergySensorEntity): + _attr_icon = "mdi:transmission-tower-import" + + +class OutEnergySensorEntity(EnergySensorEntity): + _attr_icon = "mdi:transmission-tower-export" + + +class FrequencySensorEntity(BaseSensorEntity): + _attr_device_class = SensorDeviceClass.FREQUENCY + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_native_unit_of_measurement = UnitOfFrequency.HERTZ + _attr_state_class = SensorStateClass.MEASUREMENT + + +class DecihertzSensorEntity(FrequencySensorEntity): + def _update_value(self, val: Any) -> bool: + return super()._update_value(int(val) / 10) + + +class StatusSensorEntity(SensorEntity, EcoFlowAbstractEntity): + _attr_entity_category = EntityCategory.DIAGNOSTIC + DEADLINE_PHASE = 10 + CHECK_PHASES = [2, 4, 6] + CONNECT_PHASES = [3, 5, 7] + + def __init__(self, client: EcoflowMQTTClient, check_interval_sec=30): + super().__init__(client, "Status", "status") + self._online = 0 + self.__check_interval_sec = check_interval_sec + self._attrs = OrderedDict[str, Any]() + self._attrs[ATTR_STATUS_SN] = client.device_sn + self._attrs[ATTR_STATUS_DATA_LAST_UPDATE] = self._client.data.params_time() + self._attrs[ATTR_STATUS_UPDATES] = 0 + self._attrs[ATTR_STATUS_LAST_UPDATE] = None + self._attrs[ATTR_STATUS_RECONNECTS] = 0 + self._attrs[ATTR_STATUS_PHASE] = 0 + + async def async_added_to_hass(self): + await super().async_added_to_hass() + + params_d = self._client.data.params_observable().subscribe(self.__params_update) + self.async_on_remove(params_d.dispose) + + self.async_on_remove( + async_track_time_interval(self.hass, self.__check_status, timedelta(seconds=self.__check_interval_sec))) + + self._update_status((utcnow() - self._client.data.params_time()).total_seconds()) + + def __check_status(self, now: datetime): + data_outdated_sec = (now - self._client.data.params_time()).total_seconds() + phase = math.ceil(data_outdated_sec / self.__check_interval_sec) + self._attrs[ATTR_STATUS_PHASE] = phase + time_to_reconnect = phase in self.CONNECT_PHASES + time_to_check_status = phase in self.CHECK_PHASES + + if self._online == 1: + if time_to_check_status or phase >= self.DEADLINE_PHASE: + # online and outdated - refresh status to detect if device went offline + self._update_status(data_outdated_sec) + elif time_to_reconnect: + # online, updated and outdated - reconnect + self._attrs[ATTR_STATUS_RECONNECTS] = self._attrs[ATTR_STATUS_RECONNECTS] + 1 + self._client.reconnect() + self.schedule_update_ha_state() + + elif not self._client.is_connected(): # validate connection even for offline device + self._attrs[ATTR_STATUS_RECONNECTS] = self._attrs[ATTR_STATUS_RECONNECTS] + 1 + self._client.reconnect() + self.schedule_update_ha_state() + + def __params_update(self, data: dict[str, Any]): + self._attrs[ATTR_STATUS_DATA_LAST_UPDATE] = self._client.data.params_time() + if self._online == 0: + self._update_status(0) + + self.schedule_update_ha_state() + + def _update_status(self, data_outdated_sec): + if data_outdated_sec > self.__check_interval_sec * self.DEADLINE_PHASE: + self._online = 0 + self._attr_native_value = "assume_offline" + else: + self._online = 1 + self._attr_native_value = "assume_online" + + self._attrs[ATTR_STATUS_LAST_UPDATE] = utcnow() + self._attrs[ATTR_STATUS_UPDATES] = self._attrs[ATTR_STATUS_UPDATES] + 1 + self.schedule_update_ha_state() + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + return self._attrs + + +class QuotasStatusSensorEntity(StatusSensorEntity): + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__(self, client: EcoflowMQTTClient): + super().__init__(client) + + async def async_added_to_hass(self): + + get_reply_d = self._client.data.get_reply_observable().subscribe(self.__get_reply_update) + self.async_on_remove(get_reply_d.dispose) + + await super().async_added_to_hass() + + def _update_status(self, update_delta_sec): + if self._client.is_connected(): + self._attrs[ATTR_STATUS_UPDATES] = self._attrs[ATTR_STATUS_UPDATES] + 1 + self.send_get_message({"version": "1.1", "moduleType": 0, "operateType": "latestQuotas", "params": {}}) + else: + super()._update_status(update_delta_sec) + + def __get_reply_update(self, data: list[dict[str, Any]]): + d = data[0] + if d["operateType"] == "latestQuotas": + self._online = d["data"]["online"] + self._attrs[ATTR_STATUS_LAST_UPDATE] = utcnow() + + if self._online == 1: + self._attrs[ATTR_STATUS_SN] = d["data"]["sn"] + self._attr_native_value = "online" + + # ?? self._client.data.update_data(d["data"]["quotaMap"]) + else: + self._attr_native_value = "offline" + + self.schedule_update_ha_state() diff --git a/config/custom_components/ecoflow_cloud/switch.py b/config/custom_components/ecoflow_cloud/switch.py new file mode 100644 index 0000000..9f89771 --- /dev/null +++ b/config/custom_components/ecoflow_cloud/switch.py @@ -0,0 +1,73 @@ +import logging +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import DOMAIN +from .entities import BaseSwitchEntity +from .mqtt.ecoflow_mqtt import EcoflowMQTTClient + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback): + client: EcoflowMQTTClient = hass.data[DOMAIN][entry.entry_id] + + from .devices.registry import devices + async_add_entities(devices[client.device_type].switches(client)) + + +class EnabledEntity(BaseSwitchEntity): + + def _update_value(self, val: Any) -> bool: + _LOGGER.debug("Updating switch " + self._attr_unique_id + " to " + str(val)) + self._attr_is_on = bool(val) + return True + + def turn_on(self, **kwargs: Any) -> None: + if self._command: + self.send_set_message(1, self.command_dict(1)) + + def turn_off(self, **kwargs: Any) -> None: + if self._command: + self.send_set_message(0, self.command_dict(0)) + + +class DisabledEntity(BaseSwitchEntity): + + def _update_value(self, val: Any) -> bool: + _LOGGER.debug("Updating switch " + self._attr_unique_id + " to " + str(val)) + self._attr_is_on = not bool(val) + return True + + async def async_turn_on(self, **kwargs: Any) -> None: + if self._command: + self.send_set_message(0, self.command_dict(0)) + + async def async_turn_off(self, **kwargs: Any) -> None: + if self._command: + self.send_set_message(1, self.command_dict(1)) + + +class BeeperEntity(DisabledEntity): + _attr_entity_category = EntityCategory.CONFIG + + @property + def icon(self) -> str | None: + if self.is_on: + return "mdi:volume-high" + else: + return "mdi:volume-mute" + +class InvertedBeeperEntity(EnabledEntity): + _attr_entity_category = EntityCategory.CONFIG + + @property + def icon(self) -> str | None: + if self.is_on: + return "mdi:volume-high" + else: + return "mdi:volume-mute" diff --git a/config/custom_components/ecoflow_cloud/translations/de.json b/config/custom_components/ecoflow_cloud/translations/de.json new file mode 100644 index 0000000..f64e33b --- /dev/null +++ b/config/custom_components/ecoflow_cloud/translations/de.json @@ -0,0 +1,26 @@ +{ + "title": "EcoFlow-Cloud", + "config": { + "step": { + "user": { + "data": { + "username": "Benutzer-Email", + "password": "Benutzer-Passwort", + "type": "Gerätetyp", + "name": "Gerätename", + "device_id": "Seriennummer des Gerät" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "power_step": "Ladeleistung (Schritte für Schieberegler)", + "refresh_period_sec": "Datenaktualisierung in Sekunden" + } + } + } + } +} diff --git a/config/custom_components/ecoflow_cloud/translations/en.json b/config/custom_components/ecoflow_cloud/translations/en.json new file mode 100644 index 0000000..b5bc5b3 --- /dev/null +++ b/config/custom_components/ecoflow_cloud/translations/en.json @@ -0,0 +1,26 @@ +{ + "title": "EcoFlow-Cloud", + "config": { + "step": { + "user": { + "data": { + "username": "User email", + "password": "User password", + "type": "Device type", + "name": "Device name", + "device_id": "Device SN" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "power_step": "Charging power slider step", + "refresh_period_sec": "Data refresh period (sec)" + } + } + } + } +} diff --git a/config/custom_components/ecoflow_cloud/translations/fr.json b/config/custom_components/ecoflow_cloud/translations/fr.json new file mode 100644 index 0000000..c129816 --- /dev/null +++ b/config/custom_components/ecoflow_cloud/translations/fr.json @@ -0,0 +1,27 @@ +{ + "title": "EcoFlow-Cloud", + "config": { + "step": { + "user": { + "data": { + "username": "Adresse e-mail", + "password": "Mot de passe", + "type": "Type d'appareil", + "name": "Nom de l'appareil", + "device_id": "Numéro de série" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "power_step": "Pas du curseur de puissance de charge", + "refresh_period_sec": "Durée actualisation des données (secondes)" + } + } + } + } + } + diff --git a/config/custom_components/frigate/__init__.py b/config/custom_components/frigate/__init__.py new file mode 100644 index 0000000..7ed34fb --- /dev/null +++ b/config/custom_components/frigate/__init__.py @@ -0,0 +1,483 @@ +""" +Custom integration to integrate frigate with Home Assistant. + +For more details about this integration, please refer to +https://github.com/blakeblackshear/frigate-hass-integration +""" +from __future__ import annotations + +from collections.abc import Callable +from datetime import timedelta +import logging +import re +from typing import Any, Final + +from awesomeversion import AwesomeVersion + +from custom_components.frigate.config_flow import get_config_entry_title +from homeassistant.components.mqtt.models import ReceiveMessage +from homeassistant.components.mqtt.subscription import ( + async_prepare_subscribe_topics, + async_subscribe_topics, + async_unsubscribe_topics, +) +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_MODEL, CONF_HOST, CONF_URL +from homeassistant.core import Config, HomeAssistant, callback, valid_entity_id +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.loader import async_get_integration +from homeassistant.util import slugify + +from .api import FrigateApiClient, FrigateApiClientError +from .const import ( + ATTR_CLIENT, + ATTR_CONFIG, + ATTR_COORDINATOR, + ATTRIBUTE_LABELS, + CONF_CAMERA_STATIC_IMAGE_HEIGHT, + DOMAIN, + FRIGATE_RELEASES_URL, + FRIGATE_VERSION_ERROR_CUTOFF, + NAME, + PLATFORMS, + STARTUP_MESSAGE, + STATUS_ERROR, + STATUS_RUNNING, + STATUS_STARTING, +) +from .views import async_setup as views_async_setup +from .ws_api import async_setup as ws_api_async_setup + +SCAN_INTERVAL = timedelta(seconds=5) + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +# Typing notes: +# - The HomeAssistant library does not provide usable type hints for custom +# components. Certain type checks (e.g. decorators and class inheritance) need +# to be marked as ignored or casted, when using the default Home Assistant +# mypy settings. Using the same settings is preferable, to smoothen a future +# migration to Home Assistant Core. + + +def get_frigate_device_identifier( + entry: ConfigEntry, camera_name: str | None = None +) -> tuple[str, str]: + """Get a device identifier.""" + if camera_name: + return (DOMAIN, f"{entry.entry_id}:{slugify(camera_name)}") + return (DOMAIN, entry.entry_id) + + +def get_frigate_entity_unique_id( + config_entry_id: str, type_name: str, name: str +) -> str: + """Get the unique_id for a Frigate entity.""" + return f"{config_entry_id}:{type_name}:{name}" + + +def get_friendly_name(name: str) -> str: + """Get a friendly version of a name.""" + return name.replace("_", " ").title() + + +def get_cameras(config: dict[str, Any]) -> set[str]: + """Get cameras.""" + cameras = set() + + for cam_name, _ in config["cameras"].items(): + cameras.add(cam_name) + + return cameras + + +def get_cameras_and_objects( + config: dict[str, Any], include_all: bool = True +) -> set[tuple[str, str]]: + """Get cameras and tracking object tuples.""" + camera_objects = set() + for cam_name, cam_config in config["cameras"].items(): + for obj in cam_config["objects"]["track"]: + if obj not in ATTRIBUTE_LABELS: + camera_objects.add((cam_name, obj)) + + # add an artificial all label to track + # all objects for this camera + if include_all: + camera_objects.add((cam_name, "all")) + + return camera_objects + + +def get_cameras_and_audio(config: dict[str, Any]) -> set[tuple[str, str]]: + """Get cameras and audio tuples.""" + camera_audio = set() + for cam_name, cam_config in config["cameras"].items(): + if cam_config.get("audio", {}).get("enabled_in_config", False): + for audio in cam_config.get("audio", {}).get("listen", []): + camera_audio.add((cam_name, audio)) + + return camera_audio + + +def get_cameras_zones_and_objects(config: dict[str, Any]) -> set[tuple[str, str]]: + """Get cameras/zones and tracking object tuples.""" + camera_objects = get_cameras_and_objects(config) + + zone_objects = set() + for cam_name, obj in camera_objects: + for zone_name in config["cameras"][cam_name]["zones"]: + zone_name_objects = config["cameras"][cam_name]["zones"][zone_name].get( + "objects" + ) + if not zone_name_objects or obj in zone_name_objects: + zone_objects.add((zone_name, obj)) + + # add an artificial all label to track + # all objects for this zone + zone_objects.add((zone_name, "all")) + return camera_objects.union(zone_objects) + + +def get_cameras_and_zones(config: dict[str, Any]) -> set[str]: + """Get cameras and zones.""" + cameras_zones = set() + for camera in config.get("cameras", {}).keys(): + cameras_zones.add(camera) + for zone in config["cameras"][camera].get("zones", {}).keys(): + cameras_zones.add(zone) + return cameras_zones + + +def get_zones(config: dict[str, Any]) -> set[str]: + """Get zones.""" + cameras_zones = set() + for camera in config.get("cameras", {}).keys(): + for zone in config["cameras"][camera].get("zones", {}).keys(): + cameras_zones.add(zone) + return cameras_zones + + +def decode_if_necessary(data: str | bytes) -> str: + """Decode a string if necessary.""" + return data.decode("utf-8") if isinstance(data, bytes) else data + + +async def async_setup(hass: HomeAssistant, config: Config) -> bool: + """Set up this integration using YAML is not supported.""" + integration = await async_get_integration(hass, DOMAIN) + _LOGGER.info( + STARTUP_MESSAGE, + NAME, + integration.version, + ) + + hass.data.setdefault(DOMAIN, {}) + + ws_api_async_setup(hass) + views_async_setup(hass) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up this integration using UI.""" + client = FrigateApiClient( + entry.data.get(CONF_URL), + async_get_clientsession(hass), + ) + coordinator = FrigateDataUpdateCoordinator(hass, client=client) + await coordinator.async_config_entry_first_refresh() + + try: + server_version = await client.async_get_version() + config = await client.async_get_config() + except FrigateApiClientError as exc: + raise ConfigEntryNotReady from exc + + if AwesomeVersion(server_version.split("-")[0]) <= AwesomeVersion( + FRIGATE_VERSION_ERROR_CUTOFF + ): + _LOGGER.error( + "Using a Frigate server (%s) with version %s <= %s which is not " + "compatible -- you must upgrade: %s", + entry.data[CONF_URL], + server_version, + FRIGATE_VERSION_ERROR_CUTOFF, + FRIGATE_RELEASES_URL, + ) + return False + + model = f"{(await async_get_integration(hass, DOMAIN)).version}/{server_version}" + + hass.data[DOMAIN][entry.entry_id] = { + ATTR_COORDINATOR: coordinator, + ATTR_CLIENT: client, + ATTR_CONFIG: config, + ATTR_MODEL: model, + } + + # Remove old devices associated with cameras that have since been removed + # from the Frigate server, keeping the 'master' device for this config + # entry. + current_devices: set[tuple[str, str]] = set({get_frigate_device_identifier(entry)}) + for item in get_cameras_and_zones(config): + current_devices.add(get_frigate_device_identifier(entry, item)) + + if config.get("birdseye", {}).get("restream", False): + current_devices.add(get_frigate_device_identifier(entry, "birdseye")) + + device_registry = dr.async_get(hass) + for device_entry in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ): + for identifier in device_entry.identifiers: + if identifier in current_devices: + break + else: + device_registry.async_remove_device(device_entry.id) + + # Cleanup old clips switch ( dict[str, Any]: + """Update data via library.""" + try: + stats = await self._api.async_get_stats() + self.server_status = STATUS_RUNNING + return stats + except FrigateApiClientError as exc: + self.server_status = STATUS_ERROR + raise UpdateFailed from exc + + +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Handle removal of an entry.""" + unload_ok = bool( + await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) + ) + if unload_ok: + hass.data[DOMAIN].pop(config_entry.entry_id) + + return unload_ok + + +async def _async_entry_updated(hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Handle entry updates.""" + await hass.config_entries.async_reload(config_entry.entry_id) + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate from v1 entry.""" + + if config_entry.version == 1: + _LOGGER.debug("Migrating config entry from version '%s'", config_entry.version) + + data = {**config_entry.data} + data[CONF_URL] = data.pop(CONF_HOST) + hass.config_entries.async_update_entry( + config_entry, data=data, title=get_config_entry_title(data[CONF_URL]) + ) + config_entry.version = 2 + + @callback # type: ignore[misc] + def update_unique_id(entity_entry: er.RegistryEntry) -> dict[str, str] | None: + """Update unique ID of entity entry.""" + + converters: Final[dict[re.Pattern, Callable[[re.Match], list[str]]]] = { + re.compile(rf"^{DOMAIN}_(?P\S+)_binary_sensor$"): lambda m: [ + "occupancy_sensor", + m.group("cam_obj"), + ], + re.compile(rf"^{DOMAIN}_(?P\S+)_camera$"): lambda m: [ + "camera", + m.group("cam"), + ], + re.compile(rf"^{DOMAIN}_(?P\S+)_snapshot$"): lambda m: [ + "camera_snapshots", + m.group("cam_obj"), + ], + re.compile(rf"^{DOMAIN}_detection_fps$"): lambda m: [ + "sensor_fps", + "detection", + ], + re.compile( + rf"^{DOMAIN}_(?P\S+)_inference_speed$" + ): lambda m: ["sensor_detector_speed", m.group("detector")], + re.compile(rf"^{DOMAIN}_(?P\S+)_fps$"): lambda m: [ + "sensor_fps", + m.group("cam_fps"), + ], + re.compile(rf"^{DOMAIN}_(?P\S+)_switch$"): lambda m: [ + "switch", + m.group("cam_switch"), + ], + # Caution: This is a broad but necessary match (keep until last). + re.compile(rf"^{DOMAIN}_(?P\S+)$"): lambda m: [ + "sensor_object_count", + m.group("cam_obj"), + ], + } + + for regexp, func in converters.items(): + match = regexp.match(entity_entry.unique_id) + if match: + args = [config_entry.entry_id] + func(match) + return {"new_unique_id": get_frigate_entity_unique_id(*args)} + return None + + await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id) + _LOGGER.debug( + "Migrating config entry to version '%s' successful", config_entry.version + ) + + return True + + +class FrigateEntity(Entity): # type: ignore[misc] + """Base class for Frigate entities.""" + + _attr_has_entity_name = True + + def __init__(self, config_entry: ConfigEntry): + """Construct a FrigateEntity.""" + Entity.__init__(self) + + self._config_entry = config_entry + self._available = True + + @property + def available(self) -> bool: + """Return the availability of the entity.""" + return self._available and super().available + + def _get_model(self) -> str: + """Get the Frigate device model string.""" + return str(self.hass.data[DOMAIN][self._config_entry.entry_id][ATTR_MODEL]) + + +class FrigateMQTTEntity(FrigateEntity): + """Base class for MQTT-based Frigate entities.""" + + def __init__( + self, + config_entry: ConfigEntry, + frigate_config: dict[str, Any], + topic_map: dict[str, Any], + ) -> None: + """Construct a FrigateMQTTEntity.""" + super().__init__(config_entry) + self._frigate_config = frigate_config + self._sub_state = None + self._available = False + self._topic_map = topic_map + + async def async_added_to_hass(self) -> None: + """Subscribe mqtt events.""" + self._topic_map["availability_topic"] = { + "topic": f"{self._frigate_config['mqtt']['topic_prefix']}/available", + "msg_callback": self._availability_message_received, + "qos": 0, + } + + state = async_prepare_subscribe_topics( + self.hass, + self._sub_state, + self._topic_map, + ) + self._sub_state = await async_subscribe_topics(self.hass, state) + await super().async_added_to_hass() + + async def async_will_remove_from_hass(self) -> None: + """Cleanup prior to hass removal.""" + async_unsubscribe_topics(self.hass, self._sub_state) + self._sub_state = None + await super().async_will_remove_from_hass() + + @callback # type: ignore[misc] + def _availability_message_received(self, msg: ReceiveMessage) -> None: + """Handle a new received MQTT availability message.""" + self._available = decode_if_necessary(msg.payload) == "online" + self.async_write_ha_state() diff --git a/config/custom_components/frigate/api.py b/config/custom_components/frigate/api.py new file mode 100644 index 0000000..c9709e4 --- /dev/null +++ b/config/custom_components/frigate/api.py @@ -0,0 +1,264 @@ +"""Frigate API client.""" +from __future__ import annotations + +import asyncio +import logging +import socket +from typing import Any, cast + +import aiohttp +import async_timeout +from yarl import URL + +TIMEOUT = 10 + +_LOGGER: logging.Logger = logging.getLogger(__name__) + +HEADERS = {"Content-type": "application/json; charset=UTF-8"} + +# ============================================================================== +# Please do not add HomeAssistant specific imports/functionality to this module, +# so that this library can be optionally moved to a different repo at a later +# date. +# ============================================================================== + + +class FrigateApiClientError(Exception): + """General FrigateApiClient error.""" + + +class FrigateApiClient: + """Frigate API client.""" + + def __init__(self, host: str, session: aiohttp.ClientSession) -> None: + """Construct API Client.""" + self._host = host + self._session = session + + async def async_get_version(self) -> str: + """Get data from the API.""" + return cast( + str, + await self.api_wrapper( + "get", str(URL(self._host) / "api/version"), decode_json=False + ), + ) + + async def async_get_stats(self) -> dict[str, Any]: + """Get data from the API.""" + return cast( + dict[str, Any], + await self.api_wrapper("get", str(URL(self._host) / "api/stats")), + ) + + async def async_get_events( + self, + cameras: list[str] | None = None, + labels: list[str] | None = None, + sub_labels: list[str] | None = None, + zones: list[str] | None = None, + after: int | None = None, + before: int | None = None, + limit: int | None = None, + has_clip: bool | None = None, + has_snapshot: bool | None = None, + favorites: bool | None = None, + decode_json: bool = True, + ) -> list[dict[str, Any]]: + """Get data from the API.""" + params = { + "cameras": ",".join(cameras) if cameras else None, + "labels": ",".join(labels) if labels else None, + "sub_labels": ",".join(sub_labels) if sub_labels else None, + "zones": ",".join(zones) if zones else None, + "after": after, + "before": before, + "limit": limit, + "has_clip": int(has_clip) if has_clip is not None else None, + "has_snapshot": int(has_snapshot) if has_snapshot is not None else None, + "include_thumbnails": 0, + "favorites": int(favorites) if favorites is not None else None, + } + + return cast( + list[dict[str, Any]], + await self.api_wrapper( + "get", + str( + URL(self._host) + / "api/events" + % {k: v for k, v in params.items() if v is not None} + ), + decode_json=decode_json, + ), + ) + + async def async_get_event_summary( + self, + has_clip: bool | None = None, + has_snapshot: bool | None = None, + timezone: str | None = None, + decode_json: bool = True, + ) -> list[dict[str, Any]]: + """Get data from the API.""" + params = { + "has_clip": int(has_clip) if has_clip is not None else None, + "has_snapshot": int(has_snapshot) if has_snapshot is not None else None, + "timezone": str(timezone) if timezone is not None else None, + } + + return cast( + list[dict[str, Any]], + await self.api_wrapper( + "get", + str( + URL(self._host) + / "api/events/summary" + % {k: v for k, v in params.items() if v is not None} + ), + decode_json=decode_json, + ), + ) + + async def async_get_config(self) -> dict[str, Any]: + """Get data from the API.""" + return cast( + dict[str, Any], + await self.api_wrapper("get", str(URL(self._host) / "api/config")), + ) + + async def async_get_ptz_info( + self, + camera: str, + decode_json: bool = True, + ) -> Any: + """Get PTZ info.""" + return await self.api_wrapper( + "get", + str(URL(self._host) / "api" / camera / "ptz/info"), + decode_json=decode_json, + ) + + async def async_get_path(self, path: str) -> Any: + """Get data from the API.""" + return await self.api_wrapper("get", str(URL(self._host) / f"{path}/")) + + async def async_retain( + self, event_id: str, retain: bool, decode_json: bool = True + ) -> dict[str, Any] | str: + """Un/Retain an event.""" + result = await self.api_wrapper( + "post" if retain else "delete", + str(URL(self._host) / f"api/events/{event_id}/retain"), + decode_json=decode_json, + ) + return cast(dict[str, Any], result) if decode_json else result + + async def async_export_recording( + self, + camera: str, + playback_factor: str, + start_time: float, + end_time: float, + decode_json: bool = True, + ) -> dict[str, Any] | str: + """Export recording.""" + result = await self.api_wrapper( + "post", + str( + URL(self._host) + / f"api/export/{camera}/start/{start_time}/end/{end_time}" + ), + data={"playback": playback_factor}, + decode_json=decode_json, + ) + return cast(dict[str, Any], result) if decode_json else result + + async def async_get_recordings_summary( + self, camera: str, timezone: str, decode_json: bool = True + ) -> list[dict[str, Any]] | str: + """Get recordings summary.""" + params = {"timezone": timezone} + + result = await self.api_wrapper( + "get", + str( + URL(self._host) + / f"api/{camera}/recordings/summary" + % {k: v for k, v in params.items() if v is not None} + ), + decode_json=decode_json, + ) + return cast(list[dict[str, Any]], result) if decode_json else result + + async def async_get_recordings( + self, + camera: str, + after: int | None = None, + before: int | None = None, + decode_json: bool = True, + ) -> dict[str, Any] | str: + """Get recordings.""" + params = { + "after": after, + "before": before, + } + + result = await self.api_wrapper( + "get", + str( + URL(self._host) + / f"api/{camera}/recordings" + % {k: v for k, v in params.items() if v is not None} + ), + decode_json=decode_json, + ) + return cast(dict[str, Any], result) if decode_json else result + + async def api_wrapper( + self, + method: str, + url: str, + data: dict | None = None, + headers: dict | None = None, + decode_json: bool = True, + ) -> Any: + """Get information from the API.""" + if data is None: + data = {} + if headers is None: + headers = {} + + try: + async with async_timeout.timeout(TIMEOUT): + func = getattr(self._session, method) + if func: + response = await func( + url, headers=headers, raise_for_status=True, json=data + ) + if decode_json: + return await response.json() + return await response.text() + + except asyncio.TimeoutError as exc: + _LOGGER.error( + "Timeout error fetching information from %s: %s", + url, + exc, + ) + raise FrigateApiClientError from exc + + except (KeyError, TypeError) as exc: + _LOGGER.error( + "Error parsing information from %s: %s", + url, + exc, + ) + raise FrigateApiClientError from exc + except (aiohttp.ClientError, socket.gaierror) as exc: + _LOGGER.error( + "Error fetching information from %s: %s", + url, + exc, + ) + raise FrigateApiClientError from exc diff --git a/config/custom_components/frigate/binary_sensor.py b/config/custom_components/frigate/binary_sensor.py new file mode 100644 index 0000000..e7325c8 --- /dev/null +++ b/config/custom_components/frigate/binary_sensor.py @@ -0,0 +1,303 @@ +"""Binary sensor platform for Frigate.""" +from __future__ import annotations + +import logging +from typing import Any, cast + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ( + FrigateMQTTEntity, + ReceiveMessage, + decode_if_necessary, + get_cameras, + get_cameras_and_audio, + get_cameras_zones_and_objects, + get_friendly_name, + get_frigate_device_identifier, + get_frigate_entity_unique_id, + get_zones, +) +from .const import ATTR_CONFIG, DOMAIN, NAME +from .icons import get_dynamic_icon_from_type + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Binary sensor entry setup.""" + frigate_config = hass.data[DOMAIN][entry.entry_id][ATTR_CONFIG] + + entities = [] + + # add object sensors for cameras and zones + entities.extend( + [ + FrigateObjectOccupancySensor(entry, frigate_config, cam_name, obj) + for cam_name, obj in get_cameras_zones_and_objects(frigate_config) + ] + ) + + # add audio sensors for cameras + entities.extend( + [ + FrigateAudioSensor(entry, frigate_config, cam_name, audio) + for cam_name, audio in get_cameras_and_audio(frigate_config) + ] + ) + + # add generic motion sensors for cameras + entities.extend( + [ + FrigateMotionSensor(entry, frigate_config, cam_name) + for cam_name in get_cameras(frigate_config) + ] + ) + + async_add_entities(entities) + + +class FrigateObjectOccupancySensor(FrigateMQTTEntity, BinarySensorEntity): # type: ignore[misc] + """Frigate Occupancy Sensor class.""" + + def __init__( + self, + config_entry: ConfigEntry, + frigate_config: dict[str, Any], + cam_name: str, + obj_name: str, + ) -> None: + """Construct a new FrigateObjectOccupancySensor.""" + self._cam_name = cam_name + self._obj_name = obj_name + self._is_on = False + self._frigate_config = frigate_config + + super().__init__( + config_entry, + frigate_config, + { + "state_topic": { + "msg_callback": self._state_message_received, + "qos": 0, + "topic": ( + f"{self._frigate_config['mqtt']['topic_prefix']}" + f"/{self._cam_name}/{self._obj_name}" + ), + "encoding": None, + }, + }, + ) + + @callback # type: ignore[misc] + def _state_message_received(self, msg: ReceiveMessage) -> None: + """Handle a new received MQTT state message.""" + try: + self._is_on = int(msg.payload) > 0 + except ValueError: + self._is_on = False + self.async_write_ha_state() + + @property + def unique_id(self) -> str: + """Return a unique ID for this entity.""" + return get_frigate_entity_unique_id( + self._config_entry.entry_id, + "occupancy_sensor", + f"{self._cam_name}_{self._obj_name}", + ) + + @property + def device_info(self) -> dict[str, Any]: + """Return device information.""" + return { + "identifiers": { + get_frigate_device_identifier(self._config_entry, self._cam_name) + }, + "via_device": get_frigate_device_identifier(self._config_entry), + "name": get_friendly_name(self._cam_name), + "model": self._get_model(), + "configuration_url": f"{self._config_entry.data.get(CONF_URL)}/cameras/{self._cam_name if self._cam_name not in get_zones(self._frigate_config) else ''}", + "manufacturer": NAME, + } + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return f"{self._obj_name} occupancy" + + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return self._is_on + + @property + def device_class(self) -> str: + """Return the device class.""" + return cast(str, BinarySensorDeviceClass.OCCUPANCY) + + @property + def icon(self) -> str: + """Return the icon of the sensor.""" + return get_dynamic_icon_from_type(self._obj_name, self._is_on) + + +class FrigateAudioSensor(FrigateMQTTEntity, BinarySensorEntity): # type: ignore[misc] + """Frigate Audio Sensor class.""" + + def __init__( + self, + config_entry: ConfigEntry, + frigate_config: dict[str, Any], + cam_name: str, + audio_name: str, + ) -> None: + """Construct a new FrigateAudioSensor.""" + self._cam_name = cam_name + self._audio_name = audio_name + self._is_on = False + self._frigate_config = frigate_config + + super().__init__( + config_entry, + frigate_config, + { + "state_topic": { + "msg_callback": self._state_message_received, + "qos": 0, + "topic": ( + f"{self._frigate_config['mqtt']['topic_prefix']}" + f"/{self._cam_name}/audio/{self._audio_name}" + ), + }, + }, + ) + + @callback # type: ignore[misc] + def _state_message_received(self, msg: ReceiveMessage) -> None: + """Handle a new received MQTT state message.""" + self._is_on = decode_if_necessary(msg.payload) == "ON" + self.async_write_ha_state() + + @property + def unique_id(self) -> str: + """Return a unique ID for this entity.""" + return get_frigate_entity_unique_id( + self._config_entry.entry_id, + "audio_sensor", + f"{self._cam_name}_{self._audio_name}", + ) + + @property + def device_info(self) -> dict[str, Any]: + """Return device information.""" + return { + "identifiers": { + get_frigate_device_identifier(self._config_entry, self._cam_name) + }, + "via_device": get_frigate_device_identifier(self._config_entry), + "name": get_friendly_name(self._cam_name), + "model": self._get_model(), + "configuration_url": f"{self._config_entry.data.get(CONF_URL)}/cameras/{self._cam_name}", + "manufacturer": NAME, + } + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return f"{self._audio_name} sound" + + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return self._is_on + + @property + def device_class(self) -> str: + """Return the device class.""" + return cast(str, BinarySensorDeviceClass.SOUND) + + @property + def icon(self) -> str: + """Return the icon of the sensor.""" + return get_dynamic_icon_from_type("sound", self._is_on) + + +class FrigateMotionSensor(FrigateMQTTEntity, BinarySensorEntity): # type: ignore[misc] + """Frigate Motion Sensor class.""" + + _attr_name = "Motion" + + def __init__( + self, + config_entry: ConfigEntry, + frigate_config: dict[str, Any], + cam_name: str, + ) -> None: + """Construct a new FrigateMotionSensor.""" + self._cam_name = cam_name + self._is_on = False + self._frigate_config = frigate_config + + super().__init__( + config_entry, + frigate_config, + { + "state_topic": { + "msg_callback": self._state_message_received, + "qos": 0, + "topic": ( + f"{self._frigate_config['mqtt']['topic_prefix']}" + f"/{self._cam_name}/motion" + ), + }, + }, + ) + + @callback # type: ignore[misc] + def _state_message_received(self, msg: ReceiveMessage) -> None: + """Handle a new received MQTT state message.""" + self._is_on = decode_if_necessary(msg.payload) == "ON" + self.async_write_ha_state() + + @property + def unique_id(self) -> str: + """Return a unique ID for this entity.""" + return get_frigate_entity_unique_id( + self._config_entry.entry_id, + "motion_sensor", + f"{self._cam_name}", + ) + + @property + def device_info(self) -> dict[str, Any]: + """Return device information.""" + return { + "identifiers": { + get_frigate_device_identifier(self._config_entry, self._cam_name) + }, + "via_device": get_frigate_device_identifier(self._config_entry), + "name": get_friendly_name(self._cam_name), + "model": self._get_model(), + "configuration_url": f"{self._config_entry.data.get(CONF_URL)}/cameras/{self._cam_name if self._cam_name not in get_zones(self._frigate_config) else ''}", + "manufacturer": NAME, + } + + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return self._is_on + + @property + def device_class(self) -> str: + """Return the device class.""" + return cast(str, BinarySensorDeviceClass.MOTION) diff --git a/config/custom_components/frigate/camera.py b/config/custom_components/frigate/camera.py new file mode 100644 index 0000000..cd8c6b3 --- /dev/null +++ b/config/custom_components/frigate/camera.py @@ -0,0 +1,465 @@ +"""Support for Frigate cameras.""" +from __future__ import annotations + +import datetime +import logging +from typing import Any, cast + +import aiohttp +import async_timeout +from jinja2 import Template +import voluptuous as vol +from yarl import URL + +from custom_components.frigate.api import FrigateApiClient +from homeassistant.components.camera import Camera, CameraEntityFeature, StreamType +from homeassistant.components.mqtt import async_publish +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_platform +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import ( + FrigateDataUpdateCoordinator, + FrigateEntity, + FrigateMQTTEntity, + ReceiveMessage, + decode_if_necessary, + get_friendly_name, + get_frigate_device_identifier, + get_frigate_entity_unique_id, +) +from .const import ( + ATTR_CLIENT, + ATTR_CONFIG, + ATTR_COORDINATOR, + ATTR_END_TIME, + ATTR_EVENT_ID, + ATTR_FAVORITE, + ATTR_PLAYBACK_FACTOR, + ATTR_PTZ_ACTION, + ATTR_PTZ_ARGUMENT, + ATTR_START_TIME, + CONF_ENABLE_WEBRTC, + CONF_RTMP_URL_TEMPLATE, + CONF_RTSP_URL_TEMPLATE, + DEVICE_CLASS_CAMERA, + DOMAIN, + NAME, + SERVICE_EXPORT_RECORDING, + SERVICE_FAVORITE_EVENT, + SERVICE_PTZ, +) +from .views import get_frigate_instance_id_for_config_entry + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Camera entry setup.""" + + frigate_config = hass.data[DOMAIN][entry.entry_id][ATTR_CONFIG] + frigate_client = hass.data[DOMAIN][entry.entry_id][ATTR_CLIENT] + client_id = get_frigate_instance_id_for_config_entry(hass, entry) + coordinator = hass.data[DOMAIN][entry.entry_id][ATTR_COORDINATOR] + + async_add_entities( + [ + FrigateCamera( + entry, + cam_name, + frigate_client, + client_id, + coordinator, + frigate_config, + camera_config, + ) + for cam_name, camera_config in frigate_config["cameras"].items() + ] + + ( + [BirdseyeCamera(entry, frigate_client)] + if frigate_config.get("birdseye", {}).get("restream", False) + else [] + ) + ) + + # setup services + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( + SERVICE_EXPORT_RECORDING, + { + vol.Required(ATTR_PLAYBACK_FACTOR, default="realtime"): str, + vol.Required(ATTR_START_TIME): str, + vol.Required(ATTR_END_TIME): str, + }, + SERVICE_EXPORT_RECORDING, + ) + platform.async_register_entity_service( + SERVICE_FAVORITE_EVENT, + { + vol.Required(ATTR_EVENT_ID): str, + vol.Optional(ATTR_FAVORITE, default=True): bool, + }, + SERVICE_FAVORITE_EVENT, + ) + platform.async_register_entity_service( + SERVICE_PTZ, + { + vol.Required(ATTR_PTZ_ACTION): str, + vol.Optional(ATTR_PTZ_ARGUMENT, default=""): str, + }, + SERVICE_PTZ, + ) + + +class FrigateCamera(FrigateMQTTEntity, CoordinatorEntity, Camera): # type: ignore[misc] + """Representation of a Frigate camera.""" + + # sets the entity name to same as device name ex: camera.front_doorbell + _attr_name = None + + def __init__( + self, + config_entry: ConfigEntry, + cam_name: str, + frigate_client: FrigateApiClient, + frigate_client_id: Any | None, + coordinator: FrigateDataUpdateCoordinator, + frigate_config: dict[str, Any], + camera_config: dict[str, Any], + ) -> None: + """Initialize a Frigate camera.""" + self._client = frigate_client + self._client_id = frigate_client_id + self._frigate_config = frigate_config + self._camera_config = camera_config + self._cam_name = cam_name + super().__init__( + config_entry, + frigate_config, + { + "state_topic": { + "msg_callback": self._state_message_received, + "qos": 0, + "topic": ( + f"{self._frigate_config['mqtt']['topic_prefix']}" + f"/{self._cam_name}/recordings/state" + ), + "encoding": None, + }, + "motion_topic": { + "msg_callback": self._motion_message_received, + "qos": 0, + "topic": ( + f"{self._frigate_config['mqtt']['topic_prefix']}" + f"/{self._cam_name}/motion/state" + ), + "encoding": None, + }, + }, + ) + FrigateEntity.__init__(self, config_entry) + CoordinatorEntity.__init__(self, coordinator) + Camera.__init__(self) + self._url = config_entry.data[CONF_URL] + self._attr_is_on = True + # The device_class is used to filter out regular camera entities + # from motion camera entities on selectors + self._attr_device_class = DEVICE_CLASS_CAMERA + self._stream_source = None + self._attr_is_streaming = ( + self._camera_config.get("rtmp", {}).get("enabled") + or self._cam_name + in self._frigate_config.get("go2rtc", {}).get("streams", {}).keys() + ) + self._attr_is_recording = self._camera_config.get("record", {}).get("enabled") + self._attr_motion_detection_enabled = self._camera_config.get("motion", {}).get( + "enabled" + ) + self._ptz_topic = ( + f"{frigate_config['mqtt']['topic_prefix']}" f"/{self._cam_name}/ptz" + ) + self._set_motion_topic = ( + f"{frigate_config['mqtt']['topic_prefix']}" f"/{self._cam_name}/motion/set" + ) + + if ( + self._cam_name + in self._frigate_config.get("go2rtc", {}).get("streams", {}).keys() + ): + if config_entry.options.get(CONF_ENABLE_WEBRTC, False): + self._restream_type = "webrtc" + self._attr_frontend_stream_type = StreamType.WEB_RTC + else: + self._restream_type = "rtsp" + self._attr_frontend_stream_type = StreamType.HLS + + streaming_template = config_entry.options.get( + CONF_RTSP_URL_TEMPLATE, "" + ).strip() + + if streaming_template: + # Can't use homeassistant.helpers.template as it requires hass which + # is not available in the constructor, so use direct jinja2 + # template instead. This means templates cannot access HomeAssistant + # state, but rather only the camera config. + self._stream_source = Template(streaming_template).render( + **self._camera_config + ) + else: + self._stream_source = ( + f"rtsp://{URL(self._url).host}:8554/{self._cam_name}" + ) + elif self._camera_config.get("rtmp", {}).get("enabled"): + self._restream_type = "rtmp" + streaming_template = config_entry.options.get( + CONF_RTMP_URL_TEMPLATE, "" + ).strip() + + if streaming_template: + # Can't use homeassistant.helpers.template as it requires hass which + # is not available in the constructor, so use direct jinja2 + # template instead. This means templates cannot access HomeAssistant + # state, but rather only the camera config. + self._stream_source = Template(streaming_template).render( + **self._camera_config + ) + else: + self._stream_source = ( + f"rtmp://{URL(self._url).host}/live/{self._cam_name}" + ) + else: + self._restream_type = "none" + + @callback # type: ignore[misc] + def _state_message_received(self, msg: ReceiveMessage) -> None: + """Handle a new received MQTT state message.""" + self._attr_is_recording = decode_if_necessary(msg.payload) == "ON" + self.async_write_ha_state() + + @callback # type: ignore[misc] + def _motion_message_received(self, msg: ReceiveMessage) -> None: + """Handle a new received MQTT extra message.""" + self._attr_motion_detection_enabled = decode_if_necessary(msg.payload) == "ON" + self.async_write_ha_state() + + @property + def available(self) -> bool: + """Signal when frigate loses connection to camera.""" + if self.coordinator.data: + if ( + self.coordinator.data.get("cameras", {}) + .get(self._cam_name, {}) + .get("camera_fps", 0) + == 0 + ): + return False + return super().available + + @property + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return get_frigate_entity_unique_id( + self._config_entry.entry_id, + "camera", + self._cam_name, + ) + + @property + def device_info(self) -> dict[str, Any]: + """Return the device information.""" + return { + "identifiers": { + get_frigate_device_identifier(self._config_entry, self._cam_name) + }, + "via_device": get_frigate_device_identifier(self._config_entry), + "name": get_friendly_name(self._cam_name), + "model": self._get_model(), + "configuration_url": f"{self._url}/cameras/{self._cam_name}", + "manufacturer": NAME, + } + + @property + def extra_state_attributes(self) -> dict[str, str]: + """Return entity specific state attributes.""" + return { + "client_id": str(self._client_id), + "camera_name": self._cam_name, + "restream_type": self._restream_type, + } + + @property + def supported_features(self) -> CameraEntityFeature: + """Return supported features of this camera.""" + if not self._attr_is_streaming: + return CameraEntityFeature(0) + + return CameraEntityFeature.STREAM + + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: + """Return bytes of camera image.""" + websession = cast(aiohttp.ClientSession, async_get_clientsession(self.hass)) + + image_url = str( + URL(self._url) + / f"api/{self._cam_name}/latest.jpg" + % ({"h": height} if height is not None and height > 0 else {}) + ) + + async with async_timeout.timeout(10): + response = await websession.get(image_url) + return await response.read() + + async def stream_source(self) -> str | None: + """Return the source of the stream.""" + if not self._attr_is_streaming: + return None + return self._stream_source + + async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str | None: + """Handle the WebRTC offer and return an answer.""" + websession = cast(aiohttp.ClientSession, async_get_clientsession(self.hass)) + url = f"{self._url}/api/go2rtc/webrtc?src={self._cam_name}" + payload = {"type": "offer", "sdp": offer_sdp} + async with websession.post(url, json=payload) as resp: + answer = await resp.json() + return cast(str, answer["sdp"]) + + async def async_enable_motion_detection(self) -> None: + """Enable motion detection for this camera.""" + await async_publish( + self.hass, + self._set_motion_topic, + "ON", + 0, + False, + ) + + async def async_disable_motion_detection(self) -> None: + """Disable motion detection for this camera.""" + await async_publish( + self.hass, + self._set_motion_topic, + "OFF", + 0, + False, + ) + + async def export_recording( + self, playback_factor: str, start_time: str, end_time: str + ) -> None: + """Export recording.""" + await self._client.async_export_recording( + self._cam_name, + playback_factor, + datetime.datetime.strptime(start_time, "%Y-%m-%d %H:%M:%S").timestamp(), + datetime.datetime.strptime(end_time, "%Y-%m-%d %H:%M:%S").timestamp(), + ) + + async def favorite_event(self, event_id: str, favorite: bool) -> None: + """Favorite an event.""" + await self._client.async_retain(event_id, favorite) + + async def ptz(self, action: str, argument: str) -> None: + """Run PTZ command.""" + await async_publish( + self.hass, + self._ptz_topic, + f"{action}{f'_{argument}' if argument else ''}", + 0, + False, + ) + + +class BirdseyeCamera(FrigateEntity, Camera): # type: ignore[misc] + """Representation of the Frigate birdseye camera.""" + + # sets the entity name to same as device name ex: camera.front_doorbell + _attr_name = None + + def __init__( + self, + config_entry: ConfigEntry, + frigate_client: FrigateApiClient, + ) -> None: + """Initialize the birdseye camera.""" + self._client = frigate_client + FrigateEntity.__init__(self, config_entry) + Camera.__init__(self) + self._url = config_entry.data[CONF_URL] + self._attr_is_on = True + # The device_class is used to filter out regular camera entities + # from motion camera entities on selectors + self._attr_device_class = DEVICE_CLASS_CAMERA + self._attr_is_streaming = True + self._attr_is_recording = False + + streaming_template = config_entry.options.get( + CONF_RTSP_URL_TEMPLATE, "" + ).strip() + + if streaming_template: + # Can't use homeassistant.helpers.template as it requires hass which + # is not available in the constructor, so use direct jinja2 + # template instead. This means templates cannot access HomeAssistant + # state, but rather only the camera config. + self._stream_source = Template(streaming_template).render( + {"name": "birdseye"} + ) + else: + self._stream_source = f"rtsp://{URL(self._url).host}:8554/birdseye" + + @property + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return get_frigate_entity_unique_id( + self._config_entry.entry_id, + "camera", + "birdseye", + ) + + @property + def device_info(self) -> dict[str, Any]: + """Return the device information.""" + return { + "identifiers": { + get_frigate_device_identifier(self._config_entry, "birdseye") + }, + "via_device": get_frigate_device_identifier(self._config_entry), + "name": "Birdseye", + "model": self._get_model(), + "configuration_url": f"{self._url}/cameras/birdseye", + "manufacturer": NAME, + } + + @property + def supported_features(self) -> CameraEntityFeature: + """Return supported features of this camera.""" + return CameraEntityFeature.STREAM + + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: + """Return bytes of camera image.""" + websession = cast(aiohttp.ClientSession, async_get_clientsession(self.hass)) + + image_url = str( + URL(self._url) + / "api/birdseye/latest.jpg" + % ({"h": height} if height is not None and height > 0 else {}) + ) + + async with async_timeout.timeout(10): + response = await websession.get(image_url) + return await response.read() + + async def stream_source(self) -> str | None: + """Return the source of the stream.""" + return self._stream_source diff --git a/config/custom_components/frigate/config_flow.py b/config/custom_components/frigate/config_flow.py new file mode 100644 index 0000000..6f10f7a --- /dev/null +++ b/config/custom_components/frigate/config_flow.py @@ -0,0 +1,192 @@ +"""Adds config flow for Frigate.""" +from __future__ import annotations + +import logging +from typing import Any, Dict, cast + +import voluptuous as vol +from voluptuous.validators import All, Range +from yarl import URL + +from homeassistant import config_entries +from homeassistant.const import CONF_URL +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_create_clientsession + +from .api import FrigateApiClient, FrigateApiClientError +from .const import ( + CONF_ENABLE_WEBRTC, + CONF_MEDIA_BROWSER_ENABLE, + CONF_NOTIFICATION_PROXY_ENABLE, + CONF_NOTIFICATION_PROXY_EXPIRE_AFTER_SECONDS, + CONF_RTMP_URL_TEMPLATE, + CONF_RTSP_URL_TEMPLATE, + DEFAULT_HOST, + DOMAIN, +) + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +def get_config_entry_title(url_str: str) -> str: + """Get the title of a config entry from the URL.""" + + # Strip the scheme from the URL as it's not that interesting in the title + # and space is limited on the integrations page. + url = URL(url_str) + return str(url)[len(url.scheme + "://") :] + + +class FrigateFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # type: ignore[call-arg,misc] + """Config flow for Frigate.""" + + VERSION = 2 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> dict[str, Any]: + """Handle a flow initialized by the user.""" + + if user_input is None: + return self._show_config_form() + + try: + # Cannot use cv.url validation in the schema itself, so + # apply extra validation here. + cv.url(user_input[CONF_URL]) + except vol.Invalid: + return self._show_config_form(user_input, errors={"base": "invalid_url"}) + + try: + session = async_create_clientsession(self.hass) + client = FrigateApiClient(user_input[CONF_URL], session) + await client.async_get_stats() + except FrigateApiClientError: + return self._show_config_form(user_input, errors={"base": "cannot_connect"}) + + # Search for duplicates with the same Frigate CONF_HOST value. + for existing_entry in self._async_current_entries(include_ignore=False): + if existing_entry.data.get(CONF_URL) == user_input[CONF_URL]: + return cast( + Dict[str, Any], self.async_abort(reason="already_configured") + ) + + return cast( + Dict[str, Any], + self.async_create_entry( + title=get_config_entry_title(user_input[CONF_URL]), data=user_input + ), + ) + + def _show_config_form( + self, + user_input: dict[str, Any] | None = None, + errors: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Show the configuration form.""" + if user_input is None: + user_input = {} + + return cast( + Dict[str, Any], + self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_URL, default=user_input.get(CONF_URL, DEFAULT_HOST) + ): str + } + ), + errors=errors, + ), + ) + + @staticmethod + @callback # type: ignore[misc] + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> FrigateOptionsFlowHandler: + """Get the Frigate Options flow.""" + return FrigateOptionsFlowHandler(config_entry) + + +class FrigateOptionsFlowHandler(config_entries.OptionsFlow): # type: ignore[misc] + """Frigate options flow.""" + + def __init__(self, config_entry: config_entries.ConfigEntry): + """Initialize a Frigate options flow.""" + self._config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> dict[str, Any]: + """Manage the options.""" + if user_input is not None: + return cast( + Dict[str, Any], self.async_create_entry(title="", data=user_input) + ) + + if not self.show_advanced_options: + return cast( + Dict[str, Any], self.async_abort(reason="only_advanced_options") + ) + + schema: dict[Any, Any] = { + # Whether to enable webrtc as the medium for camera streaming + vol.Optional( + CONF_ENABLE_WEBRTC, + default=self._config_entry.options.get( + CONF_ENABLE_WEBRTC, + False, + ), + ): bool, + # The input URL is not validated as being a URL to allow for the + # possibility the template input won't be a valid URL until after + # it's rendered. + vol.Optional( + CONF_RTMP_URL_TEMPLATE, + default=self._config_entry.options.get( + CONF_RTMP_URL_TEMPLATE, + "", + ), + ): str, + # The input URL is not validated as being a URL to allow for the + # possibility the template input won't be a valid URL until after + # it's rendered. + vol.Optional( + CONF_RTSP_URL_TEMPLATE, + default=self._config_entry.options.get( + CONF_RTSP_URL_TEMPLATE, + "", + ), + ): str, + vol.Optional( + CONF_NOTIFICATION_PROXY_ENABLE, + default=self._config_entry.options.get( + CONF_NOTIFICATION_PROXY_ENABLE, + True, + ), + ): bool, + vol.Optional( + CONF_MEDIA_BROWSER_ENABLE, + default=self._config_entry.options.get( + CONF_MEDIA_BROWSER_ENABLE, + True, + ), + ): bool, + vol.Optional( + CONF_NOTIFICATION_PROXY_EXPIRE_AFTER_SECONDS, + default=self._config_entry.options.get( + CONF_NOTIFICATION_PROXY_EXPIRE_AFTER_SECONDS, + 0, + ), + ): All(int, Range(min=0)), + } + + return cast( + Dict[str, Any], + self.async_show_form(step_id="init", data_schema=vol.Schema(schema)), + ) diff --git a/config/custom_components/frigate/const.py b/config/custom_components/frigate/const.py new file mode 100644 index 0000000..152152c --- /dev/null +++ b/config/custom_components/frigate/const.py @@ -0,0 +1,93 @@ +"""Constants for frigate.""" +# Base component constants +NAME = "Frigate" +DOMAIN = "frigate" +FRIGATE_VERSION_ERROR_CUTOFF = "0.12.1" +FRIGATE_RELEASES_URL = "https://github.com/blakeblackshear/frigate/releases" +FRIGATE_RELEASE_TAG_URL = f"{FRIGATE_RELEASES_URL}/tag" + +# Platforms +BINARY_SENSOR = "binary_sensor" +NUMBER = "number" +SENSOR = "sensor" +SWITCH = "switch" +CAMERA = "camera" +IMAGE = "image" +UPDATE = "update" +PLATFORMS = [SENSOR, CAMERA, IMAGE, NUMBER, SWITCH, BINARY_SENSOR, UPDATE] + +# Device Classes +# This device class does not exist in HA, but we use it to be able +# to filter cameras in selectors +DEVICE_CLASS_CAMERA = "camera" + +# Unit of measurement +FPS = "fps" +MS = "ms" + +# Attributes +ATTR_CLIENT = "client" +ATTR_CLIENT_ID = "client_id" +ATTR_CONFIG = "config" +ATTR_COORDINATOR = "coordinator" +ATTR_END_TIME = "end_time" +ATTR_EVENT_ID = "event_id" +ATTR_FAVORITE = "favorite" +ATTR_MQTT = "mqtt" +ATTR_PLAYBACK_FACTOR = "playback_factor" +ATTR_PTZ_ACTION = "action" +ATTR_PTZ_ARGUMENT = "argument" +ATTR_START_TIME = "start_time" + +# Frigate Attribute Labels +# These are labels that are not individually tracked as they are +# attributes of another label. ex: face is an attribute of person +ATTRIBUTE_LABELS = ["amazon", "face", "fedex", "license_plate", "ups"] + +# Configuration and options +CONF_CAMERA_STATIC_IMAGE_HEIGHT = "camera_image_height" +CONF_MEDIA_BROWSER_ENABLE = "media_browser_enable" +CONF_NOTIFICATION_PROXY_ENABLE = "notification_proxy_enable" +CONF_PASSWORD = "password" +CONF_PATH = "path" +CONF_RTMP_URL_TEMPLATE = "rtmp_url_template" +CONF_RTSP_URL_TEMPLATE = "rtsp_url_template" +CONF_ENABLE_WEBRTC = "enable_webrtc" +CONF_NOTIFICATION_PROXY_EXPIRE_AFTER_SECONDS = "notification_proxy_expire_after_seconds" + +# Defaults +DEFAULT_NAME = DOMAIN +DEFAULT_HOST = "http://ccab4aaf-frigate:5000" + + +STARTUP_MESSAGE = """ +------------------------------------------------------------------- +%s +Integration Version: %s +This is a custom integration! +If you have any issues with this you need to open an issue here: +https://github.com/blakeblackshear/frigate-hass-integration/issues +------------------------------------------------------------------- +""" + +# Min Values +MAX_CONTOUR_AREA = 50 +MAX_THRESHOLD = 255 + +# Min Values +MIN_CONTOUR_AREA = 1 +MIN_THRESHOLD = 1 + +# States +STATE_DETECTED = "active" +STATE_IDLE = "idle" + +# Statuses +STATUS_ERROR = "error" +STATUS_RUNNING = "running" +STATUS_STARTING = "starting" + +# Frigate Services +SERVICE_EXPORT_RECORDING = "export_recording" +SERVICE_FAVORITE_EVENT = "favorite_event" +SERVICE_PTZ = "ptz" diff --git a/config/custom_components/frigate/diagnostics.py b/config/custom_components/frigate/diagnostics.py new file mode 100644 index 0000000..1aea476 --- /dev/null +++ b/config/custom_components/frigate/diagnostics.py @@ -0,0 +1,37 @@ +"""Diagnostics support for Frigate.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import ATTR_CLIENT, ATTR_CONFIG, CONF_PASSWORD, CONF_PATH, DOMAIN + +REDACT_CONFIG = {CONF_PASSWORD, CONF_PATH} + + +def get_redacted_data(data: dict[str, Any]) -> Any: + """Redact sensitive vales from data.""" + return async_redact_data(data, REDACT_CONFIG) + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + entry: ConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + config = hass.data[DOMAIN][entry.entry_id][ATTR_CONFIG] + redacted_config = get_redacted_data(config) + + stats = await hass.data[DOMAIN][entry.entry_id][ATTR_CLIENT].async_get_stats() + redacted_stats = get_redacted_data(stats) + + data = { + "frigate_config": redacted_config, + "frigate_stats": redacted_stats, + } + return data diff --git a/config/custom_components/frigate/icons.py b/config/custom_components/frigate/icons.py new file mode 100644 index 0000000..e8a1280 --- /dev/null +++ b/config/custom_components/frigate/icons.py @@ -0,0 +1,80 @@ +"""Handles icons for different entity types.""" + +ICON_AUDIO = "mdi:ear-hearing" +ICON_AUDIO_OFF = "mdi:ear-hearing-off" +ICON_PTZ_AUTOTRACKER = "mdi:cctv" +ICON_BICYCLE = "mdi:bicycle" +ICON_CAR = "mdi:car" +ICON_CAT = "mdi:cat" +ICON_CONTRAST = "mdi:contrast-circle" +ICON_CORAL = "mdi:scoreboard-outline" +ICON_COW = "mdi:cow" +ICON_DOG = "mdi:dog-side" +ICON_FILM_MULTIPLE = "mdi:filmstrip-box-multiple" +ICON_HORSE = "mdi:horse" +ICON_IMAGE_MULTIPLE = "mdi:image-multiple" +ICON_MOTION_SENSOR = "mdi:motion-sensor" +ICON_MOTORCYCLE = "mdi:motorbike" +ICON_OTHER = "mdi:shield-alert" +ICON_PERSON = "mdi:human" +ICON_SERVER = "mdi:server" +ICON_SPEEDOMETER = "mdi:speedometer" +ICON_WAVEFORM = "mdi:waveform" + +ICON_DEFAULT_ON = "mdi:home" + +ICON_CAR_OFF = "mdi:car-off" +ICON_DEFAULT_OFF = "mdi:home-outline" +ICON_DOG_OFF = "mdi:dog-side-off" + + +def get_dynamic_icon_from_type(obj_type: str, is_on: bool) -> str: + """Get icon for a specific object type and current state.""" + + if obj_type == "car": + return ICON_CAR if is_on else ICON_CAR_OFF + if obj_type == "dog": + return ICON_DOG if is_on else ICON_DOG_OFF + if obj_type == "sound": + return ICON_AUDIO if is_on else ICON_AUDIO_OFF + + return ICON_DEFAULT_ON if is_on else ICON_DEFAULT_OFF + + +def get_icon_from_switch(switch_type: str) -> str: + """Get icon for a specific switch type.""" + if switch_type == "snapshots": + return ICON_IMAGE_MULTIPLE + if switch_type == "recordings": + return ICON_FILM_MULTIPLE + if switch_type == "improve_contrast": + return ICON_CONTRAST + if switch_type == "audio": + return ICON_AUDIO + if switch_type == "ptz_autotracker": + return ICON_PTZ_AUTOTRACKER + + return ICON_MOTION_SENSOR + + +def get_icon_from_type(obj_type: str) -> str: + """Get icon for a specific object type.""" + + if obj_type == "person": + return ICON_PERSON + if obj_type == "car": + return ICON_CAR + if obj_type == "dog": + return ICON_DOG + if obj_type == "cat": + return ICON_CAT + if obj_type == "motorcycle": + return ICON_MOTORCYCLE + if obj_type == "bicycle": + return ICON_BICYCLE + if obj_type == "cow": + return ICON_COW + if obj_type == "horse": + return ICON_HORSE + + return ICON_OTHER diff --git a/config/custom_components/frigate/image.py b/config/custom_components/frigate/image.py new file mode 100644 index 0000000..7d6c556 --- /dev/null +++ b/config/custom_components/frigate/image.py @@ -0,0 +1,123 @@ +"""Support for Frigate images.""" +from __future__ import annotations + +import datetime +import logging +from typing import Any + +from homeassistant.components.image import ImageEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ( + FrigateMQTTEntity, + ReceiveMessage, + get_cameras_and_objects, + get_friendly_name, + get_frigate_device_identifier, + get_frigate_entity_unique_id, +) +from .const import ATTR_CONFIG, DOMAIN, NAME + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Image entry setup.""" + + frigate_config = hass.data[DOMAIN][entry.entry_id][ATTR_CONFIG] + + async_add_entities( + [ + FrigateMqttSnapshots(hass, entry, frigate_config, cam_name, obj_name) + for cam_name, obj_name in get_cameras_and_objects(frigate_config, False) + ] + ) + + +class FrigateMqttSnapshots(FrigateMQTTEntity, ImageEntity): # type: ignore[misc] + """Frigate best image class.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + frigate_config: dict[str, Any], + cam_name: str, + obj_name: str, + ) -> None: + """Construct a FrigateMqttSnapshots image.""" + self._frigate_config = frigate_config + self._cam_name = cam_name + self._obj_name = obj_name + self._last_image_timestamp: datetime.datetime | None = None + self._last_image: bytes | None = None + + FrigateMQTTEntity.__init__( + self, + config_entry, + frigate_config, + { + "state_topic": { + "msg_callback": self._state_message_received, + "qos": 0, + "topic": ( + f"{self._frigate_config['mqtt']['topic_prefix']}" + f"/{self._cam_name}/{self._obj_name}/snapshot" + ), + "encoding": None, + }, + }, + ) + ImageEntity.__init__(self, hass) + + @callback # type: ignore[misc] + def _state_message_received(self, msg: ReceiveMessage) -> None: + """Handle a new received MQTT state message.""" + self._last_image_timestamp = datetime.datetime.now() + self._last_image = msg.payload + self.async_write_ha_state() + + @property + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return get_frigate_entity_unique_id( + self._config_entry.entry_id, + "image_best_snapshot", + f"{self._cam_name}_{self._obj_name}", + ) + + @property + def device_info(self) -> DeviceInfo: + """Get the device information.""" + return { + "identifiers": { + get_frigate_device_identifier(self._config_entry, self._cam_name) + }, + "via_device": get_frigate_device_identifier(self._config_entry), + "name": get_friendly_name(self._cam_name), + "model": self._get_model(), + "configuration_url": f"{self._config_entry.data.get(CONF_URL)}/cameras/{self._cam_name}", + "manufacturer": NAME, + } + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return self._obj_name.title() + + @property + def image_last_updated(self) -> datetime.datetime | None: + """Return timestamp of last image update.""" + return self._last_image_timestamp + + def image( + self, + ) -> bytes | None: # pragma: no cover (HA currently does not support a direct way to test this) + """Return bytes of image.""" + return self._last_image diff --git a/config/custom_components/frigate/manifest.json b/config/custom_components/frigate/manifest.json new file mode 100644 index 0000000..60119bb --- /dev/null +++ b/config/custom_components/frigate/manifest.json @@ -0,0 +1,18 @@ +{ + "domain": "frigate", + "name": "Frigate", + "codeowners": [ + "@blakeblackshear" + ], + "config_flow": true, + "dependencies": [ + "http", + "media_source", + "mqtt" + ], + "documentation": "https://github.com/blakeblackshear/frigate", + "iot_class": "local_push", + "issue_tracker": "https://github.com/blakeblackshear/frigate-hass-integration/issues", + "requirements": ["pytz"], + "version": "5.2.0" +} diff --git a/config/custom_components/frigate/media_source.py b/config/custom_components/frigate/media_source.py new file mode 100644 index 0000000..77e881e --- /dev/null +++ b/config/custom_components/frigate/media_source.py @@ -0,0 +1,1346 @@ +"""Frigate Media Source.""" +from __future__ import annotations + +import datetime as dt +import enum +import logging +from typing import Any, cast + +import attr +from dateutil.relativedelta import relativedelta +import pytz + +from homeassistant.components.media_player.const import ( + MEDIA_CLASS_DIRECTORY, + MEDIA_CLASS_IMAGE, + MEDIA_CLASS_MOVIE, + MEDIA_CLASS_VIDEO, + MEDIA_TYPE_IMAGE, + MEDIA_TYPE_VIDEO, +) +from homeassistant.components.media_source.error import MediaSourceError, Unresolvable +from homeassistant.components.media_source.models import ( + BrowseMediaSource, + MediaSource, + MediaSourceItem, + PlayMedia, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import system_info +from homeassistant.helpers.template import DATE_STR_FORMAT +from homeassistant.util.dt import DEFAULT_TIME_ZONE + +from . import get_friendly_name +from .api import FrigateApiClient, FrigateApiClientError +from .const import CONF_MEDIA_BROWSER_ENABLE, DOMAIN, NAME +from .views import ( + get_client_for_frigate_instance_id, + get_config_entry_for_frigate_instance_id, + get_default_config_entry, + get_frigate_instance_id_for_config_entry, +) + +_LOGGER = logging.getLogger(__name__) + +ITEM_LIMIT = 50 +SECONDS_IN_DAY = 60 * 60 * 24 +SECONDS_IN_MONTH = SECONDS_IN_DAY * 31 + + +async def async_get_media_source(hass: HomeAssistant) -> MediaSource: + """Set up Frigate media source.""" + return FrigateMediaSource(hass) + + +class FrigateBrowseMediaMetadata: + """Metadata for browsable Frigate media files.""" + + event: dict[str, Any] | None + + def __init__(self, event: dict[str, Any]): + """Initialize a FrigateBrowseMediaMetadata object.""" + self.event = event + + def as_dict(self) -> dict: + """Convert the object to a dictionary.""" + return {"event": self.event} + + +class FrigateBrowseMediaSource(BrowseMediaSource): # type: ignore[misc] + """Represent a browsable Frigate media file.""" + + children: list[FrigateBrowseMediaSource] | None + frigate: FrigateBrowseMediaMetadata + + def as_dict(self, *args: Any, **kwargs: Any) -> dict: + """Convert the object to a dictionary.""" + res: dict = super().as_dict(*args, **kwargs) + res["frigate"] = self.frigate.as_dict() + return res + + def __init__( + self, frigate: FrigateBrowseMediaMetadata, *args: Any, **kwargs: Any + ) -> None: + """Initialize media source browse media.""" + super().__init__(*args, **kwargs) + self.frigate = frigate + + +@attr.s(frozen=True) +class Identifier: + """Base class for Identifiers.""" + + frigate_instance_id: str = attr.ib( + validator=[attr.validators.instance_of(str)], + ) + + @classmethod + def _get_index(cls, data: list, index: int, default: Any = None) -> Any: + try: + return data[index] if data[index] != "" else default + except IndexError: + return default + + @classmethod + def _empty_if_none(cls, data: Any) -> str: + """Return an empty string if data is None.""" + return str(data) if data is not None else "" + + @classmethod + def from_str( + cls, + data: str, + default_frigate_instance_id: str | None = None, + ) -> EventSearchIdentifier | EventIdentifier | RecordingIdentifier | None: + """Generate a EventSearchIdentifier from a string.""" + return ( + EventSearchIdentifier.from_str(data, default_frigate_instance_id) + or EventIdentifier.from_str(data, default_frigate_instance_id) + or RecordingIdentifier.from_str(data, default_frigate_instance_id) + ) + + @classmethod + def get_identifier_type(cls) -> str: + """Get the identifier type.""" + raise NotImplementedError + + def get_integration_proxy_path(self, timezone: str) -> str: + """Get the proxy (Home Assistant view) path for this identifier.""" + raise NotImplementedError + + @classmethod + def _add_frigate_instance_id_to_parts_if_absent( + cls, parts: list[str], default_frigate_instance_id: str | None = None + ) -> list[str]: + """Add a frigate instance id if it's not specified.""" + if ( + cls._get_index(parts, 0) == cls.get_identifier_type() + and default_frigate_instance_id is not None + ): + parts.insert(0, default_frigate_instance_id) + return parts + + @property + def mime_type(self) -> str: + """Get mime type for this identifier.""" + raise NotImplementedError + + @property + def media_type(self) -> str: + """Get media type for this identifier.""" + raise NotImplementedError + + @property + def media_class(self) -> str: + """Get media class for this identifier.""" + raise NotImplementedError + + +class FrigateMediaType(enum.Enum): + """Type of media this identifier represents.""" + + CLIPS = "clips" + SNAPSHOTS = "snapshots" + + @property + def mime_type(self) -> str: + """Get mime type for this frigate media type.""" + if self == FrigateMediaType.CLIPS: + return "application/x-mpegURL" + return "image/jpg" + + @property + def media_type(self) -> str: + """Get media type for this frigate media type.""" + if self == FrigateMediaType.CLIPS: + return str(MEDIA_TYPE_VIDEO) + return str(MEDIA_TYPE_IMAGE) + + @property + def media_class(self) -> str: + """Get media class for this frigate media type.""" + if self == FrigateMediaType.CLIPS: + return str(MEDIA_CLASS_VIDEO) + return str(MEDIA_CLASS_IMAGE) + + @property + def extension(self) -> str: + """Get filename extension.""" + if self == FrigateMediaType.CLIPS: + return "m3u8" + return "jpg" + + +@attr.s(frozen=True) +class EventIdentifier(Identifier): + """Event Identifier (clip or snapshot).""" + + frigate_media_type: FrigateMediaType = attr.ib( + validator=[attr.validators.in_(FrigateMediaType)] + ) + + id: str = attr.ib( + validator=[attr.validators.instance_of(str)], + ) + + camera: str = attr.ib( + validator=[attr.validators.instance_of(str)], + ) + + def __str__(self) -> str: + """Convert to a string.""" + return "/".join( + ( + self.frigate_instance_id, + self.get_identifier_type(), + self.frigate_media_type.value, + self.camera, + self.id, + ) + ) + + @classmethod + def from_str( + cls, data: str, default_frigate_instance_id: str | None = None + ) -> EventIdentifier | None: + """Generate a EventIdentifier from a string.""" + parts = cls._add_frigate_instance_id_to_parts_if_absent( + data.split("/"), default_frigate_instance_id + ) + + if len(parts) != 5 or parts[1] != cls.get_identifier_type(): + return None + + try: + return cls( + frigate_instance_id=parts[0], + frigate_media_type=FrigateMediaType(parts[2]), + camera=parts[3], + id=parts[4], + ) + except ValueError: + return None + + @classmethod + def get_identifier_type(cls) -> str: + """Get the identifier type.""" + return "event" + + def get_integration_proxy_path(self, timezone: str) -> str: + """Get the equivalent Frigate server path.""" + if self.frigate_media_type == FrigateMediaType.CLIPS: + return f"vod/event/{self.id}/index.{self.frigate_media_type.extension}" + return f"snapshot/{self.id}" + + @property + def mime_type(self) -> str: + """Get mime type for this identifier.""" + return self.frigate_media_type.mime_type + + +def _to_int_or_none(data: str) -> int | None: + """Convert to an integer or None.""" + return int(data) if data is not None else None + + +@attr.s(frozen=True) +class EventSearchIdentifier(Identifier): + """Event Search Identifier.""" + + frigate_media_type: FrigateMediaType = attr.ib( + validator=[attr.validators.in_(FrigateMediaType)] + ) + name: str = attr.ib( + default="", + validator=[attr.validators.instance_of(str)], + ) + after: int | None = attr.ib( + default=None, + converter=_to_int_or_none, + validator=[attr.validators.instance_of((int, type(None)))], + ) + before: int | None = attr.ib( + default=None, + converter=_to_int_or_none, + validator=[attr.validators.instance_of((int, type(None)))], + ) + camera: str | None = attr.ib( + default=None, validator=[attr.validators.instance_of((str, type(None)))] + ) + label: str | None = attr.ib( + default=None, validator=[attr.validators.instance_of((str, type(None)))] + ) + zone: str | None = attr.ib( + default=None, validator=[attr.validators.instance_of((str, type(None)))] + ) + + @classmethod + def from_str( + cls, data: str, default_frigate_instance_id: str | None = None + ) -> EventSearchIdentifier | None: + """Generate a EventSearchIdentifier from a string.""" + parts = cls._add_frigate_instance_id_to_parts_if_absent( + data.split("/"), default_frigate_instance_id + ) + + if len(parts) < 3 or parts[1] != cls.get_identifier_type(): + return None + + try: + return cls( + frigate_instance_id=cls._get_index(parts, 0), + frigate_media_type=FrigateMediaType(cls._get_index(parts, 2)), + name=cls._get_index(parts, 3, ""), + after=cls._get_index(parts, 4), + before=cls._get_index(parts, 5), + camera=cls._get_index(parts, 6), + label=cls._get_index(parts, 7), + zone=cls._get_index(parts, 8), + ) + except ValueError: + return None + + def __str__(self) -> str: + """Convert to a string.""" + + return "/".join( + [self.frigate_instance_id, self.get_identifier_type()] + + [ + self._empty_if_none(val) + for val in ( + self.frigate_media_type.value, + self.name, + self.after, + self.before, + self.camera, + self.label, + self.zone, + ) + ] + ) + + def is_root(self) -> bool: + """Determine if an identifier is an event root for a given server.""" + return not any( + [self.name, self.after, self.before, self.camera, self.label, self.zone] + ) + + @classmethod + def get_identifier_type(cls) -> str: + """Get the identifier type.""" + return "event-search" + + @property + def media_type(self) -> str: + """Get mime type for this identifier.""" + return self.frigate_media_type.media_type + + @property + def media_class(self) -> str: + """Get media class for this identifier.""" + return self.frigate_media_type.media_class + + +def _validate_year_month_day( + inst: RecordingIdentifier, attribute: attr.Attribute, data: str | None +) -> None: + """Validate input.""" + if data: + try: + dt.datetime.strptime(data, "%Y-%m-%d") + except ValueError as exc: + raise ValueError(f"Invalid date in identifier: {data}") from exc + + +def _validate_hour( + inst: RecordingIdentifier, attribute: attr.Attribute, value: int | None +) -> None: + """Determine if a value is a valid hour.""" + if value is not None and (int(value) < 0 or int(value) > 23): + raise ValueError(f"Invalid hour in identifier: {value}") + + +@attr.s(frozen=True) +class RecordingIdentifier(Identifier): + """Recording Identifier.""" + + camera: str | None = attr.ib( + default=None, validator=[attr.validators.instance_of((str, type(None)))] + ) + + year_month_day: str | None = attr.ib( + default=None, + validator=[ + attr.validators.instance_of((str, type(None))), + _validate_year_month_day, + ], + ) + + hour: int | None = attr.ib( + default=None, + converter=_to_int_or_none, + validator=[ + attr.validators.instance_of((int, type(None))), + _validate_hour, + ], + ) + + @classmethod + def from_str( + cls, data: str, default_frigate_instance_id: str | None = None + ) -> RecordingIdentifier | None: + """Generate a RecordingIdentifier from a string.""" + parts = cls._add_frigate_instance_id_to_parts_if_absent( + data.split("/"), default_frigate_instance_id + ) + + if len(parts) < 2 or parts[1] != cls.get_identifier_type(): + return None + + try: + return cls( + frigate_instance_id=parts[0], + camera=cls._get_index(parts, 2), + year_month_day=cls._get_index(parts, 3), + hour=cls._get_index(parts, 4), + ) + except ValueError: + return None + + def __str__(self) -> str: + """Convert to a string.""" + return "/".join( + [self.frigate_instance_id, self.get_identifier_type()] + + [ + self._empty_if_none(val) + for val in ( + self.camera, + f"{self.year_month_day}" + if self.year_month_day is not None + else None, + f"{self.hour:02}" if self.hour is not None else None, + ) + ] + ) + + @classmethod + def get_identifier_type(cls) -> str: + """Get the identifier type.""" + return "recordings" + + def get_integration_proxy_path(self, timezone: str) -> str: + """Get the integration path that will proxy this identifier.""" + + if ( + self.camera is not None + and self.year_month_day is not None + and self.hour is not None + ): + year, month, day = self.year_month_day.split("-") + # Take the selected time in users local time and find the offset to + # UTC, convert to UTC then request the vod for that time. + start_date: dt.datetime = dt.datetime( + int(year), + int(month), + int(day), + int(self.hour), + tzinfo=dt.timezone.utc, + ) - (dt.datetime.now(pytz.timezone(timezone)).utcoffset() or dt.timedelta()) + + parts = [ + "vod", + f"{start_date.year}-{start_date.month:02}", + f"{start_date.day:02}", + f"{start_date.hour:02}", + self.camera, + "utc", + "index.m3u8", + ] + + return "/".join(parts) + + raise MediaSourceError( + "Can not get proxy-path without year_month_day and hour." + ) + + @property + def mime_type(self) -> str: + """Get mime type for this identifier.""" + return "application/x-mpegURL" + + @property + def media_class(self) -> str: + """Get media class for this identifier.""" + return str(MEDIA_CLASS_MOVIE) + + @property + def media_type(self) -> str: + """Get media type for this identifier.""" + return str(MEDIA_TYPE_VIDEO) + + +@attr.s(frozen=True) +class EventSummaryData: + """Summary data from Frigate events.""" + + data: list[dict[str, Any]] = attr.ib() + cameras: list[str] = attr.ib() + labels: list[str] = attr.ib() + zones: list[str] = attr.ib() + + @classmethod + def from_raw_data(cls, summary_data: list[dict[str, Any]]) -> EventSummaryData: + """Generate an EventSummaryData object from raw data.""" + + cameras = list({d["camera"] for d in summary_data}) + labels = list({d["label"] for d in summary_data}) + zones = list({zone for d in summary_data for zone in d["zones"]}) + return cls(summary_data, cameras, labels, zones) + + +class FrigateMediaSource(MediaSource): # type: ignore[misc] + """Provide Frigate camera recordings as media sources.""" + + name: str = "Frigate" + + def __init__(self, hass: HomeAssistant): + """Initialize Frigate source.""" + super().__init__(DOMAIN) + self.hass = hass + + def _is_allowed_as_media_source(self, instance_id: str) -> bool: + """Whether a given frigate instance is allowed as a media source.""" + config_entry: ConfigEntry = get_config_entry_for_frigate_instance_id( + self.hass, instance_id + ) + return ( + config_entry.options.get(CONF_MEDIA_BROWSER_ENABLE, True) is True + if config_entry + else False + ) + + def _get_client(self, identifier: Identifier) -> FrigateApiClient: + """Get client for a given identifier.""" + client = get_client_for_frigate_instance_id( + self.hass, identifier.frigate_instance_id + ) + if client: + return client + + raise MediaSourceError( + "Could not find client for frigate instance " + f"id: {identifier.frigate_instance_id}" + ) + + def _get_default_frigate_instance_id(self) -> str | None: + """Get the default frigate_instance_id if any.""" + default_config_entry = get_default_config_entry(self.hass) + if default_config_entry: + return get_frigate_instance_id_for_config_entry( + self.hass, default_config_entry + ) + return None + + async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: + """Resolve media to a url.""" + identifier = Identifier.from_str( + item.identifier, + default_frigate_instance_id=self._get_default_frigate_instance_id(), + ) + if identifier and self._is_allowed_as_media_source( + identifier.frigate_instance_id + ): + info = await system_info.async_get_system_info(self.hass) + server_path = identifier.get_integration_proxy_path( + info.get("timezone", "utc") + ) + return PlayMedia( + f"/api/frigate/{identifier.frigate_instance_id}/{server_path}", + identifier.mime_type, + ) + raise Unresolvable(f"Unknown or disallowed identifier: {item.identifier}") + + async def async_browse_media( + self, + item: MediaSourceItem, + ) -> BrowseMediaSource: + """Browse media.""" + + if item.identifier is None: + base = BrowseMediaSource( + domain=DOMAIN, + identifier="", + media_class=MEDIA_CLASS_DIRECTORY, + children_media_class=MEDIA_CLASS_VIDEO, + media_content_type=MEDIA_TYPE_VIDEO, + title=NAME, + can_play=False, + can_expand=True, + thumbnail=None, + children=[], + ) + for config_entry in self.hass.config_entries.async_entries(DOMAIN): + frigate_instance_id = get_frigate_instance_id_for_config_entry( + self.hass, config_entry + ) + if frigate_instance_id and self._is_allowed_as_media_source( + frigate_instance_id + ): + clips_identifier = EventSearchIdentifier( + frigate_instance_id, FrigateMediaType.CLIPS + ) + recording_identifier = RecordingIdentifier(frigate_instance_id) + snapshots_identifier = EventSearchIdentifier( + frigate_instance_id, FrigateMediaType.SNAPSHOTS + ) + # Use the media class of the children to help distinguish + # the icons in the frontend. + base.children.extend( + [ + BrowseMediaSource( + domain=DOMAIN, + identifier=clips_identifier, + media_class=MEDIA_CLASS_DIRECTORY, + children_media_class=clips_identifier.media_class, + media_content_type=clips_identifier.media_type, + title=f"Clips [{config_entry.title}]", + can_play=False, + can_expand=True, + thumbnail=None, + children=[], + ), + BrowseMediaSource( + domain=DOMAIN, + identifier=recording_identifier, + media_class=MEDIA_CLASS_DIRECTORY, + children_media_class=recording_identifier.media_class, + media_content_type=recording_identifier.media_type, + title=f"Recordings [{config_entry.title}]", + can_play=False, + can_expand=True, + thumbnail=None, + children=[], + ), + BrowseMediaSource( + domain=DOMAIN, + identifier=snapshots_identifier, + media_class=MEDIA_CLASS_DIRECTORY, + children_media_class=snapshots_identifier.media_class, + media_content_type=snapshots_identifier.media_type, + title=f"Snapshots [{config_entry.title}]", + can_play=False, + can_expand=True, + thumbnail=None, + children=[], + ), + ], + ) + return base + + identifier = Identifier.from_str( + item.identifier, + default_frigate_instance_id=self._get_default_frigate_instance_id(), + ) + + if identifier is not None and not self._is_allowed_as_media_source( + identifier.frigate_instance_id + ): + raise MediaSourceError( + f"Forbidden media source identifier: {item.identifier}" + ) + + if isinstance(identifier, EventSearchIdentifier): + if identifier.frigate_media_type == FrigateMediaType.CLIPS: + media_kwargs = {"has_clip": True} + else: + media_kwargs = {"has_snapshot": True} + try: + events = await self._get_client(identifier).async_get_events( + after=identifier.after, + before=identifier.before, + cameras=[identifier.camera] if identifier.camera else None, + labels=[identifier.label] if identifier.label else None, + sub_labels=None, + zones=[identifier.zone] if identifier.zone else None, + limit=10000 if identifier.name.endswith(".all") else ITEM_LIMIT, + **media_kwargs, + ) + except FrigateApiClientError as exc: + raise MediaSourceError from exc + + return self._browse_events( + await self._get_event_summary_data(identifier), identifier, events + ) + + if isinstance(identifier, RecordingIdentifier): + try: + if not identifier.camera: + config = await self._get_client(identifier).async_get_config() + return self._get_camera_recording_folders(identifier, config) + + info = await system_info.async_get_system_info(self.hass) + recording_summary = cast( + list[dict[str, Any]], + await self._get_client(identifier).async_get_recordings_summary( + camera=identifier.camera, timezone=info.get("timezone", "utc") + ), + ) + + if not identifier.year_month_day: + return self._get_recording_days(identifier, recording_summary) + + return self._get_recording_hours(identifier, recording_summary) + except FrigateApiClientError as exc: + raise MediaSourceError from exc + + raise MediaSourceError(f"Invalid media source identifier: {item.identifier}") + + async def _get_event_summary_data( + self, identifier: EventSearchIdentifier + ) -> EventSummaryData: + """Get event summary data.""" + + try: + info = await system_info.async_get_system_info(self.hass) + + if identifier.frigate_media_type == FrigateMediaType.CLIPS: + kwargs = {"has_clip": True} + else: + kwargs = {"has_snapshot": True} + summary_data = await self._get_client(identifier).async_get_event_summary( + timezone=info.get("timezone", "utc"), **kwargs + ) + except FrigateApiClientError as exc: + raise MediaSourceError from exc + + # Add timestamps to raw data. + for data in summary_data: + data["timestamp"] = int( + dt.datetime.strptime(data["day"], "%Y-%m-%d") + .astimezone(DEFAULT_TIME_ZONE) + .timestamp() + ) + + return EventSummaryData.from_raw_data(summary_data) + + def _browse_events( + self, + summary_data: EventSummaryData, + identifier: EventSearchIdentifier, + events: list[dict[str, Any]], + ) -> BrowseMediaSource: + """Browse events.""" + count = self._count_by(summary_data, identifier) + + if identifier.is_root(): + title = f"{identifier.frigate_media_type.value.capitalize()} ({count})" + else: + title = f"{' > '.join([s for s in get_friendly_name(identifier.name).split('.') if s != '']).title()} ({count})" + + base = BrowseMediaSource( + domain=DOMAIN, + identifier=identifier, + media_class=MEDIA_CLASS_DIRECTORY, + children_media_class=identifier.media_class, + media_content_type=identifier.media_type, + title=title, + can_play=False, + can_expand=True, + thumbnail=None, + children=[], + ) + + event_items = self._build_event_response(identifier, events) + + # if you are at the limit, but not at the root + if count > 0 and len(event_items) == ITEM_LIMIT and identifier.is_root(): + # only render if > 10% is represented in view + if ITEM_LIMIT / float(count) > 0.1: + base.children.extend(event_items) + else: + base.children.extend(event_items) + + drilldown_sources = [] + drilldown_sources.extend( + self._build_date_sources(summary_data, identifier, len(base.children)) + ) + if not identifier.camera: + drilldown_sources.extend( + self._build_camera_sources(summary_data, identifier, len(base.children)) + ) + if not identifier.label: + drilldown_sources.extend( + self._build_label_sources(summary_data, identifier, len(base.children)) + ) + if not identifier.zone: + drilldown_sources.extend( + self._build_zone_sources(summary_data, identifier, len(base.children)) + ) + + # only show the drill down options if there are more than 10 events + # and there is more than 1 drilldown or when you aren't showing any events + if len(events) > 10 and (len(drilldown_sources) > 1 or len(base.children) == 0): + base.children.extend(drilldown_sources) + + # add an all source if there are no drilldowns available and you are at the item limit + if ( + (len(base.children) == 0 or len(base.children) == len(event_items)) + and not identifier.name.endswith(".all") + and len(event_items) == ITEM_LIMIT + ): + base.children.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=attr.evolve(identifier, name=f"{identifier.name}.all"), + media_class=MEDIA_CLASS_DIRECTORY, + children_media_class=MEDIA_CLASS_DIRECTORY, + media_content_type=identifier.media_type, + title=f"All ({count})", + can_play=False, + can_expand=True, + thumbnail=None, + ) + ) + + return base + + @classmethod + def _build_event_response( + cls, identifier: EventSearchIdentifier, events: list[dict[str, Any]] + ) -> BrowseMediaSource: + children = [] + for event in events: + start_time = event.get("start_time") + end_time = event.get("end_time") + if start_time is None: + continue + + if end_time is None: + # Events that are in progress will not yet have an end_time, so + # the duration is shown as the current time minus the start + # time. + duration = int( + dt.datetime.now(DEFAULT_TIME_ZONE).timestamp() - start_time + ) + else: + duration = int(end_time - start_time) + + children.append( + FrigateBrowseMediaSource( + domain=DOMAIN, + identifier=EventIdentifier( + identifier.frigate_instance_id, + frigate_media_type=identifier.frigate_media_type, + camera=event["camera"], + id=event["id"], + ), + media_class=identifier.media_class, + media_content_type=identifier.media_type, + title=f"{dt.datetime.fromtimestamp(event['start_time'], DEFAULT_TIME_ZONE).strftime(DATE_STR_FORMAT)} [{duration}s, {event['label'].capitalize()} {int((event['data'].get('top_score') or event['top_score'] or 0)*100)}%]", + can_play=identifier.media_type == MEDIA_TYPE_VIDEO, + can_expand=False, + thumbnail=f"/api/frigate/{identifier.frigate_instance_id}/thumbnail/{event['id']}", + frigate=FrigateBrowseMediaMetadata(event=event), + ) + ) + return children + + def _build_camera_sources( + self, + summary_data: EventSummaryData, + identifier: EventSearchIdentifier, + shown_event_count: int, + ) -> BrowseMediaSource: + sources = [] + for camera in summary_data.cameras: + count = self._count_by( + summary_data, + attr.evolve( + identifier, + camera=camera, + ), + ) + if count in (0, shown_event_count): + continue + sources.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=attr.evolve( + identifier, + name=f"{identifier.name}.{camera}", + camera=camera, + ), + media_class=MEDIA_CLASS_DIRECTORY, + children_media_class=MEDIA_CLASS_DIRECTORY, + media_content_type=identifier.media_type, + title=f"{get_friendly_name(camera)} ({count})", + can_play=False, + can_expand=True, + thumbnail=None, + ) + ) + return sources + + def _build_label_sources( + self, + summary_data: EventSummaryData, + identifier: EventSearchIdentifier, + shown_event_count: int, + ) -> BrowseMediaSource: + sources = [] + for label in summary_data.labels: + count = self._count_by( + summary_data, + attr.evolve( + identifier, + label=label, + ), + ) + if count in (0, shown_event_count): + continue + sources.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=attr.evolve( + identifier, + name=f"{identifier.name}.{label}", + label=label, + ), + media_class=MEDIA_CLASS_DIRECTORY, + children_media_class=MEDIA_CLASS_DIRECTORY, + media_content_type=identifier.media_type, + title=f"{get_friendly_name(label)} ({count})", + can_play=False, + can_expand=True, + thumbnail=None, + ) + ) + return sources + + def _build_zone_sources( + self, + summary_data: EventSummaryData, + identifier: EventSearchIdentifier, + shown_event_count: int, + ) -> BrowseMediaSource: + """Build zone media sources.""" + sources = [] + for zone in summary_data.zones: + count = self._count_by(summary_data, attr.evolve(identifier, zone=zone)) + if count in (0, shown_event_count): + continue + sources.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=attr.evolve( + identifier, + name=f"{identifier.name}.{zone}", + zone=zone, + ), + media_class=MEDIA_CLASS_DIRECTORY, + children_media_class=MEDIA_CLASS_DIRECTORY, + media_content_type=identifier.media_type, + title=f"{get_friendly_name(zone)} ({count})", + can_play=False, + can_expand=True, + thumbnail=None, + ) + ) + return sources + + def _build_date_sources( + self, + summary_data: EventSummaryData, + identifier: EventSearchIdentifier, + shown_event_count: int, + ) -> BrowseMediaSource: + """Build data media sources.""" + sources = [] + + now = dt.datetime.now(DEFAULT_TIME_ZONE) + today = now.replace(hour=0, minute=0, second=0, microsecond=0) + + start_of_today = int(today.timestamp()) + start_of_yesterday = start_of_today - SECONDS_IN_DAY + start_of_month = int(today.replace(day=1).timestamp()) + start_of_last_month = int( + (today.replace(day=1) + relativedelta(months=-1)).timestamp() + ) + start_of_year = int(today.replace(month=1, day=1).timestamp()) + + count_today = self._count_by( + summary_data, attr.evolve(identifier, after=start_of_today) + ) + + count_yesterday = self._count_by( + summary_data, + attr.evolve( + identifier, + after=start_of_yesterday, + before=start_of_today, + ), + ) + count_this_month = self._count_by( + summary_data, + attr.evolve( + identifier, + after=start_of_month, + ), + ) + count_last_month = self._count_by( + summary_data, + attr.evolve( + identifier, + after=start_of_last_month, + before=start_of_month, + ), + ) + count_this_year = self._count_by( + summary_data, + attr.evolve( + identifier, + after=start_of_year, + ), + ) + + # if a date range has already been selected + if identifier.before or identifier.after: + before = identifier.before if identifier.before else int(now.timestamp()) + after = identifier.after if identifier.after else int(now.timestamp()) + + # if we are looking at years, split into months + if before - after > SECONDS_IN_MONTH: + current = after + while current < before: + current_date = ( + dt.datetime.fromtimestamp(current) + .astimezone(DEFAULT_TIME_ZONE) + .replace(hour=0, minute=0, second=0, microsecond=0) + ) + start_of_current_month = int(current_date.timestamp()) + start_of_next_month = int( + (current_date + relativedelta(months=+1)).timestamp() + ) + count_current = self._count_by( + summary_data, + attr.evolve( + identifier, + after=start_of_current_month, + before=start_of_next_month, + ), + ) + sources.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=attr.evolve( + identifier, + name=f"{identifier.name}.{current_date.strftime('%Y-%m')}", + after=start_of_current_month, + before=start_of_next_month, + ), + media_class=MEDIA_CLASS_DIRECTORY, + children_media_class=MEDIA_CLASS_DIRECTORY, + media_content_type=identifier.media_type, + title=f"{current_date.strftime('%B')} ({count_current})", + can_play=False, + can_expand=True, + thumbnail=None, + ) + ) + current = current + SECONDS_IN_MONTH + return sources + + # if we are looking at a month, split into days + if before - after > SECONDS_IN_DAY: + current = after + while current < before: + current_date = ( + dt.datetime.fromtimestamp(current) + .astimezone(DEFAULT_TIME_ZONE) + .replace(hour=0, minute=0, second=0, microsecond=0) + ) + start_of_current_day = int(current_date.timestamp()) + start_of_next_day = start_of_current_day + SECONDS_IN_DAY + count_current = self._count_by( + summary_data, + attr.evolve( + identifier, + after=start_of_current_day, + before=start_of_next_day, + ), + ) + if count_current > 0: + sources.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=attr.evolve( + identifier, + name=f"{identifier.name}.{current_date.strftime('%Y-%m-%d')}", + after=start_of_current_day, + before=start_of_next_day, + ), + media_class=MEDIA_CLASS_DIRECTORY, + children_media_class=MEDIA_CLASS_DIRECTORY, + media_content_type=identifier.media_type, + title=f"{current_date.strftime('%B %d')} ({count_current})", + can_play=False, + can_expand=True, + thumbnail=None, + ) + ) + current = current + SECONDS_IN_DAY + return sources + + return sources + + if count_today > shown_event_count: + sources.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=attr.evolve( + identifier, + name=f"{identifier.name}.today", + after=start_of_today, + ), + media_class=MEDIA_CLASS_DIRECTORY, + children_media_class=MEDIA_CLASS_DIRECTORY, + media_content_type=identifier.media_type, + title=f"Today ({count_today})", + can_play=False, + can_expand=True, + thumbnail=None, + ) + ) + + if count_yesterday > shown_event_count: + sources.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=attr.evolve( + identifier, + name=f"{identifier.name}.yesterday", + after=start_of_yesterday, + before=start_of_today, + ), + media_class=MEDIA_CLASS_DIRECTORY, + children_media_class=MEDIA_CLASS_DIRECTORY, + media_content_type=identifier.media_type, + title=f"Yesterday ({count_yesterday})", + can_play=False, + can_expand=True, + thumbnail=None, + ) + ) + + if ( + count_this_month > count_today + count_yesterday + and count_this_month > shown_event_count + ): + sources.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=attr.evolve( + identifier, + name=f"{identifier.name}.this_month", + after=start_of_month, + ), + media_class=MEDIA_CLASS_DIRECTORY, + children_media_class=MEDIA_CLASS_DIRECTORY, + media_content_type=identifier.media_type, + title=f"This Month ({count_this_month})", + can_play=False, + can_expand=True, + thumbnail=None, + ) + ) + + if count_last_month > shown_event_count: + sources.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=attr.evolve( + identifier, + name=f"{identifier.name}.last_month", + after=start_of_last_month, + before=start_of_month, + ), + media_class=MEDIA_CLASS_DIRECTORY, + children_media_class=MEDIA_CLASS_DIRECTORY, + media_content_type=identifier.media_type, + title=f"Last Month ({count_last_month})", + can_play=False, + can_expand=True, + thumbnail=None, + ) + ) + + if ( + count_this_year > count_this_month + count_last_month + and count_this_year > shown_event_count + ): + sources.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=attr.evolve( + identifier, + name=f"{identifier.name}.this_year", + after=start_of_year, + ), + media_class=MEDIA_CLASS_DIRECTORY, + children_media_class=MEDIA_CLASS_DIRECTORY, + media_content_type=identifier.media_type, + title="This Year", + can_play=False, + can_expand=True, + thumbnail=None, + ) + ) + + return sources + + def _count_by( + self, summary_data: EventSummaryData, identifier: EventSearchIdentifier + ) -> int: + """Return count of events that match the identifier.""" + return sum( + d["count"] + for d in summary_data.data + if (identifier.after is None or d["timestamp"] >= identifier.after) + and (identifier.before is None or d["timestamp"] < identifier.before) + and (identifier.camera is None or identifier.camera in d["camera"]) + and (identifier.label is None or identifier.label in d["label"]) + and (identifier.zone is None or identifier.zone in d["zones"]) + ) + + def _get_recording_base_media_source( + self, identifier: RecordingIdentifier + ) -> BrowseMediaSource: + """Get the base BrowseMediaSource object for a recording identifier.""" + return BrowseMediaSource( + domain=DOMAIN, + identifier=identifier, + media_class=MEDIA_CLASS_DIRECTORY, + children_media_class=MEDIA_CLASS_DIRECTORY, + media_content_type=identifier.media_type, + title="Recordings", + can_play=False, + can_expand=True, + thumbnail=None, + children=[], + ) + + def _get_camera_recording_folders( + self, identifier: RecordingIdentifier, config: dict[str, dict] + ) -> BrowseMediaSource: + """List cameras for recordings.""" + base = self._get_recording_base_media_source(identifier) + + for camera in config["cameras"].keys(): + base.children.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=attr.evolve( + identifier, + camera=camera, + ), + media_class=MEDIA_CLASS_DIRECTORY, + children_media_class=MEDIA_CLASS_DIRECTORY, + media_content_type=identifier.media_type, + title=get_friendly_name(camera), + can_play=False, + can_expand=True, + thumbnail=None, + ) + ) + + return base + + def _get_recording_days( + self, identifier: RecordingIdentifier, recording_days: list[dict[str, Any]] + ) -> BrowseMediaSource: + """List year-month-day options for camera.""" + base = self._get_recording_base_media_source(identifier) + + for day_item in recording_days: + try: + dt.datetime.strptime(day_item["day"], "%Y-%m-%d") + except ValueError as exc: + raise MediaSourceError( + f"Media source is not valid for {identifier} {day_item['day']}" + ) from exc + + base.children.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=attr.evolve( + identifier, + year_month_day=day_item["day"], + ), + media_class=MEDIA_CLASS_DIRECTORY, + children_media_class=MEDIA_CLASS_DIRECTORY, + media_content_type=identifier.media_type, + title=day_item["day"], + can_play=False, + can_expand=True, + thumbnail=None, + ) + ) + + return base + + def _get_recording_hours( + self, identifier: RecordingIdentifier, recording_days: list[dict[str, Any]] + ) -> BrowseMediaSource: + """Browse Frigate recordings.""" + base = self._get_recording_base_media_source(identifier) + hour_items: list[dict[str, Any]] = next( + ( + hours["hours"] + for hours in recording_days + if hours["day"] == identifier.year_month_day + ), + [], + ) + + for hour_data in hour_items: + try: + title = dt.datetime.strptime(hour_data["hour"], "%H").strftime("%H:00") + except ValueError as exc: + raise MediaSourceError( + f"Media source is not valid for {identifier} {hour_data['hour']}" + ) from exc + + base.children.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=attr.evolve(identifier, hour=hour_data["hour"]), + media_class=identifier.media_class, + media_content_type=identifier.media_type, + title=title, + can_play=True, + can_expand=False, + thumbnail=None, + ) + ) + return base diff --git a/config/custom_components/frigate/number.py b/config/custom_components/frigate/number.py new file mode 100644 index 0000000..9292344 --- /dev/null +++ b/config/custom_components/frigate/number.py @@ -0,0 +1,242 @@ +"""Number platform for frigate.""" +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.mqtt import async_publish +from homeassistant.components.number import NumberEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DeviceInfo, EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ( + FrigateMQTTEntity, + ReceiveMessage, + get_cameras, + get_friendly_name, + get_frigate_device_identifier, + get_frigate_entity_unique_id, +) +from .const import ( + ATTR_CONFIG, + DOMAIN, + MAX_CONTOUR_AREA, + MAX_THRESHOLD, + MIN_CONTOUR_AREA, + MIN_THRESHOLD, + NAME, +) +from .icons import ICON_SPEEDOMETER + +_LOGGER: logging.Logger = logging.getLogger(__name__) + +CAMERA_FPS_TYPES = ["camera", "detection", "process", "skipped"] + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Sensor entry setup.""" + frigate_config = hass.data[DOMAIN][entry.entry_id][ATTR_CONFIG] + + entities = [] + + # add motion configurations for cameras + for cam_name in get_cameras(frigate_config): + entities.extend( + [FrigateMotionContourArea(entry, frigate_config, cam_name, False)] + ) + entities.extend( + [FrigateMotionThreshold(entry, frigate_config, cam_name, False)] + ) + + async_add_entities(entities) + + +class FrigateMotionContourArea(FrigateMQTTEntity, NumberEntity): # type: ignore[misc] + """FrigateMotionContourArea class.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_name = "Contour area" + _attr_native_min_value = MIN_CONTOUR_AREA + _attr_native_max_value = MAX_CONTOUR_AREA + _attr_native_step = 1 + + def __init__( + self, + config_entry: ConfigEntry, + frigate_config: dict[str, Any], + cam_name: str, + default_enabled: bool, + ) -> None: + """Construct a FrigateNumber.""" + self._frigate_config = frigate_config + self._cam_name = cam_name + self._attr_native_value = float( + self._frigate_config["cameras"][self._cam_name]["motion"]["contour_area"] + ) + self._command_topic = ( + f"{self._frigate_config['mqtt']['topic_prefix']}" + f"/{self._cam_name}/motion_contour_area/set" + ) + + self._attr_entity_registry_enabled_default = default_enabled + + super().__init__( + config_entry, + frigate_config, + { + "state_topic": { + "msg_callback": self._state_message_received, + "qos": 0, + "topic": ( + f"{self._frigate_config['mqtt']['topic_prefix']}" + f"/{self._cam_name}/motion_contour_area/state" + ), + }, + }, + ) + + @callback # type: ignore[misc] + def _state_message_received(self, msg: ReceiveMessage) -> None: + """Handle a new received MQTT state message.""" + try: + self._attr_native_value = float(msg.payload) + except (TypeError, ValueError): + pass + + self.async_write_ha_state() + + @property + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return get_frigate_entity_unique_id( + self._config_entry.entry_id, + "number", + f"{self._cam_name}_contour_area", + ) + + @property + def device_info(self) -> DeviceInfo: + """Get device information.""" + return { + "identifiers": { + get_frigate_device_identifier(self._config_entry, self._cam_name) + }, + "via_device": get_frigate_device_identifier(self._config_entry), + "name": get_friendly_name(self._cam_name), + "model": self._get_model(), + "configuration_url": f"{self._config_entry.data.get(CONF_URL)}/cameras/{self._cam_name}", + "manufacturer": NAME, + } + + async def async_set_native_value(self, value: float) -> None: + """Update motion contour area.""" + await async_publish( + self.hass, + self._command_topic, + int(value), + 0, + False, + ) + + @property + def icon(self) -> str: + """Return the icon of the number.""" + return ICON_SPEEDOMETER + + +class FrigateMotionThreshold(FrigateMQTTEntity, NumberEntity): # type: ignore[misc] + """FrigateMotionThreshold class.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_name = "Threshold" + _attr_native_min_value = MIN_THRESHOLD + _attr_native_max_value = MAX_THRESHOLD + _attr_native_step = 1 + + def __init__( + self, + config_entry: ConfigEntry, + frigate_config: dict[str, Any], + cam_name: str, + default_enabled: bool, + ) -> None: + """Construct a FrigateMotionThreshold.""" + self._frigate_config = frigate_config + self._cam_name = cam_name + self._attr_native_value = float( + self._frigate_config["cameras"][self._cam_name]["motion"]["threshold"] + ) + self._command_topic = ( + f"{frigate_config['mqtt']['topic_prefix']}" + f"/{self._cam_name}/motion_threshold/set" + ) + + self._attr_entity_registry_enabled_default = default_enabled + + super().__init__( + config_entry, + frigate_config, + { + "state_topic": { + "msg_callback": self._state_message_received, + "qos": 0, + "topic": ( + f"{self._frigate_config['mqtt']['topic_prefix']}" + f"/{self._cam_name}/motion_threshold/state" + ), + }, + }, + ) + + @callback # type: ignore[misc] + def _state_message_received(self, msg: ReceiveMessage) -> None: + """Handle a new received MQTT state message.""" + try: + self._attr_native_value = float(msg.payload) + except (TypeError, ValueError): + pass + + self.async_write_ha_state() + + @property + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return get_frigate_entity_unique_id( + self._config_entry.entry_id, + "number", + f"{self._cam_name}_threshold", + ) + + @property + def device_info(self) -> DeviceInfo: + """Get device information.""" + return { + "identifiers": { + get_frigate_device_identifier(self._config_entry, self._cam_name) + }, + "via_device": get_frigate_device_identifier(self._config_entry), + "name": get_friendly_name(self._cam_name), + "model": self._get_model(), + "configuration_url": f"{self._config_entry.data.get(CONF_URL)}/cameras/{self._cam_name}", + "manufacturer": NAME, + } + + async def async_set_native_value(self, value: float) -> None: + """Update motion threshold.""" + await async_publish( + self.hass, + self._command_topic, + int(value), + 0, + False, + ) + + @property + def icon(self) -> str: + """Return the icon of the number.""" + return ICON_SPEEDOMETER diff --git a/config/custom_components/frigate/sensor.py b/config/custom_components/frigate/sensor.py new file mode 100644 index 0000000..6a20e1e --- /dev/null +++ b/config/custom_components/frigate/sensor.py @@ -0,0 +1,712 @@ +"""Sensor platform for frigate.""" +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_URL, + PERCENTAGE, + UnitOfSoundPressure, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DeviceInfo, EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import ( + FrigateDataUpdateCoordinator, + FrigateEntity, + FrigateMQTTEntity, + ReceiveMessage, + get_cameras, + get_cameras_zones_and_objects, + get_friendly_name, + get_frigate_device_identifier, + get_frigate_entity_unique_id, + get_zones, +) +from .const import ATTR_CONFIG, ATTR_COORDINATOR, DOMAIN, FPS, MS, NAME +from .icons import ( + ICON_CORAL, + ICON_SERVER, + ICON_SPEEDOMETER, + ICON_WAVEFORM, + get_icon_from_type, +) + +_LOGGER: logging.Logger = logging.getLogger(__name__) + +CAMERA_FPS_TYPES = ["camera", "detection", "process", "skipped"] + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Sensor entry setup.""" + frigate_config = hass.data[DOMAIN][entry.entry_id][ATTR_CONFIG] + coordinator = hass.data[DOMAIN][entry.entry_id][ATTR_COORDINATOR] + + entities = [] + for key, value in coordinator.data.items(): + if key == "detection_fps": + entities.append(FrigateFpsSensor(coordinator, entry)) + elif key == "detectors": + for name in value.keys(): + entities.append(DetectorSpeedSensor(coordinator, entry, name)) + elif key == "gpu_usages": + for name in value.keys(): + entities.append(GpuLoadSensor(coordinator, entry, name)) + elif key == "processes": + # don't create sensor for other processes + continue + elif key == "service": + # Temperature is only supported on PCIe Coral. + for name in value.get("temperatures", {}): + entities.append(DeviceTempSensor(coordinator, entry, name)) + elif key == "cpu_usages": + for camera in get_cameras(frigate_config): + entities.append( + CameraProcessCpuSensor(coordinator, entry, camera, "capture") + ) + entities.append( + CameraProcessCpuSensor(coordinator, entry, camera, "detect") + ) + entities.append( + CameraProcessCpuSensor(coordinator, entry, camera, "ffmpeg") + ) + elif key == "cameras": + for name in value.keys(): + entities.extend( + [ + CameraFpsSensor(coordinator, entry, name, t) + for t in CAMERA_FPS_TYPES + ] + ) + + if frigate_config["cameras"][name]["audio"]["enabled_in_config"]: + entities.append(CameraSoundSensor(coordinator, entry, name)) + + frigate_config = hass.data[DOMAIN][entry.entry_id][ATTR_CONFIG] + entities.extend( + [ + FrigateObjectCountSensor(entry, frigate_config, cam_name, obj) + for cam_name, obj in get_cameras_zones_and_objects(frigate_config) + ] + ) + entities.append(FrigateStatusSensor(coordinator, entry)) + async_add_entities(entities) + + +class FrigateFpsSensor(FrigateEntity, CoordinatorEntity): # type: ignore[misc] + """Frigate Sensor class.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_name = "Detection fps" + + def __init__( + self, coordinator: FrigateDataUpdateCoordinator, config_entry: ConfigEntry + ) -> None: + """Construct a FrigateFpsSensor.""" + FrigateEntity.__init__(self, config_entry) + CoordinatorEntity.__init__(self, coordinator) + self._attr_entity_registry_enabled_default = False + + @property + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return get_frigate_entity_unique_id( + self._config_entry.entry_id, "sensor_fps", "detection" + ) + + @property + def device_info(self) -> DeviceInfo: + """Get device information.""" + return { + "identifiers": {get_frigate_device_identifier(self._config_entry)}, + "name": NAME, + "model": self._get_model(), + "configuration_url": self._config_entry.data.get(CONF_URL), + "manufacturer": NAME, + } + + @property + def state(self) -> int | None: + """Return the state of the sensor.""" + if self.coordinator.data: + data = self.coordinator.data.get("detection_fps") + if data is not None: + try: + return round(float(data)) + except ValueError: + pass + return None + + @property + def unit_of_measurement(self) -> str: + """Return the unit of measurement of the sensor.""" + return FPS + + @property + def icon(self) -> str: + """Return the icon of the sensor.""" + return ICON_SPEEDOMETER + + +class FrigateStatusSensor(FrigateEntity, CoordinatorEntity): # type: ignore[misc] + """Frigate Status Sensor class.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_name = "Status" + + def __init__( + self, coordinator: FrigateDataUpdateCoordinator, config_entry: ConfigEntry + ) -> None: + """Construct a FrigateStatusSensor.""" + FrigateEntity.__init__(self, config_entry) + CoordinatorEntity.__init__(self, coordinator) + self._attr_entity_registry_enabled_default = False + + @property + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return get_frigate_entity_unique_id( + self._config_entry.entry_id, "sensor_status", "frigate" + ) + + @property + def device_info(self) -> DeviceInfo: + """Get device information.""" + return { + "identifiers": {get_frigate_device_identifier(self._config_entry)}, + "name": NAME, + "model": self._get_model(), + "configuration_url": self._config_entry.data.get(CONF_URL), + "manufacturer": NAME, + } + + @property + def state(self) -> str: + """Return the state of the sensor.""" + return str(self.coordinator.server_status) + + @property + def icon(self) -> str: + """Return the icon of the sensor.""" + return ICON_SERVER + + +class DetectorSpeedSensor(FrigateEntity, CoordinatorEntity): # type: ignore[misc] + """Frigate Detector Speed class.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, + coordinator: FrigateDataUpdateCoordinator, + config_entry: ConfigEntry, + detector_name: str, + ) -> None: + """Construct a DetectorSpeedSensor.""" + FrigateEntity.__init__(self, config_entry) + CoordinatorEntity.__init__(self, coordinator) + self._detector_name = detector_name + self._attr_entity_registry_enabled_default = False + + @property + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return get_frigate_entity_unique_id( + self._config_entry.entry_id, "sensor_detector_speed", self._detector_name + ) + + @property + def device_info(self) -> DeviceInfo: + """Get device information.""" + return { + "identifiers": {get_frigate_device_identifier(self._config_entry)}, + "name": NAME, + "model": self._get_model(), + "configuration_url": self._config_entry.data.get(CONF_URL), + "manufacturer": NAME, + } + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return f"{get_friendly_name(self._detector_name)} inference speed" + + @property + def state(self) -> int | None: + """Return the state of the sensor.""" + if self.coordinator.data: + data = ( + self.coordinator.data.get("detectors", {}) + .get(self._detector_name, {}) + .get("inference_speed") + ) + if data is not None: + try: + return round(float(data)) + except ValueError: + pass + return None + + @property + def unit_of_measurement(self) -> str: + """Return the unit of measurement of the sensor.""" + return MS + + @property + def icon(self) -> str: + """Return the icon of the sensor.""" + return ICON_SPEEDOMETER + + +class GpuLoadSensor(FrigateEntity, CoordinatorEntity): # type: ignore[misc] + """Frigate GPU Load class.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, + coordinator: FrigateDataUpdateCoordinator, + config_entry: ConfigEntry, + gpu_name: str, + ) -> None: + """Construct a GpuLoadSensor.""" + self._gpu_name = gpu_name + self._attr_name = f"{get_friendly_name(self._gpu_name)} gpu load" + FrigateEntity.__init__(self, config_entry) + CoordinatorEntity.__init__(self, coordinator) + self._attr_entity_registry_enabled_default = False + + @property + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return get_frigate_entity_unique_id( + self._config_entry.entry_id, "gpu_load", self._gpu_name + ) + + @property + def device_info(self) -> DeviceInfo: + """Get device information.""" + return { + "identifiers": {get_frigate_device_identifier(self._config_entry)}, + "name": NAME, + "model": self._get_model(), + "configuration_url": self._config_entry.data.get(CONF_URL), + "manufacturer": NAME, + } + + @property + def state(self) -> float | None: + """Return the state of the sensor.""" + if self.coordinator.data: + data = ( + self.coordinator.data.get("gpu_usages", {}) + .get(self._gpu_name, {}) + .get("gpu") + ) + + if data is None or not isinstance(data, str): + return None + + try: + return float(data.replace("%", "").strip()) + except ValueError: + pass + + return None + + @property + def unit_of_measurement(self) -> str: + """Return the unit of measurement of the sensor.""" + return "%" + + @property + def icon(self) -> str: + """Return the icon of the sensor.""" + return ICON_SPEEDOMETER + + +class CameraFpsSensor(FrigateEntity, CoordinatorEntity): # type: ignore[misc] + """Frigate Camera Fps class.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, + coordinator: FrigateDataUpdateCoordinator, + config_entry: ConfigEntry, + cam_name: str, + fps_type: str, + ) -> None: + """Construct a CameraFpsSensor.""" + FrigateEntity.__init__(self, config_entry) + CoordinatorEntity.__init__(self, coordinator) + self._cam_name = cam_name + self._fps_type = fps_type + self._attr_entity_registry_enabled_default = False + + @property + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return get_frigate_entity_unique_id( + self._config_entry.entry_id, + "sensor_fps", + f"{self._cam_name}_{self._fps_type}", + ) + + @property + def device_info(self) -> DeviceInfo: + """Get device information.""" + return { + "identifiers": { + get_frigate_device_identifier(self._config_entry, self._cam_name) + }, + "via_device": get_frigate_device_identifier(self._config_entry), + "name": get_friendly_name(self._cam_name), + "model": self._get_model(), + "configuration_url": f"{self._config_entry.data.get(CONF_URL)}/cameras/{self._cam_name}", + "manufacturer": NAME, + } + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return f"{self._fps_type} fps" + + @property + def unit_of_measurement(self) -> str: + """Return the unit of measurement of the sensor.""" + return FPS + + @property + def state(self) -> int | None: + """Return the state of the sensor.""" + + if self.coordinator.data: + data = ( + self.coordinator.data.get("cameras", {}) + .get(self._cam_name, {}) + .get(f"{self._fps_type}_fps") + ) + + if data is not None: + try: + return round(float(data)) + except ValueError: + pass + return None + + @property + def icon(self) -> str: + """Return the icon of the sensor.""" + return ICON_SPEEDOMETER + + +class CameraSoundSensor(FrigateEntity, CoordinatorEntity): # type: ignore[misc] + """Frigate Camera Sound Level class.""" + + def __init__( + self, + coordinator: FrigateDataUpdateCoordinator, + config_entry: ConfigEntry, + cam_name: str, + ) -> None: + """Construct a CameraSoundSensor.""" + FrigateEntity.__init__(self, config_entry) + CoordinatorEntity.__init__(self, coordinator) + self._cam_name = cam_name + self._attr_entity_registry_enabled_default = True + + @property + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return get_frigate_entity_unique_id( + self._config_entry.entry_id, + "sensor_sound_level", + f"{self._cam_name}_dB", + ) + + @property + def device_info(self) -> DeviceInfo: + """Get device information.""" + return { + "identifiers": { + get_frigate_device_identifier(self._config_entry, self._cam_name) + }, + "via_device": get_frigate_device_identifier(self._config_entry), + "name": get_friendly_name(self._cam_name), + "model": self._get_model(), + "configuration_url": f"{self._config_entry.data.get(CONF_URL)}/cameras/{self._cam_name}", + "manufacturer": NAME, + } + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return "sound level" + + @property + def unit_of_measurement(self) -> Any: + """Return the unit of measurement of the sensor.""" + return UnitOfSoundPressure.DECIBEL + + @property + def state(self) -> int | None: + """Return the state of the sensor.""" + + if self.coordinator.data: + data = ( + self.coordinator.data.get("cameras", {}) + .get(self._cam_name, {}) + .get("audio_dBFS") + ) + + if data is not None: + try: + return round(float(data)) + except ValueError: + pass + return None + + @property + def icon(self) -> str: + """Return the icon of the sensor.""" + return ICON_WAVEFORM + + +class FrigateObjectCountSensor(FrigateMQTTEntity): + """Frigate Motion Sensor class.""" + + def __init__( + self, + config_entry: ConfigEntry, + frigate_config: dict[str, Any], + cam_name: str, + obj_name: str, + ) -> None: + """Construct a FrigateObjectCountSensor.""" + self._cam_name = cam_name + self._obj_name = obj_name + self._state = 0 + self._frigate_config = frigate_config + self._icon = get_icon_from_type(self._obj_name) + + super().__init__( + config_entry, + frigate_config, + { + "state_topic": { + "msg_callback": self._state_message_received, + "qos": 0, + "topic": ( + f"{self._frigate_config['mqtt']['topic_prefix']}" + f"/{self._cam_name}/{self._obj_name}" + ), + "encoding": None, + }, + }, + ) + + @callback # type: ignore[misc] + def _state_message_received(self, msg: ReceiveMessage) -> None: + """Handle a new received MQTT state message.""" + try: + self._state = int(msg.payload) + self.async_write_ha_state() + except ValueError: + pass + + @property + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return get_frigate_entity_unique_id( + self._config_entry.entry_id, + "sensor_object_count", + f"{self._cam_name}_{self._obj_name}", + ) + + @property + def device_info(self) -> DeviceInfo: + """Get device information.""" + return { + "identifiers": { + get_frigate_device_identifier(self._config_entry, self._cam_name) + }, + "via_device": get_frigate_device_identifier(self._config_entry), + "name": get_friendly_name(self._cam_name), + "model": self._get_model(), + "configuration_url": f"{self._config_entry.data.get(CONF_URL)}/cameras/{self._cam_name if self._cam_name not in get_zones(self._frigate_config) else ''}", + "manufacturer": NAME, + } + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return f"{self._obj_name} count" + + @property + def state(self) -> int: + """Return true if the binary sensor is on.""" + return self._state + + @property + def unit_of_measurement(self) -> str: + """Return the unit of measurement of the sensor.""" + return "objects" + + @property + def icon(self) -> str: + """Return the icon of the sensor.""" + return self._icon + + +class DeviceTempSensor(FrigateEntity, CoordinatorEntity): # type: ignore[misc] + """Frigate Coral Temperature Sensor class.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, + coordinator: FrigateDataUpdateCoordinator, + config_entry: ConfigEntry, + name: str, + ) -> None: + """Construct a CoralTempSensor.""" + self._name = name + FrigateEntity.__init__(self, config_entry) + CoordinatorEntity.__init__(self, coordinator) + self._attr_entity_registry_enabled_default = False + + @property + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return get_frigate_entity_unique_id( + self._config_entry.entry_id, "sensor_temp", self._name + ) + + @property + def device_info(self) -> DeviceInfo: + """Get device information.""" + return { + "identifiers": {get_frigate_device_identifier(self._config_entry)}, + "name": NAME, + "model": self._get_model(), + "configuration_url": self._config_entry.data.get(CONF_URL), + "manufacturer": NAME, + } + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return f"{get_friendly_name(self._name)} temperature" + + @property + def state(self) -> float | None: + """Return the state of the sensor.""" + if self.coordinator.data: + data = ( + self.coordinator.data.get("service", {}) + .get("temperatures", {}) + .get(self._name, 0.0) + ) + try: + return float(data) + except (TypeError, ValueError): + pass + return None + + @property + def unit_of_measurement(self) -> Any: + """Return the unit of measurement of the sensor.""" + return UnitOfTemperature.CELSIUS + + @property + def icon(self) -> str: + """Return the icon of the sensor.""" + return ICON_CORAL + + +class CameraProcessCpuSensor(FrigateEntity, CoordinatorEntity): # type: ignore[misc] + """Cpu usage for camera processes class.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, + coordinator: FrigateDataUpdateCoordinator, + config_entry: ConfigEntry, + cam_name: str, + process_type: str, + ) -> None: + """Construct a CoralTempSensor.""" + self._cam_name = cam_name + self._process_type = process_type + self._attr_name = f"{self._process_type} cpu usage" + FrigateEntity.__init__(self, config_entry) + CoordinatorEntity.__init__(self, coordinator) + self._attr_entity_registry_enabled_default = False + + @property + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return get_frigate_entity_unique_id( + self._config_entry.entry_id, + f"{self._process_type}_cpu_usage", + self._cam_name, + ) + + @property + def device_info(self) -> DeviceInfo: + """Get device information.""" + return { + "identifiers": { + get_frigate_device_identifier(self._config_entry, self._cam_name) + }, + "via_device": get_frigate_device_identifier(self._config_entry), + "name": get_friendly_name(self._cam_name), + "model": self._get_model(), + "configuration_url": f"{self._config_entry.data.get(CONF_URL)}/cameras/{self._cam_name}", + "manufacturer": NAME, + } + + @property + def state(self) -> float | None: + """Return the state of the sensor.""" + if self.coordinator.data: + pid_key = ( + "pid" if self._process_type == "detect" else f"{self._process_type}_pid" + ) + + pid = str( + self.coordinator.data.get("cameras", {}) + .get(self._cam_name, {}) + .get(pid_key, "-1") + ) + + data = ( + self.coordinator.data.get("cpu_usages", {}) + .get(pid, {}) + .get("cpu", None) + ) + + try: + return float(data) + except (TypeError, ValueError): + pass + return None + + @property + def unit_of_measurement(self) -> Any: + """Return the unit of measurement of the sensor.""" + return PERCENTAGE + + @property + def icon(self) -> str: + """Return the icon of the sensor.""" + return ICON_CORAL diff --git a/config/custom_components/frigate/services.yaml b/config/custom_components/frigate/services.yaml new file mode 100644 index 0000000..7f2720e --- /dev/null +++ b/config/custom_components/frigate/services.yaml @@ -0,0 +1,103 @@ +--- +export_recording: + name: Export recording + description: Export a custom recording or timelapse. + target: + entity: + integration: frigate + domain: camera + device_class: camera + fields: + playback_factor: + name: Playback Factor + description: Playback factor for recordings + required: true + advanced: false + example: realtime + default: realtime + selector: + select: + options: + - "realtime" + - "timelapse_25x" + start_time: + name: Export Start Time + description: Start time of exported recording + required: true + advanced: false + selector: + datetime: + end_time: + name: Export End Time + description: End time of exported recording + required: true + advanced: false + selector: + datetime: + +favorite_event: + name: Favorite or unfavorite Event + description: > + Favorites or unfavorites an event. Favorited events are retained + indefinitely. + target: + entity: + integration: frigate + domain: camera + device_class: camera + fields: + event_id: + name: Event ID + description: ID of the event to favorite or unfavorite. + required: true + advanced: false + example: "1656510950.19548-ihtjj7" + default: "" + selector: + text: + favorite: + name: Favorite + description: > + If the event should be favorited or unfavorited. Enable to favorite, + disable to unfavorite. + required: false + advanced: false + example: true + default: true + selector: + boolean: + +ptz: + name: Control camera via PTZ + description: > + Pan / Tilt, Zoom, or move a camera to a preset + target: + entity: + integration: frigate + domain: camera + device_class: camera + fields: + action: + name: PTZ Service + description: Type of PTZ action + required: true + advanced: false + example: move + default: move + selector: + select: + options: + - "move" + - "preset" + - "stop" + - "zoom" + argument: + name: PTZ Action + description: > + left, right, up, down for move; in, out for zoom; name of preset + required: false + advanced: false + example: down + default: "" + selector: + text: diff --git a/config/custom_components/frigate/switch.py b/config/custom_components/frigate/switch.py new file mode 100644 index 0000000..e724bfd --- /dev/null +++ b/config/custom_components/frigate/switch.py @@ -0,0 +1,182 @@ +"""Sensor platform for frigate.""" +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.mqtt import async_publish +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DeviceInfo, EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ( + FrigateMQTTEntity, + ReceiveMessage, + decode_if_necessary, + get_friendly_name, + get_frigate_device_identifier, + get_frigate_entity_unique_id, +) +from .const import ATTR_CONFIG, DOMAIN, NAME +from .icons import get_icon_from_switch + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Switch entry setup.""" + frigate_config = hass.data[DOMAIN][entry.entry_id][ATTR_CONFIG] + + entities = [] + for camera in frigate_config["cameras"].keys(): + entities.extend( + [ + FrigateSwitch(entry, frigate_config, camera, "detect", True), + FrigateSwitch(entry, frigate_config, camera, "motion", True), + FrigateSwitch(entry, frigate_config, camera, "recordings", True), + FrigateSwitch(entry, frigate_config, camera, "snapshots", True), + FrigateSwitch(entry, frigate_config, camera, "improve_contrast", False), + ] + ) + + if ( + frigate_config["cameras"][camera] + .get("audio", {}) + .get("enabled_in_config", False) + ): + entities.append( + FrigateSwitch( + entry, frigate_config, camera, "audio", True, "audio_detection" + ), + ) + + if ( + frigate_config["cameras"][camera] + .get("onvif", {}) + .get("autotracking", {}) + .get("enabled_in_config", False) + ): + entities.append( + FrigateSwitch( + entry, + frigate_config, + camera, + "ptz_autotracker", + True, + "ptz_autotracker", + ), + ) + + async_add_entities(entities) + + +class FrigateSwitch(FrigateMQTTEntity, SwitchEntity): # type: ignore[misc] + """Frigate Switch class.""" + + _attr_entity_category = EntityCategory.CONFIG + + def __init__( + self, + config_entry: ConfigEntry, + frigate_config: dict[str, Any], + cam_name: str, + switch_name: str, + default_enabled: bool, + descriptive_name: str = "", + ) -> None: + """Construct a FrigateSwitch.""" + self._frigate_config = frigate_config + self._cam_name = cam_name + self._switch_name = switch_name + self._is_on = False + self._command_topic = ( + f"{frigate_config['mqtt']['topic_prefix']}" + f"/{self._cam_name}/{self._switch_name}/set" + ) + self._descriptive_name = descriptive_name if descriptive_name else switch_name + + self._attr_entity_registry_enabled_default = default_enabled + self._icon = get_icon_from_switch(self._switch_name) + super().__init__( + config_entry, + frigate_config, + { + "state_topic": { + "msg_callback": self._state_message_received, + "qos": 0, + "topic": ( + f"{self._frigate_config['mqtt']['topic_prefix']}" + f"/{self._cam_name}/{self._switch_name}/state" + ), + }, + }, + ) + + @callback # type: ignore[misc] + def _state_message_received(self, msg: ReceiveMessage) -> None: + """Handle a new received MQTT state message.""" + self._is_on = decode_if_necessary(msg.payload) == "ON" + self.async_write_ha_state() + + @property + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return get_frigate_entity_unique_id( + self._config_entry.entry_id, + "switch", + f"{self._cam_name}_{self._switch_name}", + ) + + @property + def device_info(self) -> DeviceInfo: + """Get device information.""" + return { + "identifiers": { + get_frigate_device_identifier(self._config_entry, self._cam_name) + }, + "via_device": get_frigate_device_identifier(self._config_entry), + "name": get_friendly_name(self._cam_name), + "model": self._get_model(), + "configuration_url": f"{self._config_entry.data.get(CONF_URL)}/cameras/{self._cam_name}", + "manufacturer": NAME, + } + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return f"{get_friendly_name(self._descriptive_name)}".title() + + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return self._is_on + + @property + def icon(self) -> str: + """Return the icon of the sensor.""" + return self._icon + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + await async_publish( + self.hass, + self._command_topic, + "ON", + 0, + False, + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + await async_publish( + self.hass, + self._command_topic, + "OFF", + 0, + False, + ) diff --git a/config/custom_components/frigate/translations/ca.json b/config/custom_components/frigate/translations/ca.json new file mode 100644 index 0000000..2863a47 --- /dev/null +++ b/config/custom_components/frigate/translations/ca.json @@ -0,0 +1,36 @@ +{ + "config": { + "step": { + "user": { + "description": "L'URL que utilitzeu per accedir a Frigate (p. ex. 'http://frigate:5000/')\\n\\nSi feu servir HassOS amb el complement, l'URL hauria de ser 'http://ccab4aaf-frigate:5000/' \\n\\nHome Assistant necessita accedir al port 5000 (api) i 1935 (rtmp) per a totes les funcions.\\n\\nLa integració configurarà sensors, càmeres i la funcionalitat del navegador multimèdia.\\n\\nSensors:\\n- Estadístiques per supervisar el rendiment de Frigate\\n- Recompte d'objectes per a totes les zones i càmeres\\n\\nCàmeres:\\n- Càmeres per a la imatge de l'últim objecte detectat per a cada càmera\\n- Entitats de càmera amb suport de transmissió (requereix RTMP)\\n\\nNavegador multimèdia:\\n - Interfície d'usuari enriquida amb miniatures per explorar clips d'esdeveniments\\n- Interfície d'usuari enriquida per navegar per enregistraments les 24 hores al dia, els set dies a la setmana, per mes, dia, càmera, hora\\n\\nAPI:\\n- API de notificació amb punts de connexió públics per a imatges a les notificacions.", + "data": { + "url": "URL" + } + } + }, + "error": { + "cannot_connect": "No s'ha pogut connectar", + "invalid_url": "URL no vàlid" + }, + "abort": { + "already_configured": "El dispositiu ja està configurat" + } + }, + "options": { + "step": { + "init": { + "data": { + "enable_webrtc": "Activa WebRTC per als fluxos de la càmera", + "rtmp_url_template": "Plantilla de l'URL del RTMP (vegeu la documentació)", + "rtsp_url_template": "Plantilla de l'URL del RTSP (vegeu la documentació)", + "media_browser_enable": "Habiliteu el navegador multimèdia", + "notification_proxy_enable": "Habiliteu el servidor intermediari no autenticat d'esdeveniments de notificacions", + "notification_proxy_expire_after_seconds": "No permetre l'accés a notificacions no autenticades després dels segons especificats (0=mai)" + } + } + }, + "abort": { + "only_advanced_options": "El mode avançat està desactivat i només hi ha opcions avançades" + } + } +} \ No newline at end of file diff --git a/config/custom_components/frigate/translations/de.json b/config/custom_components/frigate/translations/de.json new file mode 100644 index 0000000..c554b60 --- /dev/null +++ b/config/custom_components/frigate/translations/de.json @@ -0,0 +1,35 @@ +{ + "config": { + "step": { + "user": { + "description": "URL, die Sie für den Zugriff auf Frigate verwenden (z. B. \"http://frigate:5000/\")\n\nWenn Sie HassOS mit dem Addon verwenden, sollte die URL „http://ccab4aaf-frigate:5000/“ lauten\n\nHome Assistant benötigt für alle Funktionen Zugriff auf Port 5000 (api) und 1935 (rtmp).\n\nDie Integration richtet Sensoren, Kameras und Medienbrowser-Funktionen ein.\n\nSensoren:\n- Statistiken zur Überwachung der Frigate-Leistung\n- Objektzählungen für alle Zonen und Kameras\n\nKameras:\n- Kameras für Bild des zuletzt erkannten Objekts für jede Kamera\n- Kameraeinheiten mit Stream-Unterstützung (erfordert RTMP)\n\nMedienbrowser:\n- Umfangreiche Benutzeroberfläche mit Vorschaubildern zum Durchsuchen von Event-Clips\n- Umfangreiche Benutzeroberfläche zum Durchsuchen von 24/7-Aufzeichnungen nach Monat, Tag, Kamera und Uhrzeit\n\nAPI:\n- Benachrichtigungs-API mit öffentlich zugänglichen Endpunkten für Bilder in Benachrichtigungen", + "data": { + "url": "URL" + } + } + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_url": "Ungültige URL" + }, + "abort": { + "already_configured": "Gerät ist bereits konfiguriert" + } + }, + "options": { + "step": { + "init": { + "data": { + "rtmp_url_template": "RTMP-URL-Vorlage (siehe Dokumentation)", + "rtsp_url_template": "RTSP-URL-Vorlage (siehe Dokumentation)", + "media_browser_enable": "Aktivieren Sie den Medienbrowser", + "notification_proxy_enable": "Aktivieren Sie den Proxy für nicht authentifizierte Benachrichtigungsereignisse", + "notification_proxy_expire_after_seconds": "Zugriff auf nicht authentifizierte Benachrichtigungen nach Sekunden verbieten (0=nie)" + } + } + }, + "abort": { + "only_advanced_options": "Der erweiterte Modus ist deaktiviert und es stehen nur erweiterte Optionen zur Verfügung" + } + } +} diff --git a/config/custom_components/frigate/translations/en.json b/config/custom_components/frigate/translations/en.json new file mode 100644 index 0000000..2614572 --- /dev/null +++ b/config/custom_components/frigate/translations/en.json @@ -0,0 +1,36 @@ +{ + "config": { + "step": { + "user": { + "description": "URL you use to access Frigate (ie. `http://frigate:5000/`)\n\nIf you are using HassOS with the addon, the URL should be `http://ccab4aaf-frigate:5000/`\n\nHome Assistant needs access to port 5000 (api) and 1935 (rtmp) for all features.\n\nThe integration will setup sensors, cameras, and media browser functionality.\n\nSensors:\n- Stats to monitor frigate performance\n- Object counts for all zones and cameras\n\nCameras:\n- Cameras for image of the last detected object for each camera\n- Camera entities with stream support (requires RTMP)\n\nMedia Browser:\n- Rich UI with thumbnails for browsing event clips\n- Rich UI for browsing 24/7 recordings by month, day, camera, time\n\nAPI:\n- Notification API with public facing endpoints for images in notifications", + "data": { + "url": "URL" + } + } + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_url": "Invalid URL" + }, + "abort": { + "already_configured": "Device is already configured" + } + }, + "options": { + "step": { + "init": { + "data": { + "enable_webrtc": "Enable WebRTC for camera streams", + "rtmp_url_template": "RTMP URL template (see documentation)", + "rtsp_url_template": "RTSP URL template (see documentation)", + "media_browser_enable": "Enable the media browser", + "notification_proxy_enable": "Enable the unauthenticated notification event proxy", + "notification_proxy_expire_after_seconds": "Disallow unauthenticated notification access after seconds (0=never)" + } + } + }, + "abort": { + "only_advanced_options": "Advanced mode is disabled and there are only advanced options" + } + } +} \ No newline at end of file diff --git a/config/custom_components/frigate/translations/fr.json b/config/custom_components/frigate/translations/fr.json new file mode 100644 index 0000000..7765b53 --- /dev/null +++ b/config/custom_components/frigate/translations/fr.json @@ -0,0 +1,36 @@ +{ + "config": { + "step": { + "user": { + "description": "URL que vous utilisez pour accéder à Frigate (par exemple, `http://frigate:5000/`)\n\nSi vous utilisez HassOS avec l'addon, l'URL devrait être `http://ccab4aaf-frigate:5000/`\n\nHome Assistant a besoin d'accès au port 5000 (api) et 1935 (rtmp) pour toutes les fonctionnalités.\n\nL'intégration configurera des capteurs, des caméras et la fonctionnalité de navigateur multimédia.\n\nCapteurs :\n- Statistiques pour surveiller la performance de Frigate\n- Comptes d'objets pour toutes les zones et caméras\n\nCaméras :\n- Caméras pour l'image du dernier objet détecté pour chaque caméra\n- Entités de caméra avec support de flux (nécessite RTMP)\n\nNavigateur multimédia :\n- Interface riche avec miniatures pour parcourir les clips d'événements\n- Interface riche pour parcourir les enregistrements 24/7 par mois, jour, caméra, heure\n\nAPI :\n- API de notification avec des points de terminaison publics pour les images dans les notifications", + "data": { + "url": "URL" + } + } + }, + "error": { + "cannot_connect": "Échec de la connexion", + "invalid_url": "URL invalide" + }, + "abort": { + "already_configured": "L'appareil est déjà configuré" + } + }, + "options": { + "step": { + "init": { + "data": { + "enable_webrtc": "Activer WebRTC pour les flux de caméra", + "rtmp_url_template": "Modèle d'URL RTMP (voir la documentation)", + "rtsp_url_template": "Modèle d'URL RTSP (voir la documentation)", + "media_browser_enable": "Activer le navigateur multimédia", + "notification_proxy_enable": "Activer le proxy d'événement de notification non authentifié", + "notification_proxy_expire_after_seconds": "Interdire l'accès à la notification non authentifiée après secondes (0=jamais)" + } + } + }, + "abort": { + "only_advanced_options": "Le mode avancé est désactivé et il n'y a que des options avancées" + } + } +} \ No newline at end of file diff --git a/config/custom_components/frigate/translations/pt-BR.json b/config/custom_components/frigate/translations/pt-BR.json new file mode 100644 index 0000000..304348c --- /dev/null +++ b/config/custom_components/frigate/translations/pt-BR.json @@ -0,0 +1,35 @@ +{ + "config": { + "step": { + "user": { + "description": "URL que você usa para acessar o Frigate (ou seja, `http://frigate:5000/`)\n\nSe você estiver usando HassOS com o complemento, o URL deve ser `http://ccab4aaf-frigate:5000/`\n\nO Home Assistant precisa de acesso à porta 5000 (api) e 1935 (rtmp) para ter todos os recursos.\n\nA integração configurará sensores, câmeras e funcionalidades do navegador de mídia.\n\nSensores:\n- Estatísticas para monitorar o desempenho do frigate \n- Contagem de objetos para todas as zonas e câmeras\n\nCâmeras:\n- Câmeras para imagem do último objeto detectado para cada câmera\n- Entidades da câmera com suporte a stream (requer RTMP)\n\nNavegador de mídia:\n- UI avançada com miniaturas para navegar em clipes de eventos\n- UI avançada para navegar 24 horas por dia, 7 dias por semana e por mês, dia, câmera, hora\n\nAPI:\n- API de notificação com endpoints voltados para o público para imagens em notificações", + "data": { + "url": "URL" + } + } + }, + "error": { + "cannot_connect": "Falhou ao conectar", + "invalid_url": "URL inválida" + }, + "abort": { + "already_configured": "O dispositivo já está configurado" + } + }, + "options": { + "step": { + "init": { + "data": { + "rtmp_url_template": "Modelo de URL RTMP (consulte a documentação)", + "rtsp_url_template": "Modelo de URL RTSP (consulte a documentação)", + "notification_proxy_enable": "Habilitar o proxy de evento de notificação não autenticado", + "notification_proxy_expire_after_seconds": "Proibir acesso de notificação não autenticado após segundos (0=nunca)", + "media_browser_enable": "Ative o navegador de mídia" + } + } + }, + "abort": { + "only_advanced_options": "O modo avançado está desativado e existem apenas opções avançadas" + } + } +} \ No newline at end of file diff --git a/config/custom_components/frigate/translations/pt_br.json b/config/custom_components/frigate/translations/pt_br.json new file mode 100644 index 0000000..304348c --- /dev/null +++ b/config/custom_components/frigate/translations/pt_br.json @@ -0,0 +1,35 @@ +{ + "config": { + "step": { + "user": { + "description": "URL que você usa para acessar o Frigate (ou seja, `http://frigate:5000/`)\n\nSe você estiver usando HassOS com o complemento, o URL deve ser `http://ccab4aaf-frigate:5000/`\n\nO Home Assistant precisa de acesso à porta 5000 (api) e 1935 (rtmp) para ter todos os recursos.\n\nA integração configurará sensores, câmeras e funcionalidades do navegador de mídia.\n\nSensores:\n- Estatísticas para monitorar o desempenho do frigate \n- Contagem de objetos para todas as zonas e câmeras\n\nCâmeras:\n- Câmeras para imagem do último objeto detectado para cada câmera\n- Entidades da câmera com suporte a stream (requer RTMP)\n\nNavegador de mídia:\n- UI avançada com miniaturas para navegar em clipes de eventos\n- UI avançada para navegar 24 horas por dia, 7 dias por semana e por mês, dia, câmera, hora\n\nAPI:\n- API de notificação com endpoints voltados para o público para imagens em notificações", + "data": { + "url": "URL" + } + } + }, + "error": { + "cannot_connect": "Falhou ao conectar", + "invalid_url": "URL inválida" + }, + "abort": { + "already_configured": "O dispositivo já está configurado" + } + }, + "options": { + "step": { + "init": { + "data": { + "rtmp_url_template": "Modelo de URL RTMP (consulte a documentação)", + "rtsp_url_template": "Modelo de URL RTSP (consulte a documentação)", + "notification_proxy_enable": "Habilitar o proxy de evento de notificação não autenticado", + "notification_proxy_expire_after_seconds": "Proibir acesso de notificação não autenticado após segundos (0=nunca)", + "media_browser_enable": "Ative o navegador de mídia" + } + } + }, + "abort": { + "only_advanced_options": "O modo avançado está desativado e existem apenas opções avançadas" + } + } +} \ No newline at end of file diff --git a/config/custom_components/frigate/translations/ru.json b/config/custom_components/frigate/translations/ru.json new file mode 100644 index 0000000..95346f8 --- /dev/null +++ b/config/custom_components/frigate/translations/ru.json @@ -0,0 +1,36 @@ +{ + "config": { + "step": { + "user": { + "description": "URL, который вы используете для доступа к Frigate (например, http://frigate:5000/)\n\nЕсли вы используете HassOS с дополнением, URL должен быть http://ccab4aaf-frigate:5000/\n\nHome Assistant требуется доступ к порту 5000 (API) и 1935 (RTMP) для всех функций.\n\n Интеграция настроит сенсоры, камеры и функциональность медиа-браузера.\n\nСенсоры:\n- Статистика для отслеживания производительности Frigate\n- Количество объектов для всех зон и камер\n\nКамеры:\n- Камеры для снимка последнего обнаруженного объекта с каждой камеры\n- Камеры с поддержкой потока (требуется RTMP)\n\nМедиа-браузер:\n- Пользовательский интерфейс с миниатюрами для просмотра записей 24/7 по времени и камерам\n\nAPI:\n- API для отправки событий во внешние системы", + "data": { + "url": "URL" + } + } + }, + "error": { + "cannot_connect": "Не удалось подключиться", + "invalid_url": "Неверный URL" + }, + "abort": { + "already_configured": "Устройство уже настроено" + } + }, + "options": { + "step": { + "init": { + "data": { + "enable_webrtc": "Использовать WebRTC для трансляции камер", + "rtmp_url_template": "Шаблон URL для RTMP (см. документацию)", + "rtsp_url_template": "Шаблон URL для RTSP (см. документацию)", + "media_browser_enable": "Включить медиа-браузер", + "notification_proxy_enable": "Включить незащищённый прокси-сервер уведомлений", + "notification_proxy_expire_after_seconds": "Запретить неаутентифицированный доступ к уведомлениям после N секунд (0=никогда)" + } + } + }, + "abort": { + "only_advanced_options": "Режим расширенных настроек отключен; доступны только основные параметры" + } + } +} \ No newline at end of file diff --git a/config/custom_components/frigate/update.py b/config/custom_components/frigate/update.py new file mode 100644 index 0000000..6a2fe5d --- /dev/null +++ b/config/custom_components/frigate/update.py @@ -0,0 +1,100 @@ +"""Update platform for frigate.""" +from __future__ import annotations + +import logging + +from homeassistant.components.update import UpdateEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import ( + FrigateDataUpdateCoordinator, + FrigateEntity, + get_frigate_device_identifier, + get_frigate_entity_unique_id, +) +from .const import ATTR_COORDINATOR, DOMAIN, FRIGATE_RELEASE_TAG_URL, NAME + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Sensor entry setup.""" + coordinator = hass.data[DOMAIN][entry.entry_id][ATTR_COORDINATOR] + + entities = [] + entities.append(FrigateContainerUpdate(coordinator, entry)) + async_add_entities(entities) + + +class FrigateContainerUpdate(FrigateEntity, UpdateEntity, CoordinatorEntity): # type: ignore[misc] + """Frigate container update.""" + + _attr_name = "Server" + + def __init__( + self, + coordinator: FrigateDataUpdateCoordinator, + config_entry: ConfigEntry, + ) -> None: + """Construct a FrigateContainerUpdate.""" + FrigateEntity.__init__(self, config_entry) + CoordinatorEntity.__init__(self, coordinator) + + @property + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return get_frigate_entity_unique_id( + self._config_entry.entry_id, "update", "frigate_server" + ) + + @property + def device_info(self) -> DeviceInfo: + """Get device information.""" + return { + "identifiers": {get_frigate_device_identifier(self._config_entry)}, + "via_device": get_frigate_device_identifier(self._config_entry), + "name": NAME, + "model": self._get_model(), + "configuration_url": self._config_entry.data.get(CONF_URL), + "manufacturer": NAME, + } + + @property + def installed_version(self) -> str | None: + """Version currently in use.""" + + version_hash = self.coordinator.data.get("service", {}).get("version") + + if not version_hash: + return None + + version = str(version_hash).split("-", maxsplit=1)[0] + + return version + + @property + def latest_version(self) -> str | None: + """Latest version available for install.""" + + version = self.coordinator.data.get("service", {}).get("latest_version") + + if not version or version == "unknown" or version == "disabled": + return None + + return str(version) + + @property + def release_url(self) -> str | None: + """URL to the full release notes of the latest version available.""" + + if (version := self.latest_version) is None: + return None + + return f"{FRIGATE_RELEASE_TAG_URL}/v{version}" diff --git a/config/custom_components/frigate/views.py b/config/custom_components/frigate/views.py new file mode 100644 index 0000000..777f9f5 --- /dev/null +++ b/config/custom_components/frigate/views.py @@ -0,0 +1,579 @@ +"""Frigate HTTP views.""" +from __future__ import annotations + +import asyncio +from collections.abc import Mapping +import datetime +from http import HTTPStatus +from ipaddress import ip_address +import logging +from typing import Any, Optional, cast + +import aiohttp +from aiohttp import hdrs, web +from aiohttp.web_exceptions import HTTPBadGateway, HTTPUnauthorized +import jwt +from multidict import CIMultiDict +from yarl import URL + +from custom_components.frigate.api import FrigateApiClient +from custom_components.frigate.const import ( + ATTR_CLIENT, + ATTR_CLIENT_ID, + ATTR_CONFIG, + ATTR_MQTT, + CONF_NOTIFICATION_PROXY_ENABLE, + CONF_NOTIFICATION_PROXY_EXPIRE_AFTER_SECONDS, + DOMAIN, +) +from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView +from homeassistant.components.http.auth import DATA_SIGN_SECRET, SIGN_QUERY_PARAM +from homeassistant.components.http.const import KEY_HASS +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +def get_default_config_entry(hass: HomeAssistant) -> ConfigEntry | None: + """Get the default Frigate config entry. + + This is for backwards compatibility for when only a single instance was + supported. If there's more than one instance configured, then there is no + default and the user must specify explicitly which instance they want. + """ + frigate_entries = hass.config_entries.async_entries(DOMAIN) + if len(frigate_entries) == 1: + return frigate_entries[0] + return None + + +def get_frigate_instance_id(config: dict[str, Any]) -> str | None: + """Get the Frigate instance id from a Frigate configuration.""" + + # Use the MQTT client_id as a way to separate the frigate instances, rather + # than just using the config_entry_id, in order to make URLs maximally + # relatable/findable by the user. The MQTT client_id value is configured by + # the user in their Frigate configuration and will be unique per Frigate + # instance (enforced in practice on the Frigate/MQTT side). + return cast(Optional[str], config.get(ATTR_MQTT, {}).get(ATTR_CLIENT_ID)) + + +def get_config_entry_for_frigate_instance_id( + hass: HomeAssistant, frigate_instance_id: str +) -> ConfigEntry | None: + """Get a ConfigEntry for a given frigate_instance_id.""" + + for config_entry in hass.config_entries.async_entries(DOMAIN): + config = hass.data[DOMAIN].get(config_entry.entry_id, {}).get(ATTR_CONFIG, {}) + if config and get_frigate_instance_id(config) == frigate_instance_id: + return config_entry + return None + + +def get_client_for_frigate_instance_id( + hass: HomeAssistant, frigate_instance_id: str +) -> FrigateApiClient | None: + """Get a client for a given frigate_instance_id.""" + + config_entry = get_config_entry_for_frigate_instance_id(hass, frigate_instance_id) + if config_entry: + return cast( + FrigateApiClient, + hass.data[DOMAIN].get(config_entry.entry_id, {}).get(ATTR_CLIENT), + ) + return None + + +def get_frigate_instance_id_for_config_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, +) -> ConfigEntry | None: + """Get a frigate_instance_id for a ConfigEntry.""" + + config = hass.data[DOMAIN].get(config_entry.entry_id, {}).get(ATTR_CONFIG, {}) + return get_frigate_instance_id(config) if config else None + + +def async_setup(hass: HomeAssistant) -> None: + """Set up the views.""" + session = async_get_clientsession(hass) + hass.http.register_view(JSMPEGProxyView(session)) + hass.http.register_view(MSEProxyView(session)) + hass.http.register_view(WebRTCProxyView(session)) + hass.http.register_view(NotificationsProxyView(session)) + hass.http.register_view(SnapshotsProxyView(session)) + hass.http.register_view(RecordingProxyView(session)) + hass.http.register_view(ThumbnailsProxyView(session)) + hass.http.register_view(VodProxyView(session)) + hass.http.register_view(VodSegmentProxyView(session)) + + +# These proxies are inspired by: +# - https://github.com/home-assistant/supervisor/blob/main/supervisor/api/ingress.py + + +class ProxyView(HomeAssistantView): # type: ignore[misc] + """HomeAssistant view.""" + + requires_auth = True + + def __init__(self, websession: aiohttp.ClientSession): + """Initialize the frigate clips proxy view.""" + self._websession = websession + + def _get_config_entry_for_request( + self, request: web.Request, frigate_instance_id: str | None + ) -> ConfigEntry | None: + """Get a ConfigEntry for a given request.""" + hass = request.app[KEY_HASS] + + if frigate_instance_id: + return get_config_entry_for_frigate_instance_id(hass, frigate_instance_id) + return get_default_config_entry(hass) + + def _create_path(self, **kwargs: Any) -> str | None: + """Create path.""" + raise NotImplementedError # pragma: no cover + + def _permit_request( + self, request: web.Request, config_entry: ConfigEntry, **kwargs: Any + ) -> bool: + """Determine whether to permit a request.""" + return True + + async def get( + self, + request: web.Request, + **kwargs: Any, + ) -> web.Response | web.StreamResponse | web.WebSocketResponse: + """Route data to service.""" + try: + return await self._handle_request(request, **kwargs) + + except aiohttp.ClientError as err: + _LOGGER.debug("Reverse proxy error for %s: %s", request.rel_url, err) + + raise HTTPBadGateway() from None + + @staticmethod + def _get_query_params(request: web.Request) -> Mapping[str, str]: + """Get the query params to send upstream.""" + return {k: v for k, v in request.query.items() if k != "authSig"} + + async def _handle_request( + self, + request: web.Request, + frigate_instance_id: str | None = None, + **kwargs: Any, + ) -> web.Response | web.StreamResponse: + """Handle route for request.""" + config_entry = self._get_config_entry_for_request(request, frigate_instance_id) + if not config_entry: + return web.Response(status=HTTPStatus.BAD_REQUEST) + + if not self._permit_request(request, config_entry, **kwargs): + return web.Response(status=HTTPStatus.FORBIDDEN) + + full_path = self._create_path(**kwargs) + if not full_path: + return web.Response(status=HTTPStatus.NOT_FOUND) + + url = str(URL(config_entry.data[CONF_URL]) / full_path) + data = await request.read() + source_header = _init_header(request) + + async with self._websession.request( + request.method, + url, + headers=source_header, + params=self._get_query_params(request), + allow_redirects=False, + data=data, + ) as result: + headers = _response_header(result) + + # Stream response + response = web.StreamResponse(status=result.status, headers=headers) + response.content_type = result.content_type + + try: + await response.prepare(request) + async for data in result.content.iter_any(): + await response.write(data) + + except (aiohttp.ClientError, aiohttp.ClientPayloadError) as err: + _LOGGER.debug("Stream error for %s: %s", request.rel_url, err) + except ConnectionResetError: + # Connection is reset/closed by peer. + pass + + return response + + +class SnapshotsProxyView(ProxyView): + """A proxy for snapshots.""" + + url = "/api/frigate/{frigate_instance_id:.+}/snapshot/{eventid:.*}" + extra_urls = ["/api/frigate/snapshot/{eventid:.*}"] + + name = "api:frigate:snapshots" + + def _create_path(self, **kwargs: Any) -> str | None: + """Create path.""" + return f"api/events/{kwargs['eventid']}/snapshot.jpg" + + +class RecordingProxyView(ProxyView): + """A proxy for recordings.""" + + url = "/api/frigate/{frigate_instance_id:.+}/recording/{camera:.+}/start/{start:[.0-9]+}/end/{end:[.0-9]*}" + extra_urls = [ + "/api/frigate/recording/{camera:.+}/start/{start:[.0-9]+}/end/{end:[.0-9]*}" + ] + + name = "api:frigate:recording" + + def _create_path(self, **kwargs: Any) -> str | None: + """Create path.""" + return ( + f"api/{kwargs['camera']}/start/{kwargs['start']}" + + f"/end/{kwargs['end']}/clip.mp4" + ) + + +class ThumbnailsProxyView(ProxyView): + """A proxy for snapshots.""" + + url = "/api/frigate/{frigate_instance_id:.+}/thumbnail/{eventid:.*}" + + name = "api:frigate:thumbnails" + + def _create_path(self, **kwargs: Any) -> str | None: + """Create path.""" + return f"api/events/{kwargs['eventid']}/thumbnail.jpg" + + +class NotificationsProxyView(ProxyView): + """A proxy for notifications.""" + + url = "/api/frigate/{frigate_instance_id:.+}/notifications/{event_id}/{path:.*}" + extra_urls = ["/api/frigate/notifications/{event_id}/{path:.*}"] + + name = "api:frigate:notification" + requires_auth = False + + def _create_path(self, **kwargs: Any) -> str | None: + """Create path.""" + path, event_id = kwargs["path"], kwargs["event_id"] + if path == "thumbnail.jpg": + return f"api/events/{event_id}/thumbnail.jpg" + + if path == "snapshot.jpg": + return f"api/events/{event_id}/snapshot.jpg" + + if path.endswith("clip.mp4"): + return f"api/events/{event_id}/clip.mp4" + return None + + def _permit_request( + self, request: web.Request, config_entry: ConfigEntry, **kwargs: Any + ) -> bool: + """Determine whether to permit a request.""" + + is_notification_proxy_enabled = bool( + config_entry.options.get(CONF_NOTIFICATION_PROXY_ENABLE, True) + ) + + # If proxy is disabled, immediately reject + if not is_notification_proxy_enabled: + return False + + # Authenticated requests are always allowed. + if request[KEY_AUTHENTICATED]: + return True + + # If request is not authenticated, check whether it is expired. + notification_expiration_seconds = int( + config_entry.options.get(CONF_NOTIFICATION_PROXY_EXPIRE_AFTER_SECONDS, 0) + ) + + # If notification events never expire, immediately permit. + if notification_expiration_seconds == 0: + return True + + try: + event_id_timestamp = int(kwargs["event_id"].partition(".")[0]) + event_datetime = datetime.datetime.fromtimestamp( + event_id_timestamp, tz=datetime.timezone.utc + ) + now_datetime = datetime.datetime.now(tz=datetime.timezone.utc) + expiration_datetime = event_datetime + datetime.timedelta( + seconds=notification_expiration_seconds + ) + + # Otherwise, permit only if notification event is not expired + return now_datetime.timestamp() <= expiration_datetime.timestamp() + except ValueError: + _LOGGER.warning( + "The event id %s does not have a valid format.", kwargs["event_id"] + ) + return False + + +class VodProxyView(ProxyView): + """A proxy for vod playlists.""" + + url = "/api/frigate/{frigate_instance_id:.+}/vod/{path:.+}/{manifest:.+}.m3u8" + extra_urls = ["/api/frigate/vod/{path:.+}/{manifest:.+}.m3u8"] + + name = "api:frigate:vod:manifest" + + @staticmethod + def _get_query_params(request: web.Request) -> Mapping[str, str]: + """Get the query params to send upstream.""" + return request.query + + def _create_path(self, **kwargs: Any) -> str | None: + """Create path.""" + return f"vod/{kwargs['path']}/{kwargs['manifest']}.m3u8" + + +class VodSegmentProxyView(ProxyView): + """A proxy for vod segments.""" + + url = "/api/frigate/{frigate_instance_id:.+}/vod/{path:.+}/{segment:.+}.{extension:(ts|m4s|mp4)}" + extra_urls = ["/api/frigate/vod/{path:.+}/{segment:.+}.{extension:(ts|m4s|mp4)}"] + + name = "api:frigate:vod:segment" + requires_auth = False + + def _create_path(self, **kwargs: Any) -> str | None: + """Create path.""" + return f"vod/{kwargs['path']}/{kwargs['segment']}.{kwargs['extension']}" + + async def _async_validate_signed_manifest(self, request: web.Request) -> bool: + """Validate the signature for the manifest of this segment.""" + hass = request.app[KEY_HASS] + secret = hass.data.get(DATA_SIGN_SECRET) + signature = request.query.get(SIGN_QUERY_PARAM) + + if signature is None: + _LOGGER.warning("Missing authSig query parameter on VOD segment request.") + return False + + try: + claims = jwt.decode( + signature, secret, algorithms=["HS256"], options={"verify_iss": False} + ) + except jwt.InvalidTokenError: + _LOGGER.warning("Invalid JWT token for VOD segment request.") + return False + + # Check that the base path is the same as what was signed + check_path = request.path.rsplit("/", maxsplit=1)[0] + if not claims["path"].startswith(check_path): + _LOGGER.warning("%s does not start with %s", claims["path"], check_path) + return False + + return True + + async def get( + self, + request: web.Request, + **kwargs: Any, + ) -> web.Response | web.StreamResponse | web.WebSocketResponse: + """Route data to service.""" + + if not await self._async_validate_signed_manifest(request): + raise HTTPUnauthorized() + + return await super().get(request, **kwargs) + + +class WebsocketProxyView(ProxyView): + """A simple proxy for websockets.""" + + async def _proxy_msgs( + self, + ws_in: aiohttp.ClientWebSocketResponse | web.WebSocketResponse, + ws_out: aiohttp.ClientWebSocketResponse | web.WebSocketResponse, + ) -> None: + + async for msg in ws_in: + try: + if msg.type == aiohttp.WSMsgType.TEXT: + await ws_out.send_str(msg.data) + elif msg.type == aiohttp.WSMsgType.BINARY: + await ws_out.send_bytes(msg.data) + elif msg.type == aiohttp.WSMsgType.PING: + await ws_out.ping() + elif msg.type == aiohttp.WSMsgType.PONG: + await ws_out.pong() + except ConnectionResetError: + return + + async def _handle_request( + self, + request: web.Request, + frigate_instance_id: str | None = None, + **kwargs: Any, + ) -> web.Response | web.StreamResponse: + """Handle route for request.""" + + config_entry = self._get_config_entry_for_request(request, frigate_instance_id) + if not config_entry: + return web.Response(status=HTTPStatus.BAD_REQUEST) + + if not self._permit_request(request, config_entry, **kwargs): + return web.Response(status=HTTPStatus.FORBIDDEN) + + full_path = self._create_path(**kwargs) + if not full_path: + return web.Response(status=HTTPStatus.NOT_FOUND) + + req_protocols = [] + if hdrs.SEC_WEBSOCKET_PROTOCOL in request.headers: + req_protocols = [ + str(proto.strip()) + for proto in request.headers[hdrs.SEC_WEBSOCKET_PROTOCOL].split(",") + ] + + ws_to_user = web.WebSocketResponse( + protocols=req_protocols, autoclose=False, autoping=False + ) + await ws_to_user.prepare(request) + + # Preparing + url = str(URL(config_entry.data[CONF_URL]) / full_path) + source_header = _init_header(request) + + # Support GET query + if request.query_string: + url = f"{url}?{request.query_string}" + + async with self._websession.ws_connect( + url, + headers=source_header, + protocols=req_protocols, + autoclose=False, + autoping=False, + ) as ws_to_frigate: + await asyncio.wait( + [ + asyncio.create_task(self._proxy_msgs(ws_to_frigate, ws_to_user)), + asyncio.create_task(self._proxy_msgs(ws_to_user, ws_to_frigate)), + ], + return_when=asyncio.tasks.FIRST_COMPLETED, + ) + return ws_to_user + + +class JSMPEGProxyView(WebsocketProxyView): + """A proxy for JSMPEG websocket.""" + + url = "/api/frigate/{frigate_instance_id:.+}/jsmpeg/{path:.+}" + extra_urls = ["/api/frigate/jsmpeg/{path:.+}"] + + name = "api:frigate:jsmpeg" + + def _create_path(self, **kwargs: Any) -> str | None: + """Create path.""" + return f"live/jsmpeg/{kwargs['path']}" + + +class MSEProxyView(WebsocketProxyView): + """A proxy for MSE websocket.""" + + url = "/api/frigate/{frigate_instance_id:.+}/mse/{path:.+}" + extra_urls = ["/api/frigate/mse/{path:.+}"] + + name = "api:frigate:mse" + + def _create_path(self, **kwargs: Any) -> str | None: + """Create path.""" + return f"live/mse/{kwargs['path']}" + + +class WebRTCProxyView(WebsocketProxyView): + """A proxy for WebRTC websocket.""" + + url = "/api/frigate/{frigate_instance_id:.+}/webrtc/{path:.+}" + extra_urls = ["/api/frigate/webrtc/{path:.+}"] + + name = "api:frigate:webrtc" + + def _create_path(self, **kwargs: Any) -> str | None: + """Create path.""" + return f"live/webrtc/{kwargs['path']}" + + +def _init_header(request: web.Request) -> CIMultiDict | dict[str, str]: + """Create initial header.""" + headers = {} + + # filter flags + for name, value in request.headers.items(): + if name in ( + hdrs.CONTENT_LENGTH, + hdrs.CONTENT_ENCODING, + hdrs.SEC_WEBSOCKET_EXTENSIONS, + hdrs.SEC_WEBSOCKET_PROTOCOL, + hdrs.SEC_WEBSOCKET_VERSION, + hdrs.SEC_WEBSOCKET_KEY, + hdrs.HOST, + hdrs.AUTHORIZATION, + ): + continue + headers[name] = value + + # Set X-Forwarded-For + forward_for = request.headers.get(hdrs.X_FORWARDED_FOR) + assert request.transport + connected_ip = ip_address(request.transport.get_extra_info("peername")[0]) + if forward_for: + forward_for = f"{forward_for}, {connected_ip!s}" + else: + forward_for = f"{connected_ip!s}" + headers[hdrs.X_FORWARDED_FOR] = forward_for + + # Set X-Forwarded-Host + forward_host = request.headers.get(hdrs.X_FORWARDED_HOST) + if not forward_host: + forward_host = request.host + headers[hdrs.X_FORWARDED_HOST] = forward_host + + # Set X-Forwarded-Proto + forward_proto = request.headers.get(hdrs.X_FORWARDED_PROTO) + if not forward_proto: + forward_proto = request.url.scheme + headers[hdrs.X_FORWARDED_PROTO] = forward_proto + + return headers + + +def _response_header(response: aiohttp.ClientResponse) -> dict[str, str]: + """Create response header.""" + headers = {} + + for name, value in response.headers.items(): + if name in ( + hdrs.TRANSFER_ENCODING, + # Removing Content-Length header for streaming responses + # prevents seeking from working for mp4 files + # hdrs.CONTENT_LENGTH, + hdrs.CONTENT_TYPE, + hdrs.CONTENT_ENCODING, + # Strips inbound CORS response headers since the aiohttp_cors + # library will assert that they are not already present for CORS + # requests. + hdrs.ACCESS_CONTROL_ALLOW_ORIGIN, + hdrs.ACCESS_CONTROL_ALLOW_CREDENTIALS, + hdrs.ACCESS_CONTROL_EXPOSE_HEADERS, + ): + continue + headers[name] = value + + return headers diff --git a/config/custom_components/frigate/ws_api.py b/config/custom_components/frigate/ws_api.py new file mode 100644 index 0000000..62d7eba --- /dev/null +++ b/config/custom_components/frigate/ws_api.py @@ -0,0 +1,272 @@ +"""Frigate HTTP views.""" +from __future__ import annotations + +import logging + +import voluptuous as vol + +from custom_components.frigate.api import FrigateApiClient, FrigateApiClientError +from custom_components.frigate.views import get_client_for_frigate_instance_id +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +def async_setup(hass: HomeAssistant) -> None: + """Set up the recorder websocket API.""" + websocket_api.async_register_command(hass, ws_retain_event) + websocket_api.async_register_command(hass, ws_get_recordings) + websocket_api.async_register_command(hass, ws_get_recordings_summary) + websocket_api.async_register_command(hass, ws_get_events) + websocket_api.async_register_command(hass, ws_get_events_summary) + websocket_api.async_register_command(hass, ws_get_ptz_info) + + +def _get_client_or_send_error( + hass: HomeAssistant, + instance_id: str, + msg_id: int, + connection: websocket_api.ActiveConnection, +) -> FrigateApiClient | None: + """Get the API client or send an error that it cannot be found.""" + client = get_client_for_frigate_instance_id(hass, instance_id) + if client is None: + connection.send_error( + msg_id, + websocket_api.const.ERR_NOT_FOUND, + f"Unable to find Frigate instance with ID: {instance_id}", + ) + return None + return client + + +@websocket_api.websocket_command( + { + vol.Required("type"): "frigate/event/retain", + vol.Required("instance_id"): str, + vol.Required("event_id"): str, + vol.Required("retain"): bool, + } +) # type: ignore[misc] +@websocket_api.async_response # type: ignore[misc] +async def ws_retain_event( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Un/Retain an event.""" + client = _get_client_or_send_error(hass, msg["instance_id"], msg["id"], connection) + if not client: + return + try: + connection.send_result( + msg["id"], + await client.async_retain( + msg["event_id"], msg["retain"], decode_json=False + ), + ) + except FrigateApiClientError: + connection.send_error( + msg["id"], + "frigate_error", + f"API error whilst un/retaining event {msg['event_id']} " + f"for Frigate instance {msg['instance_id']}", + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "frigate/recordings/get", + vol.Required("instance_id"): str, + vol.Required("camera"): str, + vol.Optional("after"): int, + vol.Optional("before"): int, + } +) # type: ignore[misc] +@websocket_api.async_response # type: ignore[misc] +async def ws_get_recordings( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Get recordings for a camera.""" + client = _get_client_or_send_error(hass, msg["instance_id"], msg["id"], connection) + if not client: + return + try: + connection.send_result( + msg["id"], + await client.async_get_recordings( + msg["camera"], msg.get("after"), msg.get("before"), decode_json=False + ), + ) + except FrigateApiClientError: + connection.send_error( + msg["id"], + "frigate_error", + f"API error whilst retrieving recordings for camera {msg['camera']} " + f"for Frigate instance {msg['instance_id']}", + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "frigate/recordings/summary", + vol.Required("instance_id"): str, + vol.Required("camera"): str, + vol.Optional("timezone"): str, + } +) # type: ignore[misc] +@websocket_api.async_response # type: ignore[misc] +async def ws_get_recordings_summary( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Get recordings summary for a camera.""" + client = _get_client_or_send_error(hass, msg["instance_id"], msg["id"], connection) + if not client: + return + try: + connection.send_result( + msg["id"], + await client.async_get_recordings_summary( + msg["camera"], msg.get("timezone", "utc"), decode_json=False + ), + ) + except FrigateApiClientError: + connection.send_error( + msg["id"], + "frigate_error", + f"API error whilst retrieving recordings summary for camera " + f"{msg['camera']} for Frigate instance {msg['instance_id']}", + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "frigate/events/get", + vol.Required("instance_id"): str, + vol.Optional("cameras"): [str], + vol.Optional("labels"): [str], + vol.Optional("sub_labels"): [str], + vol.Optional("zones"): [str], + vol.Optional("after"): int, + vol.Optional("before"): int, + vol.Optional("limit"): int, + vol.Optional("has_clip"): bool, + vol.Optional("has_snapshot"): bool, + vol.Optional("has_snapshot"): bool, + vol.Optional("favorites"): bool, + } +) # type: ignore[misc] +@websocket_api.async_response # type: ignore[misc] +async def ws_get_events( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Get events.""" + client = _get_client_or_send_error(hass, msg["instance_id"], msg["id"], connection) + if not client: + return + + try: + connection.send_result( + msg["id"], + await client.async_get_events( + msg.get("cameras"), + msg.get("labels"), + msg.get("sub_labels"), + msg.get("zones"), + msg.get("after"), + msg.get("before"), + msg.get("limit"), + msg.get("has_clip"), + msg.get("has_snapshot"), + msg.get("favorites"), + decode_json=False, + ), + ) + except FrigateApiClientError: + connection.send_error( + msg["id"], + "frigate_error", + f"API error whilst retrieving events for cameras " + f"{msg['cameras']} for Frigate instance {msg['instance_id']}", + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "frigate/events/summary", + vol.Required("instance_id"): str, + vol.Optional("has_clip"): bool, + vol.Optional("has_snapshot"): bool, + vol.Optional("timezone"): str, + } +) # type: ignore[misc] +@websocket_api.async_response # type: ignore[misc] +async def ws_get_events_summary( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Get events.""" + client = _get_client_or_send_error(hass, msg["instance_id"], msg["id"], connection) + if not client: + return + + try: + connection.send_result( + msg["id"], + await client.async_get_event_summary( + msg.get("has_clip"), + msg.get("has_snapshot"), + msg.get("timezone", "utc"), + decode_json=False, + ), + ) + except FrigateApiClientError: + connection.send_error( + msg["id"], + "frigate_error", + f"API error whilst retrieving events summary for Frigate instance " + f"{msg['instance_id']}", + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "frigate/ptz/info", + vol.Required("instance_id"): str, + vol.Required("camera"): str, + } +) # type: ignore[misc] +@websocket_api.async_response # type: ignore[misc] +async def ws_get_ptz_info( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Get PTZ info.""" + client = _get_client_or_send_error(hass, msg["instance_id"], msg["id"], connection) + if not client: + return + + try: + connection.send_result( + msg["id"], + await client.async_get_ptz_info( + msg["camera"], + decode_json=False, + ), + ) + except FrigateApiClientError: + connection.send_error( + msg["id"], + "frigate_error", + f"API error whilst retrieving PTZ info for camera " + f"{msg['camera']} for Frigate instance {msg['instance_id']}", + ) diff --git a/config/custom_components/pollens/README.md b/config/custom_components/pollens/README.md new file mode 100644 index 0000000..91cbc05 --- /dev/null +++ b/config/custom_components/pollens/README.md @@ -0,0 +1,3 @@ +# Pollens-Async + +Custom component for pollens diff --git a/config/custom_components/pollens/__init__.py b/config/custom_components/pollens/__init__.py new file mode 100644 index 0000000..051a581 --- /dev/null +++ b/config/custom_components/pollens/__init__.py @@ -0,0 +1,191 @@ +"""Pollens Allergy component.""" +from __future__ import annotations +from datetime import timedelta +import logging +from os import error +from re import I +from typing import Any + +from aiohttp.client_exceptions import ClientError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_SCAN_INTERVAL, CONF_SENSORS, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.update_coordinator import ( + DataUpdateCoordinator, + CoordinatorEntity, + UpdateFailed, +) + +from .const import ( + ATTRIBUTION, + CONF_LITERAL, + CONF_POLLENSLIST, + CONF_VERSION, + DOMAIN, + COORDINATOR, + UNDO_LISTENER, + CONF_COUNTRYCODE, + CONF_SCAN_INTERVAL, + KEY_TO_ATTR, +) + +from .pollensasync import PollensClient + +# List of platforms to support. There should be a matching .py file for each, +# eg and +PLATFORMS: list[str] = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up pollens integation""" + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up pollens from a config entry.""" + + conf = entry.data + + session = aiohttp_client.async_get_clientsession(hass) + api = PollensClient(session) + + county = conf[CONF_COUNTRYCODE] + scan_interval = entry.options.get(CONF_SCAN_INTERVAL, 3) + + await api.Get(county) + + name = f"Pollens {api.county_name}" + + coordinator = PollensUpdateCoordinator( + hass=hass, + name=name, + scan_interval=scan_interval, + county=county, + api=api, + ) + + await coordinator.async_config_entry_first_refresh() + + # Add and update listener + undo_listener = entry.add_update_listener(_async_update_listener) + + # Setup coordinator + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = { + COORDINATOR: coordinator, + UNDO_LISTENER: undo_listener, + "pollens_api": api, + } + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + _LOGGER.debug("Setup of %s successful", entry.title) + + return True + + +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate the config entry upon new versions.""" + version = entry.version + data = {**entry.data} + + _LOGGER.debug("Migrating from version %s", version) + + # 1 -> 2: Remove unused condition data: + if version == 1: + data.pop(CONF_SENSORS, None) + data[CONF_POLLENSLIST] = [pollen for pollen in KEY_TO_ATTR] + data[CONF_LITERAL] = True + version = entry.version = CONF_VERSION + hass.config_entries.async_update_entry(entry, data=data) + _LOGGER.debug("Migration to version %s successful", version) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok + + +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry): + """Update when config_entry options update""" + await hass.config_entries.async_reload(entry.entry_id) + + +class PollensUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching Pollens data API""" + + def __init__( + self, + hass: HomeAssistant, + name: str, + scan_interval: int, + api: str, + county: str, + # level_filter: int, + ) -> None: + + super().__init__( + hass=hass, + logger=_LOGGER, + name=name, + update_interval=timedelta(hours=scan_interval), + ) + + self.api = api + self.name = name + self.county = county + + async def _async_update_data(self): + _LOGGER.info("Update data from web site for %s", self.name) + try: + return await self.api.Get(self.county) + except ClientError as error: + raise UpdateFailed(f"Error updating from RSSA : {error}") from error + + +class PollensEntity(CoordinatorEntity): + """Implementation of the base pollens Entity""" + + _attr_extra_state_attributes = {"attribution": ATTRIBUTION} + + def __init__( + self, + coordinator: PollensUpdateCoordinator, + name: str, + icon: str, + entry: ConfigEntry, + ) -> None: + """Initialize""" + + super().__init__(coordinator=coordinator) + + # self._attr_unique_id = f"{entry.entry_id}_{KEY_TO_ATTR[name.lower()][0]}" + # self._attr_icon = icon + # self._unique_id = f"pollens_{entry.entry_id}" + + @property + def device_info(self) -> DeviceInfo: + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={ + ( + DOMAIN, + str( + f"{self.platform.config_entry.unique_id}{self.platform.config_entry.data['county']}" + ), + ) + }, + manufacturer="RNSA", + model="Pollens sensor", + name=self.coordinator.name, + ) diff --git a/config/custom_components/pollens/config_flow.py b/config/custom_components/pollens/config_flow.py new file mode 100644 index 0000000..9563ac9 --- /dev/null +++ b/config/custom_components/pollens/config_flow.py @@ -0,0 +1,175 @@ +"""Config flow for Pollens integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol +import homeassistant.helpers.config_validation as cv + +from homeassistant import config_entries, exceptions +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers import selector + +from .const import ( + CONF_VERSION, + DOMAIN, + KEY_TO_ATTR, + CONF_COUNTRYCODE, + CONF_SCAN_INTERVAL, + CONF_POLLENSLIST, + CONF_LITERAL, +) +from .dept import DEPARTMENTS + +from .pollensasync import PollensClient + +_LOGGER = logging.getLogger(__name__) + + +async def validate_input(hass: HomeAssistant, data: dict) -> dict[str, Any]: + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + # Validate the data can be used to set up a connection. + if len(data[CONF_COUNTRYCODE]) != 2: + raise InvalidCounty + + session = aiohttp_client.async_get_clientsession(hass) + client = PollensClient(session) + result = await client.Get(data[CONF_COUNTRYCODE]) + if not result: + # If there is an error, raise an exception to notify HA that there was a + # problem. The UI will also show there was a problem + raise CannotConnect + title = f"Pollens {client.county_name}" + return {"title": title} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Pollens.""" + + VERSION = CONF_VERSION + # Pick one of the available connection classes in homeassistant/config_entries.py + # This tells HA if it should be asking for updates, or it'll be notified of updates + # automatically. This example uses PUSH, as the dummy hub will notify HA of + # changes. + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize""" + self.data = None + self._init_info = {} + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + self._init_info["data"] = user_input + self._init_info["info"] = await validate_input(self.hass, user_input) + return await self.async_step_select_pollens() + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidCounty: + errors["base"] = "invalid_county" + _LOGGER.exception("Invalid county selected") + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + entries = self._async_current_entries() + # Remove county from the list (already configured) + for entry in entries: + if entry.data[CONF_COUNTRYCODE] in DEPARTMENTS: + DEPARTMENTS.pop(entry.data[CONF_COUNTRYCODE]) + + # If there is no user input or there were errors, show the form again, including any errors that were found with the input. + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_COUNTRYCODE, default=["60"]): vol.In(DEPARTMENTS), + vol.Required(CONF_LITERAL, default=True): cv.boolean, + } + ), + description_placeholders={"docs_url": "pollens.fr"}, + errors=errors, + ) + + async def async_step_select_pollens(self, user_input=None): + """Select pollens step 2""" + if user_input is not None: + _LOGGER.info("Select pollens step") + self._init_info["data"][CONF_POLLENSLIST] = user_input[CONF_POLLENSLIST] + return self.async_create_entry( + title=self._init_info["info"]["title"], data=self._init_info["data"] + ) + pollens = [pollen for pollen in KEY_TO_ATTR] + return self.async_show_form( + step_id="select_pollens", + data_schema=vol.Schema( + {vol.Optional(CONF_POLLENSLIST): cv.multi_select(pollens)} + ), + ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> OptionsFlowHandler: + """Options callback for Pollens.""" + return OptionsFlowHandler(config_entry) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidCounty(exceptions.HomeAssistantError): + """Error to invalid county.""" + + +class InvalidScanInterval(exceptions.HomeAssistantError): + """Error to invalid scan interval .""" + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Config flow options for Pollens.""" + + def __init__(self, entry: ConfigEntry) -> None: + """Initialize Pollens options flow.""" + self.config_entry = entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + _LOGGER.info("Changing options of pollens integration") + errors = {} + if user_input is not None: + # Validate the data can be used to set up a connection. + _LOGGER.info( + "Change option of %s to %s", + self.config_entry.title, + user_input[CONF_SCAN_INTERVAL], + ) + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + # Configuration of scan interval mini 3 h max 24h + vol.Optional( + CONF_SCAN_INTERVAL, + default=self.config_entry.options.get(CONF_SCAN_INTERVAL, 1), + ): selector.NumberSelector(selector.NumberSelectorConfig(min=3, max=24, mode=selector.NumberSelectorMode.BOX)), + } + ), + errors=errors, + ) diff --git a/config/custom_components/pollens/const.py b/config/custom_components/pollens/const.py new file mode 100644 index 0000000..8f32379 --- /dev/null +++ b/config/custom_components/pollens/const.py @@ -0,0 +1,67 @@ +"""Constants for the Pollens integration.""" + +DOMAIN = "pollens" +ATTRIBUTION = "Data from Reseau National de Surveillance Aerobiologique " +CONF_VERSION = 2 +COORDINATOR = "coordinator" +UNDO_LISTENER = "undo_listener" + +CONF_COUNTRYCODE = "county" +CONF_SCAN_INTERVAL = "scan_interval" +CONF_SCANINTERVAL = "scaninterval" +CONF_POLLENSLIST = "pollens_list" +CONF_LITERAL = "literal_states" + +ATTR_TILLEUL = "tilleul" +ATTR_AMBROISIES = "ambroisies" +ATTR_OLIVIER = "olivier" +ATTR_PLANTAIN = "plantain" +ATTR_NOISETIER = "noisetier" +ATTR_AULNE = "aulne" +ATTR_ARMOISE = "armoise" +ATTR_CHATAIGNIER = "chataignier" +ATTR_URTICACEES = "urticacees" +ATTR_OSEILLE = "oseille" +ATTR_GRAMINEES = "graminees" +ATTR_CHENE = "chene" +ATTR_PLATANE = "platane" +ATTR_BOULEAU = "bouleau" +ATTR_CHARME = "charme" +ATTR_PEUPLIER = "peuplier" +ATTR_FRENE = "frene" +ATTR_SAULE = "saule" +ATTR_CYPRES = "cypres" +ATTR_CUPRESSASEES = "cupressacees" +ATTR_LITERAL_STATE = "literal_state" +ATTR_POLLEN_NAME = "pollen_name" + +ICON_FLOWER = "mdi:flower" +ICON_TREE = "mdi:tree" +ICON_GRASS = "mdi:grass" +KEY_TO_ATTR = { + "tilleul": [ATTR_TILLEUL, ICON_TREE], + "ambroisies": [ATTR_AMBROISIES, ICON_GRASS], + "olivier": [ATTR_OLIVIER, ICON_TREE], + "plantain": [ATTR_PLANTAIN, ICON_GRASS], + "noisetier": [ATTR_NOISETIER, ICON_TREE], + "aulne": [ATTR_AULNE, ICON_TREE], + "armoise": [ATTR_ARMOISE, ICON_GRASS], + "châtaignier": [ATTR_CHATAIGNIER, ICON_TREE], + "urticacées": [ATTR_URTICACEES, ICON_GRASS], + "oseille": [ATTR_OSEILLE, ICON_GRASS], + "graminées": [ATTR_GRAMINEES, ICON_GRASS], + "chêne": [ATTR_CHENE, ICON_TREE], + "platane": [ATTR_PLATANE, ICON_TREE], + "bouleau": [ATTR_BOULEAU, ICON_TREE], + "charme": [ATTR_CHARME, ICON_TREE], + "peuplier": [ATTR_PEUPLIER, ICON_TREE], + "frêne": [ATTR_FRENE, ICON_TREE], + "saule": [ATTR_SAULE, ICON_TREE], + "cyprès": [ATTR_CYPRES, ICON_TREE], + "cupressacées": [ATTR_CUPRESSASEES, ICON_GRASS], +} + +ATTR_COUNTY_NAME = "departement" +ATTR_URL = "url" + +LIST_RISK = ["nul", "faible", "moyen", "élevé"] diff --git a/config/custom_components/pollens/dept.py b/config/custom_components/pollens/dept.py new file mode 100644 index 0000000..887aba0 --- /dev/null +++ b/config/custom_components/pollens/dept.py @@ -0,0 +1,105 @@ +""" Dictionnaire des départements Français """ + +DEPARTMENTS = { + "01": "Ain", + "02": "Aisne", + "03": "Allier", + "04": "Alpes-de-Haute-Provence", + "05": "Hautes-Alpes", + "06": "Alpes-Maritimes", + "07": "Ardèche", + "08": "Ardennes", + "09": "Ariège", + "10": "Aube", + "11": "Aude", + "12": "Aveyron", + "13": "Bouches-du-Rhône", + "14": "Calvados", + "15": "Cantal", + "16": "Charente", + "17": "Charente-Maritime", + "18": "Cher", + "19": "Corrèze", + "20": "Corse-du-Sud", + "20": "Haute-Corse", + "21": "Côte-d'Or", + "22": "Côtes-d'Armor", + "23": "Creuse", + "24": "Dordogne", + "25": "Doubs", + "26": "Drôme", + "27": "Eure", + "28": "Eure-et-Loir", + "29": "Finistère", + "30": "Gard", + "31": "Haute-Garonne", + "32": "Gers", + "33": "Gironde", + "34": "Hérault", + "35": "Ille-et-Vilaine", + "36": "Indre", + "37": "Indre-et-Loire", + "38": "Isère", + "39": "Jura", + "40": "Landes", + "41": "Loir-et-Cher", + "42": "Loire", + "43": "Haute-Loire", + "44": "Loire-Atlantique", + "45": "Loiret", + "46": "Lot", + "47": "Lot-et-Garonne", + "48": "Lozère", + "49": "Maine-et-Loire", + "50": "Manche", + "51": "Marne", + "52": "Haute-Marne", + "53": "Mayenne", + "54": "Meurthe-et-Moselle", + "55": "Meuse", + "56": "Morbihan", + "57": "Moselle", + "58": "Nièvre", + "59": "Nord", + "60": "Oise", + "61": "Orne", + "62": "Pas-de-Calais", + "63": "Puy-de-Dôme", + "64": "Pyrénées-Atlantiques", + "65": "Hautes-Pyrénées", + "66": "Pyrénées-Orientales", + "67": "Bas-Rhin", + "68": "Haut-Rhin", + "69": "Rhône", + "70": "Haute-Saône", + "71": "Saône-et-Loire", + "72": "Sarthe", + "73": "Savoie", + "74": "Haute-Savoie", + "75": "Paris", + "76": "Seine-Maritime", + "77": "Seine-et-Marne", + "78": "Yvelines", + "79": "Deux-Sèvres", + "80": "Somme", + "81": "Tarn", + "82": "Tarn-et-Garonne", + "83": "Var", + "84": "Vaucluse", + "85": "Vendée", + "86": "Vienne", + "87": "Haute-Vienne", + "88": "Vosges", + "89": "Yonne", + "90": "Territoire de Belfort", + "91": "Essonne", + "92": "Hauts-de-Seine", + "93": "Seine-Saint-Denis", + "94": "Val-de-Marne", + "95": "Val-d'Oise", + "971": "Guadeloupe", + "972": "Martinique", + "973": "Guyane", + "974": "La Réunion", + "976": "Mayotte", +} diff --git a/config/custom_components/pollens/info.md b/config/custom_components/pollens/info.md new file mode 100644 index 0000000..7e8f93f --- /dev/null +++ b/config/custom_components/pollens/info.md @@ -0,0 +1,22 @@ +[![](https://img.shields.io/badge/MAINTAINER-%40chris60600-red)](https://github.com/chris60600) +[![hacs_badge](https://img.shields.io/badge/HACS-Custom-orange.svg)](https://github.com/custom-components/hacs) + +# HomeAssistant - Pollens Custom Component + +This module show pollen concentration inside [Homeassistant](https://home-assistant.io): + +Datas provided by 'Réseau National de Surveillance Aérobiologique' (R.N.S.A.) +https://pollens.fr + +# Installation (There are two methods, with HACS or manual) + +### 1. Easy Mode + + + +### 2. Manual + +Install it as you would do with any homeassistant custom component: + +1. Download `custom_components` folder. +2. Copy the `pollens` direcotry within the `custom_components` directory of your homeassistant installation. The `custom_components` directory resides within your homeassistant configuration directory. diff --git a/config/custom_components/pollens/manifest.json b/config/custom_components/pollens/manifest.json new file mode 100644 index 0000000..2d4c6b9 --- /dev/null +++ b/config/custom_components/pollens/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "pollens", + "name": "Reseau National de Surveillance Aerobiologique ((R.N.S.A.))", + "documentation": "https://github.com/chris60600/pollens-home-assistant", + "issue_tracker": "https://github.com/chris60600/pollens-home-assistant/issues", + "requirements": [], + "codeowners": ["@chris60600"], + "config_flow": true, + "iot_class": "cloud_polling", + "version": "2023.06.01" +} diff --git a/config/custom_components/pollens/pollensasync.py b/config/custom_components/pollens/pollensasync.py new file mode 100644 index 0000000..f2a7703 --- /dev/null +++ b/config/custom_components/pollens/pollensasync.py @@ -0,0 +1,69 @@ +"""asyncio-friendly python API for RNSA (https://pollens.fr).""" +import asyncio +import aiohttp +from aiohttp.client import ClientError, ClientTimeout +import json + +import async_timeout + +DEFAULT_TIMEOUT = 240 + +CLIENT_TIMEOUT = ClientTimeout(total=DEFAULT_TIMEOUT) + +BASE_URL = "https://pollens.fr/risks/thea/counties/{}" + + +class PollensClient: + """Pollens client implementation.""" + + def __init__(self, session: aiohttp.ClientSession = None, timeout=CLIENT_TIMEOUT): + """Constructor. + session: aiohttp.ClientSession or None to create a new session. + """ + self._county_name = None + self._params = {} + self._risk_level = None + self._risks = {} + self._timeout = timeout + if session is not None: + self._session = session + else: + self._session = aiohttp.ClientSession() + + async def Get(self, number): + """Get data by station number.""" + try: + request = await self._session.get( + BASE_URL.format(number), timeout=CLIENT_TIMEOUT + ) + if "application/json" in request.headers["content-type"]: + request_json = await request.json() + else: + request_json = await request.text() + request_json = json.loads(request_json) + + self._county_name = request_json["countyName"] + for risk in request_json["risks"]: + self._risks[risk["pollenName"]] = risk["level"] + self._risk_level = request_json["riskLevel"] + return request_json + except (ClientError, asyncio.TimeoutError, ConnectionRefusedError) as err: + return None + + @property + def county_name(self): + return self._county_name + + @property + def risks(self): + return self._risks + + @property + def risk_level(self): + return self._risk_level + + # async def _get(self, path, **kwargs): + # with async_timeout.timeout(self._timeout): + # resp = await self._session.get(path, params=dict(self._params, **kwargs)) + # print(type(resp.headers["Content-Type"])) + # return await resp.text(encoding="utf-8") diff --git a/config/custom_components/pollens/requirement.txt b/config/custom_components/pollens/requirement.txt new file mode 100644 index 0000000..4812b6f --- /dev/null +++ b/config/custom_components/pollens/requirement.txt @@ -0,0 +1 @@ +async diff --git a/config/custom_components/pollens/sensor.py b/config/custom_components/pollens/sensor.py new file mode 100644 index 0000000..8c6fbea --- /dev/null +++ b/config/custom_components/pollens/sensor.py @@ -0,0 +1,167 @@ +"""Support for the RNSA pollens service.""" + +import logging +from warnings import catch_warnings + +from yaml import KeyToken +from homeassistant.config_entries import ConfigEntry +from homeassistant.components.sensor import SensorEntity, SensorDeviceClass, SensorStateClass + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + DOMAIN, + LIST_RISK, + ATTR_URL, + ATTR_COUNTY_NAME, + ATTR_POLLEN_NAME, + ATTR_LITERAL_STATE, + KEY_TO_ATTR, + COORDINATOR, + CONF_POLLENSLIST, + CONF_LITERAL, +) +from . import PollensEntity, PollensUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +ICONS = { + 0: "mdi:decagram-outline", + 1: "mdi:decagram-check", + 2: "mdi:alert-decagram-outline", + 3: "mdi:alert-decagram", +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Setup Sensor Plateform""" + coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR] + sensors = [] + try: + enabled_pollens = entry.data[CONF_POLLENSLIST] + except KeyError: + enabled_pollens = [pollen for pollen in KEY_TO_ATTR] + for risk in coordinator.api.risks: + name = risk + icon = KEY_TO_ATTR[risk.lower()][1] + sensors.append(PollenSensor(coordinator, name=name, icon=icon, entry=entry, enabled=name.lower() in enabled_pollens)) + + name = f"pollens_{coordinator.county}" + icon = ICONS[0] + sensors.append( + RiskSensor(coordinator=coordinator, name=name, icon=icon, entry=entry, numeric=False) + ) + sensors.append( + RiskSensor(coordinator=coordinator, name=name + "_risklevel", icon=icon, entry=entry, numeric=True) + ) + + async_add_entities(sensors, True) + + +class PollenSensor(PollensEntity, SensorEntity): + """Implementation of a Pollens sensor.""" + + def __init__( + self, + coordinator: PollensUpdateCoordinator, + name: str, + icon: str, + entry: ConfigEntry, + enabled: bool + ) -> None: + super().__init__(coordinator, name, icon, entry) + self._name = f"pollens_{coordinator.county}_{KEY_TO_ATTR[name.lower()][0]}" + self._state = coordinator.api.risks[name] + self._unique_id = f"{entry.entry_id}_{self._name}" + self._attr_name = name + self._attr_unique_id = self._unique_id + self._attr_entity_registry_enabled_default = enabled + + try: + self._literal_state = entry.data[CONF_LITERAL] + # Setup DeviceClass in AQI and stateClass in numeric (Issue #15) + if not self._literal_state: + self._attr_device_class = SensorDeviceClass.AQI + self._attr_state_class = SensorStateClass.MEASUREMENT + + except KeyError: + self._literal_state = True + self._attr_icon = icon + self._friendly_name = f"{name}" + + @property + def name(self): + return self._name + + @property + def native_value(self): + value = self.coordinator.api.risks[self._attr_name] + if self._literal_state: + return LIST_RISK[value] + else: + return value + + @property + def unique_id(self): + """Return the unique id.""" + return self._unique_id + + @property + def extra_state_attributes(self): + """Return the state attributes of the last update.""" + attrs = {} + attrs[ATTR_POLLEN_NAME] = self._friendly_name + if self.coordinator.api.risks is not None: + if not self._literal_state: + value = self.coordinator.api.risks[self._attr_name] + attrs[ATTR_LITERAL_STATE] = LIST_RISK[value] + return attrs + + +class RiskSensor(PollensEntity, SensorEntity): + """Implementation of Risk Sensor""" + + def __init__( + self, + coordinator: PollensUpdateCoordinator, + name: str, + icon: str, + entry: ConfigEntry, + numeric: bool + ) -> None: + super().__init__(coordinator, name, icon, entry) + self._risk_level = coordinator.api.risk_level + self._attr_unique_id = f"{entry.entry_id}_{coordinator.county}" + self._attr_icon = icon + self._name = name + self._numeric = numeric + if numeric: + self._attr_unique_id += "_risklevel" + self._attr_device_class = SensorDeviceClass.AQI + self._attr_state_class = SensorStateClass.MEASUREMENT + + @property + def native_value(self): + value = self.coordinator.api.risk_level + if self._numeric: + return value + else: + return LIST_RISK[value] + + @property + def icon(self): + return ICONS[self._risk_level] + + @property + def name(self): + return self._name + + @property + def extra_state_attributes(self): + attrs = {} + attrs[ATTR_URL] = "https://pollens.fr" + attrs[ATTR_COUNTY_NAME] = self.coordinator.api.county_name + return attrs diff --git a/config/custom_components/pollens/strings.json b/config/custom_components/pollens/strings.json new file mode 100644 index 0000000..ea6d4ff --- /dev/null +++ b/config/custom_components/pollens/strings.json @@ -0,0 +1,45 @@ +{ + "title": "Pollens", + "config": { + "step": { + "user": { + "title":"Pollens - Step #1", + "description":"If you need information about pollens on site R.N.S.A. website, have a look here: https://www.pollens.fr", + "data": { + "county": "[%key:common::config_flow::data::county%]", + "scan_interval": "[%key:common::config_flow::data::scan_interval%]", + "literal_states":"[%key:common::config_flow::data::literal_states%]" + } + }, + "select_pollens": { + "title":"Pollens - Step #2", + "description":"Select pollens \r\nhttps://www.pollens.fr", + "data": { + "pollens_list": "Select pollens from list" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "invalid_county": "[%key:common::config_flow::error::invalid_county%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + }, + "options":{ + "step":{ + "init":{ + "data":{ + "scan_interval": "Scan interval" + } + } + }, + "error": { + "invalid_scan_interval":"[%key:common::config_flow::error::invalid_scan_interval%]" + } + + } + } diff --git a/config/custom_components/pollens/translations/en.json b/config/custom_components/pollens/translations/en.json new file mode 100644 index 0000000..dcaf2e1 --- /dev/null +++ b/config/custom_components/pollens/translations/en.json @@ -0,0 +1,46 @@ +{ + "title": "Pollens (from R.N.S.A. website)\r\nPlease visit www.pollen.fr for more information", + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "single_instance_allowed": "Only one configuration of Pollens allowed" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_county": "Only one county is actualy allowed", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "title":"Pollens - Step #1", + "description":"Select county \r\nhttps://www.pollens.fr", + "data": { + "county": "County", + "scan_interval": "Scan interval (hours)", + "literal_states":"States in literal (in numeric if not selected)" + } + }, + "select_pollens": { + "title":"Pollens - Step #2", + "description":"Select pollens \r\nhttps://www.pollens.fr", + "data": { + "pollens_list": "Select pollens from list" + } + } + } + }, + "options":{ + "step":{ + "init":{ + "data":{ + "scan_interval": "Scan interval (hours)" + } + } + }, + "error": { + "invalid_scan_interval": "Invalid scan interval. Must be higher than 1 hour" + } + + } + +} \ No newline at end of file diff --git a/config/custom_components/pollens/translations/fr.json b/config/custom_components/pollens/translations/fr.json new file mode 100644 index 0000000..b250e31 --- /dev/null +++ b/config/custom_components/pollens/translations/fr.json @@ -0,0 +1,45 @@ +{ + "title": "Pollens", + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "single_instance_allowed": "Une seule configuration de Pollens est autoris\u00e9e" + }, + "error": { + "cannot_connect": "Impossible de se connecter au serveur", + "invalid_county": "Un seul d\u00e9partment est actuellemnt g\u00e9r\u00e9", + "unknown": "Erreur inconnue" + }, + "step": { + "user": { + "data": { + "county": "D\u00e9partement", + "scan_interval": "P\u00e9riode d\u0027int\u00e9rogation (heures)", + "literal_states": "Etats en texte (non selection\u00e9 = num\u00e9rique)" + }, + "title":"Pollens - Etape #1", + "description":"Si vous avez besoin d\u0027information sur les pollens rendez vous sur le site du R.N.S.A, https://www.pollens.fr" + }, + "select_pollens": { + "title":"Pollens - Etape #2", + "description":"Selection des pollens \r\nhttps://www.pollens.fr", + "data": { + "pollens_list": "Selectionnez un/des pollens dans la liste" + } + } + } + }, + "options":{ + "step":{ + "init":{ + "data":{ + "scan_interval": "P\u00e9riode d\u0027int\u00e9rogation (heures)" + } + } + }, + "error": { + "invalid_scan_interval": "La periode d interrogation doit etre superieure a 1 heure" + + } + } +} \ No newline at end of file diff --git a/config/custom_components/versatile_thermostat/__init__.py b/config/custom_components/versatile_thermostat/__init__.py new file mode 100644 index 0000000..e132735 --- /dev/null +++ b/config/custom_components/versatile_thermostat/__init__.py @@ -0,0 +1,232 @@ +"""The Versatile Thermostat integration.""" + +from __future__ import annotations + +from typing import Dict + +import asyncio +import logging +import voluptuous as vol +import homeassistant.helpers.config_validation as cv + +from homeassistant.const import SERVICE_RELOAD, EVENT_HOMEASSISTANT_STARTED + +from homeassistant.config_entries import ConfigEntry, ConfigType +from homeassistant.core import HomeAssistant, CoreState, callback + +from .base_thermostat import BaseThermostat + +from .const import ( + DOMAIN, + PLATFORMS, + CONFIG_VERSION, + CONFIG_MINOR_VERSION, + CONF_AUTO_REGULATION_LIGHT, + CONF_AUTO_REGULATION_MEDIUM, + CONF_AUTO_REGULATION_STRONG, + CONF_AUTO_REGULATION_SLOW, + CONF_AUTO_REGULATION_EXPERT, + CONF_SHORT_EMA_PARAMS, + CONF_SAFETY_MODE, + CONF_THERMOSTAT_CENTRAL_CONFIG, + CONF_THERMOSTAT_TYPE, + CONF_USE_WINDOW_FEATURE, + CONF_USE_MOTION_FEATURE, + CONF_USE_PRESENCE_FEATURE, + CONF_USE_POWER_FEATURE, + CONF_USE_CENTRAL_BOILER_FEATURE, + CONF_POWER_SENSOR, + CONF_PRESENCE_SENSOR, +) + +from .vtherm_api import VersatileThermostatAPI + +_LOGGER = logging.getLogger(__name__) + +SELF_REGULATION_PARAM_SCHEMA = { + vol.Required("kp"): vol.Coerce(float), + vol.Required("ki"): vol.Coerce(float), + vol.Required("k_ext"): vol.Coerce(float), + vol.Required("offset_max"): vol.Coerce(float), + vol.Required("stabilization_threshold"): vol.Coerce(float), + vol.Required("accumulated_error_threshold"): vol.Coerce(float), +} + +EMA_PARAM_SCHEMA = { + vol.Required("max_alpha"): vol.Coerce(float), + vol.Required("halflife_sec"): vol.Coerce(float), + vol.Required("precision"): cv.positive_int, +} + +SAFETY_MODE_PARAM_SCHEMA = { + vol.Required("check_outdoor_sensor"): bool, +} + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + CONF_AUTO_REGULATION_EXPERT: vol.Schema(SELF_REGULATION_PARAM_SCHEMA), + CONF_SHORT_EMA_PARAMS: vol.Schema(EMA_PARAM_SCHEMA), + CONF_SAFETY_MODE: vol.Schema(SAFETY_MODE_PARAM_SCHEMA), + } + ), + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup( + hass: HomeAssistant, config: ConfigType +): # pylint: disable=unused-argument + """Initialisation de l'intégration""" + _LOGGER.info( + "Initializing %s integration with config: %s", + DOMAIN, + config.get(DOMAIN), + ) + + async def _handle_reload(_): + """The reload callback""" + await reload_all_vtherm(hass) + + hass.data.setdefault(DOMAIN, {}) + + api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(hass) + # L'argument config contient votre fichier configuration.yaml + vtherm_config = config.get(DOMAIN) + if vtherm_config is not None: + api.set_global_config(vtherm_config) + else: + _LOGGER.info("No global config from configuration.yaml available") + + # Listen HA starts to initialize all links between + @callback + async def _async_startup_internal(*_): + _LOGGER.info( + "VersatileThermostat - HA is started, initialize all links between VTherm entities" + ) + await api.init_vtherm_links() + await api.notify_central_mode_change() + await api.reload_central_boiler_entities_list() + + if hass.state == CoreState.running: + await _async_startup_internal() + else: + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _async_startup_internal) + + hass.helpers.service.async_register_admin_service( + DOMAIN, + SERVICE_RELOAD, + _handle_reload, + ) + + return True + + +async def reload_all_vtherm(hass): + """Handle reload service call.""" + _LOGGER.info("Service %s.reload called: reloading integration", DOMAIN) + + current_entries = hass.config_entries.async_entries(DOMAIN) + + reload_tasks = [ + hass.config_entries.async_reload(entry.entry_id) for entry in current_entries + ] + + await asyncio.gather(*reload_tasks) + api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(hass) + if api: + await api.reload_central_boiler_entities_list() + await api.init_vtherm_links() + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Versatile Thermostat from a config entry.""" + + _LOGGER.debug( + "Calling async_setup_entry entry: entry_id='%s', value='%s'", + entry.entry_id, + entry.data, + ) + + api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(hass) + + api.add_entry(entry) + + entry.async_on_unload(entry.add_update_listener(update_listener)) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + if hass.state == CoreState.running: + await api.reload_central_boiler_entities_list() + await api.init_vtherm_links() + + return True + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Update listener.""" + if entry.data.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_CENTRAL_CONFIG: + await reload_all_vtherm(hass) + else: + await hass.config_entries.async_reload(entry.entry_id) + # Reload the central boiler list of entities + api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(hass) + if api is not None: + await api.reload_central_boiler_entities_list() + await api.init_vtherm_links() + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(hass) + + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + if api: + api.remove_entry(entry) + await api.reload_central_boiler_entities_list() + + return unload_ok + + +# Example migration function +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry): + """Migrate old entry.""" + _LOGGER.debug( + "Migrating from version %s/%s", config_entry.version, config_entry.minor_version + ) + + if ( + config_entry.version != CONFIG_VERSION + or config_entry.minor_version != CONFIG_MINOR_VERSION + ): + _LOGGER.debug( + "Migration to %s/%s is needed", CONFIG_VERSION, CONFIG_MINOR_VERSION + ) + new = {**config_entry.data} + + if ( + config_entry.data.get(CONF_THERMOSTAT_TYPE) + == CONF_THERMOSTAT_CENTRAL_CONFIG + ): + new[CONF_USE_WINDOW_FEATURE] = True + new[CONF_USE_MOTION_FEATURE] = True + new[CONF_USE_POWER_FEATURE] = new.get(CONF_POWER_SENSOR, None) is not None + new[CONF_USE_PRESENCE_FEATURE] = ( + new.get(CONF_PRESENCE_SENSOR, None) is not None + ) + + new[CONF_USE_CENTRAL_BOILER_FEATURE] = new.get( + "add_central_boiler_control", False + ) or new.get(CONF_USE_CENTRAL_BOILER_FEATURE, False) + + hass.config_entries.async_update_entry( + config_entry, + data=new, + version=CONFIG_VERSION, + minor_version=CONFIG_MINOR_VERSION, + ) + _LOGGER.info("Migration to version %s successful", config_entry.version) + + return True diff --git a/config/custom_components/versatile_thermostat/base_thermostat.py b/config/custom_components/versatile_thermostat/base_thermostat.py new file mode 100644 index 0000000..5ce03e2 --- /dev/null +++ b/config/custom_components/versatile_thermostat/base_thermostat.py @@ -0,0 +1,2805 @@ +# pylint: disable=line-too-long +# pylint: disable=too-many-lines +# pylint: disable=invalid-name +""" Implements the VersatileThermostat climate component """ +import math +import logging + +from datetime import timedelta, datetime +from types import MappingProxyType +from typing import Any, TypeVar, Generic + +from homeassistant.util import dt as dt_util +from homeassistant.core import ( + HomeAssistant, + callback, + CoreState, + Event, + State, +) + +from homeassistant.components.climate import ClimateEntity +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.device_registry import DeviceInfo, DeviceEntryType +from homeassistant.helpers.typing import EventType as HASSEventType + +from homeassistant.helpers.event import ( + async_track_state_change_event, + async_call_later, + EventStateChangedData, +) + +from homeassistant.exceptions import ConditionError +from homeassistant.helpers import condition + +from homeassistant.components.climate import ( + ATTR_PRESET_MODE, + # ATTR_FAN_MODE, + HVACMode, + HVACAction, + # HVAC_MODE_COOL, + # HVAC_MODE_HEAT, + # HVAC_MODE_OFF, + PRESET_ACTIVITY, + # PRESET_AWAY, + PRESET_BOOST, + PRESET_COMFORT, + PRESET_ECO, + # PRESET_HOME, + PRESET_NONE, + # PRESET_SLEEP, + ClimateEntityFeature, +) + +from homeassistant.const import ( + ATTR_TEMPERATURE, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + STATE_OFF, + STATE_ON, + EVENT_HOMEASSISTANT_START, + STATE_HOME, + STATE_NOT_HOME, +) + +from .const import ( + DOMAIN, + DEVICE_MANUFACTURER, + CONF_POWER_SENSOR, + CONF_TEMP_SENSOR, + CONF_LAST_SEEN_TEMP_SENSOR, + CONF_EXTERNAL_TEMP_SENSOR, + CONF_MAX_POWER_SENSOR, + CONF_WINDOW_SENSOR, + CONF_WINDOW_DELAY, + CONF_WINDOW_AUTO_CLOSE_THRESHOLD, + CONF_WINDOW_AUTO_OPEN_THRESHOLD, + CONF_WINDOW_AUTO_MAX_DURATION, + CONF_MOTION_SENSOR, + CONF_MOTION_DELAY, + CONF_MOTION_OFF_DELAY, + CONF_MOTION_PRESET, + CONF_NO_MOTION_PRESET, + CONF_DEVICE_POWER, + CONF_PRESETS, + # CONF_PRESETS_AWAY, + # CONF_PRESETS_WITH_AC, + # CONF_PRESETS_AWAY_WITH_AC, + CONF_CYCLE_MIN, + CONF_PROP_FUNCTION, + CONF_TPI_COEF_INT, + CONF_TPI_COEF_EXT, + CONF_PRESENCE_SENSOR, + CONF_PRESET_POWER, + SUPPORT_FLAGS, + PRESET_FROST_PROTECTION, + PRESET_POWER, + PRESET_SECURITY, + PROPORTIONAL_FUNCTION_TPI, + PRESET_AWAY_SUFFIX, + CONF_SECURITY_DELAY_MIN, + CONF_SECURITY_MIN_ON_PERCENT, + CONF_SECURITY_DEFAULT_ON_PERCENT, + DEFAULT_SECURITY_MIN_ON_PERCENT, + DEFAULT_SECURITY_DEFAULT_ON_PERCENT, + CONF_MINIMAL_ACTIVATION_DELAY, + CONF_USE_MAIN_CENTRAL_CONFIG, + CONF_USE_TPI_CENTRAL_CONFIG, + CONF_USE_PRESETS_CENTRAL_CONFIG, + CONF_USE_WINDOW_CENTRAL_CONFIG, + CONF_USE_MOTION_CENTRAL_CONFIG, + CONF_USE_POWER_CENTRAL_CONFIG, + CONF_USE_PRESENCE_CENTRAL_CONFIG, + CONF_USE_ADVANCED_CENTRAL_CONFIG, + CONF_USE_PRESENCE_FEATURE, + CONF_TEMP_MAX, + CONF_TEMP_MIN, + HIDDEN_PRESETS, + CONF_AC_MODE, + EventType, + ATTR_MEAN_POWER_CYCLE, + ATTR_TOTAL_ENERGY, + PRESET_AC_SUFFIX, + DEFAULT_SHORT_EMA_PARAMS, + CENTRAL_MODE_AUTO, + CENTRAL_MODE_STOPPED, + CENTRAL_MODE_HEAT_ONLY, + CENTRAL_MODE_COOL_ONLY, + CENTRAL_MODE_FROST_PROTECTION, + send_vtherm_event, +) + +from .config_schema import * # pylint: disable=wildcard-import, unused-wildcard-import + +from .vtherm_api import VersatileThermostatAPI +from .underlyings import UnderlyingEntity + +from .prop_algorithm import PropAlgorithm +from .open_window_algorithm import WindowOpenDetectionAlgorithm +from .ema import ExponentialMovingAverage + +_LOGGER = logging.getLogger(__name__) +ConfigData = MappingProxyType[str, Any] +T = TypeVar("T", bound=UnderlyingEntity) + + +def get_tz(hass: HomeAssistant): + """Get the current timezone""" + + return dt_util.get_time_zone(hass.config.time_zone) + + +class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): + """Representation of a base class for all Versatile Thermostat device.""" + + _entity_component_unrecorded_attributes = ( + ClimateEntity._entity_component_unrecorded_attributes.union( + frozenset( + { + "is_on", + "is_controlled_by_central_mode", + "last_central_mode", + "type", + "frost_temp", + "eco_temp", + "boost_temp", + "comfort_temp", + "frost_away_temp", + "eco_away_temp", + "boost_away_temp", + "comfort_away_temp", + "power_temp", + "ac_mode", + "current_power_max", + "saved_preset_mode", + "saved_target_temp", + "saved_hvac_mode", + "security_delay_min", + "security_min_on_percent", + "security_default_on_percent", + "last_temperature_datetime", + "last_ext_temperature_datetime", + "minimal_activation_delay_sec", + "device_power", + "mean_cycle_power", + "last_update_datetime", + "timezone", + "window_sensor_entity_id", + "window_delay_sec", + "window_auto_enabled", + "window_auto_open_threshold", + "window_auto_close_threshold", + "window_auto_max_duration", + "window_action", + "motion_sensor_entity_id", + "presence_sensor_entity_id", + "power_sensor_entity_id", + "max_power_sensor_entity_id", + "temperature_unit", + "is_device_active", + "target_temperature_step", + "is_used_by_central_boiler", + } + ) + ) + ) + + def __init__( + self, + hass: HomeAssistant, + unique_id: str, + name: str, + entry_infos: ConfigData, + ): + """Initialize the thermostat.""" + + super().__init__() + + # To remove some silly warning event if code is fixed + self._enable_turn_on_off_backwards_compatibility = False + + self._hass = hass + self._entry_infos = None + self._attr_extra_state_attributes = {} + + self._unique_id = unique_id + self._name = name + self._prop_algorithm = None + self._async_cancel_cycle = None + self._hvac_mode = None + self._target_temp = None + self._saved_target_temp = None + self._saved_preset_mode = None + self._fan_mode = None + self._humidity = None + self._swing_mode = None + self._current_power = None + self._current_power_max = None + self._window_state = None + self._motion_state = None + self._saved_hvac_mode = None + self._window_call_cancel = None + self._motion_call_cancel = None + self._cur_temp = None + self._ac_mode = None + self._temp_sensor_entity_id = None + self._last_seen_temp_sensor_entity_id = None + self._ext_temp_sensor_entity_id = None + self._last_ext_temperature_measure = None + self._last_temperature_measure = None + self._cur_ext_temp = None + self._presence_state = None + self._overpowering_state = None + self._should_relaunch_control_heating = None + + self._security_delay_min = None + self._security_min_on_percent = None + self._security_default_on_percent = None + self._security_state = None + + self._thermostat_type = None + + self._attr_translation_key = "versatile_thermostat" + + self._total_energy = None + + # because energy of climate is calculated in the thermostat we have to keep that here and not in underlying entity + self._underlying_climate_start_hvac_action_date = None + self._underlying_climate_delta_t = 0 + + self._window_sensor_entity_id = None + self._window_delay_sec = None + self._window_auto_open_threshold = 0 + self._window_auto_close_threshold = 0 + self._window_auto_max_duration = 0 + self._window_auto_state = False + self._window_auto_on = False + self._window_auto_algo = None + self._window_bypass_state = False + self._window_action = None + + self._current_tz = dt_util.get_time_zone(self._hass.config.time_zone) + + self._last_change_time = None + + self._underlyings: list[T] = [] + + self._ema_temp = None + self._ema_algo = None + self._now = None + + self._attr_fan_mode = None + + self._is_central_mode = None + self._last_central_mode = None + self._is_used_by_central_boiler = False + + self._support_flags = None + # Preset will be initialized from Number entities + self._presets: dict[str, Any] = {} # presets + self._presets_away: dict[str, Any] = {} # presets_away + + self._attr_preset_modes: list[str] | None + + self._use_central_config_temperature = False + + self.post_init(entry_infos) + + def clean_central_config_doublon( + self, config_entry: ConfigData, central_config: ConfigEntry | None + ) -> dict[str, Any]: + """Removes all values from config with are concerned by central_config""" + + def clean_one(cfg, schema: vol.Schema): + """Clean one schema""" + for key, _ in schema.schema.items(): + if key in cfg: + del cfg[key] + + cfg = config_entry.copy() + if central_config and central_config.data: + # Removes config if central is used + if cfg.get(CONF_USE_MAIN_CENTRAL_CONFIG) is True: + clean_one(cfg, STEP_CENTRAL_MAIN_DATA_SCHEMA) + + if cfg.get(CONF_USE_TPI_CENTRAL_CONFIG) is True: + clean_one(cfg, STEP_CENTRAL_TPI_DATA_SCHEMA) + + if cfg.get(CONF_USE_WINDOW_CENTRAL_CONFIG) is True: + clean_one(cfg, STEP_CENTRAL_WINDOW_DATA_SCHEMA) + + if cfg.get(CONF_USE_MOTION_CENTRAL_CONFIG) is True: + clean_one(cfg, STEP_CENTRAL_MOTION_DATA_SCHEMA) + + if cfg.get(CONF_USE_POWER_CENTRAL_CONFIG) is True: + clean_one(cfg, STEP_CENTRAL_POWER_DATA_SCHEMA) + + if cfg.get(CONF_USE_PRESENCE_CENTRAL_CONFIG) is True: + clean_one(cfg, STEP_CENTRAL_PRESENCE_DATA_SCHEMA) + + if cfg.get(CONF_USE_ADVANCED_CENTRAL_CONFIG) is True: + clean_one(cfg, STEP_CENTRAL_ADVANCED_DATA_SCHEMA) + + # take all central config + entry_infos = central_config.data.copy() + # and merge with cleaned config_entry + entry_infos.update(cfg) + else: + entry_infos = cfg + + return entry_infos + + def post_init(self, config_entry: ConfigData): + """Finish the initialization of the thermostast""" + + _LOGGER.info( + "%s - Updating VersatileThermostat with infos %s", + self, + config_entry, + ) + + api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(self._hass) + central_config = api.find_central_configuration() + + entry_infos = self.clean_central_config_doublon(config_entry, central_config) + + _LOGGER.info("%s - The merged configuration is %s", self, entry_infos) + + self._entry_infos = entry_infos + + self._use_central_config_temperature = entry_infos.get( + CONF_USE_PRESETS_CENTRAL_CONFIG + ) or ( + entry_infos.get(CONF_USE_PRESENCE_CENTRAL_CONFIG) + and entry_infos.get(CONF_USE_PRESENCE_FEATURE) + ) + + self._ac_mode = entry_infos.get(CONF_AC_MODE) is True + self._attr_max_temp = entry_infos.get(CONF_TEMP_MAX) + self._attr_min_temp = entry_infos.get(CONF_TEMP_MIN) + if (step := entry_infos.get(CONF_STEP_TEMPERATURE)) is not None: + self._attr_target_temperature_step = step + + self._attr_preset_modes: list[str] | None + + if self._window_call_cancel is not None: + self._window_call_cancel() + self._window_call_cancel = None + if self._motion_call_cancel is not None: + self._motion_call_cancel() + self._motion_call_cancel = None + + self._cycle_min = entry_infos.get(CONF_CYCLE_MIN) + + # Initialize underlying entities (will be done in subclasses) + self._underlyings = [] + + self._proportional_function = entry_infos.get(CONF_PROP_FUNCTION) + self._temp_sensor_entity_id = entry_infos.get(CONF_TEMP_SENSOR) + self._last_seen_temp_sensor_entity_id = entry_infos.get( + CONF_LAST_SEEN_TEMP_SENSOR + ) + self._ext_temp_sensor_entity_id = entry_infos.get(CONF_EXTERNAL_TEMP_SENSOR) + self._power_sensor_entity_id = entry_infos.get(CONF_POWER_SENSOR) + self._max_power_sensor_entity_id = entry_infos.get(CONF_MAX_POWER_SENSOR) + self._window_sensor_entity_id = entry_infos.get(CONF_WINDOW_SENSOR) + self._window_delay_sec = entry_infos.get(CONF_WINDOW_DELAY) + + self._window_auto_open_threshold = entry_infos.get( + CONF_WINDOW_AUTO_OPEN_THRESHOLD + ) + self._window_auto_close_threshold = entry_infos.get( + CONF_WINDOW_AUTO_CLOSE_THRESHOLD + ) + self._window_auto_max_duration = entry_infos.get(CONF_WINDOW_AUTO_MAX_DURATION) + self._window_auto_on = ( + self._window_sensor_entity_id is None + and self._window_auto_open_threshold is not None + and self._window_auto_open_threshold > 0.0 + and self._window_auto_close_threshold is not None + and self._window_auto_max_duration is not None + and self._window_auto_max_duration > 0 + ) + self._window_auto_state = False + self._window_auto_algo = WindowOpenDetectionAlgorithm( + alert_threshold=self._window_auto_open_threshold, + end_alert_threshold=self._window_auto_close_threshold, + ) + + self._motion_sensor_entity_id = entry_infos.get(CONF_MOTION_SENSOR) + self._motion_delay_sec = entry_infos.get(CONF_MOTION_DELAY) + self._motion_off_delay_sec = entry_infos.get(CONF_MOTION_OFF_DELAY) + if not self._motion_off_delay_sec: + self._motion_off_delay_sec = self._motion_delay_sec + + self._motion_preset = entry_infos.get(CONF_MOTION_PRESET) + self._no_motion_preset = entry_infos.get(CONF_NO_MOTION_PRESET) + self._motion_on = ( + self._motion_sensor_entity_id is not None + and self._motion_preset is not None + and self._no_motion_preset is not None + ) + + self._tpi_coef_int = entry_infos.get(CONF_TPI_COEF_INT) + self._tpi_coef_ext = entry_infos.get(CONF_TPI_COEF_EXT) + self._presence_sensor_entity_id = entry_infos.get(CONF_PRESENCE_SENSOR) + self._power_temp = entry_infos.get(CONF_PRESET_POWER) + + self._presence_on = ( + entry_infos.get(CONF_USE_PRESENCE_FEATURE, False) + and self._presence_sensor_entity_id is not None + ) + + if self._ac_mode: + # Added by https://github.com/jmcollin78/versatile_thermostat/pull/144 + # Some over_switch can do both heating and cooling + self._hvac_list = [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF] + else: + self._hvac_list = [HVACMode.HEAT, HVACMode.OFF] + + self._unit = self._hass.config.units.temperature_unit + # Will be restored if possible + self._hvac_mode = None # HVAC_MODE_OFF + self._saved_hvac_mode = self._hvac_mode + + self._support_flags = SUPPORT_FLAGS + + # Preset will be initialized from Number entities + self._presets: dict[str, Any] = {} # presets + self._presets_away: dict[str, Any] = {} # presets_away + + # Will be restored if possible + self._attr_preset_mode = PRESET_NONE + self._saved_preset_mode = PRESET_NONE + + # Power management + self._device_power = entry_infos.get(CONF_DEVICE_POWER) or 0 + self._pmax_on = False + self._current_power = None + self._current_power_max = None + if ( + self._max_power_sensor_entity_id + and self._power_sensor_entity_id + and self._device_power + ): + self._pmax_on = True + else: + _LOGGER.info("%s - Power management is not fully configured", self) + + # will be restored if possible + self._target_temp = None + self._saved_target_temp = PRESET_NONE + self._humidity = None + self._fan_mode = None + self._swing_mode = None + self._cur_temp = None + self._cur_ext_temp = None + + # Fix parameters for TPI + if ( + self._proportional_function == PROPORTIONAL_FUNCTION_TPI + and self._ext_temp_sensor_entity_id is None + ): + _LOGGER.warning( + "Using TPI function but not external temperature sensor is set. Removing the delta temp ext factor. Thermostat will not be fully operationnal" # pylint: disable=line-too-long + ) + self._tpi_coef_ext = 0 + + self._security_delay_min = entry_infos.get(CONF_SECURITY_DELAY_MIN) + self._security_min_on_percent = ( + entry_infos.get(CONF_SECURITY_MIN_ON_PERCENT) + if entry_infos.get(CONF_SECURITY_MIN_ON_PERCENT) is not None + else DEFAULT_SECURITY_MIN_ON_PERCENT + ) + self._security_default_on_percent = ( + entry_infos.get(CONF_SECURITY_DEFAULT_ON_PERCENT) + if entry_infos.get(CONF_SECURITY_DEFAULT_ON_PERCENT) is not None + else DEFAULT_SECURITY_DEFAULT_ON_PERCENT + ) + self._minimal_activation_delay = entry_infos.get(CONF_MINIMAL_ACTIVATION_DELAY) + self._last_temperature_measure = datetime.now(tz=self._current_tz) + self._last_ext_temperature_measure = datetime.now(tz=self._current_tz) + self._security_state = False + + # Initiate the ProportionalAlgorithm + if self._prop_algorithm is not None: + del self._prop_algorithm + + # Memory synthesis state + self._motion_state = None + self._window_state = None + self._overpowering_state = None + self._presence_state = None + + self._total_energy = None + + # Read the parameter from configuration.yaml if it exists + short_ema_params = DEFAULT_SHORT_EMA_PARAMS + if api is not None and api.short_ema_params: + short_ema_params = api.short_ema_params + + self._ema_algo = ExponentialMovingAverage( + self.name, + short_ema_params.get("halflife_sec"), + # Needed for time calculation + get_tz(self._hass), + # two digits after the coma for temperature slope calculation + short_ema_params.get("precision"), + short_ema_params.get("max_alpha"), + ) + + self._is_central_mode = not ( + entry_infos.get(CONF_USE_CENTRAL_MODE) is False + ) # Default value (None) is True + + self._is_used_by_central_boiler = ( + entry_infos.get(CONF_USED_BY_CENTRAL_BOILER) is True + ) + + self._window_action = ( + entry_infos.get(CONF_WINDOW_ACTION) or CONF_WINDOW_TURN_OFF + ) + + _LOGGER.debug( + "%s - Creation of a new VersatileThermostat entity: unique_id=%s", + self, + self.unique_id, + ) + + async def async_added_to_hass(self): + """Run when entity about to be added.""" + _LOGGER.debug("Calling async_added_to_hass") + + await super().async_added_to_hass() + + self.async_on_remove( + async_track_state_change_event( + self.hass, + [self._temp_sensor_entity_id], + self._async_temperature_changed, + ) + ) + + if self._last_seen_temp_sensor_entity_id: + self.async_on_remove( + async_track_state_change_event( + self.hass, + [self._last_seen_temp_sensor_entity_id], + self._async_last_seen_temperature_changed, + ) + ) + + if self._ext_temp_sensor_entity_id: + self.async_on_remove( + async_track_state_change_event( + self.hass, + [self._ext_temp_sensor_entity_id], + self._async_ext_temperature_changed, + ) + ) + + if self._window_sensor_entity_id: + self.async_on_remove( + async_track_state_change_event( + self.hass, + [self._window_sensor_entity_id], + self._async_windows_changed, + ) + ) + if self._motion_sensor_entity_id: + self.async_on_remove( + async_track_state_change_event( + self.hass, + [self._motion_sensor_entity_id], + self._async_motion_changed, + ) + ) + + if self._power_sensor_entity_id: + self.async_on_remove( + async_track_state_change_event( + self.hass, + [self._power_sensor_entity_id], + self._async_power_changed, + ) + ) + + if self._max_power_sensor_entity_id: + self.async_on_remove( + async_track_state_change_event( + self.hass, + [self._max_power_sensor_entity_id], + self._async_max_power_changed, + ) + ) + + if self._presence_on: + self.async_on_remove( + async_track_state_change_event( + self.hass, + [self._presence_sensor_entity_id], + self._async_presence_changed, + ) + ) + + self.async_on_remove(self.remove_thermostat) + + # issue 428. Link to others entities will start at link + # await self.async_startup() + + def remove_thermostat(self): + """Called when the thermostat will be removed""" + _LOGGER.info("%s - Removing thermostat", self) + for under in self._underlyings: + under.remove_entity() + + async def async_startup(self, central_configuration): + """Triggered on startup, used to get old state and set internal states accordingly""" + _LOGGER.debug("%s - Calling async_startup", self) + + _LOGGER.debug("%s - Calling async_startup_internal", self) + need_write_state = False + + await self.get_my_previous_state() + + await self.init_presets(central_configuration) + + # Initialize all UnderlyingEntities + self.init_underlyings() + + temperature_state = self.hass.states.get(self._temp_sensor_entity_id) + if temperature_state and temperature_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + _LOGGER.debug( + "%s - temperature sensor have been retrieved: %.1f", + self, + float(temperature_state.state), + ) + await self._async_update_temp(temperature_state) + need_write_state = True + + if self._ext_temp_sensor_entity_id: + ext_temperature_state = self.hass.states.get( + self._ext_temp_sensor_entity_id + ) + if ext_temperature_state and ext_temperature_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + _LOGGER.debug( + "%s - external temperature sensor have been retrieved: %.1f", + self, + float(ext_temperature_state.state), + ) + await self._async_update_ext_temp(ext_temperature_state) + else: + _LOGGER.debug( + "%s - external temperature sensor have NOT been retrieved cause unknown or unavailable", + self, + ) + else: + _LOGGER.debug( + "%s - external temperature sensor have NOT been retrieved cause no external sensor", + self, + ) + + if self._pmax_on: + # try to acquire current power and power max + current_power_state = self.hass.states.get(self._power_sensor_entity_id) + if current_power_state and current_power_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + self._current_power = float(current_power_state.state) + _LOGGER.debug( + "%s - Current power have been retrieved: %.3f", + self, + self._current_power, + ) + need_write_state = True + + # Try to acquire power max + current_power_max_state = self.hass.states.get( + self._max_power_sensor_entity_id + ) + if current_power_max_state and current_power_max_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + self._current_power_max = float(current_power_max_state.state) + _LOGGER.debug( + "%s - Current power max have been retrieved: %.3f", + self, + self._current_power_max, + ) + need_write_state = True + + # try to acquire window entity state + if self._window_sensor_entity_id: + window_state = self.hass.states.get(self._window_sensor_entity_id) + if window_state and window_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + self._window_state = window_state.state == STATE_ON + _LOGGER.debug( + "%s - Window state have been retrieved: %s", + self, + self._window_state, + ) + need_write_state = True + + # try to acquire motion entity state + if self._motion_sensor_entity_id: + motion_state = self.hass.states.get(self._motion_sensor_entity_id) + if motion_state and motion_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + self._motion_state = motion_state.state + _LOGGER.debug( + "%s - Motion state have been retrieved: %s", + self, + self._motion_state, + ) + # recalculate the right target_temp in activity mode + await self._async_update_motion_temp() + need_write_state = True + + if self._presence_on: + # try to acquire presence entity state + presence_state = self.hass.states.get(self._presence_sensor_entity_id) + if presence_state and presence_state.state not in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + await self._async_update_presence(presence_state.state) + _LOGGER.debug( + "%s - Presence have been retrieved: %s", + self, + presence_state.state, + ) + need_write_state = True + + if need_write_state: + self.async_write_ha_state() + if self._prop_algorithm: + self._prop_algorithm.calculate( + self._target_temp, + self._cur_temp, + self._cur_ext_temp, + self._hvac_mode or HVACMode.OFF, + ) + + self.hass.create_task(self._check_initial_state()) + + self.reset_last_change_time() + + # if self.hass.state == CoreState.running: + # await _async_startup_internal() + # else: + # self.hass.bus.async_listen_once( + # EVENT_HOMEASSISTANT_START, _async_startup_internal + # ) + + def init_underlyings(self): + """Initialize all underlyings. Should be overriden if necessary""" + + def restore_specific_previous_state(self, old_state: State): + """Should be overriden in each specific thermostat + if a specific previous state or attribute should be + restored + """ + + async def get_my_previous_state(self): + """Try to get my previou state""" + # Check If we have an old state + old_state = await self.async_get_last_state() + _LOGGER.debug( + "%s - Calling get_my_previous_state old_state is %s", self, old_state + ) + if old_state is not None: + # If we have no initial temperature, restore + if self._target_temp is None: + # If we have a previously saved temperature + if old_state.attributes.get(ATTR_TEMPERATURE) is None: + if self._ac_mode: + await self._async_internal_set_temperature(self.max_temp) + else: + await self._async_internal_set_temperature(self.min_temp) + _LOGGER.warning( + "%s - Undefined target temperature, falling back to %s", + self, + self._target_temp, + ) + else: + await self._async_internal_set_temperature( + float(old_state.attributes[ATTR_TEMPERATURE]) + ) + + old_preset_mode = old_state.attributes.get(ATTR_PRESET_MODE) + # Never restore a Power or Security preset + if old_preset_mode is not None and old_preset_mode not in HIDDEN_PRESETS: + # old_preset_mode in self._attr_preset_modes + self._attr_preset_mode = old_preset_mode + self.save_preset_mode() + else: + self._attr_preset_mode = PRESET_NONE + + if old_state.state in [ + HVACMode.OFF, + HVACMode.HEAT, + HVACMode.COOL, + ]: + self._hvac_mode = old_state.state + else: + if not self._hvac_mode: + self._hvac_mode = HVACMode.OFF + + old_total_energy = old_state.attributes.get(ATTR_TOTAL_ENERGY) + self._total_energy = old_total_energy if old_total_energy else 0 + + self.restore_specific_previous_state(old_state) + else: + # No previous state, try and restore defaults + if self._target_temp is None: + if self._ac_mode: + await self._async_internal_set_temperature(self.max_temp) + else: + await self._async_internal_set_temperature(self.min_temp) + _LOGGER.warning( + "No previously saved temperature, setting to %s", self._target_temp + ) + self._total_energy = 0 + + self._saved_target_temp = self._target_temp + + # Set default state to off + if not self._hvac_mode: + self._hvac_mode = HVACMode.OFF + + self.send_event(EventType.PRESET_EVENT, {"preset": self._attr_preset_mode}) + self.send_event(EventType.HVAC_MODE_EVENT, {"hvac_mode": self._hvac_mode}) + + _LOGGER.info( + "%s - restored state is target_temp=%.1f, preset_mode=%s, hvac_mode=%s", + self, + self._target_temp, + self._attr_preset_mode, + self._hvac_mode, + ) + + def __str__(self) -> str: + return f"VersatileThermostat-{self.name}" + + @property + def is_over_climate(self) -> bool: + """True if the Thermostat is over_climate""" + return False + + @property + def is_over_switch(self) -> bool: + """True if the Thermostat is over_switch""" + return False + + @property + def is_over_valve(self) -> bool: + """True if the Thermostat is over_valve""" + return False + + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self._unique_id)}, + name=self._name, + manufacturer=DEVICE_MANUFACTURER, + model=DOMAIN, + ) + + @property + def unique_id(self) -> str: + return self._unique_id + + @property + def should_poll(self) -> bool: + return False + + @property + def name(self) -> str: + return self._name + + @property + def hvac_modes(self) -> list[HVACMode]: + """List of available operation modes.""" + return self._hvac_list + + @property + def ac_mode(self) -> bool: + """Get the ac_mode of the Themostat""" + return self._ac_mode + + @property + def fan_mode(self) -> str | None: + """Return the fan setting. + + Requires ClimateEntityFeature.FAN_MODE. + """ + return None + + @property + def fan_modes(self) -> list[str] | None: + """Return the list of available fan modes. + + Requires ClimateEntityFeature.FAN_MODE. + """ + return [] + + @property + def swing_mode(self) -> str | None: + """Return the swing setting. + + Requires ClimateEntityFeature.SWING_MODE. + """ + return None + + @property + def swing_modes(self) -> list[str] | None: + """Return the list of available swing modes. + + Requires ClimateEntityFeature.SWING_MODE. + """ + return None + + @property + def temperature_unit(self) -> str: + """Return the unit of measurement.""" + return self._unit + + @property + def ema_temperature(self) -> str: + """Return the EMA temperature.""" + return self._ema_temp + + @property + def hvac_mode(self) -> HVACMode | None: + """Return current operation.""" + # Issue #114 - returns my current hvac_mode and not the underlying hvac_mode which could be different + # delta will be managed by climate_state_change event. + # if self.is_over_climate: + # if one not OFF -> return it + # else OFF + # for under in self._underlyings: + # if (mode := under.hvac_mode) not in [HVACMode.OFF] + # return mode + # return HVACMode.OFF + + return self._hvac_mode + + @property + def hvac_action(self) -> HVACAction | None: + """Return the current running hvac operation if supported. + Need to be one of CURRENT_HVAC_*. + """ + if self._hvac_mode == HVACMode.OFF: + action = HVACAction.OFF + elif not self.is_device_active: + action = HVACAction.IDLE + elif self._hvac_mode == HVACMode.COOL: + action = HVACAction.COOLING + else: + action = HVACAction.HEATING + return action + + @property + def is_used_by_central_boiler(self) -> HVACAction | None: + """Return true is the VTherm is configured to be used by + central boiler""" + return self._is_used_by_central_boiler + + @property + def target_temperature(self) -> float | None: + """Return the temperature we try to reach.""" + return self._target_temp + + @property + def supported_features(self) -> ClimateEntityFeature: + """Return the list of supported features.""" + return self._support_flags + + @property + def is_device_active(self) -> bool: + """Returns true if one underlying is active""" + for under in self._underlyings: + if under.is_device_active: + return True + return False + + @property + def current_temperature(self) -> float | None: + """Return the sensor temperature.""" + return self._cur_temp + + @property + def is_aux_heat(self) -> bool | None: + """Return true if aux heater. + + Requires ClimateEntityFeature.AUX_HEAT. + """ + return None + + @property + def mean_cycle_power(self) -> float | None: + """Returns the mean power consumption during the cycle""" + if not self._device_power: + return None + + return float(self._device_power * self._prop_algorithm.on_percent) + + @property + def total_energy(self) -> float | None: + """Returns the total energy calculated for this thermostast""" + if self._total_energy is not None: + return round(self._total_energy, 2) + else: + return None + + @property + def device_power(self) -> float | None: + """Returns the device_power for this thermostast""" + return self._device_power + + @property + def overpowering_state(self) -> bool | None: + """Get the overpowering_state""" + return self._overpowering_state + + @property + def window_state(self) -> str | None: + """Get the window_state""" + return STATE_ON if self._window_state else STATE_OFF + + @property + def window_auto_state(self) -> str | None: + """Get the window_auto_state""" + return STATE_ON if self._window_auto_state else STATE_OFF + + @property + def window_bypass_state(self) -> bool | None: + """Get the Window Bypass""" + return self._window_bypass_state + + @property + def window_action(self) -> bool | None: + """Get the Window Action""" + return self._window_action + + @property + def security_state(self) -> bool | None: + """Get the security_state""" + return self._security_state + + @property + def motion_state(self) -> bool | None: + """Get the motion_state""" + return self._motion_state + + @property + def presence_state(self) -> bool | None: + """Get the presence_state""" + return self._presence_state + + @property + def proportional_algorithm(self) -> PropAlgorithm | None: + """Get the eventual ProportionalAlgorithm""" + return self._prop_algorithm + + @property + def last_temperature_measure(self) -> datetime | None: + """Get the last temperature datetime""" + return self._last_temperature_measure + + @property + def last_ext_temperature_measure(self) -> datetime | None: + """Get the last external temperature datetime""" + return self._last_ext_temperature_measure + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode, e.g., home, away, temp. + + Requires ClimateEntityFeature.PRESET_MODE. + """ + return self._attr_preset_mode + + @property + def preset_modes(self) -> list[str] | None: + """Return a list of available preset modes. + Requires ClimateEntityFeature.PRESET_MODE. + """ + return self._attr_preset_modes + + @property + def last_temperature_slope(self) -> float | None: + """Return the last temperature slope curve if any""" + if not self._window_auto_algo: + return None + else: + return self._window_auto_algo.last_slope + + @property + def is_window_auto_enabled(self) -> bool: + """True if the Window auto feature is enabled""" + return self._window_auto_on + + @property + def nb_underlying_entities(self) -> int: + """Returns the number of underlying entities""" + return len(self._underlyings) + + @property + def underlying_entities(self) -> int: + """Returns the underlying entities""" + return self._underlyings + + @property + def is_on(self) -> bool: + """True if the VTherm is on (! HVAC_OFF)""" + return self.hvac_mode and self.hvac_mode != HVACMode.OFF + + @property + def is_controlled_by_central_mode(self) -> bool: + """Returns True if this VTherm can be controlled by the central_mode""" + return self._is_central_mode + + @property + def last_central_mode(self) -> str | None: + """Returns the last central_mode taken into account. + Is None if the VTherm is not controlled by central_mode""" + return self._last_central_mode + + @property + def use_central_config_temperature(self): + """True if this VTHerm uses the central configuration temperature""" + return self._use_central_config_temperature + + def underlying_entity_id(self, index=0) -> str | None: + """The climate_entity_id. Added for retrocompatibility reason""" + if index < self.nb_underlying_entities: + return self.underlying_entity(index).entity_id + else: + return None + + def underlying_entity(self, index=0) -> UnderlyingEntity | None: + """Get the underlying entity at specified index""" + if index < self.nb_underlying_entities: + return self._underlyings[index] + else: + return None + + def turn_aux_heat_on(self) -> None: + """Turn auxiliary heater on.""" + raise NotImplementedError() + + @overrides + async def async_turn_aux_heat_on(self) -> None: + """Turn auxiliary heater on.""" + raise NotImplementedError() + + @overrides + def turn_aux_heat_off(self) -> None: + """Turn auxiliary heater off.""" + raise NotImplementedError() + + @overrides + async def async_turn_aux_heat_off(self) -> None: + """Turn auxiliary heater off.""" + raise NotImplementedError() + + @overrides + async def async_set_hvac_mode(self, hvac_mode: HVACMode, need_control_heating=True): + """Set new target hvac mode.""" + _LOGGER.info("%s - Set hvac mode: %s", self, hvac_mode) + + if hvac_mode is None: + return + + self._hvac_mode = hvac_mode + + # Delegate to all underlying + sub_need_control_heating = False + for under in self._underlyings: + sub_need_control_heating = ( + await under.set_hvac_mode(hvac_mode) or need_control_heating + ) + + # If AC is on maybe we have to change the temperature in force mode, but not in frost mode (there is no Frost protection possible in AC mode) + if self._hvac_mode == HVACMode.COOL and self.preset_mode != PRESET_NONE: + if self.preset_mode != PRESET_FROST_PROTECTION: + await self._async_set_preset_mode_internal(self.preset_mode, True) + else: + await self._async_set_preset_mode_internal(PRESET_ECO, True, False) + + if need_control_heating and sub_need_control_heating: + await self.async_control_heating(force=True) + + # Ensure we update the current operation after changing the mode + self.reset_last_temperature_time() + + self.reset_last_change_time() + + self.update_custom_attributes() + self.async_write_ha_state() + self.send_event(EventType.HVAC_MODE_EVENT, {"hvac_mode": self._hvac_mode}) + + @overrides + async def async_set_preset_mode( + self, preset_mode: str, overwrite_saved_preset=True + ): + """Set new preset mode.""" + + # Wer accept a new preset when: + # 1. last_central_mode is not set, + # 2. or last_central_mode is AUTO, + # 3. or last_central_mode is CENTRAL_MODE_FROST_PROTECTION and preset_mode is PRESET_FROST_PROTECTION (to be abel to re-set the preset_mode) + accept = self._last_central_mode in [ + None, + CENTRAL_MODE_AUTO, + CENTRAL_MODE_COOL_ONLY, + CENTRAL_MODE_HEAT_ONLY, + CENTRAL_MODE_STOPPED, + ] or ( + self._last_central_mode == CENTRAL_MODE_FROST_PROTECTION + and preset_mode == PRESET_FROST_PROTECTION + ) + if not accept: + _LOGGER.info( + "%s - Impossible to change the preset to %s because central mode is %s", + self, + preset_mode, + self._last_central_mode, + ) + + return + + await self._async_set_preset_mode_internal( + preset_mode, force=False, overwrite_saved_preset=overwrite_saved_preset + ) + await self.async_control_heating(force=True) + + async def _async_set_preset_mode_internal( + self, preset_mode: str, force=False, overwrite_saved_preset=True + ): + """Set new preset mode.""" + _LOGGER.info("%s - Set preset_mode: %s force=%s", self, preset_mode, force) + if ( + preset_mode not in (self._attr_preset_modes or []) + and preset_mode not in HIDDEN_PRESETS + ): + raise ValueError( + f"Got unsupported preset_mode {preset_mode}. Must be one of {self._attr_preset_modes}" # pylint: disable=line-too-long + ) + + old_preset_mode = self._attr_preset_mode + if preset_mode == old_preset_mode and not force: + # I don't think we need to call async_write_ha_state if we didn't change the state + return + + # In safety mode don't change preset but memorise the new expected preset when security will be off + if preset_mode != PRESET_SECURITY and self._security_state: + _LOGGER.debug( + "%s - is in safety mode. Just memorise the new expected ", self + ) + if preset_mode not in HIDDEN_PRESETS: + self._saved_preset_mode = preset_mode + return + + old_preset_mode = self._attr_preset_mode + if preset_mode == PRESET_NONE: + self._attr_preset_mode = PRESET_NONE + if self._saved_target_temp: + await self._async_internal_set_temperature(self._saved_target_temp) + elif preset_mode == PRESET_ACTIVITY: + self._attr_preset_mode = PRESET_ACTIVITY + await self._async_update_motion_temp() + else: + if self._attr_preset_mode == PRESET_NONE: + self._saved_target_temp = self._target_temp + self._attr_preset_mode = preset_mode + await self._async_internal_set_temperature( + self.find_preset_temp(preset_mode) + ) + + self.reset_last_temperature_time(old_preset_mode) + + if overwrite_saved_preset: + self.save_preset_mode() + + self.recalculate() + # Notify only if there was a real change + if self._attr_preset_mode != old_preset_mode: + self.send_event(EventType.PRESET_EVENT, {"preset": self._attr_preset_mode}) + + def reset_last_change_time( + self, old_preset_mode: str | None = None + ): # pylint: disable=unused-argument + """Reset to now the last change time""" + self._last_change_time = datetime.now(tz=self._current_tz) + _LOGGER.debug("%s - last_change_time is now %s", self, self._last_change_time) + + def reset_last_temperature_time(self, old_preset_mode: str | None = None): + """Reset to now the last temperature time if conditions are satisfied""" + if ( + self._attr_preset_mode not in HIDDEN_PRESETS + and old_preset_mode not in HIDDEN_PRESETS + ): + self._last_temperature_measure = self._last_ext_temperature_measure = ( + datetime.now(tz=self._current_tz) + ) + + def find_preset_temp(self, preset_mode: str): + """Find the right temperature of a preset considering the presence if configured""" + if preset_mode is None or preset_mode == "none": + return ( + self._attr_max_temp + if self._ac_mode and self._hvac_mode == HVACMode.COOL + else self._attr_min_temp + ) + + if preset_mode == PRESET_SECURITY: + return ( + self._target_temp + ) # in security just keep the current target temperature, the thermostat should be off + if preset_mode == PRESET_POWER: + return self._power_temp + if preset_mode == PRESET_ACTIVITY: + motion_preset = ( + self._motion_preset + if self._motion_state == STATE_ON + else self._no_motion_preset + ) + if motion_preset in self._presets: + return self._presets[motion_preset] + else: + return None + else: + # Select _ac presets if in COOL Mode (or over_switch with _ac_mode) + if self._ac_mode and self._hvac_mode == HVACMode.COOL: + preset_mode = preset_mode + PRESET_AC_SUFFIX + + _LOGGER.info("%s - find preset temp: %s", self, preset_mode) + + temp_val = self._presets.get(preset_mode, 0) + if not self._presence_on or self._presence_state in [ + None, + STATE_ON, + STATE_HOME, + ]: + return temp_val + else: + # We should return the preset_away temp val but if + # preset temp is 0, that means the user don't want to use + # the preset so we return 0, even if there is a value is preset_away + return ( + self._presets_away.get(self.get_preset_away_name(preset_mode), 0) + if temp_val > 0 + else temp_val + ) + + def get_preset_away_name(self, preset_mode: str) -> str: + """Get the preset name in away mode (when presence is off)""" + return preset_mode + PRESET_AWAY_SUFFIX + + async def async_set_fan_mode(self, fan_mode: str): + """Set new target fan mode.""" + _LOGGER.info("%s - Set fan mode: %s", self, fan_mode) + return + + async def async_set_humidity(self, humidity: int): + """Set new target humidity.""" + _LOGGER.info("%s - Set fan mode: %s", self, humidity) + return + + async def async_set_swing_mode(self, swing_mode: str): + """Set new target swing operation.""" + _LOGGER.info("%s - Set fan mode: %s", self, swing_mode) + return + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + _LOGGER.info("%s - Set target temp: %s", self, temperature) + if temperature is None: + return + await self._async_internal_set_temperature(temperature) + self._attr_preset_mode = PRESET_NONE + self.recalculate() + self.reset_last_change_time() + await self.async_control_heating(force=True) + + async def _async_internal_set_temperature(self, temperature: float): + """Set the target temperature and the target temperature of underlying climate if any + For testing purpose you can pass an event_timestamp. + """ + if temperature: + self._target_temp = temperature + return + + def get_state_date_or_now(self, state: State) -> datetime: + """Extract the last_changed state from State or return now if not available""" + return ( + state.last_changed.astimezone(self._current_tz) + if state.last_changed is not None + else datetime.now(tz=self._current_tz) + ) + + def get_last_updated_date_or_now(self, state: State) -> datetime: + """Extract the last_changed state from State or return now if not available""" + return ( + state.last_updated.astimezone(self._current_tz) + if state.last_updated is not None + else datetime.now(tz=self._current_tz) + ) + + @callback + async def entry_update_listener( + self, _, config_entry: ConfigEntry # hass: HomeAssistant, + ) -> None: + """Called when the entry have changed in ConfigFlow""" + _LOGGER.info("%s - Change entry with the values: %s", self, config_entry.data) + + @callback + async def _async_temperature_changed(self, event: Event): + """Handle temperature of the temperature sensor changes.""" + new_state: State = event.data.get("new_state") + _LOGGER.debug( + "%s - Temperature changed. Event.new_state is %s", + self, + new_state, + ) + if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): + return + + dearm_window_auto = await self._async_update_temp(new_state) + self.recalculate() + await self.async_control_heating(force=False) + return dearm_window_auto + + @callback + async def _async_last_seen_temperature_changed(self, event: Event): + """Handle last seen temperature sensor changes.""" + new_state: State = event.data.get("new_state") + _LOGGER.debug( + "%s - Last seen temperature changed. Event.new_state is %s", + self, + new_state, + ) + if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): + return + + # try to extract the datetime (from state) + try: + # Convertir la chaîne au format ISO 8601 en objet datetime + self._last_temperature_measure = self.get_last_updated_date_or_now( + new_state + ) + self.reset_last_change_time() + _LOGGER.debug( + "%s - new last_temperature_measure is now: %s", + self, + self._last_temperature_measure, + ) + + # try to restart if we were in safety mode + if self._security_state: + await self.check_safety() + + except ValueError as err: + # La conversion a échoué, la chaîne n'est pas au format ISO 8601 + _LOGGER.warning( + "%s - impossible to convert last seen datetime %s. Error is: %s", + self, + new_state.state, + err, + ) + + async def _async_ext_temperature_changed(self, event: Event): + """Handle external temperature opf the sensor changes.""" + new_state: State = event.data.get("new_state") + _LOGGER.debug( + "%s - external Temperature changed. Event.new_state is %s", + self, + new_state, + ) + if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): + return + + await self._async_update_ext_temp(new_state) + self.recalculate() + await self.async_control_heating(force=False) + + @callback + async def _async_windows_changed(self, event): + """Handle window changes.""" + new_state = event.data.get("new_state") + old_state = event.data.get("old_state") + _LOGGER.info( + "%s - Window changed. Event.new_state is %s, _hvac_mode=%s, _saved_hvac_mode=%s", + self, + new_state, + self._hvac_mode, + self._saved_hvac_mode, + ) + + # Check delay condition + async def try_window_condition(_): + try: + long_enough = condition.state( + self.hass, + self._window_sensor_entity_id, + new_state.state, + timedelta(seconds=self._window_delay_sec), + ) + except ConditionError: + long_enough = False + + if not long_enough: + _LOGGER.debug( + "Window delay condition is not satisfied. Ignore window event" + ) + self._window_state = old_state.state == STATE_ON + return + + _LOGGER.debug("%s - Window delay condition is satisfied", self) + # if not self._saved_hvac_mode: + # self._saved_hvac_mode = self._hvac_mode + + if self._window_state == (new_state.state == STATE_ON): + _LOGGER.debug("%s - no change in window state. Forget the event") + return + + self._window_state = new_state.state == STATE_ON + + _LOGGER.debug("%s - Window ByPass is : %s", self, self._window_bypass_state) + if self._window_bypass_state: + _LOGGER.info( + "%s - Window ByPass is activated. Ignore window event", self + ) + else: + await self.change_window_detection_state(self._window_state) + + self.update_custom_attributes() + + if new_state is None or old_state is None or new_state.state == old_state.state: + return try_window_condition + + if self._window_call_cancel: + self._window_call_cancel() + self._window_call_cancel = None + self._window_call_cancel = async_call_later( + self.hass, timedelta(seconds=self._window_delay_sec), try_window_condition + ) + # For testing purpose we need to access the inner function + return try_window_condition + + @callback + async def _async_motion_changed(self, event): + """Handle motion changes.""" + new_state = event.data.get("new_state") + _LOGGER.info( + "%s - Motion changed. Event.new_state is %s, _attr_preset_mode=%s, activity=%s", + self, + new_state, + self._attr_preset_mode, + PRESET_ACTIVITY, + ) + + if new_state is None or new_state.state not in (STATE_OFF, STATE_ON): + return + + # Check delay condition + async def try_motion_condition(_): + try: + delay = ( + self._motion_delay_sec + if new_state.state == STATE_ON + else self._motion_off_delay_sec + ) + long_enough = condition.state( + self.hass, + self._motion_sensor_entity_id, + new_state.state, + timedelta(seconds=delay), + ) + except ConditionError: + long_enough = False + + if not long_enough: + _LOGGER.debug( + "Motion delay condition is not satisfied. Ignore motion event" + ) + else: + _LOGGER.debug("%s - Motion delay condition is satisfied", self) + self._motion_state = new_state.state + if self._attr_preset_mode == PRESET_ACTIVITY: + new_preset = ( + self._motion_preset + if self._motion_state == STATE_ON + else self._no_motion_preset + ) + _LOGGER.info( + "%s - Motion condition have changes. New preset temp will be %s", + self, + new_preset, + ) + # We do not change the preset which is kept to ACTIVITY but only the target_temperature + # We take the presence into account + await self._async_internal_set_temperature( + self.find_preset_temp(new_preset) + ) + self.recalculate() + await self.async_control_heating(force=True) + self._motion_call_cancel = None + + im_on = self._motion_state == STATE_ON + delay_running = self._motion_call_cancel is not None + event_on = new_state.state == STATE_ON + + def arm(): + """Arm the timer""" + delay = ( + self._motion_delay_sec + if new_state.state == STATE_ON + else self._motion_off_delay_sec + ) + self._motion_call_cancel = async_call_later( + self.hass, timedelta(seconds=delay), try_motion_condition + ) + + def desarm(): + # restart the timer + self._motion_call_cancel() + self._motion_call_cancel = None + + # if I'm off + if not im_on: + if event_on and not delay_running: + _LOGGER.debug( + "%s - Arm delay cause i'm off and event is on and no delay is running", + self, + ) + arm() + return try_motion_condition + # Ignore the event + _LOGGER.debug("%s - Event ignored cause i'm already off", self) + return None + else: # I'm On + if not event_on and not delay_running: + _LOGGER.info("%s - Arm delay cause i'm on and event is off", self) + arm() + return try_motion_condition + if event_on and delay_running: + _LOGGER.debug( + "%s - Desarm off delay cause i'm on and event is on and a delay is running", + self, + ) + desarm() + return None + # Ignore the event + _LOGGER.debug("%s - Event ignored cause i'm already on", self) + return None + + @callback + async def _check_initial_state(self): + """Prevent the device from keep running if HVAC_MODE_OFF.""" + _LOGGER.debug("%s - Calling _check_initial_state", self) + for under in self._underlyings: + await under.check_initial_state(self._hvac_mode) + + # Starts the initial control loop (don't wait for an update of temperature) + await self.async_control_heating(force=True) + + @callback + async def _async_update_temp(self, state: State): + """Update thermostat with latest state from sensor.""" + try: + cur_temp = float(state.state) + if math.isnan(cur_temp) or math.isinf(cur_temp): + raise ValueError(f"Sensor has illegal state {state.state}") + self._cur_temp = cur_temp + + self._last_temperature_measure = self.get_state_date_or_now(state) + + # calculate the smooth_temperature with EMA calculation + self._ema_temp = self._ema_algo.calculate_ema( + self._cur_temp, self._last_temperature_measure + ) + + _LOGGER.debug( + "%s - After setting _last_temperature_measure %s , state.last_changed.replace=%s", + self, + self._last_temperature_measure, + state.last_changed.astimezone(self._current_tz), + ) + + # try to restart if we were in safety mode + if self._security_state: + await self.check_safety() + + # check window_auto + return await self._async_manage_window_auto() + + except ValueError as ex: + _LOGGER.error("Unable to update temperature from sensor: %s", ex) + + @callback + async def _async_update_ext_temp(self, state: State): + """Update thermostat with latest state from sensor.""" + try: + cur_ext_temp = float(state.state) + if math.isnan(cur_ext_temp) or math.isinf(cur_ext_temp): + raise ValueError(f"Sensor has illegal state {state.state}") + self._cur_ext_temp = cur_ext_temp + self._last_ext_temperature_measure = self.get_state_date_or_now(state) + + _LOGGER.debug( + "%s - After setting _last_ext_temperature_measure %s , state.last_changed.replace=%s", + self, + self._last_ext_temperature_measure, + state.last_changed.astimezone(self._current_tz), + ) + + # try to restart if we were in safety mode + if self._security_state: + await self.check_safety() + except ValueError as ex: + _LOGGER.error("Unable to update external temperature from sensor: %s", ex) + + @callback + async def _async_power_changed(self, event: HASSEventType[EventStateChangedData]): + """Handle power changes.""" + _LOGGER.debug("Thermostat %s - Receive new Power event", self.name) + _LOGGER.debug(event) + new_state = event.data.get("new_state") + old_state = event.data.get("old_state") + if ( + new_state is None + or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN) + or (old_state is not None and new_state.state == old_state.state) + ): + return + + try: + current_power = float(new_state.state) + if math.isnan(current_power) or math.isinf(current_power): + raise ValueError(f"Sensor has illegal state {new_state.state}") + self._current_power = current_power + + if self._attr_preset_mode == PRESET_POWER: + await self.async_control_heating() + + except ValueError as ex: + _LOGGER.error("Unable to update current_power from sensor: %s", ex) + + @callback + async def _async_max_power_changed( + self, event: HASSEventType[EventStateChangedData] + ): + """Handle power max changes.""" + _LOGGER.debug("Thermostat %s - Receive new Power Max event", self.name) + _LOGGER.debug(event) + new_state = event.data.get("new_state") + old_state = event.data.get("old_state") + if ( + new_state is None + or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN) + or (old_state is not None and new_state.state == old_state.state) + ): + return + + try: + current_power_max = float(new_state.state) + if math.isnan(current_power_max) or math.isinf(current_power_max): + raise ValueError(f"Sensor has illegal state {new_state.state}") + self._current_power_max = current_power_max + if self._attr_preset_mode == PRESET_POWER: + await self.async_control_heating() + + except ValueError as ex: + _LOGGER.error("Unable to update current_power from sensor: %s", ex) + + @callback + async def _async_presence_changed( + self, event: HASSEventType[EventStateChangedData] + ): + """Handle presence changes.""" + new_state = event.data.get("new_state") + _LOGGER.info( + "%s - Presence changed. Event.new_state is %s, _attr_preset_mode=%s, activity=%s", + self, + new_state, + self._attr_preset_mode, + PRESET_ACTIVITY, + ) + if new_state is None: + return + + await self._async_update_presence(new_state.state) + await self.async_control_heating(force=True) + + async def _async_update_presence(self, new_state: str): + _LOGGER.info("%s - Updating presence. New state is %s", self, new_state) + self._presence_state = ( + STATE_ON if new_state in (STATE_ON, STATE_HOME) else STATE_OFF + ) + if self._attr_preset_mode in HIDDEN_PRESETS or self._presence_on is False: + _LOGGER.info( + "%s - Ignoring presence change cause in Power or Security preset or presence not configured", + self, + ) + return + if new_state is None or new_state not in ( + STATE_OFF, + STATE_ON, + STATE_HOME, + STATE_NOT_HOME, + ): + return + if self._attr_preset_mode not in [PRESET_BOOST, PRESET_COMFORT, PRESET_ECO]: + return + + new_temp = self.find_preset_temp(self.preset_mode) + if new_temp is not None: + _LOGGER.debug( + "%s - presence change in temperature mode new_temp will be: %.2f", + self, + new_temp, + ) + await self._async_internal_set_temperature(new_temp) + self.recalculate() + + async def _async_update_motion_temp(self): + """Update the temperature considering the ACTIVITY preset and current motion state""" + _LOGGER.debug( + "%s - Calling _update_motion_temp preset_mode=%s, motion_state=%s", + self, + self._attr_preset_mode, + self._motion_state, + ) + if ( + self._motion_sensor_entity_id is None + or self._attr_preset_mode != PRESET_ACTIVITY + ): + return + + await self._async_internal_set_temperature( + self._presets.get( + ( + self._motion_preset + if self._motion_state == STATE_ON + else self._no_motion_preset + ), + None, + ) + ) + _LOGGER.debug( + "%s - regarding motion, target_temp have been set to %.2f", + self, + self._target_temp, + ) + + async def _async_underlying_entity_turn_off(self): + """Turn heater toggleable device off. Used by Window, overpowering, control_heating to turn all off""" + + for under in self._underlyings: + await under.turn_off() + + async def _async_manage_window_auto(self, in_cycle=False): + """The management of the window auto feature""" + + async def dearm_window_auto(_): + """Callback that will be called after end of WINDOW_AUTO_MAX_DURATION""" + _LOGGER.info("Unset window auto because MAX_DURATION is exceeded") + await deactivate_window_auto(auto=True) + + async def deactivate_window_auto(auto=False): + """Deactivation of the Window auto state""" + _LOGGER.warning( + "%s - End auto detection of open window slope=%.3f", self, slope + ) + # Send an event + cause = "max duration expiration" if auto else "end of slope alert" + self.send_event( + EventType.WINDOW_AUTO_EVENT, + {"type": "end", "cause": cause, "curve_slope": slope}, + ) + # Set attributes + self._window_auto_state = False + await self.change_window_detection_state(self._window_auto_state) + # await self.restore_hvac_mode(True) + + if self._window_call_cancel: + self._window_call_cancel() + self._window_call_cancel = None + + if not self._window_auto_algo: + return + + if in_cycle: + slope = self._window_auto_algo.check_age_last_measurement( + temperature=self._ema_temp, + datetime_now=datetime.now(get_tz(self._hass)), + ) + else: + slope = self._window_auto_algo.add_temp_measurement( + temperature=self._ema_temp, + datetime_measure=self._last_temperature_measure, + ) + + _LOGGER.debug( + "%s - Window auto is on, check the alert. last slope is %.3f", + self, + slope if slope is not None else 0.0, + ) + + if self.window_bypass_state or not self.is_window_auto_enabled: + _LOGGER.debug( + "%s - Window auto event is ignored because bypass is ON or window auto detection is disabled", + self, + ) + return + + if ( + self._window_auto_algo.is_window_open_detected() + and self._window_auto_state is False + and self.hvac_mode != HVACMode.OFF + ): + if ( + self.proportional_algorithm + and self.proportional_algorithm.on_percent <= 0.0 + ): + _LOGGER.info( + "%s - Start auto detection of open window slope=%.3f but no heating detected (on_percent<=0). Forget the event", + self, + slope, + ) + return dearm_window_auto + + _LOGGER.warning( + "%s - Start auto detection of open window slope=%.3f", self, slope + ) + + # Send an event + self.send_event( + EventType.WINDOW_AUTO_EVENT, + {"type": "start", "cause": "slope alert", "curve_slope": slope}, + ) + # Set attributes + self._window_auto_state = True + await self.change_window_detection_state(self._window_auto_state) + # self.save_hvac_mode() + # await self.async_set_hvac_mode(HVACMode.OFF) + + # Arm the end trigger + if self._window_call_cancel: + self._window_call_cancel() + self._window_call_cancel = None + self._window_call_cancel = async_call_later( + self.hass, + timedelta(minutes=self._window_auto_max_duration), + dearm_window_auto, + ) + + elif ( + self._window_auto_algo.is_window_close_detected() + and self._window_auto_state is True + ): + await deactivate_window_auto(False) + + # For testing purpose we need to return the inner function + return dearm_window_auto + + def save_preset_mode(self): + """Save the current preset mode to be restored later + We never save a hidden preset mode + """ + if ( + self._attr_preset_mode not in HIDDEN_PRESETS + and self._attr_preset_mode is not None + ): + self._saved_preset_mode = self._attr_preset_mode + + async def restore_preset_mode(self): + """Restore a previous preset mode + We never restore a hidden preset mode. Normally that is not possible + """ + if ( + self._saved_preset_mode not in HIDDEN_PRESETS + and self._saved_preset_mode is not None + ): + await self._async_set_preset_mode_internal(self._saved_preset_mode) + + def save_hvac_mode(self): + """Save the current hvac-mode to be restored later""" + self._saved_hvac_mode = self._hvac_mode + _LOGGER.debug( + "%s - Saved hvac mode - saved_hvac_mode is %s, hvac_mode is %s", + self, + self._saved_hvac_mode, + self._hvac_mode, + ) + + async def restore_hvac_mode(self, need_control_heating=False): + """Restore a previous hvac_mod""" + await self.async_set_hvac_mode(self._saved_hvac_mode, need_control_heating) + _LOGGER.debug( + "%s - Restored hvac_mode - saved_hvac_mode is %s, hvac_mode is %s", + self, + self._saved_hvac_mode, + self._hvac_mode, + ) + + async def check_overpowering(self) -> bool: + """Check the overpowering condition + Turn the preset_mode of the heater to 'power' if power conditions are exceeded + """ + + if not self._pmax_on: + _LOGGER.debug( + "%s - power not configured. check_overpowering not available", self + ) + return False + + if ( + self._current_power is None + or self._device_power is None + or self._current_power_max is None + ): + _LOGGER.warning( + "%s - power not valued. check_overpowering not available", self + ) + return False + + _LOGGER.debug( + "%s - overpowering check: power=%.3f, max_power=%.3f heater power=%.3f", + self, + self._current_power, + self._current_power_max, + self._device_power, + ) + + # issue 407 - power_consumption_max is power we need to add. If already active we don't need to add more power + if self.is_device_active: + power_consumption_max = 0 + else: + if self.is_over_climate: + power_consumption_max = self._device_power + else: + power_consumption_max = max( + self._device_power / self.nb_underlying_entities, + self._device_power * self._prop_algorithm.on_percent, + ) + + ret = (self._current_power + power_consumption_max) >= self._current_power_max + if not self._overpowering_state and ret and self._hvac_mode != HVACMode.OFF: + _LOGGER.warning( + "%s - overpowering is detected. Heater preset will be set to 'power'", + self, + ) + if self.is_over_climate: + self.save_hvac_mode() + self.save_preset_mode() + await self._async_underlying_entity_turn_off() + await self._async_set_preset_mode_internal(PRESET_POWER) + self.send_event( + EventType.POWER_EVENT, + { + "type": "start", + "current_power": self._current_power, + "device_power": self._device_power, + "current_power_max": self._current_power_max, + "current_power_consumption": power_consumption_max, + }, + ) + + # Check if we need to remove the POWER preset + if ( + self._overpowering_state + and not ret + and self._attr_preset_mode == PRESET_POWER + ): + _LOGGER.warning( + "%s - end of overpowering is detected. Heater preset will be restored to '%s'", + self, + self._saved_preset_mode, + ) + if self.is_over_climate: + await self.restore_hvac_mode(False) + await self.restore_preset_mode() + self.send_event( + EventType.POWER_EVENT, + { + "type": "end", + "current_power": self._current_power, + "device_power": self._device_power, + "current_power_max": self._current_power_max, + }, + ) + + if self._overpowering_state != ret: + self._overpowering_state = ret + self.update_custom_attributes() + + return self._overpowering_state + + async def check_central_mode( + self, new_central_mode: str | None, old_central_mode: str | None + ): + """Take into account a central mode change""" + if not self.is_controlled_by_central_mode: + self._last_central_mode = None + return + + _LOGGER.info( + "%s - Central mode have change from %s to %s", + self, + old_central_mode, + new_central_mode, + ) + + first_init = self._last_central_mode == None + + self._last_central_mode = new_central_mode + + def save_all(): + """save preset and hvac_mode""" + self.save_preset_mode() + self.save_hvac_mode() + + if new_central_mode == CENTRAL_MODE_AUTO: + if self.window_state is not STATE_ON and not first_init: + await self.restore_hvac_mode() + await self.restore_preset_mode() + + return + + if old_central_mode == CENTRAL_MODE_AUTO and self.window_state is not STATE_ON: + save_all() + + if new_central_mode == CENTRAL_MODE_STOPPED: + await self.async_set_hvac_mode(HVACMode.OFF) + return + + if new_central_mode == CENTRAL_MODE_COOL_ONLY: + if HVACMode.COOL in self.hvac_modes: + await self.async_set_hvac_mode(HVACMode.COOL) + else: + await self.async_set_hvac_mode(HVACMode.OFF) + return + + if new_central_mode == CENTRAL_MODE_HEAT_ONLY: + if HVACMode.HEAT in self.hvac_modes: + await self.async_set_hvac_mode(HVACMode.HEAT) + else: + await self.async_set_hvac_mode(HVACMode.OFF) + return + + if new_central_mode == CENTRAL_MODE_FROST_PROTECTION: + if ( + PRESET_FROST_PROTECTION in self.preset_modes + and HVACMode.HEAT in self.hvac_modes + ): + await self.async_set_hvac_mode(HVACMode.HEAT) + await self.async_set_preset_mode( + PRESET_FROST_PROTECTION, overwrite_saved_preset=False + ) + else: + await self.async_set_hvac_mode(HVACMode.OFF) + return + + def _set_now(self, now: datetime): + """Set the now timestamp. This is only for tests purpose""" + self._now = now + + @property + def now(self) -> datetime: + """Get now. The local datetime or the overloaded _set_now date""" + return self._now if self._now is not None else datetime.now(self._current_tz) + + async def check_safety(self) -> bool: + """Check if last temperature date is too long""" + now = self.now + delta_temp = ( + now - self._last_temperature_measure.replace(tzinfo=self._current_tz) + ).total_seconds() / 60.0 + delta_ext_temp = ( + now - self._last_ext_temperature_measure.replace(tzinfo=self._current_tz) + ).total_seconds() / 60.0 + + mode_cond = self._hvac_mode != HVACMode.OFF + + api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api() + is_outdoor_checked = ( + not api.safety_mode + or api.safety_mode.get("check_outdoor_sensor") is not False + ) + + temp_cond: bool = delta_temp > self._security_delay_min or ( + is_outdoor_checked and delta_ext_temp > self._security_delay_min + ) + climate_cond: bool = self.is_over_climate and self.hvac_action not in [ + HVACAction.COOLING, + HVACAction.IDLE, + ] + switch_cond: bool = ( + not self.is_over_climate + and self._prop_algorithm is not None + and self._prop_algorithm.calculated_on_percent + >= self._security_min_on_percent + ) + + _LOGGER.debug( + "%s - checking security delta_temp=%.1f delta_ext_temp=%.1f mod_cond=%s temp_cond=%s climate_cond=%s switch_cond=%s", + self, + delta_temp, + delta_ext_temp, + mode_cond, + temp_cond, + climate_cond, + switch_cond, + ) + + # Issue 99 - a climate is regulated by the device itself and not by VTherm. So a VTherm should never be in security ! + shouldClimateBeInSecurity = False # temp_cond and climate_cond + shouldSwitchBeInSecurity = temp_cond and switch_cond + shouldBeInSecurity = shouldClimateBeInSecurity or shouldSwitchBeInSecurity + + shouldStartSecurity = ( + mode_cond and not self._security_state and shouldBeInSecurity + ) + # attr_preset_mode is not necessary normaly. It is just here to be sure + shouldStopSecurity = ( + self._security_state + and not shouldBeInSecurity + and self._attr_preset_mode == PRESET_SECURITY + ) + + # Logging and event + if shouldStartSecurity: + if shouldClimateBeInSecurity: + _LOGGER.warning( + "%s - No temperature received for more than %.1f minutes (dt=%.1f, dext=%.1f) and underlying climate is %s. Setting it into safety mode", + self, + self._security_delay_min, + delta_temp, + delta_ext_temp, + self.hvac_action, + ) + elif shouldSwitchBeInSecurity: + _LOGGER.warning( + "%s - No temperature received for more than %.1f minutes (dt=%.1f, dext=%.1f) and on_percent (%.2f %%) is over defined value (%.2f %%). Set it into safety mode", + self, + self._security_delay_min, + delta_temp, + delta_ext_temp, + self._prop_algorithm.on_percent * 100, + self._security_min_on_percent * 100, + ) + + self.send_event( + EventType.TEMPERATURE_EVENT, + { + "last_temperature_measure": self._last_temperature_measure.replace( + tzinfo=self._current_tz + ).isoformat(), + "last_ext_temperature_measure": self._last_ext_temperature_measure.replace( + tzinfo=self._current_tz + ).isoformat(), + "current_temp": self._cur_temp, + "current_ext_temp": self._cur_ext_temp, + "target_temp": self.target_temperature, + }, + ) + + # Start safety mode + if shouldStartSecurity: + self._security_state = True + self.save_hvac_mode() + self.save_preset_mode() + if self._prop_algorithm: + self._prop_algorithm.set_security(self._security_default_on_percent) + await self._async_set_preset_mode_internal(PRESET_SECURITY) + # Turn off the underlying climate or heater if security default on_percent is 0 + if self.is_over_climate or self._security_default_on_percent <= 0.0: + await self.async_set_hvac_mode(HVACMode.OFF, False) + + self.send_event( + EventType.SECURITY_EVENT, + { + "type": "start", + "last_temperature_measure": self._last_temperature_measure.replace( + tzinfo=self._current_tz + ).isoformat(), + "last_ext_temperature_measure": self._last_ext_temperature_measure.replace( + tzinfo=self._current_tz + ).isoformat(), + "current_temp": self._cur_temp, + "current_ext_temp": self._cur_ext_temp, + "target_temp": self.target_temperature, + }, + ) + + # Stop safety mode + if shouldStopSecurity: + _LOGGER.warning( + "%s - End of safety mode. restoring hvac_mode to %s and preset_mode to %s", + self, + self._saved_hvac_mode, + self._saved_preset_mode, + ) + self._security_state = False + if self._prop_algorithm: + self._prop_algorithm.unset_security() + # Restore hvac_mode if previously saved + if self.is_over_climate or self._security_default_on_percent <= 0.0: + await self.restore_hvac_mode(False) + await self.restore_preset_mode() + self.send_event( + EventType.SECURITY_EVENT, + { + "type": "end", + "last_temperature_measure": self._last_temperature_measure.replace( + tzinfo=self._current_tz + ).isoformat(), + "last_ext_temperature_measure": self._last_ext_temperature_measure.replace( + tzinfo=self._current_tz + ).isoformat(), + "current_temp": self._cur_temp, + "current_ext_temp": self._cur_ext_temp, + "target_temp": self.target_temperature, + }, + ) + + return shouldBeInSecurity + + @property + def is_initialized(self) -> bool: + """Check if all underlyings are initialized + This is usefull only for over_climate in which we + should have found the underlying climate to be operational""" + return True + + async def change_window_detection_state(self, new_state): + """Change the window detection state. + new_state is on if an open window have been detected or off else + """ + if not new_state: + _LOGGER.info( + "%s - Window is closed. Restoring hvac_mode '%s' if central_mode is not STOPPED", + self, + self._saved_hvac_mode, + ) + if self._window_action in [CONF_WINDOW_FROST_TEMP, CONF_WINDOW_ECO_TEMP]: + await self._async_internal_set_temperature(self._saved_target_temp) + # default to TURN_OFF + elif self._window_action in [CONF_WINDOW_TURN_OFF, CONF_WINDOW_FAN_ONLY]: + if self.last_central_mode != CENTRAL_MODE_STOPPED: + await self.restore_hvac_mode(True) + else: + _LOGGER.error( + "%s - undefined window_action %s. Please open a bug in the github of this project with this log", + self, + self._window_action, + ) + else: + _LOGGER.info( + "%s - Window is open. Set hvac_mode to '%s'", self, HVACMode.OFF + ) + if self.last_central_mode in [CENTRAL_MODE_AUTO, None]: + if self._window_action in [CONF_WINDOW_TURN_OFF, CONF_WINDOW_FAN_ONLY]: + self.save_hvac_mode() + elif self._window_action in [ + CONF_WINDOW_FROST_TEMP, + CONF_WINDOW_ECO_TEMP, + ]: + self._saved_target_temp = self._target_temp + + if ( + self._window_action == CONF_WINDOW_FAN_ONLY + and HVACMode.FAN_ONLY in self.hvac_modes + ): + await self.async_set_hvac_mode(HVACMode.FAN_ONLY) + elif ( + self._window_action == CONF_WINDOW_FROST_TEMP + and self._presets.get(PRESET_FROST_PROTECTION) is not None + ): + await self._async_internal_set_temperature( + self.find_preset_temp(PRESET_FROST_PROTECTION) + ) + elif ( + self._window_action == CONF_WINDOW_ECO_TEMP + and self._presets.get(PRESET_ECO) is not None + ): + await self._async_internal_set_temperature( + self.find_preset_temp(PRESET_ECO) + ) + else: # default is to turn_off + await self.async_set_hvac_mode(HVACMode.OFF) + + async def async_control_heating(self, force=False, _=None) -> bool: + """The main function used to run the calculation at each cycle""" + + _LOGGER.debug( + "%s - Checking new cycle. hvac_mode=%s, security_state=%s, preset_mode=%s", + self, + self._hvac_mode, + self._security_state, + self._attr_preset_mode, + ) + + # check auto_window conditions + await self._async_manage_window_auto(in_cycle=True) + + # Issue 56 in over_climate mode, if the underlying climate is not initialized, try to initialize it + if not self.is_initialized: + if not self.init_underlyings(): + # still not found, we an stop here + return False + + # Check overpowering condition + # Not necessary for switch because each switch is checking at startup + overpowering: bool = await self.check_overpowering() + if overpowering: + _LOGGER.debug("%s - End of cycle (overpowering)", self) + return True + + security: bool = await self.check_safety() + if security and self.is_over_climate: + _LOGGER.debug("%s - End of cycle (security and over climate)", self) + return True + + # Stop here if we are off + if self._hvac_mode == HVACMode.OFF: + _LOGGER.debug("%s - End of cycle (HVAC_MODE_OFF)", self) + # A security to force stop heater if still active + if self.is_device_active: + await self._async_underlying_entity_turn_off() + return True + + for under in self._underlyings: + await under.start_cycle( + self._hvac_mode, + self._prop_algorithm.on_time_sec if self._prop_algorithm else None, + self._prop_algorithm.off_time_sec if self._prop_algorithm else None, + self._prop_algorithm.on_percent if self._prop_algorithm else None, + force, + ) + + self.update_custom_attributes() + return True + + def recalculate(self): + """A utility function to force the calculation of a the algo and + update the custom attributes and write the state. + Should be overriden by super class + """ + raise NotImplementedError() + + def incremente_energy(self): + """increment the energy counter if device is active + Should be overriden by super class + """ + raise NotImplementedError() + + def update_custom_attributes(self): + """Update the custom extra attributes for the entity""" + + self._attr_extra_state_attributes: dict[str, Any] = { + "is_on": self.is_on, + "hvac_action": self.hvac_action, + "hvac_mode": self.hvac_mode, + "preset_mode": self.preset_mode, + "type": self._thermostat_type, + "is_controlled_by_central_mode": self.is_controlled_by_central_mode, + "last_central_mode": self.last_central_mode, + "frost_temp": self._presets.get(PRESET_FROST_PROTECTION, 0), + "eco_temp": self._presets.get(PRESET_ECO, 0), + "boost_temp": self._presets.get(PRESET_BOOST, 0), + "comfort_temp": self._presets.get(PRESET_COMFORT, 0), + "frost_away_temp": self._presets_away.get( + self.get_preset_away_name(PRESET_FROST_PROTECTION), 0 + ), + "eco_away_temp": self._presets_away.get( + self.get_preset_away_name(PRESET_ECO), 0 + ), + "boost_away_temp": self._presets_away.get( + self.get_preset_away_name(PRESET_BOOST), 0 + ), + "comfort_away_temp": self._presets_away.get( + self.get_preset_away_name(PRESET_COMFORT), 0 + ), + "power_temp": self._power_temp, + "target_temperature_step": self.target_temperature_step, + "ext_current_temperature": self._cur_ext_temp, + "ac_mode": self._ac_mode, + "current_power": self._current_power, + "current_power_max": self._current_power_max, + "saved_preset_mode": self._saved_preset_mode, + "saved_target_temp": self._saved_target_temp, + "saved_hvac_mode": self._saved_hvac_mode, + "motion_sensor_entity_id": self._motion_sensor_entity_id, + "motion_state": self._motion_state, + "power_sensor_entity_id": self._power_sensor_entity_id, + "max_power_sensor_entity_id": self._max_power_sensor_entity_id, + "overpowering_state": self.overpowering_state, + "presence_sensor_entity_id": self._presence_sensor_entity_id, + "presence_state": self._presence_state, + "window_state": self.window_state, + "window_auto_state": self.window_auto_state, + "window_bypass_state": self._window_bypass_state, + "window_sensor_entity_id": self._window_sensor_entity_id, + "window_delay_sec": self._window_delay_sec, + "window_auto_enabled": self.is_window_auto_enabled, + "window_auto_open_threshold": self._window_auto_open_threshold, + "window_auto_close_threshold": self._window_auto_close_threshold, + "window_auto_max_duration": self._window_auto_max_duration, + "window_action": self.window_action, + "security_delay_min": self._security_delay_min, + "security_min_on_percent": self._security_min_on_percent, + "security_default_on_percent": self._security_default_on_percent, + "last_temperature_datetime": self._last_temperature_measure.astimezone( + self._current_tz + ).isoformat(), + "last_ext_temperature_datetime": self._last_ext_temperature_measure.astimezone( + self._current_tz + ).isoformat(), + "security_state": self._security_state, + "minimal_activation_delay_sec": self._minimal_activation_delay, + "device_power": self._device_power, + ATTR_MEAN_POWER_CYCLE: self.mean_cycle_power, + ATTR_TOTAL_ENERGY: self.total_energy, + "last_update_datetime": datetime.now() + .astimezone(self._current_tz) + .isoformat(), + "timezone": str(self._current_tz), + "temperature_unit": self.temperature_unit, + "is_device_active": self.is_device_active, + "ema_temp": self._ema_temp, + "is_used_by_central_boiler": self.is_used_by_central_boiler, + } + + @callback + def async_registry_entry_updated(self): + """update the entity if the config entry have been updated + Note: this don't work either + """ + _LOGGER.info("%s - The config entry have been updated", self) + + async def service_set_presence(self, presence: str): + """Called by a service call: + service: versatile_thermostat.set_presence + data: + presence: "off" + target: + entity_id: climate.thermostat_1 + """ + _LOGGER.info("%s - Calling service_set_presence, presence: %s", self, presence) + await self._async_update_presence(presence) + await self.async_control_heating(force=True) + + async def service_set_preset_temperature( + self, + preset: str, + temperature: float | None = None, + temperature_away: float | None = None, + ): + """Called by a service call: + service: versatile_thermostat.set_preset_temperature + data: + preset: boost + temperature: 17.8 + temperature_away: 15 + target: + entity_id: climate.thermostat_2 + """ + _LOGGER.info( + "%s - Calling service_set_preset_temperature, preset: %s, temperature: %s, temperature_away: %s", + self, + preset, + temperature, + temperature_away, + ) + if preset in self._presets: + if temperature is not None: + self._presets[preset] = temperature + if self._presence_on and temperature_away is not None: + self._presets_away[self.get_preset_away_name(preset)] = temperature_away + else: + _LOGGER.warning( + "%s - No preset %s configured for this thermostat. Ignoring set_preset_temperature call", + self, + preset, + ) + + # If the changed preset is active, change the current temperature + # Issue #119 - reload new preset temperature also in ac mode + if preset.startswith(self._attr_preset_mode): + await self._async_set_preset_mode_internal( + preset.rstrip(PRESET_AC_SUFFIX), force=True + ) + await self.async_control_heating(force=True) + + async def service_set_security( + self, + delay_min: int | None, + min_on_percent: float | None, + default_on_percent: float | None, + ): + """Called by a service call: + service: versatile_thermostat.set_security + data: + delay_min: 15 + min_on_percent: 0.5 + default_on_percent: 0.2 + target: + entity_id: climate.thermostat_2 + """ + _LOGGER.info( + "%s - Calling service_set_security, delay_min: %s, min_on_percent: %s %%, default_on_percent: %s %%", + self, + delay_min, + min_on_percent * 100, + default_on_percent * 100, + ) + if delay_min: + self._security_delay_min = delay_min + if min_on_percent: + self._security_min_on_percent = min_on_percent + if default_on_percent: + self._security_default_on_percent = default_on_percent + + if self._prop_algorithm and self._security_state: + self._prop_algorithm.set_security(self._security_default_on_percent) + + await self.async_control_heating() + self.update_custom_attributes() + + async def service_set_window_bypass_state(self, window_bypass: bool): + """Called by a service call: + service: versatile_thermostat.set_window_bypass + data: + window_bypass: True + target: + entity_id: climate.thermostat_1 + """ + _LOGGER.info( + "%s - Calling service_set_window_bypass, window_bypass: %s", + self, + window_bypass, + ) + self._window_bypass_state = window_bypass + if not self._window_bypass_state and self._window_state: + _LOGGER.info( + "%s - Last window state was open & ByPass is now off. Set hvac_mode to '%s'", + self, + HVACMode.OFF, + ) + self.save_hvac_mode() + await self.async_set_hvac_mode(HVACMode.OFF) + if self._window_bypass_state and self._window_state: + _LOGGER.info( + "%s - Last window state was open & ByPass is now on. Set hvac_mode to last available mode", + self, + ) + await self.restore_hvac_mode(True) + self.update_custom_attributes() + + def send_event(self, event_type: EventType, data: dict): + """Send an event""" + send_vtherm_event(self._hass, event_type=event_type, entity=self, data=data) + + async def init_presets(self, central_config): + """Init all presets of the VTherm""" + # If preset central config is used and central config is set , take the presets from central config + vtherm_api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api() + + presets: dict[str, Any] = {} + presets_away: dict[str, Any] = {} + + def calculate_presets(items, use_central_conf_key): + presets: dict[str, Any] = {} + config_id = self._unique_id + if ( + central_config + and self._entry_infos.get(use_central_conf_key, False) is True + ): + config_id = central_config.entry_id + + for key, preset_name in items: + _LOGGER.debug("looking for key=%s, preset_name=%s", key, preset_name) + value = vtherm_api.get_temperature_number_value( + config_id=config_id, preset_name=preset_name + ) + if value is not None: + presets[key] = value + else: + _LOGGER.debug("preset_name %s not found in VTherm API", preset_name) + presets[key] = ( + self._attr_max_temp if self._ac_mode else self._attr_min_temp + ) + return presets + + # Calculate all presets + presets = calculate_presets( + CONF_PRESETS_WITH_AC.items() if self._ac_mode else CONF_PRESETS.items(), + CONF_USE_PRESETS_CENTRAL_CONFIG, + ) + + if self._entry_infos.get(CONF_USE_PRESENCE_FEATURE) is True: + presets_away = calculate_presets( + ( + CONF_PRESETS_AWAY_WITH_AC.items() + if self._ac_mode + else CONF_PRESETS_AWAY.items() + ), + CONF_USE_PRESENCE_CENTRAL_CONFIG, + ) + + # aggregate all available presets now + self._presets: dict[str, Any] = presets + self._presets_away: dict[str, Any] = presets_away + + # Calculate all possible presets + self._attr_preset_modes = [PRESET_NONE] + if len(self._presets): + self._support_flags = SUPPORT_FLAGS | ClimateEntityFeature.PRESET_MODE + + for key, _ in CONF_PRESETS.items(): + if self.find_preset_temp(key) > 0: + self._attr_preset_modes.append(key) + + _LOGGER.debug( + "After adding presets, preset_modes to %s", self._attr_preset_modes + ) + else: + _LOGGER.debug("No preset_modes") + + if self._motion_on: + self._attr_preset_modes.append(PRESET_ACTIVITY) + + # Re-applicate the last preset if any to take change into account + if self._attr_preset_mode: + await self._async_set_preset_mode_internal(self._attr_preset_mode, True) + + async def async_turn_off(self) -> None: + await self.async_set_hvac_mode(HVACMode.OFF) + + async def async_turn_on(self) -> None: + if self._ac_mode: + await self.async_set_hvac_mode(HVACMode.COOL) + else: + await self.async_set_hvac_mode(HVACMode.HEAT) diff --git a/config/custom_components/versatile_thermostat/binary_sensor.py b/config/custom_components/versatile_thermostat/binary_sensor.py new file mode 100644 index 0000000..73561b2 --- /dev/null +++ b/config/custom_components/versatile_thermostat/binary_sensor.py @@ -0,0 +1,496 @@ +""" Implements the VersatileThermostat binary sensors component """ +# pylint: disable=unused-argument, line-too-long + +import logging + +from homeassistant.core import ( + HomeAssistant, + callback, + Event, + # CoreState, + HomeAssistantError, +) + +from homeassistant.const import STATE_ON, STATE_OFF # , EVENT_HOMEASSISTANT_START + +from homeassistant.helpers.device_registry import DeviceInfo, DeviceEntryType +from homeassistant.helpers.event import async_track_state_change_event + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorDeviceClass, +) +from homeassistant.config_entries import ConfigEntry + +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .vtherm_api import VersatileThermostatAPI +from .commons import ( + VersatileThermostatBaseEntity, + check_and_extract_service_configuration, +) +from .const import ( + DOMAIN, + DEVICE_MANUFACTURER, + CONF_NAME, + CONF_USE_POWER_FEATURE, + CONF_USE_PRESENCE_FEATURE, + CONF_USE_MOTION_FEATURE, + CONF_USE_WINDOW_FEATURE, + CONF_THERMOSTAT_TYPE, + CONF_THERMOSTAT_CENTRAL_CONFIG, + CONF_USE_CENTRAL_BOILER_FEATURE, + CONF_CENTRAL_BOILER_ACTIVATION_SRV, + CONF_CENTRAL_BOILER_DEACTIVATION_SRV, + overrides, + EventType, + send_vtherm_event, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the VersatileThermostat binary sensors with config flow.""" + _LOGGER.debug( + "Calling async_setup_entry entry=%s, data=%s", entry.entry_id, entry.data + ) + + unique_id = entry.entry_id + name = entry.data.get(CONF_NAME) + vt_type = entry.data.get(CONF_THERMOSTAT_TYPE) + + entities = None + + if vt_type == CONF_THERMOSTAT_CENTRAL_CONFIG: + if entry.data.get(CONF_USE_CENTRAL_BOILER_FEATURE): + entities = [ + CentralBoilerBinarySensor(hass, unique_id, name, entry.data), + ] + else: + entities = [ + SecurityBinarySensor(hass, unique_id, name, entry.data), + WindowByPassBinarySensor(hass, unique_id, name, entry.data), + ] + if entry.data.get(CONF_USE_MOTION_FEATURE): + entities.append(MotionBinarySensor(hass, unique_id, name, entry.data)) + if entry.data.get(CONF_USE_WINDOW_FEATURE): + entities.append(WindowBinarySensor(hass, unique_id, name, entry.data)) + if entry.data.get(CONF_USE_PRESENCE_FEATURE): + entities.append(PresenceBinarySensor(hass, unique_id, name, entry.data)) + if entry.data.get(CONF_USE_POWER_FEATURE): + entities.append(OverpoweringBinarySensor(hass, unique_id, name, entry.data)) + + if entities: + async_add_entities(entities, True) + + +class SecurityBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity): + """Representation of a BinarySensor which exposes the security state""" + + def __init__( + self, + hass: HomeAssistant, + unique_id, + name, # pylint: disable=unused-argument + entry_infos, + ) -> None: + """Initialize the SecurityState Binary sensor""" + super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) + self._attr_name = "Security state" + self._attr_unique_id = f"{self._device_name}_security_state" + self._attr_is_on = False + + @callback + async def async_my_climate_changed(self, event: Event = None): + """Called when my climate have change""" + _LOGGER.debug("%s - climate state change", self._attr_unique_id) + + old_state = self._attr_is_on + self._attr_is_on = self.my_climate.security_state is True + if old_state != self._attr_is_on: + self.async_write_ha_state() + return + + @property + def device_class(self) -> BinarySensorDeviceClass | None: + return BinarySensorDeviceClass.SAFETY + + @property + def icon(self) -> str | None: + if self._attr_is_on: + return "mdi:shield-alert" + else: + return "mdi:shield-check-outline" + + +class OverpoweringBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity): + """Representation of a BinarySensor which exposes the overpowering state""" + + def __init__( + self, + hass: HomeAssistant, + unique_id, + name, # pylint: disable=unused-argument + entry_infos, + ) -> None: + """Initialize the OverpoweringState Binary sensor""" + super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) + self._attr_name = "Overpowering state" + self._attr_unique_id = f"{self._device_name}_overpowering_state" + self._attr_is_on = False + + @callback + async def async_my_climate_changed(self, event: Event = None): + """Called when my climate have change""" + _LOGGER.debug("%s - climate state change", self._attr_unique_id) + + old_state = self._attr_is_on + self._attr_is_on = self.my_climate.overpowering_state is True + if old_state != self._attr_is_on: + self.async_write_ha_state() + return + + @property + def device_class(self) -> BinarySensorDeviceClass | None: + return BinarySensorDeviceClass.POWER + + @property + def icon(self) -> str | None: + if self._attr_is_on: + return "mdi:flash-alert-outline" + else: + return "mdi:flash-outline" + + +class WindowBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity): + """Representation of a BinarySensor which exposes the window state""" + + def __init__( + self, + hass: HomeAssistant, + unique_id, + name, # pylint: disable=unused-argument + entry_infos, + ) -> None: + """Initialize the WindowState Binary sensor""" + super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) + self._attr_name = "Window state" + self._attr_unique_id = f"{self._device_name}_window_state" + self._attr_is_on = False + + @callback + async def async_my_climate_changed(self, event: Event = None): + """Called when my climate have change""" + _LOGGER.debug("%s - climate state change", self._attr_unique_id) + + old_state = self._attr_is_on + # Issue 120 - only take defined presence value + if self.my_climate.window_state in [ + STATE_ON, + STATE_OFF, + ] or self.my_climate.window_auto_state in [STATE_ON, STATE_OFF]: + self._attr_is_on = ( + self.my_climate.window_state == STATE_ON + or self.my_climate.window_auto_state == STATE_ON + ) + if old_state != self._attr_is_on: + self.async_write_ha_state() + return + + @property + def device_class(self) -> BinarySensorDeviceClass | None: + return BinarySensorDeviceClass.WINDOW + + @property + def icon(self) -> str | None: + if self._attr_is_on: + if self.my_climate.window_state == STATE_ON: + return "mdi:window-open-variant" + else: + return "mdi:window-open" + else: + return "mdi:window-closed-variant" + + +class MotionBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity): + """Representation of a BinarySensor which exposes the motion state""" + + def __init__( + self, + hass: HomeAssistant, + unique_id, + name, # pylint: disable=unused-argument + entry_infos, + ) -> None: + """Initialize the MotionState Binary sensor""" + super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) + self._attr_name = "Motion state" + self._attr_unique_id = f"{self._device_name}_motion_state" + self._attr_is_on = False + + @callback + async def async_my_climate_changed(self, event: Event = None): + """Called when my climate have change""" + _LOGGER.debug("%s - climate state change", self._attr_unique_id) + old_state = self._attr_is_on + # Issue 120 - only take defined presence value + if self.my_climate.motion_state in [STATE_ON, STATE_OFF]: + self._attr_is_on = self.my_climate.motion_state == STATE_ON + if old_state != self._attr_is_on: + self.async_write_ha_state() + return + + @property + def device_class(self) -> BinarySensorDeviceClass | None: + return BinarySensorDeviceClass.MOTION + + @property + def icon(self) -> str | None: + if self._attr_is_on: + return "mdi:motion-sensor" + else: + return "mdi:motion-sensor-off" + + +class PresenceBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity): + """Representation of a BinarySensor which exposes the presence state""" + + def __init__( + self, + hass: HomeAssistant, + unique_id, + name, # pylint: disable=unused-argument + entry_infos, + ) -> None: + """Initialize the PresenceState Binary sensor""" + super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) + self._attr_name = "Presence state" + self._attr_unique_id = f"{self._device_name}_presence_state" + self._attr_is_on = False + + @callback + async def async_my_climate_changed(self, event: Event = None): + """Called when my climate have change""" + + _LOGGER.debug("%s - climate state change", self._attr_unique_id) + old_state = self._attr_is_on + # Issue 120 - only take defined presence value + if self.my_climate.presence_state in [STATE_ON, STATE_OFF]: + self._attr_is_on = self.my_climate.presence_state == STATE_ON + if old_state != self._attr_is_on: + self.async_write_ha_state() + return + + @property + def device_class(self) -> BinarySensorDeviceClass | None: + return BinarySensorDeviceClass.PRESENCE + + @property + def icon(self) -> str | None: + if self._attr_is_on: + return "mdi:home-account" + else: + return "mdi:nature-people" + + +class WindowByPassBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity): + """Representation of a BinarySensor which exposes the Window ByPass state""" + + def __init__( + self, + hass: HomeAssistant, + unique_id, + name, # pylint: disable=unused-argument + entry_infos, + ) -> None: + """Initialize the WindowByPass Binary sensor""" + super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) + self._attr_name = "Window bypass" + self._attr_unique_id = f"{self._device_name}_window_bypass_state" + self._attr_is_on = False + + @callback + async def async_my_climate_changed(self, event: Event = None): + """Called when my climate have change""" + _LOGGER.debug("%s - climate state change", self._attr_unique_id) + old_state = self._attr_is_on + if self.my_climate.window_bypass_state in [True, False]: + self._attr_is_on = self.my_climate.window_bypass_state + if old_state != self._attr_is_on: + self.async_write_ha_state() + return + + @property + def device_class(self) -> BinarySensorDeviceClass | None: + return BinarySensorDeviceClass.RUNNING + + @property + def icon(self) -> str | None: + if self._attr_is_on: + return "mdi:window-shutter-cog" + else: + return "mdi:window-shutter-auto" + + +class CentralBoilerBinarySensor(BinarySensorEntity): + """Representation of a BinarySensor which exposes the Central Boiler state""" + + def __init__( + self, + hass: HomeAssistant, + unique_id, + name, # pylint: disable=unused-argument + entry_infos, + ) -> None: + """Initialize the CentralBoiler Binary sensor""" + self._config_id = unique_id + self._attr_name = "Central boiler" + self._attr_unique_id = "central_boiler_state" + self._attr_is_on = False + self._device_name = entry_infos.get(CONF_NAME) + self._entities = [] + self._hass = hass + self._service_activate = check_and_extract_service_configuration( + entry_infos.get(CONF_CENTRAL_BOILER_ACTIVATION_SRV) + ) + self._service_deactivate = check_and_extract_service_configuration( + entry_infos.get(CONF_CENTRAL_BOILER_DEACTIVATION_SRV) + ) + + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self._config_id)}, + name=self._device_name, + manufacturer=DEVICE_MANUFACTURER, + model=DOMAIN, + ) + + @property + def device_class(self) -> BinarySensorDeviceClass | None: + return BinarySensorDeviceClass.RUNNING + + @property + def icon(self) -> str | None: + if self._attr_is_on: + return "mdi:water-boiler" + else: + return "mdi:water-boiler-off" + + @overrides + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + + api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(self._hass) + api.register_central_boiler(self) + + # Should be not more needed and replaced by vtherm_api.init_vtherm_links + # @callback + # async def _async_startup_internal(*_): + # _LOGGER.debug("%s - Calling async_startup_internal", self) + # await self.listen_nb_active_vtherm_entity() + # + # if self.hass.state == CoreState.running: + # await _async_startup_internal() + # else: + # self.hass.bus.async_listen_once( + # EVENT_HOMEASSISTANT_START, _async_startup_internal + # ) + + async def listen_nb_active_vtherm_entity(self): + """Initialize the listening of state change of VTherms""" + + # Listen to all VTherm state change + api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(self._hass) + + if ( + api.nb_active_device_for_boiler_entity + and api.nb_active_device_for_boiler_threshold_entity + ): + listener_cancel = async_track_state_change_event( + self._hass, + [ + api.nb_active_device_for_boiler_entity.entity_id, + api.nb_active_device_for_boiler_threshold_entity.entity_id, + ], + self.calculate_central_boiler_state, + ) + _LOGGER.debug( + "%s - entity to get the nb of active VTherm is %s", + self, + api.nb_active_device_for_boiler_entity.entity_id, + ) + self.async_on_remove(listener_cancel) + else: + _LOGGER.debug("%s - no VTherm could controls the central boiler", self) + + await self.calculate_central_boiler_state(None) + + async def calculate_central_boiler_state(self, _): + """Calculate the central boiler state depending on all VTherm that + controls this central boiler""" + + _LOGGER.debug("%s - calculating the new central boiler state", self) + api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(self._hass) + if ( + api.nb_active_device_for_boiler is None + or api.nb_active_device_for_boiler_threshold is None + ): + _LOGGER.warning( + "%s - the entities to calculate the boiler state are not initialized. Boiler state cannot be calculated", + self, + ) + return False + + active = ( + api.nb_active_device_for_boiler >= api.nb_active_device_for_boiler_threshold + ) + + if self._attr_is_on != active: + try: + if active: + await self.call_service(self._service_activate) + _LOGGER.info("%s - central boiler have been turned on", self) + else: + await self.call_service(self._service_deactivate) + _LOGGER.info("%s - central boiler have been turned off", self) + self._attr_is_on = active + send_vtherm_event( + hass=self._hass, + event_type=EventType.CENTRAL_BOILER_EVENT, + entity=self, + data={"central_boiler": active}, + ) + self.async_write_ha_state() + except HomeAssistantError as err: + _LOGGER.error( + "%s - Impossible to activate/deactivat boiler due to error %s." + "Central boiler will not being controled by VTherm." + "Please check your service configuration. Cf. README.", + self, + err, + ) + + async def call_service(self, service_config: dict): + """Make a call to a service if correctly configured""" + if not service_config: + return + + await self._hass.services.async_call( + service_config["service_domain"], + service_config["service_name"], + service_data=service_config["data"], + target={ + "entity_id": service_config["entity_id"], + }, + ) + + def __str__(self): + return f"VersatileThermostat-{self.name}" diff --git a/config/custom_components/versatile_thermostat/climate.py b/config/custom_components/versatile_thermostat/climate.py new file mode 100644 index 0000000..aa548f5 --- /dev/null +++ b/config/custom_components/versatile_thermostat/climate.py @@ -0,0 +1,136 @@ +""" Implements the VersatileThermostat climate component """ + +import logging + + +import voluptuous as vol + +from homeassistant.core import HomeAssistant + +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.entity_platform import AddEntitiesCallback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.reload import async_setup_reload_service + +from homeassistant.helpers import entity_platform + +from homeassistant.const import ( + CONF_NAME, + STATE_ON, + STATE_OFF, + STATE_HOME, + STATE_NOT_HOME, +) + +from .const import ( + DOMAIN, + PLATFORMS, + CONF_PRESETS_WITH_AC, + SERVICE_SET_PRESENCE, + SERVICE_SET_PRESET_TEMPERATURE, + SERVICE_SET_SECURITY, + SERVICE_SET_WINDOW_BYPASS, + SERVICE_SET_AUTO_REGULATION_MODE, + SERVICE_SET_AUTO_FAN_MODE, + CONF_THERMOSTAT_TYPE, + CONF_THERMOSTAT_SWITCH, + CONF_THERMOSTAT_CLIMATE, + CONF_THERMOSTAT_VALVE, + CONF_THERMOSTAT_CENTRAL_CONFIG, +) + +from .thermostat_switch import ThermostatOverSwitch +from .thermostat_climate import ThermostatOverClimate +from .thermostat_valve import ThermostatOverValve + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the VersatileThermostat thermostat with config flow.""" + _LOGGER.debug( + "Calling async_setup_entry entry=%s, data=%s", entry.entry_id, entry.data + ) + + await async_setup_reload_service(hass, DOMAIN, PLATFORMS) + + unique_id = entry.entry_id + name = entry.data.get(CONF_NAME) + vt_type = entry.data.get(CONF_THERMOSTAT_TYPE) + + if vt_type == CONF_THERMOSTAT_CENTRAL_CONFIG: + return + + # Instantiate the right base class + entity = None + if vt_type == CONF_THERMOSTAT_SWITCH: + entity = ThermostatOverSwitch(hass, unique_id, name, entry.data) + elif vt_type == CONF_THERMOSTAT_CLIMATE: + entity = ThermostatOverClimate(hass, unique_id, name, entry.data) + elif vt_type == CONF_THERMOSTAT_VALVE: + entity = ThermostatOverValve(hass, unique_id, name, entry.data) + + async_add_entities([entity], True) + + # Add services + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( + SERVICE_SET_PRESENCE, + { + vol.Required("presence"): vol.In( + [STATE_ON, STATE_OFF, STATE_HOME, STATE_NOT_HOME] + ), + }, + "service_set_presence", + ) + + platform.async_register_entity_service( + SERVICE_SET_PRESET_TEMPERATURE, + { + vol.Required("preset"): vol.In(CONF_PRESETS_WITH_AC), + vol.Optional("temperature"): vol.Coerce(float), + vol.Optional("temperature_away"): vol.Coerce(float), + }, + "service_set_preset_temperature", + ) + + platform.async_register_entity_service( + SERVICE_SET_SECURITY, + { + vol.Optional("delay_min"): cv.positive_int, + vol.Optional("min_on_percent"): vol.Coerce(float), + vol.Optional("default_on_percent"): vol.Coerce(float), + }, + "service_set_security", + ) + + platform.async_register_entity_service( + SERVICE_SET_WINDOW_BYPASS, + { + vol.Required("window_bypass"): vol.In([True, False]), + }, + "service_set_window_bypass_state", + ) + + platform.async_register_entity_service( + SERVICE_SET_AUTO_REGULATION_MODE, + { + vol.Required("auto_regulation_mode"): vol.In( + ["None", "Light", "Medium", "Strong", "Slow"] + ), + }, + "service_set_auto_regulation_mode", + ) + + platform.async_register_entity_service( + SERVICE_SET_AUTO_FAN_MODE, + { + vol.Required("auto_fan_mode"): vol.In( + ["None", "Low", "Medium", "High", "Turbo"] + ), + }, + "service_set_auto_fan_mode", + ) diff --git a/config/custom_components/versatile_thermostat/commons.py b/config/custom_components/versatile_thermostat/commons.py new file mode 100644 index 0000000..19a02b3 --- /dev/null +++ b/config/custom_components/versatile_thermostat/commons.py @@ -0,0 +1,257 @@ +""" Some usefull commons class """ + +# pylint: disable=line-too-long + +import logging +from datetime import timedelta, datetime +from homeassistant.core import HomeAssistant, callback, Event +from homeassistant.components.climate import ClimateEntity, DOMAIN as CLIMATE_DOMAIN +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.device_registry import DeviceInfo, DeviceEntryType +from homeassistant.helpers.event import async_track_state_change_event, async_call_later +from homeassistant.util import dt as dt_util + +from .base_thermostat import BaseThermostat +from .const import DOMAIN, DEVICE_MANUFACTURER, ServiceConfigurationError + +_LOGGER = logging.getLogger(__name__) + + +def get_tz(hass: HomeAssistant): + """Get the current timezone""" + + return dt_util.get_time_zone(hass.config.time_zone) + + +class NowClass: + """For testing purpose only""" + + @staticmethod + def get_now(hass: HomeAssistant) -> datetime: + """A test function to get the now. + For testing purpose this method can be overriden to get a specific + timestamp. + """ + return datetime.now(get_tz(hass)) + + +def round_to_nearest(n: float, x: float) -> float: + """Round a number to the nearest x (which should be decimal but not null) + Example: + nombre1 = 3.2 + nombre2 = 4.7 + x = 0.3 + + nombre_arrondi1 = round_to_nearest(nombre1, x) + nombre_arrondi2 = round_to_nearest(nombre2, x) + + print(nombre_arrondi1) # Output: 3.3 + print(nombre_arrondi2) # Output: 4.6 + """ + assert x > 0 + return round(n * (1 / x)) / (1 / x) + + +def check_and_extract_service_configuration(service_config) -> dict: + """Raise a ServiceConfigurationError. In return you have a dict formatted like follows. + Example if you call with 'climate.central_boiler/climate.set_temperature/temperature:10': + { + "service_domain": "climate", + "service_name": "set_temperature", + "entity_id": "climate.central_boiler", + "entity_domain": "climate", + "entity_name": "central_boiler", + "data": { + "temperature": "10" + }, + "attribute_name": "temperature", + "attribute_value: "10" + } + + For this example 'switch.central_boiler/switch.turn_off' you will have this: + { + "service_domain": "switch", + "service_name": "turn_off", + "entity_id": "switch.central_boiler", + "entity_domain": "switch", + "entity_name": "central_boiler", + "data": { }, + } + + All values are striped (white space are removed) and are string + """ + + ret = {} + + if service_config is None: + return ret + + parties = service_config.split("/") + if len(parties) < 2: + raise ServiceConfigurationError( + f"Incorrect service configuration. Service {service_config} should be formatted with: 'entity_name/service_name[/data]'. See README for more information." + ) + entity_id = parties[0] + service_name = parties[1] + + service_infos = service_name.split(".") + if len(service_infos) != 2: + raise ServiceConfigurationError( + f"Incorrect service configuration. The service {service_config} should be formatted like: 'domain.service_name' (ex: 'switch.turn_on'). See README for more information." + ) + + ret.update( + { + "service_domain": service_infos[0].strip(), + "service_name": service_infos[1].strip(), + } + ) + + entity_infos = entity_id.split(".") + if len(entity_infos) != 2: + raise ServiceConfigurationError( + f"Incorrect service configuration. The entity_id {entity_id} should be formatted like: 'domain.entity_name' (ex: 'switch.central_boiler_switch'). See README for more information." + ) + + ret.update( + { + "entity_domain": entity_infos[0].strip(), + "entity_name": entity_infos[1].strip(), + "entity_id": entity_id.strip(), + } + ) + + if len(parties) == 3: + data = parties[2] + if len(data) > 0: + data_infos = None + data_infos = data.split(":") + if ( + len(data_infos) != 2 + or len(data_infos[0]) <= 0 + or len(data_infos[1]) <= 0 + ): + raise ServiceConfigurationError( + f"Incorrect service configuration. The data {data} should be formatted like: 'attribute:value' (ex: 'value:25'). See README for more information." + ) + + ret.update( + { + "attribute_name": data_infos[0].strip(), + "attribute_value": data_infos[1].strip(), + "data": {data_infos[0].strip(): data_infos[1].strip()}, + } + ) + else: + raise ServiceConfigurationError( + f"Incorrect service configuration. The data {data} should be formatted like: 'attribute:value' (ex: 'value:25'). See README for more information." + ) + else: + ret.update({"data": {}}) + + _LOGGER.debug( + "check_and_extract_service_configuration(%s) gives '%s'", service_config, ret + ) + return ret + + +class VersatileThermostatBaseEntity(Entity): + """A base class for all entities""" + + _my_climate: BaseThermostat + hass: HomeAssistant + _config_id: str + _device_name: str + + def __init__(self, hass: HomeAssistant, config_id, device_name) -> None: + """The CTOR""" + self.hass = hass + self._config_id = config_id + self._device_name = device_name + self._my_climate = None + self._cancel_call = None + self._attr_has_entity_name = True + + @property + def should_poll(self) -> bool: + """Do not poll for those entities""" + return False + + @property + def my_climate(self) -> BaseThermostat | None: + """Returns my climate if found""" + if not self._my_climate: + self._my_climate = self.find_my_versatile_thermostat() + if self._my_climate: + # Only the first time + self.my_climate_is_initialized() + return self._my_climate + + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self._config_id)}, + name=self._device_name, + manufacturer=DEVICE_MANUFACTURER, + model=DOMAIN, + ) + + def find_my_versatile_thermostat(self) -> BaseThermostat: + """Find the underlying climate entity""" + try: + component: EntityComponent[ClimateEntity] = self.hass.data[CLIMATE_DOMAIN] + for entity in component.entities: + # _LOGGER.debug("Device_info is %s", entity.device_info) + if entity.device_info == self.device_info: + _LOGGER.debug("Found %s!", entity) + return entity + except KeyError: + pass + + return None + + @callback + async def async_added_to_hass(self): + """Listen to my climate state change""" + + # Check delay condition + async def try_find_climate(_): + _LOGGER.debug( + "%s - Calling VersatileThermostatBaseEntity.async_added_to_hass", self + ) + mcl = self.my_climate + if mcl: + if self._cancel_call: + self._cancel_call() + self._cancel_call = None + self.async_on_remove( + async_track_state_change_event( + self.hass, + [mcl.entity_id], + self.async_my_climate_changed, + ) + ) + else: + _LOGGER.debug("%s - no entity to listen. Try later", self) + self._cancel_call = async_call_later( + self.hass, timedelta(seconds=1), try_find_climate + ) + + await try_find_climate(None) + + @callback + def my_climate_is_initialized(self): + """Called when the associated climate is initialized""" + return + + @callback + async def async_my_climate_changed( + self, event: Event + ): # pylint: disable=unused-argument + """Called when my climate have change + This method aims to be overriden to take the status change + """ + return diff --git a/config/custom_components/versatile_thermostat/config_flow.py b/config/custom_components/versatile_thermostat/config_flow.py new file mode 100644 index 0000000..ba8cc64 --- /dev/null +++ b/config/custom_components/versatile_thermostat/config_flow.py @@ -0,0 +1,883 @@ +# pylint: disable=line-too-long, too-many-lines, invalid-name + +"""Config flow for Versatile Thermostat integration.""" +from __future__ import annotations + +from typing import Any +import logging +import copy +from collections.abc import Mapping +import voluptuous as vol + +from homeassistant.exceptions import HomeAssistantError + +from homeassistant.core import callback +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow as HAConfigFlow, + OptionsFlow, +) + +from homeassistant.data_entry_flow import FlowHandler, FlowResult + +from .const import * # pylint: disable=wildcard-import, unused-wildcard-import +from .config_schema import * # pylint: disable=wildcard-import, unused-wildcard-import +from .vtherm_api import VersatileThermostatAPI +from .commons import check_and_extract_service_configuration + +COMES_FROM = "comes_from" + +_LOGGER = logging.getLogger(__name__) + + +# Not used but can be useful in other context +# def schema_defaults(schema, **defaults): +# """Create a new schema with default values filled in.""" +# copy = schema.extend({}) +# for field, field_type in copy.schema.items(): +# if isinstance(field_type, vol.In): +# value = None +# +# if value in field_type.container: +# # field.default = vol.default_factory(value) +# field.description = {"suggested_value": value} +# continue +# +# if field.schema in defaults: +# # field.default = vol.default_factory(defaults[field]) +# field.description = {"suggested_value": defaults[field]} +# return copy +# + + +def add_suggested_values_to_schema( + data_schema: vol.Schema, suggested_values: Mapping[str, Any] +) -> vol.Schema: + """Make a copy of the schema, populated with suggested values. + + For each schema marker matching items in `suggested_values`, + the `suggested_value` will be set. The existing `suggested_value` will + be left untouched if there is no matching item. + """ + schema = {} + for key, val in data_schema.schema.items(): + new_key = key + if key in suggested_values and isinstance(key, vol.Marker): + # Copy the marker to not modify the flow schema + new_key = copy.copy(key) + new_key.description = {"suggested_value": suggested_values[key]} + schema[new_key] = val + _LOGGER.debug("add_suggested_values_to_schema: schema=%s", schema) + return vol.Schema(schema) + + +class VersatileThermostatBaseConfigFlow(FlowHandler): + """The base Config flow class. Used to put some code in commons.""" + + VERSION = CONFIG_VERSION + MINOR_VERSION = CONFIG_MINOR_VERSION + + _infos: dict + _placeholders = { + CONF_NAME: "", + } + + def __init__(self, infos) -> None: + super().__init__() + _LOGGER.debug("CTOR BaseConfigFlow infos: %s", infos) + self._infos = infos + + # VTherm API should have been initialized before arriving here + vtherm_api = VersatileThermostatAPI.get_vtherm_api() + if vtherm_api is not None: + self._central_config = vtherm_api.find_central_configuration() + else: + self._central_config = None + + self._init_feature_flags(infos) + self._init_central_config_flags(infos) + + def _init_feature_flags(self, _): + """Fix features selection depending to infos""" + is_empty: bool = False # TODO remove this not bool(infos) + is_central_config = ( + self._infos.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_CENTRAL_CONFIG + ) + + self._infos[CONF_USE_WINDOW_FEATURE] = ( + is_empty + or self._infos.get(CONF_WINDOW_SENSOR) is not None + or self._infos.get(CONF_WINDOW_AUTO_OPEN_THRESHOLD) is not None + ) + self._infos[CONF_USE_MOTION_FEATURE] = ( + is_empty + or self._infos.get(CONF_MOTION_SENSOR) is not None + or is_central_config + ) + self._infos[CONF_USE_POWER_FEATURE] = is_empty or ( + self._infos.get(CONF_POWER_SENSOR) is not None + and self._infos.get(CONF_MAX_POWER_SENSOR) is not None + ) + self._infos[CONF_USE_PRESENCE_FEATURE] = ( + is_empty or self._infos.get(CONF_PRESENCE_SENSOR) is not None + ) + + self._infos[CONF_USE_CENTRAL_BOILER_FEATURE] = is_empty or ( + self._infos.get(CONF_CENTRAL_BOILER_ACTIVATION_SRV) is not None + and self._infos.get(CONF_CENTRAL_BOILER_DEACTIVATION_SRV) is not None + ) + + def _init_central_config_flags(self, infos): + """Initialisation of central configuration flags""" + is_empty: bool = not bool(infos) + for config in ( + CONF_USE_MAIN_CENTRAL_CONFIG, + CONF_USE_TPI_CENTRAL_CONFIG, + CONF_USE_WINDOW_CENTRAL_CONFIG, + CONF_USE_MOTION_CENTRAL_CONFIG, + CONF_USE_POWER_CENTRAL_CONFIG, + CONF_USE_PRESETS_CENTRAL_CONFIG, + CONF_USE_PRESENCE_CENTRAL_CONFIG, + CONF_USE_ADVANCED_CENTRAL_CONFIG, + ): + if not is_empty: + current_config = self._infos.get(config, None) + self._infos[config] = current_config is True or ( + current_config is None and self._central_config is not None + ) + else: + self._infos[config] = self._central_config is not None + + if COMES_FROM in self._infos: + del self._infos[COMES_FROM] + + async def validate_input(self, data: dict) -> None: + """Validate the user input allows us to connect. + + Data has the keys from STEP_*_DATA_SCHEMA with values provided by the user. + """ + + # check the heater_entity_id + for conf in [ + CONF_HEATER, + CONF_TEMP_SENSOR, + CONF_EXTERNAL_TEMP_SENSOR, + CONF_WINDOW_SENSOR, + CONF_MOTION_SENSOR, + CONF_POWER_SENSOR, + CONF_MAX_POWER_SENSOR, + CONF_PRESENCE_SENSOR, + CONF_CLIMATE, + ]: + d = data.get(conf, None) # pylint: disable=invalid-name + if d is not None and self.hass.states.get(d) is None: + _LOGGER.error( + "Entity id %s doesn't have any state. We cannot use it in the Versatile Thermostat configuration", # pylint: disable=line-too-long + d, + ) + raise UnknownEntity(conf) + + # Check that only one window feature is used + ws = self._infos.get(CONF_WINDOW_SENSOR) # pylint: disable=invalid-name + waot = data.get(CONF_WINDOW_AUTO_OPEN_THRESHOLD) + wact = data.get(CONF_WINDOW_AUTO_CLOSE_THRESHOLD) + wamd = data.get(CONF_WINDOW_AUTO_MAX_DURATION) + if ws is not None and ( + waot is not None or wact is not None or wamd is not None + ): + _LOGGER.error( + "Only one window detection method should be used. Use window_sensor or auto window open detection but not both" + ) + raise WindowOpenDetectionMethod(CONF_WINDOW_AUTO_OPEN_THRESHOLD) + + # Check that is USE_CENTRAL config is used, that a central config exists + if self._central_config is None: + for conf in [ + CONF_USE_MAIN_CENTRAL_CONFIG, + CONF_USE_TPI_CENTRAL_CONFIG, + CONF_USE_WINDOW_CENTRAL_CONFIG, + CONF_USE_MOTION_CENTRAL_CONFIG, + CONF_USE_POWER_CENTRAL_CONFIG, + CONF_USE_PRESENCE_CENTRAL_CONFIG, + CONF_USE_PRESETS_CENTRAL_CONFIG, + CONF_USE_ADVANCED_CENTRAL_CONFIG, + ]: + if data.get(conf) is True: + _LOGGER.error( + "The use of central configuration need a central configuration Versatile Thermostat instance" + ) + raise NoCentralConfig(conf) + + # Check the service for central boiler format + if self._infos.get(CONF_USE_CENTRAL_BOILER_FEATURE): + for conf in [ + CONF_CENTRAL_BOILER_ACTIVATION_SRV, + CONF_CENTRAL_BOILER_DEACTIVATION_SRV, + ]: + try: + check_and_extract_service_configuration(data.get(conf)) + except ServiceConfigurationError as err: + raise ServiceConfigurationError(conf) from err + + def check_config_complete(self, infos) -> bool: + """True if the config is now complete (ie all mandatory attributes are set)""" + is_central_config = ( + infos.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_CENTRAL_CONFIG + ) + if is_central_config: + if ( + infos.get(CONF_NAME) is None + or infos.get(CONF_EXTERNAL_TEMP_SENSOR) is None + ): + return False + + if infos.get(CONF_USE_POWER_FEATURE, False) is True and ( + infos.get(CONF_POWER_SENSOR, None) is None + or infos.get(CONF_MAX_POWER_SENSOR, None) is None + ): + return False + + if ( + infos.get(CONF_USE_PRESENCE_FEATURE, False) is True + and infos.get(CONF_PRESENCE_SENSOR, None) is None + ): + return False + + if self._infos[CONF_USE_CENTRAL_BOILER_FEATURE] and ( + not self._infos.get(CONF_CENTRAL_BOILER_ACTIVATION_SRV, False) + or len(self._infos.get(CONF_CENTRAL_BOILER_ACTIVATION_SRV)) <= 0 + or not self._infos.get(CONF_CENTRAL_BOILER_DEACTIVATION_SRV, False) + or len(self._infos.get(CONF_CENTRAL_BOILER_DEACTIVATION_SRV)) <= 0 + ): + return False + else: + if ( + infos.get(CONF_NAME) is None + or infos.get(CONF_TEMP_SENSOR) is None + or infos.get(CONF_CYCLE_MIN) is None + ): + return False + + if ( + infos.get(CONF_USE_MAIN_CENTRAL_CONFIG, False) is False + and infos.get(CONF_EXTERNAL_TEMP_SENSOR) is None + ): + return False + + if ( + infos.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_SWITCH + and infos.get(CONF_HEATER, None) is None + ): + return False + + if ( + infos.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_CLIMATE + and infos.get(CONF_CLIMATE, None) is None + ): + return False + + if ( + infos.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_VALVE + and infos.get(CONF_VALVE, None) is None + ): + return False + + if ( + infos.get(CONF_USE_MOTION_FEATURE, False) is True + and infos.get(CONF_MOTION_SENSOR, None) is None + ): + return False + + if ( + infos.get(CONF_USE_POWER_FEATURE, False) is True + and infos.get(CONF_USE_POWER_CENTRAL_CONFIG, False) is False + and ( + infos.get(CONF_POWER_SENSOR, None) is None + or infos.get(CONF_MAX_POWER_SENSOR, None) is None + ) + ): + return False + + if ( + infos.get(CONF_USE_PRESENCE_FEATURE, False) is True + and infos.get(CONF_USE_PRESENCE_CENTRAL_CONFIG, False) is False + and infos.get(CONF_PRESENCE_SENSOR, None) is None + ): + return False + + if ( + infos.get(CONF_USE_ADVANCED_CENTRAL_CONFIG, False) is False + and infos.get(CONF_MINIMAL_ACTIVATION_DELAY, -1) == -1 + ): + return False + + return True + + def merge_user_input(self, data_schema: vol.Schema, user_input: dict): + """For each schema entry not in user_input, set or remove values in infos""" + self._infos.update(user_input) + for key, _ in data_schema.schema.items(): + if key not in user_input and isinstance(key, vol.Marker): + _LOGGER.debug( + "add_empty_values_to_user_input: %s is not in user_input", key + ) + if key in self._infos: + self._infos.pop(key) + # else: This don't work but I don't know why. _infos seems broken after this (Not serializable exactly) + # self._infos[key] = user_input[key] + + _LOGGER.debug("merge_user_input: infos is now %s", self._infos) + + async def generic_step(self, step_id, data_schema, user_input, next_step_function): + """A generic method step""" + _LOGGER.debug( + "Into ConfigFlow.async_step_%s user_input=%s", step_id, user_input + ) + + defaults = self._infos.copy() + errors = {} + + if user_input is not None: + defaults.update(user_input or {}) + try: + await self.validate_input(user_input) + except UnknownEntity as err: + errors[str(err)] = "unknown_entity" + except WindowOpenDetectionMethod as err: + errors[str(err)] = "window_open_detection_method" + except NoCentralConfig as err: + errors[str(err)] = "no_central_config" + except ServiceConfigurationError as err: + errors[str(err)] = "service_configuration_format" + except ConfigurationNotCompleteError as err: + errors["base"] = "configuration_not_complete" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + self.merge_user_input(data_schema, user_input) + # Add default values for central config flags + self._init_central_config_flags(self._infos) + _LOGGER.debug("_info is now: %s", self._infos) + return await next_step_function() + + # ds = schema_defaults(data_schema, **defaults) # pylint: disable=invalid-name + ds = add_suggested_values_to_schema( + data_schema=data_schema, suggested_values=defaults + ) # pylint: disable=invalid-name + + return self.async_show_form( + step_id=step_id, + data_schema=ds, + errors=errors, + description_placeholders=self._placeholders, + ) + + async def async_step_user(self, user_input: dict | None = None) -> FlowResult: + """Handle the flow steps""" + _LOGGER.debug("Into ConfigFlow.async_step_user user_input=%s", user_input) + + return await self.generic_step( + "user", STEP_USER_DATA_SCHEMA, user_input, self.async_step_menu + ) + + async def async_step_configuration_not_complete( + self, user_input: dict | None = None + ) -> FlowResult: + """A fake step to handle the incomplete configuration flow""" + return await self.async_step_menu(user_input) + + async def async_step_menu(self, user_input: dict | None = None) -> FlowResult: + """Handle the flow steps""" + _LOGGER.debug("Into ConfigFlow.async_step_menu user_input=%s", user_input) + + is_central_config = ( + self._infos[CONF_THERMOSTAT_TYPE] == CONF_THERMOSTAT_CENTRAL_CONFIG + ) + + menu_options = ["main", "features"] + if not is_central_config: + menu_options.append("type") + + if ( + self._infos.get(CONF_PROP_FUNCTION) == PROPORTIONAL_FUNCTION_TPI + or is_central_config + ): + menu_options.append("tpi") + + if self._infos[CONF_THERMOSTAT_TYPE] in [ + CONF_THERMOSTAT_SWITCH, + CONF_THERMOSTAT_VALVE, + CONF_THERMOSTAT_CLIMATE, + ]: + menu_options.append("presets") + + if ( + is_central_config + and self._infos.get(CONF_USE_CENTRAL_BOILER_FEATURE) is True + ): + menu_options.append("central_boiler") + + if self._infos[CONF_USE_WINDOW_FEATURE] is True: + menu_options.append("window") + + if self._infos[CONF_USE_MOTION_FEATURE] is True: + menu_options.append("motion") + + if self._infos[CONF_USE_POWER_FEATURE] is True: + menu_options.append("power") + + if self._infos[CONF_USE_PRESENCE_FEATURE] is True: + menu_options.append("presence") + + menu_options.append("advanced") + + if self.check_config_complete(self._infos): + menu_options.append("finalize") + else: + _LOGGER.info("The configuration is not terminated") + menu_options.append("configuration_not_complete") + + return self.async_show_menu( + step_id="menu", + menu_options=menu_options, + description_placeholders=self._placeholders, + ) + + async def async_step_main(self, user_input: dict | None = None) -> FlowResult: + """Handle the flow steps""" + _LOGGER.debug("Into ConfigFlow.async_step_main user_input=%s", user_input) + + next_step = self.async_step_menu + if self._infos[CONF_THERMOSTAT_TYPE] == CONF_THERMOSTAT_CENTRAL_CONFIG: + self._infos[CONF_NAME] = CENTRAL_CONFIG_NAME + schema = STEP_CENTRAL_MAIN_DATA_SCHEMA + else: + schema = STEP_MAIN_DATA_SCHEMA + + if ( + user_input + and user_input.get(CONF_USE_MAIN_CENTRAL_CONFIG, False) is False + ): + if user_input and self._infos.get(COMES_FROM) == "async_step_spec_main": + schema = STEP_CENTRAL_MAIN_DATA_SCHEMA + del self._infos[COMES_FROM] + else: + next_step = self.async_step_spec_main + + return await self.generic_step("main", schema, user_input, next_step) + + async def async_step_spec_main(self, user_input: dict | None = None) -> FlowResult: + """Handle the specific main flow steps""" + _LOGGER.debug("Into ConfigFlow.async_step_spec_main user_input=%s", user_input) + + if self._infos[CONF_THERMOSTAT_TYPE] == CONF_THERMOSTAT_CENTRAL_CONFIG: + schema = STEP_CENTRAL_MAIN_DATA_SCHEMA + else: + schema = STEP_CENTRAL_SPEC_MAIN_DATA_SCHEMA + next_step = self.async_step_menu + + self._infos[COMES_FROM] = "async_step_spec_main" + + # This will return to async_step_main (to keep the "main" step) + return await self.generic_step("main", schema, user_input, next_step) + + async def async_step_central_boiler( + self, user_input: dict | None = None + ) -> FlowResult: + """Handle the central boiler flow steps""" + _LOGGER.debug( + "Into ConfigFlow.async_step_central_boiler user_input=%s", user_input + ) + + schema = STEP_CENTRAL_BOILER_SCHEMA + next_step = self.async_step_menu + + return await self.generic_step("central_boiler", schema, user_input, next_step) + + async def async_step_type(self, user_input: dict | None = None) -> FlowResult: + """Handle the Type flow steps""" + _LOGGER.debug("Into ConfigFlow.async_step_type user_input=%s", user_input) + + if self._infos[CONF_THERMOSTAT_TYPE] == CONF_THERMOSTAT_SWITCH: + return await self.generic_step( + "type", STEP_THERMOSTAT_SWITCH, user_input, self.async_step_menu + ) + elif self._infos[CONF_THERMOSTAT_TYPE] == CONF_THERMOSTAT_VALVE: + return await self.generic_step( + "type", STEP_THERMOSTAT_VALVE, user_input, self.async_step_menu + ) + else: + return await self.generic_step( + "type", + STEP_THERMOSTAT_CLIMATE, + user_input, + self.async_step_menu, + ) + + async def async_step_features(self, user_input: dict | None = None) -> FlowResult: + """Handle the Type flow steps""" + _LOGGER.debug("Into ConfigFlow.async_step_features user_input=%s", user_input) + + return await self.generic_step( + "features", + ( + STEP_CENTRAL_FEATURES_DATA_SCHEMA + if self._infos[CONF_THERMOSTAT_TYPE] == CONF_THERMOSTAT_CENTRAL_CONFIG + else STEP_FEATURES_DATA_SCHEMA + ), + user_input, + self.async_step_menu, + ) + + async def async_step_tpi(self, user_input: dict | None = None) -> FlowResult: + """Handle the TPI flow steps""" + _LOGGER.debug("Into ConfigFlow.async_step_tpi user_input=%s", user_input) + + next_step = self.async_step_menu + if self._infos[CONF_THERMOSTAT_TYPE] == CONF_THERMOSTAT_CENTRAL_CONFIG: + schema = STEP_CENTRAL_TPI_DATA_SCHEMA + else: + schema = STEP_TPI_DATA_SCHEMA + + if ( + user_input + and user_input.get(CONF_USE_TPI_CENTRAL_CONFIG, False) is False + ): + if user_input and self._infos.get(COMES_FROM) == "async_step_spec_tpi": + schema = STEP_CENTRAL_TPI_DATA_SCHEMA + del self._infos[COMES_FROM] + else: + next_step = self.async_step_spec_tpi + + return await self.generic_step("tpi", schema, user_input, next_step) + + async def async_step_spec_tpi(self, user_input: dict | None = None) -> FlowResult: + """Handle the specific TPI flow steps""" + _LOGGER.debug("Into ConfigFlow.async_step_spec_tpi user_input=%s", user_input) + + schema = STEP_CENTRAL_TPI_DATA_SCHEMA + self._infos[COMES_FROM] = "async_step_spec_tpi" + next_step = self.async_step_menu + + return await self.generic_step("tpi", schema, user_input, next_step) + + async def async_step_presets(self, user_input: dict | None = None) -> FlowResult: + """Handle the presets flow steps""" + _LOGGER.debug("Into ConfigFlow.async_step_presets user_input=%s", user_input) + + next_step = self.async_step_menu # advanced + schema = STEP_PRESETS_DATA_SCHEMA + + # In Central config -> display the next step immedialty + if self._infos[CONF_THERMOSTAT_TYPE] == CONF_THERMOSTAT_CENTRAL_CONFIG: + # Call directly the next step, we have nothing to display here + return await self.async_step_window() # = self.async_step_window + + return await self.generic_step("presets", schema, user_input, next_step) + + async def async_step_window(self, user_input: dict | None = None) -> FlowResult: + """Handle the window sensor flow steps""" + _LOGGER.debug("Into ConfigFlow.async_step_window user_input=%s", user_input) + + next_step = self.async_step_menu + if self._infos[CONF_THERMOSTAT_TYPE] == CONF_THERMOSTAT_CENTRAL_CONFIG: + schema = STEP_CENTRAL_WINDOW_DATA_SCHEMA + else: + schema = STEP_WINDOW_DATA_SCHEMA + + if ( + user_input + and user_input.get(CONF_USE_WINDOW_CENTRAL_CONFIG, False) is False + ): + if ( + user_input + and self._infos.get(COMES_FROM) == "async_step_spec_window" + ): + if self._infos.get(CONF_WINDOW_SENSOR) is not None: + schema = STEP_CENTRAL_WINDOW_WO_AUTO_DATA_SCHEMA + else: + schema = STEP_CENTRAL_WINDOW_DATA_SCHEMA + del self._infos[COMES_FROM] + else: + next_step = self.async_step_spec_window + + return await self.generic_step("window", schema, user_input, next_step) + + async def async_step_spec_window( + self, user_input: dict | None = None + ) -> FlowResult: + """Handle the specific window flow steps""" + _LOGGER.debug( + "Into ConfigFlow.async_step_spec_window user_input=%s", user_input + ) + + schema = STEP_CENTRAL_WINDOW_DATA_SCHEMA + if self._infos.get(CONF_WINDOW_SENSOR) is not None: + schema = STEP_CENTRAL_WINDOW_WO_AUTO_DATA_SCHEMA + + self._infos[COMES_FROM] = "async_step_spec_window" + + next_step = self.async_step_motion + + # This will return to async_step_main (to keep the "main" step) + return await self.generic_step("window", schema, user_input, next_step) + + async def async_step_motion(self, user_input: dict | None = None) -> FlowResult: + """Handle the window and motion sensor flow steps""" + _LOGGER.debug("Into ConfigFlow.async_step_motion user_input=%s", user_input) + + next_step = self.async_step_menu + if self._infos[CONF_THERMOSTAT_TYPE] == CONF_THERMOSTAT_CENTRAL_CONFIG: + schema = STEP_CENTRAL_MOTION_DATA_SCHEMA + else: + schema = STEP_MOTION_DATA_SCHEMA + + if ( + user_input + and user_input.get(CONF_USE_MOTION_CENTRAL_CONFIG, False) is False + ): + if ( + user_input + and self._infos.get(COMES_FROM) == "async_step_spec_motion" + ): + schema = STEP_CENTRAL_MOTION_DATA_SCHEMA + del self._infos[COMES_FROM] + else: + next_step = self.async_step_spec_motion + + return await self.generic_step("motion", schema, user_input, next_step) + + async def async_step_spec_motion( + self, user_input: dict | None = None + ) -> FlowResult: + """Handle the specific motion flow steps""" + _LOGGER.debug( + "Into ConfigFlow.async_step_spec_motion user_input=%s", user_input + ) + + schema = STEP_CENTRAL_MOTION_DATA_SCHEMA + + self._infos[COMES_FROM] = "async_step_spec_motion" + + next_step = self.async_step_menu + + # This will return to async_step_main (to keep the "main" step) + return await self.generic_step("motion", schema, user_input, next_step) + + async def async_step_power(self, user_input: dict | None = None) -> FlowResult: + """Handle the power management flow steps""" + _LOGGER.debug("Into ConfigFlow.async_step_power user_input=%s", user_input) + + next_step = self.async_step_menu + if self._infos[CONF_THERMOSTAT_TYPE] == CONF_THERMOSTAT_CENTRAL_CONFIG: + schema = STEP_CENTRAL_POWER_DATA_SCHEMA + else: + schema = STEP_POWER_DATA_SCHEMA + + if ( + user_input + and user_input.get(CONF_USE_POWER_CENTRAL_CONFIG, False) is False + ): + if ( + user_input + and self._infos.get(COMES_FROM) == "async_step_spec_power" + ): + schema = STEP_CENTRAL_POWER_DATA_SCHEMA + del self._infos[COMES_FROM] + else: + next_step = self.async_step_spec_power + + return await self.generic_step("power", schema, user_input, next_step) + + async def async_step_spec_power(self, user_input: dict | None = None) -> FlowResult: + """Handle the specific power flow steps""" + _LOGGER.debug("Into ConfigFlow.async_step_spec_power user_input=%s", user_input) + + schema = STEP_CENTRAL_POWER_DATA_SCHEMA + + self._infos[COMES_FROM] = "async_step_spec_power" + + next_step = self.async_step_menu + + # This will return to async_step_power (to keep the "power" step) + return await self.generic_step("power", schema, user_input, next_step) + + async def async_step_presence(self, user_input: dict | None = None) -> FlowResult: + """Handle the presence management flow steps""" + _LOGGER.debug("Into ConfigFlow.async_step_presence user_input=%s", user_input) + + next_step = self.async_step_menu + if self._infos[CONF_THERMOSTAT_TYPE] == CONF_THERMOSTAT_CENTRAL_CONFIG: + schema = STEP_CENTRAL_PRESENCE_DATA_SCHEMA + else: + schema = STEP_PRESENCE_DATA_SCHEMA + + if ( + user_input + and user_input.get(CONF_USE_PRESENCE_CENTRAL_CONFIG, False) is False + ): + if ( + user_input + and self._infos.get(COMES_FROM) == "async_step_spec_presence" + ): + schema = STEP_CENTRAL_PRESENCE_DATA_SCHEMA + del self._infos[COMES_FROM] + else: + next_step = self.async_step_spec_presence + + return await self.generic_step("presence", schema, user_input, next_step) + + async def async_step_spec_presence( + self, user_input: dict | None = None + ) -> FlowResult: + """Handle the specific power flow steps""" + _LOGGER.debug( + "Into ConfigFlow.async_step_spec_presence user_input=%s", user_input + ) + + schema = STEP_CENTRAL_PRESENCE_DATA_SCHEMA + + self._infos[COMES_FROM] = "async_step_spec_presence" + + next_step = self.async_step_menu + + # This will return to async_step_power (to keep the "power" step) + return await self.generic_step("presence", schema, user_input, next_step) + + async def async_step_advanced(self, user_input: dict | None = None) -> FlowResult: + """Handle the advanced parameter flow steps""" + _LOGGER.debug("Into ConfigFlow.async_step_advanced user_input=%s", user_input) + + next_step = self.async_step_menu + if self._infos[CONF_THERMOSTAT_TYPE] == CONF_THERMOSTAT_CENTRAL_CONFIG: + schema = STEP_CENTRAL_ADVANCED_DATA_SCHEMA + else: + schema = STEP_ADVANCED_DATA_SCHEMA + + if ( + user_input + and user_input.get(CONF_USE_ADVANCED_CENTRAL_CONFIG, False) is False + ): + if ( + user_input + and self._infos.get(COMES_FROM) == "async_step_spec_advanced" + ): + schema = STEP_CENTRAL_ADVANCED_DATA_SCHEMA + del self._infos[COMES_FROM] + else: + next_step = self.async_step_spec_advanced + + return await self.generic_step("advanced", schema, user_input, next_step) + + async def async_step_spec_advanced( + self, user_input: dict | None = None + ) -> FlowResult: + """Handle the specific advanced flow steps""" + _LOGGER.debug( + "Into ConfigFlow.async_step_spec_advanced user_input=%s", user_input + ) + + schema = STEP_CENTRAL_ADVANCED_DATA_SCHEMA + + self._infos[COMES_FROM] = "async_step_spec_advanced" + + next_step = self.async_step_advanced + + # This will return to async_step_presence (to keep the "presence" step) + return await self.generic_step("advanced", schema, user_input, next_step) + + async def async_step_finalize(self, _): + """Should be implemented by Leaf classes""" + raise HomeAssistantError( + "async_finalize not implemented on VersatileThermostat sub-class" + ) + + +class VersatileThermostatConfigFlow( + VersatileThermostatBaseConfigFlow, HAConfigFlow, domain=DOMAIN +): + """Handle a config flow for Versatile Thermostat.""" + + def __init__(self) -> None: + # self._info = dict() + super().__init__(dict()) + _LOGGER.debug("CTOR ConfigFlow") + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry): + """Get options flow for this handler""" + return VersatileThermostatOptionsFlowHandler(config_entry) + + async def async_step_finalize(self, _): + """Finalization of the ConfigEntry creation""" + _LOGGER.debug("ConfigFlow.async_finalize") + # Removes temporary value + if COMES_FROM in self._infos: + del self._infos[COMES_FROM] + return self.async_create_entry(title=self._infos[CONF_NAME], data=self._infos) + + +class VersatileThermostatOptionsFlowHandler( + VersatileThermostatBaseConfigFlow, OptionsFlow +): + """Handle options flow for Versatile Thermostat integration.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + super().__init__(config_entry.data.copy()) + self.config_entry = config_entry + _LOGGER.debug( + "CTOR VersatileThermostatOptionsFlowHandler info: %s, entry_id: %s", + self._infos, + config_entry.entry_id, + ) + + async def async_step_init(self, user_input=None): + """Manage basic options.""" + _LOGGER.debug( + "Into OptionsFlowHandler.async_step_init user_input =%s", + user_input, + ) + + self._placeholders = { + CONF_NAME: self._infos[CONF_NAME], + } + + return await self.async_step_menu(user_input) + + async def async_step_finalize(self, _): + """Finalization of the ConfigEntry creation""" + if not self._infos[CONF_USE_WINDOW_FEATURE]: + self._infos[CONF_USE_WINDOW_CENTRAL_CONFIG] = False + self._infos[CONF_WINDOW_SENSOR] = None + self._infos[CONF_WINDOW_AUTO_CLOSE_THRESHOLD] = None + self._infos[CONF_WINDOW_AUTO_OPEN_THRESHOLD] = None + self._infos[CONF_WINDOW_AUTO_MAX_DURATION] = None + if not self._infos[CONF_USE_MOTION_FEATURE]: + self._infos[CONF_USE_MOTION_CENTRAL_CONFIG] = False + self._infos[CONF_MOTION_SENSOR] = None + if not self._infos[CONF_USE_POWER_FEATURE]: + self._infos[CONF_USE_POWER_CENTRAL_CONFIG] = False + self._infos[CONF_POWER_SENSOR] = None + self._infos[CONF_MAX_POWER_SENSOR] = None + if not self._infos[CONF_USE_PRESENCE_FEATURE]: + self._infos[CONF_USE_PRESENCE_CENTRAL_CONFIG] = False + self._infos[CONF_PRESENCE_SENSOR] = None + if not self._infos[CONF_USE_CENTRAL_BOILER_FEATURE]: + self._infos[CONF_CENTRAL_BOILER_ACTIVATION_SRV] = None + self._infos[CONF_CENTRAL_BOILER_DEACTIVATION_SRV] = None + + _LOGGER.info( + "Recreating entry %s due to configuration change. New config is now: %s", + self.config_entry.entry_id, + self._infos, + ) + + # Removes temporary value + if COMES_FROM in self._infos: + del self._infos[COMES_FROM] + + self.hass.config_entries.async_update_entry(self.config_entry, data=self._infos) + return self.async_create_entry(title=None, data=None) diff --git a/config/custom_components/versatile_thermostat/config_schema.py b/config/custom_components/versatile_thermostat/config_schema.py new file mode 100644 index 0000000..b12c67c --- /dev/null +++ b/config/custom_components/versatile_thermostat/config_schema.py @@ -0,0 +1,352 @@ +""" All the schemas for ConfigFlow validation""" + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import selector +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN +from homeassistant.components.input_boolean import ( + DOMAIN as INPUT_BOOLEAN_DOMAIN, +) + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.input_number import ( + DOMAIN as INPUT_NUMBER_DOMAIN, +) + +from homeassistant.components.input_datetime import ( + DOMAIN as INPUT_DATETIME_DOMAIN, +) + +from homeassistant.components.person import DOMAIN as PERSON_DOMAIN +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN + + +from .const import * # pylint: disable=wildcard-import, unused-wildcard-import + +STEP_USER_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name + { + vol.Required( + CONF_THERMOSTAT_TYPE, default=CONF_THERMOSTAT_SWITCH + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=CONF_THERMOSTAT_TYPES, + translation_key="thermostat_type", + mode="list", + ) + ) + } +) + +STEP_MAIN_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name + { + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_TEMP_SENSOR): selector.EntitySelector( + selector.EntitySelectorConfig(domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN]), + ), + vol.Optional(CONF_LAST_SEEN_TEMP_SENSOR): selector.EntitySelector( + selector.EntitySelectorConfig( + domain=[SENSOR_DOMAIN, INPUT_DATETIME_DOMAIN] + ), + ), + vol.Required(CONF_CYCLE_MIN, default=5): cv.positive_int, + vol.Optional(CONF_DEVICE_POWER, default="1"): vol.Coerce(float), + vol.Required(CONF_USE_MAIN_CENTRAL_CONFIG, default=True): cv.boolean, + vol.Optional(CONF_USE_CENTRAL_MODE, default=True): cv.boolean, + vol.Required(CONF_USED_BY_CENTRAL_BOILER, default=False): cv.boolean, + } +) + +STEP_FEATURES_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name + { + vol.Optional(CONF_USE_WINDOW_FEATURE, default=False): cv.boolean, + vol.Optional(CONF_USE_MOTION_FEATURE, default=False): cv.boolean, + vol.Optional(CONF_USE_POWER_FEATURE, default=False): cv.boolean, + vol.Optional(CONF_USE_PRESENCE_FEATURE, default=False): cv.boolean, + } +) + +STEP_CENTRAL_FEATURES_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name + { + vol.Optional(CONF_USE_WINDOW_FEATURE, default=False): cv.boolean, + vol.Optional(CONF_USE_MOTION_FEATURE, default=False): cv.boolean, + vol.Optional(CONF_USE_POWER_FEATURE, default=False): cv.boolean, + vol.Optional(CONF_USE_PRESENCE_FEATURE, default=False): cv.boolean, + vol.Optional(CONF_USE_CENTRAL_BOILER_FEATURE, default=False): cv.boolean, + } +) + +STEP_CENTRAL_MAIN_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name + { + vol.Required(CONF_EXTERNAL_TEMP_SENSOR): selector.EntitySelector( + selector.EntitySelectorConfig(domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN]), + ), + vol.Required(CONF_TEMP_MIN, default=7): vol.Coerce(float), + vol.Required(CONF_TEMP_MAX, default=35): vol.Coerce(float), + vol.Required(CONF_STEP_TEMPERATURE, default=0.1): vol.Coerce(float), + } +) + +STEP_CENTRAL_SPEC_MAIN_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name + { + vol.Required(CONF_EXTERNAL_TEMP_SENSOR): selector.EntitySelector( + selector.EntitySelectorConfig(domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN]), + ), + vol.Required(CONF_TEMP_MIN, default=7): vol.Coerce(float), + vol.Required(CONF_TEMP_MAX, default=35): vol.Coerce(float), + vol.Required(CONF_STEP_TEMPERATURE, default=0.1): vol.Coerce(float), + } +) + +STEP_CENTRAL_BOILER_SCHEMA = vol.Schema( + { + vol.Optional(CONF_CENTRAL_BOILER_ACTIVATION_SRV, default=""): str, + vol.Optional(CONF_CENTRAL_BOILER_DEACTIVATION_SRV, default=""): str, + } +) + +STEP_THERMOSTAT_SWITCH = vol.Schema( # pylint: disable=invalid-name + { + vol.Required(CONF_HEATER): selector.EntitySelector( + selector.EntitySelectorConfig(domain=[SWITCH_DOMAIN, INPUT_BOOLEAN_DOMAIN]), + ), + vol.Optional(CONF_HEATER_2): selector.EntitySelector( + selector.EntitySelectorConfig(domain=[SWITCH_DOMAIN, INPUT_BOOLEAN_DOMAIN]), + ), + vol.Optional(CONF_HEATER_3): selector.EntitySelector( + selector.EntitySelectorConfig(domain=[SWITCH_DOMAIN, INPUT_BOOLEAN_DOMAIN]), + ), + vol.Optional(CONF_HEATER_4): selector.EntitySelector( + selector.EntitySelectorConfig(domain=[SWITCH_DOMAIN, INPUT_BOOLEAN_DOMAIN]), + ), + vol.Optional(CONF_HEATER_KEEP_ALIVE): cv.positive_int, + vol.Required(CONF_PROP_FUNCTION, default=PROPORTIONAL_FUNCTION_TPI): vol.In( + [ + PROPORTIONAL_FUNCTION_TPI, + ] + ), + vol.Optional(CONF_AC_MODE, default=False): cv.boolean, + vol.Optional(CONF_INVERSE_SWITCH, default=False): cv.boolean, + } +) + +STEP_THERMOSTAT_CLIMATE = vol.Schema( # pylint: disable=invalid-name + { + vol.Required(CONF_CLIMATE): selector.EntitySelector( + selector.EntitySelectorConfig(domain=CLIMATE_DOMAIN), + ), + vol.Optional(CONF_CLIMATE_2): selector.EntitySelector( + selector.EntitySelectorConfig(domain=CLIMATE_DOMAIN), + ), + vol.Optional(CONF_CLIMATE_3): selector.EntitySelector( + selector.EntitySelectorConfig(domain=CLIMATE_DOMAIN), + ), + vol.Optional(CONF_CLIMATE_4): selector.EntitySelector( + selector.EntitySelectorConfig(domain=CLIMATE_DOMAIN), + ), + vol.Optional(CONF_AC_MODE, default=False): cv.boolean, + vol.Optional( + CONF_AUTO_REGULATION_MODE, default=CONF_AUTO_REGULATION_NONE + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=CONF_AUTO_REGULATION_MODES, + translation_key="auto_regulation_mode", + mode="dropdown", + ) + ), + vol.Optional(CONF_AUTO_REGULATION_DTEMP, default=0.5): vol.Coerce(float), + vol.Optional(CONF_AUTO_REGULATION_PERIOD_MIN, default=5): cv.positive_int, + vol.Optional( + CONF_AUTO_FAN_MODE, default=CONF_AUTO_FAN_HIGH + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=CONF_AUTO_FAN_MODES, + translation_key="auto_fan_mode", + mode="dropdown", + ) + ), + vol.Optional(CONF_AUTO_REGULATION_USE_DEVICE_TEMP, default=False): cv.boolean, + } +) + +STEP_THERMOSTAT_VALVE = vol.Schema( # pylint: disable=invalid-name + { + vol.Required(CONF_VALVE): selector.EntitySelector( + selector.EntitySelectorConfig(domain=[NUMBER_DOMAIN, INPUT_NUMBER_DOMAIN]), + ), + vol.Optional(CONF_VALVE_2): selector.EntitySelector( + selector.EntitySelectorConfig(domain=[NUMBER_DOMAIN, INPUT_NUMBER_DOMAIN]), + ), + vol.Optional(CONF_VALVE_3): selector.EntitySelector( + selector.EntitySelectorConfig(domain=[NUMBER_DOMAIN, INPUT_NUMBER_DOMAIN]), + ), + vol.Optional(CONF_VALVE_4): selector.EntitySelector( + selector.EntitySelectorConfig(domain=[NUMBER_DOMAIN, INPUT_NUMBER_DOMAIN]), + ), + vol.Required(CONF_PROP_FUNCTION, default=PROPORTIONAL_FUNCTION_TPI): vol.In( + [ + PROPORTIONAL_FUNCTION_TPI, + ] + ), + vol.Optional(CONF_AC_MODE, default=False): cv.boolean, + vol.Optional(CONF_AUTO_REGULATION_DTEMP, default=10): vol.Coerce(float), + vol.Optional(CONF_AUTO_REGULATION_PERIOD_MIN, default=5): cv.positive_int, + } +) + +STEP_TPI_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name + { + vol.Required(CONF_USE_TPI_CENTRAL_CONFIG, default=True): cv.boolean, + } +) + +STEP_CENTRAL_TPI_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name + { + vol.Required(CONF_TPI_COEF_INT, default=0.6): vol.Coerce(float), + vol.Required(CONF_TPI_COEF_EXT, default=0.01): vol.Coerce(float), + } +) + +STEP_PRESETS_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name + { + vol.Required(CONF_USE_PRESETS_CENTRAL_CONFIG, default=True): cv.boolean, + } +) + + +STEP_WINDOW_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name + { + vol.Optional(CONF_WINDOW_SENSOR): selector.EntitySelector( + selector.EntitySelectorConfig( + domain=[BINARY_SENSOR_DOMAIN, INPUT_BOOLEAN_DOMAIN] + ), + ), + vol.Required(CONF_USE_WINDOW_CENTRAL_CONFIG, default=True): cv.boolean, + } +) + +STEP_CENTRAL_WINDOW_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name + { + vol.Optional(CONF_WINDOW_DELAY, default=30): cv.positive_int, + vol.Optional(CONF_WINDOW_AUTO_OPEN_THRESHOLD, default=3): vol.Coerce(float), + vol.Optional(CONF_WINDOW_AUTO_CLOSE_THRESHOLD, default=0): vol.Coerce(float), + vol.Optional(CONF_WINDOW_AUTO_MAX_DURATION, default=30): cv.positive_int, + vol.Optional( + CONF_WINDOW_ACTION, default=CONF_WINDOW_TURN_OFF + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=CONF_WINDOW_ACTIONS, + translation_key="window_action", + mode="dropdown", + ) + ), + } +) + +STEP_CENTRAL_WINDOW_WO_AUTO_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name + { + vol.Optional(CONF_WINDOW_DELAY, default=30): cv.positive_int, + vol.Optional( + CONF_WINDOW_ACTION, default=CONF_WINDOW_TURN_OFF + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=CONF_WINDOW_ACTIONS, + translation_key="window_action", + mode="dropdown", + ) + ), + } +) + +STEP_MOTION_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name + { + vol.Required(CONF_MOTION_SENSOR): selector.EntitySelector( + selector.EntitySelectorConfig( + domain=[BINARY_SENSOR_DOMAIN, INPUT_BOOLEAN_DOMAIN] + ), + ), + vol.Required(CONF_USE_MOTION_CENTRAL_CONFIG, default=True): cv.boolean, + } +) + +STEP_CENTRAL_MOTION_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name + { + vol.Optional(CONF_MOTION_DELAY, default=30): cv.positive_int, + vol.Optional(CONF_MOTION_OFF_DELAY, default=300): cv.positive_int, + vol.Optional(CONF_MOTION_PRESET, default="comfort"): selector.SelectSelector( + selector.SelectSelectorConfig( + options=CONF_PRESETS_SELECTIONABLE, + translation_key="presets", + mode="dropdown", + ) + ), + vol.Optional(CONF_NO_MOTION_PRESET, default="eco"): selector.SelectSelector( + selector.SelectSelectorConfig( + options=CONF_PRESETS_SELECTIONABLE, + translation_key="presets", + mode="dropdown", + ) + ), + } +) + +STEP_CENTRAL_POWER_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name + { + vol.Required(CONF_POWER_SENSOR): selector.EntitySelector( + selector.EntitySelectorConfig(domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN]), + ), + vol.Required(CONF_MAX_POWER_SENSOR): selector.EntitySelector( + selector.EntitySelectorConfig(domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN]), + ), + vol.Optional(CONF_PRESET_POWER, default="13"): vol.Coerce(float), + } +) + +STEP_POWER_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name + { + vol.Required(CONF_USE_POWER_CENTRAL_CONFIG, default=True): cv.boolean, + } +) + +STEP_CENTRAL_PRESENCE_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name + { + vol.Required(CONF_PRESENCE_SENSOR): selector.EntitySelector( + selector.EntitySelectorConfig( + domain=[ + PERSON_DOMAIN, + BINARY_SENSOR_DOMAIN, + INPUT_BOOLEAN_DOMAIN, + ] + ), + ) + }, +) + +STEP_PRESENCE_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name + { + vol.Required(CONF_USE_PRESENCE_CENTRAL_CONFIG, default=True): cv.boolean, + } +) + +STEP_CENTRAL_ADVANCED_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name + { + vol.Required(CONF_MINIMAL_ACTIVATION_DELAY, default=10): cv.positive_int, + vol.Required(CONF_SECURITY_DELAY_MIN, default=60): cv.positive_int, + vol.Required( + CONF_SECURITY_MIN_ON_PERCENT, + default=DEFAULT_SECURITY_MIN_ON_PERCENT, + ): vol.Coerce(float), + vol.Required( + CONF_SECURITY_DEFAULT_ON_PERCENT, + default=DEFAULT_SECURITY_DEFAULT_ON_PERCENT, + ): vol.Coerce(float), + } +) + +STEP_ADVANCED_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name + { + vol.Required(CONF_USE_ADVANCED_CENTRAL_CONFIG, default=True): cv.boolean, + } +) diff --git a/config/custom_components/versatile_thermostat/const.py b/config/custom_components/versatile_thermostat/const.py new file mode 100644 index 0000000..5e7df19 --- /dev/null +++ b/config/custom_components/versatile_thermostat/const.py @@ -0,0 +1,489 @@ +# pylint: disable=line-too-long +"""Constants for the Versatile Thermostat integration.""" + +import logging + +from enum import Enum +from homeassistant.const import CONF_NAME, Platform + +from homeassistant.components.climate import ( + # PRESET_ACTIVITY, + PRESET_BOOST, + PRESET_COMFORT, + PRESET_ECO, + ClimateEntityFeature, +) + +from homeassistant.exceptions import HomeAssistantError + +from .prop_algorithm import ( + PROPORTIONAL_FUNCTION_TPI, +) + +_LOGGER = logging.getLogger(__name__) + +CONFIG_VERSION = 1 +CONFIG_MINOR_VERSION = 2 + +PRESET_TEMP_SUFFIX = "_temp" +PRESET_AC_SUFFIX = "_ac" +PRESET_ECO_AC = PRESET_ECO + PRESET_AC_SUFFIX +PRESET_COMFORT_AC = PRESET_COMFORT + PRESET_AC_SUFFIX +PRESET_BOOST_AC = PRESET_BOOST + PRESET_AC_SUFFIX + + +DEVICE_MANUFACTURER = "JMCOLLIN" +DEVICE_MODEL = "Versatile Thermostat" + +PRESET_POWER = "power" +PRESET_SECURITY = "security" +PRESET_FROST_PROTECTION = "frost" + +HIDDEN_PRESETS = [PRESET_POWER, PRESET_SECURITY] + +DOMAIN = "versatile_thermostat" + +# The order is important. +PLATFORMS: list[Platform] = [ + Platform.SELECT, + Platform.CLIMATE, + Platform.SENSOR, + # Number should be after CLIMATE + Platform.NUMBER, + Platform.BINARY_SENSOR, +] + +CONF_HEATER = "heater_entity_id" +CONF_HEATER_2 = "heater_entity2_id" +CONF_HEATER_3 = "heater_entity3_id" +CONF_HEATER_4 = "heater_entity4_id" +CONF_HEATER_KEEP_ALIVE = "heater_keep_alive" +CONF_TEMP_SENSOR = "temperature_sensor_entity_id" +CONF_LAST_SEEN_TEMP_SENSOR = "last_seen_temperature_sensor_entity_id" +CONF_EXTERNAL_TEMP_SENSOR = "external_temperature_sensor_entity_id" +CONF_POWER_SENSOR = "power_sensor_entity_id" +CONF_MAX_POWER_SENSOR = "max_power_sensor_entity_id" +CONF_WINDOW_SENSOR = "window_sensor_entity_id" +CONF_MOTION_SENSOR = "motion_sensor_entity_id" +CONF_DEVICE_POWER = "device_power" +CONF_CYCLE_MIN = "cycle_min" +CONF_PROP_FUNCTION = "proportional_function" +CONF_WINDOW_DELAY = "window_delay" +CONF_MOTION_DELAY = "motion_delay" +CONF_MOTION_OFF_DELAY = "motion_off_delay" +CONF_MOTION_PRESET = "motion_preset" +CONF_NO_MOTION_PRESET = "no_motion_preset" +CONF_TPI_COEF_INT = "tpi_coef_int" +CONF_TPI_COEF_EXT = "tpi_coef_ext" +CONF_PRESENCE_SENSOR = "presence_sensor_entity_id" +CONF_PRESET_POWER = "power_temp" +CONF_MINIMAL_ACTIVATION_DELAY = "minimal_activation_delay" +CONF_TEMP_MIN = "temp_min" +CONF_TEMP_MAX = "temp_max" +CONF_SECURITY_DELAY_MIN = "security_delay_min" +CONF_SECURITY_MIN_ON_PERCENT = "security_min_on_percent" +CONF_SECURITY_DEFAULT_ON_PERCENT = "security_default_on_percent" +CONF_THERMOSTAT_TYPE = "thermostat_type" +CONF_THERMOSTAT_CENTRAL_CONFIG = "thermostat_central_config" +CONF_THERMOSTAT_SWITCH = "thermostat_over_switch" +CONF_THERMOSTAT_CLIMATE = "thermostat_over_climate" +CONF_THERMOSTAT_VALVE = "thermostat_over_valve" +CONF_CLIMATE = "climate_entity_id" +CONF_CLIMATE_2 = "climate_entity2_id" +CONF_CLIMATE_3 = "climate_entity3_id" +CONF_CLIMATE_4 = "climate_entity4_id" +CONF_USE_WINDOW_FEATURE = "use_window_feature" +CONF_USE_MOTION_FEATURE = "use_motion_feature" +CONF_USE_PRESENCE_FEATURE = "use_presence_feature" +CONF_USE_POWER_FEATURE = "use_power_feature" +CONF_USE_CENTRAL_BOILER_FEATURE = "use_central_boiler_feature" +CONF_AC_MODE = "ac_mode" +CONF_WINDOW_AUTO_OPEN_THRESHOLD = "window_auto_open_threshold" +CONF_WINDOW_AUTO_CLOSE_THRESHOLD = "window_auto_close_threshold" +CONF_WINDOW_AUTO_MAX_DURATION = "window_auto_max_duration" +CONF_VALVE = "valve_entity_id" +CONF_VALVE_2 = "valve_entity2_id" +CONF_VALVE_3 = "valve_entity3_id" +CONF_VALVE_4 = "valve_entity4_id" +CONF_AUTO_REGULATION_MODE = "auto_regulation_mode" +CONF_AUTO_REGULATION_NONE = "auto_regulation_none" +CONF_AUTO_REGULATION_SLOW = "auto_regulation_slow" +CONF_AUTO_REGULATION_LIGHT = "auto_regulation_light" +CONF_AUTO_REGULATION_MEDIUM = "auto_regulation_medium" +CONF_AUTO_REGULATION_STRONG = "auto_regulation_strong" +CONF_AUTO_REGULATION_EXPERT = "auto_regulation_expert" +CONF_AUTO_REGULATION_DTEMP = "auto_regulation_dtemp" +CONF_AUTO_REGULATION_PERIOD_MIN = "auto_regulation_periode_min" +CONF_AUTO_REGULATION_USE_DEVICE_TEMP = "auto_regulation_use_device_temp" +CONF_INVERSE_SWITCH = "inverse_switch_command" +CONF_AUTO_FAN_MODE = "auto_fan_mode" +CONF_AUTO_FAN_NONE = "auto_fan_none" +CONF_AUTO_FAN_LOW = "auto_fan_low" +CONF_AUTO_FAN_MEDIUM = "auto_fan_medium" +CONF_AUTO_FAN_HIGH = "auto_fan_high" +CONF_AUTO_FAN_TURBO = "auto_fan_turbo" +CONF_STEP_TEMPERATURE = "step_temperature" + +# Global params into configuration.yaml +CONF_SHORT_EMA_PARAMS = "short_ema_params" +CONF_SAFETY_MODE = "safety_mode" + +CONF_USE_MAIN_CENTRAL_CONFIG = "use_main_central_config" +CONF_USE_TPI_CENTRAL_CONFIG = "use_tpi_central_config" +CONF_USE_WINDOW_CENTRAL_CONFIG = "use_window_central_config" +CONF_USE_MOTION_CENTRAL_CONFIG = "use_motion_central_config" +CONF_USE_POWER_CENTRAL_CONFIG = "use_power_central_config" +CONF_USE_PRESENCE_CENTRAL_CONFIG = "use_presence_central_config" +CONF_USE_PRESETS_CENTRAL_CONFIG = "use_presets_central_config" +CONF_USE_ADVANCED_CENTRAL_CONFIG = "use_advanced_central_config" + +CONF_USE_CENTRAL_MODE = "use_central_mode" + +CONF_CENTRAL_BOILER_ACTIVATION_SRV = "central_boiler_activation_service" +CONF_CENTRAL_BOILER_DEACTIVATION_SRV = "central_boiler_deactivation_service" + +CONF_USED_BY_CENTRAL_BOILER = "used_by_controls_central_boiler" +CONF_WINDOW_ACTION = "window_action" + +DEFAULT_SHORT_EMA_PARAMS = { + "max_alpha": 0.5, + # In sec + "halflife_sec": 300, + "precision": 2, +} + +CONF_PRESETS = { + p: f"{p}{PRESET_TEMP_SUFFIX}" + for p in ( + PRESET_FROST_PROTECTION, + PRESET_ECO, + PRESET_COMFORT, + PRESET_BOOST, + ) +} + +CONF_PRESETS_WITH_AC = { + p: f"{p}{PRESET_TEMP_SUFFIX}" + for p in ( + PRESET_FROST_PROTECTION, + PRESET_ECO, + PRESET_COMFORT, + PRESET_BOOST, + PRESET_ECO_AC, + PRESET_COMFORT_AC, + PRESET_BOOST_AC, + ) +} + + +PRESET_AWAY_SUFFIX = "_away" + +CONF_PRESETS_AWAY = { + p: f"{p}{PRESET_TEMP_SUFFIX}" + for p in ( + PRESET_FROST_PROTECTION + PRESET_AWAY_SUFFIX, + PRESET_ECO + PRESET_AWAY_SUFFIX, + PRESET_COMFORT + PRESET_AWAY_SUFFIX, + PRESET_BOOST + PRESET_AWAY_SUFFIX, + ) +} + +CONF_PRESETS_AWAY_WITH_AC = { + p: f"{p}{PRESET_TEMP_SUFFIX}" + for p in ( + PRESET_FROST_PROTECTION + PRESET_AWAY_SUFFIX, + PRESET_ECO + PRESET_AWAY_SUFFIX, + PRESET_COMFORT + PRESET_AWAY_SUFFIX, + PRESET_BOOST + PRESET_AWAY_SUFFIX, + PRESET_ECO_AC + PRESET_AWAY_SUFFIX, + PRESET_COMFORT_AC + PRESET_AWAY_SUFFIX, + PRESET_BOOST_AC + PRESET_AWAY_SUFFIX, + ) +} + +CONF_PRESETS_SELECTIONABLE = [ + PRESET_FROST_PROTECTION, + PRESET_ECO, + PRESET_COMFORT, + PRESET_BOOST, +] + +CONF_PRESETS_VALUES = list(CONF_PRESETS.values()) +CONF_PRESETS_AWAY_VALUES = list(CONF_PRESETS_AWAY.values()) +CONF_PRESETS_WITH_AC_VALUES = list(CONF_PRESETS_WITH_AC.values()) +CONF_PRESETS_AWAY_WITH_AC_VALUES = list(CONF_PRESETS_AWAY_WITH_AC.values()) + +ALL_CONF = ( + [ + CONF_NAME, + CONF_HEATER, + CONF_HEATER_2, + CONF_HEATER_3, + CONF_HEATER_4, + CONF_HEATER_KEEP_ALIVE, + CONF_TEMP_SENSOR, + CONF_EXTERNAL_TEMP_SENSOR, + CONF_POWER_SENSOR, + CONF_MAX_POWER_SENSOR, + CONF_WINDOW_SENSOR, + CONF_WINDOW_DELAY, + CONF_WINDOW_AUTO_OPEN_THRESHOLD, + CONF_WINDOW_AUTO_CLOSE_THRESHOLD, + CONF_WINDOW_AUTO_MAX_DURATION, + CONF_MOTION_SENSOR, + CONF_MOTION_DELAY, + CONF_MOTION_PRESET, + CONF_NO_MOTION_PRESET, + CONF_DEVICE_POWER, + CONF_CYCLE_MIN, + CONF_PROP_FUNCTION, + CONF_TPI_COEF_INT, + CONF_TPI_COEF_EXT, + CONF_PRESENCE_SENSOR, + CONF_MINIMAL_ACTIVATION_DELAY, + CONF_TEMP_MIN, + CONF_TEMP_MAX, + CONF_SECURITY_DELAY_MIN, + CONF_SECURITY_MIN_ON_PERCENT, + CONF_SECURITY_DEFAULT_ON_PERCENT, + CONF_THERMOSTAT_TYPE, + CONF_THERMOSTAT_SWITCH, + CONF_THERMOSTAT_CLIMATE, + CONF_CLIMATE, + CONF_CLIMATE_2, + CONF_CLIMATE_3, + CONF_CLIMATE_4, + CONF_USE_WINDOW_FEATURE, + CONF_USE_MOTION_FEATURE, + CONF_USE_PRESENCE_FEATURE, + CONF_USE_POWER_FEATURE, + CONF_USE_CENTRAL_BOILER_FEATURE, + CONF_AC_MODE, + CONF_VALVE, + CONF_VALVE_2, + CONF_VALVE_3, + CONF_VALVE_4, + CONF_AUTO_REGULATION_MODE, + CONF_AUTO_REGULATION_DTEMP, + CONF_AUTO_REGULATION_PERIOD_MIN, + CONF_AUTO_REGULATION_USE_DEVICE_TEMP, + CONF_INVERSE_SWITCH, + CONF_AUTO_FAN_MODE, + CONF_USE_MAIN_CENTRAL_CONFIG, + CONF_USE_TPI_CENTRAL_CONFIG, + CONF_USE_PRESETS_CENTRAL_CONFIG, + CONF_USE_WINDOW_CENTRAL_CONFIG, + CONF_USE_MOTION_CENTRAL_CONFIG, + CONF_USE_POWER_CENTRAL_CONFIG, + CONF_USE_PRESENCE_CENTRAL_CONFIG, + CONF_USE_ADVANCED_CENTRAL_CONFIG, + CONF_USE_CENTRAL_MODE, + CONF_USED_BY_CENTRAL_BOILER, + CONF_CENTRAL_BOILER_ACTIVATION_SRV, + CONF_CENTRAL_BOILER_DEACTIVATION_SRV, + CONF_WINDOW_ACTION, + CONF_STEP_TEMPERATURE, + ] + + CONF_PRESETS_VALUES + + CONF_PRESETS_AWAY_VALUES + + CONF_PRESETS_WITH_AC_VALUES + + CONF_PRESETS_AWAY_WITH_AC_VALUES, +) + +CONF_FUNCTIONS = [ + PROPORTIONAL_FUNCTION_TPI, +] + +CONF_AUTO_REGULATION_MODES = [ + CONF_AUTO_REGULATION_NONE, + CONF_AUTO_REGULATION_LIGHT, + CONF_AUTO_REGULATION_MEDIUM, + CONF_AUTO_REGULATION_STRONG, + CONF_AUTO_REGULATION_SLOW, + CONF_AUTO_REGULATION_EXPERT, +] + +CONF_THERMOSTAT_TYPES = [ + CONF_THERMOSTAT_CENTRAL_CONFIG, + CONF_THERMOSTAT_SWITCH, + CONF_THERMOSTAT_CLIMATE, + CONF_THERMOSTAT_VALVE, +] + +CONF_AUTO_FAN_MODES = [ + CONF_AUTO_FAN_NONE, + CONF_AUTO_FAN_LOW, + CONF_AUTO_FAN_MEDIUM, + CONF_AUTO_FAN_HIGH, + CONF_AUTO_FAN_TURBO, +] + +CONF_WINDOW_TURN_OFF = "window_turn_off" +CONF_WINDOW_FAN_ONLY = "window_fan_only" +CONF_WINDOW_FROST_TEMP = "window_frost_temp" +CONF_WINDOW_ECO_TEMP = "window_eco_temp" + +CONF_WINDOW_ACTIONS = [ + CONF_WINDOW_TURN_OFF, + CONF_WINDOW_FAN_ONLY, + CONF_WINDOW_FROST_TEMP, + CONF_WINDOW_ECO_TEMP, +] + +SUPPORT_FLAGS = ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + +SERVICE_SET_PRESENCE = "set_presence" +SERVICE_SET_PRESET_TEMPERATURE = "set_preset_temperature" +SERVICE_SET_SECURITY = "set_security" +SERVICE_SET_WINDOW_BYPASS = "set_window_bypass" +SERVICE_SET_AUTO_REGULATION_MODE = "set_auto_regulation_mode" +SERVICE_SET_AUTO_FAN_MODE = "set_auto_fan_mode" + +DEFAULT_SECURITY_MIN_ON_PERCENT = 0.5 +DEFAULT_SECURITY_DEFAULT_ON_PERCENT = 0.1 + +ATTR_TOTAL_ENERGY = "total_energy" +ATTR_MEAN_POWER_CYCLE = "mean_cycle_power" + +AUTO_FAN_DTEMP_THRESHOLD = 2 +AUTO_FAN_DEACTIVATED_MODES = ["mute", "auto", "low"] + +CENTRAL_CONFIG_NAME = "Central configuration" + +CENTRAL_MODE_AUTO = "Auto" +CENTRAL_MODE_STOPPED = "Stopped" +CENTRAL_MODE_HEAT_ONLY = "Heat only" +CENTRAL_MODE_COOL_ONLY = "Cool only" +CENTRAL_MODE_FROST_PROTECTION = "Frost protection" +CENTRAL_MODES = [ + CENTRAL_MODE_AUTO, + CENTRAL_MODE_STOPPED, + CENTRAL_MODE_HEAT_ONLY, + CENTRAL_MODE_COOL_ONLY, + CENTRAL_MODE_FROST_PROTECTION, +] + + +# A special regulation parameter suggested by @Maia here: https://github.com/jmcollin78/versatile_thermostat/discussions/154 +class RegulationParamSlow: + """Light parameters for slow latency regulation""" + + kp: float = ( + 0.2 # 20% of the current internal regulation offset are caused by the current difference of target temperature and room temperature + ) + ki: float = ( + 0.8 / 288.0 + ) # 80% of the current internal regulation offset are caused by the average offset of the past 24 hours + k_ext: float = ( + 1.0 / 25.0 + ) # this will add 1°C to the offset when it's 25°C colder outdoor than indoor + offset_max: float = 2.0 # limit to a final offset of -2°C to +2°C + stabilization_threshold: float = ( + 0.0 # this needs to be disabled as otherwise the long term accumulated error will always be reset when the temp briefly crosses from/to below/above the target + ) + accumulated_error_threshold: float = ( + 2.0 * 288 + ) # this allows up to 2°C long term offset in both directions + + +class RegulationParamLight: + """Light parameters for regulation""" + + kp: float = 0.2 + ki: float = 0.05 + k_ext: float = 0.05 + offset_max: float = 1.5 + stabilization_threshold: float = 0.1 + accumulated_error_threshold: float = 10 + + +class RegulationParamMedium: + """Light parameters for regulation""" + + kp: float = 0.3 + ki: float = 0.05 + k_ext: float = 0.1 + offset_max: float = 2 + stabilization_threshold: float = 0.1 + accumulated_error_threshold: float = 20 + + +class RegulationParamStrong: + """Strong parameters for regulation + A set of parameters which doesn't take into account the external temp + and concentrate to internal temp error + accumulated error. + This should work for cold external conditions which else generates + high external_offset""" + + kp: float = 0.4 + ki: float = 0.08 + k_ext: float = 0.0 + offset_max: float = 5 + stabilization_threshold: float = 0.1 + accumulated_error_threshold: float = 50 + + +# Not used now +class RegulationParamVeryStrong: + """Strong parameters for regulation""" + + kp: float = 0.6 + ki: float = 0.1 + k_ext: float = 0.2 + offset_max: float = 4 + stabilization_threshold: float = 0.1 + accumulated_error_threshold: float = 30 + + +class EventType(Enum): + """The event type that can be sent""" + + SECURITY_EVENT: str = "versatile_thermostat_security_event" + POWER_EVENT: str = "versatile_thermostat_power_event" + TEMPERATURE_EVENT: str = "versatile_thermostat_temperature_event" + HVAC_MODE_EVENT: str = "versatile_thermostat_hvac_mode_event" + CENTRAL_BOILER_EVENT: str = "versatile_thermostat_central_boiler_event" + PRESET_EVENT: str = "versatile_thermostat_preset_event" + WINDOW_AUTO_EVENT: str = "versatile_thermostat_window_auto_event" + + +def send_vtherm_event(hass, event_type: EventType, entity, data: dict): + """Send an event""" + _LOGGER.info("%s - Sending event %s with data: %s", entity, event_type, data) + data["entity_id"] = entity.entity_id + data["name"] = entity.name + data["state_attributes"] = entity.state_attributes + hass.bus.fire(event_type.value, data) + + +class UnknownEntity(HomeAssistantError): + """Error to indicate there is an unknown entity_id given.""" + + +class WindowOpenDetectionMethod(HomeAssistantError): + """Error to indicate there is an error in the window open detection method given.""" + + +class NoCentralConfig(HomeAssistantError): + """Error to indicate that we try to use a central configuration but no VTherm of type CENTRAL CONFIGURATION has been found""" + + +class ServiceConfigurationError(HomeAssistantError): + """Error in the service configuration to control the central boiler""" + + +class ConfigurationNotCompleteError(HomeAssistantError): + """Error the configuration is not complete""" + + +class overrides: # pylint: disable=invalid-name + """An annotation to inform overrides""" + + def __init__(self, func): + self.func = func + + def __get__(self, instance, owner): + return self.func.__get__(instance, owner) + + def __call__(self, *args, **kwargs): + raise RuntimeError(f"Method {self.func.__name__} should have been overridden") diff --git a/config/custom_components/versatile_thermostat/ema.py b/config/custom_components/versatile_thermostat/ema.py new file mode 100644 index 0000000..f9b45f9 --- /dev/null +++ b/config/custom_components/versatile_thermostat/ema.py @@ -0,0 +1,92 @@ +# pylint: disable=line-too-long +"""The Estimated Mobile Average calculation used for temperature slope +and maybe some others feature""" + +import logging +import math +from datetime import datetime, tzinfo + +_LOGGER = logging.getLogger(__name__) + +MIN_TIME_DECAY_SEC = 0 + +# MAX_ALPHA: +# As for the EMA calculation of irregular time series, I've seen that it might be useful to +# have an upper limit for alpha in case the last measurement was too long ago. +# For example when using a half life of 10 minutes a measurement that is 60 minutes ago +# (if there's nothing inbetween) would contribute to the smoothed value with 1,5%, +# giving the current measurement 98,5% relevance. It could be wise to limit the alpha to e.g. 4x the half life (=0.9375). + + +class ExponentialMovingAverage: + """A class that will do the Estimated Mobile Average calculation""" + + def __init__( + self, + vterm_name: str, + halflife: float, + timezone: tzinfo, + precision: int = 3, + max_alpha: float = 0.5, + ): + """The halflife is the duration in secondes of a normal cycle""" + self._halflife: float = halflife + self._timezone = timezone + self._current_ema: float = None + self._last_timestamp: datetime = datetime.now(self._timezone) + self._name = vterm_name + self._precision = precision + self._max_alpha = max_alpha + + def __str__(self) -> str: + return f"EMA-{self._name}" + + def calculate_ema(self, measurement: float, timestamp: datetime) -> float | None: + """Calculate the new EMA from a new measurement measured at timestamp + Return the EMA or None if all parameters are not initialized now + """ + + if measurement is None or timestamp is None: + _LOGGER.warning( + "%s - Cannot calculate EMA: measurement and timestamp are mandatory. This message can be normal at startup but should not persist", + self, + ) + return measurement + + if self._current_ema is None: + _LOGGER.debug( + "%s - First init of the EMA", + self, + ) + self._current_ema = measurement + self._last_timestamp = timestamp + return self._current_ema + + time_decay = (timestamp - self._last_timestamp).total_seconds() + if time_decay < MIN_TIME_DECAY_SEC: + _LOGGER.debug( + "%s - time_decay %s is too small (< %s). Forget the measurement", + self, + time_decay, + MIN_TIME_DECAY_SEC, + ) + return self._current_ema + + alpha = 1 - math.exp(math.log(0.5) * time_decay / self._halflife) + # capping alpha to avoid gap if last measurement was long time ago + alpha = min(alpha, self._max_alpha) + new_ema = alpha * measurement + (1 - alpha) * self._current_ema + + self._last_timestamp = timestamp + self._current_ema = new_ema + _LOGGER.debug( + "%s - timestamp=%s alpha=%.2f measurement=%.2f current_ema=%.2f new_ema=%.2f", + self, + timestamp, + alpha, + measurement, + self._current_ema, + new_ema, + ) + + return round(self._current_ema, self._precision) diff --git a/config/custom_components/versatile_thermostat/keep_alive.py b/config/custom_components/versatile_thermostat/keep_alive.py new file mode 100644 index 0000000..1457cdf --- /dev/null +++ b/config/custom_components/versatile_thermostat/keep_alive.py @@ -0,0 +1,63 @@ +"""Building blocks for the heater switch keep-alive feature. + +The heater switch keep-alive feature consists of regularly refreshing the state +of directly controlled switches at a configurable interval (regularly turning the +switch 'on' or 'off' again even if it is already turned 'on' or 'off'), just like +the keep_alive setting of Home Assistant's Generic Thermostat integration: + https://www.home-assistant.io/integrations/generic_thermostat/ +""" + +import logging +from collections.abc import Awaitable, Callable +from datetime import timedelta, datetime + +from homeassistant.core import HomeAssistant, CALLBACK_TYPE +from homeassistant.helpers.event import async_track_time_interval + + +_LOGGER = logging.getLogger(__name__) + + +class IntervalCaller: + """Repeatedly call a given async action function at a given regular interval. + + Convenience wrapper around Home Assistant's `async_track_time_interval` function. + """ + + def __init__(self, hass: HomeAssistant, interval_sec: float) -> None: + self._hass = hass + self._interval_sec = interval_sec + self._remove_handle: CALLBACK_TYPE | None = None + + @property + def interval_sec(self) -> float: + """Return the calling interval in seconds.""" + return self._interval_sec + + def cancel(self): + """Cancel the regular calls to the action function.""" + if self._remove_handle: + self._remove_handle() + self._remove_handle = None + + def set_async_action(self, action: Callable[[], Awaitable[None]]): + """Set the async action function to be called at regular intervals.""" + if not self._interval_sec: + return + self.cancel() + + async def callback(_time: datetime): + try: + _LOGGER.debug( + "Calling keep-alive action '%s' (%ss interval)", + action.__name__, + self._interval_sec, + ) + await action() + except Exception as e: # pylint: disable=broad-exception-caught + _LOGGER.error(e) + self.cancel() + + self._remove_handle = async_track_time_interval( + self._hass, callback, timedelta(seconds=self._interval_sec) + ) diff --git a/config/custom_components/versatile_thermostat/manifest.json b/config/custom_components/versatile_thermostat/manifest.json new file mode 100644 index 0000000..d4c7b57 --- /dev/null +++ b/config/custom_components/versatile_thermostat/manifest.json @@ -0,0 +1,19 @@ +{ + "domain": "versatile_thermostat", + "name": "Versatile Thermostat", + "codeowners": [ + "@jmcollin78" + ], + "config_flow": true, + "dependencies": [], + "documentation": "https://github.com/jmcollin78/versatile_thermostat", + "homekit": {}, + "integration_type": "device", + "iot_class": "calculated", + "issue_tracker": "https://github.com/jmcollin78/versatile_thermostat/issues", + "quality_scale": "silver", + "requirements": [], + "ssdp": [], + "version": "6.2.3", + "zeroconf": [] +} \ No newline at end of file diff --git a/config/custom_components/versatile_thermostat/number.py b/config/custom_components/versatile_thermostat/number.py new file mode 100644 index 0000000..117a607 --- /dev/null +++ b/config/custom_components/versatile_thermostat/number.py @@ -0,0 +1,525 @@ +# pylint: disable=unused-argument + +""" Implements the VersatileThermostat select component """ +import logging + +# from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.core import HomeAssistant, CoreState # , callback + +from homeassistant.components.number import ( + NumberEntity, + NumberMode, + NumberDeviceClass, + DOMAIN as NUMBER_DOMAIN, + DEFAULT_MAX_VALUE, + DEFAULT_MIN_VALUE, + DEFAULT_STEP, +) +from homeassistant.components.climate import ( + PRESET_BOOST, + PRESET_COMFORT, + PRESET_ECO, +) +from homeassistant.components.sensor import UnitOfTemperature + +from homeassistant.helpers.device_registry import DeviceInfo, DeviceEntryType +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import slugify + +from .vtherm_api import VersatileThermostatAPI +from .commons import VersatileThermostatBaseEntity + +from .const import ( + DOMAIN, + DEVICE_MANUFACTURER, + CONF_NAME, + CONF_THERMOSTAT_TYPE, + CONF_THERMOSTAT_CENTRAL_CONFIG, + CONF_TEMP_MIN, + CONF_TEMP_MAX, + CONF_STEP_TEMPERATURE, + CONF_AC_MODE, + PRESET_FROST_PROTECTION, + PRESET_ECO_AC, + PRESET_COMFORT_AC, + PRESET_BOOST_AC, + PRESET_AWAY_SUFFIX, + PRESET_TEMP_SUFFIX, + CONF_PRESETS_VALUES, + CONF_PRESETS_WITH_AC_VALUES, + CONF_PRESETS_AWAY_VALUES, + CONF_PRESETS_AWAY_WITH_AC_VALUES, + CONF_USE_PRESETS_CENTRAL_CONFIG, + CONF_USE_PRESENCE_CENTRAL_CONFIG, + CONF_USE_PRESENCE_FEATURE, + CONF_USE_CENTRAL_BOILER_FEATURE, + overrides, + CONF_USE_MAIN_CENTRAL_CONFIG, +) + +PRESET_ICON_MAPPING = { + PRESET_FROST_PROTECTION + PRESET_TEMP_SUFFIX: "mdi:snowflake-thermometer", + PRESET_ECO + PRESET_TEMP_SUFFIX: "mdi:leaf", + PRESET_COMFORT + PRESET_TEMP_SUFFIX: "mdi:sofa", + PRESET_BOOST + PRESET_TEMP_SUFFIX: "mdi:rocket-launch", + PRESET_ECO_AC + PRESET_TEMP_SUFFIX: "mdi:leaf-circle-outline", + PRESET_COMFORT_AC + PRESET_TEMP_SUFFIX: "mdi:sofa-outline", + PRESET_BOOST_AC + PRESET_TEMP_SUFFIX: "mdi:rocket-launch-outline", + PRESET_FROST_PROTECTION + + PRESET_AWAY_SUFFIX + + PRESET_TEMP_SUFFIX: "mdi:snowflake-thermometer", + PRESET_ECO + PRESET_AWAY_SUFFIX + PRESET_TEMP_SUFFIX: "mdi:leaf", + PRESET_COMFORT + PRESET_AWAY_SUFFIX + PRESET_TEMP_SUFFIX: "mdi:sofa", + PRESET_BOOST + PRESET_AWAY_SUFFIX + PRESET_TEMP_SUFFIX: "mdi:rocket-launch", + PRESET_ECO_AC + PRESET_AWAY_SUFFIX + PRESET_TEMP_SUFFIX: "mdi:leaf-circle-outline", + PRESET_COMFORT_AC + PRESET_AWAY_SUFFIX + PRESET_TEMP_SUFFIX: "mdi:sofa-outline", + PRESET_BOOST_AC + + PRESET_AWAY_SUFFIX + + PRESET_TEMP_SUFFIX: "mdi:rocket-launch-outline", +} + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the VersatileThermostat selects with config flow.""" + _LOGGER.debug( + "Calling async_setup_entry entry=%s, data=%s", entry.entry_id, entry.data + ) + + unique_id = entry.entry_id + name = entry.data.get(CONF_NAME) + vt_type = entry.data.get(CONF_THERMOSTAT_TYPE) + # is_central_boiler = entry.data.get(CONF_USE_CENTRAL_BOILER_FEATURE) + + entities = [] + + if vt_type != CONF_THERMOSTAT_CENTRAL_CONFIG: + # Creates non central temperature entities + if not entry.data.get(CONF_USE_PRESETS_CENTRAL_CONFIG, False): + if entry.data.get(CONF_AC_MODE, False): + for preset in CONF_PRESETS_WITH_AC_VALUES: + _LOGGER.debug( + "%s - configuring Number non central, AC, non AWAY for preset %s", + name, + preset, + ) + entities.append( + TemperatureNumber( + hass, unique_id, name, preset, True, False, entry.data + ) + ) + else: + for preset in CONF_PRESETS_VALUES: + _LOGGER.debug( + "%s - configuring Number non central, non AC, non AWAY for preset %s", + name, + preset, + ) + entities.append( + TemperatureNumber( + hass, unique_id, name, preset, False, False, entry.data + ) + ) + + if entry.data.get( + CONF_USE_PRESENCE_FEATURE, False + ) is True and not entry.data.get(CONF_USE_PRESENCE_CENTRAL_CONFIG, False): + if entry.data.get(CONF_AC_MODE, False): + for preset in CONF_PRESETS_AWAY_WITH_AC_VALUES: + _LOGGER.debug( + "%s - configuring Number non central, AC, AWAY for preset %s", + name, + preset, + ) + entities.append( + TemperatureNumber( + hass, unique_id, name, preset, True, True, entry.data + ) + ) + else: + for preset in CONF_PRESETS_AWAY_VALUES: + _LOGGER.debug( + "%s - configuring Number non central, non AC, AWAY for preset %s", + name, + preset, + ) + entities.append( + TemperatureNumber( + hass, unique_id, name, preset, False, True, entry.data + ) + ) + + # For central config only + else: + if entry.data.get(CONF_USE_CENTRAL_BOILER_FEATURE): + entities.append( + ActivateBoilerThresholdNumber(hass, unique_id, name, entry.data) + ) + for preset in CONF_PRESETS_WITH_AC_VALUES: + _LOGGER.debug( + "%s - configuring Number central, AC, non AWAY for preset %s", + name, + preset, + ) + entities.append( + CentralConfigTemperatureNumber( + hass, unique_id, name, preset, True, False, entry.data + ) + ) + + for preset in CONF_PRESETS_AWAY_WITH_AC_VALUES: + _LOGGER.debug( + "%s - configuring Number central, AC, AWAY for preset %s", name, preset + ) + entities.append( + CentralConfigTemperatureNumber( + hass, unique_id, name, preset, True, True, entry.data + ) + ) + + if len(entities) > 0: + async_add_entities(entities, True) + + +class ActivateBoilerThresholdNumber( + NumberEntity, RestoreEntity +): # pylint: disable=abstract-method + """Representation of the threshold of the number of VTherm + which should be active to activate the boiler""" + + def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: + """Initialize the energy sensor""" + self._hass = hass + self._config_id = unique_id + self._device_name = entry_infos.get(CONF_NAME) + self._attr_name = "Boiler Activation threshold" + self._attr_unique_id = "boiler_activation_threshold" + self._attr_value = self._attr_native_value = 1 # default value + self._attr_native_min_value = 1 + self._attr_native_max_value = 9 + self._attr_step = 1 # default value + self._attr_mode = NumberMode.AUTO + + @property + def icon(self) -> str | None: + if isinstance(self._attr_native_value, int): + val = int(self._attr_native_value) + return f"mdi:numeric-{val}-box-outline" + else: + return "mdi:numeric-0-box-outline" + + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self._config_id)}, + name=self._device_name, + manufacturer=DEVICE_MANUFACTURER, + model=DOMAIN, + ) + + @overrides + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + + api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(self._hass) + api.register_central_boiler_activation_number_threshold(self) + + old_state: CoreState = await self.async_get_last_state() + _LOGGER.debug( + "%s - Calling async_added_to_hass old_state is %s", self, old_state + ) + if old_state is not None: + self._attr_value = self._attr_native_value = int(float(old_state.state)) + + @overrides + def set_native_value(self, value: float) -> None: + """Change the value""" + int_value = int(value) + old_value = int(self._attr_native_value) + + if int_value == old_value: + return + + self._attr_value = self._attr_native_value = int_value + + def __str__(self): + return f"VersatileThermostat-{self.name}" + + +class CentralConfigTemperatureNumber( + NumberEntity, RestoreEntity +): # pylint: disable=abstract-method + """Representation of one temperature number""" + + _attr_has_entity_name = True + + def __init__( + self, + hass: HomeAssistant, + unique_id, + name, + preset_name, + is_ac, + is_away, + entry_infos, + ) -> None: + """Initialize the temperature with entry_infos if available. Else + the restoration will do the trick.""" + + self._config_id = unique_id + self._device_name = name + # self._attr_name = name + + self._attr_translation_key = preset_name + self.entity_id = f"{NUMBER_DOMAIN}.{slugify(name)}_preset_{preset_name}" + self._attr_unique_id = f"central_configuration_preset_{preset_name}" + self._attr_device_class = NumberDeviceClass.TEMPERATURE + self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + + self._attr_native_step = entry_infos.get(CONF_STEP_TEMPERATURE, 0.5) + self._attr_native_min_value = entry_infos.get(CONF_TEMP_MIN) + self._attr_native_max_value = entry_infos.get(CONF_TEMP_MAX) + + # Initialize the values if included into the entry_infos. This will do + # the temperature migration. Else the temperature will be restored from + # previous value + # TODO remove this after the next major release and just keep the init min/max + temp = None + if (temp := entry_infos.get(preset_name, None)) is not None: + self._attr_value = self._attr_native_value = temp + else: + if entry_infos.get(CONF_AC_MODE) is True: + self._attr_native_value = self._attr_native_max_value + else: + self._attr_native_value = self._attr_native_min_value + + self._attr_mode = NumberMode.BOX + self._preset_name = preset_name + self._is_away = is_away + self._is_ac = is_ac + + @property + def icon(self) -> str | None: + return PRESET_ICON_MAPPING[self._preset_name] + + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self._config_id)}, + name=self._device_name, + manufacturer=DEVICE_MANUFACTURER, + model=DOMAIN, + ) + + @overrides + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + + # register the temp entity for this device and preset + api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(self.hass) + api.register_temperature_number(self._config_id, self._preset_name, self) + + # Restore value from previous one if exists + old_state: CoreState = await self.async_get_last_state() + _LOGGER.debug( + "%s - Calling async_added_to_hass old_state is %s", self, old_state + ) + try: + if old_state is not None and ((value := float(old_state.state)) > 0): + self._attr_value = self._attr_native_value = value + except ValueError: + pass + + @overrides + async def async_set_native_value(self, value: float) -> None: + """The value have change from the Number Entity in UI""" + float_value = float(value) + old_value = ( + None if self._attr_native_value is None else float(self._attr_native_value) + ) + + if float_value == old_value: + return + + self._attr_value = self._attr_native_value = float_value + + # persist the value + self.async_write_ha_state() + + # We have to reload all VTherm for which uses the central configuration + api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(self.hass) + # Update the VTherms which have temperature in central config + self.hass.create_task(api.init_vtherm_preset_with_central()) + + def __str__(self): + return f"VersatileThermostat-{self.name}" + + @property + def native_unit_of_measurement(self) -> str | None: + """The unit of measurement""" + # TODO Kelvin ? It seems not because all internal values are stored in + # ° Celsius but only the render in front can be in °K depending on the + # user configuration. + return UnitOfTemperature.CELSIUS + + +class TemperatureNumber( # pylint: disable=abstract-method + VersatileThermostatBaseEntity, NumberEntity, RestoreEntity +): + """Representation of one temperature number""" + + _attr_has_entity_name = True + + def __init__( + self, + hass: HomeAssistant, + unique_id, + name, + preset_name, + is_ac, + is_away, + entry_infos, + ) -> None: + """Initialize the temperature with entry_infos if available. Else + the restoration will do the trick.""" + super().__init__(hass, unique_id, name) + + self._attr_translation_key = preset_name + self.entity_id = f"{NUMBER_DOMAIN}.{slugify(name)}_preset_{preset_name}" + + self._attr_unique_id = f"{self._device_name}_preset_{preset_name}" + self._attr_device_class = NumberDeviceClass.TEMPERATURE + self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + + self._has_central_main_attributes = entry_infos.get( + CONF_USE_MAIN_CENTRAL_CONFIG, False + ) + + self.init_min_max_step(entry_infos) + + # Initialize the values if included into the entry_infos. This will do + # the temperature migration. + temp = None + if (temp := entry_infos.get(preset_name, None)) is not None: + self._attr_value = self._attr_native_value = temp + else: + if entry_infos.get(CONF_AC_MODE) is True: + self._attr_native_value = self._attr_native_max_value + else: + self._attr_native_value = self._attr_native_min_value + + self._attr_mode = NumberMode.BOX + self._preset_name = preset_name + self._canonical_preset_name = preset_name.replace( + PRESET_TEMP_SUFFIX, "" + ).replace(PRESET_AWAY_SUFFIX, "") + self._is_away = is_away + self._is_ac = is_ac + + @property + def icon(self) -> str | None: + return PRESET_ICON_MAPPING[self._preset_name] + + @overrides + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + + # register the temp entity for this device and preset + api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(self.hass) + api.register_temperature_number(self._config_id, self._preset_name, self) + + old_state: CoreState = await self.async_get_last_state() + _LOGGER.debug( + "%s - Calling async_added_to_hass old_state is %s", self, old_state + ) + try: + if old_state is not None and ((value := float(old_state.state)) > 0): + self._attr_value = self._attr_native_value = value + except ValueError: + pass + + @overrides + def my_climate_is_initialized(self): + """Called when the associated climate is initialized""" + self._attr_native_step = self.my_climate.target_temperature_step + self._attr_native_min_value = self.my_climate.min_temp + self._attr_native_max_value = self.my_climate.max_temp + return + + @overrides + async def async_set_native_value(self, value: float) -> None: + """Change the value""" + + if self.my_climate is None: + _LOGGER.warning( + "%s - cannot change temperature because VTherm is not initialized", self + ) + return + + float_value = float(value) + old_value = ( + None if self._attr_native_value is None else float(self._attr_native_value) + ) + + if float_value == old_value: + return + + self._attr_value = self._attr_native_value = float_value + self.async_write_ha_state() + + # Update the VTherm temp + self.hass.create_task( + self.my_climate.service_set_preset_temperature( + self._canonical_preset_name, + self._attr_native_value if not self._is_away else None, + self._attr_native_value if self._is_away else None, + ) + ) + + # We set the min, max and step from central config if relevant because it is possible that central config + # was not loaded at startup + self.init_min_max_step() + + def __str__(self): + return f"VersatileThermostat-{self.name}" + + @property + def native_unit_of_measurement(self) -> str | None: + """The unit of measurement""" + if not self.my_climate: + return UnitOfTemperature.CELSIUS + return self.my_climate.temperature_unit + + def init_min_max_step(self, entry_infos=None): + """Initialize min, max and step value from config or from central config""" + if self._has_central_main_attributes: + vthermapi: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api() + central_config = vthermapi.find_central_configuration() + if central_config: + self._attr_native_step = central_config.data.get(CONF_STEP_TEMPERATURE) + self._attr_native_min_value = central_config.data.get(CONF_TEMP_MIN) + self._attr_native_max_value = central_config.data.get(CONF_TEMP_MAX) + + return + + if entry_infos: + self._attr_native_step = entry_infos.get( + CONF_STEP_TEMPERATURE, DEFAULT_STEP + ) + self._attr_native_min_value = entry_infos.get( + CONF_TEMP_MIN, DEFAULT_MIN_VALUE + ) + self._attr_native_max_value = entry_infos.get( + CONF_TEMP_MAX, DEFAULT_MAX_VALUE + ) diff --git a/config/custom_components/versatile_thermostat/open_window_algorithm.py b/config/custom_components/versatile_thermostat/open_window_algorithm.py new file mode 100644 index 0000000..2e83aa6 --- /dev/null +++ b/config/custom_components/versatile_thermostat/open_window_algorithm.py @@ -0,0 +1,148 @@ +# pylint: disable=line-too-long +""" This file implements the Open Window by temperature algorithm + This algo works the following way: + - each time a new temperature is measured + - calculate the slope of the temperature curve. For this we calculate the slope(t) = 1/2 slope(t-1) + 1/2 * dTemp / dt + - if the slope is lower than a threshold the window opens alert is notified + - if the slope regain positive the end of the window open alert is notified +""" + +import logging +from datetime import datetime + +_LOGGER = logging.getLogger(__name__) + +# To filter bad values +MIN_DELTA_T_SEC = 0 # two temp mesure should be > 0 sec +MAX_SLOPE_VALUE = ( + 120 # slope cannot be > 2°/min or < -2°/min -> else this is an aberrant point +) + +MAX_DURATION_MIN = 30 # a fake data point is added in the cycle if last measurement was older than 30 min + +MIN_NB_POINT = 4 # do not calculate slope until we have enough point + + +class WindowOpenDetectionAlgorithm: + """The class that implements the algorithm listed above""" + + _alert_threshold: float + _end_alert_threshold: float + _last_slope: float + _last_datetime: datetime + _last_temperature: float + _nb_point: int + + def __init__(self, alert_threshold, end_alert_threshold) -> None: + """Initalize a new algorithm with the both threshold""" + self._alert_threshold = alert_threshold + self._end_alert_threshold = end_alert_threshold + self._last_slope = None + self._last_datetime = None + self._nb_point = 0 + + def check_age_last_measurement(self, temperature, datetime_now) -> float: + """ " Check if last measurement is old and add + a fake measurement point if this is the case + """ + if self._last_datetime is None: + return self.add_temp_measurement(temperature, datetime_now) + + delta_t_sec = float((datetime_now - self._last_datetime).total_seconds()) / 60.0 + if delta_t_sec >= MAX_DURATION_MIN: + return self.add_temp_measurement(temperature, datetime_now, False) + else: + # do nothing + return self._last_slope + + def add_temp_measurement( + self, temperature: float, datetime_measure: datetime, store_date: bool = True + ) -> float: + """Add a new temperature measurement + returns the last slope + """ + if self._last_datetime is None or self._last_temperature is None: + _LOGGER.debug("First initialisation") + self._last_datetime = datetime_measure + self._last_temperature = temperature + self._nb_point = self._nb_point + 1 + return None + + _LOGGER.debug( + "We are already initialized slope=%s last_temp=%0.2f", + self._last_slope, + self._last_temperature, + ) + lspe = self._last_slope + + delta_t_sec = float((datetime_measure - self._last_datetime).total_seconds()) + delta_t = delta_t_sec / 60.0 + if delta_t_sec <= MIN_DELTA_T_SEC: + _LOGGER.debug( + "Delta t is %d < %d which should be not possible. We don't consider this value", + delta_t_sec, + MIN_DELTA_T_SEC, + ) + return lspe + + delta_t_hour = delta_t / 60.0 + + delta_temp = float(temperature - self._last_temperature) + new_slope = delta_temp / delta_t_hour + if new_slope > MAX_SLOPE_VALUE or new_slope < -MAX_SLOPE_VALUE: + _LOGGER.debug( + "New_slope is abs(%.2f) > %.2f which should be not possible. We don't consider this value", + new_slope, + MAX_SLOPE_VALUE, + ) + return lspe + + if self._last_slope is None: + self._last_slope = round(new_slope, 2) + else: + self._last_slope = round((0.2 * self._last_slope) + (0.8 * new_slope), 2) + + # if we are in cycle check and so adding a fake datapoint, we don't store the event datetime + # so that, when we will receive a real temperature point we will not calculate a wrong slope + if store_date: + self._last_datetime = datetime_measure + + self._last_temperature = temperature + + self._nb_point = self._nb_point + 1 + _LOGGER.debug( + "delta_t=%.3f delta_temp=%.3f new_slope=%.3f last_slope=%s slope=%.3f nb_point=%s", + delta_t, + delta_temp, + new_slope, + lspe, + self._last_slope, + self._nb_point, + ) + + return self._last_slope + + def is_window_open_detected(self) -> bool: + """True if the last calculated slope is under (because negative value) the _alert_threshold""" + if self._alert_threshold is None: + return False + + if self._nb_point < MIN_NB_POINT or self._last_slope is None: + return False + + return self._last_slope < -self._alert_threshold + + def is_window_close_detected(self) -> bool: + """True if the last calculated slope is above (cause negative) the _end_alert_threshold""" + if self._end_alert_threshold is None: + return False + + if self._nb_point < MIN_NB_POINT or self._last_slope is None: + return False + + return self._last_slope >= self._end_alert_threshold + + @property + def last_slope(self) -> float: + """Return the last calculated slope""" + return self._last_slope diff --git a/config/custom_components/versatile_thermostat/pi_algorithm.py b/config/custom_components/versatile_thermostat/pi_algorithm.py new file mode 100644 index 0000000..bd0767b --- /dev/null +++ b/config/custom_components/versatile_thermostat/pi_algorithm.py @@ -0,0 +1,109 @@ +# pylint: disable=line-too-long +""" The PI algorithm implementation """ + +import logging + +_LOGGER = logging.getLogger(__name__) + + +class PITemperatureRegulator: + """A class implementing a PI Algorithm + PI algorithms calculate a target temperature by adding an offset which is calculating as follow: + - offset = kp * error + ki * accumulated_error + + To use it you must: + - instanciate the class and gives the algorithm parameters: kp, ki, offset_max, stabilization_threshold, accumulated_error_threshold + - call calculate_regulated_temperature with the internal and external temperature + - call set_target_temp when the target temperature change. + """ + + def __init__( + self, + target_temp: float, + kp: float, + ki: float, + k_ext: float, + offset_max: float, + stabilization_threshold: float, + accumulated_error_threshold: float, + ): + self.target_temp: float = target_temp + self.kp: float = kp # proportionnel gain + self.ki: float = ki # integral gain + self.k_ext: float = k_ext # exterior gain + self.offset_max: float = offset_max + self.stabilization_threshold: float = stabilization_threshold + self.accumulated_error: float = 0 + self.accumulated_error_threshold: float = accumulated_error_threshold + + def reset_accumulated_error(self): + """Reset the accumulated error""" + self.accumulated_error = 0 + + def set_accumulated_error(self, accumulated_error): + """Allow to persist and restore the accumulated_error""" + self.accumulated_error = accumulated_error + + def set_target_temp(self, target_temp): + """Set the new target_temp""" + self.target_temp = target_temp + # Discussion #191. After a target change we should reset the accumulated error which is certainly wrong now. + # Discussion #384. Finally don't reset the accumulated error but smoothly reset it if the sign is inversed + # if self.accumulated_error < 0: + # self.accumulated_error = 0 + + def calculate_regulated_temperature( + self, room_temp: float, external_temp: float + ): # pylint: disable=unused-argument + """Calculate a new target_temp given some temperature""" + if room_temp is None: + _LOGGER.warning( + "Temporarily skipping the self-regulation algorithm while the configured sensor for room temperature is unavailable" + ) + return self.target_temp + if external_temp is None: + _LOGGER.warning( + "Temporarily skipping the self-regulation algorithm while the configured sensor for outdoor temperature is unavailable" + ) + return self.target_temp + + # Calculate the error factor (P) + error = self.target_temp - room_temp + + # Calculate the sum of error (I) + # Discussion #384. Finally don't reset the accumulated error but smoothly reset it if the sign is inversed + # If the error have change its sign, reset smoothly the accumulated error + if error * self.accumulated_error < 0: + self.accumulated_error = self.accumulated_error / 2.0 + + self.accumulated_error += error + + # Capping of the error + self.accumulated_error = min( + self.accumulated_error_threshold, + max(-self.accumulated_error_threshold, self.accumulated_error), + ) + + # Calculate the offset (proportionnel + intégral) + offset = self.kp * error + self.ki * self.accumulated_error + + # Calculate the exterior offset + offset_ext = self.k_ext * (room_temp - external_temp) + + # Capping of offset + total_offset = offset + offset_ext + total_offset = min(self.offset_max, max(-self.offset_max, total_offset)) + + result = round(self.target_temp + total_offset, 1) + + _LOGGER.debug( + "PITemperatureRegulator - Error: %.2f accumulated_error: %.2f offset: %.2f offset_ext: %.2f target_tem: %.1f regulatedTemp: %.1f", + error, + self.accumulated_error, + offset, + offset_ext, + self.target_temp, + result, + ) + + return result diff --git a/config/custom_components/versatile_thermostat/prop_algorithm.py b/config/custom_components/versatile_thermostat/prop_algorithm.py new file mode 100644 index 0000000..3cd4aee --- /dev/null +++ b/config/custom_components/versatile_thermostat/prop_algorithm.py @@ -0,0 +1,188 @@ +""" The TPI calculation module """ +import logging + +from homeassistant.components.climate import HVACMode + +_LOGGER = logging.getLogger(__name__) + +PROPORTIONAL_FUNCTION_ATAN = "atan" +PROPORTIONAL_FUNCTION_LINEAR = "linear" +PROPORTIONAL_FUNCTION_TPI = "tpi" + +PROPORTIONAL_MIN_DURATION_SEC = 10 + +FUNCTION_TYPE = [PROPORTIONAL_FUNCTION_ATAN, PROPORTIONAL_FUNCTION_LINEAR] + + +class PropAlgorithm: + """This class aims to do all calculation of the Proportional alogorithm""" + + def __init__( + self, + function_type: str, + tpi_coef_int, + tpi_coef_ext, + cycle_min: int, + minimal_activation_delay: int, + vtherm_entity_id: str = None, + ) -> None: + """Initialisation of the Proportional Algorithm""" + _LOGGER.debug( + "%s - Creation new PropAlgorithm function_type: %s, tpi_coef_int: %s, tpi_coef_ext: %s, cycle_min:%d, minimal_activation_delay:%d", # pylint: disable=line-too-long + vtherm_entity_id, + function_type, + tpi_coef_int, + tpi_coef_ext, + cycle_min, + minimal_activation_delay, + ) + self._vtherm_entity_id = vtherm_entity_id + self._function = function_type + self._tpi_coef_int = tpi_coef_int + self._tpi_coef_ext = tpi_coef_ext + self._cycle_min = cycle_min + self._minimal_activation_delay = minimal_activation_delay + self._on_percent = 0 + self._calculated_on_percent = 0 + self._on_time_sec = 0 + self._off_time_sec = self._cycle_min * 60 + self._security = False + self._default_on_percent = 0 + + def calculate( + self, + target_temp: float | None, + current_temp: float | None, + ext_current_temp: float | None, + hvac_mode: HVACMode, + ): + """Do the calculation of the duration""" + if target_temp is None or current_temp is None: + log = _LOGGER.debug if hvac_mode == HVACMode.OFF else _LOGGER.warning + log( + "%s - Proportional algorithm: calculation is not possible cause target_temp (%s) or current_temp (%s) is null. Heating/cooling will be disabled. This could be normal at startup", # pylint: disable=line-too-long + self._vtherm_entity_id, + target_temp, + current_temp, + ) + self._calculated_on_percent = 0 + else: + if hvac_mode == HVACMode.COOL: + delta_temp = current_temp - target_temp + delta_ext_temp = ( + ext_current_temp + if ext_current_temp is not None + else 0 - target_temp + ) + else: + delta_temp = target_temp - current_temp + delta_ext_temp = ( + target_temp - ext_current_temp + if ext_current_temp is not None + else 0 + ) + + if self._function == PROPORTIONAL_FUNCTION_TPI: + self._calculated_on_percent = ( + self._tpi_coef_int * delta_temp + + self._tpi_coef_ext * delta_ext_temp + ) + else: + _LOGGER.warning( + "%s - Proportional algorithm: unknown %s function. Heating will be disabled", + self._vtherm_entity_id, + self._function, + ) + self._calculated_on_percent = 0 + + self._calculate_internal() + + _LOGGER.debug( + "%s - heating percent calculated for current_temp %.1f, ext_current_temp %.1f and target_temp %.1f is %.2f, on_time is %d (sec), off_time is %d (sec)", # pylint: disable=line-too-long + self._vtherm_entity_id, + current_temp if current_temp else -9999.0, + ext_current_temp if ext_current_temp else -9999.0, + target_temp if target_temp else -9999.0, + self._calculated_on_percent, + self.on_time_sec, + self.off_time_sec, + ) + + def _calculate_internal(self): + """Finish the calculation to get the on_percent in seconds""" + + # calculated on_time duration in seconds + if self._calculated_on_percent > 1: + self._calculated_on_percent = 1 + if self._calculated_on_percent < 0: + self._calculated_on_percent = 0 + + if self._security: + self._on_percent = self._default_on_percent + _LOGGER.info( + "%s - Security is On using the default_on_percent %f", + self._vtherm_entity_id, + self._on_percent, + ) + else: + _LOGGER.debug( + "Security is Off using the calculated_on_percent %f", + self._calculated_on_percent, + ) + self._on_percent = self._calculated_on_percent + + self._on_time_sec = self._on_percent * self._cycle_min * 60 + + # Do not heat for less than xx sec + if self._on_time_sec < self._minimal_activation_delay: + if self._on_time_sec > 0: + _LOGGER.info( + "%s - No heating period due to heating period too small (%f < %f)", + self._vtherm_entity_id, + self._on_time_sec, + self._minimal_activation_delay, + ) + self._on_time_sec = 0 + + self._off_time_sec = self._cycle_min * 60 - self._on_time_sec + + def set_security(self, default_on_percent: float): + """Set a default value for on_percent (used for safety mode)""" + _LOGGER.info( + "%s - Proportional Algo - set security to ON", self._vtherm_entity_id + ) + self._security = True + self._default_on_percent = default_on_percent + self._calculate_internal() + + def unset_security(self): + """Unset the safety mode""" + _LOGGER.info( + "%s - Proportional Algo - set security to OFF", self._vtherm_entity_id + ) + self._security = False + self._calculate_internal() + + @property + def on_percent(self) -> float: + """Returns the percentage the heater must be ON + In safety mode this value is overriden with the _default_on_percent + (1 means the heater will be always on, 0 never on)""" # pylint: disable=line-too-long + return round(self._on_percent, 2) + + @property + def calculated_on_percent(self) -> float: + """Returns the calculated percentage the heater must be ON + Calculated means NOT overriden even in safety mode + (1 means the heater will be always on, 0 never on)""" # pylint: disable=line-too-long + return round(self._calculated_on_percent, 2) + + @property + def on_time_sec(self) -> int: + """Returns the calculated time in sec the heater must be ON""" + return int(self._on_time_sec) + + @property + def off_time_sec(self) -> int: + """Returns the calculated time in sec the heater must be OFF""" + return int(self._off_time_sec) diff --git a/config/custom_components/versatile_thermostat/select.py b/config/custom_components/versatile_thermostat/select.py new file mode 100644 index 0000000..1f17ec3 --- /dev/null +++ b/config/custom_components/versatile_thermostat/select.py @@ -0,0 +1,136 @@ +# pylint: disable=unused-argument + +""" Implements the VersatileThermostat select component """ +import logging + +from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.core import HomeAssistant, CoreState, callback + +from homeassistant.components.climate import ClimateEntity, DOMAIN as CLIMATE_DOMAIN +from homeassistant.components.select import SelectEntity +from homeassistant.helpers.device_registry import DeviceInfo, DeviceEntryType +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_component import EntityComponent + +from custom_components.versatile_thermostat.base_thermostat import ( + BaseThermostat, + ConfigData, +) + +from custom_components.versatile_thermostat.vtherm_api import VersatileThermostatAPI + +from .const import ( + DOMAIN, + DEVICE_MANUFACTURER, + CONF_NAME, + CONF_THERMOSTAT_TYPE, + CONF_THERMOSTAT_CENTRAL_CONFIG, + CENTRAL_MODE_AUTO, + CENTRAL_MODES, + overrides, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the VersatileThermostat selects with config flow.""" + _LOGGER.debug( + "Calling async_setup_entry entry=%s, data=%s", entry.entry_id, entry.data + ) + + unique_id = entry.entry_id + name = entry.data.get(CONF_NAME) + vt_type = entry.data.get(CONF_THERMOSTAT_TYPE) + + if vt_type != CONF_THERMOSTAT_CENTRAL_CONFIG: + return + + entities = [ + CentralModeSelect(hass, unique_id, name, entry.data), + ] + + async_add_entities(entities, True) + + +class CentralModeSelect(SelectEntity, RestoreEntity): + """Representation of the central mode choice""" + + def __init__( + self, hass: HomeAssistant, unique_id: str, name: str, entry_infos: ConfigData + ): + """Initialize the energy sensor""" + self._config_id = unique_id + self._device_name = entry_infos.get(CONF_NAME) + self._attr_name = "Central Mode" + self._attr_unique_id = "central_mode" + self._attr_options = CENTRAL_MODES + self._attr_current_option = CENTRAL_MODE_AUTO + + @property + def icon(self) -> str: + return "mdi:form-select" + + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self._config_id)}, + name=self._device_name, + manufacturer=DEVICE_MANUFACTURER, + model=DOMAIN, + ) + + @overrides + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + + old_state = await self.async_get_last_state() + _LOGGER.debug( + "%s - Calling async_added_to_hass old_state is %s", self, old_state + ) + if old_state is not None: + self._attr_current_option = old_state.state + + api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(self.hass) + api.register_central_mode_select(self) + + # @callback + # async def _async_startup_internal(*_): + # _LOGGER.debug("%s - Calling async_startup_internal", self) + # await self.notify_central_mode_change() + # + # if self.hass.state == CoreState.running: + # await _async_startup_internal() + # else: + # self.hass.bus.async_listen_once( + # EVENT_HOMEASSISTANT_START, _async_startup_internal + # ) + + @overrides + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + old_option = self._attr_current_option + + if option == old_option: + return + + if option in CENTRAL_MODES: + self._attr_current_option = option + await self.notify_central_mode_change(old_central_mode=old_option) + + async def notify_central_mode_change(self, old_central_mode: str | None = None): + """Notify all VTherm that the central_mode have change""" + api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(self.hass) + # Update all VTherm states + await api.notify_central_mode_change(old_central_mode) + + def __str__(self) -> str: + return f"VersatileThermostat-{self.name}" diff --git a/config/custom_components/versatile_thermostat/sensor.py b/config/custom_components/versatile_thermostat/sensor.py new file mode 100644 index 0000000..e917b9f --- /dev/null +++ b/config/custom_components/versatile_thermostat/sensor.py @@ -0,0 +1,743 @@ +# pylint: disable=unused-argument +""" Implements the VersatileThermostat sensors component """ +import logging +import math + +from homeassistant.core import HomeAssistant, callback, Event, CoreState + +from homeassistant.const import ( + UnitOfTime, + UnitOfPower, + UnitOfEnergy, + PERCENTAGE, + EVENT_HOMEASSISTANT_START, +) + +from homeassistant.components.sensor import ( + SensorEntity, + SensorDeviceClass, + SensorStateClass, + UnitOfTemperature, +) +from homeassistant.config_entries import ConfigEntry + +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.device_registry import DeviceInfo, DeviceEntryType +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.event import async_track_state_change_event + +from homeassistant.components.climate import ( + ClimateEntity, + DOMAIN as CLIMATE_DOMAIN, + HVACAction, + HVACMode, +) + + +from .base_thermostat import BaseThermostat +from .vtherm_api import VersatileThermostatAPI +from .commons import VersatileThermostatBaseEntity +from .const import ( + DOMAIN, + DEVICE_MANUFACTURER, + CONF_NAME, + CONF_DEVICE_POWER, + CONF_PROP_FUNCTION, + PROPORTIONAL_FUNCTION_TPI, + CONF_THERMOSTAT_SWITCH, + CONF_THERMOSTAT_VALVE, + CONF_THERMOSTAT_CLIMATE, + CONF_THERMOSTAT_TYPE, + CONF_THERMOSTAT_CENTRAL_CONFIG, + CONF_USE_CENTRAL_BOILER_FEATURE, + overrides, +) + +THRESHOLD_WATT_KILO = 100 + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the VersatileThermostat sensors with config flow.""" + _LOGGER.debug( + "Calling async_setup_entry entry=%s, data=%s", entry.entry_id, entry.data + ) + + unique_id = entry.entry_id + name = entry.data.get(CONF_NAME) + vt_type = entry.data.get(CONF_THERMOSTAT_TYPE) + + entities = None + + if vt_type == CONF_THERMOSTAT_CENTRAL_CONFIG: + if entry.data.get(CONF_USE_CENTRAL_BOILER_FEATURE): + entities = [ + NbActiveDeviceForBoilerSensor(hass, unique_id, name, entry.data) + ] + else: + entities = [ + LastTemperatureSensor(hass, unique_id, name, entry.data), + LastExtTemperatureSensor(hass, unique_id, name, entry.data), + TemperatureSlopeSensor(hass, unique_id, name, entry.data), + EMATemperatureSensor(hass, unique_id, name, entry.data), + ] + if entry.data.get(CONF_DEVICE_POWER): + entities.append(EnergySensor(hass, unique_id, name, entry.data)) + if entry.data.get(CONF_THERMOSTAT_TYPE) in [ + CONF_THERMOSTAT_SWITCH, + CONF_THERMOSTAT_VALVE, + ]: + entities.append(MeanPowerSensor(hass, unique_id, name, entry.data)) + + if entry.data.get(CONF_PROP_FUNCTION) == PROPORTIONAL_FUNCTION_TPI: + entities.append(OnPercentSensor(hass, unique_id, name, entry.data)) + entities.append(OnTimeSensor(hass, unique_id, name, entry.data)) + entities.append(OffTimeSensor(hass, unique_id, name, entry.data)) + + if entry.data.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_VALVE: + entities.append(ValveOpenPercentSensor(hass, unique_id, name, entry.data)) + + if entry.data.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_CLIMATE: + entities.append( + RegulatedTemperatureSensor(hass, unique_id, name, entry.data) + ) + + if entities: + async_add_entities(entities, True) + + +class EnergySensor(VersatileThermostatBaseEntity, SensorEntity): + """Representation of a Energy sensor which exposes the energy""" + + def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: + """Initialize the energy sensor""" + super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) + self._attr_name = "Energy" + self._attr_unique_id = f"{self._device_name}_energy" + + @callback + async def async_my_climate_changed(self, event: Event = None): + """Called when my climate have change""" + _LOGGER.debug("%s - climate state change", self._attr_unique_id) + + energy = self.my_climate.total_energy + if energy is None: + return + + if math.isnan(energy) or math.isinf(energy): + raise ValueError(f"Sensor has illegal state {self.my_climate.total_energy}") + + old_state = self._attr_native_value + self._attr_native_value = round(energy, self.suggested_display_precision) + if old_state != self._attr_native_value: + self.async_write_ha_state() + return + + @property + def icon(self) -> str | None: + return "mdi:lightning-bolt" + + @property + def device_class(self) -> SensorDeviceClass | None: + return SensorDeviceClass.ENERGY + + @property + def state_class(self) -> SensorStateClass | None: + return SensorStateClass.TOTAL_INCREASING + + @property + def native_unit_of_measurement(self) -> str | None: + if not self.my_climate: + return None + + if self.my_climate.device_power > THRESHOLD_WATT_KILO: + return UnitOfEnergy.WATT_HOUR + else: + return UnitOfEnergy.KILO_WATT_HOUR + + @property + def suggested_display_precision(self) -> int | None: + """Return the suggested number of decimal digits for display.""" + return 3 + + +class MeanPowerSensor(VersatileThermostatBaseEntity, SensorEntity): + """Representation of a power sensor which exposes the mean power in a cycle""" + + def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: + """Initialize the energy sensor""" + super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) + self._attr_name = "Mean power cycle" + self._attr_unique_id = f"{self._device_name}_mean_power_cycle" + + @callback + async def async_my_climate_changed(self, event: Event = None): + """Called when my climate have change""" + _LOGGER.debug("%s - climate state change", self._attr_unique_id) + + if math.isnan(float(self.my_climate.mean_cycle_power)) or math.isinf( + self.my_climate.mean_cycle_power + ): + raise ValueError( + f"Sensor has illegal state {self.my_climate.mean_cycle_power}" + ) + + old_state = self._attr_native_value + self._attr_native_value = round( + self.my_climate.mean_cycle_power, self.suggested_display_precision + ) + if old_state != self._attr_native_value: + self.async_write_ha_state() + return + + @property + def icon(self) -> str | None: + return "mdi:flash-outline" + + @property + def device_class(self) -> SensorDeviceClass | None: + return SensorDeviceClass.POWER + + @property + def state_class(self) -> SensorStateClass | None: + return SensorStateClass.MEASUREMENT + + @property + def native_unit_of_measurement(self) -> str | None: + if not self.my_climate: + return None + + if self.my_climate.device_power > THRESHOLD_WATT_KILO: + return UnitOfPower.WATT + else: + return UnitOfPower.KILO_WATT + + @property + def suggested_display_precision(self) -> int | None: + """Return the suggested number of decimal digits for display.""" + return 3 + + +class OnPercentSensor(VersatileThermostatBaseEntity, SensorEntity): + """Representation of a on percent sensor which exposes the on_percent in a cycle""" + + def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: + """Initialize the energy sensor""" + super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) + self._attr_name = "Power percent" + self._attr_unique_id = f"{self._device_name}_power_percent" + + @callback + async def async_my_climate_changed(self, event: Event = None): + """Called when my climate have change""" + _LOGGER.debug("%s - climate state change", self._attr_unique_id) + + on_percent = ( + float(self.my_climate.proportional_algorithm.on_percent) + if self.my_climate and self.my_climate.proportional_algorithm + else None + ) + if on_percent is None: + return + + if math.isnan(on_percent) or math.isinf(on_percent): + raise ValueError(f"Sensor has illegal state {on_percent}") + + old_state = self._attr_native_value + self._attr_native_value = round( + on_percent * 100.0, self.suggested_display_precision + ) + if old_state != self._attr_native_value: + self.async_write_ha_state() + return + + @property + def icon(self) -> str | None: + return "mdi:meter-electric-outline" + + @property + def device_class(self) -> SensorDeviceClass | None: + return SensorDeviceClass.POWER_FACTOR + + @property + def state_class(self) -> SensorStateClass | None: + return SensorStateClass.MEASUREMENT + + @property + def native_unit_of_measurement(self) -> str | None: + return PERCENTAGE + + @property + def suggested_display_precision(self) -> int | None: + """Return the suggested number of decimal digits for display.""" + return 1 + + +class ValveOpenPercentSensor(VersatileThermostatBaseEntity, SensorEntity): + """Representation of a on percent sensor which exposes the on_percent in a cycle""" + + def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: + """Initialize the energy sensor""" + super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) + self._attr_name = "Valve open percent" + self._attr_unique_id = f"{self._device_name}_valve_open_percent" + + @callback + async def async_my_climate_changed(self, event: Event = None): + """Called when my climate have change""" + _LOGGER.debug("%s - climate state change", self._attr_unique_id) + + old_state = self._attr_native_value + self._attr_native_value = self.my_climate.valve_open_percent + if old_state != self._attr_native_value: + self.async_write_ha_state() + return + + @property + def icon(self) -> str | None: + return "mdi:pipe-valve" + + @property + def device_class(self) -> SensorDeviceClass | None: + return SensorDeviceClass.POWER_FACTOR + + @property + def state_class(self) -> SensorStateClass | None: + return SensorStateClass.MEASUREMENT + + @property + def native_unit_of_measurement(self) -> str | None: + return PERCENTAGE + + @property + def suggested_display_precision(self) -> int | None: + """Return the suggested number of decimal digits for display.""" + return 0 + + +class OnTimeSensor(VersatileThermostatBaseEntity, SensorEntity): + """Representation of a on time sensor which exposes the on_time_sec in a cycle""" + + def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: + """Initialize the energy sensor""" + super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) + self._attr_name = "On time" + self._attr_unique_id = f"{self._device_name}_on_time" + + @callback + async def async_my_climate_changed(self, event: Event = None): + """Called when my climate have change""" + _LOGGER.debug("%s - climate state change", self._attr_unique_id) + + on_time = ( + float(self.my_climate.proportional_algorithm.on_time_sec) + if self.my_climate and self.my_climate.proportional_algorithm + else None + ) + + if on_time is None: + return + + if math.isnan(on_time) or math.isinf(on_time): + raise ValueError(f"Sensor has illegal state {on_time}") + + old_state = self._attr_native_value + self._attr_native_value = round(on_time) + if old_state != self._attr_native_value: + self.async_write_ha_state() + return + + @property + def icon(self) -> str | None: + return "mdi:timer-play" + + @property + def device_class(self) -> SensorDeviceClass | None: + return SensorDeviceClass.DURATION + + @property + def state_class(self) -> SensorStateClass | None: + return SensorStateClass.MEASUREMENT + + @property + def native_unit_of_measurement(self) -> str | None: + return UnitOfTime.SECONDS + + +class OffTimeSensor(VersatileThermostatBaseEntity, SensorEntity): + """Representation of a on time sensor which exposes the off_time_sec in a cycle""" + + def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: + """Initialize the energy sensor""" + super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) + self._attr_name = "Off time" + self._attr_unique_id = f"{self._device_name}_off_time" + + @callback + async def async_my_climate_changed(self, event: Event = None): + """Called when my climate have change""" + _LOGGER.debug("%s - climate state change", self._attr_unique_id) + + off_time = ( + float(self.my_climate.proportional_algorithm.off_time_sec) + if self.my_climate and self.my_climate.proportional_algorithm + else None + ) + if off_time is None: + return + + if math.isnan(off_time) or math.isinf(off_time): + raise ValueError(f"Sensor has illegal state {off_time}") + + old_state = self._attr_native_value + self._attr_native_value = round(off_time) + if old_state != self._attr_native_value: + self.async_write_ha_state() + return + + @property + def icon(self) -> str | None: + return "mdi:timer-off-outline" + + @property + def device_class(self) -> SensorDeviceClass | None: + return SensorDeviceClass.DURATION + + @property + def state_class(self) -> SensorStateClass | None: + return SensorStateClass.MEASUREMENT + + @property + def native_unit_of_measurement(self) -> str | None: + return UnitOfTime.SECONDS + + +class LastTemperatureSensor(VersatileThermostatBaseEntity, SensorEntity): + """Representation of a last temperature datetime sensor""" + + def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: + """Initialize the last temperature datetime sensor""" + super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) + self._attr_name = "Last temperature date" + self._attr_unique_id = f"{self._device_name}_last_temp_datetime" + + @callback + async def async_my_climate_changed(self, event: Event = None): + """Called when my climate have change""" + _LOGGER.debug("%s - climate state change", self._attr_unique_id) + + old_state = self._attr_native_value + self._attr_native_value = self.my_climate.last_temperature_measure + if old_state != self._attr_native_value: + self.async_write_ha_state() + return + + @property + def icon(self) -> str | None: + return "mdi:home-clock" + + @property + def device_class(self) -> SensorDeviceClass | None: + return SensorDeviceClass.TIMESTAMP + + +class LastExtTemperatureSensor(VersatileThermostatBaseEntity, SensorEntity): + """Representation of a last external temperature datetime sensor""" + + def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: + """Initialize the last temperature datetime sensor""" + super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) + self._attr_name = "Last external temperature date" + self._attr_unique_id = f"{self._device_name}_last_ext_temp_datetime" + + @callback + async def async_my_climate_changed(self, event: Event = None): + """Called when my climate have change""" + _LOGGER.debug("%s - climate state change", self._attr_unique_id) + + old_state = self._attr_native_value + self._attr_native_value = self.my_climate.last_ext_temperature_measure + if old_state != self._attr_native_value: + self.async_write_ha_state() + return + + @property + def icon(self) -> str | None: + return "mdi:sun-clock" + + @property + def device_class(self) -> SensorDeviceClass | None: + return SensorDeviceClass.TIMESTAMP + + +class TemperatureSlopeSensor(VersatileThermostatBaseEntity, SensorEntity): + """Representation of a sensor which exposes the temperature slope curve""" + + def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: + """Initialize the slope sensor""" + super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) + self._attr_name = "Temperature slope" + self._attr_unique_id = f"{self._device_name}_temperature_slope" + + @callback + async def async_my_climate_changed(self, event: Event = None): + """Called when my climate have change""" + _LOGGER.debug("%s - climate state change", self._attr_unique_id) + + last_slope = self.my_climate.last_temperature_slope + if last_slope is None: + return + + if math.isnan(last_slope) or math.isinf(last_slope): + raise ValueError(f"Sensor has illegal state {last_slope}") + + old_state = self._attr_native_value + self._attr_native_value = round(last_slope, self.suggested_display_precision) + if old_state != self._attr_native_value: + self.async_write_ha_state() + return + + @property + def icon(self) -> str | None: + if self._attr_native_value is None or self._attr_native_value == 0: + return "mdi:thermometer" + elif self._attr_native_value > 0: + return "mdi:thermometer-chevron-up" + else: + return "mdi:thermometer-chevron-down" + + @property + def state_class(self) -> SensorStateClass | None: + return SensorStateClass.MEASUREMENT + + @property + def native_unit_of_measurement(self) -> str | None: + if not self.my_climate: + return None + + return self.my_climate.temperature_unit + "/hour" + + @property + def suggested_display_precision(self) -> int | None: + """Return the suggested number of decimal digits for display.""" + return 2 + + +class RegulatedTemperatureSensor(VersatileThermostatBaseEntity, SensorEntity): + """Representation of a Energy sensor which exposes the energy""" + + def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: + """Initialize the regulated temperature sensor""" + super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) + self._attr_name = "Regulated temperature" + self._attr_unique_id = f"{self._device_name}_regulated_temperature" + + @callback + async def async_my_climate_changed(self, event: Event = None): + """Called when my climate have change""" + _LOGGER.debug("%s - climate state change", self._attr_unique_id) + + new_temp = self.my_climate.regulated_target_temp + if new_temp is None: + return + + if math.isnan(new_temp) or math.isinf(new_temp): + raise ValueError(f"Sensor has illegal state {new_temp}") + + old_state = self._attr_native_value + self._attr_native_value = round(new_temp, self.suggested_display_precision) + if old_state != self._attr_native_value: + self.async_write_ha_state() + return + + @property + def icon(self) -> str | None: + return "mdi:thermometer-auto" + + @property + def device_class(self) -> SensorDeviceClass | None: + return SensorDeviceClass.TEMPERATURE + + @property + def state_class(self) -> SensorStateClass | None: + return SensorStateClass.MEASUREMENT + + @property + def native_unit_of_measurement(self) -> str | None: + if not self.my_climate: + return UnitOfTemperature.CELSIUS + return self.my_climate.temperature_unit + + @property + def suggested_display_precision(self) -> int | None: + """Return the suggested number of decimal digits for display.""" + return 1 + + +class EMATemperatureSensor(VersatileThermostatBaseEntity, SensorEntity): + """Representation of a Exponential Moving Average temp""" + + def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: + """Initialize the regulated temperature sensor""" + super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) + self._attr_name = "EMA temperature" + self._attr_unique_id = f"{self._device_name}_ema_temperature" + + @callback + async def async_my_climate_changed(self, event: Event = None): + """Called when my climate have change""" + _LOGGER.debug("%s - climate state change", self._attr_unique_id) + + new_ema = self.my_climate.ema_temperature + if new_ema is None: + return + + if math.isnan(new_ema) or math.isinf(new_ema): + raise ValueError(f"Sensor has illegal state {new_ema}") + + old_state = self._attr_native_value + self._attr_native_value = new_ema + if old_state != self._attr_native_value: + self.async_write_ha_state() + return + + @property + def icon(self) -> str | None: + return "mdi:thermometer-lines" + + @property + def device_class(self) -> SensorDeviceClass | None: + return SensorDeviceClass.TEMPERATURE + + @property + def state_class(self) -> SensorStateClass | None: + return SensorStateClass.MEASUREMENT + + @property + def native_unit_of_measurement(self) -> str | None: + if not self.my_climate: + return UnitOfTemperature.CELSIUS + return self.my_climate.temperature_unit + + @property + def suggested_display_precision(self) -> int | None: + """Return the suggested number of decimal digits for display.""" + return 2 + + +class NbActiveDeviceForBoilerSensor(SensorEntity): + """Representation of the threshold of the number of VTherm + which should be active to activate the boiler""" + + def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: + """Initialize the energy sensor""" + self._hass = hass + self._config_id = unique_id + self._device_name = entry_infos.get(CONF_NAME) + self._attr_name = "Nb device active for boiler" + self._attr_unique_id = "nb_device_active_boiler" + self._attr_value = self._attr_native_value = None # default value + self._entities = [] + + @property + def icon(self) -> str | None: + return "mdi:heat-wave" + + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self._config_id)}, + name=self._device_name, + manufacturer=DEVICE_MANUFACTURER, + model=DOMAIN, + ) + + @property + def state_class(self) -> SensorStateClass | None: + return SensorStateClass.MEASUREMENT + + @property + def suggested_display_precision(self) -> int | None: + """Return the suggested number of decimal digits for display.""" + return 0 + + @overrides + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + + api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(self._hass) + api.register_nb_device_active_boiler(self) + + @callback + async def _async_startup_internal(*_): + _LOGGER.debug("%s - Calling async_startup_internal", self) + await self.listen_vtherms_entities() + + if self.hass.state == CoreState.running: + await _async_startup_internal() + else: + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, _async_startup_internal + ) + + async def listen_vtherms_entities(self): + """Initialize the listening of state change of VTherms""" + + # Listen to all VTherm state change + self._entities = [] + underlying_entities_id = [] + + component: EntityComponent[ClimateEntity] = self.hass.data[CLIMATE_DOMAIN] + for entity in component.entities: + if isinstance(entity, BaseThermostat) and entity.is_used_by_central_boiler: + self._entities.append(entity) + for under in entity.underlying_entities: + underlying_entities_id.append(under.entity_id) + if len(underlying_entities_id) > 0: + # Arme l'écoute de la première entité + listener_cancel = async_track_state_change_event( + self._hass, + underlying_entities_id, + self.calculate_nb_active_devices, + ) + _LOGGER.info( + "%s - the underlyings that could controls the central boiler are %s", + self, + underlying_entities_id, + ) + self.async_on_remove(listener_cancel) + else: + _LOGGER.debug("%s - no VTherm could controls the central boiler", self) + + await self.calculate_nb_active_devices(None) + + async def calculate_nb_active_devices(self, _): + """Calculate the number of active VTherm that have an + influence on central boiler""" + + _LOGGER.debug("%s - calculating the number of active VTherm", self) + nb_active = 0 + for entity in self._entities: + _LOGGER.debug( + "Examining the hvac_action of %s", + entity.name, + ) + if ( + entity.hvac_mode in [HVACMode.HEAT, HVACMode.AUTO] + and entity.hvac_action == HVACAction.HEATING + ): + for under in entity.underlying_entities: + nb_active += 1 if under.is_device_active else 0 + + self._attr_native_value = nb_active + self.async_write_ha_state() + + def __str__(self): + return f"VersatileThermostat-{self.name}" diff --git a/config/custom_components/versatile_thermostat/services.yaml b/config/custom_components/versatile_thermostat/services.yaml new file mode 100644 index 0000000..2168fa0 --- /dev/null +++ b/config/custom_components/versatile_thermostat/services.yaml @@ -0,0 +1,186 @@ +reload: + name: Reload + description: Reload all Versatile Thermostat entities. + +set_presence: + name: Set presence + description: Force the presence mode in thermostat + target: + entity: + integration: versatile_thermostat + fields: + presence: + name: Presence + description: Presence setting + required: true + advanced: false + example: "on" + default: "on" + selector: + select: + options: + - "on" + - "off" + - "home" + - "not_home" + +set_preset_temperature: + name: Set temperature preset + description: Change the target temperature of a preset + target: + entity: + integration: versatile_thermostat + fields: + preset: + name: Preset + description: Preset name + required: true + advanced: false + example: "comfort" + selector: + select: + options: + - "eco" + - "comfort" + - "boost" + - "frost" + - "eco_ac" + - "comfort_ac" + - "boost_ac" + temperature: + name: Temperature when present + description: Target temperature for the preset when present + required: false + advanced: false + example: "19.5" + default: "17" + selector: + number: + min: 7 + max: 35 + step: 0.1 + unit_of_measurement: ° + mode: slider + temperature_away: + name: Temperature when not present + description: Target temperature for the preset when not present + required: false + advanced: false + example: "17" + default: "15" + selector: + number: + min: 7 + max: 35 + step: 0.1 + unit_of_measurement: ° + mode: slider + +set_security: + name: Set safety + description: Change the safety parameters + target: + entity: + integration: versatile_thermostat + fields: + delay_min: + name: Delay in minutes + description: Maximum allowed delay in minutes between two temperature mesures + required: false + advanced: false + example: "30" + selector: + number: + min: 0 + max: 9999 + unit_of_measurement: "min" + mode: box + min_on_percent: + name: Minimal on_percent + description: Minimal heating percent value for safety preset activation + required: false + advanced: false + example: "0.5" + default: "0.5" + selector: + number: + min: 0 + max: 1 + step: 0.05 + unit_of_measurement: "%" + mode: slider + default_on_percent: + name: on_percent used in safety mode + description: The default heating percent value in safety preset + required: false + advanced: false + example: "0.1" + default: "0.1" + selector: + number: + min: 0 + max: 1 + step: 0.05 + unit_of_measurement: "%" + mode: slider + +set_window_bypass: + name: Set Window ByPass + description: Bypass the window state to enable heating with window open. + target: + entity: + integration: versatile_thermostat + fields: + window_bypass: + name: Window ByPass + description: ByPass value + required: true + advanced: false + default: true + selector: + boolean: + +set_auto_regulation_mode: + name: Set Auto Regulation mode + description: Change the mode of self-regulation (only for VTherm over climate) + target: + entity: + integration: versatile_thermostat + fields: + auto_regulation_mode: + name: Auto regulation mode + description: Possible values + required: true + advanced: false + default: true + selector: + select: + options: + - "None" + - "Light" + - "Medium" + - "Strong" + - "Slow" + - "Expert" + +set_auto_fan_mode: + name: Set Auto Fan mode + description: Change the mode of auto-fan (only for VTherm over climate) + target: + entity: + integration: versatile_thermostat + fields: + auto_fan_mode: + name: Auto fan mode + description: Possible values + required: true + advanced: false + default: true + selector: + select: + options: + - "None" + - "Low" + - "Medium" + - "High" + - "Turbo" diff --git a/config/custom_components/versatile_thermostat/strings.json b/config/custom_components/versatile_thermostat/strings.json new file mode 100644 index 0000000..e6a2275 --- /dev/null +++ b/config/custom_components/versatile_thermostat/strings.json @@ -0,0 +1,581 @@ +{ + "title": "Versatile Thermostat configuration", + "config": { + "flow_title": "Versatile Thermostat configuration", + "step": { + "user": { + "title": "Type of Versatile Thermostat", + "data": { + "thermostat_type": "Thermostat type" + }, + "data_description": { + "thermostat_type": "Only one central configuration type is possible" + } + }, + "menu": { + "title": "Menu", + "description": "Configure your thermostat. You will be able to finalize the configuration when all required parameters are entered.", + "menu_options": { + "main": "Main attributes", + "central_boiler": "Central boiler", + "type": "Underlyings", + "tpi": "TPI parameters", + "features": "Features", + "presets": "Presets", + "window": "Window detection", + "motion": "Motion detection", + "power": "Power management", + "presence": "Presence detection", + "advanced": "Advanced parameters", + "finalize": "All done", + "configuration_not_complete": "Configuration not complete" + } + }, + "main": { + "title": "Add new Versatile Thermostat", + "description": "Main mandatory attributes", + "data": { + "name": "Name", + "thermostat_type": "Thermostat type", + "temperature_sensor_entity_id": "Room temperature", + "last_seen_temperature_sensor_entity_id": "Last seen room temperature datetime", + "external_temperature_sensor_entity_id": "Outdoor temperature sensor entity id", + "cycle_min": "Cycle duration (minutes)", + "temp_min": "Minimum temperature allowed", + "temp_max": "Maximum temperature allowed", + "step_temperature": "Temperature step", + "device_power": "Device power", + "use_central_mode": "Enable the control by central entity (requires central config). Check to enable the control of the VTherm with the select central_mode entities.", + "use_main_central_config": "Use additional central main configuration. Check to use the central main configuration (outdoor temperature, min, max, step, ...).", + "used_by_controls_central_boiler": "Used by central boiler. Check if this VTherm should have control on the central boiler" + }, + "data_description": { + "temperature_sensor_entity_id": "Room temperature sensor entity id", + "last_seen_temperature_sensor_entity_id": "Last seen room temperature sensor entity id. Should be datetime sensor", + "external_temperature_sensor_entity_id": "Outdoor temperature sensor entity id. Not used if central configuration is selected" + } + }, + "features": { + "title": "Features", + "description": "Thermostat features", + "data": { + "use_window_feature": "Use window detection", + "use_motion_feature": "Use motion detection", + "use_power_feature": "Use power management", + "use_presence_feature": "Use presence detection", + "use_central_boiler_feature": "Use a central boiler. Check to add a control to your central boiler. You will have to configure the VTherm which will have a control of the central boiler after seecting this checkbox to take effect. If one VTherm requires heating, the boiler will be turned on. If no VTherm requires heating, the boiler will be turned off. Commands for turning on/off the central boiler are given in the related configuration page" + } + }, + "type": { + "title": "Linked entities", + "description": "Linked entities attributes", + "data": { + "heater_entity_id": "1st heater switch", + "heater_entity2_id": "2nd heater switch", + "heater_entity3_id": "3rd heater switch", + "heater_entity4_id": "4th heater switch", + "heater_keep_alive": "Switch keep-alive interval in seconds", + "proportional_function": "Algorithm", + "climate_entity_id": "1st underlying climate", + "climate_entity2_id": "2nd underlying climate", + "climate_entity3_id": "3rd underlying climate", + "climate_entity4_id": "4th underlying climate", + "ac_mode": "AC mode", + "valve_entity_id": "1st valve number", + "valve_entity2_id": "2nd valve number", + "valve_entity3_id": "3rd valve number", + "valve_entity4_id": "4th valve number", + "auto_regulation_mode": "Self-regulation", + "auto_regulation_dtemp": "Regulation threshold", + "auto_regulation_periode_min": "Regulation minimum period", + "auto_regulation_use_device_temp": "Use internal temperature of the underlying", + "inverse_switch_command": "Inverse switch command", + "auto_fan_mode": " Auto fan mode" + }, + "data_description": { + "heater_entity_id": "Mandatory heater entity id", + "heater_entity2_id": "Optional 2nd Heater entity id. Leave empty if not required", + "heater_entity3_id": "Optional 3rd Heater entity id. Leave empty if not required", + "heater_entity4_id": "Optional 4th Heater entity id. Leave empty if not required", + "heater_keep_alive": "Optional heater switch state refresh interval. Leave empty if not required.", + "proportional_function": "Algorithm to use (TPI is the only one for now)", + "climate_entity_id": "Underlying climate entity id", + "climate_entity2_id": "2nd underlying climate entity id", + "climate_entity3_id": "3rd underlying climate entity id", + "climate_entity4_id": "4th underlying climate entity id", + "ac_mode": "Use the Air Conditioning (AC) mode", + "valve_entity_id": "1st valve number entity id", + "valve_entity2_id": "2nd valve number entity id", + "valve_entity3_id": "3rd valve number entity id", + "valve_entity4_id": "4th valve number entity id", + "auto_regulation_mode": "Auto adjustment of the target temperature", + "auto_regulation_dtemp": "The threshold in ° (or % for valve) under which the temperature change will not be sent", + "auto_regulation_periode_min": "Duration in minutes between two regulation update", + "auto_regulation_use_device_temp": "Use the eventual internal temperature sensor of the underlying to speedup the self-regulation", + "inverse_switch_command": "For switch with pilot wire and diode you may need to inverse the command", + "auto_fan_mode": " Automatically activate fan when huge heating/cooling is necessary" + } + }, + "tpi": { + "title": "TPI", + "description": "Time Proportional Integral attributes", + "data": { + "tpi_coef_int": "coef_int", + "tpi_coef_ext": "coef_ext", + "use_tpi_central_config": "Use central TPI configuration" + }, + "data_description": { + "tpi_coef_int": "Coefficient to use for internal temperature delta", + "tpi_coef_ext": "Coefficient to use for external temperature delta", + "use_tpi_central_config": "Check to use the central TPI configuration. Uncheck to use a specific TPI configuration for this VTherm" + } + }, + "presets": { + "title": "Presets", + "description": "Select if the thermostat will use central preset - deselect for the thermostat to have its own presets", + "data": { + "use_presets_central_config": "Use central presets configuration" + } + }, + "window": { + "title": "Window management", + "description": "Open window management.\nYou can also configure automatic window open detection based on temperature decrease", + "data": { + "window_sensor_entity_id": "Window sensor entity id", + "window_delay": "Window sensor delay (seconds)", + "window_auto_open_threshold": "Temperature decrease threshold for automatic window open detection (in °/hours)", + "window_auto_close_threshold": "Temperature increase threshold for end of automatic detection (in °/hours)", + "window_auto_max_duration": "Maximum duration of automatic window open detection (in min)", + "use_window_central_config": "Use central window configuration", + "window_action": "Action" + }, + "data_description": { + "window_sensor_entity_id": "Leave empty if no window sensor should be used and to use the automatic detection", + "window_delay": "The delay in seconds before sensor detection is taken into account", + "window_auto_open_threshold": "Recommended value: between 3 and 10. Leave empty if automatic window open detection is not used", + "window_auto_close_threshold": "Recommended value: 0. Leave empty if automatic window open detection is not used", + "window_auto_max_duration": "Recommended value: 60 (one hour). Leave empty if automatic window open detection is not used", + "use_window_central_config": "Select to use the central window configuration. Deselect to use a specific window configuration for this VTherm", + "window_action": "Action to perform if window is deteted as open" + } + }, + "motion": { + "title": "Motion management", + "description": "Motion sensor management. Preset can switch automatically depending on motion detection\nmotion_preset and no_motion_preset should be set to the corresponding preset name", + "data": { + "motion_sensor_entity_id": "Motion sensor entity id", + "motion_delay": "Activation delay", + "motion_off_delay": "Deactivation delay", + "motion_preset": "Motion preset", + "no_motion_preset": "No motion preset", + "use_motion_central_config": "Use central motion configuration" + }, + "data_description": { + "motion_sensor_entity_id": "The entity id of the motion sensor", + "motion_delay": "Motion activation delay (seconds)", + "motion_off_delay": "Motion deactivation delay (seconds)", + "motion_preset": "Preset to use when motion is detected", + "no_motion_preset": "Preset to use when no motion is detected", + "use_motion_central_config": "Check to use the central motion configuration. Uncheck to use a specific motion configuration for this VTherm" + } + }, + "power": { + "title": "Power management", + "description": "Power management attributes.\nGives the power and max power sensor of your home.\nSpecify the power consumption of the heater when on.\nAll sensors and device power should use the same unit (kW or W).", + "data": { + "power_sensor_entity_id": "Power", + "max_power_sensor_entity_id": "Max power", + "power_temp": "Shedding temperature", + "use_power_central_config": "Use central power configuration" + }, + "data_description": { + "power_sensor_entity_id": "Power sensor entity id", + "max_power_sensor_entity_id": "Max power sensor entity id", + "power_temp": "Temperature for Power shedding", + "use_power_central_config": "Check to use the central power configuration. Uncheck to use a specific power configuration for this VTherm" + } + }, + "presence": { + "title": "Presence management", + "description": "Presence management attributes.\nGives the a presence sensor of your home (true is someone is present) and give the corresponding temperature preset setting.", + "data": { + "presence_sensor_entity_id": "Presence sensor", + "use_presence_central_config": "Use central presence temperature configuration. Deselect to use specific temperature entities" + }, + "data_description": { + "presence_sensor_entity_id": "Presence sensor entity id" + } + }, + "advanced": { + "title": "Advanced parameters", + "description": "Configuration of advanced parameters. Leave the default values if you don't know what you are doing.\nThese parameters can lead to very poor temperature control or bad power regulation.", + "data": { + "minimal_activation_delay": "Minimum activation delay", + "security_delay_min": "Safety delay (in minutes)", + "security_min_on_percent": "Minimum power percent to enable safety mode", + "security_default_on_percent": "Power percent to use in safety mode", + "use_advanced_central_config": "Use central advanced configuration" + }, + "data_description": { + "minimal_activation_delay": "Delay in seconds under which the equipment will not be activated", + "security_delay_min": "Maximum allowed delay in minutes between two temperature measurements. Above this delay the thermostat will turn to a safety off state", + "security_min_on_percent": "Minimum heating percent value for safety preset activation. Below this amount of power percent the thermostat won't go into safety preset", + "security_default_on_percent": "The default heating power percent value in safety preset. Set to 0 to switch off heater in safety preset", + "use_advanced_central_config": "Check to use the central advanced configuration. Uncheck to use a specific advanced configuration for this VTherm" + } + } + }, + "error": { + "unknown": "Unexpected error", + "unknown_entity": "Unknown entity id", + "window_open_detection_method": "Only one window open detection method should be used. Use either window sensor or automatic detection through temperature threshold but not both", + "no_central_config": "You cannot select 'use central configuration' because no central configuration was found. You need to create a Versatile Thermostat of type 'Central Configuration' to use it." + }, + "abort": { + "already_configured": "Device is already configured" + } + }, + "options": { + "flow_title": "Versatile Thermostat configuration", + "step": { + "user": { + "title": "Type - {name}", + "data": { + "thermostat_type": "Thermostat type" + }, + "data_description": { + "thermostat_type": "Only one central configuration type is possible" + } + }, + "menu": { + "title": "Menu", + "description": "Configure your thermostat. You will be able to finalize the configuration when all required parameters are entered.", + "menu_options": { + "main": "Main attributes", + "central_boiler": "Central boiler", + "type": "Underlyings", + "tpi": "TPI parameters", + "features": "Features", + "presets": "Presets", + "window": "Window detection", + "motion": "Motion detection", + "power": "Power management", + "presence": "Presence detection", + "advanced": "Advanced parameters", + "finalize": "All done", + "configuration_not_complete": "Configuration not complete" + } + }, + "main": { + "title": "Main - {name}", + "description": "Main mandatory attributes", + "data": { + "name": "Name", + "thermostat_type": "Thermostat type", + "temperature_sensor_entity_id": "Room temperature", + "last_seen_temperature_sensor_entity_id": "Last seen room temperature datetime", + "external_temperature_sensor_entity_id": "Outdoor temperature sensor entity id", + "cycle_min": "Cycle duration (minutes)", + "temp_min": "Minimum temperature allowed", + "temp_max": "Maximum temperature allowed", + "step_temperature": "Temperature step", + "device_power": "Device power", + "use_central_mode": "Enable the control by central entity (requires central config). Check to enable the control of the VTherm with the select central_mode entities.", + "use_main_central_config": "Use additional central main configuration. Check to use the central main configuration (outdoor temperature, min, max, step, ...).", + "used_by_controls_central_boiler": "Used by central boiler. Check if this VTherm should have control on the central boiler" + }, + "data_description": { + "temperature_sensor_entity_id": "Room temperature sensor entity id", + "last_seen_temperature_sensor_entity_id": "Last seen room temperature sensor entity id. Should be datetime sensor", + "external_temperature_sensor_entity_id": "Outdoor temperature sensor entity id. Not used if central configuration is selected" + } + }, + "features": { + "title": "Features - {name}", + "description": "Thermostat features", + "data": { + "use_window_feature": "Use window detection", + "use_motion_feature": "Use motion detection", + "use_power_feature": "Use power management", + "use_presence_feature": "Use presence detection", + "use_central_boiler_feature": "Use a central boiler. Check to add a control to your central boiler. You will have to configure the VTherm which will have a control of the central boiler after seecting this checkbox to take effect. If one VTherm requires heating, the boiler will be turned on. If no VTherm requires heating, the boiler will be turned off. Commands for turning on/off the central boiler are given in the related configuration page" + } + }, + "type": { + "title": "Entities - {name}", + "description": "Linked entities attributes", + "data": { + "heater_entity_id": "1st heater switch", + "heater_entity2_id": "2nd heater switch", + "heater_entity3_id": "3rd heater switch", + "heater_entity4_id": "4th heater switch", + "heater_keep_alive": "Switch keep-alive interval in seconds", + "proportional_function": "Algorithm", + "climate_entity_id": "1st underlying climate", + "climate_entity2_id": "2nd underlying climate", + "climate_entity3_id": "3rd underlying climate", + "climate_entity4_id": "4th underlying climate", + "ac_mode": "AC mode", + "valve_entity_id": "1st valve number", + "valve_entity2_id": "2nd valve number", + "valve_entity3_id": "3rd valve number", + "valve_entity4_id": "4th valve number", + "auto_regulation_mode": "Self-regulation", + "auto_regulation_dtemp": "Regulation threshold", + "auto_regulation_periode_min": "Regulation minimum period", + "auto_regulation_use_device_temp": "Use internal temperature of the underlying", + "inverse_switch_command": "Inverse switch command", + "auto_fan_mode": " Auto fan mode" + }, + "data_description": { + "heater_entity_id": "Mandatory heater entity id", + "heater_entity2_id": "Optional 2nd Heater entity id. Leave empty if not used", + "heater_entity3_id": "Optional 3rd Heater entity id. Leave empty if not used", + "heater_entity4_id": "Optional 4th Heater entity id. Leave empty if not used", + "heater_keep_alive": "Optional heater switch state refresh interval. Leave empty if not required.", + "proportional_function": "Algorithm to use (TPI is the only one for now)", + "climate_entity_id": "Underlying climate entity id", + "climate_entity2_id": "2nd underlying climate entity id", + "climate_entity3_id": "3rd underlying climate entity id", + "climate_entity4_id": "4th underlying climate entity id", + "ac_mode": "Use the Air Conditioning (AC) mode", + "valve_entity_id": "1st valve number entity id", + "valve_entity2_id": "2nd valve number entity id", + "valve_entity3_id": "3rd valve number entity id", + "valve_entity4_id": "4th valve number entity id", + "auto_regulation_mode": "Auto adjustment of the target temperature", + "auto_regulation_dtemp": "The threshold in ° (or % for valve) under which the temperature change will not be sent", + "auto_regulation_periode_min": "Duration in minutes between two regulation update", + "auto_regulation_use_device_temp": "Use the eventual internal temperature sensor of the underlying to speedup the self-regulation", + "inverse_switch_command": "For switch with pilot wire and diode you may need to invert the command", + "auto_fan_mode": " Automatically activate fan when huge heating/cooling is necessary" + } + }, + "tpi": { + "title": "TPI - {name}", + "description": "Time Proportional Integral attributes", + "data": { + "tpi_coef_int": "coef_int", + "tpi_coef_ext": "coef_ext", + "use_tpi_central_config": "Use central TPI configuration" + }, + "data_description": { + "tpi_coef_int": "Coefficient to use for internal temperature delta", + "tpi_coef_ext": "Coefficient to use for external temperature delta", + "use_tpi_central_config": "Check to use the central TPI configuration. Uncheck to use a specific TPI configuration for this VTherm" + } + }, + "presets": { + "title": "Presets - {name}", + "description": "Check if the thermostat will use central presets. Uncheck and the thermostat will have its own preset entities", + "data": { + "use_presets_central_config": "Use central presets configuration" + } + }, + "window": { + "title": "Window - {name}", + "description": "Open window management.\nYou can also configure automatic window open detection based on temperature decrease", + "data": { + "window_sensor_entity_id": "Window sensor entity id", + "window_delay": "Window sensor delay (seconds)", + "window_auto_open_threshold": "Temperature decrease threshold for automatic window open detection (in °/hours)", + "window_auto_close_threshold": "Temperature increase threshold for end of automatic detection (in °/hours)", + "window_auto_max_duration": "Maximum duration of automatic window open detection (in min)", + "use_window_central_config": "Use central window configuration", + "window_action": "Action" + }, + "data_description": { + "window_sensor_entity_id": "Leave empty if no window sensor should be used and to use the automatic detection", + "window_delay": "The delay in seconds before sensor detection is taken into account", + "window_auto_open_threshold": "Recommended value: between 3 and 10. Leave empty if automatic window open detection is not used", + "window_auto_close_threshold": "Recommended value: 0. Leave empty if automatic window open detection is not used", + "window_auto_max_duration": "Recommended value: 60 (one hour). Leave empty if automatic window open detection is not used", + "use_window_central_config": "Check to use the central window configuration. Uncheck to use a specific window configuration for this VTherm", + "window_action": "Action to do if window is deteted as open" + } + }, + "motion": { + "title": "Motion - {name}", + "description": "Motion management. Preset can switch automatically depending of a motion detection\nmotion_preset and no_motion_preset should be set to the corresponding preset name", + "data": { + "motion_sensor_entity_id": "Motion sensor entity id", + "motion_delay": "Activation delay", + "motion_off_delay": "Deactivation delay", + "motion_preset": "Motion preset", + "no_motion_preset": "No motion preset", + "use_motion_central_config": "Use central motion configuration" + }, + "data_description": { + "motion_sensor_entity_id": "The entity id of the motion sensor", + "motion_delay": "Motion activation delay (seconds)", + "motion_off_delay": "Motion deactivation delay (seconds)", + "motion_preset": "Preset to use when motion is detected", + "no_motion_preset": "Preset to use when no motion is detected", + "use_motion_central_config": "Check to use the central motion configuration. Uncheck to use a specific motion configuration for this VTherm" + } + }, + "power": { + "title": "Power - {name}", + "description": "Power management attributes.\nGives the power and max power sensor of your home.\nThen specify the power consumption of the heater when on.\nAll sensors and device power should have the same unit (kW or W).", + "data": { + "power_sensor_entity_id": "Power", + "max_power_sensor_entity_id": "Max power", + "power_temp": "Shedding temperature", + "use_power_central_config": "Use central power configuration" + }, + "data_description": { + "power_sensor_entity_id": "Power sensor entity id", + "max_power_sensor_entity_id": "Max power sensor entity id", + "power_temp": "Temperature for Power shedding", + "use_power_central_config": "Check to use the central power configuration. Uncheck to use a specific power configuration for this VTherm" + } + }, + "presence": { + "title": "Presence - {name}", + "description": "Presence management attributes.\nGives the a presence sensor of your home (true is someone is present) and give the corresponding temperature preset setting.", + "data": { + "presence_sensor_entity_id": "Presence sensor", + "use_presence_central_config": "Use central presence temperature configuration. Uncheck to use specific temperature entities" + }, + "data_description": { + "presence_sensor_entity_id": "Presence sensor entity id" + } + }, + "advanced": { + "title": "Advanced - {name}", + "description": "Advanced parameters. Leave the default values if you don't know what you are doing.\nThese parameters can lead to very poor temperature control or bad power regulation.", + "data": { + "minimal_activation_delay": "Minimum activation delay", + "security_delay_min": "Safety delay (in minutes)", + "security_min_on_percent": "Minimum power percent to enable safety mode", + "security_default_on_percent": "Power percent to use in safety mode", + "use_advanced_central_config": "Use central advanced configuration" + }, + "data_description": { + "minimal_activation_delay": "Delay in seconds under which the equipment will not be activated", + "security_delay_min": "Maximum allowed delay in minutes between two temperature measurements. Above this delay the thermostat will turn to a safety off state", + "security_min_on_percent": "Minimum heating percent value for safety preset activation. Below this amount of power percent the thermostat won't go into safety preset", + "security_default_on_percent": "The default heating power percent value in safety preset. Set to 0 to switch off heater in safety preset", + "use_advanced_central_config": "Check to use the central advanced configuration. Uncheck to use a specific advanced configuration for this VTherm" + } + } + }, + "error": { + "unknown": "Unexpected error", + "unknown_entity": "Unknown entity id", + "window_open_detection_method": "Only one window open detection method should be used. Use either window sensor or automatic detection through temperature threshold but not both", + "no_central_config": "You cannot check 'use central configuration' because no central configuration was found. You need to create a Versatile Thermostat of type 'Central Configuration' to use it.", + "service_configuration_format": "The format of the service configuration is wrong" + }, + "abort": { + "already_configured": "Device is already configured" + } + }, + "selector": { + "thermostat_type": { + "options": { + "thermostat_central_config": "Central configuration", + "thermostat_over_switch": "Thermostat over a switch", + "thermostat_over_climate": "Thermostat over a climate", + "thermostat_over_valve": "Thermostat over a valve" + } + }, + "auto_regulation_mode": { + "options": { + "auto_regulation_slow": "Slow", + "auto_regulation_strong": "Strong", + "auto_regulation_medium": "Medium", + "auto_regulation_light": "Light", + "auto_regulation_expert": "Expert", + "auto_regulation_none": "No auto-regulation" + } + }, + "auto_fan_mode": { + "options": { + "auto_fan_none": "No auto fan", + "auto_fan_low": "Low", + "auto_fan_medium": "Medium", + "auto_fan_high": "High", + "auto_fan_turbo": "Turbo" + } + }, + "window_action": { + "options": { + "window_turn_off": "Turn off", + "window_fan_only": "Fan only", + "window_frost_temp": "Frost protect", + "window_eco_temp": "Eco" + } + }, + "presets": { + "options": { + "frost": "Frost protect", + "eco": "Eco", + "comfort": "Comfort", + "boost": "Boost" + } + } + }, + "entity": { + "climate": { + "versatile_thermostat": { + "state_attributes": { + "preset_mode": { + "state": { + "power": "Shedding", + "security": "Safety", + "none": "Manual" + } + } + } + } + }, + "number": { + "frost_temp": { + "name": "Frost" + }, + "eco_temp": { + "name": "Eco" + }, + "comfort_temp": { + "name": "Comfort" + }, + "boost_temp": { + "name": "Boost" + }, + "frost_ac_temp": { + "name": "Frost ac" + }, + "eco_ac_temp": { + "name": "Eco ac" + }, + "comfort_ac_temp": { + "name": "Comfort ac" + }, + "boost_ac_temp": { + "name": "Boost ac" + }, + "frost_away_temp": { + "name": "Frost away" + }, + "eco_away_temp": { + "name": "Eco away" + }, + "comfort_away_temp": { + "name": "Comfort away" + }, + "boost_away_temp": { + "name": "Boost away" + }, + "eco_ac_away_temp": { + "name": "Eco ac away" + }, + "comfort_ac_away_temp": { + "name": "Comfort ac away" + }, + "boost_ac_away_temp": { + "name": "Boost ac away" + } + } + } +} \ No newline at end of file diff --git a/config/custom_components/versatile_thermostat/thermostat_climate.py b/config/custom_components/versatile_thermostat/thermostat_climate.py new file mode 100644 index 0000000..de2042f --- /dev/null +++ b/config/custom_components/versatile_thermostat/thermostat_climate.py @@ -0,0 +1,1090 @@ +# pylint: disable=line-too-long, too-many-lines +""" A climate over switch classe """ +import logging +from datetime import timedelta, datetime + +from homeassistant.core import HomeAssistant, State, callback +from homeassistant.helpers.event import ( + async_track_state_change_event, + async_track_time_interval, + EventStateChangedData, +) +from homeassistant.helpers.typing import EventType as HASSEventType +from homeassistant.components.climate import ( + HVACAction, + HVACMode, + ClimateEntityFeature, +) + +from .commons import NowClass, round_to_nearest +from .base_thermostat import BaseThermostat, ConfigData +from .pi_algorithm import PITemperatureRegulator + +from .const import ( + overrides, + DOMAIN, + CONF_CLIMATE, + CONF_CLIMATE_2, + CONF_CLIMATE_3, + CONF_CLIMATE_4, + CONF_AUTO_REGULATION_MODE, + CONF_AUTO_REGULATION_NONE, + CONF_AUTO_REGULATION_SLOW, + CONF_AUTO_REGULATION_LIGHT, + CONF_AUTO_REGULATION_MEDIUM, + CONF_AUTO_REGULATION_STRONG, + CONF_AUTO_REGULATION_EXPERT, + CONF_AUTO_REGULATION_DTEMP, + CONF_AUTO_REGULATION_PERIOD_MIN, + CONF_AUTO_REGULATION_USE_DEVICE_TEMP, + CONF_AUTO_FAN_MODE, + CONF_AUTO_FAN_NONE, + CONF_AUTO_FAN_LOW, + CONF_AUTO_FAN_MEDIUM, + CONF_AUTO_FAN_HIGH, + CONF_AUTO_FAN_TURBO, + RegulationParamSlow, + RegulationParamLight, + RegulationParamMedium, + RegulationParamStrong, + AUTO_FAN_DTEMP_THRESHOLD, + AUTO_FAN_DEACTIVATED_MODES, + UnknownEntity, +) + +from .vtherm_api import VersatileThermostatAPI +from .underlyings import UnderlyingClimate + +_LOGGER = logging.getLogger(__name__) + + +class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]): + """Representation of a base class for a Versatile Thermostat over a climate""" + + _auto_regulation_mode: str | None = None + _regulation_algo = None + _regulated_target_temp: float | None = None + _auto_regulation_dtemp: float | None = None + _auto_regulation_period_min: int | None = None + _last_regulation_change: datetime | None = None + # The fan mode configured in configEntry + _auto_fan_mode: str | None = None + # The current fan mode (could be change by service call) + _current_auto_fan_mode: str | None = None + # The fan_mode name depending of the current_mode + _auto_activated_fan_mode: str | None = None + _auto_deactivated_fan_mode: str | None = None + + _entity_component_unrecorded_attributes = ( + BaseThermostat._entity_component_unrecorded_attributes.union( + frozenset( + { + "is_over_climate", + "start_hvac_action_date", + "underlying_climate_0", + "underlying_climate_1", + "underlying_climate_2", + "underlying_climate_3", + "regulation_accumulated_error", + "auto_regulation_mode", + "auto_fan_mode", + "current_auto_fan_mode", + "auto_activated_fan_mode", + "auto_deactivated_fan_mode", + "auto_regulation_use_device_temp", + } + ) + ) + ) + + def __init__( + self, hass: HomeAssistant, unique_id: str, name: str, entry_infos: ConfigData + ): + """Initialize the thermostat over switch.""" + # super.__init__ calls post_init at the end. So it must be called after regulation initialization + super().__init__(hass, unique_id, name, entry_infos) + self._regulated_target_temp = self.target_temperature + self._last_regulation_change = NowClass.get_now(hass) + + @property + def is_over_climate(self) -> bool: + """True if the Thermostat is over_climate""" + return True + + @property + def hvac_action(self) -> HVACAction | None: + """Returns the current hvac_action by checking all hvac_action of the underlyings""" + + # if one not IDLE or OFF -> return it + # else if one IDLE -> IDLE + # else OFF + one_idle = False + for under in self._underlyings: + if (action := under.hvac_action) not in [ + HVACAction.IDLE, + HVACAction.OFF, + ]: + return action + if under.hvac_action == HVACAction.IDLE: + one_idle = True + if one_idle: + return HVACAction.IDLE + return HVACAction.OFF + + @overrides + async def _async_internal_set_temperature(self, temperature: float): + """Set the target temperature and the target temperature of underlying climate if any""" + await super()._async_internal_set_temperature(temperature) + + self._regulation_algo.set_target_temp(self.target_temperature) + await self._send_regulated_temperature(force=True) + + async def _send_regulated_temperature(self, force=False): + """Sends the regulated temperature to all underlying""" + + if self.hvac_mode == HVACMode.OFF: + _LOGGER.debug("%s - don't send regulated temperature cause VTherm is off ") + return + + _LOGGER.info( + "%s - Calling ThermostatClimate._send_regulated_temperature force=%s", + self, + force, + ) + + now: datetime = NowClass.get_now(self._hass) + period = float((now - self._last_regulation_change).total_seconds()) / 60.0 + if not force and period < self._auto_regulation_period_min: + _LOGGER.info( + "%s - period (%.1f) min is < %.0f min -> forget the regulation send", + self, + period, + self._auto_regulation_period_min, + ) + return + + if not self._regulated_target_temp: + self._regulated_target_temp = self.target_temperature + + _LOGGER.info("%s - regulation calculation will be done", self) + + new_regulated_temp = round_to_nearest( + self._regulation_algo.calculate_regulated_temperature( + self.current_temperature, self._cur_ext_temp + ), + self._auto_regulation_dtemp, + ) + dtemp = new_regulated_temp - self._regulated_target_temp + + if not force and abs(dtemp) < self._auto_regulation_dtemp: + _LOGGER.info( + "%s - dtemp (%.1f) is < %.1f -> forget the regulation send", + self, + dtemp, + self._auto_regulation_dtemp, + ) + return + + self._regulated_target_temp = new_regulated_temp + _LOGGER.info( + "%s - Regulated temp have changed to %.1f. Resend it to underlyings", + self, + new_regulated_temp, + ) + + self._last_regulation_change = now + for under in self._underlyings: + # issue 348 - use device temperature if configured as offset + offset_temp = 0 + device_temp = 0 + if ( + # regulation can use the device_temp + self.auto_regulation_use_device_temp + # and we have access to the device temp + and (device_temp := under.underlying_current_temperature) is not None + # and target is not reach (ie we need regulation) + and ( + ( + self.hvac_mode == HVACMode.COOL + and self.target_temperature < self.current_temperature + ) + or ( + self.hvac_mode == HVACMode.HEAT + and self.target_temperature > self.current_temperature + ) + ) + ): + offset_temp = device_temp - self.current_temperature + + target_temp = round_to_nearest(self.regulated_target_temp + offset_temp, self._auto_regulation_dtemp) + + _LOGGER.debug( + "%s - The device offset temp for regulation is %.2f - internal temp is %.2f. New target is %.2f", + self, + offset_temp, + device_temp, + target_temp, + ) + + await under.set_temperature( + target_temp, + self._attr_max_temp, + self._attr_min_temp, + ) + + async def _send_auto_fan_mode(self): + """Send the fan mode if auto_fan_mode and temperature gap is > threshold""" + if not self._auto_fan_mode or not self._auto_activated_fan_mode: + return + + dtemp = ( + self.regulated_target_temp if self.is_regulated else self.target_temperature + ) + if dtemp is None or self.current_temperature is None: + return + + dtemp = dtemp - self.current_temperature + should_activate_auto_fan = ( + dtemp >= AUTO_FAN_DTEMP_THRESHOLD or dtemp <= -AUTO_FAN_DTEMP_THRESHOLD + ) + + # deal with ac / non ac mode + hvac_mode = self.hvac_mode + if ( + (hvac_mode == HVACMode.COOL and dtemp > 0) + or (hvac_mode == HVACMode.HEAT and dtemp < 0) + or (hvac_mode == HVACMode.OFF) + ): + should_activate_auto_fan = False + + if should_activate_auto_fan and self.fan_mode != self._auto_activated_fan_mode: + _LOGGER.info( + "%s - Activate the auto fan mode with %s because delta temp is %.2f", + self, + self._auto_fan_mode, + dtemp, + ) + await self.async_set_fan_mode(self._auto_activated_fan_mode) + if ( + not should_activate_auto_fan + and self.fan_mode not in AUTO_FAN_DEACTIVATED_MODES + ): + _LOGGER.info( + "%s - DeActivate the auto fan mode with %s because delta temp is %.2f", + self, + self._auto_deactivated_fan_mode, + dtemp, + ) + await self.async_set_fan_mode(self._auto_deactivated_fan_mode) + + @overrides + def post_init(self, config_entry: ConfigData): + """Initialize the Thermostat""" + + super().post_init(config_entry) + for climate in [ + CONF_CLIMATE, + CONF_CLIMATE_2, + CONF_CLIMATE_3, + CONF_CLIMATE_4, + ]: + if config_entry.get(climate): + self._underlyings.append( + UnderlyingClimate( + hass=self._hass, + thermostat=self, + climate_entity_id=config_entry.get(climate), + ) + ) + + self.choose_auto_regulation_mode( + config_entry.get(CONF_AUTO_REGULATION_MODE) + if config_entry.get(CONF_AUTO_REGULATION_MODE) is not None + else CONF_AUTO_REGULATION_NONE + ) + + self._auto_regulation_dtemp = ( + config_entry.get(CONF_AUTO_REGULATION_DTEMP) + if config_entry.get(CONF_AUTO_REGULATION_DTEMP) is not None + else 0.5 + ) + self._auto_regulation_period_min = ( + config_entry.get(CONF_AUTO_REGULATION_PERIOD_MIN) + if config_entry.get(CONF_AUTO_REGULATION_PERIOD_MIN) is not None + else 5 + ) + + self._auto_fan_mode = ( + config_entry.get(CONF_AUTO_FAN_MODE) + if config_entry.get(CONF_AUTO_FAN_MODE) is not None + else CONF_AUTO_FAN_NONE + ) + + self._auto_regulation_use_device_temp = config_entry.get( + CONF_AUTO_REGULATION_USE_DEVICE_TEMP, False + ) + + def choose_auto_regulation_mode(self, auto_regulation_mode: str): + """Choose or change the regulation mode""" + self._auto_regulation_mode = auto_regulation_mode + if self._auto_regulation_mode == CONF_AUTO_REGULATION_LIGHT: + self._regulation_algo = PITemperatureRegulator( + self.target_temperature, + RegulationParamLight.kp, + RegulationParamLight.ki, + RegulationParamLight.k_ext, + RegulationParamLight.offset_max, + RegulationParamLight.stabilization_threshold, + RegulationParamLight.accumulated_error_threshold, + ) + elif self._auto_regulation_mode == CONF_AUTO_REGULATION_MEDIUM: + self._regulation_algo = PITemperatureRegulator( + self.target_temperature, + RegulationParamMedium.kp, + RegulationParamMedium.ki, + RegulationParamMedium.k_ext, + RegulationParamMedium.offset_max, + RegulationParamMedium.stabilization_threshold, + RegulationParamMedium.accumulated_error_threshold, + ) + elif self._auto_regulation_mode == CONF_AUTO_REGULATION_STRONG: + self._regulation_algo = PITemperatureRegulator( + self.target_temperature, + RegulationParamStrong.kp, + RegulationParamStrong.ki, + RegulationParamStrong.k_ext, + RegulationParamStrong.offset_max, + RegulationParamStrong.stabilization_threshold, + RegulationParamStrong.accumulated_error_threshold, + ) + elif self._auto_regulation_mode == CONF_AUTO_REGULATION_SLOW: + self._regulation_algo = PITemperatureRegulator( + self.target_temperature, + RegulationParamSlow.kp, + RegulationParamSlow.ki, + RegulationParamSlow.k_ext, + RegulationParamSlow.offset_max, + RegulationParamSlow.stabilization_threshold, + RegulationParamSlow.accumulated_error_threshold, + ) + elif self._auto_regulation_mode == CONF_AUTO_REGULATION_EXPERT: + api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api( + self._hass + ) + if api is not None: + if (expert_param := api.self_regulation_expert) is not None: + self._regulation_algo = PITemperatureRegulator( + self.target_temperature, + expert_param.get("kp"), + expert_param.get("ki"), + expert_param.get("k_ext"), + expert_param.get("offset_max"), + expert_param.get("stabilization_threshold"), + expert_param.get("accumulated_error_threshold"), + ) + else: + _LOGGER.error( + "%s - Cannot initialize Expert self-regulation mode due to VTherm API doesn't exists. Please contact the publisher of the integration", + self, + ) + else: + _LOGGER.error( + "%s - Cannot initialize Expert self-regulation mode cause the configuration in configuration.yaml have not been found. Please see readme documentation for %s", + self, + DOMAIN, + ) + + if not self._regulation_algo: + # A default empty algo (which does nothing) + self._regulation_algo = PITemperatureRegulator( + self.target_temperature, 0, 0, 0, 0, 0.1, 0 + ) + + def choose_auto_fan_mode(self, auto_fan_mode: str): + """Choose the correct fan mode depending of the underlying capacities and the configuration""" + + self._current_auto_fan_mode = auto_fan_mode + + # Get the supported feature of the first underlying. We suppose each underlying have the same fan attributes + fan_supported = self.supported_features & ClimateEntityFeature.FAN_MODE > 0 + + if auto_fan_mode == CONF_AUTO_FAN_NONE or not fan_supported: + self._auto_activated_fan_mode = self._auto_deactivated_fan_mode = None + return + + def find_fan_mode(fan_modes: list[str], fan_mode: str) -> str | None: + """Return the fan_mode if it exist of None if not""" + try: + return fan_mode if fan_modes.index(fan_mode) >= 0 else None + except ValueError: + return None + + fan_modes = self.fan_modes + if auto_fan_mode == CONF_AUTO_FAN_LOW: + self._auto_activated_fan_mode = find_fan_mode(fan_modes, "low") + elif auto_fan_mode == CONF_AUTO_FAN_MEDIUM: + self._auto_activated_fan_mode = find_fan_mode(fan_modes, "mid") + elif auto_fan_mode == CONF_AUTO_FAN_HIGH: + self._auto_activated_fan_mode = find_fan_mode(fan_modes, "high") + elif auto_fan_mode == CONF_AUTO_FAN_TURBO: + self._auto_activated_fan_mode = find_fan_mode( + fan_modes, "turbo" + ) or find_fan_mode(fan_modes, "high") + + for val in AUTO_FAN_DEACTIVATED_MODES: + if find_fan_mode(fan_modes, val): + self._auto_deactivated_fan_mode = val + break + + _LOGGER.info( + "%s - choose_auto_fan_mode founds current_auto_fan_mode=%s auto_activated_fan_mode=%s and auto_deactivated_fan_mode=%s", + self, + self._current_auto_fan_mode, + self._auto_activated_fan_mode, + self._auto_deactivated_fan_mode, + ) + + @overrides + async def async_added_to_hass(self): + """Run when entity about to be added.""" + _LOGGER.debug("Calling async_added_to_hass") + + await super().async_added_to_hass() + + # Add listener to all underlying entities + for climate in self._underlyings: + self.async_on_remove( + async_track_state_change_event( + self.hass, [climate.entity_id], self._async_climate_changed + ) + ) + + # Start the control_heating + # starts a cycle + self.async_on_remove( + async_track_time_interval( + self.hass, + self.async_control_heating, + interval=timedelta(minutes=self._cycle_min), + ) + ) + + # init auto_regulation_mode + # Issue 325 - do only once (in post_init and not here) + # self.choose_auto_regulation_mode(self._auto_regulation_mode) + + @overrides + def restore_specific_previous_state(self, old_state: State): + """Restore my specific attributes from previous state""" + old_error = old_state.attributes.get("regulation_accumulated_error") + if old_error: + self._regulation_algo.set_accumulated_error(old_error) + _LOGGER.debug( + "%s - Old regulation accumulated_error have been restored to %f", + self, + old_error, + ) + + @overrides + def update_custom_attributes(self): + """Custom attributes""" + super().update_custom_attributes() + + self._attr_extra_state_attributes["is_over_climate"] = self.is_over_climate + self._attr_extra_state_attributes["start_hvac_action_date"] = ( + self._underlying_climate_start_hvac_action_date + ) + self._attr_extra_state_attributes["underlying_climate_0"] = self._underlyings[ + 0 + ].entity_id + self._attr_extra_state_attributes["underlying_climate_1"] = ( + self._underlyings[1].entity_id if len(self._underlyings) > 1 else None + ) + self._attr_extra_state_attributes["underlying_climate_2"] = ( + self._underlyings[2].entity_id if len(self._underlyings) > 2 else None + ) + self._attr_extra_state_attributes["underlying_climate_3"] = ( + self._underlyings[3].entity_id if len(self._underlyings) > 3 else None + ) + + if self.is_regulated: + self._attr_extra_state_attributes["is_regulated"] = self.is_regulated + self._attr_extra_state_attributes["regulated_target_temperature"] = ( + self._regulated_target_temp + ) + self._attr_extra_state_attributes["auto_regulation_mode"] = ( + self.auto_regulation_mode + ) + self._attr_extra_state_attributes["regulation_accumulated_error"] = ( + self._regulation_algo.accumulated_error + ) + + self._attr_extra_state_attributes["auto_fan_mode"] = self.auto_fan_mode + self._attr_extra_state_attributes["current_auto_fan_mode"] = ( + self._current_auto_fan_mode + ) + + self._attr_extra_state_attributes["auto_activated_fan_mode"] = ( + self._auto_activated_fan_mode + ) + + self._attr_extra_state_attributes["auto_deactivated_fan_mode"] = ( + self._auto_deactivated_fan_mode + ) + + self._attr_extra_state_attributes["auto_regulation_use_device_temp"] = ( + self.auto_regulation_use_device_temp + ) + + self.async_write_ha_state() + _LOGGER.debug( + "%s - Calling update_custom_attributes: %s", + self, + self._attr_extra_state_attributes, + ) + + @overrides + def recalculate(self): + """A utility function to force the calculation of a the algo and + update the custom attributes and write the state + """ + _LOGGER.debug("%s - recalculate all", self) + self.update_custom_attributes() + self.async_write_ha_state() + + @overrides + async def restore_hvac_mode(self, need_control_heating=False): + """Restore a previous hvac_mod""" + old_hvac_mode = self.hvac_mode + + await super().restore_hvac_mode(need_control_heating=need_control_heating) + + # Issue 133 - force the temperature in over_climate mode if unerlying are turned on + if old_hvac_mode == HVACMode.OFF and self.hvac_mode != HVACMode.OFF: + _LOGGER.info( + "%s - Force resent target temp cause we turn on some over climate" + ) + await self._async_internal_set_temperature(self._target_temp) + + @overrides + def incremente_energy(self): + """increment the energy counter if device is active""" + + if self.hvac_mode == HVACMode.OFF: + return + + added_energy = 0 + if ( + self.is_over_climate + and self._underlying_climate_delta_t is not None + and self._device_power + ): + added_energy = self._device_power * self._underlying_climate_delta_t + + if self._total_energy is None: + self._total_energy = added_energy + else: + self._total_energy += added_energy + + _LOGGER.debug( + "%s - added energy is %.3f . Total energy is now: %.3f", + self, + added_energy, + self._total_energy, + ) + + @callback + async def _async_climate_changed(self, event: HASSEventType[EventStateChangedData]): + """Handle unerdlying climate state changes. + This method takes the underlying values and update the VTherm with them. + To avoid loops (issues #121 #101 #95 #99), we discard the event if it is received + less than 10 sec after the last command. What we want here is to take the values + from underlyings ONLY if someone have change directly on the underlying and not + as a return of the command. The only thing we take all the time is the HVACAction + which is important for feedaback and which cannot generates loops. + """ + + async def end_climate_changed(changes: bool): + """To end the event management""" + if changes: + self.async_write_ha_state() + self.update_custom_attributes() + await self.async_control_heating() + + new_state = event.data.get("new_state") + _LOGGER.debug("%s - _async_climate_changed new_state is %s", self, new_state) + if not new_state: + return + + changes = False + new_hvac_mode = new_state.state + + old_state = event.data.get("old_state") + old_hvac_action = ( + old_state.attributes.get("hvac_action") + if old_state and old_state.attributes + else None + ) + new_hvac_action = ( + new_state.attributes.get("hvac_action") + if new_state and new_state.attributes + else None + ) + + new_fan_mode = ( + new_state.attributes.get("fan_mode") + if new_state and new_state.attributes + else None + ) + + old_state_date_changed = ( + old_state.last_changed if old_state and old_state.last_changed else None + ) + old_state_date_updated = ( + old_state.last_updated if old_state and old_state.last_updated else None + ) + new_state_date_changed = ( + new_state.last_changed if new_state and new_state.last_changed else None + ) + new_state_date_updated = ( + new_state.last_updated if new_state and new_state.last_updated else None + ) + + # Issue 99 - some AC turn hvac_mode=cool and hvac_action=idle when sending a HVACMode_OFF command + # Issue 114 - Remove this because hvac_mode is now managed by local _hvac_mode and use idle action as is + # if self._hvac_mode == HVACMode.OFF and new_hvac_action == HVACAction.IDLE: + # _LOGGER.debug("The underlying switch to idle instead of OFF. We will consider it as OFF") + # new_hvac_mode = HVACMode.OFF + + _LOGGER.info( + "%s - Underlying climate %s changed. Event.new_hvac_mode is %s, current_hvac_mode=%s, new_hvac_action=%s, old_hvac_action=%s", + self, + new_state.entity_id, + new_hvac_mode, + self._hvac_mode, + new_hvac_action, + old_hvac_action, + ) + + _LOGGER.debug( + "%s - last_change_time=%s old_state_date_changed=%s old_state_date_updated=%s new_state_date_changed=%s new_state_date_updated=%s", + self, + self._last_change_time, + old_state_date_changed, + old_state_date_updated, + new_state_date_changed, + new_state_date_updated, + ) + + # Interpretation of hvac action + HVAC_ACTION_ON = [ # pylint: disable=invalid-name + HVACAction.COOLING, + HVACAction.DRYING, + HVACAction.FAN, + HVACAction.HEATING, + ] + if old_hvac_action not in HVAC_ACTION_ON and new_hvac_action in HVAC_ACTION_ON: + self._underlying_climate_start_hvac_action_date = ( + self.get_last_updated_date_or_now(new_state) + ) + _LOGGER.info( + "%s - underlying just switch ON. Set power and energy start date %s", + self, + self._underlying_climate_start_hvac_action_date.isoformat(), + ) + changes = True + + if old_hvac_action in HVAC_ACTION_ON and new_hvac_action not in HVAC_ACTION_ON: + stop_power_date = self.get_last_updated_date_or_now(new_state) + if self._underlying_climate_start_hvac_action_date: + delta = ( + stop_power_date - self._underlying_climate_start_hvac_action_date + ) + self._underlying_climate_delta_t = delta.total_seconds() / 3600.0 + + # increment energy at the end of the cycle + self.incremente_energy() + + self._underlying_climate_start_hvac_action_date = None + + _LOGGER.info( + "%s - underlying just switch OFF at %s. delta_h=%.3f h", + self, + stop_power_date.isoformat(), + self._underlying_climate_delta_t, + ) + changes = True + + # Issue #120 - Some TRV are changing target temperature a very long time (6 sec) after the change. + # In that case a loop is possible if a user change multiple times during this 6 sec. + if new_state_date_updated and self._last_change_time: + delta = (new_state_date_updated - self._last_change_time).total_seconds() + if delta < 10: + _LOGGER.info( + "%s - underlying event is received less than 10 sec after command. Forget it to avoid loop", + self, + ) + await end_climate_changed(changes) + return + + if ( + new_hvac_mode + in [ + HVACMode.OFF, + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.HEAT_COOL, + HVACMode.DRY, + HVACMode.AUTO, + HVACMode.FAN_ONLY, + None, + ] + and self._hvac_mode != new_hvac_mode + ): + # Update all underlyings state + # Issue #334 - if all underlyings are not aligned with the same hvac_mode don't change the underlying and wait they are aligned + if self.is_over_climate: + for under in self._underlyings: + if ( + under.entity_id != new_state.entity_id + and under.hvac_mode != self._hvac_mode + ): + _LOGGER.info( + "%s - the underlying's hvac_mode %s is not aligned with VTherm hvac_mode %s. So we don't diffuse the change to all other underlyings to avoid loops", + under, + under.hvac_mode, + self._hvac_mode, + ) + return + + _LOGGER.debug( + "%s - All underlyings have the same hvac_mode, so VTherm will send the new hvac mode %s", + self, + new_hvac_mode, + ) + for under in self._underlyings: + await under.set_hvac_mode(new_hvac_mode) + changes = True + self._hvac_mode = new_hvac_mode + + # A quick win to known if it has change by using the self._attr_fan_mode and not only underlying[0].fan_mode + if new_fan_mode != self._attr_fan_mode: + self._attr_fan_mode = new_fan_mode + changes = True + + if not changes: + # try to manage new target temperature set if state + _LOGGER.debug( + "Do temperature check. temperature is %s, new_state.attributes is %s", + self.target_temperature, + new_state.attributes, + ) + if ( + # we do not change target temperature on regulated VTherm + not self.is_regulated + and new_state.attributes + and (new_target_temp := new_state.attributes.get("temperature")) + and new_target_temp != self.target_temperature + ): + _LOGGER.info( + "%s - Target temp in underlying have change to %s", + self, + new_target_temp, + ) + await self.async_set_temperature(temperature=new_target_temp) + changes = True + + await end_climate_changed(changes) + + @overrides + async def async_control_heating(self, force=False, _=None) -> bool: + """The main function used to run the calculation at each cycle""" + ret = await super().async_control_heating(force, _) + + await self._send_regulated_temperature() + + if self._auto_fan_mode and self._auto_fan_mode != CONF_AUTO_FAN_NONE: + await self._send_auto_fan_mode() + + return ret + + @property + def auto_regulation_mode(self) -> str | None: + """Get the regulation mode""" + return self._auto_regulation_mode + + @property + def auto_fan_mode(self) -> str | None: + """Get the auto fan mode""" + return self._auto_fan_mode + + @property + def auto_regulation_use_device_temp(self) -> bool | None: + """Returns the value of parameter auto_regulation_use_device_temp""" + return self._auto_regulation_use_device_temp + + @property + def regulated_target_temp(self) -> float | None: + """Get the regulated target temperature""" + return self._regulated_target_temp + + @property + def is_regulated(self) -> bool: + """Check if the ThermostatOverClimate is regulated""" + return self.auto_regulation_mode != CONF_AUTO_REGULATION_NONE + + @property + def hvac_modes(self) -> list[HVACMode]: + """List of available operation modes.""" + if self.underlying_entity(0): + return self.underlying_entity(0).hvac_modes + else: + return super.hvac_modes + + @property + def mean_cycle_power(self) -> float | None: + """Returns the mean power consumption during the cycle""" + return None + + @property + def fan_mode(self) -> str | None: + """Return the fan setting. + + Requires ClimateEntityFeature.FAN_MODE. + """ + if self.underlying_entity(0): + self._attr_fan_mode = self.underlying_entity(0).fan_mode + return self._attr_fan_mode + + return None + + @property + def fan_modes(self) -> list[str] | None: + """Return the list of available fan modes. + + Requires ClimateEntityFeature.FAN_MODE. + """ + if self.underlying_entity(0): + return self.underlying_entity(0).fan_modes + + return [] + + @property + def swing_mode(self) -> str | None: + """Return the swing setting. + + Requires ClimateEntityFeature.SWING_MODE. + """ + if self.underlying_entity(0): + return self.underlying_entity(0).swing_mode + + return None + + @property + def swing_modes(self) -> list[str] | None: + """Return the list of available swing modes. + + Requires ClimateEntityFeature.SWING_MODE. + """ + if self.underlying_entity(0): + return self.underlying_entity(0).swing_modes + + return None + + @property + def temperature_unit(self) -> str: + """Return the unit of measurement.""" + if self.underlying_entity(0): + return self.underlying_entity(0).temperature_unit + + return self._unit + + @property + def supported_features(self): + """Return the list of supported features.""" + if self.underlying_entity(0): + return self.underlying_entity(0).supported_features | self._support_flags + + return self._support_flags + + # We keep the step configured for the VTherm and not the step of the underlying + # @property + # def target_temperature_step(self) -> float | None: + # """Return the supported step of target temperature.""" + # if self.underlying_entity(0): + # return self.underlying_entity(0).target_temperature_step + # + # return None + + @property + def target_temperature_high(self) -> float | None: + """Return the highbound target temperature we try to reach. + + Requires ClimateEntityFeature.TARGET_TEMPERATURE_RANGE. + """ + if self.underlying_entity(0): + return self.underlying_entity(0).target_temperature_high + + return None + + @property + def target_temperature_low(self) -> float | None: + """Return the lowbound target temperature we try to reach. + + Requires ClimateEntityFeature.TARGET_TEMPERATURE_RANGE. + """ + if self.underlying_entity(0): + return self.underlying_entity(0).target_temperature_low + + return None + + @property + def is_aux_heat(self) -> bool | None: + """Return true if aux heater. + + Requires ClimateEntityFeature.AUX_HEAT. + """ + if self.underlying_entity(0): + return self.underlying_entity(0).is_aux_heat + + return None + + @property + def is_initialized(self) -> bool: + """Check if all underlyings are initialized""" + for under in self._underlyings: + if not under.is_initialized: + return False + return True + + @overrides + def init_underlyings(self): + """Init the underlyings if not already done""" + for under in self._underlyings: + if not under.is_initialized: + _LOGGER.info( + "%s - Underlying %s is not initialized. Try to initialize it", + self, + under.entity_id, + ) + try: + under.startup() + except UnknownEntity: + # still not found, we an stop here + return False + self.choose_auto_fan_mode(self._auto_fan_mode) + + @overrides + def turn_aux_heat_on(self) -> None: + """Turn auxiliary heater on.""" + if self.underlying_entity(0): + return self.underlying_entity(0).turn_aux_heat_on() + + raise NotImplementedError() + + @overrides + async def async_turn_aux_heat_on(self) -> None: + """Turn auxiliary heater on.""" + for under in self._underlyings: + await under.async_turn_aux_heat_on() + + @overrides + def turn_aux_heat_off(self) -> None: + """Turn auxiliary heater off.""" + for under in self._underlyings: + return under.turn_aux_heat_off() + + @overrides + async def async_turn_aux_heat_off(self) -> None: + """Turn auxiliary heater off.""" + for under in self._underlyings: + await under.async_turn_aux_heat_off() + + @overrides + async def async_set_fan_mode(self, fan_mode: str): + """Set new target fan mode.""" + _LOGGER.info("%s - Set fan mode: %s", self, fan_mode) + if fan_mode is None: + return + + for under in self._underlyings: + await under.set_fan_mode(fan_mode) + self._fan_mode = fan_mode + self.async_write_ha_state() + + @overrides + async def async_set_humidity(self, humidity: int): + """Set new target humidity.""" + _LOGGER.info("%s - Set fan mode: %s", self, humidity) + if humidity is None: + return + for under in self._underlyings: + await under.set_humidity(humidity) + self._humidity = humidity + self.async_write_ha_state() + + @overrides + async def async_set_swing_mode(self, swing_mode): + """Set new target swing operation.""" + _LOGGER.info("%s - Set fan mode: %s", self, swing_mode) + if swing_mode is None: + return + for under in self._underlyings: + await under.set_swing_mode(swing_mode) + self._swing_mode = swing_mode + self.async_write_ha_state() + + async def service_set_auto_regulation_mode(self, auto_regulation_mode: str): + """Called by a service call: + service: versatile_thermostat.set_auto_regulation_mode + data: + auto_regulation_mode: [None | Light | Medium | Strong] + target: + entity_id: climate.thermostat_1 + """ + _LOGGER.info( + "%s - Calling service_set_auto_regulation_mode, auto_regulation_mode: %s", + self, + auto_regulation_mode, + ) + if auto_regulation_mode == "None": + self.choose_auto_regulation_mode(CONF_AUTO_REGULATION_NONE) + elif auto_regulation_mode == "Light": + self.choose_auto_regulation_mode(CONF_AUTO_REGULATION_LIGHT) + elif auto_regulation_mode == "Medium": + self.choose_auto_regulation_mode(CONF_AUTO_REGULATION_MEDIUM) + elif auto_regulation_mode == "Strong": + self.choose_auto_regulation_mode(CONF_AUTO_REGULATION_STRONG) + elif auto_regulation_mode == "Slow": + self.choose_auto_regulation_mode(CONF_AUTO_REGULATION_SLOW) + elif auto_regulation_mode == "Expert": + self.choose_auto_regulation_mode(CONF_AUTO_REGULATION_EXPERT) + + await self._send_regulated_temperature() + self.update_custom_attributes() + + async def service_set_auto_fan_mode(self, auto_fan_mode: str): + """Called by a service call: + service: versatile_thermostat.set_auto_fan_mode + data: + auto_fan_mode: [None | Low | Medium | High | Turbo] + target: + entity_id: climate.thermostat_1 + """ + _LOGGER.info( + "%s - Calling service_set_auto_fan_mode, auto_fan_mode: %s", + self, + auto_fan_mode, + ) + if auto_fan_mode == "None": + self.choose_auto_fan_mode(CONF_AUTO_FAN_NONE) + elif auto_fan_mode == "Low": + self.choose_auto_fan_mode(CONF_AUTO_FAN_LOW) + elif auto_fan_mode == "Medium": + self.choose_auto_fan_mode(CONF_AUTO_FAN_MEDIUM) + elif auto_fan_mode == "High": + self.choose_auto_fan_mode(CONF_AUTO_FAN_HIGH) + elif auto_fan_mode == "Turbo": + self.choose_auto_fan_mode(CONF_AUTO_FAN_TURBO) + + self.update_custom_attributes() diff --git a/config/custom_components/versatile_thermostat/thermostat_switch.py b/config/custom_components/versatile_thermostat/thermostat_switch.py new file mode 100644 index 0000000..cdfd8b9 --- /dev/null +++ b/config/custom_components/versatile_thermostat/thermostat_switch.py @@ -0,0 +1,225 @@ +# pylint: disable=line-too-long + +""" A climate over switch classe """ +import logging +from homeassistant.core import callback +from homeassistant.helpers.event import ( + async_track_state_change_event, + EventStateChangedData, +) +from homeassistant.helpers.typing import EventType as HASSEventType +from homeassistant.components.climate import HVACMode + +from .const import ( + CONF_HEATER, + CONF_HEATER_2, + CONF_HEATER_3, + CONF_HEATER_4, + CONF_HEATER_KEEP_ALIVE, + CONF_INVERSE_SWITCH, + overrides, +) + +from .base_thermostat import BaseThermostat, ConfigData +from .underlyings import UnderlyingSwitch +from .prop_algorithm import PropAlgorithm + +_LOGGER = logging.getLogger(__name__) + + +class ThermostatOverSwitch(BaseThermostat[UnderlyingSwitch]): + """Representation of a base class for a Versatile Thermostat over a switch.""" + + _entity_component_unrecorded_attributes = ( + BaseThermostat._entity_component_unrecorded_attributes.union( + frozenset( + { + "is_over_switch", + "is_inversed", + "underlying_switch_0", + "underlying_switch_1", + "underlying_switch_2", + "underlying_switch_3", + "on_time_sec", + "off_time_sec", + "cycle_min", + "function", + "tpi_coef_int", + "tpi_coef_ext", + "power_percent", + } + ) + ) + ) + + # useless for now + # def __init__(self, hass: HomeAssistant, unique_id, name, config_entry) -> None: + # """Initialize the thermostat over switch.""" + # super().__init__(hass, unique_id, name, config_entry) + _is_inversed: bool | None = None + + @property + def is_over_switch(self) -> bool: + """True if the Thermostat is over_switch""" + return True + + @property + def is_inversed(self) -> bool: + """True if the switch is inversed (for pilot wire and diode)""" + return self._is_inversed is True + + @property + def power_percent(self) -> float | None: + """Get the current on_percent value""" + if self._prop_algorithm: + return round(self._prop_algorithm.on_percent * 100, 0) + else: + return None + + @overrides + def post_init(self, config_entry: ConfigData): + """Initialize the Thermostat""" + + super().post_init(config_entry) + + self._prop_algorithm = PropAlgorithm( + self._proportional_function, + self._tpi_coef_int, + self._tpi_coef_ext, + self._cycle_min, + self._minimal_activation_delay, + self.name, + ) + + lst_switches = [config_entry.get(CONF_HEATER)] + if config_entry.get(CONF_HEATER_2): + lst_switches.append(config_entry.get(CONF_HEATER_2)) + if config_entry.get(CONF_HEATER_3): + lst_switches.append(config_entry.get(CONF_HEATER_3)) + if config_entry.get(CONF_HEATER_4): + lst_switches.append(config_entry.get(CONF_HEATER_4)) + + delta_cycle = self._cycle_min * 60 / len(lst_switches) + for idx, switch in enumerate(lst_switches): + self._underlyings.append( + UnderlyingSwitch( + hass=self._hass, + thermostat=self, + switch_entity_id=switch, + initial_delay_sec=idx * delta_cycle, + keep_alive_sec=config_entry.get(CONF_HEATER_KEEP_ALIVE, 0), + ) + ) + + self._is_inversed = config_entry.get(CONF_INVERSE_SWITCH) is True + self._should_relaunch_control_heating = False + + @overrides + async def async_added_to_hass(self): + """Run when entity about to be added.""" + _LOGGER.debug("Calling async_added_to_hass") + + await super().async_added_to_hass() + + # Add listener to all underlying entities + for switch in self._underlyings: + self.async_on_remove( + async_track_state_change_event( + self.hass, [switch.entity_id], self._async_switch_changed + ) + ) + switch.startup() + + self.hass.create_task(self.async_control_heating()) + + @overrides + def update_custom_attributes(self): + """Custom attributes""" + super().update_custom_attributes() + + under0: UnderlyingSwitch = self._underlyings[0] + self._attr_extra_state_attributes["is_over_switch"] = self.is_over_switch + self._attr_extra_state_attributes["is_inversed"] = self.is_inversed + self._attr_extra_state_attributes["keep_alive_sec"] = under0.keep_alive_sec + self._attr_extra_state_attributes["underlying_switch_0"] = under0.entity_id + self._attr_extra_state_attributes["underlying_switch_1"] = ( + self._underlyings[1].entity_id if len(self._underlyings) > 1 else None + ) + self._attr_extra_state_attributes["underlying_switch_2"] = ( + self._underlyings[2].entity_id if len(self._underlyings) > 2 else None + ) + self._attr_extra_state_attributes["underlying_switch_3"] = ( + self._underlyings[3].entity_id if len(self._underlyings) > 3 else None + ) + + self._attr_extra_state_attributes[ + "on_percent" + ] = self._prop_algorithm.on_percent + self._attr_extra_state_attributes["power_percent"] = self.power_percent + self._attr_extra_state_attributes[ + "on_time_sec" + ] = self._prop_algorithm.on_time_sec + self._attr_extra_state_attributes[ + "off_time_sec" + ] = self._prop_algorithm.off_time_sec + self._attr_extra_state_attributes["cycle_min"] = self._cycle_min + self._attr_extra_state_attributes["function"] = self._proportional_function + self._attr_extra_state_attributes["tpi_coef_int"] = self._tpi_coef_int + self._attr_extra_state_attributes["tpi_coef_ext"] = self._tpi_coef_ext + + self.async_write_ha_state() + _LOGGER.debug( + "%s - Calling update_custom_attributes: %s", + self, + self._attr_extra_state_attributes, + ) + + @overrides + def recalculate(self): + """A utility function to force the calculation of a the algo and + update the custom attributes and write the state + """ + _LOGGER.debug("%s - recalculate all", self) + self._prop_algorithm.calculate( + self._target_temp, + self._cur_temp, + self._cur_ext_temp, + self._hvac_mode or HVACMode.OFF, + ) + self.update_custom_attributes() + self.async_write_ha_state() + + @overrides + def incremente_energy(self): + """increment the energy counter if device is active""" + if self.hvac_mode == HVACMode.OFF: + return + + added_energy = 0 + if not self.is_over_climate and self.mean_cycle_power is not None: + added_energy = self.mean_cycle_power * float(self._cycle_min) / 60.0 + + if self._total_energy is None: + self._total_energy = added_energy + else: + self._total_energy += added_energy + + _LOGGER.debug( + "%s - added energy is %.3f . Total energy is now: %.3f", + self, + added_energy, + self._total_energy, + ) + + @callback + def _async_switch_changed(self, event: HASSEventType[EventStateChangedData]): + """Handle heater switch state changes.""" + new_state = event.data.get("new_state") + old_state = event.data.get("old_state") + if new_state is None: + return + if old_state is None: + self.hass.create_task(self._check_initial_state()) + + self.async_write_ha_state() + self.update_custom_attributes() diff --git a/config/custom_components/versatile_thermostat/thermostat_valve.py b/config/custom_components/versatile_thermostat/thermostat_valve.py new file mode 100644 index 0000000..f560937 --- /dev/null +++ b/config/custom_components/versatile_thermostat/thermostat_valve.py @@ -0,0 +1,292 @@ +# pylint: disable=line-too-long +""" A climate over switch classe """ +import logging +from datetime import timedelta, datetime + +from homeassistant.helpers.event import ( + async_track_state_change_event, + async_track_time_interval, + EventStateChangedData, +) +from homeassistant.helpers.typing import EventType as HASSEventType +from homeassistant.core import HomeAssistant, callback +from homeassistant.components.climate import HVACMode + +from .base_thermostat import BaseThermostat, ConfigData +from .prop_algorithm import PropAlgorithm + +from .const import ( + CONF_VALVE, + CONF_VALVE_2, + CONF_VALVE_3, + CONF_VALVE_4, + # This is not really self-regulation but regulation here + CONF_AUTO_REGULATION_DTEMP, + CONF_AUTO_REGULATION_PERIOD_MIN, + overrides, +) + +from .underlyings import UnderlyingValve + +_LOGGER = logging.getLogger(__name__) + + +class ThermostatOverValve(BaseThermostat[UnderlyingValve]): # pylint: disable=abstract-method + """Representation of a class for a Versatile Thermostat over a Valve""" + + _entity_component_unrecorded_attributes = ( + BaseThermostat._entity_component_unrecorded_attributes.union( + frozenset( + { + "is_over_valve", + "underlying_valve_0", + "underlying_valve_1", + "underlying_valve_2", + "underlying_valve_3", + "on_time_sec", + "off_time_sec", + "cycle_min", + "function", + "tpi_coef_int", + "tpi_coef_ext", + "auto_regulation_dpercent", + "auto_regulation_period_min", + "last_calculation_timestamp", + } + ) + ) + ) + + def __init__( + self, hass: HomeAssistant, unique_id: str, name: str, config_entry: ConfigData + ): + """Initialize the thermostat over switch.""" + self._valve_open_percent: int = 0 + self._last_calculation_timestamp: datetime | None = None + self._auto_regulation_dpercent: float | None = None + self._auto_regulation_period_min: int | None = None + + # Call to super must be done after initialization because it calls post_init at the end + super().__init__(hass, unique_id, name, config_entry) + + @property + def is_over_valve(self) -> bool: + """True if the Thermostat is over_valve""" + return True + + @property + def valve_open_percent(self) -> int: + """Gives the percentage of valve needed""" + if self._hvac_mode == HVACMode.OFF: + return 0 + else: + return self._valve_open_percent + + @overrides + def post_init(self, config_entry: ConfigData): + """Initialize the Thermostat""" + + super().post_init(config_entry) + + self._auto_regulation_dpercent = ( + config_entry.get(CONF_AUTO_REGULATION_DTEMP) + if config_entry.get(CONF_AUTO_REGULATION_DTEMP) is not None + else 0.0 + ) + self._auto_regulation_period_min = ( + config_entry.get(CONF_AUTO_REGULATION_PERIOD_MIN) + if config_entry.get(CONF_AUTO_REGULATION_PERIOD_MIN) is not None + else 0 + ) + + self._prop_algorithm = PropAlgorithm( + self._proportional_function, + self._tpi_coef_int, + self._tpi_coef_ext, + self._cycle_min, + self._minimal_activation_delay, + self.name, + ) + + lst_valves = [config_entry.get(CONF_VALVE)] + if config_entry.get(CONF_VALVE_2): + lst_valves.append(config_entry.get(CONF_VALVE_2)) + if config_entry.get(CONF_VALVE_3): + lst_valves.append(config_entry.get(CONF_VALVE_3)) + if config_entry.get(CONF_VALVE_4): + lst_valves.append(config_entry.get(CONF_VALVE_4)) + + for _, valve in enumerate(lst_valves): + self._underlyings.append( + UnderlyingValve(hass=self._hass, thermostat=self, valve_entity_id=valve) + ) + + self._should_relaunch_control_heating = False + + @overrides + async def async_added_to_hass(self): + """Run when entity about to be added.""" + _LOGGER.debug("Calling async_added_to_hass") + + await super().async_added_to_hass() + + # Add listener to all underlying entities + for valve in self._underlyings: + self.async_on_remove( + async_track_state_change_event( + self.hass, [valve.entity_id], self._async_valve_changed + ) + ) + + # Start the control_heating + # starts a cycle + self.async_on_remove( + async_track_time_interval( + self.hass, + self.async_control_heating, + interval=timedelta(minutes=self._cycle_min), + ) + ) + + @callback + async def _async_valve_changed(self, event: HASSEventType[EventStateChangedData]): + """Handle unerdlying valve state changes. + This method just log the change. It changes nothing to avoid loops. + """ + new_state = event.data.get("new_state") + _LOGGER.debug( + "%s - _async_valve_changed new_state is %s", self, new_state.state + ) + + @overrides + def update_custom_attributes(self): + """Custom attributes""" + super().update_custom_attributes() + self._attr_extra_state_attributes[ + "valve_open_percent" + ] = self.valve_open_percent + self._attr_extra_state_attributes["is_over_valve"] = self.is_over_valve + self._attr_extra_state_attributes["underlying_valve_0"] = self._underlyings[ + 0 + ].entity_id + self._attr_extra_state_attributes["underlying_valve_1"] = ( + self._underlyings[1].entity_id if len(self._underlyings) > 1 else None + ) + self._attr_extra_state_attributes["underlying_valve_2"] = ( + self._underlyings[2].entity_id if len(self._underlyings) > 2 else None + ) + self._attr_extra_state_attributes["underlying_valve_3"] = ( + self._underlyings[3].entity_id if len(self._underlyings) > 3 else None + ) + + self._attr_extra_state_attributes[ + "on_percent" + ] = self._prop_algorithm.on_percent + self._attr_extra_state_attributes[ + "on_time_sec" + ] = self._prop_algorithm.on_time_sec + self._attr_extra_state_attributes[ + "off_time_sec" + ] = self._prop_algorithm.off_time_sec + self._attr_extra_state_attributes["cycle_min"] = self._cycle_min + self._attr_extra_state_attributes["function"] = self._proportional_function + self._attr_extra_state_attributes["tpi_coef_int"] = self._tpi_coef_int + self._attr_extra_state_attributes["tpi_coef_ext"] = self._tpi_coef_ext + self._attr_extra_state_attributes[ + "auto_regulation_dpercent" + ] = self._auto_regulation_dpercent + self._attr_extra_state_attributes[ + "auto_regulation_period_min" + ] = self._auto_regulation_period_min + self._attr_extra_state_attributes["last_calculation_timestamp"] = ( + self._last_calculation_timestamp.astimezone(self._current_tz).isoformat() + if self._last_calculation_timestamp + else None + ) + + self.async_write_ha_state() + _LOGGER.debug( + "%s - Calling update_custom_attributes: %s", + self, + self._attr_extra_state_attributes, + ) + + @overrides + def recalculate(self): + """A utility function to force the calculation of a the algo and + update the custom attributes and write the state + """ + _LOGGER.debug("%s - recalculate the open percent", self) + + # For testing purpose. Should call _set_now() before + now = self.now + + if self._last_calculation_timestamp is not None: + period = (now - self._last_calculation_timestamp).total_seconds() / 60 + if period < self._auto_regulation_period_min: + _LOGGER.info( + "%s - do not calculate TPI because regulation_period (%d) is not exceeded", + self, + period, + ) + return + + self._prop_algorithm.calculate( + self._target_temp, + self._cur_temp, + self._cur_ext_temp, + self._hvac_mode or HVACMode.OFF, + ) + + new_valve_percent = round( + max(0, min(self.proportional_algorithm.on_percent, 1)) * 100 + ) + + dpercent = new_valve_percent - self.valve_open_percent + if ( + dpercent >= -1 * self._auto_regulation_dpercent + and dpercent < self._auto_regulation_dpercent + ): + _LOGGER.debug( + "%s - do not calculate TPI because regulation_dpercent (%.1f) is not exceeded", + self, + dpercent, + ) + + return + + if self._valve_open_percent == new_valve_percent: + _LOGGER.debug("%s - no change in valve_open_percent.", self) + return + + self._valve_open_percent = new_valve_percent + + for under in self._underlyings: + under.set_valve_open_percent() + + self._last_calculation_timestamp = now + + self.update_custom_attributes() + self.async_write_ha_state() + + @overrides + def incremente_energy(self): + """increment the energy counter if device is active""" + if self.hvac_mode == HVACMode.OFF: + return + + added_energy = 0 + if not self.is_over_climate and self.mean_cycle_power is not None: + added_energy = self.mean_cycle_power * float(self._cycle_min) / 60.0 + + if self._total_energy is None: + self._total_energy = added_energy + else: + self._total_energy += added_energy + + _LOGGER.debug( + "%s - added energy is %.3f . Total energy is now: %.3f", + self, + added_energy, + self._total_energy, + ) diff --git a/config/custom_components/versatile_thermostat/translations/el.json b/config/custom_components/versatile_thermostat/translations/el.json new file mode 100644 index 0000000..5de35f3 --- /dev/null +++ b/config/custom_components/versatile_thermostat/translations/el.json @@ -0,0 +1,392 @@ +{ + "title": "Διαμόρφωση Ευέλικτου Θερμοστάτη", + "config": { + "flow_title": "Διαμόρφωση Ευέλικτου Θερμοστάτη", + "step": { + "user": { + "title": "Προσθήκη νέου Ευέλικτου Θερμοστάτη", + "description": "Κύρια υποχρεωτικά χαρακτηριστικά", + "data": { + "name": "Όνομα", + "thermostat_type": "Τύπος Θερμοστάτη", + "temperature_sensor_entity_id": "Ταυτότητα οντότητας αισθητήρα θερμοκρασίας", + "external_temperature_sensor_entity_id": "Ταυτότητα οντότητας εξωτερικού αισθητήρα θερμοκρασίας", + "cycle_min": "Διάρκεια κύκλου (λεπτά)", + "temp_min": "Ελάχιστη επιτρεπτή θερμοκρασία", + "temp_max": "Μέγιστη επιτρεπτή θερμοκρασία", + "device_power": "Ισχύς συσκευής", + "use_window_feature": "Χρήση ανίχνευσης παραθύρου", + "use_motion_feature": "Χρήση ανίχνευσης κίνησης", + "use_power_feature": "Χρήση διαχείρισης ισχύος", + "use_presence_feature": "Χρήση ανίχνευσης παρουσίας" + } + }, + "type": { + "title": "Συνδεδεμένες οντότητες", + "description": "Χαρακτηριστικά συνδεδεμένων οντοτήτων", + "data": { + "heater_entity_id": "1ος διακόπτης θερμαντήρα", + "heater_entity2_id": "2ος διακόπτης θερμαντήρα", + "heater_entity3_id": "3ος διακόπτης θερμαντήρα", + "heater_entity4_id": "4ος διακόπτης θερμαντήρα", + "proportional_function": "Αλγόριθμος", + "climate_entity_id": "1η υποκείμενη κλιματική οντότητα", + "climate_entity2_id": "2η υποκείμενη κλιματική οντότητα", + "climate_entity3_id": "3η υποκείμενη κλιματική οντότητα", + "climate_entity4_id": "4η υποκείμενη κλιματική οντότητα", + "ac_mode": "Λειτουργία AC", + "valve_entity_id": "1ος αριθμός βαλβίδας", + "valve_entity2_id": "2ος αριθμός βαλβίδας", + "valve_entity3_id": "3ος αριθμός βαλβίδας", + "valve_entity4_id": "4ος αριθμός βαλβίδας", + "auto_regulation_mode": "Αυτόματη ρύθμιση", + "auto_regulation_dtemp": "Όριο ρύθμισης", + "auto_regulation_periode_min": "Ελάχιστη περίοδος ρύθμισης", + "inverse_switch_command": "Αντίστροφη εντολή διακόπτη", + "auto_fan_mode": " Auto fan mode" + }, + "data_description": { + "heater_entity_id": "Υποχρεωτική ταυτότητα οντότητας θερμαντήρα", + "heater_entity2_id": "Προαιρετική 2η ταυτότητα οντότητας θερμαντήρα. Αφήστε κενό αν δεν χρησιμοποιείται", + "heater_entity3_id": "Προαιρετική 3η ταυτότητα οντότητας θερμαντήρα. Αφήστε κενό αν δεν χρησιμοποιείται", + "heater_entity4_id": "Προαιρετική 4η ταυτότητα οντότητας θερμαντήρα. Αφήστε κενό αν δεν χρησιμοποιείται", + "proportional_function": "Αλγόριθμος προς χρήση (TPI είναι ο μόνος για τώρα)", + "climate_entity_id": "Ταυτότητα υποκείμενης κλιματικής οντότητας", + "climate_entity2_id": "2η ταυτότητα υποκείμενης κλιματικής οντότητας", + "climate_entity3_id": "3η ταυτότητα υποκείμενης κλιματικής οντότητας", + "climate_entity4_id": "4η ταυτότητα υποκείμενης κλιματικής οντότητας", + "ac_mode": "Χρήση της λειτουργίας Κλιματισμού (AC)", + "valve_entity_id": "1η ταυτότητα αριθμού βαλβίδας", + "valve_entity2_id": "2η ταυτότητα αριθμού βαλβίδας", + "valve_entity3_id": "3η ταυτότητα αριθμού βαλβίδας", + "valve_entity4_id": "4η ταυτότητα αριθμού βαλβίδας", + "auto_regulation_mode": "Αυτόματη προσαρμογή της στοχευμένης θερμοκρασίας", + "auto_regulation_dtemp": "Το όριο σε ° κάτω από το οποίο η αλλαγή θερμοκρασίας δεν θα αποστέλλεται", + "auto_regulation_periode_min": "Διάρκεια σε λεπτά μεταξύ δύο ενημερώσεων ρύθμισης", + "inverse_switch_command": "Για διακόπτη με πιλοτικό καλώδιο και δίοδο μπορεί να χρειαστεί να αντιστρέψετε την εντολή", + "auto_fan_mode": " Automatically activate fan when huge heating/cooling is necessary" + } + }, + "tpi": { + "title": "TPI", + "description": "Χαρακτηριστικά Χρονικά Αναλογικού Ολοκληρωτικού (TPI)", + "data": { + "tpi_coef_int": "Συντελεστής για χρήση στη διαφορά εσωτερικής θερμοκρασίας", + "tpi_coef_ext": "Συντελεστής για χρήση στη διαφορά εξωτερικής θερμοκρασίας" + } + }, + "presets": { + "title": "Προκαθορισμένα", + "description": "Για κάθε προκαθορισμένο, δώστε την επιθυμητή θερμοκρασία (0 για να αγνοηθεί το προκαθορισμένο)", + "data": { + "eco_temp": "Θερμοκρασία στο προκαθορισμένο Eco", + "comfort_temp": "Θερμοκρασία στο προκαθορισμένο Comfort", + "boost_temp": "Θερμοκρασία στο προκαθορισμένο Boost", + "frost_temp": "Θερμοκρασία στο προκαθορισμένο Frost protection", + "eco_ac_temp": "Θερμοκρασία στο προκαθορισμένο Eco για λειτουργία AC", + "comfort_ac_temp": "Θερμοκρασία στο προκαθορισμένο Comfort για λειτουργία AC", + "boost_ac_temp": "Θερμοκρασία στο προκαθορισμένο Boost για λειτουργία AC" + } + }, + "window": { + "title": "Διαχείριση Παραθύρων", + "description": "Ανοίξτε τη διαχείριση παραθύρων.\nΑφήστε το αντίστοιχο entity_id κενό αν δεν χρησιμοποιείται\nΜπορείτε επίσης να ρυθμίσετε αυτόματη ανίχνευση ανοίγματος παραθύρου με βάση τη μείωση της θερμοκρασίας", + "data": { + "window_sensor_entity_id": "Ταυτότητα οντότητας αισθητήρα παραθύρου", + "window_delay": "Καθυστέρηση αισθητήρα παραθύρου (δευτερόλεπτα)", + "window_auto_open_threshold": "Κατώφλι μείωσης θερμοκρασίας για αυτόματη ανίχνευση ανοίγματος παραθύρου (σε °/λεπτό)", + "window_auto_close_threshold": "Κατώφλι αύξησης θερμοκρασίας για τέλος αυτόματης ανίχνευσης (σε °/λεπτό)", + "window_auto_max_duration": "Μέγιστη διάρκεια αυτόματης ανίχνευσης ανοίγματος παραθύρου (σε λεπτά)" + }, + "data_description": { + "window_sensor_entity_id": "Αφήστε κενό αν δεν πρέπει να χρησιμοποιηθεί αισθητήρας παραθύρου", + "window_delay": "Η καθυστέρηση σε δευτερόλεπτα πριν ληφθεί υπόψη η ανίχνευση του αισθητήρα", + "window_auto_open_threshold": "Συνιστώμενη τιμή: μεταξύ 0.05 και 0.1. Αφήστε κενό αν δεν χρησιμοποιείται αυτόματη ανίχνευση ανοίγματος παραθύρου", + "window_auto_close_threshold": "Συνιστώμενη τιμή: 0. Αφήστε κενό αν δεν χρησιμοποιείται αυτόματη ανίχνευση ανοίγματος παραθύρου", + "window_auto_max_duration": "Συνιστώμενη τιμή: 60 (μία ώρα). Αφήστε κενό αν δεν χρησιμοποιείται αυτόματη ανίχνευση ανοίγματος παραθύρου" + } + }, + "motion": { + "title": "Διαχείριση Κίνησης", + "description": "Διαχείριση αισθητήρα κίνησης. Το προκαθορισμένο μπορεί να αλλάζει αυτόματα ανάλογα με ανίχνευση κίνησης\nΑφήστε το αντίστοιχο entity_id κενό αν δεν χρησιμοποιείται.\nΟι επιλογές motion_preset και no_motion_preset πρέπει να οριστούν στο αντίστοιχο όνομα προκαθορισμένου", + "data": { + "motion_sensor_entity_id": "Ταυτότητα οντότητας αισθητήρα κίνησης", + "motion_delay": "Καθυστέρηση ενεργοποίησης", + "motion_off_delay": "Καθυστέρηση απενεργοποίησης", + "motion_preset": "Προκαθορισμένο κίνησης", + "no_motion_preset": "Προκαθορισμένο χωρίς κίνηση" + }, + "data_description": { + "motion_sensor_entity_id": "Η ταυτότητα οντότητας του αισθητήρα κίνησης", + "motion_delay": "Καθυστέρηση ενεργοποίησης κίνησης (δευτερόλεπτα)", + "motion_off_delay": "Καθυστέρηση απενεργοποίησης κίνησης (δευτερόλεπτα)", + "motion_preset": "Το προκαθορισμένο που θα χρησιμοποιηθεί όταν ανιχνευθεί κίνηση", + "no_motion_preset": "Το προκαθορισμένο που θα χρησιμοποιηθεί όταν δεν ανιχνευθεί κίνηση" + } + }, + "power": { + "title": "Διαχείριση Ενέργειας", + "description": "Χαρακτηριστικά διαχείρισης ενέργειας.\nΔίνει τον αισθητήρα ενέργειας και τον μέγιστο αισθητήρα ενέργειας του σπιτιού σας.\nΣτη συνέχεια καθορίστε την κατανάλωση ενέργειας του θερμαντήρα όταν είναι ενεργοποιημένος.\nΌλοι οι αισθητήρες και η ισχύς της συσκευής πρέπει να έχουν την ίδια μονάδα (kW ή W).\nΑφήστε το αντίστοιχο entity_id κενό αν δεν χρησιμοποιείται.", + "data": { + "power_sensor_entity_id": "Ταυτότητα οντότητας αισθητήρα ενέργειας", + "max_power_sensor_entity_id": "Ταυτότητα οντότητας αισθητήρα μέγιστης ενέργειας", + "power_temp": "Θερμοκρασία για Αποβολή Ενέργειας" + } + }, + "presence": { + "title": "Διαχείριση Παρουσίας", + "description": "Χαρακτηριστικά διαχείρισης παρουσίας.\nΔίνει έναν αισθητήρα παρουσίας του σπιτιού σας (αληθές αν κάποιος είναι παρών).\nΣτη συνέχεια καθορίστε είτε το προκαθορισμένο που θα χρησιμοποιηθεί όταν ο αισθητήρας παρουσίας είναι ψευδής ή την απόκλιση στη θερμοκρασία που θα εφαρμοστεί.\nΑν δοθεί προκαθορισμένο, η απόκλιση δεν θα χρησιμοποιηθεί.\nΑφήστε το αντίστοιχο entity_id κενό αν δεν χρησιμοποιείται.", + "data": { + "presence_sensor_entity_id": "Ταυτότητα οντότητας αισθητήρα παρουσίας", + "eco_away_temp": "Θερμοκρασία στο προκαθορισμένο Eco όταν δεν υπάρχει παρουσία", + "comfort_away_temp": "Θερμοκρασία στο προκαθορισμένο Comfort όταν δεν υπάρχει παρουσία", + "boost_away_temp": "Θερμοκρασία στο προκαθορισμένο Boost όταν δεν υπάρχει παρουσία", + "frost_away_temp": "Θερμοκρασία στο προκαθορισμένο Frost protection όταν δεν υπάρχει παρουσία", + "eco_ac_away_temp": "Θερμοκρασία στο προκαθορισμένο Eco όταν δεν υπάρχει παρουσία σε λειτουργία AC", + "comfort_ac_away_temp": "Θερμοκρασία στο προκαθορισμένο Comfort όταν δεν υπάρχει παρουσία σε λειτουργία AC", + "boost_ac_away_temp": "Θερμοκρασία στο προκαθορισμένο Boost όταν δεν υπάρχει παρουσία σε λειτουργία AC" + } + }, + "advanced": { + "title": "Προχωρημένες Παράμετροι", + "description": "Διαμόρφωση των προχωρημένων παραμέτρων. Αφήστε τις προεπιλεγμένες τιμές αν δεν γνωρίζετε τι κάνετε.\nΑυτές οι παράμετροι μπορούν να οδηγήσουν σε πολύ κακή ρύθμιση θερμοκρασίας ή ενέργειας.", + "data": { + "minimal_activation_delay": "Ελάχιστη καθυστέρηση ενεργοποίησης", + "security_delay_min": "Καθυστέρηση ασφαλείας (σε λεπτά)", + "security_min_on_percent": "Ελάχιστο ποσοστό ισχύος για ενεργοποίηση λειτουργίας ασφαλείας", + "security_default_on_percent": "Ποσοστό ισχύος για χρήση σε λειτουργία ασφαλείας" + }, + "data_description": { + "minimal_activation_delay": "Καθυστέρηση σε δευτερόλεπτα κάτω από την οποία η συσκευή δεν θα ενεργοποιηθεί", + "security_delay_min": "Μέγιστη επιτρεπτή καθυστέρηση σε λεπτά μεταξύ δύο μετρήσεων θερμοκρασίας. Πέρα από αυτή την καθυστέρηση, ο θερμοστάτης θα μεταβεί σε κατάσταση ασφαλείας", + "security_min_on_percent": "Ελάχιστη τιμή ποσοστού θέρμανσης για την ενεργοποίηση του προεπιλεγμένου ασφάλειας. Κάτω από αυτό το ποσοστό ισχύος το θερμοστάτη δεν θα πάει στο προεπιλεγμένο ασφάλειας.", + "security_default_on_percent": "Η προεπιλεγμένη τιμή ποσοστού ισχύος θέρμανσης στο προεπιλεγμένο ασφάλειας. Ορίστε σε 0 για να απενεργοποιήσετε τη θερμάστρα στο παρόν ασφάλειας." + } + } + }, + "error": { + "unknown": "Απρόσμενο σφάλμα", + "unknown_entity": "Άγνωστο αναγνωριστικό οντότητας", + "window_open_detection_method": "Πρέπει να χρησιμοποιείται μόνο μία μέθοδος ανίχνευσης ανοιχτού παραθύρου. Χρησιμοποιήστε αισθητήρα ή αυτόματη ανίχνευση μέσω του κατωφλίου θερμοκρασίας, αλλά όχι και τα δύο" + }, + "abort": { + "already_configured": "Η συσκευή έχει ήδη ρυθμιστεί" + } + }, + "options": { + "flow_title": "Διαμόρφωση Ευέλικτου Θερμοστάτη", + "step": { + "user": { + "title": "Προσθήκη νέου Ευέλικτου Θερμοστάτη", + "description": "Κύρια υποχρεωτικά χαρακτηριστικά", + "data": { + "name": "Όνομα", + "thermostat_type": "Τύπος θερμοστάτη", + "temperature_sensor_entity_id": "Ταυτότητα οντότητας αισθητήρα θερμοκρασίας", + "external_temperature_sensor_entity_id": "Ταυτότητα οντότητας εξωτερικού αισθητήρα θερμοκρασίας", + "cycle_min": "Διάρκεια κύκλου (λεπτά)", + "temp_min": "Ελάχιστη επιτρεπόμενη θερμοκρασία", + "temp_max": "Μέγιστη επιτρεπόμενη θερμοκρασία", + "device_power": "Ισχύς συσκευής (kW)", + "use_window_feature": "Χρήση ανίχνευσης παραθύρου", + "use_motion_feature": "Χρήση ανίχνευσης κίνησης", + "use_power_feature": "Χρήση διαχείρισης ενέργειας", + "use_presence_feature": "Χρήση ανίχνευσης παρουσίας" + } + }, + "type": { + "title": "Συνδεδεμένες οντότητες", + "description": "Χαρακτηριστικά συνδεδεμένων οντοτήτων", + "data": { + "heater_entity_id": "1ος διακόπτης θερμαντήρα", + "heater_entity2_id": "2ος διακόπτης θερμαντήρα", + "heater_entity3_id": "3ος διακόπτης θερμαντήρα", + "heater_entity4_id": "4ος διακόπτης θερμαντήρα", + "proportional_function": "Αλγόριθμος", + "climate_entity_id": "1η υποκείμενη κλιματική οντότητα", + "climate_entity2_id": "2η υποκείμενη κλιματική οντότητα", + "climate_entity3_id": "3η υποκείμενη κλιματική οντότητα", + "climate_entity4_id": "4η υποκείμενη κλιματική οντότητα", + "ac_mode": "Λειτουργία AC", + "valve_entity_id": "1ος αριθμός βαλβίδας", + "valve_entity2_id": "2ος αριθμός βαλβίδας", + "valve_entity3_id": "3ος αριθμός βαλβίδας", + "valve_entity4_id": "4ος αριθμός βαλβίδας", + "auto_regulation_mode": "Αυτορύθμιση", + "auto_regulation_dtemp": "Όριο ρύθμισης", + "auto_regulation_periode_min": "Ελάχιστη περίοδος ρύθμισης", + "inverse_switch_command": "Αντίστροφη εντολή διακόπτη", + "auto_fan_mode": " Auto fan mode" + }, + "data_description": { + "heater_entity_id": "Υποχρεωτική ταυτότητα οντότητας θερμαντήρα", + "heater_entity2_id": "Προαιρετική ταυτότητα οντότητας 2ου θερμαντήρα. Αφήστε το κενό αν δεν χρησιμοποιείται", + "heater_entity3_id": "Προαιρετική ταυτότητα οντότητας 3ου θερμαντήρα. Αφήστε το κενό αν δεν χρησιμοποιείται", + "heater_entity4_id": "Προαιρετική ταυτότητα οντότητας 4ου θερμαντήρα. Αφήστε το κενό αν δεν χρησιμοποιείται", + "proportional_function": "Αλγόριθμος που θα χρησιμοποιηθεί (TPI είναι ο μόνος για τώρα)", + "climate_entity_id": "Ταυτότητα οντότητας υποκείμενου κλίματος", + "climate_entity2_id": "Ταυτότητα οντότητας 2ου υποκείμενου κλίματος", + "climate_entity3_id": "Ταυτότητα οντότητας 3ου υποκείμενου κλίματος", + "climate_entity4_id": "Ταυτότητα οντότητας 4ου υποκείμενου κλίματος", + "ac_mode": "Χρήση της λειτουργίας Κλιματισμού (AC)", + "valve_entity_id": "Ταυτότητα οντότητας 1ης βαλβίδας", + "valve_entity2_id": "Ταυτότητα οντότητας 2ης βαλβίδας", + "valve_entity3_id": "Ταυτότητα οντότητας 3ης βαλβίδας", + "valve_entity4_id": "Ταυτότητα οντότητας 4ης βαλβίδας", + "auto_regulation_mode": "Αυτόματη ρύθμιση της στοχευόμενης θερμοκρασίας", + "auto_regulation_dtemp": "Το κατώφλι σε °C κάτω από το οποίο η αλλαγή της θερμοκρασίας δεν θα αποστέλλεται", + "auto_regulation_periode_min": "Διάρκεια σε λεπτά μεταξύ δύο ενημερώσεων ρύθμισης", + "inverse_switch_command": "Για διακόπτες με πιλοτικό καλώδιο και δίοδο μπορεί να χρειαστεί να αντιστραφεί η εντολή", + "auto_fan_mode": " Automatically activate fan when huge heating/cooling is necessary" + } + }, + "tpi": { + "title": "TPI", + "description": "Χαρακτηριστικά Χρονικού Αναλογικού Ολοκληρωτικού (TPI)", + "data": { + "tpi_coef_int": "Συντελεστής που θα χρησιμοποιηθεί για την εσωτερική διαφορά θερμοκρασίας", + "tpi_coef_ext": "Συντελεστής που θα χρησιμοποιηθεί για την εξωτερική διαφορά θερμοκρασίας" + } + }, + "presets": { + "title": "Προεπιλογές", + "description": "Για κάθε προεπιλογή, δώστε τη στοχευόμενη θερμοκρασία (0 για να αγνοηθεί η προεπιλογή)", + "data": { + "eco_temp": "Θερμοκρασία στην οικονομική προεπιλογή", + "comfort_temp": "Θερμοκρασία στην άνετη προεπιλογή", + "boost_temp": "Θερμοκρασία στην ενισχυμένη προεπιλογή", + "frost_temp": "Θερμοκρασία στο προκαθορισμένο Frost protection", + "eco_ac_temp": "Θερμοκρασία στην οικονομική προεπιλογή για τη λειτουργία AC", + "comfort_ac_temp": "Θερμοκρασία στην άνετη προεπιλογή για τη λειτουργία AC", + "boost_ac_temp": "Θερμοκρασία στην ενισχυμένη προεπιλογή για τη λειτουργία AC" + } + }, + "window": { + "title": "Διαχείριση παραθύρου", + "description": "Διαχείριση ανοιχτού παραθύρου.\nΑφήστε την αντίστοιχη ταυτότητα οντότητας κενή αν δεν χρησιμοποιείται\nΜπορείτε επίσης να διαμορφώσετε την αυτόματη ανίχνευση ανοίγματος παραθύρου βάσει της μείωσης της θερμοκρασίας", + "data": { + "window_sensor_entity_id": "Ταυτότητα οντότητας αισθητήρα παραθύρου", + "window_delay": "Καθυστέρηση αισθητήρα παραθύρου (δευτερόλεπτα)", + "window_auto_open_threshold": "Όριο μείωσης θερμοκρασίας για αυτόματη ανίχνευση ανοίγματος παραθύρου (σε °/λεπτό)", + "window_auto_close_threshold": "Όριο αύξησης θερμοκρασίας για τέλος αυτόματης ανίχνευσης (σε °/λεπτό)", + "window_auto_max_duration": "Μέγιστη διάρκεια αυτόματης ανίχνευσης ανοίγματος παραθύρου (σε λεπτά)" + }, + "data_description": { + "window_sensor_entity_id": "Αφήστε κενό αν δεν πρέπει να χρησιμοποιηθεί αισθητήρας παραθύρου", + "window_delay": "Η καθυστέρηση σε δευτερόλεπτα πριν ληφθεί υπόψη η ανίχνευση αισθητήρα", + "window_auto_open_threshold": "Συνιστώμενη τιμή: μεταξύ 0.05 και 0.1. Αφήστε κενό αν δεν χρησιμοποιείται αυτόματη ανίχνευση ανοίγματος παραθύρου", + "window_auto_close_threshold": "Συνιστώμενη τιμή: 0. Αφήστε κενό αν δεν χρησιμοποιείται αυτόματη ανίχνευση ανοίγματος παραθύρου", + "window_auto_max_duration": "Συνιστώμενη τιμή: 60 (μία ώρα). Αφήστε κενό αν δεν χρησιμοποιείται αυτόματη ανίχνευση ανοίγματος παραθύρου" + } + }, + "motion": { + "title": "Διαχείριση κίνησης", + "description": "Διαχείριση αισθητήρα κίνησης. Ο προεπιλεγμένος τρόπος μπορεί να αλλάξει αυτόματα ανάλογα με την ανίχνευση κίνησης\nΑφήστε το αντίστοιχο entity_id κενό αν δεν χρησιμοποιείται.\nΤα motion_preset και no_motion_preset πρέπει να οριστούν στο αντίστοιχο όνομα προεπιλογής", + "data": { + "motion_sensor_entity_id": "Ανιχνευτής κίνησης entity id", + "motion_delay": "Καθυστέρηση ενεργοποίησης", + "motion_off_delay": "Καθυστέρηση απενεργοποίησης", + "motion_preset": "Προεπιλογή κίνησης", + "no_motion_preset": "Προεπιλογή χωρίς κίνηση" + }, + "data_description": { + "motion_sensor_entity_id": "Το entity id του ανιχνευτή κίνησης", + "motion_delay": "Καθυστέρηση ενεργοποίησης κίνησης (δευτερόλεπτα)", + "motion_off_delay": "Καθυστέρηση απενεργοποίησης κίνησης (δευτερόλεπτα)", + "motion_preset": "Η προεπιλογή που χρησιμοποιείται όταν ανιχνεύεται κίνηση", + "no_motion_preset": "Η προεπιλογή που χρησιμοποιείται όταν δεν ανιχνεύεται κίνηση" + } + }, + "power": { + "title": "Διαχείριση Ενέργειας", + "description": "Χαρακτηριστικά διαχείρισης ενέργειας.\nΠαρέχει τον αισθητήρα ισχύος και τον μέγιστο αισθητήρα ισχύος του σπιτιού σας.\nΣτη συνέχεια καθορίστε την κατανάλωση ενέργειας του θερμαντήρα όταν είναι ενεργοποιημένος.\nΌλοι οι αισθητήρες και η ισχύς της συσκευής πρέπει να έχουν την ίδια μονάδα (kW ή W).\nΑφήστε το αντίστοιχο entity_id κενό εάν δεν χρησιμοποιείται.", + "data": { + "power_sensor_entity_id": "Ταυτότητα οντότητας αισθητήρα ισχύος", + "max_power_sensor_entity_id": "Ταυτότητα οντότητας αισθητήρα μέγιστης ισχύος", + "power_temp": "Θερμοκρασία για Μείωση Ισχύος" + } + }, + "presence": { + "title": "Διαχείριση Παρουσίας", + "description": "Χαρακτηριστικά διαχείρισης παρουσίας.\nΠαρέχει έναν αισθητήρα παρουσίας του σπιτιού σας (αληθές εάν κάποιος είναι παρών).\nΣτη συνέχεια καθορίστε είτε το προεπιλεγμένο πρόγραμμα που θα χρησιμοποιηθεί όταν ο αισθητήρας παρουσίας είναι ψευδής είτε την θερμοκρασιακή διαφορά που θα εφαρμοστεί.\nΕάν δίνεται προεπιλογή, η διαφορά δεν θα χρησιμοποιηθεί.\nΑφήστε το αντίστοιχο entity_id κενό εάν δεν χρησιμοποιείται.", + "data": { + "presence_sensor_entity_id": "Ταυτότητα οντότητας αισθητήρα παρουσίας (αληθές είναι παρών)", + "eco_away_temp": "Θερμοκρασία στο πρόγραμμα Eco όταν δεν υπάρχει παρουσία", + "comfort_away_temp": "Θερμοκρασία στο πρόγραμμα Comfort όταν δεν υπάρχει παρουσία", + "boost_away_temp": "Θερμοκρασία στο πρόγραμμα Boost όταν δεν υπάρχει παρουσία", + "frost_away_temp": "Θερμοκρασία στο προκαθορισμένο Frost protection όταν δεν υπάρχει παρουσία", + "eco_ac_away_temp": "Θερμοκρασία στο πρόγραμμα Eco όταν δεν υπάρχει παρουσία σε λειτουργία AC", + "comfort_ac_away_temp": "Θερμοκρασία στο πρόγραμμα Comfort όταν δεν υπάρχει παρουσία σε λειτουργία AC", + "boost_ac_away_temp": "Θερμοκρασία στο πρόγραμμα Boost όταν δεν υπάρχει παρουσία σε λειτουργία AC" + } + }, + "advanced": { + "title": "Προηγμένες Παράμετροι", + "description": "Διαμόρφωση των προηγμένων παραμέτρων. Αφήστε τις προεπιλεγμένες τιμές εάν δεν γνωρίζετε τι κάνετε.\nΑυτές οι παράμετροι μπορούν να οδηγήσουν σε πολύ κακή ρύθμιση θερμοκρασίας ή ενέργειας.", + "data": { + "minimal_activation_delay": "Ελάχιστη καθυστέρηση ενεργοποίησης", + "security_delay_min": "Καθυστέρηση ασφαλείας (σε λεπτά)", + "security_min_on_percent": "Ελάχιστο ποσοστό ισχύος για τη λειτουργία ασφαλείας", + "security_default_on_percent": "Ποσοστό ισχύος που θα χρησιμοποιηθεί στη λειτουργία ασφαλείας" + }, + "data_description": { + "minimal_activation_delay": "Καθυστέρηση σε δευτερόλεπτα κάτω από την οποία ο εξοπλισμός δεν θα ενεργοποιηθεί", + "security_delay_min": "Μέγιστη επιτρεπόμενη καθυστέρηση σε λεπτά μεταξύ δύο μετρήσεων θερμοκρασίας. Πάνω από αυτή την καθυστέρηση, ο θερμοστάτης θα μεταβεί σε κατάσταση ασφαλείας", + "security_min_on_percent": "Ελάχιστη τιμή ποσοστού θέρμανσης για ενεργοποίηση του προεπιλεγμένου ασφαλείας. Κάτω από αυτό το ποσοστό ισχύος, ο θερμοστάτης δεν θα μεταβεί στο προεπιλεγμένο ασφαλείας", + "security_default_on_percent": "Η προεπιλεγμένη τιμή ποσοστού ισχύος θέρμανσης στο προεπιλεγμένο ασφαλείας. Ορίστε σε 0 για να απενεργοποιήσετε τη θερμάστρα στο παρόν ασφαλείας" + } + } + }, + "error": { + "unknown": "Απροσδόκητο λάθος", + "unknown_entity": "Άγνωστο αναγνωριστικό οντότητας", + "window_open_detection_method": "Πρέπει να χρησιμοποιηθεί μόνο μία μέθοδος ανίχνευσης ανοιχτού παραθύρου. Χρησιμοποιήστε αισθητήρα ή αυτόματη ανίχνευση μέσω κατωφλίου θερμοκρασίας αλλά όχι και τα δύο" + }, + "abort": { + "already_configured": "Η συσκευή έχει ήδη ρυθμιστεί" + } + }, + "selector": { + "thermostat_type": { + "options": { + "thermostat_over_switch": "Θερμοστάτης πάνω σε διακόπτη", + "thermostat_over_climate": "Θερμοστάτης πάνω σε κλίμα", + "thermostat_over_valve": "Θερμοστάτης πάνω σε βαλβίδα" + } + }, + "auto_regulation_mode": { + "options": { + "auto_regulation_slow": "Αργή", + "auto_regulation_strong": "Δυνατή", + "auto_regulation_medium": "Μέτρια", + "auto_regulation_light": "Ελαφριά", + "auto_regulation_expert": "Εμπειρογνώμων", + "auto_regulation_none": "Χωρίς αυτόματη ρύθμιση" + } + }, + "auto_fan_mode": { + "options": { + "auto_fan_none": "No auto fan", + "auto_fan_low": "Low", + "auto_fan_medium": "Medium", + "auto_fan_high": "High", + "auto_fan_turbo": "Turbo" + } + } + }, + "entity": { + "climate": { + "versatile_thermostat": { + "state_attributes": { + "preset_mode": { + "state": { + "power": "Μείωση", + "security": "Ασφάλεια", + "none": "Χειροκίνητο" + } + } + } + } + } + } +} \ No newline at end of file diff --git a/config/custom_components/versatile_thermostat/translations/en.json b/config/custom_components/versatile_thermostat/translations/en.json new file mode 100644 index 0000000..e6a2275 --- /dev/null +++ b/config/custom_components/versatile_thermostat/translations/en.json @@ -0,0 +1,581 @@ +{ + "title": "Versatile Thermostat configuration", + "config": { + "flow_title": "Versatile Thermostat configuration", + "step": { + "user": { + "title": "Type of Versatile Thermostat", + "data": { + "thermostat_type": "Thermostat type" + }, + "data_description": { + "thermostat_type": "Only one central configuration type is possible" + } + }, + "menu": { + "title": "Menu", + "description": "Configure your thermostat. You will be able to finalize the configuration when all required parameters are entered.", + "menu_options": { + "main": "Main attributes", + "central_boiler": "Central boiler", + "type": "Underlyings", + "tpi": "TPI parameters", + "features": "Features", + "presets": "Presets", + "window": "Window detection", + "motion": "Motion detection", + "power": "Power management", + "presence": "Presence detection", + "advanced": "Advanced parameters", + "finalize": "All done", + "configuration_not_complete": "Configuration not complete" + } + }, + "main": { + "title": "Add new Versatile Thermostat", + "description": "Main mandatory attributes", + "data": { + "name": "Name", + "thermostat_type": "Thermostat type", + "temperature_sensor_entity_id": "Room temperature", + "last_seen_temperature_sensor_entity_id": "Last seen room temperature datetime", + "external_temperature_sensor_entity_id": "Outdoor temperature sensor entity id", + "cycle_min": "Cycle duration (minutes)", + "temp_min": "Minimum temperature allowed", + "temp_max": "Maximum temperature allowed", + "step_temperature": "Temperature step", + "device_power": "Device power", + "use_central_mode": "Enable the control by central entity (requires central config). Check to enable the control of the VTherm with the select central_mode entities.", + "use_main_central_config": "Use additional central main configuration. Check to use the central main configuration (outdoor temperature, min, max, step, ...).", + "used_by_controls_central_boiler": "Used by central boiler. Check if this VTherm should have control on the central boiler" + }, + "data_description": { + "temperature_sensor_entity_id": "Room temperature sensor entity id", + "last_seen_temperature_sensor_entity_id": "Last seen room temperature sensor entity id. Should be datetime sensor", + "external_temperature_sensor_entity_id": "Outdoor temperature sensor entity id. Not used if central configuration is selected" + } + }, + "features": { + "title": "Features", + "description": "Thermostat features", + "data": { + "use_window_feature": "Use window detection", + "use_motion_feature": "Use motion detection", + "use_power_feature": "Use power management", + "use_presence_feature": "Use presence detection", + "use_central_boiler_feature": "Use a central boiler. Check to add a control to your central boiler. You will have to configure the VTherm which will have a control of the central boiler after seecting this checkbox to take effect. If one VTherm requires heating, the boiler will be turned on. If no VTherm requires heating, the boiler will be turned off. Commands for turning on/off the central boiler are given in the related configuration page" + } + }, + "type": { + "title": "Linked entities", + "description": "Linked entities attributes", + "data": { + "heater_entity_id": "1st heater switch", + "heater_entity2_id": "2nd heater switch", + "heater_entity3_id": "3rd heater switch", + "heater_entity4_id": "4th heater switch", + "heater_keep_alive": "Switch keep-alive interval in seconds", + "proportional_function": "Algorithm", + "climate_entity_id": "1st underlying climate", + "climate_entity2_id": "2nd underlying climate", + "climate_entity3_id": "3rd underlying climate", + "climate_entity4_id": "4th underlying climate", + "ac_mode": "AC mode", + "valve_entity_id": "1st valve number", + "valve_entity2_id": "2nd valve number", + "valve_entity3_id": "3rd valve number", + "valve_entity4_id": "4th valve number", + "auto_regulation_mode": "Self-regulation", + "auto_regulation_dtemp": "Regulation threshold", + "auto_regulation_periode_min": "Regulation minimum period", + "auto_regulation_use_device_temp": "Use internal temperature of the underlying", + "inverse_switch_command": "Inverse switch command", + "auto_fan_mode": " Auto fan mode" + }, + "data_description": { + "heater_entity_id": "Mandatory heater entity id", + "heater_entity2_id": "Optional 2nd Heater entity id. Leave empty if not required", + "heater_entity3_id": "Optional 3rd Heater entity id. Leave empty if not required", + "heater_entity4_id": "Optional 4th Heater entity id. Leave empty if not required", + "heater_keep_alive": "Optional heater switch state refresh interval. Leave empty if not required.", + "proportional_function": "Algorithm to use (TPI is the only one for now)", + "climate_entity_id": "Underlying climate entity id", + "climate_entity2_id": "2nd underlying climate entity id", + "climate_entity3_id": "3rd underlying climate entity id", + "climate_entity4_id": "4th underlying climate entity id", + "ac_mode": "Use the Air Conditioning (AC) mode", + "valve_entity_id": "1st valve number entity id", + "valve_entity2_id": "2nd valve number entity id", + "valve_entity3_id": "3rd valve number entity id", + "valve_entity4_id": "4th valve number entity id", + "auto_regulation_mode": "Auto adjustment of the target temperature", + "auto_regulation_dtemp": "The threshold in ° (or % for valve) under which the temperature change will not be sent", + "auto_regulation_periode_min": "Duration in minutes between two regulation update", + "auto_regulation_use_device_temp": "Use the eventual internal temperature sensor of the underlying to speedup the self-regulation", + "inverse_switch_command": "For switch with pilot wire and diode you may need to inverse the command", + "auto_fan_mode": " Automatically activate fan when huge heating/cooling is necessary" + } + }, + "tpi": { + "title": "TPI", + "description": "Time Proportional Integral attributes", + "data": { + "tpi_coef_int": "coef_int", + "tpi_coef_ext": "coef_ext", + "use_tpi_central_config": "Use central TPI configuration" + }, + "data_description": { + "tpi_coef_int": "Coefficient to use for internal temperature delta", + "tpi_coef_ext": "Coefficient to use for external temperature delta", + "use_tpi_central_config": "Check to use the central TPI configuration. Uncheck to use a specific TPI configuration for this VTherm" + } + }, + "presets": { + "title": "Presets", + "description": "Select if the thermostat will use central preset - deselect for the thermostat to have its own presets", + "data": { + "use_presets_central_config": "Use central presets configuration" + } + }, + "window": { + "title": "Window management", + "description": "Open window management.\nYou can also configure automatic window open detection based on temperature decrease", + "data": { + "window_sensor_entity_id": "Window sensor entity id", + "window_delay": "Window sensor delay (seconds)", + "window_auto_open_threshold": "Temperature decrease threshold for automatic window open detection (in °/hours)", + "window_auto_close_threshold": "Temperature increase threshold for end of automatic detection (in °/hours)", + "window_auto_max_duration": "Maximum duration of automatic window open detection (in min)", + "use_window_central_config": "Use central window configuration", + "window_action": "Action" + }, + "data_description": { + "window_sensor_entity_id": "Leave empty if no window sensor should be used and to use the automatic detection", + "window_delay": "The delay in seconds before sensor detection is taken into account", + "window_auto_open_threshold": "Recommended value: between 3 and 10. Leave empty if automatic window open detection is not used", + "window_auto_close_threshold": "Recommended value: 0. Leave empty if automatic window open detection is not used", + "window_auto_max_duration": "Recommended value: 60 (one hour). Leave empty if automatic window open detection is not used", + "use_window_central_config": "Select to use the central window configuration. Deselect to use a specific window configuration for this VTherm", + "window_action": "Action to perform if window is deteted as open" + } + }, + "motion": { + "title": "Motion management", + "description": "Motion sensor management. Preset can switch automatically depending on motion detection\nmotion_preset and no_motion_preset should be set to the corresponding preset name", + "data": { + "motion_sensor_entity_id": "Motion sensor entity id", + "motion_delay": "Activation delay", + "motion_off_delay": "Deactivation delay", + "motion_preset": "Motion preset", + "no_motion_preset": "No motion preset", + "use_motion_central_config": "Use central motion configuration" + }, + "data_description": { + "motion_sensor_entity_id": "The entity id of the motion sensor", + "motion_delay": "Motion activation delay (seconds)", + "motion_off_delay": "Motion deactivation delay (seconds)", + "motion_preset": "Preset to use when motion is detected", + "no_motion_preset": "Preset to use when no motion is detected", + "use_motion_central_config": "Check to use the central motion configuration. Uncheck to use a specific motion configuration for this VTherm" + } + }, + "power": { + "title": "Power management", + "description": "Power management attributes.\nGives the power and max power sensor of your home.\nSpecify the power consumption of the heater when on.\nAll sensors and device power should use the same unit (kW or W).", + "data": { + "power_sensor_entity_id": "Power", + "max_power_sensor_entity_id": "Max power", + "power_temp": "Shedding temperature", + "use_power_central_config": "Use central power configuration" + }, + "data_description": { + "power_sensor_entity_id": "Power sensor entity id", + "max_power_sensor_entity_id": "Max power sensor entity id", + "power_temp": "Temperature for Power shedding", + "use_power_central_config": "Check to use the central power configuration. Uncheck to use a specific power configuration for this VTherm" + } + }, + "presence": { + "title": "Presence management", + "description": "Presence management attributes.\nGives the a presence sensor of your home (true is someone is present) and give the corresponding temperature preset setting.", + "data": { + "presence_sensor_entity_id": "Presence sensor", + "use_presence_central_config": "Use central presence temperature configuration. Deselect to use specific temperature entities" + }, + "data_description": { + "presence_sensor_entity_id": "Presence sensor entity id" + } + }, + "advanced": { + "title": "Advanced parameters", + "description": "Configuration of advanced parameters. Leave the default values if you don't know what you are doing.\nThese parameters can lead to very poor temperature control or bad power regulation.", + "data": { + "minimal_activation_delay": "Minimum activation delay", + "security_delay_min": "Safety delay (in minutes)", + "security_min_on_percent": "Minimum power percent to enable safety mode", + "security_default_on_percent": "Power percent to use in safety mode", + "use_advanced_central_config": "Use central advanced configuration" + }, + "data_description": { + "minimal_activation_delay": "Delay in seconds under which the equipment will not be activated", + "security_delay_min": "Maximum allowed delay in minutes between two temperature measurements. Above this delay the thermostat will turn to a safety off state", + "security_min_on_percent": "Minimum heating percent value for safety preset activation. Below this amount of power percent the thermostat won't go into safety preset", + "security_default_on_percent": "The default heating power percent value in safety preset. Set to 0 to switch off heater in safety preset", + "use_advanced_central_config": "Check to use the central advanced configuration. Uncheck to use a specific advanced configuration for this VTherm" + } + } + }, + "error": { + "unknown": "Unexpected error", + "unknown_entity": "Unknown entity id", + "window_open_detection_method": "Only one window open detection method should be used. Use either window sensor or automatic detection through temperature threshold but not both", + "no_central_config": "You cannot select 'use central configuration' because no central configuration was found. You need to create a Versatile Thermostat of type 'Central Configuration' to use it." + }, + "abort": { + "already_configured": "Device is already configured" + } + }, + "options": { + "flow_title": "Versatile Thermostat configuration", + "step": { + "user": { + "title": "Type - {name}", + "data": { + "thermostat_type": "Thermostat type" + }, + "data_description": { + "thermostat_type": "Only one central configuration type is possible" + } + }, + "menu": { + "title": "Menu", + "description": "Configure your thermostat. You will be able to finalize the configuration when all required parameters are entered.", + "menu_options": { + "main": "Main attributes", + "central_boiler": "Central boiler", + "type": "Underlyings", + "tpi": "TPI parameters", + "features": "Features", + "presets": "Presets", + "window": "Window detection", + "motion": "Motion detection", + "power": "Power management", + "presence": "Presence detection", + "advanced": "Advanced parameters", + "finalize": "All done", + "configuration_not_complete": "Configuration not complete" + } + }, + "main": { + "title": "Main - {name}", + "description": "Main mandatory attributes", + "data": { + "name": "Name", + "thermostat_type": "Thermostat type", + "temperature_sensor_entity_id": "Room temperature", + "last_seen_temperature_sensor_entity_id": "Last seen room temperature datetime", + "external_temperature_sensor_entity_id": "Outdoor temperature sensor entity id", + "cycle_min": "Cycle duration (minutes)", + "temp_min": "Minimum temperature allowed", + "temp_max": "Maximum temperature allowed", + "step_temperature": "Temperature step", + "device_power": "Device power", + "use_central_mode": "Enable the control by central entity (requires central config). Check to enable the control of the VTherm with the select central_mode entities.", + "use_main_central_config": "Use additional central main configuration. Check to use the central main configuration (outdoor temperature, min, max, step, ...).", + "used_by_controls_central_boiler": "Used by central boiler. Check if this VTherm should have control on the central boiler" + }, + "data_description": { + "temperature_sensor_entity_id": "Room temperature sensor entity id", + "last_seen_temperature_sensor_entity_id": "Last seen room temperature sensor entity id. Should be datetime sensor", + "external_temperature_sensor_entity_id": "Outdoor temperature sensor entity id. Not used if central configuration is selected" + } + }, + "features": { + "title": "Features - {name}", + "description": "Thermostat features", + "data": { + "use_window_feature": "Use window detection", + "use_motion_feature": "Use motion detection", + "use_power_feature": "Use power management", + "use_presence_feature": "Use presence detection", + "use_central_boiler_feature": "Use a central boiler. Check to add a control to your central boiler. You will have to configure the VTherm which will have a control of the central boiler after seecting this checkbox to take effect. If one VTherm requires heating, the boiler will be turned on. If no VTherm requires heating, the boiler will be turned off. Commands for turning on/off the central boiler are given in the related configuration page" + } + }, + "type": { + "title": "Entities - {name}", + "description": "Linked entities attributes", + "data": { + "heater_entity_id": "1st heater switch", + "heater_entity2_id": "2nd heater switch", + "heater_entity3_id": "3rd heater switch", + "heater_entity4_id": "4th heater switch", + "heater_keep_alive": "Switch keep-alive interval in seconds", + "proportional_function": "Algorithm", + "climate_entity_id": "1st underlying climate", + "climate_entity2_id": "2nd underlying climate", + "climate_entity3_id": "3rd underlying climate", + "climate_entity4_id": "4th underlying climate", + "ac_mode": "AC mode", + "valve_entity_id": "1st valve number", + "valve_entity2_id": "2nd valve number", + "valve_entity3_id": "3rd valve number", + "valve_entity4_id": "4th valve number", + "auto_regulation_mode": "Self-regulation", + "auto_regulation_dtemp": "Regulation threshold", + "auto_regulation_periode_min": "Regulation minimum period", + "auto_regulation_use_device_temp": "Use internal temperature of the underlying", + "inverse_switch_command": "Inverse switch command", + "auto_fan_mode": " Auto fan mode" + }, + "data_description": { + "heater_entity_id": "Mandatory heater entity id", + "heater_entity2_id": "Optional 2nd Heater entity id. Leave empty if not used", + "heater_entity3_id": "Optional 3rd Heater entity id. Leave empty if not used", + "heater_entity4_id": "Optional 4th Heater entity id. Leave empty if not used", + "heater_keep_alive": "Optional heater switch state refresh interval. Leave empty if not required.", + "proportional_function": "Algorithm to use (TPI is the only one for now)", + "climate_entity_id": "Underlying climate entity id", + "climate_entity2_id": "2nd underlying climate entity id", + "climate_entity3_id": "3rd underlying climate entity id", + "climate_entity4_id": "4th underlying climate entity id", + "ac_mode": "Use the Air Conditioning (AC) mode", + "valve_entity_id": "1st valve number entity id", + "valve_entity2_id": "2nd valve number entity id", + "valve_entity3_id": "3rd valve number entity id", + "valve_entity4_id": "4th valve number entity id", + "auto_regulation_mode": "Auto adjustment of the target temperature", + "auto_regulation_dtemp": "The threshold in ° (or % for valve) under which the temperature change will not be sent", + "auto_regulation_periode_min": "Duration in minutes between two regulation update", + "auto_regulation_use_device_temp": "Use the eventual internal temperature sensor of the underlying to speedup the self-regulation", + "inverse_switch_command": "For switch with pilot wire and diode you may need to invert the command", + "auto_fan_mode": " Automatically activate fan when huge heating/cooling is necessary" + } + }, + "tpi": { + "title": "TPI - {name}", + "description": "Time Proportional Integral attributes", + "data": { + "tpi_coef_int": "coef_int", + "tpi_coef_ext": "coef_ext", + "use_tpi_central_config": "Use central TPI configuration" + }, + "data_description": { + "tpi_coef_int": "Coefficient to use for internal temperature delta", + "tpi_coef_ext": "Coefficient to use for external temperature delta", + "use_tpi_central_config": "Check to use the central TPI configuration. Uncheck to use a specific TPI configuration for this VTherm" + } + }, + "presets": { + "title": "Presets - {name}", + "description": "Check if the thermostat will use central presets. Uncheck and the thermostat will have its own preset entities", + "data": { + "use_presets_central_config": "Use central presets configuration" + } + }, + "window": { + "title": "Window - {name}", + "description": "Open window management.\nYou can also configure automatic window open detection based on temperature decrease", + "data": { + "window_sensor_entity_id": "Window sensor entity id", + "window_delay": "Window sensor delay (seconds)", + "window_auto_open_threshold": "Temperature decrease threshold for automatic window open detection (in °/hours)", + "window_auto_close_threshold": "Temperature increase threshold for end of automatic detection (in °/hours)", + "window_auto_max_duration": "Maximum duration of automatic window open detection (in min)", + "use_window_central_config": "Use central window configuration", + "window_action": "Action" + }, + "data_description": { + "window_sensor_entity_id": "Leave empty if no window sensor should be used and to use the automatic detection", + "window_delay": "The delay in seconds before sensor detection is taken into account", + "window_auto_open_threshold": "Recommended value: between 3 and 10. Leave empty if automatic window open detection is not used", + "window_auto_close_threshold": "Recommended value: 0. Leave empty if automatic window open detection is not used", + "window_auto_max_duration": "Recommended value: 60 (one hour). Leave empty if automatic window open detection is not used", + "use_window_central_config": "Check to use the central window configuration. Uncheck to use a specific window configuration for this VTherm", + "window_action": "Action to do if window is deteted as open" + } + }, + "motion": { + "title": "Motion - {name}", + "description": "Motion management. Preset can switch automatically depending of a motion detection\nmotion_preset and no_motion_preset should be set to the corresponding preset name", + "data": { + "motion_sensor_entity_id": "Motion sensor entity id", + "motion_delay": "Activation delay", + "motion_off_delay": "Deactivation delay", + "motion_preset": "Motion preset", + "no_motion_preset": "No motion preset", + "use_motion_central_config": "Use central motion configuration" + }, + "data_description": { + "motion_sensor_entity_id": "The entity id of the motion sensor", + "motion_delay": "Motion activation delay (seconds)", + "motion_off_delay": "Motion deactivation delay (seconds)", + "motion_preset": "Preset to use when motion is detected", + "no_motion_preset": "Preset to use when no motion is detected", + "use_motion_central_config": "Check to use the central motion configuration. Uncheck to use a specific motion configuration for this VTherm" + } + }, + "power": { + "title": "Power - {name}", + "description": "Power management attributes.\nGives the power and max power sensor of your home.\nThen specify the power consumption of the heater when on.\nAll sensors and device power should have the same unit (kW or W).", + "data": { + "power_sensor_entity_id": "Power", + "max_power_sensor_entity_id": "Max power", + "power_temp": "Shedding temperature", + "use_power_central_config": "Use central power configuration" + }, + "data_description": { + "power_sensor_entity_id": "Power sensor entity id", + "max_power_sensor_entity_id": "Max power sensor entity id", + "power_temp": "Temperature for Power shedding", + "use_power_central_config": "Check to use the central power configuration. Uncheck to use a specific power configuration for this VTherm" + } + }, + "presence": { + "title": "Presence - {name}", + "description": "Presence management attributes.\nGives the a presence sensor of your home (true is someone is present) and give the corresponding temperature preset setting.", + "data": { + "presence_sensor_entity_id": "Presence sensor", + "use_presence_central_config": "Use central presence temperature configuration. Uncheck to use specific temperature entities" + }, + "data_description": { + "presence_sensor_entity_id": "Presence sensor entity id" + } + }, + "advanced": { + "title": "Advanced - {name}", + "description": "Advanced parameters. Leave the default values if you don't know what you are doing.\nThese parameters can lead to very poor temperature control or bad power regulation.", + "data": { + "minimal_activation_delay": "Minimum activation delay", + "security_delay_min": "Safety delay (in minutes)", + "security_min_on_percent": "Minimum power percent to enable safety mode", + "security_default_on_percent": "Power percent to use in safety mode", + "use_advanced_central_config": "Use central advanced configuration" + }, + "data_description": { + "minimal_activation_delay": "Delay in seconds under which the equipment will not be activated", + "security_delay_min": "Maximum allowed delay in minutes between two temperature measurements. Above this delay the thermostat will turn to a safety off state", + "security_min_on_percent": "Minimum heating percent value for safety preset activation. Below this amount of power percent the thermostat won't go into safety preset", + "security_default_on_percent": "The default heating power percent value in safety preset. Set to 0 to switch off heater in safety preset", + "use_advanced_central_config": "Check to use the central advanced configuration. Uncheck to use a specific advanced configuration for this VTherm" + } + } + }, + "error": { + "unknown": "Unexpected error", + "unknown_entity": "Unknown entity id", + "window_open_detection_method": "Only one window open detection method should be used. Use either window sensor or automatic detection through temperature threshold but not both", + "no_central_config": "You cannot check 'use central configuration' because no central configuration was found. You need to create a Versatile Thermostat of type 'Central Configuration' to use it.", + "service_configuration_format": "The format of the service configuration is wrong" + }, + "abort": { + "already_configured": "Device is already configured" + } + }, + "selector": { + "thermostat_type": { + "options": { + "thermostat_central_config": "Central configuration", + "thermostat_over_switch": "Thermostat over a switch", + "thermostat_over_climate": "Thermostat over a climate", + "thermostat_over_valve": "Thermostat over a valve" + } + }, + "auto_regulation_mode": { + "options": { + "auto_regulation_slow": "Slow", + "auto_regulation_strong": "Strong", + "auto_regulation_medium": "Medium", + "auto_regulation_light": "Light", + "auto_regulation_expert": "Expert", + "auto_regulation_none": "No auto-regulation" + } + }, + "auto_fan_mode": { + "options": { + "auto_fan_none": "No auto fan", + "auto_fan_low": "Low", + "auto_fan_medium": "Medium", + "auto_fan_high": "High", + "auto_fan_turbo": "Turbo" + } + }, + "window_action": { + "options": { + "window_turn_off": "Turn off", + "window_fan_only": "Fan only", + "window_frost_temp": "Frost protect", + "window_eco_temp": "Eco" + } + }, + "presets": { + "options": { + "frost": "Frost protect", + "eco": "Eco", + "comfort": "Comfort", + "boost": "Boost" + } + } + }, + "entity": { + "climate": { + "versatile_thermostat": { + "state_attributes": { + "preset_mode": { + "state": { + "power": "Shedding", + "security": "Safety", + "none": "Manual" + } + } + } + } + }, + "number": { + "frost_temp": { + "name": "Frost" + }, + "eco_temp": { + "name": "Eco" + }, + "comfort_temp": { + "name": "Comfort" + }, + "boost_temp": { + "name": "Boost" + }, + "frost_ac_temp": { + "name": "Frost ac" + }, + "eco_ac_temp": { + "name": "Eco ac" + }, + "comfort_ac_temp": { + "name": "Comfort ac" + }, + "boost_ac_temp": { + "name": "Boost ac" + }, + "frost_away_temp": { + "name": "Frost away" + }, + "eco_away_temp": { + "name": "Eco away" + }, + "comfort_away_temp": { + "name": "Comfort away" + }, + "boost_away_temp": { + "name": "Boost away" + }, + "eco_ac_away_temp": { + "name": "Eco ac away" + }, + "comfort_ac_away_temp": { + "name": "Comfort ac away" + }, + "boost_ac_away_temp": { + "name": "Boost ac away" + } + } + } +} \ No newline at end of file diff --git a/config/custom_components/versatile_thermostat/translations/fr.json b/config/custom_components/versatile_thermostat/translations/fr.json new file mode 100644 index 0000000..ab42f6e --- /dev/null +++ b/config/custom_components/versatile_thermostat/translations/fr.json @@ -0,0 +1,599 @@ +{ + "title": "Versatile Thermostat configuration", + "config": { + "flow_title": "Versatile Thermostat configuration", + "step": { + "user": { + "title": "Type du nouveau Versatile Thermostat", + "data": { + "thermostat_type": "Type de thermostat" + }, + "data_description": { + "thermostat_type": "Un seul thermostat de type Configuration centrale est possible." + } + }, + "menu": { + "title": "Menu", + "description": "Paramétrez votre thermostat. Vous pourrez finaliser la configuration quand tous les paramètres auront été saisis.", + "menu_options": { + "main": "Principaux Attributs", + "central_boiler": "Chauffage central", + "type": "Sous-jacents", + "tpi": "Paramètres TPI", + "features": "Fonctions", + "presets": "Pre-réglages", + "window": "Détection d'ouverture", + "motion": "Détection de mouvement", + "power": "Gestion de la puissance", + "presence": "Détection de présence", + "advanced": "Paramètres avancés", + "finalize": "Finaliser la création", + "configuration_not_complete": "Configuration incomplète" + } + }, + "main": { + "title": "Ajout d'un nouveau thermostat", + "description": "Principaux attributs obligatoires", + "data": { + "name": "Nom", + "thermostat_type": "Type de thermostat", + "temperature_sensor_entity_id": "Capteur de température", + "last_seen_temperature_sensor_entity_id": "Dernière vue capteur de température", + "external_temperature_sensor_entity_id": "Capteur de température exterieure", + "cycle_min": "Durée du cycle (minutes)", + "temp_min": "Température minimale permise", + "temp_max": "Température maximale permise", + "step_temperature": "Pas de température", + "device_power": "Puissance de l'équipement", + "use_central_mode": "Autoriser le controle par une entity centrale ('nécessite une config. centrale`). Cochez pour autoriser le contrôle du VTherm par la liste déroulante 'central_mode' de l'entité configuration centrale.", + "use_main_central_config": "Utiliser la configuration centrale supplémentaire. Cochez pour utiliser la configuration centrale supplémentaire (température externe, min, max, pas, ...)", + "used_by_controls_central_boiler": "Utilisé par la chaudière centrale. Cochez si ce VTherm doit contrôler la chaudière centrale." + }, + "data_description": { + "temperature_sensor_entity_id": "Id d'entité du capteur de température", + "last_seen_temperature_sensor_entity_id": "Id d'entité du capteur donnant la date et heure de dernière vue capteur de température. L'état doit être au format date heure (ex: 2024-03-31T17:07:03+00:00)", + "external_temperature_sensor_entity_id": "Entity id du capteur de température extérieure. Non utilisé si une configuration centrale est définie" + } + }, + "features": { + "title": "Fonctions", + "description": "Fonctions du thermostat à utiliser", + "data": { + "use_window_feature": "Avec détection des ouvertures", + "use_motion_feature": "Avec détection de mouvement", + "use_power_feature": "Avec gestion de la puissance", + "use_presence_feature": "Avec détection de présence", + "use_central_boiler_feature": "Ajouter une chaudière centrale. Cochez pour ajouter un controle sur une chaudière centrale. Vous devrez ensuite configurer les VTherms qui commande la chaudière centrale pour que cette option prenne effet. Si au moins un des VTherm a besoin de chauffer, la chaudière centrale sera activée. Si aucun VTherm n'a besoin de chauffer, elle sera éteinte. Les commandes pour allumer/éteindre la chaudière centrale sont données dans la page de configuration suivante." + } + }, + "type": { + "title": "Entité(s) liée(s)", + "description": "Attributs de(s) l'entité(s) liée(s)", + "data": { + "heater_entity_id": "1er radiateur", + "heater_entity2_id": "2ème radiateur", + "heater_entity3_id": "3ème radiateur", + "heater_entity4_id": "4ème radiateur", + "heater_keep_alive": "keep-alive (sec)", + "proportional_function": "Algorithme", + "climate_entity_id": "Thermostat sous-jacent", + "climate_entity2_id": "2ème thermostat sous-jacent", + "climate_entity3_id": "3ème thermostat sous-jacent", + "climate_entity4_id": "4ème thermostat sous-jacent", + "ac_mode": "AC mode ?", + "valve_entity_id": "1ère valve number", + "valve_entity2_id": "2ème valve number", + "valve_entity3_id": "3ème valve number", + "valve_entity4_id": "4ème valve number", + "auto_regulation_mode": "Auto-régulation", + "auto_regulation_dtemp": "Seuil de régulation", + "auto_regulation_periode_min": "Période minimale de régulation", + "auto_regulation_use_device_temp": "Utiliser la température interne du sous-jacent", + "inverse_switch_command": "Inverser la commande", + "auto_fan_mode": " Auto ventilation mode" + }, + "data_description": { + "heater_entity_id": "Entity id du 1er radiateur obligatoire", + "heater_entity2_id": "Optionnel entity id du 2ème radiateur", + "heater_entity3_id": "Optionnel entity id du 3ème radiateur", + "heater_entity4_id": "Optionnel entity id du 4ème radiateur", + "heater_keep_alive": "Intervalle de rafraichissement du switch en secondes. Laisser vide pour désactiver. À n'utiliser que pour les switchs qui le nécessite.", + "proportional_function": "Algorithme à utiliser (Seul TPI est disponible pour l'instant)", + "climate_entity_id": "Entity id du thermostat sous-jacent", + "climate_entity2_id": "Entity id du 2ème thermostat sous-jacent", + "climate_entity3_id": "Entity id du 3ème thermostat sous-jacent", + "climate_entity4_id": "Entity id du 4ème thermostat sous-jacent", + "ac_mode": "Utilisation du mode Air Conditionné (AC)", + "valve_entity_id": "Entity id de la 1ère valve", + "valve_entity2_id": "Entity id de la 2ème valve", + "valve_entity3_id": "Entity id de la 3ème valve", + "valve_entity4_id": "Entity id de la 4ème valve", + "auto_regulation_mode": "Ajustement automatique de la température cible", + "auto_regulation_dtemp": "Le seuil en ° (ou % pour les valves) en-dessous duquel la régulation ne sera pas envoyée", + "auto_regulation_periode_min": "La durée en minutes entre deux mise à jour faites par la régulation", + "auto_regulation_use_device_temp": "Utiliser la temperature interne du sous-jacent pour accélérer l'auto-régulation", + "inverse_switch_command": "Inverse la commande du switch pour une installation avec fil pilote et diode", + "auto_fan_mode": "Active la ventilation automatiquement en cas d'écart important" + } + }, + "tpi": { + "title": "TPI", + "description": "Attributs de l'algo Time Proportional Integral", + "data": { + "tpi_coef_int": "coeff_int", + "tpi_coef_ext": "coeff_ext", + "use_tpi_central_config": "Utiliser la configuration TPI centrale" + }, + "data_description": { + "tpi_coef_int": "Coefficient à utiliser pour le delta de température interne", + "tpi_coef_ext": "Coefficient à utiliser pour le delta de température externe", + "use_tpi_central_config": "Cochez pour utiliser la configuration TPI centrale. Décochez et saisissez les attributs pour utiliser une configuration TPI spécifique" + } + }, + "presets": { + "title": "Pre-réglages", + "description": "Cochez pour que ce thermostat utilise les pré-réglages de la configuration centrale. Décochez pour utiliser des entités de température spécifiques", + "data": { + "use_presets_central_config": "Utiliser la configuration des pré-réglages centrale" + } + }, + "window": { + "title": "Gestion d'une ouverture", + "description": "Coupe le radiateur si l'ouverture est ouverte.\nLaissez l'id d'entité vide pour utiliser la détection automatique.", + "data": { + "window_sensor_entity_id": "Détecteur d'ouverture (entity id)", + "window_delay": "Délai avant extinction (secondes)", + "window_auto_open_threshold": "Seuil haut de chute de température pour la détection automatique (en °/heure)", + "window_auto_close_threshold": "Seuil bas de chute de température pour la fin de détection automatique (en °/heure)", + "window_auto_max_duration": "Durée maximum d'une extinction automatique (en min)", + "use_window_central_config": "Utiliser la configuration centrale des ouvertures", + "window_action": "Action" + }, + "data_description": { + "window_sensor_entity_id": "Laissez vide si vous n'avez de détecteur et pour utiliser la détection automatique", + "window_delay": "Le délai (en secondes) avant que le changement du détecteur soit pris en compte", + "window_auto_open_threshold": "Valeur recommandée: entre 3 et 10. Laissez vide si vous n'utilisez pas la détection automatique", + "window_auto_close_threshold": "Valeur recommandée: 0. Laissez vide si vous n'utilisez pas la détection automatique", + "window_auto_max_duration": "Valeur recommandée: 60 (1 heure). Laissez vide si vous n'utilisez pas la détection automatique", + "use_window_central_config": "Cochez pour utiliser la configuration centrale des ouvertures. Décochez et saisissez les attributs pour utiliser une configuration spécifique des ouvertures", + "window_action": "Action a effectuer si la fenêtre est détectée comme ouverte" + } + }, + "motion": { + "title": "Gestion de la détection de mouvement", + "description": "Le preset s'ajuste automatiquement si un mouvement est détecté\n'Preset mouvement' et 'Preset sans mouvement' doivent être choisis avec les preset à utiliser.", + "data": { + "motion_sensor_entity_id": "Détecteur de mouvement", + "motion_delay": "Délai d'activation", + "motion_off_delay": "Délai de désactivation", + "motion_preset": "Preset si mouvement", + "no_motion_preset": "Preset sans mouvement", + "use_motion_central_config": "Utiliser la condfiguration centrale du mouvement" + }, + "data_description": { + "motion_sensor_entity_id": "Id d'entité du détecteur de mouvement", + "motion_delay": "Délai avant activation lorsqu'un mouvement est détecté (secondss)", + "motion_off_delai": "Délai avant désactivation lorsqu'aucun mouvement n'est détecté (secondes)", + "motion_preset": "Preset à utiliser si mouvement détecté", + "no_motion_preset": "Preset à utiliser si pas de mouvement détecté", + "use_motion_central_config": "Cochez pour utiliser la configuration centrale du mouvement. Décochez et saisissez les attributs pour utiliser une configuration spécifique du mouvement" + } + }, + "power": { + "title": "Gestion de la puissance", + "description": "Sélectionne automatiquement le preset 'power' si la puissance consommée est supérieure à un maximum.\nDonnez les entity id des capteurs qui mesurent la puissance totale et la puissance max autorisée.\nEnsuite donnez la puissance de l'équipement.\nTous les capteurs et la puissance consommée par l'équipement doivent avoir la même unité de mesure (kW ou W).", + "data": { + "power_sensor_entity_id": "Capteur de puissance totale (entity id)", + "max_power_sensor_entity_id": "Capteur de puissance Max (entity id)", + "power_temp": "Température si délestaqe", + "use_power_central_config": "Utiliser la configuration centrale de la puissance" + }, + "data_description": { + "power_sensor_entity_id": "Entity id du capteur de puissance totale du logement", + "max_power_sensor_entity_id": "Entity id du capteur de puissance Max autorisée avant délestage", + "power_temp": "Température cible si délestaqe", + "use_power_central_config": "Cochez pour utiliser la configuration centrale de la puissance. Décochez et saisissez les attributs pour utiliser une configuration spécifique de la puissance" + } + }, + "presence": { + "title": "Gestion de la présence", + "description": "Donnez un capteur de présence (true si quelqu'un est présent) et les températures cibles à utiliser en cas d'abs.", + "data": { + "presence_sensor_entity_id": "Capteur de présence", + "use_presence_central_config": "Utiliser la configuration centrale des températures en cas d'absence. Décochez pour avoir des entités de température dédiées" + }, + "data_description": { + "presence_sensor_entity_id": "Id d'entité du capteur de présence" + } + }, + "advanced": { + "title": "Parameters avancés", + "description": "Configuration des paramètres avancés. Laissez les valeurs par défaut si vous ne savez pas ce que vous faites.\nCes paramètres peuvent induire des mauvais comportements du thermostat.", + "data": { + "minimal_activation_delay": "Délai minimal d'activation", + "security_delay_min": "Délai maximal entre 2 mesures de températures", + "security_min_on_percent": "Pourcentage minimal de puissance", + "security_default_on_percent": "Pourcentage de puissance a utiliser en mode securité", + "use_advanced_central_config": "Utiliser la configuration centrale avancée" + }, + "data_description": { + "minimal_activation_delay": "Délai en seondes en-dessous duquel l'équipement ne sera pas activé", + "security_delay_min": "Délai maximal autorisé en minutes entre 2 mesures de températures. Au-dessus de ce délai, le thermostat se mettra en position de sécurité", + "security_min_on_percent": "Seuil minimal de pourcentage de chauffage en-dessous duquel le préréglage sécurité ne sera jamais activé", + "security_default_on_percent": "Valeur par défaut pour le pourcentage de chauffage en mode sécurité. Mettre 0 pour éteindre le radiateur en mode sécurité", + "use_advanced_central_config": "Cochez pour utiliser la configuration centrale avancée. Décochez et saisissez les attributs pour utiliser une configuration spécifique avancée" + } + }, + "central_boiler": { + "title": "Contrôle de la chaudière centrale", + "description": "Donnez les services à appeler pour allumer/éteindre la chaudière centrale. Laissez vide, si aucun appel de service ne doit être effectué (dans ce cas, vous devrez gérer vous même l'allumage/extinction de votre chaudière centrale). Le service a appelé doit être formatté comme suit: `entity_id/service_name[/attribut:valeur]` (/attribut:valeur est facultatif)\nPar exemple:\n- pour allumer un switch: `switch.controle_chaudiere/switch.turn_on`\n- pour éteindre un switch: `switch.controle_chaudiere/switch.turn_off`\n- pour programmer la chaudière sur 25° et ainsi forcer son allumage: `climate.thermostat_chaudiere/climate.set_temperature/temperature:25`\n- pour envoyer 10° à la chaudière et ainsi forcer son extinction: `climate.thermostat_chaudiere/climate.set_temperature/temperature:10`", + "data": { + "central_boiler_activation_service": "Commande pour allumer", + "central_boiler_deactivation_service": "Commande pour éteindre" + }, + "data_description": { + "central_boiler_activation_service": "Commande à éxecuter pour allumer la chaudière centrale au format entity_id/service_name[/attribut:valeur]", + "central_boiler_deactivation_service": "Commande à éxecuter pour étiendre la chaudière centrale au format entity_id/service_name[/attribut:valeur]" + } + } + }, + "error": { + "unknown": "Erreur inattendue", + "unknown_entity": "entity id inconnu", + "window_open_detection_method": "Une seule méthode de détection des ouvertures ouvertes doit être utilisée. Utilisez le détecteur d'ouverture ou les seuils de température mais pas les deux.", + "no_central_config": "Vous ne pouvez pas cocher 'Utiliser la configuration centrale' car aucune configuration centrale n'a été trouvée. Vous devez créer un Versatile Thermostat de type 'Central Configuration' pour pouvoir l'utiliser." + }, + "abort": { + "already_configured": "Le device est déjà configuré" + } + }, + "options": { + "flow_title": "Versatile Thermostat configuration", + "step": { + "user": { + "title": "Type - {name}", + "data": { + "thermostat_type": "Type de thermostat" + }, + "data_description": { + "thermostat_type": "Un seul thermostat de type Configuration centrale est possible." + } + }, + "menu": { + "title": "Menu", + "description": "Paramétrez votre thermostat. Vous pourrez finaliser la configuration quand tous les paramètres auront été saisis.", + "menu_options": { + "main": "Principaux Attributs", + "central_boiler": "Chauffage central", + "type": "Sous-jacents", + "tpi": "Paramètres TPI", + "features": "Fonctions", + "presets": "Pre-réglages", + "window": "Détection d'ouvertures", + "motion": "Détection de mouvement", + "power": "Gestion de la puissance", + "presence": "Détection de présence", + "advanced": "Paramètres avancés", + "finalize": "Finaliser les modifications", + "configuration_not_complete": "Configuration incomplète" + } + }, + "main": { + "title": "Attributs - {name}", + "description": "Principaux attributs obligatoires", + "data": { + "name": "Nom", + "thermostat_type": "Type de thermostat", + "temperature_sensor_entity_id": "Capteur de température", + "last_seen_temperature_sensor_entity_id": "Dernière vue capteur de température", + "external_temperature_sensor_entity_id": "Capteur de température exterieure", + "cycle_min": "Durée du cycle (minutes)", + "temp_min": "Température minimale permise", + "temp_max": "Température maximale permise", + "step_temperature": "Pas de température", + "device_power": "Puissance de l'équipement", + "use_central_mode": "Autoriser le controle par une entity centrale ('nécessite une config. centrale`). Cochez pour autoriser le contrôle du VTherm par la liste déroulante 'central_mode' de l'entité configuration centrale.", + "use_main_central_config": "Utiliser la configuration centrale supplémentaire. Cochez pour utiliser la configuration centrale supplémentaire (température externe, min, max, pas, ...)", + "used_by_controls_central_boiler": "Utilisé par la chaudière centrale. Cochez si ce VTherm doit contrôler la chaudière centrale." + }, + "data_description": { + "temperature_sensor_entity_id": "Id d'entité du capteur de température", + "last_seen_temperature_sensor_entity_id": "Id d'entité du capteur donnant la date et heure de dernière vue capteur de température. L'état doit être au format date heure (ex: 2024-03-31T17:07:03+00:00)", + "external_temperature_sensor_entity_id": "Entity id du capteur de température extérieure. Non utilisé si une configuration centrale est définie" + } + }, + "features": { + "title": "Fonctions - {name}", + "description": "Fonctions du thermostat à utiliser", + "data": { + "use_window_feature": "Avec détection des ouvertures", + "use_motion_feature": "Avec détection de mouvement", + "use_power_feature": "Avec gestion de la puissance", + "use_presence_feature": "Avec détection de présence", + "use_central_boiler_feature": "Ajouter une chaudière centrale. Cochez pour ajouter un controle sur une chaudière centrale. Vous devrez ensuite configurer les VTherms qui commande la chaudière centrale pour que cette option prenne effet. Si au moins un des VTherm a besoin de chauffer, la chaudière centrale sera activée. Si aucun VTherm n'a besoin de chauffer, elle sera éteinte. Les commandes pour allumer/éteindre la chaudière centrale sont données dans la page de configuration suivante." + } + }, + "type": { + "title": "Entités - {name}", + "description": "Attributs de(s) l'entité(s) liée(s)", + "data": { + "heater_entity_id": "1er radiateur", + "heater_entity2_id": "2ème radiateur", + "heater_entity3_id": "3ème radiateur", + "heater_entity4_id": "4ème radiateur", + "heater_keep_alive": "Keep-alive (sec)", + "proportional_function": "Algorithme", + "climate_entity_id": "Thermostat sous-jacent", + "climate_entity2_id": "2ème thermostat sous-jacent", + "climate_entity3_id": "3ème thermostat sous-jacent", + "climate_entity4_id": "4ème thermostat sous-jacent", + "ac_mode": "AC mode ?", + "valve_entity_id": "1ère valve", + "valve_entity2_id": "2ème valve", + "valve_entity3_id": "3ème valve", + "valve_entity4_id": "4ème valve", + "auto_regulation_mode": "Auto-regulation", + "auto_regulation_dtemp": "Seuil de régulation", + "auto_regulation_periode_min": "Période minimale de régulation", + "auto_regulation_use_device_temp": "Utiliser la température interne du sous-jacent", + "inverse_switch_command": "Inverser la commande", + "auto_fan_mode": " Auto fan mode" + }, + "data_description": { + "heater_entity_id": "Entity id du 1er radiateur obligatoire", + "heater_entity2_id": "Optionnel entity id du 2ème radiateur", + "heater_entity3_id": "Optionnel entity id du 3ème radiateur", + "heater_entity4_id": "Optionnel entity id du 4ème radiateur", + "heater_keep_alive": "Intervalle de rafraichissement du switch en secondes. Laisser vide pour désactiver. À n'utiliser que pour les switchs qui le nécessite.", + "proportional_function": "Algorithme à utiliser (Seul TPI est disponible pour l'instant)", + "climate_entity_id": "Entity id du thermostat sous-jacent", + "climate_entity2_id": "Entity id du 2ème thermostat sous-jacent", + "climate_entity3_id": "Entity id du 3ème thermostat sous-jacent", + "climate_entity4_id": "Entity id du 4ème thermostat sous-jacent", + "ac_mode": "Utilisation du mode Air Conditionné (AC)", + "valve_entity_id": "Entity id de la 1ère valve", + "valve_entity2_id": "Entity id de la 2ème valve", + "valve_entity3_id": "Entity id de la 3ème valve", + "valve_entity4_id": "Entity id de la 4ème valve", + "auto_regulation_mode": "Ajustement automatique de la consigne", + "auto_regulation_dtemp": "Le seuil en ° (ou % pour les valves) en-dessous duquel la régulation ne sera pas envoyée", + "auto_regulation_periode_min": "La durée en minutes entre deux mise à jour faites par la régulation", + "auto_regulation_use_device_temp": "Utiliser la temperature interne du sous-jacent pour accélérer l'auto-régulation", + "inverse_switch_command": "Inverse la commande du switch pour une installation avec fil pilote et diode", + "auto_fan_mode": "Active la ventilation automatiquement en cas d'écart important" + } + }, + "tpi": { + "title": "TPI - {name}", + "description": "Attributs de l'algo Time Proportional Integral", + "data": { + "tpi_coef_int": "coeff_int : Coefficient à utiliser pour le delta de température interne", + "tpi_coef_ext": "coeff_ext : Coefficient à utiliser pour le delta de température externe" + } + }, + "presets": { + "title": "Pre-réglages - {name}", + "description": "Cochez pour que ce thermostat utilise les pré-réglages de la configuration centrale. Décochez pour utiliser des entités de température spécifiques", + "data": { + "use_presets_central_config": "Utiliser la configuration des pré-réglages centrale" + } + }, + "window": { + "title": "Ouverture - {name}", + "description": "Coupe le radiateur si l'ouverture est ouverte.\nLaissez l'id d'entité vide pour utiliser la détection automatique.", + "data": { + "window_sensor_entity_id": "Détecteur d'ouverture (entity id)", + "window_delay": "Délai avant extinction (secondes)", + "window_auto_open_threshold": "Seuil haut de chute de température pour la détection automatique (en °/heure)", + "window_auto_close_threshold": "Seuil bas de chute de température pour la fin de détection automatique (en °/heure)", + "window_auto_max_duration": "Durée maximum d'une extinction automatique (en min)", + "use_window_central_config": "Utiliser la configuration centrale des ouvertures", + "window_action": "Action" + }, + "data_description": { + "window_sensor_entity_id": "Laissez vide si vous n'avez de détecteur et pour utiliser la détection automatique", + "window_delay": "Le délai (en secondes) avant que le changement du détecteur soit pris en compte", + "window_auto_open_threshold": "Valeur recommandée: entre 3 et 10. Laissez vide si vous n'utilisez pas la détection automatique", + "window_auto_close_threshold": "Valeur recommandée: 0. Laissez vide si vous n'utilisez pas la détection automatique", + "window_auto_max_duration": "Valeur recommandée: 60 (1 heure). Laissez vide si vous n'utilisez pas la détection automatique", + "use_window_central_config": "Cochez pour utiliser la configuration centrale des ouvertures. Décochez et saisissez les attributs pour utiliser une configuration spécifique des ouvertures", + "window_action": "Action a effectuer si la fenêtre est détectée comme ouverte" + } + }, + "motion": { + "title": "Mouvement - {name}", + "description": "Gestion du mouvement. Le preset s'ajuste automatiquement si un mouvement est détecté\n'Preset mouvement' et 'Preset sans mouvement' doivent être choisis avec les preset à utiliser.", + "data": { + "motion_sensor_entity_id": "Détecteur de mouvement", + "motion_delay": "Délai d'activation", + "motion_off_delay": "Délai de désactivation", + "motion_preset": "Preset si mouvement", + "no_motion_preset": "Preset sans mouvement", + "use_motion_central_config": "Utiliser la condfiguration centrale du mouvement" + }, + "data_description": { + "motion_sensor_entity_id": "Id d'entité du détecteur de mouvement", + "motion_delay": "Délai avant activation lorsqu'un mouvement est détecté (secondss)", + "motion_off_delai": "Délai avant désactivation lorsqu'aucun mouvement n'est détecté (secondes)", + "motion_preset": "Preset à utiliser si mouvement détecté", + "no_motion_preset": "Preset à utiliser si pas de mouvement détecté", + "use_motion_central_config": "Cochez pour utiliser la configuration centrale du mouvement. Décochez et saisissez les attributs pour utiliser une configuration spécifique du mouvement" + } + }, + "power": { + "title": "Puissance - {name}", + "description": "Gestion de la puissance. Sélectionne automatiquement le preset 'power' si la puissance consommée est supérieure à un maximum. Tous les capteurs et la puissance consommée par l'équipement doivent avoir la même unité de mesure (kW ou W).", + "data": { + "power_sensor_entity_id": "Puissance totale", + "max_power_sensor_entity_id": "Capteur de puissance Max (entity id)", + "power_temp": "Température si délestaqe", + "use_power_central_config": "Utiliser la configuration centrale de la puissance" + }, + "data_description": { + "power_sensor_entity_id": "Entity id du capteur de puissance totale du logement", + "max_power_sensor_entity_id": "Entity id du capteur de puissance Max autorisée avant délestage", + "power_temp": "Température cible si délestaqe", + "use_power_central_config": "Cochez pour utiliser la configuration centrale de la puissance. Décochez et saisissez les attributs pour utiliser une configuration spécifique de la puissance" + } + }, + "presence": { + "title": "Présence - {name}", + "description": "Donnez un capteur de présence (true si quelqu'un est présent) et les températures cibles à utiliser en cas d'abs.", + "data": { + "presence_sensor_entity_id": "Capteur de présence", + "use_presence_central_config": "Utiliser la configuration centrale des températures en cas d'absence. Décochez pour avoir des entités de température dédiées" + }, + "data_description": { + "presence_sensor_entity_id": "Id d'entité du capteur de présence" + } + }, + "advanced": { + "title": "Avancés - {name}", + "description": "Paramètres avancés. Laissez les valeurs par défaut si vous ne savez pas ce que vous faites.\nCes paramètres peuvent induire des mauvais comportements du thermostat.", + "data": { + "minimal_activation_delay": "Délai minimal d'activation", + "security_delay_min": "Délai maximal entre 2 mesures de températures", + "security_min_on_percent": "Pourcentage minimal de puissance", + "security_default_on_percent": "Pourcentage de puissance a utiliser en mode securité", + "use_advanced_central_config": "Utiliser la configuration centrale avancée" + }, + "data_description": { + "minimal_activation_delay": "Délai en seondes en-dessous duquel l'équipement ne sera pas activé", + "security_delay_min": "Délai maximal autorisé en minutes entre 2 mesures de températures. Au-dessus de ce délai, le thermostat se mettra en position de sécurité", + "security_min_on_percent": "Seuil minimal de pourcentage de chauffage en-dessous duquel le préréglage sécurité ne sera jamais activé", + "security_default_on_percent": "Valeur par défaut pour le pourcentage de chauffage en mode sécurité. Mettre 0 pour éteindre le radiateur en mode sécurité", + "use_advanced_central_config": "Cochez pour utiliser la configuration centrale avancée. Décochez et saisissez les attributs pour utiliser une configuration spécifique avancée" + } + }, + "central_boiler": { + "title": "Contrôle de la chaudière centrale - {name}", + "description": "Donnez les services à appeler pour allumer/éteindre la chaudière centrale. Laissez vide, si aucun appel de service ne doit être effectué (dans ce cas, vous devrez gérer vous même l'allumage/extinction de votre chaudière centrale). Le service a appelé doit être formatté comme suit: `entity_id/service_name[/attribut:valeur]` (/attribut:valeur est facultatif)\nPar exemple:\n- pour allumer un switch: `switch.controle_chaudiere/switch.turn_on`\n- pour éteindre un switch: `switch.controle_chaudiere/switch.turn_off`\n- pour programmer la chaudière sur 25° et ainsi forcer son allumage: `climate.thermostat_chaudiere/climate.set_temperature/temperature:25`\n- pour envoyer 10° à la chaudière et ainsi forcer son extinction: `climate.thermostat_chaudiere/climate.set_temperature/temperature:10`", + "data": { + "central_boiler_activation_service": "Commande pour allumer", + "central_boiler_deactivation_service": "Commande pour éteindre" + }, + "data_description": { + "central_boiler_activation_service": "Commande à éxecuter pour allumer la chaudière centrale au format entity_id/service_name[/attribut:valeur]", + "central_boiler_deactivation_service": "Commande à éxecuter pour étiendre la chaudière centrale au format entity_id/service_name[/attribut:valeur]" + } + } + }, + "error": { + "unknown": "Erreur inattendue", + "unknown_entity": "entity id inconnu", + "window_open_detection_method": "Une seule méthode de détection des ouvertures ouvertes doit être utilisée. Utilisez le détecteur d'ouverture ou les seuils de température mais pas les deux.", + "no_central_config": "Vous ne pouvez pas cocher 'Utiliser la configuration centrale' car aucune configuration centrale n'a été trouvée. Vous devez créer un Versatile Thermostat de type 'Central Configuration' pour pouvoir l'utiliser.", + "service_configuration_format": "Mauvais format de la configuration du service" + }, + "abort": { + "already_configured": "Le device est déjà configuré" + } + }, + "selector": { + "thermostat_type": { + "options": { + "thermostat_central_config": "Configuration centrale", + "thermostat_over_switch": "Thermostat sur un switch", + "thermostat_over_climate": "Thermostat sur un autre thermostat", + "thermostat_over_valve": "Thermostat sur une valve" + } + }, + "auto_regulation_mode": { + "options": { + "auto_regulation_slow": "Lente", + "auto_regulation_strong": "Forte", + "auto_regulation_medium": "Moyenne", + "auto_regulation_light": "Légère", + "auto_regulation_expert": "Expert", + "auto_regulation_none": "Aucune" + } + }, + "auto_fan_mode": { + "options": { + "auto_fan_none": "Pas d'auto fan", + "auto_fan_low": "Faible", + "auto_fan_medium": "Moyenne", + "auto_fan_high": "Forte", + "auto_fan_turbo": "Turbo" + } + }, + "window_action": { + "options": { + "window_turn_off": "Eteindre", + "window_fan_only": "Ventilateur seul", + "window_frost_temp": "Hors gel", + "window_eco_temp": "Eco" + } + }, + "presets": { + "options": { + "frost": "Hors-gel", + "eco": "Eco", + "comfort": "Confort", + "boost": "Renforcé (boost)" + } + } + }, + "entity": { + "climate": { + "versatile_thermostat": { + "state_attributes": { + "preset_mode": { + "state": { + "power": "Délestage", + "security": "Sécurité", + "none": "Manuel" + } + } + } + } + }, + "number": { + "frost_temp": { + "name": "Hors gel " + }, + "eco_temp": { + "name": "Eco" + }, + "comfort_temp": { + "name": "Confort" + }, + "boost_temp": { + "name": "Boost" + }, + "frost_ac_temp": { + "name": "Hors gel clim" + }, + "eco_ac_temp": { + "name": "Eco clim" + }, + "comfort_ac_temp": { + "name": "Confort clim" + }, + "boost_ac_temp": { + "name": "Boost clim" + }, + "frost_away_temp": { + "name": "Hors gel abs" + }, + "eco_away_temp": { + "name": "Eco abs" + }, + "comfort_away_temp": { + "name": "Confort abs" + }, + "boost_away_temp": { + "name": "Boost abs" + }, + "eco_ac_away_temp": { + "name": "Eco clim abs" + }, + "comfort_ac_away_temp": { + "name": "Confort clim abs" + }, + "boost_ac_away_temp": { + "name": "Boost clim abs" + } + } + } +} \ No newline at end of file diff --git a/config/custom_components/versatile_thermostat/translations/it.json b/config/custom_components/versatile_thermostat/translations/it.json new file mode 100644 index 0000000..f93147b --- /dev/null +++ b/config/custom_components/versatile_thermostat/translations/it.json @@ -0,0 +1,374 @@ +{ + "title": "Configurazione Versatile Thermostat", + "config": { + "flow_title": "Configurazione Versatile Thermostat", + "step": { + "user": { + "title": "Aggiungi un nuovo Versatile Thermostat", + "description": "Principali parametri obbligatori", + "data": { + "name": "Nome", + "thermostat_type": "Tipologia di termostato", + "temperature_sensor_entity_id": "Entity id sensore temperatura", + "external_temperature_sensor_entity_id": "Entity id sensore temperatura esterna", + "cycle_min": "Durata del ciclo (minuti)", + "temp_min": "Temperatura minima ammessa", + "temp_max": "Temperatura massima ammessa", + "device_power": "Potenza dispositivo (kW)", + "use_window_feature": "Usa il rilevamento della finestra", + "use_motion_feature": "Usa il rilevamento del movimento", + "use_power_feature": "Usa la gestione della potenza", + "use_presence_feature": "Usa il rilevamento della presenza" + } + }, + "type": { + "title": "Entità collegate", + "description": "Parametri entità collegate", + "data": { + "heater_entity_id": "Primo riscaldatore", + "heater_entity2_id": "Secondo riscaldatore", + "heater_entity3_id": "Terzo riscaldatore", + "heater_entity4_id": "Quarto riscaldatore", + "heater_keep_alive": "Intervallo keep-alive dell'interruttore in secondi", + "proportional_function": "Algoritmo", + "climate_entity_id": "Primo termostato", + "climate_entity2_id": "Secondo termostato", + "climate_entity3_id": "Terzo termostato", + "climate_entity4_id": "Quarto termostato", + "ac_mode": "AC mode ?", + "valve_entity_id": "Prima valvola", + "valve_entity2_id": "Seconda valvolao", + "valve_entity3_id": "Terza valvola", + "valve_entity4_id": "Quarta valvola", + "auto_regulation_mode": "Autoregolamentazione", + "inverse_switch_command": "Comando inverso", + "auto_fan_mode": " Auto fan mode" + }, + "data_description": { + "heater_entity_id": "Entity id obbligatoria del primo riscaldatore", + "heater_entity2_id": "Entity id del secondo riscaldatore facoltativo. Lasciare vuoto se non utilizzato", + "heater_entity3_id": "Entity id del terzo riscaldatore facoltativo. Lasciare vuoto se non utilizzato", + "heater_entity4_id": "Entity id del quarto riscaldatore facoltativo. Lasciare vuoto se non utilizzato", + "heater_keep_alive": "Frequenza di aggiornamento dell'interruttore (facoltativo). Lasciare vuoto se non richiesto.", + "proportional_function": "Algoritmo da utilizzare (il TPI per adesso è l'unico)", + "climate_entity_id": "Entity id del primo termostato", + "climate_entity2_id": "Entity id del secondo termostato", + "climate_entity3_id": "Entity id del terzo termostato", + "climate_entity4_id": "Entity id del quarto termostato", + "ac_mode": "Utilizzare la modalità AC (Air Conditioned) ?", + "valve_entity_id": "Entity id della prima valvola", + "valve_entity2_id": "Entity id della seconda valvola", + "valve_entity3_id": "Entity id della terza valvola", + "valve_entity4_id": "Entity id della quarta valvola", + "auto_regulation_mode": "Regolazione automatica della temperatura target", + "inverse_switch_command": "Inverte il controllo dell'interruttore per un'installazione con filo pilota e diodo", + "auto_fan_mode": " Automatically activate fan when huge heating/cooling is necessary" + } + }, + "tpi": { + "title": "TPI", + "description": "Parametri del Time Proportional Integral", + "data": { + "tpi_coef_int": "Coefficiente per il delta della temperatura interna", + "tpi_coef_ext": "Coefficiente per il delta della temperatura esterna" + } + }, + "presets": { + "title": "Presets", + "description": "Per ogni preset, impostare la temperatura desiderata (0 per ignorare il preset)", + "data": { + "eco_temp": "Temperatura nel preset Eco", + "comfort_temp": "Temperatura nel preset Comfort", + "boost_temp": "Temperatura nel preset Boost", + "frost_temp": "Temperatura nel preset Frost protection", + "eco_ac_temp": "Temperatura nel preset Eco (AC mode)", + "comfort_ac_temp": "Temperatura nel preset Comfort (AC mode)", + "boost_ac_temp": "Temperatura nel preset Boost (AC mode)" + } + }, + "window": { + "title": "Gestione della finestra", + "description": "Gestione della finestra aperta.\nLasciare vuoto l'entity_id corrispondente se non utilizzato\nÈ inoltre possibile configurare il rilevamento automatico della finestra aperta in base alla diminuzione della temperatura", + "data": { + "window_sensor_entity_id": "Entity id sensore finestra", + "window_delay": "Ritardo sensore finestra (secondi)", + "window_auto_open_threshold": "Soglia di diminuzione della temperatura per il rilevamento automatico della finestra aperta (in °/ora)", + "window_auto_close_threshold": "Soglia di aumento della temperatura per la fine del rilevamento automatico (in °/ora)", + "window_auto_max_duration": "Durata massima del rilevamento automatico della finestra aperta (in min)" + }, + "data_description": { + "window_sensor_entity_id": "Lasciare vuoto se non deve essere utilizzato alcun sensore finestra", + "window_delay": "Ritardo in secondi prima che il rilevamento del sensore sia preso in considerazione", + "window_auto_open_threshold": "Valore consigliato: tra 3 e 10. Lasciare vuoto se il rilevamento automatico della finestra aperta non è utilizzato", + "window_auto_close_threshold": "Valore consigliato: 0. Lasciare vuoto se il rilevamento automatico della finestra aperta non è utilizzato", + "window_auto_max_duration": "Valore consigliato: 60 (un'ora). Lasciare vuoto se il rilevamento automatico della finestra aperta non è utilizzato" + } + }, + "motion": { + "title": "Gestione movimento", + "description": "Gestione sensore movimento. Il preset può cambiare automaticamente a seconda di un rilevamento di movimento\nLasciare vuoto l'entity_id corrispondente se non utilizzato.\nmotion_preset e no_motion_preset devono essere impostati con il nome del preset corrispondente", + "data": { + "motion_sensor_entity_id": "Entity id sensore di movimento", + "motion_delay": "Ritardo in secondi prima che il rilevamento del sensore sia preso in considerazione", + "motion_off_delay": "Ritardo in secondi di disattivazione prima che del sensore sia preso in considerazione", + "motion_preset": "Preset da utilizzare quando viene rilevato il movimento", + "no_motion_preset": "Preset da utilizzare quando non viene rilevato il movimento" + } + }, + "power": { + "title": "Gestione dell'energia", + "description": "Parametri di gestione dell'energia.\nInserire la potenza massima disponibile e l'entity_id del sensore che la misura.\nQuindi inserire il consumo del riscaldatore quando è in funzione.\nTutti i parametri devono essere nella stessa unità di misura (kW o W).\nLasciare vuoto l'entity_id corrispondente se non utilizzato.", + "data": { + "power_sensor_entity_id": "Entity id sensore potenza", + "max_power_sensor_entity_id": "Entity id sensore di massima potenza", + "power_temp": "Temperatura in caso di distribuzione del carico" + } + }, + "presence": { + "title": "Gestione della presenza", + "description": "Parametri di gestione della presenza.\nInserire un sensore di presenza (true se è presente qualcuno).\nQuindi specificare il preset o la riduzione di temperatura da utilizzare quando il sensore di presenza è in false.\nSe è impostato il preset, la riduzione non sarà utilizzata.\nLasciare vuoto l'entity_id corrispondente se non utilizzato.", + "data": { + "presence_sensor_entity_id": "Entity id sensore presenza (true se è presente qualcuno)", + "eco_away_temp": "Temperatura al preset Eco in caso d'assenza", + "comfort_away_temp": "Temperatura al preset Comfort in caso d'assenza", + "boost_away_temp": "Temperatura al preset Boost in caso d'assenza", + "frost_away_temp": "Temperatura al preset Frost protection in caso d'assenza", + "eco_ac_away_temp": "Temperatura al preset Eco in caso d'assenza (AC mode)", + "comfort_ac_away_temp": "Temperatura al preset Comfort in caso d'assenza (AC mode)", + "boost_ac_away_temp": "Temperatura al preset Boost in caso d'assenza (AC mode)" + } + }, + "advanced": { + "title": "Parametri avanzati", + "description": "Configurazione avanzata dei parametri. Lasciare i valori predefiniti se non conoscete cosa state modificando.\nQuesti parametri possono determinare una pessima gestione della temperatura e della potenza.", + "data": { + "minimal_activation_delay": "Ritardo minimo di accensione", + "security_delay_min": "Ritardo di sicurezza (in minuti)", + "security_min_on_percent": "Percentuale minima di potenza per la modalità di sicurezza", + "security_default_on_percent": "Percentuale di potenza per la modalità di sicurezza" + }, + "data_description": { + "minimal_activation_delay": "Ritardo in secondi al di sotto del quale l'apparecchiatura non verrà attivata", + "security_delay_min": "Ritardo massimo consentito in minuti tra due misure di temperatura. Al di sopra di questo ritardo, il termostato passerà allo stato di sicurezza", + "security_min_on_percent": "Soglia percentuale minima di riscaldamento al di sotto della quale il preset di sicurezza non verrà mai attivato", + "security_default_on_percent": "Valore percentuale predefinito della potenza di riscaldamento nella modalità di sicurezza. Impostare a 0 per spegnere il riscaldatore nella modalità di sicurezza" + } + } + }, + "error": { + "unknown": "Errore inatteso", + "unknown_entity": "Entity id sconosciuta", + "window_open_detection_method": "Può essere utilizzato un solo metodo di rilevamento finestra aperta. Utilizzare il sensore od il rilevamento automatico ma non entrambi" + }, + "abort": { + "already_configured": "Il dispositivo è già configurato" + } + }, + "options": { + "flow_title": "Configurazione di Versatile Thermostat", + "step": { + "user": { + "title": "Aggiungi un nuovo Versatile Thermostat", + "description": "Principali attributi obbligatori", + "data": { + "name": "Nome", + "thermostat_type": "Tipologia termostato", + "temperature_sensor_entity_id": "Entity id sensore di temperatura", + "external_temperature_sensor_entity_id": "Entity id sensore temperatura esterna", + "cycle_min": "Durata del ciclo (minuti)", + "temp_min": "Temperatura minima consentita", + "temp_max": "Temperatura massima consentita", + "device_power": "Potenza dispositivo (kW)", + "use_window_feature": "Usa il rilevamento della finestra", + "use_motion_feature": "Usa il rilevamento del movimento", + "use_power_feature": "Usa la gestione della potenza", + "use_presence_feature": "Usa il rilevamento della presenza" + } + }, + "type": { + "title": "Entità collegate", + "description": "Parametri entità collegate", + "data": { + "heater_entity_id": "Primo riscaldatore", + "heater_entity2_id": "Secondo riscaldatore", + "heater_entity3_id": "Terzo riscaldatore", + "heater_entity4_id": "Quarto riscaldatore", + "heater_keep_alive": "Intervallo keep-alive dell'interruttore in secondi", + "proportional_function": "Algoritmo", + "climate_entity_id": "Primo termostato", + "climate_entity2_id": "Secondo termostato", + "climate_entity3_id": "Terzo termostato", + "climate_entity4_id": "Quarto termostato", + "ac_mode": "AC mode ?", + "valve_entity_id": "Prima valvola", + "valve_entity2_id": "Seconda valvola", + "valve_entity3_id": "Terza valvola", + "valve_entity4_id": "Quarta valvola", + "auto_regulation_mode": "Autoregolamentazione", + "inverse_switch_command": "Comando inverso", + "auto_fan_mode": " Auto fan mode" + }, + "data_description": { + "heater_entity_id": "Entity id obbligatoria del primo riscaldatore", + "heater_entity2_id": "Entity id del secondo riscaldatore facoltativo. Lasciare vuoto se non utilizzato", + "heater_entity3_id": "Entity id del terzo riscaldatore facoltativo. Lasciare vuoto se non utilizzato", + "heater_entity4_id": "Entity id del quarto riscaldatore facoltativo. Lasciare vuoto se non utilizzato", + "heater_keep_alive": "Frequenza di aggiornamento dell'interruttore (facoltativo). Lasciare vuoto se non richiesto.", + "proportional_function": "Algoritmo da utilizzare (il TPI per adesso è l'unico)", + "climate_entity_id": "Entity id del primo termostato", + "climate_entity2_id": "Entity id del secondo termostato", + "climate_entity3_id": "Entity id del terzo termostato", + "climate_entity4_id": "Entity id del quarto termostato", + "ac_mode": "Utilizzare la modalità AC (Air Conditioned) ?", + "valve_entity_id": "Entity id della prima valvola", + "valve_entity2_id": "Entity id della seconda valvola", + "valve_entity3_id": "Entity id della terza valvola", + "valve_entity4_id": "Entity id della quarta valvola", + "auto_regulation_mode": "Autoregolamentazione", + "inverse_switch_command": "Inverte il controllo dell'interruttore per un'installazione con filo pilota e diodo", + "auto_fan_mode": " Automatically activate fan when huge heating/cooling is necessary" + } + }, + "tpi": { + "title": "TPI", + "description": "Parametri del Time Proportional Integral", + "data": { + "tpi_coef_int": "Coefficiente per il delta della temperatura interna", + "tpi_coef_ext": "Coefficiente per il delta della temperatura esterna" + } + }, + "presets": { + "title": "Presets", + "description": "Per ogni preset, impostare la temperatura desiderata (0 per ignorare il preset)", + "data": { + "eco_temp": "Temperatura nel preset Eco", + "comfort_temp": "Temperatura nel preset Comfort", + "boost_temp": "Temperatura nel preset Boost", + "frost_temp": "Temperatura nel preset Frost protection", + "eco_ac_temp": "Temperatura nel preset Eco (AC mode)", + "comfort_ac_temp": "Temperatura nel preset Comfort (AC mode)", + "boost_ac_temp": "Temperatura nel preset Boost (AC mode)" + } + }, + "window": { + "title": "Gestione della finestra", + "description": "Gestione della finestra aperta.\nLasciare vuoto l'entity_id corrispondente se non utilizzato\nÈ inoltre possibile configurare il rilevamento automatico della finestra aperta in base alla diminuzione della temperatura", + "data": { + "window_sensor_entity_id": "Entity id sensore finestra", + "window_delay": "Ritardo sensore finestra (secondi)", + "window_auto_open_threshold": "Soglia di diminuzione della temperatura per il rilevamento automatico della finestra aperta (in °/ora)", + "window_auto_close_threshold": "Soglia di aumento della temperatura per la fine del rilevamento automatico (in °/ora)", + "window_auto_max_duration": "Durata massima del rilevamento automatico della finestra aperta (in min)" + }, + "data_description": { + "window_sensor_entity_id": "Lasciare vuoto se non deve essere utilizzato alcun sensore finestra", + "window_delay": "Ritardo in secondi prima che il rilevamento del sensore sia preso in considerazione", + "window_auto_open_threshold": "Valore consigliato: tra 3 e 10. Lasciare vuoto se il rilevamento automatico della finestra aperta non è utilizzato", + "window_auto_close_threshold": "Valore consigliato: 0. Lasciare vuoto se il rilevamento automatico della finestra aperta non è utilizzato", + "window_auto_max_duration": "Valore consigliato: 60 (un'ora). Lasciare vuoto se il rilevamento automatico della finestra aperta non è utilizzato" + } + }, + "motion": { + "title": "Gestione movimento", + "description": "Gestione sensore movimento. Il preset può cambiare automaticamente a seconda di un rilevamento di movimento\nLasciare vuoto l'entity_id corrispondente se non utilizzato.\nmotion_preset e no_motion_preset devono essere impostati con il nome del preset corrispondente", + "data": { + "motion_sensor_entity_id": "Entity id sensore di movimento", + "motion_delay": "Ritardo in secondi prima che il rilevamento del sensore sia preso in considerazione", + "motion_off_delay": "Ritardo in secondi di disattivazione prima che del sensore sia preso in considerazione", + "motion_preset": "Preset da utilizzare quando viene rilevato il movimento", + "no_motion_preset": "Preset da utilizzare quando non viene rilevato il movimento" + } + }, + "power": { + "title": "Gestione dell'energia", + "description": "Parametri di gestione dell'energia.\nInserire la potenza massima disponibile e l'entity_id del sensore che la misura.\nQuindi inserire il consumo del riscaldatore quando è in funzione.\nTutti i parametri devono essere nella stessa unità di misura (kW o W).\nLasciare vuoto l'entity_id corrispondente se non utilizzato.", + "data": { + "power_sensor_entity_id": "Entity id sensore potenza", + "max_power_sensor_entity_id": "Entity id sensore di massima potenza", + "power_temp": "Temperatura in caso di distribuzione del carico" + } + }, + "presence": { + "title": "Gestione della presenza", + "description": "Parametri di gestione della presenza.\nInserire un sensore di presenza (true se è presente qualcuno).\nQuindi specificare il preset o la riduzione di temperatura da utilizzare quando il sensore di presenza è in false.\nSe è impostato il preset, la riduzione non sarà utilizzata.\nLasciare vuoto l'entity_id corrispondente se non utilizzato.", + "data": { + "presence_sensor_entity_id": "Entity id sensore presenza (true se è presente qualcuno)", + "eco_away_temp": "Temperatura al preset Eco in caso d'assenza", + "comfort_away_temp": "Temperatura al preset Comfort in caso d'assenza", + "boost_away_temp": "Temperatura al preset Boost in caso d'assenza", + "frost_away_temp": "Temperatura al preset Frost protection in caso d'assenza", + "eco_ac_away_temp": "Temperatura al preset Eco in caso d'assenza (AC mode)", + "comfort_ac_away_temp": "Temperatura al preset Comfort in caso d'assenza (AC mode)", + "boost_ac_away_temp": "Temperatura al preset Boost in caso d'assenza (AC mode)" + } + }, + "advanced": { + "title": "Parametri avanzati", + "description": "Configurazione avanzata dei parametri. Lasciare i valori predefiniti se non conoscete cosa state modificando.\nQuesti parametri possono determinare una pessima gestione della temperatura e della potenza.", + "data": { + "minimal_activation_delay": "Ritardo minimo di accensione", + "security_delay_min": "Ritardo di sicurezza (in minuti)", + "security_min_on_percent": "Percentuale minima di potenza per la modalità di sicurezza", + "security_default_on_percent": "Percentuale di potenza per la modalità di sicurezza" + }, + "data_description": { + "minimal_activation_delay": "Ritardo in secondi al di sotto del quale l'apparecchiatura non verrà attivata", + "security_delay_min": "Ritardo massimo consentito in minuti tra due misure di temperatura. Al di sopra di questo ritardo, il termostato passerà allo stato di sicurezza", + "security_min_on_percent": "Soglia percentuale minima di riscaldamento al di sotto della quale il preset di sicurezza non verrà mai attivato", + "security_default_on_percent": "Valore percentuale predefinito della potenza di riscaldamento nella modalità di sicurezza. Impostare a 0 per spegnere il riscaldatore nella modalità di sicurezza" + } + } + }, + "error": { + "unknown": "Errore inatteso", + "unknown_entity": "Entity id sconosciuta", + "window_open_detection_method": "Può essere utilizzato un solo metodo di rilevamento finestra aperta. Utilizzare il sensore od il rilevamento automatico ma non entrambi" + }, + "abort": { + "already_configured": "Il dispositivo è già configurato" + } + }, + "selector": { + "thermostat_type": { + "options": { + "thermostat_over_switch": "Termostato su un interruttore", + "thermostat_over_climate": "Termostato su un climatizzatore", + "thermostat_over_valve": "Termostato su una valvola" + } + }, + "auto_regulation_mode": { + "options": { + "auto_regulation_slow": "Lento", + "auto_regulation_strong": "Forte", + "auto_regulation_medium": "Media", + "auto_regulation_light": "Leggera", + "auto_regulation_expert": "Esperto", + "auto_regulation_none": "Nessuna autoregolamentazione" + } + }, + "auto_fan_mode": { + "options": { + "auto_fan_none": "Nessune autofan", + "auto_fan_low": "Leggera", + "auto_fan_medium": "Media", + "auto_fan_high": "Forte", + "auto_fan_turbo": "Turbo" + } + } + }, + "entity": { + "climate": { + "versatile_thermostat": { + "state_attributes": { + "preset_mode": { + "state": { + "power": "Ripartizione", + "security": "Sicurezza", + "none": "Manuale" + } + } + } + } + } + } +} \ No newline at end of file diff --git a/config/custom_components/versatile_thermostat/translations/sk.json b/config/custom_components/versatile_thermostat/translations/sk.json new file mode 100644 index 0000000..622d850 --- /dev/null +++ b/config/custom_components/versatile_thermostat/translations/sk.json @@ -0,0 +1,511 @@ +{ + "title": "Všestranná konfigurácia termostatu", + "config": { + "flow_title": "Všestranná konfigurácia termostatu", + "step": { + "user": { + "title": "Typ všestranného termostatu", + "data": { + "thermostat_type": "Typ termostatu" + }, + "data_description": { + "thermostat_type": "Len jeden centrálny typ konfigurácie je možný" + } + }, + "main": { + "title": "Pridajte nový všestranný termostat", + "description": "Hlavné povinné atribúty", + "data": { + "name": "Názov", + "thermostat_type": "Termostat typ", + "temperature_sensor_entity_id": "ID entity snímača teploty", + "external_temperature_sensor_entity_id": "ID entity externého snímača teploty", + "cycle_min": "Trvanie cyklu (minúty)", + "temp_min": "Minimálna povolená teplota", + "temp_max": "Maximálna povolená teplota", + "device_power": "Napájanie zariadenia", + "use_central_mode": "Povoliť ovládanie centrálnou entitou (potrebná centrálna konfigurácia)", + "use_window_feature": "Použite detekciu okien", + "use_motion_feature": "Použite detekciu pohybu", + "use_power_feature": "Použite správu napájania", + "use_presence_feature": "Použite detekciu prítomnosti", + "use_main_central_config": "Použite centrálnu hlavnú konfiguráciu" + }, + "data_description": { + "use_central_mode": "Zaškrtnutím povolíte ovládanie VTherm pomocou vybraných entít central_mode", + "use_main_central_config": "Začiarknite, ak chcete použiť centrálnu hlavnú konfiguráciu. Zrušte začiarknutie, ak chcete použiť špecifickú hlavnú konfiguráciu pre tento VTherm", + "external_temperature_sensor_entity_id": "ID entity snímača vonkajšej teploty. Nepoužíva sa, ak je zvolená centrálna konfigurácia" + } + }, + "type": { + "title": "Prepojené entity", + "description": "Atribúty prepojených entít", + "data": { + "heater_entity_id": "1. spínač ohrievača", + "heater_entity2_id": "2. spínač ohrievača", + "heater_entity3_id": "3. spínač ohrievača", + "heater_entity4_id": "4. spínač ohrievača", + "proportional_function": "Algoritmus", + "climate_entity_id": "1. základná klíma", + "climate_entity2_id": "2. základná klíma", + "climate_entity3_id": "3. základná klíma", + "climate_entity4_id": "4. základná klíma", + "ac_mode": "AC režim", + "valve_entity_id": "1. ventil číslo", + "valve_entity2_id": "2. ventil číslo", + "valve_entity3_id": "3. ventil číslo", + "valve_entity4_id": "4. ventil číslo", + "auto_regulation_mode": "Samoregulácia", + "auto_regulation_dtemp": "Regulačný prah", + "auto_regulation_periode_min": "Regulačné minimálne obdobie", + "inverse_switch_command": "Inverzný prepínací príkaz", + "auto_fan_mode": "Režim automatického ventilátora" + }, + "data_description": { + "heater_entity_id": "ID entity povinného ohrievača", + "heater_entity2_id": "Voliteľné ID entity 2. ohrievača. Ak sa nepoužíva, nechajte prázdne", + "heater_entity3_id": "Voliteľné ID entity 3. ohrievača. Ak sa nepoužíva, nechajte prázdne", + "heater_entity4_id": "Voliteľné ID entity 4. ohrievača. Ak sa nepoužíva, nechajte prázdne", + "proportional_function": "Algoritmus, ktorý sa má použiť (TPI je zatiaľ jediný)", + "climate_entity_id": "ID základnej klimatickej entity", + "climate_entity2_id": "2. základné identifikačné číslo klimatickej entity", + "climate_entity3_id": "3. základné identifikačné číslo klimatickej entity", + "climate_entity4_id": "4. základné identifikačné číslo klimatickej entity", + "ac_mode": "Použite režim klimatizácie (AC)", + "valve_entity_id": "1. ventil číslo entity id", + "valve_entity2_id": "2. ventil číslo entity id", + "valve_entity3_id": "3. ventil číslo entity id", + "valve_entity4_id": "4. ventil číslo entity id", + "auto_regulation_mode": "Automatické nastavenie cieľovej teploty", + "auto_regulation_dtemp": "Hranica v °, pod ktorou sa zmena teploty neodošle", + "auto_regulation_periode_min": "Trvanie v minútach medzi dvoma aktualizáciami predpisov", + "inverse_switch_command": "V prípade spínača s pilotným vodičom a diódou možno budete musieť príkaz invertovať", + "auto_fan_mode": "Automaticky aktivujte ventilátor, keď je potrebné veľké vykurovanie/chladenie" + } + }, + "tpi": { + "title": "TPI", + "description": "Časovo proporcionálne integrálne atribúty", + "data": { + "tpi_coef_int": "Koeficient na použitie pre vnútornú teplotnú deltu", + "tpi_coef_ext": "Koeficient na použitie pre deltu vonkajšej teploty", + "use_tpi_central_config": "Použite centrálnu konfiguráciu TPI" + }, + "data_description": { + "tpi_coef_int": "Koeficient na použitie pre vnútornú teplotnú deltu", + "tpi_coef_ext": "Koeficient na použitie pre deltu vonkajšej teploty", + "use_tpi_central_config": "Začiarknite, ak chcete použiť centrálnu konfiguráciu TPI. Zrušte začiarknutie, ak chcete použiť špecifickú konfiguráciu TPI pre tento VTherm" + } + }, + "presets": { + "title": "Predvoľby", + "description": "Pre každú predvoľbu zadajte cieľovú teplotu (0, ak chcete predvoľbu ignorovať)", + "data": { + "eco_temp": "Teplota v predvoľbe Eco", + "comfort_temp": "Prednastavená teplota v komfortnom režime", + "boost_temp": "Teplota v prednastavení Boost", + "frost_temp": "Teplota v prednastavení Frost protection", + "eco_ac_temp": "Teplota v režime Eco prednastavená pre režim AC", + "comfort_ac_temp": "Teplota v režime Comfort je prednastavená pre režim AC", + "boost_ac_temp": "Prednastavená teplota v režime Boost pre režim AC", + "use_presets_central_config": "Použite konfiguráciu centrálnych predvolieb" + }, + "data_description": { + "eco_temp": "Teplota v predvoľbe Eco", + "comfort_temp": "Prednastavená teplota v komfortnom režime", + "boost_temp": "Teplota v prednastavení Boost", + "frost_temp": "Teplota v prednastavenej ochrane proti mrazu", + "eco_ac_temp": "Teplota v režime Eco prednastavená pre režim AC", + "comfort_ac_temp": "Teplota v režime Comfort je prednastavená pre režim AC", + "boost_ac_temp": "Prednastavená teplota v režime Boost pre režim AC", + "use_presets_central_config": "Začiarknite, ak chcete použiť konfiguráciu centrálnych predvolieb. Zrušte začiarknutie, ak chcete použiť špecifickú konfiguráciu predvolieb pre tento VTherm" + } + }, + "window": { + "title": "Správa okien", + "description": "Otvoriť správu okien.\nAk sa príslušné entity_id nepoužíva, ponechajte prázdne\nMôžete tiež nakonfigurovať automatickú detekciu otvoreného okna na základe poklesu teploty", + "data": { + "window_sensor_entity_id": "ID entity snímača okna", + "window_delay": "Oneskorenie snímača okna (sekundy)", + "window_auto_open_threshold": "Prah poklesu teploty pre automatickú detekciu otvoreného okna (v °/hodina)", + "window_auto_close_threshold": "Prahová hodnota zvýšenia teploty pre koniec automatickej detekcie (v °/hodina)", + "window_auto_max_duration": "Maximálne trvanie automatickej detekcie otvoreného okna (v min)", + "use_window_central_config": "Použite centrálnu konfiguráciu okna" + }, + "data_description": { + "window_sensor_entity_id": "Nechajte prázdne, ak nemáte použiť žiadny okenný senzor", + "window_delay": "Zohľadňuje sa oneskorenie v sekundách pred detekciou snímača", + "window_auto_open_threshold": "Odporúčaná hodnota: medzi 3 a 10. Ak sa nepoužíva automatická detekcia otvoreného okna, nechajte prázdne", + "window_auto_close_threshold": "Odporúčaná hodnota: 0. Ak sa nepoužíva automatická detekcia otvoreného okna, nechajte prázdne", + "window_auto_max_duration": "Odporúčaná hodnota: 60 (jedna hodina). Ak sa nepoužíva automatická detekcia otvoreného okna, nechajte prázdne", + "use_window_central_config": "Začiarknite, ak chcete použiť konfiguráciu centrálneho okna. Zrušte začiarknutie, ak chcete použiť špecifickú konfiguráciu okna pre tento VTherm" + } + }, + "motion": { + "title": "Riadenie pohybu", + "description": "Správa snímača pohybu. Predvoľba sa môže automaticky prepínať v závislosti od detekcie pohybu\nAk sa nepoužíva, ponechajte zodpovedajúce entity_id prázdne.\nmotion_preset a no_motion_preset by mali byť nastavené na zodpovedajúci názov predvoľby", + "data": { + "motion_sensor_entity_id": "ID entity snímača pohybu", + "motion_delay": "Oneskorenie aktivácie", + "motion_off_delay": "Oneskorenie deaktivácie", + "motion_preset": "Prednastavený pohyb", + "no_motion_preset": "Žiadna predvoľba pohybu", + "use_motion_central_config": "Použite centrálnu konfiguráciu pohybu" + }, + "data_description": { + "motion_sensor_entity_id": "ID entity snímača pohybu", + "motion_delay": "Oneskorenie aktivácie pohybu (sekundy)", + "motion_off_delay": "Oneskorenie deaktivácie pohybu (sekundy)", + "motion_preset": "Prednastavené na použitie pri detekcii pohybu", + "no_motion_preset": "Prednastavené na použitie, keď nie je detekovaný žiadny pohyb", + "use_motion_central_config": "Začiarknite, ak chcete použiť konfiguráciu centrálneho pohybu. Zrušte začiarknutie, ak chcete použiť špecifickú konfiguráciu pohybu pre tento VTherm" + } + }, + "power": { + "title": "Správa napájania", + "description": "Atribúty správy napájania.\nPoskytuje senzor výkonu a maximálneho výkonu vášho domova.\nPotom zadajte spotrebu energie ohrievača, keď je zapnutý.\nVšetky senzory a výkon zariadenia by mali mať rovnakú jednotku (kW alebo W).\nPonechajte zodpovedajúce entity_id prázdne ak sa nepoužíva.", + "data": { + "power_sensor_entity_id": "ID entity snímača výkonu", + "max_power_sensor_entity_id": "ID entity snímača maximálneho výkonu", + "power_temp": "Teplota pre zníženie výkonu", + "use_power_central_config": "Použite centrálnu konfiguráciu napájania" + }, + "data_description": { + "power_sensor_entity_id": "ID entity snímača výkonu", + "max_power_sensor_entity_id": "ID entity snímača maximálneho výkonu", + "power_temp": "Teplota pre zníženie výkonu", + "use_power_central_config": "Začiarknite, ak chcete použiť konfiguráciu centrálneho napájania. Zrušte začiarknutie, ak chcete použiť špecifickú konfiguráciu napájania pre tento VTherm" + } + }, + "presence": { + "title": "Riadenie prítomnosti", + "description": "Atribúty správy prítomnosti.\nPoskytuje senzor prítomnosti vášho domova (pravda, ak je niekto prítomný).\nPotom zadajte buď predvoľbu, ktorá sa má použiť, keď je senzor prítomnosti nepravdivý, alebo posun teploty, ktorý sa má použiť.\nAk je zadaná predvoľba, posun sa nepoužije.\nAk sa nepoužije, ponechajte zodpovedajúce entity_id prázdne.", + "data": { + "presence_sensor_entity_id": "ID entity senzora prítomnosti", + "eco_away_temp": "Teplota v prednastavenej Eco, keď nie je žiadna prítomnosť", + "comfort_away_temp": "Teplota v režime Comfort je prednastavená, keď nie je prítomný", + "boost_away_temp": "Prednastavená teplota v režime Boost, keď nie je prítomný", + "frost_away_temp": "Prednastavená teplota v režime Frost protection, keď nie je prítomný", + "eco_ac_away_temp": "Teplota v prednastavenej Eco, keď nie je prítomná v režime AC", + "comfort_ac_away_temp": "Teplota v režime Comfort je prednastavená, keď nie je prítomný v režime AC", + "boost_ac_away_temp": "Teplota v prednastavenom Boost, keď nie je prítomný v režime AC", + "use_presence_central_config": "Použite centrálnu konfiguráciu prítomnosti" + }, + "data_description": { + "presence_sensor_entity_id": "ID entity senzora prítomnosti", + "eco_away_temp": "Teplota v prednastavenej Eco, keď nie je žiadna prítomnosť", + "comfort_away_temp": "Teplota v režime Comfort je prednastavená, keď nie je prítomný", + "boost_away_temp": "Prednastavená teplota v režime Boost, keď nie je prítomný", + "frost_away_temp": "Teplota v Prednastavená ochrana pred mrazom, keď nie je prítomný", + "eco_ac_away_temp": "Teplota v prednastavenej Eco, keď nie je prítomná v režime AC", + "comfort_ac_away_temp": "Teplota v režime Comfort je prednastavená, keď nie je prítomný v režime AC", + "boost_ac_away_temp": "Teplota v prednastavenom Boost, keď nie je prítomný v režime AC", + "use_presence_central_config": "Začiarknite, ak chcete použiť konfiguráciu centrálnej prítomnosti. Zrušte začiarknutie, ak chcete použiť špecifickú konfiguráciu prítomnosti pre tento VTherm" + } + }, + "advanced": { + "title": "Pokročilé parametre", + "description": "Konfigurácia pokročilých parametrov. Ak neviete, čo robíte, ponechajte predvolené hodnoty.\nTento parameter môže viesť k veľmi zlej regulácii teploty alebo výkonu.", + "data": { + "minimal_activation_delay": "Minimálne oneskorenie aktivácie", + "security_delay_min": "Bezpečnostné oneskorenie (v minútach)", + "security_min_on_percent": "Minimálne percento výkonu na aktiváciu bezpečnostného režimu", + "security_default_on_percent": "Percento výkonu na použitie v bezpečnostnom režime", + "use_advanced_central_config": "Použite centrálnu rozšírenú konfiguráciu" + }, + "data_description": { + "minimal_activation_delay": "Oneskorenie v sekundách, pri ktorom sa zariadenie neaktivuje", + "security_delay_min": "Maximálne povolené oneskorenie v minútach medzi dvoma meraniami teploty. Po uplynutí tohto oneskorenia sa termostat prepne do bezpečnostného vypnutého stavu", + "security_min_on_percent": "Minimálna percentuálna hodnota ohrevu pre aktiváciu prednastavenej bezpečnosti. Pod týmto percentom výkonu termostat neprejde do prednastavenia zabezpečenia", + "security_default_on_percent": "Predvolená percentuálna hodnota vykurovacieho výkonu v bezpečnostnej predvoľbe. Nastavte na 0, ak chcete vypnúť ohrievač v zabezpečenom stave", + "use_advanced_central_config": "Začiarknite, ak chcete použiť centrálnu rozšírenú konfiguráciu. Zrušte začiarknutie, ak chcete použiť špecifickú rozšírenú konfiguráciu pre tento VTherm" + } + } + }, + "error": { + "unknown": "Neočakávaná chyba", + "unknown_entity": "Neznáme ID entity", + "window_open_detection_method": "Mala by sa použiť iba jedna metóda detekcie otvoreného okna. Použite senzor alebo automatickú detekciu cez teplotný prah, ale nie oboje", + "no_central_config": "Nemôžete zaškrtnúť „použiť centrálnu konfiguráciu“, pretože sa nenašla žiadna centrálna konfigurácia. Aby ste ho mohli používať, musíte si vytvoriť všestranný termostat typu „Central Configuration“." + }, + "abort": { + "already_configured": "Zariadenie je už nakonfigurované" + } + }, + "options": { + "flow_title": "Všestranná konfigurácia termostatu", + "step": { + "user": { + "title": "Typ - {name}", + "data": { + "thermostat_type": "Typ termostatu" + }, + "data_description": { + "thermostat_type": "Je možný len jeden centrálny typ konfigurácie" + } + }, + "main": { + "title": "Hlavný - {name}", + "description": "Hlavné povinné atribúty", + "data": { + "name": "Názov", + "thermostat_type": "Termostat typ", + "temperature_sensor_entity_id": "ID entity snímača teploty", + "external_temperature_sensor_entity_id": "ID entity externého snímača teploty", + "cycle_min": "Trvanie cyklu (minúty)", + "temp_min": "Minimálna povolená teplota", + "temp_max": "Maximálna povolená teplota", + "device_power": "Výkon zariadenia (kW)", + "use_central_mode": "Povoliť ovládanie centrálnou entitou (potrebná centrálna konfigurácia)", + "use_window_feature": "Použite detekciu okien", + "use_motion_feature": "Použite detekciu pohybu", + "use_power_feature": "Použite správu napájania", + "use_presence_feature": "Použite detekciu prítomnosti", + "use_main_central_config": "Použite centrálnu hlavnú konfiguráciu" + }, + "data_description": { + "use_central_mode": "Zaškrtnutím povolíte ovládanie VTherm pomocou vybraných entít central_mode", + "use_main_central_config": "Začiarknite, ak chcete použiť centrálnu hlavnú konfiguráciu. Ak chcete použiť špecifickú konfiguráciu pre tento VTherm, zrušte začiarknutie", + "external_temperature_sensor_entity_id": "ID entity snímača vonkajšej teploty. Nepoužíva sa, ak je zvolená centrálna konfigurácia" + } + }, + "type": { + "title": "Prepojené entity - {name}", + "description": "Atribúty prepojených entít", + "data": { + "heater_entity_id": "Spínač ohrievača", + "heater_entity2_id": "2. spínač ohrievača", + "heater_entity3_id": "3. spínač ohrievača", + "heater_entity4_id": "4. spínač ohrievača", + "proportional_function": "Algoritmus", + "climate_entity_id": "Základná klíma", + "climate_entity2_id": "2. základná klíma", + "climate_entity3_id": "3. základná klíma", + "climate_entity4_id": "4. základná klíma", + "ac_mode": "AC režim", + "valve_entity_id": "1. ventil číslo", + "valve_entity2_id": "2. ventil číslo", + "valve_entity3_id": "3. ventil číslo", + "valve_entity4_id": "4. ventil číslo", + "auto_regulation_mode": "Samoregulácia", + "auto_regulation_dtemp": "Regulačný prah", + "auto_regulation_periode_min": "Regulačné minimálne obdobie", + "inverse_switch_command": "Inverzný prepínací príkaz", + "auto_fan_mode": "Režim automatického ventilátora" + }, + "data_description": { + "heater_entity_id": "ID entity povinného ohrievača", + "heater_entity2_id": "Voliteľné ID entity 2. ohrievača. Ak sa nepoužíva, nechajte prázdne", + "heater_entity3_id": "Voliteľné ID entity 3. ohrievača. Ak sa nepoužíva, nechajte prázdne", + "heater_entity4_id": "Voliteľné ID entity 4. ohrievača. Ak sa nepoužíva, nechajte prázdne", + "proportional_function": "Algoritmus, ktorý sa má použiť (TPI je zatiaľ jediný)", + "climate_entity_id": "ID základnej klimatickej entity", + "climate_entity2_id": "2. základný identifikátor klimatickej entity", + "climate_entity3_id": "3. základný identifikátor klimatickej entity", + "climate_entity4_id": "4. základný identifikátor klimatickej entity", + "ac_mode": "Použite režim klimatizácie (AC)", + "valve_entity_id": "1. ventil číslo entity id", + "valve_entity2_id": "2. ventil číslo entity id", + "valve_entity3_id": "3. ventil číslo entity id", + "valve_entity4_id": "4. ventil číslo entity id", + "auto_regulation_mode": "Automatické nastavenie cieľovej teploty", + "auto_regulation_dtemp": "Hranica v °, pod ktorou sa zmena teploty neodošle", + "auto_regulation_periode_min": "Trvanie v minútach medzi dvoma aktualizáciami predpisov", + "inverse_switch_command": "V prípade spínača s pilotným vodičom a diódou možno budete musieť príkaz invertovať", + "auto_fan_mode": "Automaticky aktivujte ventilátor, keď je potrebné veľké vykurovanie/chladenie" + } + }, + "tpi": { + "title": "TPI - {name}", + "description": "Časovo proporcionálne integrálne atribúty", + "data": { + "tpi_coef_int": "Koeficient na použitie pre vnútornú teplotnú deltu", + "tpi_coef_ext": "Koeficient na použitie pre vonkajšiu teplotnú deltu", + "use_tpi_central_config": "Použite centrálnu konfiguráciu TPI" + }, + "data_description": { + "tpi_coef_int": "Koeficient na použitie pre vnútornú teplotnú deltu", + "tpi_coef_ext": "Koeficient na použitie pre deltu vonkajšej teploty", + "use_tpi_central_config": "Začiarknite, ak chcete použiť centrálnu konfiguráciu TPI. Zrušte začiarknutie, ak chcete použiť špecifickú konfiguráciu TPI pre tento VTherm" + } + }, + "presets": { + "title": "Predvoľby - {name}", + "description": "Pre každú predvoľbu zadajte cieľovú teplotu (0, ak chcete predvoľbu ignorovať)", + "data": { + "eco_temp": "Teplota v predvoľbe Eco", + "comfort_temp": "Prednastavená teplota v komfortnom režime", + "boost_temp": "Teplota v prednastavení Boost", + "frost_temp": "Teplota v prednastavení Frost protection", + "eco_ac_temp": "Teplota v režime Eco prednastavená pre režim AC", + "comfort_ac_temp": "Teplota v režime Comfort je prednastavená pre režim AC", + "boost_ac_temp": "Prednastavená teplota v režime Boost pre režim AC", + "use_presets_central_config": "Použite konfiguráciu centrálnych predvolieb" + }, + "data_description": { + "eco_temp": "Teplota v predvoľbe Eco", + "comfort_temp": "Prednastavená teplota v komfortnom režime", + "boost_temp": "Teplota v prednastavení Boost", + "frost_temp": "Teplota v prednastavenej ochrane proti mrazu", + "eco_ac_temp": "Teplota v režime Eco prednastavená pre režim AC", + "comfort_ac_temp": "Teplota v režime Comfort je prednastavená pre režim AC", + "boost_ac_temp": "Prednastavená teplota v režime Boost pre režim AC", + "use_presets_central_config": "Začiarknite, ak chcete použiť konfiguráciu centrálnych predvolieb. Zrušte začiarknutie, ak chcete použiť špecifickú konfiguráciu predvolieb pre tento VTherm" + } + }, + "window": { + "title": "Správa okien - {name}", + "description": "Otvoriť správu okien.\nAk sa príslušné entity_id nepoužíva, ponechajte prázdne\nMôžete tiež nakonfigurovať automatickú detekciu otvoreného okna na základe poklesu teploty", + "data": { + "window_sensor_entity_id": "ID entity snímača okna", + "window_delay": "Oneskorenie snímača okna (sekundy)", + "window_auto_open_threshold": "Prah poklesu teploty pre automatickú detekciu otvoreného okna (v °/hodina)", + "window_auto_close_threshold": "Prahová hodnota zvýšenia teploty pre koniec automatickej detekcie (v °/hodina)", + "window_auto_max_duration": "Maximálne trvanie automatickej detekcie otvoreného okna (v min)", + "use_window_central_config": "Použite centrálnu konfiguráciu okna" + }, + "data_description": { + "window_sensor_entity_id": "Nechajte prázdne, ak nemáte použiť žiadny okenný senzor", + "window_delay": "Zohľadňuje sa oneskorenie v sekundách pred detekciou snímača", + "window_auto_open_threshold": "Odporúčaná hodnota: medzi 3 a 10. Ak sa nepoužíva automatická detekcia otvoreného okna, nechajte prázdne", + "window_auto_close_threshold": "Odporúčaná hodnota: 0. Ak sa nepoužíva automatická detekcia otvoreného okna, nechajte prázdne", + "window_auto_max_duration": "Odporúčaná hodnota: 60 (jedna hodina). Ak sa nepoužíva automatická detekcia otvoreného okna, nechajte prázdne", + "use_window_central_config": "Začiarknite, ak chcete použiť konfiguráciu centrálneho okna. Zrušte začiarknutie, ak chcete použiť špecifickú konfiguráciu okna pre tento VTherm" + } + }, + "motion": { + "title": "Riadenie pohybu - {name}", + "description": "Správa snímača pohybu. Predvoľba sa môže automaticky prepínať v závislosti od detekcie pohybu\nAk sa nepoužíva, ponechajte zodpovedajúce entity_id prázdne.\nmotion_preset a no_motion_preset by mali byť nastavené na zodpovedajúci názov predvoľby", + "data": { + "motion_sensor_entity_id": "ID entity snímača pohybu", + "motion_delay": "Oneskorenie aktivácie", + "motion_off_delay": "Oneskorenie deaktivácie", + "motion_preset": "Prednastavený pohyb", + "no_motion_preset": "Žiadna predvoľba pohybu", + "use_motion_central_config": "Použite centrálnu konfiguráciu pohybu" + }, + "data_description": { + "motion_sensor_entity_id": "ID entity snímača pohybu", + "motion_delay": "Oneskorenie aktivácie pohybu (sekundy)", + "motion_off_delay": "Oneskorenie deaktivácie pohybu (sekundy)", + "motion_preset": "Prednastavené na použitie pri detekcii pohybu", + "no_motion_preset": "Prednastavené na použitie, keď nie je detekovaný žiadny pohyb", + "use_motion_central_config": "Začiarknite, ak chcete použiť konfiguráciu centrálneho pohybu. Zrušte začiarknutie, ak chcete použiť špecifickú konfiguráciu pohybu pre tento VTherm" + } + }, + "power": { + "title": "Správa napájania - {name}", + "description": "Atribúty správy napájania.\nPoskytuje senzor výkonu a maximálneho výkonu vášho domova.\nPotom zadajte spotrebu energie ohrievača, keď je zapnutý.\nVšetky senzory a výkon zariadenia by mali mať rovnakú jednotku (kW alebo W).\nPonechajte zodpovedajúce entity_id prázdne ak sa nepoužíva.", + "data": { + "power_sensor_entity_id": "ID entity snímača výkonu", + "max_power_sensor_entity_id": "ID entity snímača maximálneho výkonu", + "power_temp": "Teplota pre zníženie výkonu", + "use_power_central_config": "Použite centrálnu konfiguráciu napájania" + }, + "data_description": { + "power_sensor_entity_id": "ID entity snímača výkonu", + "max_power_sensor_entity_id": "ID entity snímača maximálneho výkonu", + "power_temp": "Teplota pre zníženie výkonu", + "use_power_central_config": "Začiarknite, ak chcete použiť konfiguráciu centrálneho napájania. Zrušte začiarknutie, ak chcete použiť špecifickú konfiguráciu napájania pre tento VTherm" + } + }, + "presence": { + "title": "Riadenie prítomnosti", + "description": "Atribúty správy prítomnosti.\nPoskytuje senzor prítomnosti vášho domova (pravda, ak je niekto prítomný).\nPotom zadajte buď predvoľbu, ktorá sa má použiť, keď je senzor prítomnosti nepravdivý, alebo posun teploty, ktorý sa má použiť.\nAk je zadaná predvoľba, posun sa nepoužije.\nAk sa nepoužije, ponechajte zodpovedajúce entity_id prázdne.", + "data": { + "presence_sensor_entity_id": "ID entity senzora prítomnosti (pravda je prítomná)", + "eco_away_temp": "Teplota v prednastavenej Eco, keď nie je žiadna prítomnosť", + "comfort_away_temp": "Teplota v režime Comfort je prednastavená, keď nie je prítomný", + "boost_away_temp": "Prednastavená teplota v režime Boost, keď nie je prítomný", + "frost_away_temp": "Prednastavená teplota v režime Frost protection, keď nie je prítomný", + "eco_ac_away_temp": "Teplota v prednastavenej Eco, keď nie je prítomná v režime AC", + "comfort_ac_away_temp": "Teplota v režime Comfort je prednastavená, keď nie je prítomný v režime AC", + "boost_ac_away_temp": "Teplota v prednastavenom Boost, keď nie je prítomný v režime AC", + "use_presence_central_config": "Použite centrálnu konfiguráciu prítomnosti" + }, + "data_description": { + "presence_sensor_entity_id": "ID entity senzora prítomnosti", + "eco_away_temp": "Teplota v prednastavenej Eco, keď nie je žiadna prítomnosť", + "comfort_away_temp": "Teplota v režime Comfort je prednastavená, keď nie je prítomný", + "boost_away_temp": "Prednastavená teplota v režime Boost, keď nie je prítomný", + "frost_away_temp": "Teplota v Prednastavená ochrana pred mrazom, keď nie je prítomný", + "eco_ac_away_temp": "Teplota v prednastavenej Eco, keď nie je prítomná v režime AC", + "comfort_ac_away_temp": "Teplota v režime Comfort je prednastavená, keď nie je prítomný v režime AC", + "boost_ac_away_temp": "Teplota v prednastavenom Boost, keď nie je prítomný v režime AC", + "use_presence_central_config": "Začiarknite, ak chcete použiť konfiguráciu centrálnej prítomnosti. Zrušte začiarknutie, ak chcete použiť špecifickú konfiguráciu prítomnosti pre tento VTherm" + } + }, + "advanced": { + "title": "Pokročilé parametre - {name}", + "description": "Konfigurácia pokročilých parametrov. Ak neviete, čo robíte, ponechajte predvolené hodnoty.\nTento parameter môže viesť k veľmi zlej regulácii teploty alebo výkonu.", + "data": { + "minimal_activation_delay": "Minimálne oneskorenie aktivácie", + "security_delay_min": "Bezpečnostné oneskorenie (v minútach)", + "security_min_on_percent": "Minimálne percento výkonu pre bezpečnostný režim", + "security_default_on_percent": "Percento výkonu na použitie v bezpečnostnom režime", + "use_advanced_central_config": "Použite centrálnu rozšírenú konfiguráciu" + }, + "data_description": { + "minimal_activation_delay": "Oneskorenie v sekundách, pri ktorom sa zariadenie neaktivuje", + "security_delay_min": "Maximálne povolené oneskorenie v minútach medzi dvoma meraniami teploty. Po uplynutí tohto oneskorenia sa termostat prepne do bezpečnostného vypnutého stavu", + "security_min_on_percent": "Minimálna percentuálna hodnota ohrevu pre aktiváciu prednastavenej bezpečnosti. Pod týmto percentom výkonu termostat neprejde do prednastavenia zabezpečenia", + "security_default_on_percent": "Predvolená percentuálna hodnota vykurovacieho výkonu v bezpečnostnej predvoľbe. Nastavte na 0, ak chcete vypnúť ohrievač v zabezpečenom stave", + "use_advanced_central_config": "Začiarknite, ak chcete použiť centrálnu rozšírenú konfiguráciu. Zrušte začiarknutie, ak chcete použiť špecifickú rozšírenú konfiguráciu pre tento VTherm" + } + } + }, + "error": { + "unknown": "Neočakávaná chyba", + "unknown_entity": "Neznáme ID entity", + "window_open_detection_method": "Mala by sa použiť iba jedna metóda detekcie otvoreného okna. Použite senzor alebo automatickú detekciu cez teplotný prah, ale nie oboje", + "no_central_config": "Nemôžete zaškrtnúť „použiť centrálnu konfiguráciu“, pretože sa nenašla žiadna centrálna konfigurácia. Aby ste ho mohli používať, musíte si vytvoriť všestranný termostat typu „Central Configuration“." + }, + "abort": { + "already_configured": "Zariadenie je už nakonfigurované" + } + }, + "selector": { + "thermostat_type": { + "options": { + "thermostat_central_config": "Centrálna konfigurácia", + "thermostat_over_switch": "Termostat nad spínačom", + "thermostat_over_climate": "Termostat nad iným termostatom", + "thermostat_over_valve": "Termostat nad ventilom" + } + }, + "auto_regulation_mode": { + "options": { + "auto_regulation_slow": "Pomalé", + "auto_regulation_strong": "Silné", + "auto_regulation_medium": "Stredné", + "auto_regulation_light": "Jemné", + "auto_regulation_expert": "Expertné", + "auto_regulation_none": "Nie auto-regulácia" + } + }, + "auto_fan_mode": { + "options": { + "auto_fan_none": "Žiadny automatický ventilátor", + "auto_fan_low": "Nízky", + "auto_fan_medium": "Stredný", + "auto_fan_high": "Vysoký", + "auto_fan_turbo": "Turbo" + } + } + }, + "entity": { + "climate": { + "versatile_thermostat": { + "state_attributes": { + "preset_mode": { + "state": { + "power": "Vyradenie", + "security": "Zabezpečenie", + "none": "Manuálne" + } + } + } + } + } + } +} diff --git a/config/custom_components/versatile_thermostat/underlyings.py b/config/custom_components/versatile_thermostat/underlyings.py new file mode 100644 index 0000000..2b3580c --- /dev/null +++ b/config/custom_components/versatile_thermostat/underlyings.py @@ -0,0 +1,899 @@ +# pylint: disable=unused-argument, line-too-long + +""" Underlying entities classes """ +import logging +from typing import Any +from enum import StrEnum + +from homeassistant.const import ATTR_ENTITY_ID, STATE_ON, UnitOfTemperature +from homeassistant.core import State + +from homeassistant.exceptions import ServiceNotFound + +from homeassistant.core import HomeAssistant, CALLBACK_TYPE +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + DOMAIN as CLIMATE_DOMAIN, + HVACMode, + HVACAction, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HUMIDITY, + SERVICE_SET_SWING_MODE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_SET_TEMPERATURE, +) + +from homeassistant.components.number import SERVICE_SET_VALUE + +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.event import async_call_later + +from .const import UnknownEntity, overrides +from .keep_alive import IntervalCaller + +_LOGGER = logging.getLogger(__name__) + +# remove this +# _LOGGER.setLevel(logging.DEBUG) + + +class UnderlyingEntityType(StrEnum): + """All underlying device type""" + + # A switch + SWITCH = "switch" + + # a climate + CLIMATE = "climate" + + # a valve + VALVE = "valve" + + +class UnderlyingEntity: + """Represent a underlying device which could be a switch or a climate""" + + _hass: HomeAssistant + # Cannot import VersatileThermostat due to circular reference + _thermostat: Any + _entity_id: str + _type: UnderlyingEntityType + + def __init__( + self, + hass: HomeAssistant, + thermostat: Any, + entity_type: UnderlyingEntityType, + entity_id: str, + ) -> None: + """Initialize the underlying entity""" + self._hass = hass + self._thermostat = thermostat + self._type = entity_type + self._entity_id = entity_id + + def __str__(self): + return str(self._thermostat) + "-" + self._entity_id + + @property + def entity_id(self): + """The entiy id represented by this class""" + return self._entity_id + + @property + def entity_type(self) -> UnderlyingEntityType: + """The entity type represented by this class""" + return self._type + + @property + def is_initialized(self) -> bool: + """True if the underlying is initialized""" + return True + + def startup(self): + """Startup the Entity""" + return + + async def set_hvac_mode(self, hvac_mode: HVACMode): + """Set the HVACmode""" + return + + @property + def is_device_active(self) -> bool | None: + """If the toggleable device is currently active.""" + return None + + async def set_temperature(self, temperature, max_temp, min_temp): + """Set the target temperature""" + return + + # This should be the correct way to handle turn_off and turn_on but this breaks the unit test + # will an not understandable error: TypeError: object MagicMock can't be used in 'await' expression + async def turn_off(self): + """Turn off the underlying equipement. + Need to be overriden""" + return NotImplementedError + + async def turn_on(self): + """Turn off the underlying equipement. + Need to be overriden""" + return NotImplementedError + + @property + def is_inversed(self): + """Tells if the switch command should be inversed""" + return False + + def remove_entity(self): + """Remove the underlying entity""" + return + + async def check_initial_state(self, hvac_mode: HVACMode): + """Prevent the underlying to be on but thermostat is off""" + if hvac_mode == HVACMode.OFF and self.is_device_active: + _LOGGER.info( + "%s - The hvac mode is OFF, but the underlying device is ON. Turning off device %s", + self, + self._entity_id, + ) + await self.set_hvac_mode(hvac_mode) + elif hvac_mode != HVACMode.OFF and not self.is_device_active: + _LOGGER.info( + "%s - The hvac mode is %s, but the underlying device is not ON. Turning on device %s if needed", + self, + hvac_mode, + self._entity_id, + ) + await self.set_hvac_mode(hvac_mode) + + # override to be able to mock the call + def call_later( + self, hass: HomeAssistant, delay_sec: int, called_method + ) -> CALLBACK_TYPE: + """Call the method after a delay""" + return async_call_later(hass, delay_sec, called_method) + + async def start_cycle( + self, + hvac_mode: HVACMode, + on_time_sec: int, + off_time_sec: int, + on_percent: int, + force=False, + ): + """Starting cycle for switch""" + + def _cancel_cycle(self): + """Stops an eventual cycle""" + + def cap_sent_value(self, value) -> float: + """capping of the value send to the underlying eqt""" + return value + + +class UnderlyingSwitch(UnderlyingEntity): + """Represent a underlying switch""" + + _initialDelaySec: int + _on_time_sec: int + _off_time_sec: int + _hvac_mode: HVACMode + + def __init__( + self, + hass: HomeAssistant, + thermostat: Any, + switch_entity_id: str, + initial_delay_sec: int, + keep_alive_sec: float, + ) -> None: + """Initialize the underlying switch""" + + super().__init__( + hass=hass, + thermostat=thermostat, + entity_type=UnderlyingEntityType.SWITCH, + entity_id=switch_entity_id, + ) + self._initial_delay_sec = initial_delay_sec + self._async_cancel_cycle = None + self._should_relaunch_control_heating = False + self._on_time_sec = 0 + self._off_time_sec = 0 + self._hvac_mode = None + self._keep_alive = IntervalCaller(hass, keep_alive_sec) + + @property + def initial_delay_sec(self): + """The initial delay for this class""" + return self._initial_delay_sec + + @overrides + @property + def is_inversed(self): + """Tells if the switch command should be inversed""" + return self._thermostat.is_inversed + + @property + def keep_alive_sec(self) -> float: + """Return the switch keep-alive interval in seconds.""" + return self._keep_alive.interval_sec + + @overrides + def startup(self): + super().startup() + self._keep_alive.set_async_action(self._keep_alive_callback) + + # @overrides this breaks some unit tests TypeError: object MagicMock can't be used in 'await' expression + async def set_hvac_mode(self, hvac_mode: HVACMode) -> bool: + """Set the HVACmode. Returns true if something have change""" + + if hvac_mode == HVACMode.OFF: + if self.is_device_active: + await self.turn_off() + self._cancel_cycle() + + if self._hvac_mode != hvac_mode: + self._hvac_mode = hvac_mode + return True + else: + return False + + @property + def is_device_active(self): + """If the toggleable device is currently active.""" + real_state = self._hass.states.is_state(self._entity_id, STATE_ON) + return (self.is_inversed and not real_state) or ( + not self.is_inversed and real_state + ) + + async def _keep_alive_callback(self): + """Keep alive: Turn on if already turned on, turn off if already turned off.""" + await (self.turn_on() if self.is_device_active else self.turn_off()) + + # @overrides this breaks some unit tests TypeError: object MagicMock can't be used in 'await' expression + async def turn_off(self): + """Turn heater toggleable device off.""" + self._keep_alive.cancel() # Cancel early to avoid a turn_on/turn_off race condition + _LOGGER.debug("%s - Stopping underlying entity %s", self, self._entity_id) + command = SERVICE_TURN_OFF if not self.is_inversed else SERVICE_TURN_ON + domain = self._entity_id.split(".")[0] + # This may fails if called after shutdown + try: + try: + data = {ATTR_ENTITY_ID: self._entity_id} + await self._hass.services.async_call(domain, command, data) + self._keep_alive.set_async_action(self._keep_alive_callback) + except Exception: + self._keep_alive.cancel() + raise + except ServiceNotFound as err: + _LOGGER.error(err) + + async def turn_on(self): + """Turn heater toggleable device on.""" + self._keep_alive.cancel() # Cancel early to avoid a turn_on/turn_off race condition + _LOGGER.debug("%s - Starting underlying entity %s", self, self._entity_id) + command = SERVICE_TURN_ON if not self.is_inversed else SERVICE_TURN_OFF + domain = self._entity_id.split(".")[0] + try: + try: + data = {ATTR_ENTITY_ID: self._entity_id} + await self._hass.services.async_call(domain, command, data) + self._keep_alive.set_async_action(self._keep_alive_callback) + except Exception: + self._keep_alive.cancel() + raise + except ServiceNotFound as err: + _LOGGER.error(err) + + @overrides + async def start_cycle( + self, + hvac_mode: HVACMode, + on_time_sec: int, + off_time_sec: int, + on_percent: int, + force=False, + ): + """Starting cycle for switch""" + _LOGGER.debug( + "%s - Starting new cycle hvac_mode=%s on_time_sec=%d off_time_sec=%d force=%s", + self, + hvac_mode, + on_time_sec, + off_time_sec, + force, + ) + + self._on_time_sec = on_time_sec + self._off_time_sec = off_time_sec + self._hvac_mode = hvac_mode + + # Cancel eventual previous cycle if any + if self._async_cancel_cycle is not None: + if force: + _LOGGER.debug("%s - we force a new cycle", self) + self._cancel_cycle() + else: + _LOGGER.debug( + "%s - A previous cycle is alredy running and no force -> waits for its end", + self, + ) + # self._should_relaunch_control_heating = True + _LOGGER.debug("%s - End of cycle (2)", self) + return + + # If we should heat, starts the cycle with delay + if self._hvac_mode in [HVACMode.HEAT, HVACMode.COOL] and on_time_sec > 0: + # Starts the cycle after the initial delay + self._async_cancel_cycle = self.call_later( + self._hass, self._initial_delay_sec, self._turn_on_later + ) + _LOGGER.debug("%s - _async_cancel_cycle=%s", self, self._async_cancel_cycle) + + # if we not heat but device is active + elif self.is_device_active: + _LOGGER.info( + "%s - stop heating (2) for %d min %d sec", + self, + off_time_sec // 60, + off_time_sec % 60, + ) + await self.turn_off() + else: + _LOGGER.debug("%s - nothing to do", self) + + @overrides + def _cancel_cycle(self): + """Cancel the cycle""" + if self._async_cancel_cycle: + self._async_cancel_cycle() + self._async_cancel_cycle = None + _LOGGER.debug("%s - Stopping cycle during calculation", self) + + async def _turn_on_later(self, _): + """Turn the heater on after a delay""" + _LOGGER.debug( + "%s - calling turn_on_later hvac_mode=%s, should_relaunch_later=%s off_time_sec=%d", + self, + self._hvac_mode, + self._should_relaunch_control_heating, + self._on_time_sec, + ) + + self._cancel_cycle() + + if self._hvac_mode == HVACMode.OFF: + _LOGGER.debug("%s - End of cycle (HVAC_MODE_OFF - 2)", self) + if self.is_device_active: + await self.turn_off() + return + + if await self._thermostat.check_overpowering(): + _LOGGER.debug("%s - End of cycle (3)", self) + return + # safety mode could have change the on_time percent + await self._thermostat.check_safety() + time = self._on_time_sec + + action_label = "start" + + if time > 0: + _LOGGER.info( + "%s - %s heating for %d min %d sec", + self, + action_label, + time // 60, + time % 60, + ) + await self.turn_on() + else: + _LOGGER.debug("%s - No action on heater cause duration is 0", self) + self._async_cancel_cycle = self.call_later( + self._hass, + time, + self._turn_off_later, + ) + + async def _turn_off_later(self, _): + """Turn the heater off and call the next cycle after the delay""" + _LOGGER.debug( + "%s - calling turn_off_later hvac_mode=%s, should_relaunch_later=%s off_time_sec=%d", + self, + self._hvac_mode, + self._should_relaunch_control_heating, + self._off_time_sec, + ) + self._cancel_cycle() + + if self._hvac_mode == HVACMode.OFF: + _LOGGER.debug("%s - End of cycle (HVAC_MODE_OFF - 2)", self) + if self.is_device_active: + await self.turn_off() + return + + action_label = "stop" + time = self._off_time_sec + + if time > 0: + _LOGGER.info( + "%s - %s heating for %d min %d sec", + self, + action_label, + time // 60, + time % 60, + ) + await self.turn_off() + else: + _LOGGER.debug("%s - No action on heater cause duration is 0", self) + self._async_cancel_cycle = self.call_later( + self._hass, + time, + self._turn_on_later, + ) + + # increment energy at the end of the cycle + self._thermostat.incremente_energy() + + @overrides + def remove_entity(self): + """Remove the entity after stopping its cycle""" + self._cancel_cycle() + self._keep_alive.cancel() + + +class UnderlyingClimate(UnderlyingEntity): + """Represent a underlying climate""" + + _underlying_climate: ClimateEntity + + def __init__( + self, + hass: HomeAssistant, + thermostat: Any, + climate_entity_id: str, + ) -> None: + """Initialize the underlying climate""" + + super().__init__( + hass=hass, + thermostat=thermostat, + entity_type=UnderlyingEntityType.CLIMATE, + entity_id=climate_entity_id, + ) + self._underlying_climate = None + + def find_underlying_climate(self) -> ClimateEntity: + """Find the underlying climate entity""" + component: EntityComponent[ClimateEntity] = self._hass.data[CLIMATE_DOMAIN] + for entity in component.entities: + if self.entity_id == entity.entity_id: + return entity + return None + + def startup(self): + """Startup the Entity""" + # Get the underlying climate + self._underlying_climate = self.find_underlying_climate() + if self._underlying_climate: + _LOGGER.info( + "%s - The underlying climate entity: %s have been succesfully found", + self, + self._underlying_climate, + ) + else: + _LOGGER.info( + "%s - Cannot find the underlying climate entity: %s. Thermostat will not be operational. Will try later.", + self, + self.entity_id, + ) + # #56 keep the over_climate and try periodically to find the underlying climate + # self._is_over_climate = False + raise UnknownEntity(f"Underlying entity {self.entity_id} not found") + return + + @property + def is_initialized(self) -> bool: + """True if the underlying climate was found""" + return self._underlying_climate is not None + + async def set_hvac_mode(self, hvac_mode: HVACMode) -> bool: + """Set the HVACmode of the underlying climate. Returns true if something have change""" + if not self.is_initialized: + return False + + if self._underlying_climate.hvac_mode == hvac_mode: + _LOGGER.debug( + "%s - hvac_mode is already is requested state %s. Do not send any command", + self, + self._underlying_climate.hvac_mode, + ) + return False + + data = {ATTR_ENTITY_ID: self._entity_id, "hvac_mode": hvac_mode} + await self._hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + data, + ) + + return True + + @property + def is_device_active(self): + """If the toggleable device is currently active.""" + if self.is_initialized: + return ( + self._underlying_climate.hvac_mode != HVACMode.OFF + and self._underlying_climate.hvac_action + not in [ + HVACAction.IDLE, + HVACAction.OFF, + ] + ) + else: + return None + + async def set_fan_mode(self, fan_mode): + """Set new target fan mode.""" + if not self.is_initialized: + return + data = { + ATTR_ENTITY_ID: self._entity_id, + "fan_mode": fan_mode, + } + + await self._hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + data, + ) + + async def set_humidity(self, humidity: int): + """Set new target humidity.""" + _LOGGER.info("%s - Set fan mode: %s", self, humidity) + if not self.is_initialized: + return + data = { + ATTR_ENTITY_ID: self._entity_id, + "humidity": humidity, + } + + await self._hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HUMIDITY, + data, + ) + + async def set_swing_mode(self, swing_mode): + """Set new target swing operation.""" + _LOGGER.info("%s - Set fan mode: %s", self, swing_mode) + if not self.is_initialized: + return + data = { + ATTR_ENTITY_ID: self._entity_id, + "swing_mode": swing_mode, + } + + await self._hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_MODE, + data, + ) + + async def set_temperature(self, temperature, max_temp, min_temp): + """Set the target temperature""" + if not self.is_initialized: + return + + data = { + ATTR_ENTITY_ID: self._entity_id, + "temperature": self.cap_sent_value(temperature), + "target_temp_high": max_temp, + "target_temp_low": min_temp, + } + + await self._hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + data, + ) + + @property + def hvac_action(self) -> HVACAction | None: + """Get the hvac action of the underlying""" + if not self.is_initialized: + return None + return self._underlying_climate.hvac_action + + @property + def hvac_mode(self) -> HVACMode | None: + """Get the hvac mode of the underlying""" + if not self.is_initialized: + return None + return self._underlying_climate.hvac_mode + + @property + def fan_mode(self) -> str | None: + """Get the fan_mode of the underlying""" + if not self.is_initialized: + return None + return self._underlying_climate.fan_mode + + @property + def swing_mode(self) -> str | None: + """Get the swing_mode of the underlying""" + if not self.is_initialized: + return None + return self._underlying_climate.swing_mode + + @property + def supported_features(self) -> ClimateEntityFeature: + """Get the supported features of the climate""" + if not self.is_initialized: + return ClimateEntityFeature.TARGET_TEMPERATURE + return self._underlying_climate.supported_features + + @property + def hvac_modes(self) -> list[HVACMode]: + """Get the hvac_modes""" + if not self.is_initialized: + return [] + return self._underlying_climate.hvac_modes + + @property + def fan_modes(self) -> list[str]: + """Get the fan_modes""" + if not self.is_initialized: + return [] + return self._underlying_climate.fan_modes + + @property + def swing_modes(self) -> list[str]: + """Get the swing_modes""" + if not self.is_initialized: + return [] + return self._underlying_climate.swing_modes + + @property + def temperature_unit(self) -> str: + """Get the temperature_unit""" + if not self.is_initialized: + return UnitOfTemperature.CELSIUS + return self._underlying_climate.temperature_unit + + @property + def target_temperature_step(self) -> float: + """Get the target_temperature_step""" + if not self.is_initialized: + return 1 + return self._underlying_climate.target_temperature_step + + @property + def target_temperature_high(self) -> float: + """Get the target_temperature_high""" + if not self.is_initialized: + return 30 + return self._underlying_climate.target_temperature_high + + @property + def target_temperature_low(self) -> float: + """Get the target_temperature_low""" + if not self.is_initialized: + return 15 + return self._underlying_climate.target_temperature_low + + @property + def is_aux_heat(self) -> bool: + """Get the is_aux_heat""" + if not self.is_initialized: + return False + return self._underlying_climate.is_aux_heat + + @property + def underlying_current_temperature(self) -> float | None: + """Get the underlying current_temperature if it exists + and if initialized""" + if not self.is_initialized: + return None + + if not hasattr(self._underlying_climate, "current_temperature"): + return None + + return self._underlying_climate.current_temperature + + def turn_aux_heat_on(self) -> None: + """Turn auxiliary heater on.""" + if not self.is_initialized: + return None + return self._underlying_climate.turn_aux_heat_on() + + def turn_aux_heat_off(self) -> None: + """Turn auxiliary heater on.""" + if not self.is_initialized: + return None + return self._underlying_climate.turn_aux_heat_off() + + @overrides + def cap_sent_value(self, value) -> float: + """Try to adapt the target temp value to the min_temp / max_temp found + in the underlying entity (if any)""" + + if not self.is_initialized: + return value + + # Gets the min_temp and max_temp + if ( + self._underlying_climate.min_temp is not None + and self._underlying_climate is not None + ): + min_val = self._underlying_climate.min_temp + max_val = self._underlying_climate.max_temp + + new_value = max(min_val, min(value, max_val)) + else: + _LOGGER.debug("%s - no min and max attributes on underlying", self) + new_value = value + + if new_value != value: + _LOGGER.info( + "%s - Target temp have been updated due min, max of the underlying entity. new_value=%.0f value=%.0f min=%.0f max=%.0f", + self, + new_value, + value, + min_val, + max_val, + ) + + return new_value + + +class UnderlyingValve(UnderlyingEntity): + """Represent a underlying switch""" + + _hvac_mode: HVACMode + # This is the percentage of opening int integer (from 0 to 100) + _percent_open: int + + def __init__( + self, hass: HomeAssistant, thermostat: Any, valve_entity_id: str + ) -> None: + """Initialize the underlying switch""" + + super().__init__( + hass=hass, + thermostat=thermostat, + entity_type=UnderlyingEntityType.VALVE, + entity_id=valve_entity_id, + ) + self._async_cancel_cycle = None + self._should_relaunch_control_heating = False + self._hvac_mode = None + self._percent_open = self._thermostat.valve_open_percent + self._valve_entity_id = valve_entity_id + + async def send_percent_open(self): + """Send the percent open to the underlying valve""" + # This may fails if called after shutdown + try: + data = {"value": self._percent_open} + target = {ATTR_ENTITY_ID: self._entity_id} + domain = self._entity_id.split(".")[0] + await self._hass.services.async_call( + domain=domain, + service=SERVICE_SET_VALUE, + service_data=data, + target=target, + ) + except ServiceNotFound as err: + _LOGGER.error(err) + # This could happens in unit test if input_number domain is not yet loaded + # raise err + + async def turn_off(self): + """Turn heater toggleable device off.""" + _LOGGER.debug("%s - Stopping underlying valve entity %s", self, self._entity_id) + # Issue 341 + is_active = self.is_device_active + self._percent_open = self.cap_sent_value(0) + if is_active: + await self.send_percent_open() + + async def turn_on(self): + """Nothing to do for Valve because it cannot be turned on""" + self.set_valve_open_percent() + + async def set_hvac_mode(self, hvac_mode: HVACMode) -> bool: + """Set the HVACmode. Returns true if something have change""" + + if hvac_mode == HVACMode.OFF and self.is_device_active: + await self.turn_off() + + if hvac_mode != HVACMode.OFF and not self.is_device_active: + await self.turn_on() + + if self._hvac_mode != hvac_mode: + self._hvac_mode = hvac_mode + return True + else: + return False + + @property + def is_device_active(self): + """If the toggleable device is currently active.""" + try: + return self._percent_open > 0 + # To test if real device is open but this is causing some side effect + # because the activation can be deferred - + # or float(self._hass.states.get(self._entity_id).state) > 0 + except Exception: # pylint: disable=broad-exception-caught + return False + + @overrides + async def start_cycle( + self, + hvac_mode: HVACMode, + _1, + _2, + _3, + force=False, + ): + """We use this function to change the on_percent""" + if force: + self._percent_open = self.cap_sent_value(self._percent_open) + await self.send_percent_open() + + @overrides + def cap_sent_value(self, value) -> float: + """Try to adapt the open_percent value to the min / max found + in the underlying entity (if any)""" + + # Gets the last number state + valve_state: State = self._hass.states.get(self._valve_entity_id) + if valve_state is None: + return value + + if "min" in valve_state.attributes and "max" in valve_state.attributes: + min_val = valve_state.attributes["min"] + max_val = valve_state.attributes["max"] + + new_value = round(max(min_val, min(value / 100 * max_val, max_val))) + else: + _LOGGER.debug("%s - no min and max attributes on underlying", self) + new_value = value + + if new_value != value: + _LOGGER.info( + "%s - Valve open percent have been updated due min, max of the underlying entity. new_value=%.0f value=%.0f min=%.0f max=%.0f", + self, + new_value, + value, + min_val, + max_val, + ) + + return new_value + + def set_valve_open_percent(self): + """Update the valve open percent""" + caped_val = self.cap_sent_value(self._thermostat.valve_open_percent) + if self._percent_open == caped_val: + # No changes + return + + self._percent_open = caped_val + # Send the new command to valve via a service call + + _LOGGER.info( + "%s - Setting valve ouverture percent to %s", self, self._percent_open + ) + # Send the change to the valve, in background + self._hass.create_task(self.send_percent_open()) + + def remove_entity(self): + """Remove the entity after stopping its cycle""" + self._cancel_cycle() diff --git a/config/custom_components/versatile_thermostat/vtherm_api.py b/config/custom_components/versatile_thermostat/vtherm_api.py new file mode 100644 index 0000000..52a0609 --- /dev/null +++ b/config/custom_components/versatile_thermostat/vtherm_api.py @@ -0,0 +1,287 @@ +""" The API of Versatile Thermostat""" + +import logging +from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry + +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.components.climate import ClimateEntity, DOMAIN as CLIMATE_DOMAIN +from homeassistant.components.number import NumberEntity + +from .const import ( + DOMAIN, + CONF_AUTO_REGULATION_EXPERT, + CONF_SHORT_EMA_PARAMS, + CONF_SAFETY_MODE, + CONF_THERMOSTAT_TYPE, + CONF_THERMOSTAT_CENTRAL_CONFIG, +) + +VTHERM_API_NAME = "vtherm_api" + +_LOGGER = logging.getLogger(__name__) + + +class VersatileThermostatAPI(dict): + """The VersatileThermostatAPI""" + + _hass: HomeAssistant = None + + @classmethod + def get_vtherm_api(cls, hass=None): + """Get the eventual VTherm API class instance or + instantiate it if it doesn't exists""" + if hass is not None: + VersatileThermostatAPI._hass = hass + + if VersatileThermostatAPI._hass is None: + return None + + domain = VersatileThermostatAPI._hass.data.get(DOMAIN) + if not domain: + VersatileThermostatAPI._hass.data.setdefault(DOMAIN, {}) + + ret = VersatileThermostatAPI._hass.data.get(DOMAIN).get(VTHERM_API_NAME) + if ret is None: + ret = VersatileThermostatAPI() + VersatileThermostatAPI._hass.data[DOMAIN][VTHERM_API_NAME] = ret + return ret + + def __init__(self) -> None: + _LOGGER.debug("building a VersatileThermostatAPI") + super().__init__() + self._expert_params = None + self._short_ema_params = None + self._safety_mode = None + self._central_boiler_entity = None + self._threshold_number_entity = None + self._nb_active_number_entity = None + self._central_configuration = None + self._central_mode_select = None + # A dict that will store all Number entities which holds the temperature + self._number_temperatures = dict() + + def find_central_configuration(self): + """Search for a central configuration""" + if not self._central_configuration: + for ( + config_entry + ) in VersatileThermostatAPI._hass.config_entries.async_entries(DOMAIN): + if ( + config_entry.data.get(CONF_THERMOSTAT_TYPE) + == CONF_THERMOSTAT_CENTRAL_CONFIG + ): + self._central_configuration = config_entry + break + # return self._central_configuration + return self._central_configuration + + def add_entry(self, entry: ConfigEntry): + """Add a new entry""" + _LOGGER.debug("Add the entry %s", entry.entry_id) + # Add the entry in hass.data + VersatileThermostatAPI._hass.data[DOMAIN][entry.entry_id] = entry + + def remove_entry(self, entry: ConfigEntry): + """Remove an entry""" + _LOGGER.debug("Remove the entry %s", entry.entry_id) + VersatileThermostatAPI._hass.data[DOMAIN].pop(entry.entry_id) + # If not more entries are preset, remove the API + if len(self) == 0: + _LOGGER.debug("No more entries-> Remove the API from DOMAIN") + VersatileThermostatAPI._hass.data.pop(DOMAIN) + + def set_global_config(self, config): + """Read the global configuration from configuration.yaml file""" + _LOGGER.info("Read global config from configuration.yaml") + + self._expert_params = config.get(CONF_AUTO_REGULATION_EXPERT) + if self._expert_params: + _LOGGER.debug("We have found expert params %s", self._expert_params) + + self._short_ema_params = config.get(CONF_SHORT_EMA_PARAMS) + if self._short_ema_params: + _LOGGER.debug("We have found short ema params %s", self._short_ema_params) + + self._safety_mode = config.get(CONF_SAFETY_MODE) + if self._safety_mode: + _LOGGER.debug("We have found safet_mode params %s", self._safety_mode) + + def register_central_boiler(self, central_boiler_entity): + """Register the central boiler entity. This is used by the CentralBoilerBinarySensor + class to register itself at creation""" + self._central_boiler_entity = central_boiler_entity + + def register_central_boiler_activation_number_threshold( + self, threshold_number_entity + ): + """register the two number entities needed for boiler activation""" + self._threshold_number_entity = threshold_number_entity + # If sensor and threshold number are initialized, reload the listener + # if self._nb_active_number_entity and self._central_boiler_entity: + # self._hass.async_add_job(self.reload_central_boiler_binary_listener) + + def register_nb_device_active_boiler(self, nb_active_number_entity): + """register the two number entities needed for boiler activation""" + self._nb_active_number_entity = nb_active_number_entity + # if self._threshold_number_entity and self._central_boiler_entity: + # self._hass.async_add_job(self.reload_central_boiler_binary_listener) + + def register_temperature_number( + self, + config_id: str, + preset_name: str, + number_entity: NumberEntity, + ): + """Register the NumberEntity for a particular device / preset.""" + # Search for device_name into the _number_temperatures dict + if not self._number_temperatures.get(config_id): + self._number_temperatures[config_id] = dict() + + self._number_temperatures.get(config_id)[preset_name] = number_entity + + def get_temperature_number_value(self, config_id, preset_name) -> float | None: + """Returns the value of a previously registred NumberEntity which represent + a temperature. If no NumberEntity was previously registred, then returns None""" + entities = self._number_temperatures.get(config_id, None) + if entities: + entity = entities.get(preset_name, None) + if entity: + return entity.state + return None + + async def init_vtherm_links(self): + """Initialize all VTherms entities links + This method is called when HA is fully started (and all entities should be initialized) + Or when we need to reload all VTherm links (with Number temp entities, central boiler, ...) + """ + await self.reload_central_boiler_binary_listener() + await self.reload_central_boiler_entities_list() + # Initialization of all preset for all VTherm + component: EntityComponent[ClimateEntity] = self._hass.data.get( + CLIMATE_DOMAIN, None + ) + if component: + for entity in component.entities: + # if hasattr(entity, "init_presets"): + # if ( + # only_use_central is False + # or entity.use_central_config_temperature + # ): + # await entity.init_presets(self.find_central_configuration()) + + # A little hack to test if the climate is a VTherm. Cannot use isinstance due to circular dependency of BaseThermostat + if ( + entity.device_info + and entity.device_info.get("model", None) == DOMAIN + ): + await entity.async_startup(self.find_central_configuration()) + + async def init_vtherm_preset_with_central(self): + """Init all VTherm presets when the VTherm uses central temperature""" + # Initialization of all preset for all VTherm + component: EntityComponent[ClimateEntity] = self._hass.data.get( + CLIMATE_DOMAIN, None + ) + if component: + for entity in component.entities: + if ( + entity.device_info + and entity.device_info.get("model", None) == DOMAIN + and entity.use_central_config_temperature + ): + await entity.init_presets(self.find_central_configuration()) + + async def reload_central_boiler_binary_listener(self): + """Reloads the BinarySensor entity which listen to the number of + active devices and the thresholds entities""" + if self._central_boiler_entity: + await self._central_boiler_entity.listen_nb_active_vtherm_entity() + + async def reload_central_boiler_entities_list(self): + """Reload the central boiler list of entities if a central boiler is used""" + if self._nb_active_number_entity is not None: + await self._nb_active_number_entity.listen_vtherms_entities() + + def register_central_mode_select(self, central_mode_select): + """Register the select entity which holds the central_mode""" + self._central_mode_select = central_mode_select + + async def notify_central_mode_change(self, old_central_mode: str | None = None): + """Notify all VTherm that the central_mode have change""" + if self._central_mode_select is None: + return + + # Update all VTherm states + component: EntityComponent[ClimateEntity] = self.hass.data[CLIMATE_DOMAIN] + for entity in component.entities: + if entity.device_info and entity.device_info.get("model", None) == DOMAIN: + _LOGGER.debug( + "Changing the central_mode. We have find %s to update", + entity.name, + ) + await entity.check_central_mode( + self._central_mode_select.state, old_central_mode + ) + + @property + def self_regulation_expert(self): + """Get the self regulation params""" + return self._expert_params + + @property + def short_ema_params(self): + """Get the short EMA params in expert mode""" + return self._short_ema_params + + @property + def safety_mode(self): + """Get the safety_mode params""" + return self._safety_mode + + @property + def central_boiler_entity(self): + """Get the central boiler binary_sensor entity""" + return self._central_boiler_entity + + @property + def nb_active_device_for_boiler(self): + """Returns the number of active VTherm which have an + influence on boiler""" + if self._nb_active_number_entity is None: + return None + else: + return self._nb_active_number_entity.native_value + + @property + def nb_active_device_for_boiler_entity(self): + """Returns the number of active VTherm entity which have an + influence on boiler""" + return self._nb_active_number_entity + + @property + def nb_active_device_for_boiler_threshold_entity(self): + """Returns the number of active VTherm entity which have an + influence on boiler""" + return self._threshold_number_entity + + @property + def nb_active_device_for_boiler_threshold(self): + """Returns the number of active VTherm entity which have an + influence on boiler""" + if self._threshold_number_entity is None: + return None + return int(self._threshold_number_entity.native_value) + + @property + def central_mode(self) -> str | None: + """Get the current central mode or None""" + if self._central_mode_select: + return self._central_mode_select.state + else: + return None + + @property + def hass(self): + """Get the HomeAssistant object""" + return VersatileThermostatAPI._hass diff --git a/config/custom_components/vigieau/__init__.py b/config/custom_components/vigieau/__init__.py new file mode 100644 index 0000000..eddf477 --- /dev/null +++ b/config/custom_components/vigieau/__init__.py @@ -0,0 +1,401 @@ +import os +import re +import json +import urllib.parse +import logging +from datetime import timedelta, datetime +from zoneinfo import ZoneInfo +from typing import Any, Dict, Optional, Tuple +from dateutil import tz +from itertools import dropwhile, takewhile +import aiohttp + + +from homeassistant.const import Platform, STATE_ON +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.typing import ConfigType +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.components.sensor import RestoreSensor, SensorEntity +from .const import ( + DOMAIN, + SENSOR_DEFINITIONS, + CONF_INSEE_CODE, + CONF_CITY, + CONF_LOCATION_MODE, + DEVICE_ID_KEY, + HA_COORD, + NAME, + SENSOR_DEFINITIONS, + LOCATION_MODES, + VigieEauSensorEntityDescription, +) +from .config_flow import get_insee_code_fromcoord +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from .api import VigieauApi, VigieauApiError + + +_LOGGER = logging.getLogger(__name__) + +MIGRATED_FROM_VERSION_1 = "migrated_from_version_1" +MIGRATED_FROM_VERSION_3 = "migrated_from_version_3" + + +async def async_migrate_entry(hass, config_entry: ConfigEntry): + if config_entry.version == 1: + _LOGGER.warn("config entry version is 1, migrating to version 2") + new = {**config_entry.data} + insee_code, city_name, lat, lon = await get_insee_code_fromcoord(hass) + new[CONF_INSEE_CODE] = insee_code + new[CONF_CITY] = city_name + new[CONF_LOCATION_MODE] = HA_COORD + new[ + DEVICE_ID_KEY + ] = "Vigieau" # hardcoded to match hardcoded id from version 0.3.9 + new[CONF_LATITUDE] = lat + new[CONF_LONGITUDE] = lon + new[MIGRATED_FROM_VERSION_1] = True + _LOGGER.warn( + f"Migration detected insee code for current HA instance is {insee_code} in {city_name}" + ) + + config_entry.version = 3 + hass.config_entries.async_update_entry(config_entry, data=new) + if config_entry.version == 2: + _LOGGER.warn("config entry version is 2, migrating to version 3") + new = {**config_entry.data} + insee_code, city_name, lat, lon = await get_insee_code_fromcoord(hass) + new[CONF_LATITUDE] = lat + new[CONF_LONGITUDE] = lon + config_entry.version = 3 + hass.config_entries.async_update_entry(config_entry, data=new) + + if config_entry.version == 3: + _LOGGER.warn("config entry version is 3, migrating to version 4") + new = {**config_entry.data} + insee_code, city_name, lat, lon = await get_insee_code_fromcoord(hass) + new[MIGRATED_FROM_VERSION_3] = True + config_entry.version = 4 + hass.config_entries.async_update_entry(config_entry, data=new) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + hass.data.setdefault(DOMAIN, {}) + + # here we store the coordinator for future access + if entry.entry_id not in hass.data[DOMAIN]: + hass.data[DOMAIN][entry.entry_id] = {} + hass.data[DOMAIN][entry.entry_id]["vigieau_coordinator"] = VigieauAPICoordinator( + hass, dict(entry.data) + ) + + # will make sure async_setup_entry from sensor.py is called + await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR]) + + # subscribe to config updates + entry.async_on_unload(entry.add_update_listener(update_entry)) + + return True + + +async def update_entry(hass, entry): + """ + This method is called when options are updated + We trigger the reloading of entry (that will eventually call async_unload_entry) + """ + _LOGGER.debug("update_entry method called") + # will make sure async_setup_entry from sensor.py is called + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """This method is called to clean all sensors before re-adding them""" + _LOGGER.debug("async_unload_entry method called") + unload_ok = await hass.config_entries.async_unload_platforms( + entry, [Platform.SENSOR] + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok + + +class VigieauAPICoordinator(DataUpdateCoordinator): + """A coordinator to fetch data from the api only once""" + + def __init__(self, hass, config: ConfigType): + super().__init__( + hass, + _LOGGER, + name="vigieau api", # for logging purpose + update_interval=timedelta(hours=1), + update_method=self.update_method, + ) + self.config = config + self.hass = hass + + async def update_method(self): + """Fetch data from API endpoint.""" + try: + _LOGGER.debug( + f"Calling update method, {len(self._listeners)} listeners subscribed" + ) + if "VIGIEAU_APIFAIL" in os.environ: + raise UpdateFailed( + "Failing update on purpose to test state restoration" + ) + _LOGGER.debug("Starting collecting data") + + city_code = self.config[CONF_INSEE_CODE] + lat = self.config[CONF_LATITUDE] + long = self.config[CONF_LONGITUDE] + + session = async_get_clientsession(self.hass) + vigieau = VigieauApi(session) + try: + # TODO(kamaradclimber): there 4 supported profils: particulier, entreprise, collectivite and exploitation + data = await vigieau.get_data(lat, long, city_code, "particulier") + except VigieauApiError as e: + raise UpdateFailed(f"Failed fetching vigieau data: {e.text}") + + for usage in data["usages"]: + found = False + for sensor in SENSOR_DEFINITIONS: + for matcher in sensor.matchers: + if re.search( + matcher, + usage["nom"] + "|" + usage['thematique'], + re.IGNORECASE, + ): + found = True + if not found: + report_data = json.dumps( + {"insee code": city_code, "nom": usage["nom"]}, + ensure_ascii=False, + ) + _LOGGER.warn( + f"The following restriction is unknown from this integration, please report an issue with: {report_data}" + ) + return data + except Exception as err: + raise UpdateFailed(f"Error communicating with API: {err}") + + +class AlertLevelEntity(CoordinatorEntity, SensorEntity): + """Expose the alert level for the location""" + + def __init__( + self, + coordinator: VigieauAPICoordinator, + hass: HomeAssistant, + config_entry: ConfigEntry, + ): + super().__init__(coordinator) + self.hass = hass + self._attr_name = f"Alert level in {config_entry.data.get(CONF_CITY)}" + self._attr_native_value = None + self._attr_state_attributes = None + if MIGRATED_FROM_VERSION_1 in config_entry.data: + self._attr_unique_id = "sensor-vigieau-Alert level" + else: + self._attr_unique_id = f"sensor-vigieau-{self._attr_name}-{config_entry.data.get(CONF_INSEE_CODE)}" + + self._attr_device_info = DeviceInfo( + name=f"{NAME} {config_entry.data.get(CONF_CITY)}", + entry_type=DeviceEntryType.SERVICE, + identifiers={ + ( + DOMAIN, + str(config_entry.data.get(DEVICE_ID_KEY)), + ) + }, + manufacturer=NAME, + model=config_entry.data.get(CONF_INSEE_CODE), + ) + + def enrich_attributes(self, data: dict, key_source: str, key_target: str): + if key_source in data: + self._attr_state_attributes = self._attr_state_attributes or {} + if key_source in data: + self._attr_state_attributes[key_target] = data[key_source] + + @callback + def _handle_coordinator_update(self) -> None: + _LOGGER.debug(f"Receiving an update for {self.unique_id} sensor") + if not self.coordinator.last_update_success: + _LOGGER.debug("Last coordinator failed, assuming state has not changed") + return + self._attr_native_value = self.coordinator.data["niveauGravite"] + + self._attr_icon = { + "vigilance": "mdi:water-check", + "alerte": "mdi:water-alert", + "alerte_renforcée": "mdi:water-remove", + "alerte_renforcee": "mdi:water-remove", + "crise": "mdi:water-off", + }[self._attr_native_value.lower().replace(" ", "_")] + + self.enrich_attributes(self.coordinator.data, "cheminFichier", "source") + self.enrich_attributes( + self.coordinator.data, "cheminFichierArreteCadre", "source2" + ) + + restrictions = [ + restriction["nom"] for restriction in self.coordinator.data["usages"] + ] + self._attr_state_attributes = self._attr_state_attributes or {} + self._attr_state_attributes["current_restrictions"] = ", ".join(restrictions) + + self.async_write_ha_state() + + @property + def state_attributes(self): + return self._attr_state_attributes + + +class UsageRestrictionEntity(CoordinatorEntity, SensorEntity): + """Expose a restriction for a given usage""" + + entity_description: VigieEauSensorEntityDescription + + def __init__( + self, + coordinator: VigieauAPICoordinator, + hass: HomeAssistant, + usage_id: str, + config_entry: ConfigEntry, + description: VigieEauSensorEntityDescription, + ): + super().__init__(coordinator) + self.hass = hass + # naming the attribute very early before it's updated by first api response is a hack + # to make sure we have a decent entity_id selected by home assistant + self._attr_name = ( + f"{description.name}_restrictions_{config_entry.data.get(CONF_CITY)}" + ) + self._attr_native_value = None + self._attr_state_attributes = None + self._attr_entity_category = EntityCategory.DIAGNOSTIC + self._config = description + if MIGRATED_FROM_VERSION_1 in config_entry.data: + self._attr_unique_id = f"sensor-vigieau-{self._config.key}" + elif MIGRATED_FROM_VERSION_3 in config_entry.data: + self._attr_unique_id = f"sensor-vigieau-{self._attr_name}-{config_entry.data.get(CONF_INSEE_CODE)}-{config_entry.data.get(CONF_LATITUDE)}-{config_entry.data.get(CONF_LONGITUDE)}" + else: + self._attr_unique_id = f"sensor-vigieau-{self._config.key}-{config_entry.data.get(CONF_INSEE_CODE)}-{config_entry.data.get(CONF_LATITUDE)}-{config_entry.data.get(CONF_LONGITUDE)}" + self._attr_device_info = DeviceInfo( + name=f"{NAME} {config_entry.data.get(CONF_CITY)}", + entry_type=DeviceEntryType.SERVICE, + identifiers={ + ( + DOMAIN, + str(config_entry.data.get(DEVICE_ID_KEY)), + ) + }, + manufacturer=NAME, + model=config_entry.data.get(CONF_INSEE_CODE), + ) + + def enrich_attributes(self, usage: dict, key_source: str, key_target: str): + if key_source in usage: + self._attr_state_attributes = self._attr_state_attributes or {} + self._attr_state_attributes[key_target] = usage[key_source] + + @property + def icon(self): + return self._config.icon + + @callback + def _handle_coordinator_update(self) -> None: + _LOGGER.debug(f"Receiving an update for {self.unique_id} sensor") + if not self.coordinator.last_update_success: + _LOGGER.debug("Last coordinator failed, assuming state has not changed") + return + + self._attr_state_attributes = self._attr_state_attributes or {} + self._restrictions = [] + self._time_restrictions = {} + self._attr_name = str(self._config.name) + for usage in self.coordinator.data["usages"]: + for matcher in self._config.matchers: + fully_qualified_usage = usage["nom"] + "|" + usage['thematique'] + if re.search(matcher, fully_qualified_usage, re.IGNORECASE): + self._attr_state_attributes = self._attr_state_attributes or {} + restriction = usage.get("description") + if restriction is None: + raise UpdateFailed( + "Restriction level is not specified" + ) + self._attr_state_attributes[ + f"Categorie: {usage['nom']}" + ] = restriction + self._restrictions.append(restriction) + + self.enrich_attributes( + usage, "details", f"{usage['nom']} (details)" + ) + if "heureFin" in usage and "heureDebut" in usage: + self._time_restrictions[usage["nom"]] = [ + usage["heureDebut"], + usage["heureFin"], + ] + + # we only want to add those attributes if they are not ambiguous + if len(set([repr(r) for r in self._time_restrictions.values()])) == 1: + restrictions = list(self._time_restrictions.values())[0] + self._attr_state_attributes["heureDebut"] = restrictions[0] + self._attr_state_attributes["heureFin"] = restrictions[1] + elif len(self._time_restrictions) > 0: + _LOGGER.debug( + f"There are {len(self._time_restrictions)} usage with time restrictions for this sensor, exposing info per usage" + ) + for name in self._time_restrictions: + self._attr_state_attributes[ + f"{name} (heureDebut)" + ] = self._time_restrictions[name][0] + self._attr_state_attributes[ + f"{name} (heureFin)" + ] = self._time_restrictions[name][1] + + self._attr_native_value = self.compute_native_value() + self.async_write_ha_state() + + def compute_native_value(self) -> Optional[str]: + """This method extract the most relevant restriction level to display as aggregate""" + if len(self._restrictions) == 0: + return "Aucune restriction" + if "Interdiction sur plage horaire" in self._restrictions: + return "Interdiction sur plage horaire" + if "Interdiction sauf exception" in self._restrictions: + return "Interdiction sauf exception" + if "Interdit sauf pour les usages commerciaux après accord du service de police de l’eau." in self._restrictions: + return "Interdiction sauf exception" + if "Interdiction" in self._restrictions: + return "Interdiction" + if "Interdiction." in self._restrictions: + return "Interdiction" + if "Réduction de prélèvement" in self._restrictions: + return "Réduction de prélèvement" + if "Consulter l’arrêté" in self._restrictions: + return "Erreur: consulter l'arreté" + if "Se référer à l'arrêté de restriction en cours de validité." in self._restrictions: + return "Erreur: consulter l'arreté" + if "Pas de restriction sauf arrêté spécifique." in self._restrictions: + return "Autorisé sauf exception" + if len(self._restrictions) == 1: + return self._restrictions[0] + _LOGGER.warn(f"Restrictions are hard to interpret: {self._restrictions}") + return None + + @property + def state_attributes(self): + return self._attr_state_attributes diff --git a/config/custom_components/vigieau/api.py b/config/custom_components/vigieau/api.py new file mode 100644 index 0000000..4255310 --- /dev/null +++ b/config/custom_components/vigieau/api.py @@ -0,0 +1,132 @@ +import logging +import aiohttp +from typing import Optional, Tuple +from aiohttp.client import ClientTimeout +from homeassistant.helpers.update_coordinator import UpdateFailed +from .const import GEOAPI_GOUV_URL, ADDRESS_API_URL, VIGIEAU_API_URL +import re + +DEFAULT_TIMEOUT = 120 +CLIENT_TIMEOUT = ClientTimeout(total=DEFAULT_TIMEOUT) + +_LOGGER = logging.getLogger(__name__) + + +class InseeApiError(RuntimeError): + pass + + +class InseeApi: + """Api to get INSEE data""" + + def __init__( + self, session: Optional[aiohttp.ClientSession] = None, timeout=CLIENT_TIMEOUT + ) -> None: + self._timeout = timeout + self._session = session or aiohttp.ClientSession() + + async def get_insee_list(self): + """Get all insee codes""" + session = aiohttp.ClientSession() + resp = await session.get(GEOAPI_GOUV_URL) + + if resp.status != 200: + raise InseeApiError( + f"Unable to list all INSEE codes. API status was {resp.status}" + ) + + return await resp.json() + + async def get_data(self, zipcode) -> dict: + """Get INSEE code for a given zip code""" + url = f"{GEOAPI_GOUV_URL}&codePostal={zipcode}&format=json&geometry=centre" + + resp = await self._session.get(url) + if resp.status != 200: + raise InseeApiError(f"Unable to get Insee Code for zip {zipcode}") + + data = await resp.json() + _LOGGER.debug("Got Data GEOAPI data : %s ", data) + + if len(data) == 0: + raise InseeApiError("No data received with GeoApi") + + return data + + +class AddressApiError(RuntimeError): + pass + + +class AddressApi: + """API for Reverse geocoding""" + + def __init__( + self, session: Optional[aiohttp.ClientSession] = None, timeout=CLIENT_TIMEOUT + ) -> None: + self._timeout = timeout + self._session = session or aiohttp.ClientSession() + + async def get_data(self, lat: float, lon: float) -> Tuple[str, str, float, float]: + url = f"{ADDRESS_API_URL}/reverse/?lat={lat}&lon={lon}&type=housenumber" + resp = await self._session.get(url) + if resp.status != 200: + raise AddressApiError( + "Failed to fetch address from api-adresse.data.gouv.fr api" + ) + data = await resp.json() + _LOGGER.debug(f"Data received from {ADDRESS_API_URL}: {data}") + if len(data["features"]) == 0: + _LOGGER.warn( + "Data received from api-adresse.data.gouv.fr is empty for those coordinates: (%s, %s). Either coordinates are not located in France or the governement geocoding database has no record for them.", + lat, + lon, + ) + raise AddressApiError( + "Impossible to find approximate address of the current HA instance. API returned no result." + ) + properties = data["features"][0]["properties"] + return (properties["citycode"], properties["city"], lat, lon) + + +class VigieauApiError(RuntimeError): + def __init__(self, message, text): + super().__init__(message) + self._text = text + + @property + def text(self) -> str: + return self._text + + +class VigieauApi: + def __init__( + self, session: Optional[aiohttp.ClientSession] = None, timeout=CLIENT_TIMEOUT + ) -> None: + self._timeout = timeout + self._session = session or aiohttp.ClientSession() + + async def get_data( + self, lat: Optional[float], long: Optional[float], insee_code: str, profil: str + ) -> dict: + url = f"{VIGIEAU_API_URL}/api/zones?commune={insee_code}&profil={profil}&zoneType=SUP" + if lat is not None and long is not None: + url += f"&lat={lat}&lon={long}" + _LOGGER.debug(f"Requesting restrictions from {url}") + resp = await self._session.get(url) + if ( + resp.status == 404 + and "message" in await resp.json() + and re.match("Aucune zone.+en vigueur", (await resp.json())["message"]) + ): + _LOGGER.debug(f"Vigieau replied with no restriction, faking data") + data = {"niveauGravite": "vigilance", "usages": [], "arrete": {}} + elif resp.status == 200 and (await resp.text()) == "": + _LOGGER.debug(f"Vigieau replied with no data at all, faking data") + data = {"niveauGravite": "vigilance", "usages": [], "arrete": {}} + elif resp.status in range(200, 300): + data = await resp.json() + else: + raise VigieauApiError(f"Failed fetching vigieau data", resp.text) + _LOGGER.debug(f"Data fetched from vigieau: {data}") + return data diff --git a/config/custom_components/vigieau/config_flow.py b/config/custom_components/vigieau/config_flow.py new file mode 100644 index 0000000..9e41518 --- /dev/null +++ b/config/custom_components/vigieau/config_flow.py @@ -0,0 +1,212 @@ +import logging +from typing import Any, Optional, Tuple +import voluptuous as vol +from homeassistant.core import callback, HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant import config_entries +from .api import InseeApi, AddressApi +from .const import ( + DOMAIN, + CONF_LOCATION_MODE, + LOCATION_MODES, + HA_COORD, + ZIP_CODE, + CONF_INSEE_CODE, + CONF_CODE_POSTAL, + CONF_CITY, + DEVICE_ID_KEY, +) +from homeassistant.helpers.selector import LocationSelector +from homeassistant import config_entries +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from .api import InseeApi, AddressApi +from .const import ( + DOMAIN, + CONF_LOCATION_MODE, + LOCATION_MODES, + HA_COORD, + ZIP_CODE, + CONF_INSEE_CODE, + CONF_CODE_POSTAL, + CONF_CITY, + SELECT_COORD, + CONF_LOCATION_MAP, +) + +_LOGGER = logging.getLogger(__name__) + +# Description of the config flow: +# async_step_user is called when user starts to configure the integration +# we follow with a flow of form/menu +# eventually we call async_create_entry with a dictionnary of data +# HA calls async_setup_entry with a ConfigEntry which wraps this data (defined in __init__.py) +# in async_setup_entry we call hass.config_entries.async_forward_entry_setups to setup each relevant platform (sensor in our case) +# HA calls async_setup_entry from sensor.py + +LOCATION_SCHEMA = vol.Schema( + {vol.Required(CONF_LOCATION_MODE, default=HA_COORD): vol.In(LOCATION_MODES)} +) + +ZIPCODE_SCHEMA = vol.Schema({vol.Required(CONF_CODE_POSTAL, default=""): cv.string}) + + +async def get_insee_code_fromzip(hass: HomeAssistant, data: dict) -> None: + """Get Insee code from zip code""" + session = async_get_clientsession(hass) + try: + client = InseeApi(session) + return await client.get_data(data) + except ValueError as exc: + raise exc + + +async def get_insee_code_fromcoord( + hass: HomeAssistant, lat=None, lon=None +) -> Tuple[str, str, float, float]: + """Get Insee code from GPS coords""" + session = async_get_clientsession(hass) + try: + client = AddressApi(session) + if lat is None or lon is None: + lon = hass.config.as_dict()["longitude"] + lat = hass.config.as_dict()["latitude"] + return await client.get_data(lat, lon) + except ValueError as exc: + raise exc + + +def _build_place_key(city) -> str: + return f"{city['code']};{city['nom']};{city['centre']['coordinates'][0]};{city['centre']['coordinates'][1]}" + + +class SetupConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + VERSION = 4 + + def __init__(self): + """Initialize""" + self.data = {} + self.city_insee = [] + + @callback + def _show_setup_form(self, step_id=None, user_input=None, schema=None, errors=None): + """Show the setup form to the user.""" + + if user_input is None: + user_input = {} + + return self.async_show_form( + step_id=step_id, + data_schema=schema, + errors=errors or {}, + ) + + async def async_step_user(self, user_input: Optional[dict[str, Any]] = None): + """Called once with None as user_input, then a second time with user provided input""" + errors = {} + if user_input is not None: + self.data[CONF_LOCATION_MODE] = user_input[CONF_LOCATION_MODE] + if user_input[CONF_LOCATION_MODE] == HA_COORD: + try: + city_infos = await get_insee_code_fromcoord(self.hass) + except ValueError: + errors["base"] = "noinsee" + if not errors: + self.data[CONF_INSEE_CODE] = city_infos[0] + self.data[CONF_CITY] = city_infos[1] + self.data[CONF_LATITUDE] = city_infos[2] + self.data[CONF_LONGITUDE] = city_infos[3] + self.data[DEVICE_ID_KEY] = city_infos[0] + return await self.async_step_location(user_input=self.data) + elif user_input[CONF_LOCATION_MODE] == ZIP_CODE: + self.data = user_input + return await self.async_step_location() + elif user_input[CONF_LOCATION_MODE] == SELECT_COORD: + return await self.async_step_map_select() + + return self._show_setup_form("user", user_input, LOCATION_SCHEMA, errors) + + async def async_step_map_select(self, user_input=None): + COORD_SCHEMA = vol.Schema( + { + vol.Required( + CONF_LOCATION_MAP, + default={ + CONF_LATITUDE: self.hass.config.latitude, + CONF_LONGITUDE: self.hass.config.longitude, + }, + ): LocationSelector() + } + ) + errors = {} + if user_input is not None: + try: + city_infos = await get_insee_code_fromcoord( + self.hass, + user_input[CONF_LOCATION_MAP][CONF_LATITUDE], + user_input[CONF_LOCATION_MAP][CONF_LONGITUDE], + ) + except ValueError: + errors["base"] = "noinsee" + if not errors: + self.data[CONF_INSEE_CODE] = city_infos[0] + self.data[CONF_CITY] = city_infos[1] + # TODO(kamaradclimber): it's not clear whether we should take lat/long from user input + # or from address api results. + self.data[CONF_LATITUDE] = city_infos[2] + self.data[CONF_LONGITUDE] = city_infos[3] + self.data[DEVICE_ID_KEY] = city_infos[0] + return await self.async_step_location(user_input=self.data) + return self._show_setup_form("map_select", None, COORD_SCHEMA, errors) + + async def async_step_location(self, user_input=None): + """Handle location step""" + errors = {} + if user_input is not None: + city_insee = user_input.get(CONF_INSEE_CODE) + if not city_insee: + # get INSEE Code + try: + self.city_insee = await get_insee_code_fromzip( + self.hass, user_input[CONF_CODE_POSTAL] + ) + except ValueError: + errors["base"] = "noinsee" + if not errors: + return await self.async_step_multilocation() + else: + return self._show_setup_form( + "location", user_input, ZIPCODE_SCHEMA, errors + ) + return self.async_create_entry(title="vigieau", data=self.data) + return self._show_setup_form("location", None, ZIPCODE_SCHEMA, errors) + + async def async_step_multilocation(self, user_input=None): + """Handle location step""" + errors = {} + locations_for_form = {} + for city in self.city_insee: + locations_for_form[_build_place_key(city)] = f"{city['nom']}" + + if not user_input: + if len(self.city_insee) > 1: + return self.async_show_form( + step_id="multilocation", + data_schema=vol.Schema( + { + vol.Required("city", default=[]): vol.In( + locations_for_form + ), + } + ), + errors=errors, + ) + user_input = {CONF_CITY: _build_place_key(self.city_insee[0])} + + city_infos = user_input[CONF_CITY].split(";") + self.data[CONF_INSEE_CODE] = city_infos[0] + self.data[CONF_CITY] = city_infos[1] + self.data[CONF_LONGITUDE] = city_infos[2] + self.data[CONF_LATITUDE] = city_infos[3] + self.data[DEVICE_ID_KEY] = city_infos[0] + return await self.async_step_location(self.data) diff --git a/config/custom_components/vigieau/const.py b/config/custom_components/vigieau/const.py new file mode 100644 index 0000000..7a8238f --- /dev/null +++ b/config/custom_components/vigieau/const.py @@ -0,0 +1,264 @@ +from homeassistant.components.sensor import ( + SensorEntityDescription, +) +from dataclasses import dataclass + +DOMAIN = "vigieau" + +VIGIEAU_API_URL = "https://api.vigieau.gouv.fr" +GEOAPI_GOUV_URL = "https://geo.api.gouv.fr/communes?&fields=code,nom,centre" +ADDRESS_API_URL = "https://api-adresse.data.gouv.fr" +CONF_LOCATION_MODE = "location_mode" +HA_COORD = 0 +ZIP_CODE = 1 +SELECT_COORD = 2 +LOCATION_MODES = { + HA_COORD: "Coordonnées Home Assistant", + ZIP_CODE: "Code Postal", + SELECT_COORD: "Sélection sur carte", +} +CONF_INSEE_CODE = "INSEE" +CONF_CITY = "city" +CONF_CODE_POSTAL = "Code postal" +CONF_LOCATION_MAP = "location_map" +NAME = "Vigieau" +DEVICE_ID_KEY = "device_id" + + +@dataclass +class VigieEauRequiredKeysMixin: + """Mixin for required keys.""" + + category: str + matchers: list[str] + + +@dataclass +class VigieEauSensorEntityDescription( + SensorEntityDescription, VigieEauRequiredKeysMixin +): + """Describes VigieEau sensor entity.""" + + +SENSOR_DEFINITIONS: tuple[VigieEauSensorEntityDescription, ...] = ( + VigieEauSensorEntityDescription( + name="Alimentation des fontaines", + icon="mdi:fountain", + category="fountains", + key="fountains", + matchers=[ + "alimentation des fontaines.+", + "douches .+ plages.+", + "fontaines", + "jeux d'eau", + ], + ), + VigieEauSensorEntityDescription( + name="Arrosage des jardins potagers", + icon="mdi:watering-can", + category="potagers", + key="potagers", + matchers=[ + "Arrosage des .*potagers", + "arrosage.+arbres.+", + "arrosage.+plant.+", + ], + ), + VigieEauSensorEntityDescription( + name="Arrosage voirie et trottoirs", + icon="mdi:road", + category="roads", + key="roads", + matchers=[ + "trottoirs", + "voiries|voieries", + "Arrosage de surfaces de .+ générant de la poussière", + ], + ), + VigieEauSensorEntityDescription( + name="Arrosage des pelouses", + icon="mdi:sprinkler-variant", + category="lawn", + key="lawn", + matchers=[ + "pelouses", + "jardins d'agrément", + "massifs fleuris", + "Arrosage des espaces verts", + "Arrosage des jeunes plantations d'arbres", + "surface.+sportives.+", + "arrosage.+massif.+", + "Nettoyage / arrosage des sites de manifestations temporaires sportives et culturelles", + "Dispositifs de récupération des eaux de pluie", + "Arrosage, arbustes et arbres", + "Arrosage des jardinières et suspensions", + "Arrosage des espaces arborés", + "Arrosage des terrains de sport", + ], + ), + VigieEauSensorEntityDescription( + name="Lavage des véhicules", + icon="mdi:car-wash", + category="car_wash", + key="car_wash", + matchers=[ + "lavage.+particuliers", + "lavage.+professionnels.+portique", + "lavage.+professionnels.+haute pression", + "lavage.+(station|véhicules)", + "lavage.+professionnel.+", + "Nettoyage des véhicules et bateaux", + "Nettoyage des véhicules, des bateaux Y compris par dispositifs mobiles", + ], + ), + VigieEauSensorEntityDescription( + name="Lavage des engins nautiques", + icon="mdi:sail-boat", + category="nautical_vehicules", + key="nautical_vehicules", + matchers=[ + "Activités nautiques : cas général", + "lavage.+engins nautiques.+professionnels", + "Nettoyage.+embarcation", + "lavage.+bateau.+", + "nettoyage.+bateau.+", + "engins nautiques", + "Lavage des embarcations, motorisées ou non, par tout moyen branché sur le réseau public", + "Lavage de véhicule disposant d’un système équipé d’un recyclage de l’eau", + "Carénage des bateaux", + "Lavage et entretien des embarcations .+ en aire de carénage.", + ], + ), + VigieEauSensorEntityDescription( + name="Lavage des toitures, façades", + icon="mdi:home-roof", + category="roof_clean", + key="roof_clean", + matchers=[ + "toitures", + "façades", + "nettoyage.+bâtiments.+", + "nettoyage.+terrasse.+", + ], + ), + VigieEauSensorEntityDescription( + name="Vidange et remplissage des piscines", + icon="mdi:pool", + category="pool", + key="pool", + matchers=[ + "remplissage.+piscines.+(familial|privé)", + "vidange.+piscines", + "piscines privées", # Piscines privées et bains à remous de plus de 1m3 + "piscines non collectives", # Remplissage et vidange de piscines non collectives (de plus de 1 m3) + "baignades.+", + "Remise à niveau des piscines à usage privé", + "Remplissage des jeux d'eau", + "Remplissage des piscine privées", + "Remplissage des piscines individuelles", + "remise à niveau des piscines", + ], + ), + VigieEauSensorEntityDescription( + name="Remplissage/Vidange des plans d'eau", + icon="mdi:waves", + category="ponds", + key="ponds", + matchers=[ + "remplissage.+plan.* d.eau", + "vidange.+plan.* d.eau", + "Alimentation de plan d'eau", # Alimentation de plan d'eau en dérivation de cours d'eau à usage domestique + "alimentation.+plan.* d.eau", + "alimentation.+bassin.+", + "lestage pour stabilité", + "Alimentation d’étangs", + ], + ), + VigieEauSensorEntityDescription( + name="Travaux sur cours d'eau", + icon="mdi:hydro-power", + category="river_rate", + key="river_rate", + matchers=[ + "ouvrage.+cours d.eau", + "travaux.+cours d.eau", + "manoeuvre.+vannes", # Manoeuvre de vannes des seuils et barrages + "Gestion des ouvrages", # FIXME: we should probably match with the category as well + "travaux.+rivière", + "rabattement.+nappe.+", + "faucardage.+", + "Faucardement", + "manoeuvre.+d.ouvrage.+", + "rejet direct d’eaux polluées", + "orpaillage", + "Manœuvres des vannes d.installations hydrauliques", + "Manœuvres d’ouvrages hydrauliques", + "Tout usage domestique non sanitaire de l’eau", + "Réalisation d'un seuil provisoire", + "Rejets directs en cours d’eau", + "Pratiques ou activités dans le lit pouvant avoir un impact sur les milieux aquatiques", + "Perturbations physiques du lit des cours d’eau", + "Entretien de cours d'eau", + "Travaux et rejets", + "Travaux sur les systèmes d’assainissement occasionnant des rejets", + ], + ), + VigieEauSensorEntityDescription( + name="Navigation fluviale", + icon="mdi:ferry", + category="river_movement", + key="river_movement", + matchers=[ + "Navigation fluviale", + "Pratique du canyoning sur matériaux alluvionnaires", + "Pratique de la navigation de loisir", + ], + ), + VigieEauSensorEntityDescription( + name="Arrosage des golfs", + icon="mdi:golf", + category="golfs", + key="golfs", + matchers=["arrosage des golfs"], + ), + VigieEauSensorEntityDescription( + name="Prélèvement en canaux", + icon="mdi:water-pump", + category="canals", + key="canals", + matchers=[ + "Prélèvement en canaux", + "Prélèvements dans le milieu naturel.+", + "prélèvements.+cours d.eau.+", + "prélèvement.+hydraulique.+", + "alimentation.+canaux.+", + "Prélèvements domestiques directs dans les milieux hydrauliques, hors usage professionnel identifié", + "Prélèvement d’eau domestique en milieu", + "Prélèvement d’eau domestique dans un canal existant", + "Prélèvements énergétiques", + "Prélèvement.* en cours d'eau", + "Prélèvements destinés au fonctionnement des milieux naturels", + "Prélèvement sur le site des Marais de Sacy", + "Tout nouveau prélèvement", + "Nouvelles demandes de prélèvement d'eau et création de forages", + "Création de prélèvements", + "Prélèvement en cours d’eau", + ], + ), + VigieEauSensorEntityDescription( + name="Restriction spécifique", + category="misc", + key="misc", + matchers=[ + "Remplissage tonne de chasse", + "Activités cynégétiques", + "Structures gonflables/tubulaires privées à usage collectif > 1m3 nécessitant 1 vidange quotidienne", + "Abreuvement et hygiène des animaux", + "Abreuvement des animaux", + "Irrigation par aspersion des cultures", + # ICPE means "Installation classée pour la protection de l'environment" + "ICPE soumises à un APC relatif à la sécheresse", + "Usages récréatifs collectifs à partir d’eau potable.+", + ], + ), +) diff --git a/config/custom_components/vigieau/manifest.json b/config/custom_components/vigieau/manifest.json new file mode 100644 index 0000000..598e141 --- /dev/null +++ b/config/custom_components/vigieau/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "vigieau", + "name": "Vigieau", + "codeowners": ["@kamaradclimber"], + "config_flow": true, + "documentation": "https://github.com/kamaradclimber/vigieau", + "integration_type": "device", + "iot_class": "cloud_polling", + "issue_tracker": "https://github.com/kamaradclimber/vigieau/issues", + "requirements": [ + ], + "version": "0.1.0" +} diff --git a/config/custom_components/vigieau/scripts/__init__.py b/config/custom_components/vigieau/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/custom_components/vigieau/scripts/full_usage_list.json b/config/custom_components/vigieau/scripts/full_usage_list.json new file mode 100644 index 0000000..1b8bdd8 --- /dev/null +++ b/config/custom_components/vigieau/scripts/full_usage_list.json @@ -0,0 +1,156 @@ +{ + "restrictions": [ + { + "usage": "Abreuvement des animaux", + "thematique": "Abreuver" + }, + { + "usage": "Activités de loisirs professionnelles ou amateurs en cours d’eau.", + "thematique": "Travaux et activités en cours d'eau" + }, + { + "usage": "Alimentation des fontaines publiques", + "thematique": "Alimenter des fontaines et autres usages de loisirs" + }, + { + "usage": "Alimentation des fontaines publiques et privées d’ornement", + "thematique": "Alimenter des fontaines et autres usages de loisirs" + }, + { + "usage": "Arrosage des golfs", + "thematique": "Arroser" + }, + { + "usage": "Arrosage des golfs(Conformément à l'accord cadre golf et environnement 2019-2024", + "thematique": "Arroser" + }, + { + "usage": "Arrosage des jardins potagers", + "thematique": "Arroser" + }, + { + "usage": "Arrosage des jardins potagers collectifs", + "thematique": "Arroser" + }, + { + "usage": "Arrosage des jardins potagers individuels", + "thematique": "Arroser" + }, + { + "usage": "Arrosage des pelouses, massifs fleuris", + "thematique": "Arroser" + }, + { + "usage": "Arrosage des pelouses, massifs fleuris et espaces verts (y compris rond-points, voies de tramway).", + "thematique": "Arroser" + }, + { + "usage": "Arrosage des terrains de sport", + "thematique": "Arroser" + }, + { + "usage": "ICPE soumises à un APC relatif à la sécheresse", + "thematique": "ICPE" + }, + { + "usage": "Irrigation par aspersion des cultures", + "thematique": "Irriguer" + }, + { + "usage": "Lavage de véhicules chez les particuliers", + "thematique": "Nettoyer" + }, + { + "usage": "Lavage de véhicules en station professionnelle", + "thematique": "Nettoyer" + }, + { + "usage": "Lavage de véhicules et bateaux chez les particuliers", + "thematique": "Nettoyer" + }, + { + "usage": "Lavage de véhicules par des particuliers, y compris embarcations motorisées ou non (exemple : Jet ski).", + "thematique": "Nettoyer" + }, + { + "usage": "Lavage de véhicules par des professionnels", + "thematique": "Nettoyer" + }, + { + "usage": "Lavage de véhicules publics ou privés en stations de lavage professionnelles.", + "thematique": "Nettoyer" + }, + { + "usage": "Lavage des bateaux", + "thematique": "Nettoyer" + }, + { + "usage": "Lavage et entretien des embarcations (motorisées ou non) en aire de carénage.", + "thematique": "Nettoyer" + }, + { + "usage": "Navigation fluviale.", + "thematique": "Travaux et activités en cours d'eau" + }, + { + "usage": "Nettoyage des façades, terrasses et murs de clôture", + "thematique": "Nettoyer" + }, + { + "usage": "Nettoyage des façades, toitures, trottoirs et autres surfaces imperméabilisées", + "thematique": "Nettoyer" + }, + { + "usage": "Nettoyage des façades, toitures, trottoirs et autres surfaces imperméabilisées hors activités industrielles.", + "thematique": "Nettoyer" + }, + { + "usage": "Nettoyage des façades, toitures, trottoirs, terrasses, façades imperméabilisées...", + "thematique": "Nettoyer" + }, + { + "usage": "Nettoyage des voieries", + "thematique": "Nettoyer" + }, + { + "usage": "Orpaillage et pêche à l’aimant.", + "thematique": "Travaux et activités en cours d'eau" + }, + { + "usage": "Rejets et travaux en rivière", + "thematique": "Travaux et activités en cours d'eau" + }, + { + "usage": "Remplissage / vidange des plans d'eau", + "thematique": "Remplir ou vidanger" + }, + { + "usage": "Remplissage / vidange des plans d'eau.", + "thematique": "Remplir ou vidanger" + }, + { + "usage": "Remplissage et vidange de piscines privées", + "thematique": "Remplir ou vidanger" + }, + { + "usage": "Remplissage et vidange de piscines privées (de plus d'1 m3)", + "thematique": "Remplir ou vidanger" + }, + { + "usage": "Remplissage et vidange de piscines privées (de plus d'1 m3).", + "thematique": "Remplir ou vidanger" + }, + { + "usage": "Travaux en cours d’eau", + "thematique": "Travaux et activités en cours d'eau" + }, + { + "usage": "Travaux en cours d’eau.", + "thematique": "Travaux et activités en cours d'eau" + }, + { + "usage": "Usages récréatifs collectifs à partir d’eau potable (dans le cadre de manifestations).", + "thematique": "Remplir ou vidanger" + } + ] +} \ No newline at end of file diff --git a/config/custom_components/vigieau/scripts/generate_list.py b/config/custom_components/vigieau/scripts/generate_list.py new file mode 100644 index 0000000..7334736 --- /dev/null +++ b/config/custom_components/vigieau/scripts/generate_list.py @@ -0,0 +1,59 @@ +import aiohttp +import asyncio +import os +import json +from frozendict import frozendict +import sys + +current_dir = os.path.dirname(__file__) +parent_dir = os.path.dirname(current_dir) +sys.path.append(".") +sys.path.append(parent_dir) + +from custom_components.vigieau.api import InseeApi, VigieauApi, VigieauApiError + + +async def main(): + restriction_list = {"restrictions": []} + usages = set() + async with aiohttp.ClientSession() as session: + vigieau = VigieauApi(session) + commune_list = await InseeApi(session).get_insee_list() + for i, commune in enumerate(commune_list): + print(f"{i}/{len(commune_list)}: {commune['nom']}") + try: + restriction = await vigieau.get_data( + insee_code=commune["code"], + profil="particulier", + lat=commune["centre"]["coordinates"][1], + long=commune["centre"]["coordinates"][0], + ) + except VigieauApiError as e: + print(e.text) + # FIXME: Sometimes insee is enough to call vigieau Api, sometimes not exclude the one where it's not enough , for the moment + if restriction: + for usage in restriction.get("usages", []): + usages.add( + frozendict( + {"usage": usage["nom"], "thematique": usage["thematique"]} + ) + ) + if i % 10 == 0: + dump_restrictions(restriction_list, usages) + dump_restrictions(restriction_list, usages) + + +def dump_restrictions(restriction_list, usages): + restriction_list["restrictions"] = sorted( + list(usages), key=lambda h: h["usage"] + ) + + finaldata = json.dumps(restriction_list, ensure_ascii=False, indent=2) + file = os.path.join(os.path.dirname(__file__), "full_usage_list.json") + + with open(file, "w", encoding="utf-8") as outfile: + outfile.write(finaldata) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/config/custom_components/vigieau/sensor.py b/config/custom_components/vigieau/sensor.py new file mode 100644 index 0000000..2acca2e --- /dev/null +++ b/config/custom_components/vigieau/sensor.py @@ -0,0 +1,29 @@ +import logging + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.config_entries import ConfigEntry + +from . import ( + UsageRestrictionEntity, + AlertLevelEntity, +) +from .const import DOMAIN, SENSOR_DEFINITIONS, SENSOR_DEFINITIONS + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + vigieau_coordinator = hass.data[DOMAIN][entry.entry_id]["vigieau_coordinator"] + sensors = [ + UsageRestrictionEntity( + vigieau_coordinator, hass, "mydi", entry, sensor_description + ) + for sensor_description in SENSOR_DEFINITIONS + ] + sensors.append(AlertLevelEntity(vigieau_coordinator, hass, entry)) + + async_add_entities(sensors) + await vigieau_coordinator.async_config_entry_first_refresh() diff --git a/config/custom_components/vigieau/strings.json b/config/custom_components/vigieau/strings.json new file mode 100644 index 0000000..2f80434 --- /dev/null +++ b/config/custom_components/vigieau/strings.json @@ -0,0 +1,8 @@ +{ + "config": { + "step": { + "user": { + } + } + } +} diff --git a/config/custom_components/vigieau/tests/__init__.py b/config/custom_components/vigieau/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/custom_components/vigieau/tests/test_regexp.py b/config/custom_components/vigieau/tests/test_regexp.py new file mode 100644 index 0000000..94702bd --- /dev/null +++ b/config/custom_components/vigieau/tests/test_regexp.py @@ -0,0 +1,44 @@ +from os import path +import sys + +current_dir = path.dirname(__file__) +parent_dir = path.dirname(current_dir) +sys.path.append(".") +sys.path.append(parent_dir) +from custom_components.vigieau.const import SENSOR_DEFINITIONS +import unittest +from pathlib import Path +import json +import os +import re + + +class TestRegexp(unittest.TestCase): + def test_matcher_in_component(self): + file = os.path.join(parent_dir, "scripts/full_usage_list.json") + with open(file) as f: + input = f.read() + data = json.loads(input) + + for restriction in data["restrictions"]: # For all restrictions in the list + with self.subTest( + msg="One matcher failed" + ): # For soft fail, ref https://stackoverflow.com/questions/4732827/continuing-in-pythons-unittest-when-an-assertion-fails + found = False + for sensor in SENSOR_DEFINITIONS: + # We may have to create a function rather than copy/paste, but it's a 'simple re.search.... + for matcher in sensor.matchers: + if re.search( + matcher, + restriction["usage"] + "|" + restriction['thematique'], + re.IGNORECASE, + ): + found = True + self.assertTrue( + found, + f"Value **{restriction['usage']}** in category **{restriction['thematique']}** not found in matcher", + ) # Check for one usage if it has been found + + +if __name__ == "__main__": + unittest.main() diff --git a/config/custom_components/vigieau/translations/en.json b/config/custom_components/vigieau/translations/en.json new file mode 100644 index 0000000..1eb0d98 --- /dev/null +++ b/config/custom_components/vigieau/translations/en.json @@ -0,0 +1,33 @@ +{ + "config": { + "step": { + "user": { + "data": { + "location_mode": "" + }, + "description": "Select Localisation mode", + "title": "Localisation mode" + }, + "location": { + "data": { + "code_postal": "Zip Code" + }, + "description": "Set your zip code", + "title": "Location" + }, + "multilocation": { + "data": { + "city": "City" + }, + "title": "Many code found", + "description": "Select the city" + }, + "location_map": { + "data": { + "latitude": "latitude", + "longitude": "longitude" + } + } + } + } +} diff --git a/config/custom_components/vigieau/translations/fr.json b/config/custom_components/vigieau/translations/fr.json new file mode 100644 index 0000000..42c0445 --- /dev/null +++ b/config/custom_components/vigieau/translations/fr.json @@ -0,0 +1,33 @@ +{ + "config": { + "step": { + "user": { + "data": { + "location_mode": "" + }, + "description": "Choisissez un mode de localisation", + "title": "Mode de localisation" + }, + "location": { + "data": { + "code_postal": "Code Postal" + }, + "description": "Saisissez votre code postal", + "title": "Localisation" + }, + "multilocation": { + "data": { + "city": "Ville" + }, + "title": "Plusieurs codes INSEE trouvés pour ce code postal", + "description": "Sélectionnez la commune" + }, + "location_map": { + "data": { + "latitude": "latitude", + "longitude": "longitude" + } + } + } + } +} \ No newline at end of file diff --git a/config/custom_components/vigieau/translations/pt.json b/config/custom_components/vigieau/translations/pt.json new file mode 100644 index 0000000..8b6aee3 --- /dev/null +++ b/config/custom_components/vigieau/translations/pt.json @@ -0,0 +1,33 @@ +{ + "config": { + "step": { + "user": { + "data": { + "location_mode": "" + }, + "description": "Selecionar o modo de localização", + "title": "Modo de localizacao" + }, + "location": { + "data": { + "code_postal": "Codigo postal" + }, + "description": "Inserir o código postal", + "title": "Localização" + }, + "multilocation": { + "data": { + "city": "Cidade" + }, + "title": "Muitos codigos encontrados", + "description": "Selecione a cidade" + }, + "location_map": { + "data": { + "latitude": "latitude", + "longitude": "longitude" + } + } + } + } +} diff --git a/config/custom_components/watchman/__init__.py b/config/custom_components/watchman/__init__.py new file mode 100644 index 0000000..65134dd --- /dev/null +++ b/config/custom_components/watchman/__init__.py @@ -0,0 +1,452 @@ +"""https://github.com/dummylabs/thewatchman§""" +from datetime import timedelta +import logging +import os +import time +import json +import voluptuous as vol +from homeassistant.loader import async_get_integration +from homeassistant.helpers import config_validation as cv +from homeassistant.components import persistent_notification +from homeassistant.util import dt as dt_util +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.config_entries import ConfigEntry, SOURCE_IMPORT +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.const import ( + EVENT_HOMEASSISTANT_STARTED, + EVENT_SERVICE_REGISTERED, + EVENT_SERVICE_REMOVED, + EVENT_STATE_CHANGED, + EVENT_CALL_SERVICE, + STATE_UNKNOWN, +) + +from .coordinator import WatchmanCoordinator + +from .utils import ( + is_service, + report, + parse, + table_renderer, + text_renderer, + get_config, + get_report_path, +) + +from .const import ( + DOMAIN, + DOMAIN_DATA, + DEFAULT_HEADER, + CONF_IGNORED_FILES, + CONF_HEADER, + CONF_REPORT_PATH, + CONF_IGNORED_ITEMS, + CONF_SERVICE_NAME, + CONF_SERVICE_DATA, + CONF_SERVICE_DATA2, + CONF_INCLUDED_FOLDERS, + CONF_CHECK_LOVELACE, + CONF_IGNORED_STATES, + CONF_CHUNK_SIZE, + CONF_CREATE_FILE, + CONF_SEND_NOTIFICATION, + CONF_PARSE_CONFIG, + CONF_COLUMNS_WIDTH, + CONF_STARTUP_DELAY, + CONF_FRIENDLY_NAMES, + CONF_ALLOWED_SERVICE_PARAMS, + CONF_TEST_MODE, + EVENT_AUTOMATION_RELOADED, + EVENT_SCENE_RELOADED, + TRACKED_EVENT_DOMAINS, + MONITORED_STATES, + PLATFORMS, + VERSION, +) + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Optional(CONF_REPORT_PATH): cv.string, + vol.Optional(CONF_IGNORED_FILES): cv.ensure_list, + vol.Optional(CONF_IGNORED_ITEMS): cv.ensure_list, + vol.Optional(CONF_HEADER, default=DEFAULT_HEADER): cv.string, + vol.Optional(CONF_SERVICE_NAME): cv.string, + vol.Optional(CONF_SERVICE_DATA): vol.Schema({}, extra=vol.ALLOW_EXTRA), + vol.Optional(CONF_INCLUDED_FOLDERS): cv.ensure_list, + vol.Optional(CONF_CHECK_LOVELACE, default=False): cv.boolean, + vol.Optional(CONF_CHUNK_SIZE, default=3500): cv.positive_int, + vol.Optional(CONF_IGNORED_STATES): [ + "missing", + "unavailable", + "unknown", + ], + vol.Optional(CONF_COLUMNS_WIDTH): cv.ensure_list, + vol.Optional(CONF_STARTUP_DELAY, default=0): cv.positive_int, + vol.Optional(CONF_FRIENDLY_NAMES, default=False): cv.boolean, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass: HomeAssistantType, config: dict): + """Set up is called when Home Assistant is loading our component.""" + if config.get(DOMAIN) is None: + # We get here if the integration is set up using config flow + return True + + hass.data.setdefault(DOMAIN_DATA, config[DOMAIN]) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=hass.data[DOMAIN_DATA] + ) + ) + # Return boolean to indicate that initialization was successful. + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Set up this integration using UI""" + _LOGGER.debug(entry.options) + _LOGGER.debug("Home assistant path: %s", hass.config.path("")) + + coordinator = WatchmanCoordinator(hass, _LOGGER, name=entry.title) + coordinator.async_set_updated_data(None) + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + hass.data[DOMAIN]["coordinator"] = coordinator + hass.data[DOMAIN_DATA] = entry.options # TODO: refactor + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(update_listener)) + await add_services(hass) + await add_event_handlers(hass) + if hass.is_running: + # integration reloaded or options changed via UI + parse_config(hass, reason="changes in watchman configuration") + await coordinator.async_config_entry_first_refresh() + else: + # first run, home assistant is loading + # parse_config will be scheduled once HA is fully loaded + _LOGGER.info("Watchman started [%s]", VERSION) + + +# resources = hass.data["lovelace"]["resources"] +# await resources.async_get_info() +# for itm in resources.async_items(): +# _LOGGER.debug(itm) + + return True + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry): + """Reload integration when options changed""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry( + hass: HomeAssistant, config_entry +): # pylint: disable=unused-argument + """Handle integration unload""" + for cancel_handle in hass.data[DOMAIN].get("cancel_handlers", []): + if cancel_handle: + cancel_handle() + + if hass.services.has_service(DOMAIN, "report"): + hass.services.async_remove(DOMAIN, "report") + + unload_ok = await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ) + + if DOMAIN_DATA in hass.data: + hass.data.pop(DOMAIN_DATA) + if DOMAIN in hass.data: + hass.data.pop(DOMAIN) + + if unload_ok: + _LOGGER.info("Watchman integration successfully unloaded.") + else: + _LOGGER.error("Having trouble unloading watchman integration") + + return unload_ok + + +async def add_services(hass: HomeAssistant): + """adds report service""" + + async def async_handle_report(call): + """Handle the service call""" + config = hass.data.get(DOMAIN_DATA, {}) + path = get_report_path(hass, config.get(CONF_REPORT_PATH, None)) + send_notification = call.data.get(CONF_SEND_NOTIFICATION, False) + create_file = call.data.get(CONF_CREATE_FILE, True) + test_mode = call.data.get(CONF_TEST_MODE, False) + # validate service params + for param in call.data: + if param not in CONF_ALLOWED_SERVICE_PARAMS: + await async_notification( + hass, + "Watchman error", + f"Unknown service " f"parameter: `{param}`.", + error=True, + ) + + if not (send_notification or create_file): + message = ( + "Either `send_nofification` or `create_file` should be set to `true` " + "in service parameters." + ) + await async_notification(hass, "Watchman error", message, error=True) + + if call.data.get(CONF_PARSE_CONFIG, False): + parse_config(hass, reason="service call") + + if send_notification: + chunk_size = call.data.get(CONF_CHUNK_SIZE, config.get(CONF_CHUNK_SIZE)) + service = call.data.get(CONF_SERVICE_NAME, None) + service_data = call.data.get(CONF_SERVICE_DATA, None) + + if service_data and not service: + await async_notification( + hass, + "Watchman error", + "Missing `service` parameter. The `data` parameter can only be used " + "in conjunction with `service` parameter.", + error=True, + ) + + if onboarding(hass, service, path): + await async_notification( + hass, + "🖖 Achievement unlocked: first report!", + f"Your first watchman report was stored in `{path}` \n\n " + "TIP: set `service` parameter in configuration.yaml file to " + "receive report via notification service of choice. \n\n " + "This is one-time message, it will not bother you in the future.", + ) + else: + await async_report_to_notification( + hass, service, service_data, chunk_size + ) + + if create_file: + try: + await async_report_to_file(hass, path, test_mode=test_mode) + except OSError as exception: + await async_notification( + hass, + "Watchman error", + f"Unable to write report: {exception}", + error=True, + ) + + hass.services.async_register(DOMAIN, "report", async_handle_report) + + +async def add_event_handlers(hass: HomeAssistant): + """add event handlers""" + + async def async_schedule_refresh_states(hass, delay): + """schedule refresh of the sensors state""" + now = dt_util.utcnow() + next_interval = now + timedelta(seconds=delay) + async_track_point_in_utc_time(hass, async_delayed_refresh_states, next_interval) + + async def async_delayed_refresh_states(timedate): # pylint: disable=unused-argument + """refresh sensors state""" + # parse_config should be invoked beforehand + coordinator = hass.data[DOMAIN]["coordinator"] + await coordinator.async_refresh() + + async def async_on_home_assistant_started(event): # pylint: disable=unused-argument + parse_config(hass, reason="HA restart") + startup_delay = get_config(hass, CONF_STARTUP_DELAY, 0) + await async_schedule_refresh_states(hass, startup_delay) + + async def async_on_configuration_changed(event): + typ = event.event_type + if typ == EVENT_CALL_SERVICE: + domain = event.data.get("domain", None) + service = event.data.get("service", None) + if domain in TRACKED_EVENT_DOMAINS and service in [ + "reload_core_config", + "reload", + ]: + parse_config(hass, reason="configuration changes") + coordinator = hass.data[DOMAIN]["coordinator"] + await coordinator.async_refresh() + + elif typ in [EVENT_AUTOMATION_RELOADED, EVENT_SCENE_RELOADED]: + parse_config(hass, reason="configuration changes") + coordinator = hass.data[DOMAIN]["coordinator"] + await coordinator.async_refresh() + + async def async_on_service_changed(event): + service = f"{event.data['domain']}.{event.data['service']}" + if service in hass.data[DOMAIN].get("service_list", []): + _LOGGER.debug("Monitored service changed: %s", service) + coordinator = hass.data[DOMAIN]["coordinator"] + await coordinator.async_refresh() + + async def async_on_state_changed(event): + """refresh monitored entities on state change""" + + def state_or_missing(state_id): + """return missing state if entity not found""" + return "missing" if not event.data[state_id] else event.data[state_id].state + + if event.data["entity_id"] in hass.data[DOMAIN].get("entity_list", []): + ignored_states = get_config(hass, CONF_IGNORED_STATES, []) + old_state = state_or_missing("old_state") + new_state = state_or_missing("new_state") + checked_states = set(MONITORED_STATES) - set(ignored_states) + if new_state in checked_states or old_state in checked_states: + _LOGGER.debug("Monitored entity changed: %s", event.data["entity_id"]) + coordinator = hass.data[DOMAIN]["coordinator"] + await coordinator.async_refresh() + + # hass is not started yet, schedule config parsing once it loaded + if not hass.is_running: + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STARTED, async_on_home_assistant_started + ) + + hdlr = [] + hdlr.append( + hass.bus.async_listen(EVENT_CALL_SERVICE, async_on_configuration_changed) + ) + hdlr.append( + hass.bus.async_listen(EVENT_AUTOMATION_RELOADED, async_on_configuration_changed) + ) + hdlr.append( + hass.bus.async_listen(EVENT_SCENE_RELOADED, async_on_configuration_changed) + ) + hdlr.append( + hass.bus.async_listen(EVENT_SERVICE_REGISTERED, async_on_service_changed) + ) + hdlr.append(hass.bus.async_listen(EVENT_SERVICE_REMOVED, async_on_service_changed)) + hdlr.append(hass.bus.async_listen(EVENT_STATE_CHANGED, async_on_state_changed)) + hass.data[DOMAIN]["cancel_handlers"] = hdlr + + +def parse_config(hass: HomeAssistant, reason=None): + """parse home assistant configuration files""" + assert hass.data.get(DOMAIN_DATA) + start_time = time.time() + included_folders = get_included_folders(hass) + ignored_files = hass.data[DOMAIN_DATA].get(CONF_IGNORED_FILES, None) + + entity_list, service_list, files_parsed, files_ignored = parse( + hass, included_folders, ignored_files, hass.config.config_dir + ) + hass.data[DOMAIN]["entity_list"] = entity_list + hass.data[DOMAIN]["service_list"] = service_list + hass.data[DOMAIN]["files_parsed"] = files_parsed + hass.data[DOMAIN]["files_ignored"] = files_ignored + hass.data[DOMAIN]["parse_duration"] = time.time() - start_time + _LOGGER.info( + "%s files parsed and %s files ignored in %.2fs. due to %s", + files_parsed, + files_ignored, + hass.data[DOMAIN]["parse_duration"], + reason, + ) + + +def get_included_folders(hass): + """gather the list of folders to parse""" + folders = [] + config_folders = [hass.config.config_dir] + + if DOMAIN_DATA in hass.data: + config_folders = hass.data[DOMAIN_DATA].get("included_folders") + if not config_folders: + config_folders = [hass.config.config_dir] + + for fld in config_folders: + folders.append(os.path.join(fld, "**/*.yaml")) + + if DOMAIN_DATA in hass.data and hass.data[DOMAIN_DATA].get(CONF_CHECK_LOVELACE): + folders.append(os.path.join(hass.config.config_dir, ".storage/**/lovelace*")) + + return folders + + +async def async_report_to_file(hass, path, test_mode): + """save report to a file""" + coordinator = hass.data[DOMAIN]["coordinator"] + await coordinator.async_refresh() + report_chunks = report(hass, table_renderer, chunk_size=0, test_mode=test_mode) + # OSError exception is handled in async_handle_report + with open(path, "w", encoding="utf-8") as report_file: + for chunk in report_chunks: + report_file.write(chunk) + + +async def async_report_to_notification(hass, service_str, service_data, chunk_size): + """send report via notification service""" + if not service_str: + service_str = get_config(hass, CONF_SERVICE_NAME, None) + service_data = get_config(hass, CONF_SERVICE_DATA2, None) + + if not service_str: + await async_notification( + hass, + "Watchman Error", + "You should specify `service` parameter (in integration options or as `service` " + "parameter) in order to send report via notification", + ) + return + + if not is_service(hass, service_str): + await async_notification( + hass, + "Watchman Error", + f"{service_str} is not a valid service for notification", + ) + domain = service_str.split(".")[0] + service = ".".join(service_str.split(".")[1:]) + + data = {} if service_data is None else json.loads(service_data) + + coordinator = hass.data[DOMAIN]["coordinator"] + await coordinator.async_refresh() + report_chunks = report(hass, text_renderer, chunk_size) + for chunk in report_chunks: + data["message"] = chunk + # blocking=True ensures execution order + if not await hass.services.async_call(domain, service, data, blocking=True): + _LOGGER.error( + "Unable to call service %s.%s due to an error.", domain, service + ) + break + + +async def async_notification(hass, title, message, error=False, n_id="watchman"): + """Show a persistent notification""" + persistent_notification.async_create( + hass, + message, + title=title, + notification_id=n_id, + ) + if error: + raise HomeAssistantError(message.replace("`", "")) + + +def onboarding(hass, service, path): + """check if the user runs report for the first time""" + service = service or get_config(hass, CONF_SERVICE_NAME, None) + return not (service or os.path.exists(path)) diff --git a/config/custom_components/watchman/config_flow.py b/config/custom_components/watchman/config_flow.py new file mode 100644 index 0000000..dc3f681 --- /dev/null +++ b/config/custom_components/watchman/config_flow.py @@ -0,0 +1,291 @@ +"ConfigFlow definition for watchman" +from typing import Dict +import json +from json.decoder import JSONDecodeError +import logging +from homeassistant.config_entries import ConfigFlow, OptionsFlow, ConfigEntry +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv, selector +import voluptuous as vol +from .utils import is_service, get_columns_width, get_report_path + +from .const import ( + DOMAIN, + CONF_IGNORED_FILES, + CONF_HEADER, + CONF_REPORT_PATH, + CONF_IGNORED_ITEMS, + CONF_SERVICE_NAME, + CONF_SERVICE_DATA, + CONF_SERVICE_DATA2, + CONF_INCLUDED_FOLDERS, + CONF_CHECK_LOVELACE, + CONF_IGNORED_STATES, + CONF_CHUNK_SIZE, + CONF_COLUMNS_WIDTH, + CONF_STARTUP_DELAY, + CONF_FRIENDLY_NAMES, +) + +DEFAULT_DATA = { + CONF_SERVICE_NAME: "", + CONF_SERVICE_DATA2: "{}", + CONF_INCLUDED_FOLDERS: ["/config"], + CONF_HEADER: "-== Watchman Report ==-", + CONF_REPORT_PATH: "", + CONF_IGNORED_ITEMS: [], + CONF_IGNORED_STATES: [], + CONF_CHUNK_SIZE: 3500, + CONF_IGNORED_FILES: [], + CONF_CHECK_LOVELACE: False, + CONF_COLUMNS_WIDTH: [30, 7, 60], + CONF_STARTUP_DELAY: 0, + CONF_FRIENDLY_NAMES: False, +} + +INCLUDED_FOLDERS_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.string])) +IGNORED_ITEMS_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.string])) +IGNORED_STATES_SCHEMA = vol.Schema(["missing", "unavailable", "unknown"]) +IGNORED_FILES_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.string])) +COLUMNS_WIDTH_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.positive_int])) + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): + """Config flow""" + + async def async_step_user(self, user_input=None): + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + return self.async_create_entry(title="Watchman", data={}, options=DEFAULT_DATA) + + async def async_step_import(self, import_data): + """Import configuration.yaml settings as OptionsEntry""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + # change "data" key from configuration.yaml to "service_data" as "data" is reserved by + # OptionsFlow + import_data[CONF_SERVICE_DATA2] = import_data.get(CONF_SERVICE_DATA, {}) + if CONF_SERVICE_DATA in import_data: + import_data.pop(CONF_SERVICE_DATA) + _LOGGER.info( + "watchman settings imported successfully and can be removed from " + "configuration.yaml" + ) + _LOGGER.debug("configuration.yaml settings successfully imported to UI options") + return self.async_create_entry( + title="configuration.yaml", data={}, options=import_data + ) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(OptionsFlow): + """Handles options flow for the component.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + self.config_entry = config_entry + + def default(self, key, uinput=None): + """provide default value for an OptionsFlow field""" + if uinput and key in uinput: + # supply last entered value to display an error during form validation + result = uinput[key] + else: + # supply last saved value or default one + result = self.config_entry.options.get(key, DEFAULT_DATA[key]) + + if result == "": + # some default values cannot be empty + if DEFAULT_DATA[key]: + result = DEFAULT_DATA[key] + elif key == CONF_REPORT_PATH: + result = get_report_path(self.hass, None) + + if isinstance(result, list): + return ", ".join([str(i) for i in result]) + if isinstance(result, dict): + return json.dumps(result) + if isinstance(result, bool): + return result + return str(result) + + def to_list(self, user_input, key): + """validate user input against list requirements""" + errors: Dict[str, str] = {} + + if key not in user_input: + return DEFAULT_DATA[key], errors + + val = user_input[key] + val = [x.strip() for x in val.split(",") if x.strip()] + try: + val = INCLUDED_FOLDERS_SCHEMA(val) + except vol.Invalid: + errors[key] = f"invalid_{key}" + return val, errors + + async def _show_options_form( + self, uinput=None, errors=None, placehoders=None + ): # pylint: disable=unused-argument + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_SERVICE_NAME, + description={ + "suggested_value": self.default(CONF_SERVICE_NAME, uinput) + }, + ): cv.string, + vol.Optional( + CONF_SERVICE_DATA2, + description={ + "suggested_value": self.default(CONF_SERVICE_DATA2, uinput) + }, + ): selector.TemplateSelector(), + vol.Optional( + CONF_INCLUDED_FOLDERS, + description={ + "suggested_value": self.default( + CONF_INCLUDED_FOLDERS, uinput + ) + }, + ): selector.TextSelector( + selector.TextSelectorConfig(multiline=True) + ), + vol.Optional( + CONF_HEADER, + description={ + "suggested_value": self.default(CONF_HEADER, uinput) + }, + ): cv.string, + vol.Optional( + CONF_REPORT_PATH, + description={ + "suggested_value": self.default(CONF_REPORT_PATH, uinput) + }, + ): cv.string, + vol.Optional( + CONF_IGNORED_ITEMS, + description={ + "suggested_value": self.default(CONF_IGNORED_ITEMS, uinput) + }, + ): selector.TextSelector( + selector.TextSelectorConfig(multiline=True) + ), + vol.Optional( + CONF_IGNORED_STATES, + description={ + "suggested_value": self.default(CONF_IGNORED_STATES, uinput) + }, + ): selector.TextSelector( + selector.TextSelectorConfig(multiline=True) + ), + vol.Optional( + CONF_CHUNK_SIZE, + description={ + "suggested_value": self.default(CONF_CHUNK_SIZE, uinput) + }, + ): cv.positive_int, + vol.Optional( + CONF_IGNORED_FILES, + description={ + "suggested_value": self.default(CONF_IGNORED_FILES, uinput) + }, + ): selector.TextSelector( + selector.TextSelectorConfig(multiline=True) + ), + vol.Optional( + CONF_COLUMNS_WIDTH, + description={ + "suggested_value": self.default(CONF_COLUMNS_WIDTH, uinput) + }, + ): cv.string, + vol.Optional( + CONF_STARTUP_DELAY, + description={ + "suggested_value": self.default(CONF_STARTUP_DELAY, uinput) + }, + ): cv.positive_int, + vol.Optional( + CONF_FRIENDLY_NAMES, + description={ + "suggested_value": self.default(CONF_FRIENDLY_NAMES, uinput) + }, + ): cv.boolean, + vol.Optional( + CONF_CHECK_LOVELACE, + description={ + "suggested_value": self.default(CONF_CHECK_LOVELACE, uinput) + }, + ): cv.boolean, + } + ), + errors=errors or {}, + description_placeholders=placehoders or {}, + ) + + async def async_step_init(self, user_input=None): + """Manage the options""" + errors: Dict[str, str] = {} + placehoders: Dict[str, str] = {} + + if user_input is not None: + user_input[CONF_INCLUDED_FOLDERS], err = self.to_list( + user_input, CONF_INCLUDED_FOLDERS + ) + errors |= err + user_input[CONF_IGNORED_ITEMS], err = self.to_list( + user_input, CONF_IGNORED_ITEMS + ) + errors |= err + ignored_states, err = self.to_list(user_input, CONF_IGNORED_STATES) + errors |= err + try: + user_input[CONF_IGNORED_STATES] = IGNORED_STATES_SCHEMA(ignored_states) + except vol.Invalid: + errors[CONF_IGNORED_STATES] = "wrong_value_ignored_states" + + user_input[CONF_IGNORED_FILES], err = self.to_list( + user_input, CONF_IGNORED_FILES + ) + errors |= err + + if CONF_COLUMNS_WIDTH in user_input: + columns_width = user_input[CONF_COLUMNS_WIDTH] + try: + columns_width = [ + int(x) for x in columns_width.split(",") if x.strip() + ] + if len(columns_width) != 3: + raise ValueError() + columns_width = COLUMNS_WIDTH_SCHEMA(columns_width) + user_input[CONF_COLUMNS_WIDTH] = get_columns_width(columns_width) + except (ValueError, vol.Invalid): + errors[CONF_COLUMNS_WIDTH] = "invalid_columns_width" + + if CONF_SERVICE_DATA2 in user_input: + try: + result = json.loads(user_input[CONF_SERVICE_DATA2]) + if not isinstance(result, dict): + errors[CONF_SERVICE_DATA2] = "malformed_json" + except JSONDecodeError: + errors[CONF_SERVICE_DATA2] = "malformed_json" + if CONF_SERVICE_NAME in user_input: + if not is_service(self.hass, user_input[CONF_SERVICE_NAME]): + errors[CONF_SERVICE_NAME] = "unknown_service" + placehoders["service"] = user_input[CONF_SERVICE_NAME] + + if not errors: + return self.async_create_entry(title="", data=user_input) + else: + # provide last entered values to display error + return await self._show_options_form(user_input, errors, placehoders) + # provide default values + return await self._show_options_form() diff --git a/config/custom_components/watchman/const.py b/config/custom_components/watchman/const.py new file mode 100644 index 0000000..304feb7 --- /dev/null +++ b/config/custom_components/watchman/const.py @@ -0,0 +1,72 @@ +"definition of constants" +from homeassistant.const import Platform + +DOMAIN = "watchman" +DOMAIN_DATA = "watchman_data" +VERSION = "0.6.1" + +DEFAULT_REPORT_FILENAME = "watchman_report.txt" +DEFAULT_HEADER = "-== WATCHMAN REPORT ==- " +DEFAULT_CHUNK_SIZE = 3500 + +CONF_IGNORED_FILES = "ignored_files" +CONF_HEADER = "report_header" +CONF_REPORT_PATH = "report_path" +CONF_IGNORED_ITEMS = "ignored_items" +CONF_SERVICE_NAME = "service" +CONF_SERVICE_DATA = "data" +CONF_SERVICE_DATA2 = "service_data" +CONF_INCLUDED_FOLDERS = "included_folders" +CONF_CHECK_LOVELACE = "check_lovelace" +CONF_IGNORED_STATES = "ignored_states" +CONF_CHUNK_SIZE = "chunk_size" +CONF_CREATE_FILE = "create_file" +CONF_SEND_NOTIFICATION = "send_notification" +CONF_PARSE_CONFIG = "parse_config" +CONF_COLUMNS_WIDTH = "columns_width" +CONF_STARTUP_DELAY = "startup_delay" +CONF_FRIENDLY_NAMES = "friendly_names" +CONF_TEST_MODE = "test_mode" +# configuration parameters allowed in watchman.report service data +CONF_ALLOWED_SERVICE_PARAMS = [ + CONF_SERVICE_NAME, + CONF_CHUNK_SIZE, + CONF_CREATE_FILE, + CONF_SEND_NOTIFICATION, + CONF_PARSE_CONFIG, + CONF_SERVICE_DATA, + CONF_TEST_MODE, +] + +EVENT_AUTOMATION_RELOADED = "automation_reloaded" +EVENT_SCENE_RELOADED = "scene_reloaded" + +SENSOR_LAST_UPDATE = "watchman_last_updated" +SENSOR_MISSING_ENTITIES = "watchman_missing_entities" +SENSOR_MISSING_SERVICES = "watchman_missing_services" +MONITORED_STATES = ["unavailable", "unknown", "missing"] + +TRACKED_EVENT_DOMAINS = [ + "homeassistant", + "input_boolean", + "input_button", + "input_select", + "input_number", + "input_datetime", + "person", + "input_text", + "script", + "timer", + "zone", +] + +BUNDLED_IGNORED_ITEMS = [ + "timer.cancelled", + "timer.finished", + "timer.started", + "timer.restarted", + "timer.paused", +] + +# Platforms +PLATFORMS = [Platform.SENSOR] diff --git a/config/custom_components/watchman/coordinator.py b/config/custom_components/watchman/coordinator.py new file mode 100644 index 0000000..6f0f74f --- /dev/null +++ b/config/custom_components/watchman/coordinator.py @@ -0,0 +1,70 @@ +"""Data update coordinator for Watchman""" + +import logging +import time +from homeassistant.util import dt as dt_util +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from .const import DOMAIN +from .utils import check_entitites, check_services, get_entity_state, fill + + +_LOGGER = logging.getLogger(__name__) + + +class WatchmanCoordinator(DataUpdateCoordinator): + """My custom coordinator.""" + + def __init__(self, hass, logger, name): + """Initialize watchmman coordinator.""" + super().__init__( + hass, + _LOGGER, + name=name, # Name of the data. For logging purposes. + ) + self.hass = hass + self.data = {} + + async def _async_update_data(self) -> None: + """Fetch data from API endpoint.""" + start_time = time.time() + services_missing = check_services(self.hass) + entities_missing = check_entitites(self.hass) + self.hass.data[DOMAIN]["check_duration"] = time.time() - start_time + self.hass.data[DOMAIN]["entities_missing"] = entities_missing + self.hass.data[DOMAIN]["services_missing"] = services_missing + + # build entity attributes map for missing_entities sensor + entity_attrs = [] + entity_list = self.hass.data[DOMAIN]["entity_list"] + for entity in entities_missing: + state, name = get_entity_state(self.hass, entity, friendly_names=True) + entity_attrs.append( + { + "id": entity, + "state": state, + "friendly_name": name or "", + "occurrences": fill(entity_list[entity], 0), + } + ) + + # build service attributes map for missing_services sensor + service_attrs = [] + service_list = self.hass.data[DOMAIN]["service_list"] + for service in services_missing: + service_attrs.append( + {"id": service, "occurrences": fill(service_list[service], 0)} + ) + + self.data = { + "entities_missing": len(entities_missing), + "services_missing": len(services_missing), + "last_update": dt_util.now(), + "service_attrs": service_attrs, + "entity_attrs": entity_attrs, + } + + _LOGGER.debug("Watchman sensors updated") + _LOGGER.debug("entities missing: %s", len(entities_missing)) + _LOGGER.debug("services missing: %s", len(services_missing)) + + return self.data diff --git a/config/custom_components/watchman/entity.py b/config/custom_components/watchman/entity.py new file mode 100644 index 0000000..304c269 --- /dev/null +++ b/config/custom_components/watchman/entity.py @@ -0,0 +1,36 @@ +"""Represents Watchman service in the device registry of Home Assistant""" + +from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) +from .const import DOMAIN, VERSION + + +class WatchmanEntity(CoordinatorEntity): + """Representation of a Watchman entity.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + entity_description: EntityDescription, + ) -> None: + """Initialize Watchman entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + # per sensor unique_id + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}_{entity_description.key}" + ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, "watchman_unique_id")}, + manufacturer="dummylabs", + model="Watchman", + name="Watchman", + sw_version=VERSION, + entry_type=DeviceEntryType.SERVICE, + configuration_url="https://github.com/dummylabs/thewatchman", + ) + self._attr_extra_state_attributes = {} diff --git a/config/custom_components/watchman/manifest.json b/config/custom_components/watchman/manifest.json new file mode 100644 index 0000000..d910df8 --- /dev/null +++ b/config/custom_components/watchman/manifest.json @@ -0,0 +1,15 @@ +{ + "domain": "watchman", + "name": "Watchman", + "documentation": "https://github.com/dummylabs/thewatchman", + "issue_tracker": "https://github.com/dummylabs/thewatchman/issues", + "iot_class": "local_push", + "version": "0.5.1", + "requirements": [ + "prettytable==3.0.0" + ], + "codeowners": [ + "@dummylabs" + ], + "config_flow": true +} \ No newline at end of file diff --git a/config/custom_components/watchman/sensor.py b/config/custom_components/watchman/sensor.py new file mode 100644 index 0000000..db3550c --- /dev/null +++ b/config/custom_components/watchman/sensor.py @@ -0,0 +1,161 @@ +"""Watchman sensors definition""" +import logging +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.core import callback +from .entity import WatchmanEntity + +from .const import ( + DOMAIN, + SENSOR_LAST_UPDATE, + SENSOR_MISSING_ENTITIES, + SENSOR_MISSING_SERVICES, +) + + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, entry, async_add_devices): + """Setup sensor platform.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_devices( + [ + LastUpdateSensor( + coordinator=coordinator, + entity_description=SensorEntityDescription( + key=SENSOR_LAST_UPDATE, + name=SENSOR_LAST_UPDATE, + device_class=SensorDeviceClass.TIMESTAMP, + ), + ), + MissingEntitiesSensor( + coordinator=coordinator, + entity_description=SensorEntityDescription( + key=SENSOR_MISSING_ENTITIES, + name=SENSOR_MISSING_ENTITIES, + state_class=SensorStateClass.MEASUREMENT, + ), + ), + MissingServicesSensor( + coordinator=coordinator, + entity_description=SensorEntityDescription( + key=SENSOR_MISSING_SERVICES, + name=SENSOR_MISSING_SERVICES, + state_class=SensorStateClass.MEASUREMENT, + ), + ), + ] + ) + + +class LastUpdateSensor(WatchmanEntity, SensorEntity): + """Timestamp sensor for last watchman update time""" + + _attr_should_poll = False + _attr_icon = "mdi:shield-half-full" + + @property + def should_poll(self) -> bool: + """No polling needed.""" + return False + + @property + def native_value(self): + """Return the native value of the sensor.""" + if self.coordinator.data: + return self.coordinator.data["last_update"] + else: + return self._attr_native_value + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if self.coordinator.data: + self._attr_native_value = self.coordinator.data["last_update"] + self.async_write_ha_state() + super()._handle_coordinator_update() + + +class MissingEntitiesSensor(WatchmanEntity, SensorEntity): + """Number of missing entities from watchman report""" + + _attr_should_poll = False + _attr_icon = "mdi:shield-half-full" + _attr_native_unit_of_measurement = "items" + + @property + def should_poll(self) -> bool: + """No polling needed.""" + return False + + @property + def native_value(self): + """Return the native value of the sensor.""" + if self.coordinator.data: + return self.coordinator.data["entities_missing"] + else: + return self._attr_native_value + + @property + def extra_state_attributes(self): + """Return the state attributes.""" + if self.coordinator.data: + return {"entities": self.coordinator.data["entity_attrs"]} + else: + return {} + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if self.coordinator.data: + self._attr_native_value = self.coordinator.data["entities_missing"] + self._attr_extra_state_attributes = { + "entities": self.coordinator.data["entity_attrs"] + } + self.async_write_ha_state() + super()._handle_coordinator_update() + + +class MissingServicesSensor(WatchmanEntity, SensorEntity): + """Number of missing services from watchman report""" + + _attr_should_poll = False + _attr_icon = "mdi:shield-half-full" + _attr_native_unit_of_measurement = "items" + + @property + def should_poll(self) -> bool: + """No polling needed.""" + return False + + @property + def native_value(self): + """Return the native value of the sensor.""" + if self.coordinator.data: + return self.coordinator.data["services_missing"] + else: + return self._attr_native_value + + @property + def extra_state_attributes(self): + """Return the state attributes.""" + if self.coordinator.data: + return {"entities": self.coordinator.data["service_attrs"]} + else: + return {} + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if self.coordinator.data: + self._attr_native_value = self.coordinator.data["services_missing"] + self._attr_extra_state_attributes = { + "services": self.coordinator.data["service_attrs"] + } + self.async_write_ha_state() + super()._handle_coordinator_update() diff --git a/config/custom_components/watchman/services.yaml b/config/custom_components/watchman/services.yaml new file mode 100644 index 0000000..e84f3a6 --- /dev/null +++ b/config/custom_components/watchman/services.yaml @@ -0,0 +1,50 @@ +report: + description: Run watchman report + fields: + create_file: + description: Whether report file should be created (optional, true by default) + example: true + name: Create file report + default: true + required: false + selector: + boolean: + send_notification: + description: Whether report should be sent via notification service (optional, false by default) + example: true + name: Send notification + default: false + required: false + selector: + boolean: + service: + description: Notification service to send report via (optional). Overrides "service" setting from watchman configuration + example: "notify.telegram" + name: Notification service + required: false + selector: + text: + data: + description: Additional data in form of key:value pairs for notification service (optional) + example: "parse_mode: html" + name: Notification service data parameters + + parse_config: + description: Parse configuration files before report is created. Usually this is done by watchman automatically, so this flag is not required. (optional, false by default) + example: true + name: Parse configuration + default: false + required: false + selector: + boolean: + chunk_size: + description: Maximum message size in bytes. If report size exceeds chunk_size, the report will be sent in several subsequent notifications. (optional, default is 3500 or whatever specified in integration settings) + example: true + name: Chunk size + default: false + required: false + selector: + number: + min: 0 + max: 100000 + mode: box diff --git a/config/custom_components/watchman/translations/en.json b/config/custom_components/watchman/translations/en.json new file mode 100644 index 0000000..9002ed7 --- /dev/null +++ b/config/custom_components/watchman/translations/en.json @@ -0,0 +1,45 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Only one instance of watchman is allowed" + }, + "step": {} + }, + "options": { + "error": { + "invalid_included_folders": "included_folders should be a comma separated list of configuration folders", + "invalid_columns_width": "columns_width should be a list of 3 positive integers", + "wrong_value_ignored_states": "Accepted values are: 'unavailable', 'missing' and 'unknown'", + "malformed_json": "service data should be a valid json dictionary", + "unknown_service": "unknown service: `{service}`" + }, + "step": { + "init": { + "title": "Watchman settings", + "data": { + "service": "Notification service (e.g. notify.telegram)", + "service_data": "Notification service data", + "included_folders": "Included folders", + "report_header": "Custom header for the report", + "report_path": "Report location e.g. /config/report.txt", + "ignored_items": "Ignored entities and services", + "ignored_states": "Ignored entity states", + "chunk_size": "Message chunk size in bytes (used with notification service)", + "ignored_files": "Ignored files (comma-separated)", + "check_lovelace": "Parse dashboards UI (ex-Lovelace) configuration", + "columns_width": "List of report columns width, e.g. 30, 7, 60", + "startup_delay": "Startup delay for watchman sensors initialization", + "friendly_names": "Add friendly names to the report" + }, + "data_description": { + "service_data": "JSON object with notification service data, see documentation for details", + "included_folders": "Comma-separated list of folders where watchman should look for config files", + "ignored_items": "Comma-separated list of entities and services excluded from tracking", + "ignored_states": "Comma-separated list of the states excluded from tracking", + "ignored_files": "Comma-separated list of config files excluded from tracking" + }, + "description": "[Help on settings](https://github.com/dummylabs/thewatchman#configuration)" + } + } + } +} \ No newline at end of file diff --git a/config/custom_components/watchman/utils.py b/config/custom_components/watchman/utils.py new file mode 100644 index 0000000..f4141cd --- /dev/null +++ b/config/custom_components/watchman/utils.py @@ -0,0 +1,385 @@ +"""Miscellaneous support functions for watchman""" +import glob +import re +import fnmatch +import time +import logging +from datetime import datetime +from textwrap import wrap +import os +from typing import Any +import pytz +from prettytable import PrettyTable +from homeassistant.exceptions import HomeAssistantError +from homeassistant.core import HomeAssistant + +from .const import ( + DOMAIN, + DOMAIN_DATA, + DEFAULT_HEADER, + DEFAULT_CHUNK_SIZE, + CONF_HEADER, + CONF_IGNORED_ITEMS, + CONF_IGNORED_STATES, + CONF_CHUNK_SIZE, + CONF_COLUMNS_WIDTH, + CONF_FRIENDLY_NAMES, + BUNDLED_IGNORED_ITEMS, + DEFAULT_REPORT_FILENAME, +) + +_LOGGER = logging.getLogger(__name__) + + +async def read_file(hass: HomeAssistant, path: str) -> Any: + """Read a file.""" + + def read(): + with open(hass.config.path(path), "r", encoding="utf-8") as open_file: + return open_file.read() + + return await hass.async_add_executor_job(read) + + +async def write_file(hass: HomeAssistant, path: str, content: Any) -> None: + """Write a file.""" + + def write(): + with open(hass.config.path(path), "w", encoding="utf-8") as open_file: + open_file.write(content) + + await hass.async_add_executor_job(write) + + +def get_config(hass: HomeAssistant, key, default): + """get configuration value""" + if DOMAIN_DATA not in hass.data: + return default + return hass.data[DOMAIN_DATA].get(key, default) + + +def get_report_path(hass, path): + """if path not specified, create report in config directory with default filename""" + if not path: + path = hass.config.path(DEFAULT_REPORT_FILENAME) + folder, _ = os.path.split(path) + if not os.path.exists(folder): + raise HomeAssistantError(f"Incorrect report_path: {path}.") + return path + + +def get_columns_width(user_width): + """define width of the report columns""" + default_width = [30, 7, 60] + if not user_width: + return default_width + try: + return [7 if user_width[i] < 7 else user_width[i] for i in range(3)] + except (TypeError, IndexError): + _LOGGER.error( + "Invalid configuration for table column widths, default values" " used %s", + default_width, + ) + return default_width + + +def table_renderer(hass, entry_type): + """Render ASCII tables in the report""" + table = PrettyTable() + columns_width = get_config(hass, CONF_COLUMNS_WIDTH, None) + columns_width = get_columns_width(columns_width) + if entry_type == "service_list": + services_missing = hass.data[DOMAIN]["services_missing"] + service_list = hass.data[DOMAIN]["service_list"] + table.field_names = ["Service ID", "State", "Location"] + for service in services_missing: + row = [ + fill(service, columns_width[0]), + fill("missing", columns_width[1]), + fill(service_list[service], columns_width[2]), + ] + table.add_row(row) + table.align = "l" + return table.get_string() + elif entry_type == "entity_list": + entities_missing = hass.data[DOMAIN]["entities_missing"] + entity_list = hass.data[DOMAIN]["entity_list"] + friendly_names = get_config(hass, CONF_FRIENDLY_NAMES, False) + header = ["Entity ID", "State", "Location"] + table.field_names = header + for entity in entities_missing: + state, name = get_entity_state(hass, entity, friendly_names) + table.add_row( + [ + fill(entity, columns_width[0], name), + fill(state, columns_width[1]), + fill(entity_list[entity], columns_width[2]), + ] + ) + + table.align = "l" + return table.get_string() + + else: + return f"Table render error: unknown entry type: {entry_type}" + + +def text_renderer(hass, entry_type): + """Render plain lists in the report""" + result = "" + if entry_type == "service_list": + services_missing = hass.data[DOMAIN]["services_missing"] + service_list = hass.data[DOMAIN]["service_list"] + for service in services_missing: + result += f"{service} in {fill(service_list[service], 0)}\n" + return result + elif entry_type == "entity_list": + entities_missing = hass.data[DOMAIN]["entities_missing"] + entity_list = hass.data[DOMAIN]["entity_list"] + friendly_names = get_config(hass, CONF_FRIENDLY_NAMES, False) + for entity in entities_missing: + state, name = get_entity_state(hass, entity, friendly_names) + entity_col = entity if not name else f"{entity} ('{name}')" + result += f"{entity_col} [{state}] in: {fill(entity_list[entity], 0)}\n" + + return result + else: + return f"Text render error: unknown entry type: {entry_type}" + + +def get_next_file(folder_list, ignored_files): + """Returns next file for scan""" + if not ignored_files: + ignored_files = "" + else: + ignored_files = "|".join([f"({fnmatch.translate(f)})" for f in ignored_files]) + ignored_files_re = re.compile(ignored_files) + for folder in folder_list: + for filename in glob.iglob(folder, recursive=True): + yield (filename, (ignored_files and ignored_files_re.match(filename))) + + +def add_entry(_list, entry, yaml_file, lineno): + """Add entry to list of missing entities/services with line number information""" + _LOGGER.debug("Added %s to the list", entry) + if entry in _list: + if yaml_file in _list[entry]: + _list[entry].get(yaml_file, []).append(lineno) + else: + _list[entry] = {yaml_file: [lineno]} + + +def is_service(hass, entry): + """check whether config entry is a service""" + domain, service = entry.split(".")[0], ".".join(entry.split(".")[1:]) + return hass.services.has_service(domain, service) + + +def get_entity_state(hass, entry, friendly_names=False): + """returns entity state or missing if entity does not extst""" + entity = hass.states.get(entry) + name = None + if entity and entity.attributes.get("friendly_name", None): + if friendly_names: + name = entity.name + # fix for #75, some integrations return non-string states + state = ( + "missing" if not entity else str(entity.state).replace("unavailable", "unavail") + ) + return state, name + + +def check_services(hass): + """check if entries from config file are services""" + services_missing = {} + if "missing" in get_config(hass, CONF_IGNORED_STATES, []): + return services_missing + if DOMAIN not in hass.data or "service_list" not in hass.data[DOMAIN]: + raise HomeAssistantError("Service list not found") + service_list = hass.data[DOMAIN]["service_list"] + _LOGGER.debug("::check_services") + for entry, occurences in service_list.items(): + if not is_service(hass, entry): + services_missing[entry] = occurences + _LOGGER.debug("service %s added to missing list", entry) + return services_missing + + +def check_entitites(hass): + """check if entries from config file are entities with an active state""" + ignored_states = [ + "unavail" if s == "unavailable" else s + for s in get_config(hass, CONF_IGNORED_STATES, []) + ] + if DOMAIN not in hass.data or "entity_list" not in hass.data[DOMAIN]: + _LOGGER.error("Entity list not found") + raise Exception("Entity list not found") + entity_list = hass.data[DOMAIN]["entity_list"] + entities_missing = {} + _LOGGER.debug("::check_entities") + for entry, occurences in entity_list.items(): + if is_service(hass, entry): # this is a service, not entity + _LOGGER.debug("entry %s is service, skipping", entry) + continue + state, _ = get_entity_state(hass, entry) + if state in ignored_states: + _LOGGER.debug("entry %s ignored due to ignored_states", entry) + continue + if state in ["missing", "unknown", "unavail"]: + entities_missing[entry] = occurences + _LOGGER.debug("entry %s added to missing list", entry) + return entities_missing + + +def parse(hass, folders, ignored_files, root=None): + """Parse a yaml or json file for entities/services""" + files_parsed = 0 + entity_pattern = re.compile( + r"(?:(?<=\s)|(?<=^)|(?<=\")|(?<=\'))([A-Za-z_0-9]*\s*:)?(?:\s*)?(?:states.)?" + r"((air_quality|alarm_control_panel|alert|automation|binary_sensor|button|calendar|camera|" + r"climate|counter|device_tracker|fan|group|humidifier|input_boolean|input_datetime|" + r"input_number|input_select|light|lock|media_player|number|person|plant|proximity|remote|" + r"scene|script|select|sensor|sun|switch|timer|vacuum|weather|zone)\.[A-Za-z_*0-9]+)" + ) + service_pattern = re.compile(r"service:\s*([A-Za-z_0-9]*\.[A-Za-z_0-9]+)") + comment_pattern = re.compile(r"#.*") + entity_list = {} + service_list = {} + effectively_ignored = [] + _LOGGER.debug("::parse started") + for yaml_file, ignored in get_next_file(folders, ignored_files): + short_path = os.path.relpath(yaml_file, root) + if ignored: + effectively_ignored.append(short_path) + _LOGGER.debug("%s ignored", yaml_file) + continue + + try: + for i, line in enumerate(open(yaml_file, encoding="utf-8")): + line = re.sub(comment_pattern, "", line) + for match in re.finditer(entity_pattern, line): + typ, val = match.group(1), match.group(2) + if ( + typ != "service:" + and "*" not in val + and not val.endswith(".yaml") + ): + add_entry(entity_list, val, short_path, i + 1) + for match in re.finditer(service_pattern, line): + val = match.group(1) + add_entry(service_list, val, short_path, i + 1) + files_parsed += 1 + _LOGGER.debug("%s parsed", yaml_file) + except OSError as exception: + _LOGGER.error("Unable to parse %s: %s", yaml_file, exception) + except UnicodeDecodeError as exception: + _LOGGER.error( + "Unable to parse %s: %s. Use UTF-8 encoding to avoid this error", + yaml_file, + exception, + ) + + # remove ignored entities and services from resulting lists + ignored_items = get_config(hass, CONF_IGNORED_ITEMS, []) + ignored_items = list(set(ignored_items + BUNDLED_IGNORED_ITEMS)) + excluded_entities = [] + excluded_services = [] + for itm in ignored_items: + if itm: + excluded_entities.extend(fnmatch.filter(entity_list, itm)) + excluded_services.extend(fnmatch.filter(service_list, itm)) + + entity_list = {k: v for k, v in entity_list.items() if k not in excluded_entities} + service_list = {k: v for k, v in service_list.items() if k not in excluded_services} + + _LOGGER.debug("Parsed files: %s", files_parsed) + _LOGGER.debug("Ignored files: %s", effectively_ignored) + _LOGGER.debug("Found entities: %s", len(entity_list)) + _LOGGER.debug("Found services: %s", len(service_list)) + return (entity_list, service_list, files_parsed, len(effectively_ignored)) + + +def fill(data, width, extra=None): + """arrange data by table column width""" + if data and isinstance(data, dict): + key, val = next(iter(data.items())) + out = f"{key}:{','.join([str(v) for v in val])}" + else: + out = str(data) if not extra else f"{data} ('{extra}')" + + return ( + "\n".join([out.ljust(width) for out in wrap(out, width)]) if width > 0 else out + ) + + +def report(hass, render, chunk_size, test_mode=False): + """generates watchman report either as a table or as a list""" + if not DOMAIN in hass.data: + raise HomeAssistantError("No data for report, refresh required.") + + start_time = time.time() + header = get_config(hass, CONF_HEADER, DEFAULT_HEADER) + services_missing = hass.data[DOMAIN]["services_missing"] + service_list = hass.data[DOMAIN]["service_list"] + entities_missing = hass.data[DOMAIN]["entities_missing"] + entity_list = hass.data[DOMAIN]["entity_list"] + files_parsed = hass.data[DOMAIN]["files_parsed"] + files_ignored = hass.data[DOMAIN]["files_ignored"] + chunk_size = ( + get_config(hass, CONF_CHUNK_SIZE, DEFAULT_CHUNK_SIZE) + if chunk_size is None + else chunk_size + ) + + rep = f"{header} \n" + if services_missing: + rep += f"\n-== Missing {len(services_missing)} service(s) from " + rep += f"{len(service_list)} found in your config:\n" + rep += render(hass, "service_list") + rep += "\n" + elif len(service_list) > 0: + rep += f"\n-== Congratulations, all {len(service_list)} services from " + rep += "your config are available!\n" + else: + rep += "\n-== No services found in configuration files!\n" + + if entities_missing: + rep += f"\n-== Missing {len(entities_missing)} entity(ies) from " + rep += f"{len(entity_list)} found in your config:\n" + rep += render(hass, "entity_list") + rep += "\n" + + elif len(entity_list) > 0: + rep += f"\n-== Congratulations, all {len(entity_list)} entities from " + rep += "your config are available!\n" + else: + rep += "\n-== No entities found in configuration files!\n" + timezone = pytz.timezone(hass.config.time_zone) + + if not test_mode: + report_datetime = datetime.now(timezone).strftime("%d %b %Y %H:%M:%S") + parse_duration = hass.data[DOMAIN]["parse_duration"] + check_duration = hass.data[DOMAIN]["check_duration"] + render_duration = time.time() - start_time + else: + report_datetime = "01 Jan 1970 00:00:00" + parse_duration = 0.01 + check_duration = 0.105 + render_duration = 0.0003 + + rep += f"\n-== Report created on {report_datetime}\n" + rep += ( + f"-== Parsed {files_parsed} files in {parse_duration:.2f}s., " + f"ignored {files_ignored} files \n" + ) + rep += f"-== Generated in: {render_duration:.2f}s. Validated in: {check_duration:.2f}s." + report_chunks = [] + chunk = "" + for line in iter(rep.splitlines()): + chunk += f"{line}\n" + if chunk_size > 0 and len(chunk) > chunk_size: + report_chunks.append(chunk) + chunk = "" + if chunk: + report_chunks.append(chunk) + return report_chunks diff --git a/config/espresense/config.yaml b/config/espresense/config.yaml new file mode 100644 index 0000000..681ef71 --- /dev/null +++ b/config/espresense/config.yaml @@ -0,0 +1,207 @@ +# MQTT Connection, if empty will query and use hassio provided mqtt +mqtt: + host: 10.0.0.3 + port: 1883 + ssl: false + username: + password: + +# This gets added to the x,y,z to derive a gps location +gps: + latitude: 45.142003125930984 + longitude: 4.075053334236146 + elevation: 881 + +# How long before device considered stale +timeout: 30 +# How long before device is considered away +away_timeout: 120 + +optimization: + enabled: true + interval_secs: 3600 + limits: + absorption_min: 2.5 + absorption_max: 3.5 + tx_ref_rssi_min: -70 + tx_ref_rssi_max: -50 + rx_adj_rssi_min: -15 + rx_adj_rssi_max: 20 + +weighting: + algorithm: gaussian + props: + sigma: 0.10 + +# Floors w/ the points to draw it in meters +floors: + - id: first + name: First Floor + # Bounds (x,y,z) of map in meters + bounds: [[0, 0, 0], [17, 18, 1.5]] + rooms: + - name: Powder + points: + - [6, 12] + - [8.6, 12] + - [8.6, 10] + - [8, 9] + - [6, 9] + - [6, 12] + - name: Den + points: + - [6, 16.2] + - [9.6, 16.2] + - [9.6, 13.5] + - [8.6, 12] + - [6, 12] + - [6, 14.5] + - name: Family + points: + - [0, 0.5] + - [0, 5.5] + - [5, 5.5] + - [6, 4.5] + - [6, 0.5] + - [5.5, 0] + - [0.5, 0] + - [0, 0.5] + - name: Kitchen + points: + - [6, 2] + - [6, 9] + - [8, 9] + - [8.6, 10] + - [12.5, 10] + - [12.5, 2] + - [6, 2] + - name: Garage + points: + - [0, 17] + - [1.5, 17] + - [1.5, 17.5] + - [6, 17.5] + - [6, 7.5] + - [0, 7.5] + - [0, 17] + - name: Living + points: + - [16.5, 13.25] + - [16.5, 7.75] + - [12.5, 7.75] + - [12.5, 13.25] + - [16.5, 13.25] + - name: Dining + points: + - [16.5, 2.75] + - [16.5, 7.75] + - [12.5, 7.75] + - [12.5, 2.75] + - [16.5, 2.75] + - name: Foyer + points: + - [9.6, 13.5] + - [9.6, 15] + - [12.5, 15] + - [12.5, 10] + - [11.6, 10] + - [8.6, 10] + - [8.6, 12] + - name: Laundry + points: + - [0, 7.5] + - [3.6, 7.5] + - [3.6, 5.5] + - [0, 5.5] + - [0, 7.5] + - id: second + name: Second Floor + bounds: [[0, 0, 3.1], [17, 18, 4.6]] + rooms: + - name: Master + points: + - [3.2, 13.5] + - [6, 13.5] + - [6, 12] + - [8.6, 12.0] + - [8.6, 10.5] + - [8.6, 8.5] + - [8, 7.5] + - [3.2, 7.5] + - [3.2, 13.5] + - name: Office + points: + - [6, 16.2] + - [9.6, 16.2] + - [9.6, 13.5] + - [8.6, 12] + - [6, 12] + - [6, 14.5] + - name: Master Bathroom + points: + - [0, 13.5] + - [3.2, 13.5] + - [3.2, 7.5] + - [0, 7.5] + - [0, 13.5] + - name: Master Closet + points: + - [0, 16.5] + - [1.5, 16.5] + - [1.5, 17.0] + - [6, 17.0] + - [6, 13.5] + - [0, 13.5] + - [0, 16.5] + - id: outside + name: Outside + bounds: [[-10, -10, -10], [28, 30, 20]] + +# Locations of espresense nodes in meters +nodes: + - name: Master + point: [9.4, 13.7, 3.6] + floors: ["second"] + - name: Bathroom + point: [0.1, 10, 3.9] + floors: ["second", "outside"] + - name: Upstairs Hallway + point: [12, 10, 3.2] + floors: ["second"] + - name: Garage + point: [0.75, 16.8, 0.5] + floors: ["first", "second", "outside"] + - name: Office + point: [6.75, 15, 3.6] + floors: ["second"] + - name: Family + point: [0.25, 0.4, 0.3] + floors: ["first", "outside"] + - name: Kitchen + point: [9, 9, 0.85] + floors: ["first"] + - name: Dining + point: [16.25, 3, 1.29] + floors: ["first", "outside"] + - name: Basement + point: [16, 13, -1] + floors: ["first", "second", "outside"] + - name: Laundry + point: [4.6, 7.5, 1] + floors: ["first"] + - name: Mini + stationary: false + point: [-2, 15, 0.5] + floors: ["outside"] + +# Devices to track +devices: + - name: "*" # Track all named devices + - id: "tile:*" # Track all tiles + - id: "irk:*" # Track all IRKs + - id: "watch:*" + - id: "phone:*" + - id: "wallet:*" + - id: "keys:*" + - id: "therm:*" + - id: "iBeacon:*" diff --git a/config/packages/.vscode/settings.json b/config/packages/.vscode/settings.json new file mode 100644 index 0000000..e69de29 diff --git a/config/packages/irrigation_unlimited_adjustment.yaml.old b/config/packages/irrigation_unlimited_adjustment.yaml.old new file mode 100644 index 0000000..b91f0e1 --- /dev/null +++ b/config/packages/irrigation_unlimited_adjustment.yaml.old @@ -0,0 +1,215 @@ +# Filename: irrigation_unlimited_adjustment.yaml +# +# This file is a package and should be located in the config/packages +# folder. If you do not have a packages folder then create it and add +# the following to configuration.yaml +# +# homeassistant: +# packages: !include_dir_named packages +# +# More information on packages can be found at https://www.home-assistant.io/docs/configuration/packages +# +# Set up some observation sensors. +# This uses the Home-Assistant-wundergroundpws https://github.com/cytech/Home-Assistant-wundergroundpws integration. +# Rain information (wupws_preciptotal) is a daily accumulation total. So we want to grab the +# data just before midnight to get the daily total. We shouldn't be too eager to look after midnight +# because the reset from WU may take a few minutes to come through, currently 10 min. Increase this +# if data is unreliable. +# Note: Requires the ha-average integration to be installed https://github.com/Limych/ha-average + +sensor: + - platform: average + name: irrigation_unlimited_rain_0 + entities: + - sensor.wupws_preciptotal + precision: 1 + start: "{{ now().replace(hour=0).replace(minute=30).replace(second=0) }}" + end: "{{ now() }}" + scan_interval: 600 + + - platform: average + name: irrigation_unlimited_rain_1 + entities: + - sensor.wupws_preciptotal + precision: 1 + start: "{{ now().replace(hour=0).replace(minute=30).replace(second=0) - timedelta(days=1) }}" + end: "{{ now().replace(hour=23).replace(minute=59).replace(second=0) - timedelta(days=1) }}" + scan_interval: 600 + + - platform: average + name: irrigation_unlimited_rain_2 + entities: + - sensor.wupws_preciptotal + precision: 1 + start: "{{ now().replace(hour=0).replace(minute=30).replace(second=0) - timedelta(days=2) }}" + end: "{{ now().replace(hour=23).replace(minute=59).replace(second=0) - timedelta(days=2) }}" + scan_interval: 600 + + - platform: average + name: irrigation_unlimited_rain_3 + entities: + - sensor.wupws_preciptotal + precision: 1 + start: "{{ now().replace(hour=0).replace(minute=30).replace(second=0) - timedelta(days=3) }}" + end: "{{ now().replace(hour=23).replace(minute=59).replace(second=0) - timedelta(days=3) }}" + scan_interval: 600 + + - platform: average + name: irrigation_unlimited_rain_4 + entities: + - sensor.wupws_preciptotal + precision: 1 + start: "{{ now().replace(hour=0).replace(minute=30).replace(second=0) - timedelta(days=4) }}" + end: "{{ now().replace(hour=23).replace(minute=59).replace(second=0) - timedelta(days=4) }}" + scan_interval: 600 + + - platform: average + name: irrigation_unlimited_temperature_0 + entities: + - sensor.wupws_temp + precision: 1 + start: "{{ now().replace(hour=0).replace(minute=30).replace(second=0) }}" + end: "{{ now() }}" + scan_interval: 600 + + - platform: average + name: irrigation_unlimited_temperature_1 + entities: + - sensor.wupws_temp + precision: 1 + start: "{{ now().replace(hour=0).replace(minute=30).replace(second=0) - timedelta(days=1) }}" + end: "{{ now().replace(hour=23).replace(minute=59).replace(second=0) - timedelta(days=1) }}" + scan_interval: 600 + + - platform: average + name: irrigation_unlimited_temperature_2 + entities: + - sensor.wupws_temp + precision: 1 + start: "{{ now().replace(hour=0).replace(minute=30).replace(second=0) - timedelta(days=2) }}" + end: "{{ now().replace(hour=23).replace(minute=59).replace(second=0) - timedelta(days=2) }}" + scan_interval: 600 + + - platform: average + name: irrigation_unlimited_temperature_3 + entities: + - sensor.wupws_temp + precision: 1 + start: "{{ now().replace(hour=0).replace(minute=30).replace(second=0) - timedelta(days=3) }}" + end: "{{ now().replace(hour=23).replace(minute=59).replace(second=0) - timedelta(days=3) }}" + scan_interval: 600 + + - platform: average + name: irrigation_unlimited_temperature_4 + entities: + - sensor.wupws_temp + precision: 1 + start: "{{ now().replace(hour=0).replace(minute=30).replace(second=0) - timedelta(days=4) }}" + end: "{{ now().replace(hour=23).replace(minute=59).replace(second=0) - timedelta(days=4) }}" + scan_interval: 600 + + - platform: average + name: irrigation_unlimited_temperature_5_day_moving_average + entities: + - sensor.wupws_temp + precision: 1 + start: "{{ now().replace(hour=0).replace(minute=30).replace(second=0) - timedelta(days=4) }}" + end: "{{ now() }}" + scan_interval: 600 + + # Five day weighted rain total sensor. + # Adjust the weight values (0.7, 0.3, 0.15, 0.05) to suit your needs (0.0 = ignore that day). + - platform: template + sensors: + irrigation_unlimited_rain_weighted_total: + friendly_name: "Irrigation Unlimited Rain Weighted Total" + unit_of_measurement: "mm" + icon_template: "mdi:umbrella" + value_template: > + {% set r0 = state_attr('sensor.irrigation_unlimited_rain_0','max_value') | float(-1) %} + {% set r1 = state_attr('sensor.irrigation_unlimited_rain_1','max_value') | float(-1) %} + {% set r2 = state_attr('sensor.irrigation_unlimited_rain_2','max_value') | float(-1) %} + {% set r3 = state_attr('sensor.irrigation_unlimited_rain_3','max_value') | float(-1) %} + {% set r4 = state_attr('sensor.irrigation_unlimited_rain_4','max_value') | float(-1) %} + {% if r0 != -1 and r1 != -1 and r2 != -1 and r3 != - 1 and r4 != -1 %} + {% set rain_total = r0 %} + {% set rain_total = rain_total + r1 * 0.7 %} + {% set rain_total = rain_total + r2 * 0.3 %} + {% set rain_total = rain_total + r3 * 0.15 %} + {% set rain_total = rain_total + r4 * 0.05 %} + {{ rain_total | round(1) }} + {% else %} + {{ -1 }} + {% endif %} + scan_interval: 600 + +# Automation to adjust the run times for Irrigation Unlimited. +# It uses the 5 day weighted rain total and the moving 5 day average temperature sensors +# created above to generate a variation. +# Adjust rain_total_threshold, rain_rate_threshold and temperature_threshold variables to suit you needs. +automation: + - id: 'IU1653340123453' + alias: Irrigation Unlimited Adjustment + trigger: + # ------------------------------------------------------------------- + # Choose how you want to trigger this automation. + # Comment out/delete/change as required. + # ------------------------------------------------------------------- + # Run at a fixed time + - platform: time + at: "02:00" + # Run when Home Assistant starts + - platform: homeassistant + event: start + # Run when the sensors update. Don't use this option if any of your + # schedules use the 'anchor: finish'. It will most likely cause the + # system to skip. Use a fixed time. + - platform: state + entity_id: + - sensor.irrigation_unlimited_rain_weighted_total + - sensor.irrigation_unlimited_temperature_5_day_moving_average + - sensor.wupws_preciprate + condition: + condition: and + conditions: + - "{{ states('sensor.irrigation_unlimited_rain_weighted_total') | float(-1) != -1 }}" + - "{{ states('sensor.wupws_preciprate') | float(-1) != -1 }}" + - "{{ states('sensor.irrigation_unlimited_temperature_5_day_moving_average') | float(-273) != -273 }}" + action: + service: irrigation_unlimited.adjust_time + data: + # ------------------------------------------------------------------- + # Please see documentation regarding the adjust_time service call. + # Choose an option below. Comment out/delete/change as needed. + # *** This will NOT work as is. *** + # 1. Adjust a single zone. Change the zone as required + # entity_id: binary_sensor.irrigation_unlimited_c1_z1 + # 2. Adjust a sequence. Change the sequence_id as required + # entity_id: binary_sensor.irrigation_unlimited_c1_m + # sequence_id: 1 + # ------------------------------------------------------------------- + percentage: > + {# Threshold variables #} + {% set rain_total_threshold = 3.5 %} + {% set rain_rate_threshold = 1.0 %} + {% set temperature_threshold = 20.0 %} + + {# Sensor data #} + {% set rain_total = states('sensor.irrigation_unlimited_rain_weighted_total') | float(-1) %} + {% set rain_rate = states('sensor.wupws_preciprate') | float(-1) %} + {% set temperature_average = states('sensor.irrigation_unlimited_temperature_5_day_moving_average') | float(-273) %} + + {# Threshold variables #} + {% set rain_multiplier = (1 - (rain_total / rain_total_threshold)) %} + {% set temperature_multiplier = temperature_average / temperature_threshold %} + + {% set multiplier = 1.0 %} + {% if rain_rate < rain_rate_threshold and rain_multiplier > 0 and rain_total < rain_total_threshold %} + {% set multiplier = multiplier * temperature_multiplier %} + {% set multiplier = multiplier * rain_multiplier %} + {% else %} + {% set multiplier = 0.0 %} {# It's raining or enough already #} + {% endif %} + + {# Return multiplier as a percentage #} + {{ (multiplier * 100) | round(0) }} diff --git a/config/packages/irrigation_unlimited_controls.yaml b/config/packages/irrigation_unlimited_controls.yaml new file mode 100644 index 0000000..dd0324f --- /dev/null +++ b/config/packages/irrigation_unlimited_controls.yaml @@ -0,0 +1,46 @@ +# Irrigation Unlimited support file. +# +# Filename: irrigation_unlimited_controls.yaml +# +# This file is a package and should be located in the config/packages +# folder. If you do not have a packages folder then create it and add +# the following to configuration.yaml +# +# homeassistant: +# packages: !include_dir_named packages +# +# More information on packages can be found at https://www.home-assistant.io/docs/configuration/packages +# +input_select: + irrigation_unlimited_entities: + name: Irrigation Unlimited Entities + options: + - + + irrigation_unlimited_sequences: + name: Irrigation Unlimited Sequences + options: + - + +input_datetime: + irrigation_unlimited_run_time: + name: Run Time + has_date: false + has_time: true + +automation: + - alias: Irrigation Unlimited Load UI Controls + trigger: + - platform: homeassistant + event: start + action: + - service: irrigation_unlimited.list_config + data: + entity_id: input_select.irrigation_unlimited_entities + section: entities + first: + - service: irrigation_unlimited.list_config + data: + entity_id: input_select.irrigation_unlimited_sequences + section: sequences + first: diff --git a/config/packages/irrigation_unlimited_lts.yaml b/config/packages/irrigation_unlimited_lts.yaml new file mode 100644 index 0000000..be9035d --- /dev/null +++ b/config/packages/irrigation_unlimited_lts.yaml @@ -0,0 +1,8 @@ +template: + - sensor: + - name: Today Total C1 Z1 + state_class: total_increasing + icon: mdi:sprinkler + unit_of_measurement: m + state: > + {{ state_attr('binary_sensor.irrigation_unlimited_c1_z1', 'today_total') | float(0) }} diff --git a/config/packages/irrigation_unlimited_overnight b/config/packages/irrigation_unlimited_overnight new file mode 100644 index 0000000..6bae9e0 --- /dev/null +++ b/config/packages/irrigation_unlimited_overnight @@ -0,0 +1,42 @@ +# Filename: irrigation_unlimited_overnight.yaml +# +# Verion: 1.0.0 +# +# Description: Example automation for running from sunset to sunrise +# +# This file is a package and should be located in the config/packages +# folder. If you do not have a packages folder then create it and add +# the following to configuration.yaml +# +# homeassistant: +# packages: !include_dir_named packages +# +# More information on packages can be found at https://www.home-assistant.io/docs/configuration/packages +# +automation: + - id: 'IU1655789912900' + alias: IU Overnight + description: Run irrigation from sunset to sunrise + trigger: + - platform: sun + event: sunset + offset: -00:60:00 + condition: [] + action: + service: irrigation_unlimited.adjust_time + data: + # ------------------------------------------------------------------- + # Please see documentation regarding the adjust_time service call. + # Choose an option below. Comment out/delete/change as needed. + # *** This will NOT work as is. *** + # 1. Adjust a single zone. Change the zone as required + # entity_id: binary_sensor.irrigation_unlimited_c1_z1 + # 2. Adjust a sequence. Change the sequence_id as required + # entity_id: binary_sensor.irrigation_unlimited_c1_m + # sequence_id: 1 + # ------------------------------------------------------------------- + actual: > + {% set t1 = as_datetime(state_attr("sun.sun", "next_setting")).replace(microsecond=0) %} + {% set t2 = as_datetime(state_attr("sun.sun", "next_rising")).replace(microsecond=0) %} + {{ t2 - t1 }} + mode: single diff --git a/config/packages/irrigation_unlimited_smart_irrigation.yaml b/config/packages/irrigation_unlimited_smart_irrigation.yaml new file mode 100644 index 0000000..c908611 --- /dev/null +++ b/config/packages/irrigation_unlimited_smart_irrigation.yaml @@ -0,0 +1,62 @@ +# Filename: irrigation_unlimited_smart_irrigation.yaml +# +# Verion: 1.0.3 +# +# Description: Example automation for HAsmartirrigation integration +# (smart_irrigation)[https://github.com/jeroenterheerdt/HAsmartirrigation] +# +# This file is a package and should be located in the config/packages +# folder. If you do not have a packages folder then create it and add +# the following to configuration.yaml +# +# homeassistant: +# packages: !include_dir_named packages +# +# More information on packages can be found at https://www.home-assistant.io/docs/configuration/packages +# +# automation: +# - id: "IU1653097957047" +# alias: Smart Irrigation adjustment +# description: Adjust watering times based on smart irrigation calculations +# trigger: +# - platform: time +# at: "23:30" +# condition: +# condition: and +# conditions: +# - "{{ states('sensor.smart_irrigation_daily_adjusted_run_time') | float(-1) >= 0 }}" +# action: +# - service: irrigation_unlimited.adjust_time +# data: +# actual: "{{ timedelta(seconds=states('sensor.smart_irrigation_daily_adjusted_run_time') | int(0)) }}" +# # ------------------------------------------------------------------- +# # Please see documentation regarding the adjust_time service call. +# # Choose an option below. Comment out/delete as needed. This will NOT work as is. +# # 1. Adjust a single zone. Change the zone as required +# # entity_id: binary_sensor.irrigation_unlimited_c1_z1 +# # 2. Adjust a sequence. Change the sequence_id as required +# # entity_id: binary_sensor.irrigation_unlimited_c1_m +# # sequence_id: 1 +# # ------------------------------------------------------------------- +# mode: single + +# - id: "IU1653098247170" +# alias: Smart Irrigation reset bucket +# description: Resets the Smart Irrigation bucket after watering +# trigger: +# - platform: state +# entity_id: +# # Add Irrigation Unlimited sensors here +# - binary_sensor.irrigation_unlimited_c1_m +# from: "on" +# to: "off" +# condition: +# - condition: numeric_state +# above: "0" +# entity_id: sensor.smart_irrigation_daily_adjusted_run_time +# action: +# - delay: +# hours: 0 +# minutes: 0 + +# #- service: smart_irrigation.smart_irrigation_reset_bucket diff --git a/config/packages/irrigation_unlimited_soil_moisture.yaml b/config/packages/irrigation_unlimited_soil_moisture.yaml new file mode 100644 index 0000000..d85b23a --- /dev/null +++ b/config/packages/irrigation_unlimited_soil_moisture.yaml @@ -0,0 +1,66 @@ +# Filename: irrigation_unlimited_soil_moisture.yaml +# +# This file is a package and should be located in the config/packages +# folder. If you do not have a packages folder then create it and add +# the following to configuration.yaml +# +# homeassistant: +# packages: !include_dir_named packages +# +# More information on packages can be found at https://www.home-assistant.io/docs/configuration/packages +# +# Automation to adjust the run times for Irrigation Unlimited based on a soil moisture reading. This +# is based on the Spruce Moisture Sensor from Plaid Systems. +# Adjust the 'threshold' variable to suit you needs. +# automation: +# - id: "IU1653340127290" +# alias: Irrigation Unlimited Soil Moisture Adjustment +# trigger: +# # ------------------------------------------------------------------- +# # Choose how you want to trigger this automation. +# # Comment out/delete/change as required. +# # ------------------------------------------------------------------- +# # Run at a fixed time +# - platform: time +# at: "02:00" +# # Run when Home Assistant starts +# - platform: homeassistant +# event: start +# # Run when the sensors update. Don't use this option if your schedules +# # use the 'anchor: finish'. It will most likely cause your system to +# # skip. Use a fixed time. +# # - platform: state +# # entity_id: +# # - sensor.plaid_systems_ps_sprzms_slp3_humidity +# condition: +# condition: and +# conditions: +# - "{{ states('sensor.plaid_systems_ps_sprzms_slp3_humidity') | float(-1) != -1 }}" +# action: +# service: irrigation_unlimited.adjust_time +# data: +# # ------------------------------------------------------------------- +# # Please see documentation regarding the adjust_time service call. +# # Choose an option below. Comment out/delete/change as needed. +# # *** This will NOT work as is. *** +# # 1. Adjust a single zone. Change the zone as required +# # entity_id: binary_sensor.irrigation_unlimited_c1_z1 +# # 2. Adjust a sequence. Change the sequence_id as required +# entity_id: binary_sensor.irrigation_unlimited_c1_m +# sequence_id: 0 +# # ------------------------------------------------------------------- +# percentage: > +# {# Threshold variable 0-100 percent #} +# {% set threshold = 80 %} + +# {# Sensor data #} +# {% set humidity = states('sensor.plaid_systems_ps_sprzms_slp3_humidity') | float %} + +# {% if humidity < threshold %} +# {% set multiplier = 1 - (humidity / threshold) %} +# {% else %} +# {% set multiplier = 0.0 %} {# It's too wet, turn off #} +# {% endif %} + +# {# Return multiplier as a percentage #} +# {{ (multiplier * 100) | round(0) }} diff --git a/config/packages/irrigation_unlimited_soil_temperature.yaml.old b/config/packages/irrigation_unlimited_soil_temperature.yaml.old new file mode 100644 index 0000000..e51601d --- /dev/null +++ b/config/packages/irrigation_unlimited_soil_temperature.yaml.old @@ -0,0 +1,76 @@ +# Filename: irrigation_unlimited_soil_temperature.yaml +# +# This file is a package and should be located in the config/packages +# folder. If you do not have a packages folder then create it and add +# the following to configuration.yaml +# +# homeassistant: +# packages: !include_dir_named packages +# +# More information on packages can be found at https://www.home-assistant.io/docs/configuration/packages + +# Create a sensor for the temperature average of the last day. +sensor: + - platform: average + name: "Irrigation Unlimited Average Soil Temperature" + unique_id: "irrigation_unlimited_average_soil_temperature" + end: "{{ now().replace(hour=0).replace(minute=0).replace(second=0) }}" + duration: + hours: 24 + entities: + - sensor.plaid_systems_ps_sprzms_slp3_temperature + precision: 1 + scan_interval: 600 + +# Automation to adjust the run times for Irrigation Unlimited based on a soil temperature readings. This +# is based on the Spruce Moisture Sensor from Plaid Systems. +# Adjust the 'threshold' variable to suit you needs. +automation: + - id: "IU1653849227290" + alias: Irrigation Unlimited Soil Temperature Adjustment + trigger: + # ------------------------------------------------------------------- + # Choose how you want to trigger this automation. + # Comment out/delete/change as required. + # ------------------------------------------------------------------- + # Run at a fixed time + - platform: time + at: "02:00" + # Run when Home Assistant starts + - platform: homeassistant + event: start + # Run when the sensors update. Don't use this option if your schedules + # use the 'anchor: finish'. It will most likely cause your system to + # skip. Use a fixed time. + # - platform: state + # entity_id: + # - sensor.plaid_systems_ps_sprzms_slp3_humidity + condition: + condition: and + conditions: + - "{{ states('sensor.irrigation_unlimited_average_soil_temperature') | float(-273) != -273 }}" + action: + service: irrigation_unlimited.adjust_time + data: + # ------------------------------------------------------------------- + # Please see documentation regarding the adjust_time service call. + # Choose an option below. Comment out/delete/change as needed. + # *** This will NOT work as is. *** + # 1. Adjust a single zone. Change the zone as required + # entity_id: binary_sensor.irrigation_unlimited_c1_z1 + # 2. Adjust a sequence. Change the sequence_id as required + entity_id: binary_sensor.irrigation_unlimited_c1_m + sequence_id: 0 + # ------------------------------------------------------------------- + percentage: > + {# Threshold variables #} + {% set temperature_threshold = 20.0 %} + + {# Sensor data #} + {% set temperature_average = states('sensor.irrigation_unlimited_average_soil_temperature') | float(-273) %} + + {# Threshold variables #} + {% set temperature_multiplier = temperature_average / temperature_threshold %} + + {# Return multiplier as a percentage #} + {{ (temperature_multiplier * 100) | round(0) }} diff --git a/config/scripts.yaml b/config/scripts.yaml index e69de29..04a57b0 100644 --- a/config/scripts.yaml +++ b/config/scripts.yaml @@ -0,0 +1,15 @@ +'1717141935702': + alias: purge database + sequence: + - service: recorder.purge + data: + repack: true + apply_filter: true + keep_days: 5 + mode: single +'1717142010757': + alias: clear log + sequence: + - service: system_log.clear + data: {} + mode: single diff --git a/config/watchman_report.txt b/config/watchman_report.txt new file mode 100644 index 0000000..8b71897 --- /dev/null +++ b/config/watchman_report.txt @@ -0,0 +1,79 @@ +-== Watchman Report ==- + +-== Missing 6 service(s) from 37 found in your config: ++--------------------------------+---------+--------------------------------------------------------------+ +| Service ID | State | Location | ++--------------------------------+---------+--------------------------------------------------------------+ +| tts.google_say | missing | automations.yaml:891,1543,1576,1942 | +| script.wt32_sc01_wake_up | missing | automations.yaml:952 | +| script.wt32_sc01_sleep | missing | automations.yaml:959 | +| script.purge_database | missing | automations.yaml:1839 | +| script.1714980028797 | missing | automations.yaml:2252 | +| irrigation_unlimited.list_conf | missing | packages/irrigation_unlimited_controls.yaml:37,42 | +| ig | | | ++--------------------------------+---------+--------------------------------------------------------------+ + +-== Missing 33 entity(ies) from 109 found in your config: ++--------------------------------+---------+--------------------------------------------------------------+ +| Entity ID | State | Location | ++--------------------------------+---------+--------------------------------------------------------------+ +| binary_sensor.pir_sensor_2 | missing | automations.yaml:6,943 | +| climate.salon_3 | missing | automations.yaml:209,223,236,249 | +| switch.yoga | missing | automations.yaml:737,755,773 | +| sensor.battery_soc | missing | automations.yaml:1015,1048,1478 | +| select.charger_source_priority | missing | automations.yaml:1022,1053,1496,2137 | +| _2 | | | +| select.output_source_priority_ | missing | automations.yaml:1032,1063,1097,1107,1125,1506,2147 | +| 2 | | | +| sensor.load_power_3 | missing | automations.yaml:1090,1118 | +| switch.tasmota | missing | automations.yaml:1252,1422 | +| switch.ryzen | missing | automations.yaml:1261,1414,1431 | +| device_tracker.iphonex | missing | automations.yaml:1380,1399,1555,1690,1705 | +| switch.h801light_6f9188_h801_r | missing | automations.yaml:1613 | +| estart | | | +| switch.sonoff_4ch_restart_2 | missing | automations.yaml:1614 | +| switch.kc868_a8_d758d0_d758d0_ | missing | automations.yaml:1615 | +| kc868_a8_restart | | | +| switch.nmcuvoletporte_volet_po | missing | automations.yaml:1616 | +| rte_restart | | | +| switch.esp32_4_relays_garage_5 | missing | automations.yaml:1617,1618 | +| a10c8_esp32_4_relays_garage_re | | | +| start | | | +| switch.sonoff_4ch_garage_resta | missing | automations.yaml:1619 | +| rt | | | +| switch.nmcuvoletarriere1_volet | missing | automations.yaml:1621 | +| _arriere_restart | | | +| switch.nmcuvoletsalon1_volet_s | missing | automations.yaml:1622 | +| alon_1_restart | | | +| switch.nmcuvoletsalon2_volet_s | missing | automations.yaml:1623 | +| alon_2_restart | | | +| switch.sonoff_dressing_restart | missing | automations.yaml:1625 | +| _2 | | | +| switch.sonoff_escalier_restart | missing | automations.yaml:1626 | +| _2 | | | +| switch.nmcuvoletchambre1_volet | missing | automations.yaml:1627 | +| _chambre_1_restart | | | +| switch.volet_chambre_2_restart | missing | automations.yaml:1628 | +| _nmcuvoletchambre2 | | | +| switch.nmcuvoletcuisine1_volet | missing | automations.yaml:1629 | +| _cuisine_1_restart | | | +| switch.nmcuvoletcuisine2_volet | missing | automations.yaml:1630 | +| _cuisine_2_restart | | | +| switch.meuble_dashboard_restar | missing | automations.yaml:1631 | +| t | | | +| switch.wemos_pir_comble1_resta | missing | automations.yaml:1632 | +| rt_2 | | | +| switch.geiger_wemos_geiger_res | missing | automations.yaml:1633 | +| tart | | | +| automation.purge_db | unavail | automations.yaml:2239 | +| sensor.alerte_pluie_inondation | unavail | 01capteur/template/template.yaml:43,177 | +| sensor.alerte_grand_froid | unavail | 01capteur/template/template.yaml:123,186 | +| sensor.athom_smart_plug_elegoo | missing | 01capteur/template/template.yaml:194 | +| mars_1_wattage | | | +| sensor.energy_pj1203_energy_fl | missing | 01capteur/solar/solar_optimizer.yaml:12 | +| ow_b | | | ++--------------------------------+---------+--------------------------------------------------------------+ + +-== Report created on 31 May 2024 13:03:30 +-== Parsed 90 files in 0.53s., ignored 0 files +-== Generated in: 0.01s. Validated in: 0.00s. diff --git a/config/www/community/MeteoalarmCard/meteoalarm-card.js b/config/www/community/MeteoalarmCard/meteoalarm-card.js new file mode 100644 index 0000000..c411541 --- /dev/null +++ b/config/www/community/MeteoalarmCard/meteoalarm-card.js @@ -0,0 +1,1885 @@ +var e=function(t,i){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var i in t)Object.prototype.hasOwnProperty.call(t,i)&&(e[i]=t[i])},e(t,i)};function t(t,i){if("function"!=typeof i&&null!==i)throw new TypeError("Class extends value "+String(i)+" is not a constructor or null");function n(){this.constructor=t}e(t,i),t.prototype=null===i?Object.create(i):(n.prototype=i.prototype,new n)}var i,n,r=function(){return r=Object.assign||function(e){for(var t,i=1,n=arguments.length;i=0;s--)(r=e[s])&&(o=(a<3?r(o):a>3?r(t,i,o):r(t,i))||o);return a>3&&o&&Object.defineProperty(t,i,o),o}function o(e){var t="function"==typeof Symbol&&Symbol.iterator,i=t&&e[t],n=0;if(i)return i.call(e);if(e&&"number"==typeof e.length)return{next:function(){return e&&n>=e.length&&(e=void 0),{value:e&&e[n++],done:!e}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")}"function"==typeof SuppressedError&&SuppressedError,function(e){e.language="language",e.system="system",e.comma_decimal="comma_decimal",e.decimal_comma="decimal_comma",e.space_comma="space_comma",e.none="none"}(i||(i={})),function(e){e.language="language",e.system="system",e.am_pm="12",e.twenty_four="24"}(n||(n={}));var s=["closed","locked","off"],l=function(e,t,i,n){n=n||{},i=null==i?{}:i;var r=new Event(t,{bubbles:void 0===n.bubbles||n.bubbles,cancelable:Boolean(n.cancelable),composed:void 0===n.composed||n.composed});return r.detail=i,e.dispatchEvent(r),r},d=function(e){l(window,"haptic",e)},c=function(e,t,i,n){if(n||(n={action:"more-info"}),!n.confirmation||n.confirmation.exemptions&&n.confirmation.exemptions.some((function(e){return e.user===t.user.id}))||(d("warning"),confirm(n.confirmation.text||"Are you sure you want to "+n.action+"?")))switch(n.action){case"more-info":(i.entity||i.camera_image)&&l(e,"hass-more-info",{entityId:i.entity?i.entity:i.camera_image});break;case"navigate":n.navigation_path&&function(e,t,i){void 0===i&&(i=!1),i?history.replaceState(null,"",t):history.pushState(null,"",t),l(window,"location-changed",{replace:i})}(0,n.navigation_path);break;case"url":n.url_path&&window.open(n.url_path);break;case"toggle":i.entity&&(function(e,t){(function(e,t,i){void 0===i&&(i=!0);var n,r=function(e){return e.substr(0,e.indexOf("."))}(t),a="group"===r?"homeassistant":r;switch(r){case"lock":n=i?"unlock":"lock";break;case"cover":n=i?"open_cover":"close_cover";break;default:n=i?"turn_on":"turn_off"}e.callService(a,n,{entity_id:t})})(e,t,s.includes(e.states[t].state))}(t,i.entity),d("success"));break;case"call-service":if(!n.service)return void d("failure");var r=n.service.split(".",2);t.callService(r[0],r[1],n.service_data,n.target),d("success");break;case"fire-dom-event":l(e,"ll-custom",n)}};function p(e){return void 0!==e&&"none"!==e.action} +/** + * @license + * Copyright 2019 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const m=window.ShadowRoot&&(void 0===window.ShadyCSS||window.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,h=Symbol(),u=new Map;class f{constructor(e,t){if(this._$cssResult$=!0,t!==h)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=e}get styleSheet(){let e=u.get(this.cssText);return m&&void 0===e&&(u.set(this.cssText,e=new CSSStyleSheet),e.replaceSync(this.cssText)),e}toString(){return this.cssText}}const g=(e,...t)=>{const i=1===e.length?e[0]:t.reduce(((t,i,n)=>t+(e=>{if(!0===e._$cssResult$)return e.cssText;if("number"==typeof e)return e;throw Error("Value passed to 'css' function must be a 'css' function result: "+e+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(i)+e[n+1]),e[0]);return new f(i,h)},v=(e,t)=>{m?e.adoptedStyleSheets=t.map((e=>e instanceof CSSStyleSheet?e:e.styleSheet)):t.forEach((t=>{const i=document.createElement("style"),n=window.litNonce;void 0!==n&&i.setAttribute("nonce",n),i.textContent=t.cssText,e.appendChild(i)}))},b=m?e=>e:e=>e instanceof CSSStyleSheet?(e=>{let t="";for(const i of e.cssRules)t+=i.cssText;return(e=>new f("string"==typeof e?e:e+"",h))(t)})(e):e +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */;var y;const x=window.trustedTypes,_=x?x.emptyScript:"",w=window.reactiveElementPolyfillSupport,E={toAttribute(e,t){switch(t){case Boolean:e=e?_:null;break;case Object:case Array:e=null==e?e:JSON.stringify(e)}return e},fromAttribute(e,t){let i=e;switch(t){case Boolean:i=null!==e;break;case Number:i=null===e?null:Number(e);break;case Object:case Array:try{i=JSON.parse(e)}catch(e){i=null}}return i}},A=(e,t)=>t!==e&&(t==t||e==e),C={attribute:!0,type:String,converter:E,reflect:!1,hasChanged:A};class T extends HTMLElement{constructor(){super(),this._$Et=new Map,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Ei=null,this.o()}static addInitializer(e){var t;null!==(t=this.l)&&void 0!==t||(this.l=[]),this.l.push(e)}static get observedAttributes(){this.finalize();const e=[];return this.elementProperties.forEach(((t,i)=>{const n=this._$Eh(i,t);void 0!==n&&(this._$Eu.set(n,i),e.push(n))})),e}static createProperty(e,t=C){if(t.state&&(t.attribute=!1),this.finalize(),this.elementProperties.set(e,t),!t.noAccessor&&!this.prototype.hasOwnProperty(e)){const i="symbol"==typeof e?Symbol():"__"+e,n=this.getPropertyDescriptor(e,i,t);void 0!==n&&Object.defineProperty(this.prototype,e,n)}}static getPropertyDescriptor(e,t,i){return{get(){return this[t]},set(n){const r=this[e];this[t]=n,this.requestUpdate(e,r,i)},configurable:!0,enumerable:!0}}static getPropertyOptions(e){return this.elementProperties.get(e)||C}static finalize(){if(this.hasOwnProperty("finalized"))return!1;this.finalized=!0;const e=Object.getPrototypeOf(this);if(e.finalize(),this.elementProperties=new Map(e.elementProperties),this._$Eu=new Map,this.hasOwnProperty("properties")){const e=this.properties,t=[...Object.getOwnPropertyNames(e),...Object.getOwnPropertySymbols(e)];for(const i of t)this.createProperty(i,e[i])}return this.elementStyles=this.finalizeStyles(this.styles),!0}static finalizeStyles(e){const t=[];if(Array.isArray(e)){const i=new Set(e.flat(1/0).reverse());for(const e of i)t.unshift(b(e))}else void 0!==e&&t.push(b(e));return t}static _$Eh(e,t){const i=t.attribute;return!1===i?void 0:"string"==typeof i?i:"string"==typeof e?e.toLowerCase():void 0}o(){var e;this._$Ep=new Promise((e=>this.enableUpdating=e)),this._$AL=new Map,this._$Em(),this.requestUpdate(),null===(e=this.constructor.l)||void 0===e||e.forEach((e=>e(this)))}addController(e){var t,i;(null!==(t=this._$Eg)&&void 0!==t?t:this._$Eg=[]).push(e),void 0!==this.renderRoot&&this.isConnected&&(null===(i=e.hostConnected)||void 0===i||i.call(e))}removeController(e){var t;null===(t=this._$Eg)||void 0===t||t.splice(this._$Eg.indexOf(e)>>>0,1)}_$Em(){this.constructor.elementProperties.forEach(((e,t)=>{this.hasOwnProperty(t)&&(this._$Et.set(t,this[t]),delete this[t])}))}createRenderRoot(){var e;const t=null!==(e=this.shadowRoot)&&void 0!==e?e:this.attachShadow(this.constructor.shadowRootOptions);return v(t,this.constructor.elementStyles),t}connectedCallback(){var e;void 0===this.renderRoot&&(this.renderRoot=this.createRenderRoot()),this.enableUpdating(!0),null===(e=this._$Eg)||void 0===e||e.forEach((e=>{var t;return null===(t=e.hostConnected)||void 0===t?void 0:t.call(e)}))}enableUpdating(e){}disconnectedCallback(){var e;null===(e=this._$Eg)||void 0===e||e.forEach((e=>{var t;return null===(t=e.hostDisconnected)||void 0===t?void 0:t.call(e)}))}attributeChangedCallback(e,t,i){this._$AK(e,i)}_$ES(e,t,i=C){var n,r;const a=this.constructor._$Eh(e,i);if(void 0!==a&&!0===i.reflect){const o=(null!==(r=null===(n=i.converter)||void 0===n?void 0:n.toAttribute)&&void 0!==r?r:E.toAttribute)(t,i.type);this._$Ei=e,null==o?this.removeAttribute(a):this.setAttribute(a,o),this._$Ei=null}}_$AK(e,t){var i,n,r;const a=this.constructor,o=a._$Eu.get(e);if(void 0!==o&&this._$Ei!==o){const e=a.getPropertyOptions(o),s=e.converter,l=null!==(r=null!==(n=null===(i=s)||void 0===i?void 0:i.fromAttribute)&&void 0!==n?n:"function"==typeof s?s:null)&&void 0!==r?r:E.fromAttribute;this._$Ei=o,this[o]=l(t,e.type),this._$Ei=null}}requestUpdate(e,t,i){let n=!0;void 0!==e&&(((i=i||this.constructor.getPropertyOptions(e)).hasChanged||A)(this[e],t)?(this._$AL.has(e)||this._$AL.set(e,t),!0===i.reflect&&this._$Ei!==e&&(void 0===this._$EC&&(this._$EC=new Map),this._$EC.set(e,i))):n=!1),!this.isUpdatePending&&n&&(this._$Ep=this._$E_())}async _$E_(){this.isUpdatePending=!0;try{await this._$Ep}catch(e){Promise.reject(e)}const e=this.scheduleUpdate();return null!=e&&await e,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){var e;if(!this.isUpdatePending)return;this.hasUpdated,this._$Et&&(this._$Et.forEach(((e,t)=>this[t]=e)),this._$Et=void 0);let t=!1;const i=this._$AL;try{t=this.shouldUpdate(i),t?(this.willUpdate(i),null===(e=this._$Eg)||void 0===e||e.forEach((e=>{var t;return null===(t=e.hostUpdate)||void 0===t?void 0:t.call(e)})),this.update(i)):this._$EU()}catch(e){throw t=!1,this._$EU(),e}t&&this._$AE(i)}willUpdate(e){}_$AE(e){var t;null===(t=this._$Eg)||void 0===t||t.forEach((e=>{var t;return null===(t=e.hostUpdated)||void 0===t?void 0:t.call(e)})),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(e)),this.updated(e)}_$EU(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$Ep}shouldUpdate(e){return!0}update(e){void 0!==this._$EC&&(this._$EC.forEach(((e,t)=>this._$ES(t,this[t],e))),this._$EC=void 0),this._$EU()}updated(e){}firstUpdated(e){}} +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +var S;T.finalized=!0,T.elementProperties=new Map,T.elementStyles=[],T.shadowRootOptions={mode:"open"},null==w||w({ReactiveElement:T}),(null!==(y=globalThis.reactiveElementVersions)&&void 0!==y?y:globalThis.reactiveElementVersions=[]).push("1.3.1");const I=globalThis.trustedTypes,k=I?I.createPolicy("lit-html",{createHTML:e=>e}):void 0,O=`lit$${(Math.random()+"").slice(9)}$`,L="?"+O,z=`<${L}>`,R=document,M=(e="")=>R.createComment(e),$=e=>null===e||"object"!=typeof e&&"function"!=typeof e,F=Array.isArray,D=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,N=/-->/g,P=/>/g,B=/>|[ \n \r](?:([^\s"'>=/]+)([ \n \r]*=[ \n \r]*(?:[^ \n \r"'`<>=]|("|')|))|$)/g,H=/'/g,j=/"/g,V=/^(?:script|style|textarea|title)$/i,U=(e=>(t,...i)=>({_$litType$:e,strings:t,values:i}))(1),G=Symbol.for("lit-noChange"),W=Symbol.for("lit-nothing"),q=new WeakMap,Y=R.createTreeWalker(R,129,null,!1),X=(e,t)=>{const i=e.length-1,n=[];let r,a=2===t?"":"",o=D;for(let t=0;t"===l[0]?(o=null!=r?r:D,d=-1):void 0===l[1]?d=-2:(d=o.lastIndex-l[2].length,s=l[1],o=void 0===l[3]?B:'"'===l[3]?j:H):o===j||o===H?o=B:o===N||o===P?o=D:(o=B,r=void 0);const p=o===B&&e[t+1].startsWith("/>")?" ":"";a+=o===D?i+z:d>=0?(n.push(s),i.slice(0,d)+"$lit$"+i.slice(d)+O+p):i+O+(-2===d?(n.push(void 0),t):p)}const s=a+(e[i]||"")+(2===t?"":"");if(!Array.isArray(e)||!e.hasOwnProperty("raw"))throw Error("invalid template strings array");return[void 0!==k?k.createHTML(s):s,n]};class K{constructor({strings:e,_$litType$:t},i){let n;this.parts=[];let r=0,a=0;const o=e.length-1,s=this.parts,[l,d]=X(e,t);if(this.el=K.createElement(l,i),Y.currentNode=this.el.content,2===t){const e=this.el.content,t=e.firstChild;t.remove(),e.append(...t.childNodes)}for(;null!==(n=Y.nextNode())&&s.length0){n.textContent=I?I.emptyScript:"";for(let i=0;i{var t;return F(e)||"function"==typeof(null===(t=e)||void 0===t?void 0:t[Symbol.iterator])})(e)?this.S(e):this.$(e)}M(e,t=this._$AB){return this._$AA.parentNode.insertBefore(e,t)}k(e){this._$AH!==e&&(this._$AR(),this._$AH=this.M(e))}$(e){this._$AH!==W&&$(this._$AH)?this._$AA.nextSibling.data=e:this.k(R.createTextNode(e)),this._$AH=e}T(e){var t;const{values:i,_$litType$:n}=e,r="number"==typeof n?this._$AC(e):(void 0===n.el&&(n.el=K.createElement(n.h,this.options)),n);if((null===(t=this._$AH)||void 0===t?void 0:t._$AD)===r)this._$AH.m(i);else{const e=new Z(r,this),t=e.p(this.options);e.m(i),this.k(t),this._$AH=e}}_$AC(e){let t=q.get(e.strings);return void 0===t&&q.set(e.strings,t=new K(e)),t}S(e){F(this._$AH)||(this._$AH=[],this._$AR());const t=this._$AH;let i,n=0;for(const r of e)n===t.length?t.push(i=new J(this.M(M()),this.M(M()),this,this.options)):i=t[n],i._$AI(r),n++;n2||""!==i[0]||""!==i[1]?(this._$AH=Array(i.length-1).fill(new String),this.strings=i):this._$AH=W}get tagName(){return this.element.tagName}get _$AU(){return this._$AM._$AU}_$AI(e,t=this,i,n){const r=this.strings;let a=!1;if(void 0===r)e=Q(this,e,t,0),a=!$(e)||e!==this._$AH&&e!==G,a&&(this._$AH=e);else{const n=e;let o,s;for(e=r[0],o=0;o{var n,r;const a=null!==(n=null==i?void 0:i.renderBefore)&&void 0!==n?n:t;let o=a._$litPart$;if(void 0===o){const e=null!==(r=null==i?void 0:i.renderBefore)&&void 0!==r?r:null;a._$litPart$=o=new J(t.insertBefore(M(),e),e,void 0,null!=i?i:{})}return o._$AI(e),o})(t,this.renderRoot,this.renderOptions)}connectedCallback(){var e;super.connectedCallback(),null===(e=this._$Dt)||void 0===e||e.setConnected(!0)}disconnectedCallback(){var e;super.disconnectedCallback(),null===(e=this._$Dt)||void 0===e||e.setConnected(!1)}render(){return G}}de.finalized=!0,de._$litElement$=!0,null===(se=globalThis.litElementHydrateSupport)||void 0===se||se.call(globalThis,{LitElement:de});const ce=globalThis.litElementPolyfillSupport;null==ce||ce({LitElement:de}),(null!==(le=globalThis.litElementVersions)&&void 0!==le?le:globalThis.litElementVersions=[]).push("3.2.0"); +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const pe=e=>t=>"function"==typeof t?((e,t)=>(window.customElements.define(e,t),t))(e,t):((e,t)=>{const{kind:i,elements:n}=t;return{kind:i,elements:n,finisher(t){window.customElements.define(e,t)}}})(e,t) +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */,me=(e,t)=>"method"===t.kind&&t.descriptor&&!("value"in t.descriptor)?{...t,finisher(i){i.createProperty(t.key,e)}}:{kind:"field",key:Symbol(),placement:"own",descriptor:{},originalKey:t.key,initializer(){"function"==typeof t.initializer&&(this[t.key]=t.initializer.call(this))},finisher(i){i.createProperty(t.key,e)}};function he(e){return(t,i)=>void 0!==i?((e,t,i)=>{t.constructor.createProperty(i,e)})(e,t,i):me(e,t) +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */}function ue(e){return he({...e,state:!0})} +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */const fe=({finisher:e,descriptor:t})=>(i,n)=>{var r;if(void 0===n){const n=null!==(r=i.originalKey)&&void 0!==r?r:i.key,a=null!=t?{kind:"method",placement:"prototype",key:n,descriptor:t(i.key)}:{...i,key:n};return null!=e&&(a.finisher=function(t){e(t,n)}),a}{const r=i.constructor;void 0!==t&&Object.defineProperty(i,n,t(n)),null==e||e(r,n)}} +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */;function ge(e){return fe({finisher:(t,i)=>{Object.assign(t.prototype[i],e)}})} +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */function ve(e,t){return fe({descriptor:i=>{const n={get(){var t,i;return null!==(i=null===(t=this.renderRoot)||void 0===t?void 0:t.querySelector(e))&&void 0!==i?i:null},enumerable:!0,configurable:!0};if(t){const t="symbol"==typeof i?Symbol():"__"+i;n.get=function(){var i,n;return void 0===this[t]&&(this[t]=null!==(n=null===(i=this.renderRoot)||void 0===i?void 0:i.querySelector(e))&&void 0!==n?n:null),this[t]}}return n}})} +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */function be(e){return fe({descriptor:t=>({async get(){var t;return await this.updateComplete,null===(t=this.renderRoot)||void 0===t?void 0:t.querySelector(e)},enumerable:!0,configurable:!0})})} +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */var ye;const xe=null!=(null===(ye=window.HTMLSlotElement)||void 0===ye?void 0:ye.prototype.assignedElements)?(e,t)=>e.assignedElements(t):(e,t)=>e.assignedNodes(t).filter((e=>e.nodeType===Node.ELEMENT_NODE)); +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +function _e(e,t,i){let n,r=e;return"object"==typeof e?(r=e.slot,n=e):n={flatten:t},i?function(e){const{slot:t,selector:i}=null!=e?e:{};return fe({descriptor:n=>({get(){var n;const r="slot"+(t?`[name=${t}]`:":not([name])"),a=null===(n=this.renderRoot)||void 0===n?void 0:n.querySelector(r),o=null!=a?xe(a,e):[];return i?o.filter((e=>e.matches(i))):o},enumerable:!0,configurable:!0})})}({slot:r,flatten:t,selector:i}):fe({descriptor:e=>({get(){var e,t;const i="slot"+(r?`[name=${r}]`:":not([name])"),a=null===(e=this.renderRoot)||void 0===e?void 0:e.querySelector(i);return null!==(t=null==a?void 0:a.assignedNodes(n))&&void 0!==t?t:[]},enumerable:!0,configurable:!0})})} +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */const we=e=>null!=e?e:W;var Ee=function(){if("undefined"!=typeof Map)return Map;function e(e,t){var i=-1;return e.some((function(e,n){return e[0]===t&&(i=n,!0)})),i}return function(){function t(){this.__entries__=[]}return Object.defineProperty(t.prototype,"size",{get:function(){return this.__entries__.length},enumerable:!0,configurable:!0}),t.prototype.get=function(t){var i=e(this.__entries__,t),n=this.__entries__[i];return n&&n[1]},t.prototype.set=function(t,i){var n=e(this.__entries__,t);~n?this.__entries__[n][1]=i:this.__entries__.push([t,i])},t.prototype.delete=function(t){var i=this.__entries__,n=e(i,t);~n&&i.splice(n,1)},t.prototype.has=function(t){return!!~e(this.__entries__,t)},t.prototype.clear=function(){this.__entries__.splice(0)},t.prototype.forEach=function(e,t){void 0===t&&(t=null);for(var i=0,n=this.__entries__;i0},e.prototype.connect_=function(){Ae&&!this.connected_&&(document.addEventListener("transitionend",this.onTransitionEnd_),window.addEventListener("resize",this.refresh),Ie?(this.mutationsObserver_=new MutationObserver(this.refresh),this.mutationsObserver_.observe(document,{attributes:!0,childList:!0,characterData:!0,subtree:!0})):(document.addEventListener("DOMSubtreeModified",this.refresh),this.mutationEventsAdded_=!0),this.connected_=!0)},e.prototype.disconnect_=function(){Ae&&this.connected_&&(document.removeEventListener("transitionend",this.onTransitionEnd_),window.removeEventListener("resize",this.refresh),this.mutationsObserver_&&this.mutationsObserver_.disconnect(),this.mutationEventsAdded_&&document.removeEventListener("DOMSubtreeModified",this.refresh),this.mutationsObserver_=null,this.mutationEventsAdded_=!1,this.connected_=!1)},e.prototype.onTransitionEnd_=function(e){var t=e.propertyName,i=void 0===t?"":t;Se.some((function(e){return!!~i.indexOf(e)}))&&this.refresh()},e.getInstance=function(){return this.instance_||(this.instance_=new e),this.instance_},e.instance_=null,e}(),Oe=function(e,t){for(var i=0,n=Object.keys(t);i0},e}(),je="undefined"!=typeof WeakMap?new WeakMap:new Ee,Ve=function e(t){if(!(this instanceof e))throw new TypeError("Cannot call a class as a function.");if(!arguments.length)throw new TypeError("1 argument required, but only 0 present.");var i=ke.getInstance(),n=new He(t,i,this);je.set(this,n)};["observe","unobserve","disconnect"].forEach((function(e){Ve.prototype[e]=function(){var t;return(t=je.get(this))[e].apply(t,arguments)}}));var Ue=void 0!==Ce.ResizeObserver?Ce.ResizeObserver:Ve;function Ge(e){return null!==e&&"object"==typeof e&&"constructor"in e&&e.constructor===Object}function We(e={},t={}){Object.keys(t).forEach((i=>{void 0===e[i]?e[i]=t[i]:Ge(t[i])&&Ge(e[i])&&Object.keys(t[i]).length>0&&We(e[i],t[i])}))}const qe={body:{},addEventListener(){},removeEventListener(){},activeElement:{blur(){},nodeName:""},querySelector:()=>null,querySelectorAll:()=>[],getElementById:()=>null,createEvent:()=>({initEvent(){}}),createElement:()=>({children:[],childNodes:[],style:{},setAttribute(){},getElementsByTagName:()=>[]}),createElementNS:()=>({}),importNode:()=>null,location:{hash:"",host:"",hostname:"",href:"",origin:"",pathname:"",protocol:"",search:""}};function Ye(){const e="undefined"!=typeof document?document:{};return We(e,qe),e}const Xe={document:qe,navigator:{userAgent:""},location:{hash:"",host:"",hostname:"",href:"",origin:"",pathname:"",protocol:"",search:""},history:{replaceState(){},pushState(){},go(){},back(){}},CustomEvent:function(){return this},addEventListener(){},removeEventListener(){},getComputedStyle:()=>({getPropertyValue:()=>""}),Image(){},Date(){},screen:{},setTimeout(){},clearTimeout(){},matchMedia:()=>({}),requestAnimationFrame:e=>"undefined"==typeof setTimeout?(e(),null):setTimeout(e,0),cancelAnimationFrame(e){"undefined"!=typeof setTimeout&&clearTimeout(e)}};function Ke(){const e="undefined"!=typeof window?window:{};return We(e,Xe),e}class Qe extends Array{constructor(e){"number"==typeof e?super(e):(super(...e||[]),function(e){const t=e.__proto__;Object.defineProperty(e,"__proto__",{get:()=>t,set(e){t.__proto__=e}})}(this))}}function Ze(e=[]){const t=[];return e.forEach((e=>{Array.isArray(e)?t.push(...Ze(e)):t.push(e)})),t}function Je(e,t){return Array.prototype.filter.call(e,t)}function et(e,t){const i=Ke(),n=Ye();let r=[];if(!t&&e instanceof Qe)return e;if(!e)return new Qe(r);if("string"==typeof e){const i=e.trim();if(i.indexOf("<")>=0&&i.indexOf(">")>=0){let e="div";0===i.indexOf("e.split(" "))));return this.forEach((e=>{e.classList.add(...t)})),this},removeClass:function(...e){const t=Ze(e.map((e=>e.split(" "))));return this.forEach((e=>{e.classList.remove(...t)})),this},hasClass:function(...e){const t=Ze(e.map((e=>e.split(" "))));return Je(this,(e=>t.filter((t=>e.classList.contains(t))).length>0)).length>0},toggleClass:function(...e){const t=Ze(e.map((e=>e.split(" "))));this.forEach((e=>{t.forEach((t=>{e.classList.toggle(t)}))}))},attr:function(e,t){if(1===arguments.length&&"string"==typeof e)return this[0]?this[0].getAttribute(e):void 0;for(let i=0;i=0;e-=1){const i=o[e];n&&i.listener===n||n&&i.listener&&i.listener.dom7proxy&&i.listener.dom7proxy===n?(a.removeEventListener(t,i.proxyListener,r),o.splice(e,1)):n||(a.removeEventListener(t,i.proxyListener,r),o.splice(e,1))}}}return this},trigger:function(...e){const t=Ke(),i=e[0].split(" "),n=e[1];for(let r=0;rt>0)),r.dispatchEvent(i),r.dom7EventData=[],delete r.dom7EventData}}}return this},transitionEnd:function(e){const t=this;return e&&t.on("transitionend",(function i(n){n.target===this&&(e.call(this,n),t.off("transitionend",i))})),this},outerWidth:function(e){if(this.length>0){if(e){const e=this.styles();return this[0].offsetWidth+parseFloat(e.getPropertyValue("margin-right"))+parseFloat(e.getPropertyValue("margin-left"))}return this[0].offsetWidth}return null},outerHeight:function(e){if(this.length>0){if(e){const e=this.styles();return this[0].offsetHeight+parseFloat(e.getPropertyValue("margin-top"))+parseFloat(e.getPropertyValue("margin-bottom"))}return this[0].offsetHeight}return null},styles:function(){const e=Ke();return this[0]?e.getComputedStyle(this[0],null):{}},offset:function(){if(this.length>0){const e=Ke(),t=Ye(),i=this[0],n=i.getBoundingClientRect(),r=t.body,a=i.clientTop||r.clientTop||0,o=i.clientLeft||r.clientLeft||0,s=i===e?e.scrollY:i.scrollTop,l=i===e?e.scrollX:i.scrollLeft;return{top:n.top+s-a,left:n.left+l-o}}return null},css:function(e,t){const i=Ke();let n;if(1===arguments.length){if("string"!=typeof e){for(n=0;n{e.apply(t,[t,i])})),this):this},html:function(e){if(void 0===e)return this[0]?this[0].innerHTML:null;for(let t=0;tt-1)return et([]);if(e<0){const i=t+e;return et(i<0?[]:[this[i]])}return et([this[e]])},append:function(...e){let t;const i=Ye();for(let n=0;n=0;n-=1)this[i].insertBefore(r.childNodes[n],this[i].childNodes[0])}else if(e instanceof Qe)for(n=0;n0?e?this[0].nextElementSibling&&et(this[0].nextElementSibling).is(e)?et([this[0].nextElementSibling]):et([]):this[0].nextElementSibling?et([this[0].nextElementSibling]):et([]):et([])},nextAll:function(e){const t=[];let i=this[0];if(!i)return et([]);for(;i.nextElementSibling;){const n=i.nextElementSibling;e?et(n).is(e)&&t.push(n):t.push(n),i=n}return et(t)},prev:function(e){if(this.length>0){const t=this[0];return e?t.previousElementSibling&&et(t.previousElementSibling).is(e)?et([t.previousElementSibling]):et([]):t.previousElementSibling?et([t.previousElementSibling]):et([])}return et([])},prevAll:function(e){const t=[];let i=this[0];if(!i)return et([]);for(;i.previousElementSibling;){const n=i.previousElementSibling;e?et(n).is(e)&&t.push(n):t.push(n),i=n}return et(t)},parent:function(e){const t=[];for(let i=0;i6&&(r=r.split(", ").map((e=>e.replace(",","."))).join(", ")),a=new i.WebKitCSSMatrix("none"===r?"":r)):(a=o.MozTransform||o.OTransform||o.MsTransform||o.msTransform||o.transform||o.getPropertyValue("transform").replace("translate(","matrix(1, 0, 0, 1,"),n=a.toString().split(",")),"x"===t&&(r=i.WebKitCSSMatrix?a.m41:16===n.length?parseFloat(n[12]):parseFloat(n[4])),"y"===t&&(r=i.WebKitCSSMatrix?a.m42:16===n.length?parseFloat(n[13]):parseFloat(n[5])),r||0}function at(e){return"object"==typeof e&&null!==e&&e.constructor&&"Object"===Object.prototype.toString.call(e).slice(8,-1)}function ot(e){return"undefined"!=typeof window&&void 0!==window.HTMLElement?e instanceof HTMLElement:e&&(1===e.nodeType||11===e.nodeType)}function st(){const e=Object(arguments.length<=0?void 0:arguments[0]),t=["__proto__","constructor","prototype"];for(let i=1;it.indexOf(e)<0));for(let t=0,r=i.length;ta?"next":"prev",c=(e,t)=>"next"===d&&e>=t||"prev"===d&&e<=t,p=()=>{o=(new Date).getTime(),null===s&&(s=o);const e=Math.max(Math.min((o-s)/l,1),0),d=.5-Math.cos(e*Math.PI)/2;let m=a+d*(i-a);if(c(m,i)&&(m=i),t.wrapperEl.scrollTo({[n]:m}),c(m,i))return t.wrapperEl.style.overflow="hidden",t.wrapperEl.style.scrollSnapType="",setTimeout((()=>{t.wrapperEl.style.overflow="",t.wrapperEl.scrollTo({[n]:m})})),void r.cancelAnimationFrame(t.cssModeFrameID);t.cssModeFrameID=r.requestAnimationFrame(p)};p()}let ct,pt,mt;function ht(){return ct||(ct=function(){const e=Ke(),t=Ye();return{smoothScroll:t.documentElement&&"scrollBehavior"in t.documentElement.style,touch:!!("ontouchstart"in e||e.DocumentTouch&&t instanceof e.DocumentTouch),passiveListener:function(){let t=!1;try{const i=Object.defineProperty({},"passive",{get(){t=!0}});e.addEventListener("testPassiveListener",null,i)}catch(e){}return t}(),gestures:"ongesturestart"in e}}()),ct}function ut(e){return void 0===e&&(e={}),pt||(pt=function(e){let{userAgent:t}=void 0===e?{}:e;const i=ht(),n=Ke(),r=n.navigator.platform,a=t||n.navigator.userAgent,o={ios:!1,android:!1},s=n.screen.width,l=n.screen.height,d=a.match(/(Android);?[\s\/]+([\d.]+)?/);let c=a.match(/(iPad).*OS\s([\d_]+)/);const p=a.match(/(iPod)(.*OS\s([\d_]+))?/),m=!c&&a.match(/(iPhone\sOS|iOS)\s([\d_]+)/),h="Win32"===r;let u="MacIntel"===r;return!c&&u&&i.touch&&["1024x1366","1366x1024","834x1194","1194x834","834x1112","1112x834","768x1024","1024x768","820x1180","1180x820","810x1080","1080x810"].indexOf(`${s}x${l}`)>=0&&(c=a.match(/(Version)\/([\d.]+)/),c||(c=[0,1,"13_0_0"]),u=!1),d&&!h&&(o.os="android",o.android=!0),(c||m||p)&&(o.os="ios",o.ios=!0),o}(e)),pt}function ft(){return mt||(mt=function(){const e=Ke();return{isSafari:function(){const t=e.navigator.userAgent.toLowerCase();return t.indexOf("safari")>=0&&t.indexOf("chrome")<0&&t.indexOf("android")<0}(),isWebView:/(iPhone|iPod|iPad).*AppleWebKit(?!.*Safari)/i.test(e.navigator.userAgent)}}()),mt}Object.keys(tt).forEach((e=>{Object.defineProperty(et.fn,e,{value:tt[e],writable:!0})}));var gt={on(e,t,i){const n=this;if(!n.eventsListeners||n.destroyed)return n;if("function"!=typeof t)return n;const r=i?"unshift":"push";return e.split(" ").forEach((e=>{n.eventsListeners[e]||(n.eventsListeners[e]=[]),n.eventsListeners[e][r](t)})),n},once(e,t,i){const n=this;if(!n.eventsListeners||n.destroyed)return n;if("function"!=typeof t)return n;function r(){n.off(e,r),r.__emitterProxy&&delete r.__emitterProxy;for(var i=arguments.length,a=new Array(i),o=0;o=0&&t.eventsAnyListeners.splice(i,1),t},off(e,t){const i=this;return!i.eventsListeners||i.destroyed?i:i.eventsListeners?(e.split(" ").forEach((e=>{void 0===t?i.eventsListeners[e]=[]:i.eventsListeners[e]&&i.eventsListeners[e].forEach(((n,r)=>{(n===t||n.__emitterProxy&&n.__emitterProxy===t)&&i.eventsListeners[e].splice(r,1)}))})),i):i},emit(){const e=this;if(!e.eventsListeners||e.destroyed)return e;if(!e.eventsListeners)return e;let t,i,n;for(var r=arguments.length,a=new Array(r),o=0;o{e.eventsAnyListeners&&e.eventsAnyListeners.length&&e.eventsAnyListeners.forEach((e=>{e.apply(n,[t,...i])})),e.eventsListeners&&e.eventsListeners[t]&&e.eventsListeners[t].forEach((e=>{e.apply(n,i)}))})),e}};var vt={updateSize:function(){const e=this;let t,i;const n=e.$el;t=void 0!==e.params.width&&null!==e.params.width?e.params.width:n[0].clientWidth,i=void 0!==e.params.height&&null!==e.params.height?e.params.height:n[0].clientHeight,0===t&&e.isHorizontal()||0===i&&e.isVertical()||(t=t-parseInt(n.css("padding-left")||0,10)-parseInt(n.css("padding-right")||0,10),i=i-parseInt(n.css("padding-top")||0,10)-parseInt(n.css("padding-bottom")||0,10),Number.isNaN(t)&&(t=0),Number.isNaN(i)&&(i=0),Object.assign(e,{width:t,height:i,size:e.isHorizontal()?t:i}))},updateSlides:function(){const e=this;function t(t){return e.isHorizontal()?t:{width:"height","margin-top":"margin-left","margin-bottom ":"margin-right","margin-left":"margin-top","margin-right":"margin-bottom","padding-left":"padding-top","padding-right":"padding-bottom",marginRight:"marginBottom"}[t]}function i(e,i){return parseFloat(e.getPropertyValue(t(i))||0)}const n=e.params,{$wrapperEl:r,size:a,rtlTranslate:o,wrongRTL:s}=e,l=e.virtual&&n.virtual.enabled,d=l?e.virtual.slides.length:e.slides.length,c=r.children(`.${e.params.slideClass}`),p=l?e.virtual.slides.length:c.length;let m=[];const h=[],u=[];let f=n.slidesOffsetBefore;"function"==typeof f&&(f=n.slidesOffsetBefore.call(e));let g=n.slidesOffsetAfter;"function"==typeof g&&(g=n.slidesOffsetAfter.call(e));const v=e.snapGrid.length,b=e.slidesGrid.length;let y=n.spaceBetween,x=-f,_=0,w=0;if(void 0===a)return;"string"==typeof y&&y.indexOf("%")>=0&&(y=parseFloat(y.replace("%",""))/100*a),e.virtualSize=-y,o?c.css({marginLeft:"",marginBottom:"",marginTop:""}):c.css({marginRight:"",marginBottom:"",marginTop:""}),n.centeredSlides&&n.cssMode&&(lt(e.wrapperEl,"--swiper-centered-offset-before",""),lt(e.wrapperEl,"--swiper-centered-offset-after",""));const E=n.grid&&n.grid.rows>1&&e.grid;let A;E&&e.grid.initSlides(p);const C="auto"===n.slidesPerView&&n.breakpoints&&Object.keys(n.breakpoints).filter((e=>void 0!==n.breakpoints[e].slidesPerView)).length>0;for(let r=0;r1&&m.push(e.virtualSize-a)}if(0===m.length&&(m=[0]),0!==n.spaceBetween){const i=e.isHorizontal()&&o?"marginLeft":t("marginRight");c.filter(((e,t)=>!n.cssMode||t!==c.length-1)).css({[i]:`${y}px`})}if(n.centeredSlides&&n.centeredSlidesBounds){let e=0;u.forEach((t=>{e+=t+(n.spaceBetween?n.spaceBetween:0)})),e-=n.spaceBetween;const t=e-a;m=m.map((e=>e<0?-f:e>t?t+g:e))}if(n.centerInsufficientSlides){let e=0;if(u.forEach((t=>{e+=t+(n.spaceBetween?n.spaceBetween:0)})),e-=n.spaceBetween,e{m[i]=e-t})),h.forEach(((e,i)=>{h[i]=e+t}))}}if(Object.assign(e,{slides:c,snapGrid:m,slidesGrid:h,slidesSizesGrid:u}),n.centeredSlides&&n.cssMode&&!n.centeredSlidesBounds){lt(e.wrapperEl,"--swiper-centered-offset-before",-m[0]+"px"),lt(e.wrapperEl,"--swiper-centered-offset-after",e.size/2-u[u.length-1]/2+"px");const t=-e.snapGrid[0],i=-e.slidesGrid[0];e.snapGrid=e.snapGrid.map((e=>e+t)),e.slidesGrid=e.slidesGrid.map((e=>e+i))}if(p!==d&&e.emit("slidesLengthChange"),m.length!==v&&(e.params.watchOverflow&&e.checkOverflow(),e.emit("snapGridLengthChange")),h.length!==b&&e.emit("slidesGridLengthChange"),n.watchSlidesProgress&&e.updateSlidesOffset(),!(l||n.cssMode||"slide"!==n.effect&&"fade"!==n.effect)){const t=`${n.containerModifierClass}backface-hidden`,i=e.$el.hasClass(t);p<=n.maxBackfaceHiddenSlides?i||e.$el.addClass(t):i&&e.$el.removeClass(t)}},updateAutoHeight:function(e){const t=this,i=[],n=t.virtual&&t.params.virtual.enabled;let r,a=0;"number"==typeof e?t.setTransition(e):!0===e&&t.setTransition(t.params.speed);const o=e=>n?t.slides.filter((t=>parseInt(t.getAttribute("data-swiper-slide-index"),10)===e))[0]:t.slides.eq(e)[0];if("auto"!==t.params.slidesPerView&&t.params.slidesPerView>1)if(t.params.centeredSlides)(t.visibleSlides||et([])).each((e=>{i.push(e)}));else for(r=0;rt.slides.length&&!n)break;i.push(o(e))}else i.push(o(t.activeIndex));for(r=0;ra?e:a}(a||0===a)&&t.$wrapperEl.css("height",`${a}px`)},updateSlidesOffset:function(){const e=this,t=e.slides;for(let i=0;i=0&&p1&&m<=t.size||p<=0&&m>=t.size)&&(t.visibleSlides.push(s),t.visibleSlidesIndexes.push(e),n.eq(e).addClass(i.slideVisibleClass)),s.progress=r?-d:d,s.originalProgress=r?-c:c}t.visibleSlides=et(t.visibleSlides)},updateProgress:function(e){const t=this;if(void 0===e){const i=t.rtlTranslate?-1:1;e=t&&t.translate&&t.translate*i||0}const i=t.params,n=t.maxTranslate()-t.minTranslate();let{progress:r,isBeginning:a,isEnd:o}=t;const s=a,l=o;0===n?(r=0,a=!0,o=!0):(r=(e-t.minTranslate())/n,a=r<=0,o=r>=1),Object.assign(t,{progress:r,isBeginning:a,isEnd:o}),(i.watchSlidesProgress||i.centeredSlides&&i.autoHeight)&&t.updateSlidesProgress(e),a&&!s&&t.emit("reachBeginning toEdge"),o&&!l&&t.emit("reachEnd toEdge"),(s&&!a||l&&!o)&&t.emit("fromEdge"),t.emit("progress",r)},updateSlidesClasses:function(){const e=this,{slides:t,params:i,$wrapperEl:n,activeIndex:r,realIndex:a}=e,o=e.virtual&&i.virtual.enabled;let s;t.removeClass(`${i.slideActiveClass} ${i.slideNextClass} ${i.slidePrevClass} ${i.slideDuplicateActiveClass} ${i.slideDuplicateNextClass} ${i.slideDuplicatePrevClass}`),s=o?e.$wrapperEl.find(`.${i.slideClass}[data-swiper-slide-index="${r}"]`):t.eq(r),s.addClass(i.slideActiveClass),i.loop&&(s.hasClass(i.slideDuplicateClass)?n.children(`.${i.slideClass}:not(.${i.slideDuplicateClass})[data-swiper-slide-index="${a}"]`).addClass(i.slideDuplicateActiveClass):n.children(`.${i.slideClass}.${i.slideDuplicateClass}[data-swiper-slide-index="${a}"]`).addClass(i.slideDuplicateActiveClass));let l=s.nextAll(`.${i.slideClass}`).eq(0).addClass(i.slideNextClass);i.loop&&0===l.length&&(l=t.eq(0),l.addClass(i.slideNextClass));let d=s.prevAll(`.${i.slideClass}`).eq(0).addClass(i.slidePrevClass);i.loop&&0===d.length&&(d=t.eq(-1),d.addClass(i.slidePrevClass)),i.loop&&(l.hasClass(i.slideDuplicateClass)?n.children(`.${i.slideClass}:not(.${i.slideDuplicateClass})[data-swiper-slide-index="${l.attr("data-swiper-slide-index")}"]`).addClass(i.slideDuplicateNextClass):n.children(`.${i.slideClass}.${i.slideDuplicateClass}[data-swiper-slide-index="${l.attr("data-swiper-slide-index")}"]`).addClass(i.slideDuplicateNextClass),d.hasClass(i.slideDuplicateClass)?n.children(`.${i.slideClass}:not(.${i.slideDuplicateClass})[data-swiper-slide-index="${d.attr("data-swiper-slide-index")}"]`).addClass(i.slideDuplicatePrevClass):n.children(`.${i.slideClass}.${i.slideDuplicateClass}[data-swiper-slide-index="${d.attr("data-swiper-slide-index")}"]`).addClass(i.slideDuplicatePrevClass)),e.emitSlidesClasses()},updateActiveIndex:function(e){const t=this,i=t.rtlTranslate?t.translate:-t.translate,{slidesGrid:n,snapGrid:r,params:a,activeIndex:o,realIndex:s,snapIndex:l}=t;let d,c=e;if(void 0===c){for(let e=0;e=n[e]&&i=n[e]&&i=n[e]&&(c=e);a.normalizeSlideIndex&&(c<0||void 0===c)&&(c=0)}if(r.indexOf(i)>=0)d=r.indexOf(i);else{const e=Math.min(a.slidesPerGroupSkip,c);d=e+Math.floor((c-e)/a.slidesPerGroup)}if(d>=r.length&&(d=r.length-1),c===o)return void(d!==l&&(t.snapIndex=d,t.emit("snapIndexChange")));const p=parseInt(t.slides.eq(c).attr("data-swiper-slide-index")||c,10);Object.assign(t,{snapIndex:d,realIndex:p,previousIndex:o,activeIndex:c}),t.emit("activeIndexChange"),t.emit("snapIndexChange"),s!==p&&t.emit("realIndexChange"),(t.initialized||t.params.runCallbacksOnInit)&&t.emit("slideChange")},updateClickedSlide:function(e){const t=this,i=t.params,n=et(e).closest(`.${i.slideClass}`)[0];let r,a=!1;if(n)for(let e=0;el?l:n&&eo?"next":a=l.length&&(g=l.length-1),(p||s.initialSlide||0)===(c||0)&&i&&a.emit("beforeSlideChangeStart");const v=-l[g];if(a.updateProgress(v),s.normalizeSlideIndex)for(let e=0;e=i&&t=i&&t=i&&(o=e)}if(a.initialized&&o!==p){if(!a.allowSlideNext&&va.translate&&v>a.maxTranslate()&&(p||0)!==o)return!1}let b;if(b=o>p?"next":o{a.wrapperEl.style.scrollSnapType="",a._swiperImmediateVirtual=!1}))}else{if(!a.support.smoothScroll)return dt({swiper:a,targetPosition:i,side:e?"left":"top"}),!0;h.scrollTo({[e?"left":"top"]:i,behavior:"smooth"})}return!0}return a.setTransition(t),a.setTranslate(v),a.updateActiveIndex(o),a.updateSlidesClasses(),a.emit("beforeTransitionStart",t,n),a.transitionStart(i,b),0===t?a.transitionEnd(i,b):a.animating||(a.animating=!0,a.onSlideToWrapperTransitionEnd||(a.onSlideToWrapperTransitionEnd=function(e){a&&!a.destroyed&&e.target===this&&(a.$wrapperEl[0].removeEventListener("transitionend",a.onSlideToWrapperTransitionEnd),a.$wrapperEl[0].removeEventListener("webkitTransitionEnd",a.onSlideToWrapperTransitionEnd),a.onSlideToWrapperTransitionEnd=null,delete a.onSlideToWrapperTransitionEnd,a.transitionEnd(i,b))}),a.$wrapperEl[0].addEventListener("transitionend",a.onSlideToWrapperTransitionEnd),a.$wrapperEl[0].addEventListener("webkitTransitionEnd",a.onSlideToWrapperTransitionEnd)),!0},slideToLoop:function(e,t,i,n){if(void 0===e&&(e=0),void 0===t&&(t=this.params.speed),void 0===i&&(i=!0),"string"==typeof e){const t=parseInt(e,10);if(!isFinite(t))throw new Error(`The passed-in 'index' (string) couldn't be converted to 'number'. [${e}] given.`);e=t}const r=this;let a=e;return r.params.loop&&(a+=r.loopedSlides),r.slideTo(a,t,i,n)},slideNext:function(e,t,i){void 0===e&&(e=this.params.speed),void 0===t&&(t=!0);const n=this,{animating:r,enabled:a,params:o}=n;if(!a)return n;let s=o.slidesPerGroup;"auto"===o.slidesPerView&&1===o.slidesPerGroup&&o.slidesPerGroupAuto&&(s=Math.max(n.slidesPerViewDynamic("current",!0),1));const l=n.activeIndexc(e)));let h=o[m.indexOf(p)-1];if(void 0===h&&r.cssMode){let e;o.forEach(((t,i)=>{p>=t&&(e=i)})),void 0!==e&&(h=o[e>0?e-1:e])}let u=0;if(void 0!==h&&(u=s.indexOf(h),u<0&&(u=n.activeIndex-1),"auto"===r.slidesPerView&&1===r.slidesPerGroup&&r.slidesPerGroupAuto&&(u=u-n.slidesPerViewDynamic("previous",!0)+1,u=Math.max(u,0))),r.rewind&&n.isBeginning){const r=n.params.virtual&&n.params.virtual.enabled&&n.virtual?n.virtual.slides.length-1:n.slides.length-1;return n.slideTo(r,e,t,i)}return n.slideTo(u,e,t,i)},slideReset:function(e,t,i){return void 0===e&&(e=this.params.speed),void 0===t&&(t=!0),this.slideTo(this.activeIndex,e,t,i)},slideToClosest:function(e,t,i,n){void 0===e&&(e=this.params.speed),void 0===t&&(t=!0),void 0===n&&(n=.5);const r=this;let a=r.activeIndex;const o=Math.min(r.params.slidesPerGroupSkip,a),s=o+Math.floor((a-o)/r.params.slidesPerGroup),l=r.rtlTranslate?r.translate:-r.translate;if(l>=r.snapGrid[s]){const e=r.snapGrid[s];l-e>(r.snapGrid[s+1]-e)*n&&(a+=r.params.slidesPerGroup)}else{const e=r.snapGrid[s-1];l-e<=(r.snapGrid[s]-e)*n&&(a-=r.params.slidesPerGroup)}return a=Math.max(a,0),a=Math.min(a,r.slidesGrid.length-1),r.slideTo(a,e,t,i)},slideToClickedSlide:function(){const e=this,{params:t,$wrapperEl:i}=e,n="auto"===t.slidesPerView?e.slidesPerViewDynamic():t.slidesPerView;let r,a=e.clickedIndex;if(t.loop){if(e.animating)return;r=parseInt(et(e.clickedSlide).attr("data-swiper-slide-index"),10),t.centeredSlides?ae.slides.length-e.loopedSlides+n/2?(e.loopFix(),a=i.children(`.${t.slideClass}[data-swiper-slide-index="${r}"]:not(.${t.slideDuplicateClass})`).eq(0).index(),it((()=>{e.slideTo(a)}))):e.slideTo(a):a>e.slides.length-n?(e.loopFix(),a=i.children(`.${t.slideClass}[data-swiper-slide-index="${r}"]:not(.${t.slideDuplicateClass})`).eq(0).index(),it((()=>{e.slideTo(a)}))):e.slideTo(a)}else e.slideTo(a)}};var _t={loopCreate:function(){const e=this,t=Ye(),{params:i,$wrapperEl:n}=e,r=n.children().length>0?et(n.children()[0].parentNode):n;r.children(`.${i.slideClass}.${i.slideDuplicateClass}`).remove();let a=r.children(`.${i.slideClass}`);if(i.loopFillGroupWithBlank){const e=i.slidesPerGroup-a.length%i.slidesPerGroup;if(e!==i.slidesPerGroup){for(let n=0;na.length&&(e.loopedSlides=a.length);const o=[],s=[];a.each(((t,i)=>{const n=et(t);i=a.length-e.loopedSlides&&o.push(t),n.attr("data-swiper-slide-index",i)}));for(let e=0;e=0;e-=1)r.prepend(et(o[e].cloneNode(!0)).addClass(i.slideDuplicateClass))},loopFix:function(){const e=this;e.emit("beforeLoopFix");const{activeIndex:t,slides:i,loopedSlides:n,allowSlidePrev:r,allowSlideNext:a,snapGrid:o,rtlTranslate:s}=e;let l;e.allowSlidePrev=!0,e.allowSlideNext=!0;const d=-o[t]-e.getTranslate();if(t=i.length-n){l=-i.length+t+n,l+=n;e.slideTo(l,0,!1,!0)&&0!==d&&e.setTranslate((s?-e.translate:e.translate)-d)}e.allowSlidePrev=r,e.allowSlideNext=a,e.emit("loopFix")},loopDestroy:function(){const{$wrapperEl:e,params:t,slides:i}=this;e.children(`.${t.slideClass}.${t.slideDuplicateClass},.${t.slideClass}.${t.slideBlankClass}`).remove(),i.removeAttr("data-swiper-slide-index")}};function wt(e){const t=this,i=Ye(),n=Ke(),r=t.touchEventsData,{params:a,touches:o,enabled:s}=t;if(!s)return;if(t.animating&&a.preventInteractionOnTransition)return;!t.animating&&a.cssMode&&a.loop&&t.loopFix();let l=e;l.originalEvent&&(l=l.originalEvent);let d=et(l.target);if("wrapper"===a.touchEventsTarget&&!d.closest(t.wrapperEl).length)return;if(r.isTouchEvent="touchstart"===l.type,!r.isTouchEvent&&"which"in l&&3===l.which)return;if(!r.isTouchEvent&&"button"in l&&l.button>0)return;if(r.isTouched&&r.isMoved)return;!!a.noSwipingClass&&""!==a.noSwipingClass&&l.target&&l.target.shadowRoot&&e.path&&e.path[0]&&(d=et(e.path[0]));const c=a.noSwipingSelector?a.noSwipingSelector:`.${a.noSwipingClass}`,p=!(!l.target||!l.target.shadowRoot);if(a.noSwiping&&(p?function(e,t){return void 0===t&&(t=this),function t(i){if(!i||i===Ye()||i===Ke())return null;i.assignedSlot&&(i=i.assignedSlot);const n=i.closest(e);return n||i.getRootNode?n||t(i.getRootNode().host):null}(t)}(c,d[0]):d.closest(c)[0]))return void(t.allowClick=!0);if(a.swipeHandler&&!d.closest(a.swipeHandler)[0])return;o.currentX="touchstart"===l.type?l.targetTouches[0].pageX:l.pageX,o.currentY="touchstart"===l.type?l.targetTouches[0].pageY:l.pageY;const m=o.currentX,h=o.currentY,u=a.edgeSwipeDetection||a.iOSEdgeSwipeDetection,f=a.edgeSwipeThreshold||a.iOSEdgeSwipeThreshold;if(u&&(m<=f||m>=n.innerWidth-f)){if("prevent"!==u)return;e.preventDefault()}if(Object.assign(r,{isTouched:!0,isMoved:!1,allowTouchCallbacks:!0,isScrolling:void 0,startMoving:void 0}),o.startX=m,o.startY=h,r.touchStartTime=nt(),t.allowClick=!0,t.updateSize(),t.swipeDirection=void 0,a.threshold>0&&(r.allowThresholdMove=!1),"touchstart"!==l.type){let e=!0;d.is(r.focusableElements)&&(e=!1,"SELECT"===d[0].nodeName&&(r.isTouched=!1)),i.activeElement&&et(i.activeElement).is(r.focusableElements)&&i.activeElement!==d[0]&&i.activeElement.blur();const n=e&&t.allowTouchMove&&a.touchStartPreventDefault;!a.touchStartForcePreventDefault&&!n||d[0].isContentEditable||l.preventDefault()}t.params.freeMode&&t.params.freeMode.enabled&&t.freeMode&&t.animating&&!a.cssMode&&t.freeMode.onTouchStart(),t.emit("touchStart",l)}function Et(e){const t=Ye(),i=this,n=i.touchEventsData,{params:r,touches:a,rtlTranslate:o,enabled:s}=i;if(!s)return;let l=e;if(l.originalEvent&&(l=l.originalEvent),!n.isTouched)return void(n.startMoving&&n.isScrolling&&i.emit("touchMoveOpposite",l));if(n.isTouchEvent&&"touchmove"!==l.type)return;const d="touchmove"===l.type&&l.targetTouches&&(l.targetTouches[0]||l.changedTouches[0]),c="touchmove"===l.type?d.pageX:l.pageX,p="touchmove"===l.type?d.pageY:l.pageY;if(l.preventedByNestedSwiper)return a.startX=c,void(a.startY=p);if(!i.allowTouchMove)return et(l.target).is(n.focusableElements)||(i.allowClick=!1),void(n.isTouched&&(Object.assign(a,{startX:c,startY:p,currentX:c,currentY:p}),n.touchStartTime=nt()));if(n.isTouchEvent&&r.touchReleaseOnEdges&&!r.loop)if(i.isVertical()){if(pa.startY&&i.translate>=i.minTranslate())return n.isTouched=!1,void(n.isMoved=!1)}else if(ca.startX&&i.translate>=i.minTranslate())return;if(n.isTouchEvent&&t.activeElement&&l.target===t.activeElement&&et(l.target).is(n.focusableElements))return n.isMoved=!0,void(i.allowClick=!1);if(n.allowTouchCallbacks&&i.emit("touchMove",l),l.targetTouches&&l.targetTouches.length>1)return;a.currentX=c,a.currentY=p;const m=a.currentX-a.startX,h=a.currentY-a.startY;if(i.params.threshold&&Math.sqrt(m**2+h**2)=25&&(e=180*Math.atan2(Math.abs(h),Math.abs(m))/Math.PI,n.isScrolling=i.isHorizontal()?e>r.touchAngle:90-e>r.touchAngle)}if(n.isScrolling&&i.emit("touchMoveOpposite",l),void 0===n.startMoving&&(a.currentX===a.startX&&a.currentY===a.startY||(n.startMoving=!0)),n.isScrolling)return void(n.isTouched=!1);if(!n.startMoving)return;i.allowClick=!1,!r.cssMode&&l.cancelable&&l.preventDefault(),r.touchMoveStopPropagation&&!r.nested&&l.stopPropagation(),n.isMoved||(r.loop&&!r.cssMode&&i.loopFix(),n.startTranslate=i.getTranslate(),i.setTransition(0),i.animating&&i.$wrapperEl.trigger("webkitTransitionEnd transitionend"),n.allowMomentumBounce=!1,!r.grabCursor||!0!==i.allowSlideNext&&!0!==i.allowSlidePrev||i.setGrabCursor(!0),i.emit("sliderFirstMove",l)),i.emit("sliderMove",l),n.isMoved=!0;let u=i.isHorizontal()?m:h;a.diff=u,u*=r.touchRatio,o&&(u=-u),i.swipeDirection=u>0?"prev":"next",n.currentTranslate=u+n.startTranslate;let f=!0,g=r.resistanceRatio;if(r.touchReleaseOnEdges&&(g=0),u>0&&n.currentTranslate>i.minTranslate()?(f=!1,r.resistance&&(n.currentTranslate=i.minTranslate()-1+(-i.minTranslate()+n.startTranslate+u)**g)):u<0&&n.currentTranslaten.startTranslate&&(n.currentTranslate=n.startTranslate),i.allowSlidePrev||i.allowSlideNext||(n.currentTranslate=n.startTranslate),r.threshold>0){if(!(Math.abs(u)>r.threshold||n.allowThresholdMove))return void(n.currentTranslate=n.startTranslate);if(!n.allowThresholdMove)return n.allowThresholdMove=!0,a.startX=a.currentX,a.startY=a.currentY,n.currentTranslate=n.startTranslate,void(a.diff=i.isHorizontal()?a.currentX-a.startX:a.currentY-a.startY)}r.followFinger&&!r.cssMode&&((r.freeMode&&r.freeMode.enabled&&i.freeMode||r.watchSlidesProgress)&&(i.updateActiveIndex(),i.updateSlidesClasses()),i.params.freeMode&&r.freeMode.enabled&&i.freeMode&&i.freeMode.onTouchMove(),i.updateProgress(n.currentTranslate),i.setTranslate(n.currentTranslate))}function At(e){const t=this,i=t.touchEventsData,{params:n,touches:r,rtlTranslate:a,slidesGrid:o,enabled:s}=t;if(!s)return;let l=e;if(l.originalEvent&&(l=l.originalEvent),i.allowTouchCallbacks&&t.emit("touchEnd",l),i.allowTouchCallbacks=!1,!i.isTouched)return i.isMoved&&n.grabCursor&&t.setGrabCursor(!1),i.isMoved=!1,void(i.startMoving=!1);n.grabCursor&&i.isMoved&&i.isTouched&&(!0===t.allowSlideNext||!0===t.allowSlidePrev)&&t.setGrabCursor(!1);const d=nt(),c=d-i.touchStartTime;if(t.allowClick){const e=l.path||l.composedPath&&l.composedPath();t.updateClickedSlide(e&&e[0]||l.target),t.emit("tap click",l),c<300&&d-i.lastClickTime<300&&t.emit("doubleTap doubleClick",l)}if(i.lastClickTime=nt(),it((()=>{t.destroyed||(t.allowClick=!0)})),!i.isTouched||!i.isMoved||!t.swipeDirection||0===r.diff||i.currentTranslate===i.startTranslate)return i.isTouched=!1,i.isMoved=!1,void(i.startMoving=!1);let p;if(i.isTouched=!1,i.isMoved=!1,i.startMoving=!1,p=n.followFinger?a?t.translate:-t.translate:-i.currentTranslate,n.cssMode)return;if(t.params.freeMode&&n.freeMode.enabled)return void t.freeMode.onTouchEnd({currentPos:p});let m=0,h=t.slidesSizesGrid[0];for(let e=0;e=o[e]&&p=o[e]&&(m=e,h=o[o.length-1]-o[o.length-2])}let u=null,f=null;n.rewind&&(t.isBeginning?f=t.params.virtual&&t.params.virtual.enabled&&t.virtual?t.virtual.slides.length-1:t.slides.length-1:t.isEnd&&(u=0));const g=(p-o[m])/h,v=mn.longSwipesMs){if(!n.longSwipes)return void t.slideTo(t.activeIndex);"next"===t.swipeDirection&&(g>=n.longSwipesRatio?t.slideTo(n.rewind&&t.isEnd?u:m+v):t.slideTo(m)),"prev"===t.swipeDirection&&(g>1-n.longSwipesRatio?t.slideTo(m+v):null!==f&&g<0&&Math.abs(g)>n.longSwipesRatio?t.slideTo(f):t.slideTo(m))}else{if(!n.shortSwipes)return void t.slideTo(t.activeIndex);t.navigation&&(l.target===t.navigation.nextEl||l.target===t.navigation.prevEl)?l.target===t.navigation.nextEl?t.slideTo(m+v):t.slideTo(m):("next"===t.swipeDirection&&t.slideTo(null!==u?u:m+v),"prev"===t.swipeDirection&&t.slideTo(null!==f?f:m))}}function Ct(){const e=this,{params:t,el:i}=e;if(i&&0===i.offsetWidth)return;t.breakpoints&&e.setBreakpoint();const{allowSlideNext:n,allowSlidePrev:r,snapGrid:a}=e;e.allowSlideNext=!0,e.allowSlidePrev=!0,e.updateSize(),e.updateSlides(),e.updateSlidesClasses(),("auto"===t.slidesPerView||t.slidesPerView>1)&&e.isEnd&&!e.isBeginning&&!e.params.centeredSlides?e.slideTo(e.slides.length-1,0,!1,!0):e.slideTo(e.activeIndex,0,!1,!0),e.autoplay&&e.autoplay.running&&e.autoplay.paused&&e.autoplay.run(),e.allowSlidePrev=r,e.allowSlideNext=n,e.params.watchOverflow&&a!==e.snapGrid&&e.checkOverflow()}function Tt(e){const t=this;t.enabled&&(t.allowClick||(t.params.preventClicks&&e.preventDefault(),t.params.preventClicksPropagation&&t.animating&&(e.stopPropagation(),e.stopImmediatePropagation())))}function St(){const e=this,{wrapperEl:t,rtlTranslate:i,enabled:n}=e;if(!n)return;let r;e.previousTranslate=e.translate,e.isHorizontal()?e.translate=-t.scrollLeft:e.translate=-t.scrollTop,0===e.translate&&(e.translate=0),e.updateActiveIndex(),e.updateSlidesClasses();const a=e.maxTranslate()-e.minTranslate();r=0===a?0:(e.translate-e.minTranslate())/a,r!==e.progress&&e.updateProgress(i?-e.translate:e.translate),e.emit("setTranslate",e.translate,!1)}let It=!1;function kt(){}const Ot=(e,t)=>{const i=Ye(),{params:n,touchEvents:r,el:a,wrapperEl:o,device:s,support:l}=e,d=!!n.nested,c="on"===t?"addEventListener":"removeEventListener",p=t;if(l.touch){const t=!("touchstart"!==r.start||!l.passiveListener||!n.passiveListeners)&&{passive:!0,capture:!1};a[c](r.start,e.onTouchStart,t),a[c](r.move,e.onTouchMove,l.passiveListener?{passive:!1,capture:d}:d),a[c](r.end,e.onTouchEnd,t),r.cancel&&a[c](r.cancel,e.onTouchEnd,t)}else a[c](r.start,e.onTouchStart,!1),i[c](r.move,e.onTouchMove,d),i[c](r.end,e.onTouchEnd,!1);(n.preventClicks||n.preventClicksPropagation)&&a[c]("click",e.onClick,!0),n.cssMode&&o[c]("scroll",e.onScroll),n.updateOnWindowResize?e[p](s.ios||s.android?"resize orientationchange observerUpdate":"resize observerUpdate",Ct,!0):e[p]("observerUpdate",Ct,!0)};var Lt={attachEvents:function(){const e=this,t=Ye(),{params:i,support:n}=e;e.onTouchStart=wt.bind(e),e.onTouchMove=Et.bind(e),e.onTouchEnd=At.bind(e),i.cssMode&&(e.onScroll=St.bind(e)),e.onClick=Tt.bind(e),n.touch&&!It&&(t.addEventListener("touchstart",kt),It=!0),Ot(e,"on")},detachEvents:function(){Ot(this,"off")}};const zt=(e,t)=>e.grid&&t.grid&&t.grid.rows>1;var Rt={setBreakpoint:function(){const e=this,{activeIndex:t,initialized:i,loopedSlides:n=0,params:r,$el:a}=e,o=r.breakpoints;if(!o||o&&0===Object.keys(o).length)return;const s=e.getBreakpoint(o,e.params.breakpointsBase,e.el);if(!s||e.currentBreakpoint===s)return;const l=(s in o?o[s]:void 0)||e.originalParams,d=zt(e,r),c=zt(e,l),p=r.enabled;d&&!c?(a.removeClass(`${r.containerModifierClass}grid ${r.containerModifierClass}grid-column`),e.emitContainerClasses()):!d&&c&&(a.addClass(`${r.containerModifierClass}grid`),(l.grid.fill&&"column"===l.grid.fill||!l.grid.fill&&"column"===r.grid.fill)&&a.addClass(`${r.containerModifierClass}grid-column`),e.emitContainerClasses()),["navigation","pagination","scrollbar"].forEach((t=>{const i=r[t]&&r[t].enabled,n=l[t]&&l[t].enabled;i&&!n&&e[t].disable(),!i&&n&&e[t].enable()}));const m=l.direction&&l.direction!==r.direction,h=r.loop&&(l.slidesPerView!==r.slidesPerView||m);m&&i&&e.changeDirection(),st(e.params,l);const u=e.params.enabled;Object.assign(e,{allowTouchMove:e.params.allowTouchMove,allowSlideNext:e.params.allowSlideNext,allowSlidePrev:e.params.allowSlidePrev}),p&&!u?e.disable():!p&&u&&e.enable(),e.currentBreakpoint=s,e.emit("_beforeBreakpoint",l),h&&i&&(e.loopDestroy(),e.loopCreate(),e.updateSlides(),e.slideTo(t-n+e.loopedSlides,0,!1)),e.emit("breakpoint",l)},getBreakpoint:function(e,t,i){if(void 0===t&&(t="window"),!e||"container"===t&&!i)return;let n=!1;const r=Ke(),a="window"===t?r.innerHeight:i.clientHeight,o=Object.keys(e).map((e=>{if("string"==typeof e&&0===e.indexOf("@")){const t=parseFloat(e.substr(1));return{value:a*t,point:e}}return{value:e,point:e}}));o.sort(((e,t)=>parseInt(e.value,10)-parseInt(t.value,10)));for(let e=0;e{"object"==typeof e?Object.keys(e).forEach((n=>{e[n]&&i.push(t+n)})):"string"==typeof e&&i.push(t+e)})),i}(["initialized",i.direction,{"pointer-events":!o.touch},{"free-mode":e.params.freeMode&&i.freeMode.enabled},{autoheight:i.autoHeight},{rtl:n},{grid:i.grid&&i.grid.rows>1},{"grid-column":i.grid&&i.grid.rows>1&&"column"===i.grid.fill},{android:a.android},{ios:a.ios},{"css-mode":i.cssMode},{centered:i.cssMode&&i.centeredSlides},{"watch-progress":i.watchSlidesProgress}],i.containerModifierClass);t.push(...s),r.addClass([...t].join(" ")),e.emitContainerClasses()},removeClasses:function(){const{$el:e,classNames:t}=this;e.removeClass(t.join(" ")),this.emitContainerClasses()}};var $t={loadImage:function(e,t,i,n,r,a){const o=Ke();let s;function l(){a&&a()}et(e).parent("picture")[0]||e.complete&&r?l():t?(s=new o.Image,s.onload=l,s.onerror=l,n&&(s.sizes=n),i&&(s.srcset=i),t&&(s.src=t)):l()},preloadImages:function(){const e=this;function t(){null!=e&&e&&!e.destroyed&&(void 0!==e.imagesLoaded&&(e.imagesLoaded+=1),e.imagesLoaded===e.imagesToLoad.length&&(e.params.updateOnImagesReady&&e.update(),e.emit("imagesReady")))}e.imagesToLoad=e.$el.find("img");for(let i=0;i=0&&!0===e[n]&&(e[n]={auto:!0}),n in e&&"enabled"in r?(!0===e[n]&&(e[n]={enabled:!0}),"object"!=typeof e[n]||"enabled"in e[n]||(e[n].enabled=!0),e[n]||(e[n]={enabled:!1}),st(t,i)):st(t,i)):st(t,i)}}const Nt={eventsEmitter:gt,update:vt,translate:bt,transition:{setTransition:function(e,t){const i=this;i.params.cssMode||i.$wrapperEl.transition(e),i.emit("setTransition",e,t)},transitionStart:function(e,t){void 0===e&&(e=!0);const i=this,{params:n}=i;n.cssMode||(n.autoHeight&&i.updateAutoHeight(),yt({swiper:i,runCallbacks:e,direction:t,step:"Start"}))},transitionEnd:function(e,t){void 0===e&&(e=!0);const i=this,{params:n}=i;i.animating=!1,n.cssMode||(i.setTransition(0),yt({swiper:i,runCallbacks:e,direction:t,step:"End"}))}},slide:xt,loop:_t,grabCursor:{setGrabCursor:function(e){const t=this;if(t.support.touch||!t.params.simulateTouch||t.params.watchOverflow&&t.isLocked||t.params.cssMode)return;const i="container"===t.params.touchEventsTarget?t.el:t.wrapperEl;i.style.cursor="move",i.style.cursor=e?"grabbing":"grab"},unsetGrabCursor:function(){const e=this;e.support.touch||e.params.watchOverflow&&e.isLocked||e.params.cssMode||(e["container"===e.params.touchEventsTarget?"el":"wrapperEl"].style.cursor="")}},events:Lt,breakpoints:Rt,checkOverflow:{checkOverflow:function(){const e=this,{isLocked:t,params:i}=e,{slidesOffsetBefore:n}=i;if(n){const t=e.slides.length-1,i=e.slidesGrid[t]+e.slidesSizesGrid[t]+2*n;e.isLocked=e.size>i}else e.isLocked=1===e.snapGrid.length;!0===i.allowSlideNext&&(e.allowSlideNext=!e.isLocked),!0===i.allowSlidePrev&&(e.allowSlidePrev=!e.isLocked),t&&t!==e.isLocked&&(e.isEnd=!1),t!==e.isLocked&&e.emit(e.isLocked?"lock":"unlock")}},classes:Mt,images:$t},Pt={};class Bt{constructor(){let e,t;for(var i=arguments.length,n=new Array(i),r=0;r1){const e=[];return et(t.el).each((i=>{const n=st({},t,{el:i});e.push(new Bt(n))})),e}const a=this;a.__swiper__=!0,a.support=ht(),a.device=ut({userAgent:t.userAgent}),a.browser=ft(),a.eventsListeners={},a.eventsAnyListeners=[],a.modules=[...a.__modules__],t.modules&&Array.isArray(t.modules)&&a.modules.push(...t.modules);const o={};a.modules.forEach((e=>{e({swiper:a,extendParams:Dt(t,o),on:a.on.bind(a),once:a.once.bind(a),off:a.off.bind(a),emit:a.emit.bind(a)})}));const s=st({},Ft,o);return a.params=st({},s,Pt,t),a.originalParams=st({},a.params),a.passedParams=st({},t),a.params&&a.params.on&&Object.keys(a.params.on).forEach((e=>{a.on(e,a.params.on[e])})),a.params&&a.params.onAny&&a.onAny(a.params.onAny),a.$=et,Object.assign(a,{enabled:a.params.enabled,el:e,classNames:[],slides:et(),slidesGrid:[],snapGrid:[],slidesSizesGrid:[],isHorizontal:()=>"horizontal"===a.params.direction,isVertical:()=>"vertical"===a.params.direction,activeIndex:0,realIndex:0,isBeginning:!0,isEnd:!1,translate:0,previousTranslate:0,progress:0,velocity:0,animating:!1,allowSlideNext:a.params.allowSlideNext,allowSlidePrev:a.params.allowSlidePrev,touchEvents:function(){const e=["touchstart","touchmove","touchend","touchcancel"],t=["pointerdown","pointermove","pointerup"];return a.touchEventsTouch={start:e[0],move:e[1],end:e[2],cancel:e[3]},a.touchEventsDesktop={start:t[0],move:t[1],end:t[2]},a.support.touch||!a.params.simulateTouch?a.touchEventsTouch:a.touchEventsDesktop}(),touchEventsData:{isTouched:void 0,isMoved:void 0,allowTouchCallbacks:void 0,touchStartTime:void 0,isScrolling:void 0,currentTranslate:void 0,startTranslate:void 0,allowThresholdMove:void 0,focusableElements:a.params.focusableElements,lastClickTime:nt(),clickTimeout:void 0,velocities:[],allowMomentumBounce:void 0,isTouchEvent:void 0,startMoving:void 0},allowClick:!0,allowTouchMove:a.params.allowTouchMove,touches:{startX:0,startY:0,currentX:0,currentY:0,diff:0},imagesToLoad:[],imagesLoaded:0}),a.emit("_swiper"),a.params.init&&a.init(),a}enable(){const e=this;e.enabled||(e.enabled=!0,e.params.grabCursor&&e.setGrabCursor(),e.emit("enable"))}disable(){const e=this;e.enabled&&(e.enabled=!1,e.params.grabCursor&&e.unsetGrabCursor(),e.emit("disable"))}setProgress(e,t){const i=this;e=Math.min(Math.max(e,0),1);const n=i.minTranslate(),r=(i.maxTranslate()-n)*e+n;i.translateTo(r,void 0===t?0:t),i.updateActiveIndex(),i.updateSlidesClasses()}emitContainerClasses(){const e=this;if(!e.params._emitClasses||!e.el)return;const t=e.el.className.split(" ").filter((t=>0===t.indexOf("swiper")||0===t.indexOf(e.params.containerModifierClass)));e.emit("_containerClasses",t.join(" "))}getSlideClasses(e){const t=this;return t.destroyed?"":e.className.split(" ").filter((e=>0===e.indexOf("swiper-slide")||0===e.indexOf(t.params.slideClass))).join(" ")}emitSlidesClasses(){const e=this;if(!e.params._emitClasses||!e.el)return;const t=[];e.slides.each((i=>{const n=e.getSlideClasses(i);t.push({slideEl:i,classNames:n}),e.emit("_slideClass",i,n)})),e.emit("_slideClasses",t)}slidesPerViewDynamic(e,t){void 0===e&&(e="current"),void 0===t&&(t=!1);const{params:i,slides:n,slidesGrid:r,slidesSizesGrid:a,size:o,activeIndex:s}=this;let l=1;if(i.centeredSlides){let e,t=n[s].swiperSlideSize;for(let i=s+1;io&&(e=!0));for(let i=s-1;i>=0;i-=1)n[i]&&!e&&(t+=n[i].swiperSlideSize,l+=1,t>o&&(e=!0))}else if("current"===e)for(let e=s+1;e=0;e-=1){r[s]-r[e]1)&&e.isEnd&&!e.params.centeredSlides?e.slideTo(e.slides.length-1,0,!1,!0):e.slideTo(e.activeIndex,0,!1,!0),r||n()),i.watchOverflow&&t!==e.snapGrid&&e.checkOverflow(),e.emit("update")}changeDirection(e,t){void 0===t&&(t=!0);const i=this,n=i.params.direction;return e||(e="horizontal"===n?"vertical":"horizontal"),e===n||"horizontal"!==e&&"vertical"!==e||(i.$el.removeClass(`${i.params.containerModifierClass}${n}`).addClass(`${i.params.containerModifierClass}${e}`),i.emitContainerClasses(),i.params.direction=e,i.slides.each((t=>{"vertical"===e?t.style.width="":t.style.height=""})),i.emit("changeDirection"),t&&i.update()),i}mount(e){const t=this;if(t.mounted)return!0;const i=et(e||t.params.el);if(!(e=i[0]))return!1;e.swiper=t;const n=()=>`.${(t.params.wrapperClass||"").trim().split(" ").join(".")}`;let r=(()=>{if(e&&e.shadowRoot&&e.shadowRoot.querySelector){const t=et(e.shadowRoot.querySelector(n()));return t.children=e=>i.children(e),t}return i.children?i.children(n()):et(i).children(n())})();if(0===r.length&&t.params.createElements){const e=Ye().createElement("div");r=et(e),e.className=t.params.wrapperClass,i.append(e),i.children(`.${t.params.slideClass}`).each((e=>{r.append(e)}))}return Object.assign(t,{$el:i,el:e,$wrapperEl:r,wrapperEl:r[0],mounted:!0,rtl:"rtl"===e.dir.toLowerCase()||"rtl"===i.css("direction"),rtlTranslate:"horizontal"===t.params.direction&&("rtl"===e.dir.toLowerCase()||"rtl"===i.css("direction")),wrongRTL:"-webkit-box"===r.css("display")}),!0}init(e){const t=this;if(t.initialized)return t;return!1===t.mount(e)||(t.emit("beforeInit"),t.params.breakpoints&&t.setBreakpoint(),t.addClasses(),t.params.loop&&t.loopCreate(),t.updateSize(),t.updateSlides(),t.params.watchOverflow&&t.checkOverflow(),t.params.grabCursor&&t.enabled&&t.setGrabCursor(),t.params.preloadImages&&t.preloadImages(),t.params.loop?t.slideTo(t.params.initialSlide+t.loopedSlides,0,t.params.runCallbacksOnInit,!1,!0):t.slideTo(t.params.initialSlide,0,t.params.runCallbacksOnInit,!1,!0),t.attachEvents(),t.initialized=!0,t.emit("init"),t.emit("afterInit")),t}destroy(e,t){void 0===e&&(e=!0),void 0===t&&(t=!0);const i=this,{params:n,$el:r,$wrapperEl:a,slides:o}=i;return void 0===i.params||i.destroyed||(i.emit("beforeDestroy"),i.initialized=!1,i.detachEvents(),n.loop&&i.loopDestroy(),t&&(i.removeClasses(),r.removeAttr("style"),a.removeAttr("style"),o&&o.length&&o.removeClass([n.slideVisibleClass,n.slideActiveClass,n.slideNextClass,n.slidePrevClass].join(" ")).removeAttr("style").removeAttr("data-swiper-slide-index")),i.emit("destroy"),Object.keys(i.eventsListeners).forEach((e=>{i.off(e)})),!1!==e&&(i.$el[0].swiper=null,function(e){const t=e;Object.keys(t).forEach((e=>{try{t[e]=null}catch(e){}try{delete t[e]}catch(e){}}))}(i)),i.destroyed=!0),null}static extendDefaults(e){st(Pt,e)}static get extendedDefaults(){return Pt}static get defaults(){return Ft}static installModule(e){Bt.prototype.__modules__||(Bt.prototype.__modules__=[]);const t=Bt.prototype.__modules__;"function"==typeof e&&t.indexOf(e)<0&&t.push(e)}static use(e){return Array.isArray(e)?(e.forEach((e=>Bt.installModule(e))),Bt):(Bt.installModule(e),Bt)}}function Ht(e){return void 0===e&&(e=""),`.${e.trim().replace(/([\.:!\/])/g,"\\$1").replace(/ /g,".")}`}function jt(e){let{swiper:t,extendParams:i,on:n,emit:r}=e;const a="swiper-pagination";let o;i({pagination:{el:null,bulletElement:"span",clickable:!1,hideOnClick:!1,renderBullet:null,renderProgressbar:null,renderFraction:null,renderCustom:null,progressbarOpposite:!1,type:"bullets",dynamicBullets:!1,dynamicMainBullets:1,formatFractionCurrent:e=>e,formatFractionTotal:e=>e,bulletClass:`${a}-bullet`,bulletActiveClass:`${a}-bullet-active`,modifierClass:`${a}-`,currentClass:`${a}-current`,totalClass:`${a}-total`,hiddenClass:`${a}-hidden`,progressbarFillClass:`${a}-progressbar-fill`,progressbarOppositeClass:`${a}-progressbar-opposite`,clickableClass:`${a}-clickable`,lockClass:`${a}-lock`,horizontalClass:`${a}-horizontal`,verticalClass:`${a}-vertical`,paginationDisabledClass:`${a}-disabled`}}),t.pagination={el:null,$el:null,bullets:[]};let s=0;function l(){return!t.params.pagination.el||!t.pagination.el||!t.pagination.$el||0===t.pagination.$el.length}function d(e,i){const{bulletActiveClass:n}=t.params.pagination;e[i]().addClass(`${n}-${i}`)[i]().addClass(`${n}-${i}-${i}`)}function c(){const e=t.rtl,i=t.params.pagination;if(l())return;const n=t.virtual&&t.params.virtual.enabled?t.virtual.slides.length:t.slides.length,a=t.pagination.$el;let c;const p=t.params.loop?Math.ceil((n-2*t.loopedSlides)/t.params.slidesPerGroup):t.snapGrid.length;if(t.params.loop?(c=Math.ceil((t.activeIndex-t.loopedSlides)/t.params.slidesPerGroup),c>n-1-2*t.loopedSlides&&(c-=n-2*t.loopedSlides),c>p-1&&(c-=p),c<0&&"bullets"!==t.params.paginationType&&(c=p+c)):c=void 0!==t.snapIndex?t.snapIndex:t.activeIndex||0,"bullets"===i.type&&t.pagination.bullets&&t.pagination.bullets.length>0){const n=t.pagination.bullets;let r,l,p;if(i.dynamicBullets&&(o=n.eq(0)[t.isHorizontal()?"outerWidth":"outerHeight"](!0),a.css(t.isHorizontal()?"width":"height",o*(i.dynamicMainBullets+4)+"px"),i.dynamicMainBullets>1&&void 0!==t.previousIndex&&(s+=c-(t.previousIndex-t.loopedSlides||0),s>i.dynamicMainBullets-1?s=i.dynamicMainBullets-1:s<0&&(s=0)),r=Math.max(c-s,0),l=r+(Math.min(n.length,i.dynamicMainBullets)-1),p=(l+r)/2),n.removeClass(["","-next","-next-next","-prev","-prev-prev","-main"].map((e=>`${i.bulletActiveClass}${e}`)).join(" ")),a.length>1)n.each((e=>{const t=et(e),n=t.index();n===c&&t.addClass(i.bulletActiveClass),i.dynamicBullets&&(n>=r&&n<=l&&t.addClass(`${i.bulletActiveClass}-main`),n===r&&d(t,"prev"),n===l&&d(t,"next"))}));else{const e=n.eq(c),a=e.index();if(e.addClass(i.bulletActiveClass),i.dynamicBullets){const e=n.eq(r),o=n.eq(l);for(let e=r;e<=l;e+=1)n.eq(e).addClass(`${i.bulletActiveClass}-main`);if(t.params.loop)if(a>=n.length){for(let e=i.dynamicMainBullets;e>=0;e-=1)n.eq(n.length-e).addClass(`${i.bulletActiveClass}-main`);n.eq(n.length-i.dynamicMainBullets-1).addClass(`${i.bulletActiveClass}-prev`)}else d(e,"prev"),d(o,"next");else d(e,"prev"),d(o,"next")}}if(i.dynamicBullets){const r=Math.min(n.length,i.dynamicMainBullets+4),a=(o*r-o)/2-p*o,s=e?"right":"left";n.css(t.isHorizontal()?s:"top",`${a}px`)}}if("fraction"===i.type&&(a.find(Ht(i.currentClass)).text(i.formatFractionCurrent(c+1)),a.find(Ht(i.totalClass)).text(i.formatFractionTotal(p))),"progressbar"===i.type){let e;e=i.progressbarOpposite?t.isHorizontal()?"vertical":"horizontal":t.isHorizontal()?"horizontal":"vertical";const n=(c+1)/p;let r=1,o=1;"horizontal"===e?r=n:o=n,a.find(Ht(i.progressbarFillClass)).transform(`translate3d(0,0,0) scaleX(${r}) scaleY(${o})`).transition(t.params.speed)}"custom"===i.type&&i.renderCustom?(a.html(i.renderCustom(t,c+1,p)),r("paginationRender",a[0])):r("paginationUpdate",a[0]),t.params.watchOverflow&&t.enabled&&a[t.isLocked?"addClass":"removeClass"](i.lockClass)}function p(){const e=t.params.pagination;if(l())return;const i=t.virtual&&t.params.virtual.enabled?t.virtual.slides.length:t.slides.length,n=t.pagination.$el;let a="";if("bullets"===e.type){let r=t.params.loop?Math.ceil((i-2*t.loopedSlides)/t.params.slidesPerGroup):t.snapGrid.length;t.params.freeMode&&t.params.freeMode.enabled&&!t.params.loop&&r>i&&(r=i);for(let i=0;i`;n.html(a),t.pagination.bullets=n.find(Ht(e.bulletClass))}"fraction"===e.type&&(a=e.renderFraction?e.renderFraction.call(t,e.currentClass,e.totalClass):` / `,n.html(a)),"progressbar"===e.type&&(a=e.renderProgressbar?e.renderProgressbar.call(t,e.progressbarFillClass):``,n.html(a)),"custom"!==e.type&&r("paginationRender",t.pagination.$el[0])}function m(){t.params.pagination=function(e,t,i,n){const r=Ye();return e.params.createElements&&Object.keys(n).forEach((a=>{if(!i[a]&&!0===i.auto){let o=e.$el.children(`.${n[a]}`)[0];o||(o=r.createElement("div"),o.className=n[a],e.$el.append(o)),i[a]=o,t[a]=o}})),i}(t,t.originalParams.pagination,t.params.pagination,{el:"swiper-pagination"});const e=t.params.pagination;if(!e.el)return;let i=et(e.el);0!==i.length&&(t.params.uniqueNavElements&&"string"==typeof e.el&&i.length>1&&(i=t.$el.find(e.el),i.length>1&&(i=i.filter((e=>et(e).parents(".swiper")[0]===t.el)))),"bullets"===e.type&&e.clickable&&i.addClass(e.clickableClass),i.addClass(e.modifierClass+e.type),i.addClass(t.isHorizontal()?e.horizontalClass:e.verticalClass),"bullets"===e.type&&e.dynamicBullets&&(i.addClass(`${e.modifierClass}${e.type}-dynamic`),s=0,e.dynamicMainBullets<1&&(e.dynamicMainBullets=1)),"progressbar"===e.type&&e.progressbarOpposite&&i.addClass(e.progressbarOppositeClass),e.clickable&&i.on("click",Ht(e.bulletClass),(function(e){e.preventDefault();let i=et(this).index()*t.params.slidesPerGroup;t.params.loop&&(i+=t.loopedSlides),t.slideTo(i)})),Object.assign(t.pagination,{$el:i,el:i[0]}),t.enabled||i.addClass(e.lockClass))}function h(){const e=t.params.pagination;if(l())return;const i=t.pagination.$el;i.removeClass(e.hiddenClass),i.removeClass(e.modifierClass+e.type),i.removeClass(t.isHorizontal()?e.horizontalClass:e.verticalClass),t.pagination.bullets&&t.pagination.bullets.removeClass&&t.pagination.bullets.removeClass(e.bulletActiveClass),e.clickable&&i.off("click",Ht(e.bulletClass))}n("init",(()=>{!1===t.params.pagination.enabled?u():(m(),p(),c())})),n("activeIndexChange",(()=>{(t.params.loop||void 0===t.snapIndex)&&c()})),n("snapIndexChange",(()=>{t.params.loop||c()})),n("slidesLengthChange",(()=>{t.params.loop&&(p(),c())})),n("snapGridLengthChange",(()=>{t.params.loop||(p(),c())})),n("destroy",(()=>{h()})),n("enable disable",(()=>{const{$el:e}=t.pagination;e&&e[t.enabled?"removeClass":"addClass"](t.params.pagination.lockClass)})),n("lock unlock",(()=>{c()})),n("click",((e,i)=>{const n=i.target,{$el:a}=t.pagination;if(t.params.pagination.el&&t.params.pagination.hideOnClick&&a.length>0&&!et(n).hasClass(t.params.pagination.bulletClass)){if(t.navigation&&(t.navigation.nextEl&&n===t.navigation.nextEl||t.navigation.prevEl&&n===t.navigation.prevEl))return;const e=a.hasClass(t.params.pagination.hiddenClass);r(!0===e?"paginationShow":"paginationHide"),a.toggleClass(t.params.pagination.hiddenClass)}}));const u=()=>{t.$el.addClass(t.params.pagination.paginationDisabledClass),t.pagination.$el&&t.pagination.$el.addClass(t.params.pagination.paginationDisabledClass),h()};Object.assign(t.pagination,{enable:()=>{t.$el.removeClass(t.params.pagination.paginationDisabledClass),t.pagination.$el&&t.pagination.$el.removeClass(t.params.pagination.paginationDisabledClass),m(),p(),c()},disable:u,render:p,update:c,init:m,destroy:h})}Object.keys(Nt).forEach((e=>{Object.keys(Nt[e]).forEach((t=>{Bt.prototype[t]=Nt[e][t]}))})),Bt.use([function(e){let{swiper:t,on:i,emit:n}=e;const r=Ke();let a=null,o=null;const s=()=>{t&&!t.destroyed&&t.initialized&&(n("beforeResize"),n("resize"))},l=()=>{t&&!t.destroyed&&t.initialized&&n("orientationchange")};i("init",(()=>{t.params.resizeObserver&&void 0!==r.ResizeObserver?t&&!t.destroyed&&t.initialized&&(a=new ResizeObserver((e=>{o=r.requestAnimationFrame((()=>{const{width:i,height:n}=t;let r=i,a=n;e.forEach((e=>{let{contentBoxSize:i,contentRect:n,target:o}=e;o&&o!==t.el||(r=n?n.width:(i[0]||i).inlineSize,a=n?n.height:(i[0]||i).blockSize)})),r===i&&a===n||s()}))})),a.observe(t.el)):(r.addEventListener("resize",s),r.addEventListener("orientationchange",l))})),i("destroy",(()=>{o&&r.cancelAnimationFrame(o),a&&a.unobserve&&t.el&&(a.unobserve(t.el),a=null),r.removeEventListener("resize",s),r.removeEventListener("orientationchange",l)}))},function(e){let{swiper:t,extendParams:i,on:n,emit:r}=e;const a=[],o=Ke(),s=function(e,t){void 0===t&&(t={});const i=new(o.MutationObserver||o.WebkitMutationObserver)((e=>{if(1===e.length)return void r("observerUpdate",e[0]);const t=function(){r("observerUpdate",e[0])};o.requestAnimationFrame?o.requestAnimationFrame(t):o.setTimeout(t,0)}));i.observe(e,{attributes:void 0===t.attributes||t.attributes,childList:void 0===t.childList||t.childList,characterData:void 0===t.characterData||t.characterData}),a.push(i)};i({observer:!1,observeParents:!1,observeSlideChildren:!1}),n("init",(()=>{if(t.params.observer){if(t.params.observeParents){const e=t.$el.parents();for(let t=0;t{a.forEach((e=>{e.disconnect()})),a.splice(0,a.length)}))}]);var Vt,Ut,Gt,Wt,qt;!function(e){e[e.SingleEntity=0]="SingleEntity",e[e.CurrentExpected=1]="CurrentExpected",e[e.Slots=2]="Slots",e[e.WarningWatchStatementAdvisory=3]="WarningWatchStatementAdvisory",e[e.SeparateEvents=4]="SeparateEvents"}(Vt||(Vt={})),function(e){e[e.Current=0]="Current",e[e.Expected=1]="Expected"}(Ut||(Ut={})),function(e){e.Disabled="disabled",e.Headline="headline",e.Scale="scale",e.HeadlineAndScale="headline_and_scale"}(Gt||(Gt={})),function(e){e[e.Unknown=0]="Unknown",e[e.Nuclear=1]="Nuclear",e[e.Hurricane=2]="Hurricane",e[e.Tornado=3]="Tornado",e[e.CoastalEvent=4]="CoastalEvent",e[e.ForestFire=5]="ForestFire",e[e.Avalanches=6]="Avalanches",e[e.Earthquake=7]="Earthquake",e[e.Volcano=8]="Volcano",e[e.Flooding=9]="Flooding",e[e.SeaEvent=10]="SeaEvent",e[e.Thunderstorms=11]="Thunderstorms",e[e.Rain=12]="Rain",e[e.SnowIce=13]="SnowIce",e[e.HighTemperature=14]="HighTemperature",e[e.LowTemperature=15]="LowTemperature",e[e.Wind=16]="Wind",e[e.Fog=17]="Fog",e[e.AirQuality=18]="AirQuality",e[e.Dust=19]="Dust",e[e.Tsunami=20]="Tsunami"}(Wt||(Wt={})),function(e){e[e.Red=3]="Red",e[e.Orange=2]="Orange",e[e.Yellow=1]="Yellow",e[e.None=0]="None"}(qt||(qt={}));class Yt{static minHAversion(e,t){const i=window.frontendVersion;if(!i)return!1;const n=i.substring(0,4),r=i.substring(4,6);return Number(n)>=e||Number(n)>=e&&Number(r)>=t}static getLevelBySeverity(e,t){if(t&&t[e])return t[e];switch(e){case"Unknown":case"Minor":case"Moderate":return qt.Yellow;case"Severe":return qt.Orange;case"High":case"Extreme":return qt.Red;default:throw new Error(`[Utils.getLevelBySeverity] unknown event severity: "${e}"`)}}static convertEventTypesForMetadata(e){return[...new Set(Object.values(e))]}}class Xt{constructor(e,t,i){this.type=e,this.fullName=t,this.icon=i}get translationKey(){return"events."+this.fullName.toLocaleLowerCase().replace(" ","_").replace("/","_").replace("-","_")}}class Kt{constructor(e,t,i){this.type=e,this.fullName=t,this.cssClass=i}get translationKey(){return"messages."+this.fullName.toLocaleLowerCase().replace(" ","_").replace("/","_").replace("-","_")}}class Qt{static get events(){Yt.minHAversion(2022,8)||console.warn("MeteoalarmCard: You are using old HA version! Please update to at least 2022.08 for the best experience.");const e=Yt.minHAversion(2022,6)?"tsunami":"waves",t=Yt.minHAversion(2022,8)?"weather-dust":"weather-windy";return[new Xt(Wt.Nuclear,"Nuclear Event","radioactive"),new Xt(Wt.Hurricane,"Hurricane","weather-hurricane"),new Xt(Wt.Tornado,"Tornado","weather-tornado"),new Xt(Wt.CoastalEvent,"Coastal Event",e),new Xt(Wt.Tsunami,"Tsunami",e),new Xt(Wt.ForestFire,"Forest Fire","pine-tree-fire"),new Xt(Wt.Avalanches,"Avalanches","image-filter-hdr"),new Xt(Wt.Earthquake,"Earthquake","image-broken-variant"),new Xt(Wt.Volcano,"Volcanic Activity","volcano-outline"),new Xt(Wt.Flooding,"Flooding","home-flood"),new Xt(Wt.SeaEvent,"Sea Event","ferry"),new Xt(Wt.Thunderstorms,"Thunderstorms","weather-lightning"),new Xt(Wt.Rain,"Rain","weather-pouring"),new Xt(Wt.SnowIce,"Snow/Ice","weather-snowy-heavy"),new Xt(Wt.HighTemperature,"High Temperature","thermometer"),new Xt(Wt.LowTemperature,"Low Temperature","snowflake"),new Xt(Wt.Dust,"Dust",t),new Xt(Wt.Wind,"Wind","weather-windy"),new Xt(Wt.Fog,"Fog","weather-fog"),new Xt(Wt.AirQuality,"Air Quality","air-filter"),new Xt(Wt.Unknown,"Unknown Event","alert-circle-outline")]}static get levels(){return[new Kt(qt.Red,"Red","event-red"),new Kt(qt.Orange,"Orange","event-orange"),new Kt(qt.Yellow,"Yellow","event-yellow"),new Kt(qt.None,"None","event-none")]}static getEvent(e){return this.events.find((t=>t.type===e))}static getLevel(e){return this.levels.find((t=>t.type===e))}}var Zt={name:"Meteoalarm Card",description:"Картата Meteoalarm Ви предупреждава за текущите метеорологични събития.",warning:"Предупреждение",expected:"Очаквано",unavailable:{long:"Интеграцията не е достъпна",short:"Недостъпно"}},Jt={missing_entity:"Изисква се посочване на обекти!",invalid_integration:"Тази интеграция не е валидна!",invalid_scaling_mode:null,entity_invalid:{single:"Избраната интеграция не съответства на избрания обект.",multiple:"Избраната интеграция не съответства на избраните обекти: {entity}."}},ei={entity:"Обект",integration:"Интеграция",required:"Задължително",recommended:"Препоръчително",override_headline:"Заменете заглавието, предоставено от интеграцията",hide_when_no_warning:"Скриване, когато няма предупреждения",hide_caption:"Скриване на надписа",disable_swiper:"Деактивиране на alerts swiper",description:{start:"Избраната интеграция създава множество обекти.",current_expected:"Първият с текущите събития, а вторият с бъдещите.",slots:"The exact amount of them created is determined by it's configuration.",separate_events:"Всеки вид събитие има отделен обект.",warning_watch_statement_advisory:null,end:"Трябва да добавите всички тях, за да сте сигурни, че няма да пропуснете никакви сигнали."},error:{expected_entity:"Expected warnings entity haven't been provided. Future event won't be shown.",too_many_entities:"More entities than expected haven't been provided. Expected {expected} entities, got {got} entities.",duplicate:"Открити са дубликати в списъка с обекти."},scaling_mode:null,scaling_mode_options:{disabled:null,headline:null,scale:null,headline_and_scale:null}},ti={no_warnings:"Без опасни явления",nuclear:"ядрено събитие",tornado:"торнадо",hurricane:"ураган",wind:"вятър",earthquake:"земетресение",volcano:"вулкан",snow_ice:"сняг/поледица",fog:"мъгла",high_temperature:"високи температури",low_temperature:"ниски температури",coastal_event:"крайбрежно събитие",tsunami:"цунами",sea_event:"морско събитие",forest_fire:"горски пожари",avalanches:"лавини",thunderstorms:"гръмотевична активност",rain:"дъжд",flooding:"наводнения",air_quality:"качество на въздуха",dust:"прах"},ii={yellow:{event:"Жълт код за {event}",generic:"Жълт код",color:"Жълт"},orange:{event:"Оранжев код за {event}",generic:"Оранжев код",color:"Оранжев"},red:{event:"Червен код за {event}",generic:"Червен код",color:"Червен"}},ni={$schema:"../schema/schema.json",common:Zt,error:Jt,editor:ei,events:ti,messages:ii},ri=Object.freeze({__proto__:null,$schema:"../schema/schema.json",common:Zt,error:Jt,editor:ei,events:ti,messages:ii,default:ni}),ai={name:"Meteoalarm Card",description:"Meteoalarm karta zobrazuje aktuální meteorologické výstrahy v okolí.",warning:"Varování",expected:"Očekávané",unavailable:{long:"Výstraha není dostupná",short:"Není k dispozici"}},oi={missing_entity:"Entita je povinná!",invalid_integration:"Tato integrace není platná!",invalid_scaling_mode:"Tento režim změny velikosti není platný!",entity_invalid:{single:"Vybraná integrace se neshoduje s entitou",multiple:"Vybraná integrace se neshoduje s entitami: {entity}"}},si={entity:"Entita",integration:"Integrace",required:"Povinné",recommended:"Doporučeno",override_headline:"Přepsat nadpis poskytnutý integrací",hide_when_no_warning:"Skrýt, pokud nejsou žádná výstrahy",hide_caption:"Skrýt popis",disable_swiper:"Zakázat swipování",description:{start:"Zvolená integrace vytváří více entit.",current_expected:"První s aktuálnímí výstrahami a druhá s budoucímí.",slots:"Jejich přesný počet závisí na nastavení.",separate_events:"Každý druh výstrahy má vlastní entitu.",warning_watch_statement_advisory:"Jsou 4 entity: varování, sledování, oznámení a doporučení.",end:"Je doporučeno přidat všechny ať nepřijdete o žádná upozornění."},error:{expected_entity:"Nenalezena entita. Budoucí výstrahy nebudou zobrazeny.",too_many_entities:"Nalezeno více entit než bylo očekáváno. Očekáváno: {expected} entit, nalezeno {got} entit.",duplicate:"Nalezeny duplicity v seznamu entit."},scaling_mode:"Režim změny velikosti",scaling_mode_options:{disabled:"Vypnuto",headline:"Pouze nadpis",scale:"Pouze měřítko",headline_and_scale:"Nadpis i měřítko"}},li={no_warnings:"Žádné varování",nuclear:"jaderná událost",tornado:"tornádo",hurricane:"hurikán",wind:"vítr",earthquake:"zemětřesení",volcano:"sopečná činnost",snow_ice:"sníh/led",fog:"mlha",high_temperature:"extrémně vysoké teploty",low_temperature:"extrémně nízké teploty",coastal_event:"pobřežní událost",tsunami:"tsunami",sea_event:"mořská událost",forest_fire:"lesní požáry",avalanches:"laviny",thunderstorms:"bouřky",rain:"déšť",flooding:"povodeň",air_quality:"kvalita vzduchu",dust:"pyl"},di={yellow:{event:"Žluté varování: {event}",generic:"Žluté varování",color:"Žluté"},orange:{event:"Oranžové varování: {event}",generic:"Oranžové varování",color:"Oranžové"},red:{event:"Červené varování: {event}",generic:"Červené varování",color:"Červené"}},ci={$schema:"../schema/schema.json",common:ai,error:oi,editor:si,events:li,messages:di},pi=Object.freeze({__proto__:null,$schema:"../schema/schema.json",common:ai,error:oi,editor:si,events:li,messages:di,default:ci}),mi={name:"Meteoalarm-Karte",description:"Die Meteoalarm-Karte warnt dich vor bevorstehenden Wetterereignissen.",warning:"Warnung",expected:"Erwartet",unavailable:{long:"Integration nicht verfügbar",short:"Nicht verfügbar"}},hi={missing_entity:"Die angegebene Entität ist nicht vorhanden!",invalid_integration:"Diese Integration ist nicht gültig!",invalid_scaling_mode:null,entity_invalid:{single:"Ausgewählte Integration und Entität passen nicht zusammen.",multiple:"Ausgewählte Integration passt nicht zu Entitäten: {entity}."}},ui={entity:"Entität",integration:"Integration",required:"Erforderlich",recommended:"Empfohlen",override_headline:"Von der Integration bereitgestellte Überschrift überschreiben",hide_when_no_warning:"Ausblenden, wenn es keine Warnungen gibt",hide_caption:"Beschriftung ausblenden",disable_swiper:"Alarm-Swiper deaktiveren",description:{start:"Ausgewählte Integration erzeugt mehrere Entitäten.",current_expected:"Die erste mit aktuellen, die zweite mit zukünftigen Meldungen.",slots:"Die exakte Anzahl wird durch die Konfiguration bestimmt.",separate_events:"Jeder Ereignistyp erhält eine eigene Entität.",warning_watch_statement_advisory:null,end:"Um keine Warnhinweise zu verpassen, sollten alle ausgewählt werden."},error:{expected_entity:"Die erwartete Warnungs-Entität wurde nicht bereitgestellt. Zukünftige Ereignisse werden nicht angezeigt.",too_many_entities:"Es wurden mehr Entitäten als erwartet erzeugt. Erwarten wurde(n) {expected}, vorhanden sind {got}.",duplicate:"Doppelte Entitäten in Liste gefunden"},scaling_mode:null,scaling_mode_options:{disabled:null,headline:null,scale:null,headline_and_scale:null}},fi={no_warnings:"Keine Warnungen",nuclear:"Nuklear",tornado:"Tornado",hurricane:"Orkan/Hurrikan",wind:"Wind",earthquake:"Erdbeben",volcano:"Vulkanaktivität",snow_ice:"Schnee/Eis",fog:"Nebel",high_temperature:"Hohe Temperatur",low_temperature:"Niedrige Temperatur",coastal_event:"Küstenereignis",tsunami:"Tsunami",sea_event:"Meeresereignis",forest_fire:"Waldbrand",avalanches:"Lawinen",thunderstorms:"Gewitter",rain:"Regen",flooding:"Hochwasser/Flut",air_quality:"Luftqualität",dust:"Staub"},gi={yellow:{event:"Gelbe Warnung: {event}",generic:"Gelbe Warnung",color:"Gelb"},orange:{event:"Orange Warnung: {event}",generic:"Orange Warnung",color:"Orange"},red:{event:"Rote Warnung: {event}",generic:"Rote Warnung",color:"Rot"}},vi={$schema:"../schema/schema.json",common:mi,error:hi,editor:ui,events:fi,messages:gi},bi=Object.freeze({__proto__:null,$schema:"../schema/schema.json",common:mi,error:hi,editor:ui,events:fi,messages:gi,default:vi}),yi={name:"Meteoalarm Card",description:"Meteoalarm card warns you about current weather events.",warning:"Warning",expected:"Expected",unavailable:{long:"Integration is unavailable",short:"Unavailable"}},xi={missing_entity:"Specifying entities is required!",invalid_integration:"This integration is not valid!",invalid_scaling_mode:"This scaling mode is not valid!",entity_invalid:{single:"Selected integration doesn't match selected entity.",multiple:"Selected integration doesn't match selected entities: {entity}."}},_i={entity:"Entity",integration:"Integration",required:"Required",recommended:"Recommended",override_headline:"Override headline provided by integration",hide_when_no_warning:"Hide when there are no warnings",hide_caption:"Hide caption",disable_swiper:"Disable alerts swiper",description:{start:"Selected integration creates multiple entities.",current_expected:"First one with the current events and the second one with future ones.",slots:"The exact amount of them created is determined by it's configuration.",separate_events:"Each event kind has a separate entity.",warning_watch_statement_advisory:"There are 4 entities: warnings, watches, statements and advisories.",end:"You should add all of them to make sure you don't miss any alerts."},error:{expected_entity:"Expected warnings entity haven't been provided. Future event won't be shown.",too_many_entities:"More entities than expected haven't been provided. Expected {expected} entities, got {got} entities.",duplicate:"Found duplicates in entities list."},scaling_mode:"Scaling Mode",scaling_mode_options:{disabled:"Disabled",headline:"Headline Only",scale:"Scale Only",headline_and_scale:"Headline and scale"}},wi={no_warnings:"No warnings",nuclear:"nuclear event",tornado:"tornado",hurricane:"hurricane",wind:"wind",earthquake:"earthquake",volcano:"volcano",snow_ice:"snow/ice",fog:"fog",high_temperature:"high temperature",low_temperature:"low temperature",coastal_event:"coastal event",tsunami:"tsunami",sea_event:"sea event",forest_fire:"forest fire",avalanches:"avalanches",thunderstorms:"thunderstorms",rain:"rain",flooding:"flooding",air_quality:"air quality",dust:"dust"},Ei={yellow:{event:"Yellow {event} warning",generic:"Yellow warning",color:"Yellow"},orange:{event:"Orange {event} warning",generic:"Orange warning",color:"Orange"},red:{event:"Red {event} warning",generic:"Red warning",color:"Red"}},Ai={$schema:"../schema/schema.json",common:yi,error:xi,editor:_i,events:wi,messages:Ei},Ci=Object.freeze({__proto__:null,$schema:"../schema/schema.json",common:yi,error:xi,editor:_i,events:wi,messages:Ei,default:Ai}),Ti={name:"Tarjeta Meteoalarm",description:"La tarjeta Meteoalarm le advierte sobre eventos meteorológicos actuales.",warning:"Aviso",expected:"Esperado",unavailable:{long:"La integración no está disponible",short:"No disponible"}},Si={missing_entity:"¡Se requiere especificar la entidad!",invalid_integration:"¡Esta integración no es válida!",invalid_scaling_mode:null,entity_invalid:{single:"La integración seleccionada no coincide con la entidad seleccionada.",multiple:"La integración seleccionada no coincide con las entidades seleccionadas: {entity}."}},Ii={entity:"Entidad",integration:"Integración",required:"Necesario",recommended:"Recomendado",override_headline:"Sobreescribir cabecera",hide_when_no_warning:"Ocultar cuando no hay avisos",hide_caption:"Ocultar título",disable_swiper:"Deshabilitar el deslizador de alertas",description:{start:"La integración seleccionada crea múltiples entidades.",current_expected:"El primero con los eventos actuales y el segundo con los futuros.",slots:"La cantidad exacta creada está determinada por su configuración.",separate_events:"Cada tipo de evento tiene una entidad separada.",warning_watch_statement_advisory:null,end:"Debes agregarlos todos para asegurarte de no perderte ninguna alerta."},error:{expected_entity:"No se ha proporcionado la entidad de avisos esperada. No se mostrarán futuros eventos.",too_many_entities:"No se han proporcionado más entidades de las esperadas. Esperaba {expected} entidades, obtuve {obtuve} entidades.",duplicate:"Encontrados duplicados en la lista de entidades."},scaling_mode:null,scaling_mode_options:{disabled:null,headline:null,scale:null,headline_and_scale:null}},ki={no_warnings:"Sin avisos",nuclear:"evento nuclear",tornado:"tornado",hurricane:"huracán",wind:"viento",earthquake:"terremoto",volcano:"volcán",snow_ice:"nieve/hielo",fog:"niebla",high_temperature:"alta temperatura",low_temperature:"baja temperatura",coastal_event:"fenómeno costero",tsunami:"tsunami",sea_event:"evento marino",forest_fire:"incendio forestal",avalanches:"aludes",thunderstorms:"tormentas",rain:"lluvia",flooding:"inundación",air_quality:"calidad del aire",dust:"polvo"},Oi={yellow:{event:"Alerta amarilla por {event}",generic:"Alerta amarilla",color:"Amarillo"},orange:{event:"Alerta naranja por {event}",generic:"Alerta naranja",color:"Naranja"},red:{event:"Alerta roja por {event}",generic:"Alerta roja",color:"Rojo"}},Li={$schema:"../schema/schema.json",common:Ti,error:Si,editor:Ii,events:ki,messages:Oi},zi=Object.freeze({__proto__:null,$schema:"../schema/schema.json",common:Ti,error:Si,editor:Ii,events:ki,messages:Oi,default:Li}),Ri={name:"Ohtlike ilmanähtuste kaart",description:"Äärmuslike ilmaolude hoiatus.",warning:"Hoiatus",expected:"Eeldatavalt",unavailable:{long:"Olem pole saadaval",short:"Pole saadaval"}},Mi={missing_entity:"Vajalik on Meteoalarmi olem!",invalid_integration:"Meteoalarmi sidumine on vale!",invalid_scaling_mode:"Skaleerimisviga",entity_invalid:{single:"Skaleerimisviga",multiple:"Skaleerimisvead"}},$i={entity:"Olem",integration:"Sidumine",required:"Nõutav",recommended:"Soovituslik",override_headline:"Pealdise alistamine",hide_when_no_warning:"Hoiatuste puudumisel peida kaart",hide_caption:"Peida päis",disable_swiper:"Keela libitamine",description:{start:"Algus",current_expected:"Eeldatav",slots:"esinemist",separate_events:"eraldi nähtused",warning_watch_statement_advisory:null,end:"Lõpp"},error:{expected_entity:"Eeldatav olem",too_many_entities:"Liiga palju olemeid",duplicate:"Duplikaat"},scaling_mode:"Skaleerimine",scaling_mode_options:{disabled:"keelatud",headline:"päis",scale:"suurus",headline_and_scale:"päis ja suurus"}},Fi={no_warnings:"Hoiatusi hetkel pole",nuclear:"radioaktiivne saaste",tornado:"tornaado",hurricane:"tuulispask",wind:"tugev tuul",earthquake:"maavärin",volcano:"vulkaanipurse",snow_ice:"lumi või jää",fog:"udu",high_temperature:"kuumalaine",low_temperature:"külmalaine",coastal_event:"rannikumeri",tsunami:"tsunami",sea_event:"oht rannikul",forest_fire:"metsapõleng",avalanches:"laviin",thunderstorms:"äike",rain:"sademed",flooding:"uputus",air_quality:"õhu kvaliteet",dust:"tolm"},Di={yellow:{event:"Kollane hoiatus {event}",generic:"Kollane hoiatus",color:"Kollane"},orange:{event:"Oranž hoiatus {event}",generic:"Oranž hoiatus",color:"Oranž"},red:{event:"Punane hoiatus {event}",generic:"Punane hoiatus",color:"Punane"}},Ni={$schema:"../schema/schema.json",common:Ri,error:Mi,editor:$i,events:Fi,messages:Di},Pi=Object.freeze({__proto__:null,$schema:"../schema/schema.json",common:Ri,error:Mi,editor:$i,events:Fi,messages:Di,default:Ni}),Bi={name:"Carte Meteoalarm",description:"La carte Meteoalarm vous avertit des phénomènes météorologiques en cours.",warning:null,expected:null,unavailable:{long:"L'intégration est indisponible",short:"Indisponible"}},Hi={missing_entity:"L'entité est requise !",invalid_integration:"Cette intégration n'est pas valide !",invalid_scaling_mode:null,entity_invalid:{single:null,multiple:null}},ji={entity:"Entité",integration:"Intégration",required:"Requis",recommended:"Recommandé",override_headline:"Remplacer le titre",hide_when_no_warning:"Cacher en l'absence d'alerte",hide_caption:null,disable_swiper:null,description:{start:null,current_expected:null,slots:null,separate_events:null,warning_watch_statement_advisory:null,end:null},error:{expected_entity:null,too_many_entities:null,duplicate:null},scaling_mode:null,scaling_mode_options:{disabled:null,headline:null,scale:null,headline_and_scale:null}},Vi={no_warnings:"Aucune alerte",nuclear:"nucléaire",tornado:"tornade",hurricane:"ouragan",wind:"vent",earthquake:"tremblement de terre",volcano:"volcan",snow_ice:"neige/verglas",fog:"brouillard",high_temperature:"canicule",low_temperature:"grand froid",coastal_event:"événement côtier",tsunami:"tsunami",sea_event:"événement maritime",forest_fire:"feu de forêt",avalanches:"avalanches",thunderstorms:"orages",rain:"pluie",flooding:"inondation",air_quality:null,dust:null},Ui={yellow:{event:"Alerte jaune {event}",generic:"Alerte jaune",color:"Jaune"},orange:{event:"Alerte orange {event}",generic:"Alerte orange",color:"Orange"},red:{event:"Alerte rouge {event}",generic:"Alerte rouge",color:"Rouge"}},Gi={$schema:"../schema/schema.json",common:Bi,error:Hi,editor:ji,events:Vi,messages:Ui},Wi=Object.freeze({__proto__:null,$schema:"../schema/schema.json",common:Bi,error:Hi,editor:ji,events:Vi,messages:Ui,default:Gi}),qi={name:"Meteoalarm Card",description:"Meteoalarm card upozorava na trenutne vremenske prilike.",warning:"Upozorenje",expected:"Očekivano",unavailable:{long:"Integracija nedostupna",short:"Nedostupno"}},Yi={missing_entity:"Potrebno je navesti entitet!",invalid_integration:"Integracija nije ispravna!",invalid_scaling_mode:null,entity_invalid:{single:"Odabrana integracija se ne podudara s odabranim entitetom.",multiple:"Odabrana integracija se ne podudara s odabranim entitetima: {entity}."}},Xi={entity:"Entitet",integration:"Integracija",required:"Obavezno",recommended:"Preporučeno",override_headline:"Nadjačaj naslov",hide_when_no_warning:"Sakri kada nema upozorenja",hide_caption:"Sakri naslov",disable_swiper:"Isključi klizač upozorenja",description:{start:"Odabrana integracija kreirat će više entiteta.",current_expected:"Prvi s trenutnim događajima, a drugi sa budućim.",slots:"Točan broj je definiran konfiguracijom.",separate_events:"Svaki tip događaja ima svoj entitet.",warning_watch_statement_advisory:null,end:"Trebate ih dodati sve kako ne bi propustili niti jedno uzpozorenje."},error:{expected_entity:"Očekivani entiteti upozorenja nisu pronađeni. Budući događaji neće biti prikazani.",too_many_entities:"Pronađeno je više entiteta nego je očekivano. Očekivani {expected} entiteti, pronađeni {got} entiteti.",duplicate:"Pronađeni su dupli entiteti u listi."},scaling_mode:null,scaling_mode_options:{disabled:null,headline:null,scale:null,headline_and_scale:null}},Ki={no_warnings:"Nema upozorenja",nuclear:"nuklearni događaj",tornado:"tornado",hurricane:"uragan",wind:"vjetar",earthquake:"potres",volcano:"vulkan",snow_ice:"snijeg/poledicu",fog:"maglu",high_temperature:"visoku temperaturu",low_temperature:"nisku temperaturu",coastal_event:"obalni događaj",tsunami:"tsunami",sea_event:"morski događaj",forest_fire:"šumski požar",avalanches:"lavinu",thunderstorms:"grmljavinsku oluju",rain:"kišu",flooding:"poplavu",air_quality:"kvalitetu zraka",dust:"pršinu"},Qi={yellow:{event:"Žuto upozorenje za {event}",generic:"Žuto upozorenje",color:"Žuto"},orange:{event:"Narančasto upozorenje za {event}",generic:"Narančasto upozorenje",color:"Narančasto"},red:{event:"Crveno upozorenje za {event}",generic:"Crveno upozorenje",color:"Crveno"}},Zi={$schema:"../schema/schema.json",common:qi,error:Yi,editor:Xi,events:Ki,messages:Qi},Ji=Object.freeze({__proto__:null,$schema:"../schema/schema.json",common:qi,error:Yi,editor:Xi,events:Ki,messages:Qi,default:Zi}),en={name:"Scheda Meteoalarm",description:"La scheda Meteoalarm ti avvisa degli eventi meteorologici in corso.",warning:"Attenzione",expected:"Previsto",unavailable:{long:"L'integrazione non è disponibile",short:"Non disponibile"}},tn={missing_entity:"È necessario specificare un'entità!",invalid_integration:"Questa integrazione non è valida!",invalid_scaling_mode:null,entity_invalid:{single:"L'integrazione selezionata non corrisponde all'entità selezionata.",multiple:"L'integrazione selezionata non corrisponde alle entità selezionate: {entity}."}},nn={entity:"Entità",integration:"Integrazione",required:"Richiesto",recommended:"Consigliato",override_headline:"Sovrascrivere l'intestazione fornita dall'integrazione",hide_when_no_warning:"Nascondi quando non ci sono avvisi",hide_caption:"Nascondi la descrizione",disable_swiper:"Disabilità lo swipe delle allerte",description:{start:"L'integrazione selezionata crea più entità.",current_expected:"La prima con gli eventi attuali e la seconda con quelli futuri.",slots:"L'esatta quantità è determinata dalla sua configurazione.",separate_events:"Ogni evento ha un'entità propria e separata.",warning_watch_statement_advisory:null,end:"Dovresti aggiungerle tutte per essere sicuro di non perdere nessun avviso."},error:{expected_entity:"L'entità di avviso futuro non è stata inserita. Gli eventi futuri non saranno mostrati.",too_many_entities:"Più entità del previsto non sono state fornite. Entita previste: {expected}; Entita fornite: {got} entità.",duplicate:"Trovati duplicati nell'elenco delle entità."},scaling_mode:null,scaling_mode_options:{disabled:null,headline:null,scale:null,headline_and_scale:null}},rn={no_warnings:"Nessun allarme",nuclear:"Nucleare",tornado:"Tornadi",hurricane:"Uragani",wind:"Vento",earthquake:"Terremoti",volcano:"Eruzione vulcanica",snow_ice:"Neve/Ghiaccio",fog:"Nebbia",high_temperature:"Alte temperature",low_temperature:"Basse temperature",coastal_event:"Eventi costieri",tsunami:"Tsunami",sea_event:"Eventi marini",forest_fire:"Incendi boschivi",avalanches:"Valanghe",thunderstorms:"Temporali",rain:"Pioggia",flooding:"Allagamenti",air_quality:"Qualità dell'aria",dust:"Tempesta di Sabbia/Polveri"},an={yellow:{event:"Allerta Gialla: {event}",generic:"Allerta Gialla",color:"Giallo"},orange:{event:"Allerta Arancione: {event}",generic:"Allerta Arancione",color:"Arancione"},red:{event:"Allerta Rossa: {event}",generic:"Allerta Rossa",color:"Rosso"}},on={$schema:"../schema/schema.json",common:en,error:tn,editor:nn,events:rn,messages:an},sn=Object.freeze({__proto__:null,$schema:"../schema/schema.json",common:en,error:tn,editor:nn,events:rn,messages:an,default:on}),ln={name:"Meteoalarm Kaart",description:"Meteoalarm kaart waarschuwt u voor actuele weersomstandigheden.",warning:"Waarschuwing",expected:"Verwacht",unavailable:{long:"De integratie is niet beschikbaar",short:"Onbeschikbaar"}},dn={missing_entity:"Het specificeren van de entiteit is vereist!",invalid_integration:"Deze integratie is niet geldig!",invalid_scaling_mode:"Deze schalings modus is niet geldig!",entity_invalid:{single:"De geselecteerde integratie komt niet overeen met de geselecteerde entiteit.",multiple:"De geselecteerde integratie komt niet overeen met de geselecteerde entiteiten: {entity}."}},cn={entity:"Entiteit",integration:"Integratie",required:"Verplicht",recommended:"Aanbevolen",override_headline:"Koptekst overschrijven",hide_when_no_warning:"Verbergen als er geen waarschuwingen zijn",hide_caption:"Verberg onderschrift",disable_swiper:"Schakel waarschuwingen swiper uit",description:{start:"De geselecteerde integratie maakt meerdere entiteiten aan.",current_expected:"De eerste is eerste is een voor een huidige waarschuring en de tweede is voor toekomstige.",slots:"Het precieze aantal is afhankelijk van de configuratie.",separate_events:"Ieder waarschuwingssoort heeft een eigen entiteit.",warning_watch_statement_advisory:"Er zijn 4 entiteittypen: waarschuwingen, berichten, verklaringen en adviezen.",end:"U zult alle typen moeten toevoegen zodat u geen bericht mist."},error:{expected_entity:"Er is geen entiteit voor verwachte waarschuwingen opgegeven. Toekomstig evenement wordt niet getoond.",too_many_entities:"Er zijn niet meer entiteiten opgegeven dan verwacht. Verwachtte {expected} entiteiten, kreeg {got} entiteiten.",duplicate:"Er zijn duplicaten gevonden in de entiteitenlijst."},scaling_mode:"Schalingsmodus",scaling_mode_options:{disabled:"Uitgeschakeld",headline:"Alleen koptekst",scale:"Alleen schalen",headline_and_scale:"Koptekst en schalen"}},pn={no_warnings:"Geen waarschuwingen",nuclear:"nuclear",tornado:"tornado",hurricane:"orkaan",wind:"wind",earthquake:"aardbeving",volcano:"vulkaan",snow_ice:"sneeuw/ijzel/bevriezing",fog:"mist",high_temperature:"zeer hoge temperatuur",low_temperature:"zeer lage temperatuur",coastal_event:"kustbedreiging",tsunami:"tsunami",sea_event:"zeeniveau",forest_fire:"bos- en heidebranden",avalanches:"lawines",thunderstorms:"onweer",rain:"regen",flooding:"overstroming",air_quality:"luchtkwaliteit",dust:"stof"},mn={yellow:{event:"Waarschuwing geel {event}",generic:"Gele waarschuwing",color:"Geel"},orange:{event:"Waarschuwing oranje {event}",generic:"Oranje waarschuwing",color:"Oranje"},red:{event:"Waarschuwing rood {event}",generic:"Rode waarschuwing",color:"Rood"}},hn={$schema:"../schema/schema.json",common:ln,error:dn,editor:cn,events:pn,messages:mn},un=Object.freeze({__proto__:null,$schema:"../schema/schema.json",common:ln,error:dn,editor:cn,events:pn,messages:mn,default:hn}),fn={name:"Karta Meteoalarm",description:"Meteoalarm ostrzega cię przed aktualnymi zdarzeniami pogodowymi.",warning:"Ostrzeżenie",expected:"Spodziewane",unavailable:{long:"Integracja nie jest dostępna",short:"Niedostępny"}},gn={missing_entity:"Podanie encji jest wymagane!",invalid_integration:"Nieprawidłowa integracja!",invalid_scaling_mode:null,entity_invalid:{single:"Wybrana integracja nie opdowiada wybranej encji.",multiple:"Wybrana integracja nie odpowiada wybranym encjom: {entity}."}},vn={entity:"Encja",integration:"Integracja",required:"Wymagane",recommended:"Zalecane",override_headline:"Nadpisz nagłówek",hide_when_no_warning:"Ukryj kiedy nie ma ostrzeżeń",hide_caption:"Ukryj podpis",disable_swiper:"Wyłacz swiper ostrzeżeń",description:{start:"Wybrana integracja tworzy kilka encji.",current_expected:"Pierwszą z aktualnymi zdarzeniami a drugą z przyszłymi.",slots:"Dokładna ilość określana jest przez jej konfiguracje.",separate_events:"Każde ostrzeżenie ma swoją własną encję",warning_watch_statement_advisory:"Tworzone są 3 encje: warnings, watches, statements, advisories.",end:"Powinieneś dodać wszystkie encje aby mieć pewność że nie przegapisz żadnych ostrzeżeń."},error:{expected_entity:"Nie podano encji z przyszłymi zdarzeniami pogodowymi. Przyszłe ostrzeżenia nie bedą pokazywane.",too_many_entities:"Podano więcej encji niż się spodziewano. Spodziewano: {expected} encji, otrzymano {got} encji.",duplicate:"Znaleziono duplikaty w liście encji."},scaling_mode:"Tryb skalowania",scaling_mode_options:{disabled:"Wyłaczony",headline:"Tylko Nagłówek",scale:"Tylko Skala",headline_and_scale:"Nagłówek i skala"}},bn={no_warnings:"Brak ostrzeżeń",nuclear:"nuklearne",tornado:"tornado",hurricane:"huragan",wind:"wiatr",earthquake:"trzęsienie ziemi",volcano:"aktywność wulkaniczna",snow_ice:"śnieg/oblodzenie",fog:"mgły",high_temperature:"wysoka temperatura",low_temperature:"silne mrozy",coastal_event:"zjawiska strefy brzegowej",tsunami:"tsunami",sea_event:"wydarzenie na morzu",forest_fire:"pożary lasu",avalanches:"lawiny",thunderstorms:"burze",rain:"deszcz",flooding:"powodzie",air_quality:"jakość powietrza",dust:"pył"},yn={yellow:{event:"Żółty alert na {event}",generic:"Żółty alert",color:"Żółty"},orange:{event:"Pomarańczowy alert na {event}",generic:"Pomarańczowy alert",color:"Pomarańczowy"},red:{event:"Czerwony alert na {event}",generic:"Czerwony alert",color:"Czerwony"}},xn={$schema:"../schema/schema.json",common:fn,error:gn,editor:vn,events:bn,messages:yn},_n=Object.freeze({__proto__:null,$schema:"../schema/schema.json",common:fn,error:gn,editor:vn,events:bn,messages:yn,default:xn}),wn={name:"Cartão Meteoalarm",description:"O cartão Meteoalarm adverte sobre eventos meteorológicos actuais.",warning:"Aviso",expected:"Esperado",unavailable:{long:"A integração não está disponível",short:"Indisponível"}},En={missing_entity:"É necessário especificar a entidade!",invalid_integration:"A integração não é válida!",invalid_scaling_mode:null,entity_invalid:{single:"A integração seleccionada não conincide com a entidade seleccionada.",multiple:"A integração seleccionada não conincide com as entidades seleccionadas: {entity}."}},An={entity:"Entidade",integration:"Integração",required:"Necessário",recommended:"Recomendado",override_headline:"Modificar cabeçalho",hide_when_no_warning:"Ocultar quando não há avisos",hide_caption:"Ocultar título",disable_swiper:"Desactivar o deslizador de alertas",description:{start:"A integração seleccionada cria múltiplas entidades.",current_expected:"O primeiro com eventos actuais e o segundo com eventos futuros.",slots:"A quantidade exacta criada é determinada pela sua configuracão.",separate_events:"Cada tipo de evento tem uma entidade separada.",warning_watch_statement_advisory:null,end:"Devem ser agregados para não serem perdidos alertas."},error:{expected_entity:"Não foi especificada entidade de avisos esperada. Não se irão mostrar eventos futuros.",too_many_entities:"Foram especificadas mais entidades do que esperado. Esperavam-se {expected} entidades, mas foram especificadas {got} entidades.",duplicate:"Encontrados duplicados na lista de entidades."},scaling_mode:null,scaling_mode_options:{disabled:null,headline:null,scale:null,headline_and_scale:null}},Cn={no_warnings:"Sem avisos",nuclear:"evento nuclear",tornado:"tornado",hurricane:"furacão",wind:"vento",earthquake:"terremoto",volcano:"vulcão",snow_ice:"neve/gelo",fog:"nevoeiro",high_temperature:"alta temperatura",low_temperature:"baixa temperatura",coastal_event:"fenómeno costeiro",tsunami:"tsunami",sea_event:"evento marinho",forest_fire:"incêndio forestal",avalanches:"avalanches",thunderstorms:"trovoadas",rain:"chuva",flooding:"inundação",air_quality:"qualidade do ar",dust:"pó"},Tn={yellow:{event:"Alerta amarelo por {event}",generic:"Alerta amarelo",color:"Amarelo"},orange:{event:"Alerta laranja por {event}",generic:"Alerta laranja",color:"Laranja"},red:{event:"Alerta vermelho por {event}",generic:"Alerta vermelho",color:"Vermelho"}},Sn={$schema:"../schema/schema.json",common:wn,error:En,editor:An,events:Cn,messages:Tn},In=Object.freeze({__proto__:null,$schema:"../schema/schema.json",common:wn,error:En,editor:An,events:Cn,messages:Tn,default:Sn}),kn={name:"Meteoalarm Card",description:"Karta Meteoalarm vás upozorní na aktuálne udalosti počasia.",warning:"Varovanie",expected:"Očakavanie",unavailable:{long:"Integrácia nie je k dispozícii",short:"Nedostupné"}},On={missing_entity:"Je potrebné zadať entitu!",invalid_integration:"Táto integrácia nie je platná!",invalid_scaling_mode:null,entity_invalid:{single:"Vybratá integrácia sa nezhoduje s vybratou entitou.",multiple:"Vybratá integrácia sa nezhoduje s vybratými entitami: {entity}."}},Ln={entity:"Entity",integration:"Integrácia",required:"Požadovaný",recommended:"Odporúčané",override_headline:"Prepísať nadpis",hide_when_no_warning:"Skryť, keď nie sú žiadne upozornenia",hide_caption:"Skryť popis",disable_swiper:"Zakázať posúvanie upozornení",description:{start:"Zvolená integrácia vytvára viacero entít.",current_expected:"Prvý s aktuálnymi udalosťami a druhý s budúcimi",slots:"Ich presné množstvo je určené jeho konfiguráciou.",separate_events:"Každý druh udalosti má samostatnú entitu.",warning_watch_statement_advisory:null,end:"Mali by ste ich pridať všetky, aby ste sa uistili, že vám žiadne upozornenia neuniknú."},error:{expected_entity:"Nebola poskytnutá entita očakávaných upozornení. Budúca udalosť sa nebude zobrazovať.",too_many_entities:"Nebolo poskytnutých viac entít, ako sa očakávalo. Očakávaných entít: {expected}, počet entít: {got}.",duplicate:"V zozname entít sa našli duplikáty."},scaling_mode:null,scaling_mode_options:{disabled:null,headline:null,scale:null,headline_and_scale:null}},zn={no_warnings:"Žiadne upozornenia",nuclear:"jadrová udalosť",tornado:"tornádo",hurricane:"hurikán",wind:"vietor",earthquake:"zemetrasenie",volcano:"sopka",snow_ice:"sneh/ľad",fog:"hmla",high_temperature:"vysoká teplota",low_temperature:"nízka teplota",coastal_event:"pobrežná udalosť",tsunami:"cunami",sea_event:"námorná udalosť",forest_fire:"lesný požiar",avalanches:"lavíny",thunderstorms:"búrky",rain:"dážď",flooding:"záplavy",air_quality:"kvalitu ovzdušia",dust:"peľ"},Rn={yellow:{event:"Žlté {event} varovanie",generic:"Žlté varovanie",color:"Žlté"},orange:{event:"Oranžové {event} varovanie",generic:"Oranžové varovanie",color:"Oranžové"},red:{event:"Červené {event} varovanie",generic:"Červené varovanie",color:"Červené"}},Mn={$schema:"../schema/schema.json",common:kn,error:On,editor:Ln,events:zn,messages:Rn},$n={name:"Meteoalarm Card",description:"Meteoalarm card varnar dig om aktuella väderhändelser.",warning:"Varning",expected:"förväntad",unavailable:{long:"Integrationen är inte tillgänglig",short:"Inte tillgänglig"}},Fn={missing_entity:"Det är nödvändigt att ange en enhet!",invalid_integration:"Denna integration är inte giltig!",invalid_scaling_mode:null,entity_invalid:{single:"Den valda integrationen matchar inte den valda enheten.",multiple:"Den valda integrationen matchar inte de valda enheterna: {entity}."}},Dn={entity:"Enhet",integration:"Integration",required:"Krävs",recommended:"Rekommenderas",override_headline:"Överrida titel",hide_when_no_warning:"Göm när det inte finns några varningar",hide_caption:"Dölj bildtext",disable_swiper:null,description:{start:"Vald integration skapar flera enheter.",current_expected:"Första med nuvarande händelser och andra med framtida händelser.",slots:"Den exakta mängden av dem som skapas bestäms av dess konfiguration.",separate_events:"Varje evenemangstyp har en enskild enhet.",warning_watch_statement_advisory:null,end:"Du bör lägga till samtliga för att inte missa några varningar."},error:{expected_entity:"Förväntad enhet för varningar har inte tilldelats. Framtida event kommer inte visas.",too_many_entities:"Fler enheter än förväntat har inte tilldelats. Förväntade {expected} entiteter, fick {got} enheter",duplicate:"Hittade dupplicerade enheter i listan"},scaling_mode:null,scaling_mode_options:{disabled:null,headline:null,scale:null,headline_and_scale:null}},Nn={no_warnings:"Inga varningar",nuclear:"nukleär",tornado:"tornado",hurricane:"orkan",wind:"vind",earthquake:"jordbävning",volcano:"vulkan",snow_ice:"snö/is",fog:"dimma",high_temperature:"hög temperatur",low_temperature:"låg temperatur",coastal_event:"kusthändelse",tsunami:"flodvåg",sea_event:null,forest_fire:"skogsbrand",avalanches:"laviner",thunderstorms:"åska",rain:"regn",flooding:"översvämning",air_quality:"luftkvalitet",dust:null},Pn={yellow:{event:"Gul varning för {event}",generic:"Gul varning",color:"Gul"},orange:{event:"Orange varning för {event}",generic:"Orange varning",color:"Orange"},red:{event:"Röd varning för {event}",generic:"Röd varning",color:"Röd"}},Bn={$schema:"../schema/schema.json",common:$n,error:Fn,editor:Dn,events:Nn,messages:Pn};const Hn={en:Ci,de:bi,nl:un,pl:_n,et:Pi,fr:Wi,it:sn,es:zi,hr:Ji,sk:Object.freeze({__proto__:null,$schema:"../schema/schema.json",common:kn,error:On,editor:Ln,events:zn,messages:Rn,default:Mn}),sv:Object.freeze({__proto__:null,$schema:"../schema/schema.json",common:$n,error:Fn,editor:Dn,events:Nn,messages:Pn,default:Bn}),cs:pi,pt:In,bg:ri};function jn(e){e.toLocaleLowerCase()!=e&&console.warn(`MeteoalarmCard: Received invalid translation key: ${e}`),e=e.toLocaleLowerCase();let t=localStorage.getItem("selectedLanguage");"null"===t&&(t=null);const i=(t||navigator.language.split("-")[0]||"en").replace(/['"]+/g,"").replace("-","_");let n;try{n=e.split(".").reduce(((e,t)=>e[t]),Hn[i])}catch(t){console.warn(`MeteoalarmCard: Translation for "${e}" is not specified in "${i}" language.`)}if(null==n)try{n=e.split(".").reduce(((e,t)=>e[t]),Hn.en)}catch(t){console.warn(`MeteoalarmCard: Translation for "${e}" is not specified in fallback english language.`)}return null==n&&(n=e),n}class Vn{static unavailableCard(){return{isActive:!0,entity:void 0,icon:"cloud-question",cssClass:Qt.getLevel(qt.None).cssClass,headlines:[jn("common.unavailable.long"),jn("common.unavailable.short")]}}static noWarningsCard(e){return{isActive:!1,entity:e,icon:"shield-outline",cssClass:Qt.getLevel(qt.None).cssClass,headlines:[jn("events.no_warnings")]}}}class Un{constructor(e){this.integration=e}getEvents(e,t=!1,i=!1,n=!1,r=[],a=[]){if(this.isAnyEntityUnavailable(e))return[Vn.unavailableCard()];this.checkIfIntegrationSupportsEntities(e);let o=this.sortAlerts(this.graterAllAlerts(e));this.validateAlert(o),o=this.filterAlerts(o,r,a);const s=[];for(const e of o){const t=Qt.getEvent(e.event),r=Qt.getLevel(e.level),a=this.generateHeadlines(t,r);let o,l;!i&&e.headline&&a.unshift(e.headline),n||e.kind==Ut.Expected&&(o=jn("common.expected"),l="clock-outline"),s.push({isActive:!0,entity:e._entity,icon:t.icon,cssClass:r.cssClass,headlines:a,caption:o,captionIcon:l})}return 0==s.length?[Vn.noWarningsCard(e[0])]:t?s.slice(1):s}graterAllAlerts(e){const t=[];for(const i of e){if(!this.integration.alertActive(i))continue;let e=this.integration.getAlerts(i);if(Array.isArray(e)||(e=[e]),0==e.length)throw new Error("Integration is active but did not return any events");for(const n of e)t.push(Object.assign(Object.assign({},n),{_entity:i}))}return t}filterAlerts(e,t,i){if(0==i.length&&0==t.length)return e;const n=[];for(const r of e){const e=Qt.events.find((e=>e.type==r.event)),a=Qt.levels.find((e=>e.type==r.level));i.includes(e.fullName)||t.includes(a.fullName)||n.push(r)}return n}sortAlerts(e){let t=[...e];return t=t.sort(((e,t)=>{const i=Qt.events,n=i.indexOf(i.find((t=>t.type==e.event))),r=i.indexOf(i.find((e=>e.type==t.event)));return r-n})),t=t.sort(((e,t)=>t.level-e.level)),t=t.sort(((e,t)=>void 0===e.kind?0:e.kind==Ut.Current&&t.kind==Ut.Expected?-1:e.kind==Ut.Expected&&t.kind==Ut.Current?1:0)),t}validateAlert(e){for(const t of e){if(void 0===t.event||void 0===t.level)throw new Error(`[Alert QA Error] Invalid event object received: event: ${t.event} level: ${t.level}`);if(!this.integration.metadata.returnHeadline&&t.headline)throw new Error("[Alert QA Error] metadata.returnHeadline is false but headline was returned");if(this.integration.metadata.type==Vt.CurrentExpected&&null==t.kind)throw new Error("[Alert QA Error] CurrentExpected type is required to provide alert.kind");if(this.integration.metadata.type!=Vt.CurrentExpected&&null!=t.kind)throw new Error("[Alert QA Error] only CurrentExpected type can return alert.kind");if(!this.integration.metadata.returnMultipleAlerts&&e.length>1)throw new Error("[Alert QA Error] returnMultipleAlerts is false but more than one alert was returned")}}generateHeadlines(e,t){if(e.type===Wt.Unknown)return[jn(t.translationKey+".generic"),jn(t.translationKey+".color")];{const i=jn(e.translationKey);return[jn(t.translationKey+".event").replace("{event}",jn(e.translationKey)),i.charAt(0).toUpperCase()+i.slice(1)]}}isAnyEntityUnavailable(e){return e.some((e=>null==e||"unavailable"===(e.attributes.status||e.attributes.state||e.state)))}checkIfIntegrationSupportsEntities(e){if(!e.every((e=>this.integration.supports(e)))){if(1==e.length)throw new Error(jn("error.entity_invalid.single"));{const t=e.filter((e=>!this.integration.supports(e)));throw new Error(jn("error.entity_invalid.multiple").replace("{entity}",t.map((e=>e.entity_id)).join(", ")))}}}}var Gn=g` + @font-face { + font-family: swiper-icons; + src: url('data:application/font-woff;charset=utf-8;base64, d09GRgABAAAAAAZgABAAAAAADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABGRlRNAAAGRAAAABoAAAAci6qHkUdERUYAAAWgAAAAIwAAACQAYABXR1BPUwAABhQAAAAuAAAANuAY7+xHU1VCAAAFxAAAAFAAAABm2fPczU9TLzIAAAHcAAAASgAAAGBP9V5RY21hcAAAAkQAAACIAAABYt6F0cBjdnQgAAACzAAAAAQAAAAEABEBRGdhc3AAAAWYAAAACAAAAAj//wADZ2x5ZgAAAywAAADMAAAD2MHtryVoZWFkAAABbAAAADAAAAA2E2+eoWhoZWEAAAGcAAAAHwAAACQC9gDzaG10eAAAAigAAAAZAAAArgJkABFsb2NhAAAC0AAAAFoAAABaFQAUGG1heHAAAAG8AAAAHwAAACAAcABAbmFtZQAAA/gAAAE5AAACXvFdBwlwb3N0AAAFNAAAAGIAAACE5s74hXjaY2BkYGAAYpf5Hu/j+W2+MnAzMYDAzaX6QjD6/4//Bxj5GA8AuRwMYGkAPywL13jaY2BkYGA88P8Agx4j+/8fQDYfA1AEBWgDAIB2BOoAeNpjYGRgYNBh4GdgYgABEMnIABJzYNADCQAACWgAsQB42mNgYfzCOIGBlYGB0YcxjYGBwR1Kf2WQZGhhYGBiYGVmgAFGBiQQkOaawtDAoMBQxXjg/wEGPcYDDA4wNUA2CCgwsAAAO4EL6gAAeNpj2M0gyAACqxgGNWBkZ2D4/wMA+xkDdgAAAHjaY2BgYGaAYBkGRgYQiAHyGMF8FgYHIM3DwMHABGQrMOgyWDLEM1T9/w8UBfEMgLzE////P/5//f/V/xv+r4eaAAeMbAxwIUYmIMHEgKYAYjUcsDAwsLKxc3BycfPw8jEQA/gZBASFhEVExcQlJKWkZWTl5BUUlZRVVNXUNTQZBgMAAMR+E+gAEQFEAAAAKgAqACoANAA+AEgAUgBcAGYAcAB6AIQAjgCYAKIArAC2AMAAygDUAN4A6ADyAPwBBgEQARoBJAEuATgBQgFMAVYBYAFqAXQBfgGIAZIBnAGmAbIBzgHsAAB42u2NMQ6CUAyGW568x9AneYYgm4MJbhKFaExIOAVX8ApewSt4Bic4AfeAid3VOBixDxfPYEza5O+Xfi04YADggiUIULCuEJK8VhO4bSvpdnktHI5QCYtdi2sl8ZnXaHlqUrNKzdKcT8cjlq+rwZSvIVczNiezsfnP/uznmfPFBNODM2K7MTQ45YEAZqGP81AmGGcF3iPqOop0r1SPTaTbVkfUe4HXj97wYE+yNwWYxwWu4v1ugWHgo3S1XdZEVqWM7ET0cfnLGxWfkgR42o2PvWrDMBSFj/IHLaF0zKjRgdiVMwScNRAoWUoH78Y2icB/yIY09An6AH2Bdu/UB+yxopYshQiEvnvu0dURgDt8QeC8PDw7Fpji3fEA4z/PEJ6YOB5hKh4dj3EvXhxPqH/SKUY3rJ7srZ4FZnh1PMAtPhwP6fl2PMJMPDgeQ4rY8YT6Gzao0eAEA409DuggmTnFnOcSCiEiLMgxCiTI6Cq5DZUd3Qmp10vO0LaLTd2cjN4fOumlc7lUYbSQcZFkutRG7g6JKZKy0RmdLY680CDnEJ+UMkpFFe1RN7nxdVpXrC4aTtnaurOnYercZg2YVmLN/d/gczfEimrE/fs/bOuq29Zmn8tloORaXgZgGa78yO9/cnXm2BpaGvq25Dv9S4E9+5SIc9PqupJKhYFSSl47+Qcr1mYNAAAAeNptw0cKwkAAAMDZJA8Q7OUJvkLsPfZ6zFVERPy8qHh2YER+3i/BP83vIBLLySsoKimrqKqpa2hp6+jq6RsYGhmbmJqZSy0sraxtbO3sHRydnEMU4uR6yx7JJXveP7WrDycAAAAAAAH//wACeNpjYGRgYOABYhkgZgJCZgZNBkYGLQZtIJsFLMYAAAw3ALgAeNolizEKgDAQBCchRbC2sFER0YD6qVQiBCv/H9ezGI6Z5XBAw8CBK/m5iQQVauVbXLnOrMZv2oLdKFa8Pjuru2hJzGabmOSLzNMzvutpB3N42mNgZGBg4GKQYzBhYMxJLMlj4GBgAYow/P/PAJJhLM6sSoWKfWCAAwDAjgbRAAB42mNgYGBkAIIbCZo5IPrmUn0hGA0AO8EFTQAA'); + font-weight: 400; + font-style: normal; + } + :root { + --swiper-theme-color: #007aff; + } + .swiper { + margin-left: auto; + margin-right: auto; + position: relative; + overflow: hidden; + list-style: none; + padding: 0; + z-index: 1; + } + .swiper-vertical > .swiper-wrapper { + flex-direction: column; + } + .swiper-wrapper { + position: relative; + width: 100%; + height: 100%; + z-index: 1; + display: flex; + transition-property: transform; + box-sizing: content-box; + } + .swiper-android .swiper-slide, + .swiper-wrapper { + transform: translate3d(0px, 0, 0); + } + .swiper-pointer-events { + touch-action: pan-y; + } + .swiper-pointer-events.swiper-vertical { + touch-action: pan-x; + } + .swiper-slide { + flex-shrink: 0; + width: 100%; + height: 100%; + position: relative; + transition-property: transform; + } + .swiper-slide-invisible-blank { + visibility: hidden; + } + .swiper-autoheight, + .swiper-autoheight .swiper-slide { + height: auto; + } + .swiper-autoheight .swiper-wrapper { + align-items: flex-start; + transition-property: transform, height; + } + .swiper-backface-hidden .swiper-slide { + transform: translateZ(0); + -webkit-backface-visibility: hidden; + backface-visibility: hidden; + } + .swiper-3d, + .swiper-3d.swiper-css-mode .swiper-wrapper { + perspective: 1200px; + } + .swiper-3d .swiper-cube-shadow, + .swiper-3d .swiper-slide, + .swiper-3d .swiper-slide-shadow, + .swiper-3d .swiper-slide-shadow-bottom, + .swiper-3d .swiper-slide-shadow-left, + .swiper-3d .swiper-slide-shadow-right, + .swiper-3d .swiper-slide-shadow-top, + .swiper-3d .swiper-wrapper { + transform-style: preserve-3d; + } + .swiper-3d .swiper-slide-shadow, + .swiper-3d .swiper-slide-shadow-bottom, + .swiper-3d .swiper-slide-shadow-left, + .swiper-3d .swiper-slide-shadow-right, + .swiper-3d .swiper-slide-shadow-top { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 10; + } + .swiper-3d .swiper-slide-shadow { + background: rgba(0, 0, 0, 0.15); + } + .swiper-3d .swiper-slide-shadow-left { + background-image: linear-gradient(to left, rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0)); + } + .swiper-3d .swiper-slide-shadow-right { + background-image: linear-gradient(to right, rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0)); + } + .swiper-3d .swiper-slide-shadow-top { + background-image: linear-gradient(to top, rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0)); + } + .swiper-3d .swiper-slide-shadow-bottom { + background-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0)); + } + .swiper-css-mode > .swiper-wrapper { + overflow: auto; + scrollbar-width: none; + -ms-overflow-style: none; + } + .swiper-css-mode > .swiper-wrapper::-webkit-scrollbar { + display: none; + } + .swiper-css-mode > .swiper-wrapper > .swiper-slide { + scroll-snap-align: start start; + } + .swiper-horizontal.swiper-css-mode > .swiper-wrapper { + scroll-snap-type: x mandatory; + } + .swiper-vertical.swiper-css-mode > .swiper-wrapper { + scroll-snap-type: y mandatory; + } + .swiper-centered > .swiper-wrapper::before { + content: ''; + flex-shrink: 0; + order: 9999; + } + .swiper-centered.swiper-horizontal > .swiper-wrapper > .swiper-slide:first-child { + margin-inline-start: var(--swiper-centered-offset-before); + } + .swiper-centered.swiper-horizontal > .swiper-wrapper::before { + height: 100%; + min-height: 1px; + width: var(--swiper-centered-offset-after); + } + .swiper-centered.swiper-vertical > .swiper-wrapper > .swiper-slide:first-child { + margin-block-start: var(--swiper-centered-offset-before); + } + .swiper-centered.swiper-vertical > .swiper-wrapper::before { + width: 100%; + min-width: 1px; + height: var(--swiper-centered-offset-after); + } + .swiper-centered > .swiper-wrapper > .swiper-slide { + scroll-snap-align: center center; + } + .swiper-virtual .swiper-slide { + -webkit-backface-visibility: hidden; + transform: translateZ(0); + } + .swiper-virtual.swiper-css-mode .swiper-wrapper::after { + content: ''; + position: absolute; + left: 0; + top: 0; + pointer-events: none; + } + .swiper-virtual.swiper-css-mode.swiper-horizontal .swiper-wrapper::after { + height: 1px; + width: var(--swiper-virtual-size); + } + .swiper-virtual.swiper-css-mode.swiper-vertical .swiper-wrapper::after { + width: 1px; + height: var(--swiper-virtual-size); + } + :root { + --swiper-navigation-size: 44px; + } + .swiper-button-next, + .swiper-button-prev { + position: absolute; + top: 50%; + width: calc(var(--swiper-navigation-size) / 44 * 27); + height: var(--swiper-navigation-size); + margin-top: calc(0px - (var(--swiper-navigation-size) / 2)); + z-index: 10; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: var(--swiper-navigation-color, var(--swiper-theme-color)); + } + .swiper-button-next.swiper-button-disabled, + .swiper-button-prev.swiper-button-disabled { + opacity: 0.35; + cursor: auto; + pointer-events: none; + } + .swiper-button-next.swiper-button-hidden, + .swiper-button-prev.swiper-button-hidden { + opacity: 0; + cursor: auto; + pointer-events: none; + } + .swiper-navigation-disabled .swiper-button-next, + .swiper-navigation-disabled .swiper-button-prev { + display: none !important; + } + .swiper-button-next:after, + .swiper-button-prev:after { + font-family: swiper-icons; + font-size: var(--swiper-navigation-size); + text-transform: none !important; + letter-spacing: 0; + font-variant: initial; + line-height: 1; + } + .swiper-button-prev, + .swiper-rtl .swiper-button-next { + left: 10px; + right: auto; + } + .swiper-button-prev:after, + .swiper-rtl .swiper-button-next:after { + content: 'prev'; + } + .swiper-button-next, + .swiper-rtl .swiper-button-prev { + right: 10px; + left: auto; + } + .swiper-button-next:after, + .swiper-rtl .swiper-button-prev:after { + content: 'next'; + } + .swiper-button-lock { + display: none; + } + .swiper-pagination { + position: absolute; + text-align: center; + transition: 0.3s opacity; + transform: translate3d(0, 0, 0); + z-index: 10; + } + .swiper-pagination.swiper-pagination-hidden { + opacity: 0; + } + .swiper-pagination-disabled > .swiper-pagination, + .swiper-pagination.swiper-pagination-disabled { + display: none !important; + } + .swiper-horizontal > .swiper-pagination-bullets, + .swiper-pagination-bullets.swiper-pagination-horizontal, + .swiper-pagination-custom, + .swiper-pagination-fraction { + bottom: 10px; + left: 0; + width: 100%; + } + .swiper-pagination-bullets-dynamic { + overflow: hidden; + font-size: 0; + } + .swiper-pagination-bullets-dynamic .swiper-pagination-bullet { + transform: scale(0.33); + position: relative; + } + .swiper-pagination-bullets-dynamic .swiper-pagination-bullet-active { + transform: scale(1); + } + .swiper-pagination-bullets-dynamic .swiper-pagination-bullet-active-main { + transform: scale(1); + } + .swiper-pagination-bullets-dynamic .swiper-pagination-bullet-active-prev { + transform: scale(0.66); + } + .swiper-pagination-bullets-dynamic .swiper-pagination-bullet-active-prev-prev { + transform: scale(0.33); + } + .swiper-pagination-bullets-dynamic .swiper-pagination-bullet-active-next { + transform: scale(0.66); + } + .swiper-pagination-bullets-dynamic .swiper-pagination-bullet-active-next-next { + transform: scale(0.33); + } + .swiper-pagination-bullet { + width: var(--swiper-pagination-bullet-width, var(--swiper-pagination-bullet-size, 8px)); + height: var(--swiper-pagination-bullet-height, var(--swiper-pagination-bullet-size, 8px)); + display: inline-block; + border-radius: 50%; + background: var(--swiper-pagination-bullet-inactive-color, #000); + opacity: var(--swiper-pagination-bullet-inactive-opacity, 0.2); + } + button.swiper-pagination-bullet { + border: none; + margin: 0; + padding: 0; + box-shadow: none; + -webkit-appearance: none; + appearance: none; + } + .swiper-pagination-clickable .swiper-pagination-bullet { + cursor: pointer; + } + .swiper-pagination-bullet:only-child { + display: none !important; + } + .swiper-pagination-bullet-active { + opacity: var(--swiper-pagination-bullet-opacity, 1); + background: var(--swiper-pagination-color, var(--swiper-theme-color)); + } + .swiper-pagination-vertical.swiper-pagination-bullets, + .swiper-vertical > .swiper-pagination-bullets { + right: 10px; + top: 50%; + transform: translate3d(0px, -50%, 0); + } + .swiper-pagination-vertical.swiper-pagination-bullets .swiper-pagination-bullet, + .swiper-vertical > .swiper-pagination-bullets .swiper-pagination-bullet { + margin: var(--swiper-pagination-bullet-vertical-gap, 6px) 0; + display: block; + } + .swiper-pagination-vertical.swiper-pagination-bullets.swiper-pagination-bullets-dynamic, + .swiper-vertical > .swiper-pagination-bullets.swiper-pagination-bullets-dynamic { + top: 50%; + transform: translateY(-50%); + width: 8px; + } + .swiper-pagination-vertical.swiper-pagination-bullets.swiper-pagination-bullets-dynamic + .swiper-pagination-bullet, + .swiper-vertical + > .swiper-pagination-bullets.swiper-pagination-bullets-dynamic + .swiper-pagination-bullet { + display: inline-block; + transition: + 0.2s transform, + 0.2s top; + } + .swiper-horizontal > .swiper-pagination-bullets .swiper-pagination-bullet, + .swiper-pagination-horizontal.swiper-pagination-bullets .swiper-pagination-bullet { + margin: 0 var(--swiper-pagination-bullet-horizontal-gap, 4px); + } + .swiper-horizontal > .swiper-pagination-bullets.swiper-pagination-bullets-dynamic, + .swiper-pagination-horizontal.swiper-pagination-bullets.swiper-pagination-bullets-dynamic { + left: 50%; + transform: translateX(-50%); + white-space: nowrap; + } + .swiper-horizontal + > .swiper-pagination-bullets.swiper-pagination-bullets-dynamic + .swiper-pagination-bullet, + .swiper-pagination-horizontal.swiper-pagination-bullets.swiper-pagination-bullets-dynamic + .swiper-pagination-bullet { + transition: + 0.2s transform, + 0.2s left; + } + .swiper-horizontal.swiper-rtl > .swiper-pagination-bullets-dynamic .swiper-pagination-bullet { + transition: + 0.2s transform, + 0.2s right; + } + .swiper-pagination-progressbar { + background: rgba(0, 0, 0, 0.25); + position: absolute; + } + .swiper-pagination-progressbar .swiper-pagination-progressbar-fill { + background: var(--swiper-pagination-color, var(--swiper-theme-color)); + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + transform: scale(0); + transform-origin: left top; + } + .swiper-rtl .swiper-pagination-progressbar .swiper-pagination-progressbar-fill { + transform-origin: right top; + } + .swiper-horizontal > .swiper-pagination-progressbar, + .swiper-pagination-progressbar.swiper-pagination-horizontal, + .swiper-pagination-progressbar.swiper-pagination-vertical.swiper-pagination-progressbar-opposite, + .swiper-vertical > .swiper-pagination-progressbar.swiper-pagination-progressbar-opposite { + width: 100%; + height: 4px; + left: 0; + top: 0; + } + .swiper-horizontal > .swiper-pagination-progressbar.swiper-pagination-progressbar-opposite, + .swiper-pagination-progressbar.swiper-pagination-horizontal.swiper-pagination-progressbar-opposite, + .swiper-pagination-progressbar.swiper-pagination-vertical, + .swiper-vertical > .swiper-pagination-progressbar { + width: 4px; + height: 100%; + left: 0; + top: 0; + } + .swiper-pagination-lock { + display: none; + } + .swiper-scrollbar { + border-radius: 10px; + position: relative; + -ms-touch-action: none; + background: rgba(0, 0, 0, 0.1); + } + .swiper-scrollbar-disabled > .swiper-scrollbar, + .swiper-scrollbar.swiper-scrollbar-disabled { + display: none !important; + } + .swiper-horizontal > .swiper-scrollbar { + position: absolute; + left: 1%; + bottom: 3px; + z-index: 50; + height: 5px; + width: 98%; + } + .swiper-vertical > .swiper-scrollbar { + position: absolute; + right: 3px; + top: 1%; + z-index: 50; + width: 5px; + height: 98%; + } + .swiper-scrollbar-drag { + height: 100%; + width: 100%; + position: relative; + background: rgba(0, 0, 0, 0.5); + border-radius: 10px; + left: 0; + top: 0; + } + .swiper-scrollbar-cursor-drag { + cursor: move; + } + .swiper-scrollbar-lock { + display: none; + } + .swiper-zoom-container { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + text-align: center; + } + .swiper-zoom-container > canvas, + .swiper-zoom-container > img, + .swiper-zoom-container > svg { + max-width: 100%; + max-height: 100%; + object-fit: contain; + } + .swiper-slide-zoomed { + cursor: move; + } + .swiper-lazy-preloader { + width: 42px; + height: 42px; + position: absolute; + left: 50%; + top: 50%; + margin-left: -21px; + margin-top: -21px; + z-index: 10; + transform-origin: 50%; + box-sizing: border-box; + border: 4px solid var(--swiper-preloader-color, var(--swiper-theme-color)); + border-radius: 50%; + border-top-color: transparent; + } + .swiper-watch-progress .swiper-slide-visible .swiper-lazy-preloader, + .swiper:not(.swiper-watch-progress) .swiper-lazy-preloader { + animation: swiper-preloader-spin 1s infinite linear; + } + .swiper-lazy-preloader-white { + --swiper-preloader-color: #fff; + } + .swiper-lazy-preloader-black { + --swiper-preloader-color: #000; + } + @keyframes swiper-preloader-spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } + .swiper .swiper-notification { + position: absolute; + left: 0; + top: 0; + pointer-events: none; + opacity: 0; + z-index: -1000; + } + .swiper-free-mode > .swiper-wrapper { + transition-timing-function: ease-out; + margin: 0 auto; + } + .swiper-grid > .swiper-wrapper { + flex-wrap: wrap; + } + .swiper-grid-column > .swiper-wrapper { + flex-wrap: wrap; + flex-direction: column; + } + .swiper-fade.swiper-free-mode .swiper-slide { + transition-timing-function: ease-out; + } + .swiper-fade .swiper-slide { + pointer-events: none; + transition-property: opacity; + } + .swiper-fade .swiper-slide .swiper-slide { + pointer-events: none; + } + .swiper-fade .swiper-slide-active, + .swiper-fade .swiper-slide-active .swiper-slide-active { + pointer-events: auto; + } + .swiper-cube { + overflow: visible; + } + .swiper-cube .swiper-slide { + pointer-events: none; + -webkit-backface-visibility: hidden; + backface-visibility: hidden; + z-index: 1; + visibility: hidden; + transform-origin: 0 0; + width: 100%; + height: 100%; + } + .swiper-cube .swiper-slide .swiper-slide { + pointer-events: none; + } + .swiper-cube.swiper-rtl .swiper-slide { + transform-origin: 100% 0; + } + .swiper-cube .swiper-slide-active, + .swiper-cube .swiper-slide-active .swiper-slide-active { + pointer-events: auto; + } + .swiper-cube .swiper-slide-active, + .swiper-cube .swiper-slide-next, + .swiper-cube .swiper-slide-next + .swiper-slide, + .swiper-cube .swiper-slide-prev { + pointer-events: auto; + visibility: visible; + } + .swiper-cube .swiper-slide-shadow-bottom, + .swiper-cube .swiper-slide-shadow-left, + .swiper-cube .swiper-slide-shadow-right, + .swiper-cube .swiper-slide-shadow-top { + z-index: 0; + -webkit-backface-visibility: hidden; + backface-visibility: hidden; + } + .swiper-cube .swiper-cube-shadow { + position: absolute; + left: 0; + bottom: 0px; + width: 100%; + height: 100%; + opacity: 0.6; + z-index: 0; + } + .swiper-cube .swiper-cube-shadow:before { + content: ''; + background: #000; + position: absolute; + left: 0; + top: 0; + bottom: 0; + right: 0; + filter: blur(50px); + } + .swiper-flip { + overflow: visible; + } + .swiper-flip .swiper-slide { + pointer-events: none; + -webkit-backface-visibility: hidden; + backface-visibility: hidden; + z-index: 1; + } + .swiper-flip .swiper-slide .swiper-slide { + pointer-events: none; + } + .swiper-flip .swiper-slide-active, + .swiper-flip .swiper-slide-active .swiper-slide-active { + pointer-events: auto; + } + .swiper-flip .swiper-slide-shadow-bottom, + .swiper-flip .swiper-slide-shadow-left, + .swiper-flip .swiper-slide-shadow-right, + .swiper-flip .swiper-slide-shadow-top { + z-index: 0; + -webkit-backface-visibility: hidden; + backface-visibility: hidden; + } + .swiper-creative .swiper-slide { + -webkit-backface-visibility: hidden; + backface-visibility: hidden; + overflow: hidden; + transition-property: transform, opacity, height; + } + .swiper-cards { + overflow: visible; + } + .swiper-cards .swiper-slide { + transform-origin: center bottom; + -webkit-backface-visibility: hidden; + backface-visibility: hidden; + overflow: hidden; + } +` +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */;const Wn=1,qn=3,Yn=4,Xn=e=>(...t)=>({_$litDirective$:e,values:t});class Kn{constructor(e){}get _$AU(){return this._$AM._$AU}_$AT(e,t,i){this._$Ct=e,this._$AM=t,this._$Ci=i}_$AS(e,t){return this.update(e,t)}update(e,t){return this.render(...t)}}const Qn="ontouchstart"in window||navigator.maxTouchPoints>0||navigator.maxTouchPoints>0;class Zn extends HTMLElement{constructor(){super(),this.holdTime=500,this.held=!1,this.ripple=document.createElement("mwc-ripple")}connectedCallback(){Object.assign(this.style,{position:"absolute",width:Qn?"100px":"50px",height:Qn?"100px":"50px",transform:"translate(-50%, -50%)",pointerEvents:"none",zIndex:"999"}),this.appendChild(this.ripple),this.ripple.primary=!0,["touchcancel","mouseout","mouseup","touchmove","mousewheel","wheel","scroll"].forEach((e=>{document.addEventListener(e,(()=>{clearTimeout(this.timer),this.stopAnimation(),this.timer=void 0}),{passive:!0})}))}bind(e,t){if(e.actionHandler)return;e.actionHandler=!0,e.addEventListener("contextmenu",(e=>{const t=e||window.event;return t.preventDefault&&t.preventDefault(),t.stopPropagation&&t.stopPropagation(),t.cancelBubble=!0,t.returnValue=!1,!1}));const i=e=>{let t,i;this.held=!1,e.touches?(t=e.touches[0].pageX,i=e.touches[0].pageY):(t=e.pageX,i=e.pageY),this.timer=window.setTimeout((()=>{this.startAnimation(t,i),this.held=!0}),this.holdTime)},n=i=>{i.preventDefault(),["touchend","touchcancel"].includes(i.type)&&void 0===this.timer||(clearTimeout(this.timer),this.stopAnimation(),this.timer=void 0,this.held?l(e,"action",{action:"hold"}):t.hasDoubleClick?"click"===i.type&&i.detail<2||!this.dblClickTimeout?this.dblClickTimeout=window.setTimeout((()=>{this.dblClickTimeout=void 0,l(e,"action",{action:"tap"})}),250):(clearTimeout(this.dblClickTimeout),this.dblClickTimeout=void 0,l(e,"action",{action:"double_tap"})):l(e,"action",{action:"tap"}))};e.addEventListener("touchstart",i,{passive:!0}),e.addEventListener("touchend",n),e.addEventListener("touchcancel",n),e.addEventListener("mousedown",i,{passive:!0}),e.addEventListener("click",n),e.addEventListener("keyup",(e=>{13===e.keyCode&&n(e)}))}startAnimation(e,t){Object.assign(this.style,{left:`${e}px`,top:`${t}px`,display:null}),this.ripple.disabled=!1,this.ripple.active=!0,this.ripple.unbounded=!0}stopAnimation(){this.ripple.active=!1,this.ripple.disabled=!0,this.style.display="none"}}customElements.define("action-handler-meteoalarm",Zn);const Jn=(e,t)=>{const i=(()=>{const e=document.body;if(e.querySelector("action-handler-meteoalarm"))return e.querySelector("action-handler-meteoalarm");const t=document.createElement("action-handler-meteoalarm");return e.appendChild(t),t})();i&&i.bind(e,t)},er=Xn(class extends Kn{update(e,[t]){return Jn(e.element,t),G}render(e){}});var tr;!function(e){e[e.Warning=0]="Warning",e[e.Watch=1]="Watch",e[e.Statement=2]="Statement",e[e.Advisory=3]="Advisory"}(tr||(tr={}));const ir="Data provided by Environment Canada",nr="Données fournies par Environnement Canada";const rr={"Vent violent":Wt.Wind,"Pluie-inondation":Wt.Flooding,Orages:Wt.Thunderstorms,Inondation:Wt.Flooding,"Neige-verglas":Wt.SnowIce,Canicule:Wt.HighTemperature,"Grand-froid":Wt.LowTemperature,Avalanches:Wt.Avalanches,"Vagues-submersion":Wt.CoastalEvent},ar={Jaune:qt.Yellow,Orange:qt.Orange,Rouge:qt.Red};const or=[class{get metadata(){return{key:"meteoalarm",name:"Meteoalarm",type:Vt.SingleEntity,returnHeadline:!0,returnMultipleAlerts:!1,entitiesCount:1,monitoredConditions:this.eventTypes}}supports(e){return"Information provided by MeteoAlarm"==e.attributes.attribution}alertActive(e){return"off"!=(e.attributes.status||e.attributes.state||e.state)}get eventTypes(){return[Wt.Wind,Wt.SnowIce,Wt.Thunderstorms,Wt.Fog,Wt.HighTemperature,Wt.LowTemperature,Wt.CoastalEvent,Wt.ForestFire,Wt.Avalanches,Wt.Rain,Wt.Unknown,Wt.Flooding,Wt.Flooding]}getAlerts(e){const{event:t,headline:i,severity:n,awareness_type:r,awareness_level:a}=e.attributes;let o,s;if(null!=r&&(o=this.eventTypes[Number(r.split(";")[0])-1]),null!=a){let e=Number(a.split(";")[0]);1==e&&(e=2),s=e-1}if(void 0===s&&void 0!==n&&(s=Yt.getLevelBySeverity(n)),void 0===s)throw new Error("Failed to determine alert level. awareness_level nor severity are provided");return[{headline:t||i,level:s,event:o||Wt.Unknown}]}},class{get metadata(){return{key:"meteofrance",name:"Météo-France",type:Vt.SingleEntity,returnHeadline:!1,returnMultipleAlerts:!0,entitiesCount:1,monitoredConditions:[Wt.Wind,Wt.Flooding,Wt.Thunderstorms,Wt.Flooding,Wt.SnowIce,Wt.HighTemperature,Wt.LowTemperature,Wt.Avalanches,Wt.CoastalEvent]}}supports(e){return"Data provided by Météo-France"==e.attributes.attribution&&null!=e.attributes["Vent violent"]}alertActive(e){return"Vert"!==e.state}getAlerts(e){const t=[];for(const[i,n]of Object.entries(rr)){const r=e.attributes[i];r&&("Vert"!==r&&t.push({level:ar[r],event:n}))}return t}},class{get metadata(){return{key:"dwd",name:"Deutscher Wetterdienst (DWD)",type:Vt.CurrentExpected,returnHeadline:!0,returnMultipleAlerts:!0,entitiesCount:2,monitoredConditions:Yt.convertEventTypesForMetadata(this.eventTypes)}}supports(e){return"Data provided by DWD"==e.attributes.attribution&&void 0!==this.getEntityKind(e)}alertActive(e){return e.attributes.warning_count>0}get eventTypes(){return{22:Wt.SnowIce,31:Wt.Thunderstorms,33:Wt.Thunderstorms,34:Wt.Thunderstorms,36:Wt.Thunderstorms,38:Wt.Thunderstorms,40:Wt.Thunderstorms,41:Wt.Thunderstorms,42:Wt.Thunderstorms,44:Wt.Thunderstorms,45:Wt.Thunderstorms,46:Wt.Thunderstorms,48:Wt.Thunderstorms,49:Wt.Thunderstorms,51:Wt.Wind,52:Wt.Wind,53:Wt.Wind,54:Wt.Wind,55:Wt.Wind,56:Wt.Wind,57:Wt.Wind,58:Wt.Wind,59:Wt.Fog,61:Wt.Rain,62:Wt.Rain,63:Wt.Rain,64:Wt.Rain,65:Wt.Rain,66:Wt.Rain,70:Wt.SnowIce,71:Wt.SnowIce,72:Wt.SnowIce,73:Wt.SnowIce,74:Wt.SnowIce,75:Wt.SnowIce,76:Wt.SnowIce,79:Wt.SnowIce,82:Wt.SnowIce,84:Wt.SnowIce,85:Wt.SnowIce,86:Wt.SnowIce,87:Wt.SnowIce,88:Wt.SnowIce,89:Wt.SnowIce,90:Wt.Thunderstorms,91:Wt.Thunderstorms,92:Wt.Thunderstorms,93:Wt.Thunderstorms,95:Wt.Thunderstorms,96:Wt.Thunderstorms,246:Wt.HighTemperature,247:Wt.HighTemperature,248:Wt.HighTemperature,11:Wt.CoastalEvent,12:Wt.CoastalEvent,13:Wt.CoastalEvent,14:Wt.CoastalEvent,15:Wt.CoastalEvent,16:Wt.CoastalEvent,98:Wt.Unknown,99:Wt.Unknown}}getAlerts(e){const{warning_count:t}=e.attributes,i=[],n=this.getEntityKind(e);for(let r=1;re.toLocaleLowerCase())),n=null==t?void 0:t.split(" ").map((e=>e.toLocaleLowerCase()));return["current","aktuelle"].some((e=>i.includes(e)||n.includes(e)))?Ut.Current:["advance","vorwarnstufe"].some((e=>i.includes(e)||n.includes(e)))?Ut.Expected:void 0}},class{get metadata(){return{key:"nina",name:"NINA",type:Vt.Slots,returnHeadline:!0,returnMultipleAlerts:!0,entitiesCount:0,monitoredConditions:[Wt.Unknown]}}supports(e){return["on","off"].includes(e.state)}alertActive(e){return"on"==e.state}getAlerts(e){const{severity:t,headline:i}=e.attributes;return[{event:Wt.Unknown,headline:i,level:Yt.getLevelBySeverity(t,{Moderate:qt.Orange})}]}},class{get metadata(){return{key:"env_canada",name:"Environment Canada",type:Vt.WarningWatchStatementAdvisory,returnHeadline:!1,returnMultipleAlerts:!0,entitiesCount:4,monitoredConditions:[...new Set(this.eventTypes.map((e=>e.type)))]}}supports(e){const t=!Number.isNaN(Number(e.state));return[ir,nr].includes(e.attributes.attribution)&&void 0!==this.getEntityType(e)&&t}alertActive(e){return Number(e.state)>0}get eventTypes(){return[{en:"Arctic Outflow",fr:"Poussée d’air Arctique",type:Wt.SnowIce},{en:"Blizzard",fr:"Blizzard",type:Wt.SnowIce},{en:"Blowing Snow",fr:"Poudrerie",type:Wt.SnowIce},{en:"Dust Storm",fr:"Tempête de Poussière",type:Wt.Dust},{en:"Extreme Cold",fr:"Froid Extrême",type:Wt.LowTemperature},{en:"Flash Freeze",fr:"Refroidissement Soudain",type:Wt.SnowIce},{en:"Fog",fr:"Brouillard",type:Wt.Fog},{en:"Freezing Drizzle",fr:"Bruine Verglaçante",type:Wt.SnowIce},{en:"Freezing Rain",fr:"Pluie Verglaçante",type:Wt.SnowIce},{en:"Frost",fr:"Gel",type:Wt.SnowIce},{en:"Heat",fr:"Chaleur",type:Wt.HighTemperature},{en:"Hurricane",fr:"Ouragan",type:Wt.Hurricane},{en:"Rainfall",fr:"Pluie",type:Wt.Rain},{en:"Severe Thunderstorm",fr:"Orages Violents",type:Wt.Thunderstorms},{en:"Smog",fr:"Smog",type:Wt.AirQuality},{en:"Snowfall",fr:"Neige",type:Wt.SnowIce},{en:"Snow Squall",fr:"Bourrasques de Neige",type:Wt.SnowIce},{en:"Storm Surge",fr:"Onde de Tempête",type:Wt.Thunderstorms},{en:"Tornado",fr:"Tornade",type:Wt.Tornado},{en:"Tropical Storm",fr:"Tempête Tropicale",type:Wt.Hurricane},{en:"Tsunami",fr:"Tsunami",type:Wt.Tsunami},{en:"Weather",fr:"Météorologique",type:Wt.Unknown},{en:"Wind",fr:"Vents",type:Wt.Wind},{en:"Winter Storm",fr:"Tempête Hivernale",type:Wt.SnowIce},{en:"Special Weather",fr:"Météorologique Spécial",type:Wt.Unknown},{en:"Special Air Quality",fr:"Spécial Sur La Qualité De L'Air",type:Wt.AirQuality}]}get entityTypeTranslation(){return[{type:tr.Warning,en:["Warning"],fr:["Avertissement De","Avertissement D"]},{type:tr.Watch,en:["Watch"],fr:["Veille De","Veille D'"]},{type:tr.Statement,en:["Statement"],fr:["Bulletin"]},{type:tr.Advisory,en:["Advisory"],fr:["Avis De","Avis D'","Avis"]}]}praseAlertName(e,t,i){const n=this.entityTypeTranslation.find((e=>e.type==t)),r=i?n.fr:n.en,a=r.find((t=>e.includes(t)));if(null==a)throw new Error(`Failed to match one of the known prefixes to alert name! Was looking for [ "${r.join('", "')}" ] and failed (isFrench=${i})`);return e=e.replace(a,"").trim(),this.eventTypes.find((t=>i&&t.fr==e||!i&&t.en==e))}getAlerts(e){const t=Number(e.state),i=[],n=this.getEntityType(e);for(let r=1;r0}get eventTypes(){return{"911 Telephone":Wt.Unknown,Administrative:Wt.Unknown,"Air Quality":Wt.AirQuality,"Air Stagnation":Wt.AirQuality,"Arroyo And Small Stream Flood":Wt.Flooding,Ashfall:Wt.Volcano,Avalanche:Wt.Avalanches,"Beach Hazards":Wt.CoastalEvent,Blizzard:Wt.SnowIce,"Blowing Dust":Wt.Dust,"Brisk Wind":Wt.Wind,"Child Abduction":Wt.Unknown,Civil:Wt.Unknown,"Civil Emergency":Wt.Unknown,"Coastal Flood":Wt.Flooding,"Dense Fog":Wt.Fog,"Dense Smoke":Wt.Fog,Dust:Wt.Dust,"Dust Storm":Wt.Dust,Earthquake:Wt.Earthquake,"Excessive Heat":Wt.HighTemperature,"Extreme Cold":Wt.LowTemperature,"Extreme Fire":Wt.ForestFire,"Extreme Wind":Wt.Wind,Fire:Wt.ForestFire,"Fire Weather":Wt.ForestFire,"Flash Flood":Wt.Flooding,Flood:Wt.Flooding,Freeze:Wt.LowTemperature,"Freezing Fog":Wt.SnowIce,"Freezing Rain":Wt.SnowIce,"Freezing Spray":Wt.SeaEvent,Frost:Wt.LowTemperature,Gale:Wt.SeaEvent,"Hard Freeze":Wt.LowTemperature,"Hazardous Materials":Wt.Unknown,"Hazardous Seas":Wt.SeaEvent,"Hazardous Weather":Wt.Unknown,Heat:Wt.HighTemperature,"Heavy Freezing Spray":Wt.SeaEvent,"High Surf":Wt.CoastalEvent,"High Wind":Wt.Wind,"Hurricane Force Wind":Wt.Hurricane,"Hurricane Local":Wt.Hurricane,Hurricane:Wt.Hurricane,Hydrologic:Wt.CoastalEvent,"Ice Storm":Wt.SnowIce,"Lake Effect Snow":Wt.SnowIce,"Lake Wind":Wt.Wind,"Lakeshore Flood":Wt.Flooding,"Law Enforcement":Wt.Unknown,"Local Area":Wt.Unknown,"Low Water":Wt.SeaEvent,"Marine Weather":Wt.SeaEvent,"Nuclear Power Plant":Wt.Nuclear,"Radiological Hazard":Wt.Nuclear,"Red Flag":Wt.ForestFire,"Rip Current":Wt.CoastalEvent,"River Flood":Wt.Flooding,"Severe Thunderstorm":Wt.Thunderstorms,"Severe Weather":Wt.Unknown,"Shelter In Place":Wt.Unknown,"Short Term":Wt.Unknown,"Small Craft":Wt.SeaEvent,"Small Stream Flood":Wt.Flooding,"Snow Squall":Wt.SnowIce,"Special Marine":Wt.SeaEvent,"Special Weather":Wt.Unknown,"Storm Surge":Wt.CoastalEvent,Storm:Wt.Thunderstorms,Tornado:Wt.Tornado,"Tropical Depression Local":Wt.Hurricane,"Tropical Storm Local":Wt.Hurricane,"Tropical Storm":Wt.Hurricane,"Tropical Cyclone":Wt.Hurricane,Tsunami:Wt.Tsunami,"Typhoon Local":Wt.Hurricane,Typhoon:Wt.Hurricane,"Urban And Small Stream Flood":Wt.Flooding,Volcano:Wt.Volcano,Wind:Wt.Wind,"Wind Chill":Wt.LowTemperature,"Winter Storm":Wt.SnowIce,"Winter Weather":Wt.SnowIce,Blue:Wt.Unknown}}get eventLevels(){return{Warning:qt.Red,Statement:qt.Orange,Watch:qt.Orange,Advisory:qt.Yellow,Alert:qt.Yellow,Emergency:qt.Red,Danger:qt.Red,Message:qt.Orange,Outage:qt.Orange}}getAlerts(e){const{alerts:t}=e.attributes,i=[];for(const e of t){const t=e.event;let n,r;for(const[e,i]of Object.entries(this.eventLevels)){if(!t.includes(e))continue;n=i;const a=t.replace(e,"").trim();if(r=this.eventTypes[a],null==r)throw Error(`Unknown weatheralerts alert type: ${a}`)}if(null==n)throw Error(`Unknown weatheralerts alert level: ${t}`);i.push({headline:t,level:n,event:r})}return i}}];function sr(e,t){const i=document.createElement("canvas").getContext("2d");i.font=t;return i.measureText(e).width}function lr(e,t){return window.getComputedStyle(e,null).getPropertyValue(t)}function dr(e=document.body,t){return`normal ${t||lr(e,"font-size")||"16px"} ${lr(e,"font-family")||"Times New Roman"}`}var cr,pr=g` + :host { + display: flex; + flex: 1; + flex-direction: column; + + --text-color: inherit; + --text-color-active: white; + --headline-font-size: 22px; + --caption-font-size: 13px; + + --inactive-background-color: inherit; + --red-level-color: var(---error-color, #db4437); + --orange-level-background-color: #ee5a24; + --yellow-level-background-color: var(--warning-color, #ffa600); + } + + ha-card { + flex-direction: column; + flex: 1; + position: relative; + padding: 0px; + border-radius: var(--ha-card-border-radius, 12px); + box-shadow: var( + --ha-card-box-shadow, + 0px 2px 1px -1px rgba(0, 0, 0, 0.2), + 0px 1px 1px 0px rgba(0, 0, 0, 0.14), + 0px 1px 3px 0px rgba(0, 0, 0, 0.12) + ); + overflow: hidden; + transition: all 0.3s ease-out 0s; + color: var(--text-color); + } + + a { + color: var(--secondary-text-color); + } + + .container { + background: var(--card-background-color); + cursor: pointer; + overflow: hidden; + position: relative; + } + + .content { + display: flex; + padding: 36px 28px; + justify-content: center; + } + + .main-icon { + --mdc-icon-size: 50px; + height: 50px; + flex: 0; + } + + .headline { + flex: 1; + font-size: var(--headline-font-size); + line-height: normal; + margin: auto; + margin-left: 18px; + text-align: center; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .caption { + top: 0; + right: 0; + position: absolute; + display: flex; + align-items: center; + margin: 10px 12px; + font-size: var(--caption-font-size); + line-height: normal; + } + + .caption-icon { + --mdc-icon-size: 19px; + height: 19px; + flex: 0; + margin-left: 5px; + } + + .headline-narrow, + .headline-verynarrow { + display: none; + } + + .event-red { + background-color: var(--red-level-color); + } + + .event-orange { + background-color: var(--orange-level-background-color); + } + + .event-yellow { + background-color: var(--yellow-level-background-color); + } + + .event-red, + .event-orange, + .event-yellow { + color: var(--text-color-active); + } + + .event-none { + background-color: var(--inactive-background-color); + } + + .swiper { + --swiper-pagination-bullet-size: 5px; + } + + .swiper-pagination-bullet { + background-color: #dfdfdf; + } + .swiper-pagination-bullet-active { + background-color: #ffffff; + } +`;console.info("%c MeteoalarmCard %c 2.7.1 ","color: white; font-weight: bold; background: #1c1c1c","color: white; font-weight: bold; background: #db4437"),window.customCards=window.customCards||[],window.customCards.push({preview:!0,type:"meteoalarm-card",name:jn("common.name"),description:jn("common.description")});let mr=cr=class extends de{static get integrations(){return or.map((e=>new e))}static async getConfigElement(){return await Promise.resolve().then((function(){return cs})),document.createElement("meteoalarm-card-editor")}static getStubConfig(e,t){const i=[Vt.SingleEntity,Vt.CurrentExpected];for(const n of t){const t=cr.integrations.filter((e=>i.includes(e.metadata.type)));for(const i of t)if(i.supports(e.states[n]))return{entities:{entity:n},integration:i.metadata.key}}return{entities:"",integration:""}}setConfig(e){if(!e)throw new Error(jn("common.invalid_configuration"));if(null==e.entities||Array.isArray(e.entities)&&0==e.entities.length||Array.isArray(e.entities)&&e.entities.every((e=>null==e)))throw new Error(jn("error.missing_entity"));if(null==e.integration)throw new Error(jn("error.invalid_integration"));this.config=Object.assign({name:"Meteoalarm"},e)}static get styles(){return[Gn,pr]}getCardSize(){return 2}shouldUpdate(e){return function(e,t,i){if(t.has("config")||i)return!0;if(e.config.entity){var n=t.get("hass");return!n||n.states[e.config.entity]!==e.hass.states[e.config.entity]}return!1}(this,e,!1)}firstUpdated(){this.measureCard(),this.attachObserver();const e=this.renderRoot.getElementById("swiper");e&&(this.swiper=new Bt(e,{modules:[jt],pagination:{el:e.getElementsByClassName("swiper-pagination")[0]},observer:!0}),this.swiper.on("transitionEnd",(()=>{this.updateCurrentEntity()})),this.swiper.on("observerUpdate",(()=>{this.updateCurrentEntity()})))}updateCurrentEntity(){const e=this.swiper.slides[this.swiper.realIndex];this.currentEntity=e.getAttribute("entity_id")}attachObserver(){this.resizeObserver||(this.resizeObserver=new Ue(function(e,t,i){var n;return void 0===i&&(i=!1),function(){var r=[].slice.call(arguments),a=this,o=function(){n=null,i||e.apply(a,r)},s=i&&!n;clearTimeout(n),n=setTimeout(o,t),s&&e.apply(a,r)}}((()=>this.measureCard()),250,!1)));const e=this.shadowRoot.querySelector("ha-card");e&&this.resizeObserver.observe(e)}getHeadlineElements(e){return[e.querySelector(".headline-regular"),e.querySelector(".headline-narrow"),e.querySelector(".headline-verynarrow")]}measureCard(){if(!this.isConnected)return;const e=this.shadowRoot.querySelector("ha-card");if(!e)return;if(this.scalingMode==Gt.Disabled)return;const t=[Gt.Scale,Gt.HeadlineAndScale].includes(this.scalingMode),i=[Gt.Headline,Gt.HeadlineAndScale].includes(this.scalingMode),n=e.querySelector(".swiper-wrapper"),r=null==n?void 0:n.getElementsByClassName("swiper-slide");for(const e of r){const[n,r,a]=this.getHeadlineElements(e),o=[["regular",n]];i&&(o.push(["narrow",r]),o.push(["veryNarrow",a])),this.setCardScaling(e,"regular",22);let s=!1;for(const[i,r]of o){if(s)break;const a=t?17:22;for(let t=22;t>=a;t--){if(sr(r.textContent,dr(n,t+"px"))<=n.clientWidth){this.setCardScaling(e,i,t),s=!0;break}}}s||(i?this.setCardScaling(e,"icon",22):this.setCardScaling(e,"regular",17))}}setCardScaling(e,t,i){const[n,r,a]=this.getHeadlineElements(e);"regular"==t?(n.style.fontSize=`${i}px`,n.style.display="block",r.style.display="none",a.style.display="none"):"narrow"==t?(r.style.fontSize=`${i}px`,n.style.display="none",r.style.display="block",a.style.display="none"):"veryNarrow"==t?(a.style.fontSize=`${i}px`,n.style.display="none",r.style.display="none",a.style.display="block"):"icon"==t&&(n.style.display="none",r.style.display="none",a.style.display="none")}get entities(){const e=function(e){return Array.isArray(e)||(e=[e]),e.length>0&&e.every((e=>null==e))?[]:e.map(((e,t)=>{if("object"==typeof e&&!Array.isArray(e)&&e.type)return e;let i;if("string"==typeof e)i={entity:e};else{if("object"!=typeof e||Array.isArray(e))throw new Error(`Invalid entity specified at position ${t}.`);if(!("entity"in e))throw new Error(`Entity object at position ${t} is missing entity field.`);i=e}return i}))}(this.config.entities);return e.map((e=>this.hass.states[e.entity]))}get integration(){const e=cr.integrations.find((e=>e.metadata.key===this.config.integration));if(void 0===e)throw new Error(jn("error.invalid_integration"));return e}get scalingMode(){const e=this.config.scaling_mode;if(!e)return Gt.HeadlineAndScale;if(!Object.values(Gt).includes(e))throw new Error("MeteoalarmCard: "+jn("error.invalid_scaling_mode"));return e}render(){try{const e=new Un(this.integration).getEvents(this.entities,this.config.disable_swiper,this.config.override_headline,this.config.hide_caption,this.config.ignored_levels,this.config.ignored_events);return e.every((e=>!e.isActive))&&this.config.hide_when_no_warning?(console.log("MeteoalarmCard: Card is hidden - hide_when_no_warning is enabled and there are no warnings"),this.setCardMargin(!1),U``):(this.setCardMargin(!0),U` + +
+
+
+ ${e.map((e=>{var t;return U` +
+
+ ${this.renderMainIcon(e.icon)} ${this.renderHeadlines(e.headlines)} +
+ ${e.caption&&e.captionIcon?U` +
+ ${this.renderCaption(e.captionIcon,e.caption)} +
+ `:""} +
+ `}))} +
+
+
+
+
+ `)}catch(e){return console.error("[METEOALARM CARD ERROR]\nReport issue: https://bit.ly/3hK1hL4 \n\n",e),this.showError(e)}}renderMainIcon(e){return U``}renderHeadlines(e){let t="",i="",n="";if(0==e.length)throw new Error("headlines array length is 0");if(1==e.length)t=e[0],i=e[0],n=e[0];else if(2==e.length)t=e[0],i=e[1],n=e[1];else if(3==e.length)t=e[0],i=e[1],n=e[2];else if(e.length>3)throw new Error("headlines array length is higher than 3");return U` +
${t}
+
${i}
+
${n}
+ `}renderCaption(e,t){return U` + ${t} + + `}setCardMargin(e){var t;const i=null===(t=this.shadowRoot)||void 0===t?void 0:t.host;i&&(i.style.margin=e?"":"0px")}showError(e){const t=document.createElement("hui-error-card");return t.setConfig({type:"error",error:e,origConfig:this.config}),U` ${t} `}handleAction(e){const t=Object.assign(Object.assign({},this.config),{entity:this.currentEntity});this.hass&&this.config&&e.detail.action&&function(e,t,i,n){var r;"double_tap"===n&&i.double_tap_action?r=i.double_tap_action:"hold"===n&&i.hold_action?r=i.hold_action:"tap"===n&&i.tap_action&&(r=i.tap_action),c(e,t,i,r)}(this,this.hass,t,e.detail.action)}};a([he({attribute:!1})],mr.prototype,"hass",void 0),a([ue()],mr.prototype,"config",void 0),mr=cr=a([pe("meteoalarm-card")],mr); +/** + * @license + * Copyright 2016 Google Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +var hr=function(){function e(e){void 0===e&&(e={}),this.adapter=e}return Object.defineProperty(e,"cssClasses",{get:function(){return{}},enumerable:!1,configurable:!0}),Object.defineProperty(e,"strings",{get:function(){return{}},enumerable:!1,configurable:!0}),Object.defineProperty(e,"numbers",{get:function(){return{}},enumerable:!1,configurable:!0}),Object.defineProperty(e,"defaultAdapter",{get:function(){return{}},enumerable:!1,configurable:!0}),e.prototype.init=function(){},e.prototype.destroy=function(){},e}(),ur={ROOT:"mdc-form-field"},fr={LABEL_SELECTOR:".mdc-form-field > label"},gr=function(e){function i(t){var n=e.call(this,r(r({},i.defaultAdapter),t))||this;return n.click=function(){n.handleClick()},n}return t(i,e),Object.defineProperty(i,"cssClasses",{get:function(){return ur},enumerable:!1,configurable:!0}),Object.defineProperty(i,"strings",{get:function(){return fr},enumerable:!1,configurable:!0}),Object.defineProperty(i,"defaultAdapter",{get:function(){return{activateInputRipple:function(){},deactivateInputRipple:function(){},deregisterInteractionHandler:function(){},registerInteractionHandler:function(){}}},enumerable:!1,configurable:!0}),i.prototype.init=function(){this.adapter.registerInteractionHandler("click",this.click)},i.prototype.destroy=function(){this.adapter.deregisterInteractionHandler("click",this.click)},i.prototype.handleClick=function(){var e=this;this.adapter.activateInputRipple(),requestAnimationFrame((function(){e.adapter.deactivateInputRipple()}))},i}(hr); +/** + * @license + * Copyright 2017 Google Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +const vr=e=>e.nodeType===Node.ELEMENT_NODE;function br(e){return{addClass:t=>{e.classList.add(t)},removeClass:t=>{e.classList.remove(t)},hasClass:t=>e.classList.contains(t)}}const yr=()=>{},xr={get passive(){return!1}};document.addEventListener("x",yr,xr),document.removeEventListener("x",yr);const _r=(e=window.document)=>{let t=e.activeElement;const i=[];if(!t)return i;for(;t&&(i.push(t),t.shadowRoot);)t=t.shadowRoot.activeElement;return i},wr=e=>{const t=_r();if(!t.length)return!1;const i=t[t.length-1],n=new Event("check-if-focused",{bubbles:!0,composed:!0});let r=[];const a=e=>{r=e.composedPath()};return document.body.addEventListener("check-if-focused",a),i.dispatchEvent(n),document.body.removeEventListener("check-if-focused",a),-1!==r.indexOf(e)}; +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +class Er extends de{click(){if(this.mdcRoot)return this.mdcRoot.focus(),void this.mdcRoot.click();super.click()}createFoundation(){void 0!==this.mdcFoundation&&this.mdcFoundation.destroy(),this.mdcFoundationClass&&(this.mdcFoundation=new this.mdcFoundationClass(this.createAdapter()),this.mdcFoundation.init())}firstUpdated(){this.createFoundation()}} +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */var Ar,Cr;const Tr=null!==(Cr=null===(Ar=window.ShadyDOM)||void 0===Ar?void 0:Ar.inUse)&&void 0!==Cr&&Cr;class Sr extends Er{constructor(){super(...arguments),this.disabled=!1,this.containingForm=null,this.formDataListener=e=>{this.disabled||this.setFormData(e.formData)}}findFormElement(){if(!this.shadowRoot||Tr)return null;const e=this.getRootNode().querySelectorAll("form");for(const t of Array.from(e))if(t.contains(this))return t;return null}connectedCallback(){var e;super.connectedCallback(),this.containingForm=this.findFormElement(),null===(e=this.containingForm)||void 0===e||e.addEventListener("formdata",this.formDataListener)}disconnectedCallback(){var e;super.disconnectedCallback(),null===(e=this.containingForm)||void 0===e||e.removeEventListener("formdata",this.formDataListener),this.containingForm=null}click(){this.formElement&&!this.disabled&&(this.formElement.focus(),this.formElement.click())}firstUpdated(){super.firstUpdated(),this.shadowRoot&&this.mdcRoot.addEventListener("change",(e=>{this.dispatchEvent(new Event("change",e))}))}}Sr.shadowRootOptions={mode:"open",delegatesFocus:!0},a([he({type:Boolean})],Sr.prototype,"disabled",void 0); +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +const Ir=e=>(t,i)=>{if(t.constructor._observers){if(!t.constructor.hasOwnProperty("_observers")){const e=t.constructor._observers;t.constructor._observers=new Map,e.forEach(((e,i)=>t.constructor._observers.set(i,e)))}}else{t.constructor._observers=new Map;const e=t.updated;t.updated=function(t){e.call(this,t),t.forEach(((e,t)=>{const i=this.constructor._observers.get(t);void 0!==i&&i.call(this,this[t],e)}))}}t.constructor._observers.set(i,e)} +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */,kr=Xn(class extends Kn{constructor(e){var t;if(super(e),e.type!==Wn||"class"!==e.name||(null===(t=e.strings)||void 0===t?void 0:t.length)>2)throw Error("`classMap()` can only be used in the `class` attribute and must be the only part in the attribute.")}render(e){return" "+Object.keys(e).filter((t=>e[t])).join(" ")+" "}update(e,[t]){var i,n;if(void 0===this.et){this.et=new Set,void 0!==e.strings&&(this.st=new Set(e.strings.join(" ").split(/\s/).filter((e=>""!==e))));for(const e in t)t[e]&&!(null===(i=this.st)||void 0===i?void 0:i.has(e))&&this.et.add(e);return this.render(t)}const r=e.element.classList;this.et.forEach((e=>{e in t||(r.remove(e),this.et.delete(e))}));for(const e in t){const i=!!t[e];i===this.et.has(e)||(null===(n=this.st)||void 0===n?void 0:n.has(e))||(i?(r.add(e),this.et.add(e)):(r.remove(e),this.et.delete(e)))}return G}}); +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +class Or extends Er{constructor(){super(...arguments),this.alignEnd=!1,this.spaceBetween=!1,this.nowrap=!1,this.label="",this.mdcFoundationClass=gr}createAdapter(){return{registerInteractionHandler:(e,t)=>{this.labelEl.addEventListener(e,t)},deregisterInteractionHandler:(e,t)=>{this.labelEl.removeEventListener(e,t)},activateInputRipple:async()=>{const e=this.input;if(e instanceof Sr){const t=await e.ripple;t&&t.startPress()}},deactivateInputRipple:async()=>{const e=this.input;if(e instanceof Sr){const t=await e.ripple;t&&t.endPress()}}}}get input(){var e,t;return null!==(t=null===(e=this.slottedInputs)||void 0===e?void 0:e[0])&&void 0!==t?t:null}render(){const e={"mdc-form-field--align-end":this.alignEnd,"mdc-form-field--space-between":this.spaceBetween,"mdc-form-field--nowrap":this.nowrap};return U` +
+ + +
`}click(){this._labelClick()}_labelClick(){const e=this.input;e&&(e.focus(),e.click())}}a([he({type:Boolean})],Or.prototype,"alignEnd",void 0),a([he({type:Boolean})],Or.prototype,"spaceBetween",void 0),a([he({type:Boolean})],Or.prototype,"nowrap",void 0),a([he({type:String}),Ir((async function(e){var t;null===(t=this.input)||void 0===t||t.setAttribute("aria-label",e)}))],Or.prototype,"label",void 0),a([ve(".mdc-form-field")],Or.prototype,"mdcRoot",void 0),a([_e("",!0,"*")],Or.prototype,"slottedInputs",void 0),a([ve("label")],Or.prototype,"labelEl",void 0); +/** + * @license + * Copyright 2021 Google LLC + * SPDX-LIcense-Identifier: Apache-2.0 + */ +const Lr=g`.mdc-form-field{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;font-family:Roboto, sans-serif;font-family:var(--mdc-typography-body2-font-family, var(--mdc-typography-font-family, Roboto, sans-serif));font-size:0.875rem;font-size:var(--mdc-typography-body2-font-size, 0.875rem);line-height:1.25rem;line-height:var(--mdc-typography-body2-line-height, 1.25rem);font-weight:400;font-weight:var(--mdc-typography-body2-font-weight, 400);letter-spacing:0.0178571429em;letter-spacing:var(--mdc-typography-body2-letter-spacing, 0.0178571429em);text-decoration:inherit;text-decoration:var(--mdc-typography-body2-text-decoration, inherit);text-transform:inherit;text-transform:var(--mdc-typography-body2-text-transform, inherit);color:rgba(0, 0, 0, 0.87);color:var(--mdc-theme-text-primary-on-background, rgba(0, 0, 0, 0.87));display:inline-flex;align-items:center;vertical-align:middle}.mdc-form-field>label{margin-left:0;margin-right:auto;padding-left:4px;padding-right:0;order:0}[dir=rtl] .mdc-form-field>label,.mdc-form-field>label[dir=rtl]{margin-left:auto;margin-right:0}[dir=rtl] .mdc-form-field>label,.mdc-form-field>label[dir=rtl]{padding-left:0;padding-right:4px}.mdc-form-field--nowrap>label{text-overflow:ellipsis;overflow:hidden;white-space:nowrap}.mdc-form-field--align-end>label{margin-left:auto;margin-right:0;padding-left:0;padding-right:4px;order:-1}[dir=rtl] .mdc-form-field--align-end>label,.mdc-form-field--align-end>label[dir=rtl]{margin-left:0;margin-right:auto}[dir=rtl] .mdc-form-field--align-end>label,.mdc-form-field--align-end>label[dir=rtl]{padding-left:4px;padding-right:0}.mdc-form-field--space-between{justify-content:space-between}.mdc-form-field--space-between>label{margin:0}[dir=rtl] .mdc-form-field--space-between>label,.mdc-form-field--space-between>label[dir=rtl]{margin:0}:host{display:inline-flex}.mdc-form-field{width:100%}::slotted(*){-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;font-family:Roboto, sans-serif;font-family:var(--mdc-typography-body2-font-family, var(--mdc-typography-font-family, Roboto, sans-serif));font-size:0.875rem;font-size:var(--mdc-typography-body2-font-size, 0.875rem);line-height:1.25rem;line-height:var(--mdc-typography-body2-line-height, 1.25rem);font-weight:400;font-weight:var(--mdc-typography-body2-font-weight, 400);letter-spacing:0.0178571429em;letter-spacing:var(--mdc-typography-body2-letter-spacing, 0.0178571429em);text-decoration:inherit;text-decoration:var(--mdc-typography-body2-text-decoration, inherit);text-transform:inherit;text-transform:var(--mdc-typography-body2-text-transform, inherit);color:rgba(0, 0, 0, 0.87);color:var(--mdc-theme-text-primary-on-background, rgba(0, 0, 0, 0.87))}::slotted(mwc-switch){margin-right:10px}[dir=rtl] ::slotted(mwc-switch),::slotted(mwc-switch[dir=rtl]){margin-left:10px}`,zr={"mwc-formfield":class extends Or{static get styles(){return Lr}}}; +/** + * @license + * Copyright 2020 Google Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +var Rr="Unknown",Mr="Backspace",$r="Enter",Fr="Spacebar",Dr="PageUp",Nr="PageDown",Pr="End",Br="Home",Hr="ArrowLeft",jr="ArrowUp",Vr="ArrowRight",Ur="ArrowDown",Gr="Delete",Wr="Escape",qr="Tab",Yr=new Set;Yr.add(Mr),Yr.add($r),Yr.add(Fr),Yr.add(Dr),Yr.add(Nr),Yr.add(Pr),Yr.add(Br),Yr.add(Hr),Yr.add(jr),Yr.add(Vr),Yr.add(Ur),Yr.add(Gr),Yr.add(Wr),Yr.add(qr);var Xr=8,Kr=13,Qr=32,Zr=33,Jr=34,ea=35,ta=36,ia=37,na=38,ra=39,aa=40,oa=46,sa=27,la=9,da=new Map;da.set(Xr,Mr),da.set(Kr,$r),da.set(Qr,Fr),da.set(Zr,Dr),da.set(Jr,Nr),da.set(ea,Pr),da.set(ta,Br),da.set(ia,Hr),da.set(na,jr),da.set(ra,Vr),da.set(aa,Ur),da.set(oa,Gr),da.set(sa,Wr),da.set(la,qr);var ca,pa,ma=new Set;function ha(e){var t=e.key;if(Yr.has(t))return t;var i=da.get(e.keyCode);return i||Rr} +/** + * @license + * Copyright 2018 Google Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ma.add(Dr),ma.add(Nr),ma.add(Pr),ma.add(Br),ma.add(Hr),ma.add(jr),ma.add(Vr),ma.add(Ur);var ua="mdc-list-item--activated",fa="mdc-list-item",ga="mdc-list-item--disabled",va="mdc-list-item--selected",ba="mdc-list-item__text",ya="mdc-list-item__primary-text",xa="mdc-list";(ca={})[""+ua]="mdc-list-item--activated",ca[""+fa]="mdc-list-item",ca[""+ga]="mdc-list-item--disabled",ca[""+va]="mdc-list-item--selected",ca[""+ya]="mdc-list-item__primary-text",ca[""+xa]="mdc-list";var _a=((pa={})[""+ua]="mdc-deprecated-list-item--activated",pa[""+fa]="mdc-deprecated-list-item",pa[""+ga]="mdc-deprecated-list-item--disabled",pa[""+va]="mdc-deprecated-list-item--selected",pa[""+ba]="mdc-deprecated-list-item__text",pa[""+ya]="mdc-deprecated-list-item__primary-text",pa[""+xa]="mdc-deprecated-list",pa),wa={ACTION_EVENT:"MDCList:action",ARIA_CHECKED:"aria-checked",ARIA_CHECKED_CHECKBOX_SELECTOR:'[role="checkbox"][aria-checked="true"]',ARIA_CHECKED_RADIO_SELECTOR:'[role="radio"][aria-checked="true"]',ARIA_CURRENT:"aria-current",ARIA_DISABLED:"aria-disabled",ARIA_ORIENTATION:"aria-orientation",ARIA_ORIENTATION_HORIZONTAL:"horizontal",ARIA_ROLE_CHECKBOX_SELECTOR:'[role="checkbox"]',ARIA_SELECTED:"aria-selected",ARIA_INTERACTIVE_ROLES_SELECTOR:'[role="listbox"], [role="menu"]',ARIA_MULTI_SELECTABLE_SELECTOR:'[aria-multiselectable="true"]',CHECKBOX_RADIO_SELECTOR:'input[type="checkbox"], input[type="radio"]',CHECKBOX_SELECTOR:'input[type="checkbox"]',CHILD_ELEMENTS_TO_TOGGLE_TABINDEX:"\n ."+fa+" button:not(:disabled),\n ."+fa+" a,\n ."+_a[fa]+" button:not(:disabled),\n ."+_a[fa]+" a\n ",DEPRECATED_SELECTOR:".mdc-deprecated-list",FOCUSABLE_CHILD_ELEMENTS:"\n ."+fa+" button:not(:disabled),\n ."+fa+" a,\n ."+fa+' input[type="radio"]:not(:disabled),\n .'+fa+' input[type="checkbox"]:not(:disabled),\n .'+_a[fa]+" button:not(:disabled),\n ."+_a[fa]+" a,\n ."+_a[fa]+' input[type="radio"]:not(:disabled),\n .'+_a[fa]+' input[type="checkbox"]:not(:disabled)\n ',RADIO_SELECTOR:'input[type="radio"]',SELECTED_ITEM_SELECTOR:'[aria-selected="true"], [aria-current="true"]'},Ea={UNSET_INDEX:-1,TYPEAHEAD_BUFFER_CLEAR_TIMEOUT_MS:300},Aa=["input","button","textarea","select"],Ca=function(e){var t=e.target;if(t){var i=(""+t.tagName).toLowerCase();-1===Aa.indexOf(i)&&e.preventDefault()}};function Ta(e,t){for(var i=new Map,n=0;nt&&!i(a[s].index)){l=s;break}if(-1!==l)return n.sortedIndexCursor=l,a[n.sortedIndexCursor].index;return-1}(a,o,l,t):function(e,t,i){var n=i.typeaheadBuffer[0],r=e.get(n);if(!r)return-1;var a=r[i.sortedIndexCursor];if(0===a.text.lastIndexOf(i.typeaheadBuffer,0)&&!t(a.index))return a.index;var o=(i.sortedIndexCursor+1)%r.length,s=-1;for(;o!==i.sortedIndexCursor;){var l=r[o],d=0===l.text.lastIndexOf(i.typeaheadBuffer,0),c=!t(l.index);if(d&&c){s=o;break}o=(o+1)%r.length}if(-1!==s)return i.sortedIndexCursor=s,r[i.sortedIndexCursor].index;return-1}(a,l,t),-1===i||s||r(i),i}function Ia(e){return e.typeaheadBuffer.length>0} +/** + * @license + * Copyright 2016 Google Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +var ka={LABEL_FLOAT_ABOVE:"mdc-floating-label--float-above",LABEL_REQUIRED:"mdc-floating-label--required",LABEL_SHAKE:"mdc-floating-label--shake",ROOT:"mdc-floating-label"},Oa=function(e){function i(t){var n=e.call(this,r(r({},i.defaultAdapter),t))||this;return n.shakeAnimationEndHandler=function(){n.handleShakeAnimationEnd()},n}return t(i,e),Object.defineProperty(i,"cssClasses",{get:function(){return ka},enumerable:!1,configurable:!0}),Object.defineProperty(i,"defaultAdapter",{get:function(){return{addClass:function(){},removeClass:function(){},getWidth:function(){return 0},registerInteractionHandler:function(){},deregisterInteractionHandler:function(){}}},enumerable:!1,configurable:!0}),i.prototype.init=function(){this.adapter.registerInteractionHandler("animationend",this.shakeAnimationEndHandler)},i.prototype.destroy=function(){this.adapter.deregisterInteractionHandler("animationend",this.shakeAnimationEndHandler)},i.prototype.getWidth=function(){return this.adapter.getWidth()},i.prototype.shake=function(e){var t=i.cssClasses.LABEL_SHAKE;e?this.adapter.addClass(t):this.adapter.removeClass(t)},i.prototype.float=function(e){var t=i.cssClasses,n=t.LABEL_FLOAT_ABOVE,r=t.LABEL_SHAKE;e?this.adapter.addClass(n):(this.adapter.removeClass(n),this.adapter.removeClass(r))},i.prototype.setRequired=function(e){var t=i.cssClasses.LABEL_REQUIRED;e?this.adapter.addClass(t):this.adapter.removeClass(t)},i.prototype.handleShakeAnimationEnd=function(){var e=i.cssClasses.LABEL_SHAKE;this.adapter.removeClass(e)},i}(hr); +/** + * @license + * Copyright 2016 Google Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */const La=Xn(class extends Kn{constructor(e){switch(super(e),this.foundation=null,this.previousPart=null,e.type){case Wn:case qn:break;default:throw new Error("FloatingLabel directive only support attribute and property parts")}}update(e,[t]){if(e!==this.previousPart){this.foundation&&this.foundation.destroy(),this.previousPart=e;const t=e.element;t.classList.add("mdc-floating-label");const i=(e=>({addClass:t=>e.classList.add(t),removeClass:t=>e.classList.remove(t),getWidth:()=>e.scrollWidth,registerInteractionHandler:(t,i)=>{e.addEventListener(t,i)},deregisterInteractionHandler:(t,i)=>{e.removeEventListener(t,i)}}))(t);this.foundation=new Oa(i),this.foundation.init()}return this.render(t)}render(e){return this.foundation}}); +/** + * @license + * Copyright 2018 Google Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */var za={LINE_RIPPLE_ACTIVE:"mdc-line-ripple--active",LINE_RIPPLE_DEACTIVATING:"mdc-line-ripple--deactivating"},Ra=function(e){function i(t){var n=e.call(this,r(r({},i.defaultAdapter),t))||this;return n.transitionEndHandler=function(e){n.handleTransitionEnd(e)},n}return t(i,e),Object.defineProperty(i,"cssClasses",{get:function(){return za},enumerable:!1,configurable:!0}),Object.defineProperty(i,"defaultAdapter",{get:function(){return{addClass:function(){},removeClass:function(){},hasClass:function(){return!1},setStyle:function(){},registerEventHandler:function(){},deregisterEventHandler:function(){}}},enumerable:!1,configurable:!0}),i.prototype.init=function(){this.adapter.registerEventHandler("transitionend",this.transitionEndHandler)},i.prototype.destroy=function(){this.adapter.deregisterEventHandler("transitionend",this.transitionEndHandler)},i.prototype.activate=function(){this.adapter.removeClass(za.LINE_RIPPLE_DEACTIVATING),this.adapter.addClass(za.LINE_RIPPLE_ACTIVE)},i.prototype.setRippleCenter=function(e){this.adapter.setStyle("transform-origin",e+"px center")},i.prototype.deactivate=function(){this.adapter.addClass(za.LINE_RIPPLE_DEACTIVATING)},i.prototype.handleTransitionEnd=function(e){var t=this.adapter.hasClass(za.LINE_RIPPLE_DEACTIVATING);"opacity"===e.propertyName&&t&&(this.adapter.removeClass(za.LINE_RIPPLE_ACTIVE),this.adapter.removeClass(za.LINE_RIPPLE_DEACTIVATING))},i}(hr); +/** + * @license + * Copyright 2018 Google Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */const Ma=Xn(class extends Kn{constructor(e){switch(super(e),this.previousPart=null,this.foundation=null,e.type){case Wn:case qn:return;default:throw new Error("LineRipple only support attribute and property parts.")}}update(e,t){if(this.previousPart!==e){this.foundation&&this.foundation.destroy(),this.previousPart=e;const t=e.element;t.classList.add("mdc-line-ripple");const i=(e=>({addClass:t=>e.classList.add(t),removeClass:t=>e.classList.remove(t),hasClass:t=>e.classList.contains(t),setStyle:(t,i)=>e.style.setProperty(t,i),registerEventHandler:(t,i)=>{e.addEventListener(t,i)},deregisterEventHandler:(t,i)=>{e.removeEventListener(t,i)}}))(t);this.foundation=new Ra(i),this.foundation.init()}return this.render()}render(){return this.foundation}}); +/** + * @license + * Copyright 2018 Google Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */var $a,Fa,Da={ANCHOR:"mdc-menu-surface--anchor",ANIMATING_CLOSED:"mdc-menu-surface--animating-closed",ANIMATING_OPEN:"mdc-menu-surface--animating-open",FIXED:"mdc-menu-surface--fixed",IS_OPEN_BELOW:"mdc-menu-surface--is-open-below",OPEN:"mdc-menu-surface--open",ROOT:"mdc-menu-surface"},Na={CLOSED_EVENT:"MDCMenuSurface:closed",CLOSING_EVENT:"MDCMenuSurface:closing",OPENED_EVENT:"MDCMenuSurface:opened",FOCUSABLE_ELEMENTS:["button:not(:disabled)",'[href]:not([aria-disabled="true"])',"input:not(:disabled)","select:not(:disabled)","textarea:not(:disabled)",'[tabindex]:not([tabindex="-1"]):not([aria-disabled="true"])'].join(", ")},Pa={TRANSITION_OPEN_DURATION:120,TRANSITION_CLOSE_DURATION:75,MARGIN_TO_EDGE:32,ANCHOR_TO_MENU_SURFACE_WIDTH_RATIO:.67,TOUCH_EVENT_WAIT_MS:30};!function(e){e[e.BOTTOM=1]="BOTTOM",e[e.CENTER=2]="CENTER",e[e.RIGHT=4]="RIGHT",e[e.FLIP_RTL=8]="FLIP_RTL"}($a||($a={})),function(e){e[e.TOP_LEFT=0]="TOP_LEFT",e[e.TOP_RIGHT=4]="TOP_RIGHT",e[e.BOTTOM_LEFT=1]="BOTTOM_LEFT",e[e.BOTTOM_RIGHT=5]="BOTTOM_RIGHT",e[e.TOP_START=8]="TOP_START",e[e.TOP_END=12]="TOP_END",e[e.BOTTOM_START=9]="BOTTOM_START",e[e.BOTTOM_END=13]="BOTTOM_END"}(Fa||(Fa={})); +/** + * @license + * Copyright 2016 Google Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +var Ba={ACTIVATED:"mdc-select--activated",DISABLED:"mdc-select--disabled",FOCUSED:"mdc-select--focused",INVALID:"mdc-select--invalid",MENU_INVALID:"mdc-select__menu--invalid",OUTLINED:"mdc-select--outlined",REQUIRED:"mdc-select--required",ROOT:"mdc-select",WITH_LEADING_ICON:"mdc-select--with-leading-icon"},Ha={ARIA_CONTROLS:"aria-controls",ARIA_DESCRIBEDBY:"aria-describedby",ARIA_SELECTED_ATTR:"aria-selected",CHANGE_EVENT:"MDCSelect:change",HIDDEN_INPUT_SELECTOR:'input[type="hidden"]',LABEL_SELECTOR:".mdc-floating-label",LEADING_ICON_SELECTOR:".mdc-select__icon",LINE_RIPPLE_SELECTOR:".mdc-line-ripple",MENU_SELECTOR:".mdc-select__menu",OUTLINE_SELECTOR:".mdc-notched-outline",SELECTED_TEXT_SELECTOR:".mdc-select__selected-text",SELECT_ANCHOR_SELECTOR:".mdc-select__anchor",VALUE_ATTR:"data-value"},ja={LABEL_SCALE:.75,UNSET_INDEX:-1,CLICK_DEBOUNCE_TIMEOUT_MS:330},Va=function(e){function i(t,n){void 0===n&&(n={});var a=e.call(this,r(r({},i.defaultAdapter),t))||this;return a.disabled=!1,a.isMenuOpen=!1,a.useDefaultValidation=!0,a.customValidity=!0,a.lastSelectedIndex=ja.UNSET_INDEX,a.clickDebounceTimeout=0,a.recentlyClicked=!1,a.leadingIcon=n.leadingIcon,a.helperText=n.helperText,a}return t(i,e),Object.defineProperty(i,"cssClasses",{get:function(){return Ba},enumerable:!1,configurable:!0}),Object.defineProperty(i,"numbers",{get:function(){return ja},enumerable:!1,configurable:!0}),Object.defineProperty(i,"strings",{get:function(){return Ha},enumerable:!1,configurable:!0}),Object.defineProperty(i,"defaultAdapter",{get:function(){return{addClass:function(){},removeClass:function(){},hasClass:function(){return!1},activateBottomLine:function(){},deactivateBottomLine:function(){},getSelectedIndex:function(){return-1},setSelectedIndex:function(){},hasLabel:function(){return!1},floatLabel:function(){},getLabelWidth:function(){return 0},setLabelRequired:function(){},hasOutline:function(){return!1},notchOutline:function(){},closeOutline:function(){},setRippleCenter:function(){},notifyChange:function(){},setSelectedText:function(){},isSelectAnchorFocused:function(){return!1},getSelectAnchorAttr:function(){return""},setSelectAnchorAttr:function(){},removeSelectAnchorAttr:function(){},addMenuClass:function(){},removeMenuClass:function(){},openMenu:function(){},closeMenu:function(){},getAnchorElement:function(){return null},setMenuAnchorElement:function(){},setMenuAnchorCorner:function(){},setMenuWrapFocus:function(){},focusMenuItemAtIndex:function(){},getMenuItemCount:function(){return 0},getMenuItemValues:function(){return[]},getMenuItemTextAtIndex:function(){return""},isTypeaheadInProgress:function(){return!1},typeaheadMatchItem:function(){return-1}}},enumerable:!1,configurable:!0}),i.prototype.getSelectedIndex=function(){return this.adapter.getSelectedIndex()},i.prototype.setSelectedIndex=function(e,t,i){void 0===t&&(t=!1),void 0===i&&(i=!1),e>=this.adapter.getMenuItemCount()||(e===ja.UNSET_INDEX?this.adapter.setSelectedText(""):this.adapter.setSelectedText(this.adapter.getMenuItemTextAtIndex(e).trim()),this.adapter.setSelectedIndex(e),t&&this.adapter.closeMenu(),i||this.lastSelectedIndex===e||this.handleChange(),this.lastSelectedIndex=e)},i.prototype.setValue=function(e,t){void 0===t&&(t=!1);var i=this.adapter.getMenuItemValues().indexOf(e);this.setSelectedIndex(i,!1,t)},i.prototype.getValue=function(){var e=this.adapter.getSelectedIndex(),t=this.adapter.getMenuItemValues();return e!==ja.UNSET_INDEX?t[e]:""},i.prototype.getDisabled=function(){return this.disabled},i.prototype.setDisabled=function(e){this.disabled=e,this.disabled?(this.adapter.addClass(Ba.DISABLED),this.adapter.closeMenu()):this.adapter.removeClass(Ba.DISABLED),this.leadingIcon&&this.leadingIcon.setDisabled(this.disabled),this.disabled?this.adapter.removeSelectAnchorAttr("tabindex"):this.adapter.setSelectAnchorAttr("tabindex","0"),this.adapter.setSelectAnchorAttr("aria-disabled",this.disabled.toString())},i.prototype.openMenu=function(){this.adapter.addClass(Ba.ACTIVATED),this.adapter.openMenu(),this.isMenuOpen=!0,this.adapter.setSelectAnchorAttr("aria-expanded","true")},i.prototype.setHelperTextContent=function(e){this.helperText&&this.helperText.setContent(e)},i.prototype.layout=function(){if(this.adapter.hasLabel()){var e=this.getValue().length>0,t=this.adapter.hasClass(Ba.FOCUSED),i=e||t,n=this.adapter.hasClass(Ba.REQUIRED);this.notchOutline(i),this.adapter.floatLabel(i),this.adapter.setLabelRequired(n)}},i.prototype.layoutOptions=function(){var e=this.adapter.getMenuItemValues().indexOf(this.getValue());this.setSelectedIndex(e,!1,!0)},i.prototype.handleMenuOpened=function(){if(0!==this.adapter.getMenuItemValues().length){var e=this.getSelectedIndex(),t=e>=0?e:0;this.adapter.focusMenuItemAtIndex(t)}},i.prototype.handleMenuClosing=function(){this.adapter.setSelectAnchorAttr("aria-expanded","false")},i.prototype.handleMenuClosed=function(){this.adapter.removeClass(Ba.ACTIVATED),this.isMenuOpen=!1,this.adapter.isSelectAnchorFocused()||this.blur()},i.prototype.handleChange=function(){this.layout(),this.adapter.notifyChange(this.getValue()),this.adapter.hasClass(Ba.REQUIRED)&&this.useDefaultValidation&&this.setValid(this.isValid())},i.prototype.handleMenuItemAction=function(e){this.setSelectedIndex(e,!0)},i.prototype.handleFocus=function(){this.adapter.addClass(Ba.FOCUSED),this.layout(),this.adapter.activateBottomLine()},i.prototype.handleBlur=function(){this.isMenuOpen||this.blur()},i.prototype.handleClick=function(e){this.disabled||this.recentlyClicked||(this.setClickDebounceTimeout(),this.isMenuOpen?this.adapter.closeMenu():(this.adapter.setRippleCenter(e),this.openMenu()))},i.prototype.handleKeydown=function(e){if(!this.isMenuOpen&&this.adapter.hasClass(Ba.FOCUSED)){var t=ha(e)===$r,i=ha(e)===Fr,n=ha(e)===jr,r=ha(e)===Ur;if(!(e.ctrlKey||e.metaKey)&&(!i&&e.key&&1===e.key.length||i&&this.adapter.isTypeaheadInProgress())){var a=i?" ":e.key,o=this.adapter.typeaheadMatchItem(a,this.getSelectedIndex());return o>=0&&this.setSelectedIndex(o),void e.preventDefault()}(t||i||n||r)&&(n&&this.getSelectedIndex()>0?this.setSelectedIndex(this.getSelectedIndex()-1):r&&this.getSelectedIndex(){const t={};for(const i in e)t[i]=e[i];return Object.assign({badInput:!1,customError:!1,patternMismatch:!1,rangeOverflow:!1,rangeUnderflow:!1,stepMismatch:!1,tooLong:!1,tooShort:!1,typeMismatch:!1,valid:!0,valueMissing:!1},t)};class Wa extends Sr{constructor(){super(...arguments),this.mdcFoundationClass=Ua,this.disabled=!1,this.outlined=!1,this.label="",this.outlineOpen=!1,this.outlineWidth=0,this.value="",this.name="",this.selectedText="",this.icon="",this.menuOpen=!1,this.helper="",this.validateOnInitialRender=!1,this.validationMessage="",this.required=!1,this.naturalMenuWidth=!1,this.isUiValid=!0,this.fixedMenuPosition=!1,this.typeaheadState={bufferClearTimeout:0,currentFirstChar:"",sortedIndexCursor:0,typeaheadBuffer:""},this.sortedIndexByFirstChar=new Map,this.menuElement_=null,this.listeners=[],this.onBodyClickBound=()=>{},this._menuUpdateComplete=null,this.valueSetDirectly=!1,this.validityTransform=null,this._validity=Ga()}get items(){return this.menuElement_||(this.menuElement_=this.menuElement),this.menuElement_?this.menuElement_.items:[]}get selected(){const e=this.menuElement;return e?e.selected:null}get index(){const e=this.menuElement;return e?e.index:-1}get shouldRenderHelperText(){return!!this.helper||!!this.validationMessage}get validity(){return this._checkValidity(this.value),this._validity}render(){const e={"mdc-select--disabled":this.disabled,"mdc-select--no-label":!this.label,"mdc-select--filled":!this.outlined,"mdc-select--outlined":this.outlined,"mdc-select--with-leading-icon":!!this.icon,"mdc-select--required":this.required,"mdc-select--invalid":!this.isUiValid},t={"mdc-select__menu--invalid":!this.isUiValid},i=this.label?"label":void 0,n=this.shouldRenderHelperText?"helper-text":void 0;return U` +
+ + +
+ ${this.renderRipple()} + ${this.outlined?this.renderOutline():this.renderLabel()} + ${this.renderLeadingIcon()} + + ${this.selectedText} + + + + + + + + + + ${this.renderLineRipple()} +
+ + + +
+ ${this.renderHelperText()}`}renderRipple(){return this.outlined?W:U` + + `}renderOutline(){return this.outlined?U` + + ${this.renderLabel()} + `:W}renderLabel(){return this.label?U` + ${this.label} + `:W}renderLeadingIcon(){return this.icon?U`
${this.icon}
`:W}renderLineRipple(){return this.outlined?W:U` + + `}renderHelperText(){if(!this.shouldRenderHelperText)return W;const e=this.validationMessage&&!this.isUiValid;return U` +

${e?this.validationMessage:this.helper}

`}createAdapter(){return Object.assign(Object.assign({},br(this.mdcRoot)),{activateBottomLine:()=>{this.lineRippleElement&&this.lineRippleElement.lineRippleFoundation.activate()},deactivateBottomLine:()=>{this.lineRippleElement&&this.lineRippleElement.lineRippleFoundation.deactivate()},hasLabel:()=>!!this.label,floatLabel:e=>{this.labelElement&&this.labelElement.floatingLabelFoundation.float(e)},getLabelWidth:()=>this.labelElement?this.labelElement.floatingLabelFoundation.getWidth():0,setLabelRequired:e=>{this.labelElement&&this.labelElement.floatingLabelFoundation.setRequired(e)},hasOutline:()=>this.outlined,notchOutline:e=>{this.outlineElement&&!this.outlineOpen&&(this.outlineWidth=e,this.outlineOpen=!0)},closeOutline:()=>{this.outlineElement&&(this.outlineOpen=!1)},setRippleCenter:e=>{if(this.lineRippleElement){this.lineRippleElement.lineRippleFoundation.setRippleCenter(e)}},notifyChange:async e=>{if(!this.valueSetDirectly&&e===this.value)return;this.valueSetDirectly=!1,this.value=e,await this.updateComplete;const t=new Event("change",{bubbles:!0});this.dispatchEvent(t)},setSelectedText:e=>this.selectedText=e,isSelectAnchorFocused:()=>{const e=this.anchorElement;if(!e)return!1;return e.getRootNode().activeElement===e},getSelectAnchorAttr:e=>{const t=this.anchorElement;return t?t.getAttribute(e):null},setSelectAnchorAttr:(e,t)=>{const i=this.anchorElement;i&&i.setAttribute(e,t)},removeSelectAnchorAttr:e=>{const t=this.anchorElement;t&&t.removeAttribute(e)},openMenu:()=>{this.menuOpen=!0},closeMenu:()=>{this.menuOpen=!1},addMenuClass:()=>{},removeMenuClass:()=>{},getAnchorElement:()=>this.anchorElement,setMenuAnchorElement:()=>{},setMenuAnchorCorner:()=>{const e=this.menuElement;e&&(e.corner="BOTTOM_START")},setMenuWrapFocus:e=>{const t=this.menuElement;t&&(t.wrapFocus=e)},focusMenuItemAtIndex:e=>{const t=this.menuElement;if(!t)return;const i=t.items[e];i&&i.focus()},getMenuItemCount:()=>{const e=this.menuElement;return e?e.items.length:0},getMenuItemValues:()=>{const e=this.menuElement;if(!e)return[];return e.items.map((e=>e.value))},getMenuItemTextAtIndex:e=>{const t=this.menuElement;if(!t)return"";const i=t.items[e];return i?i.text:""},getSelectedIndex:()=>this.index,setSelectedIndex:()=>{},isTypeaheadInProgress:()=>Ia(this.typeaheadState),typeaheadMatchItem:(e,t)=>{if(!this.menuElement)return-1;const i={focusItemAtIndex:e=>{this.menuElement.focusItemAtIndex(e)},focusedItemIndex:t||this.menuElement.getFocusedItemIndex(),nextChar:e,sortedIndexByFirstChar:this.sortedIndexByFirstChar,skipFocus:!1,isItemAtIndexDisabled:e=>this.items[e].disabled},n=Sa(i,this.typeaheadState);return-1!==n&&this.select(n),n}})}checkValidity(){const e=this._checkValidity(this.value);if(!e){const e=new Event("invalid",{bubbles:!1,cancelable:!0});this.dispatchEvent(e)}return e}reportValidity(){const e=this.checkValidity();return this.isUiValid=e,e}_checkValidity(e){const t=this.formElement.validity;let i=Ga(t);if(this.validityTransform){const t=this.validityTransform(e,i);i=Object.assign(Object.assign({},i),t)}return this._validity=i,this._validity.valid}setCustomValidity(e){this.validationMessage=e,this.formElement.setCustomValidity(e)}async getUpdateComplete(){await this._menuUpdateComplete;return await super.getUpdateComplete()}async firstUpdated(){const e=this.menuElement;if(e&&(this._menuUpdateComplete=e.updateComplete,await this._menuUpdateComplete),super.firstUpdated(),this.mdcFoundation.isValid=()=>!0,this.mdcFoundation.setValid=()=>{},this.mdcFoundation.setDisabled(this.disabled),this.validateOnInitialRender&&this.reportValidity(),!this.selected){!this.items.length&&this.slotElement&&this.slotElement.assignedNodes({flatten:!0}).length&&(await new Promise((e=>requestAnimationFrame(e))),await this.layout());const e=this.items.length&&""===this.items[0].value;if(!this.value&&e)return void this.select(0);this.selectByValue(this.value)}this.sortedIndexByFirstChar=Ta(this.items.length,(e=>this.items[e].text))}onItemsUpdated(){this.sortedIndexByFirstChar=Ta(this.items.length,(e=>this.items[e].text))}select(e){const t=this.menuElement;t&&t.select(e)}selectByValue(e){let t=-1;for(let i=0;i0,r=i&&this.index{this.menuElement.focusItemAtIndex(e)},focusedItemIndex:t,isTargetListItem:!!i&&i.hasAttribute("mwc-list-item"),sortedIndexByFirstChar:this.sortedIndexByFirstChar,isItemAtIndexDisabled:e=>this.items[e].disabled};!function(e,t){var i=e.event,n=e.isTargetListItem,r=e.focusedItemIndex,a=e.focusItemAtIndex,o=e.sortedIndexByFirstChar,s=e.isItemAtIndexDisabled,l="ArrowLeft"===ha(i),d="ArrowUp"===ha(i),c="ArrowRight"===ha(i),p="ArrowDown"===ha(i),m="Home"===ha(i),h="End"===ha(i),u="Enter"===ha(i),f="Spacebar"===ha(i);i.ctrlKey||i.metaKey||l||d||c||p||m||h||u||(f||1!==i.key.length?f&&(n&&Ca(i),n&&Ia(t)&&Sa({focusItemAtIndex:a,focusedItemIndex:r,nextChar:" ",sortedIndexByFirstChar:o,skipFocus:!1,isItemAtIndexDisabled:s},t)):(Ca(i),Sa({focusItemAtIndex:a,focusedItemIndex:r,nextChar:i.key.toLowerCase(),sortedIndexByFirstChar:o,skipFocus:!1,isItemAtIndexDisabled:s},t)))}(n,this.typeaheadState)}async onSelected(e){this.mdcFoundation||await this.updateComplete,this.mdcFoundation.handleMenuItemAction(e.detail.index);const t=this.items[e.detail.index];t&&(this.value=t.value)}onOpened(){this.mdcFoundation&&(this.menuOpen=!0,this.mdcFoundation.handleMenuOpened())}onClosed(){this.mdcFoundation&&(this.menuOpen=!1,this.mdcFoundation.handleMenuClosed())}setFormData(e){this.name&&null!==this.selected&&e.append(this.name,this.value)}async layout(e=!0){this.mdcFoundation&&this.mdcFoundation.layout(),await this.updateComplete;const t=this.menuElement;t&&t.layout(e);const i=this.labelElement;if(!i)return void(this.outlineOpen=!1);const n=!!this.label&&!!this.value;if(i.floatingLabelFoundation.float(n),!this.outlined)return;this.outlineOpen=n,await this.updateComplete;const r=i.floatingLabelFoundation.getWidth();this.outlineOpen&&(this.outlineWidth=r)}async layoutOptions(){this.mdcFoundation&&this.mdcFoundation.layoutOptions()}}a([ve(".mdc-select")],Wa.prototype,"mdcRoot",void 0),a([ve(".formElement")],Wa.prototype,"formElement",void 0),a([ve("slot")],Wa.prototype,"slotElement",void 0),a([ve("select")],Wa.prototype,"nativeSelectElement",void 0),a([ve("input")],Wa.prototype,"nativeInputElement",void 0),a([ve(".mdc-line-ripple")],Wa.prototype,"lineRippleElement",void 0),a([ve(".mdc-floating-label")],Wa.prototype,"labelElement",void 0),a([ve("mwc-notched-outline")],Wa.prototype,"outlineElement",void 0),a([ve(".mdc-menu")],Wa.prototype,"menuElement",void 0),a([ve(".mdc-select__anchor")],Wa.prototype,"anchorElement",void 0),a([he({type:Boolean,attribute:"disabled",reflect:!0}),Ir((function(e){this.mdcFoundation&&this.mdcFoundation.setDisabled(e)}))],Wa.prototype,"disabled",void 0),a([he({type:Boolean}),Ir((function(e,t){void 0!==t&&this.outlined!==t&&this.layout(!1)}))],Wa.prototype,"outlined",void 0),a([he({type:String}),Ir((function(e,t){void 0!==t&&this.label!==t&&this.layout(!1)}))],Wa.prototype,"label",void 0),a([ue()],Wa.prototype,"outlineOpen",void 0),a([ue()],Wa.prototype,"outlineWidth",void 0),a([he({type:String}),Ir((function(e){if(this.mdcFoundation){const t=null===this.selected&&!!e,i=this.selected&&this.selected.value!==e;(t||i)&&this.selectByValue(e),this.reportValidity()}}))],Wa.prototype,"value",void 0),a([he()],Wa.prototype,"name",void 0),a([ue()],Wa.prototype,"selectedText",void 0),a([he({type:String})],Wa.prototype,"icon",void 0),a([ue()],Wa.prototype,"menuOpen",void 0),a([he({type:String})],Wa.prototype,"helper",void 0),a([he({type:Boolean})],Wa.prototype,"validateOnInitialRender",void 0),a([he({type:String})],Wa.prototype,"validationMessage",void 0),a([he({type:Boolean})],Wa.prototype,"required",void 0),a([he({type:Boolean})],Wa.prototype,"naturalMenuWidth",void 0),a([ue()],Wa.prototype,"isUiValid",void 0),a([he({type:Boolean})],Wa.prototype,"fixedMenuPosition",void 0),a([ge({capture:!0})],Wa.prototype,"handleTypeahead",null); +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +const qa=(e,t)=>e-t,Ya=["input","button","textarea","select"];function Xa(e){return e instanceof Set}const Ka=e=>{const t=e===Ea.UNSET_INDEX?new Set:e;return Xa(t)?new Set(t):new Set([t])};class Qa extends hr{constructor(e){super(Object.assign(Object.assign({},Qa.defaultAdapter),e)),this.isMulti_=!1,this.wrapFocus_=!1,this.isVertical_=!0,this.selectedIndex_=Ea.UNSET_INDEX,this.focusedItemIndex_=Ea.UNSET_INDEX,this.useActivatedClass_=!1,this.ariaCurrentAttrValue_=null}static get strings(){return wa}static get numbers(){return Ea}static get defaultAdapter(){return{focusItemAtIndex:()=>{},getFocusedElementIndex:()=>0,getListItemCount:()=>0,isFocusInsideList:()=>!1,isRootFocused:()=>!1,notifyAction:()=>{},notifySelected:()=>{},getSelectedStateForElementIndex:()=>!1,setDisabledStateForElementIndex:()=>{},getDisabledStateForElementIndex:()=>!1,setSelectedStateForElementIndex:()=>{},setActivatedStateForElementIndex:()=>{},setTabIndexForElementIndex:()=>{},setAttributeForElementIndex:()=>{},getAttributeForElementIndex:()=>null}}setWrapFocus(e){this.wrapFocus_=e}setMulti(e){this.isMulti_=e;const t=this.selectedIndex_;if(e){if(!Xa(t)){const e=t===Ea.UNSET_INDEX;this.selectedIndex_=e?new Set:new Set([t])}}else if(Xa(t))if(t.size){const e=Array.from(t).sort(qa);this.selectedIndex_=e[0]}else this.selectedIndex_=Ea.UNSET_INDEX}setVerticalOrientation(e){this.isVertical_=e}setUseActivatedClass(e){this.useActivatedClass_=e}getSelectedIndex(){return this.selectedIndex_}setSelectedIndex(e){this.isIndexValid_(e)&&(this.isMulti_?this.setMultiSelectionAtIndex_(Ka(e)):this.setSingleSelectionAtIndex_(e))}handleFocusIn(e,t){t>=0&&this.adapter.setTabIndexForElementIndex(t,0)}handleFocusOut(e,t){t>=0&&this.adapter.setTabIndexForElementIndex(t,-1),setTimeout((()=>{this.adapter.isFocusInsideList()||this.setTabindexToFirstSelectedItem_()}),0)}handleKeydown(e,t,i){const n="ArrowLeft"===ha(e),r="ArrowUp"===ha(e),a="ArrowRight"===ha(e),o="ArrowDown"===ha(e),s="Home"===ha(e),l="End"===ha(e),d="Enter"===ha(e),c="Spacebar"===ha(e);if(this.adapter.isRootFocused())return void(r||l?(e.preventDefault(),this.focusLastElement()):(o||s)&&(e.preventDefault(),this.focusFirstElement()));let p,m=this.adapter.getFocusedElementIndex();if(!(-1===m&&(m=i,m<0))){if(this.isVertical_&&o||!this.isVertical_&&a)this.preventDefaultEvent(e),p=this.focusNextElement(m);else if(this.isVertical_&&r||!this.isVertical_&&n)this.preventDefaultEvent(e),p=this.focusPrevElement(m);else if(s)this.preventDefaultEvent(e),p=this.focusFirstElement();else if(l)this.preventDefaultEvent(e),p=this.focusLastElement();else if((d||c)&&t){const t=e.target;if(t&&"A"===t.tagName&&d)return;this.preventDefaultEvent(e),this.setSelectedIndexOnAction_(m,!0)}this.focusedItemIndex_=m,void 0!==p&&(this.setTabindexAtIndex_(p),this.focusedItemIndex_=p)}}handleSingleSelection(e,t,i){e!==Ea.UNSET_INDEX&&(this.setSelectedIndexOnAction_(e,t,i),this.setTabindexAtIndex_(e),this.focusedItemIndex_=e)}focusNextElement(e){let t=e+1;if(t>=this.adapter.getListItemCount()){if(!this.wrapFocus_)return e;t=0}return this.adapter.focusItemAtIndex(t),t}focusPrevElement(e){let t=e-1;if(t<0){if(!this.wrapFocus_)return e;t=this.adapter.getListItemCount()-1}return this.adapter.focusItemAtIndex(t),t}focusFirstElement(){return this.adapter.focusItemAtIndex(0),0}focusLastElement(){const e=this.adapter.getListItemCount()-1;return this.adapter.focusItemAtIndex(e),e}setEnabled(e,t){this.isIndexValid_(e)&&this.adapter.setDisabledStateForElementIndex(e,!t)}preventDefaultEvent(e){const t=`${e.target.tagName}`.toLowerCase();-1===Ya.indexOf(t)&&e.preventDefault()}setSingleSelectionAtIndex_(e,t=!0){this.selectedIndex_!==e&&(this.selectedIndex_!==Ea.UNSET_INDEX&&(this.adapter.setSelectedStateForElementIndex(this.selectedIndex_,!1),this.useActivatedClass_&&this.adapter.setActivatedStateForElementIndex(this.selectedIndex_,!1)),t&&this.adapter.setSelectedStateForElementIndex(e,!0),this.useActivatedClass_&&this.adapter.setActivatedStateForElementIndex(e,!0),this.setAriaForSingleSelectionAtIndex_(e),this.selectedIndex_=e,this.adapter.notifySelected(e))}setMultiSelectionAtIndex_(e,t=!0){const i=((e,t)=>{const i=Array.from(e),n=Array.from(t),r={added:[],removed:[]},a=i.sort(qa),o=n.sort(qa);let s=0,l=0;for(;s=0&&this.focusedItemIndex_!==e&&this.adapter.setTabIndexForElementIndex(this.focusedItemIndex_,-1),this.adapter.setTabIndexForElementIndex(e,0)}setTabindexToFirstSelectedItem_(){let e=0;"number"==typeof this.selectedIndex_&&this.selectedIndex_!==Ea.UNSET_INDEX?e=this.selectedIndex_:Xa(this.selectedIndex_)&&this.selectedIndex_.size>0&&(e=Math.min(...this.selectedIndex_)),this.setTabindexAtIndex_(e)}isIndexValid_(e){if(e instanceof Set){if(!this.isMulti_)throw new Error("MDCListFoundation: Array of index is only supported for checkbox based list");if(0===e.size)return!0;{let t=!1;for(const i of e)if(t=this.isIndexInRange_(i),t)break;return t}}if("number"==typeof e){if(this.isMulti_)throw new Error("MDCListFoundation: Expected array of index for checkbox based list but got number: "+e);return e===Ea.UNSET_INDEX||this.isIndexInRange_(e)}return!1}isIndexInRange_(e){const t=this.adapter.getListItemCount();return e>=0&&ee.hasAttribute("mwc-list-item");function Ja(){const e=this.itemsReadyResolver;this.itemsReady=new Promise((e=>this.itemsReadyResolver=e)),e()}class eo extends Er{constructor(){super(),this.mdcAdapter=null,this.mdcFoundationClass=Qa,this.activatable=!1,this.multi=!1,this.wrapFocus=!1,this.itemRoles=null,this.innerRole=null,this.innerAriaLabel=null,this.rootTabbable=!1,this.previousTabindex=null,this.noninteractive=!1,this.itemsReadyResolver=()=>{},this.itemsReady=Promise.resolve([]),this.items_=[];const e=function(e,t=50){let i;return function(n=!0){clearTimeout(i),i=setTimeout((()=>{e(n)}),t)}}(this.layout.bind(this));this.debouncedLayout=(t=!0)=>{Ja.call(this),e(t)}}async getUpdateComplete(){const e=await super.getUpdateComplete();return await this.itemsReady,e}get items(){return this.items_}updateItems(){var e;const t=null!==(e=this.assignedElements)&&void 0!==e?e:[],i=[];for(const e of t)Za(e)&&(i.push(e),e._managingList=this),e.hasAttribute("divider")&&!e.hasAttribute("role")&&e.setAttribute("role","separator");this.items_=i;const n=new Set;if(this.items_.forEach(((e,t)=>{this.itemRoles?e.setAttribute("role",this.itemRoles):e.removeAttribute("role"),e.selected&&n.add(t)})),this.multi)this.select(n);else{const e=n.size?n.entries().next().value[1]:-1;this.select(e)}const r=new Event("items-updated",{bubbles:!0,composed:!0});this.dispatchEvent(r)}get selected(){const e=this.index;if(!Xa(e))return-1===e?null:this.items[e];const t=[];for(const i of e)t.push(this.items[i]);return t}get index(){return this.mdcFoundation?this.mdcFoundation.getSelectedIndex():-1}render(){const e=null===this.innerRole?void 0:this.innerRole,t=null===this.innerAriaLabel?void 0:this.innerAriaLabel,i=this.rootTabbable?"0":"-1";return U` + +
    + + ${this.renderPlaceholder()} +
+ `}renderPlaceholder(){var e;const t=null!==(e=this.assignedElements)&&void 0!==e?e:[];return void 0!==this.emptyMessage&&0===t.length?U` + ${this.emptyMessage} + `:null}firstUpdated(){super.firstUpdated(),this.items.length||(this.mdcFoundation.setMulti(this.multi),this.layout())}onFocusIn(e){if(this.mdcFoundation&&this.mdcRoot){const t=this.getIndexOfTarget(e);this.mdcFoundation.handleFocusIn(e,t)}}onFocusOut(e){if(this.mdcFoundation&&this.mdcRoot){const t=this.getIndexOfTarget(e);this.mdcFoundation.handleFocusOut(e,t)}}onKeydown(e){if(this.mdcFoundation&&this.mdcRoot){const t=this.getIndexOfTarget(e),i=e.target,n=Za(i);this.mdcFoundation.handleKeydown(e,n,t)}}onRequestSelected(e){if(this.mdcFoundation){let t=this.getIndexOfTarget(e);if(-1===t&&(this.layout(),t=this.getIndexOfTarget(e),-1===t))return;if(this.items[t].disabled)return;const i=e.detail.selected,n=e.detail.source;this.mdcFoundation.handleSingleSelection(t,"interaction"===n,i),e.stopPropagation()}}getIndexOfTarget(e){const t=this.items,i=e.composedPath();for(const e of i){let i=-1;if(vr(e)&&Za(e)&&(i=t.indexOf(e)),-1!==i)return i}return-1}createAdapter(){return this.mdcAdapter={getListItemCount:()=>this.mdcRoot?this.items.length:0,getFocusedElementIndex:this.getFocusedItemIndex,getAttributeForElementIndex:(e,t)=>{if(!this.mdcRoot)return"";const i=this.items[e];return i?i.getAttribute(t):""},setAttributeForElementIndex:(e,t,i)=>{if(!this.mdcRoot)return;const n=this.items[e];n&&n.setAttribute(t,i)},focusItemAtIndex:e=>{const t=this.items[e];t&&t.focus()},setTabIndexForElementIndex:(e,t)=>{const i=this.items[e];i&&(i.tabindex=t)},notifyAction:e=>{const t={bubbles:!0,composed:!0};t.detail={index:e};const i=new CustomEvent("action",t);this.dispatchEvent(i)},notifySelected:(e,t)=>{const i={bubbles:!0,composed:!0};i.detail={index:e,diff:t};const n=new CustomEvent("selected",i);this.dispatchEvent(n)},isFocusInsideList:()=>wr(this),isRootFocused:()=>{const e=this.mdcRoot;return e.getRootNode().activeElement===e},setDisabledStateForElementIndex:(e,t)=>{const i=this.items[e];i&&(i.disabled=t)},getDisabledStateForElementIndex:e=>{const t=this.items[e];return!!t&&t.disabled},setSelectedStateForElementIndex:(e,t)=>{const i=this.items[e];i&&(i.selected=t)},getSelectedStateForElementIndex:e=>{const t=this.items[e];return!!t&&t.selected},setActivatedStateForElementIndex:(e,t)=>{const i=this.items[e];i&&(i.activated=t)}},this.mdcAdapter}selectUi(e,t=!1){const i=this.items[e];i&&(i.selected=!0,i.activated=t)}deselectUi(e){const t=this.items[e];t&&(t.selected=!1,t.activated=!1)}select(e){this.mdcFoundation&&this.mdcFoundation.setSelectedIndex(e)}toggle(e,t){this.multi&&this.mdcFoundation.toggleMultiAtIndex(e,t)}onListItemConnected(e){const t=e.target;this.layout(-1===this.items.indexOf(t))}layout(e=!0){e&&this.updateItems();const t=this.items[0];for(const e of this.items)e.tabindex=-1;t&&(this.noninteractive?this.previousTabindex||(this.previousTabindex=t):t.tabindex=0),this.itemsReadyResolver()}getFocusedItemIndex(){if(!this.mdcRoot)return-1;if(!this.items.length)return-1;const e=_r();if(!e.length)return-1;for(let t=e.length-1;t>=0;t--){const i=e[t];if(Za(i))return this.items.indexOf(i)}return-1}focusItemAtIndex(e){for(const e of this.items)if(0===e.tabindex){e.tabindex=-1;break}this.items[e].tabindex=0,this.items[e].focus()}focus(){const e=this.mdcRoot;e&&e.focus()}blur(){const e=this.mdcRoot;e&&e.blur()}}a([he({type:String})],eo.prototype,"emptyMessage",void 0),a([ve(".mdc-deprecated-list")],eo.prototype,"mdcRoot",void 0),a([_e("",!0,"*")],eo.prototype,"assignedElements",void 0),a([_e("",!0,'[tabindex="0"]')],eo.prototype,"tabbableElements",void 0),a([he({type:Boolean}),Ir((function(e){this.mdcFoundation&&this.mdcFoundation.setUseActivatedClass(e)}))],eo.prototype,"activatable",void 0),a([he({type:Boolean}),Ir((function(e,t){this.mdcFoundation&&this.mdcFoundation.setMulti(e),void 0!==t&&this.layout()}))],eo.prototype,"multi",void 0),a([he({type:Boolean}),Ir((function(e){this.mdcFoundation&&this.mdcFoundation.setWrapFocus(e)}))],eo.prototype,"wrapFocus",void 0),a([he({type:String}),Ir((function(e,t){void 0!==t&&this.updateItems()}))],eo.prototype,"itemRoles",void 0),a([he({type:String})],eo.prototype,"innerRole",void 0),a([he({type:String})],eo.prototype,"innerAriaLabel",void 0),a([he({type:Boolean})],eo.prototype,"rootTabbable",void 0),a([he({type:Boolean,reflect:!0}),Ir((function(e){var t,i;if(e){const e=null!==(i=null===(t=this.tabbableElements)||void 0===t?void 0:t[0])&&void 0!==i?i:null;this.previousTabindex=e,e&&e.setAttribute("tabindex","-1")}else!e&&this.previousTabindex&&(this.previousTabindex.setAttribute("tabindex","0"),this.previousTabindex=null)}))],eo.prototype,"noninteractive",void 0); +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +class to{constructor(e){this.startPress=t=>{e().then((e=>{e&&e.startPress(t)}))},this.endPress=()=>{e().then((e=>{e&&e.endPress()}))},this.startFocus=()=>{e().then((e=>{e&&e.startFocus()}))},this.endFocus=()=>{e().then((e=>{e&&e.endFocus()}))},this.startHover=()=>{e().then((e=>{e&&e.startHover()}))},this.endHover=()=>{e().then((e=>{e&&e.endHover()}))}}} +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */class io extends de{constructor(){super(...arguments),this.value="",this.group=null,this.tabindex=-1,this.disabled=!1,this.twoline=!1,this.activated=!1,this.graphic=null,this.multipleGraphics=!1,this.hasMeta=!1,this.noninteractive=!1,this.selected=!1,this.shouldRenderRipple=!1,this._managingList=null,this.boundOnClick=this.onClick.bind(this),this._firstChanged=!0,this._skipPropRequest=!1,this.rippleHandlers=new to((()=>(this.shouldRenderRipple=!0,this.ripple))),this.listeners=[{target:this,eventNames:["click"],cb:()=>{this.onClick()}},{target:this,eventNames:["mouseenter"],cb:this.rippleHandlers.startHover},{target:this,eventNames:["mouseleave"],cb:this.rippleHandlers.endHover},{target:this,eventNames:["focus"],cb:this.rippleHandlers.startFocus},{target:this,eventNames:["blur"],cb:this.rippleHandlers.endFocus},{target:this,eventNames:["mousedown","touchstart"],cb:e=>{const t=e.type;this.onDown("mousedown"===t?"mouseup":"touchend",e)}}]}get text(){const e=this.textContent;return e?e.trim():""}render(){const e=this.renderText(),t=this.graphic?this.renderGraphic():U``,i=this.hasMeta?this.renderMeta():U``;return U` + ${this.renderRipple()} + ${t} + ${e} + ${i}`}renderRipple(){return this.shouldRenderRipple?U` + + `:this.activated?U`
`:""}renderGraphic(){const e={multi:this.multipleGraphics};return U` + + + `}renderMeta(){return U` + + + `}renderText(){const e=this.twoline?this.renderTwoline():this.renderSingleLine();return U` + + ${e} + `}renderSingleLine(){return U``}renderTwoline(){return U` + + + + + + + `}onClick(){this.fireRequestSelected(!this.selected,"interaction")}onDown(e,t){const i=()=>{window.removeEventListener(e,i),this.rippleHandlers.endPress()};window.addEventListener(e,i),this.rippleHandlers.startPress(t)}fireRequestSelected(e,t){if(this.noninteractive)return;const i=new CustomEvent("request-selected",{bubbles:!0,composed:!0,detail:{source:t,selected:e}});this.dispatchEvent(i)}connectedCallback(){super.connectedCallback(),this.noninteractive||this.setAttribute("mwc-list-item","");for(const e of this.listeners)for(const t of e.eventNames)e.target.addEventListener(t,e.cb,{passive:!0})}disconnectedCallback(){super.disconnectedCallback();for(const e of this.listeners)for(const t of e.eventNames)e.target.removeEventListener(t,e.cb);this._managingList&&(this._managingList.debouncedLayout?this._managingList.debouncedLayout(!0):this._managingList.layout(!0))}firstUpdated(){const e=new Event("list-item-rendered",{bubbles:!0,composed:!0});this.dispatchEvent(e)}}a([ve("slot")],io.prototype,"slotElement",void 0),a([be("mwc-ripple")],io.prototype,"ripple",void 0),a([he({type:String})],io.prototype,"value",void 0),a([he({type:String,reflect:!0})],io.prototype,"group",void 0),a([he({type:Number,reflect:!0})],io.prototype,"tabindex",void 0),a([he({type:Boolean,reflect:!0}),Ir((function(e){e?this.setAttribute("aria-disabled","true"):this.setAttribute("aria-disabled","false")}))],io.prototype,"disabled",void 0),a([he({type:Boolean,reflect:!0})],io.prototype,"twoline",void 0),a([he({type:Boolean,reflect:!0})],io.prototype,"activated",void 0),a([he({type:String,reflect:!0})],io.prototype,"graphic",void 0),a([he({type:Boolean})],io.prototype,"multipleGraphics",void 0),a([he({type:Boolean})],io.prototype,"hasMeta",void 0),a([he({type:Boolean,reflect:!0}),Ir((function(e){e?(this.removeAttribute("aria-checked"),this.removeAttribute("mwc-list-item"),this.selected=!1,this.activated=!1,this.tabIndex=-1):this.setAttribute("mwc-list-item","")}))],io.prototype,"noninteractive",void 0),a([he({type:Boolean,reflect:!0}),Ir((function(e){const t=this.getAttribute("role"),i="gridcell"===t||"option"===t||"row"===t||"tab"===t;i&&e?this.setAttribute("aria-selected","true"):i&&this.setAttribute("aria-selected","false"),this._firstChanged?this._firstChanged=!1:this._skipPropRequest||this.fireRequestSelected(e,"property")}))],io.prototype,"selected",void 0),a([ue()],io.prototype,"shouldRenderRipple",void 0),a([ue()],io.prototype,"_managingList",void 0); +/** + * @license + * Copyright 2018 Google Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +var no,ro={MENU_SELECTED_LIST_ITEM:"mdc-menu-item--selected",MENU_SELECTION_GROUP:"mdc-menu__selection-group",ROOT:"mdc-menu"},ao={ARIA_CHECKED_ATTR:"aria-checked",ARIA_DISABLED_ATTR:"aria-disabled",CHECKBOX_SELECTOR:'input[type="checkbox"]',LIST_SELECTOR:".mdc-list,.mdc-deprecated-list",SELECTED_EVENT:"MDCMenu:selected",SKIP_RESTORE_FOCUS:"data-menu-item-skip-restore-focus"},oo={FOCUS_ROOT_INDEX:-1};!function(e){e[e.NONE=0]="NONE",e[e.LIST_ROOT=1]="LIST_ROOT",e[e.FIRST_ITEM=2]="FIRST_ITEM",e[e.LAST_ITEM=3]="LAST_ITEM"}(no||(no={})); +/** + * @license + * Copyright 2018 Google Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +var so=function(e){function i(t){var n=e.call(this,r(r({},i.defaultAdapter),t))||this;return n.isSurfaceOpen=!1,n.isQuickOpen=!1,n.isHoistedElement=!1,n.isFixedPosition=!1,n.isHorizontallyCenteredOnViewport=!1,n.maxHeight=0,n.openBottomBias=0,n.openAnimationEndTimerId=0,n.closeAnimationEndTimerId=0,n.animationRequestId=0,n.anchorCorner=Fa.TOP_START,n.originCorner=Fa.TOP_START,n.anchorMargin={top:0,right:0,bottom:0,left:0},n.position={x:0,y:0},n}return t(i,e),Object.defineProperty(i,"cssClasses",{get:function(){return Da},enumerable:!1,configurable:!0}),Object.defineProperty(i,"strings",{get:function(){return Na},enumerable:!1,configurable:!0}),Object.defineProperty(i,"numbers",{get:function(){return Pa},enumerable:!1,configurable:!0}),Object.defineProperty(i,"Corner",{get:function(){return Fa},enumerable:!1,configurable:!0}),Object.defineProperty(i,"defaultAdapter",{get:function(){return{addClass:function(){},removeClass:function(){},hasClass:function(){return!1},hasAnchor:function(){return!1},isElementInContainer:function(){return!1},isFocused:function(){return!1},isRtl:function(){return!1},getInnerDimensions:function(){return{height:0,width:0}},getAnchorDimensions:function(){return null},getWindowDimensions:function(){return{height:0,width:0}},getBodyDimensions:function(){return{height:0,width:0}},getWindowScroll:function(){return{x:0,y:0}},setPosition:function(){},setMaxHeight:function(){},setTransformOrigin:function(){},saveFocus:function(){},restoreFocus:function(){},notifyClose:function(){},notifyOpen:function(){},notifyClosing:function(){}}},enumerable:!1,configurable:!0}),i.prototype.init=function(){var e=i.cssClasses,t=e.ROOT,n=e.OPEN;if(!this.adapter.hasClass(t))throw new Error(t+" class required in root element.");this.adapter.hasClass(n)&&(this.isSurfaceOpen=!0)},i.prototype.destroy=function(){clearTimeout(this.openAnimationEndTimerId),clearTimeout(this.closeAnimationEndTimerId),cancelAnimationFrame(this.animationRequestId)},i.prototype.setAnchorCorner=function(e){this.anchorCorner=e},i.prototype.flipCornerHorizontally=function(){this.originCorner=this.originCorner^$a.RIGHT},i.prototype.setAnchorMargin=function(e){this.anchorMargin.top=e.top||0,this.anchorMargin.right=e.right||0,this.anchorMargin.bottom=e.bottom||0,this.anchorMargin.left=e.left||0},i.prototype.setIsHoisted=function(e){this.isHoistedElement=e},i.prototype.setFixedPosition=function(e){this.isFixedPosition=e},i.prototype.isFixed=function(){return this.isFixedPosition},i.prototype.setAbsolutePosition=function(e,t){this.position.x=this.isFinite(e)?e:0,this.position.y=this.isFinite(t)?t:0},i.prototype.setIsHorizontallyCenteredOnViewport=function(e){this.isHorizontallyCenteredOnViewport=e},i.prototype.setQuickOpen=function(e){this.isQuickOpen=e},i.prototype.setMaxHeight=function(e){this.maxHeight=e},i.prototype.setOpenBottomBias=function(e){this.openBottomBias=e},i.prototype.isOpen=function(){return this.isSurfaceOpen},i.prototype.open=function(){var e=this;this.isSurfaceOpen||(this.adapter.saveFocus(),this.isQuickOpen?(this.isSurfaceOpen=!0,this.adapter.addClass(i.cssClasses.OPEN),this.dimensions=this.adapter.getInnerDimensions(),this.autoposition(),this.adapter.notifyOpen()):(this.adapter.addClass(i.cssClasses.ANIMATING_OPEN),this.animationRequestId=requestAnimationFrame((function(){e.dimensions=e.adapter.getInnerDimensions(),e.autoposition(),e.adapter.addClass(i.cssClasses.OPEN),e.openAnimationEndTimerId=setTimeout((function(){e.openAnimationEndTimerId=0,e.adapter.removeClass(i.cssClasses.ANIMATING_OPEN),e.adapter.notifyOpen()}),Pa.TRANSITION_OPEN_DURATION)})),this.isSurfaceOpen=!0))},i.prototype.close=function(e){var t=this;if(void 0===e&&(e=!1),this.isSurfaceOpen){if(this.adapter.notifyClosing(),this.isQuickOpen)return this.isSurfaceOpen=!1,e||this.maybeRestoreFocus(),this.adapter.removeClass(i.cssClasses.OPEN),this.adapter.removeClass(i.cssClasses.IS_OPEN_BELOW),void this.adapter.notifyClose();this.adapter.addClass(i.cssClasses.ANIMATING_CLOSED),requestAnimationFrame((function(){t.adapter.removeClass(i.cssClasses.OPEN),t.adapter.removeClass(i.cssClasses.IS_OPEN_BELOW),t.closeAnimationEndTimerId=setTimeout((function(){t.closeAnimationEndTimerId=0,t.adapter.removeClass(i.cssClasses.ANIMATING_CLOSED),t.adapter.notifyClose()}),Pa.TRANSITION_CLOSE_DURATION)})),this.isSurfaceOpen=!1,e||this.maybeRestoreFocus()}},i.prototype.handleBodyClick=function(e){var t=e.target;this.adapter.isElementInContainer(t)||this.close()},i.prototype.handleKeydown=function(e){var t=e.keyCode;("Escape"===e.key||27===t)&&this.close()},i.prototype.autoposition=function(){var e;this.measurements=this.getAutoLayoutmeasurements();var t=this.getoriginCorner(),n=this.getMenuSurfaceMaxHeight(t),r=this.hasBit(t,$a.BOTTOM)?"bottom":"top",a=this.hasBit(t,$a.RIGHT)?"right":"left",o=this.getHorizontalOriginOffset(t),s=this.getVerticalOriginOffset(t),l=this.measurements,d=l.anchorSize,c=l.surfaceSize,p=((e={})[a]=o,e[r]=s,e);d.width/c.width>Pa.ANCHOR_TO_MENU_SURFACE_WIDTH_RATIO&&(a="center"),(this.isHoistedElement||this.isFixedPosition)&&this.adjustPositionForHoistedElement(p),this.adapter.setTransformOrigin(a+" "+r),this.adapter.setPosition(p),this.adapter.setMaxHeight(n?n+"px":""),this.hasBit(t,$a.BOTTOM)||this.adapter.addClass(i.cssClasses.IS_OPEN_BELOW)},i.prototype.getAutoLayoutmeasurements=function(){var e=this.adapter.getAnchorDimensions(),t=this.adapter.getBodyDimensions(),i=this.adapter.getWindowDimensions(),n=this.adapter.getWindowScroll();return e||(e={top:this.position.y,right:this.position.x,bottom:this.position.y,left:this.position.x,width:0,height:0}),{anchorSize:e,bodySize:t,surfaceSize:this.dimensions,viewportDistance:{top:e.top,right:i.width-e.right,bottom:i.height-e.bottom,left:e.left},viewportSize:i,windowScroll:n}},i.prototype.getoriginCorner=function(){var e,t,n=this.originCorner,r=this.measurements,a=r.viewportDistance,o=r.anchorSize,s=r.surfaceSize,l=i.numbers.MARGIN_TO_EDGE;this.hasBit(this.anchorCorner,$a.BOTTOM)?(e=a.top-l+this.anchorMargin.bottom,t=a.bottom-l-this.anchorMargin.bottom):(e=a.top-l+this.anchorMargin.top,t=a.bottom-l+o.height-this.anchorMargin.top),!(t-s.height>0)&&e>t+this.openBottomBias&&(n=this.setBit(n,$a.BOTTOM));var d,c,p=this.adapter.isRtl(),m=this.hasBit(this.anchorCorner,$a.FLIP_RTL),h=this.hasBit(this.anchorCorner,$a.RIGHT)||this.hasBit(n,$a.RIGHT),u=!1;(u=p&&m?!h:h)?(d=a.left+o.width+this.anchorMargin.right,c=a.right-this.anchorMargin.right):(d=a.left+this.anchorMargin.left,c=a.right+o.width-this.anchorMargin.left);var f=d-s.width>0,g=c-s.width>0,v=this.hasBit(n,$a.FLIP_RTL)&&this.hasBit(n,$a.RIGHT);return g&&v&&p||!f&&v?n=this.unsetBit(n,$a.RIGHT):(f&&u&&p||f&&!u&&h||!g&&d>=c)&&(n=this.setBit(n,$a.RIGHT)),n},i.prototype.getMenuSurfaceMaxHeight=function(e){if(this.maxHeight>0)return this.maxHeight;var t=this.measurements.viewportDistance,n=0,r=this.hasBit(e,$a.BOTTOM),a=this.hasBit(this.anchorCorner,$a.BOTTOM),o=i.numbers.MARGIN_TO_EDGE;return r?(n=t.top+this.anchorMargin.top-o,a||(n+=this.measurements.anchorSize.height)):(n=t.bottom-this.anchorMargin.bottom+this.measurements.anchorSize.height-o,a&&(n-=this.measurements.anchorSize.height)),n},i.prototype.getHorizontalOriginOffset=function(e){var t=this.measurements.anchorSize,i=this.hasBit(e,$a.RIGHT),n=this.hasBit(this.anchorCorner,$a.RIGHT);if(i){var r=n?t.width-this.anchorMargin.left:this.anchorMargin.right;return this.isHoistedElement||this.isFixedPosition?r-(this.measurements.viewportSize.width-this.measurements.bodySize.width):r}return n?t.width-this.anchorMargin.right:this.anchorMargin.left},i.prototype.getVerticalOriginOffset=function(e){var t=this.measurements.anchorSize,i=this.hasBit(e,$a.BOTTOM),n=this.hasBit(this.anchorCorner,$a.BOTTOM);return i?n?t.height-this.anchorMargin.top:-this.anchorMargin.bottom:n?t.height+this.anchorMargin.bottom:this.anchorMargin.top},i.prototype.adjustPositionForHoistedElement=function(e){var t,i,n=this.measurements,r=n.windowScroll,a=n.viewportDistance,s=n.surfaceSize,l=n.viewportSize,d=Object.keys(e);try{for(var c=o(d),p=c.next();!p.done;p=c.next()){var m=p.value,h=e[m]||0;!this.isHorizontallyCenteredOnViewport||"left"!==m&&"right"!==m?(h+=a[m],this.isFixedPosition||("top"===m?h+=r.y:"bottom"===m?h-=r.y:"left"===m?h+=r.x:h-=r.x),e[m]=h):e[m]=(l.width-s.width)/2}}catch(e){t={error:e}}finally{try{p&&!p.done&&(i=c.return)&&i.call(c)}finally{if(t)throw t.error}}},i.prototype.maybeRestoreFocus=function(){var e=this,t=this.adapter.isFocused(),i=document.activeElement&&this.adapter.isElementInContainer(document.activeElement);(t||i)&&setTimeout((function(){e.adapter.restoreFocus()}),Pa.TOUCH_EVENT_WAIT_MS)},i.prototype.hasBit=function(e,t){return Boolean(e&t)},i.prototype.setBit=function(e,t){return e|t},i.prototype.unsetBit=function(e,t){return e^t},i.prototype.isFinite=function(e){return"number"==typeof e&&isFinite(e)},i}(hr),lo=so,co=function(e){function i(t){var n=e.call(this,r(r({},i.defaultAdapter),t))||this;return n.closeAnimationEndTimerId=0,n.defaultFocusState=no.LIST_ROOT,n.selectedIndex=-1,n}return t(i,e),Object.defineProperty(i,"cssClasses",{get:function(){return ro},enumerable:!1,configurable:!0}),Object.defineProperty(i,"strings",{get:function(){return ao},enumerable:!1,configurable:!0}),Object.defineProperty(i,"numbers",{get:function(){return oo},enumerable:!1,configurable:!0}),Object.defineProperty(i,"defaultAdapter",{get:function(){return{addClassToElementAtIndex:function(){},removeClassFromElementAtIndex:function(){},addAttributeToElementAtIndex:function(){},removeAttributeFromElementAtIndex:function(){},getAttributeFromElementAtIndex:function(){return null},elementContainsClass:function(){return!1},closeSurface:function(){},getElementIndex:function(){return-1},notifySelected:function(){},getMenuItemCount:function(){return 0},focusItemAtIndex:function(){},focusListRoot:function(){},getSelectedSiblingOfItemAtIndex:function(){return-1},isSelectableItemAtIndex:function(){return!1}}},enumerable:!1,configurable:!0}),i.prototype.destroy=function(){this.closeAnimationEndTimerId&&clearTimeout(this.closeAnimationEndTimerId),this.adapter.closeSurface()},i.prototype.handleKeydown=function(e){var t=e.key,i=e.keyCode;("Tab"===t||9===i)&&this.adapter.closeSurface(!0)},i.prototype.handleItemAction=function(e){var t=this,i=this.adapter.getElementIndex(e);if(!(i<0)){this.adapter.notifySelected({index:i});var n="true"===this.adapter.getAttributeFromElementAtIndex(i,ao.SKIP_RESTORE_FOCUS);this.adapter.closeSurface(n),this.closeAnimationEndTimerId=setTimeout((function(){var i=t.adapter.getElementIndex(e);i>=0&&t.adapter.isSelectableItemAtIndex(i)&&t.setSelectedIndex(i)}),so.numbers.TRANSITION_CLOSE_DURATION)}},i.prototype.handleMenuSurfaceOpened=function(){switch(this.defaultFocusState){case no.FIRST_ITEM:this.adapter.focusItemAtIndex(0);break;case no.LAST_ITEM:this.adapter.focusItemAtIndex(this.adapter.getMenuItemCount()-1);break;case no.NONE:break;default:this.adapter.focusListRoot()}},i.prototype.setDefaultFocusState=function(e){this.defaultFocusState=e},i.prototype.getSelectedIndex=function(){return this.selectedIndex},i.prototype.setSelectedIndex=function(e){if(this.validatedIndex(e),!this.adapter.isSelectableItemAtIndex(e))throw new Error("MDCMenuFoundation: No selection group at specified index.");var t=this.adapter.getSelectedSiblingOfItemAtIndex(e);t>=0&&(this.adapter.removeAttributeFromElementAtIndex(t,ao.ARIA_CHECKED_ATTR),this.adapter.removeClassFromElementAtIndex(t,ro.MENU_SELECTED_LIST_ITEM)),this.adapter.addClassToElementAtIndex(e,ro.MENU_SELECTED_LIST_ITEM),this.adapter.addAttributeToElementAtIndex(e,ao.ARIA_CHECKED_ATTR,"true"),this.selectedIndex=e},i.prototype.setEnabled=function(e,t){this.validatedIndex(e),t?(this.adapter.removeClassFromElementAtIndex(e,ga),this.adapter.addAttributeToElementAtIndex(e,ao.ARIA_DISABLED_ATTR,"false")):(this.adapter.addClassToElementAtIndex(e,ga),this.adapter.addAttributeToElementAtIndex(e,ao.ARIA_DISABLED_ATTR,"true"))},i.prototype.validatedIndex=function(e){var t=this.adapter.getMenuItemCount();if(!(e>=0&&e + + + + `}createAdapter(){return{addClassToElementAtIndex:(e,t)=>{const i=this.listElement;if(!i)return;const n=i.items[e];n&&("mdc-menu-item--selected"===t?this.forceGroupSelection&&!n.selected&&i.toggle(e,!0):n.classList.add(t))},removeClassFromElementAtIndex:(e,t)=>{const i=this.listElement;if(!i)return;const n=i.items[e];n&&("mdc-menu-item--selected"===t?n.selected&&i.toggle(e,!1):n.classList.remove(t))},addAttributeToElementAtIndex:(e,t,i)=>{const n=this.listElement;if(!n)return;const r=n.items[e];r&&r.setAttribute(t,i)},removeAttributeFromElementAtIndex:(e,t)=>{const i=this.listElement;if(!i)return;const n=i.items[e];n&&n.removeAttribute(t)},getAttributeFromElementAtIndex:(e,t)=>{const i=this.listElement;if(!i)return null;const n=i.items[e];return n?n.getAttribute(t):null},elementContainsClass:(e,t)=>e.classList.contains(t),closeSurface:()=>{this.open=!1},getElementIndex:e=>{const t=this.listElement;return t?t.items.indexOf(e):-1},notifySelected:()=>{},getMenuItemCount:()=>{const e=this.listElement;return e?e.items.length:0},focusItemAtIndex:e=>{const t=this.listElement;if(!t)return;const i=t.items[e];i&&i.focus()},focusListRoot:()=>{this.listElement&&this.listElement.focus()},getSelectedSiblingOfItemAtIndex:e=>{const t=this.listElement;if(!t)return-1;const i=t.items[e];if(!i||!i.group)return-1;for(let n=0;n{const t=this.listElement;if(!t)return!1;const i=t.items[e];return!!i&&i.hasAttribute("group")}}}onKeydown(e){this.mdcFoundation&&this.mdcFoundation.handleKeydown(e)}onAction(e){const t=this.listElement;if(this.mdcFoundation&&t){const i=e.detail.index,n=t.items[i];n&&this.mdcFoundation.handleItemAction(n)}}onOpened(){this.open=!0,this.mdcFoundation&&this.mdcFoundation.handleMenuSurfaceOpened()}onClosed(){this.open=!1}async getUpdateComplete(){await this._listUpdateComplete;return await super.getUpdateComplete()}async firstUpdated(){super.firstUpdated();const e=this.listElement;e&&(this._listUpdateComplete=e.updateComplete,await this._listUpdateComplete)}select(e){const t=this.listElement;t&&t.select(e)}close(){this.open=!1}show(){this.open=!0}getFocusedItemIndex(){const e=this.listElement;return e?e.getFocusedItemIndex():-1}focusItemAtIndex(e){const t=this.listElement;t&&t.focusItemAtIndex(e)}layout(e=!0){const t=this.listElement;t&&t.layout(e)}}a([ve(".mdc-menu")],mo.prototype,"mdcRoot",void 0),a([ve("slot")],mo.prototype,"slotElement",void 0),a([he({type:Object})],mo.prototype,"anchor",void 0),a([he({type:Boolean,reflect:!0})],mo.prototype,"open",void 0),a([he({type:Boolean})],mo.prototype,"quick",void 0),a([he({type:Boolean})],mo.prototype,"wrapFocus",void 0),a([he({type:String})],mo.prototype,"innerRole",void 0),a([he({type:String})],mo.prototype,"innerAriaLabel",void 0),a([he({type:String})],mo.prototype,"corner",void 0),a([he({type:Number})],mo.prototype,"x",void 0),a([he({type:Number})],mo.prototype,"y",void 0),a([he({type:Boolean})],mo.prototype,"absolute",void 0),a([he({type:Boolean})],mo.prototype,"multi",void 0),a([he({type:Boolean})],mo.prototype,"activatable",void 0),a([he({type:Boolean})],mo.prototype,"fixed",void 0),a([he({type:Boolean})],mo.prototype,"forceGroupSelection",void 0),a([he({type:Boolean})],mo.prototype,"fullwidth",void 0),a([he({type:String})],mo.prototype,"menuCorner",void 0),a([he({type:Boolean})],mo.prototype,"stayOpenOnBodyClick",void 0),a([he({type:String}),Ir((function(e){this.mdcFoundation&&this.mdcFoundation.setDefaultFocusState(no[e])}))],mo.prototype,"defaultFocus",void 0); +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const ho=Xn(class extends Kn{constructor(e){var t;if(super(e),e.type!==Wn||"style"!==e.name||(null===(t=e.strings)||void 0===t?void 0:t.length)>2)throw Error("The `styleMap` directive must be used in the `style` attribute and must be the only part in the attribute.")}render(e){return Object.keys(e).reduce(((t,i)=>{const n=e[i];return null==n?t:t+`${i=i.replace(/(?:^(webkit|moz|ms|o)|)(?=[A-Z])/g,"-$&").toLowerCase()}:${n};`}),"")}update(e,[t]){const{style:i}=e.element;if(void 0===this.ct){this.ct=new Set;for(const e in t)this.ct.add(e);return this.render(t)}this.ct.forEach((e=>{null==t[e]&&(this.ct.delete(e),e.includes("-")?i.removeProperty(e):i[e]="")}));for(const e in t){const n=t[e];null!=n&&(this.ct.add(e),e.includes("-")?i.setProperty(e,n):i[e]=n)}return G}}),uo={TOP_LEFT:Fa.TOP_LEFT,TOP_RIGHT:Fa.TOP_RIGHT,BOTTOM_LEFT:Fa.BOTTOM_LEFT,BOTTOM_RIGHT:Fa.BOTTOM_RIGHT,TOP_START:Fa.TOP_START,TOP_END:Fa.TOP_END,BOTTOM_START:Fa.BOTTOM_START,BOTTOM_END:Fa.BOTTOM_END}; +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */class fo extends Er{constructor(){super(...arguments),this.mdcFoundationClass=lo,this.absolute=!1,this.fullwidth=!1,this.fixed=!1,this.x=null,this.y=null,this.quick=!1,this.open=!1,this.stayOpenOnBodyClick=!1,this.bitwiseCorner=Fa.TOP_START,this.previousMenuCorner=null,this.menuCorner="START",this.corner="TOP_START",this.styleTop="",this.styleLeft="",this.styleRight="",this.styleBottom="",this.styleMaxHeight="",this.styleTransformOrigin="",this.anchor=null,this.previouslyFocused=null,this.previousAnchor=null,this.onBodyClickBound=()=>{}}render(){const e={"mdc-menu-surface--fixed":this.fixed,"mdc-menu-surface--fullwidth":this.fullwidth},t={top:this.styleTop,left:this.styleLeft,right:this.styleRight,bottom:this.styleBottom,"max-height":this.styleMaxHeight,"transform-origin":this.styleTransformOrigin};return U` +
+ +
`}createAdapter(){return Object.assign(Object.assign({},br(this.mdcRoot)),{hasAnchor:()=>!!this.anchor,notifyClose:()=>{const e=new CustomEvent("closed",{bubbles:!0,composed:!0});this.open=!1,this.mdcRoot.dispatchEvent(e)},notifyClosing:()=>{const e=new CustomEvent("closing",{bubbles:!0,composed:!0});this.mdcRoot.dispatchEvent(e)},notifyOpen:()=>{const e=new CustomEvent("opened",{bubbles:!0,composed:!0});this.open=!0,this.mdcRoot.dispatchEvent(e)},isElementInContainer:()=>!1,isRtl:()=>!!this.mdcRoot&&"rtl"===getComputedStyle(this.mdcRoot).direction,setTransformOrigin:e=>{this.mdcRoot&&(this.styleTransformOrigin=e)},isFocused:()=>wr(this),saveFocus:()=>{const e=_r(),t=e.length;t||(this.previouslyFocused=null),this.previouslyFocused=e[t-1]},restoreFocus:()=>{this.previouslyFocused&&"focus"in this.previouslyFocused&&this.previouslyFocused.focus()},getInnerDimensions:()=>{const e=this.mdcRoot;return e?{width:e.offsetWidth,height:e.offsetHeight}:{width:0,height:0}},getAnchorDimensions:()=>{const e=this.anchor;return e?e.getBoundingClientRect():null},getBodyDimensions:()=>({width:document.body.clientWidth,height:document.body.clientHeight}),getWindowDimensions:()=>({width:window.innerWidth,height:window.innerHeight}),getWindowScroll:()=>({x:window.pageXOffset,y:window.pageYOffset}),setPosition:e=>{this.mdcRoot&&(this.styleLeft="left"in e?`${e.left}px`:"",this.styleRight="right"in e?`${e.right}px`:"",this.styleTop="top"in e?`${e.top}px`:"",this.styleBottom="bottom"in e?`${e.bottom}px`:"")},setMaxHeight:async e=>{this.mdcRoot&&(this.styleMaxHeight=e,await this.updateComplete,this.styleMaxHeight=`var(--mdc-menu-max-height, ${e})`)}})}onKeydown(e){this.mdcFoundation&&this.mdcFoundation.handleKeydown(e)}onBodyClick(e){if(this.stayOpenOnBodyClick)return;-1===e.composedPath().indexOf(this)&&this.close()}registerBodyClick(){this.onBodyClickBound=this.onBodyClick.bind(this),document.body.addEventListener("click",this.onBodyClickBound,{passive:!0,capture:!0})}deregisterBodyClick(){document.body.removeEventListener("click",this.onBodyClickBound,{capture:!0})}close(){this.open=!1}show(){this.open=!0}}a([ve(".mdc-menu-surface")],fo.prototype,"mdcRoot",void 0),a([ve("slot")],fo.prototype,"slotElement",void 0),a([he({type:Boolean}),Ir((function(e){this.mdcFoundation&&!this.fixed&&this.mdcFoundation.setIsHoisted(e)}))],fo.prototype,"absolute",void 0),a([he({type:Boolean})],fo.prototype,"fullwidth",void 0),a([he({type:Boolean}),Ir((function(e){this.mdcFoundation&&!this.absolute&&this.mdcFoundation.setFixedPosition(e)}))],fo.prototype,"fixed",void 0),a([he({type:Number}),Ir((function(e){this.mdcFoundation&&null!==this.y&&null!==e&&(this.mdcFoundation.setAbsolutePosition(e,this.y),this.mdcFoundation.setAnchorMargin({left:e,top:this.y,right:-e,bottom:this.y}))}))],fo.prototype,"x",void 0),a([he({type:Number}),Ir((function(e){this.mdcFoundation&&null!==this.x&&null!==e&&(this.mdcFoundation.setAbsolutePosition(this.x,e),this.mdcFoundation.setAnchorMargin({left:this.x,top:e,right:-this.x,bottom:e}))}))],fo.prototype,"y",void 0),a([he({type:Boolean}),Ir((function(e){this.mdcFoundation&&this.mdcFoundation.setQuickOpen(e)}))],fo.prototype,"quick",void 0),a([he({type:Boolean,reflect:!0}),Ir((function(e,t){this.mdcFoundation&&(e?this.mdcFoundation.open():void 0!==t&&this.mdcFoundation.close())}))],fo.prototype,"open",void 0),a([he({type:Boolean})],fo.prototype,"stayOpenOnBodyClick",void 0),a([ue(),Ir((function(e){this.mdcFoundation&&this.mdcFoundation.setAnchorCorner(e)}))],fo.prototype,"bitwiseCorner",void 0),a([he({type:String}),Ir((function(e){if(this.mdcFoundation){const t="START"===e||"END"===e,i=null===this.previousMenuCorner,n=!i&&e!==this.previousMenuCorner,r=i&&"END"===e;t&&(n||r)&&(this.bitwiseCorner=this.bitwiseCorner^$a.RIGHT,this.mdcFoundation.flipCornerHorizontally(),this.previousMenuCorner=e)}}))],fo.prototype,"menuCorner",void 0),a([he({type:String}),Ir((function(e){if(this.mdcFoundation&&e){let t=uo[e];"END"===this.menuCorner&&(t^=$a.RIGHT),this.bitwiseCorner=t}}))],fo.prototype,"corner",void 0),a([ue()],fo.prototype,"styleTop",void 0),a([ue()],fo.prototype,"styleLeft",void 0),a([ue()],fo.prototype,"styleRight",void 0),a([ue()],fo.prototype,"styleBottom",void 0),a([ue()],fo.prototype,"styleMaxHeight",void 0),a([ue()],fo.prototype,"styleTransformOrigin",void 0); +/** + * @license + * Copyright 2016 Google Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +var go={BG_FOCUSED:"mdc-ripple-upgraded--background-focused",FG_ACTIVATION:"mdc-ripple-upgraded--foreground-activation",FG_DEACTIVATION:"mdc-ripple-upgraded--foreground-deactivation",ROOT:"mdc-ripple-upgraded",UNBOUNDED:"mdc-ripple-upgraded--unbounded"},vo={VAR_FG_SCALE:"--mdc-ripple-fg-scale",VAR_FG_SIZE:"--mdc-ripple-fg-size",VAR_FG_TRANSLATE_END:"--mdc-ripple-fg-translate-end",VAR_FG_TRANSLATE_START:"--mdc-ripple-fg-translate-start",VAR_LEFT:"--mdc-ripple-left",VAR_TOP:"--mdc-ripple-top"},bo={DEACTIVATION_TIMEOUT_MS:225,FG_DEACTIVATION_MS:150,INITIAL_ORIGIN_SCALE:.6,PADDING:10,TAP_DELAY_MS:300}; +/** + * @license + * Copyright 2016 Google Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +var yo=["touchstart","pointerdown","mousedown","keydown"],xo=["touchend","pointerup","mouseup","contextmenu"],_o=[],wo=function(e){function i(t){var n=e.call(this,r(r({},i.defaultAdapter),t))||this;return n.activationAnimationHasEnded=!1,n.activationTimer=0,n.fgDeactivationRemovalTimer=0,n.fgScale="0",n.frame={width:0,height:0},n.initialSize=0,n.layoutFrame=0,n.maxRadius=0,n.unboundedCoords={left:0,top:0},n.activationState=n.defaultActivationState(),n.activationTimerCallback=function(){n.activationAnimationHasEnded=!0,n.runDeactivationUXLogicIfReady()},n.activateHandler=function(e){n.activateImpl(e)},n.deactivateHandler=function(){n.deactivateImpl()},n.focusHandler=function(){n.handleFocus()},n.blurHandler=function(){n.handleBlur()},n.resizeHandler=function(){n.layout()},n}return t(i,e),Object.defineProperty(i,"cssClasses",{get:function(){return go},enumerable:!1,configurable:!0}),Object.defineProperty(i,"strings",{get:function(){return vo},enumerable:!1,configurable:!0}),Object.defineProperty(i,"numbers",{get:function(){return bo},enumerable:!1,configurable:!0}),Object.defineProperty(i,"defaultAdapter",{get:function(){return{addClass:function(){},browserSupportsCssVars:function(){return!0},computeBoundingRect:function(){return{top:0,right:0,bottom:0,left:0,width:0,height:0}},containsEventTarget:function(){return!0},deregisterDocumentInteractionHandler:function(){},deregisterInteractionHandler:function(){},deregisterResizeHandler:function(){},getWindowPageOffset:function(){return{x:0,y:0}},isSurfaceActive:function(){return!0},isSurfaceDisabled:function(){return!0},isUnbounded:function(){return!0},registerDocumentInteractionHandler:function(){},registerInteractionHandler:function(){},registerResizeHandler:function(){},removeClass:function(){},updateCssVariable:function(){}}},enumerable:!1,configurable:!0}),i.prototype.init=function(){var e=this,t=this.supportsPressRipple();if(this.registerRootHandlers(t),t){var n=i.cssClasses,r=n.ROOT,a=n.UNBOUNDED;requestAnimationFrame((function(){e.adapter.addClass(r),e.adapter.isUnbounded()&&(e.adapter.addClass(a),e.layoutInternal())}))}},i.prototype.destroy=function(){var e=this;if(this.supportsPressRipple()){this.activationTimer&&(clearTimeout(this.activationTimer),this.activationTimer=0,this.adapter.removeClass(i.cssClasses.FG_ACTIVATION)),this.fgDeactivationRemovalTimer&&(clearTimeout(this.fgDeactivationRemovalTimer),this.fgDeactivationRemovalTimer=0,this.adapter.removeClass(i.cssClasses.FG_DEACTIVATION));var t=i.cssClasses,n=t.ROOT,r=t.UNBOUNDED;requestAnimationFrame((function(){e.adapter.removeClass(n),e.adapter.removeClass(r),e.removeCssVars()}))}this.deregisterRootHandlers(),this.deregisterDeactivationHandlers()},i.prototype.activate=function(e){this.activateImpl(e)},i.prototype.deactivate=function(){this.deactivateImpl()},i.prototype.layout=function(){var e=this;this.layoutFrame&&cancelAnimationFrame(this.layoutFrame),this.layoutFrame=requestAnimationFrame((function(){e.layoutInternal(),e.layoutFrame=0}))},i.prototype.setUnbounded=function(e){var t=i.cssClasses.UNBOUNDED;e?this.adapter.addClass(t):this.adapter.removeClass(t)},i.prototype.handleFocus=function(){var e=this;requestAnimationFrame((function(){return e.adapter.addClass(i.cssClasses.BG_FOCUSED)}))},i.prototype.handleBlur=function(){var e=this;requestAnimationFrame((function(){return e.adapter.removeClass(i.cssClasses.BG_FOCUSED)}))},i.prototype.supportsPressRipple=function(){return this.adapter.browserSupportsCssVars()},i.prototype.defaultActivationState=function(){return{activationEvent:void 0,hasDeactivationUXRun:!1,isActivated:!1,isProgrammatic:!1,wasActivatedByPointer:!1,wasElementMadeActive:!1}},i.prototype.registerRootHandlers=function(e){var t,i;if(e){try{for(var n=o(yo),r=n.next();!r.done;r=n.next()){var a=r.value;this.adapter.registerInteractionHandler(a,this.activateHandler)}}catch(e){t={error:e}}finally{try{r&&!r.done&&(i=n.return)&&i.call(n)}finally{if(t)throw t.error}}this.adapter.isUnbounded()&&this.adapter.registerResizeHandler(this.resizeHandler)}this.adapter.registerInteractionHandler("focus",this.focusHandler),this.adapter.registerInteractionHandler("blur",this.blurHandler)},i.prototype.registerDeactivationHandlers=function(e){var t,i;if("keydown"===e.type)this.adapter.registerInteractionHandler("keyup",this.deactivateHandler);else try{for(var n=o(xo),r=n.next();!r.done;r=n.next()){var a=r.value;this.adapter.registerDocumentInteractionHandler(a,this.deactivateHandler)}}catch(e){t={error:e}}finally{try{r&&!r.done&&(i=n.return)&&i.call(n)}finally{if(t)throw t.error}}},i.prototype.deregisterRootHandlers=function(){var e,t;try{for(var i=o(yo),n=i.next();!n.done;n=i.next()){var r=n.value;this.adapter.deregisterInteractionHandler(r,this.activateHandler)}}catch(t){e={error:t}}finally{try{n&&!n.done&&(t=i.return)&&t.call(i)}finally{if(e)throw e.error}}this.adapter.deregisterInteractionHandler("focus",this.focusHandler),this.adapter.deregisterInteractionHandler("blur",this.blurHandler),this.adapter.isUnbounded()&&this.adapter.deregisterResizeHandler(this.resizeHandler)},i.prototype.deregisterDeactivationHandlers=function(){var e,t;this.adapter.deregisterInteractionHandler("keyup",this.deactivateHandler);try{for(var i=o(xo),n=i.next();!n.done;n=i.next()){var r=n.value;this.adapter.deregisterDocumentInteractionHandler(r,this.deactivateHandler)}}catch(t){e={error:t}}finally{try{n&&!n.done&&(t=i.return)&&t.call(i)}finally{if(e)throw e.error}}},i.prototype.removeCssVars=function(){var e=this,t=i.strings;Object.keys(t).forEach((function(i){0===i.indexOf("VAR_")&&e.adapter.updateCssVariable(t[i],null)}))},i.prototype.activateImpl=function(e){var t=this;if(!this.adapter.isSurfaceDisabled()){var i=this.activationState;if(!i.isActivated){var n=this.previousActivationEvent;if(!(n&&void 0!==e&&n.type!==e.type))i.isActivated=!0,i.isProgrammatic=void 0===e,i.activationEvent=e,i.wasActivatedByPointer=!i.isProgrammatic&&(void 0!==e&&("mousedown"===e.type||"touchstart"===e.type||"pointerdown"===e.type)),void 0!==e&&_o.length>0&&_o.some((function(e){return t.adapter.containsEventTarget(e)}))?this.resetActivationState():(void 0!==e&&(_o.push(e.target),this.registerDeactivationHandlers(e)),i.wasElementMadeActive=this.checkElementMadeActive(e),i.wasElementMadeActive&&this.animateActivation(),requestAnimationFrame((function(){_o=[],i.wasElementMadeActive||void 0===e||" "!==e.key&&32!==e.keyCode||(i.wasElementMadeActive=t.checkElementMadeActive(e),i.wasElementMadeActive&&t.animateActivation()),i.wasElementMadeActive||(t.activationState=t.defaultActivationState())})))}}},i.prototype.checkElementMadeActive=function(e){return void 0===e||"keydown"!==e.type||this.adapter.isSurfaceActive()},i.prototype.animateActivation=function(){var e=this,t=i.strings,n=t.VAR_FG_TRANSLATE_START,r=t.VAR_FG_TRANSLATE_END,a=i.cssClasses,o=a.FG_DEACTIVATION,s=a.FG_ACTIVATION,l=i.numbers.DEACTIVATION_TIMEOUT_MS;this.layoutInternal();var d="",c="";if(!this.adapter.isUnbounded()){var p=this.getFgTranslationCoordinates(),m=p.startPoint,h=p.endPoint;d=m.x+"px, "+m.y+"px",c=h.x+"px, "+h.y+"px"}this.adapter.updateCssVariable(n,d),this.adapter.updateCssVariable(r,c),clearTimeout(this.activationTimer),clearTimeout(this.fgDeactivationRemovalTimer),this.rmBoundedActivationClasses(),this.adapter.removeClass(o),this.adapter.computeBoundingRect(),this.adapter.addClass(s),this.activationTimer=setTimeout((function(){e.activationTimerCallback()}),l)},i.prototype.getFgTranslationCoordinates=function(){var e,t=this.activationState,i=t.activationEvent;return e=t.wasActivatedByPointer?function(e,t,i){if(!e)return{x:0,y:0};var n,r,a=t.x,o=t.y,s=a+i.left,l=o+i.top;if("touchstart"===e.type){var d=e;n=d.changedTouches[0].pageX-s,r=d.changedTouches[0].pageY-l}else{var c=e;n=c.pageX-s,r=c.pageY-l}return{x:n,y:r}}(i,this.adapter.getWindowPageOffset(),this.adapter.computeBoundingRect()):{x:this.frame.width/2,y:this.frame.height/2},{startPoint:e={x:e.x-this.initialSize/2,y:e.y-this.initialSize/2},endPoint:{x:this.frame.width/2-this.initialSize/2,y:this.frame.height/2-this.initialSize/2}}},i.prototype.runDeactivationUXLogicIfReady=function(){var e=this,t=i.cssClasses.FG_DEACTIVATION,n=this.activationState,r=n.hasDeactivationUXRun,a=n.isActivated;(r||!a)&&this.activationAnimationHasEnded&&(this.rmBoundedActivationClasses(),this.adapter.addClass(t),this.fgDeactivationRemovalTimer=setTimeout((function(){e.adapter.removeClass(t)}),bo.FG_DEACTIVATION_MS))},i.prototype.rmBoundedActivationClasses=function(){var e=i.cssClasses.FG_ACTIVATION;this.adapter.removeClass(e),this.activationAnimationHasEnded=!1,this.adapter.computeBoundingRect()},i.prototype.resetActivationState=function(){var e=this;this.previousActivationEvent=this.activationState.activationEvent,this.activationState=this.defaultActivationState(),setTimeout((function(){return e.previousActivationEvent=void 0}),i.numbers.TAP_DELAY_MS)},i.prototype.deactivateImpl=function(){var e=this,t=this.activationState;if(t.isActivated){var i=r({},t);t.isProgrammatic?(requestAnimationFrame((function(){e.animateDeactivation(i)})),this.resetActivationState()):(this.deregisterDeactivationHandlers(),requestAnimationFrame((function(){e.activationState.hasDeactivationUXRun=!0,e.animateDeactivation(i),e.resetActivationState()})))}},i.prototype.animateDeactivation=function(e){var t=e.wasActivatedByPointer,i=e.wasElementMadeActive;(t||i)&&this.runDeactivationUXLogicIfReady()},i.prototype.layoutInternal=function(){var e=this;this.frame=this.adapter.computeBoundingRect();var t=Math.max(this.frame.height,this.frame.width);this.maxRadius=this.adapter.isUnbounded()?t:Math.sqrt(Math.pow(e.frame.width,2)+Math.pow(e.frame.height,2))+i.numbers.PADDING;var n=Math.floor(t*i.numbers.INITIAL_ORIGIN_SCALE);this.adapter.isUnbounded()&&n%2!=0?this.initialSize=n-1:this.initialSize=n,this.fgScale=""+this.maxRadius/this.initialSize,this.updateLayoutCssVars()},i.prototype.updateLayoutCssVars=function(){var e=i.strings,t=e.VAR_FG_SIZE,n=e.VAR_LEFT,r=e.VAR_TOP,a=e.VAR_FG_SCALE;this.adapter.updateCssVariable(t,this.initialSize+"px"),this.adapter.updateCssVariable(a,this.fgScale),this.adapter.isUnbounded()&&(this.unboundedCoords={left:Math.round(this.frame.width/2-this.initialSize/2),top:Math.round(this.frame.height/2-this.initialSize/2)},this.adapter.updateCssVariable(n,this.unboundedCoords.left+"px"),this.adapter.updateCssVariable(r,this.unboundedCoords.top+"px"))},i}(hr),Eo=wo; +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +class Ao extends Er{constructor(){super(...arguments),this.primary=!1,this.accent=!1,this.unbounded=!1,this.disabled=!1,this.activated=!1,this.selected=!1,this.internalUseStateLayerCustomProperties=!1,this.hovering=!1,this.bgFocused=!1,this.fgActivation=!1,this.fgDeactivation=!1,this.fgScale="",this.fgSize="",this.translateStart="",this.translateEnd="",this.leftPos="",this.topPos="",this.mdcFoundationClass=Eo}get isActive(){return e=this.parentElement||this,t=":active",(e.matches||e.webkitMatchesSelector||e.msMatchesSelector).call(e,t); +/** + * @license + * Copyright 2018 Google Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +var e,t}createAdapter(){return{browserSupportsCssVars:()=>!0,isUnbounded:()=>this.unbounded,isSurfaceActive:()=>this.isActive,isSurfaceDisabled:()=>this.disabled,addClass:e=>{switch(e){case"mdc-ripple-upgraded--background-focused":this.bgFocused=!0;break;case"mdc-ripple-upgraded--foreground-activation":this.fgActivation=!0;break;case"mdc-ripple-upgraded--foreground-deactivation":this.fgDeactivation=!0}},removeClass:e=>{switch(e){case"mdc-ripple-upgraded--background-focused":this.bgFocused=!1;break;case"mdc-ripple-upgraded--foreground-activation":this.fgActivation=!1;break;case"mdc-ripple-upgraded--foreground-deactivation":this.fgDeactivation=!1}},containsEventTarget:()=>!0,registerInteractionHandler:()=>{},deregisterInteractionHandler:()=>{},registerDocumentInteractionHandler:()=>{},deregisterDocumentInteractionHandler:()=>{},registerResizeHandler:()=>{},deregisterResizeHandler:()=>{},updateCssVariable:(e,t)=>{switch(e){case"--mdc-ripple-fg-scale":this.fgScale=t;break;case"--mdc-ripple-fg-size":this.fgSize=t;break;case"--mdc-ripple-fg-translate-end":this.translateEnd=t;break;case"--mdc-ripple-fg-translate-start":this.translateStart=t;break;case"--mdc-ripple-left":this.leftPos=t;break;case"--mdc-ripple-top":this.topPos=t}},computeBoundingRect:()=>(this.parentElement||this).getBoundingClientRect(),getWindowPageOffset:()=>({x:window.pageXOffset,y:window.pageYOffset})}}startPress(e){this.waitForFoundation((()=>{this.mdcFoundation.activate(e)}))}endPress(){this.waitForFoundation((()=>{this.mdcFoundation.deactivate()}))}startFocus(){this.waitForFoundation((()=>{this.mdcFoundation.handleFocus()}))}endFocus(){this.waitForFoundation((()=>{this.mdcFoundation.handleBlur()}))}startHover(){this.hovering=!0}endHover(){this.hovering=!1}waitForFoundation(e){this.mdcFoundation?e():this.updateComplete.then(e)}update(e){e.has("disabled")&&this.disabled&&this.endHover(),super.update(e)}render(){const e=this.activated&&(this.primary||!this.accent),t=this.selected&&(this.primary||!this.accent),i={"mdc-ripple-surface--accent":this.accent,"mdc-ripple-surface--primary--activated":e,"mdc-ripple-surface--accent--activated":this.accent&&this.activated,"mdc-ripple-surface--primary--selected":t,"mdc-ripple-surface--accent--selected":this.accent&&this.selected,"mdc-ripple-surface--disabled":this.disabled,"mdc-ripple-surface--hover":this.hovering,"mdc-ripple-surface--primary":this.primary,"mdc-ripple-surface--selected":this.selected,"mdc-ripple-upgraded--background-focused":this.bgFocused,"mdc-ripple-upgraded--foreground-activation":this.fgActivation,"mdc-ripple-upgraded--foreground-deactivation":this.fgDeactivation,"mdc-ripple-upgraded--unbounded":this.unbounded,"mdc-ripple-surface--internal-use-state-layer-custom-properties":this.internalUseStateLayerCustomProperties};return U` +
`}}a([ve(".mdc-ripple-surface")],Ao.prototype,"mdcRoot",void 0),a([he({type:Boolean})],Ao.prototype,"primary",void 0),a([he({type:Boolean})],Ao.prototype,"accent",void 0),a([he({type:Boolean})],Ao.prototype,"unbounded",void 0),a([he({type:Boolean})],Ao.prototype,"disabled",void 0),a([he({type:Boolean})],Ao.prototype,"activated",void 0),a([he({type:Boolean})],Ao.prototype,"selected",void 0),a([he({type:Boolean})],Ao.prototype,"internalUseStateLayerCustomProperties",void 0),a([ue()],Ao.prototype,"hovering",void 0),a([ue()],Ao.prototype,"bgFocused",void 0),a([ue()],Ao.prototype,"fgActivation",void 0),a([ue()],Ao.prototype,"fgDeactivation",void 0),a([ue()],Ao.prototype,"fgScale",void 0),a([ue()],Ao.prototype,"fgSize",void 0),a([ue()],Ao.prototype,"translateStart",void 0),a([ue()],Ao.prototype,"translateEnd",void 0),a([ue()],Ao.prototype,"leftPos",void 0),a([ue()],Ao.prototype,"topPos",void 0); +/** + * @license + * Copyright 2018 Google Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +var Co={NOTCH_ELEMENT_SELECTOR:".mdc-notched-outline__notch"},To={NOTCH_ELEMENT_PADDING:8},So={NO_LABEL:"mdc-notched-outline--no-label",OUTLINE_NOTCHED:"mdc-notched-outline--notched",OUTLINE_UPGRADED:"mdc-notched-outline--upgraded"},Io=function(e){function i(t){return e.call(this,r(r({},i.defaultAdapter),t))||this}return t(i,e),Object.defineProperty(i,"strings",{get:function(){return Co},enumerable:!1,configurable:!0}),Object.defineProperty(i,"cssClasses",{get:function(){return So},enumerable:!1,configurable:!0}),Object.defineProperty(i,"numbers",{get:function(){return To},enumerable:!1,configurable:!0}),Object.defineProperty(i,"defaultAdapter",{get:function(){return{addClass:function(){},removeClass:function(){},setNotchWidthProperty:function(){},removeNotchWidthProperty:function(){}}},enumerable:!1,configurable:!0}),i.prototype.notch=function(e){var t=i.cssClasses.OUTLINE_NOTCHED;e>0&&(e+=To.NOTCH_ELEMENT_PADDING),this.adapter.setNotchWidthProperty(e),this.adapter.addClass(t)},i.prototype.closeNotch=function(){var e=i.cssClasses.OUTLINE_NOTCHED;this.adapter.removeClass(e),this.adapter.removeNotchWidthProperty()},i}(hr); +/** + * @license + * Copyright 2019 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +class ko extends Er{constructor(){super(...arguments),this.mdcFoundationClass=Io,this.width=0,this.open=!1,this.lastOpen=this.open}createAdapter(){return{addClass:e=>this.mdcRoot.classList.add(e),removeClass:e=>this.mdcRoot.classList.remove(e),setNotchWidthProperty:e=>this.notchElement.style.setProperty("width",`${e}px`),removeNotchWidthProperty:()=>this.notchElement.style.removeProperty("width")}}openOrClose(e,t){this.mdcFoundation&&(e&&void 0!==t?this.mdcFoundation.notch(t):this.mdcFoundation.closeNotch())}render(){this.openOrClose(this.open,this.width);const e=kr({"mdc-notched-outline--notched":this.open});return U` + + + + + + + `}}a([ve(".mdc-notched-outline")],ko.prototype,"mdcRoot",void 0),a([he({type:Number})],ko.prototype,"width",void 0),a([he({type:Boolean,reflect:!0})],ko.prototype,"open",void 0),a([ve(".mdc-notched-outline__notch")],ko.prototype,"notchElement",void 0); +/** + * @license + * Copyright 2021 Google LLC + * SPDX-LIcense-Identifier: Apache-2.0 + */ +const Oo=g`.mdc-floating-label{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;font-family:Roboto, sans-serif;font-family:var(--mdc-typography-subtitle1-font-family, var(--mdc-typography-font-family, Roboto, sans-serif));font-size:1rem;font-size:var(--mdc-typography-subtitle1-font-size, 1rem);font-weight:400;font-weight:var(--mdc-typography-subtitle1-font-weight, 400);letter-spacing:0.009375em;letter-spacing:var(--mdc-typography-subtitle1-letter-spacing, 0.009375em);text-decoration:inherit;text-decoration:var(--mdc-typography-subtitle1-text-decoration, inherit);text-transform:inherit;text-transform:var(--mdc-typography-subtitle1-text-transform, inherit);position:absolute;left:0;-webkit-transform-origin:left top;transform-origin:left top;line-height:1.15rem;text-align:left;text-overflow:ellipsis;white-space:nowrap;cursor:text;overflow:hidden;will-change:transform;transition:transform 150ms cubic-bezier(0.4, 0, 0.2, 1),color 150ms cubic-bezier(0.4, 0, 0.2, 1)}[dir=rtl] .mdc-floating-label,.mdc-floating-label[dir=rtl]{right:0;left:auto;-webkit-transform-origin:right top;transform-origin:right top;text-align:right}.mdc-floating-label--float-above{cursor:auto}.mdc-floating-label--required::after{margin-left:1px;margin-right:0px;content:"*"}[dir=rtl] .mdc-floating-label--required::after,.mdc-floating-label--required[dir=rtl]::after{margin-left:0;margin-right:1px}.mdc-floating-label--float-above{transform:translateY(-106%) scale(0.75)}.mdc-floating-label--shake{animation:mdc-floating-label-shake-float-above-standard 250ms 1}@keyframes mdc-floating-label-shake-float-above-standard{0%{transform:translateX(calc(0 - 0%)) translateY(-106%) scale(0.75)}33%{animation-timing-function:cubic-bezier(0.5, 0, 0.701732, 0.495819);transform:translateX(calc(4% - 0%)) translateY(-106%) scale(0.75)}66%{animation-timing-function:cubic-bezier(0.302435, 0.381352, 0.55, 0.956352);transform:translateX(calc(-4% - 0%)) translateY(-106%) scale(0.75)}100%{transform:translateX(calc(0 - 0%)) translateY(-106%) scale(0.75)}}@keyframes mdc-ripple-fg-radius-in{from{animation-timing-function:cubic-bezier(0.4, 0, 0.2, 1);transform:translate(var(--mdc-ripple-fg-translate-start, 0)) scale(1)}to{transform:translate(var(--mdc-ripple-fg-translate-end, 0)) scale(var(--mdc-ripple-fg-scale, 1))}}@keyframes mdc-ripple-fg-opacity-in{from{animation-timing-function:linear;opacity:0}to{opacity:var(--mdc-ripple-fg-opacity, 0)}}@keyframes mdc-ripple-fg-opacity-out{from{animation-timing-function:linear;opacity:var(--mdc-ripple-fg-opacity, 0)}to{opacity:0}}.mdc-line-ripple::before,.mdc-line-ripple::after{position:absolute;bottom:0;left:0;width:100%;border-bottom-style:solid;content:""}.mdc-line-ripple::before{border-bottom-width:1px;z-index:1}.mdc-line-ripple::after{transform:scaleX(0);border-bottom-width:2px;opacity:0;z-index:2}.mdc-line-ripple::after{transition:transform 180ms cubic-bezier(0.4, 0, 0.2, 1),opacity 180ms cubic-bezier(0.4, 0, 0.2, 1)}.mdc-line-ripple--active::after{transform:scaleX(1);opacity:1}.mdc-line-ripple--deactivating::after{opacity:0}.mdc-notched-outline{display:flex;position:absolute;top:0;right:0;left:0;box-sizing:border-box;width:100%;max-width:100%;height:100%;text-align:left;pointer-events:none}[dir=rtl] .mdc-notched-outline,.mdc-notched-outline[dir=rtl]{text-align:right}.mdc-notched-outline__leading,.mdc-notched-outline__notch,.mdc-notched-outline__trailing{box-sizing:border-box;height:100%;border-top:1px solid;border-bottom:1px solid;pointer-events:none}.mdc-notched-outline__leading{border-left:1px solid;border-right:none;width:12px}[dir=rtl] .mdc-notched-outline__leading,.mdc-notched-outline__leading[dir=rtl]{border-left:none;border-right:1px solid}.mdc-notched-outline__trailing{border-left:none;border-right:1px solid;flex-grow:1}[dir=rtl] .mdc-notched-outline__trailing,.mdc-notched-outline__trailing[dir=rtl]{border-left:1px solid;border-right:none}.mdc-notched-outline__notch{flex:0 0 auto;width:auto;max-width:calc(100% - 12px * 2)}.mdc-notched-outline .mdc-floating-label{display:inline-block;position:relative;max-width:100%}.mdc-notched-outline .mdc-floating-label--float-above{text-overflow:clip}.mdc-notched-outline--upgraded .mdc-floating-label--float-above{max-width:calc(100% / 0.75)}.mdc-notched-outline--notched .mdc-notched-outline__notch{padding-left:0;padding-right:8px;border-top:none}[dir=rtl] .mdc-notched-outline--notched .mdc-notched-outline__notch,.mdc-notched-outline--notched .mdc-notched-outline__notch[dir=rtl]{padding-left:8px;padding-right:0}.mdc-notched-outline--no-label .mdc-notched-outline__notch{display:none}.mdc-select{display:inline-flex;position:relative}.mdc-select:not(.mdc-select--disabled) .mdc-select__selected-text{color:rgba(0, 0, 0, 0.87)}.mdc-select.mdc-select--disabled .mdc-select__selected-text{color:rgba(0, 0, 0, 0.38)}.mdc-select:not(.mdc-select--disabled) .mdc-floating-label{color:rgba(0, 0, 0, 0.6)}.mdc-select:not(.mdc-select--disabled).mdc-select--focused .mdc-floating-label{color:rgba(98, 0, 238, 0.87)}.mdc-select.mdc-select--disabled .mdc-floating-label{color:rgba(0, 0, 0, 0.38)}.mdc-select:not(.mdc-select--disabled) .mdc-select__dropdown-icon{fill:rgba(0, 0, 0, 0.54)}.mdc-select:not(.mdc-select--disabled).mdc-select--focused .mdc-select__dropdown-icon{fill:#6200ee;fill:var(--mdc-theme-primary, #6200ee)}.mdc-select.mdc-select--disabled .mdc-select__dropdown-icon{fill:rgba(0, 0, 0, 0.38)}.mdc-select:not(.mdc-select--disabled)+.mdc-select-helper-text{color:rgba(0, 0, 0, 0.6)}.mdc-select.mdc-select--disabled+.mdc-select-helper-text{color:rgba(0, 0, 0, 0.38)}.mdc-select:not(.mdc-select--disabled) .mdc-select__icon{color:rgba(0, 0, 0, 0.54)}.mdc-select.mdc-select--disabled .mdc-select__icon{color:rgba(0, 0, 0, 0.38)}@media screen and (forced-colors: active),(-ms-high-contrast: active){.mdc-select.mdc-select--disabled .mdc-select__selected-text{color:GrayText}.mdc-select.mdc-select--disabled .mdc-select__dropdown-icon{fill:red}.mdc-select.mdc-select--disabled .mdc-floating-label{color:GrayText}.mdc-select.mdc-select--disabled .mdc-line-ripple::before{border-bottom-color:GrayText}.mdc-select.mdc-select--disabled .mdc-notched-outline__leading,.mdc-select.mdc-select--disabled .mdc-notched-outline__notch,.mdc-select.mdc-select--disabled .mdc-notched-outline__trailing{border-color:GrayText}.mdc-select.mdc-select--disabled .mdc-select__icon{color:GrayText}.mdc-select.mdc-select--disabled+.mdc-select-helper-text{color:GrayText}}.mdc-select .mdc-floating-label{top:50%;transform:translateY(-50%);pointer-events:none}.mdc-select .mdc-select__anchor{padding-left:16px;padding-right:0}[dir=rtl] .mdc-select .mdc-select__anchor,.mdc-select .mdc-select__anchor[dir=rtl]{padding-left:0;padding-right:16px}.mdc-select.mdc-select--with-leading-icon .mdc-select__anchor{padding-left:0;padding-right:0}[dir=rtl] .mdc-select.mdc-select--with-leading-icon .mdc-select__anchor,.mdc-select.mdc-select--with-leading-icon .mdc-select__anchor[dir=rtl]{padding-left:0;padding-right:0}.mdc-select .mdc-select__icon{width:24px;height:24px;font-size:24px}.mdc-select .mdc-select__dropdown-icon{width:24px;height:24px}.mdc-select .mdc-select__menu .mdc-deprecated-list-item{padding-left:16px;padding-right:16px}[dir=rtl] .mdc-select .mdc-select__menu .mdc-deprecated-list-item,.mdc-select .mdc-select__menu .mdc-deprecated-list-item[dir=rtl]{padding-left:16px;padding-right:16px}.mdc-select .mdc-select__menu .mdc-deprecated-list-item__graphic{margin-left:0;margin-right:12px}[dir=rtl] .mdc-select .mdc-select__menu .mdc-deprecated-list-item__graphic,.mdc-select .mdc-select__menu .mdc-deprecated-list-item__graphic[dir=rtl]{margin-left:12px;margin-right:0}.mdc-select__dropdown-icon{margin-left:12px;margin-right:12px;display:inline-flex;position:relative;align-self:center;align-items:center;justify-content:center;flex-shrink:0;pointer-events:none}.mdc-select__dropdown-icon .mdc-select__dropdown-icon-active,.mdc-select__dropdown-icon .mdc-select__dropdown-icon-inactive{position:absolute;top:0;left:0}.mdc-select__dropdown-icon .mdc-select__dropdown-icon-graphic{width:41.6666666667%;height:20.8333333333%}.mdc-select__dropdown-icon .mdc-select__dropdown-icon-inactive{opacity:1;transition:opacity 75ms linear 75ms}.mdc-select__dropdown-icon .mdc-select__dropdown-icon-active{opacity:0;transition:opacity 75ms linear}[dir=rtl] .mdc-select__dropdown-icon,.mdc-select__dropdown-icon[dir=rtl]{margin-left:12px;margin-right:12px}.mdc-select--activated .mdc-select__dropdown-icon .mdc-select__dropdown-icon-inactive{opacity:0;transition:opacity 49.5ms linear}.mdc-select--activated .mdc-select__dropdown-icon .mdc-select__dropdown-icon-active{opacity:1;transition:opacity 100.5ms linear 49.5ms}.mdc-select__anchor{width:200px;min-width:0;flex:1 1 auto;position:relative;box-sizing:border-box;overflow:hidden;outline:none;cursor:pointer}.mdc-select__anchor .mdc-floating-label--float-above{transform:translateY(-106%) scale(0.75)}.mdc-select__selected-text-container{display:flex;appearance:none;pointer-events:none;box-sizing:border-box;width:auto;min-width:0;flex-grow:1;height:28px;border:none;outline:none;padding:0;background-color:transparent;color:inherit}.mdc-select__selected-text{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;font-family:Roboto, sans-serif;font-family:var(--mdc-typography-subtitle1-font-family, var(--mdc-typography-font-family, Roboto, sans-serif));font-size:1rem;font-size:var(--mdc-typography-subtitle1-font-size, 1rem);line-height:1.75rem;line-height:var(--mdc-typography-subtitle1-line-height, 1.75rem);font-weight:400;font-weight:var(--mdc-typography-subtitle1-font-weight, 400);letter-spacing:0.009375em;letter-spacing:var(--mdc-typography-subtitle1-letter-spacing, 0.009375em);text-decoration:inherit;text-decoration:var(--mdc-typography-subtitle1-text-decoration, inherit);text-transform:inherit;text-transform:var(--mdc-typography-subtitle1-text-transform, inherit);text-overflow:ellipsis;white-space:nowrap;overflow:hidden;display:block;width:100%;text-align:left}[dir=rtl] .mdc-select__selected-text,.mdc-select__selected-text[dir=rtl]{text-align:right}.mdc-select--invalid:not(.mdc-select--disabled) .mdc-floating-label{color:#b00020;color:var(--mdc-theme-error, #b00020)}.mdc-select--invalid:not(.mdc-select--disabled).mdc-select--focused .mdc-floating-label{color:#b00020;color:var(--mdc-theme-error, #b00020)}.mdc-select--invalid:not(.mdc-select--disabled).mdc-select--invalid+.mdc-select-helper-text--validation-msg{color:#b00020;color:var(--mdc-theme-error, #b00020)}.mdc-select--invalid:not(.mdc-select--disabled) .mdc-select__dropdown-icon{fill:#b00020;fill:var(--mdc-theme-error, #b00020)}.mdc-select--invalid:not(.mdc-select--disabled).mdc-select--focused .mdc-select__dropdown-icon{fill:#b00020;fill:var(--mdc-theme-error, #b00020)}.mdc-select--disabled{cursor:default;pointer-events:none}.mdc-select--with-leading-icon .mdc-select__menu .mdc-deprecated-list-item{padding-left:12px;padding-right:12px}[dir=rtl] .mdc-select--with-leading-icon .mdc-select__menu .mdc-deprecated-list-item,.mdc-select--with-leading-icon .mdc-select__menu .mdc-deprecated-list-item[dir=rtl]{padding-left:12px;padding-right:12px}.mdc-select__menu .mdc-deprecated-list .mdc-select__icon,.mdc-select__menu .mdc-list .mdc-select__icon{margin-left:0;margin-right:0}[dir=rtl] .mdc-select__menu .mdc-deprecated-list .mdc-select__icon,[dir=rtl] .mdc-select__menu .mdc-list .mdc-select__icon,.mdc-select__menu .mdc-deprecated-list .mdc-select__icon[dir=rtl],.mdc-select__menu .mdc-list .mdc-select__icon[dir=rtl]{margin-left:0;margin-right:0}.mdc-select__menu .mdc-deprecated-list .mdc-deprecated-list-item--selected,.mdc-select__menu .mdc-deprecated-list .mdc-deprecated-list-item--activated,.mdc-select__menu .mdc-list .mdc-deprecated-list-item--selected,.mdc-select__menu .mdc-list .mdc-deprecated-list-item--activated{color:#000;color:var(--mdc-theme-on-surface, #000)}.mdc-select__menu .mdc-deprecated-list .mdc-deprecated-list-item--selected .mdc-deprecated-list-item__graphic,.mdc-select__menu .mdc-deprecated-list .mdc-deprecated-list-item--activated .mdc-deprecated-list-item__graphic,.mdc-select__menu .mdc-list .mdc-deprecated-list-item--selected .mdc-deprecated-list-item__graphic,.mdc-select__menu .mdc-list .mdc-deprecated-list-item--activated .mdc-deprecated-list-item__graphic{color:#000;color:var(--mdc-theme-on-surface, #000)}.mdc-select__menu .mdc-list-item__start{display:inline-flex;align-items:center}.mdc-select__option{padding-left:16px;padding-right:16px}[dir=rtl] .mdc-select__option,.mdc-select__option[dir=rtl]{padding-left:16px;padding-right:16px}.mdc-select__one-line-option.mdc-list-item--with-one-line{height:48px}.mdc-select__two-line-option.mdc-list-item--with-two-lines{height:64px}.mdc-select__two-line-option.mdc-list-item--with-two-lines .mdc-list-item__start{margin-top:20px}.mdc-select__two-line-option.mdc-list-item--with-two-lines .mdc-list-item__primary-text{display:block;margin-top:0;line-height:normal;margin-bottom:-20px}.mdc-select__two-line-option.mdc-list-item--with-two-lines .mdc-list-item__primary-text::before{display:inline-block;width:0;height:28px;content:"";vertical-align:0}.mdc-select__two-line-option.mdc-list-item--with-two-lines .mdc-list-item__primary-text::after{display:inline-block;width:0;height:20px;content:"";vertical-align:-20px}.mdc-select__two-line-option.mdc-list-item--with-two-lines.mdc-list-item--with-trailing-meta .mdc-list-item__end{display:block;margin-top:0;line-height:normal}.mdc-select__two-line-option.mdc-list-item--with-two-lines.mdc-list-item--with-trailing-meta .mdc-list-item__end::before{display:inline-block;width:0;height:36px;content:"";vertical-align:0}.mdc-select__option-with-leading-content{padding-left:0;padding-right:12px}.mdc-select__option-with-leading-content.mdc-list-item{padding-left:0;padding-right:auto}[dir=rtl] .mdc-select__option-with-leading-content.mdc-list-item,.mdc-select__option-with-leading-content.mdc-list-item[dir=rtl]{padding-left:auto;padding-right:0}.mdc-select__option-with-leading-content .mdc-list-item__start{margin-left:12px;margin-right:0}[dir=rtl] .mdc-select__option-with-leading-content .mdc-list-item__start,.mdc-select__option-with-leading-content .mdc-list-item__start[dir=rtl]{margin-left:0;margin-right:12px}.mdc-select__option-with-leading-content .mdc-list-item__start{width:36px;height:24px}[dir=rtl] .mdc-select__option-with-leading-content,.mdc-select__option-with-leading-content[dir=rtl]{padding-left:12px;padding-right:0}.mdc-select__option-with-meta.mdc-list-item{padding-left:auto;padding-right:0}[dir=rtl] .mdc-select__option-with-meta.mdc-list-item,.mdc-select__option-with-meta.mdc-list-item[dir=rtl]{padding-left:0;padding-right:auto}.mdc-select__option-with-meta .mdc-list-item__end{margin-left:12px;margin-right:12px}[dir=rtl] .mdc-select__option-with-meta .mdc-list-item__end,.mdc-select__option-with-meta .mdc-list-item__end[dir=rtl]{margin-left:12px;margin-right:12px}.mdc-select--filled .mdc-select__anchor{height:56px;display:flex;align-items:baseline}.mdc-select--filled .mdc-select__anchor::before{display:inline-block;width:0;height:40px;content:"";vertical-align:0}.mdc-select--filled.mdc-select--no-label .mdc-select__anchor .mdc-select__selected-text::before{content:"​"}.mdc-select--filled.mdc-select--no-label .mdc-select__anchor .mdc-select__selected-text-container{height:100%;display:inline-flex;align-items:center}.mdc-select--filled.mdc-select--no-label .mdc-select__anchor::before{display:none}.mdc-select--filled .mdc-select__anchor{border-top-left-radius:4px;border-top-left-radius:var(--mdc-shape-small, 4px);border-top-right-radius:4px;border-top-right-radius:var(--mdc-shape-small, 4px);border-bottom-right-radius:0;border-bottom-left-radius:0}.mdc-select--filled:not(.mdc-select--disabled) .mdc-select__anchor{background-color:whitesmoke}.mdc-select--filled.mdc-select--disabled .mdc-select__anchor{background-color:#fafafa}.mdc-select--filled:not(.mdc-select--disabled) .mdc-line-ripple::before{border-bottom-color:rgba(0, 0, 0, 0.42)}.mdc-select--filled:not(.mdc-select--disabled):hover .mdc-line-ripple::before{border-bottom-color:rgba(0, 0, 0, 0.87)}.mdc-select--filled:not(.mdc-select--disabled) .mdc-line-ripple::after{border-bottom-color:#6200ee;border-bottom-color:var(--mdc-theme-primary, #6200ee)}.mdc-select--filled.mdc-select--disabled .mdc-line-ripple::before{border-bottom-color:rgba(0, 0, 0, 0.06)}.mdc-select--filled .mdc-floating-label{max-width:calc(100% - 64px)}.mdc-select--filled .mdc-floating-label--float-above{max-width:calc(100% / 0.75 - 64px / 0.75)}.mdc-select--filled .mdc-menu-surface--is-open-below{border-top-left-radius:0px;border-top-right-radius:0px}.mdc-select--filled.mdc-select--focused.mdc-line-ripple::after{transform:scale(1, 2);opacity:1}.mdc-select--filled .mdc-floating-label{left:16px;right:initial}[dir=rtl] .mdc-select--filled .mdc-floating-label,.mdc-select--filled .mdc-floating-label[dir=rtl]{left:initial;right:16px}.mdc-select--filled.mdc-select--with-leading-icon .mdc-floating-label{left:48px;right:initial}[dir=rtl] .mdc-select--filled.mdc-select--with-leading-icon .mdc-floating-label,.mdc-select--filled.mdc-select--with-leading-icon .mdc-floating-label[dir=rtl]{left:initial;right:48px}.mdc-select--filled.mdc-select--with-leading-icon .mdc-floating-label{max-width:calc(100% - 96px)}.mdc-select--filled.mdc-select--with-leading-icon .mdc-floating-label--float-above{max-width:calc(100% / 0.75 - 96px / 0.75)}.mdc-select--invalid:not(.mdc-select--disabled) .mdc-line-ripple::before{border-bottom-color:#b00020;border-bottom-color:var(--mdc-theme-error, #b00020)}.mdc-select--invalid:not(.mdc-select--disabled):hover .mdc-line-ripple::before{border-bottom-color:#b00020;border-bottom-color:var(--mdc-theme-error, #b00020)}.mdc-select--invalid:not(.mdc-select--disabled) .mdc-line-ripple::after{border-bottom-color:#b00020;border-bottom-color:var(--mdc-theme-error, #b00020)}.mdc-select--outlined{border:none}.mdc-select--outlined .mdc-select__anchor{height:56px}.mdc-select--outlined .mdc-select__anchor .mdc-floating-label--float-above{transform:translateY(-37.25px) scale(1)}.mdc-select--outlined .mdc-select__anchor .mdc-floating-label--float-above{font-size:.75rem}.mdc-select--outlined .mdc-select__anchor.mdc-notched-outline--upgraded .mdc-floating-label--float-above,.mdc-select--outlined .mdc-select__anchor .mdc-notched-outline--upgraded .mdc-floating-label--float-above{transform:translateY(-34.75px) scale(0.75)}.mdc-select--outlined .mdc-select__anchor.mdc-notched-outline--upgraded .mdc-floating-label--float-above,.mdc-select--outlined .mdc-select__anchor .mdc-notched-outline--upgraded .mdc-floating-label--float-above{font-size:1rem}.mdc-select--outlined .mdc-select__anchor .mdc-floating-label--shake{animation:mdc-floating-label-shake-float-above-select-outlined-56px 250ms 1}@keyframes mdc-floating-label-shake-float-above-select-outlined-56px{0%{transform:translateX(calc(0 - 0%)) translateY(-34.75px) scale(0.75)}33%{animation-timing-function:cubic-bezier(0.5, 0, 0.701732, 0.495819);transform:translateX(calc(4% - 0%)) translateY(-34.75px) scale(0.75)}66%{animation-timing-function:cubic-bezier(0.302435, 0.381352, 0.55, 0.956352);transform:translateX(calc(-4% - 0%)) translateY(-34.75px) scale(0.75)}100%{transform:translateX(calc(0 - 0%)) translateY(-34.75px) scale(0.75)}}.mdc-select--outlined .mdc-notched-outline .mdc-notched-outline__leading{border-top-left-radius:4px;border-top-left-radius:var(--mdc-shape-small, 4px);border-top-right-radius:0;border-bottom-right-radius:0;border-bottom-left-radius:4px;border-bottom-left-radius:var(--mdc-shape-small, 4px)}[dir=rtl] .mdc-select--outlined .mdc-notched-outline .mdc-notched-outline__leading,.mdc-select--outlined .mdc-notched-outline .mdc-notched-outline__leading[dir=rtl]{border-top-left-radius:0;border-top-right-radius:4px;border-top-right-radius:var(--mdc-shape-small, 4px);border-bottom-right-radius:4px;border-bottom-right-radius:var(--mdc-shape-small, 4px);border-bottom-left-radius:0}@supports(top: max(0%)){.mdc-select--outlined .mdc-notched-outline .mdc-notched-outline__leading{width:max(12px, var(--mdc-shape-small, 4px))}}@supports(top: max(0%)){.mdc-select--outlined .mdc-notched-outline .mdc-notched-outline__notch{max-width:calc(100% - max(12px, var(--mdc-shape-small, 4px)) * 2)}}.mdc-select--outlined .mdc-notched-outline .mdc-notched-outline__trailing{border-top-left-radius:0;border-top-right-radius:4px;border-top-right-radius:var(--mdc-shape-small, 4px);border-bottom-right-radius:4px;border-bottom-right-radius:var(--mdc-shape-small, 4px);border-bottom-left-radius:0}[dir=rtl] .mdc-select--outlined .mdc-notched-outline .mdc-notched-outline__trailing,.mdc-select--outlined .mdc-notched-outline .mdc-notched-outline__trailing[dir=rtl]{border-top-left-radius:4px;border-top-left-radius:var(--mdc-shape-small, 4px);border-top-right-radius:0;border-bottom-right-radius:0;border-bottom-left-radius:4px;border-bottom-left-radius:var(--mdc-shape-small, 4px)}@supports(top: max(0%)){.mdc-select--outlined .mdc-select__anchor{padding-left:max(16px, calc(var(--mdc-shape-small, 4px) + 4px))}}[dir=rtl] .mdc-select--outlined .mdc-select__anchor,.mdc-select--outlined .mdc-select__anchor[dir=rtl]{padding-left:0}@supports(top: max(0%)){[dir=rtl] .mdc-select--outlined .mdc-select__anchor,.mdc-select--outlined .mdc-select__anchor[dir=rtl]{padding-right:max(16px, calc(var(--mdc-shape-small, 4px) + 4px))}}@supports(top: max(0%)){.mdc-select--outlined+.mdc-select-helper-text{margin-left:max(16px, calc(var(--mdc-shape-small, 4px) + 4px))}}[dir=rtl] .mdc-select--outlined+.mdc-select-helper-text,.mdc-select--outlined+.mdc-select-helper-text[dir=rtl]{margin-left:0}@supports(top: max(0%)){[dir=rtl] .mdc-select--outlined+.mdc-select-helper-text,.mdc-select--outlined+.mdc-select-helper-text[dir=rtl]{margin-right:max(16px, calc(var(--mdc-shape-small, 4px) + 4px))}}.mdc-select--outlined:not(.mdc-select--disabled) .mdc-select__anchor{background-color:transparent}.mdc-select--outlined.mdc-select--disabled .mdc-select__anchor{background-color:transparent}.mdc-select--outlined:not(.mdc-select--disabled) .mdc-notched-outline__leading,.mdc-select--outlined:not(.mdc-select--disabled) .mdc-notched-outline__notch,.mdc-select--outlined:not(.mdc-select--disabled) .mdc-notched-outline__trailing{border-color:rgba(0, 0, 0, 0.38)}.mdc-select--outlined:not(.mdc-select--disabled):not(.mdc-select--focused) .mdc-select__anchor:hover .mdc-notched-outline .mdc-notched-outline__leading,.mdc-select--outlined:not(.mdc-select--disabled):not(.mdc-select--focused) .mdc-select__anchor:hover .mdc-notched-outline .mdc-notched-outline__notch,.mdc-select--outlined:not(.mdc-select--disabled):not(.mdc-select--focused) .mdc-select__anchor:hover .mdc-notched-outline .mdc-notched-outline__trailing{border-color:rgba(0, 0, 0, 0.87)}.mdc-select--outlined:not(.mdc-select--disabled).mdc-select--focused .mdc-notched-outline .mdc-notched-outline__leading,.mdc-select--outlined:not(.mdc-select--disabled).mdc-select--focused .mdc-notched-outline .mdc-notched-outline__notch,.mdc-select--outlined:not(.mdc-select--disabled).mdc-select--focused .mdc-notched-outline .mdc-notched-outline__trailing{border-width:2px}.mdc-select--outlined:not(.mdc-select--disabled).mdc-select--focused .mdc-notched-outline .mdc-notched-outline__leading,.mdc-select--outlined:not(.mdc-select--disabled).mdc-select--focused .mdc-notched-outline .mdc-notched-outline__notch,.mdc-select--outlined:not(.mdc-select--disabled).mdc-select--focused .mdc-notched-outline .mdc-notched-outline__trailing{border-color:#6200ee;border-color:var(--mdc-theme-primary, #6200ee)}.mdc-select--outlined.mdc-select--disabled .mdc-notched-outline__leading,.mdc-select--outlined.mdc-select--disabled .mdc-notched-outline__notch,.mdc-select--outlined.mdc-select--disabled .mdc-notched-outline__trailing{border-color:rgba(0, 0, 0, 0.06)}.mdc-select--outlined .mdc-select__anchor :not(.mdc-notched-outline--notched) .mdc-notched-outline__notch{max-width:calc(100% - 60px)}.mdc-select--outlined .mdc-select__anchor{display:flex;align-items:baseline;overflow:visible}.mdc-select--outlined .mdc-select__anchor .mdc-floating-label--shake{animation:mdc-floating-label-shake-float-above-select-outlined 250ms 1}.mdc-select--outlined .mdc-select__anchor .mdc-floating-label--float-above{transform:translateY(-37.25px) scale(1)}.mdc-select--outlined .mdc-select__anchor .mdc-floating-label--float-above{font-size:.75rem}.mdc-select--outlined .mdc-select__anchor.mdc-notched-outline--upgraded .mdc-floating-label--float-above,.mdc-select--outlined .mdc-select__anchor .mdc-notched-outline--upgraded .mdc-floating-label--float-above{transform:translateY(-34.75px) scale(0.75)}.mdc-select--outlined .mdc-select__anchor.mdc-notched-outline--upgraded .mdc-floating-label--float-above,.mdc-select--outlined .mdc-select__anchor .mdc-notched-outline--upgraded .mdc-floating-label--float-above{font-size:1rem}.mdc-select--outlined .mdc-select__anchor .mdc-notched-outline--notched .mdc-notched-outline__notch{padding-top:1px}.mdc-select--outlined .mdc-select__anchor .mdc-select__selected-text::before{content:"​"}.mdc-select--outlined .mdc-select__anchor .mdc-select__selected-text-container{height:100%;display:inline-flex;align-items:center}.mdc-select--outlined .mdc-select__anchor::before{display:none}.mdc-select--outlined .mdc-select__selected-text-container{display:flex;border:none;z-index:1;background-color:transparent}.mdc-select--outlined .mdc-select__icon{z-index:2}.mdc-select--outlined .mdc-floating-label{line-height:1.15rem;left:4px;right:initial}[dir=rtl] .mdc-select--outlined .mdc-floating-label,.mdc-select--outlined .mdc-floating-label[dir=rtl]{left:initial;right:4px}.mdc-select--outlined.mdc-select--focused .mdc-notched-outline--notched .mdc-notched-outline__notch{padding-top:2px}.mdc-select--outlined.mdc-select--invalid:not(.mdc-select--disabled) .mdc-notched-outline__leading,.mdc-select--outlined.mdc-select--invalid:not(.mdc-select--disabled) .mdc-notched-outline__notch,.mdc-select--outlined.mdc-select--invalid:not(.mdc-select--disabled) .mdc-notched-outline__trailing{border-color:#b00020;border-color:var(--mdc-theme-error, #b00020)}.mdc-select--outlined.mdc-select--invalid:not(.mdc-select--disabled):not(.mdc-select--focused) .mdc-select__anchor:hover .mdc-notched-outline .mdc-notched-outline__leading,.mdc-select--outlined.mdc-select--invalid:not(.mdc-select--disabled):not(.mdc-select--focused) .mdc-select__anchor:hover .mdc-notched-outline .mdc-notched-outline__notch,.mdc-select--outlined.mdc-select--invalid:not(.mdc-select--disabled):not(.mdc-select--focused) .mdc-select__anchor:hover .mdc-notched-outline .mdc-notched-outline__trailing{border-color:#b00020;border-color:var(--mdc-theme-error, #b00020)}.mdc-select--outlined.mdc-select--invalid:not(.mdc-select--disabled).mdc-select--focused .mdc-notched-outline .mdc-notched-outline__leading,.mdc-select--outlined.mdc-select--invalid:not(.mdc-select--disabled).mdc-select--focused .mdc-notched-outline .mdc-notched-outline__notch,.mdc-select--outlined.mdc-select--invalid:not(.mdc-select--disabled).mdc-select--focused .mdc-notched-outline .mdc-notched-outline__trailing{border-width:2px}.mdc-select--outlined.mdc-select--invalid:not(.mdc-select--disabled).mdc-select--focused .mdc-notched-outline .mdc-notched-outline__leading,.mdc-select--outlined.mdc-select--invalid:not(.mdc-select--disabled).mdc-select--focused .mdc-notched-outline .mdc-notched-outline__notch,.mdc-select--outlined.mdc-select--invalid:not(.mdc-select--disabled).mdc-select--focused .mdc-notched-outline .mdc-notched-outline__trailing{border-color:#b00020;border-color:var(--mdc-theme-error, #b00020)}.mdc-select--outlined.mdc-select--with-leading-icon .mdc-floating-label{left:36px;right:initial}[dir=rtl] .mdc-select--outlined.mdc-select--with-leading-icon .mdc-floating-label,.mdc-select--outlined.mdc-select--with-leading-icon .mdc-floating-label[dir=rtl]{left:initial;right:36px}.mdc-select--outlined.mdc-select--with-leading-icon .mdc-floating-label--float-above{transform:translateY(-37.25px) translateX(-32px) scale(1)}[dir=rtl] .mdc-select--outlined.mdc-select--with-leading-icon .mdc-floating-label--float-above,.mdc-select--outlined.mdc-select--with-leading-icon .mdc-floating-label--float-above[dir=rtl]{transform:translateY(-37.25px) translateX(32px) scale(1)}.mdc-select--outlined.mdc-select--with-leading-icon .mdc-floating-label--float-above{font-size:.75rem}.mdc-select--outlined.mdc-select--with-leading-icon.mdc-notched-outline--upgraded .mdc-floating-label--float-above,.mdc-select--outlined.mdc-select--with-leading-icon .mdc-notched-outline--upgraded .mdc-floating-label--float-above{transform:translateY(-34.75px) translateX(-32px) scale(0.75)}[dir=rtl] .mdc-select--outlined.mdc-select--with-leading-icon.mdc-notched-outline--upgraded .mdc-floating-label--float-above,[dir=rtl] .mdc-select--outlined.mdc-select--with-leading-icon .mdc-notched-outline--upgraded .mdc-floating-label--float-above,.mdc-select--outlined.mdc-select--with-leading-icon.mdc-notched-outline--upgraded .mdc-floating-label--float-above[dir=rtl],.mdc-select--outlined.mdc-select--with-leading-icon .mdc-notched-outline--upgraded .mdc-floating-label--float-above[dir=rtl]{transform:translateY(-34.75px) translateX(32px) scale(0.75)}.mdc-select--outlined.mdc-select--with-leading-icon.mdc-notched-outline--upgraded .mdc-floating-label--float-above,.mdc-select--outlined.mdc-select--with-leading-icon .mdc-notched-outline--upgraded .mdc-floating-label--float-above{font-size:1rem}.mdc-select--outlined.mdc-select--with-leading-icon .mdc-floating-label--shake{animation:mdc-floating-label-shake-float-above-select-outlined-leading-icon-56px 250ms 1}@keyframes mdc-floating-label-shake-float-above-select-outlined-leading-icon-56px{0%{transform:translateX(calc(0 - 32px)) translateY(-34.75px) scale(0.75)}33%{animation-timing-function:cubic-bezier(0.5, 0, 0.701732, 0.495819);transform:translateX(calc(4% - 32px)) translateY(-34.75px) scale(0.75)}66%{animation-timing-function:cubic-bezier(0.302435, 0.381352, 0.55, 0.956352);transform:translateX(calc(-4% - 32px)) translateY(-34.75px) scale(0.75)}100%{transform:translateX(calc(0 - 32px)) translateY(-34.75px) scale(0.75)}}[dir=rtl] .mdc-select--outlined.mdc-select--with-leading-icon .mdc-floating-label--shake,.mdc-select--outlined.mdc-select--with-leading-icon[dir=rtl] .mdc-floating-label--shake{animation:mdc-floating-label-shake-float-above-select-outlined-leading-icon-56px 250ms 1}@keyframes mdc-floating-label-shake-float-above-select-outlined-leading-icon-56px-rtl{0%{transform:translateX(calc(0 - -32px)) translateY(-34.75px) scale(0.75)}33%{animation-timing-function:cubic-bezier(0.5, 0, 0.701732, 0.495819);transform:translateX(calc(4% - -32px)) translateY(-34.75px) scale(0.75)}66%{animation-timing-function:cubic-bezier(0.302435, 0.381352, 0.55, 0.956352);transform:translateX(calc(-4% - -32px)) translateY(-34.75px) scale(0.75)}100%{transform:translateX(calc(0 - -32px)) translateY(-34.75px) scale(0.75)}}.mdc-select--outlined.mdc-select--with-leading-icon .mdc-select__anchor :not(.mdc-notched-outline--notched) .mdc-notched-outline__notch{max-width:calc(100% - 96px)}.mdc-select--outlined .mdc-menu-surface{margin-bottom:8px}.mdc-select--outlined.mdc-select--no-label .mdc-menu-surface,.mdc-select--outlined .mdc-menu-surface--is-open-below{margin-bottom:0}.mdc-select__anchor{--mdc-ripple-fg-size: 0;--mdc-ripple-left: 0;--mdc-ripple-top: 0;--mdc-ripple-fg-scale: 1;--mdc-ripple-fg-translate-end: 0;--mdc-ripple-fg-translate-start: 0;-webkit-tap-highlight-color:rgba(0,0,0,0);will-change:transform,opacity}.mdc-select__anchor .mdc-select__ripple::before,.mdc-select__anchor .mdc-select__ripple::after{position:absolute;border-radius:50%;opacity:0;pointer-events:none;content:""}.mdc-select__anchor .mdc-select__ripple::before{transition:opacity 15ms linear,background-color 15ms linear;z-index:1;z-index:var(--mdc-ripple-z-index, 1)}.mdc-select__anchor .mdc-select__ripple::after{z-index:0;z-index:var(--mdc-ripple-z-index, 0)}.mdc-select__anchor.mdc-ripple-upgraded .mdc-select__ripple::before{transform:scale(var(--mdc-ripple-fg-scale, 1))}.mdc-select__anchor.mdc-ripple-upgraded .mdc-select__ripple::after{top:0;left:0;transform:scale(0);transform-origin:center center}.mdc-select__anchor.mdc-ripple-upgraded--unbounded .mdc-select__ripple::after{top:var(--mdc-ripple-top, 0);left:var(--mdc-ripple-left, 0)}.mdc-select__anchor.mdc-ripple-upgraded--foreground-activation .mdc-select__ripple::after{animation:mdc-ripple-fg-radius-in 225ms forwards,mdc-ripple-fg-opacity-in 75ms forwards}.mdc-select__anchor.mdc-ripple-upgraded--foreground-deactivation .mdc-select__ripple::after{animation:mdc-ripple-fg-opacity-out 150ms;transform:translate(var(--mdc-ripple-fg-translate-end, 0)) scale(var(--mdc-ripple-fg-scale, 1))}.mdc-select__anchor .mdc-select__ripple::before,.mdc-select__anchor .mdc-select__ripple::after{top:calc(50% - 100%);left:calc(50% - 100%);width:200%;height:200%}.mdc-select__anchor.mdc-ripple-upgraded .mdc-select__ripple::after{width:var(--mdc-ripple-fg-size, 100%);height:var(--mdc-ripple-fg-size, 100%)}.mdc-select__anchor .mdc-select__ripple::before,.mdc-select__anchor .mdc-select__ripple::after{background-color:rgba(0, 0, 0, 0.87);background-color:var(--mdc-ripple-color, rgba(0, 0, 0, 0.87))}.mdc-select__anchor:hover .mdc-select__ripple::before,.mdc-select__anchor.mdc-ripple-surface--hover .mdc-select__ripple::before{opacity:0.04;opacity:var(--mdc-ripple-hover-opacity, 0.04)}.mdc-select__anchor.mdc-ripple-upgraded--background-focused .mdc-select__ripple::before,.mdc-select__anchor:not(.mdc-ripple-upgraded):focus .mdc-select__ripple::before{transition-duration:75ms;opacity:0.12;opacity:var(--mdc-ripple-focus-opacity, 0.12)}.mdc-select__anchor .mdc-select__ripple{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none}.mdc-select__menu .mdc-deprecated-list .mdc-deprecated-list-item--selected .mdc-deprecated-list-item__ripple::before,.mdc-select__menu .mdc-deprecated-list .mdc-deprecated-list-item--selected .mdc-deprecated-list-item__ripple::after{background-color:#000;background-color:var(--mdc-ripple-color, var(--mdc-theme-on-surface, #000))}.mdc-select__menu .mdc-deprecated-list .mdc-deprecated-list-item--selected:hover .mdc-deprecated-list-item__ripple::before,.mdc-select__menu .mdc-deprecated-list .mdc-deprecated-list-item--selected.mdc-ripple-surface--hover .mdc-deprecated-list-item__ripple::before{opacity:0.04;opacity:var(--mdc-ripple-hover-opacity, 0.04)}.mdc-select__menu .mdc-deprecated-list .mdc-deprecated-list-item--selected.mdc-ripple-upgraded--background-focused .mdc-deprecated-list-item__ripple::before,.mdc-select__menu .mdc-deprecated-list .mdc-deprecated-list-item--selected:not(.mdc-ripple-upgraded):focus .mdc-deprecated-list-item__ripple::before{transition-duration:75ms;opacity:0.12;opacity:var(--mdc-ripple-focus-opacity, 0.12)}.mdc-select__menu .mdc-deprecated-list .mdc-deprecated-list-item--selected:not(.mdc-ripple-upgraded) .mdc-deprecated-list-item__ripple::after{transition:opacity 150ms linear}.mdc-select__menu .mdc-deprecated-list .mdc-deprecated-list-item--selected:not(.mdc-ripple-upgraded):active .mdc-deprecated-list-item__ripple::after{transition-duration:75ms;opacity:0.12;opacity:var(--mdc-ripple-press-opacity, 0.12)}.mdc-select__menu .mdc-deprecated-list .mdc-deprecated-list-item--selected.mdc-ripple-upgraded{--mdc-ripple-fg-opacity:var(--mdc-ripple-press-opacity, 0.12)}.mdc-select__menu .mdc-deprecated-list .mdc-deprecated-list-item--selected .mdc-list-item__ripple::before,.mdc-select__menu .mdc-deprecated-list .mdc-deprecated-list-item--selected .mdc-list-item__ripple::after{background-color:#000;background-color:var(--mdc-ripple-color, var(--mdc-theme-on-surface, #000))}.mdc-select__menu .mdc-deprecated-list .mdc-deprecated-list-item--selected:hover .mdc-list-item__ripple::before,.mdc-select__menu .mdc-deprecated-list .mdc-deprecated-list-item--selected.mdc-ripple-surface--hover .mdc-list-item__ripple::before{opacity:0.04;opacity:var(--mdc-ripple-hover-opacity, 0.04)}.mdc-select__menu .mdc-deprecated-list .mdc-deprecated-list-item--selected.mdc-ripple-upgraded--background-focused .mdc-list-item__ripple::before,.mdc-select__menu .mdc-deprecated-list .mdc-deprecated-list-item--selected:not(.mdc-ripple-upgraded):focus .mdc-list-item__ripple::before{transition-duration:75ms;opacity:0.12;opacity:var(--mdc-ripple-focus-opacity, 0.12)}.mdc-select__menu .mdc-deprecated-list .mdc-deprecated-list-item--selected:not(.mdc-ripple-upgraded) .mdc-list-item__ripple::after{transition:opacity 150ms linear}.mdc-select__menu .mdc-deprecated-list .mdc-deprecated-list-item--selected:not(.mdc-ripple-upgraded):active .mdc-list-item__ripple::after{transition-duration:75ms;opacity:0.12;opacity:var(--mdc-ripple-press-opacity, 0.12)}.mdc-select__menu .mdc-deprecated-list .mdc-deprecated-list-item--selected.mdc-ripple-upgraded{--mdc-ripple-fg-opacity:var(--mdc-ripple-press-opacity, 0.12)}.mdc-select-helper-text{margin:0;margin-left:16px;margin-right:16px;-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;font-family:Roboto, sans-serif;font-family:var(--mdc-typography-caption-font-family, var(--mdc-typography-font-family, Roboto, sans-serif));font-size:0.75rem;font-size:var(--mdc-typography-caption-font-size, 0.75rem);line-height:1.25rem;line-height:var(--mdc-typography-caption-line-height, 1.25rem);font-weight:400;font-weight:var(--mdc-typography-caption-font-weight, 400);letter-spacing:0.0333333333em;letter-spacing:var(--mdc-typography-caption-letter-spacing, 0.0333333333em);text-decoration:inherit;text-decoration:var(--mdc-typography-caption-text-decoration, inherit);text-transform:inherit;text-transform:var(--mdc-typography-caption-text-transform, inherit);display:block;margin-top:0;line-height:normal}[dir=rtl] .mdc-select-helper-text,.mdc-select-helper-text[dir=rtl]{margin-left:16px;margin-right:16px}.mdc-select-helper-text::before{display:inline-block;width:0;height:16px;content:"";vertical-align:0}.mdc-select-helper-text--validation-msg{opacity:0;transition:opacity 180ms cubic-bezier(0.4, 0, 0.2, 1)}.mdc-select--invalid+.mdc-select-helper-text--validation-msg,.mdc-select-helper-text--validation-msg-persistent{opacity:1}.mdc-select--with-leading-icon .mdc-select__icon{display:inline-block;box-sizing:border-box;border:none;text-decoration:none;cursor:pointer;user-select:none;flex-shrink:0;align-self:center;background-color:transparent;fill:currentColor}.mdc-select--with-leading-icon .mdc-select__icon{margin-left:12px;margin-right:12px}[dir=rtl] .mdc-select--with-leading-icon .mdc-select__icon,.mdc-select--with-leading-icon .mdc-select__icon[dir=rtl]{margin-left:12px;margin-right:12px}.mdc-select__icon:not([tabindex]),.mdc-select__icon[tabindex="-1"]{cursor:default;pointer-events:none}.material-icons{font-family:var(--mdc-icon-font, "Material Icons");font-weight:normal;font-style:normal;font-size:var(--mdc-icon-size, 24px);line-height:1;letter-spacing:normal;text-transform:none;display:inline-block;white-space:nowrap;word-wrap:normal;direction:ltr;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;-moz-osx-font-smoothing:grayscale;font-feature-settings:"liga"}:host{display:inline-block;vertical-align:top;outline:none}.mdc-select{width:100%}[hidden]{display:none}.mdc-select__icon{z-index:2}.mdc-select--with-leading-icon{--mdc-list-item-graphic-margin: calc( 48px - var(--mdc-list-item-graphic-size, 24px) - var(--mdc-list-side-padding, 16px) )}.mdc-select .mdc-select__anchor .mdc-select__selected-text{overflow:hidden}.mdc-select .mdc-select__anchor *{display:inline-flex}.mdc-select .mdc-select__anchor .mdc-floating-label{display:inline-block}mwc-notched-outline{--mdc-notched-outline-border-color: var( --mdc-select-outlined-idle-border-color, rgba(0, 0, 0, 0.38) );--mdc-notched-outline-notch-offset: 1px}:host(:not([disabled]):hover) .mdc-select:not(.mdc-select--invalid):not(.mdc-select--focused) mwc-notched-outline{--mdc-notched-outline-border-color: var( --mdc-select-outlined-hover-border-color, rgba(0, 0, 0, 0.87) )}:host(:not([disabled])) .mdc-select:not(.mdc-select--disabled) .mdc-select__selected-text{color:rgba(0, 0, 0, 0.87);color:var(--mdc-select-ink-color, rgba(0, 0, 0, 0.87))}:host(:not([disabled])) .mdc-select:not(.mdc-select--disabled) .mdc-line-ripple::before{border-bottom-color:rgba(0, 0, 0, 0.42);border-bottom-color:var(--mdc-select-idle-line-color, rgba(0, 0, 0, 0.42))}:host(:not([disabled])) .mdc-select:not(.mdc-select--disabled):hover .mdc-line-ripple::before{border-bottom-color:rgba(0, 0, 0, 0.87);border-bottom-color:var(--mdc-select-hover-line-color, rgba(0, 0, 0, 0.87))}:host(:not([disabled])) .mdc-select:not(.mdc-select--outlined):not(.mdc-select--disabled) .mdc-select__anchor{background-color:whitesmoke;background-color:var(--mdc-select-fill-color, whitesmoke)}:host(:not([disabled])) .mdc-select.mdc-select--invalid .mdc-select__dropdown-icon{fill:var(--mdc-select-error-dropdown-icon-color, var(--mdc-select-error-color, var(--mdc-theme-error, #b00020)))}:host(:not([disabled])) .mdc-select.mdc-select--invalid .mdc-floating-label,:host(:not([disabled])) .mdc-select.mdc-select--invalid .mdc-floating-label::after{color:var(--mdc-select-error-color, var(--mdc-theme-error, #b00020))}:host(:not([disabled])) .mdc-select.mdc-select--invalid mwc-notched-outline{--mdc-notched-outline-border-color: var(--mdc-select-error-color, var(--mdc-theme-error, #b00020))}.mdc-select__menu--invalid{--mdc-theme-primary: var(--mdc-select-error-color, var(--mdc-theme-error, #b00020))}:host(:not([disabled])) .mdc-select:not(.mdc-select--invalid):not(.mdc-select--focused) .mdc-floating-label,:host(:not([disabled])) .mdc-select:not(.mdc-select--invalid):not(.mdc-select--focused) .mdc-floating-label::after{color:rgba(0, 0, 0, 0.6);color:var(--mdc-select-label-ink-color, rgba(0, 0, 0, 0.6))}:host(:not([disabled])) .mdc-select:not(.mdc-select--invalid):not(.mdc-select--focused) .mdc-select__dropdown-icon{fill:rgba(0, 0, 0, 0.54);fill:var(--mdc-select-dropdown-icon-color, rgba(0, 0, 0, 0.54))}:host(:not([disabled])) .mdc-select.mdc-select--focused mwc-notched-outline{--mdc-notched-outline-stroke-width: 2px;--mdc-notched-outline-notch-offset: 2px}:host(:not([disabled])) .mdc-select.mdc-select--focused:not(.mdc-select--invalid) mwc-notched-outline{--mdc-notched-outline-border-color: var( --mdc-select-focused-label-color, var(--mdc-theme-primary, rgba(98, 0, 238, 0.87)) )}:host(:not([disabled])) .mdc-select.mdc-select--focused:not(.mdc-select--invalid) .mdc-select__dropdown-icon{fill:rgba(98,0,238,.87);fill:var(--mdc-select-focused-dropdown-icon-color, var(--mdc-theme-primary, rgba(98, 0, 238, 0.87)))}:host(:not([disabled])) .mdc-select.mdc-select--focused:not(.mdc-select--invalid) .mdc-floating-label{color:#6200ee;color:var(--mdc-theme-primary, #6200ee)}:host(:not([disabled])) .mdc-select.mdc-select--focused:not(.mdc-select--invalid) .mdc-floating-label::after{color:#6200ee;color:var(--mdc-theme-primary, #6200ee)}:host(:not([disabled])) .mdc-select-helper-text:not(.mdc-select-helper-text--validation-msg){color:var(--mdc-select-label-ink-color, rgba(0, 0, 0, 0.6))}:host([disabled]){pointer-events:none}:host([disabled]) .mdc-select:not(.mdc-select--outlined).mdc-select--disabled .mdc-select__anchor{background-color:#fafafa;background-color:var(--mdc-select-disabled-fill-color, #fafafa)}:host([disabled]) .mdc-select.mdc-select--outlined mwc-notched-outline{--mdc-notched-outline-border-color: var( --mdc-select-outlined-disabled-border-color, rgba(0, 0, 0, 0.06) )}:host([disabled]) .mdc-select .mdc-select__dropdown-icon{fill:rgba(0, 0, 0, 0.38);fill:var(--mdc-select-disabled-dropdown-icon-color, rgba(0, 0, 0, 0.38))}:host([disabled]) .mdc-select:not(.mdc-select--invalid):not(.mdc-select--focused) .mdc-floating-label,:host([disabled]) .mdc-select:not(.mdc-select--invalid):not(.mdc-select--focused) .mdc-floating-label::after{color:rgba(0, 0, 0, 0.38);color:var(--mdc-select-disabled-ink-color, rgba(0, 0, 0, 0.38))}:host([disabled]) .mdc-select-helper-text{color:rgba(0, 0, 0, 0.38);color:var(--mdc-select-disabled-ink-color, rgba(0, 0, 0, 0.38))}:host([disabled]) .mdc-select__selected-text{color:rgba(0, 0, 0, 0.38);color:var(--mdc-select-disabled-ink-color, rgba(0, 0, 0, 0.38))}` +/** + * @license + * Copyright 2021 Google LLC + * SPDX-LIcense-Identifier: Apache-2.0 + */,Lo=g`@keyframes mdc-ripple-fg-radius-in{from{animation-timing-function:cubic-bezier(0.4, 0, 0.2, 1);transform:translate(var(--mdc-ripple-fg-translate-start, 0)) scale(1)}to{transform:translate(var(--mdc-ripple-fg-translate-end, 0)) scale(var(--mdc-ripple-fg-scale, 1))}}@keyframes mdc-ripple-fg-opacity-in{from{animation-timing-function:linear;opacity:0}to{opacity:var(--mdc-ripple-fg-opacity, 0)}}@keyframes mdc-ripple-fg-opacity-out{from{animation-timing-function:linear;opacity:var(--mdc-ripple-fg-opacity, 0)}to{opacity:0}}:host{display:block}.mdc-deprecated-list{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;font-family:Roboto, sans-serif;font-family:var(--mdc-typography-subtitle1-font-family, var(--mdc-typography-font-family, Roboto, sans-serif));font-size:1rem;font-size:var(--mdc-typography-subtitle1-font-size, 1rem);line-height:1.75rem;line-height:var(--mdc-typography-subtitle1-line-height, 1.75rem);font-weight:400;font-weight:var(--mdc-typography-subtitle1-font-weight, 400);letter-spacing:0.009375em;letter-spacing:var(--mdc-typography-subtitle1-letter-spacing, 0.009375em);text-decoration:inherit;text-decoration:var(--mdc-typography-subtitle1-text-decoration, inherit);text-transform:inherit;text-transform:var(--mdc-typography-subtitle1-text-transform, inherit);line-height:1.5rem;margin:0;padding:8px 0;list-style-type:none;color:rgba(0, 0, 0, 0.87);color:var(--mdc-theme-text-primary-on-background, rgba(0, 0, 0, 0.87));padding:var(--mdc-list-vertical-padding, 8px) 0}.mdc-deprecated-list:focus{outline:none}.mdc-deprecated-list-item{height:48px}.mdc-deprecated-list--dense{padding-top:4px;padding-bottom:4px;font-size:.812rem}.mdc-deprecated-list ::slotted([divider]){height:0;margin:0;border:none;border-bottom-width:1px;border-bottom-style:solid;border-bottom-color:rgba(0, 0, 0, 0.12)}.mdc-deprecated-list ::slotted([divider][padded]){margin:0 var(--mdc-list-side-padding, 16px)}.mdc-deprecated-list ::slotted([divider][inset]){margin-left:var(--mdc-list-inset-margin, 72px);margin-right:0;width:calc( 100% - var(--mdc-list-inset-margin, 72px) )}[dir=rtl] .mdc-deprecated-list ::slotted([divider][inset]),.mdc-deprecated-list ::slotted([divider][inset][dir=rtl]){margin-left:0;margin-right:var(--mdc-list-inset-margin, 72px)}.mdc-deprecated-list ::slotted([divider][inset][padded]){width:calc( 100% - var(--mdc-list-inset-margin, 72px) - var(--mdc-list-side-padding, 16px) )}.mdc-deprecated-list--dense ::slotted([mwc-list-item]){height:40px}.mdc-deprecated-list--dense ::slotted([mwc-list]){--mdc-list-item-graphic-size: 20px}.mdc-deprecated-list--two-line.mdc-deprecated-list--dense ::slotted([mwc-list-item]),.mdc-deprecated-list--avatar-list.mdc-deprecated-list--dense ::slotted([mwc-list-item]){height:60px}.mdc-deprecated-list--avatar-list.mdc-deprecated-list--dense ::slotted([mwc-list]){--mdc-list-item-graphic-size: 36px}:host([noninteractive]){pointer-events:none;cursor:default}.mdc-deprecated-list--dense ::slotted(.mdc-deprecated-list-item__primary-text){display:block;margin-top:0;line-height:normal;margin-bottom:-20px}.mdc-deprecated-list--dense ::slotted(.mdc-deprecated-list-item__primary-text)::before{display:inline-block;width:0;height:24px;content:"";vertical-align:0}.mdc-deprecated-list--dense ::slotted(.mdc-deprecated-list-item__primary-text)::after{display:inline-block;width:0;height:20px;content:"";vertical-align:-20px}` +/** + * @license + * Copyright 2021 Google LLC + * SPDX-LIcense-Identifier: Apache-2.0 + */,zo=g`:host{cursor:pointer;user-select:none;-webkit-tap-highlight-color:transparent;height:48px;display:flex;position:relative;align-items:center;justify-content:flex-start;overflow:hidden;padding:0;padding-left:var(--mdc-list-side-padding, 16px);padding-right:var(--mdc-list-side-padding, 16px);outline:none;height:48px;color:rgba(0,0,0,.87);color:var(--mdc-theme-text-primary-on-background, rgba(0, 0, 0, 0.87))}:host:focus{outline:none}:host([activated]){color:#6200ee;color:var(--mdc-theme-primary, #6200ee);--mdc-ripple-color: var( --mdc-theme-primary, #6200ee )}:host([activated]) .mdc-deprecated-list-item__graphic{color:#6200ee;color:var(--mdc-theme-primary, #6200ee)}:host([activated]) .fake-activated-ripple::before{position:absolute;display:block;top:0;bottom:0;left:0;right:0;width:100%;height:100%;pointer-events:none;z-index:1;content:"";opacity:0.12;opacity:var(--mdc-ripple-activated-opacity, 0.12);background-color:#6200ee;background-color:var(--mdc-ripple-color, var(--mdc-theme-primary, #6200ee))}.mdc-deprecated-list-item__graphic{flex-shrink:0;align-items:center;justify-content:center;fill:currentColor;display:inline-flex}.mdc-deprecated-list-item__graphic ::slotted(*){flex-shrink:0;align-items:center;justify-content:center;fill:currentColor;width:100%;height:100%;text-align:center}.mdc-deprecated-list-item__meta{width:var(--mdc-list-item-meta-size, 24px);height:var(--mdc-list-item-meta-size, 24px);margin-left:auto;margin-right:0;color:rgba(0, 0, 0, 0.38);color:var(--mdc-theme-text-hint-on-background, rgba(0, 0, 0, 0.38))}.mdc-deprecated-list-item__meta.multi{width:auto}.mdc-deprecated-list-item__meta ::slotted(*){width:var(--mdc-list-item-meta-size, 24px);line-height:var(--mdc-list-item-meta-size, 24px)}.mdc-deprecated-list-item__meta ::slotted(.material-icons),.mdc-deprecated-list-item__meta ::slotted(mwc-icon){line-height:var(--mdc-list-item-meta-size, 24px) !important}.mdc-deprecated-list-item__meta ::slotted(:not(.material-icons):not(mwc-icon)){-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;font-family:Roboto, sans-serif;font-family:var(--mdc-typography-caption-font-family, var(--mdc-typography-font-family, Roboto, sans-serif));font-size:0.75rem;font-size:var(--mdc-typography-caption-font-size, 0.75rem);line-height:1.25rem;line-height:var(--mdc-typography-caption-line-height, 1.25rem);font-weight:400;font-weight:var(--mdc-typography-caption-font-weight, 400);letter-spacing:0.0333333333em;letter-spacing:var(--mdc-typography-caption-letter-spacing, 0.0333333333em);text-decoration:inherit;text-decoration:var(--mdc-typography-caption-text-decoration, inherit);text-transform:inherit;text-transform:var(--mdc-typography-caption-text-transform, inherit)}[dir=rtl] .mdc-deprecated-list-item__meta,.mdc-deprecated-list-item__meta[dir=rtl]{margin-left:0;margin-right:auto}.mdc-deprecated-list-item__meta ::slotted(*){width:100%;height:100%}.mdc-deprecated-list-item__text{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.mdc-deprecated-list-item__text ::slotted([for]),.mdc-deprecated-list-item__text[for]{pointer-events:none}.mdc-deprecated-list-item__primary-text{text-overflow:ellipsis;white-space:nowrap;overflow:hidden;display:block;margin-top:0;line-height:normal;margin-bottom:-20px;display:block}.mdc-deprecated-list-item__primary-text::before{display:inline-block;width:0;height:32px;content:"";vertical-align:0}.mdc-deprecated-list-item__primary-text::after{display:inline-block;width:0;height:20px;content:"";vertical-align:-20px}.mdc-deprecated-list-item__secondary-text{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;font-family:Roboto, sans-serif;font-family:var(--mdc-typography-body2-font-family, var(--mdc-typography-font-family, Roboto, sans-serif));font-size:0.875rem;font-size:var(--mdc-typography-body2-font-size, 0.875rem);line-height:1.25rem;line-height:var(--mdc-typography-body2-line-height, 1.25rem);font-weight:400;font-weight:var(--mdc-typography-body2-font-weight, 400);letter-spacing:0.0178571429em;letter-spacing:var(--mdc-typography-body2-letter-spacing, 0.0178571429em);text-decoration:inherit;text-decoration:var(--mdc-typography-body2-text-decoration, inherit);text-transform:inherit;text-transform:var(--mdc-typography-body2-text-transform, inherit);text-overflow:ellipsis;white-space:nowrap;overflow:hidden;display:block;margin-top:0;line-height:normal;display:block}.mdc-deprecated-list-item__secondary-text::before{display:inline-block;width:0;height:20px;content:"";vertical-align:0}.mdc-deprecated-list--dense .mdc-deprecated-list-item__secondary-text{font-size:inherit}* ::slotted(a),a{color:inherit;text-decoration:none}:host([twoline]){height:72px}:host([twoline]) .mdc-deprecated-list-item__text{align-self:flex-start}:host([disabled]),:host([noninteractive]){cursor:default;pointer-events:none}:host([disabled]) .mdc-deprecated-list-item__text ::slotted(*){opacity:.38}:host([disabled]) .mdc-deprecated-list-item__text ::slotted(*),:host([disabled]) .mdc-deprecated-list-item__primary-text ::slotted(*),:host([disabled]) .mdc-deprecated-list-item__secondary-text ::slotted(*){color:#000;color:var(--mdc-theme-on-surface, #000)}.mdc-deprecated-list-item__secondary-text ::slotted(*){color:rgba(0, 0, 0, 0.54);color:var(--mdc-theme-text-secondary-on-background, rgba(0, 0, 0, 0.54))}.mdc-deprecated-list-item__graphic ::slotted(*){background-color:transparent;color:rgba(0, 0, 0, 0.38);color:var(--mdc-theme-text-icon-on-background, rgba(0, 0, 0, 0.38))}.mdc-deprecated-list-group__subheader ::slotted(*){color:rgba(0, 0, 0, 0.87);color:var(--mdc-theme-text-primary-on-background, rgba(0, 0, 0, 0.87))}:host([graphic=avatar]) .mdc-deprecated-list-item__graphic{width:var(--mdc-list-item-graphic-size, 40px);height:var(--mdc-list-item-graphic-size, 40px)}:host([graphic=avatar]) .mdc-deprecated-list-item__graphic.multi{width:auto}:host([graphic=avatar]) .mdc-deprecated-list-item__graphic ::slotted(*){width:var(--mdc-list-item-graphic-size, 40px);line-height:var(--mdc-list-item-graphic-size, 40px)}:host([graphic=avatar]) .mdc-deprecated-list-item__graphic ::slotted(.material-icons),:host([graphic=avatar]) .mdc-deprecated-list-item__graphic ::slotted(mwc-icon){line-height:var(--mdc-list-item-graphic-size, 40px) !important}:host([graphic=avatar]) .mdc-deprecated-list-item__graphic ::slotted(*){border-radius:50%}:host([graphic=avatar]) .mdc-deprecated-list-item__graphic,:host([graphic=medium]) .mdc-deprecated-list-item__graphic,:host([graphic=large]) .mdc-deprecated-list-item__graphic,:host([graphic=control]) .mdc-deprecated-list-item__graphic{margin-left:0;margin-right:var(--mdc-list-item-graphic-margin, 16px)}[dir=rtl] :host([graphic=avatar]) .mdc-deprecated-list-item__graphic,[dir=rtl] :host([graphic=medium]) .mdc-deprecated-list-item__graphic,[dir=rtl] :host([graphic=large]) .mdc-deprecated-list-item__graphic,[dir=rtl] :host([graphic=control]) .mdc-deprecated-list-item__graphic,:host([graphic=avatar]) .mdc-deprecated-list-item__graphic[dir=rtl],:host([graphic=medium]) .mdc-deprecated-list-item__graphic[dir=rtl],:host([graphic=large]) .mdc-deprecated-list-item__graphic[dir=rtl],:host([graphic=control]) .mdc-deprecated-list-item__graphic[dir=rtl]{margin-left:var(--mdc-list-item-graphic-margin, 16px);margin-right:0}:host([graphic=icon]) .mdc-deprecated-list-item__graphic{width:var(--mdc-list-item-graphic-size, 24px);height:var(--mdc-list-item-graphic-size, 24px);margin-left:0;margin-right:var(--mdc-list-item-graphic-margin, 32px)}:host([graphic=icon]) .mdc-deprecated-list-item__graphic.multi{width:auto}:host([graphic=icon]) .mdc-deprecated-list-item__graphic ::slotted(*){width:var(--mdc-list-item-graphic-size, 24px);line-height:var(--mdc-list-item-graphic-size, 24px)}:host([graphic=icon]) .mdc-deprecated-list-item__graphic ::slotted(.material-icons),:host([graphic=icon]) .mdc-deprecated-list-item__graphic ::slotted(mwc-icon){line-height:var(--mdc-list-item-graphic-size, 24px) !important}[dir=rtl] :host([graphic=icon]) .mdc-deprecated-list-item__graphic,:host([graphic=icon]) .mdc-deprecated-list-item__graphic[dir=rtl]{margin-left:var(--mdc-list-item-graphic-margin, 32px);margin-right:0}:host([graphic=avatar]:not([twoLine])),:host([graphic=icon]:not([twoLine])){height:56px}:host([graphic=medium]:not([twoLine])),:host([graphic=large]:not([twoLine])){height:72px}:host([graphic=medium]) .mdc-deprecated-list-item__graphic,:host([graphic=large]) .mdc-deprecated-list-item__graphic{width:var(--mdc-list-item-graphic-size, 56px);height:var(--mdc-list-item-graphic-size, 56px)}:host([graphic=medium]) .mdc-deprecated-list-item__graphic.multi,:host([graphic=large]) .mdc-deprecated-list-item__graphic.multi{width:auto}:host([graphic=medium]) .mdc-deprecated-list-item__graphic ::slotted(*),:host([graphic=large]) .mdc-deprecated-list-item__graphic ::slotted(*){width:var(--mdc-list-item-graphic-size, 56px);line-height:var(--mdc-list-item-graphic-size, 56px)}:host([graphic=medium]) .mdc-deprecated-list-item__graphic ::slotted(.material-icons),:host([graphic=medium]) .mdc-deprecated-list-item__graphic ::slotted(mwc-icon),:host([graphic=large]) .mdc-deprecated-list-item__graphic ::slotted(.material-icons),:host([graphic=large]) .mdc-deprecated-list-item__graphic ::slotted(mwc-icon){line-height:var(--mdc-list-item-graphic-size, 56px) !important}:host([graphic=large]){padding-left:0px}` +/** + * @license + * Copyright 2021 Google LLC + * SPDX-LIcense-Identifier: Apache-2.0 + */,Ro=g`.mdc-ripple-surface{--mdc-ripple-fg-size: 0;--mdc-ripple-left: 0;--mdc-ripple-top: 0;--mdc-ripple-fg-scale: 1;--mdc-ripple-fg-translate-end: 0;--mdc-ripple-fg-translate-start: 0;-webkit-tap-highlight-color:rgba(0,0,0,0);will-change:transform,opacity;position:relative;outline:none;overflow:hidden}.mdc-ripple-surface::before,.mdc-ripple-surface::after{position:absolute;border-radius:50%;opacity:0;pointer-events:none;content:""}.mdc-ripple-surface::before{transition:opacity 15ms linear,background-color 15ms linear;z-index:1;z-index:var(--mdc-ripple-z-index, 1)}.mdc-ripple-surface::after{z-index:0;z-index:var(--mdc-ripple-z-index, 0)}.mdc-ripple-surface.mdc-ripple-upgraded::before{transform:scale(var(--mdc-ripple-fg-scale, 1))}.mdc-ripple-surface.mdc-ripple-upgraded::after{top:0;left:0;transform:scale(0);transform-origin:center center}.mdc-ripple-surface.mdc-ripple-upgraded--unbounded::after{top:var(--mdc-ripple-top, 0);left:var(--mdc-ripple-left, 0)}.mdc-ripple-surface.mdc-ripple-upgraded--foreground-activation::after{animation:mdc-ripple-fg-radius-in 225ms forwards,mdc-ripple-fg-opacity-in 75ms forwards}.mdc-ripple-surface.mdc-ripple-upgraded--foreground-deactivation::after{animation:mdc-ripple-fg-opacity-out 150ms;transform:translate(var(--mdc-ripple-fg-translate-end, 0)) scale(var(--mdc-ripple-fg-scale, 1))}.mdc-ripple-surface::before,.mdc-ripple-surface::after{top:calc(50% - 100%);left:calc(50% - 100%);width:200%;height:200%}.mdc-ripple-surface.mdc-ripple-upgraded::after{width:var(--mdc-ripple-fg-size, 100%);height:var(--mdc-ripple-fg-size, 100%)}.mdc-ripple-surface[data-mdc-ripple-is-unbounded],.mdc-ripple-upgraded--unbounded{overflow:visible}.mdc-ripple-surface[data-mdc-ripple-is-unbounded]::before,.mdc-ripple-surface[data-mdc-ripple-is-unbounded]::after,.mdc-ripple-upgraded--unbounded::before,.mdc-ripple-upgraded--unbounded::after{top:calc(50% - 50%);left:calc(50% - 50%);width:100%;height:100%}.mdc-ripple-surface[data-mdc-ripple-is-unbounded].mdc-ripple-upgraded::before,.mdc-ripple-surface[data-mdc-ripple-is-unbounded].mdc-ripple-upgraded::after,.mdc-ripple-upgraded--unbounded.mdc-ripple-upgraded::before,.mdc-ripple-upgraded--unbounded.mdc-ripple-upgraded::after{top:var(--mdc-ripple-top, calc(50% - 50%));left:var(--mdc-ripple-left, calc(50% - 50%));width:var(--mdc-ripple-fg-size, 100%);height:var(--mdc-ripple-fg-size, 100%)}.mdc-ripple-surface[data-mdc-ripple-is-unbounded].mdc-ripple-upgraded::after,.mdc-ripple-upgraded--unbounded.mdc-ripple-upgraded::after{width:var(--mdc-ripple-fg-size, 100%);height:var(--mdc-ripple-fg-size, 100%)}.mdc-ripple-surface::before,.mdc-ripple-surface::after{background-color:#000;background-color:var(--mdc-ripple-color, #000)}.mdc-ripple-surface:hover::before,.mdc-ripple-surface.mdc-ripple-surface--hover::before{opacity:0.04;opacity:var(--mdc-ripple-hover-opacity, 0.04)}.mdc-ripple-surface.mdc-ripple-upgraded--background-focused::before,.mdc-ripple-surface:not(.mdc-ripple-upgraded):focus::before{transition-duration:75ms;opacity:0.12;opacity:var(--mdc-ripple-focus-opacity, 0.12)}.mdc-ripple-surface:not(.mdc-ripple-upgraded)::after{transition:opacity 150ms linear}.mdc-ripple-surface:not(.mdc-ripple-upgraded):active::after{transition-duration:75ms;opacity:0.12;opacity:var(--mdc-ripple-press-opacity, 0.12)}.mdc-ripple-surface.mdc-ripple-upgraded{--mdc-ripple-fg-opacity:var(--mdc-ripple-press-opacity, 0.12)}@keyframes mdc-ripple-fg-radius-in{from{animation-timing-function:cubic-bezier(0.4, 0, 0.2, 1);transform:translate(var(--mdc-ripple-fg-translate-start, 0)) scale(1)}to{transform:translate(var(--mdc-ripple-fg-translate-end, 0)) scale(var(--mdc-ripple-fg-scale, 1))}}@keyframes mdc-ripple-fg-opacity-in{from{animation-timing-function:linear;opacity:0}to{opacity:var(--mdc-ripple-fg-opacity, 0)}}@keyframes mdc-ripple-fg-opacity-out{from{animation-timing-function:linear;opacity:var(--mdc-ripple-fg-opacity, 0)}to{opacity:0}}:host{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;display:block}:host .mdc-ripple-surface{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;will-change:unset}.mdc-ripple-surface--primary::before,.mdc-ripple-surface--primary::after{background-color:#6200ee;background-color:var(--mdc-ripple-color, var(--mdc-theme-primary, #6200ee))}.mdc-ripple-surface--primary:hover::before,.mdc-ripple-surface--primary.mdc-ripple-surface--hover::before{opacity:0.04;opacity:var(--mdc-ripple-hover-opacity, 0.04)}.mdc-ripple-surface--primary.mdc-ripple-upgraded--background-focused::before,.mdc-ripple-surface--primary:not(.mdc-ripple-upgraded):focus::before{transition-duration:75ms;opacity:0.12;opacity:var(--mdc-ripple-focus-opacity, 0.12)}.mdc-ripple-surface--primary:not(.mdc-ripple-upgraded)::after{transition:opacity 150ms linear}.mdc-ripple-surface--primary:not(.mdc-ripple-upgraded):active::after{transition-duration:75ms;opacity:0.12;opacity:var(--mdc-ripple-press-opacity, 0.12)}.mdc-ripple-surface--primary.mdc-ripple-upgraded{--mdc-ripple-fg-opacity:var(--mdc-ripple-press-opacity, 0.12)}.mdc-ripple-surface--primary--activated::before{opacity:0.12;opacity:var(--mdc-ripple-activated-opacity, 0.12)}.mdc-ripple-surface--primary--activated::before,.mdc-ripple-surface--primary--activated::after{background-color:#6200ee;background-color:var(--mdc-ripple-color, var(--mdc-theme-primary, #6200ee))}.mdc-ripple-surface--primary--activated:hover::before,.mdc-ripple-surface--primary--activated.mdc-ripple-surface--hover::before{opacity:0.16;opacity:var(--mdc-ripple-hover-opacity, 0.16)}.mdc-ripple-surface--primary--activated.mdc-ripple-upgraded--background-focused::before,.mdc-ripple-surface--primary--activated:not(.mdc-ripple-upgraded):focus::before{transition-duration:75ms;opacity:0.24;opacity:var(--mdc-ripple-focus-opacity, 0.24)}.mdc-ripple-surface--primary--activated:not(.mdc-ripple-upgraded)::after{transition:opacity 150ms linear}.mdc-ripple-surface--primary--activated:not(.mdc-ripple-upgraded):active::after{transition-duration:75ms;opacity:0.24;opacity:var(--mdc-ripple-press-opacity, 0.24)}.mdc-ripple-surface--primary--activated.mdc-ripple-upgraded{--mdc-ripple-fg-opacity:var(--mdc-ripple-press-opacity, 0.24)}.mdc-ripple-surface--primary--selected::before{opacity:0.08;opacity:var(--mdc-ripple-selected-opacity, 0.08)}.mdc-ripple-surface--primary--selected::before,.mdc-ripple-surface--primary--selected::after{background-color:#6200ee;background-color:var(--mdc-ripple-color, var(--mdc-theme-primary, #6200ee))}.mdc-ripple-surface--primary--selected:hover::before,.mdc-ripple-surface--primary--selected.mdc-ripple-surface--hover::before{opacity:0.12;opacity:var(--mdc-ripple-hover-opacity, 0.12)}.mdc-ripple-surface--primary--selected.mdc-ripple-upgraded--background-focused::before,.mdc-ripple-surface--primary--selected:not(.mdc-ripple-upgraded):focus::before{transition-duration:75ms;opacity:0.2;opacity:var(--mdc-ripple-focus-opacity, 0.2)}.mdc-ripple-surface--primary--selected:not(.mdc-ripple-upgraded)::after{transition:opacity 150ms linear}.mdc-ripple-surface--primary--selected:not(.mdc-ripple-upgraded):active::after{transition-duration:75ms;opacity:0.2;opacity:var(--mdc-ripple-press-opacity, 0.2)}.mdc-ripple-surface--primary--selected.mdc-ripple-upgraded{--mdc-ripple-fg-opacity:var(--mdc-ripple-press-opacity, 0.2)}.mdc-ripple-surface--accent::before,.mdc-ripple-surface--accent::after{background-color:#018786;background-color:var(--mdc-ripple-color, var(--mdc-theme-secondary, #018786))}.mdc-ripple-surface--accent:hover::before,.mdc-ripple-surface--accent.mdc-ripple-surface--hover::before{opacity:0.04;opacity:var(--mdc-ripple-hover-opacity, 0.04)}.mdc-ripple-surface--accent.mdc-ripple-upgraded--background-focused::before,.mdc-ripple-surface--accent:not(.mdc-ripple-upgraded):focus::before{transition-duration:75ms;opacity:0.12;opacity:var(--mdc-ripple-focus-opacity, 0.12)}.mdc-ripple-surface--accent:not(.mdc-ripple-upgraded)::after{transition:opacity 150ms linear}.mdc-ripple-surface--accent:not(.mdc-ripple-upgraded):active::after{transition-duration:75ms;opacity:0.12;opacity:var(--mdc-ripple-press-opacity, 0.12)}.mdc-ripple-surface--accent.mdc-ripple-upgraded{--mdc-ripple-fg-opacity:var(--mdc-ripple-press-opacity, 0.12)}.mdc-ripple-surface--accent--activated::before{opacity:0.12;opacity:var(--mdc-ripple-activated-opacity, 0.12)}.mdc-ripple-surface--accent--activated::before,.mdc-ripple-surface--accent--activated::after{background-color:#018786;background-color:var(--mdc-ripple-color, var(--mdc-theme-secondary, #018786))}.mdc-ripple-surface--accent--activated:hover::before,.mdc-ripple-surface--accent--activated.mdc-ripple-surface--hover::before{opacity:0.16;opacity:var(--mdc-ripple-hover-opacity, 0.16)}.mdc-ripple-surface--accent--activated.mdc-ripple-upgraded--background-focused::before,.mdc-ripple-surface--accent--activated:not(.mdc-ripple-upgraded):focus::before{transition-duration:75ms;opacity:0.24;opacity:var(--mdc-ripple-focus-opacity, 0.24)}.mdc-ripple-surface--accent--activated:not(.mdc-ripple-upgraded)::after{transition:opacity 150ms linear}.mdc-ripple-surface--accent--activated:not(.mdc-ripple-upgraded):active::after{transition-duration:75ms;opacity:0.24;opacity:var(--mdc-ripple-press-opacity, 0.24)}.mdc-ripple-surface--accent--activated.mdc-ripple-upgraded{--mdc-ripple-fg-opacity:var(--mdc-ripple-press-opacity, 0.24)}.mdc-ripple-surface--accent--selected::before{opacity:0.08;opacity:var(--mdc-ripple-selected-opacity, 0.08)}.mdc-ripple-surface--accent--selected::before,.mdc-ripple-surface--accent--selected::after{background-color:#018786;background-color:var(--mdc-ripple-color, var(--mdc-theme-secondary, #018786))}.mdc-ripple-surface--accent--selected:hover::before,.mdc-ripple-surface--accent--selected.mdc-ripple-surface--hover::before{opacity:0.12;opacity:var(--mdc-ripple-hover-opacity, 0.12)}.mdc-ripple-surface--accent--selected.mdc-ripple-upgraded--background-focused::before,.mdc-ripple-surface--accent--selected:not(.mdc-ripple-upgraded):focus::before{transition-duration:75ms;opacity:0.2;opacity:var(--mdc-ripple-focus-opacity, 0.2)}.mdc-ripple-surface--accent--selected:not(.mdc-ripple-upgraded)::after{transition:opacity 150ms linear}.mdc-ripple-surface--accent--selected:not(.mdc-ripple-upgraded):active::after{transition-duration:75ms;opacity:0.2;opacity:var(--mdc-ripple-press-opacity, 0.2)}.mdc-ripple-surface--accent--selected.mdc-ripple-upgraded{--mdc-ripple-fg-opacity:var(--mdc-ripple-press-opacity, 0.2)}.mdc-ripple-surface--disabled{opacity:0}.mdc-ripple-surface--internal-use-state-layer-custom-properties::before,.mdc-ripple-surface--internal-use-state-layer-custom-properties::after{background-color:#000;background-color:var(--mdc-ripple-hover-state-layer-color, #000)}.mdc-ripple-surface--internal-use-state-layer-custom-properties:hover::before,.mdc-ripple-surface--internal-use-state-layer-custom-properties.mdc-ripple-surface--hover::before{opacity:0.04;opacity:var(--mdc-ripple-hover-state-layer-opacity, 0.04)}.mdc-ripple-surface--internal-use-state-layer-custom-properties.mdc-ripple-upgraded--background-focused::before,.mdc-ripple-surface--internal-use-state-layer-custom-properties:not(.mdc-ripple-upgraded):focus::before{transition-duration:75ms;opacity:0.12;opacity:var(--mdc-ripple-focus-state-layer-opacity, 0.12)}.mdc-ripple-surface--internal-use-state-layer-custom-properties:not(.mdc-ripple-upgraded)::after{transition:opacity 150ms linear}.mdc-ripple-surface--internal-use-state-layer-custom-properties:not(.mdc-ripple-upgraded):active::after{transition-duration:75ms;opacity:0.12;opacity:var(--mdc-ripple-pressed-state-layer-opacity, 0.12)}.mdc-ripple-surface--internal-use-state-layer-custom-properties.mdc-ripple-upgraded{--mdc-ripple-fg-opacity:var(--mdc-ripple-pressed-state-layer-opacity, 0.12)}` +/** + * @license + * Copyright 2021 Google LLC + * SPDX-LIcense-Identifier: Apache-2.0 + */,Mo=g`mwc-list ::slotted([mwc-list-item]:not([twoline])),mwc-list ::slotted([noninteractive]:not([twoline])){height:var(--mdc-menu-item-height, 48px)}` +/** + * @license + * Copyright 2021 Google LLC + * SPDX-LIcense-Identifier: Apache-2.0 + */,$o=g`.mdc-menu-surface{display:none;position:absolute;box-sizing:border-box;max-width:calc(100vw - 32px);max-width:var(--mdc-menu-max-width, calc(100vw - 32px));max-height:calc(100vh - 32px);max-height:var(--mdc-menu-max-height, calc(100vh - 32px));margin:0;padding:0;transform:scale(1);transform-origin:top left;opacity:0;overflow:auto;will-change:transform,opacity;z-index:8;transition:opacity .03s linear,transform .12s cubic-bezier(0, 0, 0.2, 1),height 250ms cubic-bezier(0, 0, 0.2, 1);box-shadow:0px 5px 5px -3px rgba(0, 0, 0, 0.2),0px 8px 10px 1px rgba(0, 0, 0, 0.14),0px 3px 14px 2px rgba(0,0,0,.12);background-color:#fff;background-color:var(--mdc-theme-surface, #fff);color:#000;color:var(--mdc-theme-on-surface, #000);border-radius:4px;border-radius:var(--mdc-shape-medium, 4px);transform-origin-left:top left;transform-origin-right:top right}.mdc-menu-surface:focus{outline:none}.mdc-menu-surface--animating-open{display:inline-block;transform:scale(0.8);opacity:0}.mdc-menu-surface--open{display:inline-block;transform:scale(1);opacity:1}.mdc-menu-surface--animating-closed{display:inline-block;opacity:0;transition:opacity .075s linear}[dir=rtl] .mdc-menu-surface,.mdc-menu-surface[dir=rtl]{transform-origin-left:top right;transform-origin-right:top left}.mdc-menu-surface--anchor{position:relative;overflow:visible}.mdc-menu-surface--fixed{position:fixed}.mdc-menu-surface--fullwidth{width:100%}:host(:not([open])){display:none}.mdc-menu-surface{z-index:8;z-index:var(--mdc-menu-z-index, 8);min-width:112px;min-width:var(--mdc-menu-min-width, 112px)}` +/** + * @license + * Copyright 2021 Google LLC + * SPDX-LIcense-Identifier: Apache-2.0 + */,Fo=g`.mdc-notched-outline{display:flex;position:absolute;top:0;right:0;left:0;box-sizing:border-box;width:100%;max-width:100%;height:100%;text-align:left;pointer-events:none}[dir=rtl] .mdc-notched-outline,.mdc-notched-outline[dir=rtl]{text-align:right}.mdc-notched-outline__leading,.mdc-notched-outline__notch,.mdc-notched-outline__trailing{box-sizing:border-box;height:100%;border-top:1px solid;border-bottom:1px solid;pointer-events:none}.mdc-notched-outline__leading{border-left:1px solid;border-right:none;width:12px}[dir=rtl] .mdc-notched-outline__leading,.mdc-notched-outline__leading[dir=rtl]{border-left:none;border-right:1px solid}.mdc-notched-outline__trailing{border-left:none;border-right:1px solid;flex-grow:1}[dir=rtl] .mdc-notched-outline__trailing,.mdc-notched-outline__trailing[dir=rtl]{border-left:1px solid;border-right:none}.mdc-notched-outline__notch{flex:0 0 auto;width:auto;max-width:calc(100% - 12px * 2)}.mdc-notched-outline .mdc-floating-label{display:inline-block;position:relative;max-width:100%}.mdc-notched-outline .mdc-floating-label--float-above{text-overflow:clip}.mdc-notched-outline--upgraded .mdc-floating-label--float-above{max-width:calc(100% / 0.75)}.mdc-notched-outline--notched .mdc-notched-outline__notch{padding-left:0;padding-right:8px;border-top:none}[dir=rtl] .mdc-notched-outline--notched .mdc-notched-outline__notch,.mdc-notched-outline--notched .mdc-notched-outline__notch[dir=rtl]{padding-left:8px;padding-right:0}.mdc-notched-outline--no-label .mdc-notched-outline__notch{display:none}:host{display:block;position:absolute;right:0;left:0;box-sizing:border-box;width:100%;max-width:100%;height:100%;text-align:left;pointer-events:none}[dir=rtl] :host,:host([dir=rtl]){text-align:right}::slotted(.mdc-floating-label){display:inline-block;position:relative;top:17px;bottom:auto;max-width:100%}::slotted(.mdc-floating-label--float-above){text-overflow:clip}.mdc-notched-outline--upgraded ::slotted(.mdc-floating-label--float-above){max-width:calc(100% / 0.75)}.mdc-notched-outline .mdc-notched-outline__leading{border-top-left-radius:4px;border-top-left-radius:var(--mdc-shape-small, 4px);border-top-right-radius:0;border-bottom-right-radius:0;border-bottom-left-radius:4px;border-bottom-left-radius:var(--mdc-shape-small, 4px)}[dir=rtl] .mdc-notched-outline .mdc-notched-outline__leading,.mdc-notched-outline .mdc-notched-outline__leading[dir=rtl]{border-top-left-radius:0;border-top-right-radius:4px;border-top-right-radius:var(--mdc-shape-small, 4px);border-bottom-right-radius:4px;border-bottom-right-radius:var(--mdc-shape-small, 4px);border-bottom-left-radius:0}@supports(top: max(0%)){.mdc-notched-outline .mdc-notched-outline__leading{width:max(12px, var(--mdc-shape-small, 4px))}}@supports(top: max(0%)){.mdc-notched-outline .mdc-notched-outline__notch{max-width:calc(100% - max(12px, var(--mdc-shape-small, 4px)) * 2)}}.mdc-notched-outline .mdc-notched-outline__trailing{border-top-left-radius:0;border-top-right-radius:4px;border-top-right-radius:var(--mdc-shape-small, 4px);border-bottom-right-radius:4px;border-bottom-right-radius:var(--mdc-shape-small, 4px);border-bottom-left-radius:0}[dir=rtl] .mdc-notched-outline .mdc-notched-outline__trailing,.mdc-notched-outline .mdc-notched-outline__trailing[dir=rtl]{border-top-left-radius:4px;border-top-left-radius:var(--mdc-shape-small, 4px);border-top-right-radius:0;border-bottom-right-radius:0;border-bottom-left-radius:4px;border-bottom-left-radius:var(--mdc-shape-small, 4px)}.mdc-notched-outline__leading,.mdc-notched-outline__notch,.mdc-notched-outline__trailing{border-color:var(--mdc-notched-outline-border-color, var(--mdc-theme-primary, #6200ee));border-width:1px;border-width:var(--mdc-notched-outline-stroke-width, 1px)}.mdc-notched-outline--notched .mdc-notched-outline__notch{padding-top:0;padding-top:var(--mdc-notched-outline-notch-offset, 0)}`,Do={"mwc-select":class extends Wa{static get styles(){return Oo}},"mwc-list":class extends eo{static get styles(){return Lo}},"mwc-list-item":class extends io{static get styles(){return zo}},"mwc-ripple":class extends Ao{static get styles(){return Ro}},"mwc-menu":class extends mo{static get styles(){return Mo}},"mwc-menu-surface":class extends fo{static get styles(){return $o}},"mwc-notched-outline":class extends ko{static get styles(){return Fo}}};function No(e,t,i){if(void 0!==t) +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +return function(e,t,i){const n=e.constructor;if(!i){const e=`__${t}`;if(!(i=n.getPropertyDescriptor(t,e)))throw new Error("@ariaProperty must be used after a @property decorator")}const r=i;let a="";if(!r.set)throw new Error(`@ariaProperty requires a setter for ${t}`);if(e.dispatchWizEvent)return i;const o={configurable:!0,enumerable:!0,set(e){if(""===a){const e=n.getPropertyOptions(t);a="string"==typeof e.attribute?e.attribute:t}this.hasAttribute(a)&&this.removeAttribute(a),r.set.call(this,e)}};return r.get&&(o.get=function(){return r.get.call(this)}),o}(e,t,i);throw new Error("@ariaProperty only supports TypeScript Decorators")} +/** + * @license + * Copyright 2018 Google Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */var Po={CHECKED:"mdc-switch--checked",DISABLED:"mdc-switch--disabled"},Bo={ARIA_CHECKED_ATTR:"aria-checked",NATIVE_CONTROL_SELECTOR:".mdc-switch__native-control",RIPPLE_SURFACE_SELECTOR:".mdc-switch__thumb-underlay"},Ho=function(e){function i(t){return e.call(this,r(r({},i.defaultAdapter),t))||this}return t(i,e),Object.defineProperty(i,"strings",{get:function(){return Bo},enumerable:!1,configurable:!0}),Object.defineProperty(i,"cssClasses",{get:function(){return Po},enumerable:!1,configurable:!0}),Object.defineProperty(i,"defaultAdapter",{get:function(){return{addClass:function(){},removeClass:function(){},setNativeControlChecked:function(){},setNativeControlDisabled:function(){},setNativeControlAttr:function(){}}},enumerable:!1,configurable:!0}),i.prototype.setChecked=function(e){this.adapter.setNativeControlChecked(e),this.updateAriaChecked(e),this.updateCheckedStyling(e)},i.prototype.setDisabled=function(e){this.adapter.setNativeControlDisabled(e),e?this.adapter.addClass(Po.DISABLED):this.adapter.removeClass(Po.DISABLED)},i.prototype.handleChange=function(e){var t=e.target;this.updateAriaChecked(t.checked),this.updateCheckedStyling(t.checked)},i.prototype.updateCheckedStyling=function(e){e?this.adapter.addClass(Po.CHECKED):this.adapter.removeClass(Po.CHECKED)},i.prototype.updateAriaChecked=function(e){this.adapter.setNativeControlAttr(Bo.ARIA_CHECKED_ATTR,""+!!e)},i}(hr); +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +class jo extends Er{constructor(){super(...arguments),this.checked=!1,this.disabled=!1,this.shouldRenderRipple=!1,this.mdcFoundationClass=Ho,this.rippleHandlers=new to((()=>(this.shouldRenderRipple=!0,this.ripple)))}changeHandler(e){this.mdcFoundation.handleChange(e),this.checked=this.formElement.checked}createAdapter(){return Object.assign(Object.assign({},br(this.mdcRoot)),{setNativeControlChecked:e=>{this.formElement.checked=e},setNativeControlDisabled:e=>{this.formElement.disabled=e},setNativeControlAttr:(e,t)=>{this.formElement.setAttribute(e,t)}})}renderRipple(){return this.shouldRenderRipple?U` + + `:""}focus(){const e=this.formElement;e&&(this.rippleHandlers.startFocus(),e.focus())}blur(){const e=this.formElement;e&&(this.rippleHandlers.endFocus(),e.blur())}click(){this.formElement&&!this.disabled&&(this.formElement.focus(),this.formElement.click())}firstUpdated(){super.firstUpdated(),this.shadowRoot&&this.mdcRoot.addEventListener("change",(e=>{this.dispatchEvent(new Event("change",e))}))}render(){return U` +
+
+
+ ${this.renderRipple()} +
+ +
+
+
`}handleRippleMouseDown(e){const t=()=>{window.removeEventListener("mouseup",t),this.handleRippleDeactivate()};window.addEventListener("mouseup",t),this.rippleHandlers.startPress(e)}handleRippleTouchStart(e){this.rippleHandlers.startPress(e)}handleRippleDeactivate(){this.rippleHandlers.endPress()}handleRippleMouseEnter(){this.rippleHandlers.startHover()}handleRippleMouseLeave(){this.rippleHandlers.endHover()}handleRippleFocus(){this.rippleHandlers.startFocus()}handleRippleBlur(){this.rippleHandlers.endFocus()}}a([he({type:Boolean}),Ir((function(e){this.mdcFoundation.setChecked(e)}))],jo.prototype,"checked",void 0),a([he({type:Boolean}),Ir((function(e){this.mdcFoundation.setDisabled(e)}))],jo.prototype,"disabled",void 0),a([No,he({attribute:"aria-label"})],jo.prototype,"ariaLabel",void 0),a([No,he({attribute:"aria-labelledby"})],jo.prototype,"ariaLabelledBy",void 0),a([ve(".mdc-switch")],jo.prototype,"mdcRoot",void 0),a([ve("input")],jo.prototype,"formElement",void 0),a([be("mwc-ripple")],jo.prototype,"ripple",void 0),a([ue()],jo.prototype,"shouldRenderRipple",void 0),a([ge({passive:!0})],jo.prototype,"handleRippleMouseDown",null),a([ge({passive:!0})],jo.prototype,"handleRippleTouchStart",null); +/** + * @license + * Copyright 2021 Google LLC + * SPDX-LIcense-Identifier: Apache-2.0 + */ +const Vo=g`.mdc-switch__thumb-underlay{left:-14px;right:initial;top:-17px;width:48px;height:48px}[dir=rtl] .mdc-switch__thumb-underlay,.mdc-switch__thumb-underlay[dir=rtl]{left:initial;right:-14px}.mdc-switch__native-control{width:64px;height:48px}.mdc-switch{display:inline-block;position:relative;outline:none;user-select:none}.mdc-switch.mdc-switch--checked .mdc-switch__track{background-color:#018786;background-color:var(--mdc-theme-secondary, #018786)}.mdc-switch.mdc-switch--checked .mdc-switch__thumb{background-color:#018786;background-color:var(--mdc-theme-secondary, #018786);border-color:#018786;border-color:var(--mdc-theme-secondary, #018786)}.mdc-switch:not(.mdc-switch--checked) .mdc-switch__track{background-color:#000;background-color:var(--mdc-theme-on-surface, #000)}.mdc-switch:not(.mdc-switch--checked) .mdc-switch__thumb{background-color:#fff;background-color:var(--mdc-theme-surface, #fff);border-color:#fff;border-color:var(--mdc-theme-surface, #fff)}.mdc-switch__native-control{left:0;right:initial;position:absolute;top:0;margin:0;opacity:0;cursor:pointer;pointer-events:auto;transition:transform 90ms cubic-bezier(0.4, 0, 0.2, 1)}[dir=rtl] .mdc-switch__native-control,.mdc-switch__native-control[dir=rtl]{left:initial;right:0}.mdc-switch__track{box-sizing:border-box;width:36px;height:14px;border:1px solid transparent;border-radius:7px;opacity:.38;transition:opacity 90ms cubic-bezier(0.4, 0, 0.2, 1),background-color 90ms cubic-bezier(0.4, 0, 0.2, 1),border-color 90ms cubic-bezier(0.4, 0, 0.2, 1)}.mdc-switch__thumb-underlay{display:flex;position:absolute;align-items:center;justify-content:center;transform:translateX(0);transition:transform 90ms cubic-bezier(0.4, 0, 0.2, 1),background-color 90ms cubic-bezier(0.4, 0, 0.2, 1),border-color 90ms cubic-bezier(0.4, 0, 0.2, 1)}.mdc-switch__thumb{box-shadow:0px 3px 1px -2px rgba(0, 0, 0, 0.2),0px 2px 2px 0px rgba(0, 0, 0, 0.14),0px 1px 5px 0px rgba(0,0,0,.12);box-sizing:border-box;width:20px;height:20px;border:10px solid;border-radius:50%;pointer-events:none;z-index:1}.mdc-switch--checked .mdc-switch__track{opacity:.54}.mdc-switch--checked .mdc-switch__thumb-underlay{transform:translateX(16px)}[dir=rtl] .mdc-switch--checked .mdc-switch__thumb-underlay,.mdc-switch--checked .mdc-switch__thumb-underlay[dir=rtl]{transform:translateX(-16px)}.mdc-switch--checked .mdc-switch__native-control{transform:translateX(-16px)}[dir=rtl] .mdc-switch--checked .mdc-switch__native-control,.mdc-switch--checked .mdc-switch__native-control[dir=rtl]{transform:translateX(16px)}.mdc-switch--disabled{opacity:.38;pointer-events:none}.mdc-switch--disabled .mdc-switch__thumb{border-width:1px}.mdc-switch--disabled .mdc-switch__native-control{cursor:default;pointer-events:none}:host{display:inline-flex;outline:none;-webkit-tap-highlight-color:transparent}`,Uo={"mwc-switch":class extends jo{static get styles(){return Vo}},"mwc-ripple":class extends Ao{static get styles(){return Ro}}}; +/** + * @license + * Copyright 2016 Google Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +var Go={ARIA_CONTROLS:"aria-controls",ARIA_DESCRIBEDBY:"aria-describedby",INPUT_SELECTOR:".mdc-text-field__input",LABEL_SELECTOR:".mdc-floating-label",LEADING_ICON_SELECTOR:".mdc-text-field__icon--leading",LINE_RIPPLE_SELECTOR:".mdc-line-ripple",OUTLINE_SELECTOR:".mdc-notched-outline",PREFIX_SELECTOR:".mdc-text-field__affix--prefix",SUFFIX_SELECTOR:".mdc-text-field__affix--suffix",TRAILING_ICON_SELECTOR:".mdc-text-field__icon--trailing"},Wo={DISABLED:"mdc-text-field--disabled",FOCUSED:"mdc-text-field--focused",HELPER_LINE:"mdc-text-field-helper-line",INVALID:"mdc-text-field--invalid",LABEL_FLOATING:"mdc-text-field--label-floating",NO_LABEL:"mdc-text-field--no-label",OUTLINED:"mdc-text-field--outlined",ROOT:"mdc-text-field",TEXTAREA:"mdc-text-field--textarea",WITH_LEADING_ICON:"mdc-text-field--with-leading-icon",WITH_TRAILING_ICON:"mdc-text-field--with-trailing-icon",WITH_INTERNAL_COUNTER:"mdc-text-field--with-internal-counter"},qo={LABEL_SCALE:.75},Yo=["pattern","min","max","required","step","minlength","maxlength"],Xo=["color","date","datetime-local","month","range","time","week"],Ko=["mousedown","touchstart"],Qo=["click","keydown"],Zo=function(e){function i(t,n){void 0===n&&(n={});var a=e.call(this,r(r({},i.defaultAdapter),t))||this;return a.isFocused=!1,a.receivedUserInput=!1,a.valid=!0,a.useNativeValidation=!0,a.validateOnValueChange=!0,a.helperText=n.helperText,a.characterCounter=n.characterCounter,a.leadingIcon=n.leadingIcon,a.trailingIcon=n.trailingIcon,a.inputFocusHandler=function(){a.activateFocus()},a.inputBlurHandler=function(){a.deactivateFocus()},a.inputInputHandler=function(){a.handleInput()},a.setPointerXOffset=function(e){a.setTransformOrigin(e)},a.textFieldInteractionHandler=function(){a.handleTextFieldInteraction()},a.validationAttributeChangeHandler=function(e){a.handleValidationAttributeChange(e)},a}return t(i,e),Object.defineProperty(i,"cssClasses",{get:function(){return Wo},enumerable:!1,configurable:!0}),Object.defineProperty(i,"strings",{get:function(){return Go},enumerable:!1,configurable:!0}),Object.defineProperty(i,"numbers",{get:function(){return qo},enumerable:!1,configurable:!0}),Object.defineProperty(i.prototype,"shouldAlwaysFloat",{get:function(){var e=this.getNativeInput().type;return Xo.indexOf(e)>=0},enumerable:!1,configurable:!0}),Object.defineProperty(i.prototype,"shouldFloat",{get:function(){return this.shouldAlwaysFloat||this.isFocused||!!this.getValue()||this.isBadInput()},enumerable:!1,configurable:!0}),Object.defineProperty(i.prototype,"shouldShake",{get:function(){return!this.isFocused&&!this.isValid()&&!!this.getValue()},enumerable:!1,configurable:!0}),Object.defineProperty(i,"defaultAdapter",{get:function(){return{addClass:function(){},removeClass:function(){},hasClass:function(){return!0},setInputAttr:function(){},removeInputAttr:function(){},registerTextFieldInteractionHandler:function(){},deregisterTextFieldInteractionHandler:function(){},registerInputInteractionHandler:function(){},deregisterInputInteractionHandler:function(){},registerValidationAttributeChangeHandler:function(){return new MutationObserver((function(){}))},deregisterValidationAttributeChangeHandler:function(){},getNativeInput:function(){return null},isFocused:function(){return!1},activateLineRipple:function(){},deactivateLineRipple:function(){},setLineRippleTransformOrigin:function(){},shakeLabel:function(){},floatLabel:function(){},setLabelRequired:function(){},hasLabel:function(){return!1},getLabelWidth:function(){return 0},hasOutline:function(){return!1},notchOutline:function(){},closeOutline:function(){}}},enumerable:!1,configurable:!0}),i.prototype.init=function(){var e,t,i,n;this.adapter.hasLabel()&&this.getNativeInput().required&&this.adapter.setLabelRequired(!0),this.adapter.isFocused()?this.inputFocusHandler():this.adapter.hasLabel()&&this.shouldFloat&&(this.notchOutline(!0),this.adapter.floatLabel(!0),this.styleFloating(!0)),this.adapter.registerInputInteractionHandler("focus",this.inputFocusHandler),this.adapter.registerInputInteractionHandler("blur",this.inputBlurHandler),this.adapter.registerInputInteractionHandler("input",this.inputInputHandler);try{for(var r=o(Ko),a=r.next();!a.done;a=r.next()){var s=a.value;this.adapter.registerInputInteractionHandler(s,this.setPointerXOffset)}}catch(t){e={error:t}}finally{try{a&&!a.done&&(t=r.return)&&t.call(r)}finally{if(e)throw e.error}}try{for(var l=o(Qo),d=l.next();!d.done;d=l.next()){s=d.value;this.adapter.registerTextFieldInteractionHandler(s,this.textFieldInteractionHandler)}}catch(e){i={error:e}}finally{try{d&&!d.done&&(n=l.return)&&n.call(l)}finally{if(i)throw i.error}}this.validationObserver=this.adapter.registerValidationAttributeChangeHandler(this.validationAttributeChangeHandler),this.setcharacterCounter(this.getValue().length)},i.prototype.destroy=function(){var e,t,i,n;this.adapter.deregisterInputInteractionHandler("focus",this.inputFocusHandler),this.adapter.deregisterInputInteractionHandler("blur",this.inputBlurHandler),this.adapter.deregisterInputInteractionHandler("input",this.inputInputHandler);try{for(var r=o(Ko),a=r.next();!a.done;a=r.next()){var s=a.value;this.adapter.deregisterInputInteractionHandler(s,this.setPointerXOffset)}}catch(t){e={error:t}}finally{try{a&&!a.done&&(t=r.return)&&t.call(r)}finally{if(e)throw e.error}}try{for(var l=o(Qo),d=l.next();!d.done;d=l.next()){s=d.value;this.adapter.deregisterTextFieldInteractionHandler(s,this.textFieldInteractionHandler)}}catch(e){i={error:e}}finally{try{d&&!d.done&&(n=l.return)&&n.call(l)}finally{if(i)throw i.error}}this.adapter.deregisterValidationAttributeChangeHandler(this.validationObserver)},i.prototype.handleTextFieldInteraction=function(){var e=this.adapter.getNativeInput();e&&e.disabled||(this.receivedUserInput=!0)},i.prototype.handleValidationAttributeChange=function(e){var t=this;e.some((function(e){return Yo.indexOf(e)>-1&&(t.styleValidity(!0),t.adapter.setLabelRequired(t.getNativeInput().required),!0)})),e.indexOf("maxlength")>-1&&this.setcharacterCounter(this.getValue().length)},i.prototype.notchOutline=function(e){if(this.adapter.hasOutline()&&this.adapter.hasLabel())if(e){var t=this.adapter.getLabelWidth()*qo.LABEL_SCALE;this.adapter.notchOutline(t)}else this.adapter.closeOutline()},i.prototype.activateFocus=function(){this.isFocused=!0,this.styleFocused(this.isFocused),this.adapter.activateLineRipple(),this.adapter.hasLabel()&&(this.notchOutline(this.shouldFloat),this.adapter.floatLabel(this.shouldFloat),this.styleFloating(this.shouldFloat),this.adapter.shakeLabel(this.shouldShake)),!this.helperText||!this.helperText.isPersistent()&&this.helperText.isValidation()&&this.valid||this.helperText.showToScreenReader()},i.prototype.setTransformOrigin=function(e){if(!this.isDisabled()&&!this.adapter.hasOutline()){var t=e.touches,i=t?t[0]:e,n=i.target.getBoundingClientRect(),r=i.clientX-n.left;this.adapter.setLineRippleTransformOrigin(r)}},i.prototype.handleInput=function(){this.autoCompleteFocus(),this.setcharacterCounter(this.getValue().length)},i.prototype.autoCompleteFocus=function(){this.receivedUserInput||this.activateFocus()},i.prototype.deactivateFocus=function(){this.isFocused=!1,this.adapter.deactivateLineRipple();var e=this.isValid();this.styleValidity(e),this.styleFocused(this.isFocused),this.adapter.hasLabel()&&(this.notchOutline(this.shouldFloat),this.adapter.floatLabel(this.shouldFloat),this.styleFloating(this.shouldFloat),this.adapter.shakeLabel(this.shouldShake)),this.shouldFloat||(this.receivedUserInput=!1)},i.prototype.getValue=function(){return this.getNativeInput().value},i.prototype.setValue=function(e){if(this.getValue()!==e&&(this.getNativeInput().value=e),this.setcharacterCounter(e.length),this.validateOnValueChange){var t=this.isValid();this.styleValidity(t)}this.adapter.hasLabel()&&(this.notchOutline(this.shouldFloat),this.adapter.floatLabel(this.shouldFloat),this.styleFloating(this.shouldFloat),this.validateOnValueChange&&this.adapter.shakeLabel(this.shouldShake))},i.prototype.isValid=function(){return this.useNativeValidation?this.isNativeInputValid():this.valid},i.prototype.setValid=function(e){this.valid=e,this.styleValidity(e);var t=!e&&!this.isFocused&&!!this.getValue();this.adapter.hasLabel()&&this.adapter.shakeLabel(t)},i.prototype.setValidateOnValueChange=function(e){this.validateOnValueChange=e},i.prototype.getValidateOnValueChange=function(){return this.validateOnValueChange},i.prototype.setUseNativeValidation=function(e){this.useNativeValidation=e},i.prototype.isDisabled=function(){return this.getNativeInput().disabled},i.prototype.setDisabled=function(e){this.getNativeInput().disabled=e,this.styleDisabled(e)},i.prototype.setHelperTextContent=function(e){this.helperText&&this.helperText.setContent(e)},i.prototype.setLeadingIconAriaLabel=function(e){this.leadingIcon&&this.leadingIcon.setAriaLabel(e)},i.prototype.setLeadingIconContent=function(e){this.leadingIcon&&this.leadingIcon.setContent(e)},i.prototype.setTrailingIconAriaLabel=function(e){this.trailingIcon&&this.trailingIcon.setAriaLabel(e)},i.prototype.setTrailingIconContent=function(e){this.trailingIcon&&this.trailingIcon.setContent(e)},i.prototype.setcharacterCounter=function(e){if(this.characterCounter){var t=this.getNativeInput().maxLength;if(-1===t)throw new Error("MDCTextFieldFoundation: Expected maxlength html property on text input or textarea.");this.characterCounter.setCounterValue(e,t)}},i.prototype.isBadInput=function(){return this.getNativeInput().validity.badInput||!1},i.prototype.isNativeInputValid=function(){return this.getNativeInput().validity.valid},i.prototype.styleValidity=function(e){var t=i.cssClasses.INVALID;if(e?this.adapter.removeClass(t):this.adapter.addClass(t),this.helperText){if(this.helperText.setValidity(e),!this.helperText.isValidation())return;var n=this.helperText.isVisible(),r=this.helperText.getId();n&&r?this.adapter.setInputAttr(Go.ARIA_DESCRIBEDBY,r):this.adapter.removeInputAttr(Go.ARIA_DESCRIBEDBY)}},i.prototype.styleFocused=function(e){var t=i.cssClasses.FOCUSED;e?this.adapter.addClass(t):this.adapter.removeClass(t)},i.prototype.styleDisabled=function(e){var t=i.cssClasses,n=t.DISABLED,r=t.INVALID;e?(this.adapter.addClass(n),this.adapter.removeClass(r)):this.adapter.removeClass(n),this.leadingIcon&&this.leadingIcon.setDisabled(e),this.trailingIcon&&this.trailingIcon.setDisabled(e)},i.prototype.styleFloating=function(e){var t=i.cssClasses.LABEL_FLOATING;e?this.adapter.addClass(t):this.adapter.removeClass(t)},i.prototype.getNativeInput=function(){return(this.adapter?this.adapter.getNativeInput():null)||{disabled:!1,maxLength:-1,required:!1,type:"input",validity:{badInput:!1,valid:!0},value:""}},i}(hr),Jo=Zo; +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const es={},ts=Xn(class extends Kn{constructor(e){if(super(e),e.type!==qn&&e.type!==Wn&&e.type!==Yn)throw Error("The `live` directive is not allowed on child or event bindings");if(!(e=>void 0===e.strings)(e))throw Error("`live` bindings can only contain a single expression")}render(e){return e}update(e,[t]){if(t===G||t===W)return t;const i=e.element,n=e.name;if(e.type===qn){if(t===i[n])return G}else if(e.type===Yn){if(!!t===i.hasAttribute(n))return G}else if(e.type===Wn&&i.getAttribute(n)===t+"")return G;return((e,t=es)=>{e._$AH=t; +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */})(e),t}}),is=["touchstart","touchmove","scroll","mousewheel"],ns=(e={})=>{const t={};for(const i in e)t[i]=e[i];return Object.assign({badInput:!1,customError:!1,patternMismatch:!1,rangeOverflow:!1,rangeUnderflow:!1,stepMismatch:!1,tooLong:!1,tooShort:!1,typeMismatch:!1,valid:!0,valueMissing:!1},t)};class rs extends Sr{constructor(){super(...arguments),this.mdcFoundationClass=Jo,this.value="",this.type="text",this.placeholder="",this.label="",this.icon="",this.iconTrailing="",this.disabled=!1,this.required=!1,this.minLength=-1,this.maxLength=-1,this.outlined=!1,this.helper="",this.validateOnInitialRender=!1,this.validationMessage="",this.autoValidate=!1,this.pattern="",this.min="",this.max="",this.step=null,this.size=null,this.helperPersistent=!1,this.charCounter=!1,this.endAligned=!1,this.prefix="",this.suffix="",this.name="",this.readOnly=!1,this.autocapitalize="",this.outlineOpen=!1,this.outlineWidth=0,this.isUiValid=!0,this.focused=!1,this._validity=ns(),this.validityTransform=null}get validity(){return this._checkValidity(this.value),this._validity}get willValidate(){return this.formElement.willValidate}get selectionStart(){return this.formElement.selectionStart}get selectionEnd(){return this.formElement.selectionEnd}focus(){const e=new CustomEvent("focus");this.formElement.dispatchEvent(e),this.formElement.focus()}blur(){const e=new CustomEvent("blur");this.formElement.dispatchEvent(e),this.formElement.blur()}select(){this.formElement.select()}setSelectionRange(e,t,i){this.formElement.setSelectionRange(e,t,i)}update(e){e.has("autoValidate")&&this.mdcFoundation&&this.mdcFoundation.setValidateOnValueChange(this.autoValidate),e.has("value")&&"string"!=typeof this.value&&(this.value=`${this.value}`),super.update(e)}setFormData(e){this.name&&e.append(this.name,this.value)}render(){const e=this.charCounter&&-1!==this.maxLength,t=!!this.helper||!!this.validationMessage||e,i={"mdc-text-field--disabled":this.disabled,"mdc-text-field--no-label":!this.label,"mdc-text-field--filled":!this.outlined,"mdc-text-field--outlined":this.outlined,"mdc-text-field--with-leading-icon":this.icon,"mdc-text-field--with-trailing-icon":this.iconTrailing,"mdc-text-field--end-aligned":this.endAligned};return U` + + ${this.renderHelperText(t,e)} + `}updated(e){e.has("value")&&void 0!==e.get("value")&&(this.mdcFoundation.setValue(this.value),this.autoValidate&&this.reportValidity())}renderRipple(){return this.outlined?"":U` + + `}renderOutline(){return this.outlined?U` + + ${this.renderLabel()} + `:""}renderLabel(){return this.label?U` + ${this.label} + `:""}renderLeadingIcon(){return this.icon?this.renderIcon(this.icon):""}renderTrailingIcon(){return this.iconTrailing?this.renderIcon(this.iconTrailing,!0):""}renderIcon(e,t=!1){return U`${e}`}renderPrefix(){return this.prefix?this.renderAffix(this.prefix):""}renderSuffix(){return this.suffix?this.renderAffix(this.suffix,!0):""}renderAffix(e,t=!1){return U` + ${e}`}renderInput(e){const t=-1===this.minLength?void 0:this.minLength,i=-1===this.maxLength?void 0:this.maxLength,n=this.autocapitalize?this.autocapitalize:void 0,r=this.validationMessage&&!this.isUiValid,a=this.label?"label":void 0,o=e?"helper-text":void 0,s=this.focused||this.helperPersistent||r?"helper-text":void 0;return U` + `}renderLineRipple(){return this.outlined?"":U` + + `}renderHelperText(e,t){const i=this.validationMessage&&!this.isUiValid,n={"mdc-text-field-helper-text--persistent":this.helperPersistent,"mdc-text-field-helper-text--validation-msg":i},r=this.focused||this.helperPersistent||i?void 0:"true",a=i?this.validationMessage:this.helper;return e?U` +
+
${a}
+ ${this.renderCharCounter(t)} +
`:""}renderCharCounter(e){const t=Math.min(this.value.length,this.maxLength);return e?U` + ${t} / ${this.maxLength}`:""}onInputFocus(){this.focused=!0}onInputBlur(){this.focused=!1,this.reportValidity()}checkValidity(){const e=this._checkValidity(this.value);if(!e){const e=new Event("invalid",{bubbles:!1,cancelable:!0});this.dispatchEvent(e)}return e}reportValidity(){const e=this.checkValidity();return this.mdcFoundation.setValid(e),this.isUiValid=e,e}_checkValidity(e){const t=this.formElement.validity;let i=ns(t);if(this.validityTransform){const t=this.validityTransform(e,i);i=Object.assign(Object.assign({},i),t),this.mdcFoundation.setUseNativeValidation(!1)}else this.mdcFoundation.setUseNativeValidation(!0);return this._validity=i,this._validity.valid}setCustomValidity(e){this.validationMessage=e,this.formElement.setCustomValidity(e)}handleInputChange(){this.value=this.formElement.value}createAdapter(){return Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({},this.getRootAdapterMethods()),this.getInputAdapterMethods()),this.getLabelAdapterMethods()),this.getLineRippleAdapterMethods()),this.getOutlineAdapterMethods())}getRootAdapterMethods(){return Object.assign({registerTextFieldInteractionHandler:(e,t)=>this.addEventListener(e,t),deregisterTextFieldInteractionHandler:(e,t)=>this.removeEventListener(e,t),registerValidationAttributeChangeHandler:e=>{const t=new MutationObserver((t=>{e((e=>e.map((e=>e.attributeName)).filter((e=>e)))(t))}));return t.observe(this.formElement,{attributes:!0}),t},deregisterValidationAttributeChangeHandler:e=>e.disconnect()},br(this.mdcRoot))}getInputAdapterMethods(){return{getNativeInput:()=>this.formElement,setInputAttr:()=>{},removeInputAttr:()=>{},isFocused:()=>!!this.shadowRoot&&this.shadowRoot.activeElement===this.formElement,registerInputInteractionHandler:(e,t)=>this.formElement.addEventListener(e,t,{passive:e in is}),deregisterInputInteractionHandler:(e,t)=>this.formElement.removeEventListener(e,t)}}getLabelAdapterMethods(){return{floatLabel:e=>this.labelElement&&this.labelElement.floatingLabelFoundation.float(e),getLabelWidth:()=>this.labelElement?this.labelElement.floatingLabelFoundation.getWidth():0,hasLabel:()=>Boolean(this.labelElement),shakeLabel:e=>this.labelElement&&this.labelElement.floatingLabelFoundation.shake(e),setLabelRequired:e=>{this.labelElement&&this.labelElement.floatingLabelFoundation.setRequired(e)}}}getLineRippleAdapterMethods(){return{activateLineRipple:()=>{this.lineRippleElement&&this.lineRippleElement.lineRippleFoundation.activate()},deactivateLineRipple:()=>{this.lineRippleElement&&this.lineRippleElement.lineRippleFoundation.deactivate()},setLineRippleTransformOrigin:e=>{this.lineRippleElement&&this.lineRippleElement.lineRippleFoundation.setRippleCenter(e)}}}async getUpdateComplete(){var e;const t=await super.getUpdateComplete();return await(null===(e=this.outlineElement)||void 0===e?void 0:e.updateComplete),t}firstUpdated(){var e;super.firstUpdated(),this.mdcFoundation.setValidateOnValueChange(this.autoValidate),this.validateOnInitialRender&&this.reportValidity(),null===(e=this.outlineElement)||void 0===e||e.updateComplete.then((()=>{var e;this.outlineWidth=(null===(e=this.labelElement)||void 0===e?void 0:e.floatingLabelFoundation.getWidth())||0}))}getOutlineAdapterMethods(){return{closeOutline:()=>this.outlineElement&&(this.outlineOpen=!1),hasOutline:()=>Boolean(this.outlineElement),notchOutline:e=>{this.outlineElement&&!this.outlineOpen&&(this.outlineWidth=e,this.outlineOpen=!0)}}}async layout(){await this.updateComplete;const e=this.labelElement;if(!e)return void(this.outlineOpen=!1);const t=!!this.label&&!!this.value;if(e.floatingLabelFoundation.float(t),!this.outlined)return;this.outlineOpen=t,await this.updateComplete;const i=e.floatingLabelFoundation.getWidth();this.outlineOpen&&(this.outlineWidth=i,await this.updateComplete)}}a([ve(".mdc-text-field")],rs.prototype,"mdcRoot",void 0),a([ve("input")],rs.prototype,"formElement",void 0),a([ve(".mdc-floating-label")],rs.prototype,"labelElement",void 0),a([ve(".mdc-line-ripple")],rs.prototype,"lineRippleElement",void 0),a([ve("mwc-notched-outline")],rs.prototype,"outlineElement",void 0),a([ve(".mdc-notched-outline__notch")],rs.prototype,"notchElement",void 0),a([he({type:String})],rs.prototype,"value",void 0),a([he({type:String})],rs.prototype,"type",void 0),a([he({type:String})],rs.prototype,"placeholder",void 0),a([he({type:String}),Ir((function(e,t){void 0!==t&&this.label!==t&&this.layout()}))],rs.prototype,"label",void 0),a([he({type:String})],rs.prototype,"icon",void 0),a([he({type:String})],rs.prototype,"iconTrailing",void 0),a([he({type:Boolean,reflect:!0})],rs.prototype,"disabled",void 0),a([he({type:Boolean})],rs.prototype,"required",void 0),a([he({type:Number})],rs.prototype,"minLength",void 0),a([he({type:Number})],rs.prototype,"maxLength",void 0),a([he({type:Boolean,reflect:!0}),Ir((function(e,t){void 0!==t&&this.outlined!==t&&this.layout()}))],rs.prototype,"outlined",void 0),a([he({type:String})],rs.prototype,"helper",void 0),a([he({type:Boolean})],rs.prototype,"validateOnInitialRender",void 0),a([he({type:String})],rs.prototype,"validationMessage",void 0),a([he({type:Boolean})],rs.prototype,"autoValidate",void 0),a([he({type:String})],rs.prototype,"pattern",void 0),a([he({type:String})],rs.prototype,"min",void 0),a([he({type:String})],rs.prototype,"max",void 0),a([he({type:String})],rs.prototype,"step",void 0),a([he({type:Number})],rs.prototype,"size",void 0),a([he({type:Boolean})],rs.prototype,"helperPersistent",void 0),a([he({type:Boolean})],rs.prototype,"charCounter",void 0),a([he({type:Boolean})],rs.prototype,"endAligned",void 0),a([he({type:String})],rs.prototype,"prefix",void 0),a([he({type:String})],rs.prototype,"suffix",void 0),a([he({type:String})],rs.prototype,"name",void 0),a([he({type:String})],rs.prototype,"inputMode",void 0),a([he({type:Boolean})],rs.prototype,"readOnly",void 0),a([he({type:String})],rs.prototype,"autocapitalize",void 0),a([ue()],rs.prototype,"outlineOpen",void 0),a([ue()],rs.prototype,"outlineWidth",void 0),a([ue()],rs.prototype,"isUiValid",void 0),a([ue()],rs.prototype,"focused",void 0),a([ge({passive:!0})],rs.prototype,"handleInputChange",null); +/** + * @license + * Copyright 2021 Google LLC + * SPDX-LIcense-Identifier: Apache-2.0 + */ +const as=g`.mdc-floating-label{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;font-family:Roboto, sans-serif;font-family:var(--mdc-typography-subtitle1-font-family, var(--mdc-typography-font-family, Roboto, sans-serif));font-size:1rem;font-size:var(--mdc-typography-subtitle1-font-size, 1rem);font-weight:400;font-weight:var(--mdc-typography-subtitle1-font-weight, 400);letter-spacing:0.009375em;letter-spacing:var(--mdc-typography-subtitle1-letter-spacing, 0.009375em);text-decoration:inherit;text-decoration:var(--mdc-typography-subtitle1-text-decoration, inherit);text-transform:inherit;text-transform:var(--mdc-typography-subtitle1-text-transform, inherit);position:absolute;left:0;-webkit-transform-origin:left top;transform-origin:left top;line-height:1.15rem;text-align:left;text-overflow:ellipsis;white-space:nowrap;cursor:text;overflow:hidden;will-change:transform;transition:transform 150ms cubic-bezier(0.4, 0, 0.2, 1),color 150ms cubic-bezier(0.4, 0, 0.2, 1)}[dir=rtl] .mdc-floating-label,.mdc-floating-label[dir=rtl]{right:0;left:auto;-webkit-transform-origin:right top;transform-origin:right top;text-align:right}.mdc-floating-label--float-above{cursor:auto}.mdc-floating-label--required::after{margin-left:1px;margin-right:0px;content:"*"}[dir=rtl] .mdc-floating-label--required::after,.mdc-floating-label--required[dir=rtl]::after{margin-left:0;margin-right:1px}.mdc-floating-label--float-above{transform:translateY(-106%) scale(0.75)}.mdc-floating-label--shake{animation:mdc-floating-label-shake-float-above-standard 250ms 1}@keyframes mdc-floating-label-shake-float-above-standard{0%{transform:translateX(calc(0 - 0%)) translateY(-106%) scale(0.75)}33%{animation-timing-function:cubic-bezier(0.5, 0, 0.701732, 0.495819);transform:translateX(calc(4% - 0%)) translateY(-106%) scale(0.75)}66%{animation-timing-function:cubic-bezier(0.302435, 0.381352, 0.55, 0.956352);transform:translateX(calc(-4% - 0%)) translateY(-106%) scale(0.75)}100%{transform:translateX(calc(0 - 0%)) translateY(-106%) scale(0.75)}}.mdc-line-ripple::before,.mdc-line-ripple::after{position:absolute;bottom:0;left:0;width:100%;border-bottom-style:solid;content:""}.mdc-line-ripple::before{border-bottom-width:1px;z-index:1}.mdc-line-ripple::after{transform:scaleX(0);border-bottom-width:2px;opacity:0;z-index:2}.mdc-line-ripple::after{transition:transform 180ms cubic-bezier(0.4, 0, 0.2, 1),opacity 180ms cubic-bezier(0.4, 0, 0.2, 1)}.mdc-line-ripple--active::after{transform:scaleX(1);opacity:1}.mdc-line-ripple--deactivating::after{opacity:0}.mdc-notched-outline{display:flex;position:absolute;top:0;right:0;left:0;box-sizing:border-box;width:100%;max-width:100%;height:100%;text-align:left;pointer-events:none}[dir=rtl] .mdc-notched-outline,.mdc-notched-outline[dir=rtl]{text-align:right}.mdc-notched-outline__leading,.mdc-notched-outline__notch,.mdc-notched-outline__trailing{box-sizing:border-box;height:100%;border-top:1px solid;border-bottom:1px solid;pointer-events:none}.mdc-notched-outline__leading{border-left:1px solid;border-right:none;width:12px}[dir=rtl] .mdc-notched-outline__leading,.mdc-notched-outline__leading[dir=rtl]{border-left:none;border-right:1px solid}.mdc-notched-outline__trailing{border-left:none;border-right:1px solid;flex-grow:1}[dir=rtl] .mdc-notched-outline__trailing,.mdc-notched-outline__trailing[dir=rtl]{border-left:1px solid;border-right:none}.mdc-notched-outline__notch{flex:0 0 auto;width:auto;max-width:calc(100% - 12px * 2)}.mdc-notched-outline .mdc-floating-label{display:inline-block;position:relative;max-width:100%}.mdc-notched-outline .mdc-floating-label--float-above{text-overflow:clip}.mdc-notched-outline--upgraded .mdc-floating-label--float-above{max-width:calc(100% / 0.75)}.mdc-notched-outline--notched .mdc-notched-outline__notch{padding-left:0;padding-right:8px;border-top:none}[dir=rtl] .mdc-notched-outline--notched .mdc-notched-outline__notch,.mdc-notched-outline--notched .mdc-notched-outline__notch[dir=rtl]{padding-left:8px;padding-right:0}.mdc-notched-outline--no-label .mdc-notched-outline__notch{display:none}@keyframes mdc-ripple-fg-radius-in{from{animation-timing-function:cubic-bezier(0.4, 0, 0.2, 1);transform:translate(var(--mdc-ripple-fg-translate-start, 0)) scale(1)}to{transform:translate(var(--mdc-ripple-fg-translate-end, 0)) scale(var(--mdc-ripple-fg-scale, 1))}}@keyframes mdc-ripple-fg-opacity-in{from{animation-timing-function:linear;opacity:0}to{opacity:var(--mdc-ripple-fg-opacity, 0)}}@keyframes mdc-ripple-fg-opacity-out{from{animation-timing-function:linear;opacity:var(--mdc-ripple-fg-opacity, 0)}to{opacity:0}}.mdc-text-field--filled{--mdc-ripple-fg-size: 0;--mdc-ripple-left: 0;--mdc-ripple-top: 0;--mdc-ripple-fg-scale: 1;--mdc-ripple-fg-translate-end: 0;--mdc-ripple-fg-translate-start: 0;-webkit-tap-highlight-color:rgba(0,0,0,0);will-change:transform,opacity}.mdc-text-field--filled .mdc-text-field__ripple::before,.mdc-text-field--filled .mdc-text-field__ripple::after{position:absolute;border-radius:50%;opacity:0;pointer-events:none;content:""}.mdc-text-field--filled .mdc-text-field__ripple::before{transition:opacity 15ms linear,background-color 15ms linear;z-index:1;z-index:var(--mdc-ripple-z-index, 1)}.mdc-text-field--filled .mdc-text-field__ripple::after{z-index:0;z-index:var(--mdc-ripple-z-index, 0)}.mdc-text-field--filled.mdc-ripple-upgraded .mdc-text-field__ripple::before{transform:scale(var(--mdc-ripple-fg-scale, 1))}.mdc-text-field--filled.mdc-ripple-upgraded .mdc-text-field__ripple::after{top:0;left:0;transform:scale(0);transform-origin:center center}.mdc-text-field--filled.mdc-ripple-upgraded--unbounded .mdc-text-field__ripple::after{top:var(--mdc-ripple-top, 0);left:var(--mdc-ripple-left, 0)}.mdc-text-field--filled.mdc-ripple-upgraded--foreground-activation .mdc-text-field__ripple::after{animation:mdc-ripple-fg-radius-in 225ms forwards,mdc-ripple-fg-opacity-in 75ms forwards}.mdc-text-field--filled.mdc-ripple-upgraded--foreground-deactivation .mdc-text-field__ripple::after{animation:mdc-ripple-fg-opacity-out 150ms;transform:translate(var(--mdc-ripple-fg-translate-end, 0)) scale(var(--mdc-ripple-fg-scale, 1))}.mdc-text-field--filled .mdc-text-field__ripple::before,.mdc-text-field--filled .mdc-text-field__ripple::after{top:calc(50% - 100%);left:calc(50% - 100%);width:200%;height:200%}.mdc-text-field--filled.mdc-ripple-upgraded .mdc-text-field__ripple::after{width:var(--mdc-ripple-fg-size, 100%);height:var(--mdc-ripple-fg-size, 100%)}.mdc-text-field__ripple{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none}.mdc-text-field{border-top-left-radius:4px;border-top-left-radius:var(--mdc-shape-small, 4px);border-top-right-radius:4px;border-top-right-radius:var(--mdc-shape-small, 4px);border-bottom-right-radius:0;border-bottom-left-radius:0;display:inline-flex;align-items:baseline;padding:0 16px;position:relative;box-sizing:border-box;overflow:hidden;will-change:opacity,transform,color}.mdc-text-field:not(.mdc-text-field--disabled) .mdc-floating-label{color:rgba(0, 0, 0, 0.6)}.mdc-text-field:not(.mdc-text-field--disabled) .mdc-text-field__input{color:rgba(0, 0, 0, 0.87)}@media all{.mdc-text-field:not(.mdc-text-field--disabled) .mdc-text-field__input::placeholder{color:rgba(0, 0, 0, 0.54)}}@media all{.mdc-text-field:not(.mdc-text-field--disabled) .mdc-text-field__input:-ms-input-placeholder{color:rgba(0, 0, 0, 0.54)}}.mdc-text-field .mdc-text-field__input{caret-color:#6200ee;caret-color:var(--mdc-theme-primary, #6200ee)}.mdc-text-field:not(.mdc-text-field--disabled)+.mdc-text-field-helper-line .mdc-text-field-helper-text{color:rgba(0, 0, 0, 0.6)}.mdc-text-field:not(.mdc-text-field--disabled) .mdc-text-field-character-counter,.mdc-text-field:not(.mdc-text-field--disabled)+.mdc-text-field-helper-line .mdc-text-field-character-counter{color:rgba(0, 0, 0, 0.6)}.mdc-text-field:not(.mdc-text-field--disabled) .mdc-text-field__icon--leading{color:rgba(0, 0, 0, 0.54)}.mdc-text-field:not(.mdc-text-field--disabled) .mdc-text-field__icon--trailing{color:rgba(0, 0, 0, 0.54)}.mdc-text-field:not(.mdc-text-field--disabled) .mdc-text-field__affix--prefix{color:rgba(0, 0, 0, 0.6)}.mdc-text-field:not(.mdc-text-field--disabled) .mdc-text-field__affix--suffix{color:rgba(0, 0, 0, 0.6)}.mdc-text-field .mdc-floating-label{top:50%;transform:translateY(-50%);pointer-events:none}.mdc-text-field__input{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;font-family:Roboto, sans-serif;font-family:var(--mdc-typography-subtitle1-font-family, var(--mdc-typography-font-family, Roboto, sans-serif));font-size:1rem;font-size:var(--mdc-typography-subtitle1-font-size, 1rem);font-weight:400;font-weight:var(--mdc-typography-subtitle1-font-weight, 400);letter-spacing:0.009375em;letter-spacing:var(--mdc-typography-subtitle1-letter-spacing, 0.009375em);text-decoration:inherit;text-decoration:var(--mdc-typography-subtitle1-text-decoration, inherit);text-transform:inherit;text-transform:var(--mdc-typography-subtitle1-text-transform, inherit);height:28px;transition:opacity 150ms 0ms cubic-bezier(0.4, 0, 0.2, 1);width:100%;min-width:0;border:none;border-radius:0;background:none;appearance:none;padding:0}.mdc-text-field__input::-ms-clear{display:none}.mdc-text-field__input::-webkit-calendar-picker-indicator{display:none}.mdc-text-field__input:focus{outline:none}.mdc-text-field__input:invalid{box-shadow:none}@media all{.mdc-text-field__input::placeholder{transition:opacity 67ms 0ms cubic-bezier(0.4, 0, 0.2, 1);opacity:0}}@media all{.mdc-text-field__input:-ms-input-placeholder{transition:opacity 67ms 0ms cubic-bezier(0.4, 0, 0.2, 1);opacity:0}}@media all{.mdc-text-field--no-label .mdc-text-field__input::placeholder,.mdc-text-field--focused .mdc-text-field__input::placeholder{transition-delay:40ms;transition-duration:110ms;opacity:1}}@media all{.mdc-text-field--no-label .mdc-text-field__input:-ms-input-placeholder,.mdc-text-field--focused .mdc-text-field__input:-ms-input-placeholder{transition-delay:40ms;transition-duration:110ms;opacity:1}}.mdc-text-field__affix{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;font-family:Roboto, sans-serif;font-family:var(--mdc-typography-subtitle1-font-family, var(--mdc-typography-font-family, Roboto, sans-serif));font-size:1rem;font-size:var(--mdc-typography-subtitle1-font-size, 1rem);font-weight:400;font-weight:var(--mdc-typography-subtitle1-font-weight, 400);letter-spacing:0.009375em;letter-spacing:var(--mdc-typography-subtitle1-letter-spacing, 0.009375em);text-decoration:inherit;text-decoration:var(--mdc-typography-subtitle1-text-decoration, inherit);text-transform:inherit;text-transform:var(--mdc-typography-subtitle1-text-transform, inherit);height:28px;transition:opacity 150ms 0ms cubic-bezier(0.4, 0, 0.2, 1);opacity:0;white-space:nowrap}.mdc-text-field--label-floating .mdc-text-field__affix,.mdc-text-field--no-label .mdc-text-field__affix{opacity:1}@supports(-webkit-hyphens: none){.mdc-text-field--outlined .mdc-text-field__affix{align-items:center;align-self:center;display:inline-flex;height:100%}}.mdc-text-field__affix--prefix{padding-left:0;padding-right:2px}[dir=rtl] .mdc-text-field__affix--prefix,.mdc-text-field__affix--prefix[dir=rtl]{padding-left:2px;padding-right:0}.mdc-text-field--end-aligned .mdc-text-field__affix--prefix{padding-left:0;padding-right:12px}[dir=rtl] .mdc-text-field--end-aligned .mdc-text-field__affix--prefix,.mdc-text-field--end-aligned .mdc-text-field__affix--prefix[dir=rtl]{padding-left:12px;padding-right:0}.mdc-text-field__affix--suffix{padding-left:12px;padding-right:0}[dir=rtl] .mdc-text-field__affix--suffix,.mdc-text-field__affix--suffix[dir=rtl]{padding-left:0;padding-right:12px}.mdc-text-field--end-aligned .mdc-text-field__affix--suffix{padding-left:2px;padding-right:0}[dir=rtl] .mdc-text-field--end-aligned .mdc-text-field__affix--suffix,.mdc-text-field--end-aligned .mdc-text-field__affix--suffix[dir=rtl]{padding-left:0;padding-right:2px}.mdc-text-field--filled{height:56px}.mdc-text-field--filled .mdc-text-field__ripple::before,.mdc-text-field--filled .mdc-text-field__ripple::after{background-color:rgba(0, 0, 0, 0.87);background-color:var(--mdc-ripple-color, rgba(0, 0, 0, 0.87))}.mdc-text-field--filled:hover .mdc-text-field__ripple::before,.mdc-text-field--filled.mdc-ripple-surface--hover .mdc-text-field__ripple::before{opacity:0.04;opacity:var(--mdc-ripple-hover-opacity, 0.04)}.mdc-text-field--filled.mdc-ripple-upgraded--background-focused .mdc-text-field__ripple::before,.mdc-text-field--filled:not(.mdc-ripple-upgraded):focus .mdc-text-field__ripple::before{transition-duration:75ms;opacity:0.12;opacity:var(--mdc-ripple-focus-opacity, 0.12)}.mdc-text-field--filled::before{display:inline-block;width:0;height:40px;content:"";vertical-align:0}.mdc-text-field--filled:not(.mdc-text-field--disabled){background-color:whitesmoke}.mdc-text-field--filled:not(.mdc-text-field--disabled) .mdc-line-ripple::before{border-bottom-color:rgba(0, 0, 0, 0.42)}.mdc-text-field--filled:not(.mdc-text-field--disabled):hover .mdc-line-ripple::before{border-bottom-color:rgba(0, 0, 0, 0.87)}.mdc-text-field--filled .mdc-line-ripple::after{border-bottom-color:#6200ee;border-bottom-color:var(--mdc-theme-primary, #6200ee)}.mdc-text-field--filled .mdc-floating-label{left:16px;right:initial}[dir=rtl] .mdc-text-field--filled .mdc-floating-label,.mdc-text-field--filled .mdc-floating-label[dir=rtl]{left:initial;right:16px}.mdc-text-field--filled .mdc-floating-label--float-above{transform:translateY(-106%) scale(0.75)}.mdc-text-field--filled.mdc-text-field--no-label .mdc-text-field__input{height:100%}.mdc-text-field--filled.mdc-text-field--no-label .mdc-floating-label{display:none}.mdc-text-field--filled.mdc-text-field--no-label::before{display:none}@supports(-webkit-hyphens: none){.mdc-text-field--filled.mdc-text-field--no-label .mdc-text-field__affix{align-items:center;align-self:center;display:inline-flex;height:100%}}.mdc-text-field--outlined{height:56px;overflow:visible}.mdc-text-field--outlined .mdc-floating-label--float-above{transform:translateY(-37.25px) scale(1)}.mdc-text-field--outlined .mdc-floating-label--float-above{font-size:.75rem}.mdc-text-field--outlined.mdc-notched-outline--upgraded .mdc-floating-label--float-above,.mdc-text-field--outlined .mdc-notched-outline--upgraded .mdc-floating-label--float-above{transform:translateY(-34.75px) scale(0.75)}.mdc-text-field--outlined.mdc-notched-outline--upgraded .mdc-floating-label--float-above,.mdc-text-field--outlined .mdc-notched-outline--upgraded .mdc-floating-label--float-above{font-size:1rem}.mdc-text-field--outlined .mdc-floating-label--shake{animation:mdc-floating-label-shake-float-above-text-field-outlined 250ms 1}@keyframes mdc-floating-label-shake-float-above-text-field-outlined{0%{transform:translateX(calc(0 - 0%)) translateY(-34.75px) scale(0.75)}33%{animation-timing-function:cubic-bezier(0.5, 0, 0.701732, 0.495819);transform:translateX(calc(4% - 0%)) translateY(-34.75px) scale(0.75)}66%{animation-timing-function:cubic-bezier(0.302435, 0.381352, 0.55, 0.956352);transform:translateX(calc(-4% - 0%)) translateY(-34.75px) scale(0.75)}100%{transform:translateX(calc(0 - 0%)) translateY(-34.75px) scale(0.75)}}.mdc-text-field--outlined .mdc-text-field__input{height:100%}.mdc-text-field--outlined:not(.mdc-text-field--disabled) .mdc-notched-outline__leading,.mdc-text-field--outlined:not(.mdc-text-field--disabled) .mdc-notched-outline__notch,.mdc-text-field--outlined:not(.mdc-text-field--disabled) .mdc-notched-outline__trailing{border-color:rgba(0, 0, 0, 0.38)}.mdc-text-field--outlined:not(.mdc-text-field--disabled):not(.mdc-text-field--focused):hover .mdc-notched-outline .mdc-notched-outline__leading,.mdc-text-field--outlined:not(.mdc-text-field--disabled):not(.mdc-text-field--focused):hover .mdc-notched-outline .mdc-notched-outline__notch,.mdc-text-field--outlined:not(.mdc-text-field--disabled):not(.mdc-text-field--focused):hover .mdc-notched-outline .mdc-notched-outline__trailing{border-color:rgba(0, 0, 0, 0.87)}.mdc-text-field--outlined:not(.mdc-text-field--disabled).mdc-text-field--focused .mdc-notched-outline__leading,.mdc-text-field--outlined:not(.mdc-text-field--disabled).mdc-text-field--focused .mdc-notched-outline__notch,.mdc-text-field--outlined:not(.mdc-text-field--disabled).mdc-text-field--focused .mdc-notched-outline__trailing{border-color:#6200ee;border-color:var(--mdc-theme-primary, #6200ee)}.mdc-text-field--outlined .mdc-notched-outline .mdc-notched-outline__leading{border-top-left-radius:4px;border-top-left-radius:var(--mdc-shape-small, 4px);border-top-right-radius:0;border-bottom-right-radius:0;border-bottom-left-radius:4px;border-bottom-left-radius:var(--mdc-shape-small, 4px)}[dir=rtl] .mdc-text-field--outlined .mdc-notched-outline .mdc-notched-outline__leading,.mdc-text-field--outlined .mdc-notched-outline .mdc-notched-outline__leading[dir=rtl]{border-top-left-radius:0;border-top-right-radius:4px;border-top-right-radius:var(--mdc-shape-small, 4px);border-bottom-right-radius:4px;border-bottom-right-radius:var(--mdc-shape-small, 4px);border-bottom-left-radius:0}@supports(top: max(0%)){.mdc-text-field--outlined .mdc-notched-outline .mdc-notched-outline__leading{width:max(12px, var(--mdc-shape-small, 4px))}}@supports(top: max(0%)){.mdc-text-field--outlined .mdc-notched-outline .mdc-notched-outline__notch{max-width:calc(100% - max(12px, var(--mdc-shape-small, 4px)) * 2)}}.mdc-text-field--outlined .mdc-notched-outline .mdc-notched-outline__trailing{border-top-left-radius:0;border-top-right-radius:4px;border-top-right-radius:var(--mdc-shape-small, 4px);border-bottom-right-radius:4px;border-bottom-right-radius:var(--mdc-shape-small, 4px);border-bottom-left-radius:0}[dir=rtl] .mdc-text-field--outlined .mdc-notched-outline .mdc-notched-outline__trailing,.mdc-text-field--outlined .mdc-notched-outline .mdc-notched-outline__trailing[dir=rtl]{border-top-left-radius:4px;border-top-left-radius:var(--mdc-shape-small, 4px);border-top-right-radius:0;border-bottom-right-radius:0;border-bottom-left-radius:4px;border-bottom-left-radius:var(--mdc-shape-small, 4px)}@supports(top: max(0%)){.mdc-text-field--outlined{padding-left:max(16px, calc(var(--mdc-shape-small, 4px) + 4px))}}@supports(top: max(0%)){.mdc-text-field--outlined{padding-right:max(16px, var(--mdc-shape-small, 4px))}}@supports(top: max(0%)){.mdc-text-field--outlined+.mdc-text-field-helper-line{padding-left:max(16px, calc(var(--mdc-shape-small, 4px) + 4px))}}@supports(top: max(0%)){.mdc-text-field--outlined+.mdc-text-field-helper-line{padding-right:max(16px, var(--mdc-shape-small, 4px))}}.mdc-text-field--outlined.mdc-text-field--with-leading-icon{padding-left:0}@supports(top: max(0%)){.mdc-text-field--outlined.mdc-text-field--with-leading-icon{padding-right:max(16px, var(--mdc-shape-small, 4px))}}[dir=rtl] .mdc-text-field--outlined.mdc-text-field--with-leading-icon,.mdc-text-field--outlined.mdc-text-field--with-leading-icon[dir=rtl]{padding-right:0}@supports(top: max(0%)){[dir=rtl] .mdc-text-field--outlined.mdc-text-field--with-leading-icon,.mdc-text-field--outlined.mdc-text-field--with-leading-icon[dir=rtl]{padding-left:max(16px, var(--mdc-shape-small, 4px))}}.mdc-text-field--outlined.mdc-text-field--with-trailing-icon{padding-right:0}@supports(top: max(0%)){.mdc-text-field--outlined.mdc-text-field--with-trailing-icon{padding-left:max(16px, calc(var(--mdc-shape-small, 4px) + 4px))}}[dir=rtl] .mdc-text-field--outlined.mdc-text-field--with-trailing-icon,.mdc-text-field--outlined.mdc-text-field--with-trailing-icon[dir=rtl]{padding-left:0}@supports(top: max(0%)){[dir=rtl] .mdc-text-field--outlined.mdc-text-field--with-trailing-icon,.mdc-text-field--outlined.mdc-text-field--with-trailing-icon[dir=rtl]{padding-right:max(16px, calc(var(--mdc-shape-small, 4px) + 4px))}}.mdc-text-field--outlined.mdc-text-field--with-leading-icon.mdc-text-field--with-trailing-icon{padding-left:0;padding-right:0}.mdc-text-field--outlined .mdc-notched-outline--notched .mdc-notched-outline__notch{padding-top:1px}.mdc-text-field--outlined .mdc-text-field__ripple::before,.mdc-text-field--outlined .mdc-text-field__ripple::after{content:none}.mdc-text-field--outlined .mdc-floating-label{left:4px;right:initial}[dir=rtl] .mdc-text-field--outlined .mdc-floating-label,.mdc-text-field--outlined .mdc-floating-label[dir=rtl]{left:initial;right:4px}.mdc-text-field--outlined .mdc-text-field__input{display:flex;border:none !important;background-color:transparent}.mdc-text-field--outlined .mdc-notched-outline{z-index:1}.mdc-text-field--textarea{flex-direction:column;align-items:center;width:auto;height:auto;padding:0;transition:none}.mdc-text-field--textarea .mdc-floating-label{top:19px}.mdc-text-field--textarea .mdc-floating-label:not(.mdc-floating-label--float-above){transform:none}.mdc-text-field--textarea .mdc-text-field__input{flex-grow:1;height:auto;min-height:1.5rem;overflow-x:hidden;overflow-y:auto;box-sizing:border-box;resize:none;padding:0 16px;line-height:1.5rem}.mdc-text-field--textarea.mdc-text-field--filled::before{display:none}.mdc-text-field--textarea.mdc-text-field--filled .mdc-floating-label--float-above{transform:translateY(-10.25px) scale(0.75)}.mdc-text-field--textarea.mdc-text-field--filled .mdc-floating-label--shake{animation:mdc-floating-label-shake-float-above-textarea-filled 250ms 1}@keyframes mdc-floating-label-shake-float-above-textarea-filled{0%{transform:translateX(calc(0 - 0%)) translateY(-10.25px) scale(0.75)}33%{animation-timing-function:cubic-bezier(0.5, 0, 0.701732, 0.495819);transform:translateX(calc(4% - 0%)) translateY(-10.25px) scale(0.75)}66%{animation-timing-function:cubic-bezier(0.302435, 0.381352, 0.55, 0.956352);transform:translateX(calc(-4% - 0%)) translateY(-10.25px) scale(0.75)}100%{transform:translateX(calc(0 - 0%)) translateY(-10.25px) scale(0.75)}}.mdc-text-field--textarea.mdc-text-field--filled .mdc-text-field__input{margin-top:23px;margin-bottom:9px}.mdc-text-field--textarea.mdc-text-field--filled.mdc-text-field--no-label .mdc-text-field__input{margin-top:16px;margin-bottom:16px}.mdc-text-field--textarea.mdc-text-field--outlined .mdc-notched-outline--notched .mdc-notched-outline__notch{padding-top:0}.mdc-text-field--textarea.mdc-text-field--outlined .mdc-floating-label--float-above{transform:translateY(-27.25px) scale(1)}.mdc-text-field--textarea.mdc-text-field--outlined .mdc-floating-label--float-above{font-size:.75rem}.mdc-text-field--textarea.mdc-text-field--outlined.mdc-notched-outline--upgraded .mdc-floating-label--float-above,.mdc-text-field--textarea.mdc-text-field--outlined .mdc-notched-outline--upgraded .mdc-floating-label--float-above{transform:translateY(-24.75px) scale(0.75)}.mdc-text-field--textarea.mdc-text-field--outlined.mdc-notched-outline--upgraded .mdc-floating-label--float-above,.mdc-text-field--textarea.mdc-text-field--outlined .mdc-notched-outline--upgraded .mdc-floating-label--float-above{font-size:1rem}.mdc-text-field--textarea.mdc-text-field--outlined .mdc-floating-label--shake{animation:mdc-floating-label-shake-float-above-textarea-outlined 250ms 1}@keyframes mdc-floating-label-shake-float-above-textarea-outlined{0%{transform:translateX(calc(0 - 0%)) translateY(-24.75px) scale(0.75)}33%{animation-timing-function:cubic-bezier(0.5, 0, 0.701732, 0.495819);transform:translateX(calc(4% - 0%)) translateY(-24.75px) scale(0.75)}66%{animation-timing-function:cubic-bezier(0.302435, 0.381352, 0.55, 0.956352);transform:translateX(calc(-4% - 0%)) translateY(-24.75px) scale(0.75)}100%{transform:translateX(calc(0 - 0%)) translateY(-24.75px) scale(0.75)}}.mdc-text-field--textarea.mdc-text-field--outlined .mdc-text-field__input{margin-top:16px;margin-bottom:16px}.mdc-text-field--textarea.mdc-text-field--outlined .mdc-floating-label{top:18px}.mdc-text-field--textarea.mdc-text-field--with-internal-counter .mdc-text-field__input{margin-bottom:2px}.mdc-text-field--textarea.mdc-text-field--with-internal-counter .mdc-text-field-character-counter{align-self:flex-end;padding:0 16px}.mdc-text-field--textarea.mdc-text-field--with-internal-counter .mdc-text-field-character-counter::after{display:inline-block;width:0;height:16px;content:"";vertical-align:-16px}.mdc-text-field--textarea.mdc-text-field--with-internal-counter .mdc-text-field-character-counter::before{display:none}.mdc-text-field__resizer{align-self:stretch;display:inline-flex;flex-direction:column;flex-grow:1;max-height:100%;max-width:100%;min-height:56px;min-width:fit-content;min-width:-moz-available;min-width:-webkit-fill-available;overflow:hidden;resize:both}.mdc-text-field--filled .mdc-text-field__resizer{transform:translateY(-1px)}.mdc-text-field--filled .mdc-text-field__resizer .mdc-text-field__input,.mdc-text-field--filled .mdc-text-field__resizer .mdc-text-field-character-counter{transform:translateY(1px)}.mdc-text-field--outlined .mdc-text-field__resizer{transform:translateX(-1px) translateY(-1px)}[dir=rtl] .mdc-text-field--outlined .mdc-text-field__resizer,.mdc-text-field--outlined .mdc-text-field__resizer[dir=rtl]{transform:translateX(1px) translateY(-1px)}.mdc-text-field--outlined .mdc-text-field__resizer .mdc-text-field__input,.mdc-text-field--outlined .mdc-text-field__resizer .mdc-text-field-character-counter{transform:translateX(1px) translateY(1px)}[dir=rtl] .mdc-text-field--outlined .mdc-text-field__resizer .mdc-text-field__input,[dir=rtl] .mdc-text-field--outlined .mdc-text-field__resizer .mdc-text-field-character-counter,.mdc-text-field--outlined .mdc-text-field__resizer .mdc-text-field__input[dir=rtl],.mdc-text-field--outlined .mdc-text-field__resizer .mdc-text-field-character-counter[dir=rtl]{transform:translateX(-1px) translateY(1px)}.mdc-text-field--with-leading-icon{padding-left:0;padding-right:16px}[dir=rtl] .mdc-text-field--with-leading-icon,.mdc-text-field--with-leading-icon[dir=rtl]{padding-left:16px;padding-right:0}.mdc-text-field--with-leading-icon.mdc-text-field--filled .mdc-floating-label{max-width:calc(100% - 48px);left:48px;right:initial}[dir=rtl] .mdc-text-field--with-leading-icon.mdc-text-field--filled .mdc-floating-label,.mdc-text-field--with-leading-icon.mdc-text-field--filled .mdc-floating-label[dir=rtl]{left:initial;right:48px}.mdc-text-field--with-leading-icon.mdc-text-field--filled .mdc-floating-label--float-above{max-width:calc(100% / 0.75 - 64px / 0.75)}.mdc-text-field--with-leading-icon.mdc-text-field--outlined .mdc-floating-label{left:36px;right:initial}[dir=rtl] .mdc-text-field--with-leading-icon.mdc-text-field--outlined .mdc-floating-label,.mdc-text-field--with-leading-icon.mdc-text-field--outlined .mdc-floating-label[dir=rtl]{left:initial;right:36px}.mdc-text-field--with-leading-icon.mdc-text-field--outlined :not(.mdc-notched-outline--notched) .mdc-notched-outline__notch{max-width:calc(100% - 60px)}.mdc-text-field--with-leading-icon.mdc-text-field--outlined .mdc-floating-label--float-above{transform:translateY(-37.25px) translateX(-32px) scale(1)}[dir=rtl] .mdc-text-field--with-leading-icon.mdc-text-field--outlined .mdc-floating-label--float-above,.mdc-text-field--with-leading-icon.mdc-text-field--outlined .mdc-floating-label--float-above[dir=rtl]{transform:translateY(-37.25px) translateX(32px) scale(1)}.mdc-text-field--with-leading-icon.mdc-text-field--outlined .mdc-floating-label--float-above{font-size:.75rem}.mdc-text-field--with-leading-icon.mdc-text-field--outlined.mdc-notched-outline--upgraded .mdc-floating-label--float-above,.mdc-text-field--with-leading-icon.mdc-text-field--outlined .mdc-notched-outline--upgraded .mdc-floating-label--float-above{transform:translateY(-34.75px) translateX(-32px) scale(0.75)}[dir=rtl] .mdc-text-field--with-leading-icon.mdc-text-field--outlined.mdc-notched-outline--upgraded .mdc-floating-label--float-above,[dir=rtl] .mdc-text-field--with-leading-icon.mdc-text-field--outlined .mdc-notched-outline--upgraded .mdc-floating-label--float-above,.mdc-text-field--with-leading-icon.mdc-text-field--outlined.mdc-notched-outline--upgraded .mdc-floating-label--float-above[dir=rtl],.mdc-text-field--with-leading-icon.mdc-text-field--outlined .mdc-notched-outline--upgraded .mdc-floating-label--float-above[dir=rtl]{transform:translateY(-34.75px) translateX(32px) scale(0.75)}.mdc-text-field--with-leading-icon.mdc-text-field--outlined.mdc-notched-outline--upgraded .mdc-floating-label--float-above,.mdc-text-field--with-leading-icon.mdc-text-field--outlined .mdc-notched-outline--upgraded .mdc-floating-label--float-above{font-size:1rem}.mdc-text-field--with-leading-icon.mdc-text-field--outlined .mdc-floating-label--shake{animation:mdc-floating-label-shake-float-above-text-field-outlined-leading-icon 250ms 1}@keyframes mdc-floating-label-shake-float-above-text-field-outlined-leading-icon{0%{transform:translateX(calc(0 - 32px)) translateY(-34.75px) scale(0.75)}33%{animation-timing-function:cubic-bezier(0.5, 0, 0.701732, 0.495819);transform:translateX(calc(4% - 32px)) translateY(-34.75px) scale(0.75)}66%{animation-timing-function:cubic-bezier(0.302435, 0.381352, 0.55, 0.956352);transform:translateX(calc(-4% - 32px)) translateY(-34.75px) scale(0.75)}100%{transform:translateX(calc(0 - 32px)) translateY(-34.75px) scale(0.75)}}[dir=rtl] .mdc-text-field--with-leading-icon.mdc-text-field--outlined .mdc-floating-label--shake,.mdc-text-field--with-leading-icon.mdc-text-field--outlined[dir=rtl] .mdc-floating-label--shake{animation:mdc-floating-label-shake-float-above-text-field-outlined-leading-icon 250ms 1}@keyframes mdc-floating-label-shake-float-above-text-field-outlined-leading-icon-rtl{0%{transform:translateX(calc(0 - -32px)) translateY(-34.75px) scale(0.75)}33%{animation-timing-function:cubic-bezier(0.5, 0, 0.701732, 0.495819);transform:translateX(calc(4% - -32px)) translateY(-34.75px) scale(0.75)}66%{animation-timing-function:cubic-bezier(0.302435, 0.381352, 0.55, 0.956352);transform:translateX(calc(-4% - -32px)) translateY(-34.75px) scale(0.75)}100%{transform:translateX(calc(0 - -32px)) translateY(-34.75px) scale(0.75)}}.mdc-text-field--with-trailing-icon{padding-left:16px;padding-right:0}[dir=rtl] .mdc-text-field--with-trailing-icon,.mdc-text-field--with-trailing-icon[dir=rtl]{padding-left:0;padding-right:16px}.mdc-text-field--with-trailing-icon.mdc-text-field--filled .mdc-floating-label{max-width:calc(100% - 64px)}.mdc-text-field--with-trailing-icon.mdc-text-field--filled .mdc-floating-label--float-above{max-width:calc(100% / 0.75 - 64px / 0.75)}.mdc-text-field--with-trailing-icon.mdc-text-field--outlined :not(.mdc-notched-outline--notched) .mdc-notched-outline__notch{max-width:calc(100% - 60px)}.mdc-text-field--with-leading-icon.mdc-text-field--with-trailing-icon{padding-left:0;padding-right:0}.mdc-text-field--with-leading-icon.mdc-text-field--with-trailing-icon.mdc-text-field--filled .mdc-floating-label{max-width:calc(100% - 96px)}.mdc-text-field--with-leading-icon.mdc-text-field--with-trailing-icon.mdc-text-field--filled .mdc-floating-label--float-above{max-width:calc(100% / 0.75 - 96px / 0.75)}.mdc-text-field-helper-line{display:flex;justify-content:space-between;box-sizing:border-box}.mdc-text-field+.mdc-text-field-helper-line{padding-right:16px;padding-left:16px}.mdc-form-field>.mdc-text-field+label{align-self:flex-start}.mdc-text-field--focused:not(.mdc-text-field--disabled) .mdc-floating-label{color:rgba(98, 0, 238, 0.87)}.mdc-text-field--focused .mdc-notched-outline__leading,.mdc-text-field--focused .mdc-notched-outline__notch,.mdc-text-field--focused .mdc-notched-outline__trailing{border-width:2px}.mdc-text-field--focused+.mdc-text-field-helper-line .mdc-text-field-helper-text:not(.mdc-text-field-helper-text--validation-msg){opacity:1}.mdc-text-field--focused.mdc-text-field--outlined .mdc-notched-outline--notched .mdc-notched-outline__notch{padding-top:2px}.mdc-text-field--focused.mdc-text-field--outlined.mdc-text-field--textarea .mdc-notched-outline--notched .mdc-notched-outline__notch{padding-top:0}.mdc-text-field--invalid:not(.mdc-text-field--disabled):hover .mdc-line-ripple::before{border-bottom-color:#b00020;border-bottom-color:var(--mdc-theme-error, #b00020)}.mdc-text-field--invalid:not(.mdc-text-field--disabled) .mdc-line-ripple::after{border-bottom-color:#b00020;border-bottom-color:var(--mdc-theme-error, #b00020)}.mdc-text-field--invalid:not(.mdc-text-field--disabled) .mdc-floating-label{color:#b00020;color:var(--mdc-theme-error, #b00020)}.mdc-text-field--invalid:not(.mdc-text-field--disabled).mdc-text-field--invalid+.mdc-text-field-helper-line .mdc-text-field-helper-text--validation-msg{color:#b00020;color:var(--mdc-theme-error, #b00020)}.mdc-text-field--invalid .mdc-text-field__input{caret-color:#b00020;caret-color:var(--mdc-theme-error, #b00020)}.mdc-text-field--invalid:not(.mdc-text-field--disabled) .mdc-text-field__icon--trailing{color:#b00020;color:var(--mdc-theme-error, #b00020)}.mdc-text-field--invalid:not(.mdc-text-field--disabled) .mdc-line-ripple::before{border-bottom-color:#b00020;border-bottom-color:var(--mdc-theme-error, #b00020)}.mdc-text-field--invalid:not(.mdc-text-field--disabled) .mdc-notched-outline__leading,.mdc-text-field--invalid:not(.mdc-text-field--disabled) .mdc-notched-outline__notch,.mdc-text-field--invalid:not(.mdc-text-field--disabled) .mdc-notched-outline__trailing{border-color:#b00020;border-color:var(--mdc-theme-error, #b00020)}.mdc-text-field--invalid:not(.mdc-text-field--disabled):not(.mdc-text-field--focused):hover .mdc-notched-outline .mdc-notched-outline__leading,.mdc-text-field--invalid:not(.mdc-text-field--disabled):not(.mdc-text-field--focused):hover .mdc-notched-outline .mdc-notched-outline__notch,.mdc-text-field--invalid:not(.mdc-text-field--disabled):not(.mdc-text-field--focused):hover .mdc-notched-outline .mdc-notched-outline__trailing{border-color:#b00020;border-color:var(--mdc-theme-error, #b00020)}.mdc-text-field--invalid:not(.mdc-text-field--disabled).mdc-text-field--focused .mdc-notched-outline__leading,.mdc-text-field--invalid:not(.mdc-text-field--disabled).mdc-text-field--focused .mdc-notched-outline__notch,.mdc-text-field--invalid:not(.mdc-text-field--disabled).mdc-text-field--focused .mdc-notched-outline__trailing{border-color:#b00020;border-color:var(--mdc-theme-error, #b00020)}.mdc-text-field--invalid+.mdc-text-field-helper-line .mdc-text-field-helper-text--validation-msg{opacity:1}.mdc-text-field--disabled{pointer-events:none}.mdc-text-field--disabled .mdc-text-field__input{color:rgba(0, 0, 0, 0.38)}@media all{.mdc-text-field--disabled .mdc-text-field__input::placeholder{color:rgba(0, 0, 0, 0.38)}}@media all{.mdc-text-field--disabled .mdc-text-field__input:-ms-input-placeholder{color:rgba(0, 0, 0, 0.38)}}.mdc-text-field--disabled .mdc-floating-label{color:rgba(0, 0, 0, 0.38)}.mdc-text-field--disabled+.mdc-text-field-helper-line .mdc-text-field-helper-text{color:rgba(0, 0, 0, 0.38)}.mdc-text-field--disabled .mdc-text-field-character-counter,.mdc-text-field--disabled+.mdc-text-field-helper-line .mdc-text-field-character-counter{color:rgba(0, 0, 0, 0.38)}.mdc-text-field--disabled .mdc-text-field__icon--leading{color:rgba(0, 0, 0, 0.3)}.mdc-text-field--disabled .mdc-text-field__icon--trailing{color:rgba(0, 0, 0, 0.3)}.mdc-text-field--disabled .mdc-text-field__affix--prefix{color:rgba(0, 0, 0, 0.38)}.mdc-text-field--disabled .mdc-text-field__affix--suffix{color:rgba(0, 0, 0, 0.38)}.mdc-text-field--disabled .mdc-line-ripple::before{border-bottom-color:rgba(0, 0, 0, 0.06)}.mdc-text-field--disabled .mdc-notched-outline__leading,.mdc-text-field--disabled .mdc-notched-outline__notch,.mdc-text-field--disabled .mdc-notched-outline__trailing{border-color:rgba(0, 0, 0, 0.06)}@media screen and (forced-colors: active),(-ms-high-contrast: active){.mdc-text-field--disabled .mdc-text-field__input::placeholder{color:GrayText}}@media screen and (forced-colors: active),(-ms-high-contrast: active){.mdc-text-field--disabled .mdc-text-field__input:-ms-input-placeholder{color:GrayText}}@media screen and (forced-colors: active),(-ms-high-contrast: active){.mdc-text-field--disabled .mdc-floating-label{color:GrayText}}@media screen and (forced-colors: active),(-ms-high-contrast: active){.mdc-text-field--disabled+.mdc-text-field-helper-line .mdc-text-field-helper-text{color:GrayText}}@media screen and (forced-colors: active),(-ms-high-contrast: active){.mdc-text-field--disabled .mdc-text-field-character-counter,.mdc-text-field--disabled+.mdc-text-field-helper-line .mdc-text-field-character-counter{color:GrayText}}@media screen and (forced-colors: active),(-ms-high-contrast: active){.mdc-text-field--disabled .mdc-text-field__icon--leading{color:GrayText}}@media screen and (forced-colors: active),(-ms-high-contrast: active){.mdc-text-field--disabled .mdc-text-field__icon--trailing{color:GrayText}}@media screen and (forced-colors: active),(-ms-high-contrast: active){.mdc-text-field--disabled .mdc-text-field__affix--prefix{color:GrayText}}@media screen and (forced-colors: active),(-ms-high-contrast: active){.mdc-text-field--disabled .mdc-text-field__affix--suffix{color:GrayText}}@media screen and (forced-colors: active),(-ms-high-contrast: active){.mdc-text-field--disabled .mdc-line-ripple::before{border-bottom-color:GrayText}}@media screen and (forced-colors: active),(-ms-high-contrast: active){.mdc-text-field--disabled .mdc-notched-outline__leading,.mdc-text-field--disabled .mdc-notched-outline__notch,.mdc-text-field--disabled .mdc-notched-outline__trailing{border-color:GrayText}}@media screen and (forced-colors: active){.mdc-text-field--disabled .mdc-text-field__input{background-color:Window}.mdc-text-field--disabled .mdc-floating-label{z-index:1}}.mdc-text-field--disabled .mdc-floating-label{cursor:default}.mdc-text-field--disabled.mdc-text-field--filled{background-color:#fafafa}.mdc-text-field--disabled.mdc-text-field--filled .mdc-text-field__ripple{display:none}.mdc-text-field--disabled .mdc-text-field__input{pointer-events:auto}.mdc-text-field--end-aligned .mdc-text-field__input{text-align:right}[dir=rtl] .mdc-text-field--end-aligned .mdc-text-field__input,.mdc-text-field--end-aligned .mdc-text-field__input[dir=rtl]{text-align:left}[dir=rtl] .mdc-text-field--ltr-text .mdc-text-field__input,[dir=rtl] .mdc-text-field--ltr-text .mdc-text-field__affix,.mdc-text-field--ltr-text[dir=rtl] .mdc-text-field__input,.mdc-text-field--ltr-text[dir=rtl] .mdc-text-field__affix{direction:ltr}[dir=rtl] .mdc-text-field--ltr-text .mdc-text-field__affix--prefix,.mdc-text-field--ltr-text[dir=rtl] .mdc-text-field__affix--prefix{padding-left:0;padding-right:2px}[dir=rtl] .mdc-text-field--ltr-text .mdc-text-field__affix--suffix,.mdc-text-field--ltr-text[dir=rtl] .mdc-text-field__affix--suffix{padding-left:12px;padding-right:0}[dir=rtl] .mdc-text-field--ltr-text .mdc-text-field__icon--leading,.mdc-text-field--ltr-text[dir=rtl] .mdc-text-field__icon--leading{order:1}[dir=rtl] .mdc-text-field--ltr-text .mdc-text-field__affix--suffix,.mdc-text-field--ltr-text[dir=rtl] .mdc-text-field__affix--suffix{order:2}[dir=rtl] .mdc-text-field--ltr-text .mdc-text-field__input,.mdc-text-field--ltr-text[dir=rtl] .mdc-text-field__input{order:3}[dir=rtl] .mdc-text-field--ltr-text .mdc-text-field__affix--prefix,.mdc-text-field--ltr-text[dir=rtl] .mdc-text-field__affix--prefix{order:4}[dir=rtl] .mdc-text-field--ltr-text .mdc-text-field__icon--trailing,.mdc-text-field--ltr-text[dir=rtl] .mdc-text-field__icon--trailing{order:5}[dir=rtl] .mdc-text-field--ltr-text.mdc-text-field--end-aligned .mdc-text-field__input,.mdc-text-field--ltr-text.mdc-text-field--end-aligned[dir=rtl] .mdc-text-field__input{text-align:right}[dir=rtl] .mdc-text-field--ltr-text.mdc-text-field--end-aligned .mdc-text-field__affix--prefix,.mdc-text-field--ltr-text.mdc-text-field--end-aligned[dir=rtl] .mdc-text-field__affix--prefix{padding-right:12px}[dir=rtl] .mdc-text-field--ltr-text.mdc-text-field--end-aligned .mdc-text-field__affix--suffix,.mdc-text-field--ltr-text.mdc-text-field--end-aligned[dir=rtl] .mdc-text-field__affix--suffix{padding-left:2px}.mdc-text-field-helper-text{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;font-family:Roboto, sans-serif;font-family:var(--mdc-typography-caption-font-family, var(--mdc-typography-font-family, Roboto, sans-serif));font-size:0.75rem;font-size:var(--mdc-typography-caption-font-size, 0.75rem);line-height:1.25rem;line-height:var(--mdc-typography-caption-line-height, 1.25rem);font-weight:400;font-weight:var(--mdc-typography-caption-font-weight, 400);letter-spacing:0.0333333333em;letter-spacing:var(--mdc-typography-caption-letter-spacing, 0.0333333333em);text-decoration:inherit;text-decoration:var(--mdc-typography-caption-text-decoration, inherit);text-transform:inherit;text-transform:var(--mdc-typography-caption-text-transform, inherit);display:block;margin-top:0;line-height:normal;margin:0;opacity:0;will-change:opacity;transition:opacity 150ms 0ms cubic-bezier(0.4, 0, 0.2, 1)}.mdc-text-field-helper-text::before{display:inline-block;width:0;height:16px;content:"";vertical-align:0}.mdc-text-field-helper-text--persistent{transition:none;opacity:1;will-change:initial}.mdc-text-field-character-counter{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;font-family:Roboto, sans-serif;font-family:var(--mdc-typography-caption-font-family, var(--mdc-typography-font-family, Roboto, sans-serif));font-size:0.75rem;font-size:var(--mdc-typography-caption-font-size, 0.75rem);line-height:1.25rem;line-height:var(--mdc-typography-caption-line-height, 1.25rem);font-weight:400;font-weight:var(--mdc-typography-caption-font-weight, 400);letter-spacing:0.0333333333em;letter-spacing:var(--mdc-typography-caption-letter-spacing, 0.0333333333em);text-decoration:inherit;text-decoration:var(--mdc-typography-caption-text-decoration, inherit);text-transform:inherit;text-transform:var(--mdc-typography-caption-text-transform, inherit);display:block;margin-top:0;line-height:normal;margin-left:auto;margin-right:0;padding-left:16px;padding-right:0;white-space:nowrap}.mdc-text-field-character-counter::before{display:inline-block;width:0;height:16px;content:"";vertical-align:0}[dir=rtl] .mdc-text-field-character-counter,.mdc-text-field-character-counter[dir=rtl]{margin-left:0;margin-right:auto}[dir=rtl] .mdc-text-field-character-counter,.mdc-text-field-character-counter[dir=rtl]{padding-left:0;padding-right:16px}.mdc-text-field__icon{align-self:center;cursor:pointer}.mdc-text-field__icon:not([tabindex]),.mdc-text-field__icon[tabindex="-1"]{cursor:default;pointer-events:none}.mdc-text-field__icon svg{display:block}.mdc-text-field__icon--leading{margin-left:16px;margin-right:8px}[dir=rtl] .mdc-text-field__icon--leading,.mdc-text-field__icon--leading[dir=rtl]{margin-left:8px;margin-right:16px}.mdc-text-field__icon--trailing{padding:12px;margin-left:0px;margin-right:0px}[dir=rtl] .mdc-text-field__icon--trailing,.mdc-text-field__icon--trailing[dir=rtl]{margin-left:0px;margin-right:0px}.material-icons{font-family:var(--mdc-icon-font, "Material Icons");font-weight:normal;font-style:normal;font-size:var(--mdc-icon-size, 24px);line-height:1;letter-spacing:normal;text-transform:none;display:inline-block;white-space:nowrap;word-wrap:normal;direction:ltr;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;-moz-osx-font-smoothing:grayscale;font-feature-settings:"liga"}:host{display:inline-flex;flex-direction:column;outline:none}.mdc-text-field{width:100%}.mdc-text-field:not(.mdc-text-field--disabled) .mdc-line-ripple::before{border-bottom-color:rgba(0, 0, 0, 0.42);border-bottom-color:var(--mdc-text-field-idle-line-color, rgba(0, 0, 0, 0.42))}.mdc-text-field:not(.mdc-text-field--disabled):hover .mdc-line-ripple::before{border-bottom-color:rgba(0, 0, 0, 0.87);border-bottom-color:var(--mdc-text-field-hover-line-color, rgba(0, 0, 0, 0.87))}.mdc-text-field.mdc-text-field--disabled .mdc-line-ripple::before{border-bottom-color:rgba(0, 0, 0, 0.06);border-bottom-color:var(--mdc-text-field-disabled-line-color, rgba(0, 0, 0, 0.06))}.mdc-text-field.mdc-text-field--invalid:not(.mdc-text-field--disabled) .mdc-line-ripple::before{border-bottom-color:#b00020;border-bottom-color:var(--mdc-theme-error, #b00020)}.mdc-text-field__input{direction:inherit}mwc-notched-outline{--mdc-notched-outline-border-color: var( --mdc-text-field-outlined-idle-border-color, rgba(0, 0, 0, 0.38) )}:host(:not([disabled]):hover) :not(.mdc-text-field--invalid):not(.mdc-text-field--focused) mwc-notched-outline{--mdc-notched-outline-border-color: var( --mdc-text-field-outlined-hover-border-color, rgba(0, 0, 0, 0.87) )}:host(:not([disabled])) .mdc-text-field:not(.mdc-text-field--outlined){background-color:var(--mdc-text-field-fill-color, whitesmoke)}:host(:not([disabled])) .mdc-text-field.mdc-text-field--invalid mwc-notched-outline{--mdc-notched-outline-border-color: var( --mdc-text-field-error-color, var(--mdc-theme-error, #b00020) )}:host(:not([disabled])) .mdc-text-field.mdc-text-field--invalid+.mdc-text-field-helper-line .mdc-text-field-character-counter,:host(:not([disabled])) .mdc-text-field.mdc-text-field--invalid .mdc-text-field__icon{color:var(--mdc-text-field-error-color, var(--mdc-theme-error, #b00020))}:host(:not([disabled])) .mdc-text-field:not(.mdc-text-field--invalid):not(.mdc-text-field--focused) .mdc-floating-label,:host(:not([disabled])) .mdc-text-field:not(.mdc-text-field--invalid):not(.mdc-text-field--focused) .mdc-floating-label::after{color:var(--mdc-text-field-label-ink-color, rgba(0, 0, 0, 0.6))}:host(:not([disabled])) .mdc-text-field.mdc-text-field--focused mwc-notched-outline{--mdc-notched-outline-stroke-width: 2px}:host(:not([disabled])) .mdc-text-field.mdc-text-field--focused:not(.mdc-text-field--invalid) mwc-notched-outline{--mdc-notched-outline-border-color: var( --mdc-text-field-focused-label-color, var(--mdc-theme-primary, rgba(98, 0, 238, 0.87)) )}:host(:not([disabled])) .mdc-text-field.mdc-text-field--focused:not(.mdc-text-field--invalid) .mdc-floating-label{color:#6200ee;color:var(--mdc-theme-primary, #6200ee)}:host(:not([disabled])) .mdc-text-field .mdc-text-field__input{color:var(--mdc-text-field-ink-color, rgba(0, 0, 0, 0.87))}:host(:not([disabled])) .mdc-text-field .mdc-text-field__input::placeholder{color:var(--mdc-text-field-label-ink-color, rgba(0, 0, 0, 0.6))}:host(:not([disabled])) .mdc-text-field-helper-line .mdc-text-field-helper-text:not(.mdc-text-field-helper-text--validation-msg),:host(:not([disabled])) .mdc-text-field-helper-line:not(.mdc-text-field--invalid) .mdc-text-field-character-counter{color:var(--mdc-text-field-label-ink-color, rgba(0, 0, 0, 0.6))}:host([disabled]) .mdc-text-field:not(.mdc-text-field--outlined){background-color:var(--mdc-text-field-disabled-fill-color, #fafafa)}:host([disabled]) .mdc-text-field.mdc-text-field--outlined mwc-notched-outline{--mdc-notched-outline-border-color: var( --mdc-text-field-outlined-disabled-border-color, rgba(0, 0, 0, 0.06) )}:host([disabled]) .mdc-text-field:not(.mdc-text-field--invalid):not(.mdc-text-field--focused) .mdc-floating-label,:host([disabled]) .mdc-text-field:not(.mdc-text-field--invalid):not(.mdc-text-field--focused) .mdc-floating-label::after{color:var(--mdc-text-field-disabled-ink-color, rgba(0, 0, 0, 0.38))}:host([disabled]) .mdc-text-field .mdc-text-field__input,:host([disabled]) .mdc-text-field .mdc-text-field__input::placeholder{color:var(--mdc-text-field-disabled-ink-color, rgba(0, 0, 0, 0.38))}:host([disabled]) .mdc-text-field-helper-line .mdc-text-field-helper-text,:host([disabled]) .mdc-text-field-helper-line .mdc-text-field-character-counter{color:var(--mdc-text-field-disabled-ink-color, rgba(0, 0, 0, 0.38))}`,os={"mwc-textfield":class extends rs{static get styles(){return as}},"mwc-notched-outline":class extends ko{static get styles(){return Fo}}};function ss(e,t){return Array.isArray(t)||(t=[]),e?U` + ${function(e){const t=Array.from(new Set(e.map((e=>e.entity))));if(t.length!=e.length)return U` + + ${jn("editor.error.duplicate")} + + `;return U``}(t)} ${function(e,t){if((null==e?void 0:e.metadata.type)==Vt.CurrentExpected&&1==t.length)return U` + + ${jn("editor.error.expected_entity")} + + `;return U``}(e,t)} + ${function(e,t){if(e.metadata.entitiesCount>0&&t.length>e.metadata.entitiesCount)return U` + + ${jn("editor.error.too_many_entities").replace("{expected}",String(e.metadata.entitiesCount)).replace("{got}",String(t.length))} + + `;return U``}(e,t)} + `:U``}function ls(e){return null==e?[]:(Array.isArray(e)||(e=[e]),e.length>0&&e.every((e=>null==e))?[]:e.map((e=>"string"==typeof e?{entity:e}:e)))}let ds=class extends( +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +function(e){return class extends e{createRenderRoot(){const e=this.constructor,{registry:t,elementDefinitions:i,shadowRootOptions:n}=e;i&&!t&&(e.registry=new CustomElementRegistry,Object.entries(i).forEach((([t,i])=>e.registry.define(t,i))));const r=this.renderOptions.creationScope=this.attachShadow({...n,customElements:e.registry});return v(r,this.constructor.elementStyles),r}}}(de)){constructor(){super(...arguments),this._initialized=!1}setConfig(e){this._config=e,this._configEntities=ls(e.entities)}firstUpdated(){this.loadLovelaceElements()}shouldUpdate(){return this._initialized||this._initialize(),!0}get _integration(){var e;return(null===(e=this._config)||void 0===e?void 0:e.integration)||""}get _override_headline(){var e;return(null===(e=this._config)||void 0===e?void 0:e.override_headline)||!1}get _hide_when_no_warning(){var e;return(null===(e=this._config)||void 0===e?void 0:e.hide_when_no_warning)||!1}get _hide_caption(){var e;return(null===(e=this._config)||void 0===e?void 0:e.hide_caption)||!1}get _disable_swiper(){var e;return(null===(e=this._config)||void 0===e?void 0:e.disable_swiper)||!1}get _scaling_mode(){var e;return(null===(e=this._config)||void 0===e?void 0:e.scaling_mode)||"headline_and_scale"}render(){var e;if(!this.hass)return U``;const t=mr.integrations.find((e=>e.metadata.key===this._integration));return U` + + ${ss(t,this._configEntities)} + + + e.stopPropagation()} + > + ${mr.integrations.map((e=>U`${e.metadata.name}`))} + + + + ${(null==t?void 0:t.metadata.type)==Vt.SingleEntity?U` + 0?this._configEntities[0].entity:""} + @value-changed=${this._valueChanged} + > + `:U` +

${jn("editor.entity")} (${jn("editor.required")})

+

+ ${jn("editor.description.start")} ${" "} + ${(null==t?void 0:t.metadata.type)==Vt.CurrentExpected?U` + ${jn("editor.description.current_expected")}

+ `:""} + ${(null==t?void 0:t.metadata.type)==Vt.Slots?U` + ${jn("editor.description.slots")}

+ `:""} + ${(null==t?void 0:t.metadata.type)==Vt.WarningWatchStatementAdvisory?U` + ${jn("editor.description.warning_watch_statement_advisory")}

+ `:""} + ${(null==t?void 0:t.metadata.type)==Vt.SeparateEvents?U` + ${jn("editor.description.separate_events")}

+ `:""} + ${" "} ${jn("editor.description.end")} +

+ + + `} + + +
+ + ${(null==t?void 0:t.metadata.returnMultipleAlerts)?U` + + + + `:""} + + + ${(null==t?void 0:t.metadata.returnHeadline)?U` + + + + `:""} + + + ${(null==t?void 0:t.metadata.type)==Vt.CurrentExpected?U` + + + + `:""} + + + + + +
+ + +
+
+ e.stopPropagation()} + > + ${Object.values(Gt).map((e=>U` + ${jn(`editor.scaling_mode_options.${e}`)} + `))} + + + Scaling mode documentation + +
+
+ `}_initialize(){void 0!==this.hass&&void 0!==this._config&&void 0!==this._helpers&&(this._initialized=!0)}async loadLovelaceElements(){var e;const t=null===(e=this.shadowRoot)||void 0===e?void 0:e.customElements;if(!t)return;if(t.get("ha-entity-picker"))return;this._helpers=await window.loadCardHelpers();const i=await this._helpers.createCardElement({type:"entities",entities:[]});await i.constructor.getConfigElement();const n=await this._helpers.createCardElement({type:"glance",entities:[]});await n.constructor.getConfigElement(),t.define("ha-entity-picker",window.customElements.get("ha-entity-picker")),t.define("hui-entity-editor",window.customElements.get("hui-entity-editor")),t.define("ha-alert",window.customElements.get("ha-alert"))}_valueChanged(e){if(!this._config||!this.hass)return;const t=e.target;if(this[`_${t.configValue}`]!==t.value){if(t.configValue)if(""===t.value){const e=Object.assign({},this._config);delete e[t.configValue],this._config=e}else{this._config=Object.assign(Object.assign({},this._config),{[t.configValue]:void 0!==t.checked?t.checked:t.value}),this._config=Object.assign(Object.assign({},this._config),{entities:ls(this._config.entities)});const e=mr.integrations.find((e=>e.metadata.key===this._integration));if((null==e?void 0:e.metadata.type)==Vt.SingleEntity){const e=this._config.entities;this._config.entities=[e[0]]}}l(this,"config-changed",{config:this._config})}}_entitiesChanged(e){let t=this._config;t=Object.assign(Object.assign({},t),{entities:e.detail.entities}),this._configEntities=ls(this._config.entities),l(this,"config-changed",{config:t})}};ds.elementDefinitions=Object.assign(Object.assign(Object.assign(Object.assign({},os),Do),Uo),zr),ds.styles=g` + mwc-select, + mwc-textfield, + ha-entity-picker, + hui-entity-editor, + ha-alert { + margin-bottom: 16px; + display: block; + } + mwc-formfield { + padding-bottom: 8px; + } + mwc-switch { + --mdc-theme-secondary: var(--switch-checked-color); + --mdc-theme-surface: #999999; + } + .options { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 18px; + margin: 24px 0; + } + p { + max-width: 600px; + } + `,a([he({attribute:!1})],ds.prototype,"hass",void 0),a([ue()],ds.prototype,"_config",void 0),a([ue()],ds.prototype,"_helpers",void 0),a([ue()],ds.prototype,"_configEntities",void 0),ds=a([pe("meteoalarm-card-editor")],ds);var cs=Object.freeze({__proto__:null,get MeteoalarmCardCardEditor(){return ds}});export{mr as MeteoalarmCard}; diff --git a/config/www/community/bar-card/bar-card.js b/config/www/community/bar-card/bar-card.js new file mode 100644 index 0000000..dd0275d --- /dev/null +++ b/config/www/community/bar-card/bar-card.js @@ -0,0 +1,5384 @@ +/*! ***************************************************************************** +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. You may obtain a copy of the +License at http://www.apache.org/licenses/LICENSE-2.0 + +THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED +WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE, +MERCHANTABLITY OR NON-INFRINGEMENT. + +See the Apache Version 2.0 License for specific language governing permissions +and limitations under the License. +***************************************************************************** */ + +function __decorate(decorators, target, key, desc) { + var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; + if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); + else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; + return c > 3 && r && Object.defineProperty(target, key, r), r; +} + +/** + * @license + * Copyright (c) 2017 The Polymer Project Authors. All rights reserved. + * This code may only be used under the BSD style license found at + * http://polymer.github.io/LICENSE.txt + * The complete set of authors may be found at + * http://polymer.github.io/AUTHORS.txt + * The complete set of contributors may be found at + * http://polymer.github.io/CONTRIBUTORS.txt + * Code distributed by Google as part of the polymer project is also + * subject to an additional IP rights grant found at + * http://polymer.github.io/PATENTS.txt + */ +/** + * True if the custom elements polyfill is in use. + */ +const isCEPolyfill = typeof window !== 'undefined' && + window.customElements != null && + window.customElements.polyfillWrapFlushCallback !== + undefined; +/** + * Removes nodes, starting from `start` (inclusive) to `end` (exclusive), from + * `container`. + */ +const removeNodes = (container, start, end = null) => { + while (start !== end) { + const n = start.nextSibling; + container.removeChild(start); + start = n; + } +}; + +/** + * @license + * Copyright (c) 2017 The Polymer Project Authors. All rights reserved. + * This code may only be used under the BSD style license found at + * http://polymer.github.io/LICENSE.txt + * The complete set of authors may be found at + * http://polymer.github.io/AUTHORS.txt + * The complete set of contributors may be found at + * http://polymer.github.io/CONTRIBUTORS.txt + * Code distributed by Google as part of the polymer project is also + * subject to an additional IP rights grant found at + * http://polymer.github.io/PATENTS.txt + */ +/** + * An expression marker with embedded unique key to avoid collision with + * possible text in templates. + */ +const marker = `{{lit-${String(Math.random()).slice(2)}}}`; +/** + * An expression marker used text-positions, multi-binding attributes, and + * attributes with markup-like text values. + */ +const nodeMarker = ``; +const markerRegex = new RegExp(`${marker}|${nodeMarker}`); +/** + * Suffix appended to all bound attribute names. + */ +const boundAttributeSuffix = '$lit$'; +/** + * An updatable Template that tracks the location of dynamic parts. + */ +class Template { + constructor(result, element) { + this.parts = []; + this.element = element; + const nodesToRemove = []; + const stack = []; + // Edge needs all 4 parameters present; IE11 needs 3rd parameter to be null + const walker = document.createTreeWalker(element.content, 133 /* NodeFilter.SHOW_{ELEMENT|COMMENT|TEXT} */, null, false); + // Keeps track of the last index associated with a part. We try to delete + // unnecessary nodes, but we never want to associate two different parts + // to the same index. They must have a constant node between. + let lastPartIndex = 0; + let index = -1; + let partIndex = 0; + const { strings, values: { length } } = result; + while (partIndex < length) { + const node = walker.nextNode(); + if (node === null) { + // We've exhausted the content inside a nested template element. + // Because we still have parts (the outer for-loop), we know: + // - There is a template in the stack + // - The walker will find a nextNode outside the template + walker.currentNode = stack.pop(); + continue; + } + index++; + if (node.nodeType === 1 /* Node.ELEMENT_NODE */) { + if (node.hasAttributes()) { + const attributes = node.attributes; + const { length } = attributes; + // Per + // https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap, + // attributes are not guaranteed to be returned in document order. + // In particular, Edge/IE can return them out of order, so we cannot + // assume a correspondence between part index and attribute index. + let count = 0; + for (let i = 0; i < length; i++) { + if (endsWith(attributes[i].name, boundAttributeSuffix)) { + count++; + } + } + while (count-- > 0) { + // Get the template literal section leading up to the first + // expression in this attribute + const stringForPart = strings[partIndex]; + // Find the attribute name + const name = lastAttributeNameRegex.exec(stringForPart)[2]; + // Find the corresponding attribute + // All bound attributes have had a suffix added in + // TemplateResult#getHTML to opt out of special attribute + // handling. To look up the attribute value we also need to add + // the suffix. + const attributeLookupName = name.toLowerCase() + boundAttributeSuffix; + const attributeValue = node.getAttribute(attributeLookupName); + node.removeAttribute(attributeLookupName); + const statics = attributeValue.split(markerRegex); + this.parts.push({ type: 'attribute', index, name, strings: statics }); + partIndex += statics.length - 1; + } + } + if (node.tagName === 'TEMPLATE') { + stack.push(node); + walker.currentNode = node.content; + } + } + else if (node.nodeType === 3 /* Node.TEXT_NODE */) { + const data = node.data; + if (data.indexOf(marker) >= 0) { + const parent = node.parentNode; + const strings = data.split(markerRegex); + const lastIndex = strings.length - 1; + // Generate a new text node for each literal section + // These nodes are also used as the markers for node parts + for (let i = 0; i < lastIndex; i++) { + let insert; + let s = strings[i]; + if (s === '') { + insert = createMarker(); + } + else { + const match = lastAttributeNameRegex.exec(s); + if (match !== null && endsWith(match[2], boundAttributeSuffix)) { + s = s.slice(0, match.index) + match[1] + + match[2].slice(0, -boundAttributeSuffix.length) + match[3]; + } + insert = document.createTextNode(s); + } + parent.insertBefore(insert, node); + this.parts.push({ type: 'node', index: ++index }); + } + // If there's no text, we must insert a comment to mark our place. + // Else, we can trust it will stick around after cloning. + if (strings[lastIndex] === '') { + parent.insertBefore(createMarker(), node); + nodesToRemove.push(node); + } + else { + node.data = strings[lastIndex]; + } + // We have a part for each match found + partIndex += lastIndex; + } + } + else if (node.nodeType === 8 /* Node.COMMENT_NODE */) { + if (node.data === marker) { + const parent = node.parentNode; + // Add a new marker node to be the startNode of the Part if any of + // the following are true: + // * We don't have a previousSibling + // * The previousSibling is already the start of a previous part + if (node.previousSibling === null || index === lastPartIndex) { + index++; + parent.insertBefore(createMarker(), node); + } + lastPartIndex = index; + this.parts.push({ type: 'node', index }); + // If we don't have a nextSibling, keep this node so we have an end. + // Else, we can remove it to save future costs. + if (node.nextSibling === null) { + node.data = ''; + } + else { + nodesToRemove.push(node); + index--; + } + partIndex++; + } + else { + let i = -1; + while ((i = node.data.indexOf(marker, i + 1)) !== -1) { + // Comment node has a binding marker inside, make an inactive part + // The binding won't work, but subsequent bindings will + // TODO (justinfagnani): consider whether it's even worth it to + // make bindings in comments work + this.parts.push({ type: 'node', index: -1 }); + partIndex++; + } + } + } + } + // Remove text binding nodes after the walk to not disturb the TreeWalker + for (const n of nodesToRemove) { + n.parentNode.removeChild(n); + } + } +} +const endsWith = (str, suffix) => { + const index = str.length - suffix.length; + return index >= 0 && str.slice(index) === suffix; +}; +const isTemplatePartActive = (part) => part.index !== -1; +// Allows `document.createComment('')` to be renamed for a +// small manual size-savings. +const createMarker = () => document.createComment(''); +/** + * This regex extracts the attribute name preceding an attribute-position + * expression. It does this by matching the syntax allowed for attributes + * against the string literal directly preceding the expression, assuming that + * the expression is in an attribute-value position. + * + * See attributes in the HTML spec: + * https://www.w3.org/TR/html5/syntax.html#elements-attributes + * + * " \x09\x0a\x0c\x0d" are HTML space characters: + * https://www.w3.org/TR/html5/infrastructure.html#space-characters + * + * "\0-\x1F\x7F-\x9F" are Unicode control characters, which includes every + * space character except " ". + * + * So an attribute is: + * * The name: any character except a control character, space character, ('), + * ("), ">", "=", or "/" + * * Followed by zero or more space characters + * * Followed by "=" + * * Followed by zero or more space characters + * * Followed by: + * * Any character except space, ('), ("), "<", ">", "=", (`), or + * * (") then any non-("), or + * * (') then any non-(') + */ +const lastAttributeNameRegex = +// eslint-disable-next-line no-control-regex +/([ \x09\x0a\x0c\x0d])([^\0-\x1F\x7F-\x9F "'>=/]+)([ \x09\x0a\x0c\x0d]*=[ \x09\x0a\x0c\x0d]*(?:[^ \x09\x0a\x0c\x0d"'`<>=]*|"[^"]*|'[^']*))$/; + +/** + * @license + * Copyright (c) 2017 The Polymer Project Authors. All rights reserved. + * This code may only be used under the BSD style license found at + * http://polymer.github.io/LICENSE.txt + * The complete set of authors may be found at + * http://polymer.github.io/AUTHORS.txt + * The complete set of contributors may be found at + * http://polymer.github.io/CONTRIBUTORS.txt + * Code distributed by Google as part of the polymer project is also + * subject to an additional IP rights grant found at + * http://polymer.github.io/PATENTS.txt + */ +const walkerNodeFilter = 133 /* NodeFilter.SHOW_{ELEMENT|COMMENT|TEXT} */; +/** + * Removes the list of nodes from a Template safely. In addition to removing + * nodes from the Template, the Template part indices are updated to match + * the mutated Template DOM. + * + * As the template is walked the removal state is tracked and + * part indices are adjusted as needed. + * + * div + * div#1 (remove) <-- start removing (removing node is div#1) + * div + * div#2 (remove) <-- continue removing (removing node is still div#1) + * div + * div <-- stop removing since previous sibling is the removing node (div#1, + * removed 4 nodes) + */ +function removeNodesFromTemplate(template, nodesToRemove) { + const { element: { content }, parts } = template; + const walker = document.createTreeWalker(content, walkerNodeFilter, null, false); + let partIndex = nextActiveIndexInTemplateParts(parts); + let part = parts[partIndex]; + let nodeIndex = -1; + let removeCount = 0; + const nodesToRemoveInTemplate = []; + let currentRemovingNode = null; + while (walker.nextNode()) { + nodeIndex++; + const node = walker.currentNode; + // End removal if stepped past the removing node + if (node.previousSibling === currentRemovingNode) { + currentRemovingNode = null; + } + // A node to remove was found in the template + if (nodesToRemove.has(node)) { + nodesToRemoveInTemplate.push(node); + // Track node we're removing + if (currentRemovingNode === null) { + currentRemovingNode = node; + } + } + // When removing, increment count by which to adjust subsequent part indices + if (currentRemovingNode !== null) { + removeCount++; + } + while (part !== undefined && part.index === nodeIndex) { + // If part is in a removed node deactivate it by setting index to -1 or + // adjust the index as needed. + part.index = currentRemovingNode !== null ? -1 : part.index - removeCount; + // go to the next active part. + partIndex = nextActiveIndexInTemplateParts(parts, partIndex); + part = parts[partIndex]; + } + } + nodesToRemoveInTemplate.forEach((n) => n.parentNode.removeChild(n)); +} +const countNodes = (node) => { + let count = (node.nodeType === 11 /* Node.DOCUMENT_FRAGMENT_NODE */) ? 0 : 1; + const walker = document.createTreeWalker(node, walkerNodeFilter, null, false); + while (walker.nextNode()) { + count++; + } + return count; +}; +const nextActiveIndexInTemplateParts = (parts, startIndex = -1) => { + for (let i = startIndex + 1; i < parts.length; i++) { + const part = parts[i]; + if (isTemplatePartActive(part)) { + return i; + } + } + return -1; +}; +/** + * Inserts the given node into the Template, optionally before the given + * refNode. In addition to inserting the node into the Template, the Template + * part indices are updated to match the mutated Template DOM. + */ +function insertNodeIntoTemplate(template, node, refNode = null) { + const { element: { content }, parts } = template; + // If there's no refNode, then put node at end of template. + // No part indices need to be shifted in this case. + if (refNode === null || refNode === undefined) { + content.appendChild(node); + return; + } + const walker = document.createTreeWalker(content, walkerNodeFilter, null, false); + let partIndex = nextActiveIndexInTemplateParts(parts); + let insertCount = 0; + let walkerIndex = -1; + while (walker.nextNode()) { + walkerIndex++; + const walkerNode = walker.currentNode; + if (walkerNode === refNode) { + insertCount = countNodes(node); + refNode.parentNode.insertBefore(node, refNode); + } + while (partIndex !== -1 && parts[partIndex].index === walkerIndex) { + // If we've inserted the node, simply adjust all subsequent parts + if (insertCount > 0) { + while (partIndex !== -1) { + parts[partIndex].index += insertCount; + partIndex = nextActiveIndexInTemplateParts(parts, partIndex); + } + return; + } + partIndex = nextActiveIndexInTemplateParts(parts, partIndex); + } + } +} + +/** + * @license + * Copyright (c) 2017 The Polymer Project Authors. All rights reserved. + * This code may only be used under the BSD style license found at + * http://polymer.github.io/LICENSE.txt + * The complete set of authors may be found at + * http://polymer.github.io/AUTHORS.txt + * The complete set of contributors may be found at + * http://polymer.github.io/CONTRIBUTORS.txt + * Code distributed by Google as part of the polymer project is also + * subject to an additional IP rights grant found at + * http://polymer.github.io/PATENTS.txt + */ +const directives = new WeakMap(); +/** + * Brands a function as a directive factory function so that lit-html will call + * the function during template rendering, rather than passing as a value. + * + * A _directive_ is a function that takes a Part as an argument. It has the + * signature: `(part: Part) => void`. + * + * A directive _factory_ is a function that takes arguments for data and + * configuration and returns a directive. Users of directive usually refer to + * the directive factory as the directive. For example, "The repeat directive". + * + * Usually a template author will invoke a directive factory in their template + * with relevant arguments, which will then return a directive function. + * + * Here's an example of using the `repeat()` directive factory that takes an + * array and a function to render an item: + * + * ```js + * html`
    <${repeat(items, (item) => html`
  • ${item}
  • `)}
` + * ``` + * + * When `repeat` is invoked, it returns a directive function that closes over + * `items` and the template function. When the outer template is rendered, the + * return directive function is called with the Part for the expression. + * `repeat` then performs it's custom logic to render multiple items. + * + * @param f The directive factory function. Must be a function that returns a + * function of the signature `(part: Part) => void`. The returned function will + * be called with the part object. + * + * @example + * + * import {directive, html} from 'lit-html'; + * + * const immutable = directive((v) => (part) => { + * if (part.value !== v) { + * part.setValue(v) + * } + * }); + */ +const directive = (f) => ((...args) => { + const d = f(...args); + directives.set(d, true); + return d; +}); +const isDirective = (o) => { + return typeof o === 'function' && directives.has(o); +}; + +/** + * @license + * Copyright (c) 2018 The Polymer Project Authors. All rights reserved. + * This code may only be used under the BSD style license found at + * http://polymer.github.io/LICENSE.txt + * The complete set of authors may be found at + * http://polymer.github.io/AUTHORS.txt + * The complete set of contributors may be found at + * http://polymer.github.io/CONTRIBUTORS.txt + * Code distributed by Google as part of the polymer project is also + * subject to an additional IP rights grant found at + * http://polymer.github.io/PATENTS.txt + */ +/** + * A sentinel value that signals that a value was handled by a directive and + * should not be written to the DOM. + */ +const noChange = {}; +/** + * A sentinel value that signals a NodePart to fully clear its content. + */ +const nothing = {}; + +/** + * @license + * Copyright (c) 2017 The Polymer Project Authors. All rights reserved. + * This code may only be used under the BSD style license found at + * http://polymer.github.io/LICENSE.txt + * The complete set of authors may be found at + * http://polymer.github.io/AUTHORS.txt + * The complete set of contributors may be found at + * http://polymer.github.io/CONTRIBUTORS.txt + * Code distributed by Google as part of the polymer project is also + * subject to an additional IP rights grant found at + * http://polymer.github.io/PATENTS.txt + */ +/** + * An instance of a `Template` that can be attached to the DOM and updated + * with new values. + */ +class TemplateInstance { + constructor(template, processor, options) { + this.__parts = []; + this.template = template; + this.processor = processor; + this.options = options; + } + update(values) { + let i = 0; + for (const part of this.__parts) { + if (part !== undefined) { + part.setValue(values[i]); + } + i++; + } + for (const part of this.__parts) { + if (part !== undefined) { + part.commit(); + } + } + } + _clone() { + // There are a number of steps in the lifecycle of a template instance's + // DOM fragment: + // 1. Clone - create the instance fragment + // 2. Adopt - adopt into the main document + // 3. Process - find part markers and create parts + // 4. Upgrade - upgrade custom elements + // 5. Update - set node, attribute, property, etc., values + // 6. Connect - connect to the document. Optional and outside of this + // method. + // + // We have a few constraints on the ordering of these steps: + // * We need to upgrade before updating, so that property values will pass + // through any property setters. + // * We would like to process before upgrading so that we're sure that the + // cloned fragment is inert and not disturbed by self-modifying DOM. + // * We want custom elements to upgrade even in disconnected fragments. + // + // Given these constraints, with full custom elements support we would + // prefer the order: Clone, Process, Adopt, Upgrade, Update, Connect + // + // But Safari does not implement CustomElementRegistry#upgrade, so we + // can not implement that order and still have upgrade-before-update and + // upgrade disconnected fragments. So we instead sacrifice the + // process-before-upgrade constraint, since in Custom Elements v1 elements + // must not modify their light DOM in the constructor. We still have issues + // when co-existing with CEv0 elements like Polymer 1, and with polyfills + // that don't strictly adhere to the no-modification rule because shadow + // DOM, which may be created in the constructor, is emulated by being placed + // in the light DOM. + // + // The resulting order is on native is: Clone, Adopt, Upgrade, Process, + // Update, Connect. document.importNode() performs Clone, Adopt, and Upgrade + // in one step. + // + // The Custom Elements v1 polyfill supports upgrade(), so the order when + // polyfilled is the more ideal: Clone, Process, Adopt, Upgrade, Update, + // Connect. + const fragment = isCEPolyfill ? + this.template.element.content.cloneNode(true) : + document.importNode(this.template.element.content, true); + const stack = []; + const parts = this.template.parts; + // Edge needs all 4 parameters present; IE11 needs 3rd parameter to be null + const walker = document.createTreeWalker(fragment, 133 /* NodeFilter.SHOW_{ELEMENT|COMMENT|TEXT} */, null, false); + let partIndex = 0; + let nodeIndex = 0; + let part; + let node = walker.nextNode(); + // Loop through all the nodes and parts of a template + while (partIndex < parts.length) { + part = parts[partIndex]; + if (!isTemplatePartActive(part)) { + this.__parts.push(undefined); + partIndex++; + continue; + } + // Progress the tree walker until we find our next part's node. + // Note that multiple parts may share the same node (attribute parts + // on a single element), so this loop may not run at all. + while (nodeIndex < part.index) { + nodeIndex++; + if (node.nodeName === 'TEMPLATE') { + stack.push(node); + walker.currentNode = node.content; + } + if ((node = walker.nextNode()) === null) { + // We've exhausted the content inside a nested template element. + // Because we still have parts (the outer for-loop), we know: + // - There is a template in the stack + // - The walker will find a nextNode outside the template + walker.currentNode = stack.pop(); + node = walker.nextNode(); + } + } + // We've arrived at our part's node. + if (part.type === 'node') { + const part = this.processor.handleTextExpression(this.options); + part.insertAfterNode(node.previousSibling); + this.__parts.push(part); + } + else { + this.__parts.push(...this.processor.handleAttributeExpressions(node, part.name, part.strings, this.options)); + } + partIndex++; + } + if (isCEPolyfill) { + document.adoptNode(fragment); + customElements.upgrade(fragment); + } + return fragment; + } +} + +/** + * @license + * Copyright (c) 2017 The Polymer Project Authors. All rights reserved. + * This code may only be used under the BSD style license found at + * http://polymer.github.io/LICENSE.txt + * The complete set of authors may be found at + * http://polymer.github.io/AUTHORS.txt + * The complete set of contributors may be found at + * http://polymer.github.io/CONTRIBUTORS.txt + * Code distributed by Google as part of the polymer project is also + * subject to an additional IP rights grant found at + * http://polymer.github.io/PATENTS.txt + */ +const commentMarker = ` ${marker} `; +/** + * The return type of `html`, which holds a Template and the values from + * interpolated expressions. + */ +class TemplateResult { + constructor(strings, values, type, processor) { + this.strings = strings; + this.values = values; + this.type = type; + this.processor = processor; + } + /** + * Returns a string of HTML used to create a `