Ver código fonte

first commit

hmetzner 3 anos atrás
commit
9cec9e1a18
100 arquivos alterados com 8516 adições e 0 exclusões
  1. 1 0
      addons.yaml
  2. 1 0
      automations.yaml
  3. 54 0
      blueprints/automation/homeassistant/motion_light.yaml
  4. 44 0
      blueprints/automation/homeassistant/notify_leaving_zone.yaml
  5. 84 0
      blueprints/script/homeassistant/confirmable_notification.yaml
  6. 86 0
      configuration.yaml
  7. 119 0
      custom_components/config_editor/__init__.py
  8. 9 0
      custom_components/config_editor/manifest.json
  9. 106 0
      custom_components/gardena_smart_system/__init__.py
  10. BIN
      custom_components/gardena_smart_system/__pycache__/__init__.cpython-310.pyc
  11. BIN
      custom_components/gardena_smart_system/__pycache__/__init__.cpython-39.pyc
  12. BIN
      custom_components/gardena_smart_system/__pycache__/binary_sensor.cpython-310.pyc
  13. BIN
      custom_components/gardena_smart_system/__pycache__/binary_sensor.cpython-39.pyc
  14. BIN
      custom_components/gardena_smart_system/__pycache__/config_flow.cpython-310.pyc
  15. BIN
      custom_components/gardena_smart_system/__pycache__/config_flow.cpython-39.pyc
  16. BIN
      custom_components/gardena_smart_system/__pycache__/const.cpython-310.pyc
  17. BIN
      custom_components/gardena_smart_system/__pycache__/const.cpython-39.pyc
  18. BIN
      custom_components/gardena_smart_system/__pycache__/sensor.cpython-310.pyc
  19. BIN
      custom_components/gardena_smart_system/__pycache__/sensor.cpython-39.pyc
  20. BIN
      custom_components/gardena_smart_system/__pycache__/switch.cpython-310.pyc
  21. BIN
      custom_components/gardena_smart_system/__pycache__/switch.cpython-39.pyc
  22. BIN
      custom_components/gardena_smart_system/__pycache__/vacuum.cpython-310.pyc
  23. BIN
      custom_components/gardena_smart_system/__pycache__/vacuum.cpython-39.pyc
  24. 57 0
      custom_components/gardena_smart_system/binary_sensor.py
  25. 126 0
      custom_components/gardena_smart_system/config_flow.py
  26. 22 0
      custom_components/gardena_smart_system/const.py
  27. 11 0
      custom_components/gardena_smart_system/manifest.json
  28. 138 0
      custom_components/gardena_smart_system/sensor.py
  29. 35 0
      custom_components/gardena_smart_system/strings.json
  30. 381 0
      custom_components/gardena_smart_system/switch.py
  31. 36 0
      custom_components/gardena_smart_system/translations/de.json
  32. 36 0
      custom_components/gardena_smart_system/translations/en.json
  33. 36 0
      custom_components/gardena_smart_system/translations/fr.json
  34. 36 0
      custom_components/gardena_smart_system/translations/nb.json
  35. 36 0
      custom_components/gardena_smart_system/translations/nl.json
  36. 29 0
      custom_components/gardena_smart_system/translations/vacuum.da.json
  37. 29 0
      custom_components/gardena_smart_system/translations/vacuum.de.json
  38. 29 0
      custom_components/gardena_smart_system/translations/vacuum.en.json
  39. 29 0
      custom_components/gardena_smart_system/translations/vacuum.nl.json
  40. 222 0
      custom_components/gardena_smart_system/vacuum.py
  41. 263 0
      custom_components/hacs/__init__.py
  42. BIN
      custom_components/hacs/__pycache__/__init__.cpython-310.pyc
  43. BIN
      custom_components/hacs/__pycache__/base.cpython-310.pyc
  44. BIN
      custom_components/hacs/__pycache__/config_flow.cpython-310.pyc
  45. BIN
      custom_components/hacs/__pycache__/const.cpython-310.pyc
  46. BIN
      custom_components/hacs/__pycache__/diagnostics.cpython-310.pyc
  47. BIN
      custom_components/hacs/__pycache__/entity.cpython-310.pyc
  48. BIN
      custom_components/hacs/__pycache__/enums.cpython-310.pyc
  49. BIN
      custom_components/hacs/__pycache__/exceptions.cpython-310.pyc
  50. BIN
      custom_components/hacs/__pycache__/frontend.cpython-310.pyc
  51. BIN
      custom_components/hacs/__pycache__/sensor.cpython-310.pyc
  52. BIN
      custom_components/hacs/__pycache__/system_health.cpython-310.pyc
  53. 967 0
      custom_components/hacs/base.py
  54. 182 0
      custom_components/hacs/config_flow.py
  55. 289 0
      custom_components/hacs/const.py
  56. 82 0
      custom_components/hacs/diagnostics.py
  57. 119 0
      custom_components/hacs/entity.py
  58. 75 0
      custom_components/hacs/enums.py
  59. 49 0
      custom_components/hacs/exceptions.py
  60. 97 0
      custom_components/hacs/frontend.py
  61. 5 0
      custom_components/hacs/hacs_frontend/__init__.py
  62. BIN
      custom_components/hacs/hacs_frontend/__pycache__/__init__.cpython-310.pyc
  63. BIN
      custom_components/hacs/hacs_frontend/__pycache__/version.cpython-310.pyc
  64. 1 0
      custom_components/hacs/hacs_frontend/c.004a7b01.js
  65. BIN
      custom_components/hacs/hacs_frontend/c.004a7b01.js.gz
  66. 1 0
      custom_components/hacs/hacs_frontend/c.014b1a3b.js
  67. BIN
      custom_components/hacs/hacs_frontend/c.014b1a3b.js.gz
  68. 3940 0
      custom_components/hacs/hacs_frontend/c.07dde5c0.js
  69. BIN
      custom_components/hacs/hacs_frontend/c.07dde5c0.js.gz
  70. 83 0
      custom_components/hacs/hacs_frontend/c.09384688.js
  71. BIN
      custom_components/hacs/hacs_frontend/c.09384688.js.gz
  72. 1 0
      custom_components/hacs/hacs_frontend/c.10c7d0ce.js
  73. BIN
      custom_components/hacs/hacs_frontend/c.10c7d0ce.js.gz
  74. 30 0
      custom_components/hacs/hacs_frontend/c.138a5fae.js
  75. BIN
      custom_components/hacs/hacs_frontend/c.138a5fae.js.gz
  76. 178 0
      custom_components/hacs/hacs_frontend/c.167d87ac.js
  77. BIN
      custom_components/hacs/hacs_frontend/c.167d87ac.js.gz
  78. 1 0
      custom_components/hacs/hacs_frontend/c.21c042d4.js
  79. 1 0
      custom_components/hacs/hacs_frontend/c.24bd2446.js
  80. BIN
      custom_components/hacs/hacs_frontend/c.24bd2446.js.gz
  81. 7 0
      custom_components/hacs/hacs_frontend/c.28c2a1ee.js
  82. BIN
      custom_components/hacs/hacs_frontend/c.28c2a1ee.js.gz
  83. 14 0
      custom_components/hacs/hacs_frontend/c.3f8082e4.js
  84. BIN
      custom_components/hacs/hacs_frontend/c.3f8082e4.js.gz
  85. 163 0
      custom_components/hacs/hacs_frontend/c.476721bc.js
  86. BIN
      custom_components/hacs/hacs_frontend/c.476721bc.js.gz
  87. 7 0
      custom_components/hacs/hacs_frontend/c.48057b49.js
  88. BIN
      custom_components/hacs/hacs_frontend/c.48057b49.js.gz
  89. 41 0
      custom_components/hacs/hacs_frontend/c.497c36cc.js
  90. BIN
      custom_components/hacs/hacs_frontend/c.497c36cc.js.gz
  91. 1 0
      custom_components/hacs/hacs_frontend/c.4a97632a.js
  92. BIN
      custom_components/hacs/hacs_frontend/c.4a97632a.js.gz
  93. 74 0
      custom_components/hacs/hacs_frontend/c.4d0a19ff.js
  94. BIN
      custom_components/hacs/hacs_frontend/c.4d0a19ff.js.gz
  95. 1 0
      custom_components/hacs/hacs_frontend/c.50bfd408.js
  96. BIN
      custom_components/hacs/hacs_frontend/c.50bfd408.js.gz
  97. 1 0
      custom_components/hacs/hacs_frontend/c.50ff9066.js
  98. BIN
      custom_components/hacs/hacs_frontend/c.50ff9066.js.gz
  99. 51 0
      custom_components/hacs/hacs_frontend/c.51d1da7b.js
  100. 0 0
      custom_components/hacs/hacs_frontend/c.51d1da7b.js.gz

+ 1 - 0
addons.yaml

@@ -0,0 +1 @@
+

+ 1 - 0
automations.yaml

@@ -0,0 +1 @@
+[]

+ 54 - 0
blueprints/automation/homeassistant/motion_light.yaml

@@ -0,0 +1,54 @@
+blueprint:
+  name: Motion-activated Light
+  description: Turn on a light when motion is detected.
+  domain: automation
+  source_url: https://github.com/home-assistant/core/blob/dev/homeassistant/components/automation/blueprints/motion_light.yaml
+  input:
+    motion_entity:
+      name: Motion Sensor
+      selector:
+        entity:
+          domain: binary_sensor
+          device_class: motion
+    light_target:
+      name: Light
+      selector:
+        target:
+          entity:
+            domain: light
+    no_motion_wait:
+      name: Wait time
+      description: Time to leave the light on after last motion is detected.
+      default: 120
+      selector:
+        number:
+          min: 0
+          max: 3600
+          unit_of_measurement: seconds
+
+# If motion is detected within the delay,
+# we restart the script.
+mode: restart
+max_exceeded: silent
+
+trigger:
+  platform: state
+  entity_id: !input motion_entity
+  from: "off"
+  to: "on"
+
+action:
+  - alias: "Turn on the light"
+    service: light.turn_on
+    target: !input light_target
+  - alias: "Wait until there is no motion from device"
+    wait_for_trigger:
+      platform: state
+      entity_id: !input motion_entity
+      from: "on"
+      to: "off"
+  - alias: "Wait the number of seconds that has been set"
+    delay: !input no_motion_wait
+  - alias: "Turn off the light"
+    service: light.turn_off
+    target: !input light_target

+ 44 - 0
blueprints/automation/homeassistant/notify_leaving_zone.yaml

@@ -0,0 +1,44 @@
+blueprint:
+  name: Zone Notification
+  description: Send a notification to a device when a person leaves a specific zone.
+  domain: automation
+  source_url: https://github.com/home-assistant/core/blob/dev/homeassistant/components/automation/blueprints/notify_leaving_zone.yaml
+  input:
+    person_entity:
+      name: Person
+      selector:
+        entity:
+          domain: person
+    zone_entity:
+      name: Zone
+      selector:
+        entity:
+          domain: zone
+    notify_device:
+      name: Device to notify
+      description: Device needs to run the official Home Assistant app to receive notifications.
+      selector:
+        device:
+          integration: mobile_app
+
+trigger:
+  platform: state
+  entity_id: !input person_entity
+
+variables:
+  zone_entity: !input zone_entity
+  # This is the state of the person when it's in this zone.
+  zone_state: "{{ states[zone_entity].name }}"
+  person_entity: !input person_entity
+  person_name: "{{ states[person_entity].name }}"
+
+condition:
+  condition: template
+  value_template: "{{ trigger.from_state.state == zone_state and trigger.to_state.state != zone_state }}"
+
+action:
+  - alias: "Notify that a person has left the zone"
+    domain: mobile_app
+    type: notify
+    device_id: !input notify_device
+    message: "{{ person_name }} has left {{ zone_state }}"

+ 84 - 0
blueprints/script/homeassistant/confirmable_notification.yaml

@@ -0,0 +1,84 @@
+blueprint:
+  name: Confirmable Notification
+  description: >-
+    A script that sends an actionable notification with a confirmation before
+    running the specified action.
+  domain: script
+  source_url: https://github.com/home-assistant/core/blob/master/homeassistant/components/script/blueprints/confirmable_notification.yaml
+  input:
+    notify_device:
+      name: Device to notify
+      description: Device needs to run the official Home Assistant app to receive notifications.
+      selector:
+        device:
+          integration: mobile_app
+    title:
+      name: "Title"
+      description: "The title of the button shown in the notification."
+      default: ""
+      selector:
+        text:
+    message:
+      name: "Message"
+      description: "The message body"
+      selector:
+        text:
+    confirm_text:
+      name: "Confirmation Text"
+      description: "Text to show on the confirmation button"
+      default: "Confirm"
+      selector:
+        text:
+    confirm_action:
+      name: "Confirmation Action"
+      description: "Action to run when notification is confirmed"
+      default: []
+      selector:
+        action:
+    dismiss_text:
+      name: "Dismiss Text"
+      description: "Text to show on the dismiss button"
+      default: "Dismiss"
+      selector:
+        text:
+    dismiss_action:
+      name: "Dismiss Action"
+      description: "Action to run when notification is dismissed"
+      default: []
+      selector:
+        action:
+
+mode: restart
+
+sequence:
+  - alias: "Set up variables"
+    variables:
+      action_confirm: "{{ 'CONFIRM_' ~ context.id }}"
+      action_dismiss: "{{ 'DISMISS_' ~ context.id }}"
+  - alias: "Send notification"
+    domain: mobile_app
+    type: notify
+    device_id: !input notify_device
+    title: !input title
+    message: !input message
+    data:
+      actions:
+        - action: "{{ action_confirm }}"
+          title: !input confirm_text
+        - action: "{{ action_dismiss }}"
+          title: !input dismiss_text
+  - alias: "Awaiting response"
+    wait_for_trigger:
+      - platform: event
+        event_type: mobile_app_notification_action
+        event_data:
+          action: "{{ action_confirm }}"
+      - platform: event
+        event_type: mobile_app_notification_action
+        event_data:
+          action: "{{ action_dismiss }}"
+  - choose:
+      - conditions: "{{ wait.trigger.event.data.action == action_confirm }}"
+        sequence: !input confirm_action
+      - conditions: "{{ wait.trigger.event.data.action == action_dismiss }}"
+        sequence: !input dismiss_action

+ 86 - 0
configuration.yaml

@@ -0,0 +1,86 @@
+#Add ons
+#Add Container User Interfaces to Navigation Menu
+#Portainer
+panel_iframe:
+  portainer:
+    title: "Portainer"
+    url: "https://docker.metzner.myhome-server.de/#!/1/docker/containers"
+    icon: mdi:docker
+    require_admin: true
+  esphome:
+    title: "ESPHome"
+    url: "https://esph.metzner.myhome-server.de/"
+    icon: mdi:chip
+    require_admin: true
+
+# Configure a default setup of Home Assistant (frontend, api, etc)
+default_config:
+
+# Text to speech
+tts:
+  - platform: google_translate
+
+group: !include groups.yaml
+automation: !include automations.yaml
+script: !include scripts.yaml
+scene: !include scenes.yaml
+
+#Stop to record the history of some domains and entities
+recorder:
+#  auto_purge: true
+#  purge_keep_days: 5
+  exclude:
+    domains:
+      - updater
+      - media_player
+    entities:
+      - sensor.power_text
+      - sensor.total_energy_text
+
+# Home Assistant configuration.yaml
+template:
+  - sensor:
+      - name: "Total Energy Consumption"
+        unit_of_measurement: "kWh"
+        state: >
+          {% if states('sensor.total_energy_text') == 'unavailable' %}
+            {{ states('sensor.total_energy_consumption') }}
+          {% else %}
+            {{ ((states('sensor.total_energy_text') | float) * 0.0001) | round(2) }}
+          {% endif %}
+      
+      - name: "Current Power Consumption"
+        unit_of_measurement: "W"
+        state: >
+          {% if states('sensor.power_text') == 'unavailable' %}
+            {{ states('sensor.current_power_consumption') }}
+          {% else %}
+            {{ ((states('sensor.power_text') | float) * 0.01) | round(2) }}
+          {% endif %}
+
+# SONOFF S20_02
+mqtt:
+  switch:
+    - unique_id: studio_switch
+      name: "Studio Schalter"
+      state_topic: "/SmartHome/Sonoff/S20_02/stat/POWER1"
+      command_topic: "/SmartHome/Sonoff/S20_02/cmnd/power"
+      availability:
+        - topic: "/SmartHome/Sonoff/S20_02/tele/LWT"
+          payload_available: "Online"
+          payload_not_available: "Offline"
+      payload_on: "on"
+      payload_off: "off"
+      state_on: "ON"
+      state_off: "OFF"
+      optimistic: false
+      qos: 0
+      retain: true
+
+# Proxyconfig
+http:
+  use_x_forwarded_for: true
+  trusted_proxies:
+    - 172.20.0.3
+  ip_ban_enabled: true
+  login_attempts_threshold: 3

+ 119 - 0
custom_components/config_editor/__init__.py

