├── LICENSE.txt ├── README.md ├── conf ├── appdaemon.yaml ├── apps.yaml └── apps │ ├── binary_changing_sensors.py │ ├── bot_event_listener.py │ ├── enerpi_alarm.py │ ├── example_dumb_bot.py │ ├── family_tracker.py │ ├── kodi_ambient_lights.py │ ├── kodi_input_select.py │ ├── morning_alarm_clock.py │ ├── motion_alarm_push_email.py │ ├── motion_lights.py │ ├── publish_states_in_master.py │ ├── raw_bin_sensors.py │ ├── templates │ ├── persistent_notif_alarm.html │ ├── persistent_notif_prealarm.html │ ├── raw_text_pbnotif.html │ └── report_template.html │ └── youtube_search.py ├── scripts ├── README.md └── appdaemon.service └── slave_hass ├── appdaemon.yaml └── apps.yaml /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Eugenio Panadero 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Personal automation apps (for [Home Assistant](https://home-assistant.io/) as [AppDaemon](https://github.com/home-assistant/appdaemon) apps) 2 | 3 | Some home automation tasks for my personal use, integrated with my [personal config](https://github.com/azogue/hass_config) of [Home Assistant](https://home-assistant.io/), which is running 24/7 on a Raspberry PI 3 at home. 4 | 5 | - **[enerpi_alarm.py](https://github.com/azogue/hass_appdaemon_apps/blob/master/conf/apps/enerpi_alarm.py)**: App for rich iOS notifications on power peaks (for *custom_component* **[enerPI current meter](https://github.com/azogue/enerpi)**). 6 | - **[kodi_ambient_lights.py](https://github.com/azogue/hass_appdaemon_apps/blob/master/conf/apps/kodi_ambient_lights.py)**: Set ambient light when playing something with KODI; also, send iOS notifications with the plot of what's playing and custom actions for light control. 7 | - **[morning_alarm_clock.py](https://github.com/azogue/hass_appdaemon_apps/blob/master/conf/apps/morning_alarm_clock.py)**: Alarm clock app which simulates a fast dawn with Hue lights, while waking up the home cinema system, waiting for the start of the broadcast of La Cafetera radio program to start playing it (or, if the alarm is at a different time of the typical emision time, it just plays the last published episode). It talks directly with KODI (through its JSONRPC API), which has to run a specific Kodi Add-On: [plugin.audio.lacafetera](https://github.com/azogue/plugin.audio.lacafetera). It also runs with Mopidy without any add-on, to play the audio stream in another RPI. Also, with custom iOS notifications, I can postpone the alarm (+X min) or turn off directly. 8 | - **[motion_lights.py](https://github.com/azogue/hass_appdaemon_apps/blob/master/conf/apps/motion_lights.py)**: App for control some hue lights for turning them ON with motion detection, only under some custom circunstances, like the media player is not running, or there aren't any more lights in 'on' state in the room. 9 | - **[publish_states_in_master.py](https://github.com/azogue/hass_appdaemon_apps/blob/master/conf/apps/publish_states_in_master.py)**: App for posting state changes from sensors & binary_sensors from a 'slave' HA instance to another 'master' HA Instance. 10 | - **[bot_event_listener.py](https://github.com/azogue/hass_appdaemon_apps/blob/master/conf/apps/bot_event_listener.py)**: App for listen to and produce feedback in a conversation with a Telegram Bot (including not only sending complex commands but a HASS wizard too), or from iOS notification action pressed. 11 | - **[motion_alarm_push_email.py](https://github.com/azogue/hass_appdaemon_apps/blob/master/conf/apps/enerpi_alarm.py):** Complex motion detection alarm with multiple actuators, BT sensing, pre-alarm logic, push notifications, rich html emails, and some configuration options. 12 | - ... Other automations in active? development ... 13 | 14 | ``` 15 | 16 | *Switchs*: 17 | {% for state in states.switch%} 18 | - {{state.attributes.friendly_name}} --> {{state.state}} [{{relative_time(state.last_changed)}}]{% endfor %} 19 | 20 | *Binary sensors*: 21 | {% for state in states.binary_sensor%} 22 | - {{state.attributes.friendly_name}} [{{state.attributes.device_class}}] --> {{state.state}} [{{relative_time(state.last_changed)}}]{% endfor %} 23 | 24 | *Sensors*: 25 | {% for state in states.sensor%} 26 | - {{state.attributes.friendly_name}} --> {{state.state}} [{{relative_time(state.last_changed)}}]{% endfor %} 27 | 28 | *Lights*: 29 | {% for state in states.light%} 30 | - {{state.attributes.friendly_name}} --> {{state.state}} [{{relative_time(state.last_changed)}}]{% endfor %} 31 | 32 | 33 | ``` 34 | 35 | **v0.7** - Cambios en sistema de alarma: 36 | - Se puede definir un tiempo máximo de alarma conectada (pasado éste, la alarma sigue activada pero vuelve al estado de reposo; también apaga los relés asociados) 37 | - Se puede definir un tiempo de repetición del aviso de alarma activada, mientras no se apague o resetee. 38 | - Se pueden definir luces RGB para simular una sirena visual cuando salte la alarma. 39 | - Con la alarma activada, se toma nota de los dispositivos BT que entran en escena. 40 | 41 | 42 | 43 | # '''*Switchs*: 44 | # {% for state in states.switch%} 45 | # - {{state.attributes.friendly_name}} --> {{state.state}} [{{relative_time(state.last_changed)}}]{% endfor %} 46 | # 47 | # *Binary sensors*: 48 | # {% for state in states.binary_sensor%} 49 | # - {{state.attributes.friendly_name}} [{{state.attributes.device_class}}] --> {{state.state}} [{{relative_time(state.last_changed)}}]{% endfor %} 50 | # 51 | # *Sensors*: 52 | # {% for state in states.sensor%} 53 | # - {{state.attributes.friendly_name}} --> {{state.state}} [{{relative_time(state.last_changed)}}]{% endfor %} 54 | # 55 | # *Lights*: 56 | # {% for state in states.light%} 57 | # - {{state.attributes.friendly_name}} --> {{state.state}} [{{relative_time(state.last_changed)}}]{% endfor %} 58 | # 59 | # 60 | # *Switchs*: 61 | # {% for state in states.switch%} 62 | # - {{state.attributes.entity_id}} --> {{state.state}} [{{relative_time(state.last_changed)}}]{% endfor %} 63 | # 64 | # *Binary sensors*: 65 | # {% for state in states.binary_sensor%} 66 | # - {{state.entity_id}} [{{state.attributes.device_class}}] --> {{state.state}} [{{relative_time(state.last_changed)}}]{% endfor %} 67 | # 68 | # *Sensors*: 69 | # {% for state in states.sensor%} 70 | # - {{state.entity_id}} --> {{state.state}} [{{relative_time(state.last_changed)}}]{% endfor %} 71 | # 72 | # *Lights*: 73 | # {% for state in states.light%} 74 | # - {{state.entity_id}} --> {{state.state}} [{{relative_time(state.last_changed)}}]{% endfor %} 75 | # 76 | 77 | # 78 | # 79 | # - switch.systemd_appdaemon --> on [1 hour] 80 | # - switch.systemd_homebridge --> on [1 hour] 81 | # - switch.toggle_config_kodi_ambilight --> on [1 hour] 82 | # 83 | # *Binary sensors*: 84 | # 85 | # - binary_sensor.email_online [connectivity] --> on [1 hour] 86 | # - binary_sensor.internet_online [connectivity] --> on [1 hour] 87 | # - binary_sensor.ios_online [connectivity] --> on [1 hour] 88 | # - binary_sensor.kodi_online [connectivity] --> on [1 hour] 89 | # 90 | # - binary_sensor.pushbullet_online [connectivity] --> on [1 hour] 91 | # - binary_sensor.router_on [connectivity] --> on [1 hour] 92 | # - binary_sensor.services_notok [safety] --> off [1 hour] 93 | # - binary_sensor.telegram_online [connectivity] --> on [1 hour] 94 | # 95 | # *Sensors*: 96 | # 97 | # - sensor.alarm_clock_hour --> 8 [1 hour] 98 | # - sensor.alarm_clock_minute --> 0 [1 hour] 99 | # 100 | # - sensor.cpu_use --> 3 [27 seconds] 101 | # - sensor.cpu_use_rpi2h --> 4 [16 seconds] 102 | # - sensor.cpu_use_rpi2mpd --> 2 [1 minute] 103 | # 104 | # - sensor.disk_use_home --> 30.0 [59 minutes] 105 | # - sensor.error_counter_notifiers --> 0 [1 hour] 106 | # 107 | # 108 | # - sensor.ip_externa --> 185.97.169.163 [1 hour] 109 | # - sensor.iphone_battery_level --> 74 [1 hour] 110 | # - sensor.iphone_battery_state --> Unplugged [1 hour] 111 | # - sensor.last_boot --> 2017-03-24 [1 hour] 112 | # - sensor.ram_free --> 654.7 [27 seconds] 113 | # - sensor.ram_free_rpi2h --> 393.2 [47 seconds] 114 | # - sensor.ram_free_rpi2mpd --> 594.3 [1 second] 115 | # 116 | # - sensor.speedtest_download --> 46.81 [11 minutes] 117 | # - sensor.speedtest_ping --> 19.21 [11 minutes] 118 | # - sensor.speedtest_upload --> 9.14 [11 minutes] 119 | # 120 | # - sensor.villena_cloud_coverage --> 20 [1 hour] 121 | # - sensor.villena_condition --> few clouds [1 hour] 122 | # - sensor.villena_forecast --> Clouds [1 hour] 123 | # - sensor.villena_humidity --> 50 [58 minutes] 124 | # - sensor.villena_pressure --> 1013 [1 hour] 125 | # - sensor.villena_rain --> not raining [1 hour] 126 | # - sensor.villena_temperature --> 14.0 [1 hour] 127 | # - sensor.villena_wind_speed --> 2.6 [8 seconds] 128 | # - sensor.warning_counter_core --> 0 [1 hour] 129 | # - sensor.yr_symbol --> 2 [1 hour] 130 | -------------------------------------------------------------------------------- /conf/appdaemon.yaml: -------------------------------------------------------------------------------- 1 | # HADashboard: {} 2 | HASS: 3 | ha_key: !secret ha_key 4 | ha_url: !secret ha_url 5 | 6 | 7 | AppDaemon: 8 | app_dir: /home/homeassistant/appdaemon_apps/apps 9 | errorfile: /home/homeassistant/appdaemon_err.log 10 | logfile: /home/homeassistant/appdaemon.log 11 | threads: 30 12 | 13 | # EXTRA - common vars 14 | base_url: !secret base_url 15 | path_base_data: /mnt/usbdrive 16 | path_ha_conf: /home/homeassistant/.homeassistant 17 | 18 | notifier: notify.ios_iphone 19 | chatid_sensor: sensor.telegram_default_chatid 20 | bot_chatids: !secret bot_chatids 21 | bot_group: notify.telegram_group 22 | bot_group_target: !secret bot_group_target 23 | bot_name: !secret bot_name 24 | bot_nicknames: !secret bot_nicknames 25 | bot_notifier: telegram_bot.send_message 26 | 27 | media_player: media_player.kodi 28 | 29 | media_player_mopidy: media_player.dormitorio_mopidy 30 | mopidy_ip: !secret mopidy_ip 31 | mopidy_port: !secret mopidy_port 32 | -------------------------------------------------------------------------------- /conf/apps.yaml: -------------------------------------------------------------------------------- 1 | AutoFeedback: 2 | class: EventListener 3 | module: bot_event_listener 4 | lights_notif: light.cuenco 5 | 6 | FamilyTracker: 7 | class: FamilyTracker 8 | module: family_tracker 9 | home_group: group.family 10 | people: # Content of home_group: chat_id and optional extra_tracker 11 | group.eugenio: 12 | chat_id_idx: 0 13 | extra_tracker: input_boolean.eu_presence 14 | group.mary: 15 | chat_id_idx: 1 16 | extra_tracker: input_boolean.carmen_presence 17 | 18 | EnerpiPeakNotifier: 19 | class: EnerpiPeakNotifier 20 | module: enerpi_alarm 21 | camera: camera.enerpi_tile_power 22 | constrain_input_boolean: input_boolean.switch_control_enerpi_max_power 23 | control: sensor.enerpi_power 24 | max_power_kw: input_number.enerpi_max_power 25 | max_power_kw_reset: input_number.enerpi_max_power_reset 26 | min_time_high: 15 27 | min_time_low: 60 28 | 29 | KodiMediaSelect: 30 | class: DynamicKodiInputSelect 31 | module: kodi_input_select 32 | 33 | KodiYoutube: 34 | module: youtube_search 35 | class: YoutubeSearch 36 | input_select: input_select.youtube_videos 37 | input_text: input_text.q_youtube 38 | media_player: media_player.kodi 39 | youtube_key: !secret youtube_key 40 | 41 | KodiNotifier: 42 | class: KodiAssistant 43 | module: kodi_ambient_lights 44 | lights_dim_off: light.bola_pequena,light.pie_sofa,light.lamparita,light.cuenco,light.pie_tv 45 | lights_dim_on: light.pie_sofa 46 | lights_off: light.bola_grande,light.central 47 | switch_dim_lights_use: switch.toggle_kodi_ambilight 48 | 49 | MorningAlarmClock: 50 | class: AlarmClock 51 | module: morning_alarm_clock 52 | alarm_time: sensor.alarm_clock_time 53 | alarmdays: mon,tue,wed,thu,fri,sat 54 | constrain_input_boolean: input_boolean.alarm_clock_status 55 | sunrise_duration: 300 56 | duration_volume_ramp_sec: 300 57 | max_volume: 25 58 | postponer_minutos: 7 59 | room_select: input_select.room_altavoces 60 | lights_alarm: group.luces_dormitorio 61 | manual_trigger: input_boolean.manual_trigger_lacafetera 62 | 63 | MotionLightsSalon: 64 | class: MotionLights 65 | module: motion_lights 66 | constrain_end_time: sunrise + 03:00:00 67 | constrain_input_boolean: input_boolean.switch_motion_lights 68 | constrain_input_boolean_2: input_boolean.switch_master_alarm 69 | constrain_start_time: sunset - 02:00:00 70 | lights_check_off: light.pie_sofa,light.lamparita,light.pie_tv,light.central 71 | lights_motion: light.bola_grande,light.bola_pequena,light.cuenco 72 | motion_light_timeout: input_number.light_duration_after_motion 73 | pir: binary_sensor.pir_salon 74 | 75 | # ------------------------------------------------------ 76 | # ------------------------------------------------------ 77 | 78 | ALARM: 79 | class: MotionAlarm 80 | module: motion_alarm_push_email 81 | 82 | path_base_data: /mnt/usbdrive 83 | path_ha_conf: /home/homeassistant/.homeassistant 84 | 85 | # ------------------------------------------------------ 86 | # Configuración notifiers 87 | # ------------------------------------------------------ 88 | # TODO multiple notifiers 89 | push_notifier: notify/ios_iphone 90 | # push_notifier: notify/pushbullet 91 | # push_notifier: notify/telegram_bot 92 | email_notifier: notify/gmail 93 | extra_push_params: '''{"cmd": "snapPicture2", "usr": "vigiferia_cam_1", "pwd": "cm136sdgq28r"}''' 94 | # extra_data_push_params: '{"url": {"badge": 1, "sound": "US-EN-Morgan-Freeman-Motion-Detected.wav", "category": "ALARMSOUNDED"}' 95 | # extra_data_push_params: '{"push": {"badge": 1, "sound": "US-EN-Morgan-Freeman-Motion-Detected.wav", "category": "ALARMSOUNDED"}' 96 | # "attachment": {"url": img_url}}}'''' 97 | 98 | # ------------------------------------------------------ 99 | # Configuración raw binary sensors 100 | # ------------------------------------------------------ 101 | #raw_binary_sensors: binary_sensor.vibration_sensor_raw_rpi2h,binary_sensor.sound_sensor_raw_rpi2h 102 | #raw_binary_sensors_sufijo: _raw 103 | #raw_binary_sensors_time_off: 2 104 | 105 | # ------------------------------------------------------ 106 | # Configuración entidades de HA 107 | # ------------------------------------------------------ 108 | # Sensores de activación y switchs de uso (use default: True) 109 | pirs: binary_sensor.pir_salon,binary_sensor.pir_estudio_rpi2h,binary_sensor.pir_dormitorio_rpi2mpd,binary_sensor.esp1_pir,binary_sensor.esp3_pir 110 | use_pirs: switch.use_pir_salon,switch.use_pir_estudio_rpi2h,switch.use_pir_dormitorio_rpi2mpd,switch.use_esp1_pir,switch.use_esp3_pir 111 | camera_movs: binary_sensor.motioncam_salon,binary_sensor.motioncam_estudio 112 | use_cam_movs: switch.motioncam_escam,switch.motioncam_estudio 113 | extra_sensors: binary_sensor.vibration_sensor_rpi2h 114 | use_extra_sensors: switch.use_vibration_sensor_rpi2h 115 | 116 | # Usar pushbullet notificaciones (vs text email, default = True) 117 | usar_push_notifier: True 118 | # Si se define (off-> # comment), marca los segundos entre avisos de alarma conectada 119 | retry_push_alarm: 40 120 | # Si se define (off-> # comment), marca el tiempo máximo que estará la alarma conectada, si se dispara, en segundos 121 | max_time_alarm_on: 600 122 | 123 | # Videostreams asociados a sensores 124 | videostreams: 125 | camera.escam_qf001: 126 | - binary_sensor.motioncam_salon 127 | # - binary_sensor.pir_dormitorio_rpi2mpd 128 | # - binary_sensor.esp1_pir 129 | camera.picamera_salon: 130 | - binary_sensor.pir_salon 131 | camera.picamera_estudio: 132 | - binary_sensor.motioncam_estudio 133 | - binary_sensor.pir_estudio_rpi2h 134 | - binary_sensor.vibration_sensor_rpi2h 135 | - binary_sensor.esp3_pir 136 | 137 | # Cámaras, ip y params para jpg request 138 | # cameras_jpg_ip_secret: motioneye_jpg_cam_1,hasscam_jpg_cam_3,motioneye_jpg_cam_2 139 | # cameras_jpg_params_secret: foscam_url_cam_1_params,foscam_url_cam_2_params 140 | cameras_jpg_ip_secret: motioneye_jpg_cam_1,motioneye_jpg_cam_2 141 | 142 | # Interruptor de alarma y de modo silencioso (sin turn on relés) 143 | main_switch: switch.switch_master_alarm 144 | # silent_mode: input_boolean.silent_mode 145 | 146 | # Actuadores en caso de activación 147 | rele_sirena: light.flexo 148 | # rele_sirena: switch.rele_de_sirena 149 | # rele_sirena: switch.rele_1 150 | # rele_secundario: switch.rele_2 151 | # Avisador de actividad / prealarma / activación (opc) 152 | # led_act: switch.rele_de_sirena 153 | 154 | # Luces RGB para simulación de alarma visual 155 | # alarm_rgb_lights: light.bola_grande,light.bola_pequena,light.pie_sofa,light.lamparita,light.cuenco,light.pie_tv 156 | 157 | # Configuración de sensibilidad 158 | # ------------------------------------------------------ 159 | # Parámetro de tiempo de espera desde conexión a armado de alarma: 160 | espera_a_armado_sec: 15 161 | # Parámetro de tiempo de espera en pre-alarma para conectar la alarma si ocurre un nuevo evento: 162 | reset_prealarm_time_sec: 15 163 | # Segundos entre captura de eventos con la alarma conectada 164 | min_delta_sec_events: 2 165 | delta_secs_trigger: 30 166 | # Número de eventos máximo a incluir por informe en correo electrónico. Se limita eliminando eventos de baja prioridad 167 | num_max_eventos_por_informe: 10 168 | # Hora de envío del informe diario de eventos detectados (si los ha habido). Comentar con # para desactivar 169 | hora_informe: '9:30' 170 | -------------------------------------------------------------------------------- /conf/apps/binary_changing_sensors.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Automation task as a AppDaemon App for Home Assistant 4 | 5 | Aplicación para generar binary_sensors que representan ON para cambio de estado en los últimos X segundos, 6 | OFF si el último cambio es más antiguo, a partir de otros binary_sensors para los que se obvia el valor booleano y 7 | se atiende a la antigüedad del mismo. 8 | 9 | Los binary_sensors generados copian los atributos de los sensores "en bruto", y se nombran eliminando un sufijo 10 | especificado, que deber estar contenido en el nombre de la entidad "en bruto". Ejemplo: 11 | 12 | "binary_sensor.my_sensor_raw" + sufijo "_raw" ---> "binary_sensor.my_sensor" 13 | 14 | """ 15 | import appdaemon.appapi as appapi 16 | import datetime as dt 17 | from dateutil.parser import parse 18 | from math import ceil 19 | 20 | 21 | DEFAULT_RAWBS_SECS_OFF = 10 22 | 23 | 24 | # noinspection PyClassHasNoInit 25 | class PublisherRawSensors(appapi.AppDaemon): 26 | """App for publishing binary_sensors turned on as changed in X seconds.""" 27 | _raw_sensors = None 28 | _raw_sensors_sufix = None 29 | _raw_sensors_seconds_to_off = None 30 | _raw_sensors_last_states = {} 31 | _raw_sensors_attributes = {} 32 | 33 | def initialize(self): 34 | """AppDaemon required method for app init.""" 35 | 36 | self._raw_sensors = self.args.get('raw_binary_sensors', None) 37 | if self._raw_sensors is not None: 38 | self._raw_sensors = self._raw_sensors.split(',') 39 | self._raw_sensors_sufix = self.args.get('raw_binary_sensors_sufijo', '_raw') 40 | # Persistencia en segundos de último valor hasta considerarlos 'off' 41 | self._raw_sensors_seconds_to_off = int(self.args.get('raw_binary_sensors_time_off', DEFAULT_RAWBS_SECS_OFF)) 42 | 43 | # Handlers de cambio en raw binary_sensors: 44 | l1, l2 = 'attributes', 'last_changed' 45 | for s in self._raw_sensors: 46 | self._raw_sensors_attributes[s] = (s.replace(self._raw_sensors_sufix, ''), self.get_state(s, l1)) 47 | self._raw_sensors_last_states[s] = [parse(self.get_state(s, l2)).replace(tzinfo=None), False] 48 | self.listen_state(self._turn_on_raw_sensor_on_change, s) 49 | # self.log('seconds_to_off: {}'.format(self._raw_sensors_seconds_to_off)) 50 | # self.log('attributes_sensors: {}'.format(self._raw_sensors_attributes)) 51 | # self.log('last_changes: {}'.format(self._raw_sensors_last_states)) 52 | [self.set_state(dev, state='off', attributes=attrs) for dev, attrs in self._raw_sensors_attributes.values()] 53 | next_run = self.datetime() + dt.timedelta(seconds=self._raw_sensors_seconds_to_off) 54 | self.run_every(self._turn_off_raw_sensor_if_not_updated, next_run, self._raw_sensors_seconds_to_off) 55 | 56 | # noinspection PyUnusedLocal 57 | def _turn_on_raw_sensor_on_change(self, entity, attribute, old, new, kwargs): 58 | _, last_st = self._raw_sensors_last_states[entity] 59 | self._raw_sensors_last_states[entity] = [self.datetime(), True] 60 | if not last_st: 61 | name, attrs = self._raw_sensors_attributes[entity] 62 | self.set_state(name, state='on', attributes=attrs) 63 | self.log('TURN ON "{}" (de {} a {} --> {})'.format(entity, old, new, name)) 64 | 65 | # noinspection PyUnusedLocal 66 | def _turn_off_raw_sensor_if_not_updated(self, *kwargs): 67 | now = self.datetime() 68 | for s, (ts, st) in self._raw_sensors_last_states.copy().items(): 69 | if st and ceil((now - ts).total_seconds()) >= self._raw_sensors_seconds_to_off: 70 | self.log('TURN OFF "{}" (last ch: {})'.format(s, ts)) 71 | name, attrs = self._raw_sensors_attributes[s] 72 | self._raw_sensors_last_states[s] = [now, False] 73 | self.set_state(name, state='off', attributes=attrs) 74 | -------------------------------------------------------------------------------- /conf/apps/enerpi_alarm.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Automation task as a AppDaemon App for Home Assistant - enerPI PEAK POWER notifications 4 | 5 | * This is the AppDaemon adaptation of these yaml automations (Maxpower & MaxpowerOff): 6 | 7 | ``` 8 | - alias: Maxpower 9 | trigger: 10 | platform: template 11 | value_template: "{% if (states('sensor.enerpi_power')|float / 1000 > 12 | states.input_number.enerpi_max_power.state|float) %}true{% else %}false{% endif %}" 13 | condition: 14 | condition: and 15 | conditions: 16 | - condition: state 17 | entity_id: input_boolean.switch_control_enerpi_max_power 18 | state: 'on' 19 | - condition: state 20 | entity_id: input_boolean.state_enerpi_alarm_max_power 21 | state: 'off' 22 | for: 23 | seconds: 30 24 | action: 25 | - service: homeassistant.turn_on 26 | entity_id: input_boolean.state_enerpi_alarm_max_power 27 | - service: notify.ios 28 | data_template: 29 | title: "Alto consumo eléctrico!" 30 | message: "Potencia actual: {{ states.sensor.enerpi_power.state }} W. Ojo con los cortes de ICP por exceso..." 31 | data: 32 | push: 33 | badge: '{{ states.sensor.enerpi_power.state }}' 34 | sound: "US-EN-Morgan-Freeman-Vacate-The-Premises.wav" 35 | category: "ALARM" # Needs to match the top level identifier you used in the ios configuration 36 | 37 | - alias: MaxpowerOff 38 | trigger: 39 | platform: template 40 | value_template: > 41 | {{ states('sensor.enerpi_power')|float / 1000 < states.input_number.enerpi_max_power_reset.state|float }} 42 | condition: 43 | condition: and 44 | conditions: 45 | - condition: state 46 | entity_id: input_boolean.switch_control_enerpi_max_power 47 | state: 'on' 48 | - condition: state 49 | entity_id: input_boolean.state_enerpi_alarm_max_power 50 | state: 'on' 51 | for: 52 | minutes: 1 53 | action: 54 | - service: homeassistant.turn_off 55 | entity_id: input_boolean.state_enerpi_alarm_max_power 56 | - service: notify.ios 57 | data_template: 58 | title: "Consumo eléctrico: Normal" 59 | message: "Potencia eléctrica actual: {{ states.sensor.enerpi_power.state }} W. 60 | Ya no hay peligro de corte por sobre-consumo." 61 | ``` 62 | """ 63 | import datetime as dt 64 | import appdaemon.appapi as appapi 65 | 66 | 67 | LOG_LEVEL = 'INFO' 68 | DEFAULT_UPPER_LIMIT_KW = 4 69 | DEFAULT_LOWER_LIMIT_KW = 2 70 | DEFAULT_MIN_TIME_UPPER_SEC = 3 71 | DEFAULT_MIN_TIME_LOWER_SEC = 60 72 | MASK_MSG_MAX_POWER = {"title": "Alto consumo eléctrico!", 73 | "message": "Pico de potencia: {} W en {}"} 74 | MASK_MSG_MAX_POWER_RESET = {"title": "Consumo eléctrico: Normal", 75 | "message": "Potencia normal desde {}, Pico de potencia: {} W."} 76 | 77 | 78 | # noinspection PyClassHasNoInit 79 | class EnerpiPeakNotifier(appapi.AppDaemon): 80 | """App for Notifying the power peaks when they are greater than a certain limit, and after that, 81 | notify when back to normal (lower than another user defined limit).""" 82 | 83 | # Limit Values 84 | _upper_limit = None 85 | _min_time_upper = None 86 | _lower_limit = None 87 | _min_time_lower = None 88 | 89 | # App user inputs 90 | # _switch_on_off_app = None --> `constrain_input_boolean` 91 | _main_power = None 92 | _notifier = None 93 | _target_sensor = None 94 | _camera = None 95 | _slider_upper_limit = None 96 | _slider_lower_limit = None 97 | 98 | _alarm_state = False 99 | _last_trigger = None 100 | _current_peak = 0 101 | 102 | def initialize(self): 103 | """AppDaemon required method for app init.""" 104 | self._main_power = self.args.get('control') 105 | conf_data = dict(self.config['AppDaemon']) 106 | self._notifier = conf_data.get('notifier').replace('.', '/') 107 | self._target_sensor = conf_data.get('chatid_sensor') 108 | self._camera = self.args.get('camera') 109 | self._min_time_upper = int(self.args.get('min_time_high', DEFAULT_MIN_TIME_UPPER_SEC)) 110 | self._min_time_lower = int(self.args.get('min_time_low', DEFAULT_MIN_TIME_LOWER_SEC)) 111 | 112 | # App user inputs 113 | self._slider_upper_limit = self.args.get('max_power_kw', '') 114 | self._slider_lower_limit = self.args.get('max_power_kw_reset', '') 115 | self._upper_limit = DEFAULT_UPPER_LIMIT_KW * 1000 116 | self._lower_limit = DEFAULT_LOWER_LIMIT_KW * 1000 117 | if self._slider_upper_limit: 118 | try: 119 | self._upper_limit = int(1000 * float(self._slider_upper_limit)) 120 | except ValueError: 121 | state = self.get_state(self._slider_lower_limit) 122 | if state: 123 | self._upper_limit = int(1000 * float(self.get_state(self._slider_upper_limit))) 124 | self.listen_state(self._slider_limit_change, self._slider_upper_limit) 125 | if self._slider_lower_limit: 126 | try: 127 | self._lower_limit = int(1000 * float(self._slider_lower_limit)) 128 | except ValueError: 129 | state = self.get_state(self._slider_lower_limit) 130 | if state: 131 | self._lower_limit = int(1000 * float(self.get_state(self._slider_lower_limit))) 132 | self.listen_state(self._slider_limit_change, self._slider_lower_limit) 133 | elif self._slider_upper_limit: 134 | self._lower_limit = self._upper_limit // 2 135 | 136 | # Listen for Main Power changes: 137 | self.listen_state(self._main_power_change, self._main_power) 138 | 139 | self.log('EnerpiPeakNotifier Initialized. P={}, with P>{} W for {} secs, (low={} W for {} secs). Notify: {}' 140 | .format(self._main_power, self._upper_limit, self._min_time_upper, 141 | self._lower_limit, self._min_time_lower, self._notifier)) 142 | 143 | def _get_notif_data(self, reset_alarm=False): 144 | time_now = '{:%H:%M:%S}'.format(self._last_trigger) if self._last_trigger is not None else '???' 145 | if reset_alarm: 146 | data_msg = MASK_MSG_MAX_POWER_RESET.copy() 147 | data_msg["message"] = data_msg["message"].format(time_now, self._current_peak) 148 | else: 149 | data_msg = MASK_MSG_MAX_POWER.copy() 150 | data_msg["message"] = data_msg["message"].format(self._current_peak, time_now) 151 | return data_msg 152 | 153 | def _make_ios_message(self, reset_alarm=False): 154 | data_msg = self._get_notif_data(reset_alarm) 155 | if reset_alarm: 156 | data_msg["data"] = {"push": {"category": "camera", "badge": 0}, 157 | "entity_id": self._camera} 158 | else: 159 | data_msg["data"] = { 160 | "push": { 161 | "category": "camera", "badge": 1, 162 | "sound": "US-EN-Morgan-Freeman-Vacate-The-Premises.wav"}, 163 | "entity_id": self._camera} 164 | return data_msg 165 | 166 | def _make_telegram_message(self, reset_alarm=False): 167 | data_msg = self._get_notif_data(reset_alarm) 168 | data_msg["target"] = self.get_state(self._target_sensor) 169 | data_msg["inline_keyboard"] = [[('Luces ON', '/luceson'), 170 | ('Luces OFF', '/lucesoff')], 171 | [('Potencia eléctrica', '/enerpi'), 172 | ('Grafs. enerPI', '/enerpitiles')], 173 | [('Status', '/status'), ('+', '/init')]] 174 | return data_msg 175 | 176 | # noinspection PyUnusedLocal 177 | def _slider_limit_change(self, entity, attribute, old, new, kwargs): 178 | if entity == self._slider_upper_limit: 179 | self._upper_limit = int(1000 * float(new)) 180 | elif entity == self._slider_lower_limit: 181 | self._lower_limit = int(1000 * float(new)) 182 | self.log('LIMIT CHANGE FROM "{}" TO "{}" --> upper_limit={} W, lower_limit={} W' 183 | .format(old, new, self._upper_limit, self._lower_limit)) 184 | 185 | # noinspection PyUnusedLocal 186 | def _main_power_change(self, entity, attribute, old, new, kwargs): 187 | """Power Peak ALARM logic control.""" 188 | now = dt.datetime.now() 189 | new = int(new) 190 | # self.log('DEBUG main_power_change in {}: attr={}; from "{}" to "{}"'.format(entity, attribute, old, new)) 191 | # Update peak 192 | if new > self._current_peak: 193 | self._current_peak = new 194 | if not self._alarm_state and (new > self._upper_limit): # Pre-Alarm state, before trigger 195 | # Prealarm 196 | if self._last_trigger is None: # Start power peak event 197 | self.log('New power peak event at {} with P={} W'.format(now, new), level=LOG_LEVEL) 198 | self._last_trigger = now 199 | elif (now - self._last_trigger).total_seconds() > self._min_time_upper: 200 | # TRIGGER ALARM 201 | alarm_msg = self._make_ios_message() 202 | self.log('TRIGGER ALARM with msg={}' 203 | .format(alarm_msg), level=LOG_LEVEL) 204 | self.call_service(self._notifier, **alarm_msg) 205 | self.call_service('telegram_bot/send_message', 206 | **self._make_telegram_message()) 207 | self._alarm_state = True 208 | self._last_trigger = now 209 | # else: # wait some more time (this is the same power peak event, waiting min time to trigger alarm) 210 | # pass 211 | elif self._alarm_state: # Alarm state, waiting for reset 212 | if new < self._lower_limit: 213 | # self.log('IN ALARM MODE!!') 214 | if (now - self._last_trigger).total_seconds() > self._min_time_lower: 215 | self.log('RESET ALARM MODE at {}'.format(now), level=LOG_LEVEL) 216 | # RESET ALARM 217 | self.call_service( 218 | self._notifier, 219 | **self._make_ios_message(reset_alarm=True)) 220 | self.call_service( 221 | 'telegram_bot/send_message', 222 | **self._make_telegram_message(reset_alarm=True)) 223 | self._alarm_state = False 224 | self._last_trigger = None 225 | self._current_peak = 0 226 | else: 227 | self._last_trigger = now 228 | else: # Normal operation, reset last trigger if no more in min_time_lower 229 | if (self._last_trigger is not None) and ((now - self._last_trigger).total_seconds() > self._min_time_lower): 230 | self.log('RESET LAST TRIGGER (was in {})'.format(self._last_trigger), level=LOG_LEVEL) 231 | self._last_trigger = None 232 | -------------------------------------------------------------------------------- /conf/apps/example_dumb_bot.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Automation task as a AppDaemon App for Home Assistant 4 | 5 | Example of a dumb, but interactive, Telegram Bot. 6 | 7 | """ 8 | 9 | import appdaemon.appapi as appapi 10 | 11 | 12 | class TelegramBotEventListener(appapi.AppDaemon): 13 | """Event listener for Telegram bot events.""" 14 | 15 | def initialize(self): 16 | """Listen to Telegram Bot events.""" 17 | self.listen_event(self.receive_telegram_text, 'telegram_text') 18 | self.listen_event(self.receive_telegram_callback, 'telegram_callback') 19 | 20 | def receive_telegram_callback(self, event_id, payload_event, *args): 21 | """Event listener for Telegram callback queries.""" 22 | assert event_id == 'telegram_callback' 23 | data_callback = payload_event['data'] 24 | callback_id = payload_event['id'] 25 | user_id = payload_event['user_id'] 26 | 27 | if data_callback == '/edit': # Message editor: 28 | # Answer callback query 29 | data = dict(callback_query= 30 | dict(callback_query_id=callback_id, show_alert=True)) 31 | self.call_service('notify/telegram_bot', 32 | target=user_id, 33 | message='Editing the message!', 34 | data=data) 35 | 36 | # Edit the message origin of the callback query 37 | msg_id = payload_event['message']['message_id'] 38 | user = payload_event['from_first'] 39 | title = '*Message edit*' 40 | msg = 'Callback received from %s. Message id: %s. Data: ``` %s ```' 41 | keyboard = ['/edit,/NO', '/remove button'] 42 | data = dict(edit_message=dict(message_id=msg_id), 43 | disable_notification=True, 44 | inline_keyboard=keyboard) 45 | self.call_service('notify/telegram_bot', 46 | target=user_id, 47 | title=title, 48 | message=msg % (user, msg_id, data_callback), 49 | data=data) 50 | 51 | elif data_callback == '/remove button': # Keyboard editor: 52 | # Answer callback query 53 | data = dict(callback_query= 54 | dict(callback_query_id=callback_id, show_alert=False)) 55 | self.call_service('notify/telegram_bot', 56 | target=user_id, 57 | message='Callback received for editing the ' 58 | 'inline keyboard!', 59 | data=data) 60 | 61 | # Edit the keyboard 62 | new_keyboard = ['/edit,/NO'] 63 | data = dict(edit_replymarkup=dict(message_id='last'), 64 | disable_notification=True, 65 | inline_keyboard=new_keyboard) 66 | self.call_service('notify/telegram_bot', 67 | target=user_id, 68 | message='', 69 | data=data) 70 | 71 | elif data_callback == '/NO': # Only Answer to callback query 72 | data = dict(callback_query= 73 | dict(callback_query_id=callback_id, show_alert=False)) 74 | self.call_service('notify/telegram_bot', 75 | target=user_id, 76 | message='OK, you said no!', 77 | data=data) 78 | 79 | def receive_telegram_text(self, event_id, payload_event, *args): 80 | """Text repeater.""" 81 | assert event_id == 'telegram_text' 82 | user_id = payload_event['user_id'] 83 | msg = 'You said: ``` %s ```' % payload_event['text'] 84 | keyboard = ['/edit,/NO', '/remove button'] 85 | self.call_service('notify/telegram_bot', 86 | title='*Dumb automation*', 87 | target=user_id, 88 | message=msg, 89 | data=dict(disable_notification=True, 90 | inline_keyboard=keyboard)) 91 | -------------------------------------------------------------------------------- /conf/apps/family_tracker.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Automation task as a AppDaemon App for Home Assistant 4 | 5 | Event listener for actions triggered from a Telegram Bot chat 6 | or from iOS notification actions. 7 | 8 | Harcoded custom logic for controlling HA with feedback from these actions. 9 | 10 | """ 11 | from datetime import datetime as dt 12 | from dateutil.parser import parse 13 | 14 | import appdaemon.appapi as appapi 15 | import appdaemon.conf as conf 16 | 17 | 18 | # DELAY_TO_SET_DEFAULT_TARGET = 1800 # sec 19 | DELAY_TO_SET_DEFAULT_TARGET = 120 # sec 20 | 21 | 22 | # noinspection PyClassHasNoInit 23 | class FamilyTracker(appapi.AppDaemon): 24 | """Family Tracker.""" 25 | 26 | _tracking_state = None 27 | _telegram_targets = None 28 | _notifier = None 29 | _timer_update_target = None 30 | _base_url = None 31 | _anybody_home = None 32 | 33 | def initialize(self): 34 | """AppDaemon required method for app init.""" 35 | config = dict(self.config['AppDaemon']) 36 | _chatids = [int(x) for x in config.get('bot_chatids').split(',')] 37 | self._notifier = config.get('notifier').replace('.', '/') 38 | self._base_url = config.get('base_url').replace('.', '/') 39 | self._anybody_home = False 40 | 41 | # Get home group 42 | home_group = self.args.get('home_group', 'group.family') 43 | 44 | # Get default chat_id for home 45 | default_chat_id = config.get('bot_group_target') 46 | self._telegram_targets = {"default": ('Casa', default_chat_id)} 47 | 48 | people_track = self.args.get('people', {}) 49 | # self.log("people_track: {}".format(people_track)) 50 | 51 | # Get devices to track: 52 | _devs_track = self.get_state( 53 | home_group, attribute='attributes')['entity_id'] 54 | 55 | # Get tracking states: 56 | self._tracking_state = {} 57 | for dev in _devs_track: 58 | target = None 59 | name = self.friendly_name(dev) 60 | tracking_st = [self.get_state(dev), 61 | parse(self.get_state(dev, attribute='last_changed') 62 | ).astimezone(conf.tz)] 63 | self._tracking_state[dev] = tracking_st 64 | 65 | # Listen for state changes: 66 | self.listen_state(self.track_zone_ch, dev, old="home", duration=60) 67 | self.listen_state(self.track_zone_ch, dev, new="home", duration=2) 68 | 69 | # Get details for each device/group: 70 | if dev in people_track: 71 | # Get telegram target 72 | if 'chat_id_idx' in people_track[dev]: 73 | target = _chatids[people_track[dev]['chat_id_idx']] 74 | # Listen for extra devices (input_booleans): 75 | if 'extra_tracker' in people_track[dev]: 76 | dev_extra = people_track[dev]['extra_tracker'] 77 | extra_tracking_st = [ 78 | self.get_state(dev_extra), 79 | parse(self.get_state(dev, attribute='last_changed') 80 | ).astimezone(conf.tz)] 81 | self._telegram_targets[dev_extra] = (name, target) 82 | self._tracking_state[dev_extra] = extra_tracking_st 83 | self.listen_state(self.track_zone_ch, dev_extra) 84 | self._telegram_targets[dev] = (name, target) 85 | 86 | # Process (and write globals) who is at home 87 | self._who_is_at_home(False) 88 | 89 | def _make_notifications(self, exiting_home, telegram_target): 90 | if exiting_home: 91 | # Salida de casa: 92 | title = "¡Vuelve pronto!" 93 | message = "¿Apagamos luces o encendemos alarma?" 94 | cat = "away" 95 | keyboard = ['Activar alarma:/armado', 96 | 'Activar vigilancia:/vigilancia', 97 | 'Apagar luces:/lucesoff, +:/init'] 98 | else: 99 | # Llegada: 100 | title = "Welcome home!" 101 | message = "¿Qué puedo hacer por ti?" 102 | cat = "inhome" 103 | keyboard = ['Welcome:/llegada', 104 | 'Welcome + TV:/llegadatv', 105 | 'Ignorar:/ignorar, +:/init'] 106 | 107 | # Service calls payloads 108 | data_ios = { 109 | "title": title, 110 | "message": message, 111 | "data": {"push": {"badge": 1 if exiting_home else 0, 112 | "category": cat}} 113 | } 114 | data_telegram = { 115 | "title": '*{}*'.format(title), 116 | "message": message + '\n[Go home]({})'.format(self._base_url), 117 | "inline_keyboard": keyboard, 118 | "target": telegram_target, 119 | "disable_notification": True, 120 | } 121 | return data_ios, data_telegram 122 | 123 | def _who_is_at_home(self, zone_changed): 124 | if self._timer_update_target is not None: 125 | self.cancel_timer(self._timer_update_target) 126 | self._timer_update_target = None 127 | 128 | people, new_target = {}, None 129 | for dev, (st, last_ch) in self._tracking_state.items(): 130 | at_home = (st == 'home') or (st == 'on') 131 | person, target = self._telegram_targets[dev] 132 | if person not in people: 133 | people[person] = at_home, last_ch, dev 134 | if at_home: 135 | new_target = target 136 | elif last_ch > people[person][1]: 137 | people[person] = at_home, last_ch, dev 138 | 139 | # Set default chat_id and people_home 140 | people_home = {k: v for k, v in people.items() if v[0]} 141 | new_anybody_home = len(people_home) > 0 142 | if not new_anybody_home and zone_changed: 143 | # Set last person exiting the house (at least for some time) 144 | last_dev = list(sorted(people.values(), key=lambda x: x[2]))[-1][2] 145 | _last_person, new_target = self._telegram_targets[last_dev] 146 | self._timer_update_target = self.run_in( 147 | self._who_is_at_home, DELAY_TO_SET_DEFAULT_TARGET, 148 | zone_changed=False) 149 | elif not new_anybody_home or len(people_home) > 1: 150 | new_target = self._telegram_targets["default"][1] 151 | else: 152 | self.log("WHO IS AT HOME? people: {}, zone_changed:{}, target:{}" 153 | .format(people, zone_changed, new_target)) 154 | 155 | if new_target is None: 156 | return 157 | 158 | self.call_service( 159 | 'python_script/set_telegram_chatid_sensor', chat_id=new_target) 160 | 161 | # Todo entradas - salidas de personas individuales 162 | if new_anybody_home != self._anybody_home: 163 | data_ios, data_telegram = self._make_notifications( 164 | not new_anybody_home, new_target) 165 | # self.log('IOS NOTIF: {}'.format(data_ios)) 166 | # self.log('TELEGRAM NOTIF: {}'.format(data_telegram)) 167 | self.call_service(self._notifier, **data_ios) 168 | self.call_service('telegram_bot/send_message', 169 | **data_telegram) 170 | self._anybody_home = new_anybody_home 171 | 172 | # noinspection PyUnusedLocal 173 | def track_zone_ch(self, entity, attribute, old, new, kwargs): 174 | """State change listener.""" 175 | last_st, last_ch = self._tracking_state[entity] 176 | self._tracking_state[entity] = [new, dt.now(tz=conf.tz)] 177 | 178 | # Process changes 179 | self._who_is_at_home(True) 180 | 181 | if last_st != old: 182 | self.log('!!BAD TRACKING_STATE_CHANGE "{}" from "{}" [!="{}"' 183 | ', changed at {}] to "{}"' 184 | .format(entity, old, last_st, last_ch, new)) 185 | else: 186 | self.log('TRACKING_STATE_CHANGE "{}" from "{}" [{}] to "{}"' 187 | .format(entity, old, last_ch, new)) 188 | -------------------------------------------------------------------------------- /conf/apps/kodi_ambient_lights.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Automation task as a AppDaemon App for Home Assistant 4 | 5 | This little app controls the ambient light when Kodi plays video, 6 | dimming some lights and turning off others, and returning to the 7 | initial state when the playback is finished. 8 | 9 | In addition, it also sends notifications when starting the video playback, 10 | reporting the video info in the message. 11 | For that, it talks with Kodi through its JSONRPC API by HA service calls. 12 | 13 | """ 14 | import datetime as dt 15 | from urllib import parse 16 | import appdaemon.appapi as appapi 17 | import appdaemon.utils as utils 18 | from homeassistant.components.media_player.kodi import ( 19 | EVENT_KODI_CALL_METHOD_RESULT) 20 | 21 | 22 | LOG_LEVEL = 'DEBUG' 23 | 24 | METHOD_GET_PLAYERS = "Player.GetPlayers" 25 | METHOD_GET_ITEM = "Player.GetItem" 26 | PARAMS_GET_ITEM = { 27 | "playerid": 1, 28 | "properties": ["title", "artist", "albumartist", "genre", "year", 29 | "rating", "album", "track", "duration", "playcount", 30 | "fanart", "plot", "originaltitle", "lastplayed", 31 | "firstaired", "season", "episode", "showtitle", 32 | "thumbnail", "file", "tvshowid", "watchedepisodes", 33 | "art", "description", "theme", "dateadded", "runtime", 34 | "starttime", "endtime"]} 35 | TYPE_ITEMS_NOTIFY = ['movie', 'episode'] 36 | TYPE_HA_ITEMS_NOTIFY = ['tvshow', 'movie'] 37 | # TYPE_ITEMS_IGNORE = ['channel', 'unknown'] # grabaciones: 'unknown' 38 | TELEGRAM_KEYBOARD_KODI = ['/luceson', '/ambilighttoggle, /ambilightconfig', 39 | '/pitemps, /tvshowsnext'] 40 | TELEGRAM_INLINEKEYBOARD_KODI = [ 41 | [('Luces ON', '/luceson')], 42 | [('Switch Ambilight', '/ambilighttoggle'), 43 | ('Ch. config', '/ambilightconfig')], 44 | [('Tª', '/pitemps'), ('Next TvShows', '/tvshowsnext')]] 45 | 46 | 47 | def _get_max_brightness_ambient_lights(): 48 | if utils.now_is_between('09:00:00', '19:00:00'): 49 | return 200 50 | elif utils.now_is_between('19:00:00', '22:00:00'): 51 | return 150 52 | elif utils.now_is_between('22:00:00', '04:00:00'): 53 | return 75 54 | return 25 55 | 56 | 57 | # noinspection PyClassHasNoInit 58 | class KodiAssistant(appapi.AppDaemon): 59 | """App for Ambient light control when playing video with KODI.""" 60 | 61 | _lights = None 62 | _light_states = {} 63 | 64 | _media_player = None 65 | _is_playing_video = False 66 | _item_playing = None 67 | _last_play = None 68 | 69 | _notifier_bot = 'telegram_bot' 70 | _target_sensor = None 71 | _ios_notifier = None 72 | 73 | def initialize(self): 74 | """AppDaemon required method for app init.""" 75 | conf_data = dict(self.config['AppDaemon']) 76 | 77 | _lights_dim_on = self.args.get('lights_dim_on', '').split(',') 78 | _lights_dim_off = self.args.get('lights_dim_off', '').split(',') 79 | _lights_off = self.args.get('lights_off', '').split(',') 80 | _switch_dim_group = self.args.get('switch_dim_lights_use') 81 | self._lights = {"dim": {"on": _lights_dim_on, "off": _lights_dim_off}, 82 | "off": _lights_off, 83 | "state": self.get_state(_switch_dim_group)} 84 | # Listen for ambilight changes to change light dim group: 85 | self.listen_state(self.ch_dim_lights_group, _switch_dim_group) 86 | 87 | self._media_player = conf_data.get('media_player') 88 | self._ios_notifier = conf_data.get('notifier').replace('.', '/') 89 | self._target_sensor = conf_data.get('chatid_sensor') 90 | 91 | # Listen for Kodi changes: 92 | self._last_play = utils.get_now() 93 | self.listen_state(self.kodi_state, self._media_player) 94 | self.listen_event(self._receive_kodi_result, 95 | EVENT_KODI_CALL_METHOD_RESULT) 96 | # self.log('KodiAssist Initialized with dim_lights_on={}, ' 97 | # 'dim_lights_off={}, off_lights={}.' 98 | # .format(self._lights['dim']['on'], self._lights['dim']['off'], 99 | # self._lights['off'])) 100 | 101 | def _ask_for_playing_item(self): 102 | self.call_service('media_player/kodi_call_method', 103 | entity_id=self._media_player, 104 | method=METHOD_GET_ITEM, **PARAMS_GET_ITEM) 105 | 106 | # noinspection PyUnusedLocal 107 | def _receive_kodi_result(self, event_id, payload_event, *args): 108 | result = payload_event['result'] 109 | method = payload_event['input']['method'] 110 | if event_id == EVENT_KODI_CALL_METHOD_RESULT \ 111 | and method == METHOD_GET_ITEM: 112 | if 'item' in result: 113 | item = result['item'] 114 | new_video = (self._item_playing is None 115 | or self._item_playing != item) 116 | self._is_playing_video = item['type'] in TYPE_ITEMS_NOTIFY 117 | self._item_playing = item 118 | delta = utils.get_now() - self._last_play 119 | if (self._is_playing_video and 120 | (new_video or delta > dt.timedelta(minutes=20))): 121 | self._last_play = utils.get_now() 122 | self._adjust_kodi_lights(play=True) 123 | # Notifications 124 | self._notify_ios_message(self._item_playing) 125 | self._notify_telegram_message(self._item_playing) 126 | else: 127 | self.log('RECEIVED BAD KODI RESULT: {}' 128 | .format(result), 'warn') 129 | elif event_id == EVENT_KODI_CALL_METHOD_RESULT \ 130 | and method == METHOD_GET_PLAYERS: 131 | self.log('KODI GET_PLAYERS RECEIVED: {}'.format(result)) 132 | 133 | def _get_kodi_info_params(self, item): 134 | """ 135 | media_content_id: { 136 | "unknown": "304004" 137 | } 138 | entity_picture: /api/media_player_proxy/media_player.kodi?token=... 139 | media_duration: 1297 140 | media_title: The One Where Chandler Takes A Bath 141 | media_album_name: 142 | media_season: 8 143 | media_episode: 13 144 | is_volume_muted: false 145 | media_series_title: Friends 146 | media_content_type: tvshow 147 | """ 148 | if item['type'] == 'episode': 149 | title = "{} S{:02d}E{:02d} {}".format( 150 | item['showtitle'], item['season'], 151 | item['episode'], item['title']) 152 | else: 153 | title = "Playing: {}".format(item['title']) 154 | if item['year']: 155 | title += " [{}]".format(item['year']) 156 | message = "{}\n∆T: {}.".format( 157 | item['plot'], dt.timedelta(hours=item['runtime'] / 3600)) 158 | img_url = None 159 | try: 160 | if 'thumbnail' in item: 161 | raw_img_url = item['thumbnail'] 162 | elif 'thumb' in item: 163 | raw_img_url = item['thumb'] 164 | elif 'poster' in item['art']: 165 | raw_img_url = item['art']['poster'] 166 | elif 'season.poster' in item['art']: 167 | raw_img_url = item['art']['season.poster'] 168 | else: 169 | self.log('No poster in item[art]={}'.format(item['art'])) 170 | k = list(item['art'].keys())[0] 171 | raw_img_url = item['art'][k] 172 | img_url = parse.unquote_plus( 173 | raw_img_url).rstrip('/').lstrip('image://') 174 | if ('192.168.' not in img_url) \ 175 | and img_url.startswith('http://'): 176 | img_url = img_url.replace('http:', 'https:') 177 | # self.log('MESSAGE: T={}, M={}, URL={}' 178 | # .format(title, message, img_url)) 179 | except KeyError as e: 180 | self.log('MESSAGE KeyError: {}; item={}'.format(e, item)) 181 | return title, message, img_url 182 | 183 | def _valid_image_url(self, img_url): 184 | if (img_url is not None) and img_url.startswith('http'): 185 | return True 186 | if img_url is not None: 187 | self.error('BAD IMAGE URL: {}'.format(img_url), level='ERROR') 188 | return False 189 | 190 | def _notify_ios_message(self, item): 191 | title, message, img_url = self._get_kodi_info_params(item) 192 | if self._valid_image_url(img_url): 193 | data_msg = {"title": title, "message": message, 194 | "data": {"attachment": {"url": img_url}, 195 | "push": {"category": "kodiplay"}}} 196 | else: 197 | data_msg = {"title": title, "message": message, 198 | "data": {"push": {"category": "kodiplay"}}} 199 | self.call_service(self._ios_notifier, **data_msg) 200 | 201 | def _notify_telegram_message(self, item): 202 | title, message, img_url = self._get_kodi_info_params(item) 203 | target = self.get_state(self._target_sensor) 204 | if self._valid_image_url(img_url): 205 | data_photo = { 206 | "url": img_url, 207 | "keyboard": TELEGRAM_KEYBOARD_KODI, 208 | "disable_notification": True} 209 | self.call_service('{}/send_photo'.format(self._notifier_bot), 210 | target=target, **data_photo) 211 | message + "\n{}\nEND".format(img_url) 212 | data_msg = {"message": message, "title": '*{}*'.format(title), 213 | "inline_keyboard": TELEGRAM_INLINEKEYBOARD_KODI, 214 | "disable_notification": True} 215 | self.call_service('{}/send_message'.format(self._notifier_bot), 216 | target=target, **data_msg) 217 | 218 | def _adjust_kodi_lights(self, play=True): 219 | k_l = self._lights['dim'][self._lights['state']] + self._lights['off'] 220 | for light_id in k_l: 221 | if play: 222 | light_state = self.get_state(light_id) 223 | attrs_light = self.get_state(light_id, attribute='attributes') 224 | attrs_light.update({"state": light_state}) 225 | self._light_states[light_id] = attrs_light 226 | max_brightness = _get_max_brightness_ambient_lights() 227 | if light_id in self._lights['off']: 228 | self.log('Apagando light {} para KODI PLAY' 229 | .format(light_id), LOG_LEVEL) 230 | self.call_service( 231 | "light/turn_off", entity_id=light_id, transition=2) 232 | elif ("brightness" in attrs_light.keys() 233 | ) and (attrs_light["brightness"] > max_brightness): 234 | self.log('Atenuando light {} para KODI PLAY' 235 | .format(light_id), LOG_LEVEL) 236 | self.call_service("light/turn_on", entity_id=light_id, 237 | transition=2, brightness=max_brightness) 238 | else: 239 | try: 240 | state_before = self._light_states[light_id] 241 | except KeyError: 242 | state_before = {} 243 | if ('state' in state_before) \ 244 | and (state_before['state'] == 'on'): 245 | try: 246 | new_state_attrs = { 247 | "xy_color": state_before["xy_color"], 248 | "brightness": state_before["brightness"]} 249 | except KeyError: 250 | new_state_attrs = { 251 | "color_temp": state_before["color_temp"], 252 | "brightness": state_before["brightness"]} 253 | self.log('Reponiendo light {}, con state_before={}' 254 | .format(light_id, state_before), LOG_LEVEL) 255 | self.call_service("light/turn_on", entity_id=light_id, 256 | transition=2, **new_state_attrs) 257 | else: 258 | self.log('Doing nothing with light {}, state_before={}' 259 | .format(light_id, state_before), LOG_LEVEL) 260 | 261 | # noinspection PyUnusedLocal 262 | def kodi_state(self, entity, attribute, old, new, kwargs): 263 | """Kodi state change main control.""" 264 | if new == 'playing': 265 | kodi_attrs = self.get_state( 266 | entity_id=self._media_player, attribute="attributes") 267 | self._is_playing_video = ( 268 | 'media_content_type' in kodi_attrs 269 | and kodi_attrs['media_content_type'] in TYPE_HA_ITEMS_NOTIFY) 270 | # self.log('KODI ATTRS: {}, is_playing_video={}' 271 | # .format(kodi_attrs, self._is_playing_video)) 272 | if self._is_playing_video: 273 | self._ask_for_playing_item() 274 | elif ((new == 'idle') and self._is_playing_video) or (new == 'off'): 275 | self._is_playing_video = False 276 | self._last_play = utils.get_now() 277 | self.log('KODI STOP. old:{}, new:{}, type_lp={}' 278 | .format(old, new, type(self._last_play)), LOG_LEVEL) 279 | # self._item_playing = None 280 | self._adjust_kodi_lights(play=False) 281 | 282 | # noinspection PyUnusedLocal 283 | def ch_dim_lights_group(self, entity, attribute, old, new, kwargs): 284 | """Change dim lights group with the change in the ambilight switch.""" 285 | self._lights['state'] = new 286 | self.log('Dim Lights group changed from {} to {}' 287 | .format(self._lights['dim'][old], self._lights['dim'][new])) 288 | -------------------------------------------------------------------------------- /conf/apps/kodi_input_select.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Automation task as a AppDaemon App for Home Assistant 4 | 5 | Populate dinamically an `input_select` with Kodi play options 6 | and react when selected. 7 | 8 | It reacts to `kodi_call_method_result` events, when the used API method is: 9 | - VideoLibrary.GetRecentlyAddedMovies 10 | - VideoLibrary.GetRecentlyAddedEpisodes 11 | - PVR.GetChannels 12 | """ 13 | 14 | import appdaemon.appapi as appapi 15 | from homeassistant.components.media_player.kodi import ( 16 | EVENT_KODI_CALL_METHOD_RESULT) 17 | 18 | ENTITY = 'input_select.kodi_results' 19 | MEDIA_PLAYER = 'media_player.kodi' 20 | DEFAULT_ACTION = "Nada que hacer" 21 | MAX_RESULTS = 20 22 | 23 | 24 | # noinspection PyClassHasNoInit 25 | class DynamicKodiInputSelect(appapi.AppDaemon): 26 | """App to populate an input select with Kodi API calls results.""" 27 | 28 | _ids_options = None 29 | _last_values = None 30 | 31 | def initialize(self): 32 | """Set up appdaemon app.""" 33 | self.listen_event(self._receive_kodi_result, 34 | EVENT_KODI_CALL_METHOD_RESULT) 35 | self.listen_state(self._change_selected_result, ENTITY) 36 | 37 | # Input select: 38 | self._ids_options = {DEFAULT_ACTION: None} 39 | self._last_values = [] 40 | 41 | # noinspection PyUnusedLocal 42 | def _receive_kodi_result(self, event_id, payload_event, *args): 43 | result = payload_event['result'] 44 | method = payload_event['input']['method'] 45 | 46 | if event_id == EVENT_KODI_CALL_METHOD_RESULT: 47 | if method == 'VideoLibrary.GetRecentlyAddedMovies': 48 | # values = list(filter(lambda r: not r['lastplayed'], 49 | # result['movies']))[:MAX_RESULTS] 50 | values = result['movies'][:MAX_RESULTS] 51 | data = [('{} ({})'.format(r['label'], r['year']), 52 | ('MOVIE', r['file'], None)) for r in values] 53 | self._ids_options.update(dict(zip(*zip(*data)))) 54 | labels = list(list(zip(*data))[0]) 55 | self._last_values = labels 56 | self.log('{} NEW MOVIE OPTIONS:\n{}' 57 | .format(len(labels), labels)) 58 | self.call_service('input_select/set_options', entity_id=ENTITY, 59 | options=[DEFAULT_ACTION] + labels) 60 | self.set_state(ENTITY, 61 | attributes={"friendly_name": 'Recent Movies', 62 | "icon": 'mdi:movie'}) 63 | elif method == 'VideoLibrary.GetRecentlyAddedEpisodes': 64 | values = result['episodes'] 65 | data = [('{} - {}'.format(r['showtitle'], r['label']), 66 | ('TVSHOW', r['file'], r['lastplayed'])) 67 | for r in values] 68 | d_data = dict(zip(*zip(*data))) 69 | labels = list(list(zip(*data))[0]) 70 | if not self._last_values or \ 71 | not all(map(lambda x: x in labels, self._last_values)): 72 | # First press --> filter non watched episodes 73 | data = filter(lambda x: not x[1][2], data) 74 | labels = list(list(zip(*data))[0]) 75 | self.log('{} NEW TVSHOW OPTIONS:\n{}' 76 | .format(len(labels), labels)) 77 | self._ids_options.update(d_data) 78 | 79 | self._last_values = labels 80 | self.call_service('input_select/set_options', entity_id=ENTITY, 81 | options=[DEFAULT_ACTION] + labels) 82 | self.set_state(ENTITY, 83 | attributes={"friendly_name": 'Recent TvShows', 84 | "icon": 'mdi:play-circle'}) 85 | elif method == 'PVR.GetChannels': 86 | values = result['channels'] 87 | data = [(r['label'], ('CHANNEL', r['channelid'], None)) 88 | for r in values] 89 | self._ids_options.update(dict(zip(*zip(*data)))) 90 | labels = list(list(zip(*data))[0]) 91 | self._last_values = labels 92 | self.log('{} NEW PVR OPTIONS:\n{}'.format(len(labels), labels)) 93 | self.call_service('input_select/set_options', entity_id=ENTITY, 94 | options=[DEFAULT_ACTION] + labels) 95 | self.set_state(ENTITY, 96 | attributes={"friendly_name": 'TV channels', 97 | "icon": 'mdi:play-box-outline'}) 98 | 99 | # noinspection PyUnusedLocal 100 | def _change_selected_result(self, entity, attribute, old, new, kwargs): 101 | if new != old: 102 | # self.log('SELECTED OPTION: {} (from {})'.format(new, old)) 103 | selected = self._ids_options[new] 104 | if selected: 105 | mediatype, file, _last_played = selected 106 | self.log('PLAY MEDIA: {} {} [file={}]' 107 | .format(mediatype, new, file)) 108 | self.call_service('media_player/play_media', 109 | entity_id=MEDIA_PLAYER, 110 | media_content_type=mediatype, 111 | media_content_id=file) 112 | -------------------------------------------------------------------------------- /conf/apps/morning_alarm_clock.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Automation task as a AppDaemon App for Home Assistant 4 | 5 | This little app is a not too simple alarm clock, 6 | which simulates a fast dawn with Hue lights, 7 | while waking up the home cinema system, 8 | waiting for the start of the broadcast of La Cafetera radio program to 9 | start playing it (or, if the alarm is at a different time of 10 | the typical emmision time, it just play the last published episode). 11 | 12 | For doing that, it talks directly with Kodi (or Mopidy, without any add-on) 13 | through its JSONRPC API, which has to run a specific Kodi Add-On: 14 | `plugin.audio.lacafetera` 15 | 16 | """ 17 | import appdaemon.appapi as appapi 18 | import appdaemon.conf as conf 19 | import datetime as dt 20 | from dateutil.parser import parse 21 | from functools import reduce 22 | import json 23 | import pytz 24 | import requests 25 | 26 | 27 | LOG_LEVEL = 'INFO' 28 | 29 | # Defaults para La Cafetera Alarm Clock: 30 | MIN_VOLUME = 1 31 | DEFAULT_MAX_VOLUME_MOPIDY = 60 32 | DEFAULT_DURATION_VOLUME_RAMP = 120 33 | DEFAULT_DURATION = 1.2 # h 34 | DEFAULT_EMISION_TIME = "08:30:00" 35 | DEFAULT_MIN_POSPONER = 9 36 | MAX_WAIT_TIME = dt.timedelta(minutes=10) 37 | STEP_RETRYING_SEC = 20 38 | WARM_UP_TIME_DELTA = dt.timedelta(seconds=25) 39 | MIN_INTERVAL_BETWEEN_EPS = dt.timedelta(hours=8) 40 | MASK_URL_STREAM_MOPIDY = "http://api.spreaker.com/listen/episode/{}/http" 41 | # TELEGRAM_KEYBOARD_ALARMCLOCK = ['/ducha', '/posponer', 42 | # '/despertadoroff', '/hasswiz, /init'] 43 | TELEGRAM_INLINE_KEYBOARD_ALARMCLOCK = [ 44 | [('A la ducha!', '/ducha'), 45 | ('Calefactor', '/service_call switch.toggle switch.calefactor')], 46 | [('Un poquito +', '/posponer'), ('OFF', '/despertadoroff')]] 47 | WEEKDAYS_DICT = {'mon': 0, 'tue': 1, 'wed': 2, 48 | 'thu': 3, 'fri': 4, 'sat': 5, 'sun': 6} 49 | 50 | SUNRISE_PHASES = [ 51 | {'brightness': 4, 'xy_color': [0.6051, 0.282], 'rgb_color': (62, 16, 17)}, 52 | {'brightness': 30, 'xy_color': [0.672, 0.327], 'rgb_color': (183, 66, 0)}, 53 | {'brightness': 60, 'xy_color': [0.629, 0.353], 'rgb_color': (224, 105, 19)}, 54 | {'brightness': 147, 'xy_color': [0.533, 0.421], 'rgb_color': (255, 175, 53)}, 55 | {'brightness': 196, 'xy_color': [0.4872, 0.4201], 'rgb_color': (255, 191, 92)}, 56 | {'brightness': 222, 'xy_color': [0.4587, 0.4103], 'rgb_color': (255, 199, 117)}, 57 | {'brightness': 254, 'xy_color': [0.449, 0.4078], 'rgb_color': (255, 203, 124)}] 58 | 59 | 60 | def get_info_last_ep(tz, limit=1): 61 | """Extrae la información del último (o 'n-último') 62 | episodio disponible de La Cafetera de Radiocable.com""" 63 | base_url_v2 = 'https://api.spreaker.com/v2/' 64 | cafetera_showid = 1060718 65 | duration = None 66 | mask_url = base_url_v2 + 'shows/' + str(cafetera_showid) \ 67 | + '/episodes?limit=' + str(limit) 68 | r = requests.get(mask_url) 69 | if r.ok: 70 | data = r.json() 71 | if ('response' in data) and ('items' in data['response']): 72 | episode = data['response']['items'][-1] 73 | published = parse(episode['published_at']).replace( 74 | tzinfo=pytz.UTC).astimezone(tz).replace(tzinfo=None) 75 | is_live = episode['type'] == 'LIVE' 76 | if not is_live: 77 | duration = dt.timedelta(seconds=episode['duration'] / 1000) 78 | return True, {'published': published, 79 | 'is_live': is_live, 80 | 'duration': duration, 81 | 'episode': episode} 82 | return False, data 83 | return False, None 84 | 85 | 86 | def is_last_episode_ready_for_play(now, tz): 87 | """Comprueba si hay un nuevo episodio disponible de La Cafetera. 88 | 89 | :param now: appdaemon datetime.now() 90 | :param tz: timezone, para corregir las fechas en UTC a local 91 | :return: (play_now, info_last_episode) 92 | :rtype: tuple(bool, dict) 93 | """ 94 | est_today = dt.datetime.combine(now.date(), 95 | parse(DEFAULT_EMISION_TIME).time()) 96 | ok, info = get_info_last_ep(tz) 97 | if ok: 98 | if (info['is_live'] or 99 | (now - info['published'] < MIN_INTERVAL_BETWEEN_EPS) or 100 | (now + MAX_WAIT_TIME < est_today) or 101 | (now - MAX_WAIT_TIME > est_today)): 102 | # Reproducir YA 103 | return True, info 104 | else: 105 | # Esperar un poco más a que empiece 106 | return False, info 107 | # Network error? 108 | return False, None 109 | 110 | 111 | def _make_notification_episode(ep_info): 112 | """Crea los datos para la notificación de alarma, 113 | con información del episodio de La Cafetera a reproducir.""" 114 | message = ("La Cafetera [{}]: {}\n(Publicado: {})" 115 | .format(ep_info['episode']['title'], 116 | 'LIVE' if ep_info['is_live'] else 'RECORDED', 117 | ep_info['published'])) 118 | img_url = ep_info['episode']['image_url'] 119 | title = "Comienza el día en positivo!" 120 | return title, message, img_url 121 | 122 | 123 | def _make_ios_notification_episode(ep_info): 124 | """Crea los datos para la notificación de alarma para iOS.""" 125 | title, message, img_url = _make_notification_episode(ep_info) 126 | return {"title": title, "message": message, 127 | "data": {"push": {"badge": 0, 128 | "sound": "US-EN-Morgan-Freeman-Good-Morning.wav", 129 | "category": "alarmclock"}, 130 | "attachment": {"url": img_url}}} 131 | 132 | 133 | def _make_telegram_notification_episode(ep_info): 134 | """Crea los datos para la notificación de alarma para telegram.""" 135 | title, message, img_url = _make_notification_episode(ep_info) 136 | title = '*{}*'.format(title) 137 | if img_url is not None: 138 | message += "\n{}\n".format(img_url) 139 | data_msg = {"title": title, "message": message, 140 | # "keyboard": TELEGRAM_KEYBOARD_ALARMCLOCK, 141 | "inline_keyboard": TELEGRAM_INLINE_KEYBOARD_ALARMCLOCK, 142 | "disable_notification": False} 143 | return data_msg 144 | 145 | 146 | def _weekday(str_wday): 147 | str_wday = str_wday.lower().rstrip().lstrip() 148 | if str_wday in WEEKDAYS_DICT: 149 | return WEEKDAYS_DICT[str_wday] 150 | print('Error parsing weekday: "{}" -> mon,tue,wed,thu,fri,sat,sun' 151 | .format(str_wday)) 152 | return -1 153 | 154 | 155 | # noinspection PyClassHasNoInit 156 | class AlarmClock(appapi.AppDaemon): 157 | """App for run a complex morning alarm. 158 | 159 | With sunrise light simulation and launching of a Kodi add-on, 160 | after waking up the home-cinema system, 161 | or a Modipy instance with a streaming audio.""" 162 | 163 | _alarm_time_sensor = None 164 | _delta_time_postponer_sec = None 165 | _max_volume = None 166 | _volume_ramp_sec = None 167 | _weekdays_alarm = None 168 | _notifier = None 169 | _transit_time = None 170 | _phases_sunrise = [] 171 | _tz = None 172 | _lights_alarm = None 173 | 174 | _room_select = None 175 | _manual_trigger = None 176 | _selected_player = None 177 | _target_sensor = None 178 | 179 | _media_player_kodi = None 180 | _media_player_mopidy = None 181 | _mopidy_ip = None 182 | _mopidy_port = None 183 | 184 | _next_alarm = None 185 | _handle_alarm = None 186 | _last_trigger = None 187 | _in_alarm_mode = False 188 | _handler_turnoff = None 189 | 190 | def initialize(self): 191 | """AppDaemon required method for app init.""" 192 | conf_data = dict(self.config['AppDaemon']) 193 | self._tz = conf.tz 194 | self._alarm_time_sensor = self.args.get('alarm_time') 195 | self._delta_time_postponer_sec = int( 196 | self.args.get('postponer_minutos', DEFAULT_MIN_POSPONER)) * 60 197 | self._max_volume = int( 198 | self.args.get('max_volume', DEFAULT_MAX_VOLUME_MOPIDY)) 199 | self._volume_ramp_sec = int( 200 | self.args.get('duration_volume_ramp_sec', 201 | DEFAULT_DURATION_VOLUME_RAMP)) 202 | self._weekdays_alarm = [_weekday(d) for d in self.args.get( 203 | 'alarmdays', 'mon,tue,wed,thu,fri').split(',') if _weekday(d) >= 0] 204 | self.listen_state(self.alarm_time_change, self._alarm_time_sensor) 205 | 206 | # Room selection: 207 | self._selected_player = 'KODI' 208 | self._room_select = self.args.get('room_select', None) 209 | if self._room_select is not None: 210 | self._selected_player = self.get_state(entity_id=self._room_select) 211 | self.listen_state(self.change_player, self._room_select) 212 | 213 | self._media_player_kodi = conf_data.get('media_player') 214 | self._media_player_mopidy = conf_data.get('media_player_mopidy') 215 | self._mopidy_ip = conf_data.get('mopidy_ip') 216 | self._mopidy_port = int(conf_data.get('mopidy_port')) 217 | self._target_sensor = conf_data.get('chatid_sensor') 218 | 219 | # Trigger for last episode and boolean for play status 220 | self._manual_trigger = self.args.get('manual_trigger', None) 221 | if self._manual_trigger is not None: 222 | self.listen_state(self.manual_triggering, self._manual_trigger) 223 | 224 | # Listen to ios/telegram notification actions: 225 | self.listen_event( 226 | self.postpone_secuencia_despertador, 'postponer_despertador') 227 | self._notifier = conf_data.get('notifier').replace('.', '/') 228 | 229 | self._lights_alarm = self.args.get('lights_alarm', None) 230 | total_duration = int(self.args.get('sunrise_duration', 60)) 231 | if not self._phases_sunrise: 232 | self._phases_sunrise = SUNRISE_PHASES.copy() 233 | self._transit_time = total_duration // len(self._phases_sunrise) + 1 234 | 235 | self._set_new_alarm_time() 236 | self.log('INIT WITH NEXT ALARM IN: {:%d-%m-%Y %H:%M:%S} ({})' 237 | .format(self._next_alarm, self._selected_player), LOG_LEVEL) 238 | 239 | @property 240 | def play_in_kodi(self): 241 | """Boolean for select each player (Kodi / Mopidy).""" 242 | return 'KODI' in self._selected_player.upper() 243 | 244 | def turn_on_morning_services(self, kwargs): 245 | """Turn ON the water boiler and so on in the morning.""" 246 | self.call_service('switch/turn_on', entity_id="switch.caldera") 247 | if 'delta_to_repeat' in kwargs: 248 | self.run_in(self.turn_on_morning_services, 249 | kwargs['delta_to_repeat']) 250 | 251 | def notify_alarmclock(self, ep_info): 252 | """Send notification with episode info.""" 253 | self.call_service('telegram_bot/send_message', 254 | target=self.get_state(self._target_sensor), 255 | **_make_telegram_notification_episode(ep_info)) 256 | self.call_service(self._notifier.replace('.', '/'), 257 | **_make_ios_notification_episode(ep_info)) 258 | 259 | # noinspection PyUnusedLocal 260 | def change_player(self, entity, attribute, old, new, kwargs): 261 | """Change player.""" 262 | self.log('CHANGE PLAYER from {} to {}' 263 | .format(self._selected_player, new)) 264 | self._selected_player = new 265 | 266 | # noinspection PyUnusedLocal 267 | def turn_off_alarm_clock(self, *args): 268 | """Stop current play when turning off the input_boolean.""" 269 | if self._in_alarm_mode: 270 | if self.play_in_kodi and (self.get_state( 271 | entity_id=self._media_player_kodi) == 'playing'): 272 | self.call_service('media_player/turn_off', 273 | entity_id=self._media_player_kodi) 274 | if self._manual_trigger is not None: 275 | self._last_trigger = None 276 | self.set_state(entity_id=self._manual_trigger, state='off') 277 | self.log('TURN_OFF KODI') 278 | elif not self.play_in_kodi: 279 | if self.get_state( 280 | entity_id=self._media_player_mopidy) == 'playing': 281 | self.call_service('media_player/turn_off', 282 | entity_id=self._media_player_mopidy) 283 | self.call_service('switch/turn_off', 284 | entity_id="switch.altavoz") 285 | if self._manual_trigger is not None: 286 | self._last_trigger = None 287 | self.set_state(entity_id=self._manual_trigger, state='off') 288 | self.log('TURN_OFF MOPIDY') 289 | if self._handler_turnoff is not None: 290 | self.cancel_timer(self._handler_turnoff) 291 | self._handler_turnoff = None 292 | else: 293 | self.log('TURN_OFF ALARM CLOCK, BUT ALREADY OFF?') 294 | self._in_alarm_mode = False 295 | self._handler_turnoff = None 296 | 297 | # noinspection PyUnusedLocal 298 | def manual_triggering(self, entity, attribute, old, new, kwargs): 299 | """Start reproduction manually.""" 300 | self.log('MANUAL_TRIGGERING BOOLEAN CHANGED from {} to {}' 301 | .format(old, new)) 302 | # Manual triggering 303 | if (new == 'on') and ((self._last_trigger is None) 304 | or ((dt.datetime.now() - self._last_trigger) 305 | .total_seconds() > 30)): 306 | _ready, ep_info = is_last_episode_ready_for_play( 307 | self.datetime(), self._tz) 308 | self.log('TRIGGER_START with ep_ready, ep_info --> {}, {}' 309 | .format(_ready, ep_info)) 310 | if self.play_in_kodi: 311 | ok = self.run_kodi_addon_lacafetera() 312 | else: 313 | ok = self.run_mopidy_stream_lacafetera(ep_info) 314 | # Notification: 315 | self.notify_alarmclock(ep_info) 316 | # Manual stop after at least 10 sec 317 | elif ((new == 'off') and (old == 'on') 318 | and (self._last_trigger is not None) and 319 | ((dt.datetime.now() - self._last_trigger) 320 | .total_seconds() > 10)): 321 | # Stop if it's playing 322 | self.log('TRIGGER_STOP (last trigger at {})' 323 | .format(self._last_trigger)) 324 | self.turn_off_alarm_clock() 325 | 326 | # noinspection PyUnusedLocal 327 | def alarm_time_change(self, entity, attribute, old, new, kwargs): 328 | """Re-schedule next alarm when alarm time sliders change.""" 329 | self._set_new_alarm_time() 330 | self.log('CHANGING ALARM TIME TO: {:%H:%M:%S}' 331 | .format(self._next_alarm), LOG_LEVEL) 332 | 333 | # noinspection PyUnusedLocal 334 | def _set_new_alarm_time(self, *args): 335 | if self._handle_alarm is not None: 336 | self.cancel_timer(self._handle_alarm) 337 | str_time_alarm = self.get_state(entity_id=self._alarm_time_sensor) 338 | if ':' not in str_time_alarm: 339 | str_time_alarm = DEFAULT_EMISION_TIME 340 | time_alarm = reduce(lambda x, y: x.replace(**{y[1]: int(y[0])}), 341 | zip(str_time_alarm.split(':'), 342 | ['hour', 'minute', 'second']), 343 | self.datetime().replace(second=0, microsecond=0)) 344 | self._next_alarm = time_alarm - WARM_UP_TIME_DELTA 345 | self._handle_alarm = self.run_daily( 346 | self.run_alarm, self._next_alarm.time()) 347 | 348 | def _set_sunrise_phase(self, *args_runin): 349 | self.log('SET_SUNRISE_PHASE: XY={xy_color}, ' 350 | 'BRIGHT={brightness}, TRANSITION={transition}' 351 | .format(**args_runin[0]), 'DEBUG') 352 | if self._in_alarm_mode: 353 | self.call_service('light/turn_on', **args_runin[0]) 354 | 355 | # noinspection PyUnusedLocal 356 | def turn_on_lights_as_sunrise(self, *args): 357 | """Turn on the lights with a sunrise simulation. 358 | 359 | (done with multiple slow transitions)""" 360 | # self.log('RUN_SUNRISE') 361 | self.call_service( 362 | 'light/turn_off', entity_id=self._lights_alarm, transition=0) 363 | self.call_service( 364 | 'light/turn_on', entity_id=self._lights_alarm, transition=1, 365 | xy_color=self._phases_sunrise[0]['xy_color'], brightness=1) 366 | run_in = 2 367 | for phase in self._phases_sunrise: 368 | # noinspection PyTypeChecker 369 | xy_color, brightness = phase['xy_color'], phase['brightness'] 370 | self.run_in(self._set_sunrise_phase, run_in, 371 | entity_id=self._lights_alarm, xy_color=xy_color, 372 | transition=self._transit_time, brightness=brightness) 373 | run_in += self._transit_time + 1 374 | 375 | def run_kodi_addon_lacafetera(self, mode="playlast"): 376 | """Run Kodi add-on with parameters vith JSONRPC API.""" 377 | self.log('RUN_KODI_ADDON_LACAFETERA with mode={}' 378 | .format(mode), LOG_LEVEL) 379 | data = {"method": "Addons.ExecuteAddon", 380 | "params": {"mode": mode}, 381 | "addonid": "plugin.audio.lacafetera"} 382 | self.call_service("media_player/kodi_call_method", 383 | entity_id=self._media_player_kodi, **data) 384 | self._in_alarm_mode = True 385 | self._last_trigger = dt.datetime.now() 386 | return True 387 | 388 | def run_command_mopidy(self, command='core.tracklist.get_tl_tracks', 389 | params=None, check_result=True): 390 | """Play stream in mopidy.""" 391 | url_base = 'http://{}:{}/mopidy/rpc'.format( 392 | self._mopidy_ip, self._mopidy_port) 393 | headers = {'Content-Type': 'application/json'} 394 | payload = {"method": command, "jsonrpc": "2.0", "id": 1} 395 | if params is not None: 396 | payload.update(params=params) 397 | r = requests.post(url_base, headers=headers, data=json.dumps(payload)) 398 | if r.ok: 399 | try: 400 | res = json.loads(r.content.decode()) 401 | except json.decoder.JSONDecodeError: 402 | self.log("ERROR PARSING MPD RESULT: {}".format(r.content)) 403 | return None 404 | if check_result and not res['result']: 405 | self.error('RUN MOPIDY {} COMMAND BAD RESPONSE? -> {}' 406 | .format(command.upper(), r.content.decode())) 407 | return res 408 | return None 409 | 410 | # noinspection PyUnusedLocal 411 | def increase_volume(self, *args): 412 | """Recursive method to increase the playback volume until max.""" 413 | repeat = True 414 | if self._in_alarm_mode and self._last_trigger is not None: 415 | delta_sec = (dt.datetime.now() 416 | - self._last_trigger).total_seconds() 417 | if delta_sec > self._volume_ramp_sec: 418 | volume_set = self._max_volume 419 | repeat = False 420 | else: 421 | volume_set = int(max(MIN_VOLUME, 422 | (delta_sec / self._volume_ramp_sec) 423 | * self._max_volume)) 424 | self.run_command_mopidy('core.mixer.set_volume', 425 | params=dict(volume=volume_set)) 426 | else: 427 | repeat = False 428 | if repeat: 429 | self.run_in(self.increase_volume, 10) 430 | 431 | def run_mopidy_stream_lacafetera(self, ep_info): 432 | """Play stream in mopidy.""" 433 | self.log('DEBUG MPD: {}'.format(ep_info)) 434 | self.call_service('switch/turn_on', entity_id="switch.altavoz") 435 | self.run_command_mopidy('core.tracklist.clear', check_result=False) 436 | params = {"tracks": [{"__model__": "Track", 437 | "uri": MASK_URL_STREAM_MOPIDY.format( 438 | ep_info['episode']['episode_id']), 439 | "name": ep_info['episode']['title'], 440 | # "artist": "Fernando Berlín", 441 | # "album": "La Cafetera", 442 | "date": "{:%Y-%m-%d}".format( 443 | ep_info['published'])}]} 444 | json_res = self.run_command_mopidy('core.tracklist.add', params=params) 445 | if json_res is not None: 446 | # self.log('Added track OK --> {}'.format(json_res)) 447 | if ("result" in json_res) and (len(json_res["result"]) > 0): 448 | track_info = json_res["result"][0] 449 | self.run_command_mopidy( 450 | 'core.mixer.set_volume', params=dict(volume=5)) 451 | res_play = self.run_command_mopidy( 452 | 'core.playback.play', 453 | params={"tl_track": track_info}, check_result=False) 454 | if res_play is not None: 455 | self._in_alarm_mode = True 456 | self._last_trigger = dt.datetime.now() 457 | self.run_in(self.increase_volume, 5) 458 | return True 459 | self.error('MOPIDY NOT PRESENT??, mopidy json_res={}' 460 | .format(json_res), 'ERROR') 461 | return False 462 | 463 | def prepare_context_alarm(self): 464 | """Initialize the alarm context. 465 | 466 | (turn on devices, get ready the context, etc)""" 467 | # self.log('PREPARE_CONTEXT_ALARM', LOG_LEVEL) 468 | self.turn_on_morning_services(dict(delta_to_repeat=10)) 469 | if self.play_in_kodi: 470 | return self.run_kodi_addon_lacafetera(mode='wakeup') 471 | else: 472 | return self.call_service( 473 | 'switch/turn_on', entity_id="switch.altavoz") 474 | 475 | # noinspection PyUnusedLocal 476 | def trigger_service_in_alarm(self, *args): 477 | """Launch alarm secuence. 478 | 479 | Launch if ready, or set itself to retry in the short future.""" 480 | # Check if alarm is ready to launch 481 | if not self._in_alarm_mode: 482 | alarm_ready, alarm_info = is_last_episode_ready_for_play( 483 | self.datetime(), self._tz) 484 | if alarm_ready: 485 | self.turn_on_morning_services(dict(delta_to_repeat=30)) 486 | if self.play_in_kodi: 487 | ok = self.run_kodi_addon_lacafetera() 488 | else: 489 | ok = self.run_mopidy_stream_lacafetera(alarm_info) 490 | self.turn_on_lights_as_sunrise() 491 | # Notification: 492 | self.notify_alarmclock(alarm_info) 493 | self.set_state(self._manual_trigger, state='on') 494 | if alarm_info['duration'] is not None: 495 | duration = alarm_info['duration'].total_seconds() + 20 496 | self._handler_turnoff = self.run_in(self.turn_off_alarm_clock, 497 | int(duration)) 498 | self.log('ALARM RUNNING NOW. AUTO STANDBY PROGRAMMED ' 499 | 'IN {:.0f} SECONDS'.format(duration), LOG_LEVEL) 500 | elif not self.play_in_kodi: 501 | self._handler_turnoff = self.listen_state( 502 | self.turn_off_alarm_clock, self._media_player_mopidy, 503 | new="off", duration=20) 504 | else: 505 | self.log('POSTPONE ALARM', LOG_LEVEL) 506 | self.run_in(self.trigger_service_in_alarm, STEP_RETRYING_SEC) 507 | 508 | # noinspection PyUnusedLocal 509 | def run_alarm(self, *args): 510 | """Run the alarm main secuence: prepare, trigger & schedule next""" 511 | if not self.get_state(self._manual_trigger) == 'on': 512 | self.set_state(self._manual_trigger, state='off') 513 | if self.datetime().weekday() in self._weekdays_alarm: 514 | ok = self.prepare_context_alarm() 515 | self.run_in(self.trigger_service_in_alarm, 516 | WARM_UP_TIME_DELTA.total_seconds()) 517 | else: 518 | self.log('ALARM CLOCK NOT TRIGGERED TODAY ' 519 | '(weekday={}, alarm weekdays={})' 520 | .format(self.datetime().weekday(), self._weekdays_alarm)) 521 | self.run_in(self.turn_on_morning_services, 1800, delta_to_repeat=10) 522 | else: 523 | self.log('Alarm clock is running manually, no auto-triggering now') 524 | 525 | # noinspection PyUnusedLocal 526 | def postpone_secuencia_despertador(self, *args): 527 | """Botón de postponer alarma X min""" 528 | self.turn_off_alarm_clock() 529 | self.call_service('light/turn_off', 530 | entity_id=self._lights_alarm, transition=1) 531 | self.run_in(self.trigger_service_in_alarm, 532 | self._delta_time_postponer_sec) 533 | self.log('Postponiendo alarma {:.1f} minutos...' 534 | .format(self._delta_time_postponer_sec / 60.)) 535 | -------------------------------------------------------------------------------- /conf/apps/motion_alarm_push_email.py: -------------------------------------------------------------------------------- 1 | 2 | # -*- coding: utf-8 -*- 3 | """ 4 | # Alarma de activación por detección de movimiento. 5 | 6 | Activada con estímulos en sensores binarios (PIR's, de sonido, vibración, inclinación, movimiento en cámaras), 7 | zonificación con cámaras y sensores asociados a cada zona, para la captura de eventos que se envían como html por 8 | email, además de emitir notificaciones push para los eventos típicos de activación, alarma, desactivación e inicio. 9 | 10 | Ante estímulos de los sensores de movimiento, genera "eventos" con el estado de los sensores y capturas jpg de 11 | las cámaras asociadas. Estos eventos, tipificados, forman los informes generados, que se guardan en disco para estar 12 | disponibles por el servidor web de HomeAssistant como ficheros locales. 13 | 14 | En el disparo de alarma, activa 2 relés (sirena y opcional), si la alarma no está definida como "silenciosa", en cuyo 15 | caso opera igualmente, excepto por el encendido de los relés asociados. 16 | 17 | Los tiempos de espera a armado, periodo de pre-alarma, ∆T min entre eventos, ∆T para la captura periódica de eventos 18 | en estado de alarma, y el # máximo de eventos (con imágenes) en los informes (para reducir el peso de los emails), 19 | son editables en la configuración de la AppDaemon app: 20 | 21 | ``` 22 | [Alarm] 23 | module = motion_alarm_push_email 24 | class = MotionAlarm 25 | 26 | 27 | # Hora de envío del informe diario de eventos detectados (si los ha habido). Comentar con # para desactivar 28 | hora_informe = 07:30 29 | # Parámetro de tiempo de espera desde conexión a armado de alarma: 30 | espera_a_armado_sec = 20 31 | # Parámetro de tiempo de espera en pre-alarma para conectar la alarma si ocurre un nuevo evento: 32 | reset_prealarm_time_sec = 15 33 | # Segundos entre captura de eventos con la alarma conectada 34 | min_delta_sec_events = 3 35 | delta_secs_trigger = 150 36 | # Número de eventos máx. a incluir por informe en correo electrónico. Se limita eliminando eventos de baja prioridad 37 | num_max_eventos_por_informe = 10 38 | ``` 39 | 40 | """ 41 | import appdaemon.appapi as appapi 42 | import appdaemon.conf as conf 43 | # import asyncio 44 | # from base64 import b64encode 45 | from collections import OrderedDict 46 | import datetime as dt 47 | from dateutil.parser import parse 48 | from functools import reduce 49 | from itertools import cycle 50 | from jinja2 import Environment, FileSystemLoader 51 | import json 52 | from math import ceil 53 | import os 54 | import re 55 | import requests 56 | from time import time, sleep 57 | import yaml 58 | 59 | 60 | # LOG_LEVEL = 'DEBUG' 61 | LOG_LEVEL = 'INFO' 62 | 63 | NUM_RETRIES_MAX_GET_JPG_FROM_CAM = 10 64 | BYTES_MIN_FOR_JPG = 10. 65 | MIN_TIME_BETWEEN_MOTION = 1 # secs 66 | 67 | DEFAULT_RAWBS_SECS_OFF = 5 68 | DEFAULT_ESPERA_A_ARMADO_SEC = 10 69 | DEFAULT_RESET_PREALARM_TIME_SEC = 15 70 | DEFAULT_MIN_DELTA_SEC_EVENTS = 6 71 | DEFAULT_DELTA_SECS_TRIGGER = 60 72 | DEFAULT_NUM_MAX_EVENTOS_POR_INFORME = 15 73 | DIR_INFORMES = 'alarm_reports' 74 | DIR_CAPTURAS = 'eventos' 75 | 76 | # jinja2 template environment 77 | basedir = os.path.dirname(os.path.abspath(__file__)) 78 | PATH_TEMPLATES = os.path.join(basedir, 'templates') 79 | JINJA2_ENV = Environment(loader=FileSystemLoader(PATH_TEMPLATES), trim_blocks=True) 80 | 81 | # Leyenda de eventos: 82 | EVENT_INICIO = "INICIO" 83 | EVENT_ACTIVACION = "ACTIVACION" 84 | EVENT_DESCONEXION = "DESCONEXION" 85 | EVENT_PREALARMA = "PRE-ALARMA" 86 | EVENT_ALARMA = "ALARMA" 87 | EVENT_EN_ALARMA = "EN ALARMA (ACTIVACION)" 88 | EVENT_ALARMA_ENCENDIDA = "ALARMA ENCENDIDA" 89 | AVISO_RETRY_ALARMA_ENCENDIDA_TITLE = "ALARMA ENCENDIDA" 90 | AVISO_RETRY_ALARMA_ENCENDIDA_MSG = "La alarma sigue encendida, desde las {:%H:%M:%S}. {}" 91 | HASS_COLOR = '#58C1F0' 92 | DEFAULT_ALARM_COLORS = [(255, 0, 0), (50, 0, 255)] # para cycle en luces RGB (simulación de sirena) 93 | # Título, color, es_prioritario, subject_report 94 | EVENT_TYPES = OrderedDict(zip([EVENT_INICIO, EVENT_ACTIVACION, EVENT_DESCONEXION, EVENT_PREALARMA, 95 | EVENT_ALARMA, EVENT_EN_ALARMA, EVENT_ALARMA_ENCENDIDA], 96 | [('Inicio del sistema', "#1393f0", 1, 'Informe de eventos'), 97 | ('Activación de sistema', "#1393f0", 3, 'Informe de eventos'), 98 | ('Desconexión de sistema', "#1393f0", 3, 'Informe de desconexión de alarma'), 99 | ('PRE-ALARMA', "#f0aa28", 1, 'Informe de eventos'), 100 | ('ALARMA!', "#f00a2d", 10, 'ALARMA ACTIVADA'), 101 | ('en ALARMA', "#f0426a", 5, 'ALARMA ACTIVADA'), 102 | ('Alarma encendida', "#f040aa", 0, 'ALARMA ACTIVADA')])) 103 | SOUND_MOTION = "US-EN-Morgan-Freeman-Motion-Detected.wav" 104 | 105 | 106 | def _read_hass_secret_conf(path_ha_conf): 107 | """Read config values from secrets.yaml file & get also the known_devices.yaml path""" 108 | path_secrets = os.path.join(path_ha_conf, 'secrets.yaml') 109 | path_known_dev = os.path.join(path_ha_conf, 'known_devices.yaml') 110 | with open(path_secrets) as _file: 111 | secrets = yaml.load(_file.read()) 112 | return dict(secrets=secrets, hass_base_url=secrets['base_url'], path_known_dev=path_known_dev, 113 | email_target=secrets['email_target'], pb_target=secrets['pb_target']) 114 | 115 | 116 | def _get_events_path(path_base_data): 117 | path_reports = os.path.join(path_base_data, DIR_INFORMES) 118 | path_captures = os.path.join(path_base_data, DIR_CAPTURAS) 119 | if not os.path.exists(path_reports): 120 | os.mkdir(path_reports) 121 | if not os.path.exists(path_captures): 122 | os.mkdir(path_captures) 123 | return path_captures, path_reports 124 | 125 | 126 | # noinspection PyClassHasNoInit 127 | class MotionAlarm(appapi.AppDaemon): 128 | """App for handle the main intrusion alarm.""" 129 | _lock = None 130 | _path_captures = None 131 | _path_reports = None 132 | _secrets = None 133 | 134 | _pirs = None 135 | _use_pirs = None 136 | _camera_movs = None 137 | _use_cams_movs = None 138 | _extra_sensors = None 139 | _use_extra_sensors = None 140 | _dict_asign_switchs_inputs = None 141 | 142 | _videostreams = {} 143 | _cameras_jpg_ip = None 144 | _cameras_jpg_params = None 145 | 146 | _main_switch = None 147 | _rele_sirena = None 148 | _rele_secundario = None 149 | _led_act = None 150 | _use_push_notifier_switch = None 151 | _email_notifier = None 152 | _push_notifier = None 153 | _silent_mode_switch = None 154 | 155 | _tz = None 156 | 157 | _time_report = None 158 | _espera_a_armado_sec = None 159 | _reset_prealarm_time_sec = None 160 | _min_delta_sec_events = None 161 | _delta_secs_trigger = None 162 | _use_push_notifier = False 163 | _retry_push_alarm = None 164 | _max_time_sirena_on = None 165 | _max_report_events = None 166 | _alarm_lights = None 167 | _cycle_colors = None 168 | 169 | _alarm_on = False 170 | _silent_mode = False 171 | _alarm_state = False 172 | _alarm_state_ts_trigger = None 173 | _alarm_state_entity_trigger = None 174 | _pre_alarm_on = False 175 | _pre_alarm_ts_trigger = None 176 | _pre_alarms = [] 177 | _post_alarms = [] 178 | _events_data = None 179 | _in_capture_mode = False 180 | _ts_lastcap = None 181 | _dict_use_inputs = None 182 | _dict_friendly_names = None 183 | _dict_sensor_classes = None 184 | _handler_periodic_trigger = None 185 | _handler_retry_alert = None 186 | _handler_armado_alarma = None 187 | _ts_lastbeat = None 188 | 189 | _known_devices = None 190 | 191 | _raw_sensors = None 192 | _raw_sensors_sufix = None 193 | _raw_sensors_seconds_to_off = None 194 | _raw_sensors_last_states = {} 195 | _raw_sensors_attributes = {} 196 | 197 | def initialize(self): 198 | """AppDaemon required method for app init.""" 199 | self._lock = conf.callbacks_lock 200 | self._tz = conf.tz 201 | # self.log('INIT w/conf_data: {}'.format(conf_data)) 202 | # Paths 203 | _path_base_data = self.args.get('path_base_data') 204 | _path_hass_conf = self.args.get('path_ha_conf') 205 | self._path_captures, self._path_reports = _get_events_path(_path_base_data) 206 | self._secrets = _read_hass_secret_conf(_path_hass_conf) 207 | 208 | # Interruptor principal 209 | self._main_switch = self.args.get('main_switch') 210 | 211 | # Sensores de movimiento (PIR's, cam_movs, extra) 212 | self._raw_sensors = self.args.get('raw_binary_sensors', None) 213 | self._pirs = self._listconf_param(self.args, 'pirs') 214 | self._camera_movs = self._listconf_param(self.args, 'camera_movs') 215 | self._extra_sensors = self._listconf_param(self.args, 'extra_sensors') 216 | self._use_pirs = self._listconf_param(self.args, 'use_pirs', min_len=len(self._pirs), default=True) 217 | self._use_cams_movs = self._listconf_param(self.args, 'use_cam_movs', 218 | min_len=len(self._camera_movs), default=True) 219 | self._use_extra_sensors = self._listconf_param(self.args, 'use_extra_sensors', 220 | min_len=len(self._extra_sensors), default=True) 221 | # self.log('_use_pirs: {}'.format(self._use_pirs)) 222 | # self.log('_use_cams_movs: {}'.format(self._use_cams_movs)) 223 | # self.log('use_extra_sensors: {}'.format(self._use_extra_sensors)) 224 | 225 | # Video streams asociados a sensores para notif 226 | _streams = self._listconf_param(self.args, 'videostreams') 227 | if _streams: 228 | self._videostreams = {sensor: cam 229 | for cam, list_triggers in _streams[0].items() 230 | for sensor in list_triggers} 231 | 232 | # Streams de vídeo (HA entities, URLs + PAYLOADS for request jpg images) 233 | self._cameras_jpg_ip = self._listconf_param(self.args, 'cameras_jpg_ip_secret', is_secret=True) 234 | self._cameras_jpg_params = self._listconf_param(self.args, 'cameras_jpg_params_secret', 235 | is_secret=True, is_json=True, min_len=len(self._cameras_jpg_ip)) 236 | 237 | # Actuadores en caso de alarma (relays, LED's, ...) 238 | self._rele_sirena = self.args.get('rele_sirena', None) 239 | self._rele_secundario = self.args.get('rele_secundario', None) 240 | self._led_act = self.args.get('led_act', None) 241 | 242 | # Switch de modo silencioso (sin relays) 243 | self._silent_mode_switch = self.args.get('silent_mode', 'False') 244 | # Configuración de notificaciones 245 | self._email_notifier = self.args.get('email_notifier') 246 | self._push_notifier = self.args.get('push_notifier') 247 | self._use_push_notifier_switch = self.args.get('usar_push_notifier', 'True') 248 | 249 | # Hora de envío del informe diario de eventos detectados (si los ha habido) 250 | self._time_report = self.args.get('hora_informe', None) 251 | # Parámetro de tiempo de espera desde conexión a armado de alarma: 252 | self._espera_a_armado_sec = int(self.args.get('espera_a_armado_sec', DEFAULT_ESPERA_A_ARMADO_SEC)) 253 | # Parámetro de tiempo de espera en pre-alarma para conectar la alarma si ocurre un nuevo evento: 254 | self._reset_prealarm_time_sec = int(self.args.get('reset_prealarm_time_sec', DEFAULT_RESET_PREALARM_TIME_SEC)) 255 | # Segundos entre captura de eventos con la alarma conectada 256 | self._min_delta_sec_events = int(self.args.get('min_delta_sec_events', DEFAULT_MIN_DELTA_SEC_EVENTS)) 257 | self._delta_secs_trigger = int(self.args.get('delta_secs_trigger', DEFAULT_DELTA_SECS_TRIGGER)) 258 | # Número de eventos máximo a incluir por informe en email. Se limita eliminando eventos de baja prioridad 259 | self._max_report_events = int(self.args.get('num_max_eventos_por_informe', DEFAULT_NUM_MAX_EVENTOS_POR_INFORME)) 260 | self._alarm_lights = self.args.get('alarm_rgb_lights', None) 261 | 262 | # Insistencia de notificación de alarma encendida 263 | self._retry_push_alarm = self.args.get('retry_push_alarm', None) 264 | if self._retry_push_alarm is not None: 265 | self._retry_push_alarm = int(self._retry_push_alarm) 266 | # Persistencia de alarma encendida 267 | self._max_time_sirena_on = self.args.get('max_time_alarm_on', None) 268 | if self._max_time_sirena_on is not None: 269 | self._max_time_sirena_on = int(self._max_time_sirena_on) 270 | # self.log('Insistencia de notificación de alarma: {};' 271 | # ' Persistencia de sirena: {}' 272 | # .format(self._retry_push_alarm, self._max_time_sirena_on)) 273 | 274 | # RAW SENSORS: 275 | if self._raw_sensors is not None: 276 | self._raw_sensors = self._raw_sensors.split(',') 277 | self._raw_sensors_sufix = self.args.get('raw_binary_sensors_sufijo', '_raw') 278 | # Persistencia en segundos de último valor hasta considerarlos 'off' 279 | self._raw_sensors_seconds_to_off = int(self.args.get('raw_binary_sensors_time_off', DEFAULT_RAWBS_SECS_OFF)) 280 | 281 | # Handlers de cambio en raw binary_sensors: 282 | l1, l2 = 'attributes', 'last_changed' 283 | for s in self._raw_sensors: 284 | self._raw_sensors_attributes[s] = (s.replace(self._raw_sensors_sufix, ''), self.get_state(s, l1)) 285 | # self._raw_sensors_last_states[s] = [parse(self.get_state(s, l2)).replace(tzinfo=None), False] 286 | self._raw_sensors_last_states[s] = [self.datetime(), False] 287 | self.listen_state(self._turn_on_raw_sensor_on_change, s) 288 | # self.log('seconds_to_off: {}'.format(self._raw_sensors_seconds_to_off)) 289 | # self.log('attributes_sensors: {}'.format(self._raw_sensors_attributes)) 290 | # self.log('last_changes: {}'.format(self._raw_sensors_last_states)) 291 | [self.set_state(dev, state='off', attributes=attrs) for dev, attrs in self._raw_sensors_attributes.values()] 292 | next_run = self.datetime() + dt.timedelta(seconds=self._raw_sensors_seconds_to_off) 293 | self.run_every(self._turn_off_raw_sensor_if_not_updated, next_run, self._raw_sensors_seconds_to_off) 294 | 295 | self._events_data = [] 296 | 297 | # Main switches: 298 | self._alarm_on = self._listen_to_switch('main_switch', self._main_switch, self._main_switch_ch) 299 | 300 | # set_global(self, GLOBAL_ALARM_STATE, self._alarm_on) 301 | self._use_push_notifier = self._listen_to_switch('push_n', self._use_push_notifier_switch, self._main_switch_ch) 302 | self._silent_mode = self._listen_to_switch('silent_mode', self._silent_mode_switch, self._main_switch_ch) 303 | 304 | # Sensors states & input usage: 305 | all_sensors = self._pirs + self._camera_movs + self._extra_sensors 306 | all_sensors_use = self._use_pirs + self._use_cams_movs + self._use_extra_sensors 307 | self._dict_asign_switchs_inputs = {s_use: s_input for s_input, s_use in zip(all_sensors, all_sensors_use) 308 | if type(s_use) is not bool} 309 | self._dict_use_inputs = {s_input: self._listen_to_switch(s_input, s_use, self._switch_usar_input) 310 | for s_input, s_use in zip(all_sensors, all_sensors_use)} 311 | self._dict_friendly_names = {s: self.get_state(s, attribute='friendly_name') for s in all_sensors} 312 | # self._dict_friendly_names.update({c: self.get_state(c, attribute='friendly_name') for c in self._videostreams}) 313 | self._dict_sensor_classes = {s: self.get_state(s, attribute='device_class') for s in all_sensors} 314 | 315 | # Movement detection 316 | for s_mov in all_sensors: 317 | self.listen_state(self._motion_detected, s_mov, new="on", duration=1) 318 | 319 | # Programación de informe de actividad 320 | if self._time_report is not None: 321 | time_alarm = reduce(lambda x, y: x.replace(**{y[1]: int(y[0])}), 322 | zip(self._time_report.split(':'), ['hour', 'minute', 'second']), 323 | self.datetime().replace(second=0, microsecond=0)) 324 | self.run_daily(self.email_events_data, time_alarm.time()) 325 | self.log('Creado timer para informe diario de eventos a las {} de cada día'.format(time_alarm.time())) 326 | 327 | # Simulación de alarma visual con luces RBG (opcional) 328 | if self._alarm_lights is not None: 329 | self._cycle_colors = cycle(DEFAULT_ALARM_COLORS) 330 | self.log('Alarma visual con luces RGB: {}; colores: {}'.format(self._alarm_lights, self._cycle_colors)) 331 | 332 | # Listen to main events: 333 | self.listen_event(self.receive_init_event, 'ha_started') 334 | self.listen_event(self.device_tracker_new_device, 'device_tracker_new_device') 335 | self.listen_event(self._reset_alarm_state, 'reset_alarm_state') 336 | self.listen_event(self._turn_off_sirena_in_alarm_state, 'silent_alarm_state') 337 | 338 | def _listconf_param(self, conf_args, param_name, is_secret=False, is_json=False, min_len=None, default=None): 339 | """Carga de configuración de lista de entidades de HA""" 340 | p_config = conf_args.get(param_name, default) 341 | # self.log('DEBUG listconf_param: {}, min_l={} --> {}'.format(param_name, min_len, p_config)) 342 | if (type(p_config) is str) and ',' in p_config: 343 | p_config = p_config.split(',') 344 | if is_json and is_secret: 345 | return [json.loads(self._secrets['secrets'][p]) for p in p_config] 346 | if is_json: 347 | return [json.loads(p) for p in p_config] 348 | elif is_secret: 349 | return [self._secrets['secrets'][p] for p in p_config] 350 | else: 351 | return p_config 352 | elif p_config is not None: 353 | if is_secret: 354 | p_config = self._secrets['secrets'][p_config] 355 | if is_json: 356 | p_config = json.loads(p_config) 357 | if min_len is not None: 358 | return [p_config] * min_len 359 | return [p_config] 360 | if min_len is not None: 361 | return [default] * min_len 362 | return [] 363 | 364 | # noinspection PyUnusedLocal 365 | def _turn_on_raw_sensor_on_change(self, entity, attribute, old, new, kwargs): 366 | _, last_st = self._raw_sensors_last_states[entity] 367 | self._raw_sensors_last_states[entity] = [self.datetime(), True] 368 | if not last_st: 369 | name, attrs = self._raw_sensors_attributes[entity] 370 | self.set_state(name, state='on', attributes=attrs) 371 | # self.log('TURN ON "{}" (de {} a {} --> {})'.format(entity, old, new, name)) 372 | 373 | # noinspection PyUnusedLocal 374 | def _turn_off_raw_sensor_if_not_updated(self, *kwargs): 375 | now = self.datetime() 376 | for s, (ts, st) in self._raw_sensors_last_states.copy().items(): 377 | if st and ceil((now - ts).total_seconds()) >= self._raw_sensors_seconds_to_off: 378 | # self.log('TURN OFF "{}" (last ch: {})'.format(s, ts)) 379 | name, attrs = self._raw_sensors_attributes[s] 380 | self._raw_sensors_last_states[s] = [now, False] 381 | self.set_state(name, state='off', attributes=attrs) 382 | 383 | def _listen_to_switch(self, identif, entity_switch, func_listen_change): 384 | if type(entity_switch) is bool: 385 | # self.log('FIXED BOOL: {} -> {}' 386 | # .format(identif, entity_switch), LOG_LEVEL) 387 | return entity_switch 388 | if entity_switch.lower() in ['true', 'false', 'on', 'off', '1', '0']: 389 | fixed_bool = entity_switch.lower() in ['true', 'on', '1'] 390 | # self.log('FIXED SWITCH: {} -> "{}": {}' 391 | # .format(identif, entity_switch, fixed_bool), LOG_LEVEL) 392 | return fixed_bool 393 | else: 394 | state = self.get_state(entity_switch) == 'on' 395 | self.listen_state(func_listen_change, entity_switch) 396 | # self.log('LISTEN TO CHANGES IN SWITCH: {} -> {}, ST={}' 397 | # .format(identif, entity_switch, state), LOG_LEVEL) 398 | return state 399 | 400 | def _is_too_old(self, ts, delta_secs): 401 | if ts is None: 402 | return True 403 | else: 404 | now = dt.datetime.now(tz=self._tz) 405 | return (now - ts).total_seconds() > delta_secs 406 | 407 | # noinspection PyUnusedLocal 408 | def track_device_in_zone(self, entity, attribute, old, new, kwargs): 409 | if self._alarm_on: 410 | self.log('* DEVICE: "{}", from "{}" to "{}"'.format(entity, kwargs['codename'], old, new)) 411 | 412 | # noinspection PyUnusedLocal 413 | def _reload_known_devices(self, *args): 414 | # Reload known_devices from yaml file: 415 | with open(self._secrets['path_known_dev']) as f: 416 | new_known_devices = yaml.load(f.read()) 417 | if self._known_devices is None: 418 | self.log('KNOWN_DEVICES: {}'.format(['{name} [{mac}]'.format(**v) for v in new_known_devices.values()])) 419 | else: 420 | if any([dev not in self._known_devices.keys() for dev in new_known_devices.keys()]): 421 | for dev, dev_data in new_known_devices.items(): 422 | if dev not in new_known_devices.keys(): 423 | new_dev = '{name} [{mac}]'.format(**dev_data) 424 | self.listen_state(self.track_device_in_zone, dev, old="home", codename=new_dev) 425 | self.log('NEW KNOWN_DEV: {}'.format(new_dev)) 426 | self._known_devices = new_known_devices 427 | 428 | # noinspection PyUnusedLocal 429 | def device_tracker_new_device(self, event_id, payload_event, *args): 430 | """Event listener.""" 431 | dev = payload_event['entity_id'] 432 | self.log('* DEVICE_TRACKER_NEW_DEVICE RECEIVED * --> {}: {}'.format(dev, payload_event)) 433 | self.run_in(self._reload_known_devices, 5) 434 | 435 | # noinspection PyUnusedLocal 436 | def receive_init_event(self, event_id, payload_event, *args): 437 | """Event listener.""" 438 | self.log('* INIT_EVENT * RECEIVED: "{}", payload={}'.format(event_id, payload_event)) 439 | self.append_event_data(dict(event_type=EVENT_INICIO)) 440 | self.text_notification() 441 | self._reload_known_devices() 442 | 443 | def _make_event_path(self, event_type, id_cam): 444 | now = dt.datetime.now(tz=self._tz) 445 | ev_clean = re.sub('\(|\)', '', re.sub(':|-|\+| |\.', '_', event_type)) 446 | name = 'evento_{}_cam{}_ts{:%Y%m%d_%H%M%S}.jpg'.format(ev_clean, id_cam, now) 447 | sub_dir = 'ts_{:%Y_%m_%d}'.format(now.date()) 448 | base_path = os.path.join(self._path_captures, sub_dir) 449 | if not os.path.exists(base_path): 450 | os.mkdir(base_path) 451 | url = '{}/{}/{}/{}/{}'.format(self._secrets['hass_base_url'], 'local', DIR_CAPTURAS, sub_dir, name) 452 | return name, os.path.join(base_path, name), url 453 | 454 | def _append_pic_to_data(self, data, event_type, index, url, params=None): 455 | # Get PIC from IP cams or from MotionEye in LocalHost: 456 | pic, ok, retries = None, False, 0 457 | name_pic, path_pic, url_pic = 'NONAME', None, None 458 | while not ok and (retries < NUM_RETRIES_MAX_GET_JPG_FROM_CAM): 459 | try: 460 | r = requests.get(url, params=params, timeout=5) 461 | length = float(r.headers['Content-Length']) 462 | if r.ok and (r.headers['Content-type'] == 'image/jpeg') and (length > BYTES_MIN_FOR_JPG): 463 | pic = r.content 464 | ok = True 465 | if retries > 5: 466 | self.log('CGI PIC OK CON {} INTENTOS: {}, length={}' 467 | .format(retries + 1, url, length), 'WARNING') 468 | break 469 | elif not r.ok: 470 | self.log('ERROR {} EN CGI PIC: {}, length={}'.format(r.status_code, url, length), 'WARNING') 471 | except requests.ConnectionError: 472 | if retries > 0: 473 | self.log('ConnectionError EN CGI PIC en {}?{}'.format(url, params), 'ERROR') 474 | break 475 | except requests.Timeout: 476 | if retries > 0: 477 | self.log('Timeout EN CGI PIC en {}?{}'.format(url, params), 'ERROR') 478 | break 479 | retries += 1 480 | # TODO ASYNC!! 481 | # asyncio.sleep(.2) 482 | sleep(.2) 483 | 484 | # Save PIC & (opc) b64 encode: 485 | if ok: 486 | name_pic, path_pic, url_pic = self._make_event_path(event_type, index + 1) 487 | with open(path_pic, 'wb') as f: 488 | f.write(pic) 489 | # pic_b64 = b64encode(pic).decode() 490 | data['ok_img{}'.format(index + 1)] = True 491 | else: 492 | # pic_b64 = 'NOIMG' 493 | data['ok_img{}'.format(index + 1)] = False 494 | # data['incluir'] = False 495 | self.log('ERROR EN CAPTURE PIC con event_type: "{}", cam #{}'.format(event_type, index + 1)) 496 | data['path_img{}'.format(index + 1)] = path_pic 497 | data['url_img{}'.format(index + 1)] = url_pic 498 | data['name_img{}'.format(index + 1)] = name_pic 499 | # data['base64_img{}'.format(index + 1)] = pic_b64 500 | 501 | def _append_state_to_data(self, data, entity, prefix): 502 | st = self.get_state(entity) 503 | ts = self.get_state(entity, attribute='last_changed') 504 | if ts: 505 | ts = '{:%-H:%M:%S %-d/%-m}'.format(parse(ts).astimezone(self._tz)) 506 | data[prefix + '_st'] = st 507 | data[prefix + '_ts'] = ts 508 | data[prefix + '_fn'] = self._dict_friendly_names[entity] 509 | 510 | # noinspection PyUnusedLocal 511 | def append_event_data(self, kwargs, *args): 512 | """Creación de eventos. 513 | params = dict(pir_1_st='ON', pir_2_st='OFF', cam_mov_1_st='OFF', cam_mov_2_st='ON', 514 | pir_1_ts='ON', pir_2_ts='OFF', cam_mov_1_ts='OFF', cam_mov_2_ts='ON', 515 | base64_img1=b64encode(bytes_img1).decode(), 516 | base64_img2=b64encode(bytes_img2).decode()) 517 | """ 518 | event_type = kwargs.get('event_type') 519 | entity_trigger = kwargs.get('entity_trigger', None) 520 | prioridad = EVENT_TYPES[event_type][2] 521 | 522 | proceed = False 523 | with self._lock: 524 | tic = time() 525 | if not self._in_capture_mode: 526 | proceed = (prioridad > 1) or self._is_too_old(self._ts_lastcap, self._min_delta_sec_events) 527 | self._in_capture_mode = proceed 528 | if proceed: 529 | now = dt.datetime.now(tz=self._tz) 530 | params = dict(ts=now, ts_event='{:%H:%M:%S}'.format(now), incluir=True, prioridad=prioridad, 531 | event_type=event_type, event_color=EVENT_TYPES[event_type][1], entity_trigger=entity_trigger) 532 | 533 | # Binary sensors: PIR's, camera_movs, extra_sensors: 534 | for i, p in enumerate(self._pirs): 535 | mask_pirs = 'pir_{}' 536 | self._append_state_to_data(params, p, 'pir_{}'.format(i + 1)) 537 | for i, cm in enumerate(self._camera_movs): 538 | self._append_state_to_data(params, cm, 'cam_mov_{}'.format(i + 1)) 539 | for extra_s in self._extra_sensors: 540 | # extra_sensor_usar = extra_s.replace('_raw', '') 541 | self._append_state_to_data(params, extra_s, self._dict_sensor_classes[extra_s]) 542 | 543 | # image captures: 544 | if self._cameras_jpg_ip: 545 | if self._cameras_jpg_params is not None: 546 | for i, (url, params_req) in enumerate(zip(self._cameras_jpg_ip, self._cameras_jpg_params)): 547 | self._append_pic_to_data(params, event_type, i, url, params_req) 548 | else: 549 | for i, url in enumerate(self._cameras_jpg_ip): 550 | self._append_pic_to_data(params, event_type, i, url) 551 | 552 | params['took'] = time() - tic 553 | self.log('Nuevo evento "{}" adquirido en {:.2f}s, con ts={}' 554 | .format(event_type, params['took'], params['ts'])) 555 | self._events_data.append(params) 556 | 557 | with self._lock: 558 | self._in_capture_mode = False 559 | # self._ts_lastcap = now + dt.timedelta(seconds=params['took']) 560 | self._ts_lastcap = now 561 | else: 562 | if prioridad > 1: 563 | self.log('SOLAPAMIENTO DE LLAMADAS A APPEND_EVENT. POSPUESTO. "{}"; ts_lastcap={}' 564 | .format(event_type, self._ts_lastcap), 'WARNING') 565 | self.run_in(self.append_event_data, 1, **kwargs) 566 | else: 567 | self.log('SOLAPAMIENTO DE LLAMADAS A APPEND_EVENT. DESECHADO. "{}"; ts_lastcap={}' 568 | .format(event_type, self._ts_lastcap)) 569 | 570 | def _reset_session_data(self): 571 | with self._lock: 572 | self._in_capture_mode = False 573 | self._alarm_state = False 574 | self._alarm_state_ts_trigger = None 575 | self._alarm_state_entity_trigger = None 576 | self._pre_alarm_on = False 577 | self._pre_alarm_ts_trigger = None 578 | self._pre_alarms = [] 579 | self._post_alarms = [] 580 | self._handler_periodic_trigger = None 581 | self._handler_retry_alert = None 582 | 583 | # noinspection PyUnusedLocal 584 | def _armado_sistema(self, *args): 585 | with self._lock: 586 | self._handler_armado_alarma = None 587 | self._alarm_on = True 588 | # set_global(self, GLOBAL_ALARM_STATE, True) 589 | self._reset_session_data() 590 | self.append_event_data(dict(event_type=EVENT_ACTIVACION)) 591 | self.text_notification() 592 | 593 | # noinspection PyUnusedLocal 594 | def _main_switch_ch(self, entity, attribute, old, new, kwargs): 595 | if entity == self._main_switch: 596 | alarm_on = new == 'on' 597 | if alarm_on and (old == 'off'): # turn_on_alarm with delay 598 | self._handler_armado_alarma = self.run_in(self._armado_sistema, self._espera_a_armado_sec) 599 | self.log('--> ALARMA CONECTADA DENTRO DE {} SEGUNDOS'.format(self._espera_a_armado_sec)) 600 | elif not alarm_on and (old == 'on'): # turn_off_alarm 601 | if self._handler_armado_alarma is not None: 602 | self.cancel_timer(self._handler_armado_alarma) 603 | self._handler_armado_alarma = None 604 | 605 | with self._lock: 606 | self._alarm_on = False 607 | # set_global(self, GLOBAL_ALARM_STATE, False) 608 | self._alarm_state = False 609 | 610 | # Operación con relés en apagado de alarma: 611 | [self.call_service('{}/turn_off'.format(ent.split('.')[0]), entity_id=ent) 612 | for ent in [self._rele_sirena, self._rele_secundario, self._led_act] if ent is not None] 613 | 614 | # send & reset events 615 | if self._events_data: 616 | self.append_event_data(dict(event_type=EVENT_DESCONEXION)) 617 | self.text_notification() 618 | self.email_events_data() 619 | 620 | # reset ts alarm & pre-alarm 621 | self._reset_session_data() 622 | if self._alarm_lights is not None: 623 | self.call_service("light/turn_off", entity_id=self._alarm_lights, transition=1) 624 | self.log('--> ALARMA DESCONECTADA') 625 | elif entity == self._use_push_notifier_switch: 626 | self._use_push_notifier = new == 'on' 627 | self.log('SWITCH USAR PUSH NOTIFS: de "{}" a "{}" --> {}'.format(old, new, self._use_push_notifier)) 628 | elif entity == self._silent_mode_switch: 629 | self._silent_mode = new == 'on' 630 | self.log('SILENT MODE: {}'.format(self._silent_mode)) 631 | if self._alarm_state and self._silent_mode and (self._rele_sirena is not None): 632 | self.call_service('{}/turn_off'.format(self._rele_sirena.split('.')[0]), entity_id=self._rele_sirena) 633 | else: 634 | self.log('Entity unknown in _main_switch_ch: {} (from {} to {}, attrs={}' 635 | .format(entity, old, new, attribute), 'ERROR') 636 | 637 | # noinspection PyUnusedLocal 638 | def _switch_usar_input(self, entity, attribute, old, new, kwargs): 639 | k = self._dict_asign_switchs_inputs[entity] 640 | if (new == 'on') and (old == 'off'): 641 | # Turn ON input 642 | self._dict_use_inputs[k] = True 643 | elif (new == 'off') and (old == 'on'): 644 | # Turn OFF input 645 | self._dict_use_inputs[k] = False 646 | self.log('SWITCH USAR INPUT "{}" from {} to {}'.format(entity, old, new)) 647 | 648 | def _validate_input(self, entity): 649 | # DEBUGGING NEW SENSORS 650 | # if entity in self._extra_sensors: 651 | # self.log('EXTRA SENSOR "{}": {}->{}'.format(entity, old, new)) 652 | if self._alarm_on: 653 | if (entity in self._dict_use_inputs) and (self._dict_use_inputs[entity]): 654 | return True 655 | return False 656 | 657 | # noinspection PyUnusedLocal 658 | def _reset_alarm_state(self, *args): 659 | """Reset del estado de alarma ON. La alarma sigue encendida, pero se pasa a estado inactivo en espera""" 660 | process = False 661 | with self._lock: 662 | if self._alarm_on and self._alarm_state: 663 | self._alarm_state = False 664 | self._alarm_state_ts_trigger = None 665 | self._alarm_state_entity_trigger = None 666 | # self._events_data = [] 667 | self._pre_alarms = [] 668 | self._post_alarms = [] 669 | self._pre_alarm_on = False 670 | self._pre_alarm_ts_trigger = None 671 | self._handler_periodic_trigger = None 672 | self._handler_retry_alert = None 673 | process = True 674 | if process: 675 | self.log('** RESET OF ALARM STATE') 676 | # apagado de relés de alarma: 677 | [self.call_service('{}/turn_off'.format(ent.split('.')[0]), entity_id=ent) 678 | for ent in [self._rele_sirena, self._rele_secundario, self._led_act] if ent is not None] 679 | if self._alarm_lights is not None: 680 | self.call_service("light/turn_off", entity_id=self._alarm_lights, transition=1) 681 | 682 | # noinspection PyUnusedLocal 683 | def _turn_off_sirena_in_alarm_state(self, *args): 684 | """Apaga el relé asociado a la sirena. 685 | La alarma sigue encendida y grabando eventos en activaciones de sensor y periódicamente.""" 686 | process = False 687 | with self._lock: 688 | if self._alarm_on and self._alarm_state and not self._silent_mode: 689 | # self._silent_mode = True 690 | process = True 691 | if process: 692 | # apagado de relés de alarma: 693 | self.log('** Apagado del relé de la sirena de alarma') 694 | if self._rele_sirena is not None: 695 | self.call_service('{}/turn_off'.format(self._rele_sirena.split('.')[0]), entity_id=self._rele_sirena) 696 | if self._alarm_lights is not None: 697 | self.call_service("light/turn_off", entity_id=self._alarm_lights, transition=1) 698 | 699 | # noinspection PyUnusedLocal 700 | def _turn_off_prealarm(self, *args): 701 | proceed = False 702 | with self._lock: 703 | if self._pre_alarm_on and not self._alarm_state: 704 | self._pre_alarm_ts_trigger = None 705 | self._pre_alarm_on = False 706 | proceed = True 707 | if proceed: 708 | if self._led_act is not None: 709 | self.call_service('switch/turn_off', entity_id=self._led_act) 710 | self.log('*PREALARMA DESACTIVADA*') 711 | 712 | # noinspection PyUnusedLocal 713 | def _motion_detected(self, entity, attribute, old, new, kwargs): 714 | """Lógica de activación de alarma por detección de movimiento. 715 | - El 1º evento pone al sistema en 'pre-alerta', durante un tiempo determinado. Se genera un evento. 716 | - Si se produce un 2º evento en estado de pre-alerta, comienza el estado de alerta, se genera un evento, 717 | se disparan los relés asociados, se notifica al usuario con push_notif + email, y se inician los actuadores 718 | periódicos. 719 | - Las siguientes detecciones generan nuevos eventos, que se acumulan hasta que se desconecte la alarma y se 720 | notifique al usuario por email. 721 | """ 722 | # self.log('DEBUG MOTION: {}, {}->{}'.format(entity, old, new)) 723 | if self._validate_input(entity): 724 | # Actualiza persistent_notification de entity en cualquier caso 725 | # self._persistent_notification(entity) 726 | now = dt.datetime.now(tz=self._tz) 727 | delta_beat = 100 728 | 729 | # LOCK 730 | priority = 0 731 | with self._lock: 732 | if self._ts_lastbeat is not None: 733 | delta_beat = (now - self._ts_lastbeat).total_seconds() 734 | if delta_beat > MIN_TIME_BETWEEN_MOTION: 735 | if self._alarm_state: 736 | priority = 1 737 | elif self._pre_alarm_on: 738 | priority = 3 739 | self._alarm_state = True 740 | else: 741 | priority = 2 742 | self._pre_alarm_on = True 743 | self._ts_lastbeat = now 744 | 745 | # self.log('DEBUG MOTION "{}": "{}"->"{}" at {:%H:%M:%S.%f}. A={}, ST_A={}, ST_PRE-A={}' 746 | # .format(entity, old, new, now, self._alarm_on, self._alarm_state, self._pre_alarm_on)) 747 | # Nuevo evento, con alarma conectada. Se ignora por ahora 748 | # if self._alarm_state: 749 | if priority == 1: 750 | self.log('(IN ALARM MODE) motion_detected in {}, ∆Tbeat={:.6f}s'.format(entity, delta_beat)) 751 | if self._led_act is not None: 752 | self.call_service('switch/toggle', entity_id=self._led_act) 753 | if self._is_too_old(self._ts_lastcap, self._min_delta_sec_events): 754 | self.append_event_data(dict(event_type=EVENT_EN_ALARMA, entity_trigger=entity)) 755 | self.alarm_persistent_notification(entity, now) 756 | # Trigger ALARMA después de pre-alarma 757 | # elif self._pre_alarm_on: 758 | elif priority == 3: 759 | self.log('**** ALARMA!! **** activada por "{}", ∆Tbeat={:.6f}s'.format(entity, delta_beat)) 760 | # self.turn_on_alarm() 761 | self._alarm_state_ts_trigger = now 762 | self._alarm_state_entity_trigger = entity 763 | self._alarm_state = True 764 | if self._handler_periodic_trigger is None: # Sólo 1ª vez! 765 | if not self._silent_mode and (self._rele_sirena is not None): 766 | self.call_service('{}/turn_on'.format(self._rele_sirena.split('.')[0]), 767 | entity_id=self._rele_sirena) 768 | if self._rele_secundario is not None: 769 | self.call_service('{}/turn_on'.format(self._rele_secundario.split('.')[0]), 770 | entity_id=self._rele_secundario) 771 | self.append_event_data(dict(event_type=EVENT_ALARMA, entity_trigger=entity)) 772 | self.text_notification(append_extra_data=True) 773 | self.alarm_persistent_notification() 774 | self.email_events_data() 775 | # Empieza a grabar eventos periódicos cada DELTA_SECS_TRIGGER: 776 | self._handler_periodic_trigger = self.run_in(self.periodic_capture_mode, self._delta_secs_trigger) 777 | if self._max_time_sirena_on is not None: 778 | # Programa el apagado automático de la sirena pasado cierto tiempo desde la activación. 779 | self.run_in(self._turn_off_sirena_in_alarm_state, self._max_time_sirena_on) 780 | if (self._handler_retry_alert is None) and (self._retry_push_alarm is not None): # Sólo 1ª vez! 781 | # Empieza a notificar la alarma conectada cada X minutos 782 | self._handler_retry_alert = self.run_in(self.periodic_alert, self._retry_push_alarm) 783 | # Sirena visual con RGB lights: 784 | if self._alarm_lights is not None: 785 | self.run_in(self._flash_alarm_lights, 2) 786 | # Dispara estado pre-alarma 787 | elif priority == 2: 788 | self.log('** PRE-ALARMA ** activada por "{}"'.format(entity), LOG_LEVEL) 789 | self._pre_alarm_ts_trigger = now 790 | self._pre_alarm_on = True 791 | self.run_in(self._turn_off_prealarm, self._reset_prealarm_time_sec) 792 | self.prealarm_persistent_notification(entity, now) 793 | self.append_event_data(dict(event_type=EVENT_PREALARMA, entity_trigger=entity)) 794 | if self._led_act is not None: 795 | self.call_service('switch/turn_on', entity_id=self._led_act) 796 | else: 797 | self.log('** MOVIMIENTO DESECHADO ** activado por "{}", ∆Tbeat={:.6f}s'.format(entity, delta_beat)) 798 | 799 | # noinspection PyUnusedLocal 800 | def periodic_capture_mode(self, *args): 801 | """Ejecución periódica con la alarma encendida para capturar eventos cada cierto tiempo.""" 802 | # self.log('EN PERIODIC_CAPTURE_MODE con ∆T={} s'.format(self._delta_secs_trigger)) 803 | proceed = append_event = False 804 | with self._lock: 805 | if self._alarm_state: 806 | proceed = True 807 | append_event = self._is_too_old(self._ts_lastbeat, self._min_delta_sec_events) 808 | if proceed: 809 | if append_event: 810 | self.append_event_data(dict(event_type=EVENT_ALARMA_ENCENDIDA)) 811 | self.run_in(self.periodic_capture_mode, self._delta_secs_trigger) 812 | else: 813 | # self.log('STOP PERIODIC CAPTURE MODE') 814 | self._handler_periodic_trigger = None 815 | 816 | # noinspection PyUnusedLocal 817 | def periodic_alert(self, *args): 818 | """Ejecución periódica con la alarma encendida para enviar una notificación recordando dicho estado.""" 819 | self.log('EN PERIODIC_ALERT con ∆T={} s'.format(self._retry_push_alarm)) 820 | proceed = False 821 | with self._lock: 822 | if self._alarm_state: 823 | proceed = True 824 | if proceed: 825 | self.periodic_alert_notification() 826 | self.run_in(self.periodic_alert, self._retry_push_alarm) 827 | else: 828 | self.log('STOP PERIODIC ALERT') 829 | self._handler_retry_alert = None 830 | 831 | # def _persistent_notification(self, trigger_entity, ts, title=None, unique_id=True): 832 | # f_name = notif_id = self._dict_friendly_names[trigger_entity] 833 | # if not unique_id: 834 | # notif_id += '_{:%y%m%d%H%M%S}'.format(ts) 835 | # message = 'Activación a las {:%H:%M:%S de %d/%m/%Y} por "{}"'.format(ts, f_name) 836 | # params = dict(message=message, title=title if title is not None else f_name, id=notif_id) 837 | # self._post_alarms.append((self._dict_friendly_names[trigger_entity], '{:%H:%M:%S}'.format(ts))) 838 | # self.persistent_notification(**params) 839 | # # self.log('PERSISTENT NOTIFICATION: {}'.format(params)) 840 | 841 | def alarm_persistent_notification(self, trigger_entity=None, ts=None): 842 | """Notificación en el frontend de alarma activada.""" 843 | if trigger_entity is not None: 844 | self._post_alarms.append((self._dict_friendly_names[trigger_entity], '{:%H:%M:%S}'.format(ts))) 845 | params_templ = dict(ts='{:%H:%M:%S}'.format(self._alarm_state_ts_trigger), 846 | entity=self._dict_friendly_names[self._alarm_state_entity_trigger], 847 | postalarms=self._post_alarms[::-1]) 848 | message = JINJA2_ENV.get_template('persistent_notif_alarm.html').render(**params_templ) 849 | params = dict(message=message, title="ALARMA!!", id='alarm') 850 | # self.log('DEBUG ALARM PERSISTENT NOTIFICATION: {}'.format(params)) 851 | self.persistent_notification(**params) 852 | 853 | def prealarm_persistent_notification(self, trigger_entity, ts): 854 | """Notificación en el frontend de pre-alarma activada.""" 855 | self._pre_alarms.append((self._dict_friendly_names[trigger_entity], '{:%H:%M:%S}'.format(ts))) 856 | message = JINJA2_ENV.get_template('persistent_notif_prealarm.html').render(prealarms=self._pre_alarms[::-1]) 857 | params = dict(message=message, title="PRE-ALARMA", id='prealarm') 858 | # self.log('DEBUG PRE-ALARM PERSISTENT NOTIFICATION: {}'.format(params)) 859 | self.persistent_notification(**params) 860 | 861 | def _update_ios_notify_params(self, params, url_usar): 862 | if ((self._alarm_state_entity_trigger is not None) and 863 | (self._videostreams.get(self._alarm_state_entity_trigger))): 864 | # Get the camera video stream as function of trigger 865 | cam_entity = self._videostreams.get( 866 | self._alarm_state_entity_trigger) 867 | params.update( 868 | data=dict( 869 | push=dict(badge=10, sound=SOUND_MOTION, 870 | category="camera"), 871 | entity_id=cam_entity, 872 | attachment=dict(url=url_usar))) 873 | else: 874 | params.update( 875 | data=dict( 876 | push=dict(badge=10, sound=SOUND_MOTION, 877 | category="alarmsounded"), 878 | attachment=dict(url=url_usar))) 879 | return params 880 | 881 | def periodic_alert_notification(self): 882 | """Notificación de recordatorio de alarma encendida.""" 883 | if self._alarm_state: 884 | extra = '' 885 | if self._rele_sirena is not None: 886 | extra += 'Sirena: {}. '.format(self.get_state(self._rele_sirena)) 887 | msg = AVISO_RETRY_ALARMA_ENCENDIDA_MSG.format(self._alarm_state_ts_trigger, extra) 888 | params = dict(title=AVISO_RETRY_ALARMA_ENCENDIDA_TITLE, message=msg) 889 | if self._use_push_notifier: 890 | service = self._push_notifier 891 | if self._events_data and ('url_img1' in self._events_data[-1]): 892 | url_usar = self._events_data[-1]['url_img1'] 893 | else: 894 | url_usar = self._secrets['hass_base_url'] 895 | if 'ios' in service: 896 | params = self._update_ios_notify_params(params, url_usar) 897 | elif 'pushbullet' in service: 898 | # params.update(data=dict(url=url_usar)) 899 | params.update(target=self._secrets['pb_target'], data=dict(url=url_usar)) 900 | 901 | self.log('PUSH TEXT NOTIFICATION "{title}": {message}, {data}'.format(**params)) 902 | else: 903 | params.update(target=self._secrets['email_target']) 904 | params['message'] += '\n\nURL del sistema de vigilancia: {}'.format(self._secrets['hass_base_url']) 905 | service = self._email_notifier 906 | self.log('EMAIL RAW TEXT NOTIFICATION: {title}: {message}'.format(**params)) 907 | self.call_service(service, **params) 908 | 909 | def text_notification(self, append_extra_data=False): 910 | """Envía una notificación de texto plano con el status del último evento añadido.""" 911 | if self._events_data: 912 | last_event = self._events_data[-1] 913 | event_type = last_event['event_type'] 914 | pre_alarm_ts = '{:%H:%M:%S}'.format(self._pre_alarm_ts_trigger) if self._pre_alarm_ts_trigger else None 915 | alarm_ts = '{:%H:%M:%S}'.format(self._alarm_state_ts_trigger) if self._alarm_state_ts_trigger else None 916 | params_templ = dict(pre_alarm_ts=pre_alarm_ts, alarm_ts=alarm_ts, 917 | alarm_entity=self._alarm_state_entity_trigger, evento=last_event, 918 | pirs=self._pirs, cam_movs=self._camera_movs, 919 | extra_sensors=[(s, self._dict_sensor_classes[s]) for s in self._extra_sensors], 920 | friendly_names=self._dict_friendly_names) 921 | msg = JINJA2_ENV.get_template('raw_text_pbnotif.html').render(**params_templ) 922 | msg_text = msg.replace('', '').replace('
', '') 923 | params = dict(title=EVENT_TYPES[event_type][0], message=msg_text) 924 | if self._use_push_notifier: 925 | service = self._push_notifier 926 | if 'pushbullet' in service: 927 | params.update(target=self._secrets['pb_target']) 928 | if append_extra_data: 929 | if 'url_img1' in last_event: 930 | url_usar = last_event['url_img1'] 931 | else: 932 | url_usar = self._secrets['hass_base_url'] 933 | if 'ios' in service: 934 | params = self._update_ios_notify_params( 935 | params, url_usar) 936 | elif 'pushbullet' in service: 937 | params.update(data=dict(url=url_usar)) 938 | # self.log('PUSH TEXT NOTIFICATION "{title}: {message}"'.format(**params)) 939 | self.log('PUSH TEXT NOTIFICATION "{title}"'.format(**params)) 940 | else: 941 | params.update(target=self._secrets['email_target']) 942 | service = self._email_notifier 943 | self.log('EMAIL RAW TEXT NOTIFICATION: {title}: {message}'.format(**params)) 944 | self.call_service(service, **params) 945 | 946 | def get_events_for_email(self): 947 | """Devuelve los eventos acumulados filtrados y ordenados, junto a los paths de las imágenes adjuntadas.""" 948 | 949 | def _count_included_events(evs): 950 | """Cuenta los eventos marcados para inclusión.""" 951 | return len(list(filter(lambda x: x['incluir'], evs))) 952 | 953 | def _ok_num_events(evs, num_max, prioridad_filtro, logger): 954 | """Marca 'incluir' = False para eventos de prioridad < X, hasta reducir a num_max.""" 955 | n_included = n_included_init = _count_included_events(evs) 956 | if n_included > num_max: 957 | # Filtrado eliminando eventos periódicos, después prealarmas 958 | idx = len(evs) - 1 959 | while (idx >= 0) and (n_included > num_max): 960 | if evs[idx]['incluir'] and (evs[idx]['prioridad'] < prioridad_filtro): 961 | evs[idx]['incluir'] = False 962 | n_included -= 1 963 | idx -= 1 964 | logger('Filtrado de eventos con P < {} por exceso. De {}, quedan {} eventos.' 965 | .format(prioridad_filtro, n_included_init, n_included)) 966 | return n_included <= num_max 967 | 968 | eventos = self._events_data.copy() 969 | self._events_data = [] 970 | 971 | # Filtrado de eventos de baja prioridad si hay demasiados 972 | ok_filter, prioridad_min = False, 1 973 | while (not _ok_num_events(eventos, self._max_report_events, prioridad_min, self.log) 974 | and (prioridad_min <= 5)): 975 | # self.log('Filtrado de eventos con P < {} por exceso. De {}, quedan {} eventos.' 976 | # .format(prioridad_min, len(self._events_data), _count_included_events(self._events_data))) 977 | prioridad_min += 1 978 | 979 | # Eventos e imágenes para email attachments (cid:#): 980 | num_included_events = _count_included_events(eventos) 981 | eventos = eventos[::-1] 982 | counter_imgs, paths_imgs = 0, [] 983 | for event in filter(lambda x: x['incluir'], eventos): 984 | for i in range(len(self._cameras_jpg_ip)): 985 | if event['ok_img{}'.format(i + 1)]: 986 | event['id_img{}'.format(i + 1)] = event['name_img{}'.format(i + 1)] 987 | paths_imgs.append(event['path_img{}'.format(i + 1)]) 988 | counter_imgs += 1 989 | return eventos, paths_imgs, num_included_events 990 | 991 | # noinspection PyUnusedLocal 992 | def email_events_data(self, *args): 993 | """Envía por email los eventos acumulados.""" 994 | tic = time() 995 | if self._events_data: 996 | now = dt.datetime.now(tz=self._tz) 997 | eventos, paths_imgs, num_included_events = self.get_events_for_email() 998 | 999 | # Informe 1000 | r_name = 'report_{:%Y%m%d_%H%M%S}.html'.format(now) 1001 | last_event = eventos[0] 1002 | color_title = last_event['event_color'] if EVENT_TYPES[last_event['event_type']][2] else HASS_COLOR 1003 | url_local_path_report = '{}/{}/{}/{}'.format(self._secrets['hass_base_url'], 'local', DIR_INFORMES, r_name) 1004 | title = EVENT_TYPES[last_event['event_type']][3] 1005 | ts_title = '{:%-d-%-m-%Y}'.format(now.date()) 1006 | 1007 | # Render html reports for email & static server 1008 | report_templ = JINJA2_ENV.get_template('report_template.html') 1009 | params_templ = dict(title=title, ts_title=ts_title, color_title=color_title, 1010 | eventos=eventos, include_images_base64=False, 1011 | num_cameras=len(self._cameras_jpg_ip), 1012 | pirs=self._pirs, cam_movs=self._camera_movs, 1013 | extra_sensors=[(s, self._dict_sensor_classes[s]) for s in self._extra_sensors], 1014 | friendly_names=self._dict_friendly_names) 1015 | html_email = report_templ.render(is_email=True, url_local_report=url_local_path_report, **params_templ) 1016 | html_static = report_templ.render(is_email=False, **params_templ) 1017 | 1018 | path_disk_report = os.path.join(self._path_reports, r_name) 1019 | try: 1020 | with open(path_disk_report, 'w') as f: 1021 | f.write(html_static) 1022 | self.log('INFORME POR EMAIL con {} eventos ({} con imágenes [{}]) generado y guardado en {} en {:.2f} s' 1023 | .format(len(eventos), num_included_events, len(paths_imgs), path_disk_report, time() - tic)) 1024 | except Exception as e: 1025 | self.log('ERROR EN SAVE REPORT TO DISK: {} [{}]'.format(e, e.__class__)) 1026 | self._events_data = [] 1027 | params = dict(title="{} - {}".format(title, ts_title), target=self._secrets['email_target'], 1028 | message='No text!', data=dict(html=html_email, images=paths_imgs)) 1029 | self.call_service(self._email_notifier, **params) 1030 | else: 1031 | self.log('Se solicita enviar eventos, pero no hay ninguno! --> {}'.format(self._events_data), 'ERROR') 1032 | 1033 | # noinspection PyUnusedLocal 1034 | def _flash_alarm_lights(self, *args): 1035 | """Recursive-like method for flashing lights with cycling colors.""" 1036 | if self._alarm_lights is not None: 1037 | if self._alarm_state: 1038 | self.call_service("light/turn_on", entity_id=self._alarm_lights, 1039 | rgb_color=next(self._cycle_colors), brightness=255, transition=1) 1040 | self.run_in(self._flash_alarm_lights, 3) -------------------------------------------------------------------------------- /conf/apps/motion_lights.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Automation task as a AppDaemon App for Home Assistant 4 | 5 | This little app controls some hue lights for turning them ON with motion detection, 6 | only under some custom circunstances, like the media player is not running, 7 | or there aren't any more lights in 'on' state in the room. 8 | 9 | """ 10 | import appdaemon.appapi as appapi 11 | 12 | 13 | LOG_LEVEL = 'INFO' 14 | 15 | 16 | # noinspection PyClassHasNoInit 17 | class MotionLights(appapi.AppDaemon): 18 | """App for control lights with a motion sensor.""" 19 | 20 | _pir = None 21 | _motion_light_timeout = None 22 | _lights_motion = None 23 | _lights_check_off = None 24 | _media_player = None 25 | _extra_constrain_input_boolean = None 26 | 27 | _handle_motion_on = None 28 | _handle_motion_off = None 29 | 30 | _motion_lights_running = False 31 | _extra_condition = True 32 | _media_player_active = False 33 | _lights_motion_active = None 34 | 35 | def initialize(self): 36 | """AppDaemon required method for app init.""" 37 | conf_data = dict(self.config['AppDaemon']) 38 | pir = self.args.get('pir', None) 39 | self._extra_constrain_input_boolean = self.args.get( 40 | 'constrain_input_boolean_2', None) 41 | motion_light_timeout_slider = self.args.get( 42 | 'motion_light_timeout', None) 43 | self._lights_motion = self.args.get('lights_motion', '') 44 | self._lights_check_off = [l for l in self.args.get( 45 | 'lights_check_off', '').split(',') if len(l) > 0] 46 | self._media_player = conf_data.get('media_player', None) 47 | 48 | if pir and motion_light_timeout_slider and self._lights_motion: 49 | self._pir = pir 50 | # Motion Lights States 51 | self._lights_motion_active = {} 52 | self._read_light_motion_states(set_listen_state=True) 53 | self.run_minutely(self._read_light_motion_states, None) 54 | # for l in self._lights_motion.split(','): 55 | # self._lights_motion_active[l] = self.get_state(l) == 'on' 56 | # self.listen_state(self._light_motion_state, l) 57 | # Light Timeout 58 | if motion_light_timeout_slider.startswith('input_number'): 59 | self._motion_light_timeout = int( 60 | round(float(self.get_state(motion_light_timeout_slider)))) 61 | self.listen_state( 62 | self._set_motion_timeout, motion_light_timeout_slider) 63 | else: 64 | self._motion_light_timeout = int( 65 | round(float(motion_light_timeout_slider))) 66 | self._handle_motion_on = self.listen_state( 67 | self.turn_on_motion_lights, self._pir, new="on") 68 | self._handle_motion_off = self.listen_state( 69 | self.turn_off_motion_lights, self._pir, 70 | new="off", duration=self._motion_light_timeout) 71 | # Media player dependency 72 | if self._media_player is not None: 73 | self._media_player_active = self.get_state( 74 | self._media_player) == 'playing' 75 | self.listen_state( 76 | self._media_player_state_ch, self._media_player) 77 | self.log('MotionLightsConstrain media player "{}" (PIR={})' 78 | .format(self._media_player, self._pir)) 79 | 80 | # Extra dependency (inverse logic) --> GENERAL ALARM ON 81 | if self._extra_constrain_input_boolean is not None: 82 | self._extra_condition = self.get_state( 83 | self._extra_constrain_input_boolean) == 'off' 84 | self.listen_state( 85 | self._extra_switch_change, 86 | self._extra_constrain_input_boolean) 87 | self.log('MotionLightsConstrain extra "{}" (extra_cond now={})' 88 | .format(self._extra_constrain_input_boolean, 89 | self._extra_condition)) 90 | self.log('MotionLights [{}] with motion in "{}", ' 91 | 'with timeout={} s, check_off={}. ---> ACTIVE' 92 | .format(self._lights_motion, self._pir, 93 | self._motion_light_timeout, 94 | self._lights_check_off)) 95 | else: 96 | self.log('No se inicializa MotionLights, ' 97 | 'faltan parámetros (req: {})' 98 | .format('motion_light_timeout, lights_motion, pir'), 99 | level='ERROR') 100 | 101 | # noinspection PyUnusedLocal 102 | def _media_player_state_ch(self, entity, attribute, old, new, kwargs): 103 | self._media_player_active = new == 'playing' 104 | 105 | # noinspection PyUnusedLocal 106 | def _extra_switch_change(self, entity, attribute, old, new, kwargs): 107 | self.log('Extra switch condition change: {} from {} to {}' 108 | .format(entity, old, new)) 109 | self._extra_condition = new == 'off' 110 | 111 | # noinspection PyUnusedLocal 112 | def _set_motion_timeout(self, entity, attribute, old, new, kwargs): 113 | new_timeout = int(round(float(new))) 114 | if new_timeout != self._motion_light_timeout: 115 | self._motion_light_timeout = new_timeout 116 | if self._handle_motion_off is not None: 117 | self.log('Cancelling {}'.format(self._handle_motion_off)) 118 | self.cancel_listen_state(self._handle_motion_off) 119 | self._handle_motion_off = self.listen_state( 120 | self.turn_off_motion_lights, self._pir, 121 | new="off", duration=self._motion_light_timeout) 122 | self.log('Se establece nuevo timeout para MotionLights: {} segs' 123 | .format(self._motion_light_timeout)) 124 | 125 | # noinspection PyUnusedLocal 126 | def _read_light_motion_states(self, set_listen_state=False, **kwargs): 127 | bkp = self._lights_motion_active.copy() 128 | if not self._motion_lights_running: 129 | for l in self._lights_motion.split(','): 130 | self._lights_motion_active[l] = self.get_state(l) == 'on' 131 | if set_listen_state: 132 | self.listen_state(self._light_motion_state, l) 133 | elif any(filter(lambda x: x is False, 134 | self._lights_motion_active.values())): 135 | self.log('MOTION LIGHTS OFF (some lights were turn off manually)' 136 | ' --> {}'.format(self._lights_motion_active)) 137 | self._motion_lights_running = False 138 | if set_listen_state or (bkp != self._lights_motion_active): 139 | self.log('UPDATE light_motion_states from {} to {}' 140 | .format(bkp, self._lights_motion_active, 141 | set_listen_state)) 142 | 143 | # noinspection PyUnusedLocal 144 | def _light_motion_state(self, entity, attribute, old, new, kwargs): 145 | self._lights_motion_active[entity] = new == 'on' 146 | 147 | def _lights_are_off(self, include_motion_lights=True): 148 | other_lights_are_off = ((self._lights_check_off is None) 149 | or all([(self.get_state(l) == 'off') 150 | or (self.get_state(l) is None) 151 | for l in self._lights_check_off])) 152 | if include_motion_lights: 153 | return other_lights_are_off and not any( 154 | self._lights_motion_active.values()) 155 | return other_lights_are_off 156 | 157 | # noinspection PyUnusedLocal 158 | def turn_on_motion_lights(self, entity, attribute, old, new, kwargs): 159 | """Method for turning on the motion-controlled lights.""" 160 | if (not self._motion_lights_running and 161 | self._lights_are_off(include_motion_lights=True) and 162 | self._extra_condition and not self._media_player_active): 163 | self._motion_lights_running = True 164 | self.log('TURN_ON MOTION_LIGHTS ({}), with timeout: {} sec. ' 165 | 'lights_motion: {}' 166 | .format(self._lights_motion, self._motion_light_timeout, 167 | self._lights_motion_active.values()), 168 | LOG_LEVEL) 169 | self.call_service("light/turn_on", entity_id=self._lights_motion, 170 | color_temp=300, brightness=200, transition=0) 171 | 172 | # noinspection PyUnusedLocal 173 | def turn_off_motion_lights(self, entity, attribute, old, new, kwargs): 174 | """Method for turning off the motion-controlled lights 175 | after some time without any movement.""" 176 | if self._motion_lights_running and \ 177 | self._extra_condition and not self._media_player_active: 178 | if self._lights_are_off(include_motion_lights=False): 179 | self.log('TURNING_OFF MOTION_LIGHTS, id={}, old={}, new={}' 180 | .format(entity, old, new), LOG_LEVEL) 181 | self.call_service("light/turn_off", 182 | entity_id=self._lights_motion, transition=1) 183 | else: 184 | self.log('NO TURN_OFF MOTION_LIGHTS ' 185 | '(other lights in the room are ON={})' 186 | .format([self.get_state(l) 187 | for l in self._lights_check_off]), LOG_LEVEL) 188 | self._motion_lights_running = False 189 | -------------------------------------------------------------------------------- /conf/apps/publish_states_in_master.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Automation task as a AppDaemon App for Home Assistant 4 | 5 | DEPRECATED --> Use mqtt_statestream, much better! 6 | 7 | AppDaemon App which posts any state change in one HASS instance 8 | (the local/main instance, as slave) to another HASS (the master instance). 9 | 10 | I prefer this method than config multiple REST sensors in the master HASS, 11 | with their customization & grouping. 12 | Also, the master-slave configuration for multiple instances explained in docs 13 | looks like doesn't want to run for me (?), and I don't need (nor want) to get 14 | master state updates in the slave instance, so it's a one-way information 15 | pipe, **only from slave to master**, and it's better (=quicker response) than 16 | the REST sensors because it doesn't depend of scan intervals. 17 | 18 | """ 19 | import appdaemon.appapi as appapi 20 | import homeassistant.remote as remote 21 | import datetime as dt 22 | from dateutil.parser import parse 23 | from math import ceil 24 | 25 | 26 | LOG_LEVEL = 'INFO' 27 | DEFAULT_SUFFIX = '_slave' 28 | DEFAULT_RAWBS_SECS_OFF = 10 29 | 30 | 31 | # noinspection PyClassHasNoInit 32 | class SlavePublisher(appapi.AppDaemon): 33 | """SlavePublisher. 34 | 35 | AppDaemon Class for setting states 36 | from one HASS instance (main) in Another (remote). 37 | Valid for binary_sensors and sensors.""" 38 | 39 | _hass_master_url = None 40 | _hass_master_key = None 41 | _hass_master_port = None 42 | _master_ha_api = None 43 | 44 | _sufix = None 45 | _sensor_updates = None 46 | 47 | _raw_sensors = None 48 | _raw_sensors_sufix = None 49 | _raw_sensors_seconds_to_off = None 50 | _raw_sensors_last_states = {} 51 | _raw_sensors_attributes = {} 52 | 53 | def initialize(self): 54 | """AppDaemon required method for app init.""" 55 | 56 | self._hass_master_url = self.args.get('master_ha_url') 57 | self._hass_master_key = self.args.get('master_ha_key', '') 58 | self._hass_master_port = int(self.args.get('master_ha_port', '8123')) 59 | self._sufix = self.args.get('slave_sufix', DEFAULT_SUFFIX) 60 | self._master_ha_api = remote.API( 61 | self._hass_master_url, self._hass_master_key, 62 | port=self._hass_master_port) 63 | 64 | # Raw binary sensors 65 | self._raw_sensors = self.args.get('raw_binary_sensors', None) 66 | if self._raw_sensors is not None: 67 | self._raw_sensors = self._raw_sensors.split(',') 68 | self._raw_sensors_sufix = self.args.get('raw_binary_sensors_sufijo', '_raw') 69 | # Persistencia en segundos de último valor hasta considerarlos 'off' 70 | self._raw_sensors_seconds_to_off = int(self.args.get('raw_binary_sensors_time_off', DEFAULT_RAWBS_SECS_OFF)) 71 | 72 | # Handlers de cambio en raw binary_sensors: 73 | l1, l2 = 'attributes', 'last_changed' 74 | for s in self._raw_sensors: 75 | self._raw_sensors_attributes[s] = (s.replace(self._raw_sensors_sufix, ''), self.get_state(s, l1)) 76 | self._raw_sensors_last_states[s] = [parse(self.get_state(s, l2)).replace(tzinfo=None), False] 77 | self.listen_state(self._turn_on_raw_sensor_on_change, s) 78 | self.log('seconds_to_off: {}'.format(self._raw_sensors_seconds_to_off)) 79 | self.log('attributes_sensors: {}'.format(self._raw_sensors_attributes)) 80 | self.log('last_changes: {}'.format(self._raw_sensors_last_states)) 81 | # [self.set_state(dev, state='off', attributes=attrs) for dev, attrs in .._raw_sensors_attributes.values()] 82 | [remote.set_state(self._master_ha_api, dev + self._sufix, 'off', attributes=attrs) 83 | for dev, attrs in self._raw_sensors_attributes.values()] 84 | next_run = self.datetime() + dt.timedelta(seconds=self._raw_sensors_seconds_to_off) 85 | self.run_every(self._turn_off_raw_sensor_if_not_updated, next_run, self._raw_sensors_seconds_to_off) 86 | 87 | # Publish slave states in master 88 | bs_states = self.get_state('binary_sensor') 89 | if self._raw_sensors is not None: 90 | [bs_states.pop(raw) for raw in self._raw_sensors] 91 | 92 | s_states = self.get_state('sensor') 93 | sensors = dict(**s_states) 94 | sensors.update(bs_states) 95 | now = self.datetime() 96 | sensor_updates = {} 97 | for entity_id, state_atts in sensors.items(): 98 | self.log('SENSOR: {}, ATTRS={}'.format(entity_id, state_atts)) 99 | remote.set_state(self._master_ha_api, entity_id + self._sufix, 100 | state_atts['state'], 101 | attributes=state_atts['attributes']) 102 | self.listen_state(self._ch_state, entity_id, 103 | attributes=state_atts['attributes']) 104 | sensor_updates.update({entity_id + self._sufix: now}) 105 | self._sensor_updates = sensor_updates 106 | self.run_minutely(self._update_states, None) 107 | self.log('Transfer states from slave to master in {} COMPLETE' 108 | .format(self._master_ha_api)) 109 | 110 | # noinspection PyUnusedLocal 111 | def _update_states(self, kwargs): 112 | """Update states in master if they are not changed.""" 113 | now = self.datetime() 114 | s_states = self.get_state('sensor') 115 | bs_states = self.get_state('binary_sensor') 116 | sensors = dict(**s_states) 117 | sensors.update(bs_states) 118 | for entity_id, state_atts in sensors.items(): 119 | key = entity_id + self._sufix 120 | if key not in self._sensor_updates \ 121 | or (now - self._sensor_updates[key]).total_seconds() > 60: 122 | remote.set_state( 123 | self._master_ha_api, key, state_atts['state'], 124 | attributes=state_atts['attributes']) 125 | self._sensor_updates[key] = now 126 | 127 | # noinspection PyUnusedLocal 128 | def _ch_state(self, entity, attribute, old, new, kwargs): 129 | remote.set_state( 130 | self._master_ha_api, entity + self._sufix, new, **kwargs) 131 | self._sensor_updates[entity + self._sufix] = self.datetime() 132 | 133 | # noinspection PyUnusedLocal 134 | def _turn_on_raw_sensor_on_change(self, entity, attribute, 135 | old, new, kwargs): 136 | _, last_st = self._raw_sensors_last_states[entity] 137 | self._raw_sensors_last_states[entity] = [self.datetime(), True] 138 | if not last_st: 139 | name, attrs = self._raw_sensors_attributes[entity] 140 | remote.set_state( 141 | self._master_ha_api, name + self._sufix, 'on', 142 | attributes=attrs) 143 | 144 | # noinspection PyUnusedLocal 145 | def _turn_off_raw_sensor_if_not_updated(self, *kwargs): 146 | now = self.datetime() 147 | for s, (ts, st) in self._raw_sensors_last_states.copy().items(): 148 | if st and ceil((now - ts).total_seconds() 149 | ) >= self._raw_sensors_seconds_to_off: 150 | name, attrs = self._raw_sensors_attributes[s] 151 | self._raw_sensors_last_states[s] = [now, False] 152 | remote.set_state( 153 | self._master_ha_api, name + self._sufix, 'off', 154 | attributes=attrs) 155 | -------------------------------------------------------------------------------- /conf/apps/raw_bin_sensors.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Automation task as a AppDaemon App for Home Assistant 4 | 5 | """ 6 | import appdaemon.appapi as appapi 7 | import datetime as dt 8 | from dateutil.parser import parse 9 | from math import ceil 10 | 11 | 12 | LOG_LEVEL = 'INFO' 13 | DEFAULT_SUFFIX = '_raw' 14 | DEFAULT_RAWBS_SECS_OFF = 10 15 | 16 | 17 | # noinspection PyClassHasNoInit 18 | class RawBinarySensors(appapi.AppDaemon): 19 | """Raw binary sensors. 20 | 21 | AppDaemon Class for creating binary sensors which turn on when another 22 | bin sensors changes, and turn off after some inactivity time.""" 23 | 24 | _raw_sensors = None 25 | _raw_sensors_sufix = None 26 | _raw_sensors_seconds_to_off = None 27 | _raw_sensors_last_states = {} 28 | _raw_sensors_attributes = {} 29 | 30 | def initialize(self): 31 | """AppDaemon required method for app init.""" 32 | self._raw_sensors = self.args.get('raw_binary_sensors').split(',') 33 | self._raw_sensors_sufix = self.args.get( 34 | 'raw_binary_sensors_sufijo', DEFAULT_SUFFIX) 35 | # Persistencia en segundos de último valor hasta considerarlos 'off' 36 | self._raw_sensors_seconds_to_off = int(self.args.get( 37 | 'raw_binary_sensors_time_off', DEFAULT_RAWBS_SECS_OFF)) 38 | 39 | # Handlers de cambio en raw binary_sensors: 40 | l1, l2 = 'attributes', 'last_changed' 41 | for s in self._raw_sensors: 42 | self._raw_sensors_attributes[s] = (s.replace(self._raw_sensors_sufix, ''), self.get_state(s, l1)) 43 | self._raw_sensors_last_states[s] = [parse(self.get_state(s, l2)).replace(tzinfo=None), False] 44 | self.listen_state(self._turn_on_raw_sensor_on_change, s) 45 | self.log('seconds_to_off: {}'.format(self._raw_sensors_seconds_to_off)) 46 | self.log('attributes_sensors: {}'.format(self._raw_sensors_attributes)) 47 | self.log('last_changes: {}'.format(self._raw_sensors_last_states)) 48 | 49 | next_run = self.datetime() + dt.timedelta(seconds=self._raw_sensors_seconds_to_off) 50 | self.run_every(self._turn_off_raw_sensor_if_not_updated, next_run, self._raw_sensors_seconds_to_off) 51 | 52 | # noinspection PyUnusedLocal 53 | def _turn_on_raw_sensor_on_change(self, entity, attribute, 54 | old, new, kwargs): 55 | _, last_st = self._raw_sensors_last_states[entity] 56 | self._raw_sensors_last_states[entity] = [self.datetime(), True] 57 | if not last_st: 58 | name, attrs = self._raw_sensors_attributes[entity] 59 | self.set_state(name, state='on', attributes=attrs) 60 | 61 | # noinspection PyUnusedLocal 62 | def _turn_off_raw_sensor_if_not_updated(self, *kwargs): 63 | now = self.datetime() 64 | for s, (ts, st) in self._raw_sensors_last_states.copy().items(): 65 | if st and ceil((now - ts).total_seconds() 66 | ) >= self._raw_sensors_seconds_to_off: 67 | name, attrs = self._raw_sensors_attributes[s] 68 | self._raw_sensors_last_states[s] = [now, False] 69 | self.set_state(name, state='off', attributes=attrs) 70 | -------------------------------------------------------------------------------- /conf/apps/templates/persistent_notif_alarm.html: -------------------------------------------------------------------------------- 1 |2 |
{{ ts }} | 7 |{{ entity }} | 8 |
Activación de alarma: | 12 ||
{{ ts }} | 15 |**{{ entity }}** | 16 |
{{ ts }} | 7 |{{ entity }} | 8 |
{% if alarm_ts %}** ALARMA ACTIVADA a las {{ alarm_ts }} por {{ alarm_entity }}** 2 | {% endif %}- Último evento registrado: 3 | **Evento "{{ evento['event_type'] }}" a las {{ evento['ts_event'] }}:** 4 | {% for pir in pirs %} 5 | *{{ friendly_names[pir] }}: {{ evento['pir_' + loop.index|string + '_st']|upper }} ({{ evento['pir_' + loop.index|string + '_ts'] }}) 6 | {% endfor %} 7 | {% for cam_mov in cam_movs %} 8 | *{{ friendly_names[cam_mov] }}: {{ evento['cam_mov_' + loop.index|string + '_st']|upper }} ({{ evento['cam_mov_' + loop.index|string + '_ts'] }}) 9 | {% endfor %} 10 | {% for extra_s, code in extra_sensors %} 11 | *{{ friendly_names[extra_s] }}: {{ evento[code + '_st']|upper }} ({{ evento[code + '_ts'] }}) 12 | {% endfor %} 13 | {% if pre_alarm_ts %}[Pre-alarma activada a las {{ pre_alarm_ts }}]{% endif %}14 | -------------------------------------------------------------------------------- /conf/apps/templates/report_template.html: -------------------------------------------------------------------------------- 1 | {% macro row_sensor(params_row, name, basename, color_ts='gray') %} 2 |
** Versión online de este informe **
139 |