diff --git a/appdaemon/settings/appdaemon.yaml b/appdaemon/settings/appdaemon.yaml index 75f4609..d9dbb2a 100644 --- a/appdaemon/settings/appdaemon.yaml +++ b/appdaemon/settings/appdaemon.yaml @@ -14,7 +14,11 @@ appdaemon: narodmon_key: !secret narodmon_key api: - + + namespaces: + worktime_namespace: + writeback: safe + plugins: HASS: type: hass diff --git a/appdaemon/settings/apps/doorbells/intercom.py b/appdaemon/settings/apps/doorbells/intercom.py index 6d5c02e..1186645 100644 --- a/appdaemon/settings/apps/doorbells/intercom.py +++ b/appdaemon/settings/apps/doorbells/intercom.py @@ -19,8 +19,8 @@ class Intercom(hass.Hass): ringtone = 10 gw_mac = '' def initialize(self): - if "notify" not in self.args: - self.error("Please provide notify in config!") + if 'chat_id' not in self.args or "notify" not in self.args: + self.error("Please provide chat_id, notify in config!") return if "gw_mac" in self.args: self.gw_mac = self.args['gw_mac'] @@ -28,9 +28,30 @@ class Intercom(hass.Hass): if 'ringtone' in self.args: self.ringtone = self.args['ringtone'] self.listen_event(self.domofon_call, "domofon_call") + self.listen_event(self.receive_telegram_callback, 'telegram_callback') + # self.domofon_call(None, None, None) def domofon_call(self, event_id, event_args, kwargs): self.log("domofon_call") self.log(event_args) self.call_service("xiaomi_aqara/play_ringtone", gw_mac=self.gw_mac, ringtone_id=self.ringtone, ringtone_vol=100) - self.notify("Звонок в домофон!!!", name = self.args['notify']) + keyboard = [[("Открыть", "/domofon_open")]] + self.call_service('telegram_bot/send_message', + target=self.args['chat_id'], + message="Звонок в домофон!!!", + disable_notification=False, + inline_keyboard=keyboard) + + def receive_telegram_callback(self, event_id, payload_event, *args): + """Event listener for Telegram callback queries.""" + assert event_id == 'telegram_callback' + data_callback = payload_event['data'] + callback_id = payload_event['id'] + chat_id = payload_event['chat_id'] + + if data_callback == '/domofon_open': + self.turn_on("switch.domofon_open") + self.turn_off("switch.domofon_open") + self.call_service('telegram_bot/answer_callback_query', + message='Готово', + callback_query_id=callback_id) \ No newline at end of file diff --git a/appdaemon/settings/apps/doorbells/intercom.yaml b/appdaemon/settings/apps/doorbells/intercom.yaml index 65f47be..634b371 100644 --- a/appdaemon/settings/apps/doorbells/intercom.yaml +++ b/appdaemon/settings/apps/doorbells/intercom.yaml @@ -6,5 +6,6 @@ intercom: class: Intercom notify: telegram gw_mac: !secret aqara1_mac + chat_id: !secret telegram_group_chat global_dependencies: - globals \ No newline at end of file diff --git a/appdaemon/settings/apps/notifiers/watchdog.py b/appdaemon/settings/apps/notifiers/watchdog.py index 564e51b..85c79ba 100644 --- a/appdaemon/settings/apps/notifiers/watchdog.py +++ b/appdaemon/settings/apps/notifiers/watchdog.py @@ -21,34 +21,44 @@ class Watchdog(hass.Hass): listen_event_handle_list = [] timezone = 3 def initialize(self): - if not globals.check_args(self, ['entity','update_interval', 'timeout']): + if not globals.check_args(self, ['check_interval', 'timeout']): return - self.timer = self.run_every(self.watchdog_check, self.datetime()+timedelta(seconds=10), self.args['update_interval']) + self.timer = self.run_every(self.watchdog_check, self.datetime()+timedelta(seconds=10), self.args['check_interval']) def watchdog_check(self, kwargs): if 'constraint' in self.args and not self.constrain_input_boolean(self.args['constraint']): return + + entities = [] + if 'entity' in self.args: + entities.append(self.args['entity']) + if 'entities' in self.args: + for entity in self.args['entities']: + entities.append(entity) + + # self.log(entities) + for entity in entities: + # entity = self.args['entity'] + attributes = self.get_state(entity, attribute='all') + if not 'last_changed' in attributes: + return + last_changed = attributes['last_changed'] + last_changed_date = datetime.datetime.strptime(last_changed, '%Y-%m-%dT%H:%M:%S.%f+00:00') + timedelta(hours=self.timezone) + time_diff = datetime.datetime.now() - last_changed_date + if time_diff.seconds > self.args['timeout']: + self.log('Entity {} was changed {} seconds ago.'.format(entity, time_diff.seconds)) - entity = self.args['entity'] - attributes = self.get_state(entity, attribute='all') - if not 'last_changed' in attributes: - return - last_changed = attributes['last_changed'] - last_changed_date = datetime.datetime.strptime(last_changed, '%Y-%m-%dT%H:%M:%S.%f+00:00') + timedelta(hours=self.timezone) - time_diff = datetime.datetime.now() - last_changed_date - if time_diff.seconds > self.args['timeout']: - self.log('Entity {} was changed {} seconds ago.'.format(entity, time_diff.seconds)) - - friendly_name = self.friendly_name(entity) - message = "Датчик {} ({}) не обновлялся в течении {} секунд.".format(friendly_name, entity, self.args['timeout']) - self.notify(message, name = "telegram") - self.reboot_sensor() + friendly_name = self.friendly_name(entity) + message = "Датчик {} ({}) не обновлялся в течении {} секунд.".format(friendly_name, entity, self.args['timeout']) + self.notify(message, name = "telegram") + + if 'reboot' in self.args and self.args['reboot']: + self.reboot_sensor() def reboot_sensor(self): - if 'reboot' in self.args and self.args['reboot']: - host = self.args['host'] - headers = {'Content-Type': 'application/json; charset=utf-8', 'Referer': 'http://{}/others.html'.format(host), 'X-Requested-With': 'XMLHttpRequest'} - data = 'msg={"CID":20003,"PL":{}}' - response = requests.post('http://{}/cmd'.format(host), headers=headers, data=data, auth=('admin', self.args['password'])) - json = response.json() - self.log("reboot status: {}".format(json)) + host = self.args['host'] + headers = {'Content-Type': 'application/json; charset=utf-8', 'Referer': 'http://{}/others.html'.format(host), 'X-Requested-With': 'XMLHttpRequest'} + data = 'msg={"CID":20003,"PL":{}}' + response = requests.post('http://{}/cmd'.format(host), headers=headers, data=data, auth=('admin', self.args['password'])) + json = response.json() + self.log("reboot status: {}".format(json)) diff --git a/appdaemon/settings/apps/notifiers/watchdog.yaml b/appdaemon/settings/apps/notifiers/watchdog.yaml index dba0375..8259cb6 100644 --- a/appdaemon/settings/apps/notifiers/watchdog.yaml +++ b/appdaemon/settings/apps/notifiers/watchdog.yaml @@ -5,10 +5,36 @@ watchdog_sensor_power_active_power: class: Watchdog module: watchdog entity: sensor.power_active_power - update_interval: 60 + check_interval: 60 timeout: 360 reboot: True host: !secret modbus_host password: !secret modbus_admin_pass global_dependencies: - globals + +watchdog_wemos_uptime: + class: Watchdog + module: watchdog + entities: + - sensor.uptime_airfresher + - sensor.uptime_bath + - sensor.uptime_domofon + - sensor.uptime_kitchen_led + - sensor.uptime_nest + check_interval: 60 + timeout: 360 + global_dependencies: + - globals + +watchdog_sonoff_uptime: + class: Watchdog + module: watchdog + entities: + - sensor.uptime_bathlight + - sensor.uptime_kitchen + check_interval: 60 + timeout: 7200 + global_dependencies: + - globals + diff --git a/appdaemon/settings/apps/presence/home/purifier.py b/appdaemon/settings/apps/presence/home/purifier.py new file mode 100644 index 0000000..4003c26 --- /dev/null +++ b/appdaemon/settings/apps/presence/home/purifier.py @@ -0,0 +1,103 @@ +import appdaemon.plugins.hass.hassapi as hass +import globals +import datetime + +# +# Purifier controller +# Turn on and off purifier by light sensor and timers +# +# Args: +# +# None +# +# Release Notes +# +# Version 1.0: +# Initial Version + +class Purifier(hass.Hass): + away_fan_speed = 8 #1-14 + listen_event_handle_list = [] + mode = 'auto' + + def initialize(self): + if not globals.check_args(self, ["on_time", "off_time"]): + return + on_time = self.parse_time(self.args["on_time"]) + off_time = self.parse_time(self.args["off_time"]) + + self.on_timer = self.run_daily(self.day_mode_timer_event, on_time) + self.off_timer = self.run_daily(self.night_mode_timer_event, off_time) + + self.listen_event_handle_list.append(self.listen_event(self.away_mode_event, "away_mode")) + self.listen_event_handle_list.append(self.listen_event(self.return_home_mode_event, "return_home_mode")) + self.listen_event_handle_list.append(self.listen_state(self.state_change, self.args['entity'], attribute='illuminance')) + + def state_change(self, entity, attribute, old, new, kwargs): + self.log("{} changed from {} to {}".format(attribute, old, new)) + if old > 0 and new == 0 and self.time_is_between(self.datetime(), self.args["on_time"], self.args["off_time"]): + self.night_mode() + elif old == 0 and new > 0 and self.time_is_between(self.datetime(), self.args["on_time"], self.args["off_time"]): + self.auto_mode() + #self.auto_mode() + + def return_home_mode_event(self, event_id, event_args, kwargs): + self.auto_mode() + + def away_mode_event(self, event_id, event_args, kwargs): + self.away_mode() + + def night_mode_timer_event(self, kwargs): + self.night_mode() + + def day_mode_timer_event(self, kwargs): + self.auto_mode() + + + def away_mode(self): + self.mode='away' + self.log('Set purifier mode to favorite, speed: {}'.format(self.away_fan_speed)) + if 'entity' in self.args: + self.call_service("fan/set_speed", entity_id = self.args['entity'], speed='favorite') + self.call_service("fan/xiaomi_miio_set_favorite_level", entity_id = self.args['entity'], level=self.away_fan_speed) + self.call_service("fan/xiaomi_miio_set_led_on", entity_id = self.args['entity']) + self.call_service("fan/xiaomi_miio_set_buzzer_on", entity_id = self.args['entity']) + + def auto_mode(self): + self.mode='auto' + self.log('Set purifier mode to auto.') + if 'entity' in self.args: + self.call_service("fan/set_speed", entity_id = self.args['entity'], speed='auto') + self.call_service("fan/xiaomi_miio_set_led_on", entity_id = self.args['entity']) + self.call_service("fan/xiaomi_miio_set_buzzer_on", entity_id = self.args['entity']) + + def night_mode(self): + self.mode='night' + self.log('Set purifier mode to silent.') + if 'entity' in self.args: + self.call_service("fan/xiaomi_miio_set_buzzer_off", entity_id = self.args['entity']) + self.call_service("fan/set_speed", entity_id = self.args['entity'], speed='silent') + self.call_service("fan/xiaomi_miio_set_led_off", entity_id = self.args['entity']) + + + def time_is_between( + hass, target_dt: datetime, start_time: str, + end_time: str) -> bool: + """Generalization of AppDaemon's now_is_between method.""" + start_time_dt = hass.parse_time(start_time) # type: datetime + end_time_dt = hass.parse_time(end_time) # type: datetime + start_dt = target_dt.replace( + hour=start_time_dt.hour, + minute=start_time_dt.minute, + second=start_time_dt.second) + end_dt = target_dt.replace( + hour=end_time_dt.hour, + minute=end_time_dt.minute, + second=end_time_dt.second) + + if end_dt < start_dt: + # Spans midnight + if target_dt < start_dt and target_dt < end_dt: + target_dt = target_dt + timedelta(days=1) + end_dt = end_dt + timedelta(days=1) + return start_dt <= target_dt <= end_dt \ No newline at end of file diff --git a/appdaemon/settings/apps/presence/home/purifier.yaml b/appdaemon/settings/apps/presence/home/purifier.yaml new file mode 100644 index 0000000..3b35dc3 --- /dev/null +++ b/appdaemon/settings/apps/presence/home/purifier.yaml @@ -0,0 +1,8 @@ +purifier_timer: + module: purifier + class: Purifier + on_time: "06:45:00" + off_time: "23:00:00" + entity: fan.xiaomi_miio_device + global_dependencies: + - globals \ No newline at end of file diff --git a/appdaemon/settings/apps/presence/homekit/homekitpresense.py b/appdaemon/settings/apps/presence/homekit/homekitpresense.py new file mode 100644 index 0000000..5522fbc --- /dev/null +++ b/appdaemon/settings/apps/presence/homekit/homekitpresense.py @@ -0,0 +1,29 @@ +import appdaemon.plugins.hass.hassapi as hass + +# +# Listen for homekit presence +# +# Args: +# sensor - home presence 'sensor' +# ha_panel - alarm control panel entity (to arm and disarm). +# constraint - (optional, input_boolen), if turned off - alarm panel will be not armed\disarmed. +# +# Release Notes +# +# Version 1.0: +# Initial Version + +class HomekitPresense(hass.Hass): + def initialize(self): + if "sensor" not in self.args or "arrive_event" not in self.args or "leave_event" not in self.args: + self.error("Please provide sensor, arrive_event, leave_event in config!") + return + self.listen_state(self.sensor_trigger, self.args['sensor']) + + def sensor_trigger(self, entity, attribute, old, new, kwargs): + if new == "on" and old == "off": + self.fire_event(self.args['arrive_event']) + elif new == "off" and old == "on": + self.fire_event(self.args['leave_event']) + self.log("{} turned {}".format(entity, new)) + self.notify("{} turned {}".format(entity, new), name = self.args['notify']) \ No newline at end of file diff --git a/appdaemon/settings/apps/presence/homekit/homekitpresense.yaml b/appdaemon/settings/apps/presence/homekit/homekitpresense.yaml new file mode 100644 index 0000000..6ea1eaa --- /dev/null +++ b/appdaemon/settings/apps/presence/homekit/homekitpresense.yaml @@ -0,0 +1,35 @@ +################################# +# HOMEKIT PRESENSE # +################################# +monster_home_presence: + module: homekitpresense + class: HomekitPresense + notify: telegram_monster + sensor: input_boolean.monster_home + arrive_event: monster_home_arrive + leave_event: monster_home_leave + constraint: input_boolean.presence_control + global_dependencies: + - globals + +monster_work_presence: + module: homekitpresense + class: HomekitPresense + notify: telegram_monster + sensor: input_boolean.monster_work + arrive_event: monster_work_arrive + leave_event: monster_work_leave + constraint: input_boolean.presence_control + global_dependencies: + - globals + +homekit_home_presence: + module: homekitpresense + class: HomekitPresense + notify: telegram_monster + sensor: input_boolean.homekit_presense + arrive_event: homekit_home_arrive + leave_event: homekit_home_leave + constraint: input_boolean.presence_control + global_dependencies: + - globals \ No newline at end of file diff --git a/appdaemon/settings/apps/presence/homekit/intercom_switch.py b/appdaemon/settings/apps/presence/homekit/intercom_switch.py new file mode 100644 index 0000000..e30ffb2 --- /dev/null +++ b/appdaemon/settings/apps/presence/homekit/intercom_switch.py @@ -0,0 +1,37 @@ +import appdaemon.plugins.hass.hassapi as hass +import datetime +# +# Intercom auto open switch by presence +# +# Args: +# entity_id - intercom autp open switch +# arrive_event - Arrive event name. +# leave_event - Leave event name +# constraint - (optional, input_boolen), if turned off - no notifications will be sent. +# +# Release Notes +# +# Version 1.0: +# Initial Version + +class IntercomSwitch(hass.Hass): + def initialize(self): + if "entity_id" not in self.args or "arrive_event" not in self.args or "leave_event" not in self.args: + self.error("Please provide entity_id, arrive_event, leave_event in config!") + return + self.listen_event(self.arrive_event, self.args['arrive_event']) + self.listen_event(self.leave_event, self.args['leave_event']) + + def arrive_event(self, event_id, event_args, kwargs): + if 'constraint' in self.args and not self.constrain_input_boolean(self.args['constraint']): + return + entity_id = self.args['entity_id'] + self.log("Somebody come home, turn on {}".format(entity_id)) + self.turn_on(entity_id) + + def leave_event(self, event_id, event_args, kwargs): + if 'constraint' in self.args and not self.constrain_input_boolean(self.args['constraint']): + return + entity_id = self.args['entity_id'] + self.log("All persons leave home, turn off {}".format(entity_id)) + self.turn_off(entity_id) \ No newline at end of file diff --git a/appdaemon/settings/apps/presence/homekit/intercom_switch.yaml b/appdaemon/settings/apps/presence/homekit/intercom_switch.yaml new file mode 100644 index 0000000..6fa29de --- /dev/null +++ b/appdaemon/settings/apps/presence/homekit/intercom_switch.yaml @@ -0,0 +1,11 @@ +################################# +# WORK PRESENSE # +################################# +intercom_auto_open: + module: intercom_switch + class: IntercomSwitch + entity_id: switch.domofon_mode + arrive_event: homekit_home_arrive + leave_event: homekit_home_leave + global_dependencies: + - globals \ No newline at end of file diff --git a/appdaemon/settings/apps/presence/homekit/worktime.py b/appdaemon/settings/apps/presence/homekit/worktime.py new file mode 100644 index 0000000..faa3ec6 --- /dev/null +++ b/appdaemon/settings/apps/presence/homekit/worktime.py @@ -0,0 +1,116 @@ +import appdaemon.plugins.hass.hassapi as hass +import datetime +# +# Listen for homekit presence +# +# Args: +# arrive_event - Arrive event name. +# leave_event - Leave event name +# notify - notify platform +# constraint - (optional, input_boolen), if turned off - no notifications will be sent. +# +# Release Notes +# +# Version 1.0: +# Initial Version + +class WorkTime(hass.Hass): + arrive_time = None + leave_time = None + notify_time = None + app_namespace = "worktime_namespace" + work_hours = 9 + notify_before_leave_in_minutes = 15 + notify_every_seconds = 300 + timers = [] + + def initialize(self): + if 'notify' not in self.args or "arrive_event" not in self.args or "leave_event" not in self.args: + self.error("Please provide arrive_event, leave_event in config!") + return + self.listen_event(self.arrive_event, self.args['arrive_event']) + self.listen_event(self.leave_event, self.args['leave_event']) + self.run_daily(self.reset_timer_tick, self.parse_time("23:59:59")) + self.load_state() + self.arrive_event(None, None, None) + #self.save_state() + + def arrive_event(self, event_id, event_args, kwargs): + if 'constraint' in self.args and not self.constrain_input_boolean(self.args['constraint']): + return + if self.arrive_time != None: + self.log('You have already arrived to the work today at {}'.format(self.arrive_time)) + return + self.arrive_time = self.datetime() + self.leave_time = self.datetime()+datetime.timedelta(hours=self.work_hours) + self.notify_time = self.leave_time-datetime.timedelta(minutes=self.notify_before_leave_in_minutes) + if self.notify_time None: + if 'constraint' in self.args and not self.constrain_input_boolean(self.args['constraint']): + return + if self.datetime() None: + for timer in self.timers: + self.cancel_timer(timer) + self.timers = [] + + def reset_timer_tick(self, kwargs): + self.arrive_time = None + self.leave_time = None + self.notify_time = None + self.save_state() + + def load_state(self): + self.arrive_time=self.deserialize_datetime(self.get_state("sensor.arrive_time", namespace=self.app_namespace)) + self.leave_time=self.deserialize_datetime(self.get_state("sensor.leave_time", namespace=self.app_namespace)) + self.notify_time=self.deserialize_datetime(self.get_state("sensor.notify_time", namespace=self.app_namespace)) + self.log("App is reloaded. Saved values - arrive_time: {}, leave_time: {}, notify_time: {}".format(self.arrive_time, self.leave_time, self.notify_time)) + + def save_state(self): + self.set_state("sensor.arrive_time", state=self.serialize_datetime(self.arrive_time), namespace=self.app_namespace) + self.set_state("sensor.leave_time", state=self.serialize_datetime(self.leave_time), namespace=self.app_namespace) + self.set_state("sensor.notify_time", state=self.serialize_datetime(self.notify_time), namespace=self.app_namespace) + + def format_time(self, date): + return date.strftime("%H:%M") + + def serialize_datetime(self, date): + if date is None: + return None + return date.isoformat() + + def deserialize_datetime(seld, date): + if (date is None): + return None + return datetime.datetime.fromisoformat(date) diff --git a/appdaemon/settings/apps/presence/homekit/worktime.yaml b/appdaemon/settings/apps/presence/homekit/worktime.yaml new file mode 100644 index 0000000..d33f65a --- /dev/null +++ b/appdaemon/settings/apps/presence/homekit/worktime.yaml @@ -0,0 +1,13 @@ +################################# +# WORK PRESENSE # +################################# +monster_worktime: + module: worktime + class: WorkTime + notify: telegram_monster + arrive_event: monster_work_arrive + leave_event: monster_work_leave + work_to_home_time: sensor.monster_travel_time_work_to_home + constraint: input_boolean.work_presence_report + global_dependencies: + - globals \ No newline at end of file diff --git a/appdaemon/settings/apps/presence/work/work.py b/appdaemon/settings/apps/presence/work/work.py deleted file mode 100644 index 05989ac..0000000 --- a/appdaemon/settings/apps/presence/work/work.py +++ /dev/null @@ -1,47 +0,0 @@ -import appdaemon.plugins.hass.hassapi as hass - -# -# Work leave reminder -# Send reminder about 'time to leave your work' -# -# Args: -# -# Release Notes -# -# Version 1.0: -# Initial Version - -class WorkPresence(hass.Hass): - def initialize(self): - self.listen_event(self.office_enter, "office_enter") - self.listen_event(self.office_leave, "office_leave") - self.listen_event(self.office_leave_reminder, "office_leave_reminder") - - def office_leave_reminder(self, event_id, event_args, kwargs): - if 'constraint' in self.args and not self.constrain_input_boolean(self.args['constraint']): - return - self.log('leave_reminder') - enter_time = self.get_state('sensor.work_enter_time') - leave_time = self.get_state('sensor.work_plan_leave_time') - if 'payload' in event_args: - leave_time = event_args['payload'] - message = "Ты пришел на работу в {}, работаешь до {}. Пора домой!".format(enter_time, leave_time) - self.call_service('notify/telegram', message=message) - - def office_leave(self, event_id, event_args, kwargs): - if 'constraint' in self.args and not self.constrain_input_boolean(self.args['constraint']): - return - self.log('leave') - work_to_home_time = self.get_state('sensor.work_to_home_time') - message="Время в пути до дома {} минут.".format(work_to_home_time) - self.call_service('notify/telegram', message=message) - - def office_enter(self, event_id, event_args, kwargs): - if 'constraint' in self.args and not self.constrain_input_boolean(self.args['constraint']): - return - self.log('enter') - enter_time = event_args.get('payload', None) - if enter_time == '' or enter_time == None: - return - self.log(enter_time) - self.call_service('notify/telegram', message='Добро пожаловать в офис. Приятного дня =)))') \ No newline at end of file diff --git a/appdaemon/settings/apps/presence/work/work.yaml b/appdaemon/settings/apps/presence/work/work.yaml deleted file mode 100644 index 408162d..0000000 --- a/appdaemon/settings/apps/presence/work/work.yaml +++ /dev/null @@ -1,9 +0,0 @@ -################################# -# WORK PRESENSE # -################################# -work_presence: - module: work - class: WorkPresence - constraint: input_boolean.work_presence_report - global_dependencies: - - globals \ No newline at end of file diff --git a/appdaemon/settings/apps/test.py.save b/appdaemon/settings/apps/test.py.save deleted file mode 100644 index 6df9d26..0000000 --- a/appdaemon/settings/apps/test.py.save +++ /dev/null @@ -1,39 +0,0 @@ -# -*- coding: utf-8 -*- -import appdaemon.plugins.hass.hassapi as hass -from notification import send_notification -import datetime -import sys, locale -import logging -#import unicode -# from automation import Automation # type: ignore - -""" -Monitor events and output changes to the verbose_log. Nice for debugging purposes. -Arguments: - - events: List of events to monitor -""" -class Test(hass.Hass): - def initialize(self) -> None: - root_logger= logging.getLogger() - root_logger.setLevel(logging.DEBUG) # or whatever - handler = logging.FileHandler('test.log', 'w', 'utf-8') # or whatever - formatter = logging.Formatter('%(name)s %(message)s') # or whatever - handler.setFormatter(formatter) # Pass handler as a parameter, not assign - root_logger.addHandler(handler) - UTF8Writer = codecs.getwriter('utf8') - sys.stdout = UTF8Writer(sys.stdout.buffer) - self.log(u"Привет") - self.log(str(u'ô')) - # id = send_notification( - # self, targets="telegram_monster", message="Repeated message", title="title", when=datetime.datetime.now(), interval=10, iterations=2 - # ) - # self.log("id:{}".format(id)) - # id = send_notification( - # self, targets="telegram_monster", message="Dummy", title="title" - # ) - - def terminate(self): - self.log('terminate') - # for listen_event_handle in self.listen_event_handle_list: - # self.cancel_listen_event(listen_event_handle) - diff --git a/appdaemon/settings/apps/test/telegram_test.py b/appdaemon/settings/apps/test/telegram_test.py new file mode 100644 index 0000000..799ff65 --- /dev/null +++ b/appdaemon/settings/apps/test/telegram_test.py @@ -0,0 +1,73 @@ +import appdaemon.plugins.hass.hassapi as hass + +class TelegramTest(hass.Hass): + def initialize(self): + """Listen to Telegram Bot events of interest.""" + self.listen_event(self.receive_telegram_text, 'telegram_text') + self.listen_event(self.receive_telegram_callback, 'telegram_callback') + + def receive_telegram_text(self, event_id, payload_event, *args): + """Text repeater.""" + assert event_id == 'telegram_text' + user_id = payload_event['user_id'] + msg = 'You said: ``` %s ```' % payload_event['text'] + keyboard = [[("Edit message", "/edit_msg"), + ("Don't", "/do_nothing")], + [("Remove this button", "/remove button")]] + self.call_service('telegram_bot/send_message', + title='*Dumb automation*', + target=user_id, + message=msg, + disable_notification=True, + inline_keyboard=keyboard) + + def receive_telegram_callback(self, event_id, payload_event, *args): + """Event listener for Telegram callback queries.""" + assert event_id == 'telegram_callback' + data_callback = payload_event['data'] + callback_id = payload_event['id'] + chat_id = payload_event['chat_id'] + self.log(payload_event) + # keyboard = ["Edit message:/edit_msg, Don't:/do_nothing", + # "Remove this button:/remove button"] + keyboard = [[("Edit message", "/edit_msg"), + ("Don't", "/do_nothing")], + [("Remove this button", "/remove button")]] + + if data_callback == '/edit_msg': # Message editor: + # Answer callback query + self.call_service('telegram_bot/answer_callback_query', + message='Editing the message!', + callback_query_id=callback_id, + show_alert=True) + + # Edit the message origin of the callback query + msg_id = payload_event['message']['message_id'] + user = payload_event['from_first'] + title = '*Message edit*' + msg = 'Callback received from %s. Message id: %s. Data: ``` %s ```' + self.call_service('telegram_bot/edit_message', + chat_id=chat_id, + message_id=msg_id, + title=title, + message=msg % (user, msg_id, data_callback), + inline_keyboard=keyboard) + + elif data_callback == '/remove button': # Keyboard editor: + # Answer callback query + self.call_service('telegram_bot/answer_callback_query', + message='Callback received for editing the ' + 'inline keyboard!', + callback_query_id=callback_id) + + # Edit the keyboard + new_keyboard = keyboard[:1] + self.call_service('telegram_bot/edit_replymarkup', + chat_id=chat_id, + message_id='last', + inline_keyboard=new_keyboard) + + elif data_callback == '/do_nothing': # Only Answer to callback query + self.call_service('telegram_bot/answer_callback_query', + message='OK, you said no!', + callback_query_id=callback_id) \ No newline at end of file diff --git a/appdaemon/settings/apps/test/telegram_test.yaml b/appdaemon/settings/apps/test/telegram_test.yaml new file mode 100644 index 0000000..6343aa3 --- /dev/null +++ b/appdaemon/settings/apps/test/telegram_test.yaml @@ -0,0 +1,3 @@ +telegram_test: + module: telegram_test + class: TelegramTest diff --git a/hass/settings/configuration/alice.yaml b/hass/settings/configuration/alice.yaml index 1fe1204..7849b68 100644 --- a/hass/settings/configuration/alice.yaml +++ b/hass/settings/configuration/alice.yaml @@ -6,6 +6,7 @@ filter: - light - vacuum - cover + - fan include_entities: - input_boolean.alice_night_mode - input_boolean.alice_turn_off_light diff --git a/hass/settings/configuration/customize/fan.yaml b/hass/settings/configuration/customize/fan.yaml new file mode 100644 index 0000000..b765de6 --- /dev/null +++ b/hass/settings/configuration/customize/fan.yaml @@ -0,0 +1,2 @@ +fan.xiaomi_miio_device: + friendly_name: Воздухоочиститель \ No newline at end of file diff --git a/hass/settings/configuration/homekit/homekit.yaml b/hass/settings/configuration/homekit/homekit.yaml index 6b88eb6..6e3145b 100644 --- a/hass/settings/configuration/homekit/homekit.yaml +++ b/hass/settings/configuration/homekit/homekit.yaml @@ -6,6 +6,9 @@ filter: - light - vacuum - cover + - fan + - climate + - camera include_entities: - sensor.humidity_158d0001149b3c - sensor.humidity_158d000117d37a @@ -15,6 +18,9 @@ filter: - sensor.temperature_158d000117d37a - sensor.temperature_158d00012d9ad7 - sensor.temperature_158d000149c128 + - input_boolean.monster_work + - input_boolean.monster_home + - input_boolean.homekit_presense exclude_entities: - switch.plug_158d00010dd98d - switch.xiaomi_miio_switch diff --git a/hass/settings/configuration/input/input_boolean/homekit_locations.yaml b/hass/settings/configuration/input/input_boolean/homekit_locations.yaml new file mode 100644 index 0000000..4b28749 --- /dev/null +++ b/hass/settings/configuration/input/input_boolean/homekit_locations.yaml @@ -0,0 +1,6 @@ +monster_work: + name: Монстр на работе +monster_home: + name: Монстр дома +homekit_presense: + name: Присутсиве по HomeKit diff --git a/hass/settings/configuration/sensor/uptime.yaml b/hass/settings/configuration/sensor/uptime.yaml new file mode 100644 index 0000000..e8b07bd --- /dev/null +++ b/hass/settings/configuration/sensor/uptime.yaml @@ -0,0 +1,21 @@ +- platform: mqtt + state_topic: 'home/domofon/$uptime' + name: 'uptime_domofon' +- platform: mqtt + state_topic: 'home/bath/$uptime' + name: 'uptime_bath' +- platform: mqtt + state_topic: 'home/kitchen-led/$uptime' + name: 'uptime_kitchen-led' +- platform: mqtt + state_topic: 'home/nest/$uptime' + name: 'uptime_nest' +- platform: mqtt + state_topic: 'home/airfresher/$uptime' + name: 'uptime_airfresher' +- platform: mqtt + state_topic: 'tele/bathlight/UPTIME' + name: 'uptime_bathlight' +- platform: mqtt + state_topic: 'tele/kitchen/UPTIME' + name: 'uptime_kitchen' \ No newline at end of file diff --git a/hass/settings/configuration/sensor/yandex_maps.yaml b/hass/settings/configuration/sensor/yandex_maps.yaml new file mode 100644 index 0000000..50c0466 --- /dev/null +++ b/hass/settings/configuration/sensor/yandex_maps.yaml @@ -0,0 +1,11 @@ +################################################# +# CAR TRAVEL TIME # +################################################# +- platform: yandex_maps + start: !secret home_coords + destination: !secret work_coords + name: monster_travel_time_home_to_work +- platform: yandex_maps + start: !secret work_coords + destination: !secret home_coords + name: monster_travel_time_work_to_home \ No newline at end of file diff --git a/hass/settings/custom_components/hacs.zip b/hass/settings/custom_components/hacs.zip new file mode 100644 index 0000000..f2de9e5 Binary files /dev/null and b/hass/settings/custom_components/hacs.zip differ diff --git a/hass/settings/custom_components/hacs/__init__.py b/hass/settings/custom_components/hacs/__init__.py new file mode 100644 index 0000000..15f8034 --- /dev/null +++ b/hass/settings/custom_components/hacs/__init__.py @@ -0,0 +1,187 @@ +""" +Custom element manager for community created elements. + +For more details about this integration, please refer to the documentation at +https://hacs.xyz/ +""" +import voluptuous as vol +from aiogithubapi import AIOGitHub +from homeassistant import config_entries +from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.const import __version__ as HAVERSION +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.event import async_call_later + +from .configuration_schema import hacs_base_config_schema, hacs_config_option_schema +from .const import DEV_MODE, DOMAIN, ELEMENT_TYPES, STARTUP, VERSION +from .constrains import check_constans +from .hacsbase import Hacs +from .hacsbase.configuration import Configuration +from .hacsbase.data import HacsData +from .hacsbase.migration import ValidateData +from .setup import add_sensor, load_hacs_repository, setup_frontend + +SCHEMA = hacs_base_config_schema() +SCHEMA[vol.Optional("options")] = hacs_config_option_schema() +CONFIG_SCHEMA = vol.Schema({DOMAIN: SCHEMA}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up this integration using yaml.""" + if DOMAIN not in config: + return True + hass.data[DOMAIN] = config + Hacs.hass = hass + Hacs.configuration = Configuration.from_dict( + config[DOMAIN], config[DOMAIN].get("options") + ) + Hacs.configuration.config_type = "yaml" + await startup_wrapper_for_yaml(Hacs) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data={} + ) + ) + return True + + +async def async_setup_entry(hass, config_entry): + """Set up this integration using UI.""" + conf = hass.data.get(DOMAIN) + if config_entry.source == config_entries.SOURCE_IMPORT: + if conf is None: + hass.async_create_task( + hass.config_entries.async_remove(config_entry.entry_id) + ) + return False + Hacs.hass = hass + Hacs.configuration = Configuration.from_dict( + config_entry.data, config_entry.options + ) + Hacs.configuration.config_type = "flow" + Hacs.configuration.config_entry = config_entry + config_entry.add_update_listener(reload_hacs) + startup_result = await hacs_startup(Hacs) + if not startup_result: + raise ConfigEntryNotReady + return startup_result + + +async def startup_wrapper_for_yaml(hacs): + """Startup wrapper for yaml config.""" + startup_result = await hacs_startup(hacs) + if not startup_result: + hacs.hass.components.frontend.async_remove_panel( + hacs.configuration.sidepanel_title.lower() + .replace(" ", "_") + .replace("-", "_") + ) + hacs.logger.info("Could not setup HACS, trying again in 15 min") + async_call_later(hacs.hass, 900, startup_wrapper_for_yaml(hacs)) + + +async def hacs_startup(hacs): + """HACS startup tasks.""" + hacs.logger.debug(f"Configuration type: {hacs.configuration.config_type}") + hacs.version = VERSION + hacs.logger.info(STARTUP) + hacs.system.config_path = hacs.hass.config.path() + hacs.system.ha_version = HAVERSION + hacs.system.disabled = False + hacs.github = AIOGitHub( + hacs.configuration.token, async_create_clientsession(hacs.hass) + ) + hacs.data = HacsData() + + # Check HACS Constrains + if not check_constans(hacs): + if hacs.configuration.config_type == "flow": + if hacs.configuration.config_entry is not None: + await async_remove_entry(hacs.hass, hacs.configuration.config_entry) + return False + + # Set up frontend + await setup_frontend(hacs) + + # Load HACS + if not await load_hacs_repository(hacs): + if hacs.configuration.config_type == "flow": + if hacs.configuration.config_entry is not None: + await async_remove_entry(hacs.hass, hacs.configuration.config_entry) + return False + + val = ValidateData() + if not val.validate_local_data_file(): + if hacs.configuration.config_type == "flow": + if hacs.configuration.config_entry is not None: + await async_remove_entry(hacs.hass, hacs.configuration.config_entry) + return False + + # Restore from storefiles + if not await hacs.data.restore(): + hacs_repo = hacs().get_by_name("hacs/integration") + hacs_repo.pending_restart = True + if hacs.configuration.config_type == "flow": + if hacs.configuration.config_entry is not None: + await async_remove_entry(hacs.hass, hacs.configuration.config_entry) + return False + + # Add aditional categories + if hacs.configuration.appdaemon: + ELEMENT_TYPES.append("appdaemon") + if hacs.configuration.python_script: + ELEMENT_TYPES.append("python_script") + if hacs.configuration.theme: + ELEMENT_TYPES.append("theme") + hacs.common.categories = sorted(ELEMENT_TYPES) + + # Setup startup tasks + if hacs.configuration.config_type == "yaml": + hacs.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, hacs().startup_tasks() + ) + else: + async_call_later(hacs.hass, 5, hacs().startup_tasks()) + + # Print DEV warning + if hacs.configuration.dev: + hacs.logger.warning(DEV_MODE) + hacs.hass.components.persistent_notification.create( + title="HACS DEV MODE", message=DEV_MODE, notification_id="hacs_dev_mode" + ) + + # Add sensor + add_sensor(hacs) + + # Set up services + # await add_services(hacs) + + # Mischief managed! + return True + + +async def async_remove_entry(hass, config_entry): + """Handle removal of an entry.""" + Hacs().logger.info("Disabling HACS") + Hacs().logger.info("Removing recuring tasks") + for task in Hacs().tasks: + task() + Hacs().logger.info("Removing sensor") + try: + await hass.config_entries.async_forward_entry_unload(config_entry, "sensor") + except ValueError: + pass + Hacs().logger.info("Removing sidepanel") + try: + hass.components.frontend.async_remove_panel("hacs_web") + except AttributeError: + pass + Hacs().system.disabled = True + Hacs().logger.info("HACS is now disabled") + + +async def reload_hacs(hass, config_entry): + """Reload HACS.""" + await async_remove_entry(hass, config_entry) + await async_setup_entry(hass, config_entry) diff --git a/hass/settings/custom_components/hacs/api.py b/hass/settings/custom_components/hacs/api.py new file mode 100644 index 0000000..fc064ec --- /dev/null +++ b/hass/settings/custom_components/hacs/api.py @@ -0,0 +1,421 @@ +"""API Endpoins.""" +from time import time +from aiohttp import web + +from integrationhelper import Logger + +from .http import HacsWebResponse + +APIRESPONSE = {} + + +def apiresponse(classname): + """Decorator used to register API Responses.""" + APIRESPONSE[classname.name] = classname + return classname + + +class HacsAPI(HacsWebResponse): + """HacsAPI class.""" + + name = "hacsapi" + + def __init__(self): + """Initialize.""" + self.logger = Logger("hacs.api") + self.url = self.hacsapi + "/{endpoint}" + + async def post(self, request, endpoint): # pylint: disable=unused-argument + """Handle HACS API requests.""" + if self.system.disabled: + return web.Response(status=404) + self.endpoint = endpoint + self.postdata = await request.post() + self.raw_headers = request.raw_headers + self.request = request + self.logger.debug(f"Endpoint ({endpoint}) called") + if self.configuration.dev: + self.logger.debug(f"Raw headers ({self.raw_headers})") + self.logger.debug(f"Postdata ({self.postdata})") + if self.endpoint in APIRESPONSE: + try: + response = APIRESPONSE[self.endpoint] + response = await response.response(self) + except Exception as exception: + render = self.render(f"error", message=exception) + return web.Response( + body=render, content_type="text/html", charset="utf-8" + ) + else: + # Return default response. + response = await APIRESPONSE["generic"].response(self) + + # set headers + response.headers["Cache-Control"] = "max-age=0, must-revalidate" + + # serve the response + return response + + +class HacsRunningTask(HacsAPI): + """Return if BG task is running.""" + + name = "hacs:task" + + def __init__(self): + """Initialize.""" + self.url = "/hacs_task" + + async def get(self, request): # pylint: disable=unused-argument + """Handle GET request.""" + return web.json_response({"task": self.system.status.background_task}) + + +@apiresponse +class Generic(HacsAPI): + """Generic API response.""" + + name = "generic" + + async def response(self): + """Response.""" + self.logger.error(f"Unknown endpoint '{self.endpoint}'") + return web.HTTPFound(f"/hacsweb/{self.token}/settings?timestamp={time()}") + + +@apiresponse +class RemoveNewFlag(HacsAPI): + """Remove new flag on all repositories.""" + + name = "remove_new_flag" + + async def response(self): + """Response.""" + for repository in self.repositories: + repository.status.new = False + await self.data.async_write() + return web.HTTPFound( + f"/hacsweb/{self.token}/{self.postdata['return']}?timestamp={time()}" + ) + + +@apiresponse +class DevTemplate(HacsAPI): + """Remove new flag on all repositories.""" + + name = "dev_template" + + async def response(self): + """Response.""" + from .handler.template import render_template + + if "set" in self.postdata: + self.developer.template_id = self.postdata.get("repository_id") + repository = self.get_by_id(self.developer.template_id) + template = render_template(self.postdata.get("template", ""), repository) + info = await self.github.render_markdown(template) + info = info.replace("

", "

").replace("
", "") + info = info.replace("

", "

").replace("
", "") + info = info.replace("

", "

").replace("

", "") + info = info.replace("", "") + info = info.replace( + '", "") + info = info.replace("", "") + self.developer.template_content = info + else: + self.developer.template_content = "" + self.developer.template_id = "Repository ID" + render = self.render("settings/dev/template_test") + return web.Response(body=render, content_type="text/html", charset="utf-8") + + +@apiresponse +class DevView(HacsAPI): + """Set HA version view.""" + + name = "devview" + + async def response(self): + """Response.""" + render = self.render(f"settings/dev/{self.postdata['view']}") + return web.Response(body=render, content_type="text/html", charset="utf-8") + + +@apiresponse +class SetHAVersionAction(HacsAPI): + """Set HA version action.""" + + name = "set_ha_version_action" + + async def response(self): + """Response.""" + self.common.ha_version = self.postdata["ha_version"] + render = self.render("settings/dev/set_ha_version") + return web.Response(body=render, content_type="text/html", charset="utf-8") + + +@apiresponse +class RepositoryInstall(HacsAPI): + """Install repository.""" + + name = "repository_install" + + async def response(self): + """Response.""" + repository = self.get_by_id(self.postdata["repository_id"]) + await repository.install() + await self.data.async_write() + return web.HTTPFound( + f"/hacsweb/{self.token}/repository/{repository.information.uid}?timestamp={time()}" + ) + + +@apiresponse +class RepositoryUpdate(HacsAPI): + """Update repository.""" + + name = "repository_update" + + async def response(self): + """Response.""" + repository = self.get_by_id(self.postdata["repository_id"]) + await repository.update_repository() + await self.data.async_write() + return web.HTTPFound( + f"/hacsweb/{self.token}/repository/{repository.information.uid}?timestamp={time()}" + ) + + +@apiresponse +class RepositoryUninstall(HacsAPI): + """Uninstall repository.""" + + name = "repository_uninstall" + + async def response(self): + """Response.""" + repository = self.get_by_id(self.postdata["repository_id"]) + await repository.uninstall() + await self.data.async_write() + return web.HTTPFound(f"/hacsweb/{self.token}/overview?timestamp={time()}") + + +@apiresponse +class RepositoryRemove(HacsAPI): + """Remove repository.""" + + name = "repository_remove" + + async def response(self): + """Response.""" + repository = self.get_by_id(self.postdata["repository_id"]) + await repository.remove() + await self.data.async_write() + return web.HTTPFound(f"/hacsweb/{self.token}/settings?timestamp={time()}") + + +@apiresponse +class RepositoryHide(HacsAPI): + """Hide repository.""" + + name = "repository_hide" + + async def response(self): + """Response.""" + repository = self.get_by_id(self.postdata["repository_id"]) + repository.hide = True + await self.data.async_write() + return web.HTTPFound(f"/hacsweb/{self.token}/store?timestamp={time()}") + + +@apiresponse +class RepositoryUnhide(HacsAPI): + """Unhide repository.""" + + name = "repository_unhide" + + async def response(self): + """Response.""" + repository = self.get_by_id(self.postdata["repository_id"]) + repository.hide = False + await self.data.async_write() + return web.HTTPFound(f"/hacsweb/{self.token}/settings?timestamp={time()}") + + +@apiresponse +class RepositoryBetaHide(HacsAPI): + """Hide Beta repository.""" + + name = "repository_beta_hide" + + async def response(self): + """Response.""" + repository = self.get_by_id(self.postdata["repository_id"]) + repository.status.show_beta = False + await repository.update_repository() + await self.data.async_write() + return web.HTTPFound( + f"/hacsweb/{self.token}/repository/{repository.information.uid}?timestamp={time()}" + ) + + +@apiresponse +class RepositoryBetaShow(HacsAPI): + """Show Beta repository.""" + + name = "repository_beta_show" + + async def response(self): + """Response.""" + repository = self.get_by_id(self.postdata["repository_id"]) + repository.status.show_beta = True + await repository.update_repository() + await self.data.async_write() + return web.HTTPFound( + f"/hacsweb/{self.token}/repository/{repository.information.uid}?timestamp={time()}" + ) + + +@apiresponse +class RepositoriesReload(HacsAPI): + """Reload repository data.""" + + name = "repositories_reload" + + async def response(self): + """Response.""" + self.hass.async_create_task(self.recuring_tasks_all()) + return web.HTTPFound(f"/hacsweb/{self.token}/settings?timestamp={time()}") + + +@apiresponse +class RepositoriesUpgradeAll(HacsAPI): + """Upgrade all repositories.""" + + name = "repositories_upgrade_all" + + async def response(self): + """Response.""" + for repository in self.repositories: + if repository.pending_upgrade: + await repository.install() + await self.data.async_write() + return web.HTTPFound(f"/hacsweb/{self.token}/settings?timestamp={time()}") + + +@apiresponse +class RepositoryRegister(HacsAPI): + """Register repository.""" + + name = "repository_register" + + async def response(self): + """Response.""" + repository_name = self.postdata.get("custom_url") + repository_type = self.postdata.get("repository_type") + + # Validate data + if not repository_name: + message = "Repository URL is missing." + return web.HTTPFound( + f"/hacsweb/{self.token}/settings?timestamp={time()}&message={message}" + ) + if repository_type is None: + message = "Type is missing for '{}'.".format(repository_name) + return web.HTTPFound( + f"/hacsweb/{self.token}/settings?timestamp={time()}&message={message}" + ) + + # Stip first part if it's an URL. + if "github" in repository_name: + repository_name = repository_name.split("github.com/")[-1] + + # Strip whitespace + repository_name = repository_name.split()[0] + + # If it still have content, continue. + if repository_name != "": + if len(repository_name.split("/")) != 2: + message = f""" + {repository_name} is not a valid format. + Correct format is 'https://github.com/DEVELOPER/REPOSITORY' + or 'DEVELOPER/REPOSITORY'. + """ + + return web.HTTPFound( + f"/hacsweb/{self.token}/settings?timestamp={time()}&message={message}" + ) + + is_known_repository = self.is_known(repository_name) + if is_known_repository: + message = f"'{repository_name}' is already registered, look for it in the store." + return web.HTTPFound( + f"/hacsweb/{self.token}/settings?timestamp={time()}&message={message}" + ) + + if repository_name in self.common.blacklist: + self.common.blacklist.remove(repository_name) + + await self.register_repository(repository_name, repository_type) + + repository = self.get_by_name(repository_name) + if repository is not None: + return web.HTTPFound( + f"/hacsweb/{self.token}/repository/{repository.information.uid}?timestamp={time()}" + ) + + message = f""" + Could not add '{repository_name}' with type '{repository_type}' at this time.
+ If you used the correct type, check the log for more details.""" + return web.HTTPFound( + f"/hacsweb/{self.token}/settings?timestamp={time()}&message={message}" + ) + + +@apiresponse +class RepositorySelectTag(HacsAPI): + """Select tag for Repository.""" + + name = "repository_select_tag" + + async def response(self): + """Response.""" + from aiogithubapi import AIOGitHubException + from .hacsbase.exceptions import HacsRequirement + + repository = self.get_by_id(self.postdata["repository_id"]) + if self.postdata["selected_tag"] == repository.releases.last_release: + repository.status.selected_tag = None + else: + repository.status.selected_tag = self.postdata["selected_tag"] + + try: + await repository.update_repository() + except (AIOGitHubException, HacsRequirement): + repository.status.selected_tag = repository.releases.last_release + await repository.update_repository() + message = "The version {} is not valid for use with HACS.".format( + self.postdata["selected_tag"] + ) + return web.HTTPFound( + f"/hacsweb/{self.token}/repository/{repository.information.uid}?timestamp={time()}&message={message}" + ) + await self.data.async_write() + return web.HTTPFound( + f"/hacsweb/{self.token}/repository/{repository.information.uid}?timestamp={time()}" + ) + + +@apiresponse +class FrontentMode(HacsAPI): + """Set the frontend mode.""" + + name = "frontend_mode" + + async def response(self): + """Response.""" + self.configuration.frontend_mode = self.postdata["view_type"] + await self.data.async_write() + return web.HTTPFound(f"/hacsweb/{self.token}/settings?timestamp={time()}") diff --git a/hass/settings/custom_components/hacs/config_flow.py b/hass/settings/custom_components/hacs/config_flow.py new file mode 100644 index 0000000..cc392c7 --- /dev/null +++ b/hass/settings/custom_components/hacs/config_flow.py @@ -0,0 +1,100 @@ +"""Adds config flow for HACS.""" +# pylint: disable=dangerous-default-value +import logging +import voluptuous as vol +from aiogithubapi import AIOGitHub, AIOGitHubException, AIOGitHubAuthentication +from homeassistant import config_entries +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN +from .configuration_schema import hacs_base_config_schema, hacs_config_option_schema + + +_LOGGER = logging.getLogger(__name__) + + +class HacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for HACS.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize.""" + self._errors = {} + + async def async_step_user(self, user_input={}): + """Handle a flow initialized by the user.""" + self._errors = {} + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + if self.hass.data.get(DOMAIN): + return self.async_abort(reason="single_instance_allowed") + + if user_input is not None: + if await self._test_token(user_input["token"]): + return self.async_create_entry(title="", data=user_input) + + self._errors["base"] = "auth" + return await self._show_config_form(user_input) + + return await self._show_config_form(user_input) + + async def _show_config_form(self, user_input): + """Show the configuration form to edit location data.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema(hacs_base_config_schema(user_input)), + errors=self._errors, + ) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + return HacsOptionsFlowHandler(config_entry) + + async def async_step_import(self, user_input): + """Import a config entry. + Special type of import, we're not actually going to store any data. + Instead, we're going to rely on the values that are in config file. + """ + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + return self.async_create_entry(title="configuration.yaml", data={}) + + async def _test_token(self, token): + """Return true if token is valid.""" + try: + session = aiohttp_client.async_get_clientsession(self.hass) + client = AIOGitHub(token, session) + await client.get_repo("hacs/org") + return True + except (AIOGitHubException, AIOGitHubAuthentication): + pass + return False + + +class HacsOptionsFlowHandler(config_entries.OptionsFlow): + """HACS config flow options handler.""" + + def __init__(self, config_entry): + """Initialize HACS options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the options.""" + return await self.async_step_user() + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + hacs_config_option_schema(self.config_entry.options) + ), + ) diff --git a/hass/settings/custom_components/hacs/configuration_schema.py b/hass/settings/custom_components/hacs/configuration_schema.py new file mode 100644 index 0000000..ff88cfc --- /dev/null +++ b/hass/settings/custom_components/hacs/configuration_schema.py @@ -0,0 +1,49 @@ +"""HACS Configuration Schemas.""" +# pylint: disable=dangerous-default-value +import voluptuous as vol +from .const import LOCALE + +# Configuration: +TOKEN = "token" +SIDEPANEL_TITLE = "sidepanel_title" +SIDEPANEL_ICON = "sidepanel_icon" +APPDAEMON = "appdaemon" +PYTHON_SCRIPT = "python_script" +THEME = "theme" + +# Options: +COUNTRY = "country" +RELEASE_LIMIT = "release_limit" +EXPERIMENTAL = "experimental" + + +def hacs_base_config_schema(config: dict = {}) -> dict: + """Return a shcema configuration dict for HACS.""" + if not config: + config = { + TOKEN: "xxxxxxxxxxxxxxxxxxxxxxxxxxx", + SIDEPANEL_TITLE: "Community", + SIDEPANEL_ICON: "mdi:alpha-c-box", + APPDAEMON: False, + PYTHON_SCRIPT: False, + THEME: False, + } + return { + vol.Required(TOKEN, default=config.get(TOKEN)): str, + vol.Optional(SIDEPANEL_TITLE, default=config.get(SIDEPANEL_TITLE)): str, + vol.Optional(SIDEPANEL_ICON, default=config.get(SIDEPANEL_ICON)): str, + vol.Optional(APPDAEMON, default=config.get(APPDAEMON)): bool, + vol.Optional(PYTHON_SCRIPT, default=config.get(PYTHON_SCRIPT)): bool, + vol.Optional(THEME, default=config.get(THEME)): bool, + } + + +def hacs_config_option_schema(options: dict = {}) -> dict: + """Return a shcema for HACS configuration options.""" + if not options: + options = {COUNTRY: "ALL", RELEASE_LIMIT: 5, EXPERIMENTAL: False} + return { + vol.Optional("country", default=options.get(COUNTRY)): vol.In(LOCALE), + vol.Optional(RELEASE_LIMIT, default=options.get(RELEASE_LIMIT)): int, + vol.Optional(EXPERIMENTAL, default=options.get(EXPERIMENTAL)): bool, + } diff --git a/hass/settings/custom_components/hacs/const.py b/hass/settings/custom_components/hacs/const.py new file mode 100644 index 0000000..529dbb0 --- /dev/null +++ b/hass/settings/custom_components/hacs/const.py @@ -0,0 +1,295 @@ +"""Constants for HACS""" +NAME_LONG = "HACS (Home Assistant Community Store)" +NAME_SHORT = "HACS" +VERSION = "0.16.3" +DOMAIN = "hacs" +PROJECT_URL = "https://github.com/hacs/integration/" +CUSTOM_UPDATER_LOCATIONS = [ + "{}/custom_components/custom_updater.py", + "{}/custom_components/custom_updater/__init__.py", +] + +ISSUE_URL = f"{PROJECT_URL}issues" +DOMAIN_DATA = f"{NAME_SHORT.lower()}_data" + +ELEMENT_TYPES = ["integration", "plugin"] + +IFRAME = { + "title": "Community", + "icon": "mdi:alpha-c-box", + "url": "/community_overview", + "path": "community", + "require_admin": True, +} + + +# Messages +CUSTOM_UPDATER_WARNING = """ +This cannot be used with custom_updater. +To use this you need to remove custom_updater form {} +""" + +DEV_MODE = "You have 'dev' enabled for HACS, this is not intended for regular use, no support will be given if you break something." + +STARTUP = f""" +------------------------------------------------------------------- +HACS (Home Assistant Community Store) + +Version: {VERSION} +This is a custom integration +If you have any issues with this you need to open an issue here: +https://github.com/hacs/integration/issues +------------------------------------------------------------------- +""" + +LOCALE = [ + "ALL", + "AF", + "AL", + "DZ", + "AS", + "AD", + "AO", + "AI", + "AQ", + "AG", + "AR", + "AM", + "AW", + "AU", + "AT", + "AZ", + "BS", + "BH", + "BD", + "BB", + "BY", + "BE", + "BZ", + "BJ", + "BM", + "BT", + "BO", + "BQ", + "BA", + "BW", + "BV", + "BR", + "IO", + "BN", + "BG", + "BF", + "BI", + "KH", + "CM", + "CA", + "CV", + "KY", + "CF", + "TD", + "CL", + "CN", + "CX", + "CC", + "CO", + "KM", + "CG", + "CD", + "CK", + "CR", + "HR", + "CU", + "CW", + "CY", + "CZ", + "CI", + "DK", + "DJ", + "DM", + "DO", + "EC", + "EG", + "SV", + "GQ", + "ER", + "EE", + "ET", + "FK", + "FO", + "FJ", + "FI", + "FR", + "GF", + "PF", + "TF", + "GA", + "GM", + "GE", + "DE", + "GH", + "GI", + "GR", + "GL", + "GD", + "GP", + "GU", + "GT", + "GG", + "GN", + "GW", + "GY", + "HT", + "HM", + "VA", + "HN", + "HK", + "HU", + "IS", + "IN", + "ID", + "IR", + "IQ", + "IE", + "IM", + "IL", + "IT", + "JM", + "JP", + "JE", + "JO", + "KZ", + "KE", + "KI", + "KP", + "KR", + "KW", + "KG", + "LA", + "LV", + "LB", + "LS", + "LR", + "LY", + "LI", + "LT", + "LU", + "MO", + "MK", + "MG", + "MW", + "MY", + "MV", + "ML", + "MT", + "MH", + "MQ", + "MR", + "MU", + "YT", + "MX", + "FM", + "MD", + "MC", + "MN", + "ME", + "MS", + "MA", + "MZ", + "MM", + "NA", + "NR", + "NP", + "NL", + "NC", + "NZ", + "NI", + "NE", + "NG", + "NU", + "NF", + "MP", + "NO", + "OM", + "PK", + "PW", + "PS", + "PA", + "PG", + "PY", + "PE", + "PH", + "PN", + "PL", + "PT", + "PR", + "QA", + "RO", + "RU", + "RW", + "RE", + "BL", + "SH", + "KN", + "LC", + "MF", + "PM", + "VC", + "WS", + "SM", + "ST", + "SA", + "SN", + "RS", + "SC", + "SL", + "SG", + "SX", + "SK", + "SI", + "SB", + "SO", + "ZA", + "GS", + "SS", + "ES", + "LK", + "SD", + "SR", + "SJ", + "SZ", + "SE", + "CH", + "SY", + "TW", + "TJ", + "TZ", + "TH", + "TL", + "TG", + "TK", + "TO", + "TT", + "TN", + "TR", + "TM", + "TC", + "TV", + "UG", + "UA", + "AE", + "GB", + "US", + "UM", + "UY", + "UZ", + "VU", + "VE", + "VN", + "VG", + "VI", + "WF", + "EH", + "YE", + "ZM", + "ZW", +] diff --git a/hass/settings/custom_components/hacs/constrains.py b/hass/settings/custom_components/hacs/constrains.py new file mode 100644 index 0000000..cc8d52e --- /dev/null +++ b/hass/settings/custom_components/hacs/constrains.py @@ -0,0 +1,44 @@ +"""HACS Startup constrains.""" +# pylint: disable=bad-continuation +import os +import json + +from .const import CUSTOM_UPDATER_LOCATIONS, CUSTOM_UPDATER_WARNING +from .helpers.misc import version_is_newer_than_version + + +def check_constans(hacs): + """Check HACS constrains.""" + if not constrain_custom_updater(hacs): + return False + if not constrain_version(hacs): + return False + return True + + +def constrain_custom_updater(hacs): + """Check if custom_updater exist.""" + for location in CUSTOM_UPDATER_LOCATIONS: + if os.path.exists(location.format(hacs.system.config_path)): + msg = CUSTOM_UPDATER_WARNING.format( + location.format(hacs.system.config_path) + ) + hacs.logger.critical(msg) + return False + return True + + +def constrain_version(hacs): + """Check if the version is valid.""" + with open( + f"{hacs.system.config_path}/custom_components/hacs/manifest.json", "r" + ) as read: + manifest = json.loads(read.read()) + + # Check if HA is the required version. + if version_is_newer_than_version(manifest["homeassistant"], hacs.system.ha_version): + hacs.logger.critical( + f"You need HA version {manifest['homeassistant']} or newer to use this integration." + ) + return False + return True diff --git a/hass/settings/custom_components/hacs/deprecated.py b/hass/settings/custom_components/hacs/deprecated.py new file mode 100644 index 0000000..43fd957 --- /dev/null +++ b/hass/settings/custom_components/hacs/deprecated.py @@ -0,0 +1,49 @@ +"""Deprecated functions, should be fine to remove.""" + + +async def add_services(hacs): + """Add services.""" + # Service registration + async def service_hacs_install(call): + """Install a repository.""" + repository = str(call.data["repository"]) + if repository not in hacs().store.repositories: + hacs.logger.error("%s is not a konwn repository!", repository) + return + repository = hacs().store.repositories[repository] + await repository.install() + + async def service_hacs_register(call): + """register a repository.""" + repository = call.data["repository"] + repository_type = call.data["repository_type"] + if await hacs().is_known_repository(repository): + hacs.logger.error("%s is already a konwn repository!", repository) + return + await hacs().register_new_repository(repository_type, repository) + + async def service_hacs_load(call): + """register a repository.""" + from homeassistant.loader import async_get_custom_components + + del hacs.hass.data["custom_components"] + await async_get_custom_components(hacs.hass) + + hacs.hass.services.async_register("hacs", "install", service_hacs_install) + hacs.hass.services.async_register("hacs", "register", service_hacs_register) + hacs.hass.services.async_register("hacs", "load", service_hacs_load) + + +# This should probably be in a dedicated test "package" +async def test_repositories(hacs): + """Test repositories.""" + await hacs().register_repository("ludeeus/theme-hacs", "theme") + await hacs().register_repository("ludeeus/ps-hacs", "python_script") + await hacs().register_repository("ludeeus/integration-hacs", "integration") + await hacs().register_repository( + "rgruebel/ha_zigbee2mqtt_networkmap", "integration" + ) + await hacs().register_repository("ludeeus/ad-hacs", "appdaemon") + await hacs().register_repository("jonkristian/entur-card", "plugin") # Dist + await hacs().register_repository("kalkih/mini-media-player", "plugin") # Release + await hacs().register_repository("custom-cards/monster-card", "plugin") # root diff --git a/hass/settings/custom_components/hacs/frontend/__init__.py b/hass/settings/custom_components/hacs/frontend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hass/settings/custom_components/hacs/frontend/elements/all.min.css.gz b/hass/settings/custom_components/hacs/frontend/elements/all.min.css.gz new file mode 100644 index 0000000..4d22287 Binary files /dev/null and b/hass/settings/custom_components/hacs/frontend/elements/all.min.css.gz differ diff --git a/hass/settings/custom_components/hacs/frontend/elements/hacs.css b/hass/settings/custom_components/hacs/frontend/elements/hacs.css new file mode 100644 index 0000000..2cf3259 --- /dev/null +++ b/hass/settings/custom_components/hacs/frontend/elements/hacs.css @@ -0,0 +1,504 @@ +.yaml { + font-family: monospace, monospace; + font-size: 1em; + border-style: solid; + border-width: thin; + margin: 0; + overflow: auto; + display: inline-flex; + width: calc(100% - 20px); + white-space: pre-wrap; +} + +.hacslist { + border: none; +} + +.hacs-card-container { + display: grid; + grid-gap: 10px; +} + +.hacs-overview-container { + width: 95%; + margin-left: 2.5%; + margin-right: 2.5%; + margin-bottom: 10px; + display: inline-block; +} + +.hacs-form { + display: contents; +} + +.hacs-table { + background-color: var(--paper-card-background-color); +} + +.hacs-content { + position: absolute; + top: 49px; + left: 0px; + right: 0px; + bottom: 0px; + overflow-y: auto; + overflow-x: hidden; + padding-top: 20px; + padding-bottom: 15px; +} + +.hacs-card { + height: 100%; + background-color: var(--paper-card-background-color) !important; + padding: 3px; + line-height: 1; +} + +.overview { + height: 100%; +} + +.progressbar { + margin-left: 2.5%; + margin-right: 2.5%; + margin-bottom: 20px; +} + +.hacs-card-title { + font-size: medium; + color: var(--primary-text-color); +} + +tr.hacs-table-row.repository { + border-bottom: 0px; + display: table-row-group; +} + +td.repository { + width: 50%; + padding: 0px; +} +td.repository.text { + padding-left: 12px; +} + + +select.hacs-select.repository { + width: 100%; +} +.hacs-card-content { + color: var(--secondary-text-color); + font-style: italic; + font-size: small; +} + +.card-status { + font-size: 18px; + padding-right: 4px; +} + +.hacs-card-standalone { + background-color: var(--paper-card-background-color) !important; + color: var(--primary-text-color); + padding: 16px; +} + +.installed { + color: var(--hacs-status-installed); +} + +.pending-restart { + color: var(--hacs-status-pending-restart); +} + +.pending-upgrade { + color: var(--hacs-status-pending-update); +} + +.default { + color: var(--primary-text-color); +} + +.hacs-nav { + background-color: var(--primary-color) !important; + position: fixed; + height: auto; +} + +a { + color: var(--primary-color); +} + +.disabledaction { + cursor: default; + color: var(--primary-color); + opacity: 0.5; +} + +.hacs-bar { + background-color: var(--primary-color) !important; +} +.hacs-bar-background { + background-color: var(--dark-primary-color) !important; +} + +.errorview { + display: block; + margin-bottom: 30px; +} + +.hacscolor { + background-color: var(--paper-card-background-color) !important; + color: var(--primary-text-color); +} + +.hacsbutton { + margin: 3%; + background-color: var(--primary-color) !important; +} + +.hacsadminbutton { + margin: 1%; + background-color: var(--primary-color) !important; +} + +body { + background-color: var(--primary-background-color) !important; + color: var(--primary-text-color); + overflow: hidden; + font-size: 14px; +} + +input[type="text"]:not(.browser-default):focus:not([readonly]) { + border-bottom: 1px solid var(--primary-color); + -webkit-box-shadow: 0 1px 0 0 var(--primary-color); + box-shadow: 0 1px 0 0 var(--primary-color); +} + +.dropdown-list-item { + color: var(--primary-color) !important; + background-color: var(--paper-card-background-color) !important; +} + +a.dropdown-list-item:hover { + opacity: 0.95; +} + +.actionlink { + color: var(--primary-color) !important +} + +.uninstall { + float: right; + color: var(--google-red-500) !important; + font-weight: bold; +} + +@media all and (max-width: 550px) { + select.hacs-select.custom_repository { + width: 100%; + } + input#custom_url { + width: 100% !important; + } + + .actionlink { + width: 100%; + padding: 5px; + display: block; + font-size: x-large; + text-align: center; + } + + .uninstall { + float: none; + } +} +.minigrid { + display: contents; +} + +.card-action a:hover { + opacity: 0.6 !important; +} + +.codeinfo { + opacity: 0.6 !important; +} + +.hacs-select { + display: initial !important; + margin-left: 1%; + width: 20%; + background-color: transparent; + border-color: transparent; + color: var(--primary-text-color); +} + +.hacs-badge { + background-color: var(--hacs-badge-color); + margin-right: 1%; + font-size: 12px; + padding: 0px 4px 2px 4px; + color: var(--hacs-badge-text-color); + -moz-border-radius: 10px 10px 10px 10px; + border-radius: 10px 10px 10px 10px; +} + +tr.hacs-table-row:hover { + cursor: pointer; +} + + +/* color vars*/ +:root { + --hacs-status-installed: #126e15; + --hacs-status-pending-restart: #a70000; + --hacs-status-pending-update: #ffab40; + --hacs-status-default: var(--primary-text-color); + --hacs-badge-color: var(--primary-color); + --hacs-badge-text-color: var(--primary-text-color); + --primary-color: #03a9f4; + --primary-text-color: #000000; + --secondary-text-color: #424141; + --primary-background-color: #fafafa; + --paper-card-background-color: #ffffff; + --dark-primary-color: #00405d; + --google-red-500: #db4437; +} + +/* hug screens*/ +@media all and (min-width: 2000px) { + .hacs-card-container { + grid-template-columns: repeat(6, 1fr); + } +} + +/* even bigger screens*/ +@media all and (min-width: 1500px) and (max-width: 1999px) { + .hacs-card-container { + grid-template-columns: repeat(5, 1fr); + } +} + +/* bigger screens*/ +@media all and (min-width: 1000px) and (max-width: 1499px) { + .hacs-card-container { + grid-template-columns: repeat(4, 1fr); + } +} + +/* big screens */ +@media all and (min-width: 550px) and (max-width: 999px) { + .hacs-card-container { + grid-template-columns: repeat(3, 1fr); + } + + + #main::-webkit-scrollbar-track + { + border-radius: 10px; + } + + #main::-webkit-scrollbar + { + width: 12px; + } + + #main::-webkit-scrollbar-thumb + { + border-radius: 10px; + background-color: var(--primary-color); + } +} + +/* smal screens */ +@media all and (max-width: 550px) { + body { + overflow: scroll !important; + } + + .minigrid { + display: grid; + } + + .smal-hide { + display: none !important; + } + + .hacs-content { + overflow: scroll !important; + -webkit-overflow-scrolling: touch; + } +} + + +/* laoder */ + +*.hidden { + display: none !important; +} + +div.loading{ + position: fixed; + z-index: 999; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(16, 16, 16, 0.5); +} + +@-webkit-keyframes uil-ring-anim { + 0% { + -ms-transform: rotate(0deg); + -moz-transform: rotate(0deg); + -webkit-transform: rotate(0deg); + -o-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -ms-transform: rotate(360deg); + -moz-transform: rotate(360deg); + -webkit-transform: rotate(360deg); + -o-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@-webkit-keyframes uil-ring-anim { + 0% { + -ms-transform: rotate(0deg); + -moz-transform: rotate(0deg); + -webkit-transform: rotate(0deg); + -o-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -ms-transform: rotate(360deg); + -moz-transform: rotate(360deg); + -webkit-transform: rotate(360deg); + -o-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@-moz-keyframes uil-ring-anim { + 0% { + -ms-transform: rotate(0deg); + -moz-transform: rotate(0deg); + -webkit-transform: rotate(0deg); + -o-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -ms-transform: rotate(360deg); + -moz-transform: rotate(360deg); + -webkit-transform: rotate(360deg); + -o-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@-ms-keyframes uil-ring-anim { + 0% { + -ms-transform: rotate(0deg); + -moz-transform: rotate(0deg); + -webkit-transform: rotate(0deg); + -o-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -ms-transform: rotate(360deg); + -moz-transform: rotate(360deg); + -webkit-transform: rotate(360deg); + -o-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@-moz-keyframes uil-ring-anim { + 0% { + -ms-transform: rotate(0deg); + -moz-transform: rotate(0deg); + -webkit-transform: rotate(0deg); + -o-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -ms-transform: rotate(360deg); + -moz-transform: rotate(360deg); + -webkit-transform: rotate(360deg); + -o-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@-webkit-keyframes uil-ring-anim { + 0% { + -ms-transform: rotate(0deg); + -moz-transform: rotate(0deg); + -webkit-transform: rotate(0deg); + -o-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -ms-transform: rotate(360deg); + -moz-transform: rotate(360deg); + -webkit-transform: rotate(360deg); + -o-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@-o-keyframes uil-ring-anim { + 0% { + -ms-transform: rotate(0deg); + -moz-transform: rotate(0deg); + -webkit-transform: rotate(0deg); + -o-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -ms-transform: rotate(360deg); + -moz-transform: rotate(360deg); + -webkit-transform: rotate(360deg); + -o-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@keyframes uil-ring-anim { + 0% { + -ms-transform: rotate(0deg); + -moz-transform: rotate(0deg); + -webkit-transform: rotate(0deg); + -o-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -ms-transform: rotate(360deg); + -moz-transform: rotate(360deg); + -webkit-transform: rotate(360deg); + -o-transform: rotate(360deg); + transform: rotate(360deg); + } +} +.uil-ring-css { + margin: auto; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + width: 200px; + height: 200px; +} +.uil-ring-css > div { + position: absolute; + display: block; + width: 160px; + height: 160px; + top: 20px; + left: 20px; + border-radius: 80px; + box-shadow: 0 6px 0 0 var(--primary-color); + -ms-animation: uil-ring-anim 1s linear infinite; + -moz-animation: uil-ring-anim 1s linear infinite; + -webkit-animation: uil-ring-anim 1s linear infinite; + -o-animation: uil-ring-anim 1s linear infinite; + animation: uil-ring-anim 1s linear infinite; +} \ No newline at end of file diff --git a/hass/settings/custom_components/hacs/frontend/elements/hacs.js b/hass/settings/custom_components/hacs/frontend/elements/hacs.js new file mode 100644 index 0000000..102a1bc --- /dev/null +++ b/hass/settings/custom_components/hacs/frontend/elements/hacs.js @@ -0,0 +1,111 @@ +// Copy active HA theme +document.getElementsByTagName("html").item(0).setAttribute("style", parent.document.getElementsByTagName("html").item(0).style.cssText) + +// Copy yhe content of the Lovelace example to the clipboard. +function CopyToLovelaceExampleToClipboard() { + window.getSelection().selectAllChildren(document.getElementById("LovelaceExample")); + document.execCommand("copy"); + document.getSelection().empty() + document.getElementById('lovelacecopy').style.color = 'forestgreen'; +} + +// Show progress bar +function ShowProgressBar() { + document.getElementById('progressbar').style.display = 'block'; +} + +// Hard reloading +function HardReload() { + parent.location.reload(true) +} + +// Searchbar +function Search() { + var input = document.getElementById("Search"); + if (input) { + var filter = input.value.toLowerCase(); + var nodes = document.getElementsByClassName('hacs-card'); + for (var i = 0; i < nodes.length; i++) { + if (nodes[i].innerHTML.toLowerCase().includes(filter)) { + nodes[i].style.display = "block"; + } else { + nodes[i].style.display = "none"; + } + } + var nodes = document.getElementsByClassName('hacs-table-row'); + for (i = 0; i < nodes.length; i++) { + if (nodes[i].innerHTML.toLowerCase().includes(filter)) { + nodes[i].style.display = "table-row"; + } else { + nodes[i].style.display = "none"; + } + } + } +} + + +// Dropdown +document.addEventListener('DOMContentLoaded', function () { + var elems = document.querySelectorAll('.dropdown-trigger'); + M.Dropdown.init(elems, { hover: false, constrainWidth: false }); +}); + +// Modal +document.addEventListener('DOMContentLoaded', function () { + var elems = document.querySelectorAll('.modal'); + M.Modal.init(elems, {}); +}); + + +// Loader +function toggleLoading() { + var loadingOverlay = document.querySelector('.loading'); + loadingOverlay.classList.remove('hidden') + document.activeElement.blur(); +} + + +// Check if we can reload +function sleep(time) { + return new Promise((resolve) => setTimeout(resolve, time)); +} +function CheckIfWeCanReload() { + var data = true; + const hacsrequest = new XMLHttpRequest() + hacsrequest.open('GET', '/hacs_task', true) + hacsrequest.onload = function () { + data = JSON.parse(this.response) + data = data["task"] + if (!data) { + console.log("Background task is no longer running, reloading in 5s...") + sleep(5000).then(() => { + location.reload() + }); + } + } + hacsrequest.send() +} + +function IsTaskRunning() { + let retval = false; + let disp = document.getElementsByClassName("progress") + if (disp) { + disp = disp.item(0).style.display; + if (disp == "block") { + retval = true + } else { + retval = false + } + } + return retval +} + +window.setInterval(function () { + var running = false; + running = IsTaskRunning(); + if (running) { + CheckIfWeCanReload(); + } +}, 10000); + + diff --git a/hass/settings/custom_components/hacs/frontend/elements/materialize.min.css.gz b/hass/settings/custom_components/hacs/frontend/elements/materialize.min.css.gz new file mode 100644 index 0000000..f7b1f50 Binary files /dev/null and b/hass/settings/custom_components/hacs/frontend/elements/materialize.min.css.gz differ diff --git a/hass/settings/custom_components/hacs/frontend/elements/materialize.min.js.gz b/hass/settings/custom_components/hacs/frontend/elements/materialize.min.js.gz new file mode 100644 index 0000000..10db151 Binary files /dev/null and b/hass/settings/custom_components/hacs/frontend/elements/materialize.min.js.gz differ diff --git a/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-brands-400.eot.gz b/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-brands-400.eot.gz new file mode 100644 index 0000000..9cb0839 Binary files /dev/null and b/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-brands-400.eot.gz differ diff --git a/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-brands-400.svg.gz b/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-brands-400.svg.gz new file mode 100644 index 0000000..952de01 Binary files /dev/null and b/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-brands-400.svg.gz differ diff --git a/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-brands-400.ttf.gz b/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-brands-400.ttf.gz new file mode 100644 index 0000000..c3d423e Binary files /dev/null and b/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-brands-400.ttf.gz differ diff --git a/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-brands-400.woff.gz b/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-brands-400.woff.gz new file mode 100644 index 0000000..fae25d3 Binary files /dev/null and b/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-brands-400.woff.gz differ diff --git a/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-brands-400.woff2.gz b/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-brands-400.woff2.gz new file mode 100644 index 0000000..f045d23 Binary files /dev/null and b/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-brands-400.woff2.gz differ diff --git a/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-regular-400.eot.gz b/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-regular-400.eot.gz new file mode 100644 index 0000000..d7eb70f Binary files /dev/null and b/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-regular-400.eot.gz differ diff --git a/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-regular-400.svg.gz b/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-regular-400.svg.gz new file mode 100644 index 0000000..da80623 Binary files /dev/null and b/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-regular-400.svg.gz differ diff --git a/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-regular-400.ttf.gz b/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-regular-400.ttf.gz new file mode 100644 index 0000000..a8876ed Binary files /dev/null and b/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-regular-400.ttf.gz differ diff --git a/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-regular-400.woff.gz b/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-regular-400.woff.gz new file mode 100644 index 0000000..8e35036 Binary files /dev/null and b/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-regular-400.woff.gz differ diff --git a/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-regular-400.woff2.gz b/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-regular-400.woff2.gz new file mode 100644 index 0000000..7839668 Binary files /dev/null and b/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-regular-400.woff2.gz differ diff --git a/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-solid-900.eot.gz b/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-solid-900.eot.gz new file mode 100644 index 0000000..9872832 Binary files /dev/null and b/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-solid-900.eot.gz differ diff --git a/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-solid-900.svg.gz b/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-solid-900.svg.gz new file mode 100644 index 0000000..f7b5b3d Binary files /dev/null and b/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-solid-900.svg.gz differ diff --git a/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-solid-900.ttf.gz b/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-solid-900.ttf.gz new file mode 100644 index 0000000..20526ef Binary files /dev/null and b/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-solid-900.ttf.gz differ diff --git a/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-solid-900.woff.gz b/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-solid-900.woff.gz new file mode 100644 index 0000000..6b08ead Binary files /dev/null and b/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-solid-900.woff.gz differ diff --git a/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-solid-900.woff2.gz b/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-solid-900.woff2.gz new file mode 100644 index 0000000..3c0908a Binary files /dev/null and b/hass/settings/custom_components/hacs/frontend/elements/webfonts/fa-solid-900.woff2.gz differ diff --git a/hass/settings/custom_components/hacs/frontend/experimental/main.js.gz b/hass/settings/custom_components/hacs/frontend/experimental/main.js.gz new file mode 100644 index 0000000..aa04a5f Binary files /dev/null and b/hass/settings/custom_components/hacs/frontend/experimental/main.js.gz differ diff --git a/hass/settings/custom_components/hacs/frontend/templates/base.html b/hass/settings/custom_components/hacs/frontend/templates/base.html new file mode 100644 index 0000000..561298a --- /dev/null +++ b/hass/settings/custom_components/hacs/frontend/templates/base.html @@ -0,0 +1,51 @@ +{% set progressbar = 'block' if hacs.system.status.background_task else 'none' %} + + + + + + + + + + + + +
+
+
+

Background task running, this page will reload when it's done.

+
+
+
+
+ + {% if message %} + {% include 'message.html' with context %} + {% endif %} + {% set hacs_repo = hacs.get_by_name("hacs/integration") %} + {% if hacs_repo.pending_restart %} + {% set message = "The frontend of HACS has been disabled until you restart Home Assistant." %} + {% include 'message.html' with context %} + {% else %} + {% block content %} + {% endblock %} + {% endif %} +
+ + + diff --git a/hass/settings/custom_components/hacs/frontend/templates/error.html b/hass/settings/custom_components/hacs/frontend/templates/error.html new file mode 100644 index 0000000..7824de3 --- /dev/null +++ b/hass/settings/custom_components/hacs/frontend/templates/error.html @@ -0,0 +1,4 @@ +{% extends "base.html" %} +{% block content %} + +{% endblock %} \ No newline at end of file diff --git a/hass/settings/custom_components/hacs/frontend/templates/grid.html b/hass/settings/custom_components/hacs/frontend/templates/grid.html new file mode 100644 index 0000000..e0c3a62 --- /dev/null +++ b/hass/settings/custom_components/hacs/frontend/templates/grid.html @@ -0,0 +1,36 @@ + +
+
\ No newline at end of file diff --git a/hass/settings/custom_components/hacs/frontend/templates/grid_new.html b/hass/settings/custom_components/hacs/frontend/templates/grid_new.html new file mode 100644 index 0000000..b72c64a --- /dev/null +++ b/hass/settings/custom_components/hacs/frontend/templates/grid_new.html @@ -0,0 +1,47 @@ +{% set repositories = namespace(repositories=[]) %} +{% for repository in hacs.sorted_by_name %} +{% if repository.status.new %} +{% set repositories.repositories = repositories.repositories+[repository] %} +{% endif %} +{% endfor %} +{% if repositories.repositories %} + +
+
+{% endif %} \ No newline at end of file diff --git a/hass/settings/custom_components/hacs/frontend/templates/message.html b/hass/settings/custom_components/hacs/frontend/templates/message.html new file mode 100644 index 0000000..d615a3d --- /dev/null +++ b/hass/settings/custom_components/hacs/frontend/templates/message.html @@ -0,0 +1,13 @@ +
+
+
+
+
+ + {{message}} + +
+
+
+
+
\ No newline at end of file diff --git a/hass/settings/custom_components/hacs/frontend/templates/modal/upgrade_all.html b/hass/settings/custom_components/hacs/frontend/templates/modal/upgrade_all.html new file mode 100644 index 0000000..51c07d8 --- /dev/null +++ b/hass/settings/custom_components/hacs/frontend/templates/modal/upgrade_all.html @@ -0,0 +1,33 @@ + diff --git a/hass/settings/custom_components/hacs/frontend/templates/modal/wrong_ha_version.html b/hass/settings/custom_components/hacs/frontend/templates/modal/wrong_ha_version.html new file mode 100644 index 0000000..baea02b --- /dev/null +++ b/hass/settings/custom_components/hacs/frontend/templates/modal/wrong_ha_version.html @@ -0,0 +1,12 @@ + \ No newline at end of file diff --git a/hass/settings/custom_components/hacs/frontend/templates/overviews.html b/hass/settings/custom_components/hacs/frontend/templates/overviews.html new file mode 100644 index 0000000..d66bfa5 --- /dev/null +++ b/hass/settings/custom_components/hacs/frontend/templates/overviews.html @@ -0,0 +1,77 @@ + +{% extends "base.html" %} +{% block content %} +{% if location == 'store' %} +
+ +
+{% if hacs.configuration.frontend_mode == "Grid" %} +{% include 'grid_new.html' with context %} +{% else %} +{% include 'table_new.html' with context %} +{% endif %} +{% endif %} + +{% for type in hacs.common.categories %} + +{% if type == 'appdaemon' %} +{% set displaytype = 'APPDAEMON APPS' %} +{% elif type == 'python_script' %} +{% set displaytype = 'PYTHON SCRIPTS' %} +{% else %} +{% set displaytype = (type + 's') | upper %} +{% endif %} + +{% set repositories = namespace(repositories=[]) %} + +{% for repository in hacs.sorted_by_name %} +{% set repo = namespace(include=True) %} + +{% if repository.status.hide and repo.include %} +{% set repo.include = False %} +{% endif %} + +{% if repository.information.category != type and repo.include %} +{% set repo.include = False %} +{% endif %} + + +{% if location == 'overview' %} +{% if not repository.status.installed and repo.include %} +{% set repo.include = False %} +{% if repository.information.uid == "172733314" %} +{% set repo.include = True %} +{% endif %} +{% endif %} +{% else %} +{% if repository.information.uid == "172733314" %} +{% set repo.include = False %} +{% endif %} +{% if hacs.configuration.country and repo.include %} +{% if repository.repository_manifest %} +{% if repository.repository_manifest.country %} +{% if hacs.configuration.country not in repository.repository_manifest.country %} +{% set repo.include = False %} +{% endif %} +{% endif %} +{% endif %} +{% endif %} +{% endif %} + +{% if repo.include %} +{% set repositories.repositories = repositories.repositories+[repository] %} +{% endif %} +{% endfor %} + +{% if repositories.repositories %} +{% if hacs.configuration.frontend_mode == "Grid" %} +{% include 'grid.html' with context %} +{% else %} +{% include 'table.html' with context %} +{% endif %} +{% endif %} + +{% endfor %} + +{% endblock %} \ No newline at end of file diff --git a/hass/settings/custom_components/hacs/frontend/templates/repository.html b/hass/settings/custom_components/hacs/frontend/templates/repository.html new file mode 100644 index 0000000..2a2dcd6 --- /dev/null +++ b/hass/settings/custom_components/hacs/frontend/templates/repository.html @@ -0,0 +1,93 @@ +{% extends "base.html" %} +{% block content %} + + +{% if repository.pending_restart %} +{% set message = "You need to restart (and potentially reconfigure) Home Assistant, for your last operation to be loaded." %} +{% endif %} + +{% if repository.information.category == "integration" and repository.status.first_install and repository.status.installed and repository.config_flow %} +{% set message = " + Good news!
+ This integration supports config flows!
+ This means that you can go to 'Configuration' --> 'Integrations' --> '+' to configure it.
+ The best part if you do that, is that you do not need to restart Home Assistant.

+ You may need to clear the cache on the integrations page before it shows in the list (+)."%} +{% endif %} + + +{% if message %} +{% include 'message.html' with context %} +{% endif %} + + +
+
+
+
+
+ + {{repository.display_name}} + + + + + {% include 'repository/menu.html' with context %} + +

{{repository.information.description}}


+ + {% if repository.status.installed %} + + + + {% endif %} + + + {% if repository.releases.published_tags %} + {% include 'repository/versionselect.html' with context %} + {% else %} + + + {% endif %} + + {% if repository.status.selected_tag == repository.information.default_branch %} + + + + + {% endif %} + {% include 'repository/authors.html' with context %} +
+ Installed {{repository.display_version_or_commit}}: + + {{repository.display_installed_version}} +
+ Available {{repository.display_version_or_commit}}: + + {{repository.display_available_version}} +
+ Last updated: + + {{repository.information.last_updated}} +
+
+ {% include 'repository/buttons.html' with context %} +
+
+
+
+ +
+
+
+
+
+ {{ repository.information.additional_info }} + {% include 'repository/note.html' with context %} +
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/hass/settings/custom_components/hacs/frontend/templates/repository/authors.html b/hass/settings/custom_components/hacs/frontend/templates/repository/authors.html new file mode 100644 index 0000000..5b0b192 --- /dev/null +++ b/hass/settings/custom_components/hacs/frontend/templates/repository/authors.html @@ -0,0 +1,27 @@ +{% if repository.information.authors %} +{% if repository.information.category == "integration" %} + + + Author(s): + + + {% for author in repository.information.authors %} + {% if "@" in author %} + {% set author = author.split("@")[-1] %} + {% endif %} + @{{author}} + {% endfor %} + + +{% else %} + + + Author: + + + {{repository.information.authors}} + + +{% endif %} +{% endif %} \ No newline at end of file diff --git a/hass/settings/custom_components/hacs/frontend/templates/repository/buttons.html b/hass/settings/custom_components/hacs/frontend/templates/repository/buttons.html new file mode 100644 index 0000000..e17097e --- /dev/null +++ b/hass/settings/custom_components/hacs/frontend/templates/repository/buttons.html @@ -0,0 +1,50 @@ +{% if not repository.can_install %} +{% include 'modal/wrong_ha_version.html' with context %} +{% endif %} + +
+ {% if repository.can_install %} +
+ + + {{repository.main_action}} + +
+ {% else %} + + {{repository.main_action}} + + {% endif %} + + {% if repository.pending_upgrade and repository.display_version_or_commit == "version" %} + + CHANGELOG + + {% endif %} + + + repository + + + {% if repository.status.installed and repository.information.category == "plugin" %} + {% set name = repository.information.full_name.split("/")[-1] %} + + OPEN PLUGIN + + {% endif %} + + {% if repository.status.installed and repository.information.uid != "172733314" %} +
+ + + UNINSTALL + +
+ {% endif %} + +
\ No newline at end of file diff --git a/hass/settings/custom_components/hacs/frontend/templates/repository/menu.html b/hass/settings/custom_components/hacs/frontend/templates/repository/menu.html new file mode 100644 index 0000000..7104e99 --- /dev/null +++ b/hass/settings/custom_components/hacs/frontend/templates/repository/menu.html @@ -0,0 +1,61 @@ + \ No newline at end of file diff --git a/hass/settings/custom_components/hacs/frontend/templates/repository/note.html b/hass/settings/custom_components/hacs/frontend/templates/repository/note.html new file mode 100644 index 0000000..edb8766 --- /dev/null +++ b/hass/settings/custom_components/hacs/frontend/templates/repository/note.html @@ -0,0 +1,42 @@ +{% if repository.additional_info %} +
+

+{% endif %} + +{% if repository.information.category == "appdaemon" %} + + + When installed, this will be located in '{{repository.content.path.local}}', + you still need to add it to your 'apps.yaml' file. + + +{% elif repository.information.category == "integration" %} + + + When installed, this will be located in '{{repository.content.path.local}}', + you still need to add it to your 'configuration.yaml' file. + + +{% elif repository.information.category == "plugin" %} +{% set name = repository.information.full_name.split("/")[-1] %} + + + When installed, this will be located in '{{repository.content.path.local }}', + you still need to add it to your lovelace configuration ('ui-lovelace.yaml' or the raw UI config editor). + +

+ + When you add this to your configuration use this: +
+
+  - url: /community_plugin/{{name}}/{{repository.information.file_name}}
+  {%- if repository.information.javascript_type %}
+    type: {{repository.information.javascript_type}}
+  {%- else %}
+    type: Could not determine the type of this plugin, check the repository.
+  {%- endif -%}
+    
+ + + +{% endif %} \ No newline at end of file diff --git a/hass/settings/custom_components/hacs/frontend/templates/repository/versionselect.html b/hass/settings/custom_components/hacs/frontend/templates/repository/versionselect.html new file mode 100644 index 0000000..42a2b78 --- /dev/null +++ b/hass/settings/custom_components/hacs/frontend/templates/repository/versionselect.html @@ -0,0 +1,24 @@ +{% set tags = repository.releases.published_tags | sort(True) %} +{% set tags = tags + [repository.information.default_branch] %} + + +{% if repository.status.selected_tag %} +{% set selected_tag = repository.status.selected_tag %} +{% else %} +{% set selected_tag = repository.versions.available %} +{% endif %} + + Available versions: + + +
+ + +
+ \ No newline at end of file diff --git a/hass/settings/custom_components/hacs/frontend/templates/settings.html b/hass/settings/custom_components/hacs/frontend/templates/settings.html new file mode 100644 index 0000000..1d4f9ca --- /dev/null +++ b/hass/settings/custom_components/hacs/frontend/templates/settings.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% block content %} +{% if hacs.configuration.dev %} +{% set message = "You have 'dev' enabled for HACS, this is not intended for regular use, no support will be given if you break something." %} +{% include 'message.html' with context %} +{% endif %} + +{% include 'modal/upgrade_all.html' with context %} + + +{% include 'settings/hacs_info.html' with context %} +{% include 'settings/buttons.html' with context %} +{% include 'settings/custom_repositories.html' with context %} +{% include 'settings/hidden_repositories.html' with context %} + + +{% if hacs.configuration.dev and not hacs.system.status.background_task %} +{% include 'settings/developer.html' with context %} +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/hass/settings/custom_components/hacs/frontend/templates/settings/buttons.html b/hass/settings/custom_components/hacs/frontend/templates/settings/buttons.html new file mode 100644 index 0000000..0db42a9 --- /dev/null +++ b/hass/settings/custom_components/hacs/frontend/templates/settings/buttons.html @@ -0,0 +1,26 @@ + \ No newline at end of file diff --git a/hass/settings/custom_components/hacs/frontend/templates/settings/custom_repositories.html b/hass/settings/custom_components/hacs/frontend/templates/settings/custom_repositories.html new file mode 100644 index 0000000..20a819e --- /dev/null +++ b/hass/settings/custom_components/hacs/frontend/templates/settings/custom_repositories.html @@ -0,0 +1,62 @@ +
+
+ + + {% if not hacs.system.status.background_task %} +
+ + + + +
+ {% endif %} +
+
\ No newline at end of file diff --git a/hass/settings/custom_components/hacs/frontend/templates/settings/dev/remove_new_flag.html b/hass/settings/custom_components/hacs/frontend/templates/settings/dev/remove_new_flag.html new file mode 100644 index 0000000..ac6f966 --- /dev/null +++ b/hass/settings/custom_components/hacs/frontend/templates/settings/dev/remove_new_flag.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% block content %} +
+
+
+ +
+
+
+{% endblock %} \ No newline at end of file diff --git a/hass/settings/custom_components/hacs/frontend/templates/settings/dev/repositories.html b/hass/settings/custom_components/hacs/frontend/templates/settings/dev/repositories.html new file mode 100644 index 0000000..91f598e --- /dev/null +++ b/hass/settings/custom_components/hacs/frontend/templates/settings/dev/repositories.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% block content %} +{% for repository in hacs.store.frontend|sort(attribute='name') %} +
+ + +
+{% endfor %} +{% endblock %} \ No newline at end of file diff --git a/hass/settings/custom_components/hacs/frontend/templates/settings/dev/repository.html b/hass/settings/custom_components/hacs/frontend/templates/settings/dev/repository.html new file mode 100644 index 0000000..e69de29 diff --git a/hass/settings/custom_components/hacs/frontend/templates/settings/dev/set_ha_version.html b/hass/settings/custom_components/hacs/frontend/templates/settings/dev/set_ha_version.html new file mode 100644 index 0000000..bda9fd8 --- /dev/null +++ b/hass/settings/custom_components/hacs/frontend/templates/settings/dev/set_ha_version.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% block content %} +
+
+
+ + +
+
+
+{% endblock %} \ No newline at end of file diff --git a/hass/settings/custom_components/hacs/frontend/templates/settings/dev/template_test.html b/hass/settings/custom_components/hacs/frontend/templates/settings/dev/template_test.html new file mode 100644 index 0000000..f89b786 --- /dev/null +++ b/hass/settings/custom_components/hacs/frontend/templates/settings/dev/template_test.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block content %} +
+
+
+ +
+ + +
+
+
+ +{% if hacs.dev_template %} +
+
+ {{ hacs.dev_template }} +
+
+{% endif %} +{% endblock %} \ No newline at end of file diff --git a/hass/settings/custom_components/hacs/frontend/templates/settings/dev/token.html b/hass/settings/custom_components/hacs/frontend/templates/settings/dev/token.html new file mode 100644 index 0000000..a1ade04 --- /dev/null +++ b/hass/settings/custom_components/hacs/frontend/templates/settings/dev/token.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% block content %} +
+
+ +
+
+{% endblock %} \ No newline at end of file diff --git a/hass/settings/custom_components/hacs/frontend/templates/settings/developer.html b/hass/settings/custom_components/hacs/frontend/templates/settings/developer.html new file mode 100644 index 0000000..ee81e55 --- /dev/null +++ b/hass/settings/custom_components/hacs/frontend/templates/settings/developer.html @@ -0,0 +1,19 @@ +
+
+
DEVELOPER TOOLS
+ {% for button in [ + "remove_new_flag", + "repositories", + "set_ha_version", + "token", + "template_test" + ] %} +
+ + +
+ {% endfor %} +
+
\ No newline at end of file diff --git a/hass/settings/custom_components/hacs/frontend/templates/settings/hacs_info.html b/hass/settings/custom_components/hacs/frontend/templates/settings/hacs_info.html new file mode 100644 index 0000000..c1851bd --- /dev/null +++ b/hass/settings/custom_components/hacs/frontend/templates/settings/hacs_info.html @@ -0,0 +1,38 @@ +{% if hacs.get_by_id("172733314").pending_restart %} +{% set pending_restart = " (RESTART PENDING!)" %} +{% else %} +{% set pending_restart = "" %} +{% endif %} + +
+
+
HACS (Home Assistant Community Store)
+ + + + + + + + + +
+ Version: + + {{hacs.version}}{{pending_restart}} +
+ Display: + +
+ +
+
+
+
\ No newline at end of file diff --git a/hass/settings/custom_components/hacs/frontend/templates/settings/hidden_repositories.html b/hass/settings/custom_components/hacs/frontend/templates/settings/hidden_repositories.html new file mode 100644 index 0000000..1e60f7b --- /dev/null +++ b/hass/settings/custom_components/hacs/frontend/templates/settings/hidden_repositories.html @@ -0,0 +1,42 @@ +{% set repositories = namespace(hidden=False) %} +{% for repository in hacs.repositories_list_repo %} +{% if repository.hide %} +{% set repositories.hidden = True %} +{% endif %} +{% endfor %} + +{% if repositories.hidden %} +
+
+
    +
  • +
    HIDDEN REPOSITORIES
    +
  • + {% if hacs.system.status.background_task %} +
  • + Loading hidden repositories... +
  • + {% else %} + {% for repository in hacs.repositories_list_repo %} + {% if repository.hide %} +
  • +
    +
    + + + + + {{repository.repository_name}} + {{repository.repository_type}} +
    +
    +
  • + {% endif %} + {% endfor %} + {% endif %} +
+
+
+{% endif %} \ No newline at end of file diff --git a/hass/settings/custom_components/hacs/frontend/templates/table.html b/hass/settings/custom_components/hacs/frontend/templates/table.html new file mode 100644 index 0000000..982afb1 --- /dev/null +++ b/hass/settings/custom_components/hacs/frontend/templates/table.html @@ -0,0 +1,42 @@ +
+
+
{{ displaytype }}
+ + + + + + + {% if location == 'overview' %} + + + {% endif %} + + + + {% for repository in repositories.repositories %} + + + + + + {% endif %} + + + {% if location == 'overview' %} + + + {% endif %} + + {% endfor %} + +
StatusNameDescriptionInstalledAvailable
+ {% if location == 'store' and repository.status.new %} + NEW + {% else %} + + {{repository.display_name}} + {{repository.information.description}}{{repository.display_installed_version}}{{repository.display_available_version}}
+
+
\ No newline at end of file diff --git a/hass/settings/custom_components/hacs/frontend/templates/table_new.html b/hass/settings/custom_components/hacs/frontend/templates/table_new.html new file mode 100644 index 0000000..f87486d --- /dev/null +++ b/hass/settings/custom_components/hacs/frontend/templates/table_new.html @@ -0,0 +1,44 @@ +{% set repositories = namespace(repositories=[]) %} +{% for repository in hacs.sorted_by_name %} +{% if repository.status.new %} +{% set repositories.repositories = repositories.repositories+[repository] %} +{% endif %} +{% endfor %} +{% if repositories.repositories %} +
+
+
NEW REPOSITORIES (ALL)
+
+ + + Remove the NEW flag for all new items + +
+ + + + + + + + + + {% for repository in repositories.repositories %} + + + + + + {% endfor %} + +
CategoryNameDescription
+ {{repository.information.category | upper}} + + {{repository.display_name}} + {{repository.information.description}}
+
+
+{% endif %} \ No newline at end of file diff --git a/hass/settings/custom_components/hacs/hacsbase/__init__.py b/hass/settings/custom_components/hacs/hacsbase/__init__.py new file mode 100644 index 0000000..cfe374b --- /dev/null +++ b/hass/settings/custom_components/hacs/hacsbase/__init__.py @@ -0,0 +1,391 @@ +"""Initialize the HACS base.""" +# pylint: disable=unused-argument, bad-continuation +import json +import uuid +from datetime import timedelta + +from homeassistant.helpers.event import async_call_later, async_track_time_interval + +from aiogithubapi import AIOGitHubException, AIOGitHubRatelimit +from integrationhelper import Logger + + +from ..const import ELEMENT_TYPES +from ..store import async_load_from_store, async_save_to_store + + +class HacsStatus: + """HacsStatus.""" + + startup = False + background_task = False + reloading_data = False + upgrading_all = False + + +class HacsCommon: + """Common for HACS.""" + + categories = [] + blacklist = [] + default = [] + installed = [] + skip = [] + + +class System: + """System info.""" + + status = HacsStatus() + config_path = None + ha_version = None + disabled = False + + +class Developer: + """Developer settings/tools.""" + + template_id = "Repository ID" + template_content = "" + template_raw = "" + + @property + def devcontainer(self): + """Is it a devcontainer?""" + import os + + if "DEVCONTAINER" in os.environ: + return True + return False + + +class Hacs: + """The base class of HACS, nested thoughout the project.""" + + token = f"{str(uuid.uuid4())}-{str(uuid.uuid4())}" + hacsweb = f"/hacsweb/{token}" + hacsapi = f"/hacsapi/{token}" + repositories = [] + repo = None + data_repo = None + developer = Developer() + data = None + configuration = None + logger = Logger("hacs") + github = None + hass = None + version = None + system = System() + tasks = [] + common = HacsCommon() + + @staticmethod + def init(hass, github_token): + """Return a initialized HACS object.""" + return Hacs() + + def get_by_id(self, repository_id): + """Get repository by ID.""" + try: + for repository in self.repositories: + if repository.information.uid == repository_id: + return repository + except Exception: # pylint: disable=broad-except + pass + return None + + def get_by_name(self, repository_full_name): + """Get repository by full_name.""" + try: + for repository in self.repositories: + if repository.information.full_name == repository_full_name: + return repository + except Exception: # pylint: disable=broad-except + pass + return None + + def is_known(self, repository_full_name): + """Return a bool if the repository is known.""" + for repository in self.repositories: + if repository.information.full_name == repository_full_name: + return True + return False + + @property + def sorted_by_name(self): + """Return a sorted(by name) list of repository objects.""" + return sorted(self.repositories, key=lambda x: x.display_name) + + @property + def sorted_by_repository_name(self): + """Return a sorted(by repository_name) list of repository objects.""" + return sorted(self.repositories, key=lambda x: x.information.full_name) + + async def register_repository(self, full_name, category, check=True): + """Register a repository.""" + from ..repositories.repository import RERPOSITORY_CLASSES + + if full_name in self.common.skip: + if full_name != "hacs/integration": + self.logger.debug(f"Skipping {full_name}") + return + + if category not in RERPOSITORY_CLASSES: + self.logger.error(f"{category} is not a valid repository category.") + return False + + repository = RERPOSITORY_CLASSES[category](full_name) + if check: + try: + await repository.registration() + if repository.validate.errors: + self.common.skip.append(repository.information.full_name) + if not self.system.status.startup: + self.logger.error(f"Validation for {full_name} failed.") + return repository.validate.errors + repository.logger.info("Registration complete") + except AIOGitHubException as exception: + self.logger.debug(self.github.ratelimits.remaining) + self.logger.debug(self.github.ratelimits.reset_utc) + self.common.skip.append(repository.information.full_name) + if not self.system.status.startup: + self.logger.error( + f"Validation for {full_name} failed with {exception}." + ) + return exception + self.hass.bus.async_fire( + "hacs/repository", + { + "id": 1337, + "action": "registration", + "repository": repository.information.full_name, + "repository_id": repository.information.uid, + }, + ) + self.repositories.append(repository) + + async def startup_tasks(self): + """Tasks tha are started after startup.""" + self.system.status.background_task = True + self.hass.bus.async_fire("hacs/status", {}) + self.logger.debug(self.github.ratelimits.remaining) + self.logger.debug(self.github.ratelimits.reset_utc) + + await self.handle_critical_repositories_startup() + await self.handle_critical_repositories() + await self.load_known_repositories() + await self.clear_out_blacklisted_repositories() + + self.tasks.append( + async_track_time_interval( + self.hass, self.recuring_tasks_installed, timedelta(minutes=30) + ) + ) + self.tasks.append( + async_track_time_interval( + self.hass, self.recuring_tasks_all, timedelta(minutes=800) + ) + ) + + self.system.status.startup = False + self.system.status.background_task = False + self.hass.bus.async_fire("hacs/status", {}) + await self.data.async_write() + + async def handle_critical_repositories_startup(self): + """Handled critical repositories during startup.""" + alert = False + critical = await async_load_from_store(self.hass, "critical") + if not critical: + return + for repo in critical: + if not repo["acknowledged"]: + alert = True + if alert: + self.logger.critical("URGENT!: Check the HACS panel!") + self.hass.components.persistent_notification.create( + title="URGENT!", message="**Check the HACS panel!**" + ) + + async def handle_critical_repositories(self): + """Handled critical repositories during runtime.""" + # Get critical repositories + instored = [] + critical = [] + was_installed = False + + try: + critical = await self.data_repo.get_contents("critical") + critical = json.loads(critical.content) + except AIOGitHubException: + pass + + if not critical: + self.logger.debug("No critical repositories") + return + + stored_critical = await async_load_from_store(self.hass, "critical") + + for stored in stored_critical or []: + instored.append(stored["repository"]) + + stored_critical = [] + + for repository in critical: + self.common.blacklist.append(repository["repository"]) + repo = self.get_by_name(repository["repository"]) + + stored = { + "repository": repository["repository"], + "reason": repository["reason"], + "link": repository["link"], + "acknowledged": True, + } + + if repository["repository"] not in instored: + if repo is not None and repo.installed: + self.logger.critical( + f"Removing repository {repository['repository']}, it is marked as critical" + ) + was_installed = True + stored["acknowledged"] = False + # Uninstall from HACS + repo.remove() + await repo.uninstall() + stored_critical.append(stored) + + # Save to FS + await async_save_to_store(self.hass, "critical", stored_critical) + + # Resart HASS + if was_installed: + self.logger.critical("Resarting Home Assistant") + self.hass.async_create_task(self.hass.async_stop(100)) + + async def recuring_tasks_installed(self, notarealarg=None): + """Recuring tasks for installed repositories.""" + self.logger.debug( + "Starting recuring background task for installed repositories" + ) + self.system.status.background_task = True + self.hass.bus.async_fire("hacs/status", {}) + self.logger.debug(self.github.ratelimits.remaining) + self.logger.debug(self.github.ratelimits.reset_utc) + for repository in self.repositories: + if repository.status.installed: + try: + await repository.update_repository() + repository.logger.debug("Information update done.") + except AIOGitHubException: + self.system.status.background_task = False + self.hass.bus.async_fire("hacs/status", {}) + await self.data.async_write() + self.logger.debug( + "Recuring background task for installed repositories done" + ) + return + await self.handle_critical_repositories() + self.system.status.background_task = False + self.hass.bus.async_fire("hacs/status", {}) + await self.data.async_write() + self.logger.debug("Recuring background task for installed repositories done") + + async def recuring_tasks_all(self, notarealarg=None): + """Recuring tasks for all repositories.""" + self.logger.debug("Starting recuring background task for all repositories") + self.system.status.background_task = True + self.hass.bus.async_fire("hacs/status", {}) + self.logger.debug(self.github.ratelimits.remaining) + self.logger.debug(self.github.ratelimits.reset_utc) + for repository in self.repositories: + try: + await repository.update_repository() + repository.logger.debug("Information update done.") + except AIOGitHubException: + self.system.status.background_task = False + self.hass.bus.async_fire("hacs/status", {}) + await self.data.async_write() + self.logger.debug("Recuring background task for all repositories done") + return + await self.load_known_repositories() + await self.clear_out_blacklisted_repositories() + self.system.status.background_task = False + await self.data.async_write() + self.hass.bus.async_fire("hacs/status", {}) + self.hass.bus.async_fire("hacs/repository", {"action": "reload"}) + self.logger.debug("Recuring background task for all repositories done") + + async def clear_out_blacklisted_repositories(self): + """Clear out blaclisted repositories.""" + need_to_save = False + for repository in self.common.blacklist: + if self.is_known(repository): + repository = self.get_by_name(repository) + if repository.status.installed: + self.logger.error( + f"You have {repository.information.full_name} installed with HACS, this repositroy has been blacklisted, please consider removing it." + ) + else: + need_to_save = True + repository.remove() + + if need_to_save: + await self.data.async_write() + + async def get_repositories(self): + """Return a list of repositories.""" + repositories = {} + if self.configuration.dev: + if self.developer.devcontainer: + repositories = { + "appdaemon": ["ludeeus/ad-hacs"], + "integration": ["ludeeus/integration-hacs"], + "plugin": ["maykar/compact-custom-header"], + "python_script": ["ludeeus/ps-hacs"], + "theme": ["ludeeus/theme-hacs"], + } + else: + for category in self.common.categories: + remote = await self.data_repo.get_contents(category) + repositories[category] = json.loads(remote.content) + if category == "plugin": + org = await self.github.get_org_repos("custom-cards") + for repo in org: + repositories[category].append(repo.full_name) + if category == "integration": + org = await self.github.get_org_repos("custom-components") + for repo in org: + repositories[category].append(repo.full_name) + if category == "theme": + org = await self.github.get_org_repos( + "home-assistant-community-themes" + ) + for repo in org: + repositories[category].append(repo.full_name) + + for category in repositories: + for repo in repositories[category]: + if repo not in self.common.default: + self.common.default.append(repo) + return repositories + + async def load_known_repositories(self): + """Load known repositories.""" + self.logger.info("Loading known repositories") + blacklist = await self.data_repo.get_contents("blacklist") + repositories = await self.get_repositories() + + for item in json.loads(blacklist.content): + if item not in self.common.blacklist: + self.common.blacklist.append(item) + + for category in repositories: + for repo in repositories[category]: + if repo in self.common.blacklist: + continue + if self.is_known(repo): + continue + try: + await self.register_repository(repo, category) + except (AIOGitHubException, AIOGitHubRatelimit): + pass diff --git a/hass/settings/custom_components/hacs/hacsbase/backup.py b/hass/settings/custom_components/hacs/hacsbase/backup.py new file mode 100644 index 0000000..2702dcc --- /dev/null +++ b/hass/settings/custom_components/hacs/hacsbase/backup.py @@ -0,0 +1,72 @@ +"""Backup.""" +import os +import shutil +import tempfile +from time import sleep + +from integrationhelper import Logger + +BACKUP_PATH = tempfile.gettempdir() + "/hacs_backup/" + + +class Backup: + """Backup.""" + + def __init__(self, local_path, backup_path=BACKUP_PATH): + """initialize.""" + self.logger = Logger("hacs.backup") + self.local_path = local_path + self.backup_path = backup_path + self.backup_path_full = f"{self.backup_path}{self.local_path.split('/')[-1]}" + + def create(self): + """Create a backup in /tmp""" + if not os.path.exists(self.local_path): + return + if os.path.exists(self.backup_path): + shutil.rmtree(self.backup_path) + while os.path.exists(self.backup_path): + sleep(0.1) + os.makedirs(self.backup_path, exist_ok=True) + + try: + if os.path.isfile(self.local_path): + shutil.copyfile(self.local_path, self.backup_path_full) + os.remove(self.local_path) + else: + shutil.copytree(self.local_path, self.backup_path_full) + shutil.rmtree(self.local_path) + while os.path.exists(self.local_path): + sleep(0.1) + self.logger.debug( + f"Backup for {self.local_path}, created in {self.backup_path_full}" + ) + except Exception: # pylint: disable=broad-except + pass + + def restore(self): + """Restore from backup.""" + if not os.path.exists(self.backup_path_full): + return + + if os.path.isfile(self.backup_path_full): + if os.path.exists(self.local_path): + os.remove(self.local_path) + shutil.copyfile(self.backup_path_full, self.local_path) + else: + if os.path.exists(self.local_path): + shutil.rmtree(self.local_path) + while os.path.exists(self.local_path): + sleep(0.1) + shutil.copytree(self.backup_path_full, self.local_path) + self.logger.debug( + f"Restored {self.local_path}, from backup {self.backup_path_full}" + ) + + def cleanup(self): + """Cleanup backup files.""" + if os.path.exists(self.backup_path): + shutil.rmtree(self.backup_path) + while os.path.exists(self.backup_path): + sleep(0.1) + self.logger.debug(f"Backup dir {self.backup_path} cleared") diff --git a/hass/settings/custom_components/hacs/hacsbase/configuration.py b/hass/settings/custom_components/hacs/hacsbase/configuration.py new file mode 100644 index 0000000..6d81be3 --- /dev/null +++ b/hass/settings/custom_components/hacs/hacsbase/configuration.py @@ -0,0 +1,55 @@ +"""HACS Configuration.""" +import attr + +from custom_components.hacs.hacsbase.exceptions import HacsUserScrewupException + + +@attr.s(auto_attribs=True) +class Configuration: + """Configuration class.""" + + # Main configuration: + appdaemon_path: str = "appdaemon/apps/" + appdaemon: bool = False + config: dict = {} + config_entry: dict = {} + config_type: str = None + dev: bool = False + frontend_mode: str = "Grid" + options: dict = {} + plugin_path: str = "www/community/" + python_script_path: str = "python_scripts/" + python_script: bool = False + sidepanel_icon: str = "mdi:alpha-c-box" + sidepanel_title: str = "Community" + theme_path: str = "themes/" + theme: bool = False + token: str = None + + # Config options: + country: str = "ALL" + experimental: bool = False + release_limit: int = 5 + + @staticmethod + def from_dict(configuration: dict, options: dict): + """Set attributes from dicts.""" + if isinstance(options, bool) or isinstance(configuration.get("options"), bool): + raise HacsUserScrewupException("Configuration is not valid.") + + if options is None: + options = {} + + if not configuration: + raise HacsUserScrewupException("Configuration is not valid.") + + config = Configuration() + + config.config = configuration + config.options = options + + for conf_type in [configuration, options]: + for key in conf_type: + setattr(config, key, conf_type[key]) + + return config diff --git a/hass/settings/custom_components/hacs/hacsbase/const.py b/hass/settings/custom_components/hacs/hacsbase/const.py new file mode 100644 index 0000000..1e72154 --- /dev/null +++ b/hass/settings/custom_components/hacs/hacsbase/const.py @@ -0,0 +1,10 @@ +"""Constants for HACS""" +# pylint: disable=unused-import +STORAGE_VERSION = "6" +STORENAME = "hacs" + +# Messages +NOT_SUPPORTED_HA_VERSION = "You have version '{}' of Home Assistant, but version '{}' of '{}' require version '{}' of Home Assistant, install and upgrades are disabled for this integration untill you upgrade Home Assistant." + + +NO_ELEMENTS = "No elements to show, open the store to install some awesome stuff." diff --git a/hass/settings/custom_components/hacs/hacsbase/data.py b/hass/settings/custom_components/hacs/hacsbase/data.py new file mode 100644 index 0000000..1521ee4 --- /dev/null +++ b/hass/settings/custom_components/hacs/hacsbase/data.py @@ -0,0 +1,215 @@ +"""Data handler for HACS.""" +import os +import json +from integrationhelper import Logger +from . import Hacs +from .const import STORAGE_VERSION +from ..const import VERSION +from ..repositories.manifest import HacsManifest +from ..store import async_save_to_store, async_load_from_store + + +STORES = { + "old": "hacs", + "hacs": "hacs.hacs", + "installed": "hacs.installed", + "repositories": "hacs.repositories", +} + + +class HacsData(Hacs): + """HacsData class.""" + + def __init__(self): + """Initialize.""" + self.logger = Logger("hacs.data") + + def check_corrupted_files(self): + """Return True if one (or more) of the files are corrupted.""" + for store in STORES: + path = f"{self.system.config_path}/.storage/{STORES[store]}" + if os.path.exists(path): + if os.stat(path).st_size == 0: + # File is empty (corrupted) + return True + return False + + def read(self, store): + """Return data from a store.""" + path = f"{self.system.config_path}/.storage/{STORES[store]}" + content = None + if os.path.exists(path): + with open(path, "r", encoding="utf-8") as storefile: + content = storefile.read() + content = json.loads(content) + return content + + async def async_write(self): + """Write content to the store files.""" + if self.system.status.background_task: + return + + self.logger.debug("Saving data") + + # Hacs + await async_save_to_store( + self.hass, "hacs", {"view": self.configuration.frontend_mode} + ) + + # Repositories + content = {} + for repository in self.repositories: + if repository.repository_manifest is not None: + repository_manifest = repository.repository_manifest.manifest + else: + repository_manifest = None + content[repository.information.uid] = { + "authors": repository.information.authors, + "topics": repository.information.topics, + "category": repository.information.category, + "description": repository.information.description, + "full_name": repository.information.full_name, + "hide": repository.status.hide, + "installed_commit": repository.versions.installed_commit, + "installed": repository.status.installed, + "last_commit": repository.versions.available_commit, + "last_release_tag": repository.versions.available, + "repository_manifest": repository_manifest, + "name": repository.information.name, + "new": repository.status.new, + "selected_tag": repository.status.selected_tag, + "show_beta": repository.status.show_beta, + "version_installed": repository.versions.installed, + } + + await async_save_to_store(self.hass, "repositories", content) + self.hass.bus.async_fire("hacs/repository", {}) + self.hass.bus.fire("hacs/config", {}) + + async def restore(self): + """Restore saved data.""" + hacs = {} + repositories = {} + + try: + hacs = await async_load_from_store(self.hass, "hacs") + except KeyError: + await async_save_to_store(self.hass, "hacs", self.data.read("hacs")["data"]) + hacs = await async_load_from_store(self.hass, "hacs") + + try: + repositories = await async_load_from_store(self.hass, "repositories") + except KeyError: + await async_save_to_store( + self.hass, "repositories", self.data.read("repositories")["data"] + ) + repositories = await async_load_from_store(self.hass, "repositories") + + try: + if self.check_corrupted_files(): + # Coruptted installation + self.logger.critical("Restore failed one or more files are corrupted!") + return False + if hacs is None and repositories is None: + # Assume new install + return True + + self.logger.info("Restore started") + + # Hacs + self.configuration.frontend_mode = hacs.get("view", "Grid") + + # Repositories + repositories = repositories + for entry in repositories: + repo = repositories[entry] + if repo["full_name"] == "custom-components/hacs": + # Skip the old repo location + continue + if not self.is_known(repo["full_name"]): + await self.register_repository( + repo["full_name"], repo["category"], False + ) + repository = self.get_by_name(repo["full_name"]) + if repository is None: + self.logger.error(f"Did not find {repo['full_name']}") + continue + + # Restore repository attributes + if repo.get("authors") is not None: + repository.information.authors = repo["authors"] + + if repo.get("topics", []): + repository.information.topics = repo["topics"] + + if repo.get("description") is not None: + repository.information.description = repo["description"] + + if repo.get("name") is not None: + repository.information.name = repo["name"] + + if repo.get("hide") is not None: + repository.status.hide = repo["hide"] + + if repo.get("installed") is not None: + repository.status.installed = repo["installed"] + if repository.status.installed: + repository.status.first_install = False + + if repo.get("selected_tag") is not None: + repository.status.selected_tag = repo["selected_tag"] + + if repo.get("repository_manifest") is not None: + repository.repository_manifest = HacsManifest.from_dict( + repo["repository_manifest"] + ) + + if repo.get("show_beta") is not None: + repository.status.show_beta = repo["show_beta"] + + if repo.get("last_commit") is not None: + repository.versions.available_commit = repo["last_commit"] + + repository.information.uid = entry + + if repo.get("last_release_tag") is not None: + repository.releases.last_release = repo["last_release_tag"] + repository.versions.available = repo["last_release_tag"] + + if repo.get("new") is not None: + repository.status.new = repo["new"] + + if repo["full_name"] == "hacs/integration": + repository.versions.installed = VERSION + repository.status.installed = True + if "b" in VERSION: + repository.status.show_beta = True + elif repo.get("version_installed") is not None: + repository.versions.installed = repo["version_installed"] + + if repo.get("installed_commit") is not None: + repository.versions.installed_commit = repo["installed_commit"] + + self.logger.info("Restore done") + except Exception as exception: + self.logger.critical( + f"[{exception}] Restore Failed! see https://github.com/hacs/integration/issues/639 for more details." + ) + return False + return True + + +def save(logger, path, content): + """Save file.""" + from .backup import Backup + + backup = Backup(path) + backup.create() + try: + content = {"data": content, "schema": STORAGE_VERSION} + with open(path, "w", encoding="utf-8") as storefile: + json.dump(content, storefile, indent=4) + except Exception as exception: # pylint: disable=broad-except + logger.warning(f"Saving {path} failed - {exception}") + backup.restore() + backup.cleanup() diff --git a/hass/settings/custom_components/hacs/hacsbase/exceptions.py b/hass/settings/custom_components/hacs/hacsbase/exceptions.py new file mode 100644 index 0000000..48ab086 --- /dev/null +++ b/hass/settings/custom_components/hacs/hacsbase/exceptions.py @@ -0,0 +1,45 @@ +"""Custom Exceptions.""" + + +class HacsBaseException(Exception): + """Super basic.""" + + +class HacsUserScrewupException(HacsBaseException): + """Raise this when the user does something they should not do.""" + + +class HacsNotSoBasicException(HacsBaseException): + """Not that basic.""" + + +class HacsDataFileMissing(HacsBaseException): + """Raise this storage datafile is missing.""" + + +class HacsDataNotExpected(HacsBaseException): + """Raise this when data returned from storage is not ok.""" + + +class HacsRepositoryInfo(HacsBaseException): + """Raise this when repository info is missing/wrong.""" + + +class HacsRequirement(HacsBaseException): + """Raise this when repository is missing a requirement.""" + + +class HacsMissingManifest(HacsBaseException): + """Raise this when manifest is missing.""" + + def __init__(self, message="The manifest file is missing in the repository."): + super().__init__(message) + self.message = message + + +class HacsBlacklistException(HacsBaseException): + """Raise this when the repository is currently in the blacklist.""" + + def __init__(self, message="The repository is currently in the blacklist."): + super().__init__(message) + self.message = message diff --git a/hass/settings/custom_components/hacs/hacsbase/migration.py b/hass/settings/custom_components/hacs/hacsbase/migration.py new file mode 100644 index 0000000..a2feb14 --- /dev/null +++ b/hass/settings/custom_components/hacs/hacsbase/migration.py @@ -0,0 +1,129 @@ +"""HACS Migration logic.""" +# pylint: disable=broad-except,no-member +from shutil import copy2 + +from integrationhelper import Logger, Validate + +from . import Hacs +from .data import save, STORES +from .const import STORAGE_VERSION + + +MIGRATIONS = {} + + +def register(cls): + """Register steps.""" + MIGRATIONS[cls.from_version] = cls + return cls + + +class ValidateData(Hacs): + """Validate.""" + + def validate_local_data_file(self): + """Validate content.""" + validate = Validate() + old_data = self.data.read("old") + + if old_data is None: + # new install. + return True + + if old_data.get("hacs", {}).get("schema") is None: + return True + + if old_data.get("hacs", {}).get("schema") == STORAGE_VERSION: + # Newest version, no need to do anything. + return + + current = old_data.get("hacs", {}).get("schema") + + for version in range(int(current), int(STORAGE_VERSION)): + if current in MIGRATIONS: + MIGRATIONS[current](old_data).migrate() + else: + validate.errors.append(f"Missing migration step for {version}") + + if validate.errors: + for error in validate.errors: + self.logger.critical(error) + return validate.success + + +class Migration(Hacs): + """Hacs migrations""" + + def __init__(self, old_data=None): + """initialize migration.""" + self.old_data = old_data + self.logger = Logger("hacs.migration") + self.source = f"{self.system.config_path}/.storage/hacs" + self.cleanup() + self.backup() + + def cleanup(self): + """Remove files no longer in use.""" + + def backup(self): + """Back up old file.""" + destination = f"{self.source}.{self.from_version}" + self.logger.info(f"Backing up current file to '{destination}'") + copy2(self.source, destination) + + +@register +class FromVersion4(Migration): + """Migrate from version 4""" + + from_version = "4" + + def migrate(self): + """Start migration.""" + self.logger.info(f"Starting migration from {self.from_version}") + hacs = self.old_data["hacs"] + repositories = self.old_data["repositories"] + installed = {} + + for repository in repositories: + repository = repositories[repository] + repository["full_name"] = repository["repository_name"] + repository["category"] = repository["repository_type"] + if not repository["installed"]: + repository["show_beta"] = False + else: + if repository["version_installed"] is not None: + version_type = "version" + version_installed = repository["version_installed"] + version_available = repository["last_release_tag"] + else: + version_type = "commit" + version_installed = repository["installed_commit"] + version_available = repository["last_commit"] + if repository["full_name"] != "hacs/integration": + installed[repository["repository_name"]] = { + "version_type": str(version_type), + "version_installed": str(version_installed), + "version_available": str(version_available), + } + + path = f"{self.system.config_path}/.storage/{STORES['hacs']}" + save(self.logger, path, hacs) + + path = f"{self.system.config_path}/.storage/{STORES['repositories']}" + save(self.logger, path, repositories) + + path = f"{self.system.config_path}/.storage/{STORES['installed']}" + save(self.logger, path, installed) + self.logger.info("Migration done") + + +@register +class FromVersion5(Migration): + """Migrate from version 5""" + + from_version = "5" + + def migrate(self): + """Start migration.""" + self.logger.info("Migration done") diff --git a/hass/settings/custom_components/hacs/hacsbase/startup.py b/hass/settings/custom_components/hacs/hacsbase/startup.py new file mode 100644 index 0000000..b936b97 --- /dev/null +++ b/hass/settings/custom_components/hacs/hacsbase/startup.py @@ -0,0 +1,16 @@ +"""HACS Startup.""" +from . import Hacs + + +class HacsStartup(Hacs): + """Startup class.""" + + async def run_startup(self): + """Run startup tasks for HACS.""" + self.logger.critical("Startup!") + if self.configuration.dev: + self.logger.critical("Running in DEV mode!") + if self.developer.devcontainer: + self.logger.critical("Running inside a devcontainer") + self.logger.critical("Some features have been disabled") + self.logger.critical("It will only fetch one repository pr category") diff --git a/hass/settings/custom_components/hacs/hacsbase/update.py b/hass/settings/custom_components/hacs/hacsbase/update.py new file mode 100644 index 0000000..dda5f3d --- /dev/null +++ b/hass/settings/custom_components/hacs/hacsbase/update.py @@ -0,0 +1,6 @@ +"""HACS Updater.""" +from . import Hacs + + +class HacsUpdate(Hacs): + """Update class.""" diff --git a/hass/settings/custom_components/hacs/handler/__init__.py b/hass/settings/custom_components/hacs/handler/__init__.py new file mode 100644 index 0000000..ab064c7 --- /dev/null +++ b/hass/settings/custom_components/hacs/handler/__init__.py @@ -0,0 +1 @@ +"""Initialize handlers.""" diff --git a/hass/settings/custom_components/hacs/handler/download.py b/hass/settings/custom_components/hacs/handler/download.py new file mode 100644 index 0000000..385db21 --- /dev/null +++ b/hass/settings/custom_components/hacs/handler/download.py @@ -0,0 +1,78 @@ +"""Download.""" +import os +import gzip +import shutil + +import aiofiles +import async_timeout +from integrationhelper import Logger +import backoff +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from ..hacsbase.exceptions import HacsNotSoBasicException + + +@backoff.on_exception(backoff.expo, Exception, max_tries=5) +async def async_download_file(hass, url): + """ + Download files, and return the content. + """ + logger = Logger("hacs.download.downloader") + if url is None: + return + + # There is a bug somewhere... TODO: Find that bug.... + if "tags/" in url: + url = url.replace("tags/", "") + + logger.debug(f"Donwloading {url}") + + result = None + + with async_timeout.timeout(60, loop=hass.loop): + request = await async_get_clientsession(hass).get(url) + + # Make sure that we got a valid result + if request.status == 200: + result = await request.read() + else: + raise HacsNotSoBasicException( + "Got status code {} when trying to download {}".format( + request.status, url + ) + ) + + return result + + +async def async_save_file(location, content): + """Save files.""" + logger = Logger("hacs.download.save") + logger.debug(f"Saving {location}") + mode = "w" + encoding = "utf-8" + errors = "ignore" + + if not isinstance(content, str): + mode = "wb" + encoding = None + errors = None + + try: + async with aiofiles.open( + location, mode=mode, encoding=encoding, errors=errors + ) as outfile: + await outfile.write(content) + outfile.close() + + # Create gz for .js files + if os.path.isfile(location): + if location.endswith(".js") or location.endswith(".css"): + with open(location, "rb") as f_in: + with gzip.open(location + ".gz", "wb") as f_out: + shutil.copyfileobj(f_in, f_out) + + except Exception as error: # pylint: disable=broad-except + msg = "Could not write data to {} - {}".format(location, error) + logger.debug(msg) + + return os.path.exists(location) diff --git a/hass/settings/custom_components/hacs/handler/template.py b/hass/settings/custom_components/hacs/handler/template.py new file mode 100644 index 0000000..ca77e7a --- /dev/null +++ b/hass/settings/custom_components/hacs/handler/template.py @@ -0,0 +1,29 @@ +"""Custom template support.""" +# pylint: disable=broad-except +from jinja2 import Template +from integrationhelper import Logger + + +def render_template(content, context): + """Render templates in content.""" + # Fix None issues + if context.releases.last_release_object is not None: + prerelease = context.releases.last_release_object.prerelease + else: + prerelease = False + + # Render the template + try: + render = Template(content) + render = render.render( + installed=context.status.installed, + pending_update=context.pending_upgrade, + prerelease=prerelease, + selected_tag=context.status.selected_tag, + version_available=context.releases.last_release, + version_installed=context.display_installed_version, + ) + return render + except Exception as exception: + Logger("hacs.template").debug(exception) + return content diff --git a/hass/settings/custom_components/hacs/helpers/misc.py b/hass/settings/custom_components/hacs/helpers/misc.py new file mode 100644 index 0000000..19d6a40 --- /dev/null +++ b/hass/settings/custom_components/hacs/helpers/misc.py @@ -0,0 +1,27 @@ +"""Helper functions: misc""" +import semantic_version + + +def get_repository_name( + hacs_manifest, repository_name: str, category: str = None, manifest: dict = None +) -> str: + """Return the name of the repository for use in the frontend.""" + + if hacs_manifest.name is not None: + return hacs_manifest.name + + if category == "integration": + if manifest: + if "name" in manifest: + return manifest["name"] + + return repository_name.replace("-", " ").replace("_", " ").title() + + +def version_is_newer_than_version(new: str, old: str) -> bool: + """Return a bool if source is newer than target, will also be true if identical.""" + if not isinstance(new, str) or not isinstance(old, str): + return False + if new == old: + return True + return semantic_version.Version.coerce(new) > semantic_version.Version.coerce(old) diff --git a/hass/settings/custom_components/hacs/http.py b/hass/settings/custom_components/hacs/http.py new file mode 100644 index 0000000..db0dfaa --- /dev/null +++ b/hass/settings/custom_components/hacs/http.py @@ -0,0 +1,262 @@ +"""Blueprint for HacsWebResponse.""" +import os +from time import time +from homeassistant.components.http import HomeAssistantView +from jinja2 import Environment, PackageLoader +from aiohttp import web + +from integrationhelper import Logger + +from .hacsbase import Hacs + + +WEBRESPONSE = {} + + +def webresponse(classname): + """Decorator used to register Web Responses.""" + WEBRESPONSE[classname.endpoint] = classname + return classname + + +class HacsExperimental(HomeAssistantView, Hacs): + """Base View Class for HACS.""" + + requires_auth = False + name = "hacs" + url = r"/hacs_experimental/{requested_file:.+}" + + def __init__(self): + """Initialize.""" + self.logger = Logger("hacs.http") + + async def get(self, request, requested_file): # pylint: disable=unused-argument + """Handle HACS Experimental Web requests.""" + servefile = f"{self.system.config_path}/custom_components/hacs/frontend/experimental/{requested_file}" + + # Serve .gz file if it exist + if os.path.exists(f"{servefile}.gz"): + servefile += ".gz" + + if os.path.exists(servefile): + return web.FileResponse(servefile) + else: + return web.Response(status=404) + + +class HacsWebResponse(HomeAssistantView, Hacs): + """Base View Class for HACS.""" + + requires_auth = False + name = "hacs" + + def __init__(self): + """Initialize.""" + self.logger = Logger("hacs.http") + self.url = self.hacsweb + "/{path:.+}" + self.endpoint = None + self.postdata = None + self.raw_headers = None + self.repository_id = None + self.request = None + self.requested_file = None + + async def get(self, request, path): # pylint: disable=unused-argument + """Handle HACS Web requests.""" + if self.system.disabled: + return web.Response(status=404) + self.endpoint = path.split("/")[0] + self.raw_headers = request.raw_headers + self.request = request + self.requested_file = path.replace(self.endpoint + "/", "") + self.repository_id = path.replace(self.endpoint + "/", "") + if self.endpoint != "static": + self.logger.debug(f"Endpoint ({self.endpoint}) called") + if self.endpoint in WEBRESPONSE: + try: + response = WEBRESPONSE[self.endpoint] + response = await response.response(self) + except Exception as exception: + render = self.render("error", message=exception) + return web.Response( + body=render, content_type="text/html", charset="utf-8" + ) + else: + # Return default response. + response = await WEBRESPONSE["generic"].response(self) + + # set headers + response.headers["Cache-Control"] = "no-cache, must-revalidate, s-max_age=0" + response.headers["Pragma"] = "no-cache" + + # serve the response + return response + + def render(self, templatefile, location=None, repository=None, message=None): + """Render a template file.""" + loader = Environment(loader=PackageLoader("custom_components.hacs.frontend")) + template = loader.get_template(templatefile + ".html") + return template.render( + { + "hacs": self, + "location": location, + "repository": repository, + "message": message, + "timestamp": time(), + } + ) + + +class HacsPluginView(HacsWebResponse): + """Serve plugins.""" + + name = "hacs:plugin" + + def __init__(self): + """Initialize.""" + self.url = r"/community_plugin/{requested_file:.+}" + + async def get(self, request, requested_file): # pylint: disable=unused-argument + """Serve plugins for lovelace.""" + try: + # Strip '?' from URL + if "?" in requested_file: + requested_file = requested_file.split("?")[0] + + file = f"{self.system.config_path}/www/community/{requested_file}" + + # Serve .gz if it exist + if os.path.exists(file + ".gz"): + file += ".gz" + + response = None + if os.path.exists(file): + self.logger.debug("Serving {} from {}".format(requested_file, file)) + response = web.FileResponse(file) + response.headers["Cache-Control"] = "max-age=0, must-revalidate" + else: + self.logger.error(f"Tried to serve up '{file}' but it does not exist") + response = web.Response(status=404) + + except Exception as error: # pylint: disable=broad-except + self.logger.debug( + "there was an issue trying to serve {} - {}".format( + requested_file, error + ) + ) + response = web.Response(status=404) + + return response + + +class HacsPlugin(HacsPluginView): + """Alias for HacsPluginView.""" + + def __init__(self): + """Initialize.""" + self.url = r"/hacsplugin/{requested_file:.+}" + + +@webresponse +class Settings(HacsWebResponse): + """Serve HacsSettingsView.""" + + endpoint = "settings" + + async def response(self): + """Serve HacsOverviewView.""" + message = self.request.rel_url.query.get("message") + render = self.render("settings", "settings", message=message) + return web.Response(body=render, content_type="text/html", charset="utf-8") + + +@webresponse +class Static(HacsWebResponse): + """Serve static files.""" + + endpoint = "static" + + async def response(self): + """Serve static files.""" + servefile = f"{self.system.config_path}/custom_components/hacs/frontend/elements/{self.requested_file}" + if os.path.exists(servefile + ".gz"): + return web.FileResponse(servefile + ".gz") + else: + if os.path.exists(servefile): + return web.FileResponse(servefile) + else: + return web.Response(status=404) + + +@webresponse +class Store(HacsWebResponse): + """Serve HacsOverviewView.""" + + endpoint = "store" + + async def response(self): + """Serve HacsStoreView.""" + render = self.render("overviews", "store") + return web.Response(body=render, content_type="text/html", charset="utf-8") + + +@webresponse +class Overview(HacsWebResponse): + """Serve HacsOverviewView.""" + + endpoint = "overview" + + async def response(self): + """Serve HacsOverviewView.""" + render = self.render("overviews", "overview") + return web.Response(body=render, content_type="text/html", charset="utf-8") + + +@webresponse +class Repository(HacsWebResponse): + """Serve HacsRepositoryView.""" + + endpoint = "repository" + + async def response(self): + """Serve HacsRepositoryView.""" + message = self.request.rel_url.query.get("message") + repository = self.get_by_id(str(self.repository_id)) + if repository is None: + self.logger.error(f"No repository found with ID {str(self.repository_id)}") + return web.Response(status=404) + + if not repository.status.updated_info: + await repository.update_repository() + repository.status.updated_info = True + + await self.data.async_write() + + if repository.status.new: + repository.status.new = False + await self.data.async_write() + + render = self.render("repository", repository=repository, message=message) + return web.Response(body=render, content_type="text/html", charset="utf-8") + + +@webresponse +class Error(HacsWebResponse): + """Serve error page.""" + + endpoint = "error" + + async def response(self): + """Serve error page.""" + # Generate content + content = """ +
+ +
+ """ + + return web.Response( + body=self.render("error", message=content), + content_type="text/html", + charset="utf-8", + ) diff --git a/hass/settings/custom_components/hacs/manifest.json b/hass/settings/custom_components/hacs/manifest.json new file mode 100644 index 0000000..498b779 --- /dev/null +++ b/hass/settings/custom_components/hacs/manifest.json @@ -0,0 +1,23 @@ +{ + "domain": "hacs", + "name": "HACS (Home Assistant Community Store)", + "documentation": "https://hacs.xyz", + "dependencies": [ + "websocket_api", + "frontend", + "persistent_notification" + ], + "config_flow": true, + "codeowners": [ + "@ludeeus" + ], + "homeassistant": "0.98.0", + "requirements": [ + "aiofiles==0.4.0", + "attrs==19.2.0", + "aiogithubapi==0.4.2", + "backoff==1.8.0", + "integrationhelper==0.2.2", + "semantic_version==2.8.2" + ] +} \ No newline at end of file diff --git a/hass/settings/custom_components/hacs/repositories/__init__.py b/hass/settings/custom_components/hacs/repositories/__init__.py new file mode 100644 index 0000000..b4cfae8 --- /dev/null +++ b/hass/settings/custom_components/hacs/repositories/__init__.py @@ -0,0 +1,6 @@ +"""Initialize repositories.""" +from .theme import HacsTheme +from .integration import HacsIntegration +from .python_script import HacsPythonScript +from .appdaemon import HacsAppdaemon +from .plugin import HacsPlugin diff --git a/hass/settings/custom_components/hacs/repositories/appdaemon.py b/hass/settings/custom_components/hacs/repositories/appdaemon.py new file mode 100644 index 0000000..6e5dba7 --- /dev/null +++ b/hass/settings/custom_components/hacs/repositories/appdaemon.py @@ -0,0 +1,87 @@ +"""Class for appdaemon apps in HACS.""" +from .repository import HacsRepository, register_repository_class + + +@register_repository_class +class HacsAppdaemon(HacsRepository): + """Appdaemon apps in HACS.""" + + category = "appdaemon" + + def __init__(self, full_name): + """Initialize.""" + super().__init__() + self.information.full_name = full_name + self.information.category = self.category + self.content.path.local = self.localpath + self.content.path.remote = "apps" + + @property + def localpath(self): + """Return localpath.""" + return f"{self.system.config_path}/appdaemon/apps/{self.information.name}" + + async def validate_repository(self): + """Validate.""" + await self.common_validate() + + # Custom step 1: Validate content. + addir = await self.repository_object.get_contents("apps", self.ref) + if not isinstance(addir, list): + self.validate.errors.append("Repostitory structure not compliant") + + self.content.path.remote = addir[0].path + self.information.name = addir[0].name + self.content.objects = await self.repository_object.get_contents( + self.content.path.remote, self.ref + ) + + self.content.files = [] + for filename in self.content.objects: + self.content.files.append(filename.name) + + # Handle potential errors + if self.validate.errors: + for error in self.validate.errors: + if not self.system.status.startup: + self.logger.error(error) + return self.validate.success + + async def registration(self): + """Registration.""" + if not await self.validate_repository(): + return False + + # Run common registration steps. + await self.common_registration() + + # Set local path + self.content.path.local = self.localpath + + async def update_repository(self): + """Update.""" + if self.github.ratelimits.remaining == 0: + return + await self.common_update() + + # Get appdaemon objects. + if self.repository_manifest: + if self.repository_manifest.content_in_root: + self.content.path.remote = "" + + if self.content.path.remote == "apps": + addir = await self.repository_object.get_contents( + self.content.path.remote, self.ref + ) + self.content.path.remote = addir[0].path + self.information.name = addir[0].name + self.content.objects = await self.repository_object.get_contents( + self.content.path.remote, self.ref + ) + + self.content.files = [] + for filename in self.content.objects: + self.content.files.append(filename.name) + + # Set local path + self.content.path.local = self.localpath diff --git a/hass/settings/custom_components/hacs/repositories/integration.py b/hass/settings/custom_components/hacs/repositories/integration.py new file mode 100644 index 0000000..ec56472 --- /dev/null +++ b/hass/settings/custom_components/hacs/repositories/integration.py @@ -0,0 +1,164 @@ +"""Class for integrations in HACS.""" +import json +from aiogithubapi import AIOGitHubException +from homeassistant.loader import async_get_custom_components +from .repository import HacsRepository, register_repository_class + + +@register_repository_class +class HacsIntegration(HacsRepository): + """Integrations in HACS.""" + + category = "integration" + + def __init__(self, full_name): + """Initialize.""" + super().__init__() + self.information.full_name = full_name + self.information.category = self.category + self.domain = None + self.content.path.remote = "custom_components" + self.content.path.local = self.localpath + + @property + def localpath(self): + """Return localpath.""" + return f"{self.system.config_path}/custom_components/{self.domain}" + + @property + def config_flow(self): + """Return bool if integration has config_flow.""" + if self.manifest: + if self.information.full_name == "hacs/integration": + return False + return self.manifest.get("config_flow", False) + return False + + async def validate_repository(self): + """Validate.""" + await self.common_validate() + + # Attach repository + if self.repository_object is None: + self.repository_object = await self.github.get_repo( + self.information.full_name + ) + + # Custom step 1: Validate content. + if self.repository_manifest: + if self.repository_manifest.content_in_root: + self.content.path.remote = "" + + if self.content.path.remote == "custom_components": + ccdir = await self.repository_object.get_contents( + self.content.path.remote, self.ref + ) + if not isinstance(ccdir, list): + self.validate.errors.append("Repostitory structure not compliant") + + for item in ccdir or []: + if item.type == "dir": + self.content.path.remote = item.path + break + + if self.repository_manifest.zip_release: + self.content.objects = self.releases.last_release_object.assets + + else: + self.content.objects = await self.repository_object.get_contents( + self.content.path.remote, self.ref + ) + + self.content.files = [] + for filename in self.content.objects or []: + self.content.files.append(filename.name) + + if not await self.get_manifest(): + self.validate.errors.append("Missing manifest file.") + + # Handle potential errors + if self.validate.errors: + for error in self.validate.errors: + if not self.system.status.startup: + self.logger.error(error) + return self.validate.success + + async def registration(self): + """Registration.""" + if not await self.validate_repository(): + return False + + # Run common registration steps. + await self.common_registration() + + # Get the content of the manifest file. + await self.get_manifest() + + # Set local path + self.content.path.local = self.localpath + + async def update_repository(self): + """Update.""" + if self.github.ratelimits.remaining == 0: + return + await self.common_update() + + # Get integration objects. + + if self.repository_manifest: + if self.repository_manifest.content_in_root: + self.content.path.remote = "" + + if self.content.path.remote == "custom_components": + ccdir = await self.repository_object.get_contents( + self.content.path.remote, self.ref + ) + if not isinstance(ccdir, list): + self.validate.errors.append("Repostitory structure not compliant") + + self.content.path.remote = ccdir[0].path + + try: + self.content.objects = await self.repository_object.get_contents( + self.content.path.remote, self.ref + ) + except AIOGitHubException: + return + + self.content.files = [] + for filename in self.content.objects or []: + self.content.files.append(filename.name) + + await self.get_manifest() + + # Set local path + self.content.path.local = self.localpath + + async def reload_custom_components(self): + """Reload custom_components (and config flows)in HA.""" + self.logger.info("Reloading custom_component cache") + del self.hass.data["custom_components"] + await async_get_custom_components(self.hass) + + async def get_manifest(self): + """Get info from the manifest file.""" + manifest_path = f"{self.content.path.remote}/manifest.json" + try: + manifest = await self.repository_object.get_contents( + manifest_path, self.ref + ) + manifest = json.loads(manifest.content) + except Exception: # pylint: disable=broad-except + return False + + if manifest: + self.manifest = manifest + self.information.authors = manifest["codeowners"] + self.domain = manifest["domain"] + self.information.name = manifest["name"] + self.information.homeassistant_version = manifest.get("homeassistant") + + # Set local path + self.content.path.local = self.localpath + return True + return False diff --git a/hass/settings/custom_components/hacs/repositories/manifest.py b/hass/settings/custom_components/hacs/repositories/manifest.py new file mode 100644 index 0000000..a3c2084 --- /dev/null +++ b/hass/settings/custom_components/hacs/repositories/manifest.py @@ -0,0 +1,41 @@ +""" +Manifest handling of a repository. + +https://hacs.xyz/docs/publish/start#hacsjson +""" +from typing import List +import attr + +from custom_components.hacs.hacsbase.exceptions import HacsRepositoryInfo + + +@attr.s(auto_attribs=True) +class HacsManifest: + """HacsManifest class.""" + + name: str = None + content_in_root: bool = False + zip_release: bool = False + filename: str = None + manifest: dict = {} + hacs: str = None + domains: List[str] = [] + country: List[str] = [] + homeassistant: str = None + persistent_directory: str = None + iot_class: str = None + render_readme: bool = False + + @staticmethod + def from_dict(manifest: dict): + """Set attributes from dicts.""" + if manifest is None: + raise HacsRepositoryInfo("Missing manifest data") + + manifest_data = HacsManifest() + + manifest_data.manifest = manifest + + for key in manifest: + setattr(manifest_data, key, manifest[key]) + return manifest_data diff --git a/hass/settings/custom_components/hacs/repositories/plugin.py b/hass/settings/custom_components/hacs/repositories/plugin.py new file mode 100644 index 0000000..5b45a85 --- /dev/null +++ b/hass/settings/custom_components/hacs/repositories/plugin.py @@ -0,0 +1,169 @@ +"""Class for plugins in HACS.""" +import json +from aiogithubapi import AIOGitHubException +from .repository import HacsRepository, register_repository_class + + +@register_repository_class +class HacsPlugin(HacsRepository): + """Plugins in HACS.""" + + category = "plugin" + + def __init__(self, full_name): + """Initialize.""" + super().__init__() + self.information.full_name = full_name + self.information.category = self.category + self.information.file_name = None + self.information.javascript_type = None + self.content.path.local = ( + f"{self.system.config_path}/www/community/{full_name.split('/')[-1]}" + ) + + async def validate_repository(self): + """Validate.""" + # Run common validation steps. + await self.common_validate() + + # Custom step 1: Validate content. + await self.get_plugin_location() + + if self.content.path.remote is None: + self.validate.errors.append("Repostitory structure not compliant") + + if self.content.path.remote == "release": + self.content.single = True + + self.content.files = [] + for filename in self.content.objects: + self.content.files.append(filename.name) + + # Handle potential errors + if self.validate.errors: + for error in self.validate.errors: + if not self.system.status.startup: + self.logger.error(error) + return self.validate.success + + async def registration(self): + """Registration.""" + if not await self.validate_repository(): + return False + + # Run common registration steps. + await self.common_registration() + + async def update_repository(self): + """Update.""" + if self.github.ratelimits.remaining == 0: + return + # Run common update steps. + await self.common_update() + + # Get plugin objects. + await self.get_plugin_location() + + # Get JS type + await self.parse_readme_for_jstype() + + if self.content.path.remote is None: + self.validate.errors.append("Repostitory structure not compliant") + + if self.content.path.remote == "release": + self.content.single = True + + self.content.files = [] + for filename in self.content.objects: + self.content.files.append(filename.name) + + async def get_plugin_location(self): + """Get plugin location.""" + if self.content.path.remote is not None: + return + + possible_locations = ["dist", "release", ""] + + if self.repository_manifest: + if self.repository_manifest.content_in_root: + possible_locations = [""] + + for location in possible_locations: + if self.content.path.remote is not None: + continue + try: + objects = [] + files = [] + if location != "release": + try: + objects = await self.repository_object.get_contents( + location, self.ref + ) + except AIOGitHubException: + continue + else: + await self.get_releases() + if self.releases.releases: + if self.releases.last_release_object.assets is not None: + objects = self.releases.last_release_object.assets + + for item in objects: + if item.name.endswith(".js"): + files.append(item.name) + + # Handler for plug requirement 3 + valid_filenames = [ + f"{self.information.name.replace('lovelace-', '')}.js", + f"{self.information.name}.js", + f"{self.information.name}.umd.js", + f"{self.information.name}-bundle.js", + ] + + if self.repository_manifest: + if self.repository_manifest.filename: + valid_filenames.append(self.repository_manifest.filename) + + for name in valid_filenames: + if name in files: + # YES! We got it! + self.information.file_name = name + self.content.path.remote = location + self.content.objects = objects + self.content.files = files + break + + except SystemError: + pass + + async def get_package_content(self): + """Get package content.""" + try: + package = await self.repository_object.get_contents("package.json") + package = json.loads(package.content) + + if package: + self.information.authors = package["author"] + except Exception: # pylint: disable=broad-except + pass + + async def parse_readme_for_jstype(self): + """Parse the readme looking for js type.""" + readme = None + readme_files = ["readme", "readme.md"] + root = await self.repository_object.get_contents("") + for file in root: + if file.name.lower() in readme_files: + readme = await self.repository_object.get_contents(file.name) + break + + if readme is None: + return + + readme = readme.content + for line in readme.splitlines(): + if "type: module" in line: + self.information.javascript_type = "module" + break + elif "type: js" in line: + self.information.javascript_type = "js" + break diff --git a/hass/settings/custom_components/hacs/repositories/python_script.py b/hass/settings/custom_components/hacs/repositories/python_script.py new file mode 100644 index 0000000..5d6af42 --- /dev/null +++ b/hass/settings/custom_components/hacs/repositories/python_script.py @@ -0,0 +1,79 @@ +"""Class for python_scripts in HACS.""" +from .repository import HacsRepository, register_repository_class + + +@register_repository_class +class HacsPythonScript(HacsRepository): + """python_scripts in HACS.""" + + category = "python_script" + + def __init__(self, full_name): + """Initialize.""" + super().__init__() + self.information.full_name = full_name + self.information.category = self.category + self.content.path.remote = "python_scripts" + self.content.path.local = f"{self.system.config_path}/python_scripts" + self.content.single = True + + async def validate_repository(self): + """Validate.""" + # Run common validation steps. + await self.common_validate() + + # Custom step 1: Validate content. + self.content.objects = await self.repository_object.get_contents( + self.content.path.remote, self.ref + ) + if not isinstance(self.content.objects, list): + self.validate.errors.append("Repostitory structure not compliant") + + self.content.files = [] + for filename in self.content.objects: + self.content.files.append(filename.name) + + # Handle potential errors + if self.validate.errors: + for error in self.validate.errors: + if not self.system.status.startup: + self.logger.error(error) + return self.validate.success + + async def registration(self): + """Registration.""" + if not await self.validate_repository(): + return False + + # Run common registration steps. + await self.common_registration() + + # Set name + self.information.name = self.content.objects[0].name.replace(".py", "") + + async def update_repository(self): # lgtm[py/similar-function] + """Update.""" + if self.github.ratelimits.remaining == 0: + return + # Run common update steps. + await self.common_update() + + # Get python_script objects. + if self.repository_manifest: + if self.repository_manifest.content_in_root: + self.content.path.remote = "" + + self.content.objects = await self.repository_object.get_contents( + self.content.path.remote, self.ref + ) + + self.content.files = [] + for filename in self.content.objects: + self.content.files.append(filename.name) + + # Update name + self.information.name = self.content.objects[0].name.replace(".py", "") + + self.content.files = [] + for filename in self.content.objects: + self.content.files.append(filename.name) diff --git a/hass/settings/custom_components/hacs/repositories/repository.py b/hass/settings/custom_components/hacs/repositories/repository.py new file mode 100644 index 0000000..e62e2e3 --- /dev/null +++ b/hass/settings/custom_components/hacs/repositories/repository.py @@ -0,0 +1,697 @@ +"""Repository.""" +# pylint: disable=broad-except, bad-continuation, no-member +import pathlib +import json +import os +import tempfile +import zipfile +from integrationhelper import Validate, Logger +from aiogithubapi import AIOGitHubException +from .manifest import HacsManifest +from ..helpers.misc import get_repository_name +from ..hacsbase import Hacs +from ..hacsbase.backup import Backup +from ..handler.download import async_download_file, async_save_file +from ..helpers.misc import version_is_newer_than_version + + +RERPOSITORY_CLASSES = {} + + +def register_repository_class(cls): + """Register class.""" + RERPOSITORY_CLASSES[cls.category] = cls + return cls + + +class RepositoryVersions: + """Versions.""" + + available = None + available_commit = None + installed = None + installed_commit = None + + +class RepositoryStatus: + """Repository status.""" + + hide = False + installed = False + last_updated = None + new = True + selected_tag = None + show_beta = False + track = True + updated_info = False + first_install = True + + +class RepositoryInformation: + """RepositoryInformation.""" + + additional_info = None + authors = [] + category = None + default_branch = None + description = "" + state = None + full_name = None + file_name = None + javascript_type = None + homeassistant_version = None + last_updated = None + uid = None + info = None + name = None + topics = [] + + +class RepositoryReleases: + """RepositoyReleases.""" + + last_release = None + last_release_object = None + published_tags = [] + objects = [] + releases = False + + +class RepositoryPath: + """RepositoryPath.""" + + local = None + remote = None + + +class RepositoryContent: + """RepositoryContent.""" + + path = None + files = [] + objects = [] + single = False + + +class HacsRepository(Hacs): + """HacsRepository.""" + + def __init__(self): + """Set up HacsRepository.""" + + self.content = RepositoryContent() + self.content.path = RepositoryPath() + self.information = RepositoryInformation() + self.repository_object = None + self.status = RepositoryStatus() + self.state = None + self.manifest = {} + self.repository_manifest = HacsManifest.from_dict({}) + self.validate = Validate() + self.releases = RepositoryReleases() + self.versions = RepositoryVersions() + self.pending_restart = False + self.logger = None + + @property + def pending_upgrade(self): + """Return pending upgrade.""" + if self.status.installed: + if self.display_installed_version != self.display_available_version: + return True + + return False + + @property + def ref(self): + """Return the ref.""" + if self.status.selected_tag is not None: + if self.status.selected_tag == self.information.default_branch: + return self.information.default_branch + return "tags/{}".format(self.status.selected_tag) + + if self.releases.releases: + return "tags/{}".format(self.versions.available) + + return self.information.default_branch + + @property + def custom(self): + """Return flag if the repository is custom.""" + if self.information.full_name.split("/")[0] in [ + "custom-components", + "custom-cards", + ]: + return False + if self.information.full_name in self.common.default: + return False + if self.information.full_name == "hacs/integration": + return False + return True + + @property + def can_install(self): + """Return bool if repository can be installed.""" + target = None + if self.information.homeassistant_version is not None: + target = self.information.homeassistant_version + if self.repository_manifest is not None: + if self.repository_manifest.homeassistant is not None: + target = self.repository_manifest.homeassistant + + if target is not None: + if self.releases.releases: + if version_is_newer_than_version(target, self.system.ha_version): + return False + return True + + @property + def display_name(self): + """Return display name.""" + return get_repository_name( + self.repository_manifest, + self.information.name, + self.information.category, + self.manifest, + ) + + @property + def display_status(self): + """Return display_status.""" + if self.status.new: + status = "new" + elif self.pending_restart: + status = "pending-restart" + elif self.pending_upgrade: + status = "pending-upgrade" + elif self.status.installed: + status = "installed" + else: + status = "default" + return status + + @property + def display_status_description(self): + """Return display_status_description.""" + description = { + "default": "Not installed.", + "pending-restart": "Restart pending.", + "pending-upgrade": "Upgrade pending.", + "installed": "No action required.", + "new": "This is a newly added repository.", + } + return description[self.display_status] + + @property + def display_installed_version(self): + """Return display_authors""" + if self.versions.installed is not None: + installed = self.versions.installed + else: + if self.versions.installed_commit is not None: + installed = self.versions.installed_commit + else: + installed = "" + return installed + + @property + def display_available_version(self): + """Return display_authors""" + if self.versions.available is not None: + available = self.versions.available + else: + if self.versions.available_commit is not None: + available = self.versions.available_commit + else: + available = "" + return available + + @property + def display_version_or_commit(self): + """Does the repositoriy use releases or commits?""" + if self.versions.installed is not None: + version_or_commit = "version" + else: + version_or_commit = "commit" + return version_or_commit + + @property + def main_action(self): + """Return the main action.""" + actions = { + "new": "INSTALL", + "default": "INSTALL", + "installed": "REINSTALL", + "pending-restart": "REINSTALL", + "pending-upgrade": "UPGRADE", + } + return actions[self.display_status] + + async def common_validate(self): + """Common validation steps of the repository.""" + # Attach helpers + self.validate.errors = [] + self.logger = Logger( + f"hacs.repository.{self.information.category}.{self.information.full_name}" + ) + + # Step 1: Make sure the repository exist. + self.logger.debug("Checking repository.") + try: + self.repository_object = await self.github.get_repo( + self.information.full_name + ) + except Exception as exception: # Gotta Catch 'Em All + if not self.system.status.startup: + self.logger.error(exception) + self.validate.errors.append("Repository does not exist.") + return + + # Step 2: Make sure the repository is not archived. + if self.repository_object.archived: + self.validate.errors.append("Repository is archived.") + return + + # Step 3: Make sure the repository is not in the blacklist. + if self.information.full_name in self.common.blacklist: + self.validate.errors.append("Repository is in the blacklist.") + return + + # Step 4: default branch + self.information.default_branch = self.repository_object.default_branch + + # Step 5: Get releases. + await self.get_releases() + + # Step 6: Get the content of hacs.json + await self.get_repository_manifest_content() + + # Set repository name + self.information.name = self.information.full_name.split("/")[1] + + async def common_registration(self): + """Common registration steps of the repository.""" + # Attach logger + if self.logger is None: + self.logger = Logger( + f"hacs.repository.{self.information.category}.{self.information.full_name}" + ) + + # Attach repository + if self.repository_object is None: + self.repository_object = await self.github.get_repo( + self.information.full_name + ) + + # Set id + self.information.uid = str(self.repository_object.id) + + # Set topics + self.information.topics = self.repository_object.topics + + # Set description + if self.repository_object.description: + self.information.description = self.repository_object.description + + async def common_update(self): + """Common information update steps of the repository.""" + # Attach logger + if self.logger is None: + self.logger = Logger( + f"hacs.repository.{self.information.category}.{self.information.full_name}" + ) + + # Attach repository + self.repository_object = await self.github.get_repo(self.information.full_name) + + # Update description + if self.repository_object.description: + self.information.description = self.repository_object.description + + # Update default branch + self.information.default_branch = self.repository_object.default_branch + + # Update last available commit + await self.repository_object.set_last_commit() + self.versions.available_commit = self.repository_object.last_commit + + # Update last updaeted + self.information.last_updated = self.repository_object.pushed_at + + # Update topics + self.information.topics = self.repository_object.topics + + # Get the content of hacs.json + await self.get_repository_manifest_content() + + # Update "info.md" + await self.get_info_md_content() + + # Update releases + await self.get_releases() + + async def install(self): + """Common installation steps of the repository.""" + self.validate.errors = [] + persistent_directory = None + + await self.update_repository() + + if self.repository_manifest: + if self.repository_manifest.persistent_directory: + if os.path.exists( + f"{self.content.path.local}/{self.repository_manifest.persistent_directory}" + ): + persistent_directory = Backup( + f"{self.content.path.local}/{self.repository_manifest.persistent_directory}", + tempfile.TemporaryFile() + "/hacs_persistent_directory/", + ) + persistent_directory.create() + + if self.status.installed and not self.content.single: + backup = Backup(self.content.path.local) + backup.create() + + if self.repository_manifest.zip_release: + validate = await self.download_zip(self.validate) + else: + validate = await self.download_content( + self.validate, + self.content.path.remote, + self.content.path.local, + self.ref, + ) + + if validate.errors: + for error in validate.errors: + self.logger.error(error) + if self.status.installed and not self.content.single: + backup.restore() + + if self.status.installed and not self.content.single: + backup.cleanup() + + if persistent_directory is not None: + persistent_directory.restore() + persistent_directory.cleanup() + + if validate.success: + if self.information.full_name not in self.common.installed: + if self.information.full_name == "hacs/integration": + self.common.installed.append(self.information.full_name) + self.status.installed = True + self.versions.installed_commit = self.versions.available_commit + + if self.status.selected_tag is not None: + self.versions.installed = self.status.selected_tag + else: + self.versions.installed = self.versions.available + + if self.information.category == "integration": + if ( + self.config_flow + and self.information.full_name != "hacs/integration" + ): + await self.reload_custom_components() + else: + self.pending_restart = True + + elif self.information.category == "theme": + try: + await self.hass.services.async_call("frontend", "reload_themes", {}) + except Exception: # pylint: disable=broad-except + pass + self.hass.bus.async_fire( + "hacs/repository", + { + "id": 1337, + "action": "install", + "repository": self.information.full_name, + }, + ) + + async def download_zip(self, validate): + """Download ZIP archive from repository release.""" + try: + contents = False + + for release in self.releases.objects: + self.logger.info(f"ref: {self.ref} --- tag: {release.tag_name}") + if release.tag_name == self.ref.split("/")[1]: + contents = release.assets + + if not contents: + return validate + + for content in contents or []: + filecontent = await async_download_file(self.hass, content.download_url) + + if filecontent is None: + validate.errors.append(f"[{content.name}] was not downloaded.") + continue + + result = await async_save_file( + f"{tempfile.gettempdir()}/{self.repository_manifest.filename}", + filecontent, + ) + with zipfile.ZipFile( + f"{tempfile.gettempdir()}/{self.repository_manifest.filename}", "r" + ) as zip_file: + zip_file.extractall(self.content.path.local) + + if result: + self.logger.info(f"download of {content.name} complete") + continue + validate.errors.append(f"[{content.name}] was not downloaded.") + except Exception: + validate.errors.append(f"Download was not complete.") + + return validate + + async def download_content(self, validate, directory_path, local_directory, ref): + """Download the content of a directory.""" + try: + # Get content + if self.content.single: + contents = self.content.objects + else: + contents = await self.repository_object.get_contents( + directory_path, self.ref + ) + + for content in contents: + if content.type == "dir" and ( + self.repository_manifest.content_in_root + or self.content.path.remote != "" + ): + await self.download_content( + validate, content.path, local_directory, ref + ) + continue + if self.information.category == "plugin": + if not content.name.endswith(".js"): + if self.content.path.remote != "dist": + continue + + self.logger.debug(f"Downloading {content.name}") + + filecontent = await async_download_file(self.hass, content.download_url) + + if filecontent is None: + validate.errors.append(f"[{content.name}] was not downloaded.") + continue + + # Save the content of the file. + if self.content.single: + local_directory = self.content.path.local + + else: + _content_path = content.path + if not self.repository_manifest.content_in_root: + _content_path = _content_path.replace( + f"{self.content.path.remote}/", "" + ) + + local_directory = f"{self.content.path.local}/{_content_path}" + local_directory = local_directory.split("/") + del local_directory[-1] + local_directory = "/".join(local_directory) + + # Check local directory + pathlib.Path(local_directory).mkdir(parents=True, exist_ok=True) + + local_file_path = f"{local_directory}/{content.name}" + result = await async_save_file(local_file_path, filecontent) + if result: + self.logger.info(f"download of {content.name} complete") + continue + validate.errors.append(f"[{content.name}] was not downloaded.") + + except Exception: + validate.errors.append(f"Download was not complete.") + return validate + + async def get_repository_manifest_content(self): + """Get the content of the hacs.json file.""" + try: + manifest = await self.repository_object.get_contents("hacs.json", self.ref) + self.repository_manifest = HacsManifest.from_dict( + json.loads(manifest.content) + ) + except (AIOGitHubException, Exception): # Gotta Catch 'Em All + pass + + async def get_info_md_content(self): + """Get the content of info.md""" + from ..handler.template import render_template + + info = None + info_files = ["info", "info.md"] + + if self.repository_manifest is not None: + if self.repository_manifest.render_readme: + info_files = ["readme", "readme.md"] + try: + root = await self.repository_object.get_contents("", self.ref) + for file in root: + if file.name.lower() in info_files: + + info = await self.repository_object.get_rendered_contents( + file.name, self.ref + ) + break + if info is None: + self.information.additional_info = "" + else: + info = info.replace("<", "<") + info = info.replace("", "
").replace("
", "") + info = info.replace("

", "

").replace("
", "") + info = info.replace("

", "

").replace("

", "") + info = info.replace("", "") + info = info.replace( + '", "
  • ") + + # Special changes that needs to be done: + info = info.replace( + "