@@ -0,0 +1,119 @@
+import logging
+import os
+import voluptuous as vol
+from homeassistant.components import websocket_api
+from atomicwrites import AtomicWriter
+
+DOMAIN = 'config_editor'
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup(hass, config):
+    hass.components.websocket_api.async_register_command(websocket_create)
+    hass.states.async_set(DOMAIN+".version", 4)
+    return True
+
+
+@websocket_api.require_admin
+@websocket_api.async_response
+@websocket_api.websocket_command(
+    {
+        vol.Required("type"): DOMAIN+"/ws",
+        vol.Required("action"): str,
+        vol.Required("file"): str,
+        vol.Required("data"): str,
+        vol.Required("ext"): str,
+        vol.Optional("depth", default=2): int
+    }
+)
+async def websocket_create(hass, connection, msg):
+    action = msg["action"]
+    ext = msg["ext"]
+    if ext not in ["yaml","py","json","conf","js","txt","log","css","all"]:
+        ext = "yaml"
+
+    def extok(e):
+        if len(e)<2:
+            return False
+        return ( ext == 'all' or e.endswith("."+ext) )
+
+    def rec(p, q):
+        r = [
+            f for f in os.listdir(p) if os.path.isfile(os.path.join(p, f)) and
+            extok(f)
+        ]
+        for j in r:
+            p = j if q == '' else os.path.join(q, j)
+            listyaml.append(p)
+
+    def drec(r, s):
+        for d in os.listdir(r):
+            v = os.path.join(r, d)
+            if os.path.isdir(v):
+                p = d if s == '' else os.path.join(s, d)
+                if(p.count(os.sep) < msg["depth"]) and ( ext == 'all' or p != 'custom_components' ):
+                    rec(v, p)
+                    drec(v, p)
+
+    yamlname = msg["file"].replace("../", "/").strip('/')
+
+    if not extok(msg["file"]):
+        yamlname = "temptest."+ext
+        
+    fullpath = hass.config.path(yamlname)
+    if (action == 'load'):
+        _LOGGER.info('Loading '+fullpath)
+        content = ''
+        res = 'Loaded'
+        try:
+            with open(fullpath, encoding="utf-8") as fdesc:
+                content = fdesc.read()
+        except:
+            res = 'Reading Failed'
+            _LOGGER.exception("Reading failed: %s", fullpath)
+        finally:
+            connection.send_result(
+                msg["id"],
+                {'msg': res+': '+fullpath, 'file': yamlname, 'data': content, 'ext': ext}
+            )
+
+    elif (action == 'save'):
+        _LOGGER.info('Saving '+fullpath)
+        content = msg["data"]
+        res = "Saved"
+        try:
+            dirnm = os.path.dirname(fullpath)
+            if not os.path.isdir(dirnm):
+                os.makedirs(dirnm, exist_ok=True)
+            try:
+                mode = os.stat(fullpath).st_mode
+            except:
+                mode = 0o666
+            with AtomicWriter(fullpath, overwrite=True).open() as fdesc:
+                fdesc.write(content)
+            with open(fullpath, 'a') as fdesc:
+                try:
+                    os.fchmod(fdesc.fileno(), mode)
+                except:
+                    pass
+        except:
+            res = "Saving Failed"
+            _LOGGER.exception(res+": %s", fullpath)
+        finally:
+            connection.send_result(
+                msg["id"],
+                {'msg': res+': '+fullpath}
+            )
+
+    elif (action == 'list'):
+        dirnm = os.path.dirname(hass.config.path(yamlname))
+        listyaml = []
+        rec(dirnm, '')
+        if msg["depth"]>0:
+            drec(dirnm, '')
+        if (len(listyaml) < 1):
+            listyaml = ['list_error.'+ext]
+        connection.send_result(
+            msg["id"],
+            {'msg': str(len(listyaml))+' File(s)', 'file': listyaml, 'ext': ext}
+        )

+ 9 - 0
custom_components/config_editor/manifest.json

@@ -0,0 +1,9 @@
+{
+	"domain": "config_editor",
+	"name": "Config Editor",
+	"version": "4.1",
+	"codeowners": ["@htmltiger"],
+	"documentation": "https://github.com/htmltiger/config-editor-card",
+	"issue_tracker": "https://github.com/htmltiger/config-editor/issues",
+	"iot_class": "local_polling"
+}

+ 106 - 0
custom_components/gardena_smart_system/__init__.py

@@ -0,0 +1,106 @@
+"""Support for Gardena Smart System devices."""
+import asyncio
+import logging
+
+from gardena.exceptions.authentication_exception import AuthenticationException
+from gardena.smart_system import SmartSystem
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import (
+    CONF_CLIENT_ID,
+    CONF_CLIENT_SECRET,
+    EVENT_HOMEASSISTANT_STOP,
+)
+from homeassistant.core import HomeAssistant
+from oauthlib.oauth2.rfc6749.errors import (
+    AccessDeniedError,
+    InvalidClientError,
+    MissingTokenError,
+)
+
+from .const import (
+    DOMAIN,
+    GARDENA_LOCATION,
+    GARDENA_SYSTEM,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORMS = ["vacuum", "sensor", "switch", "binary_sensor"]
+
+
+async def async_setup(hass: HomeAssistant, config: dict):
+    """Set up the Gardena Smart System integration."""
+
+    if DOMAIN not in hass.data:
+        hass.data[DOMAIN] = {}
+    return True
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+    _LOGGER.debug("Setting up Gardena Smart System component")
+
+    gardena_system = GardenaSmartSystem(
+        hass,
+        client_id=entry.data[CONF_CLIENT_ID],
+        client_secret=entry.data[CONF_CLIENT_SECRET],
+    )
+
+    try:
+        await gardena_system.start()
+    except AccessDeniedError as ex:
+        _LOGGER.error('Got Access Denied Error when setting up Gardena Smart System: %s', ex)
+        return False
+    except InvalidClientError as ex:
+        _LOGGER.error('Got Invalid Client Error when setting up Gardena Smart System: %s', ex)
+        return False
+    except MissingTokenError as ex:
+        _LOGGER.error('Got Missing Token Error when setting up Gardena Smart System: %s', ex)
+        return False
+
+    hass.data[DOMAIN][GARDENA_SYSTEM] = gardena_system
+
+    hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, lambda event: hass.async_create_task(gardena_system.stop()))
+
+    for component in PLATFORMS:
+        hass.async_create_task(
+            hass.config_entries.async_forward_entry_setup(entry, component))
+
+    _LOGGER.debug("Gardena Smart System component setup finished")
+    return True
+
+
+class GardenaSmartSystem:
+    """A Gardena Smart System wrapper class."""
+
+    def __init__(self, hass, client_id, client_secret):
+        """Initialize the Gardena Smart System."""
+        self._hass = hass
+        self.smart_system = SmartSystem(
+            client_id=client_id,
+            client_secret=client_secret)
+
+    async def start(self):
+        try:
+            _LOGGER.debug("Starting GardenaSmartSystem")
+            await self.smart_system.authenticate()
+            await self.smart_system.update_locations()
+
+            if len(self.smart_system.locations) < 1:
+                _LOGGER.error("No locations found")
+                raise Exception("No locations found")
+
+            # currently gardena supports only one location and gateway, so we can take the first
+            location = list(self.smart_system.locations.values())[0]
+            _LOGGER.debug(f"Using location: {location.name} ({location.id})")
+            await self.smart_system.update_devices(location)
+            self._hass.data[DOMAIN][GARDENA_LOCATION] = location
+            _LOGGER.debug("Starting GardenaSmartSystem websocket")
+            asyncio.create_task(self.smart_system.start_ws(self._hass.data[DOMAIN][GARDENA_LOCATION]))
+            _LOGGER.debug("Websocket thread launched !")
+        except AuthenticationException as ex:
+            _LOGGER.error(
+                f"Authentication failed : {ex.message}. You may need to check your token or create a new app in the gardena api and use the new token.")
+
+    async def stop(self):
+        _LOGGER.debug("Stopping GardenaSmartSystem")
+        await self.smart_system.quit()

BIN
custom_components/gardena_smart_system/__pycache__/__init__.cpython-310.pyc


BIN
custom_components/gardena_smart_system/__pycache__/__init__.cpython-39.pyc


BIN
custom_components/gardena_smart_system/__pycache__/binary_sensor.cpython-310.pyc


BIN
custom_components/gardena_smart_system/__pycache__/binary_sensor.cpython-39.pyc


BIN
custom_components/gardena_smart_system/__pycache__/config_flow.cpython-310.pyc


BIN
custom_components/gardena_smart_system/__pycache__/config_flow.cpython-39.pyc


BIN
custom_components/gardena_smart_system/__pycache__/const.cpython-310.pyc


BIN
custom_components/gardena_smart_system/__pycache__/const.cpython-39.pyc


BIN
custom_components/gardena_smart_system/__pycache__/sensor.cpython-310.pyc


BIN
custom_components/gardena_smart_system/__pycache__/sensor.cpython-39.pyc


BIN
custom_components/gardena_smart_system/__pycache__/switch.cpython-310.pyc


BIN
custom_components/gardena_smart_system/__pycache__/switch.cpython-39.pyc


BIN
custom_components/gardena_smart_system/__pycache__/vacuum.cpython-310.pyc


BIN
custom_components/gardena_smart_system/__pycache__/vacuum.cpython-39.pyc


+ 57 - 0
custom_components/gardena_smart_system/binary_sensor.py

@@ -0,0 +1,57 @@
+"""Support for Gardena Smart System websocket connection status."""
+from homeassistant.components.binary_sensor import (
+    DEVICE_CLASS_CONNECTIVITY,
+    BinarySensorEntity,
+)
+
+from custom_components.gardena_smart_system import GARDENA_SYSTEM
+from .const import DOMAIN
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+    """Perform the setup for Gardena websocket connection status."""
+    async_add_entities([SmartSystemWebsocketStatus(hass.data[DOMAIN][GARDENA_SYSTEM].smart_system)], True)
+
+
+class SmartSystemWebsocketStatus(BinarySensorEntity):
+    """Representation of Gardena Smart System websocket connection status."""
+
+    def __init__(self, smart_system) -> None:
+        """Initialize the binary sensor."""
+        super().__init__()
+        self._unique_id = "smart_gardena_websocket_status"
+        self._name = "Gardena Smart System connection"
+        self._smart_system = smart_system
+
+    async def async_added_to_hass(self):
+        """Subscribe to events."""
+        self._smart_system.add_ws_status_callback(self.update_callback)
+
+    @property
+    def name(self):
+        """Return the name of the device."""
+        return self._name
+
+    @property
+    def unique_id(self) -> str:
+        """Return a unique ID."""
+        return self._unique_id
+
+    @property
+    def is_on(self) -> bool:
+        """Return the status of the sensor."""
+        return self._smart_system.is_ws_connected
+
+    @property
+    def should_poll(self) -> bool:
+        """No polling needed for a sensor."""
+        return False
+
+    def update_callback(self, status):
+        """Call update for Home Assistant when the device is updated."""
+        self.schedule_update_ha_state(True)
+
+    @property
+    def device_class(self):
+        """Return the class of this device, from component DEVICE_CLASSES."""
+        return DEVICE_CLASS_CONNECTIVITY

+ 126 - 0
custom_components/gardena_smart_system/config_flow.py

@@ -0,0 +1,126 @@
+"""Config flow for Gardena integration."""
+import logging
+from collections import OrderedDict
+
+import homeassistant.helpers.config_validation as cv
+import voluptuous as vol
+from gardena.smart_system import SmartSystem
+from homeassistant import config_entries
+from homeassistant.const import (
+    CONF_CLIENT_ID,
+    CONF_CLIENT_SECRET,
+    CONF_ID,
+)
+from homeassistant.core import callback
+
+from .const import (
+    DOMAIN,
+    CONF_MOWER_DURATION,
+    CONF_SMART_IRRIGATION_DURATION,
+    CONF_SMART_WATERING_DURATION,
+    DEFAULT_MOWER_DURATION,
+    DEFAULT_SMART_IRRIGATION_DURATION,
+    DEFAULT_SMART_WATERING_DURATION,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_OPTIONS = {
+    CONF_MOWER_DURATION: DEFAULT_MOWER_DURATION,
+    CONF_SMART_IRRIGATION_DURATION: DEFAULT_SMART_IRRIGATION_DURATION,
+    CONF_SMART_WATERING_DURATION: DEFAULT_SMART_WATERING_DURATION,
+}
+
+
+class GardenaSmartSystemConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
+    """Handle a config flow for Gardena."""
+
+    VERSION = 1
+    CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH
+
+    async def _show_setup_form(self, errors=None):
+        """Show the setup form to the user."""
+        errors = {}
+
+        fields = OrderedDict()
+        fields[vol.Required(CONF_CLIENT_ID)] = str
+        fields[vol.Required(CONF_CLIENT_SECRET)] = str
+
+        return self.async_show_form(
+            step_id="user", data_schema=vol.Schema(fields), errors=errors
+        )
+
+    async def async_step_user(self, user_input=None):
+        """Handle the initial step."""
+        if user_input is None:
+            return await self._show_setup_form()
+
+        errors = {}
+        # try:
+        #     await try_connection(
+        #         user_input[CONF_CLIENT_ID],
+        #         user_input[CONF_CLIENT_SECRET])
+        # except Exception:  # pylint: disable=broad-except
+        #     _LOGGER.exception("Unexpected exception")
+        #     errors["base"] = "unknown"
+        #     return await self._show_setup_form(errors)
+
+        unique_id = user_input[CONF_CLIENT_ID]
+
+        await self.async_set_unique_id(unique_id)
+        self._abort_if_unique_id_configured()
+
+        return self.async_create_entry(
+            title="",
+            data={
+                CONF_ID: unique_id,
+                CONF_CLIENT_ID: user_input[CONF_CLIENT_ID],
+                CONF_CLIENT_SECRET: user_input[CONF_CLIENT_SECRET]
+            })
+
+    @staticmethod
+    @callback
+    def async_get_options_flow(config_entry):
+        return GardenaSmartSystemOptionsFlowHandler(config_entry)
+
+
+class GardenaSmartSystemOptionsFlowHandler(config_entries.OptionsFlow):
+    def __init__(self, config_entry):
+        """Initialize Gardena Smart Sytem 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."""
+        errors = {}
+        if user_input is not None:
+            # TODO: Validate options (min, max values)
+            return self.async_create_entry(title="", data=user_input)
+
+        fields = OrderedDict()
+        fields[vol.Optional(
+            CONF_MOWER_DURATION,
+            default=self.config_entry.options.get(
+                CONF_MOWER_DURATION, DEFAULT_MOWER_DURATION))] = cv.positive_int
+        fields[vol.Optional(
+            CONF_SMART_IRRIGATION_DURATION,
+            default=self.config_entry.options.get(
+                CONF_SMART_IRRIGATION_DURATION, DEFAULT_SMART_IRRIGATION_DURATION))] = cv.positive_int
+        fields[vol.Optional(
+            CONF_SMART_WATERING_DURATION,
+            default=self.config_entry.options.get(
+                CONF_SMART_WATERING_DURATION, DEFAULT_SMART_WATERING_DURATION))] = cv.positive_int
+
+        return self.async_show_form(step_id="user", data_schema=vol.Schema(fields), errors=errors)
+
+
+async def try_connection(client_id, client_secret):
+    _LOGGER.debug("Trying to connect to Gardena during setup")
+    smart_system = SmartSystem(client_id=client_id, client_secret=client_secret)
+    await smart_system.authenticate()
+    await smart_system.update_locations()
+    await smart_system.quit()
+    _LOGGER.debug("Successfully connected to Gardena during setup")

+ 22 - 0
custom_components/gardena_smart_system/const.py

@@ -0,0 +1,22 @@
+DOMAIN = "gardena_smart_system"
+GARDENA_SYSTEM = "gardena_system"
+GARDENA_LOCATION = "gardena_location"
+
+CONF_MOWER_DURATION = "mower_duration"
+CONF_SMART_IRRIGATION_DURATION = "smart_irrigation_control_duration"
+CONF_SMART_WATERING_DURATION = "smart_watering_duration"
+
+DEFAULT_MOWER_DURATION = 60
+DEFAULT_SMART_IRRIGATION_DURATION = 30
+DEFAULT_SMART_WATERING_DURATION = 30
+
+ATTR_NAME = "name"
+ATTR_ACTIVITY = "activity"
+ATTR_BATTERY_STATE = "battery_state"
+ATTR_RF_LINK_LEVEL = "rf_link_level"
+ATTR_RF_LINK_STATE = "rf_link_state"
+ATTR_SERIAL = "serial"
+ATTR_OPERATING_HOURS = "operating_hours"
+ATTR_LAST_ERROR = "last_error"
+ATTR_ERROR = "error"
+ATTR_STATE = "state"

+ 11 - 0
custom_components/gardena_smart_system/manifest.json

@@ -0,0 +1,11 @@
+{
+  "domain": "gardena_smart_system",
+  "name": "Gardena Smart System integration",
+  "version": "1.0.0",
+  "config_flow": true,
+  "documentation": "https://github.com/py-smart-gardena/hass-gardena-smart-system",
+  "issue_tracker": "https://github.com/py-smart-gardena/hass-gardena-smart-system/issues",
+  "dependencies": [],
+  "codeowners": ["@py-smart-gardena"],
+  "requirements": ["py-smart-gardena==1.3.6"]
+}

+ 138 - 0
custom_components/gardena_smart_system/sensor.py

@@ -0,0 +1,138 @@
+"""Support for Gardena Smart System sensors."""
+import logging
+
+from homeassistant.const import (
+    DEVICE_CLASS_HUMIDITY,
+    DEVICE_CLASS_ILLUMINANCE,
+    DEVICE_CLASS_TEMPERATURE,
+    TEMP_CELSIUS,
+)
+from homeassistant.helpers.entity import Entity
+from homeassistant.core import callback
+from homeassistant.const import (
+    ATTR_BATTERY_LEVEL,
+    DEVICE_CLASS_BATTERY,
+    PERCENTAGE,
+)
+from homeassistant.helpers.typing import ConfigType, HomeAssistantType
+
+from .const import (
+    DOMAIN,
+    ATTR_BATTERY_STATE,
+    ATTR_RF_LINK_LEVEL,
+    ATTR_RF_LINK_STATE,
+    ATTR_SERIAL,
+    GARDENA_LOCATION,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+SOIL_SENSOR_TYPES = {
+    "soil_temperature": [TEMP_CELSIUS, "mdi:thermometer", DEVICE_CLASS_TEMPERATURE],
+    "soil_humidity": ["%", "mdi:water-percent", DEVICE_CLASS_HUMIDITY],
+    ATTR_BATTERY_LEVEL: [PERCENTAGE, "mdi:battery", DEVICE_CLASS_BATTERY],
+}
+
+SENSOR_TYPES = {**{
+    "ambient_temperature": [TEMP_CELSIUS, "mdi:thermometer", DEVICE_CLASS_TEMPERATURE],
+    "light_intensity": ["lx", None, DEVICE_CLASS_ILLUMINANCE],
+}, **SOIL_SENSOR_TYPES}
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+    """Perform the setup for Gardena sensor devices."""
+    entities = []
+    for sensor in hass.data[DOMAIN][GARDENA_LOCATION].find_device_by_type("SENSOR"):
+        for sensor_type in SENSOR_TYPES:
+            entities.append(GardenaSensor(sensor, sensor_type))
+
+    for sensor in hass.data[DOMAIN][GARDENA_LOCATION].find_device_by_type("SOIL_SENSOR"):
+        for sensor_type in SOIL_SENSOR_TYPES:
+            entities.append(GardenaSensor(sensor, sensor_type))
+
+    for mower in hass.data[DOMAIN][GARDENA_LOCATION].find_device_by_type("MOWER"):
+        # Add battery sensor for mower
+        entities.append(GardenaSensor(mower, ATTR_BATTERY_LEVEL))
+
+    for water_control in hass.data[DOMAIN][GARDENA_LOCATION].find_device_by_type("WATER_CONTROL"):
+        # Add battery sensor for water control
+        entities.append(GardenaSensor(water_control, ATTR_BATTERY_LEVEL))
+    _LOGGER.debug("Adding sensor as sensor %s", entities)
+    async_add_entities(entities, True)
+
+
+class GardenaSensor(Entity):
+    """Representation of a Gardena Sensor."""
+
+    def __init__(self, device, sensor_type):
+        """Initialize the Gardena Sensor."""
+        self._sensor_type = sensor_type
+        self._name = f"{device.name} {sensor_type.replace('_', ' ')}"
+        self._unique_id = f"{device.serial}-{sensor_type}"
+        self._device = device
+
+    async def async_added_to_hass(self):
+        """Subscribe to sensor events."""
+        self._device.add_callback(self.update_callback)
+
+    @property
+    def should_poll(self) -> bool:
+        """No polling needed for a sensor."""
+        return False
+
+    def update_callback(self, device):
+        """Call update for Home Assistant when the device is updated."""
+        self.schedule_update_ha_state(True)
+
+    @property
+    def name(self):
+        """Return the name of the device."""
+        return self._name
+
+    @property
+    def unique_id(self) -> str:
+        """Return a unique ID."""
+        return self._unique_id
+
+    @property
+    def icon(self):
+        """Return the icon to use in the frontend."""
+        return SENSOR_TYPES[self._sensor_type][1]
+
+    @property
+    def unit_of_measurement(self):
+        """Return the unit of measurement of this entity, if any."""
+        return SENSOR_TYPES[self._sensor_type][0]
+
+    @property
+    def device_class(self):
+        """Return the device class of this entity."""
+        if self._sensor_type in SENSOR_TYPES:
+            return SENSOR_TYPES[self._sensor_type][2]
+        return None
+
+    @property
+    def state(self):
+        """Return the state of the sensor."""
+        return getattr(self._device, self._sensor_type)
+
+    @property
+    def extra_state_attributes(self):
+        """Return the state attributes of the sensor."""
+        return {
+            ATTR_BATTERY_LEVEL: self._device.battery_level,
+            ATTR_BATTERY_STATE: self._device.battery_state,
+            ATTR_RF_LINK_LEVEL: self._device.rf_link_level,
+            ATTR_RF_LINK_STATE: self._device.rf_link_state,
+        }
+
+    @property
+    def device_info(self):
+        return {
+            "identifiers": {
+                # Serial numbers are unique identifiers within a specific domain
+                (DOMAIN, self._device.serial)
+            },
+            "name": self._device.name,
+            "manufacturer": "Gardena",
+            "model": self._device.model_type,
+        }

+ 35 - 0
custom_components/gardena_smart_system/strings.json

@@ -0,0 +1,35 @@
+{
+  "config": {
+    "abort": {
+      "already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
+    },
+    "error": {
+      "cannot_connect": "Failed to connect, please try again.",
+      "invalid_auth": "Invalid authentication.",
+      "too_many_requests": "Too many requests, retry later.",
+      "unknown": "Unexpected error."
+    },
+    "step": {
+      "user": {
+        "description": "Please enter your credentials.",
+        "data": {
+          "client_id": "Application Key / Client ID",
+          "client_secret": "Application secret / Client secret"
+        },
+        "title": "Gardena Smart System"
+      }
+    }
+  },
+  "options": {
+    "step": {
+      "user": {
+        "data": {
+          "mower_duration": "Mower Duration (minutes)",
+          "smart_irrigation_control_duration": "Smart Irrigation Control Duration (minutes)",
+          "smart_watering_duration": "Smart Watering Duration (minutes)"
+        },
+        "title": "Gardena Smart System - Options"
+      }
+    }
+  }
+}

+ 381 - 0
custom_components/gardena_smart_system/switch.py

@@ -0,0 +1,381 @@
+"""Support for Gardena switch (Power control, water control, smart irrigation control)."""
+import asyncio
+import logging
+
+from homeassistant.core import callback
+from homeassistant.components.switch import SwitchEntity
+from homeassistant.const import ATTR_BATTERY_LEVEL
+
+from .const import (
+    ATTR_ACTIVITY,
+    ATTR_BATTERY_STATE,
+    ATTR_LAST_ERROR,
+    ATTR_RF_LINK_LEVEL,
+    ATTR_RF_LINK_STATE,
+    ATTR_SERIAL,
+    CONF_SMART_IRRIGATION_DURATION,
+    CONF_SMART_WATERING_DURATION,
+    DEFAULT_SMART_IRRIGATION_DURATION,
+    DEFAULT_SMART_WATERING_DURATION,
+    DOMAIN,
+    GARDENA_LOCATION,
+)
+from .sensor import GardenaSensor
+
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+    """Set up the switches platform."""
+
+    entities = []
+    for water_control in hass.data[DOMAIN][GARDENA_LOCATION].find_device_by_type("WATER_CONTROL"):
+        entities.append(GardenaSmartWaterControl(water_control, config_entry.options))
+
+    for power_switch in hass.data[DOMAIN][GARDENA_LOCATION].find_device_by_type("POWER_SOCKET"):
+        entities.append(GardenaPowerSocket(power_switch))
+
+    for smart_irrigation in hass.data[DOMAIN][GARDENA_LOCATION].find_device_by_type("SMART_IRRIGATION_CONTROL"):
+        for valve in smart_irrigation.valves.values():
+            entities.append(GardenaSmartIrrigationControl(
+                smart_irrigation, valve['id'], config_entry.options))
+
+    _LOGGER.debug(
+        "Adding water control, power socket and smart irrigation control as switch: %s",
+        entities)
+    async_add_entities(entities, True)
+
+
+class GardenaSmartWaterControl(SwitchEntity):
+    """Representation of a Gardena Smart Water Control."""
+
+    def __init__(self, wc, options):
+        """Initialize the Gardena Smart Water Control."""
+        self._device = wc
+        self._options = options
+        self._name = f"{self._device.name}"
+        self._unique_id = f"{self._device.serial}-valve"
+        self._state = None
+        self._error_message = ""
+
+    async def async_added_to_hass(self):
+        """Subscribe to events."""
+        self._device.add_callback(self.update_callback)
+
+    @property
+    def should_poll(self) -> bool:
+        """No polling needed for a water valve."""
+        return False
+
+    def update_callback(self, device):
+        """Call update for Home Assistant when the device is updated."""
+        self.schedule_update_ha_state(True)
+
+    async def async_update(self):
+        """Update the states of Gardena devices."""
+        _LOGGER.debug("Running Gardena update")
+        # Managing state
+        state = self._device.valve_state
+        _LOGGER.debug("Water control has state %s", state)
+        if state in ["WARNING", "ERROR", "UNAVAILABLE"]:
+            _LOGGER.debug("Water control has an error")
+            self._state = False
+            self._error_message = self._device.last_error_code
+        else:
+            _LOGGER.debug("Getting water control state")
+            activity = self._device.valve_activity
+            self._error_message = ""
+            _LOGGER.debug("Water control has activity %s", activity)
+            if activity == "CLOSED":
+                self._state = False
+            elif activity in ["MANUAL_WATERING", "SCHEDULED_WATERING"]:
+                self._state = True
+            else:
+                _LOGGER.debug("Water control has none activity")
+
+    @property
+    def name(self):
+        """Return the name of the device."""
+        return self._name
+
+    @property
+    def unique_id(self) -> str:
+        """Return a unique ID."""
+        return self._unique_id
+
+    @property
+    def is_on(self):
+        """Return true if it is on."""
+        return self._state
+
+    @property
+    def available(self):
+        """Return True if the device is available."""
+        return self._device.valve_state != "UNAVAILABLE"
+
+    def error(self):
+        """Return the error message."""
+        return self._error_message
+
+    @property
+    def extra_state_attributes(self):
+        """Return the state attributes of the water valve."""
+        return {
+            ATTR_ACTIVITY: self._device.valve_activity,
+            ATTR_BATTERY_LEVEL: self._device.battery_level,
+            ATTR_BATTERY_STATE: self._device.battery_state,
+            ATTR_RF_LINK_LEVEL: self._device.rf_link_level,
+            ATTR_RF_LINK_STATE: self._device.rf_link_state,
+            ATTR_LAST_ERROR: self._error_message,
+        }
+
+    @property
+    def option_smart_watering_duration(self) -> int:
+        return self._options.get(
+            CONF_SMART_WATERING_DURATION, DEFAULT_SMART_WATERING_DURATION
+        )
+
+    def turn_on(self, **kwargs):
+        """Start watering."""
+        duration = self.option_smart_watering_duration * 60
+        return asyncio.run_coroutine_threadsafe(
+            self._device.start_seconds_to_override(duration), self.hass.loop
+        ).result()
+
+    def turn_off(self, **kwargs):
+        """Stop watering."""
+        return asyncio.run_coroutine_threadsafe(
+            self._device.stop_until_next_task(), self.hass.loop
+        ).result()
+
+    @property
+    def device_info(self):
+        return {
+            "identifiers": {
+                # Serial numbers are unique identifiers within a specific domain
+                (DOMAIN, self._device.serial)
+            },
+            "name": self._device.name,
+            "manufacturer": "Gardena",
+            "model": self._device.model_type,
+        }
+
+
+class GardenaPowerSocket(SwitchEntity):
+    """Representation of a Gardena Power Socket."""
+
+    def __init__(self, ps):
+        """Initialize the Gardena Power Socket."""
+        self._device = ps
+        self._name = f"{self._device.name}"
+        self._unique_id = f"{self._device.serial}"
+        self._state = None
+        self._error_message = ""
+
+    async def async_added_to_hass(self):
+        """Subscribe to events."""
+        self._device.add_callback(self.update_callback)
+
+    @property
+    def should_poll(self) -> bool:
+        """No polling needed for a power socket."""
+        return False
+
+    def update_callback(self, device):
+        """Call update for Home Assistant when the device is updated."""
+        self.schedule_update_ha_state(True)
+
+    async def async_update(self):
+        """Update the states of Gardena devices."""
+        _LOGGER.debug("Running Gardena update")
+        # Managing state
+        state = self._device.state
+        _LOGGER.debug("Power socket has state %s", state)
+        if state in ["WARNING", "ERROR", "UNAVAILABLE"]:
+            _LOGGER.debug("Power socket has an error")
+            self._state = False
+            self._error_message = self._device.last_error_code
+        else:
+            _LOGGER.debug("Getting Power socket state")
+            activity = self._device.activity
+            self._error_message = ""
+            _LOGGER.debug("Power socket has activity %s", activity)
+            if activity == "OFF":
+                self._state = False
+            elif activity in ["FOREVER_ON", "TIME_LIMITED_ON", "SCHEDULED_ON"]:
+                self._state = True
+            else:
+                _LOGGER.debug("Power socket has none activity")
+
+    @property
+    def name(self):
+        """Return the name of the device."""
+        return self._name
+
+    @property
+    def unique_id(self) -> str:
+        """Return a unique ID."""
+        return self._unique_id
+
+    @property
+    def is_on(self):
+        """Return true if it is on."""
+        return self._state
+
+    @property
+    def available(self):
+        """Return True if the device is available."""
+        return self._device.state != "UNAVAILABLE"
+
+    def error(self):
+        """Return the error message."""
+        return self._error_message
+
+    @property
+    def extra_state_attributes(self):
+        """Return the state attributes of the power switch."""
+        return {
+            ATTR_ACTIVITY: self._device.activity,
+            ATTR_RF_LINK_LEVEL: self._device.rf_link_level,
+            ATTR_RF_LINK_STATE: self._device.rf_link_state,
+            ATTR_LAST_ERROR: self._error_message,
+        }
+
+    def turn_on(self, **kwargs):
+        """Start watering."""
+        return asyncio.run_coroutine_threadsafe(
+            self._device.start_override(), self.hass.loop
+        ).result()
+
+    def turn_off(self, **kwargs):
+        """Stop watering."""
+        return asyncio.run_coroutine_threadsafe(
+            self._device.stop_until_next_task(), self.hass.loop
+        ).result()
+
+    @property
+    def device_info(self):
+        return {
+            "identifiers": {
+                # Serial numbers are unique identifiers within a specific domain
+                (DOMAIN, self._device.serial)
+            },
+            "name": self._device.name,
+            "manufacturer": "Gardena",
+            "model": self._device.model_type,
+        }
+
+
+class GardenaSmartIrrigationControl(SwitchEntity):
+    """Representation of a Gardena Smart Irrigation Control."""
+
+    def __init__(self, sic, valve_id, options):
+        """Initialize the Gardena Smart Irrigation Control."""
+        self._device = sic
+        self._valve_id = valve_id
+        self._options = options
+        self._name = f"{self._device.name} - {self._device.valves[self._valve_id]['name']}"
+        self._unique_id = f"{self._device.serial}-{self._valve_id}"
+        self._state = None
+        self._error_message = ""
+
+    async def async_added_to_hass(self):
+        """Subscribe to events."""
+        self._device.add_callback(self.update_callback)
+
+    @property
+    def should_poll(self) -> bool:
+        """No polling needed for a smart irrigation control."""
+        return False
+
+    def update_callback(self, device):
+        """Call update for Home Assistant when the device is updated."""
+        self.schedule_update_ha_state(True)
+
+    async def async_update(self):
+        """Update the states of Gardena devices."""
+        _LOGGER.debug("Running Gardena update")
+        # Managing state
+        valve = self._device.valves[self._valve_id]
+        _LOGGER.debug("Valve has state: %s", valve["state"])
+        if valve["state"] in ["WARNING", "ERROR", "UNAVAILABLE"]:
+            _LOGGER.debug("Valve has an error")
+            self._state = False
+            self._error_message = valve["last_error_code"]
+        else:
+            _LOGGER.debug("Getting Valve state")
+            activity = valve["activity"]
+            self._error_message = ""
+            _LOGGER.debug("Valve has activity: %s", activity)
+            if activity == "CLOSED":
+                self._state = False
+            elif activity in ["MANUAL_WATERING", "SCHEDULED_WATERING"]:
+                self._state = True
+            else:
+                _LOGGER.debug("Valve has unknown activity")
+
+    @property
+    def name(self):
+        """Return the name of the device."""
+        return self._name
+
+    @property
+    def unique_id(self) -> str:
+        """Return a unique ID."""
+        return self._unique_id
+
+    @property
+    def is_on(self):
+        """Return true if it is on."""
+        return self._state
+
+    @property
+    def available(self):
+        """Return True if the device is available."""
+        return self._device.valves[self._valve_id]["state"] != "UNAVAILABLE"
+
+    def error(self):
+        """Return the error message."""
+        return self._error_message
+
+    @property
+    def extra_state_attributes(self):
+        """Return the state attributes of the smart irrigation control."""
+        return {
+            ATTR_ACTIVITY: self._device.valves[self._valve_id]["activity"],
+            ATTR_RF_LINK_LEVEL: self._device.rf_link_level,
+            ATTR_RF_LINK_STATE: self._device.rf_link_state,
+            ATTR_LAST_ERROR: self._error_message,
+        }
+
+    @property
+    def option_smart_irrigation_duration(self) -> int:
+        return self._options.get(
+            CONF_SMART_IRRIGATION_DURATION, DEFAULT_SMART_IRRIGATION_DURATION
+        )
+
+    def turn_on(self, **kwargs):
+        """Start watering."""
+        duration = self.option_smart_irrigation_duration * 60
+        return asyncio.run_coroutine_threadsafe(
+            self._device.start_seconds_to_override(duration, self._valve_id), self.hass.loop
+        ).result()
+
+    def turn_off(self, **kwargs):
+        """Stop watering."""
+        return asyncio.run_coroutine_threadsafe(
+            self._device.stop_until_next_task(self._valve_id), self.hass.loop
+        ).result()
+
+    @property
+    def device_info(self):
+        return {
+            "identifiers": {
+                # Serial numbers are unique identifiers within a specific domain
+                (DOMAIN, self._device.serial)
+            },
+            "name": self._device.name,
+            "manufacturer": "Gardena",
+            "model": self._device.model_type,
+        }

+ 36 - 0
custom_components/gardena_smart_system/translations/de.json

@@ -0,0 +1,36 @@
+{
+  "config": {
+    "abort": {
+      "already_configured": "Der Standort ist bereits konfiguriert"
+    },
+    "error": {
+      "cannot_connect": "Verbindung fehlgeschlagen, versuchen Sie es erneut.",
+      "invalid_auth": "Ungültige Authentifizierung.",
+      "too_many_requests": "Zu viele Anfragen, versuchen Sie es später erneut.",
+      "unknown": "Unerwarteter Fehler."
+    },
+    "step": {
+      "user": {
+        "description": "Bitte geben Sie Ihre Anmeldeinformationen ein.",
+        "data": {
+          "email": "E-Mail",
+          "password": "Passwort",
+          "client_id": "Application Key / Client ID"
+        },
+        "title": "Gardena Smart System"
+      }
+    }
+  },
+  "options": {
+    "step": {
+      "user": {
+        "data": {
+          "mower_duration": "Mäherdauer (Minuten)",
+          "smart_irrigation_control_duration": "Smart Irrigation Control Dauer (Minuten)",
+          "smart_watering_duration": "Smarte Bewässerungs Dauer (Minuten)"
+        },
+        "title": "Gardena Smart System - Optionen"
+      }
+    }
+  }
+}

+ 36 - 0
custom_components/gardena_smart_system/translations/en.json

@@ -0,0 +1,36 @@
+{
+  "config": {
+    "abort": {
+      "already_configured": "Location is already configured"
+    },
+    "error": {
+      "cannot_connect": "Failed to connect, please try again.",
+      "invalid_auth": "Invalid authentication.",
+      "too_many_requests": "Too many requests, retry later.",
+      "unknown": "Unexpected error."
+    },
+    "step": {
+      "user": {
+        "description": "Please enter your credentials.",
+        "data": {
+          "email": "E-mail",
+          "password": "Password",
+          "client_id": "Application Key / Client ID"
+        },
+        "title": "Gardena Smart System"
+      }
+    }
+  },
+  "options": {
+    "step": {
+      "user": {
+        "data": {
+          "mower_duration": "Mower Duration (minutes)",
+          "smart_irrigation_control_duration": "Smart Irrigation Control Duration (minutes)",
+          "smart_watering_duration": "Smart Watering Duration (minutes)"
+        },
+        "title": "Gardena Smart System - Options"
+      }
+    }
+  }
+}

+ 36 - 0
custom_components/gardena_smart_system/translations/fr.json

@@ -0,0 +1,36 @@
+{
+  "config": {
+    "abort": {
+      "already_configured": "La localisation est déjà configurée"
+    },
+    "error": {
+      "cannot_connect": "Impossible de se connecter, veuillez ré-essayer.",
+      "invalid_auth": "Authentification invalide.",
+      "too_many_requests": "Trop de requêtes, veuillez ré-essayer plus tard.",
+      "unknown": "Erreur innatendue."
+    },
+    "step": {
+      "user": {
+        "description": "Veuillez renseigner vos identifiants.",
+        "data": {
+          "email": "E-mail",
+          "password": "Mot de passe",
+          "client_id": "Application Key / Client ID"
+        },
+        "title": "Gardena Smart System"
+      }
+    }
+  },
+  "options": {
+    "step": {
+      "user": {
+        "data": {
+          "mower_duration": "Durée de tonte (minutes)",
+          "smart_irrigation_control_duration": "Durée de Smart Irrigation Control (minutes)",
+          "smart_watering_duration": "Durée de Smart Watering (minutes)"
+        },
+        "title": "Gardena Smart System - Options"
+      }
+    }
+  }
+}

+ 36 - 0
custom_components/gardena_smart_system/translations/nb.json

@@ -0,0 +1,36 @@
+{
+  "config": {
+    "abort": {
+      "already_configured": "Plasseringen er allerede konfigurert"
+    },
+    "error": {
+      "cannot_connect": "Kunne ikke koble til, prøv igjen.",
+      "invalid_auth": "Ugyldig godkjenning",
+      "too_many_requests": "For mange forespørsler, prøv senere.",
+      "unknown": "Uventet feil."
+    },
+    "step": {
+      "user": {
+        "description": "Vennligst skriv inn legitimasjonen din.",
+        "data": {
+          "email": "E-post",
+          "password": "Passord",
+          "client_id": "Programnøkkel/klient-ID"
+        },
+        "title": "Gardena Smart System"
+      }
+    }
+  },
+  "options": {
+    "step": {
+      "user": {
+        "data": {
+          "mower_duration": "Klippevarighet (minutter)",
+          "smart_irrigation_control_duration": "Smart vanningskontrollvarighet (minutter)",
+          "smart_watering_duration": "Smart vanningstid (minutter)"
+        },
+        "title": "Gardena Smart System - Alternativer"
+      }
+    }
+  }
+}

+ 36 - 0
custom_components/gardena_smart_system/translations/nl.json

@@ -0,0 +1,36 @@
+{
+  "config": {
+    "abort": {
+      "already_configured": "Locatie is reeds geconfigureerd."
+    },
+    "error": {
+      "cannot_connect": "Probleem tijdens connecteren, probeer het later opnieuw.",
+      "invalid_auth": "Foutieve inloggegevens.",
+      "too_many_requests": "Te veel aanvragen, probeer het later opnieuw.",
+      "unknown": "Onverwachte fout."
+    },
+    "step": {
+      "user": {
+        "description": "Gelieve aan te melden.",
+        "data": {
+          "email": "E-mail",
+          "password": "Wachtwoord",
+          "client_id": "Application Key / Client ID"
+        },
+        "title": "Gardena Smart System"
+      }
+    }
+  },
+  "options": {
+    "step": {
+      "user": {
+        "data": {
+          "mower_duration": "Maaitijd (minuten)",
+          "smart_irrigation_control_duration": "Smart Irrigation Control tijd (minuten)",
+          "smart_watering_duration": "Smart Watering tijd (minuten)"
+        },
+        "title": "Gardena Smart System - Opties"
+      }
+    }
+  }
+}

+ 29 - 0
custom_components/gardena_smart_system/translations/vacuum.da.json

@@ -0,0 +1,29 @@
+{
+    "device_automation": {
+        "action_type": {
+            "clean": "Lad {entity_name} klippe",
+            "dock": "Lad {entity_name} vende tilbage til oplader"
+        },
+        "condition_type": {
+            "is_cleaning": "{entity_name} klipper",
+            "is_docked": "{entity_name} er i oplader"
+        },
+        "trigger_type": {
+            "cleaning": "{entity_name} begyndte at klippe",
+            "docked": "{entity_name} er i dock"
+        }
+    },
+    "state": {
+        "_": {
+            "cleaning": "Klipper",
+            "docked": "I oplader",
+            "error": "Fejl",
+            "idle": "[%key:common::state::idle%]",
+            "off": "[%key:common::state::off%]",
+            "on": "[%key:common::state::on%]",
+            "paused": "Sat p\u00e5 pause",
+             "returning": "Vender tilbage til oplader"
+        }
+    },
+    "title": "Pl\u00e6neklipper"
+}

+ 29 - 0
custom_components/gardena_smart_system/translations/vacuum.de.json

@@ -0,0 +1,29 @@
+{
+    "title": "Mäher",
+    "device_automation": {
+      "condition_type": {
+        "is_docked": "{entity_name} ist angedockt",
+        "is_cleaning": "{entity_name} mäht"
+      },
+      "trigger_type": {
+        "cleaning": "{entity_name} hat begonnen zu mähen",
+        "docked": "{entity_name} hat angedockt"
+      },
+      "action_type": {
+        "clean": "Lasse {entity_name} mähen",
+        "dock": "Lasse {entity_name} zum Dock zurückkehren"
+      }
+    },
+    "state": {
+      "_": {
+        "cleaning": "Mäht",
+        "docked": "Angedockt",
+        "error": "Fehler",
+        "idle": "[%key:common::state::idle%]",
+        "off": "[%key:common::state::off%]",
+        "on": "[%key:common::state::on%]",
+        "paused": "[%key:common::state::paused%]",
+        "returning": "Zurück zum Dock"
+      }
+    }
+  }

+ 29 - 0
custom_components/gardena_smart_system/translations/vacuum.en.json

@@ -0,0 +1,29 @@
+{
+    "title": "Mower",
+    "device_automation": {
+      "condition_type": {
+        "is_docked": "{entity_name} is docked",
+        "is_cleaning": "{entity_name} is cutting"
+      },
+      "trigger_type": {
+        "cleaning": "{entity_name} started cutting",
+        "docked": "{entity_name} docked"
+      },
+      "action_type": {
+        "clean": "Let {entity_name} cut",
+        "dock": "Let {entity_name} return to the dock"
+      }
+    },
+    "state": {
+      "_": {
+        "cleaning": "Cutting",
+        "docked": "Docked",
+        "error": "Error",
+        "idle": "[%key:common::state::idle%]",
+        "off": "[%key:common::state::off%]",
+        "on": "[%key:common::state::on%]",
+        "paused": "[%key:common::state::paused%]",
+        "returning": "Returning to dock"
+      }
+    }
+  }

+ 29 - 0
custom_components/gardena_smart_system/translations/vacuum.nl.json

@@ -0,0 +1,29 @@
+{
+    "title": "Maaier",
+    "device_automation": {
+      "condition_type": {
+        "is_docked": "{entity_name} is geparkeerd",
+        "is_cleaning": "{entity_name} is aan het maaien"
+      },
+      "trigger_type": {
+        "cleaning": "{entity_name} is gestart met maaien",
+        "docked": "{entity_name} parkeert"
+      },
+      "action_type": {
+        "clean": "Laat {entity_name} maaien",
+        "dock": "Laat {entity_name} parkeren"
+      }
+    },
+    "state": {
+      "_": {
+        "cleaning": "Maaien",
+        "docked": "Geparkeerd",
+        "error": "Fout",
+        "idle": "[%key:common::state::idle%]",
+        "off": "[%key:common::state::off%]",
+        "on": "[%key:common::state::on%]",
+        "paused": "[%key:common::state::paused%]",
+        "returning": "Terug naar laadstation aan het gaan"
+      }
+    }
+  }

+ 222 - 0
custom_components/gardena_smart_system/vacuum.py

@@ -0,0 +1,222 @@
+"""Support for Gardena mower."""
+import asyncio
+import logging
+from datetime import timedelta
+
+from homeassistant.core import callback
+from homeassistant.components.vacuum import (
+    StateVacuumEntity,
+    SUPPORT_BATTERY,
+    SUPPORT_RETURN_HOME,
+    SUPPORT_STATE,
+    SUPPORT_STOP,
+    SUPPORT_START,
+    STATE_PAUSED,
+    STATE_CLEANING,
+    STATE_DOCKED,
+    STATE_RETURNING,
+    STATE_ERROR,
+    ATTR_BATTERY_LEVEL,
+)
+
+from .const import (
+    ATTR_ACTIVITY,
+    ATTR_BATTERY_STATE,
+    ATTR_NAME,
+    ATTR_OPERATING_HOURS,
+    ATTR_RF_LINK_LEVEL,
+    ATTR_RF_LINK_STATE,
+    ATTR_SERIAL,
+    ATTR_LAST_ERROR,
+    ATTR_ERROR,
+    ATTR_STATE,
+    CONF_MOWER_DURATION,
+    DEFAULT_MOWER_DURATION,
+    DOMAIN,
+    GARDENA_LOCATION,
+)
+from .sensor import GardenaSensor
+
+
+_LOGGER = logging.getLogger(__name__)
+
+SCAN_INTERVAL = timedelta(minutes=1)
+
+SUPPORT_GARDENA = (
+    SUPPORT_BATTERY | SUPPORT_RETURN_HOME | SUPPORT_STOP | SUPPORT_START | SUPPORT_STATE
+)
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+    """Set up the Gardena smart mower system."""
+    entities = []
+    for mower in hass.data[DOMAIN][GARDENA_LOCATION].find_device_by_type("MOWER"):
+        entities.append(GardenaSmartMower(hass, mower, config_entry.options))
+
+    _LOGGER.debug("Adding mower as vacuums: %s", entities)
+    async_add_entities(entities, True)
+
+
+class GardenaSmartMower(StateVacuumEntity):
+    """Representation of a Gardena Connected Mower."""
+
+    def __init__(self, hass, mower, options):
+        """Initialize the Gardena Connected Mower."""
+        self.hass = hass
+        self._device = mower
+        self._options = options
+        self._name = "{}".format(self._device.name)
+        self._unique_id = f"{self._device.serial}-mower"
+        self._state = None
+        self._error_message = ""
+
+    async def async_added_to_hass(self):
+        """Subscribe to events."""
+        self._device.add_callback(self.update_callback)
+
+    @property
+    def should_poll(self) -> bool:
+        """No polling needed for a vacuum."""
+        return False
+
+    def update_callback(self, device):
+        """Call update for Home Assistant when the device is updated."""
+        self.schedule_update_ha_state(True)
+
+    async def async_update(self):
+        """Update the states of Gardena devices."""
+        _LOGGER.debug("Running Gardena update")
+        # Managing state
+        state = self._device.state
+        _LOGGER.debug("Mower has state %s", state)
+        if state in ["WARNING", "ERROR", "UNAVAILABLE"]:
+            _LOGGER.debug("Mower has an error")
+            self._state = STATE_ERROR
+            self._error_message = self._device.last_error_code
+        else:
+            _LOGGER.debug("Getting mower state")
+            activity = self._device.activity
+            _LOGGER.debug("Mower has activity %s", activity)
+            if activity == "PAUSED":
+                self._state = STATE_PAUSED
+            elif activity in [
+                "OK_CUTTING",
+                "OK_CUTTING_TIMER_OVERRIDDEN",
+                "OK_LEAVING",
+            ]:
+                self._state = STATE_CLEANING
+            elif activity == "OK_SEARCHING":
+                self._state = STATE_RETURNING
+            elif activity in [
+                "OK_CHARGING",
+                "PARKED_TIMER",
+                "PARKED_PARK_SELECTED",
+                "PARKED_AUTOTIMER",
+            ]:
+                self._state = STATE_DOCKED
+            elif activity == "NONE":
+                self._state = None
+                _LOGGER.debug("Mower has no activity")
+
+    @property
+    def name(self):
+        """Return the name of the device."""
+        return self._device.name
+
+    @property
+    def supported_features(self):
+        """Flag lawn mower robot features that are supported."""
+        return SUPPORT_GARDENA
+
+    @property
+    def battery_level(self):
+        """Return the battery level of the lawn mower."""
+        return self._device.battery_level
+
+    @property
+    def state(self):
+        """Return the status of the lawn mower."""
+        return self._state
+
+    @property
+    def available(self):
+        """Return True if the device is available."""
+        return self._device.state != "UNAVAILABLE"
+
+    def error(self):
+        """Return the error message."""
+        if self._state == STATE_ERROR:
+            return self._error_message
+        return ""
+
+    @property
+    def extra_state_attributes(self):
+        """Return the state attributes of the lawn mower."""
+        return {
+            ATTR_ACTIVITY: self._device.activity,
+            ATTR_BATTERY_LEVEL: self._device.battery_level,
+            ATTR_BATTERY_STATE: self._device.battery_state,
+            ATTR_RF_LINK_LEVEL: self._device.rf_link_level,
+            ATTR_RF_LINK_STATE: self._device.rf_link_state,
+            ATTR_OPERATING_HOURS: self._device.operating_hours,
+            ATTR_LAST_ERROR: self._device.last_error_code,
+            ATTR_ERROR: "NONE" if self._device.activity != "NONE" else self._device.last_error_code,
+            ATTR_STATE: self._device.activity if self._device.activity != "NONE" else self._device.last_error_code
+        }
+
+    @property
+    def option_mower_duration(self) -> int:
+        return self._options.get(CONF_MOWER_DURATION, DEFAULT_MOWER_DURATION)
+
+    def start(self):
+        """Start the mower using Gardena API command START_SECONDS_TO_OVERRIDE. Duration is read from integration options."""
+        duration = self.option_mower_duration * 60
+        _LOGGER.debug("Mower command:  vacuum.start => START_SECONDS_TO_OVERRIDE, %s", duration)
+        return asyncio.run_coroutine_threadsafe(
+            self._device.start_seconds_to_override(duration), self.hass.loop
+        ).result()
+
+    def stop(self, **kwargs):
+        """Stop the mower using Gardena API command PARK_UNTIL_FURTHER_NOTICE."""
+        _LOGGER.debug("Mower command:  vacuum.stop => PARK_UNTIL_FURTHER_NOTICE")
+        asyncio.run_coroutine_threadsafe(
+            self._device.park_until_further_notice(), self.hass.loop
+        ).result()
+
+    def turn_on(self, **kwargs):
+        """Start the mower using Gardena API command START_DONT_OVERRIDE."""
+        _LOGGER.debug("Mower command:  vacuum.turn_on => START_DONT_OVERRIDE")
+        asyncio.run_coroutine_threadsafe(
+            self._device.start_dont_override(), self.hass.loop
+        ).result()
+
+    def turn_off(self, **kwargs):
+        """Stop the mower using Gardena API command PARK_UNTIL_FURTHER_NOTICE."""
+        _LOGGER.debug("Mower command:  vacuum.turn_off => PARK_UNTIL_FURTHER_NOTICE")
+        asyncio.run_coroutine_threadsafe(
+            self._device.park_until_further_notice(), self.hass.loop
+        ).result()
+
+    def return_to_base(self, **kwargs):
+        """Stop the mower using Gardena API command PARK_UNTIL_NEXT_TASK."""
+        _LOGGER.debug("Mower command:  vacuum.return_to_base => PARK_UNTIL_NEXT_TASK")
+        asyncio.run_coroutine_threadsafe(
+            self._device.park_until_next_task(), self.hass.loop
+        ).result()
+
+    @property
+    def unique_id(self) -> str:
+        """Return a unique ID."""
+        return self._unique_id
+
+    @property
+    def device_info(self):
+        return {
+            "identifiers": {
+                # Serial numbers are unique identifiers within a specific domain
+                (DOMAIN, self._device.serial)
+            },
+            "name": self._device.name,
+            "manufacturer": "Gardena",
+            "model": self._device.model_type,
+        }

+ 263 - 0
custom_components/hacs/__init__.py

@@ -0,0 +1,263 @@
+"""
+HACS gives you a powerful UI to handle downloads of all your custom needs.
+
+For more details about this integration, please refer to the documentation at
+https://hacs.xyz/
+"""
+from __future__ import annotations
+
+import os
+from typing import Any
+
+from aiogithubapi import AIOGitHubAPIException, GitHub, GitHubAPI
+from aiogithubapi.const import ACCEPT_HEADERS
+from awesomeversion import AwesomeVersion
+from homeassistant.components.lovelace.system_health import system_health_info
+from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
+from homeassistant.const import Platform, __version__ as HAVERSION
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.discovery import async_load_platform
+from homeassistant.helpers.event import async_call_later
+from homeassistant.helpers.start import async_at_start
+from homeassistant.loader import async_get_integration
+import voluptuous as vol
+
+from .base import HacsBase
+from .const import DOMAIN, MINIMUM_HA_VERSION, STARTUP
+from .enums import ConfigurationType, HacsDisabledReason, HacsStage, LovelaceMode
+from .frontend import async_register_frontend
+from .utils.configuration_schema import hacs_config_combined
+from .utils.data import HacsData
+from .utils.queue_manager import QueueManager
+from .utils.version import version_left_higher_or_equal_then_right
+from .websocket import async_register_websocket_commands
+
+CONFIG_SCHEMA = vol.Schema({DOMAIN: hacs_config_combined()}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_initialize_integration(
+    hass: HomeAssistant,
+    *,
+    config_entry: ConfigEntry | None = None,
+    config: dict[str, Any] | None = None,
+) -> bool:
+    """Initialize the integration"""
+    hass.data[DOMAIN] = hacs = HacsBase()
+    hacs.enable_hacs()
+
+    if config is not None:
+        if DOMAIN not in config:
+            return True
+        if hacs.configuration.config_type == ConfigurationType.CONFIG_ENTRY:
+            return True
+        hacs.configuration.update_from_dict(
+            {
+                "config_type": ConfigurationType.YAML,
+                **config[DOMAIN],
+                "config": config[DOMAIN],
+            }
+        )
+
+    if config_entry is not None:
+        if config_entry.source == SOURCE_IMPORT:
+            hass.async_create_task(hass.config_entries.async_remove(config_entry.entry_id))
+            return False
+
+        hacs.configuration.update_from_dict(
+            {
+                "config_entry": config_entry,
+                "config_type": ConfigurationType.CONFIG_ENTRY,
+                **config_entry.data,
+                **config_entry.options,
+            }
+        )
+
+    integration = await async_get_integration(hass, DOMAIN)
+
+    hacs.set_stage(None)
+
+    hacs.log.info(STARTUP, integration.version)
+
+    clientsession = async_get_clientsession(hass)
+
+    hacs.integration = integration
+    hacs.version = integration.version
+    hacs.configuration.dev = integration.version == "0.0.0"
+    hacs.hass = hass
+    hacs.queue = QueueManager(hass=hass)
+    hacs.data = HacsData(hacs=hacs)
+    hacs.system.running = True
+    hacs.session = clientsession
+
+    hacs.core.lovelace_mode = LovelaceMode.YAML
+    try:
+        lovelace_info = await system_health_info(hacs.hass)
+        hacs.core.lovelace_mode = LovelaceMode(lovelace_info.get("mode", "yaml"))
+    except BaseException:  # lgtm [py/catch-base-exception] pylint: disable=broad-except
+        # If this happens, the users YAML is not valid, we assume YAML mode
+        pass
+    hacs.log.debug("Configuration type: %s", hacs.configuration.config_type)
+    hacs.core.config_path = hacs.hass.config.path()
+
+    if hacs.core.ha_version is None:
+        hacs.core.ha_version = AwesomeVersion(HAVERSION)
+
+    ## Legacy GitHub client
+    hacs.github = GitHub(
+        hacs.configuration.token,
+        clientsession,
+        headers={
+            "User-Agent": f"HACS/{hacs.version}",
+            "Accept": ACCEPT_HEADERS["preview"],
+        },
+    )
+
+    ## New GitHub client
+    hacs.githubapi = GitHubAPI(
+        token=hacs.configuration.token,
+        session=clientsession,
+        **{"client_name": f"HACS/{hacs.version}"},
+    )
+
+    async def async_startup():
+        """HACS startup tasks."""
+        hacs.enable_hacs()
+
+        for location in (
+            hass.config.path("custom_components/custom_updater.py"),
+            hass.config.path("custom_components/custom_updater/__init__.py"),
+        ):
+            if os.path.exists(location):
+                hacs.log.critical(
+                    "This cannot be used with custom_updater. "
+                    "To use this you need to remove custom_updater form %s",
+                    location,
+                )
+
+                hacs.disable_hacs(HacsDisabledReason.CONSTRAINS)
+                return False
+
+        if not version_left_higher_or_equal_then_right(
+            hacs.core.ha_version.string,
+            MINIMUM_HA_VERSION,
+        ):
+            hacs.log.critical(
+                "You need HA version %s or newer to use this integration.",
+                MINIMUM_HA_VERSION,
+            )
+            hacs.disable_hacs(HacsDisabledReason.CONSTRAINS)
+            return False
+
+        if not await hacs.data.restore():
+            hacs.disable_hacs(HacsDisabledReason.RESTORE)
+            return False
+
+        can_update = await hacs.async_can_update()
+        hacs.log.debug("Can update %s repositories", can_update)
+
+        hacs.set_active_categories()
+
+        async_register_websocket_commands(hass)
+        async_register_frontend(hass, hacs)
+
+        if hacs.configuration.config_type == ConfigurationType.YAML:
+            hass.async_create_task(
+                async_load_platform(hass, Platform.SENSOR, DOMAIN, {}, hacs.configuration.config)
+            )
+            hacs.log.info("Update entities are only supported when using UI configuration")
+
+        else:
+            hass.config_entries.async_setup_platforms(
+                config_entry,
+                [Platform.SENSOR, Platform.UPDATE]
+                if hacs.configuration.experimental
+                else [Platform.SENSOR],
+            )
+
+        hacs.set_stage(HacsStage.SETUP)
+        if hacs.system.disabled:
+            return False
+
+        # Schedule startup tasks
+        async_at_start(hass=hass, at_start_cb=hacs.startup_tasks)
+
+        hacs.set_stage(HacsStage.WAITING)
+        hacs.log.info("Setup complete, waiting for Home Assistant before startup tasks starts")
+
+        return not hacs.system.disabled
+
+    async def async_try_startup(_=None):
+        """Startup wrapper for yaml config."""
+        try:
+            startup_result = await async_startup()
+        except AIOGitHubAPIException:
+            startup_result = False
+        if not startup_result:
+            if (
+                hacs.configuration.config_type == ConfigurationType.YAML
+                or hacs.system.disabled_reason != HacsDisabledReason.INVALID_TOKEN
+            ):
+                hacs.log.info("Could not setup HACS, trying again in 15 min")
+                async_call_later(hass, 900, async_try_startup)
+            return
+        hacs.enable_hacs()
+
+    await async_try_startup()
+
+    # Mischief managed!
+    return True
+
+
+async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
+    """Set up this integration using yaml."""
+    return await async_initialize_integration(hass=hass, config=config)
+
+
+async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+    """Set up this integration using UI."""
+    config_entry.async_on_unload(config_entry.add_update_listener(async_reload_entry))
+    setup_result = await async_initialize_integration(hass=hass, config_entry=config_entry)
+    hacs: HacsBase = hass.data[DOMAIN]
+    return setup_result and not hacs.system.disabled
+
+
+async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+    """Handle removal of an entry."""
+    hacs: HacsBase = hass.data[DOMAIN]
+
+    # Clear out pending queue
+    hacs.queue.clear()
+
+    for task in hacs.recuring_tasks:
+        # Cancel all pending tasks
+        task()
+
+    # Store data
+    await hacs.data.async_write(force=True)
+
+    try:
+        if hass.data.get("frontend_panels", {}).get("hacs"):
+            hacs.log.info("Removing sidepanel")
+            hass.components.frontend.async_remove_panel("hacs")
+    except AttributeError:
+        pass
+
+    platforms = ["sensor"]
+    if hacs.configuration.experimental:
+        platforms.append("update")
+
+    unload_ok = await hass.config_entries.async_unload_platforms(config_entry, platforms)
+
+    hacs.set_stage(None)
+    hacs.disable_hacs(HacsDisabledReason.REMOVED)
+
+    hass.data.pop(DOMAIN, None)
+
+    return unload_ok
+
+
+async def async_reload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
+    """Reload the HACS config entry."""
+    await async_unload_entry(hass, config_entry)
+    await async_setup_entry(hass, config_entry)

BIN
custom_components/hacs/__pycache__/__init__.cpython-310.pyc


BIN
custom_components/hacs/__pycache__/base.cpython-310.pyc


BIN
custom_components/hacs/__pycache__/config_flow.cpython-310.pyc


BIN
custom_components/hacs/__pycache__/const.cpython-310.pyc


BIN
custom_components/hacs/__pycache__/diagnostics.cpython-310.pyc


BIN
custom_components/hacs/__pycache__/entity.cpython-310.pyc


BIN
custom_components/hacs/__pycache__/enums.cpython-310.pyc


BIN
custom_components/hacs/__pycache__/exceptions.cpython-310.pyc


BIN
custom_components/hacs/__pycache__/frontend.cpython-310.pyc


BIN
custom_components/hacs/__pycache__/sensor.cpython-310.pyc


BIN
custom_components/hacs/__pycache__/system_health.cpython-310.pyc


+ 967 - 0
custom_components/hacs/base.py

@@ -0,0 +1,967 @@
+"""Base HACS class."""
+from __future__ import annotations
+
+import asyncio
+from dataclasses import asdict, dataclass, field
+from datetime import timedelta
+import gzip
+import logging
+import math
+import os
+import pathlib
+import shutil
+from typing import TYPE_CHECKING, Any, Awaitable, Callable
+
+from aiogithubapi import (
+    AIOGitHubAPIException,
+    GitHub,
+    GitHubAPI,
+    GitHubAuthenticationException,
+    GitHubException,
+    GitHubNotModifiedException,
+    GitHubRatelimitException,
+)
+from aiogithubapi.objects.repository import AIOGitHubAPIRepository
+from aiohttp.client import ClientSession, ClientTimeout
+from awesomeversion import AwesomeVersion
+from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE, Platform
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.loader import Integration
+from homeassistant.util import dt
+
+from .const import TV
+from .enums import (
+    ConfigurationType,
+    HacsCategory,
+    HacsDisabledReason,
+    HacsDispatchEvent,
+    HacsGitHubRepo,
+    HacsStage,
+    LovelaceMode,
+)
+from .exceptions import (
+    AddonRepositoryException,
+    HacsException,
+    HacsExecutionStillInProgress,
+    HacsExpectedException,
+    HacsRepositoryArchivedException,
+    HacsRepositoryExistException,
+    HomeAssistantCoreRepositoryException,
+)
+from .repositories import RERPOSITORY_CLASSES
+from .utils.decode import decode_content
+from .utils.json import json_loads
+from .utils.logger import LOGGER
+from .utils.queue_manager import QueueManager
+from .utils.store import async_load_from_store, async_save_to_store
+
+if TYPE_CHECKING:
+    from .repositories.base import HacsRepository
+    from .utils.data import HacsData
+    from .validate.manager import ValidationManager
+
+
+@dataclass
+class RemovedRepository:
+    """Removed repository."""
+
+    repository: str | None = None
+    reason: str | None = None
+    link: str | None = None
+    removal_type: str = None  # archived, not_compliant, critical, dev, broken
+    acknowledged: bool = False
+
+    def update_data(self, data: dict):
+        """Update data of the repository."""
+        for key in data:
+            if data[key] is None:
+                continue
+            if key in (
+                "reason",
+                "link",
+                "removal_type",
+                "acknowledged",
+            ):
+                self.__setattr__(key, data[key])
+
+    def to_json(self):
+        """Return a JSON representation of the data."""
+        return {
+            "repository": self.repository,
+            "reason": self.reason,
+            "link": self.link,
+            "removal_type": self.removal_type,
+            "acknowledged": self.acknowledged,
+        }
+
+
+@dataclass
+class HacsConfiguration:
+    """HacsConfiguration class."""
+
+    appdaemon_path: str = "appdaemon/apps/"
+    appdaemon: bool = False
+    config: dict[str, Any] = field(default_factory=dict)
+    config_entry: ConfigEntry | None = None
+    config_type: ConfigurationType | None = None
+    country: str = "ALL"
+    debug: bool = False
+    dev: bool = False
+    experimental: bool = False
+    frontend_repo_url: str = ""
+    frontend_repo: str = ""
+    netdaemon_path: str = "netdaemon/apps/"
+    netdaemon: bool = False
+    plugin_path: str = "www/community/"
+    python_script_path: str = "python_scripts/"
+    python_script: bool = False
+    release_limit: int = 5
+    sidepanel_icon: str = "hacs:hacs"
+    sidepanel_title: str = "HACS"
+    theme_path: str = "themes/"
+    theme: bool = False
+    token: str = None
+
+    def to_json(self) -> str:
+        """Return a json string."""
+        return asdict(self)
+
+    def update_from_dict(self, data: dict) -> None:
+        """Set attributes from dicts."""
+        if not isinstance(data, dict):
+            raise HacsException("Configuration is not valid.")
+
+        for key in data:
+            self.__setattr__(key, data[key])
+
+
+@dataclass
+class HacsCore:
+    """HACS Core info."""
+
+    config_path: pathlib.Path | None = None
+    ha_version: AwesomeVersion | None = None
+    lovelace_mode = LovelaceMode("yaml")
+
+
+@dataclass
+class HacsCommon:
+    """Common for HACS."""
+
+    categories: set[str] = field(default_factory=set)
+    renamed_repositories: dict[str, str] = field(default_factory=dict)
+    archived_repositories: list[str] = field(default_factory=list)
+    ignored_repositories: list[str] = field(default_factory=list)
+    skip: list[str] = field(default_factory=list)
+
+
+@dataclass
+class HacsStatus:
+    """HacsStatus."""
+
+    startup: bool = True
+    new: bool = False
+
+
+@dataclass
+class HacsSystem:
+    """HACS System info."""
+
+    disabled_reason: HacsDisabledReason | None = None
+    running: bool = False
+    stage = HacsStage.SETUP
+    action: bool = False
+
+    @property
+    def disabled(self) -> bool:
+        """Return if HACS is disabled."""
+        return self.disabled_reason is not None
+
+
+@dataclass
+class HacsRepositories:
+    """HACS Repositories."""
+
+    _default_repositories: set[str] = field(default_factory=set)
+    _repositories: list[HacsRepository] = field(default_factory=list)
+    _repositories_by_full_name: dict[str, str] = field(default_factory=dict)
+    _repositories_by_id: dict[str, str] = field(default_factory=dict)
+    _removed_repositories: list[RemovedRepository] = field(default_factory=list)
+
+    @property
+    def list_all(self) -> list[HacsRepository]:
+        """Return a list of repositories."""
+        return self._repositories
+
+    @property
+    def list_removed(self) -> list[RemovedRepository]:
+        """Return a list of removed repositories."""
+        return self._removed_repositories
+
+    @property
+    def list_downloaded(self) -> list[HacsRepository]:
+        """Return a list of downloaded repositories."""
+        return [repo for repo in self._repositories if repo.data.installed]
+
+    def register(self, repository: HacsRepository, default: bool = False) -> None:
+        """Register a repository."""
+        repo_id = str(repository.data.id)
+
+        if repo_id == "0":
+            return
+
+        if self.is_registered(repository_id=repo_id):
+            return
+
+        if repository not in self._repositories:
+            self._repositories.append(repository)
+
+        self._repositories_by_id[repo_id] = repository
+        self._repositories_by_full_name[repository.data.full_name_lower] = repository
+
+        if default:
+            self.mark_default(repository)
+
+    def unregister(self, repository: HacsRepository) -> None:
+        """Unregister a repository."""
+        repo_id = str(repository.data.id)
+
+        if repo_id == "0":
+            return
+
+        if not self.is_registered(repository_id=repo_id):
+            return
+
+        if self.is_default(repo_id):
+            self._default_repositories.remove(repo_id)
+
+        if repository in self._repositories:
+            self._repositories.remove(repository)
+
+        self._repositories_by_id.pop(repo_id, None)
+        self._repositories_by_full_name.pop(repository.data.full_name_lower, None)
+
+    def mark_default(self, repository: HacsRepository) -> None:
+        """Mark a repository as default."""
+        repo_id = str(repository.data.id)
+
+        if repo_id == "0":
+            return
+
+        if not self.is_registered(repository_id=repo_id):
+            return
+
+        self._default_repositories.add(repo_id)
+
+    def set_repository_id(self, repository, repo_id):
+        """Update a repository id."""
+        existing_repo_id = str(repository.data.id)
+        if existing_repo_id == repo_id:
+            return
+        if existing_repo_id != "0":
+            raise ValueError(
+                f"The repo id for {repository.data.full_name_lower} "
+                f"is already set to {existing_repo_id}"
+            )
+        repository.data.id = repo_id
+        self.register(repository)
+
+    def is_default(self, repository_id: str | None = None) -> bool:
+        """Check if a repository is default."""
+        if not repository_id:
+            return False
+        return repository_id in self._default_repositories
+
+    def is_registered(
+        self,
+        repository_id: str | None = None,
+        repository_full_name: str | None = None,
+    ) -> bool:
+        """Check if a repository is registered."""
+        if repository_id is not None:
+            return repository_id in self._repositories_by_id
+        if repository_full_name is not None:
+            return repository_full_name in self._repositories_by_full_name
+        return False
+
+    def is_downloaded(
+        self,
+        repository_id: str | None = None,
+        repository_full_name: str | None = None,
+    ) -> bool:
+        """Check if a repository is registered."""
+        if repository_id is not None:
+            repo = self.get_by_id(repository_id)
+        if repository_full_name is not None:
+            repo = self.get_by_full_name(repository_full_name)
+        if repo is None:
+            return False
+        return repo.data.installed
+
+    def get_by_id(self, repository_id: str | None) -> HacsRepository | None:
+        """Get repository by id."""
+        if not repository_id:
+            return None
+        return self._repositories_by_id.get(str(repository_id))
+
+    def get_by_full_name(self, repository_full_name: str | None) -> HacsRepository | None:
+        """Get repository by full name."""
+        if not repository_full_name:
+            return None
+        return self._repositories_by_full_name.get(repository_full_name.lower())
+
+    def is_removed(self, repository_full_name: str) -> bool:
+        """Check if a repository is removed."""
+        return repository_full_name in (
+            repository.repository for repository in self._removed_repositories
+        )
+
+    def removed_repository(self, repository_full_name: str) -> RemovedRepository:
+        """Get repository by full name."""
+        if self.is_removed(repository_full_name):
+            if removed := [
+                repository
+                for repository in self._removed_repositories
+                if repository.repository == repository_full_name
+            ]:
+                return removed[0]
+
+        removed = RemovedRepository(repository=repository_full_name)
+        self._removed_repositories.append(removed)
+        return removed
+
+
+class HacsBase:
+    """Base HACS class."""
+
+    common = HacsCommon()
+    configuration = HacsConfiguration()
+    core = HacsCore()
+    data: HacsData | None = None
+    frontend_version: str | None = None
+    github: GitHub | None = None
+    githubapi: GitHubAPI | None = None
+    hass: HomeAssistant | None = None
+    integration: Integration | None = None
+    log: logging.Logger = LOGGER
+    queue: QueueManager | None = None
+    recuring_tasks = []
+    repositories: HacsRepositories = HacsRepositories()
+    repository: AIOGitHubAPIRepository | None = None
+    session: ClientSession | None = None
+    stage: HacsStage | None = None
+    status = HacsStatus()
+    system = HacsSystem()
+    validation: ValidationManager | None = None
+    version: str | None = None
+
+    @property
+    def integration_dir(self) -> pathlib.Path:
+        """Return the HACS integration dir."""
+        return self.integration.file_path
+
+    def set_stage(self, stage: HacsStage | None) -> None:
+        """Set HACS stage."""
+        if stage and self.stage == stage:
+            return
+
+        self.stage = stage
+        if stage is not None:
+            self.log.info("Stage changed: %s", self.stage)
+            self.async_dispatch(HacsDispatchEvent.STAGE, {"stage": self.stage})
+
+    def disable_hacs(self, reason: HacsDisabledReason) -> None:
+        """Disable HACS."""
+        if self.system.disabled_reason == reason:
+            return
+
+        self.system.disabled_reason = reason
+        if reason != HacsDisabledReason.REMOVED:
+            self.log.error("HACS is disabled - %s", reason)
+
+        if (
+            reason == HacsDisabledReason.INVALID_TOKEN
+            and self.configuration.config_type == ConfigurationType.CONFIG_ENTRY
+        ):
+            self.configuration.config_entry.state = ConfigEntryState.SETUP_ERROR
+            self.configuration.config_entry.reason = "Authentication failed"
+            self.hass.add_job(self.configuration.config_entry.async_start_reauth, self.hass)
+
+    def enable_hacs(self) -> None:
+        """Enable HACS."""
+        if self.system.disabled_reason is not None:
+            self.system.disabled_reason = None
+            self.log.info("HACS is enabled")
+
+    def enable_hacs_category(self, category: HacsCategory) -> None:
+        """Enable HACS category."""
+        if category not in self.common.categories:
+            self.log.info("Enable category: %s", category)
+            self.common.categories.add(category)
+
+    def disable_hacs_category(self, category: HacsCategory) -> None:
+        """Disable HACS category."""
+        if category in self.common.categories:
+            self.log.info("Disabling category: %s", category)
+            self.common.categories.pop(category)
+
+    async def async_save_file(self, file_path: str, content: Any) -> bool:
+        """Save a file."""
+
+        def _write_file():
+            with open(
+                file_path,
+                mode="w" if isinstance(content, str) else "wb",
+                encoding="utf-8" if isinstance(content, str) else None,
+                errors="ignore" if isinstance(content, str) else None,
+            ) as file_handler:
+                file_handler.write(content)
+
+            # Create gz for .js files
+            if os.path.isfile(file_path):
+                if file_path.endswith(".js"):
+                    with open(file_path, "rb") as f_in:
+                        with gzip.open(file_path + ".gz", "wb") as f_out:
+                            shutil.copyfileobj(f_in, f_out)
+
+            # LEGACY! Remove with 2.0
+            if "themes" in file_path and file_path.endswith(".yaml"):
+                filename = file_path.split("/")[-1]
+                base = file_path.split("/themes/")[0]
+                combined = f"{base}/themes/{filename}"
+                if os.path.exists(combined):
+                    self.log.info("Removing old theme file %s", combined)
+                    os.remove(combined)
+
+        try:
+            await self.hass.async_add_executor_job(_write_file)
+        except BaseException as error:  # lgtm [py/catch-base-exception] pylint: disable=broad-except
+            self.log.error("Could not write data to %s - %s", file_path, error)
+            return False
+
+        return os.path.exists(file_path)
+
+    async def async_can_update(self) -> int:
+        """Helper to calculate the number of repositories we can fetch data for."""
+        try:
+            response = await self.async_github_api_method(self.githubapi.rate_limit)
+            if ((limit := response.data.resources.core.remaining or 0) - 1000) >= 10:
+                return math.floor((limit - 1000) / 10)
+            reset = dt.as_local(dt.utc_from_timestamp(response.data.resources.core.reset))
+            self.log.info(
+                "GitHub API ratelimited - %s remaining (%s)",
+                response.data.resources.core.remaining,
+                f"{reset.hour}:{reset.minute}:{reset.second}",
+            )
+            self.disable_hacs(HacsDisabledReason.RATE_LIMIT)
+        except BaseException as exception:  # lgtm [py/catch-base-exception] pylint: disable=broad-except
+            self.log.exception(exception)
+
+        return 0
+
+    async def async_github_get_hacs_default_file(self, filename: str) -> list:
+        """Get the content of a default file."""
+        response = await self.async_github_api_method(
+            method=self.githubapi.repos.contents.get,
+            repository=HacsGitHubRepo.DEFAULT,
+            path=filename,
+        )
+        if response is None:
+            return []
+
+        return json_loads(decode_content(response.data.content))
+
+    async def async_github_api_method(
+        self,
+        method: Callable[[], Awaitable[TV]],
+        *args,
+        raise_exception: bool = True,
+        **kwargs,
+    ) -> TV | None:
+        """Call a GitHub API method"""
+        _exception = None
+
+        try:
+            return await method(*args, **kwargs)
+        except GitHubAuthenticationException as exception:
+            self.disable_hacs(HacsDisabledReason.INVALID_TOKEN)
+            _exception = exception
+        except GitHubRatelimitException as exception:
+            self.disable_hacs(HacsDisabledReason.RATE_LIMIT)
+            _exception = exception
+        except GitHubNotModifiedException as exception:
+            raise exception
+        except GitHubException as exception:
+            _exception = exception
+        except BaseException as exception:  # lgtm [py/catch-base-exception] pylint: disable=broad-except
+            self.log.exception(exception)
+            _exception = exception
+
+        if raise_exception and _exception is not None:
+            raise HacsException(_exception)
+        return None
+
+    async def async_register_repository(
+        self,
+        repository_full_name: str,
+        category: HacsCategory,
+        *,
+        check: bool = True,
+        ref: str | None = None,
+        repository_id: str | None = None,
+        default: bool = False,
+    ) -> None:
+        """Register a repository."""
+        if repository_full_name in self.common.skip:
+            if repository_full_name != HacsGitHubRepo.INTEGRATION:
+                raise HacsExpectedException(f"Skipping {repository_full_name}")
+
+        if repository_full_name == "home-assistant/core":
+            raise HomeAssistantCoreRepositoryException()
+
+        if repository_full_name == "home-assistant/addons" or repository_full_name.startswith(
+            "hassio-addons/"
+        ):
+            raise AddonRepositoryException()
+
+        if category not in RERPOSITORY_CLASSES:
+            raise HacsException(f"{category} is not a valid repository category.")
+
+        if (renamed := self.common.renamed_repositories.get(repository_full_name)) is not None:
+            repository_full_name = renamed
+
+        repository: HacsRepository = RERPOSITORY_CLASSES[category](self, repository_full_name)
+        if check:
+            try:
+                await repository.async_registration(ref)
+                if self.status.new:
+                    repository.data.new = False
+                if repository.validate.errors:
+                    self.common.skip.append(repository.data.full_name)
+                    if not self.status.startup:
+                        self.log.error("Validation for %s failed.", repository_full_name)
+                    if self.system.action:
+                        raise HacsException(
+                            f"::error:: Validation for {repository_full_name} failed."
+                        )
+                    return repository.validate.errors
+                if self.system.action:
+                    repository.logger.info("%s Validation completed", repository.string)
+                else:
+                    repository.logger.info("%s Registration completed", repository.string)
+            except (HacsRepositoryExistException, HacsRepositoryArchivedException):
+                return
+            except AIOGitHubAPIException as exception:
+                self.common.skip.append(repository.data.full_name)
+                raise HacsException(
+                    f"Validation for {repository_full_name} failed with {exception}."
+                ) from exception
+
+        if repository_id is not None:
+            repository.data.id = repository_id
+
+        if str(repository.data.id) != "0" and (
+            exists := self.repositories.get_by_id(repository.data.id)
+        ):
+            self.repositories.unregister(exists)
+
+        else:
+            if self.hass is not None and ((check and repository.data.new) or self.status.new):
+                self.async_dispatch(
+                    HacsDispatchEvent.REPOSITORY,
+                    {
+                        "action": "registration",
+                        "repository": repository.data.full_name,
+                        "repository_id": repository.data.id,
+                    },
+                )
+
+        self.repositories.register(repository, default)
+
+    async def startup_tasks(self, _=None) -> None:
+        """Tasks that are started after setup."""
+        self.set_stage(HacsStage.STARTUP)
+
+        try:
+            repository = self.repositories.get_by_full_name(HacsGitHubRepo.INTEGRATION)
+            if repository is None:
+                await self.async_register_repository(
+                    repository_full_name=HacsGitHubRepo.INTEGRATION,
+                    category=HacsCategory.INTEGRATION,
+                    default=True,
+                )
+                repository = self.repositories.get_by_full_name(HacsGitHubRepo.INTEGRATION)
+            if repository is None:
+                raise HacsException("Unknown error")
+
+            repository.data.installed = True
+            repository.data.installed_version = self.integration.version.string
+            repository.data.new = False
+            repository.data.releases = True
+
+            self.repository = repository.repository_object
+            self.repositories.mark_default(repository)
+        except HacsException as exception:
+            if "403" in str(exception):
+                self.log.critical(
+                    "GitHub API is ratelimited, or the token is wrong.",
+                )
+            else:
+                self.log.critical("Could not load HACS! - %s", exception)
+            self.disable_hacs(HacsDisabledReason.LOAD_HACS)
+
+        if critical := await async_load_from_store(self.hass, "critical"):
+            for repo in critical:
+                if not repo["acknowledged"]:
+                    self.log.critical("URGENT!: Check the HACS panel!")
+                    self.hass.components.persistent_notification.create(
+                        title="URGENT!", message="**Check the HACS panel!**"
+                    )
+                    break
+
+        self.recuring_tasks.append(
+            self.hass.helpers.event.async_track_time_interval(
+                self.async_get_all_category_repositories, timedelta(hours=3)
+            )
+        )
+        self.recuring_tasks.append(
+            self.hass.helpers.event.async_track_time_interval(
+                self.async_update_all_repositories, timedelta(hours=25)
+            )
+        )
+        self.recuring_tasks.append(
+            self.hass.helpers.event.async_track_time_interval(
+                self.async_check_rate_limit, timedelta(minutes=5)
+            )
+        )
+        self.recuring_tasks.append(
+            self.hass.helpers.event.async_track_time_interval(
+                self.async_prosess_queue, timedelta(minutes=10)
+            )
+        )
+        self.recuring_tasks.append(
+            self.hass.helpers.event.async_track_time_interval(
+                self.async_update_downloaded_repositories, timedelta(hours=2)
+            )
+        )
+        self.recuring_tasks.append(
+            self.hass.helpers.event.async_track_time_interval(
+                self.async_handle_critical_repositories, timedelta(hours=2)
+            )
+        )
+
+        self.hass.bus.async_listen_once(
+            EVENT_HOMEASSISTANT_FINAL_WRITE, self.data.async_force_write
+        )
+
+        self.status.startup = False
+        self.async_dispatch(HacsDispatchEvent.STATUS, {})
+
+        await self.async_handle_removed_repositories()
+        await self.async_get_all_category_repositories()
+        await self.async_update_downloaded_repositories()
+
+        self.set_stage(HacsStage.RUNNING)
+
+        self.async_dispatch(HacsDispatchEvent.RELOAD, {"force": True})
+
+        await self.async_handle_critical_repositories()
+        await self.async_prosess_queue()
+
+        self.async_dispatch(HacsDispatchEvent.STATUS, {})
+
+    async def async_download_file(self, url: str, *, headers: dict | None = None) -> bytes | None:
+        """Download files, and return the content."""
+        if url is None:
+            return None
+
+        if "tags/" in url:
+            url = url.replace("tags/", "")
+
+        self.log.debug("Downloading %s", url)
+        timeouts = 0
+
+        while timeouts < 5:
+            try:
+                request = await self.session.get(
+                    url=url,
+                    timeout=ClientTimeout(total=60),
+                    headers=headers,
+                )
+
+                # Make sure that we got a valid result
+                if request.status == 200:
+                    return await request.read()
+
+                raise HacsException(
+                    f"Got status code {request.status} when trying to download {url}"
+                )
+            except asyncio.TimeoutError:
+                self.log.warning(
+                    "A timeout of 60! seconds was encountered while downloading %s, "
+                    "using over 60 seconds to download a single file is not normal. "
+                    "This is not a problem with HACS but how your host communicates with GitHub. "
+                    "Retrying up to 5 times to mask/hide your host/network problems to "
+                    "stop the flow of issues opened about it. "
+                    "Tries left %s",
+                    url,
+                    (4 - timeouts),
+                )
+                timeouts += 1
+                await asyncio.sleep(1)
+                continue
+
+            except BaseException as exception:  # lgtm [py/catch-base-exception] pylint: disable=broad-except
+                self.log.exception("Download failed - %s", exception)
+
+            return None
+
+    async def async_recreate_entities(self) -> None:
+        """Recreate entities."""
+        if self.configuration == ConfigurationType.YAML or not self.configuration.experimental:
+            return
+
+        platforms = [Platform.SENSOR, Platform.UPDATE]
+
+        await self.hass.config_entries.async_unload_platforms(
+            entry=self.configuration.config_entry,
+            platforms=platforms,
+        )
+        self.hass.config_entries.async_setup_platforms(self.configuration.config_entry, platforms)
+
+    @callback
+    def async_dispatch(self, signal: HacsDispatchEvent, data: dict | None = None) -> None:
+        """Dispatch a signal with data."""
+        async_dispatcher_send(self.hass, signal, data)
+
+    def set_active_categories(self) -> None:
+        """Set the active categories."""
+        self.common.categories = set()
+        for category in (HacsCategory.INTEGRATION, HacsCategory.PLUGIN):
+            self.enable_hacs_category(HacsCategory(category))
+
+        if HacsCategory.PYTHON_SCRIPT in self.hass.config.components:
+            self.enable_hacs_category(HacsCategory.PYTHON_SCRIPT)
+
+        if self.hass.services.has_service("frontend", "reload_themes"):
+            self.enable_hacs_category(HacsCategory.THEME)
+
+        if self.configuration.appdaemon:
+            self.enable_hacs_category(HacsCategory.APPDAEMON)
+        if self.configuration.netdaemon:
+            self.enable_hacs_category(HacsCategory.NETDAEMON)
+
+    async def async_get_all_category_repositories(self, _=None) -> None:
+        """Get all category repositories."""
+        if self.system.disabled:
+            return
+        self.log.info("Loading known repositories")
+        await asyncio.gather(
+            *[
+                self.async_get_category_repositories(HacsCategory(category))
+                for category in self.common.categories or []
+            ]
+        )
+
+    async def async_get_category_repositories(self, category: HacsCategory) -> None:
+        """Get repositories from category."""
+        if self.system.disabled:
+            return
+        try:
+            repositories = await self.async_github_get_hacs_default_file(category)
+        except HacsException:
+            return
+
+        for repo in repositories:
+            if self.common.renamed_repositories.get(repo):
+                repo = self.common.renamed_repositories[repo]
+            if self.repositories.is_removed(repo):
+                continue
+            if repo in self.common.archived_repositories:
+                continue
+            repository = self.repositories.get_by_full_name(repo)
+            if repository is not None:
+                self.repositories.mark_default(repository)
+                if self.status.new and self.configuration.dev:
+                    # Force update for new installations
+                    self.queue.add(repository.common_update())
+                continue
+
+            self.queue.add(
+                self.async_register_repository(
+                    repository_full_name=repo,
+                    category=category,
+                    default=True,
+                )
+            )
+
+    async def async_update_all_repositories(self, _=None) -> None:
+        """Update all repositories."""
+        if self.system.disabled:
+            return
+        self.log.debug("Starting recurring background task for all repositories")
+
+        for repository in self.repositories.list_all:
+            if repository.data.category in self.common.categories:
+                self.queue.add(repository.common_update())
+
+        self.async_dispatch(HacsDispatchEvent.REPOSITORY, {"action": "reload"})
+        self.log.debug("Recurring background task for all repositories done")
+
+    async def async_check_rate_limit(self, _=None) -> None:
+        """Check rate limit."""
+        if not self.system.disabled or self.system.disabled_reason != HacsDisabledReason.RATE_LIMIT:
+            return
+
+        self.log.debug("Checking if ratelimit has lifted")
+        can_update = await self.async_can_update()
+        self.log.debug("Ratelimit indicate we can update %s", can_update)
+        if can_update > 0:
+            self.enable_hacs()
+            await self.async_prosess_queue()
+
+    async def async_prosess_queue(self, _=None) -> None:
+        """Process the queue."""
+        if self.system.disabled:
+            self.log.debug("HACS is disabled")
+            return
+        if not self.queue.has_pending_tasks:
+            self.log.debug("Nothing in the queue")
+            return
+        if self.queue.running:
+            self.log.debug("Queue is already running")
+            return
+
+        async def _handle_queue():
+            if not self.queue.has_pending_tasks:
+                await self.data.async_write()
+                return
+            can_update = await self.async_can_update()
+            self.log.debug(
+                "Can update %s repositories, " "items in queue %s",
+                can_update,
+                self.queue.pending_tasks,
+            )
+            if can_update != 0:
+                try:
+                    await self.queue.execute(can_update)
+                except HacsExecutionStillInProgress:
+                    return
+
+                await _handle_queue()
+
+        await _handle_queue()
+
+    async def async_handle_removed_repositories(self, _=None) -> None:
+        """Handle removed repositories."""
+        if self.system.disabled:
+            return
+        need_to_save = False
+        self.log.info("Loading removed repositories")
+
+        try:
+            removed_repositories = await self.async_github_get_hacs_default_file(
+                HacsCategory.REMOVED
+            )
+        except HacsException:
+            return
+
+        for item in removed_repositories:
+            removed = self.repositories.removed_repository(item["repository"])
+            removed.update_data(item)
+
+        for removed in self.repositories.list_removed:
+            if (repository := self.repositories.get_by_full_name(removed.repository)) is None:
+                continue
+            if repository.data.full_name in self.common.ignored_repositories:
+                continue
+            if repository.data.installed and removed.removal_type != "critical":
+                self.log.warning(
+                    "You have '%s' installed with HACS "
+                    "this repository has been removed from HACS, please consider removing it. "
+                    "Removal reason (%s)",
+                    repository.data.full_name,
+                    removed.reason,
+                )
+            else:
+                need_to_save = True
+                repository.remove()
+
+        if need_to_save:
+            await self.data.async_write()
+
+    async def async_update_downloaded_repositories(self, _=None) -> None:
+        """Execute the task."""
+        if self.system.disabled:
+            return
+        self.log.info("Starting recurring background task for downloaded repositories")
+
+        for repository in self.repositories.list_downloaded:
+            if repository.data.category in self.common.categories:
+                self.queue.add(repository.update_repository(ignore_issues=True))
+
+        self.log.debug("Recurring background task for downloaded repositories done")
+
+    async def async_handle_critical_repositories(self, _=None) -> None:
+        """Handle critical repositories."""
+        critical_queue = QueueManager(hass=self.hass)
+        instored = []
+        critical = []
+        was_installed = False
+
+        try:
+            critical = await self.async_github_get_hacs_default_file("critical")
+        except GitHubNotModifiedException:
+            return
+        except HacsException:
+            pass
+
+        if not critical:
+            self.log.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:
+            removed_repo = self.repositories.removed_repository(repository["repository"])
+            removed_repo.removal_type = "critical"
+            repo = self.repositories.get_by_full_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.data.installed:
+                    self.log.critical(
+                        "Removing repository %s, it is marked as critical",
+                        repository["repository"],
+                    )
+                    was_installed = True
+                    stored["acknowledged"] = False
+                    # Remove from HACS
+                    critical_queue.add(repo.uninstall())
+                    repo.remove()
+
+            stored_critical.append(stored)
+            removed_repo.update_data(stored)
+
+        # Uninstall
+        await critical_queue.execute()
+
+        # Save to FS
+        await async_save_to_store(self.hass, "critical", stored_critical)
+
+        # Restart HASS
+        if was_installed:
+            self.log.critical("Restarting Home Assistant")
+            self.hass.async_create_task(self.hass.async_stop(100))

+ 182 - 0
custom_components/hacs/config_flow.py

@@ -0,0 +1,182 @@
+"""Adds config flow for HACS."""
+from aiogithubapi import GitHubDeviceAPI, GitHubException
+from aiogithubapi.common.const import OAUTH_USER_LOGIN
+from awesomeversion import AwesomeVersion
+from homeassistant import config_entries
+from homeassistant.const import __version__ as HAVERSION
+from homeassistant.core import callback
+from homeassistant.helpers import aiohttp_client
+from homeassistant.helpers.event import async_call_later
+from homeassistant.loader import async_get_integration
+import voluptuous as vol
+
+from .base import HacsBase
+from .const import CLIENT_ID, DOMAIN, MINIMUM_HA_VERSION
+from .enums import ConfigurationType
+from .utils.configuration_schema import RELEASE_LIMIT, hacs_config_option_schema
+from .utils.logger import LOGGER
+
+
+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 = {}
+        self.device = None
+        self.activation = None
+        self.log = LOGGER
+        self._progress_task = None
+        self._login_device = None
+        self._reauth = False
+
+    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:
+            if [x for x in user_input if not user_input[x]]:
+                self._errors["base"] = "acc"
+                return await self._show_config_form(user_input)
+
+            return await self.async_step_device(user_input)
+
+        ## Initial form
+        return await self._show_config_form(user_input)
+
+    async def async_step_device(self, _user_input):
+        """Handle device steps"""
+
+        async def _wait_for_activation(_=None):
+            if self._login_device is None or self._login_device.expires_in is None:
+                async_call_later(self.hass, 1, _wait_for_activation)
+                return
+
+            response = await self.device.activation(device_code=self._login_device.device_code)
+            self.activation = response.data
+            self.hass.async_create_task(
+                self.hass.config_entries.flow.async_configure(flow_id=self.flow_id)
+            )
+
+        if not self.activation:
+            integration = await async_get_integration(self.hass, DOMAIN)
+            if not self.device:
+                self.device = GitHubDeviceAPI(
+                    client_id=CLIENT_ID,
+                    session=aiohttp_client.async_get_clientsession(self.hass),
+                    **{"client_name": f"HACS/{integration.version}"},
+                )
+            async_call_later(self.hass, 1, _wait_for_activation)
+            try:
+                response = await self.device.register()
+                self._login_device = response.data
+                return self.async_show_progress(
+                    step_id="device",
+                    progress_action="wait_for_device",
+                    description_placeholders={
+                        "url": OAUTH_USER_LOGIN,
+                        "code": self._login_device.user_code,
+                    },
+                )
+            except GitHubException as exception:
+                self.log.error(exception)
+                return self.async_abort(reason="github")
+
+        return self.async_show_progress_done(next_step_id="device_done")
+
+    async def _show_config_form(self, user_input):
+        """Show the configuration form to edit location data."""
+
+        if not user_input:
+            user_input = {}
+
+        if AwesomeVersion(HAVERSION) < MINIMUM_HA_VERSION:
+            return self.async_abort(
+                reason="min_ha_version",
+                description_placeholders={"version": MINIMUM_HA_VERSION},
+            )
+        return self.async_show_form(
+            step_id="user",
+            data_schema=vol.Schema(
+                {
+                    vol.Required("acc_logs", default=user_input.get("acc_logs", False)): bool,
+                    vol.Required("acc_addons", default=user_input.get("acc_addons", False)): bool,
+                    vol.Required(
+                        "acc_untested", default=user_input.get("acc_untested", False)
+                    ): bool,
+                    vol.Required("acc_disable", default=user_input.get("acc_disable", False)): bool,
+                }
+            ),
+            errors=self._errors,
+        )
+
+    async def async_step_device_done(self, _user_input):
+        """Handle device steps"""
+        if self._reauth:
+            existing_entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
+            self.hass.config_entries.async_update_entry(
+                existing_entry, data={"token": self.activation.access_token}
+            )
+            await self.hass.config_entries.async_reload(existing_entry.entry_id)
+            return self.async_abort(reason="reauth_successful")
+
+        return self.async_create_entry(title="", data={"token": self.activation.access_token})
+
+    async def async_step_reauth(self, user_input=None):
+        """Perform reauth upon an API authentication error."""
+        return await self.async_step_reauth_confirm()
+
+    async def async_step_reauth_confirm(self, user_input=None):
+        """Dialog that informs the user that reauth is required."""
+        if user_input is None:
+            return self.async_show_form(
+                step_id="reauth_confirm",
+                data_schema=vol.Schema({}),
+            )
+        self._reauth = True
+        return await self.async_step_device(None)
+
+    @staticmethod
+    @callback
+    def async_get_options_flow(config_entry):
+        return HacsOptionsFlowHandler(config_entry)
+
+
+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."""
+        hacs: HacsBase = self.hass.data.get(DOMAIN)
+        if user_input is not None:
+            limit = int(user_input.get(RELEASE_LIMIT, 5))
+            if limit <= 0 or limit > 100:
+                return self.async_abort(reason="release_limit_value")
+            return self.async_create_entry(title="", data=user_input)
+
+        if hacs is None or hacs.configuration is None:
+            return self.async_abort(reason="not_setup")
+
+        if hacs.configuration.config_type == ConfigurationType.YAML:
+            schema = {vol.Optional("not_in_use", default=""): str}
+        else:
+            schema = hacs_config_option_schema(self.config_entry.options)
+            del schema["frontend_repo"]
+            del schema["frontend_repo_url"]
+
+        return self.async_show_form(step_id="user", data_schema=vol.Schema(schema))

+ 289 - 0
custom_components/hacs/const.py

@@ -0,0 +1,289 @@
+"""Constants for HACS"""
+from typing import TypeVar
+
+from aiogithubapi.common.const import ACCEPT_HEADERS
+
+NAME_SHORT = "HACS"
+DOMAIN = "hacs"
+CLIENT_ID = "395a8e669c5de9f7c6e8"
+MINIMUM_HA_VERSION = "2022.8.0"
+
+TV = TypeVar("TV")
+
+PACKAGE_NAME = "custom_components.hacs"
+
+DEFAULT_CONCURRENT_TASKS = 15
+DEFAULT_CONCURRENT_BACKOFF_TIME = 1
+
+HACS_ACTION_GITHUB_API_HEADERS = {
+    "User-Agent": "HACS/action",
+    "Accept": ACCEPT_HEADERS["preview"],
+}
+
+VERSION_STORAGE = "6"
+STORENAME = "hacs"
+
+HACS_SYSTEM_ID = "0717a0cd-745c-48fd-9b16-c8534c9704f9-bc944b0f-fd42-4a58-a072-ade38d1444cd"
+
+STARTUP = """
+-------------------------------------------------------------------
+HACS (Home Assistant Community Store)
+
+Version: %s
+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",
+]

+ 82 - 0
custom_components/hacs/diagnostics.py

@@ -0,0 +1,82 @@
+"""Diagnostics support for HACS."""
+from __future__ import annotations
+
+from typing import Any
+
+from aiogithubapi import GitHubException
+from homeassistant.components.diagnostics import async_redact_data
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+
+from .base import HacsBase
+from .const import DOMAIN
+from .utils.configuration_schema import TOKEN
+
+
+async def async_get_config_entry_diagnostics(
+    hass: HomeAssistant,
+    entry: ConfigEntry,
+) -> dict[str, Any]:
+    """Return diagnostics for a config entry."""
+    hacs: HacsBase = hass.data[DOMAIN]
+
+    data = {
+        "entry": entry.as_dict(),
+        "hacs": {
+            "stage": hacs.stage,
+            "version": hacs.version,
+            "disabled_reason": hacs.system.disabled_reason,
+            "new": hacs.status.new,
+            "startup": hacs.status.startup,
+            "categories": hacs.common.categories,
+            "renamed_repositories": hacs.common.renamed_repositories,
+            "archived_repositories": hacs.common.archived_repositories,
+            "ignored_repositories": hacs.common.ignored_repositories,
+            "lovelace_mode": hacs.core.lovelace_mode,
+            "configuration": {},
+        },
+        "custom_repositories": [
+            repo.data.full_name
+            for repo in hacs.repositories.list_all
+            if not hacs.repositories.is_default(str(repo.data.id))
+        ],
+        "repositories": [],
+    }
+
+    for key in (
+        "appdaemon",
+        "country",
+        "debug",
+        "dev",
+        "experimental",
+        "netdaemon",
+        "python_script",
+        "release_limit",
+        "theme",
+    ):
+        data["hacs"]["configuration"][key] = getattr(hacs.configuration, key, None)
+
+    for repository in hacs.repositories.list_downloaded:
+        data["repositories"].append(
+            {
+                "data": repository.data.to_json(),
+                "integration_manifest": repository.integration_manifest,
+                "repository_manifest": repository.repository_manifest.to_dict(),
+                "ref": repository.ref,
+                "paths": {
+                    "localpath": repository.localpath.replace(hacs.core.config_path, "/config"),
+                    "local": repository.content.path.local.replace(
+                        hacs.core.config_path, "/config"
+                    ),
+                    "remote": repository.content.path.remote,
+                },
+            }
+        )
+
+    try:
+        rate_limit_response = await hacs.githubapi.rate_limit()
+        data["rate_limit"] = rate_limit_response.data.as_dict
+    except GitHubException as exception:
+        data["rate_limit"] = str(exception)
+
+    return async_redact_data(data, (TOKEN,))

+ 119 - 0
custom_components/hacs/entity.py

@@ -0,0 +1,119 @@
+"""HACS Base entities."""
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+from homeassistant.core import callback
+from homeassistant.helpers.device_registry import DeviceEntryType
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity import Entity
+
+from .const import DOMAIN, HACS_SYSTEM_ID, NAME_SHORT
+from .enums import HacsDispatchEvent, HacsGitHubRepo
+
+if TYPE_CHECKING:
+    from .base import HacsBase
+    from .repositories.base import HacsRepository
+
+
+def system_info(hacs: HacsBase) -> dict:
+    """Return system info."""
+    return {
+        "identifiers": {(DOMAIN, HACS_SYSTEM_ID)},
+        "name": NAME_SHORT,
+        "manufacturer": "hacs.xyz",
+        "model": "",
+        "sw_version": str(hacs.version),
+        "configuration_url": "homeassistant://hacs",
+        "entry_type": DeviceEntryType.SERVICE,
+    }
+
+
+class HacsBaseEntity(Entity):
+    """Base HACS entity."""
+
+    repository: HacsRepository | None = None
+    _attr_should_poll = False
+
+    def __init__(self, hacs: HacsBase) -> None:
+        """Initialize."""
+        self.hacs = hacs
+
+    async def async_added_to_hass(self) -> None:
+        """Register for status events."""
+        self.async_on_remove(
+            async_dispatcher_connect(
+                self.hass,
+                HacsDispatchEvent.REPOSITORY,
+                self._update_and_write_state,
+            )
+        )
+
+    @callback
+    def _update(self) -> None:
+        """Update the sensor."""
+
+    async def async_update(self) -> None:
+        """Manual updates of the sensor."""
+        self._update()
+
+    @callback
+    def _update_and_write_state(self, _: Any) -> None:
+        """Update the entity and write state."""
+        self._update()
+        self.async_write_ha_state()
+
+
+class HacsSystemEntity(HacsBaseEntity):
+    """Base system entity."""
+
+    _attr_icon = "hacs:hacs"
+    _attr_unique_id = HACS_SYSTEM_ID
+
+    @property
+    def device_info(self) -> dict[str, any]:
+        """Return device information about HACS."""
+        return system_info(self.hacs)
+
+
+class HacsRepositoryEntity(HacsBaseEntity):
+    """Base repository entity."""
+
+    def __init__(
+        self,
+        hacs: HacsBase,
+        repository: HacsRepository,
+    ) -> None:
+        """Initialize."""
+        super().__init__(hacs=hacs)
+        self.repository = repository
+        self._attr_unique_id = str(repository.data.id)
+
+    @property
+    def available(self) -> bool:
+        """Return True if entity is available."""
+        return self.hacs.repositories.is_downloaded(repository_id=str(self.repository.data.id))
+
+    @property
+    def device_info(self) -> dict[str, any]:
+        """Return device information about HACS."""
+        if self.repository.data.full_name == HacsGitHubRepo.INTEGRATION:
+            return system_info(self.hacs)
+
+        return {
+            "identifiers": {(DOMAIN, str(self.repository.data.id))},
+            "name": self.repository.display_name,
+            "model": self.repository.data.category,
+            "manufacturer": ", ".join(
+                author.replace("@", "") for author in self.repository.data.authors
+            ),
+            "configuration_url": "homeassistant://hacs",
+            "entry_type": DeviceEntryType.SERVICE,
+        }
+
+    @callback
+    def _update_and_write_state(self, data: dict) -> None:
+        """Update the entity and write state."""
+        if data.get("repository_id") == self.repository.data.id:
+            self._update()
+            self.async_write_ha_state()

+ 75 - 0
custom_components/hacs/enums.py

@@ -0,0 +1,75 @@
+"""Helper constants."""
+# pylint: disable=missing-class-docstring
+from enum import Enum
+
+
+class HacsGitHubRepo(str, Enum):
+    """HacsGitHubRepo."""
+
+    DEFAULT = "hacs/default"
+    INTEGRATION = "hacs/integration"
+
+
+class HacsCategory(str, Enum):
+    APPDAEMON = "appdaemon"
+    INTEGRATION = "integration"
+    LOVELACE = "lovelace"
+    PLUGIN = "plugin"  # Kept for legacy purposes
+    NETDAEMON = "netdaemon"
+    PYTHON_SCRIPT = "python_script"
+    THEME = "theme"
+    REMOVED = "removed"
+
+    def __str__(self):
+        return str(self.value)
+
+
+class HacsDispatchEvent(str, Enum):
+    """HacsDispatchEvent."""
+
+    CONFIG = "hacs_dispatch_config"
+    ERROR = "hacs_dispatch_error"
+    RELOAD = "hacs_dispatch_reload"
+    REPOSITORY = "hacs_dispatch_repository"
+    REPOSITORY_DOWNLOAD_PROGRESS = "hacs_dispatch_repository_download_progress"
+    STAGE = "hacs_dispatch_stage"
+    STARTUP = "hacs_dispatch_startup"
+    STATUS = "hacs_dispatch_status"
+
+
+class RepositoryFile(str, Enum):
+    """Repository file names."""
+
+    HACS_JSON = "hacs.json"
+    MAINIFEST_JSON = "manifest.json"
+
+
+class ConfigurationType(str, Enum):
+    YAML = "yaml"
+    CONFIG_ENTRY = "config_entry"
+
+
+class LovelaceMode(str, Enum):
+    """Lovelace Modes."""
+
+    STORAGE = "storage"
+    AUTO = "auto"
+    AUTO_GEN = "auto-gen"
+    YAML = "yaml"
+
+
+class HacsStage(str, Enum):
+    SETUP = "setup"
+    STARTUP = "startup"
+    WAITING = "waiting"
+    RUNNING = "running"
+    BACKGROUND = "background"
+
+
+class HacsDisabledReason(str, Enum):
+    RATE_LIMIT = "rate_limit"
+    REMOVED = "removed"
+    INVALID_TOKEN = "invalid_token"
+    CONSTRAINS = "constrains"
+    LOAD_HACS = "load_hacs"
+    RESTORE = "restore"

+ 49 - 0
custom_components/hacs/exceptions.py

@@ -0,0 +1,49 @@
+"""Custom Exceptions for HACS."""
+
+
+class HacsException(Exception):
+    """Super basic."""
+
+
+class HacsRepositoryArchivedException(HacsException):
+    """For repositories that are archived."""
+
+
+class HacsNotModifiedException(HacsException):
+    """For responses that are not modified."""
+
+
+class HacsExpectedException(HacsException):
+    """For stuff that are expected."""
+
+
+class HacsRepositoryExistException(HacsException):
+    """For repositories that are already exist."""
+
+
+class HacsExecutionStillInProgress(HacsException):
+    """Exception to raise if execution is still in progress."""
+
+
+class AddonRepositoryException(HacsException):
+    """Exception to raise when user tries to add add-on repository."""
+
+    exception_message = (
+        "The repository does not seem to be a integration, "
+        "but an add-on repository. HACS does not manage add-ons."
+    )
+
+    def __init__(self) -> None:
+        super().__init__(self.exception_message)
+
+
+class HomeAssistantCoreRepositoryException(HacsException):
+    """Exception to raise when user tries to add the home-assistant/core repository."""
+
+    exception_message = (
+        "You can not add homeassistant/core, to use core integrations "
+        "check the Home Assistant documentation for how to add them."
+    )
+
+    def __init__(self) -> None:
+        super().__init__(self.exception_message)

+ 97 - 0
custom_components/hacs/frontend.py

@@ -0,0 +1,97 @@
+""""Starting setup task: Frontend"."""
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from aiohttp import web
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.core import HomeAssistant, callback
+
+from .const import DOMAIN
+from .hacs_frontend import locate_dir
+from .hacs_frontend.version import VERSION as FE_VERSION
+
+URL_BASE = "/hacsfiles"
+
+if TYPE_CHECKING:
+    from .base import HacsBase
+
+
+@callback
+def async_register_frontend(hass: HomeAssistant, hacs: HacsBase) -> None:
+    """Register the frontend."""
+
+    # Register themes
+    hass.http.register_static_path(f"{URL_BASE}/themes", hass.config.path("themes"))
+
+    # Register frontend
+    if hacs.configuration.frontend_repo_url:
+        hacs.log.warning(
+            "<HacsFrontend> Frontend development mode enabled. Do not run in production!"
+        )
+        hass.http.register_view(HacsFrontendDev())
+    else:
+        #
+        hass.http.register_static_path(f"{URL_BASE}/frontend", locate_dir(), cache_headers=False)
+
+    # Custom iconset
+    hass.http.register_static_path(
+        f"{URL_BASE}/iconset.js", str(hacs.integration_dir / "iconset.js")
+    )
+    if "frontend_extra_module_url" not in hass.data:
+        hass.data["frontend_extra_module_url"] = set()
+    hass.data["frontend_extra_module_url"].add(f"{URL_BASE}/iconset.js")
+
+    # Register www/community for all other files
+    use_cache = hacs.core.lovelace_mode == "storage"
+    hacs.log.info(
+        "<HacsFrontend> %s mode, cache for /hacsfiles/: %s",
+        hacs.core.lovelace_mode,
+        use_cache,
+    )
+
+    hass.http.register_static_path(
+        URL_BASE,
+        hass.config.path("www/community"),
+        cache_headers=use_cache,
+    )
+
+    hacs.frontend_version = FE_VERSION
+
+    # Add to sidepanel if needed
+    if DOMAIN not in hass.data.get("frontend_panels", {}):
+        hass.components.frontend.async_register_built_in_panel(
+            component_name="custom",
+            sidebar_title=hacs.configuration.sidepanel_title,
+            sidebar_icon=hacs.configuration.sidepanel_icon,
+            frontend_url_path=DOMAIN,
+            config={
+                "_panel_custom": {
+                    "name": "hacs-frontend",
+                    "embed_iframe": True,
+                    "trust_external": False,
+                    "js_url": f"/hacsfiles/frontend/entrypoint.js?hacstag={FE_VERSION}",
+                }
+            },
+            require_admin=True,
+        )
+
+
+class HacsFrontendDev(HomeAssistantView):
+    """Dev View Class for HACS."""
+
+    requires_auth = False
+    name = "hacs_files:frontend"
+    url = r"/hacsfiles/frontend/{requested_file:.+}"
+
+    async def get(self, request, requested_file):  # pylint: disable=unused-argument
+        """Handle HACS Web requests."""
+        hacs: HacsBase = request.app["hass"].data.get(DOMAIN)
+        requested = requested_file.split("/")[-1]
+        request = await hacs.session.get(f"{hacs.configuration.frontend_repo_url}/{requested}")
+        if request.status == 200:
+            result = await request.read()
+            response = web.Response(body=result)
+            response.headers["Content-Type"] = "application/javascript"
+
+            return response

+ 5 - 0
custom_components/hacs/hacs_frontend/__init__.py

@@ -0,0 +1,5 @@
+"""HACS Frontend"""
+from .version import VERSION
+
+def locate_dir():
+    return __path__[0]

BIN
custom_components/hacs/hacs_frontend/__pycache__/__init__.cpython-310.pyc


BIN
custom_components/hacs/hacs_frontend/__pycache__/version.cpython-310.pyc


Diferenças do arquivo suprimidas por serem muito extensas
+ 1 - 0
custom_components/hacs/hacs_frontend/c.004a7b01.js


BIN
custom_components/hacs/hacs_frontend/c.004a7b01.js.gz


Diferenças do arquivo suprimidas por serem muito extensas
+ 1 - 0
custom_components/hacs/hacs_frontend/c.014b1a3b.js


BIN
custom_components/hacs/hacs_frontend/c.014b1a3b.js.gz


Diferenças do arquivo suprimidas por serem muito extensas
+ 3940 - 0
custom_components/hacs/hacs_frontend/c.07dde5c0.js


BIN
custom_components/hacs/hacs_frontend/c.07dde5c0.js.gz


Diferenças do arquivo suprimidas por serem muito extensas
+ 83 - 0
custom_components/hacs/hacs_frontend/c.09384688.js


BIN
custom_components/hacs/hacs_frontend/c.09384688.js.gz


Diferenças do arquivo suprimidas por serem muito extensas
+ 1 - 0
custom_components/hacs/hacs_frontend/c.10c7d0ce.js


BIN
custom_components/hacs/hacs_frontend/c.10c7d0ce.js.gz


Diferenças do arquivo suprimidas por serem muito extensas
+ 30 - 0
custom_components/hacs/hacs_frontend/c.138a5fae.js


BIN
custom_components/hacs/hacs_frontend/c.138a5fae.js.gz


Diferenças do arquivo suprimidas por serem muito extensas
+ 178 - 0
custom_components/hacs/hacs_frontend/c.167d87ac.js


BIN
custom_components/hacs/hacs_frontend/c.167d87ac.js.gz


+ 1 - 0
custom_components/hacs/hacs_frontend/c.21c042d4.js

@@ -0,0 +1 @@
+const n=(n,o)=>n&&n.config.components.includes(o);export{n as i};

Diferenças do arquivo suprimidas por serem muito extensas
+ 1 - 0
custom_components/hacs/hacs_frontend/c.24bd2446.js


BIN
custom_components/hacs/hacs_frontend/c.24bd2446.js.gz


Diferenças do arquivo suprimidas por serem muito extensas
+ 7 - 0
custom_components/hacs/hacs_frontend/c.28c2a1ee.js


BIN
custom_components/hacs/hacs_frontend/c.28c2a1ee.js.gz


Diferenças do arquivo suprimidas por serem muito extensas
+ 14 - 0
custom_components/hacs/hacs_frontend/c.3f8082e4.js


BIN
custom_components/hacs/hacs_frontend/c.3f8082e4.js.gz


Diferenças do arquivo suprimidas por serem muito extensas
+ 163 - 0
custom_components/hacs/hacs_frontend/c.476721bc.js


BIN
custom_components/hacs/hacs_frontend/c.476721bc.js.gz


Diferenças do arquivo suprimidas por serem muito extensas
+ 7 - 0
custom_components/hacs/hacs_frontend/c.48057b49.js


BIN
custom_components/hacs/hacs_frontend/c.48057b49.js.gz


Diferenças do arquivo suprimidas por serem muito extensas
+ 41 - 0
custom_components/hacs/hacs_frontend/c.497c36cc.js


BIN
custom_components/hacs/hacs_frontend/c.497c36cc.js.gz


+ 1 - 0
custom_components/hacs/hacs_frontend/c.4a97632a.js

@@ -0,0 +1 @@
+function t(t){const a=t.language||"en";return t.translationMetadata.translations[a]&&t.translationMetadata.translations[a].isRTL||!1}function a(a){return t(a)?"rtl":"ltr"}export{a,t as c};

BIN
custom_components/hacs/hacs_frontend/c.4a97632a.js.gz


Diferenças do arquivo suprimidas por serem muito extensas
+ 74 - 0
custom_components/hacs/hacs_frontend/c.4d0a19ff.js


BIN
custom_components/hacs/hacs_frontend/c.4d0a19ff.js.gz


+ 1 - 0
custom_components/hacs/hacs_frontend/c.50bfd408.js

@@ -0,0 +1 @@
+const e=()=>{const e={},r=new URLSearchParams(location.search);for(const[n,t]of r.entries())e[n]=t;return e},r=e=>{const r=new URLSearchParams;return Object.entries(e).forEach((([e,n])=>{r.append(e,n)})),r.toString()};export{r as c,e};

BIN
custom_components/hacs/hacs_frontend/c.50bfd408.js.gz


Diferenças do arquivo suprimidas por serem muito extensas
+ 1 - 0
custom_components/hacs/hacs_frontend/c.50ff9066.js


BIN
custom_components/hacs/hacs_frontend/c.50ff9066.js.gz


Diferenças do arquivo suprimidas por serem muito extensas
+ 51 - 0
custom_components/hacs/hacs_frontend/c.51d1da7b.js


+ 0 - 0
custom_components/hacs/hacs_frontend/c.51d1da7b.js.gz


Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff