Auto Merge of PR 2 - swag-config-additions_2025-08-31T07-14-27
Merged by Trez.One
This commit was merged in pull request #2.
This commit is contained in:
@@ -8,7 +8,9 @@ on:
|
||||
paths:
|
||||
- 'app-configs/**'
|
||||
- 'inventory/hosts.yml'
|
||||
- 'host_vars/**.yml'
|
||||
- 'host_vars/**'
|
||||
- 'group_vars/**'
|
||||
- '**/gitea_tar-valon_ansible_deploy.yml'
|
||||
|
||||
env:
|
||||
VAULT_ADDR: ${{ secrets.VAULT_ADDR }}
|
||||
@@ -81,8 +83,8 @@ jobs:
|
||||
- name: Cache Ansible Galaxy Collections
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ansible/collections
|
||||
key: ${{ runner.os }}-ansible-${{ hashFiles('./ansible/collections/requirements.yml') }}
|
||||
path: collections
|
||||
key: ${{ runner.os }}-ansible-${{ hashFiles('./collections/requirements.yml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-ansible-
|
||||
|
||||
@@ -110,8 +112,9 @@ jobs:
|
||||
- name: Run Ansible Dry Run
|
||||
uses: dawidd6/action-ansible-playbook@v3
|
||||
with:
|
||||
directory: ansible/
|
||||
playbook: homelab_config_deploy.yml
|
||||
directory: .
|
||||
playbook: tar-valon_config_deploy.yml
|
||||
vault_password: ${{ secrets.ANSIBLE_VAULT_PASSWORD }}
|
||||
key: ${{ secrets.ANSIBLE_PRIVATE_KEY }}
|
||||
requirements: collections/requirements.yml
|
||||
options: |
|
||||
@@ -199,8 +202,9 @@ jobs:
|
||||
- name: Run Ansible Config Deployment
|
||||
uses: dawidd6/action-ansible-playbook@v3
|
||||
with:
|
||||
directory: ansible/
|
||||
playbook: homelab_config_deploy.yml
|
||||
directory: .
|
||||
playbook: tar-valon_config_deploy.yml
|
||||
vault_password: ${{ secrets.ANSIBLE_VAULT_PASSWORD }}
|
||||
key: ${{ secrets.ANSIBLE_PRIVATE_KEY }}
|
||||
requirements: collections/requirements.yml
|
||||
options: |
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
{% set vault_addr = 'https://vault.trez.wtf' %}
|
||||
{% set secrets_path = 'benedikta-config/env' %}
|
||||
{% set secrets_path = 'benedikta-docker/env' %}
|
||||
|
||||
{
|
||||
"api_key": "{{ lookup('community.hashi_vault.vault_kv2_get', 'env', engine_mount_point='benedikta-config', url=vault_addr, token=vault_token_cleaned)['secret']['HOME_ASSISTANT_LONG_LIVED_TOKEN'] }}",
|
||||
"api_key": "{{ lookup('community.hashi_vault.vault_kv2_get', 'env', engine_mount_point='benedikta-docker', url=vault_addr, token=vault_token)['secret']['HOME_ASSISTANT_LONG_LIVED_TOKEN'] }}",
|
||||
"host": "192.168.1.250:8123",
|
||||
"__mycroft_skill_firstrun": false
|
||||
}
|
||||
@@ -9,7 +9,7 @@ http:
|
||||
session_ttl: 720h
|
||||
users:
|
||||
- name: admin
|
||||
password: {{ lookup('community.hashi_vault.vault_kv2_get', 'env', engine_mount_point='rikku-docker', url=vault_addr, token=vault_token_cleaned)['secret']['ADGUARD_BCRYPT'] }}
|
||||
password: {{ lookup('community.hashi_vault.vault_kv2_get', 'env', engine_mount_point='rikku-docker', url=vault_addr, token=vault_token)['secret']['ADGUARD_BCRYPT'] }}
|
||||
auth_attempts: 5
|
||||
block_auth_min: 15
|
||||
http_proxy: ""
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Created by https://www.toptal.com/api/homeassistant
|
||||
# Edit at https://www.toptal.com?templates=homeassistant
|
||||
|
||||
### HomeAssistant ###
|
||||
# Files with personal details
|
||||
*.crt
|
||||
*.csr
|
||||
*.key
|
||||
.google.token
|
||||
.uuid
|
||||
icloud/
|
||||
google_calendars.yaml
|
||||
harmony_media_room.conf
|
||||
home-assistant.db
|
||||
home-assistant_v2.db
|
||||
home-assistant_v2.db-*
|
||||
html5_push_registrations.conf
|
||||
ip_bans.yaml
|
||||
known_devices.yaml
|
||||
phue.conf
|
||||
plex.conf
|
||||
pyozw.sqlite
|
||||
secrets.yaml
|
||||
tradfri.conf
|
||||
|
||||
# Temporary files
|
||||
*.db-journal
|
||||
*.pid
|
||||
tts
|
||||
|
||||
# automatically downloaded dependencies
|
||||
deps
|
||||
lib
|
||||
www
|
||||
|
||||
# Log files
|
||||
home-assistant.log
|
||||
ozw_log.txt
|
||||
|
||||
# Development files
|
||||
custom_components/
|
||||
themes
|
||||
image
|
||||
|
||||
# End of https://www.toptal.com/api/homeassistant
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from .const import DOMAIN
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: dict):
|
||||
"""Set up the Cloudflare Tunnel component."""
|
||||
return True
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||
"""Set up Cloudflare Tunnel from a config entry."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data[DOMAIN][entry.entry_id] = entry.data
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, ["sensor"])
|
||||
|
||||
return True
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_forward_entry_unload(entry, "sensor")
|
||||
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
else:
|
||||
_LOGGER.error("Failed to unload entry: %s", entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
import aiohttp
|
||||
import async_timeout
|
||||
import voluptuous as vol
|
||||
from homeassistant import config_entries, exceptions
|
||||
from .const import DOMAIN, CONF_API_KEY, CONF_ACCOUNT_ID, LABEL_API_KEY, LABEL_ACCOUNT_ID, PLACEHOLDER_API_KEY, PLACEHOLDER_ACCOUNT_ID
|
||||
|
||||
# Constants
|
||||
URL = "https://api.cloudflare.com/client/v4/user/tokens/verify"
|
||||
TIMEOUT = 10
|
||||
|
||||
# Custom exceptions
|
||||
class CannotConnect(exceptions.HomeAssistantError):
|
||||
"""Error to indicate we cannot connect."""
|
||||
|
||||
class InvalidAuth(exceptions.HomeAssistantError):
|
||||
"""Error to indicate there is invalid auth."""
|
||||
|
||||
DATA_SCHEMA = vol.Schema({
|
||||
vol.Required(CONF_ACCOUNT_ID, description={LABEL_ACCOUNT_ID}): str,
|
||||
vol.Required(CONF_API_KEY, description={LABEL_API_KEY}): str
|
||||
})
|
||||
|
||||
async def validate_credentials(hass, data):
|
||||
"""Validate the provided credentials are correct."""
|
||||
api_key = data["api_key"]
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bearer {api_key}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with async_timeout.timeout(TIMEOUT):
|
||||
async with session.get(URL, headers=headers) as response:
|
||||
if response.status == 200:
|
||||
return True
|
||||
elif response.status == 401:
|
||||
raise InvalidAuth
|
||||
else:
|
||||
raise CannotConnect
|
||||
except aiohttp.ClientError:
|
||||
raise CannotConnect
|
||||
except async_timeout.TimeoutError:
|
||||
raise CannotConnect
|
||||
|
||||
class CloudflareConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Cloudflare config flow."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""Handle a flow initiated by the user."""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
await validate_credentials(self.hass, user_input)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception:
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_create_entry(title="Cloudflare Tunnel Monitor", data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=DATA_SCHEMA,
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
CONF_API_KEY: PLACEHOLDER_API_KEY,
|
||||
CONF_ACCOUNT_ID: PLACEHOLDER_ACCOUNT_ID,
|
||||
}
|
||||
)
|
||||
@@ -1,14 +0,0 @@
|
||||
DOMAIN = "cloudflare_tunnel_monitor"
|
||||
|
||||
# Constants for user data keys
|
||||
CONF_ACCOUNT_ID = "account_id"
|
||||
CONF_API_KEY = "api_key"
|
||||
|
||||
|
||||
# Constants for labels displayed in the user interface
|
||||
LABEL_ACCOUNT_ID = "Account ID"
|
||||
LABEL_API_KEY = "API Token"
|
||||
|
||||
# Constants for placeholders or suggested values
|
||||
PLACEHOLDER_API_KEY = "api-token"
|
||||
PLACEHOLDER_ACCOUNT_ID = "your-account-id"
|
||||
-249
@@ -1,249 +0,0 @@
|
||||
import logging
|
||||
import aiohttp
|
||||
import asyncio
|
||||
import async_timeout
|
||||
from homeassistant.components.sensor import SensorEntity, SensorDeviceClass
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.exceptions import PlatformNotReady
|
||||
from datetime import timedelta
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Constants
|
||||
URL = "https://api.cloudflare.com/client/v4/accounts/{}/cfd_tunnel?is_deleted=false"
|
||||
TIMEOUT = 10
|
||||
RETRY_DELAY = 20
|
||||
MAX_RETRIES = 5
|
||||
|
||||
def create_headers(api_key):
|
||||
"""Create headers for API requests."""
|
||||
return {
|
||||
'Authorization': f'Bearer {api_key}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
async def fetch_tunnels(api_key, account_id, hass, entry_id, retries=0):
|
||||
"""Retrieve Cloudflare tunnel status using aiohttp."""
|
||||
headers = create_headers(api_key)
|
||||
url = URL.format(account_id)
|
||||
|
||||
_LOGGER.debug(f"Attempt {retries + 1} to fetch tunnels from URL: {url}")
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with async_timeout.timeout(TIMEOUT):
|
||||
async with session.get(url, headers=headers) as response:
|
||||
_LOGGER.debug(f"Response status: {response.status}")
|
||||
if response.status == 200:
|
||||
json_response = await response.json()
|
||||
_LOGGER.debug(f"Received data: {json_response}")
|
||||
return json_response['result']
|
||||
elif response.status == 401:
|
||||
raise UpdateFailed("Unauthorized access - check your API key")
|
||||
else:
|
||||
raise UpdateFailed(f"Error fetching Cloudflare tunnels: {response.status}, {response.reason}")
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.error(f"Client error fetching data: {err}")
|
||||
raise UpdateFailed("Client error occurred while fetching data") from err
|
||||
except asyncio.TimeoutError:
|
||||
_LOGGER.error("Timeout error fetching data")
|
||||
raise UpdateFailed("Timeout error occurred while fetching data")
|
||||
except Exception as err:
|
||||
_LOGGER.error(f"Unexpected error fetching data: {err}")
|
||||
if retries < MAX_RETRIES:
|
||||
_LOGGER.info(f"Retrying in {RETRY_DELAY} seconds...")
|
||||
await asyncio.sleep(RETRY_DELAY)
|
||||
return await fetch_tunnels(api_key, account_id, hass, entry_id, retries + 1)
|
||||
else:
|
||||
_LOGGER.error("Maximum number of retries reached, scheduling integration reload")
|
||||
await schedule_integration_reload(hass, entry_id)
|
||||
raise UpdateFailed("Maximum retries reached, integration reload scheduled")
|
||||
|
||||
class CloudflareTunnelsDevice(Entity):
|
||||
"""Representation of the Cloudflare Tunnels device."""
|
||||
|
||||
def __init__(self, account_id, domain):
|
||||
"""Initialize the Cloudflare Tunnels device."""
|
||||
self._account_id = account_id
|
||||
self._domain = domain
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return a unique ID."""
|
||||
return f"{self._domain}_cloudflare_tunnels_{self._account_id}"
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the device."""
|
||||
return "Cloudflare Tunnels"
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
"""Return device information."""
|
||||
return {
|
||||
"identifiers": {(self._domain, self.unique_id)},
|
||||
"name": self.name,
|
||||
"manufacturer": "Cloudflare",
|
||||
}
|
||||
|
||||
class CloudflareTunnelSensor(SensorEntity):
|
||||
"""Representation of a Cloudflare tunnel sensor."""
|
||||
|
||||
def __init__(self, tunnel, coordinator, device):
|
||||
"""Initialize the Cloudflare tunnel sensor."""
|
||||
self.coordinator = coordinator
|
||||
self._tunnel = tunnel
|
||||
self._device = device
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the sensor."""
|
||||
return f"Cloudflare Tunnel {self._tunnel['name']}"
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return a unique ID."""
|
||||
return f"{self._device._domain}_{self._tunnel['id']}"
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state of the sensor."""
|
||||
return self._tunnel['status']
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
"""Return the icon of the sensor."""
|
||||
return 'mdi:cloud-check' if self._tunnel['status'] == 'healthy' else 'mdi:cloud-off-outline'
|
||||
|
||||
@property
|
||||
def options(self):
|
||||
"""Return the possible values of the sensor."""
|
||||
return ["inactive", "degraded", "healthy", "down"]
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the device class of the sensor."""
|
||||
return SensorDeviceClass.ENUM
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
"""Return device information."""
|
||||
return {
|
||||
"identifiers": {(self._device._domain, self._device.unique_id)},
|
||||
"name": self._device.name,
|
||||
"manufacturer": "Cloudflare",
|
||||
}
|
||||
|
||||
async def async_update(self):
|
||||
"""Update the state of the sensor."""
|
||||
_LOGGER.debug(f"Requesting refresh for tunnel {self._tunnel['id']}")
|
||||
await self.coordinator.async_request_refresh()
|
||||
if self.coordinator.data is not None:
|
||||
_LOGGER.debug(f"Coordinator data is not None. Searching for updated tunnel data for {self._tunnel['id']}")
|
||||
|
||||
updated_tunnel = next((tunnel for tunnel in self.coordinator.data if tunnel.get('id') == self._tunnel.get('id')), None)
|
||||
if updated_tunnel is not None:
|
||||
_LOGGER.debug(f"Found updated data for tunnel {self._tunnel['id']}")
|
||||
self._tunnel = updated_tunnel
|
||||
_LOGGER.debug("Tunnel updated data: %s", self._tunnel)
|
||||
else:
|
||||
_LOGGER.error("Tunnel with ID %s not found in the updated data", self._tunnel.get('id'))
|
||||
else:
|
||||
_LOGGER.error("No data received in coordinator during update, maintaining previous state")
|
||||
|
||||
class CloudflareTunnelManager:
|
||||
"""Manages Cloudflare Tunnel Sensor entities."""
|
||||
|
||||
def __init__(self, hass, async_add_entities, coordinator, device):
|
||||
self._hass = hass
|
||||
self._async_add_entities = async_add_entities
|
||||
self._coordinator = coordinator
|
||||
self._device = device
|
||||
self._sensors = {}
|
||||
|
||||
async def update_sensors(self, new_tunnels, removed_tunnels):
|
||||
"""Update sensor entities based on the tunnel changes."""
|
||||
_LOGGER.debug(f"Updating sensors. New: {new_tunnels}, Removed: {removed_tunnels}")
|
||||
|
||||
for tunnel in new_tunnels:
|
||||
sensor_id = f"{self._device._domain}_{tunnel['id']}"
|
||||
if sensor_id not in self._sensors:
|
||||
_LOGGER.info(f"Adding new sensor for tunnel: {tunnel['id']}")
|
||||
sensor = CloudflareTunnelSensor(tunnel, self._coordinator, self._device)
|
||||
self._sensors[sensor_id] = sensor
|
||||
self._async_add_entities([sensor], True)
|
||||
|
||||
for tunnel in removed_tunnels:
|
||||
sensor_id = f"{self._device._domain}_{tunnel['id']}"
|
||||
if sensor_id in self._sensors:
|
||||
_LOGGER.info(f"Removing sensor for tunnel: {sensor_id}")
|
||||
try:
|
||||
sensor = self._sensors.pop(sensor_id)
|
||||
await sensor.async_remove()
|
||||
except Exception as e:
|
||||
_LOGGER.error(f"Error removing sensor for tunnel {sensor_id}: {e}")
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up the Cloudflare tunnel sensor."""
|
||||
api_key = config_entry.data["api_key"]
|
||||
account_id = config_entry.data["account_id"]
|
||||
device = CloudflareTunnelsDevice(account_id, DOMAIN)
|
||||
|
||||
async def async_update_data():
|
||||
"""Fetch data from API endpoint and detect changes in tunnels."""
|
||||
_LOGGER.debug("Fetching new tunnel data from Cloudflare")
|
||||
new_data = await fetch_tunnels(api_key, account_id, hass, config_entry.entry_id)
|
||||
if new_data is None:
|
||||
new_data = []
|
||||
|
||||
if coordinator.data is None:
|
||||
coordinator.data = []
|
||||
|
||||
current_ids = {tunnel['id'] for tunnel in coordinator.data}
|
||||
new_ids = {tunnel['id'] for tunnel in new_data}
|
||||
|
||||
added_tunnels = [tunnel for tunnel in new_data if tunnel['id'] not in current_ids]
|
||||
|
||||
removed_tunnels = [tunnel for tunnel in coordinator.data if tunnel['id'] not in new_ids]
|
||||
|
||||
_LOGGER.debug(f"Added tunnels: {added_tunnels}, Removed tunnels: {removed_tunnels}")
|
||||
|
||||
if added_tunnels or removed_tunnels:
|
||||
await tunnel_manager.update_sensors(added_tunnels, removed_tunnels)
|
||||
|
||||
return new_data
|
||||
|
||||
coordinator = DataUpdateCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name="cloudflare_tunnel",
|
||||
update_method=async_update_data,
|
||||
update_interval=timedelta(minutes=1),
|
||||
)
|
||||
|
||||
tunnel_manager = CloudflareTunnelManager(hass, async_add_entities, coordinator, device)
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
if not hasattr(tunnel_manager, 'initialized') or not tunnel_manager.initialized:
|
||||
_LOGGER.debug("Creating initial sensor entities")
|
||||
for tunnel in coordinator.data:
|
||||
sensor_id = f"{device._domain}_{tunnel['id']}"
|
||||
if sensor_id not in tunnel_manager._sensors:
|
||||
sensor = CloudflareTunnelSensor(tunnel, coordinator, device)
|
||||
tunnel_manager._sensors[sensor_id] = sensor
|
||||
async_add_entities([sensor], True)
|
||||
tunnel_manager.initialized = True
|
||||
|
||||
hass.bus.async_listen_once("homeassistant_stop", async_shutdown)
|
||||
|
||||
async def async_shutdown(event):
|
||||
"""Close aiohttp session when Home Assistant stops."""
|
||||
_LOGGER.debug("Cloudflare Tunnel Monitor - aiohttp session closed")
|
||||
|
||||
async def schedule_integration_reload(hass, entry_id):
|
||||
"""Schedule a reload of the integration."""
|
||||
_LOGGER.info(f"Scheduling reload of integration with entry_id {entry_id}")
|
||||
await hass.config_entries.async_reload(entry_id)
|
||||
@@ -1,38 +0,0 @@
|
||||
"""The Cync Room Lights integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from .const import DOMAIN
|
||||
from .cync_hub import CyncHub
|
||||
|
||||
PLATFORMS: list[str] = ["light","binary_sensor","switch","fan"]
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Cync Room Lights from a config entry."""
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
remove_options_update_listener = entry.add_update_listener(options_update_listener)
|
||||
hub = CyncHub(entry.data, entry.options, remove_options_update_listener)
|
||||
hass.data[DOMAIN][entry.entry_id] = hub
|
||||
hub.start_tcp_client()
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
async def options_update_listener(
|
||||
hass: HomeAssistant, config_entry: config_entries.ConfigEntry
|
||||
):
|
||||
"""Handle options update."""
|
||||
await hass.config_entries.async_reload(config_entry.entry_id)
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
hub = hass.data[DOMAIN][entry.entry_id]
|
||||
hub.remove_options_update_listener()
|
||||
hub.disconnect()
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
@@ -1,123 +0,0 @@
|
||||
"""Platform for binary sensor integration."""
|
||||
from __future__ import annotations
|
||||
from typing import Any
|
||||
from homeassistant.components.binary_sensor import (BinarySensorDeviceClass, BinarySensorEntity)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from .const import DOMAIN
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
hub = hass.data[DOMAIN][config_entry.entry_id]
|
||||
|
||||
new_devices = []
|
||||
for sensor in hub.cync_motion_sensors:
|
||||
if not hub.cync_motion_sensors[sensor]._update_callback and sensor in config_entry.options["motion_sensors"]:
|
||||
new_devices.append(CyncMotionSensorEntity(hub.cync_motion_sensors[sensor]))
|
||||
for sensor in hub.cync_ambient_light_sensors:
|
||||
if not hub.cync_ambient_light_sensors[sensor]._update_callback and sensor in config_entry.options["ambient_light_sensors"]:
|
||||
new_devices.append(CyncAmbientLightSensorEntity(hub.cync_ambient_light_sensors[sensor]))
|
||||
|
||||
if new_devices:
|
||||
async_add_entities(new_devices)
|
||||
|
||||
|
||||
class CyncMotionSensorEntity(BinarySensorEntity):
|
||||
"""Representation of a Cync Motion Sensor."""
|
||||
|
||||
should_poll = False
|
||||
|
||||
def __init__(self, motion_sensor) -> None:
|
||||
"""Initialize the sensor."""
|
||||
self.motion_sensor = motion_sensor
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when this Entity has been added to HA."""
|
||||
self.motion_sensor.register(self.schedule_update_ha_state)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Entity being removed from hass."""
|
||||
self.motion_sensor.reset()
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return device registry information for this entity."""
|
||||
return DeviceInfo(
|
||||
identifiers = {(DOMAIN, f"{self.motion_sensor.room.name} ({self.motion_sensor.home_name})")},
|
||||
manufacturer = "Cync by Savant",
|
||||
name = f"{self.motion_sensor.room.name} ({self.motion_sensor.home_name})",
|
||||
suggested_area = f"{self.motion_sensor.room.name}",
|
||||
)
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Return Unique ID string."""
|
||||
return 'cync_motion_sensor_' + self.motion_sensor.device_id
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the name of the motion_sensor."""
|
||||
return self.motion_sensor.name + " Motion"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if light is on."""
|
||||
return self.motion_sensor.motion
|
||||
|
||||
@property
|
||||
def device_class(self) -> str | None:
|
||||
"""Return the device class"""
|
||||
return BinarySensorDeviceClass.MOTION
|
||||
|
||||
class CyncAmbientLightSensorEntity(BinarySensorEntity):
|
||||
"""Representation of a Cync Ambient Light Sensor."""
|
||||
|
||||
should_poll = False
|
||||
|
||||
def __init__(self, ambient_light_sensor) -> None:
|
||||
"""Initialize the sensor."""
|
||||
self.ambient_light_sensor = ambient_light_sensor
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when this Entity has been added to HA."""
|
||||
self.ambient_light_sensor.register(self.schedule_update_ha_state)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Entity being removed from hass."""
|
||||
self.ambient_light_sensor.reset()
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return device registry information for this entity."""
|
||||
return DeviceInfo(
|
||||
identifiers = {(DOMAIN, f"{self.ambient_light_sensor.room.name} ({self.ambient_light_sensor.home_name})")},
|
||||
manufacturer = "Cync by Savant",
|
||||
name = f"{self.ambient_light_sensor.room.name} ({self.ambient_light_sensor.home_name})",
|
||||
suggested_area = f"{self.ambient_light_sensor.room.name}",
|
||||
)
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Return Unique ID string."""
|
||||
return 'cync_ambient_light_sensor_' + self.ambient_light_sensor.device_id
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the name of the ambient_light_sensor."""
|
||||
return self.ambient_light_sensor.name + " Ambient Light"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if light is on."""
|
||||
return self.ambient_light_sensor.ambient_light
|
||||
|
||||
@property
|
||||
def device_class(self) -> str | None:
|
||||
"""Return the device class"""
|
||||
return BinarySensorDeviceClass.LIGHT
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
"""Config flow for Cync Room Lights integration."""
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
import voluptuous as vol
|
||||
from typing import Any
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.core import callback
|
||||
from .const import DOMAIN
|
||||
from .cync_hub import CyncUserData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("username"): str,
|
||||
vol.Required("password"): str,
|
||||
}
|
||||
)
|
||||
STEP_TWO_FACTOR_CODE = vol.Schema(
|
||||
{
|
||||
vol.Required("two_factor_code"): str,
|
||||
}
|
||||
)
|
||||
|
||||
async def cync_login(hub, user_input: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Validate the user input"""
|
||||
|
||||
response = await hub.authenticate(user_input["username"], user_input["password"])
|
||||
if response['authorized']:
|
||||
return {'title':'cync_lights_'+ user_input['username'],'data':{'cync_credentials': hub.auth_code, 'user_input':user_input}}
|
||||
else:
|
||||
if response['two_factor_code_required']:
|
||||
raise TwoFactorCodeRequired
|
||||
else:
|
||||
raise InvalidAuth
|
||||
|
||||
async def submit_two_factor_code(hub, user_input: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Validate the two factor code"""
|
||||
|
||||
response = await hub.auth_two_factor(user_input["two_factor_code"])
|
||||
if response['authorized']:
|
||||
return {'title':'cync_lights_'+ hub.username,'data':{'cync_credentials': hub.auth_code, 'user_input': {'username':hub.username,'password':hub.password}}}
|
||||
else:
|
||||
raise InvalidAuth
|
||||
|
||||
class CyncConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Cync Room Lights."""
|
||||
|
||||
def __init__(self):
|
||||
self.cync_hub = CyncUserData()
|
||||
self.data ={}
|
||||
self.options = {}
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle user and password for Cync account."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA
|
||||
)
|
||||
|
||||
errors = {}
|
||||
|
||||
try:
|
||||
info = await cync_login(self.cync_hub, user_input)
|
||||
info["data"]["cync_config"] = await self.cync_hub.get_cync_config()
|
||||
except TwoFactorCodeRequired:
|
||||
return await self.async_step_two_factor_code()
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
_LOGGER.error(str(type(e).__name__) + ": " + str(e))
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
self.data = info
|
||||
return await self.async_step_finish_setup()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_two_factor_code(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle two factor authentication for Cync account."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="two_factor_code", data_schema=STEP_TWO_FACTOR_CODE
|
||||
)
|
||||
|
||||
errors = {}
|
||||
|
||||
try:
|
||||
info = await submit_two_factor_code(self.cync_hub, user_input)
|
||||
info["data"]["cync_config"] = await self.cync_hub.get_cync_config()
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
_LOGGER.error(str(type(e).__name__) + ": " + str(e))
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
self.data = info
|
||||
return await self.async_step_select_switches()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
|
||||
async def async_step_select_switches(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Select rooms and individual switches for entity creation"""
|
||||
if user_input is not None:
|
||||
self.options = user_input
|
||||
return await self._async_finish_setup()
|
||||
|
||||
switches_data_schema = vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
"rooms",
|
||||
description = {"suggested_value" : [room for room in self.data["data"]["cync_config"]["rooms"].keys() if not self.data["data"]["cync_config"]["rooms"][room]['isSubgroup']]},
|
||||
): cv.multi_select({room : f'{room_info["name"]} ({room_info["home_name"]})' for room,room_info in self.data["data"]["cync_config"]["rooms"].items() if not self.data["data"]["cync_config"]["rooms"][room]['isSubgroup']}),
|
||||
vol.Optional(
|
||||
"subgroups",
|
||||
description = {"suggested_value" : [room for room in self.data["data"]["cync_config"]["rooms"].keys() if self.data["data"]["cync_config"]["rooms"][room]['isSubgroup']]},
|
||||
): cv.multi_select({room : f'{room_info["name"]} ({room_info.get("parent_room","")}:{room_info["home_name"]})' for room,room_info in self.data["data"]["cync_config"]["rooms"].items() if self.data["data"]["cync_config"]["rooms"][room]['isSubgroup']}),
|
||||
vol.Optional(
|
||||
"switches",
|
||||
description = {"suggested_value" : [device_id for device_id,device_info in self.data["data"]["cync_config"]["devices"].items() if device_info['FAN']]},
|
||||
): cv.multi_select({switch_id : f'{sw_info["name"]} ({sw_info["room_name"]}:{sw_info["home_name"]})' for switch_id,sw_info in self.data["data"]["cync_config"]["devices"].items() if sw_info.get('ONOFF',False) and sw_info.get('MULTIELEMENT',1) == 1}),
|
||||
vol.Optional(
|
||||
"motion_sensors",
|
||||
description = {"suggested_value" : [device_id for device_id,device_info in self.data["data"]["cync_config"]["devices"].items() if device_info['MOTION']]},
|
||||
): cv.multi_select({device_id : f'{device_info["name"]} ({device_info["room_name"]}:{device_info["home_name"]})' for device_id,device_info in self.data["data"]["cync_config"]["devices"].items() if device_info.get('MOTION',False)}),
|
||||
vol.Optional(
|
||||
"ambient_light_sensors",
|
||||
description = {"suggested_value" : [device_id for device_id,device_info in self.data["data"]["cync_config"]["devices"].items() if device_info['AMBIENT_LIGHT']]},
|
||||
): cv.multi_select({device_id : f'{device_info["name"]} ({device_info["room_name"]}:{device_info["home_name"]})' for device_id,device_info in self.data["data"]["cync_config"]["devices"].items() if device_info.get('AMBIENT_LIGHT',False)}),
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(step_id="select_switches", data_schema=switches_data_schema)
|
||||
|
||||
async def _async_finish_setup(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Finish setup and create entry"""
|
||||
|
||||
existing_entry = await self.async_set_unique_id(self.data['title'])
|
||||
if not existing_entry:
|
||||
return self.async_create_entry(title=self.data["title"], data=self.data["data"], options=self.options)
|
||||
else:
|
||||
self.hass.config_entries.async_update_entry(existing_entry, data=self.data['data'], options=self.options)
|
||||
await self.hass.config_entries.async_reload(existing_entry.entry_id)
|
||||
return self.hass.config_entries.async_abort(reason="reauth_successful")
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry):
|
||||
"""Get the options flow for this handler."""
|
||||
return CyncOptionsFlowHandler(config_entry)
|
||||
|
||||
|
||||
class CyncOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
|
||||
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
|
||||
"""Initialize options flow."""
|
||||
self.entry = config_entry
|
||||
self.cync_hub = CyncUserData()
|
||||
self.data = {}
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Manage the options."""
|
||||
|
||||
if user_input is not None:
|
||||
if user_input['re-authenticate'] == "No":
|
||||
return await self.async_step_select_switches()
|
||||
else:
|
||||
return await self.async_step_auth()
|
||||
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
"re-authenticate",default="No"): vol.In(["Yes","No"]),
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(step_id="init", data_schema=data_schema)
|
||||
|
||||
async def async_step_auth(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Attempt to re-authenticate"""
|
||||
|
||||
errors = {}
|
||||
|
||||
try:
|
||||
info = await cync_login(self.cync_hub, self.entry.data['user_input'])
|
||||
info["data"]["cync_config"] = await self.cync_hub.get_cync_config()
|
||||
except TwoFactorCodeRequired:
|
||||
return await self.async_step_two_factor_code()
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
_LOGGER.error(str(type(e).__name__) + ": " + str(e))
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
self.data = info
|
||||
return await self.async_step_select_switches()
|
||||
|
||||
async def async_step_two_factor_code(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle two factor authentication for Cync account."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="two_factor_code", data_schema=STEP_TWO_FACTOR_CODE
|
||||
)
|
||||
|
||||
errors = {}
|
||||
|
||||
try:
|
||||
info = await submit_two_factor_code(self.cync_hub, user_input)
|
||||
info["data"]["cync_config"] = await self.cync_hub.get_cync_config()
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
_LOGGER.error(str(type(e).__name__) + ": " + str(e))
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
self.data = info
|
||||
return await self.async_step_select_switches()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="two_factor_code", data_schema=STEP_TWO_FACTOR_CODE, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_select_switches(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Manage the options."""
|
||||
|
||||
if "data" in self.data and self.data["data"] != self.entry.data:
|
||||
self.hass.config_entries.async_update_entry(self.entry, data = self.data["data"])
|
||||
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="",data=user_input)
|
||||
|
||||
switches_data_schema = vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
"rooms",
|
||||
description = {"suggested_value" : [room for room in self.entry.options["rooms"] if room in self.entry.data["cync_config"]["rooms"].keys()]},
|
||||
): cv.multi_select({room : f'{room_info["name"]} ({room_info["home_name"]})' for room,room_info in self.entry.data["cync_config"]["rooms"].items() if not self.data["data"]["cync_config"]["rooms"][room]['isSubgroup']}),
|
||||
vol.Optional(
|
||||
"subgroups",
|
||||
description = {"suggested_value" : [room for room in self.entry.options["subgroups"] if room in self.entry.data["cync_config"]["rooms"].keys()]},
|
||||
): cv.multi_select({room : f'{room_info["name"]} ({room_info.get("parent_room","")}:{room_info["home_name"]})' for room,room_info in self.entry.data["cync_config"]["rooms"].items() if self.data["data"]["cync_config"]["rooms"][room]['isSubgroup']}),
|
||||
vol.Optional(
|
||||
"switches",
|
||||
description = {"suggested_value" : [sw for sw in self.entry.options["switches"] if sw in self.entry.data["cync_config"]["devices"].keys()]},
|
||||
): cv.multi_select({switch_id : f'{sw_info["name"]} ({sw_info["room_name"]}:{sw_info["home_name"]})' for switch_id,sw_info in self.entry.data["cync_config"]["devices"].items() if sw_info.get('ONOFF',False) and sw_info.get('MULTIELEMENT',1) == 1}),
|
||||
vol.Optional(
|
||||
"motion_sensors",
|
||||
description = {"suggested_value" : [sensor for sensor in self.entry.options["motion_sensors"] if sensor in self.entry.data["cync_config"]["devices"].keys()]},
|
||||
): cv.multi_select({device_id : f'{device_info["name"]} ({device_info["room_name"]}:{device_info["home_name"]})' for device_id,device_info in self.entry.data["cync_config"]["devices"].items() if device_info.get('MOTION',False)}),
|
||||
vol.Optional(
|
||||
"ambient_light_sensors",
|
||||
description = {"suggested_value" : [sensor for sensor in self.entry.options["ambient_light_sensors"] if sensor in self.entry.data["cync_config"]["devices"].keys()]},
|
||||
): cv.multi_select({device_id : f'{device_info["name"]} ({device_info["room_name"]}:{device_info["home_name"]})' for device_id,device_info in self.entry.data["cync_config"]["devices"].items() if device_info.get('AMBIENT_LIGHT',False)}),
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(step_id="select_switches", data_schema=switches_data_schema)
|
||||
|
||||
class TwoFactorCodeRequired(HomeAssistantError):
|
||||
"""Error to indicate we cannot connect."""
|
||||
|
||||
class InvalidAuth(HomeAssistantError):
|
||||
"""Error to indicate there is invalid auth."""
|
||||
@@ -1,3 +0,0 @@
|
||||
"""Constants for the Cync Room Lights integration."""
|
||||
|
||||
DOMAIN = "cync_lights"
|
||||
@@ -1,870 +0,0 @@
|
||||
import logging
|
||||
import threading
|
||||
import asyncio
|
||||
import struct
|
||||
import aiohttp
|
||||
import math
|
||||
import ssl
|
||||
from typing import Any
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
API_AUTH = "https://api.gelighting.com/v2/user_auth"
|
||||
API_REQUEST_CODE = "https://api.gelighting.com/v2/two_factor/email/verifycode"
|
||||
API_2FACTOR_AUTH = "https://api.gelighting.com/v2/user_auth/two_factor"
|
||||
API_DEVICES = "https://api.gelighting.com/v2/user/{user}/subscribe/devices"
|
||||
API_DEVICE_INFO = "https://api.gelighting.com/v2/product/{product_id}/device/{device_id}/property"
|
||||
|
||||
Capabilities = {
|
||||
"ONOFF":[1,5,6,7,8,9,10,11,13,14,15,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,48,49,51,52,53,54,55,56,57,58,59,61,62,63,64,65,66,67,68,80,81,82,83,85,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,158,159,160,161,162,163,164,165,166,169,170],
|
||||
"BRIGHTNESS":[1,5,6,7,8,9,10,11,13,14,15,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,48,49,55,56,80,81,82,83,85,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,158,159,160,161,162,163,164,165,166,169,170],
|
||||
"COLORTEMP":[5,6,7,8,10,11,14,15,19,20,21,22,23,25,26,28,29,30,31,32,33,34,35,80,82,83,85,129,130,131,132,133,135,136,137,138,139,140,141,142,143,144,145,146,147,153,154,155,156,158,159,160,161,162,163,164,165,166,169,170],
|
||||
"RGB":[6,7,8,21,22,23,30,31,32,33,34,35,131,132,133,137,138,139,140,141,142,143,146,147,153,154,155,156,158,159,160,161,162,163,164,165,166,169,170],
|
||||
"MOTION":[37,49,54],
|
||||
"AMBIENT_LIGHT":[37,49,54],
|
||||
"WIFICONTROL":[36,37,38,39,40,48,49,51,52,53,54,55,56,57,58,59,61,62,63,64,65,66,67,68,80,81,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,158,159,160,161,162,163,164,165,166,169,170],
|
||||
"PLUG":[64,65,66,67,68],
|
||||
"FAN":[81],
|
||||
"MULTIELEMENT":{'67':2}
|
||||
}
|
||||
|
||||
class CyncHub:
|
||||
|
||||
def __init__(self, user_data, options, remove_options_update_listener):
|
||||
|
||||
self.thread = None
|
||||
self.loop = None
|
||||
self.reader = None
|
||||
self.writer = None
|
||||
self.login_code = bytearray(user_data['cync_credentials'])
|
||||
self.logged_in = False
|
||||
self.home_devices = user_data['cync_config']['home_devices']
|
||||
self.home_controllers = user_data['cync_config']['home_controllers']
|
||||
self.switchID_to_homeID = user_data['cync_config']['switchID_to_homeID']
|
||||
self.connected_devices = {home_id:[] for home_id in self.home_controllers.keys()}
|
||||
self.shutting_down = False
|
||||
self.remove_options_update_listener = remove_options_update_listener
|
||||
self.cync_rooms = {room_id:CyncRoom(room_id,room_info,self) for room_id,room_info in user_data['cync_config']['rooms'].items()}
|
||||
self.cync_switches = {device_id:CyncSwitch(device_id,switch_info,self.cync_rooms.get(switch_info['room'], None),self) for device_id,switch_info in user_data['cync_config']['devices'].items() if switch_info.get("ONOFF",False)}
|
||||
self.cync_motion_sensors = {device_id:CyncMotionSensor(device_id,device_info,self.cync_rooms.get(device_info['room'], None)) for device_id,device_info in user_data['cync_config']['devices'].items() if device_info.get("MOTION",False)}
|
||||
self.cync_ambient_light_sensors = {device_id:CyncAmbientLightSensor(device_id,device_info,self.cync_rooms.get(device_info['room'], None)) for device_id,device_info in user_data['cync_config']['devices'].items() if device_info.get("AMBIENT_LIGHT",False)}
|
||||
self.switchID_to_deviceIDs = {device_info.switch_id:[dev_id for dev_id, dev_info in self.cync_switches.items() if dev_info.switch_id == device_info.switch_id] for device_id, device_info in self.cync_switches.items() if int(device_info.switch_id) > 0}
|
||||
self.connected_devices_updated = False
|
||||
self.options = options
|
||||
self._seq_num = 0
|
||||
self.pending_commands = {}
|
||||
[room.initialize() for room in self.cync_rooms.values() if room.is_subgroup]
|
||||
[room.initialize() for room in self.cync_rooms.values() if not room.is_subgroup]
|
||||
|
||||
def start_tcp_client(self):
|
||||
self.thread = threading.Thread(target=self._start_tcp_client,daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def _start_tcp_client(self):
|
||||
self.loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self.loop)
|
||||
self.loop.run_until_complete(self._connect())
|
||||
|
||||
def disconnect(self):
|
||||
self.shutting_down = True
|
||||
for home_controllers in self.home_controllers.values(): #send packets to server to generate data to be read which will initiate shutdown
|
||||
for controller in home_controllers:
|
||||
seq = self.get_seq_num()
|
||||
state_request = bytes.fromhex('7300000018') + int(controller).to_bytes(4,'big') + seq.to_bytes(2,'big') + bytes.fromhex('007e00000000f85206000000ffff0000567e')
|
||||
self.loop.call_soon_threadsafe(self.send_request,state_request)
|
||||
|
||||
async def _connect(self):
|
||||
while not self.shutting_down:
|
||||
try:
|
||||
context = ssl.create_default_context()
|
||||
try:
|
||||
self.reader, self.writer = await asyncio.open_connection('cm.gelighting.com', 23779, ssl = context)
|
||||
except Exception as e:
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
try:
|
||||
self.reader, self.writer = await asyncio.open_connection('cm.gelighting.com', 23779, ssl = context)
|
||||
except Exception as e:
|
||||
self.reader, self.writer = await asyncio.open_connection('cm.gelighting.com', 23778)
|
||||
except Exception as e:
|
||||
_LOGGER.error(str(type(e).__name__) + ": " + str(e))
|
||||
await asyncio.sleep(5)
|
||||
else:
|
||||
read_tcp_messages = asyncio.create_task(self._read_tcp_messages(), name = "Read TCP Messages")
|
||||
maintain_connection = asyncio.create_task(self._maintain_connection(), name = "Maintain Connection")
|
||||
update_state = asyncio.create_task(self._update_state(), name = "Update State")
|
||||
update_connected_devices = asyncio.create_task(self._update_connected_devices(), name = "Update Connected Devices")
|
||||
read_write_tasks = [read_tcp_messages, maintain_connection, update_state, update_connected_devices]
|
||||
try:
|
||||
done, pending = await asyncio.wait(read_write_tasks,return_when=asyncio.FIRST_EXCEPTION)
|
||||
for task in done:
|
||||
name = task.get_name()
|
||||
exception = task.exception()
|
||||
try:
|
||||
result = task.result()
|
||||
except Exception as e:
|
||||
_LOGGER.error(str(type(e).__name__) + ": " + str(e))
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
if not self.shutting_down:
|
||||
_LOGGER.error("Connection to Cync server reset, restarting in 15 seconds")
|
||||
await asyncio.sleep(15)
|
||||
else:
|
||||
_LOGGER.debug("Cync client shutting down")
|
||||
except Exception as e:
|
||||
_LOGGER.error(str(type(e).__name__) + ": " + str(e))
|
||||
|
||||
async def _read_tcp_messages(self):
|
||||
self.writer.write(self.login_code)
|
||||
await self.writer.drain()
|
||||
await self.reader.read(1000)
|
||||
self.logged_in = True
|
||||
while not self.shutting_down:
|
||||
data = await self.reader.read(1000)
|
||||
if len(data) == 0:
|
||||
self.logged_in = False
|
||||
raise LostConnection
|
||||
while len(data) >= 12:
|
||||
packet_type = int(data[0])
|
||||
packet_length = struct.unpack(">I", data[1:5])[0]
|
||||
packet = data[5:packet_length+5]
|
||||
try:
|
||||
if packet_length == len(packet):
|
||||
if packet_type == 115:
|
||||
switch_id = str(struct.unpack(">I", packet[0:4])[0])
|
||||
home_id = self.switchID_to_homeID[switch_id]
|
||||
|
||||
#send response packet
|
||||
response_id = struct.unpack(">H", packet[4:6])[0]
|
||||
response_packet = bytes.fromhex('7300000007') + int(switch_id).to_bytes(4,'big') + response_id.to_bytes(2,'big') + bytes.fromhex('00')
|
||||
self.loop.call_soon_threadsafe(self.send_request, response_packet)
|
||||
|
||||
if packet_length >= 33 and int(packet[13]) == 219:
|
||||
#parse state and brightness change packet
|
||||
deviceID = self.home_devices[home_id][int(packet[21])]
|
||||
state = int(packet[27]) > 0
|
||||
brightness = int(packet[28]) if state else 0
|
||||
if deviceID in self.cync_switches:
|
||||
self.cync_switches[deviceID].update_switch(state,brightness,self.cync_switches[deviceID].color_temp_kelvin,self.cync_switches[deviceID].rgb)
|
||||
elif packet_length >= 25 and int(packet[13]) == 84:
|
||||
#parse motion and ambient light sensor packet
|
||||
deviceID = self.home_devices[home_id][int(packet[16])]
|
||||
motion = int(packet[22]) > 0
|
||||
ambient_light = int(packet[24]) > 0
|
||||
if deviceID in self.cync_motion_sensors:
|
||||
self.cync_motion_sensors[deviceID].update_motion_sensor(motion)
|
||||
if deviceID in self.cync_ambient_light_sensors:
|
||||
self.cync_ambient_light_sensors[deviceID].update_ambient_light_sensor(ambient_light)
|
||||
elif packet_length > 51 and int(packet[13]) == 82:
|
||||
#parse initial state packet
|
||||
switch_id = str(struct.unpack(">I", packet[0:4])[0])
|
||||
home_id = self.switchID_to_homeID[switch_id]
|
||||
self._add_connected_devices(switch_id, home_id)
|
||||
packet = packet[22:]
|
||||
while len(packet) > 24:
|
||||
deviceID = self.home_devices[home_id][int(packet[0])]
|
||||
if deviceID in self.cync_switches:
|
||||
if self.cync_switches[deviceID].elements > 1:
|
||||
for i in range(self.cync_switches[deviceID].elements):
|
||||
device_id = self.home_devices[home_id][(i+1)*256 + int(packet[0])]
|
||||
state = int((int(packet[12]) >> i) & int(packet[8])) > 0
|
||||
brightness = 100 if state else 0
|
||||
self.cync_switches[device_id].update_switch(state,brightness,self.cync_switches[device_id].color_temp_kelvin,self.cync_switches[device_id].rgb)
|
||||
else:
|
||||
state = int(packet[8]) > 0
|
||||
brightness = int(packet[12]) if state else 0
|
||||
color_temp = int(packet[16])
|
||||
rgb = {'r':int(packet[20]),'g':int(packet[21]),'b':int(packet[22]),'active':int(packet[16])==254}
|
||||
self.cync_switches[deviceID].update_switch(state,brightness,color_temp,rgb)
|
||||
packet = packet[24:]
|
||||
elif packet_type == 131:
|
||||
switch_id = str(struct.unpack(">I", packet[0:4])[0])
|
||||
home_id = self.switchID_to_homeID[switch_id]
|
||||
if packet_length >= 33 and int(packet[13]) == 219:
|
||||
#parse state and brightness change packet
|
||||
deviceID = self.home_devices[home_id][int(packet[21])]
|
||||
state = int(packet[27]) > 0
|
||||
brightness = int(packet[28]) if state else 0
|
||||
if deviceID in self.cync_switches:
|
||||
self.cync_switches[deviceID].update_switch(state,brightness,self.cync_switches[deviceID].color_temp_kelvin,self.cync_switches[deviceID].rgb)
|
||||
elif packet_length >= 25 and int(packet[13]) == 84:
|
||||
#parse motion and ambient light sensor packet
|
||||
deviceID = self.home_devices[home_id][int(packet[16])]
|
||||
motion = int(packet[22]) > 0
|
||||
ambient_light = int(packet[24]) > 0
|
||||
if deviceID in self.cync_motion_sensors:
|
||||
self.cync_motion_sensors[deviceID].update_motion_sensor(motion)
|
||||
if deviceID in self.cync_ambient_light_sensors:
|
||||
self.cync_ambient_light_sensors[deviceID].update_ambient_light_sensor(ambient_light)
|
||||
elif packet_type == 67 and packet_length >= 26 and int(packet[4]) == 1 and int(packet[5]) == 1 and int(packet[6]) == 6:
|
||||
#parse state packet
|
||||
switch_id = str(struct.unpack(">I", packet[0:4])[0])
|
||||
home_id = self.switchID_to_homeID[switch_id]
|
||||
packet = packet[7:]
|
||||
while len(packet) >= 19:
|
||||
if int(packet[3]) < len(self.home_devices[home_id]):
|
||||
deviceID = self.home_devices[home_id][int(packet[3])]
|
||||
if deviceID in self.cync_switches:
|
||||
if self.cync_switches[deviceID].elements > 1:
|
||||
for i in range(self.cync_switches[deviceID].elements):
|
||||
device_id = self.home_devices[home_id][(i+1)*256 + int(packet[3])]
|
||||
state = int((int(packet[5]) >> i) & int(packet[4])) > 0
|
||||
brightness = 100 if state else 0
|
||||
self.cync_switches[device_id].update_switch(state,brightness,self.cync_switches[device_id].color_temp_kelvin,self.cync_switches[device_id].rgb)
|
||||
else:
|
||||
state = int(packet[4]) > 0
|
||||
brightness = int(packet[5]) if state else 0
|
||||
color_temp = int(packet[6])
|
||||
rgb = {'r':int(packet[7]),'g':int(packet[8]),'b':int(packet[9]),'active':int(packet[6])==254}
|
||||
self.cync_switches[deviceID].update_switch(state,brightness,color_temp,rgb)
|
||||
packet = packet[19:]
|
||||
elif packet_type == 171:
|
||||
switch_id = str(struct.unpack(">I", packet[0:4])[0])
|
||||
home_id = self.switchID_to_homeID[switch_id]
|
||||
self._add_connected_devices(switch_id, home_id)
|
||||
elif packet_type == 123:
|
||||
seq = str(struct.unpack(">H", packet[4:6])[0])
|
||||
command_received = self.pending_commands.get(seq,None)
|
||||
if command_received is not None:
|
||||
command_received(seq)
|
||||
except Exception as e:
|
||||
_LOGGER.error(str(type(e).__name__) + ": " + str(e))
|
||||
data = data[packet_length+5:]
|
||||
raise ShuttingDown
|
||||
|
||||
|
||||
async def _maintain_connection(self):
|
||||
while not self.shutting_down:
|
||||
await asyncio.sleep(180)
|
||||
self.writer.write(bytes.fromhex('d300000000'))
|
||||
await self.writer.drain()
|
||||
raise ShuttingDown
|
||||
|
||||
def _add_connected_devices(self,switch_id, home_id):
|
||||
for dev in self.switchID_to_deviceIDs[switch_id]:
|
||||
#update list of WiFi connected devices
|
||||
if dev not in self.connected_devices[home_id]:
|
||||
self.connected_devices[home_id].append(dev)
|
||||
if self.connected_devices_updated:
|
||||
for dev in self.cync_switches.values():
|
||||
dev.update_controllers()
|
||||
for room in self.cync_rooms.values():
|
||||
room.update_controllers()
|
||||
|
||||
async def _update_connected_devices(self):
|
||||
while not self.shutting_down:
|
||||
self.connected_devices_updated = False
|
||||
for devices in self.connected_devices.values():
|
||||
devices.clear()
|
||||
while not self.logged_in:
|
||||
await asyncio.sleep(2)
|
||||
attempts = 0
|
||||
while True in [len(devices) < len(self.home_controllers[home_id]) * 0.5 for home_id,devices in self.connected_devices.items()] and attempts < 10:
|
||||
for home_id, home_controllers in self.home_controllers.items():
|
||||
for controller in home_controllers:
|
||||
seq = self.get_seq_num()
|
||||
ping = bytes.fromhex('a300000007') + int(controller).to_bytes(4,'big') + seq.to_bytes(2,'big') + bytes.fromhex('00')
|
||||
self.loop.call_soon_threadsafe(self.send_request, ping)
|
||||
await asyncio.sleep(0.15)
|
||||
await asyncio.sleep(2)
|
||||
attempts += 1
|
||||
for dev in self.cync_switches.values():
|
||||
dev.update_controllers()
|
||||
for room in self.cync_rooms.values():
|
||||
room.update_controllers()
|
||||
self.connected_devices_updated = True
|
||||
await asyncio.sleep(3600)
|
||||
raise ShuttingDown
|
||||
|
||||
async def _update_state(self):
|
||||
while not self.connected_devices_updated:
|
||||
await asyncio.sleep(2)
|
||||
for connected_devices in self.connected_devices.values():
|
||||
if len(connected_devices) > 0:
|
||||
controller = self.cync_switches[connected_devices[0]].switch_id
|
||||
seq = self.get_seq_num()
|
||||
state_request = bytes.fromhex('7300000018') + int(controller).to_bytes(4,'big') + seq.to_bytes(2,'big') + bytes.fromhex('007e00000000f85206000000ffff0000567e')
|
||||
self.loop.call_soon_threadsafe(self.send_request,state_request)
|
||||
while False in [self.cync_switches[dev_id]._update_callback is not None for dev_id in self.options["switches"]] and False in [self.cync_rooms[dev_id]._update_callback is not None for dev_id in self.options["rooms"]]:
|
||||
await asyncio.sleep(2)
|
||||
for dev in self.cync_switches.values():
|
||||
dev.publish_update()
|
||||
for room in self.cync_rooms.values():
|
||||
dev.publish_update()
|
||||
|
||||
def send_request(self,request):
|
||||
async def send():
|
||||
self.writer.write(request)
|
||||
await self.writer.drain()
|
||||
self.loop.create_task(send())
|
||||
|
||||
def combo_control(self,state,brightness,color_tone,rgb,switch_id,mesh_id,seq):
|
||||
combo_request = bytes.fromhex('7300000022') + int(switch_id).to_bytes(4,'big') + int(seq).to_bytes(2,'big') + bytes.fromhex('007e00000000f8f010000000000000') + mesh_id + bytes.fromhex('f00000') + (1 if state else 0).to_bytes(1,'big') + brightness.to_bytes(1,'big') + color_tone.to_bytes(1,'big') + rgb[0].to_bytes(1,'big') + rgb[1].to_bytes(1,'big') + rgb[2].to_bytes(1,'big') + ((496 + int(mesh_id[0]) + int(mesh_id[1]) + (1 if state else 0) + brightness + color_tone + sum(rgb))%256).to_bytes(1,'big') + bytes.fromhex('7e')
|
||||
self.loop.call_soon_threadsafe(self.send_request,combo_request)
|
||||
|
||||
def turn_on(self,switch_id,mesh_id,seq):
|
||||
power_request = bytes.fromhex('730000001f') + int(switch_id).to_bytes(4,'big') + int(seq).to_bytes(2,'big') + bytes.fromhex('007e00000000f8d00d000000000000') + mesh_id + bytes.fromhex('d00000010000') + ((430 + int(mesh_id[0]) + int(mesh_id[1]))%256).to_bytes(1,'big') + bytes.fromhex('7e')
|
||||
self.loop.call_soon_threadsafe(self.send_request,power_request)
|
||||
|
||||
def turn_off(self,switch_id,mesh_id,seq):
|
||||
power_request = bytes.fromhex('730000001f') + int(switch_id).to_bytes(4,'big') + int(seq).to_bytes(2,'big') + bytes.fromhex('007e00000000f8d00d000000000000') + mesh_id + bytes.fromhex('d00000000000') + ((429 + int(mesh_id[0]) + int(mesh_id[1]))%256).to_bytes(1,'big') + bytes.fromhex('7e')
|
||||
self.loop.call_soon_threadsafe(self.send_request,power_request)
|
||||
|
||||
def set_color_temp(self,color_temp,switch_id,mesh_id,seq):
|
||||
color_temp_request = bytes.fromhex('730000001e') + int(switch_id).to_bytes(4,'big') + int(seq).to_bytes(2,'big') + bytes.fromhex('007e00000000f8e20c000000000000') + mesh_id + bytes.fromhex('e2000005') + color_temp.to_bytes(1,'big') + ((469 + int(mesh_id[0]) + int(mesh_id[1]) + color_temp)%256).to_bytes(1,'big') + bytes.fromhex('7e')
|
||||
self.loop.call_soon_threadsafe(self.send_request,color_temp_request)
|
||||
|
||||
def get_seq_num(self):
|
||||
if self._seq_num == 65535:
|
||||
self._seq_num = 1
|
||||
else:
|
||||
self._seq_num += 1
|
||||
return self._seq_num
|
||||
|
||||
class CyncRoom:
|
||||
|
||||
def __init__(self, room_id, room_info, hub):
|
||||
|
||||
self.hub = hub
|
||||
self.room_id = room_id
|
||||
self.home_id = room_id.split('-')[0]
|
||||
self.name = room_info.get('name','unknown')
|
||||
self.home_name = room_info.get('home_name','unknown')
|
||||
self.parent_room = room_info.get('parent_room', 'unknown')
|
||||
self.mesh_id = int(room_info.get('mesh_id',0)).to_bytes(2,'little')
|
||||
self.power_state = False
|
||||
self.brightness = 0
|
||||
self.color_temp_kelvin = 0
|
||||
self.rgb = {'r':0, 'g':0, 'b':0, 'active': False}
|
||||
self.switches = room_info.get('switches',[])
|
||||
self.subgroups = room_info.get('subgroups',[])
|
||||
self.is_subgroup = room_info.get('isSubgroup', False)
|
||||
self.all_room_switches = self.switches
|
||||
self.controllers = []
|
||||
self.default_controller = room_info.get('room_controller',self.hub.home_controllers[self.home_id][0])
|
||||
self._update_callback = None
|
||||
self._update_parent_room = None
|
||||
self.support_brightness = False
|
||||
self.support_color_temp = False
|
||||
self.support_rgb = False
|
||||
self.switches_support_brightness = False
|
||||
self.switches_support_color_temp = False
|
||||
self.switches_support_rgb = False
|
||||
self.groups_support_brightness = False
|
||||
self.groups_support_color_temp = False
|
||||
self.groups_support_rgb = False
|
||||
self._command_timout = 0.5
|
||||
self._command_retry_time = 5
|
||||
|
||||
def initialize(self):
|
||||
"""Initialization of supported features and registration of update function for all switches and subgroups in the room"""
|
||||
self.switches_support_brightness = [device_id for device_id in self.switches if self.hub.cync_switches[device_id].support_brightness]
|
||||
self.switches_support_color_temp = [device_id for device_id in self.switches if self.hub.cync_switches[device_id].support_color_temp]
|
||||
self.switches_support_rgb = [device_id for device_id in self.switches if self.hub.cync_switches[device_id].support_rgb]
|
||||
self.groups_support_brightness = [room_id for room_id in self.subgroups if self.hub.cync_rooms[room_id].support_brightness]
|
||||
self.groups_support_color_temp = [room_id for room_id in self.subgroups if self.hub.cync_rooms[room_id].support_color_temp]
|
||||
self.groups_support_rgb = [room_id for room_id in self.subgroups if self.hub.cync_rooms[room_id].support_rgb]
|
||||
self.support_brightness = (len(self.switches_support_brightness) + len(self.groups_support_brightness)) > 0
|
||||
self.support_color_temp = (len(self.switches_support_color_temp) + len(self.groups_support_color_temp)) > 0
|
||||
self.support_rgb = (len(self.switches_support_rgb) + len(self.groups_support_rgb)) > 0
|
||||
for switch_id in self.switches:
|
||||
self.hub.cync_switches[switch_id].register_room_updater(self.update_room)
|
||||
for subgroup in self.subgroups:
|
||||
self.hub.cync_rooms[subgroup].register_room_updater(self.update_room)
|
||||
self.all_room_switches = self.all_room_switches + self.hub.cync_rooms[subgroup].switches
|
||||
for subgroup in self.subgroups:
|
||||
self.hub.cync_rooms[subgroup].all_room_switches = self.all_room_switches
|
||||
|
||||
def register(self, update_callback) -> None:
|
||||
"""Register callback, called when switch changes state."""
|
||||
self._update_callback = update_callback
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Remove previously registered callback."""
|
||||
self._update_callback = None
|
||||
|
||||
def register_room_updater(self, parent_updater):
|
||||
self._update_parent_room = parent_updater
|
||||
|
||||
@property
|
||||
def max_color_temp_kelvin(self) -> int:
|
||||
"""Return maximum supported color temperature."""
|
||||
return 7000
|
||||
|
||||
@property
|
||||
def min_color_temp_kelvin(self) -> int:
|
||||
"""Return minimum supported color temperature."""
|
||||
return 2000
|
||||
|
||||
async def turn_on(self, attr_rgb, attr_br, attr_ct) -> None:
|
||||
"""Turn on the light."""
|
||||
attempts = 0
|
||||
update_received = False
|
||||
while not update_received and attempts < int(self._command_retry_time/self._command_timout):
|
||||
seq = str(self.hub.get_seq_num())
|
||||
if len(self.controllers) > 0:
|
||||
controller = self.controllers[attempts%len(self.controllers)]
|
||||
else:
|
||||
controller = self.default_controller
|
||||
if attr_rgb is not None and attr_br is not None:
|
||||
if math.isclose(attr_br, max([self.rgb['r'],self.rgb['g'],self.rgb['b']])*self.brightness/100, abs_tol = 2):
|
||||
self.hub.combo_control(True, self.brightness, 254, attr_rgb, controller, self.mesh_id, seq)
|
||||
else:
|
||||
self.hub.combo_control(True, round(attr_br*100/255), 255, [255,255,255], controller, self.mesh_id, seq)
|
||||
elif attr_rgb is None and attr_ct is None and attr_br is not None:
|
||||
self.hub.combo_control(True, round(attr_br*100/255), 255, [255,255,255], controller, self.mesh_id, seq)
|
||||
elif attr_rgb is not None and attr_br is None:
|
||||
self.hub.combo_control(True, self.brightness, 254, attr_rgb, controller, self.mesh_id, seq)
|
||||
elif attr_ct is not None:
|
||||
self.hub.turn_on(controller, self.mesh_id, seq)
|
||||
# Sync wants the color temp as a percentage of the range. So we need to
|
||||
# calculate what percentage of the color temp range is being requested
|
||||
# before sending it to the server.
|
||||
color_temp = round(
|
||||
(
|
||||
(attr_ct - self.min_color_temp_kelvin) /
|
||||
self.max_color_temp_kelvin
|
||||
) * 100
|
||||
)
|
||||
self.hub.set_color_temp(color_temp, controller, self.mesh_id, seq)
|
||||
else:
|
||||
self.hub.turn_on(controller, self.mesh_id, seq)
|
||||
self.hub.pending_commands[seq] = self.command_received
|
||||
await asyncio.sleep(self._command_timout)
|
||||
if self.hub.pending_commands.get(seq, None) is not None:
|
||||
self.hub.pending_commands.pop(seq)
|
||||
attempts += 1
|
||||
else:
|
||||
update_received = True
|
||||
|
||||
async def turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the light."""
|
||||
attempts = 0
|
||||
update_received = False
|
||||
while not update_received and attempts < int(self._command_retry_time/self._command_timout):
|
||||
seq = str(self.hub.get_seq_num())
|
||||
if len(self.controllers) > 0:
|
||||
controller = self.controllers[attempts%len(self.controllers)]
|
||||
else:
|
||||
controller = self.default_controller
|
||||
self.hub.turn_off(controller, self.mesh_id, seq)
|
||||
self.hub.pending_commands[seq] = self.command_received
|
||||
await asyncio.sleep(self._command_timout)
|
||||
if self.hub.pending_commands.get(seq, None) is not None:
|
||||
self.hub.pending_commands.pop(seq)
|
||||
attempts += 1
|
||||
else:
|
||||
update_received = True
|
||||
|
||||
def command_received(self, seq):
|
||||
"""Remove command from hub.pending_commands when a reply is received from Cync server"""
|
||||
if self.hub.pending_commands.get(seq) is not None:
|
||||
self.hub.pending_commands.pop(seq)
|
||||
|
||||
def update_room(self):
|
||||
"""Update the current state of the room"""
|
||||
_brightness = self.brightness
|
||||
_color_temp = self.color_temp_kelvin
|
||||
_rgb = self.rgb
|
||||
_power_state = True in ([self.hub.cync_switches[device_id].power_state for device_id in self.switches] + [self.hub.cync_rooms[room_id].power_state for room_id in self.subgroups])
|
||||
if self.support_brightness:
|
||||
_brightness = round(sum([self.hub.cync_switches[device_id].brightness for device_id in self.switches] + [self.hub.cync_rooms[room_id].brightness for room_id in self.subgroups])/(len(self.switches) + len(self.subgroups)))
|
||||
else:
|
||||
_brightness = 100 if _power_state else 0
|
||||
if self.support_color_temp:
|
||||
_color_temp = round(sum([self.hub.cync_switches[device_id].color_temp_kelvin for device_id in self.switches_support_color_temp] + [self.hub.cync_rooms[room_id].color_temp_kelvin for room_id in self.groups_support_color_temp])/(len(self.switches_support_color_temp) + len(self.groups_support_color_temp)))
|
||||
if self.support_rgb:
|
||||
_rgb['r'] = round(sum([self.hub.cync_switches[device_id].rgb['r'] for device_id in self.switches_support_rgb] + [self.hub.cync_rooms[room_id].rgb['r'] for room_id in self.groups_support_rgb])/(len(self.switches_support_rgb) + len(self.groups_support_rgb)))
|
||||
_rgb['g'] = round(sum([self.hub.cync_switches[device_id].rgb['g'] for device_id in self.switches_support_rgb] + [self.hub.cync_rooms[room_id].rgb['g'] for room_id in self.groups_support_rgb])/(len(self.switches_support_rgb) + len(self.groups_support_rgb)))
|
||||
_rgb['b'] = round(sum([self.hub.cync_switches[device_id].rgb['b'] for device_id in self.switches_support_rgb] + [self.hub.cync_rooms[room_id].rgb['b'] for room_id in self.groups_support_rgb])/(len(self.switches_support_rgb) + len(self.groups_support_rgb)))
|
||||
_rgb['active'] = True in ([self.hub.cync_switches[device_id].rgb['active'] for device_id in self.switches_support_rgb] + [self.hub.cync_rooms[room_id].rgb['active'] for room_id in self.groups_support_rgb])
|
||||
if _power_state != self.power_state or _brightness != self.brightness or _color_temp != self.color_temp_kelvin or _rgb != self.rgb:
|
||||
self.power_state = _power_state
|
||||
self.brightness = _brightness
|
||||
self.color_temp_kelvin = _color_temp
|
||||
self.rgb = _rgb
|
||||
self.publish_update()
|
||||
if self._update_parent_room:
|
||||
self._update_parent_room()
|
||||
|
||||
def update_controllers(self):
|
||||
"""Update the list of responsive, Wi-Fi connected controller devices"""
|
||||
connected_devices = self.hub.connected_devices[self.home_id]
|
||||
controllers = []
|
||||
if len(connected_devices) > 0:
|
||||
controllers = [self.hub.cync_switches[dev_id].switch_id for dev_id in self.all_room_switches if dev_id in connected_devices]
|
||||
others_available = [self.hub.cync_switches[dev_id].switch_id for dev_id in connected_devices]
|
||||
for controller in controllers:
|
||||
if controller in others_available:
|
||||
others_available.remove(controller)
|
||||
self.controllers = controllers + others_available
|
||||
else:
|
||||
self.controllers = [self.default_controller]
|
||||
|
||||
def publish_update(self):
|
||||
if self._update_callback:
|
||||
self._update_callback()
|
||||
|
||||
class CyncSwitch:
|
||||
|
||||
def __init__(self, device_id, switch_info, room, hub):
|
||||
self.hub = hub
|
||||
self.device_id = device_id
|
||||
self.switch_id = switch_info.get('switch_id','0')
|
||||
self.home_id = [home_id for home_id, home_devices in self.hub.home_devices.items() if self.device_id in home_devices][0]
|
||||
self.name = switch_info.get('name','unknown')
|
||||
self.home_name = switch_info.get('home_name','unknown')
|
||||
self.mesh_id = switch_info.get('mesh_id',0).to_bytes(2,'little')
|
||||
self.room = room
|
||||
self.power_state = False
|
||||
self.brightness = 0
|
||||
self.color_temp_kelvin = 0
|
||||
self.rgb = {'r':0, 'g':0, 'b':0, 'active':False}
|
||||
self.default_controller = switch_info.get('switch_controller',self.hub.home_controllers[self.home_id][0])
|
||||
self.controllers = []
|
||||
self._update_callback = None
|
||||
self._update_parent_room = None
|
||||
self.support_brightness = switch_info.get('BRIGHTNESS',False)
|
||||
self.support_color_temp = switch_info.get('COLORTEMP',False)
|
||||
self.support_rgb = switch_info.get('RGB',False)
|
||||
self.plug = switch_info.get('PLUG',False)
|
||||
self.fan = switch_info.get('FAN',False)
|
||||
self.elements = switch_info.get('MULTIELEMENT',1)
|
||||
self._command_timout = 0.5
|
||||
self._command_retry_time = 5
|
||||
|
||||
def register(self, update_callback) -> None:
|
||||
"""Register callback, called when switch changes state."""
|
||||
self._update_callback = update_callback
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Remove previously registered callback."""
|
||||
self._update_callback = None
|
||||
|
||||
def register_room_updater(self, parent_updater):
|
||||
self._update_parent_room = parent_updater
|
||||
|
||||
@property
|
||||
def max_color_temp_kelvin(self) -> int:
|
||||
"""Return maximum supported color temperature."""
|
||||
return 7000
|
||||
|
||||
@property
|
||||
def min_color_temp_kelvin(self) -> int:
|
||||
"""Return minimum supported color temperature."""
|
||||
return 2000
|
||||
|
||||
async def turn_on(self, attr_rgb, attr_br, attr_ct) -> None:
|
||||
"""Turn on the light."""
|
||||
attempts = 0
|
||||
update_received = False
|
||||
while not update_received and attempts < int(self._command_retry_time/self._command_timout):
|
||||
seq = str(self.hub.get_seq_num())
|
||||
if len(self.controllers) > 0:
|
||||
controller = self.controllers[attempts%len(self.controllers)]
|
||||
else:
|
||||
controller = self.default_controller
|
||||
if attr_rgb is not None and attr_br is not None:
|
||||
if math.isclose(attr_br, max([self.rgb['r'],self.rgb['g'],self.rgb['b']])*self.brightness/100, abs_tol = 2):
|
||||
self.hub.combo_control(True, self.brightness, 254, attr_rgb, controller, self.mesh_id, seq)
|
||||
else:
|
||||
self.hub.combo_control(True, round(attr_br*100/255), 255, [255,255,255], controller, self.mesh_id, seq)
|
||||
elif attr_rgb is None and attr_ct is None and attr_br is not None:
|
||||
self.hub.combo_control(True, round(attr_br*100/255), 255, [255,255,255], controller, self.mesh_id, seq)
|
||||
elif attr_rgb is not None and attr_br is None:
|
||||
self.hub.combo_control(True, self.brightness, 254, attr_rgb, controller, self.mesh_id, seq)
|
||||
elif attr_ct is not None:
|
||||
# Sync wants the color temp as a percentage of the range. So we need to
|
||||
# calculate what percentage of the color temp range is being requested
|
||||
# before sending it to the server.
|
||||
color_temp = round(
|
||||
(
|
||||
(attr_ct - self.min_color_temp_kelvin) /
|
||||
self.max_color_temp_kelvin
|
||||
) * 100
|
||||
)
|
||||
self.hub.set_color_temp(color_temp, controller, self.mesh_id, seq)
|
||||
else:
|
||||
self.hub.turn_on(controller, self.mesh_id, seq)
|
||||
self.hub.pending_commands[seq] = self.command_received
|
||||
await asyncio.sleep(self._command_timout)
|
||||
if self.hub.pending_commands.get(seq, None) is not None:
|
||||
self.hub.pending_commands.pop(seq)
|
||||
attempts += 1
|
||||
else:
|
||||
update_received = True
|
||||
|
||||
async def turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the light."""
|
||||
attempts = 0
|
||||
update_received = False
|
||||
while not update_received and attempts < int(self._command_retry_time/self._command_timout):
|
||||
seq = str(self.hub.get_seq_num())
|
||||
if len(self.controllers) > 0:
|
||||
controller = self.controllers[attempts%len(self.controllers)]
|
||||
else:
|
||||
controller = self.default_controller
|
||||
self.hub.turn_off(controller, self.mesh_id, seq)
|
||||
self.hub.pending_commands[seq] = self.command_received
|
||||
await asyncio.sleep(self._command_timout)
|
||||
if self.hub.pending_commands.get(seq, None) is not None:
|
||||
self.hub.pending_commands.pop(seq)
|
||||
attempts += 1
|
||||
else:
|
||||
update_received = True
|
||||
|
||||
def command_received(self, seq):
|
||||
"""Remove command from hub.pending_commands when a reply is received from Cync server"""
|
||||
if self.hub.pending_commands.get(seq) is not None:
|
||||
self.hub.pending_commands.pop(seq)
|
||||
|
||||
def update_switch(self,state,brightness,color_temp,rgb):
|
||||
"""Update the state of the switch as updates are received from the Cync server"""
|
||||
self.update_received = True
|
||||
# Cync sends the color temp as a percentage from 0-100 based on the max and min
|
||||
# color temp. Most Cync bulbs support 2000K-7000K, so we have to calculate what
|
||||
# is actually being requested.
|
||||
_color_temp = round(
|
||||
(self.max_color_temp_kelvin - self.min_color_temp_kelvin) *
|
||||
(color_temp / 100) +
|
||||
self.min_color_temp_kelvin
|
||||
)
|
||||
if self.power_state != state or self.brightness != brightness or self.color_temp_kelvin != _color_temp or self.rgb != rgb:
|
||||
self.power_state = state
|
||||
self.brightness = brightness if self.support_brightness and state else 100 if state else 0
|
||||
# Cync sends the color temp as a percentage from 0-100 based on the max and
|
||||
# min color temp. Most Cync bulbs support 2000K-7000K, so we have to
|
||||
# calculate what is actually being requested.
|
||||
self.color_temp_kelvin = _color_temp
|
||||
self.rgb = rgb
|
||||
self.publish_update()
|
||||
if self._update_parent_room:
|
||||
self._update_parent_room()
|
||||
|
||||
def update_controllers(self):
|
||||
"""Update the list of responsive, Wi-Fi connected controller devices"""
|
||||
connected_devices = self.hub.connected_devices[self.home_id]
|
||||
controllers = []
|
||||
if len(connected_devices) > 0:
|
||||
if int(self.switch_id) > 0:
|
||||
if self.device_id in connected_devices:
|
||||
#if this device is connected, make this the first available controller
|
||||
controllers.append(self.switch_id)
|
||||
if self.room:
|
||||
controllers = controllers + [self.hub.cync_switches[device_id].switch_id for device_id in self.room.all_room_switches if device_id in connected_devices and device_id != self.device_id]
|
||||
others_available = [self.hub.cync_switches[device_id].switch_id for device_id in connected_devices]
|
||||
for controller in controllers:
|
||||
if controller in others_available:
|
||||
others_available.remove(controller)
|
||||
self.controllers = controllers + others_available
|
||||
else:
|
||||
self.controllers = [self.default_controller]
|
||||
|
||||
def publish_update(self):
|
||||
if self._update_callback:
|
||||
self._update_callback()
|
||||
|
||||
class CyncMotionSensor:
|
||||
|
||||
def __init__(self, device_id, device_info, room):
|
||||
|
||||
self.device_id = device_id
|
||||
self.name = device_info['name']
|
||||
self.home_name = device_info['home_name']
|
||||
self.room = room
|
||||
self.motion = False
|
||||
self._update_callback = None
|
||||
|
||||
def register(self, update_callback) -> None:
|
||||
"""Register callback, called when switch changes state."""
|
||||
self._update_callback = update_callback
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Remove previously registered callback."""
|
||||
self._update_callback = None
|
||||
|
||||
def update_motion_sensor(self,motion):
|
||||
self.motion = motion
|
||||
self.publish_update()
|
||||
|
||||
def publish_update(self):
|
||||
if self._update_callback:
|
||||
self._update_callback()
|
||||
|
||||
class CyncAmbientLightSensor:
|
||||
|
||||
def __init__(self, device_id, device_info, room):
|
||||
|
||||
self.device_id = device_id
|
||||
self.name = device_info['name']
|
||||
self.home_name = device_info['home_name']
|
||||
self.room = room
|
||||
self.ambient_light = False
|
||||
self._update_callback = None
|
||||
|
||||
def register(self, update_callback) -> None:
|
||||
"""Register callback, called when switch changes state."""
|
||||
self._update_callback = update_callback
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Remove previously registered callback."""
|
||||
self._update_callback = None
|
||||
|
||||
def update_ambient_light_sensor(self,ambient_light):
|
||||
self.ambient_light = ambient_light
|
||||
self.publish_update()
|
||||
|
||||
def publish_update(self):
|
||||
if self._update_callback:
|
||||
self._update_callback()
|
||||
|
||||
class CyncUserData:
|
||||
|
||||
def __init__(self):
|
||||
self.username = ''
|
||||
self.password = ''
|
||||
self.auth_code = None
|
||||
self.user_credentials = {}
|
||||
|
||||
async def authenticate(self,username,password):
|
||||
"""Authenticate with the API and get a token."""
|
||||
self.username = username
|
||||
self.password = password
|
||||
auth_data = {'corp_id': "1007d2ad150c4000", 'email': self.username, 'password': self.password}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(API_AUTH, json=auth_data) as resp:
|
||||
if resp.status == 200:
|
||||
self.user_credentials = await resp.json()
|
||||
login_code = bytearray.fromhex('13000000') + (10 + len(self.user_credentials['authorize'])).to_bytes(1,'big') + bytearray.fromhex('03') + self.user_credentials['user_id'].to_bytes(4,'big') + len(self.user_credentials['authorize']).to_bytes(2,'big') + bytearray(self.user_credentials['authorize'],'ascii') + bytearray.fromhex('0000b4')
|
||||
self.auth_code = [int.from_bytes([byt],'big') for byt in login_code]
|
||||
return {'authorized':True}
|
||||
elif resp.status == 400:
|
||||
request_code_data = {'corp_id': "1007d2ad150c4000", 'email': self.username, 'local_lang': "en-us"}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(API_REQUEST_CODE,json=request_code_data) as resp:
|
||||
if resp.status == 200:
|
||||
return {'authorized':False,'two_factor_code_required':True}
|
||||
else:
|
||||
return {'authorized':False,'two_factor_code_required':False}
|
||||
else:
|
||||
return {'authorized':False,'two_factor_code_required':False}
|
||||
|
||||
async def auth_two_factor(self, code):
|
||||
"""Authenticate with 2 Factor Code."""
|
||||
two_factor_data = {'corp_id': "1007d2ad150c4000", 'email': self.username,'password': self.password, 'two_factor': code, 'resource':"abcdefghijklmnop"}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(API_2FACTOR_AUTH,json=two_factor_data) as resp:
|
||||
if resp.status == 200:
|
||||
self.user_credentials = await resp.json()
|
||||
login_code = bytearray.fromhex('13000000') + (10 + len(self.user_credentials['authorize'])).to_bytes(1,'big') + bytearray.fromhex('03') + self.user_credentials['user_id'].to_bytes(4,'big') + len(self.user_credentials['authorize']).to_bytes(2,'big') + bytearray(self.user_credentials['authorize'],'ascii') + bytearray.fromhex('0000b4')
|
||||
self.auth_code = [int.from_bytes([byt],'big') for byt in login_code]
|
||||
return {'authorized':True}
|
||||
else:
|
||||
return {'authorized':False}
|
||||
|
||||
async def get_cync_config(self):
|
||||
home_devices = {}
|
||||
home_controllers = {}
|
||||
switchID_to_homeID = {}
|
||||
devices = {}
|
||||
rooms = {}
|
||||
homes = await self._get_homes()
|
||||
for home in homes:
|
||||
home_info = await self._get_home_properties(home['product_id'], home['id'])
|
||||
if home_info.get('groupsArray',False) and home_info.get('bulbsArray',False) and len(home_info['groupsArray']) > 0 and len(home_info['bulbsArray']) > 0:
|
||||
home_id = str(home['id'])
|
||||
bulbs_array_length = max([((device['deviceID'] % home['id']) % 1000) + (int((device['deviceID'] % home['id']) / 1000)*256) for device in home_info['bulbsArray']]) + 1
|
||||
home_devices[home_id] = [""]*(bulbs_array_length)
|
||||
home_controllers[home_id] = []
|
||||
for device in home_info['bulbsArray']:
|
||||
device_type = device['deviceType']
|
||||
device_id = str(device['deviceID'])
|
||||
current_index = ((device['deviceID'] % home['id']) % 1000) + (int((device['deviceID'] % home['id']) / 1000)*256)
|
||||
home_devices[home_id][current_index] = device_id
|
||||
devices[device_id] = {'name':device['displayName'],
|
||||
'mesh_id':current_index,
|
||||
'switch_id':str(device.get('switchID',0)),
|
||||
'ONOFF': device_type in Capabilities['ONOFF'],
|
||||
'BRIGHTNESS': device_type in Capabilities["BRIGHTNESS"],
|
||||
"COLORTEMP":device_type in Capabilities["COLORTEMP"],
|
||||
"RGB": device_type in Capabilities["RGB"],
|
||||
"MOTION": device_type in Capabilities["MOTION"],
|
||||
"AMBIENT_LIGHT": device_type in Capabilities["AMBIENT_LIGHT"],
|
||||
"WIFICONTROL": device_type in Capabilities["WIFICONTROL"],
|
||||
"PLUG" : device_type in Capabilities["PLUG"],
|
||||
"FAN" : device_type in Capabilities["FAN"],
|
||||
'home_name':home['name'],
|
||||
'room':'',
|
||||
'room_name':''
|
||||
}
|
||||
if str(device_type) in Capabilities['MULTIELEMENT'] and current_index < 256:
|
||||
devices[device_id]['MULTIELEMENT'] = Capabilities['MULTIELEMENT'][str(device_type)]
|
||||
if devices[device_id].get('WIFICONTROL',False) and 'switchID' in device and device['switchID'] > 0:
|
||||
switchID_to_homeID[str(device['switchID'])] = home_id
|
||||
devices[device_id]['switch_controller'] = device['switchID']
|
||||
home_controllers[home_id].append(device['switchID'])
|
||||
if len(home_controllers[home_id]) == 0:
|
||||
for device in home_info['bulbsArray']:
|
||||
device_id = str(device['deviceID'])
|
||||
devices.pop(device_id,'')
|
||||
home_devices.pop(home_id,'')
|
||||
home_controllers.pop(home_id,'')
|
||||
else:
|
||||
for room in home_info['groupsArray']:
|
||||
if (len(room.get('deviceIDArray',[])) + len(room.get('subgroupIDArray',[]))) > 0:
|
||||
room_id = home_id + '-' + str(room['groupID'])
|
||||
room_controller = home_controllers[home_id][0]
|
||||
available_room_controllers = [(id%1000) + (int(id/1000)*256) for id in room.get('deviceIDArray',[]) if 'switch_controller' in devices[home_devices[home_id][(id%1000)+(int(id/1000)*256)]]]
|
||||
if len(available_room_controllers) > 0:
|
||||
room_controller = devices[home_devices[home_id][available_room_controllers[0]]]['switch_controller']
|
||||
for id in room.get('deviceIDArray',[]):
|
||||
id = (id % 1000) + (int(id / 1000)*256)
|
||||
devices[home_devices[home_id][id]]['room'] = room_id
|
||||
devices[home_devices[home_id][id]]['room_name'] = room['displayName']
|
||||
if 'switch_controller' not in devices[home_devices[home_id][id]] and devices[home_devices[home_id][id]].get('ONOFF',False):
|
||||
devices[home_devices[home_id][id]]['switch_controller'] = room_controller
|
||||
rooms[room_id] = {'name':room['displayName'],
|
||||
'mesh_id' : room['groupID'],
|
||||
'room_controller' : room_controller,
|
||||
'home_name' : home['name'],
|
||||
'switches' : [home_devices[home_id][(i%1000)+(int(i/1000)*256)] for i in room.get('deviceIDArray',[]) if devices[home_devices[home_id][(i%1000)+(int(i/1000)*256)]].get('ONOFF',False)],
|
||||
'isSubgroup' : room.get('isSubgroup',False),
|
||||
'subgroups' : [home_id + '-' + str(subgroup) for subgroup in room.get('subgroupIDArray',[])]
|
||||
}
|
||||
for room,room_info in rooms.items():
|
||||
if not room_info.get("isSubgroup",False) and len(subgroups := room_info.get("subgroups",[]).copy()) > 0:
|
||||
for subgroup in subgroups:
|
||||
if rooms.get(subgroup,None):
|
||||
rooms[subgroup]["parent_room"] = room_info["name"]
|
||||
else:
|
||||
room_info['subgroups'].pop(room_info['subgroups'].index(subgroup))
|
||||
|
||||
if len(rooms) == 0 or len(devices) == 0 or len(home_controllers) == 0 or len(home_devices) == 0 or len(switchID_to_homeID) == 0:
|
||||
raise InvalidCyncConfiguration
|
||||
else:
|
||||
return {'rooms':rooms, 'devices':devices, 'home_devices':home_devices, 'home_controllers':home_controllers, 'switchID_to_homeID':switchID_to_homeID}
|
||||
|
||||
async def _get_homes(self):
|
||||
"""Get a list of devices for a particular user."""
|
||||
headers = {'Access-Token': self.user_credentials['access_token']}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(API_DEVICES.format(user=self.user_credentials['user_id']), headers=headers) as resp:
|
||||
response = await resp.json()
|
||||
return response
|
||||
|
||||
async def _get_home_properties(self, product_id, device_id):
|
||||
"""Get properties for a single device."""
|
||||
headers = {'Access-Token': self.user_credentials['access_token']}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(API_DEVICE_INFO.format(product_id=product_id, device_id=device_id), headers=headers) as resp:
|
||||
response = await resp.json()
|
||||
return response
|
||||
|
||||
class LostConnection(Exception):
|
||||
"""Lost connection to Cync Server"""
|
||||
|
||||
class ShuttingDown(Exception):
|
||||
"""Cync client shutting down"""
|
||||
|
||||
class InvalidCyncConfiguration(Exception):
|
||||
"""Cync configuration is not supported"""
|
||||
@@ -1,104 +0,0 @@
|
||||
"""Platform for light integration."""
|
||||
from __future__ import annotations
|
||||
from typing import Any
|
||||
from homeassistant.components.fan import FanEntity, FanEntityFeature
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from .const import DOMAIN
|
||||
import logging
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
hub = hass.data[DOMAIN][config_entry.entry_id]
|
||||
|
||||
new_devices = []
|
||||
for switch_id in hub.cync_switches:
|
||||
if not hub.cync_switches[switch_id]._update_callback and hub.cync_switches[switch_id].fan and switch_id in config_entry.options["switches"]:
|
||||
new_devices.append(CyncFanEntity(hub.cync_switches[switch_id]))
|
||||
|
||||
if new_devices:
|
||||
async_add_entities(new_devices)
|
||||
|
||||
class CyncFanEntity(FanEntity):
|
||||
"""Representation of a Cync Fan Switch Entity."""
|
||||
|
||||
should_poll = False
|
||||
|
||||
def __init__(self, cync_switch) -> None:
|
||||
"""Initialize the light."""
|
||||
self.cync_switch = cync_switch
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when this Entity has been added to HA."""
|
||||
self.cync_switch.register(self.schedule_update_ha_state)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Entity being removed from hass."""
|
||||
self.cync_switch.reset()
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return device registry information for this entity."""
|
||||
return DeviceInfo(
|
||||
identifiers = {(DOMAIN, f"{self.cync_switch.room.name} ({self.cync_switch.home_name})")},
|
||||
manufacturer = "Cync by Savant",
|
||||
name = f"{self.cync_switch.room.name} ({self.cync_switch.home_name})",
|
||||
suggested_area = f"{self.cync_switch.room.name}",
|
||||
)
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Return Unique ID string."""
|
||||
return 'cync_switch_' + self.cync_switch.device_id
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the name of the switch."""
|
||||
return self.cync_switch.name
|
||||
|
||||
@property
|
||||
def supported_features(self) -> int:
|
||||
"""Return the list of supported features."""
|
||||
return FanEntityFeature.SET_SPEED | FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if fan is on."""
|
||||
return self.cync_switch.power_state
|
||||
|
||||
@property
|
||||
def percentage(self) -> int | None:
|
||||
"""Return the fan speed percentage of this switch"""
|
||||
return self.cync_switch.brightness
|
||||
|
||||
@property
|
||||
def speed_count(self) -> int:
|
||||
"""Return the number of speeds the fan supports."""
|
||||
return 4
|
||||
|
||||
async def async_turn_on(
|
||||
self,
|
||||
percentage: int | None = None,
|
||||
preset_mode: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Turn on the light."""
|
||||
await self.cync_switch.turn_on(None,percentage*255/100 if percentage is not None else None,None)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the light."""
|
||||
await self.cync_switch.turn_off()
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Set the speed of the fan, as a percentage."""
|
||||
if percentage == 0:
|
||||
await self.async_turn_off()
|
||||
else:
|
||||
await self.cync_switch.turn_on(None,percentage*255/100,None)
|
||||
@@ -1,253 +0,0 @@
|
||||
"""Platform for light integration."""
|
||||
from __future__ import annotations
|
||||
from typing import Any
|
||||
from homeassistant.components.light import (ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR, ColorMode, LightEntity)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from .const import DOMAIN
|
||||
import logging
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
hub = hass.data[DOMAIN][config_entry.entry_id]
|
||||
|
||||
new_devices = []
|
||||
for room in hub.cync_rooms:
|
||||
if not hub.cync_rooms[room]._update_callback and (room in config_entry.options["rooms"] or room in config_entry.options["subgroups"]):
|
||||
new_devices.append(CyncRoomEntity(hub.cync_rooms[room]))
|
||||
|
||||
for switch_id in hub.cync_switches:
|
||||
if not hub.cync_switches[switch_id]._update_callback and not hub.cync_switches[switch_id].plug and not hub.cync_switches[switch_id].fan and switch_id in config_entry.options["switches"]:
|
||||
new_devices.append(CyncSwitchEntity(hub.cync_switches[switch_id]))
|
||||
|
||||
if new_devices:
|
||||
async_add_entities(new_devices)
|
||||
|
||||
|
||||
class CyncRoomEntity(LightEntity):
|
||||
"""Representation of a Cync Room Light Entity."""
|
||||
|
||||
should_poll = False
|
||||
|
||||
def __init__(self, room) -> None:
|
||||
"""Initialize the light."""
|
||||
self.room = room
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when this Entity has been added to HA."""
|
||||
self.room.register(self.schedule_update_ha_state)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Entity being removed from hass."""
|
||||
self.room.reset()
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return device registry information for this entity."""
|
||||
return DeviceInfo(
|
||||
identifiers = {(DOMAIN, f"{self.room.parent_room if self.room.is_subgroup else self.room.name} ({self.room.home_name})")},
|
||||
manufacturer = "Cync by Savant",
|
||||
name = f"{self.room.parent_room if self.room.is_subgroup else self.room.name} ({self.room.home_name})",
|
||||
suggested_area = f"{self.room.parent_room if self.room.is_subgroup else self.room.name}",
|
||||
)
|
||||
|
||||
@property
|
||||
def icon(self) -> str | None:
|
||||
"""Icon of the entity."""
|
||||
if self.room.is_subgroup:
|
||||
return "mdi:lightbulb-group-outline"
|
||||
else:
|
||||
return "mdi:lightbulb-group"
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Return Unique ID string."""
|
||||
uid = 'cync_room_' + '-'.join(self.room.switches) + '_' + '-'.join(self.room.subgroups)
|
||||
return uid
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the name of the room."""
|
||||
return self.room.name
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if light is on."""
|
||||
return self.room.power_state
|
||||
|
||||
@property
|
||||
def brightness(self) -> int | None:
|
||||
"""Return the brightness of this room between 0..255."""
|
||||
return round(self.room.brightness*255/100)
|
||||
|
||||
@property
|
||||
def max_color_temp_kelvin(self) -> int:
|
||||
"""Return maximum supported color temperature."""
|
||||
return self.room.max_color_temp_kelvin
|
||||
|
||||
@property
|
||||
def min_color_temp_kelvin(self) -> int:
|
||||
"""Return minimum supported color temperature."""
|
||||
return self.room.min_color_temp_kelvin
|
||||
|
||||
@property
|
||||
def color_temp_kelvin(self) -> int:
|
||||
"""Return color temperature in kelvin."""
|
||||
return self.room.color_temp_kelvin
|
||||
|
||||
@property
|
||||
def rgb_color(self) -> tuple[int, int, int] | None:
|
||||
"""Return the RGB color tuple of this light switch"""
|
||||
return (self.room.rgb['r'],self.room.rgb['g'],self.room.rgb['b'])
|
||||
|
||||
@property
|
||||
def supported_color_modes(self) -> set[str] | None:
|
||||
"""Return list of available color modes."""
|
||||
|
||||
modes: set[ColorMode | str] = set()
|
||||
|
||||
if self.room.support_color_temp:
|
||||
modes.add(ColorMode.COLOR_TEMP)
|
||||
if self.room.support_rgb:
|
||||
modes.add(ColorMode.RGB)
|
||||
if self.room.support_brightness:
|
||||
modes.add(ColorMode.BRIGHTNESS)
|
||||
if not modes:
|
||||
modes.add(ColorMode.ONOFF)
|
||||
|
||||
return modes
|
||||
|
||||
@property
|
||||
def color_mode(self) -> str | None:
|
||||
"""Return the active color mode."""
|
||||
|
||||
if self.room.support_color_temp:
|
||||
if self.room.support_rgb and self.room.rgb['active']:
|
||||
return ColorMode.RGB
|
||||
else:
|
||||
return ColorMode.COLOR_TEMP
|
||||
if self.room.support_brightness:
|
||||
return ColorMode.BRIGHTNESS
|
||||
else:
|
||||
return ColorMode.ONOFF
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the light."""
|
||||
await self.room.turn_on(kwargs.get(ATTR_RGB_COLOR),kwargs.get(ATTR_BRIGHTNESS),kwargs.get(ATTR_COLOR_TEMP_KELVIN))
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the light."""
|
||||
await self.room.turn_off()
|
||||
|
||||
class CyncSwitchEntity(LightEntity):
|
||||
"""Representation of a Cync Switch Light Entity."""
|
||||
|
||||
should_poll = False
|
||||
|
||||
def __init__(self, cync_switch) -> None:
|
||||
"""Initialize the light."""
|
||||
self.cync_switch = cync_switch
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when this Entity has been added to HA."""
|
||||
self.cync_switch.register(self.schedule_update_ha_state)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Entity being removed from hass."""
|
||||
self.cync_switch.reset()
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return device registry information for this entity."""
|
||||
return DeviceInfo(
|
||||
identifiers = {(DOMAIN, f"{self.cync_switch.room.name} ({self.cync_switch.home_name})")},
|
||||
manufacturer = "Cync by Savant",
|
||||
name = f"{self.cync_switch.room.name} ({self.cync_switch.home_name})",
|
||||
suggested_area = f"{self.cync_switch.room.name}",
|
||||
)
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Return Unique ID string."""
|
||||
return 'cync_switch_' + self.cync_switch.device_id
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the name of the switch."""
|
||||
return self.cync_switch.name
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if light is on."""
|
||||
return self.cync_switch.power_state
|
||||
|
||||
@property
|
||||
def brightness(self) -> int | None:
|
||||
"""Return the brightness of this switch between 0..255."""
|
||||
return round(self.cync_switch.brightness*255/100)
|
||||
|
||||
@property
|
||||
def max_color_temp_kelvin(self) -> int:
|
||||
"""Return maximum supported color temperature."""
|
||||
return self.cync_switch.max_color_temp_kelvin
|
||||
|
||||
@property
|
||||
def min_color_temp_kelvin(self) -> int:
|
||||
"""Return minimum supported color temperature."""
|
||||
return self.cync_switch.min_color_temp_kelvin
|
||||
|
||||
@property
|
||||
def color_temp_kelvin(self) -> int | None:
|
||||
"""Return the color temperature of this light for HA."""
|
||||
return self.cync_switch.color_temp_kelvin
|
||||
|
||||
@property
|
||||
def rgb_color(self) -> tuple[int, int, int] | None:
|
||||
"""Return the RGB color tuple of this light switch"""
|
||||
return (self.cync_switch.rgb['r'],self.cync_switch.rgb['g'],self.cync_switch.rgb['b'])
|
||||
|
||||
@property
|
||||
def supported_color_modes(self) -> set[str] | None:
|
||||
"""Return list of available color modes."""
|
||||
|
||||
modes: set[ColorMode | str] = set()
|
||||
|
||||
if self.cync_switch.support_color_temp:
|
||||
modes.add(ColorMode.COLOR_TEMP)
|
||||
if self.cync_switch.support_rgb:
|
||||
modes.add(ColorMode.RGB)
|
||||
if self.cync_switch.support_brightness:
|
||||
modes.add(ColorMode.BRIGHTNESS)
|
||||
if not modes:
|
||||
modes.add(ColorMode.ONOFF)
|
||||
|
||||
return modes
|
||||
|
||||
@property
|
||||
def color_mode(self) -> str | None:
|
||||
"""Return the active color mode."""
|
||||
|
||||
if self.cync_switch.support_color_temp:
|
||||
if self.cync_switch.support_rgb and self.cync_switch.rgb['active']:
|
||||
return ColorMode.RGB
|
||||
else:
|
||||
return ColorMode.COLOR_TEMP
|
||||
if self.cync_switch.support_brightness:
|
||||
return ColorMode.BRIGHTNESS
|
||||
else:
|
||||
return ColorMode.ONOFF
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the light."""
|
||||
await self.cync_switch.turn_on(kwargs.get(ATTR_RGB_COLOR),kwargs.get(ATTR_BRIGHTNESS),kwargs.get(ATTR_COLOR_TEMP_KELVIN))
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the light."""
|
||||
await self.cync_switch.turn_off()
|
||||
@@ -1,82 +0,0 @@
|
||||
"""Platform for light integration."""
|
||||
from __future__ import annotations
|
||||
from typing import Any
|
||||
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from .const import DOMAIN
|
||||
import logging
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
hub = hass.data[DOMAIN][config_entry.entry_id]
|
||||
|
||||
new_devices = []
|
||||
for switch_id in hub.cync_switches:
|
||||
if not hub.cync_switches[switch_id]._update_callback and hub.cync_switches[switch_id].plug and switch_id in config_entry.options["switches"]:
|
||||
new_devices.append(CyncPlugEntity(hub.cync_switches[switch_id]))
|
||||
|
||||
if new_devices:
|
||||
async_add_entities(new_devices)
|
||||
|
||||
class CyncPlugEntity(SwitchEntity):
|
||||
"""Representation of a Cync Switch Light Entity."""
|
||||
|
||||
should_poll = False
|
||||
|
||||
def __init__(self, cync_switch) -> None:
|
||||
"""Initialize the light."""
|
||||
self.cync_switch = cync_switch
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when this Entity has been added to HA."""
|
||||
self.cync_switch.register(self.schedule_update_ha_state)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Entity being removed from hass."""
|
||||
self.cync_switch.reset()
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return device registry information for this entity."""
|
||||
return DeviceInfo(
|
||||
identifiers = {(DOMAIN, f"{self.cync_switch.room.name} ({self.cync_switch.home_name})")},
|
||||
manufacturer = "Cync by Savant",
|
||||
name = f"{self.cync_switch.room.name} ({self.cync_switch.home_name})",
|
||||
suggested_area = f"{self.cync_switch.room.name}",
|
||||
)
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Return Unique ID string."""
|
||||
return 'cync_switch_' + self.cync_switch.device_id
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the name of the switch."""
|
||||
return self.cync_switch.name
|
||||
|
||||
@property
|
||||
def device_class(self) -> str | None:
|
||||
"""Return the device class"""
|
||||
return SwitchDeviceClass.OUTLET
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if light is on."""
|
||||
return self.cync_switch.power_state
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the outlet."""
|
||||
await self.cync_switch.turn_on(None, None, None)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the outlet."""
|
||||
await self.cync_switch.turn_off()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,41 +0,0 @@
|
||||
import logging
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.core import callback
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Configuration:
|
||||
SIDEPANEL_TITLE = "sidepanel_title"
|
||||
SIDEPANEL_ICON = "sidepanel_icon"
|
||||
|
||||
@config_entries.HANDLERS.register("dwains_dashboard")
|
||||
class DwainsDashboardConfigFlow(config_entries.ConfigFlow):
|
||||
async def async_step_user(self, user_input=None):
|
||||
if self._async_current_entries():
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
return self.async_create_entry(title="", data={})
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry):
|
||||
return DwainsDashboardEditFlow(config_entry)
|
||||
|
||||
class DwainsDashboardEditFlow(config_entries.OptionsFlow):
|
||||
def __init__(self, config_entry):
|
||||
self.config_entry = config_entry
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
|
||||
schema = {
|
||||
vol.Optional(SIDEPANEL_TITLE, default=self.config_entry.options.get("sidepanel_title", "Dwains Dashboard")): str,
|
||||
vol.Optional(SIDEPANEL_ICON, default=self.config_entry.options.get("sidepanel_icon", "mdi:alpha-d-box")): str,
|
||||
}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=vol.Schema(schema)
|
||||
)
|
||||
@@ -1,2 +0,0 @@
|
||||
DOMAIN = "dwains_dashboard"
|
||||
VERSION = "3.8.0"
|
||||
@@ -1,4 +0,0 @@
|
||||
node_modules/
|
||||
src/*
|
||||
!src/translations.js
|
||||
dwains-dashboard.js.map
|
||||
-6172
File diff suppressed because one or more lines are too long
-1713
File diff suppressed because it is too large
Load Diff
-40
@@ -1,40 +0,0 @@
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
entry: [
|
||||
'./src/dwains-navigation-card.js',
|
||||
'./src/dwains-dashboard.js',
|
||||
'./src/dwains-dashboard-layout.js',
|
||||
'./src/dwains-homepage-card.js',
|
||||
'./src/dwains-more-pages-card.js',
|
||||
'./src/dwains-more-page-card.js',
|
||||
'./src/dwains-edit-more-page-card.js',
|
||||
'./src/dwains-notification-card.js',
|
||||
'./src/dwains-house-information-card.js',
|
||||
'./src/dwains-house-information-more-info-card.js',
|
||||
'./src/dwains-blueprint-card.js',
|
||||
'./src/dwains-devicespage-card.js',
|
||||
//'./src/dwains-thermostat-card.js',
|
||||
//'./src/dwains-light-card.js',
|
||||
//'./src/dwains-cover-card.js',
|
||||
//'./src/dwains-button-card.js',
|
||||
'./src/dwains-flexbox-card.js',
|
||||
'./src/dwains-heading-card.js',
|
||||
'./src/dwains-create-custom-card-card.js',
|
||||
'./src/dwains-edit-area-button-card.js',
|
||||
'./src/dwains-edit-entity-card-card.js',
|
||||
'./src/dwains-edit-entity-card.js',
|
||||
'./src/dwains-edit-entity-popup-card.js',
|
||||
'./src/dwains-edit-homepage-header-card.js',
|
||||
'./src/dwains-edit-device-card-card.js',
|
||||
'./src/dwains-edit-device-popup-card.js',
|
||||
'./src/dwains-edit-device-button-card.js',
|
||||
'./src/dwains-popup.js',
|
||||
],
|
||||
mode: 'production',
|
||||
output: {
|
||||
filename: 'dwains-dashboard.js',
|
||||
path: path.resolve(__dirname)
|
||||
},
|
||||
devtool: "source-map"
|
||||
};
|
||||
BIN
Binary file not shown.
@@ -1,36 +0,0 @@
|
||||
import logging
|
||||
|
||||
from homeassistant.components.lovelace.dashboard import LovelaceYAML
|
||||
from homeassistant.components.lovelace import _register_panel
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
def load_dashboard(hass, config_entry):
|
||||
|
||||
#_LOGGER.warning(config_entry.options)
|
||||
#_LOGGER.warning(config_entry.options["sidepanel_title"])
|
||||
|
||||
sidepanel_title = "Dwains Dashboard"
|
||||
sidepanel_icon = "mdi:alpha-d-box"
|
||||
|
||||
if("sidepanel_title" in config_entry.options):
|
||||
sidepanel_title = config_entry.options["sidepanel_title"]
|
||||
|
||||
if("sidepanel_icon" in config_entry.options):
|
||||
sidepanel_icon = config_entry.options["sidepanel_icon"]
|
||||
|
||||
dashboard_url = "dwains-dashboard"
|
||||
dashboard_config = {
|
||||
"mode": "yaml",
|
||||
"icon": sidepanel_icon,
|
||||
"title": sidepanel_title,
|
||||
"filename": "custom_components/dwains_dashboard/lovelace/ui-lovelace.yaml",
|
||||
"show_in_sidebar": True,
|
||||
"require_admin": False,
|
||||
}
|
||||
|
||||
hass.data["lovelace"].dashboards[dashboard_url] = LovelaceYAML(hass, dashboard_url, dashboard_config)
|
||||
|
||||
_register_panel(hass, dashboard_url, "yaml", dashboard_config, False)
|
||||
@@ -1,19 +0,0 @@
|
||||
import logging
|
||||
from homeassistant.components.frontend import add_extra_js_url
|
||||
from homeassistant.components.http import HomeAssistantHTTP
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
|
||||
DATA_EXTRA_MODULE_URL = 'frontend_extra_module_url'
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
from .const import VERSION
|
||||
|
||||
async def load_plugins(hass, name):
|
||||
#_LOGGER.warning(f"load_plugins() version: {VERSION}")
|
||||
|
||||
add_extra_js_url(hass, f"/dwains_dashboard/js/dwains-dashboard.js?version={VERSION}")
|
||||
|
||||
await hass.http.async_register_static_paths(
|
||||
[StaticPathConfig("/dwains_dashboard/js", hass.config.path(f"custom_components/{name}/js"), True)]
|
||||
)
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
# Start of the dwains dashboard ui-lovelace.yaml
|
||||
|
||||
#Load all settings for Dwains Dashboard
|
||||
dwains_dashboard:
|
||||
active: true
|
||||
|
||||
#For people who want to use button card templates
|
||||
button_card_templates:
|
||||
!include_dir_merge_named ../../../dwains-dashboard/button_card_templates
|
||||
|
||||
#For people who want to use apexcharts card templates
|
||||
apexcharts_card_templates:
|
||||
!include_dir_merge_named ../../../dwains-dashboard/apexcharts_card_templates
|
||||
|
||||
# Start of main lovelace theme
|
||||
lovelace-background: var(--background-image)
|
||||
views:
|
||||
!include_dir_merge_list views/
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
- title: Home
|
||||
icon: mdi:home
|
||||
path: home
|
||||
type: custom:dwains-dashboard-layout
|
||||
cards:
|
||||
- type: custom:homepage-card
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
- title: Devices
|
||||
icon: mdi:format-list-bulleted-type
|
||||
path: devices
|
||||
type: custom:dwains-dashboard-layout
|
||||
cards:
|
||||
- type: custom:devices-card
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
# dwains_dashboard
|
||||
|
||||
#More_page addon view
|
||||
{% if _dd_more_pages %}
|
||||
{% for addon in _dd_more_pages %}
|
||||
- title: {{ _dd_more_pages[addon]["name"] }}
|
||||
path: more_page_{{ addon|lower|replace("'", "_")|replace(" ", "_") }}
|
||||
type: custom:dwains-dashboard-layout
|
||||
icon: {{ _dd_more_pages[addon]["icon"] }}
|
||||
visible: true
|
||||
cards:
|
||||
- type: custom:more-page-card
|
||||
name: {{ _dd_more_pages[addon]["name"] }}
|
||||
icon: {{ _dd_more_pages[addon]["icon"] }}
|
||||
show_in_navbar: {{ _dd_more_pages[addon]["show_in_navbar"] }}
|
||||
foldername: {{ addon }}
|
||||
card: !include ../../../../{{ _dd_more_pages[addon]["path"] }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
- title: More page
|
||||
path: more_page
|
||||
icon: mdi:dots-horizontal
|
||||
visible: true
|
||||
type: custom:dwains-dashboard-layout
|
||||
cards:
|
||||
- type: custom:more-pages-card
|
||||
@@ -1,221 +0,0 @@
|
||||
import os
|
||||
import logging
|
||||
import json
|
||||
import io
|
||||
import time
|
||||
import voluptuous as vol
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from collections import OrderedDict
|
||||
from typing import Any, Mapping, MutableMapping, Optional
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity import Entity, async_generate_entity_id
|
||||
from homeassistant.loader import bind_hass
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
ATTR_CREATED_AT = "created_at"
|
||||
ATTR_MESSAGE = "message"
|
||||
ATTR_NOTIFICATION_ID = "notification_id"
|
||||
ATTR_TITLE = "title"
|
||||
ATTR_STATUS = "status"
|
||||
|
||||
ENTITY_ID_FORMAT = DOMAIN + ".{}"
|
||||
|
||||
EVENT_DWAINS_dashboard_NOTIFICATIONS_UPDATED = "dwains_dashboard_notifications_updated"
|
||||
|
||||
SERVICE_CREATE = "notification_create"
|
||||
SERVICE_DISMISS = "notification_dismiss"
|
||||
SERVICE_MARK_READ = "notification_mark_read"
|
||||
|
||||
SCHEMA_SERVICE_CREATE = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_MESSAGE): cv.template,
|
||||
vol.Optional(ATTR_TITLE): cv.template,
|
||||
vol.Optional(ATTR_NOTIFICATION_ID): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_SERVICE_DISMISS = vol.Schema({vol.Required(ATTR_NOTIFICATION_ID): cv.string})
|
||||
|
||||
SCHEMA_SERVICE_MARK_READ = vol.Schema({vol.Required(ATTR_NOTIFICATION_ID): cv.string})
|
||||
|
||||
DEFAULT_OBJECT_ID = "notification"
|
||||
|
||||
STATE = "notifying"
|
||||
STATUS_UNREAD = "unread"
|
||||
STATUS_READ = "read"
|
||||
|
||||
#Notifications part
|
||||
@bind_hass
|
||||
def create(hass, message, title=None, notification_id=None):
|
||||
"""Generate a notification."""
|
||||
hass.add_job(async_create, hass, message, title, notification_id)
|
||||
|
||||
@bind_hass
|
||||
def dismiss(hass, notification_id):
|
||||
"""Remove a notification."""
|
||||
hass.add_job(async_dismiss, hass, notification_id)
|
||||
|
||||
@callback
|
||||
@bind_hass
|
||||
def async_create(
|
||||
hass: HomeAssistant,
|
||||
message: str,
|
||||
title: Optional[str] = None,
|
||||
notification_id: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Generate a notification."""
|
||||
data = {
|
||||
key: value
|
||||
for key, value in [
|
||||
(ATTR_TITLE, title),
|
||||
(ATTR_MESSAGE, message),
|
||||
(ATTR_NOTIFICATION_ID, notification_id),
|
||||
]
|
||||
if value is not None
|
||||
}
|
||||
|
||||
hass.async_create_task(hass.services.async_call(DOMAIN, SERVICE_CREATE, data))
|
||||
|
||||
@callback
|
||||
@bind_hass
|
||||
def async_dismiss(hass: HomeAssistant, notification_id: str) -> None:
|
||||
"""Remove a notification."""
|
||||
data = {ATTR_NOTIFICATION_ID: notification_id}
|
||||
|
||||
hass.async_create_task(hass.services.async_call(DOMAIN, SERVICE_DISMISS, data))
|
||||
|
||||
@callback
|
||||
@websocket_api.websocket_command({vol.Required("type"): "dwains_dashboard_notification/get"})
|
||||
def websocket_get_notifications(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: Mapping[str, Any],
|
||||
) -> None:
|
||||
"""Return a list of dwains_dashboard_notifications."""
|
||||
connection.send_message(
|
||||
websocket_api.result_message(
|
||||
msg["id"],
|
||||
[
|
||||
{
|
||||
key: data[key]
|
||||
for key in (
|
||||
ATTR_NOTIFICATION_ID,
|
||||
ATTR_MESSAGE,
|
||||
ATTR_STATUS,
|
||||
ATTR_TITLE,
|
||||
ATTR_CREATED_AT,
|
||||
)
|
||||
}
|
||||
for data in hass.data[DOMAIN]["notifications"].values()
|
||||
],
|
||||
)
|
||||
)
|
||||
#End notifications part
|
||||
|
||||
def notifications(hass, name):
|
||||
#Notifications part setup
|
||||
"""Set up the dwains dashboard notification component."""
|
||||
|
||||
dwains_dashboard_notifications: MutableMapping[str, MutableMapping] = OrderedDict()
|
||||
hass.data[DOMAIN]["notifications"] = dwains_dashboard_notifications
|
||||
|
||||
@callback
|
||||
def create_service(call):
|
||||
"""Handle a create notification service call."""
|
||||
title = call.data.get(ATTR_TITLE)
|
||||
message = call.data.get(ATTR_MESSAGE)
|
||||
notification_id = call.data.get(ATTR_NOTIFICATION_ID)
|
||||
|
||||
if notification_id is not None:
|
||||
entity_id = ENTITY_ID_FORMAT.format(slugify(notification_id))
|
||||
else:
|
||||
entity_id = async_generate_entity_id(
|
||||
ENTITY_ID_FORMAT, DEFAULT_OBJECT_ID, hass=hass
|
||||
)
|
||||
notification_id = entity_id.split(".")[1]
|
||||
|
||||
attr = {}
|
||||
if title is not None:
|
||||
try:
|
||||
title.hass = hass
|
||||
title = title.async_render()
|
||||
except TemplateError as ex:
|
||||
_LOGGER.error("Error rendering title %s: %s", title, ex)
|
||||
title = title.template
|
||||
|
||||
attr[ATTR_TITLE] = title
|
||||
|
||||
try:
|
||||
message.hass = hass
|
||||
message = message.async_render()
|
||||
except TemplateError as ex:
|
||||
_LOGGER.error("Error rendering message %s: %s", message, ex)
|
||||
message = message.template
|
||||
|
||||
attr[ATTR_MESSAGE] = message
|
||||
|
||||
hass.states.async_set(entity_id, STATE, attr)
|
||||
|
||||
# Store notification and fire event
|
||||
# This will eventually replace state machine storage
|
||||
dwains_dashboard_notifications[entity_id] = {
|
||||
ATTR_MESSAGE: message,
|
||||
ATTR_NOTIFICATION_ID: notification_id,
|
||||
ATTR_STATUS: STATUS_UNREAD,
|
||||
ATTR_TITLE: title,
|
||||
ATTR_CREATED_AT: dt_util.utcnow(),
|
||||
}
|
||||
|
||||
hass.bus.async_fire(EVENT_DWAINS_dashboard_NOTIFICATIONS_UPDATED)
|
||||
|
||||
@callback
|
||||
def dismiss_service(call):
|
||||
"""Handle the dismiss notification service call."""
|
||||
notification_id = call.data.get(ATTR_NOTIFICATION_ID)
|
||||
entity_id = ENTITY_ID_FORMAT.format(slugify(notification_id))
|
||||
|
||||
if entity_id not in dwains_dashboard_notifications:
|
||||
return
|
||||
|
||||
hass.states.async_remove(entity_id)
|
||||
|
||||
del dwains_dashboard_notifications[entity_id]
|
||||
hass.bus.async_fire(EVENT_DWAINS_dashboard_NOTIFICATIONS_UPDATED)
|
||||
|
||||
@callback
|
||||
def mark_read_service(call):
|
||||
"""Handle the mark_read notification service call."""
|
||||
notification_id = call.data.get(ATTR_NOTIFICATION_ID)
|
||||
entity_id = ENTITY_ID_FORMAT.format(slugify(notification_id))
|
||||
|
||||
if entity_id not in dwains_dashboard_notifications:
|
||||
_LOGGER.error(
|
||||
"Marking dwains dashboard_notification read failed: "
|
||||
"Notification ID %s not found.",
|
||||
notification_id,
|
||||
)
|
||||
return
|
||||
|
||||
dwains_dashboard_notifications[entity_id][ATTR_STATUS] = STATUS_READ
|
||||
hass.bus.async_fire(EVENT_DWAINS_dashboard_NOTIFICATIONS_UPDATED)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_CREATE, create_service, SCHEMA_SERVICE_CREATE
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_DISMISS, dismiss_service, SCHEMA_SERVICE_DISMISS
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_MARK_READ, mark_read_service, SCHEMA_SERVICE_MARK_READ
|
||||
)
|
||||
|
||||
websocket_api.async_register_command(hass, websocket_get_notifications)
|
||||
#End notifications part setup
|
||||
@@ -1,213 +0,0 @@
|
||||
import logging
|
||||
import yaml
|
||||
import os
|
||||
import logging
|
||||
import json
|
||||
import io
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
import jinja2
|
||||
import shutil
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import asyncio
|
||||
from aiofiles.os import scandir
|
||||
|
||||
#from homeassistant.util.yaml import Secrets, loader
|
||||
from annotatedyaml import loader
|
||||
from annotatedyaml.loader import Secrets
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DOMAIN, VERSION
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
def fromjson(value):
|
||||
return json.loads(value)
|
||||
|
||||
jinja = jinja2.Environment(loader=jinja2.FileSystemLoader("/"))
|
||||
|
||||
jinja.filters['fromjson'] = fromjson
|
||||
|
||||
dwains_dashboard_more_pages = {}
|
||||
llgen_config = {}
|
||||
|
||||
def load_yamll(fname, secrets = None, args={}):
|
||||
try:
|
||||
process_yaml = False
|
||||
with open(fname, encoding="utf-8") as f:
|
||||
if f.readline().lower().startswith(("# dwains_dashboard", "# dwains_theme", "# lovelace_gen", "#dwains_dashboard")):
|
||||
process_yaml = True
|
||||
|
||||
#_LOGGER.debug(f"load_yamll() Loading YAML: {fname}, process_yaml={process_yaml}")
|
||||
|
||||
if process_yaml:
|
||||
stream = io.StringIO(jinja.get_template(fname).render({
|
||||
**args,
|
||||
"_dd_more_pages": dwains_dashboard_more_pages,
|
||||
"_global": llgen_config
|
||||
}))
|
||||
stream.name = fname
|
||||
return loader.yaml.load(stream, Loader=lambda _stream: loader.PythonSafeLoader(_stream, secrets)) or OrderedDict()
|
||||
else:
|
||||
with open(fname, encoding="utf-8") as config_file:
|
||||
data = loader.yaml.load(config_file, Loader=lambda stream: loader.PythonSafeLoader(stream, secrets)) or OrderedDict()
|
||||
#_LOGGER.warning(f"load_yamll() DATA: {data}")
|
||||
return data
|
||||
|
||||
except loader.yaml.YAMLError as exc:
|
||||
_LOGGER.error(f"YAMLError: {str(exc)}")
|
||||
raise HomeAssistantError(exc)
|
||||
except UnicodeDecodeError as exc:
|
||||
_LOGGER.error("Unicode Error :: Unable to read file %s: %s", fname, exc)
|
||||
raise HomeAssistantError(exc)
|
||||
|
||||
|
||||
def _include_yaml(ldr, node):
|
||||
args = {}
|
||||
if isinstance(node.value, str):
|
||||
fn = node.value
|
||||
else:
|
||||
fn, args, *_ = ldr.construct_sequence(node)
|
||||
fname = os.path.abspath(os.path.join(os.path.dirname(ldr.name), fn))
|
||||
try:
|
||||
return loader._add_reference(load_yamll(fname, ldr.secrets, args=args), ldr, node)
|
||||
except FileNotFoundError as exc:
|
||||
_LOGGER.error("Unable to include file %s: %s", fname, exc);
|
||||
raise HomeAssistantError(exc)
|
||||
|
||||
loader.load_yaml = load_yamll
|
||||
loader.PythonSafeLoader.add_constructor("!include", _include_yaml)
|
||||
|
||||
def compose_node(self, parent, index):
|
||||
if self.check_event(yaml.events.AliasEvent):
|
||||
event = self.get_event()
|
||||
anchor = event.anchor
|
||||
if anchor not in self.anchors:
|
||||
raise yaml.composer.ComposerError(None, None, "found undefined alias %r"
|
||||
% anchor, event.start_mark)
|
||||
return self.anchors[anchor]
|
||||
event = self.peek_event()
|
||||
anchor = event.anchor
|
||||
self.descend_resolver(parent, index)
|
||||
if self.check_event(yaml.events.ScalarEvent):
|
||||
node = self.compose_scalar_node(anchor)
|
||||
elif self.check_event(yaml.events.SequenceStartEvent):
|
||||
node = self.compose_sequence_node(anchor)
|
||||
elif self.check_event(yaml.events.MappingStartEvent):
|
||||
node = self.compose_mapping_node(anchor)
|
||||
self.ascend_resolver()
|
||||
return node
|
||||
|
||||
yaml.composer.Composer.compose_node = compose_node
|
||||
|
||||
|
||||
async def process_yaml(hass: HomeAssistant, config_entry):
|
||||
"""Process all YAML files for Dwains Dashboard."""
|
||||
#_LOGGER.warning('Start of function to process all yaml files!')
|
||||
|
||||
# Check for HKI installation
|
||||
if os.path.exists(hass.config.path("hki-user/config")):
|
||||
#_LOGGER.warning("HKI Installed!")
|
||||
for fname in loader._find_files(hass.config.path("hki-user/config"), "*.yaml"):
|
||||
loaded_yaml = load_yamll(fname)
|
||||
if isinstance(loaded_yaml, dict):
|
||||
llgen_config.update(loaded_yaml)
|
||||
|
||||
if os.path.exists(hass.config.path("dwains-dashboard/configs")):
|
||||
if os.path.isdir(hass.config.path("dwains-dashboard/configs/more_pages")):
|
||||
#for subdir in os.listdir(hass.config.path("dwains-dashboard/configs/more_pages")):
|
||||
more_pages_path = hass.config.path("dwains-dashboard/configs/more_pages")
|
||||
subdirs = await hass.async_add_executor_job(os.listdir, more_pages_path)
|
||||
for subdir in subdirs:
|
||||
#Lets check if there is a page.yaml in the more_pages folder
|
||||
if os.path.exists(hass.config.path("dwains-dashboard/configs/more_pages/"+subdir+"/page.yaml")):
|
||||
# Page.yaml exists now check if there is a config.yaml otherwise create it
|
||||
if not os.path.exists(hass.config.path("dwains-dashboard/configs/more_pages/"+subdir+"/config.yaml")):
|
||||
#_LOGGER.warning(f"process_yaml() config.yaml does not exist, {subdir}")
|
||||
#with open(hass.config.path("dwains-dashboard/configs/more_pages/"+subdir+"/config.yaml"), 'w') as f:
|
||||
file_content = await hass.async_add_executor_job(open, hass.config.path("dwains-dashboard/configs/more_pages/"+subdir+"/config.yaml"), "w")
|
||||
with file_content as f:
|
||||
page_config = OrderedDict()
|
||||
page_config.update({
|
||||
"name": subdir,
|
||||
"icon": "mdi:puzzle"
|
||||
})
|
||||
yaml.safe_dump(page_config, f, default_flow_style=False)
|
||||
dwains_dashboard_more_pages[subdir] = {
|
||||
"name": subdir,
|
||||
"icon": "mdi:puzzle",
|
||||
"path": "dwains-dashboard/configs/more_pages/"+subdir+"/page.yaml",
|
||||
}
|
||||
else:
|
||||
#_LOGGER.warning(f"process_yaml() config.yaml exists, {subdir}")
|
||||
try:
|
||||
#with open(hass.config.path("dwains-dashboard/configs/more_pages/"+subdir+"/config.yaml")) as f:
|
||||
data = await hass.async_add_executor_job(open, hass.config.path("dwains-dashboard/configs/more_pages/"+subdir+"/config.yaml"), "r")
|
||||
with data as f:
|
||||
filecontent = yaml.safe_load(f)
|
||||
|
||||
#_LOGGER.warning(f"FILE CONTENT: {filecontent}")
|
||||
if "name" in filecontent and "icon" in filecontent:
|
||||
dwains_dashboard_more_pages[subdir] = {
|
||||
"name": filecontent["name"],
|
||||
"icon": filecontent["icon"],
|
||||
"path": "dwains-dashboard/configs/more_pages/"+subdir+"/page.yaml",
|
||||
}
|
||||
else:
|
||||
_LOGGER.warning(f"Invalid config.yaml in {subdir}: Missing 'name' or 'icon'")
|
||||
except Exception as e:
|
||||
_LOGGER.error(f"Failed to read config.yaml in {subdir}: {e}")
|
||||
|
||||
hass.bus.async_fire("dwains_dashboard_reload")
|
||||
|
||||
async def handle_reload(call):
|
||||
#Service call to reload Dwains Theme config
|
||||
_LOGGER.warning("Reload Dwains Dashboard Configuration")
|
||||
|
||||
await reload_configuration(hass)
|
||||
|
||||
# Register service dwains_dashboard.reload
|
||||
hass.services.async_register(DOMAIN, "reload", handle_reload)
|
||||
|
||||
|
||||
|
||||
async def reload_configuration(hass):
|
||||
_LOGGER.warning('Reload YAML configuration files...!')
|
||||
|
||||
if os.path.exists(hass.config.path("dwains-dashboard/configs")):
|
||||
if os.path.isdir(hass.config.path("dwains-dashboard/configs/more_pages")):
|
||||
#for subdir in os.listdir(hass.config.path("dwains-dashboard/configs/more_pages")):
|
||||
more_pages_path = hass.config.path("dwains-dashboard/configs/more_pages")
|
||||
subdirs = await hass.async_add_executor_job(os.listdir, more_pages_path)
|
||||
for subdir in subdirs:
|
||||
#Lets check if there is a page.yaml in the more_pages folder
|
||||
if os.path.exists(hass.config.path("dwains-dashboard/configs/more_pages/"+subdir+"/page.yaml")):
|
||||
page_config = hass.config.path("dwains-dashboard/configs/more_pages/"+subdir+"/config.yaml")
|
||||
#Page.yaml exists now check if there is a config.yaml otherwise create it
|
||||
if not os.path.exists(page_config):
|
||||
data = await hass.async_add_executor_job(open, page_config, "w")
|
||||
with data as f:
|
||||
page_config = OrderedDict()
|
||||
page_config.update({
|
||||
"name": subdir,
|
||||
"icon": "mdi:puzzle"
|
||||
})
|
||||
yaml.safe_dump(page_config, f, default_flow_style=False)
|
||||
dwains_dashboard_more_pages[subdir] = {
|
||||
"name": subdir,
|
||||
"icon": "mdi:puzzle",
|
||||
"path": "dwains-dashboard/configs/more_pages/"+subdir+"/page.yaml",
|
||||
}
|
||||
else:
|
||||
data = await hass.async_add_executor_job(open, page_config, "r")
|
||||
with data as f:
|
||||
filecontent = yaml.safe_load(f)
|
||||
dwains_dashboard_more_pages[subdir] = {
|
||||
"name": filecontent["name"],
|
||||
"icon": filecontent["icon"],
|
||||
"path": "dwains-dashboard/configs/more_pages/"+subdir+"/page.yaml",
|
||||
}
|
||||
|
||||
hass.bus.async_fire("dwains_dashboard_reload")
|
||||
@@ -1,100 +0,0 @@
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from datetime import datetime, timedelta
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
from .const import DOMAIN, VERSION
|
||||
|
||||
import logging
|
||||
|
||||
import asyncio
|
||||
import aiohttp
|
||||
import async_timeout
|
||||
import json
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_RESOURCE = "https://dwains-dashboard.dwainscheeren.nl/version?v="+VERSION
|
||||
|
||||
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=800)
|
||||
|
||||
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
|
||||
"""Setup sensor platform."""
|
||||
#_LOGGER.error("async_setup_platform called")
|
||||
async_add_entities([LatestVersionSensor()])
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_devices):
|
||||
"""Setup sensor platform."""
|
||||
#_LOGGER.error("async_setup_entry called")
|
||||
|
||||
data = LatestVersion(hass)
|
||||
async_add_devices([LatestVersionSensor(data)])
|
||||
|
||||
|
||||
class LatestVersionSensor(Entity):
|
||||
"""Latest version sensor."""
|
||||
|
||||
def __init__(self, data):
|
||||
"""Initialize the sensor."""
|
||||
self._state = None
|
||||
self.data = data
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return a unique ID to use for this sensor."""
|
||||
return (
|
||||
"dwains-dashboard-latest-version"
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the sensor."""
|
||||
return "Dwains Dashboard Latest version"
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
"""Return the icon of the sensor."""
|
||||
return "mdi:alpha-d-box"
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state of the sensor."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def unit_of_measurement(self):
|
||||
"""Return the unit of measurement."""
|
||||
return "latest version"
|
||||
|
||||
# def update(self):
|
||||
# """Fetch new state data for the sensor.
|
||||
# This is the only method that should fetch new data for Home Assistant.
|
||||
# """
|
||||
# self._state = self.hass.data[DOMAIN]['latest_version']
|
||||
|
||||
async def async_update(self):
|
||||
await self.data.update()
|
||||
self._state = self.hass.data[DOMAIN]['latest_version']
|
||||
|
||||
class LatestVersion:
|
||||
|
||||
def __init__(self, hass):
|
||||
self._hass = hass
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_UPDATES)
|
||||
async def update(self):
|
||||
|
||||
session = async_get_clientsession(self._hass)
|
||||
|
||||
try:
|
||||
with async_timeout.timeout(10):
|
||||
response = await session.get(_RESOURCE)
|
||||
result = await response.read()
|
||||
data = json.loads(result)
|
||||
if "latest_version" in data:
|
||||
#_LOGGER.error(data)
|
||||
self._hass.data[DOMAIN]['latest_version'] = json.loads(result)["latest_version"]
|
||||
except ValueError as err:
|
||||
_LOGGER.error("Dwains Dashboard version check failed %s", err.args)
|
||||
except (asyncio.TimeoutError, aiohttp.ClientError) as err:
|
||||
_LOGGER.error("Dwains Dashboard version check failed %s", repr(err))
|
||||
@@ -1,26 +0,0 @@
|
||||
reload:
|
||||
description: Reload dashboard configuration from Dwains dashboard
|
||||
|
||||
notification_create:
|
||||
description: Show a notification in the frontend.
|
||||
fields:
|
||||
message:
|
||||
description: Message body of the notification. [Templates accepted]
|
||||
example: Dishwasher is done! :D
|
||||
notification_id:
|
||||
description: Target ID of the notification, will replace a notification with the same Id. [Optional]
|
||||
example: 1234
|
||||
|
||||
notification_dismiss:
|
||||
description: Remove a notification from the frontend.
|
||||
fields:
|
||||
notification_id:
|
||||
description: Target ID of the notification, which should be removed. [Required]
|
||||
example: 1234
|
||||
|
||||
notification_mark_read:
|
||||
description: Mark a notification read.
|
||||
fields:
|
||||
notification_id:
|
||||
description: Target ID of the notification, which should be mark read. [Required]
|
||||
example: 1234
|
||||
@@ -1 +0,0 @@
|
||||
"""foobar2000 media player custom component init file"""
|
||||
@@ -1,329 +0,0 @@
|
||||
"""
|
||||
Support for foobar2000 Music Player as media player
|
||||
via pyfoobar2k https://gitlab.com/ed0zer-projects/pyfoobar2k
|
||||
And foobar2000 component foo_httpcontrol by oblikoamorale https://bitbucket.org/oblikoamorale/foo_httpcontrol
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.helpers import script, config_validation as cv
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
try:
|
||||
from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity
|
||||
except ImportError:
|
||||
from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerDevice as MediaPlayerEntity
|
||||
|
||||
from homeassistant.components.media_player.const import (
|
||||
MEDIA_TYPE_MUSIC, SUPPORT_VOLUME_STEP, SUPPORT_TURN_ON, SUPPORT_TURN_OFF,
|
||||
SUPPORT_STOP, SUPPORT_SELECT_SOURCE, SUPPORT_SEEK, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_MUTE,
|
||||
SUPPORT_PLAY, SUPPORT_PREVIOUS_TRACK, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_SHUFFLE_SET)
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_NAME, CONF_HOST, CONF_PORT, CONF_USERNAME,
|
||||
CONF_PASSWORD, STATE_OFF, STATE_PAUSED, STATE_PLAYING,
|
||||
CONF_TIMEOUT, STATE_UNKNOWN, STATE_IDLE)
|
||||
|
||||
REQUIREMENTS = ['pyfoobar2k==0.2.8']
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_NAME = 'Foobar2000'
|
||||
DEFAULT_PORT = '8888'
|
||||
DEFAULT_TIMEOUT = 3
|
||||
DEFAULT_VOLUME_STEP = 5
|
||||
CONF_VOLUME_STEP = 'volume_step'
|
||||
CONF_TURN_ON_ACTION = 'turn_on_action'
|
||||
CONF_TURN_OFF_ACTION = 'turn_off_action'
|
||||
|
||||
SUPPORT_FOOBAR_PLAYER = \
|
||||
SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \
|
||||
SUPPORT_PLAY | SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK | \
|
||||
SUPPORT_STOP | SUPPORT_SEEK | SUPPORT_SHUFFLE_SET | \
|
||||
SUPPORT_VOLUME_STEP | SUPPORT_SELECT_SOURCE
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Optional(CONF_USERNAME): cv.string,
|
||||
vol.Optional(CONF_PASSWORD): cv.string,
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
||||
vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
|
||||
vol.Optional(CONF_VOLUME_STEP, default=DEFAULT_VOLUME_STEP): cv.positive_int,
|
||||
vol.Optional(CONF_TURN_ON_ACTION, default=None): cv.SCRIPT_SCHEMA,
|
||||
vol.Optional(CONF_TURN_OFF_ACTION, default=None): cv.SCRIPT_SCHEMA})
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up the Foobar Player platform."""
|
||||
from pyfoobar2k import FoobarRemote
|
||||
name = config.get(CONF_NAME)
|
||||
host = config.get(CONF_HOST)
|
||||
port = config.get(CONF_PORT)
|
||||
username = config.get(CONF_USERNAME)
|
||||
password = config.get(CONF_PASSWORD)
|
||||
timeout = config.get(CONF_TIMEOUT)
|
||||
volume_step = config.get(CONF_VOLUME_STEP)
|
||||
turn_on_action = config.get(CONF_TURN_ON_ACTION)
|
||||
turn_off_action = config.get(CONF_TURN_OFF_ACTION)
|
||||
|
||||
remote = FoobarRemote(host, port, username, password, timeout)
|
||||
|
||||
add_devices([FoobarDevice(hass, remote, name, volume_step, turn_on_action, turn_off_action)])
|
||||
|
||||
|
||||
class FoobarDevice(MediaPlayerEntity):
|
||||
|
||||
def __init__(self, hass, remote, name, volume_step, turn_on_action=None, turn_off_action=None):
|
||||
|
||||
self._name = name
|
||||
self._remote = remote
|
||||
self.hass = hass
|
||||
self._volume = 0.0
|
||||
self._track_name = ''
|
||||
self._track_artist = ''
|
||||
self._track_album_name = ''
|
||||
self._track_duration = 0
|
||||
self._track_position = 0
|
||||
self._track_position_updated_at = None
|
||||
self._albumart = ''
|
||||
self._current_playlist = ''
|
||||
self._playlists = []
|
||||
self._shuffle = 0
|
||||
self._volume_step = volume_step
|
||||
self._selected_source = None
|
||||
self._state = STATE_UNKNOWN
|
||||
self._base_url = self._remote.url
|
||||
self._albumart_path = ''
|
||||
# Script creation for the turn on/off config options
|
||||
if turn_on_action is not None:
|
||||
turn_on_action = script.Script(
|
||||
self.hass, turn_on_action,
|
||||
"{} turn ON script".format(self.name),
|
||||
self.async_update_ha_state(True))
|
||||
if turn_off_action is not None:
|
||||
turn_off_action = script.Script(
|
||||
self.hass, turn_off_action,
|
||||
"{} turn OFF script".format(self.name),
|
||||
self.async_update_ha_state(True))
|
||||
|
||||
self._turn_on_action = turn_on_action
|
||||
self._turn_off_action = turn_off_action
|
||||
|
||||
def update(self):
|
||||
try:
|
||||
info = self._remote.state()
|
||||
if info:
|
||||
if info['isPlaying'] == '1':
|
||||
self._state = STATE_PLAYING
|
||||
elif info['isPaused'] == '1':
|
||||
self._state = STATE_PAUSED
|
||||
else:
|
||||
self._state = STATE_IDLE
|
||||
else:
|
||||
self._state = STATE_OFF
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
if self._state in [STATE_PLAYING, STATE_PAUSED]:
|
||||
self._track_name = info['title']
|
||||
self._track_artist = info['artist']
|
||||
self._track_album_name = info['album']
|
||||
self._volume = int(info['volume']) / 100
|
||||
self._shuffle = info['playbackorder']
|
||||
self._track_duration = int(info['itemPlayingLen'])
|
||||
self._albumart_path = info['albumArt']
|
||||
self._track_position = int(info['itemPlayingPos'])
|
||||
self._track_position_updated_at = dt_util.utcnow()
|
||||
|
||||
if self._state in [STATE_PLAYING, STATE_PAUSED, STATE_IDLE]:
|
||||
sources_info = self._remote.playlist()
|
||||
if sources_info:
|
||||
current_playlist_position = int(sources_info['playlistActive'])
|
||||
playlists_raw = sources_info['playlists']
|
||||
self._current_playlist = playlists_raw[current_playlist_position]['name']
|
||||
self._playlists = [item["name"] for item in playlists_raw]
|
||||
else:
|
||||
_LOGGER.warning("Updating %s sources failed:", self._name)
|
||||
except Exception as e:
|
||||
_LOGGER.error("Updating %s state failed: %s", self._name, e)
|
||||
self._state = STATE_UNKNOWN
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the device."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state of the device."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""Return True if entity has to be polled for state."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def volume_level(self):
|
||||
"""Volume level of the media player (0 to 1)."""
|
||||
return float(self._volume)
|
||||
|
||||
@property
|
||||
def is_volume_muted(self):
|
||||
"""Return True if volume is muted."""
|
||||
return float(self._volume) == 0
|
||||
|
||||
@property
|
||||
def media_content_type(self):
|
||||
"""Content type of current playing media."""
|
||||
return MEDIA_TYPE_MUSIC
|
||||
|
||||
@property
|
||||
def media_title(self):
|
||||
"""Title of current playing track."""
|
||||
return self._track_name
|
||||
|
||||
@property
|
||||
def media_artist(self):
|
||||
"""Artist of current playing track."""
|
||||
return self._track_artist
|
||||
|
||||
@property
|
||||
def media_album_name(self):
|
||||
"""Album name of current playing track."""
|
||||
return self._track_album_name
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Flag media player features that are supported."""
|
||||
supported_features = SUPPORT_FOOBAR_PLAYER
|
||||
if self._turn_on_action is not None:
|
||||
supported_features |= SUPPORT_TURN_ON
|
||||
if self._turn_off_action is not None:
|
||||
supported_features |= SUPPORT_TURN_OFF
|
||||
return supported_features
|
||||
|
||||
def turn_on(self):
|
||||
"""Execute turn_on_action to turn on media player."""
|
||||
if self._turn_on_action is not None:
|
||||
self._turn_on_action.run(variables={"entity_id": self.entity_id})
|
||||
else:
|
||||
_LOGGER.warning("turn_on requested but turn_on_action is none")
|
||||
|
||||
def turn_off(self):
|
||||
"""Execute turn_off_action to turn on media player."""
|
||||
if self._turn_off_action is not None:
|
||||
self._turn_off_action.run(variables={"entity_id": self.entity_id})
|
||||
else:
|
||||
_LOGGER.warning("turn_off requested but turn_off_action is none")
|
||||
|
||||
def media_play_pause(self):
|
||||
"""Send the media player the command for play/pause."""
|
||||
self._remote.cmd('PlayOrPause')
|
||||
|
||||
def media_pause(self):
|
||||
"""Send the media player the command for play/pause if playing."""
|
||||
if self._state == STATE_PLAYING:
|
||||
self._remote.cmd('PlayOrPause')
|
||||
|
||||
def media_stop(self):
|
||||
"""Send the media player the stop command."""
|
||||
self._remote.cmd('Stop')
|
||||
|
||||
def media_play(self):
|
||||
"""Send the media player the command to play at the current playlist."""
|
||||
self._remote.cmd('Start')
|
||||
|
||||
def media_previous_track(self):
|
||||
"""Send the media player the command for prev track."""
|
||||
self._remote.cmd('StartPrevious')
|
||||
|
||||
def media_next_track(self):
|
||||
"""Send the media player the command for next track."""
|
||||
self._remote.cmd('StartNext')
|
||||
|
||||
def set_volume_level(self, volume):
|
||||
"""Send the media player the command for setting the volume."""
|
||||
self._remote.cmd('Volume', int(volume * 100))
|
||||
|
||||
def volume_up(self):
|
||||
"""Send the media player the command for volume down."""
|
||||
self._remote.cmd('VolumeDelta', self._volume_step)
|
||||
|
||||
def volume_down(self):
|
||||
"""Send the media player the command for volume down."""
|
||||
self._remote.cmd('VolumeDelta', -self._volume_step)
|
||||
|
||||
def mute_volume(self, mute):
|
||||
"""Mute the volume."""
|
||||
self._remote.cmd('VolumeMuteToggle')
|
||||
|
||||
@property
|
||||
def media_position_updated_at(self):
|
||||
"""When was the position of the current playing media valid.
|
||||
|
||||
Returns value from homeassistant.util.dt.utcnow().
|
||||
"""
|
||||
return self._track_position_updated_at
|
||||
|
||||
@property
|
||||
def media_duration(self):
|
||||
"""Duration of current playing media in seconds."""
|
||||
return self._track_duration
|
||||
|
||||
@property
|
||||
def media_position(self):
|
||||
"""Position of current playing media in seconds."""
|
||||
if self._state in [STATE_PLAYING, STATE_PAUSED]:
|
||||
return self._track_position
|
||||
|
||||
def media_seek(self, position):
|
||||
"""Send the media player the command to seek in current playing media."""
|
||||
self._remote.cmd('SeekSecond', position)
|
||||
|
||||
@property
|
||||
def media_image_url(self):
|
||||
"""Image url of current playing media."""
|
||||
if 'cover_not_available' not in self._albumart_path:
|
||||
self._albumart = '{}/{}'.format(self._base_url, self._albumart_path)
|
||||
return self._albumart
|
||||
|
||||
@property
|
||||
def source(self):
|
||||
"""Return current source name."""
|
||||
return self._current_playlist
|
||||
|
||||
@property
|
||||
def source_list(self):
|
||||
"""List of available input sources."""
|
||||
return self._playlists
|
||||
|
||||
def select_source(self, source):
|
||||
"""Select input source."""
|
||||
playlists = self._remote.playlist()['playlists']
|
||||
source_position = [index for index, item in enumerate(playlists) if item['name'] == source][0]
|
||||
"""ignoring first playlist in playlists index"""
|
||||
if source_position == 0:
|
||||
return None
|
||||
if source_position is not None:
|
||||
self._remote.cmd('SwitchPlaylist', source_position)
|
||||
self._remote.cmd('Start', 0)
|
||||
self._current_playlist = source
|
||||
|
||||
@property
|
||||
def media_playlist(self):
|
||||
"""Title of Playlist currently playing."""
|
||||
return self._current_playlist
|
||||
|
||||
def set_shuffle(self, shuffle):
|
||||
"""Send the media player the command to enable/disable shuffle mode."""
|
||||
playback_order = 4 if shuffle else 0
|
||||
self._remote.cmd('PlaybackOrder', playback_order)
|
||||
|
||||
@property
|
||||
def shuffle(self):
|
||||
"""Boolean if shuffle is enabled."""
|
||||
return True if int(self._shuffle) == 4 else False
|
||||
@@ -1,229 +0,0 @@
|
||||
"""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
|
||||
|
||||
from aiogithubapi import AIOGitHubAPIException, GitHub, GitHubAPI
|
||||
from aiogithubapi.const import ACCEPT_HEADERS
|
||||
from awesomeversion import AwesomeVersion
|
||||
from homeassistant.components.frontend import async_remove_panel
|
||||
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.entity_registry import async_get as async_get_entity_registry
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.start import async_at_start
|
||||
from homeassistant.loader import async_get_integration
|
||||
|
||||
from .base import HacsBase
|
||||
from .const import DOMAIN, HACS_SYSTEM_ID, MINIMUM_HA_VERSION, STARTUP
|
||||
from .data_client import HacsDataClient
|
||||
from .enums import HacsDisabledReason, HacsStage, LovelaceMode
|
||||
from .frontend import async_register_frontend
|
||||
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
|
||||
|
||||
PLATFORMS = [Platform.SWITCH, Platform.UPDATE]
|
||||
|
||||
|
||||
async def _async_initialize_integration(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
) -> bool:
|
||||
"""Initialize the integration"""
|
||||
hass.data[DOMAIN] = hacs = HacsBase()
|
||||
hacs.enable_hacs()
|
||||
|
||||
if config_entry.source == SOURCE_IMPORT:
|
||||
# Import is not supported
|
||||
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_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.data_client = HacsDataClient(
|
||||
session=clientsession,
|
||||
client_name=f"HACS/{integration.version}",
|
||||
)
|
||||
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.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()
|
||||
|
||||
try:
|
||||
import custom_components.custom_updater
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
hacs.log.critical(
|
||||
"HACS cannot be used with custom_updater. "
|
||||
"To use HACS you need to remove custom_updater from `custom_components`",
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
hacs.set_active_categories()
|
||||
|
||||
async_register_websocket_commands(hass)
|
||||
await async_register_frontend(hass, hacs)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||
|
||||
hacs.set_stage(HacsStage.SETUP)
|
||||
if hacs.system.disabled:
|
||||
return False
|
||||
|
||||
hacs.set_stage(HacsStage.WAITING)
|
||||
hacs.log.info("Setup complete, waiting for Home Assistant before startup tasks starts")
|
||||
|
||||
# Schedule startup tasks
|
||||
async_at_start(hass=hass, at_start_cb=hacs.startup_tasks)
|
||||
|
||||
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.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()
|
||||
|
||||
# Remove old (v0-v1) sensor if it exists, can be removed in v3
|
||||
er = async_get_entity_registry(hass)
|
||||
if old_sensor := er.async_get_entity_id("sensor", DOMAIN, HACS_SYSTEM_ID):
|
||||
er.async_remove(old_sensor)
|
||||
|
||||
# Mischief managed!
|
||||
return True
|
||||
|
||||
|
||||
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]
|
||||
|
||||
if hacs.queue.has_pending_tasks:
|
||||
hacs.log.warning("Pending tasks, can not unload, try again later.")
|
||||
return False
|
||||
|
||||
# Clear out pending queue
|
||||
hacs.queue.clear()
|
||||
|
||||
for task in hacs.recurring_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")
|
||||
async_remove_panel(hass, "hacs")
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
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."""
|
||||
if not await async_unload_entry(hass, config_entry):
|
||||
return
|
||||
await async_setup_entry(hass, config_entry)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,225 +0,0 @@
|
||||
"""Adds config flow for HACS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aiogithubapi import (
|
||||
GitHubDeviceAPI,
|
||||
GitHubException,
|
||||
GitHubLoginDeviceModel,
|
||||
GitHubLoginOauthModel,
|
||||
)
|
||||
from aiogithubapi.common.const import OAUTH_USER_LOGIN
|
||||
from awesomeversion import AwesomeVersion
|
||||
from homeassistant.config_entries import ConfigFlow, OptionsFlow
|
||||
from homeassistant.const import __version__ as HAVERSION
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import UnknownFlow
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
from homeassistant.loader import async_get_integration
|
||||
import voluptuous as vol
|
||||
|
||||
from .base import HacsBase
|
||||
from .const import CLIENT_ID, DOMAIN, LOCALE, MINIMUM_HA_VERSION
|
||||
from .utils.configuration_schema import (
|
||||
APPDAEMON,
|
||||
COUNTRY,
|
||||
SIDEPANEL_ICON,
|
||||
SIDEPANEL_TITLE,
|
||||
)
|
||||
from .utils.logger import LOGGER
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
|
||||
class HacsFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Config flow for HACS."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
hass: HomeAssistant
|
||||
activation_task: asyncio.Task | None = None
|
||||
device: GitHubDeviceAPI | None = None
|
||||
|
||||
_registration: GitHubLoginDeviceModel | None = None
|
||||
_activation: GitHubLoginOauthModel | None = None
|
||||
_reauth: bool = False
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize."""
|
||||
self._errors = {}
|
||||
self._user_input = {}
|
||||
|
||||
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 x.startswith("acc_") and not user_input[x]]:
|
||||
self._errors["base"] = "acc"
|
||||
return await self._show_config_form(user_input)
|
||||
|
||||
self._user_input = 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:
|
||||
try:
|
||||
response = await self.device.activation(device_code=self._registration.device_code)
|
||||
self._activation = response.data
|
||||
finally:
|
||||
|
||||
async def _progress():
|
||||
with suppress(UnknownFlow):
|
||||
await self.hass.config_entries.flow.async_configure(flow_id=self.flow_id)
|
||||
|
||||
if not self.device:
|
||||
integration = await async_get_integration(self.hass, DOMAIN)
|
||||
self.device = GitHubDeviceAPI(
|
||||
client_id=CLIENT_ID,
|
||||
session=aiohttp_client.async_get_clientsession(self.hass),
|
||||
**{"client_name": f"HACS/{integration.version}"},
|
||||
)
|
||||
try:
|
||||
response = await self.device.register()
|
||||
self._registration = response.data
|
||||
except GitHubException as exception:
|
||||
LOGGER.exception(exception)
|
||||
return self.async_abort(reason="could_not_register")
|
||||
|
||||
if self.activation_task is None:
|
||||
self.activation_task = self.hass.async_create_task(_wait_for_activation())
|
||||
|
||||
if self.activation_task.done():
|
||||
if (exception := self.activation_task.exception()) is not None:
|
||||
LOGGER.exception(exception)
|
||||
return self.async_show_progress_done(next_step_id="could_not_register")
|
||||
return self.async_show_progress_done(next_step_id="device_done")
|
||||
|
||||
show_progress_kwargs = {
|
||||
"step_id": "device",
|
||||
"progress_action": "wait_for_device",
|
||||
"description_placeholders": {
|
||||
"url": OAUTH_USER_LOGIN,
|
||||
"code": self._registration.user_code,
|
||||
},
|
||||
"progress_task": self.activation_task,
|
||||
}
|
||||
return self.async_show_progress(**show_progress_kwargs)
|
||||
|
||||
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: dict[str, bool] | None = None):
|
||||
"""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={**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,
|
||||
},
|
||||
options={
|
||||
"experimental": True,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_could_not_register(self, _user_input=None):
|
||||
"""Handle issues that need transition await from progress step."""
|
||||
return self.async_abort(reason="could_not_register")
|
||||
|
||||
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(OptionsFlow):
|
||||
"""HACS config flow options handler."""
|
||||
|
||||
def __init__(self, config_entry):
|
||||
"""Initialize HACS options flow."""
|
||||
if AwesomeVersion(HAVERSION) < "2024.11.99":
|
||||
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:
|
||||
return self.async_create_entry(title="", data={**user_input, "experimental": True})
|
||||
|
||||
if hacs is None or hacs.configuration is None:
|
||||
return self.async_abort(reason="not_setup")
|
||||
|
||||
if hacs.queue.has_pending_tasks:
|
||||
return self.async_abort(reason="pending_tasks")
|
||||
|
||||
schema = {
|
||||
vol.Optional(SIDEPANEL_TITLE, default=hacs.configuration.sidepanel_title): str,
|
||||
vol.Optional(SIDEPANEL_ICON, default=hacs.configuration.sidepanel_icon): str,
|
||||
vol.Optional(COUNTRY, default=hacs.configuration.country): vol.In(LOCALE),
|
||||
vol.Optional(APPDAEMON, default=hacs.configuration.appdaemon): bool,
|
||||
}
|
||||
|
||||
return self.async_show_form(step_id="user", data_schema=vol.Schema(schema))
|
||||
@@ -1,294 +0,0 @@
|
||||
"""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 = "2024.4.1"
|
||||
|
||||
URL_BASE = "/hacsfiles"
|
||||
|
||||
TV = TypeVar("TV")
|
||||
|
||||
PACKAGE_NAME = "custom_components.hacs"
|
||||
|
||||
DEFAULT_CONCURRENT_TASKS = 15
|
||||
DEFAULT_CONCURRENT_BACKOFF_TIME = 1
|
||||
|
||||
HACS_REPOSITORY_ID = "172733314"
|
||||
|
||||
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",
|
||||
]
|
||||
@@ -1,38 +0,0 @@
|
||||
"""Coordinator to trigger entity updates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import CALLBACK_TYPE, callback
|
||||
from homeassistant.helpers.update_coordinator import BaseDataUpdateCoordinatorProtocol
|
||||
|
||||
|
||||
class HacsUpdateCoordinator(BaseDataUpdateCoordinatorProtocol):
|
||||
"""Dispatch updates to update entities."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize."""
|
||||
self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {}
|
||||
|
||||
@callback
|
||||
def async_add_listener(
|
||||
self, update_callback: CALLBACK_TYPE, context: Any = None
|
||||
) -> Callable[[], None]:
|
||||
"""Listen for data updates."""
|
||||
|
||||
@callback
|
||||
def remove_listener() -> None:
|
||||
"""Remove update listener."""
|
||||
self._listeners.pop(remove_listener)
|
||||
|
||||
self._listeners[remove_listener] = (update_callback, context)
|
||||
|
||||
return remove_listener
|
||||
|
||||
@callback
|
||||
def async_update_listeners(self) -> None:
|
||||
"""Update all registered listeners."""
|
||||
for update_callback, _ in list(self._listeners.values()):
|
||||
update_callback()
|
||||
@@ -1,98 +0,0 @@
|
||||
"""HACS Data client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientSession, ClientTimeout
|
||||
import voluptuous as vol
|
||||
|
||||
from .exceptions import HacsException, HacsNotModifiedException
|
||||
from .utils.logger import LOGGER
|
||||
from .utils.validate import (
|
||||
VALIDATE_FETCHED_V2_CRITICAL_REPO_SCHEMA,
|
||||
VALIDATE_FETCHED_V2_REMOVED_REPO_SCHEMA,
|
||||
VALIDATE_FETCHED_V2_REPO_DATA,
|
||||
)
|
||||
|
||||
CRITICAL_REMOVED_VALIDATORS = {
|
||||
"critical": VALIDATE_FETCHED_V2_CRITICAL_REPO_SCHEMA,
|
||||
"removed": VALIDATE_FETCHED_V2_REMOVED_REPO_SCHEMA,
|
||||
}
|
||||
|
||||
|
||||
class HacsDataClient:
|
||||
"""HACS Data client."""
|
||||
|
||||
def __init__(self, session: ClientSession, client_name: str) -> None:
|
||||
"""Initialize."""
|
||||
self._client_name = client_name
|
||||
self._etags = {}
|
||||
self._session = session
|
||||
|
||||
async def _do_request(
|
||||
self,
|
||||
filename: str,
|
||||
section: str | None = None,
|
||||
) -> dict[str, dict[str, Any]] | list[str]:
|
||||
"""Do request."""
|
||||
endpoint = "/".join([v for v in [section, filename] if v is not None])
|
||||
try:
|
||||
response = await self._session.get(
|
||||
f"https://data-v2.hacs.xyz/{endpoint}",
|
||||
timeout=ClientTimeout(total=60),
|
||||
headers={
|
||||
"User-Agent": self._client_name,
|
||||
"If-None-Match": self._etags.get(endpoint, ""),
|
||||
},
|
||||
)
|
||||
if response.status == 304:
|
||||
raise HacsNotModifiedException() from None
|
||||
response.raise_for_status()
|
||||
except HacsNotModifiedException:
|
||||
raise
|
||||
except TimeoutError:
|
||||
raise HacsException("Timeout of 60s reached") from None
|
||||
except Exception as exception:
|
||||
raise HacsException(f"Error fetching data from HACS: {exception}") from exception
|
||||
|
||||
self._etags[endpoint] = response.headers.get("etag")
|
||||
|
||||
return await response.json()
|
||||
|
||||
async def get_data(self, section: str | None, *, validate: bool) -> dict[str, dict[str, Any]]:
|
||||
"""Get data."""
|
||||
data = await self._do_request(filename="data.json", section=section)
|
||||
if not validate:
|
||||
return data
|
||||
|
||||
if section in VALIDATE_FETCHED_V2_REPO_DATA:
|
||||
validated = {}
|
||||
for key, repo_data in data.items():
|
||||
try:
|
||||
validated[key] = VALIDATE_FETCHED_V2_REPO_DATA[section](repo_data)
|
||||
except vol.Invalid as exception:
|
||||
LOGGER.info(
|
||||
"Got invalid data for %s (%s)", repo_data.get("full_name", key), exception
|
||||
)
|
||||
continue
|
||||
|
||||
return validated
|
||||
|
||||
if not (validator := CRITICAL_REMOVED_VALIDATORS.get(section)):
|
||||
raise ValueError(f"Do not know how to validate {section}")
|
||||
|
||||
validated = []
|
||||
for repo_data in data:
|
||||
try:
|
||||
validated.append(validator(repo_data))
|
||||
except vol.Invalid as exception:
|
||||
LOGGER.info("Got invalid data for %s (%s)", section, exception)
|
||||
continue
|
||||
|
||||
return validated
|
||||
|
||||
async def get_repositories(self, section: str) -> list[str]:
|
||||
"""Get repositories."""
|
||||
return await self._do_request(filename="repositories.json", section=section)
|
||||
@@ -1,80 +0,0 @@
|
||||
"""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
|
||||
|
||||
|
||||
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",
|
||||
"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",))
|
||||
@@ -1,143 +0,0 @@
|
||||
"""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 homeassistant.helpers.update_coordinator import BaseCoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, HACS_SYSTEM_ID, NAME_SHORT
|
||||
from .coordinator import HacsUpdateCoordinator
|
||||
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
|
||||
|
||||
|
||||
class HacsDispatcherEntity(HacsBaseEntity):
|
||||
"""Base HACS entity listening to dispatcher signals."""
|
||||
|
||||
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(HacsDispatcherEntity):
|
||||
"""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(BaseCoordinatorEntity[HacsUpdateCoordinator], HacsBaseEntity):
|
||||
"""Base repository entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hacs: HacsBase,
|
||||
repository: HacsRepository,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
BaseCoordinatorEntity.__init__(self, hacs.coordinators[repository.data.category])
|
||||
HacsBaseEntity.__init__(self, hacs=hacs)
|
||||
self.repository = repository
|
||||
self._attr_unique_id = str(repository.data.id)
|
||||
self._repo_last_fetched = repository.data.last_fetched
|
||||
|
||||
@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)
|
||||
|
||||
def _manufacturer():
|
||||
if authors := self.repository.data.authors:
|
||||
return ", ".join(author.replace("@", "") for author in authors)
|
||||
return self.repository.data.full_name.split("/")[0]
|
||||
|
||||
return {
|
||||
"identifiers": {(DOMAIN, str(self.repository.data.id))},
|
||||
"name": self.repository.display_name,
|
||||
"model": self.repository.data.category,
|
||||
"manufacturer": _manufacturer(),
|
||||
"configuration_url": f"homeassistant://hacs/repository/{self.repository.data.id}",
|
||||
"entry_type": DeviceEntryType.SERVICE,
|
||||
}
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
if (
|
||||
self._repo_last_fetched is not None
|
||||
and self.repository.data.last_fetched is not None
|
||||
and self._repo_last_fetched >= self.repository.data.last_fetched
|
||||
):
|
||||
return
|
||||
|
||||
self._repo_last_fetched = self.repository.data.last_fetched
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update the entity.
|
||||
|
||||
Only used by the generic entity update service.
|
||||
"""
|
||||
@@ -1,71 +0,0 @@
|
||||
"""Helper constants."""
|
||||
|
||||
# pylint: disable=missing-class-docstring
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class HacsGitHubRepo(StrEnum):
|
||||
"""HacsGitHubRepo."""
|
||||
|
||||
DEFAULT = "hacs/default"
|
||||
INTEGRATION = "hacs/integration"
|
||||
|
||||
|
||||
class HacsCategory(StrEnum):
|
||||
APPDAEMON = "appdaemon"
|
||||
INTEGRATION = "integration"
|
||||
LOVELACE = "lovelace"
|
||||
PLUGIN = "plugin" # Kept for legacy purposes
|
||||
PYTHON_SCRIPT = "python_script"
|
||||
TEMPLATE = "template"
|
||||
THEME = "theme"
|
||||
REMOVED = "removed"
|
||||
|
||||
def __str__(self):
|
||||
return str(self.value)
|
||||
|
||||
|
||||
class HacsDispatchEvent(StrEnum):
|
||||
"""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(StrEnum):
|
||||
"""Repository file names."""
|
||||
|
||||
HACS_JSON = "hacs.json"
|
||||
MAINIFEST_JSON = "manifest.json"
|
||||
|
||||
|
||||
class LovelaceMode(StrEnum):
|
||||
"""Lovelace Modes."""
|
||||
|
||||
STORAGE = "storage"
|
||||
AUTO = "auto"
|
||||
AUTO_GEN = "auto-gen"
|
||||
YAML = "yaml"
|
||||
|
||||
|
||||
class HacsStage(StrEnum):
|
||||
SETUP = "setup"
|
||||
STARTUP = "startup"
|
||||
WAITING = "waiting"
|
||||
RUNNING = "running"
|
||||
BACKGROUND = "background"
|
||||
|
||||
|
||||
class HacsDisabledReason(StrEnum):
|
||||
RATE_LIMIT = "rate_limit"
|
||||
REMOVED = "removed"
|
||||
INVALID_TOKEN = "invalid_token"
|
||||
CONSTRAINS = "constrains"
|
||||
LOAD_HACS = "load_hacs"
|
||||
RESTORE = "restore"
|
||||
@@ -1,49 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,67 +0,0 @@
|
||||
"""Starting setup task: Frontend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.components.frontend import (
|
||||
add_extra_js_url,
|
||||
async_register_built_in_panel,
|
||||
)
|
||||
|
||||
from .const import DOMAIN, URL_BASE
|
||||
from .hacs_frontend import VERSION as FE_VERSION, locate_dir
|
||||
from .utils.workarounds import async_register_static_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .base import HacsBase
|
||||
|
||||
|
||||
async def async_register_frontend(hass: HomeAssistant, hacs: HacsBase) -> None:
|
||||
"""Register the frontend."""
|
||||
|
||||
# Register frontend
|
||||
if hacs.configuration.dev and (frontend_path := os.getenv("HACS_FRONTEND_DIR")):
|
||||
hacs.log.warning(
|
||||
"<HacsFrontend> Frontend development mode enabled. Do not run in production!"
|
||||
)
|
||||
await async_register_static_path(
|
||||
hass, f"{URL_BASE}/frontend", f"{frontend_path}/hacs_frontend", cache_headers=False
|
||||
)
|
||||
hacs.frontend_version = "dev"
|
||||
else:
|
||||
await async_register_static_path(
|
||||
hass, f"{URL_BASE}/frontend", locate_dir(), cache_headers=False
|
||||
)
|
||||
hacs.frontend_version = FE_VERSION
|
||||
|
||||
# Custom iconset
|
||||
await async_register_static_path(
|
||||
hass, f"{URL_BASE}/iconset.js", str(hacs.integration_dir / "iconset.js")
|
||||
)
|
||||
add_extra_js_url(hass, f"{URL_BASE}/iconset.js")
|
||||
|
||||
# Add to sidepanel if needed
|
||||
if DOMAIN not in hass.data.get("frontend_panels", {}):
|
||||
async_register_built_in_panel(
|
||||
hass,
|
||||
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={hacs.frontend_version}",
|
||||
}
|
||||
},
|
||||
require_admin=True,
|
||||
)
|
||||
|
||||
# Setup plugin endpoint if needed
|
||||
await hacs.async_setup_frontend_endpoint_plugin()
|
||||
@@ -1,5 +0,0 @@
|
||||
"""HACS Frontend"""
|
||||
from .version import VERSION
|
||||
|
||||
def locate_dir():
|
||||
return __path__[0]
|
||||
@@ -1 +0,0 @@
|
||||
!function(){function n(n){var e=document.createElement("script");e.src=n,document.body.appendChild(e)}if(/.*Version\/(?:11|12)(?:\.\d+)*.*Safari\//.test(navigator.userAgent))n("/hacsfiles/frontend/frontend_es5/entrypoint.c180d0b256f9b6d0.js");else try{new Function("import('/hacsfiles/frontend/frontend_latest/entrypoint.bb9d28f38e9fba76.js')")()}catch(e){n("/hacsfiles/frontend/frontend_es5/entrypoint.c180d0b256f9b6d0.js")}}()
|
||||
@@ -1 +0,0 @@
|
||||
!function(){function e(e){var n=document.createElement("script");n.src=e,document.body.appendChild(n)}if(/.*Version\/(?:11|12)(?:\.\d+)*.*Safari\//.test(navigator.userAgent))e("/hacsfiles/frontend/frontend_es5/extra.5b474fd28ce35f7e.js");else try{new Function("import('/hacsfiles/frontend/frontend_latest/extra.fb9760592efef202.js')")()}catch(n){e("/hacsfiles/frontend/frontend_es5/extra.5b474fd28ce35f7e.js")}}()
|
||||
-3
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
-1
File diff suppressed because one or more lines are too long
-2
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
-1
File diff suppressed because one or more lines are too long
-2
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
-1
File diff suppressed because one or more lines are too long
-2
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
-1
File diff suppressed because one or more lines are too long
-2
@@ -1,2 +0,0 @@
|
||||
(self.webpackChunkhacs_frontend=self.webpackChunkhacs_frontend||[]).push([["1236"],{4121:function(){Intl.PluralRules&&"function"==typeof Intl.PluralRules.__addLocaleData&&Intl.PluralRules.__addLocaleData({data:{categories:{cardinal:["one","other"],ordinal:["one","two","few","other"]},fn:function(e,n){var a=String(e).split("."),l=!a[1],t=Number(a[0])==e,o=t&&a[0].slice(-1),r=t&&a[0].slice(-2);return n?1==o&&11!=r?"one":2==o&&12!=r?"two":3==o&&13!=r?"few":"other":1==e&&l?"one":"other"}},locale:"en"})}}]);
|
||||
//# sourceMappingURL=1236.7495ccc08957b0ec.js.map
|
||||
BIN
Binary file not shown.
-1
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"1236.7495ccc08957b0ec.js","sources":["no-source/node_modules/@formatjs/intl-pluralrules/locale-data/en.js"],"names":["Intl","PluralRules","__addLocaleData","n","ord","s","String","split","v0","t0","Number","n10","slice","n100"],"mappings":"oGAEIA,KAAKC,aAA2D,mBAArCD,KAAKC,YAAYC,iBAC9CF,KAAKC,YAAYC,gBAAgB,CAAC,KAAO,CAAC,WAAa,CAAC,SAAW,CAAC,MAAM,SAAS,QAAU,CAAC,MAAM,MAAM,MAAM,UAAU,GAAK,SAASC,EAAGC,GAC3I,IAAIC,EAAIC,OAAOH,GAAGI,MAAM,KAAMC,GAAMH,EAAE,GAAII,EAAKC,OAAOL,EAAE,KAAOF,EAAGQ,EAAMF,GAAMJ,EAAE,GAAGO,OAAO,GAAIC,EAAOJ,GAAMJ,EAAE,GAAGO,OAAO,GACvH,OAAIR,EAAmB,GAAPO,GAAoB,IAARE,EAAa,MAC9B,GAAPF,GAAoB,IAARE,EAAa,MAClB,GAAPF,GAAoB,IAARE,EAAa,MACzB,QACQ,GAALV,GAAUK,EAAK,MAAQ,OAChC,GAAG,OAAS,M"}
|
||||
-9
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
-1
File diff suppressed because one or more lines are too long
-2
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
-1
File diff suppressed because one or more lines are too long
-2
@@ -1,2 +0,0 @@
|
||||
"use strict";(self.webpackChunkhacs_frontend=self.webpackChunkhacs_frontend||[]).push([["1442"],{9643:function(e,r,t){t.r(r),t.d(r,{HaQrCode:function(){return k}});var a=t(73577),i=t(72621),s=(t(71695),t(23669),t(13334),t(47021),t(57243)),o=t(50778),n=t(54647),d=(t(17949),t(91635));let c,l,h,u=e=>e,k=(0,a.Z)([(0,o.Mo)("ha-qr-code")],(function(e,r){class t extends r{constructor(...r){super(...r),e(this)}}return{F:t,d:[{kind:"field",decorators:[(0,o.Cb)()],key:"data",value:void 0},{kind:"field",decorators:[(0,o.Cb)({attribute:"error-correction-level"})],key:"errorCorrectionLevel",value(){return"medium"}},{kind:"field",decorators:[(0,o.Cb)({type:Number})],key:"width",value(){return 4}},{kind:"field",decorators:[(0,o.Cb)({type:Number})],key:"scale",value(){return 4}},{kind:"field",decorators:[(0,o.Cb)({type:Number})],key:"margin",value(){return 4}},{kind:"field",decorators:[(0,o.Cb)({attribute:!1,type:Number})],key:"maskPattern",value:void 0},{kind:"field",decorators:[(0,o.Cb)({attribute:"center-image"})],key:"centerImage",value:void 0},{kind:"field",decorators:[(0,o.SB)()],key:"_error",value:void 0},{kind:"field",decorators:[(0,o.IO)("canvas")],key:"_canvas",value:void 0},{kind:"method",key:"willUpdate",value:function(e){(0,i.Z)(t,"willUpdate",this,3)([e]),(e.has("data")||e.has("scale")||e.has("width")||e.has("margin")||e.has("maskPattern")||e.has("errorCorrectionLevel"))&&this._error&&(this._error=void 0)}},{kind:"method",key:"updated",value:function(e){const r=this._canvas;if(r&&this.data&&(e.has("data")||e.has("scale")||e.has("width")||e.has("margin")||e.has("maskPattern")||e.has("errorCorrectionLevel")||e.has("centerImage"))){const e=getComputedStyle(this),t=e.getPropertyValue("--rgb-primary-text-color"),a=e.getPropertyValue("--rgb-card-background-color"),i=(0,d.CO)(t.split(",").map((e=>parseInt(e,10)))),s=(0,d.CO)(a.split(",").map((e=>parseInt(e,10))));if(n.toCanvas(r,this.data,{errorCorrectionLevel:this.errorCorrectionLevel||(this.centerImage?"Q":"M"),width:this.width,scale:this.scale,margin:this.margin,maskPattern:this.maskPattern,color:{light:s,dark:i}}).catch((e=>{this._error=e.message})),this.centerImage){const e=this._canvas.getContext("2d"),t=new Image;t.src=this.centerImage,t.onload=()=>{null==e||e.drawImage(t,.375*r.width,.375*r.height,r.width/4,r.height/4)}}}}},{kind:"method",key:"render",value:function(){return this.data?this._error?(0,s.dy)(c||(c=u`<ha-alert alert-type="error">${0}</ha-alert>`),this._error):(0,s.dy)(l||(l=u`<canvas></canvas>`)):s.Ld}},{kind:"field",static:!0,key:"styles",value(){return(0,s.iv)(h||(h=u`:host{display:block}`))}}]}}),s.oi)}}]);
|
||||
//# sourceMappingURL=1442.4559b6261e356849.js.map
|
||||
BIN
Binary file not shown.
-1
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"1442.4559b6261e356849.js","sources":["https://raw.githubusercontent.com/home-assistant/frontend/3ffbd435e0e5cf23872057187f3da53bb62441a2\n/src/components/ha-qr-code.ts"],"names":["HaQrCode","_decorate","customElement","_initialize","_LitElement","constructor","args","F","d","kind","decorators","property","key","value","attribute","type","Number","state","query","changedProperties","_superPropGet","has","this","_error","undefined","canvas","_canvas","data","computedStyles","getComputedStyle","textRgb","getPropertyValue","backgroundRgb","textHex","rgb2hex","split","map","a","parseInt","backgroundHex","QRCode","errorCorrectionLevel","centerImage","width","scale","margin","maskPattern","color","light","dark","catch","err","message","context","getContext","imageObj","Image","src","onload","drawImage","height","html","_t","_","_t2","nothing","static","css","_t3","LitElement"],"mappings":"4SAQaA,GAAQC,EAAAA,EAAAA,GAAA,EADpBC,EAAAA,EAAAA,IAAc,gBAAa,SAAAC,EAAAC,GAA5B,MACaJ,UAAQI,EAAoBC,WAAAA,IAAAC,GAAA,SAAAA,GAAAH,EAAA,OA0HxC,OAAAI,EA1HYP,EAAQQ,EAAA,EAAAC,KAAA,QAAAC,WAAA,EAClBC,EAAAA,EAAAA,OAAUC,IAAA,OAAAC,WAAA,IAAAJ,KAAA,QAAAC,WAAA,EAEVC,EAAAA,EAAAA,IAAS,CAAEG,UAAW,4BAA2BF,IAAA,uBAAAC,KAAAA,GAAA,MAEhD,QAAQ,IAAAJ,KAAA,QAAAC,WAAA,EAETC,EAAAA,EAAAA,IAAS,CAAEI,KAAMC,UAASJ,IAAA,QAAAC,KAAAA,GAAA,OACZ,CAAC,IAAAJ,KAAA,QAAAC,WAAA,EAEfC,EAAAA,EAAAA,IAAS,CAAEI,KAAMC,UAASJ,IAAA,QAAAC,KAAAA,GAAA,OACZ,CAAC,IAAAJ,KAAA,QAAAC,WAAA,EAEfC,EAAAA,EAAAA,IAAS,CAAEI,KAAMC,UAASJ,IAAA,SAAAC,KAAAA,GAAA,OACX,CAAC,IAAAJ,KAAA,QAAAC,WAAA,EAEhBC,EAAAA,EAAAA,IAAS,CAAEG,WAAW,EAAOC,KAAMC,UAASJ,IAAA,cAAAC,WAAA,IAAAJ,KAAA,QAAAC,WAAA,EAG5CC,EAAAA,EAAAA,IAAS,CAAEG,UAAW,kBAAiBF,IAAA,cAAAC,WAAA,IAAAJ,KAAA,QAAAC,WAAA,EAEvCO,EAAAA,EAAAA,OAAOL,IAAA,SAAAC,WAAA,IAAAJ,KAAA,QAAAC,WAAA,EAEPQ,EAAAA,EAAAA,IAAM,WAASN,IAAA,UAAAC,WAAA,IAAAJ,KAAA,SAAAG,IAAA,aAAAC,MAEhB,SAAqBM,IACnBC,EAAAA,EAAAA,GA1BSpB,EAAQ,oBA0BjBoB,CA1BiB,CA0BAD,KAEdA,EAAkBE,IAAI,SACrBF,EAAkBE,IAAI,UACtBF,EAAkBE,IAAI,UACtBF,EAAkBE,IAAI,WACtBF,EAAkBE,IAAI,gBACtBF,EAAkBE,IAAI,0BACxBC,KAAKC,SAELD,KAAKC,YAASC,EAElB,GAAC,CAAAf,KAAA,SAAAG,IAAA,UAAAC,MAED,SAAQM,GACN,MAAMM,EAASH,KAAKI,QACpB,GACED,GACAH,KAAKK,OACJR,EAAkBE,IAAI,SACrBF,EAAkBE,IAAI,UACtBF,EAAkBE,IAAI,UACtBF,EAAkBE,IAAI,WACtBF,EAAkBE,IAAI,gBACtBF,EAAkBE,IAAI,yBACtBF,EAAkBE,IAAI,gBACxB,CACA,MAAMO,EAAiBC,iBAAiBP,MAClCQ,EAAUF,EAAeG,iBAC7B,4BAEIC,EAAgBJ,EAAeG,iBACnC,+BAEIE,GAAUC,EAAAA,EAAAA,IACdJ,EAAQK,MAAM,KAAKC,KAAKC,GAAMC,SAASD,EAAG,OAMtCE,GAAgBL,EAAAA,EAAAA,IACpBF,EAAcG,MAAM,KAAKC,KAAKC,GAAMC,SAASD,EAAG,OAsBlD,GAfAG,EAAAA,SAAgBf,EAAQH,KAAKK,KAAM,CACjCc,qBACEnB,KAAKmB,uBAAyBnB,KAAKoB,YAAc,IAAM,KACzDC,MAAOrB,KAAKqB,MACZC,MAAOtB,KAAKsB,MACZC,OAAQvB,KAAKuB,OACbC,YAAaxB,KAAKwB,YAClBC,MAAO,CACLC,MAAOT,EACPU,KAAMhB,KAEPiB,OAAOC,IACR7B,KAAKC,OAAS4B,EAAIC,OAAO,IAGvB9B,KAAKoB,YAAa,CACpB,MAAMW,EAAU/B,KAAKI,QAAS4B,WAAW,MACnCC,EAAW,IAAIC,MACrBD,EAASE,IAAMnC,KAAKoB,YACpBa,EAASG,OAAS,KAChBL,SAAAA,EAASM,UACPJ,EACe,KAAf9B,EAAOkB,MACS,KAAhBlB,EAAOmC,OACPnC,EAAOkB,MAAQ,EACflB,EAAOmC,OAAS,EACjB,CAEL,CACF,CACF,GAAC,CAAAnD,KAAA,SAAAG,IAAA,SAAAC,MAED,WACE,OAAKS,KAAKK,KAGNL,KAAKC,QACAsC,EAAAA,EAAAA,IAAIC,IAAAA,EAAAC,CAAA,gCAAgC,gBAAAzC,KAAKC,SAE3CsC,EAAAA,EAAAA,IAAIG,IAAAA,EAAAD,CAAA,sBALFE,EAAAA,EAMX,GAAC,CAAAxD,KAAA,QAAAyD,QAAA,EAAAtD,IAAA,SAAAC,KAAAA,GAAA,OAEesD,EAAAA,EAAAA,IAAGC,IAAAA,EAAAL,CAAA,+BArHSM,EAAAA,G"}
|
||||
-2
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
-1
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"1477.aa80831c9f0c4257.js","sources":["https://raw.githubusercontent.com/home-assistant/frontend/3ffbd435e0e5cf23872057187f3da53bb62441a2\n/src/components/ha-selector/ha-selector-time.ts","https://raw.githubusercontent.com/home-assistant/frontend/3ffbd435e0e5cf23872057187f3da53bb62441a2\n/src/components/ha-textfield.ts"],"names":["HaTimeSelector","_decorate","customElement","_initialize","_LitElement","F","constructor","args","d","kind","decorators","property","attribute","key","value","type","Boolean","_this$selector$time","html","_t","_","this","undefined","hass","locale","disabled","required","helper","label","selector","time","no_second","LitElement","_TextFieldBase","HaTextField","query","changedProperties","_superPropGet","has","setCustomValidity","invalid","errorMessage","validationMessage","validateOnInitialRender","get","reportValidity","autocomplete","formElement","setAttribute","removeAttribute","autocorrect","inputSpellcheck","_icon","isTrailingIcon","static","styles","css","_t2","mainWindow","_t3","_t4","TextFieldBase"],"mappings":"0PAOaA,GAAcC,EAAAA,EAAAA,GAAA,EAD1BC,EAAAA,EAAAA,IAAc,sBAAmB,SAAAC,EAAAC,GA8BjC,OAAAC,EA9BD,cAC2BD,EAAoBE,WAAAA,IAAAC,GAAA,SAAAA,GAAAJ,EAAA,QAApBK,EAAA,EAAAC,KAAA,QAAAC,WAAA,EACxBC,EAAAA,EAAAA,IAAS,CAAEC,WAAW,KAAQC,IAAA,OAAAC,WAAA,IAAAL,KAAA,QAAAC,WAAA,EAE9BC,EAAAA,EAAAA,IAAS,CAAEC,WAAW,KAAQC,IAAA,WAAAC,WAAA,IAAAL,KAAA,QAAAC,WAAA,EAE9BC,EAAAA,EAAAA,OAAUE,IAAA,QAAAC,WAAA,IAAAL,KAAA,QAAAC,WAAA,EAEVC,EAAAA,EAAAA,OAAUE,IAAA,QAAAC,WAAA,IAAAL,KAAA,QAAAC,WAAA,EAEVC,EAAAA,EAAAA,OAAUE,IAAA,SAAAC,WAAA,IAAAL,KAAA,QAAAC,WAAA,EAEVC,EAAAA,EAAAA,IAAS,CAAEI,KAAMC,WAAUH,IAAA,WAAAC,KAAAA,GAAA,OAAmB,CAAK,IAAAL,KAAA,QAAAC,WAAA,EAEnDC,EAAAA,EAAAA,IAAS,CAAEI,KAAMC,WAAUH,IAAA,WAAAC,KAAAA,GAAA,OAAmB,CAAK,IAAAL,KAAA,SAAAI,IAAA,SAAAC,MAEpD,WAAmB,IAAAG,EACjB,OAAOC,EAAAA,EAAAA,IAAIC,IAAAA,EAAAC,CAAA,gKAEwB,iBAAfC,KAAKP,MAAqBO,KAAKP,WAAQQ,EAC7CD,KAAKE,KAAKC,OACRH,KAAKI,SACLJ,KAAKK,SAEPL,KAAKM,OACNN,KAAKO,QACqB,QAAnBX,EAACI,KAAKQ,SAASC,YAAI,IAAAb,GAAlBA,EAAoBc,WAG3C,IAAC,GA5BiCC,EAAAA,G,gJCCZ/B,EAAAA,EAAAA,GAAA,EADvBC,EAAAA,EAAAA,IAAc,kBAAe,SAAAC,EAAA8B,GAA9B,MACaC,UAAWD,EAAuB3B,WAAAA,IAAAC,GAAA,SAAAA,GAAAJ,EAAA,OA4N9C,OAAAE,EA5NY6B,EAAW1B,EAAA,EAAAC,KAAA,QAAAC,WAAA,EACrBC,EAAAA,EAAAA,IAAS,CAAEI,KAAMC,WAAUH,IAAA,UAAAC,WAAA,IAAAL,KAAA,QAAAC,WAAA,EAE3BC,EAAAA,EAAAA,IAAS,CAAEC,UAAW,mBAAkBC,IAAA,eAAAC,WAAA,IAAAL,KAAA,QAAAC,WAAA,EAGxCC,EAAAA,EAAAA,IAAS,CAAEI,KAAMC,WAAUH,IAAA,OAAAC,KAAAA,GAAA,OAAe,CAAK,IAAAL,KAAA,QAAAC,WAAA,EAI/CC,EAAAA,EAAAA,IAAS,CAAEI,KAAMC,WAAUH,IAAA,eAAAC,KAAAA,GAAA,OAAuB,CAAK,IAAAL,KAAA,QAAAC,WAAA,EAEvDC,EAAAA,EAAAA,OAAUE,IAAA,eAAAC,WAAA,IAAAL,KAAA,QAAAC,WAAA,EAEVC,EAAAA,EAAAA,OAAUE,IAAA,cAAAC,WAAA,IAAAL,KAAA,QAAAC,WAAA,EAEVC,EAAAA,EAAAA,IAAS,CAAEC,UAAW,sBAAqBC,IAAA,kBAAAC,WAAA,IAAAL,KAAA,QAAAC,WAAA,EAG3CyB,EAAAA,EAAAA,IAAM,UAAQtB,IAAA,cAAAC,WAAA,IAAAL,KAAA,SAAAI,IAAA,UAAAC,MAEf,SAAiBsB,IACfC,EAAAA,EAAAA,GAtBSH,EAAW,iBAsBpBG,CAtBoB,CAsBND,KAEZA,EAAkBE,IAAI,YACtBF,EAAkBE,IAAI,mBAEtBjB,KAAKkB,kBACHlB,KAAKmB,QACDnB,KAAKoB,cAAgBpB,KAAKqB,mBAAqB,UAC/C,KAGJrB,KAAKmB,SACLnB,KAAKsB,yBACJP,EAAkBE,IAAI,iBACgBhB,IAArCc,EAAkBQ,IAAI,aAIxBvB,KAAKwB,kBAGLT,EAAkBE,IAAI,kBACpBjB,KAAKyB,aACPzB,KAAK0B,YAAYC,aAAa,eAAgB3B,KAAKyB,cAEnDzB,KAAK0B,YAAYE,gBAAgB,iBAGjCb,EAAkBE,IAAI,iBACpBjB,KAAK6B,YACP7B,KAAK0B,YAAYC,aAAa,cAAe3B,KAAK6B,aAElD7B,KAAK0B,YAAYE,gBAAgB,gBAGjCb,EAAkBE,IAAI,qBACpBjB,KAAK8B,gBACP9B,KAAK0B,YAAYC,aAAa,aAAc3B,KAAK8B,iBAEjD9B,KAAK0B,YAAYE,gBAAgB,cAGvC,GAAC,CAAAxC,KAAA,SAAAI,IAAA,aAAAC,MAED,SACEsC,EACAC,GAAiB,GAEjB,MAAMtC,EAAOsC,EAAiB,WAAa,UAE3C,OAAOnC,EAAAA,EAAAA,IAAIC,IAAAA,EAAAC,CAAA,0HAE6CL,EACzCsC,EAAiB,GAAK,EAEnBtC,EAGpB,GAAC,CAAAN,KAAA,QAAA6C,QAAA,EAAAzC,IAAA,SAAAC,KAAAA,GAAA,MAEwB,CACvByC,EAAAA,GACAC,EAAAA,EAAAA,IAAGC,IAAAA,EAAArC,CAAA,+wFA0HyB,QAA5BsC,EAAAA,EAAAA,SAAAA,KACIF,EAAAA,EAAAA,IAAGG,IAAAA,EAAAvC,CAAA,4OAWHoC,EAAAA,EAAAA,IAAGI,IAAAA,EAAAxC,CAAA,KACR,OA3N8ByC,EAAAA,E"}
|
||||
-18
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
-1
File diff suppressed because one or more lines are too long
-3
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
-1
File diff suppressed because one or more lines are too long
-3
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
-1
File diff suppressed because one or more lines are too long
-2
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
-1
File diff suppressed because one or more lines are too long
-2
@@ -1,2 +0,0 @@
|
||||
"use strict";(self.webpackChunkhacs_frontend=self.webpackChunkhacs_frontend||[]).push([["170"],{3961:function(e,t,n){n.r(t),n.d(t,{HaIconButtonPrev:function(){return s}});var o=n(73577),i=(n(71695),n(47021),n(57243)),a=n(50778),r=n(13089);n(59897);let d,l=e=>e;let s=(0,o.Z)([(0,a.Mo)("ha-icon-button-prev")],(function(e,t){return{F:class extends t{constructor(...t){super(...t),e(this)}},d:[{kind:"field",decorators:[(0,a.Cb)({attribute:!1})],key:"hass",value:void 0},{kind:"field",decorators:[(0,a.Cb)({type:Boolean})],key:"disabled",value(){return!1}},{kind:"field",decorators:[(0,a.Cb)()],key:"label",value:void 0},{kind:"field",decorators:[(0,a.SB)()],key:"_icon",value(){return"rtl"===r.E.document.dir?"M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z":"M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z"}},{kind:"method",key:"render",value:function(){var e;return(0,i.dy)(d||(d=l` <ha-icon-button .disabled="${0}" .label="${0}" .path="${0}"></ha-icon-button> `),this.disabled,this.label||(null===(e=this.hass)||void 0===e?void 0:e.localize("ui.common.back"))||"Back",this._icon)}}]}}),i.oi)}}]);
|
||||
//# sourceMappingURL=170.4f38a07dc7aa96bd.js.map
|
||||
BIN
Binary file not shown.
-1
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"170.4f38a07dc7aa96bd.js","sources":["https://raw.githubusercontent.com/home-assistant/frontend/3ffbd435e0e5cf23872057187f3da53bb62441a2\n/src/components/ha-icon-button-prev.ts"],"names":["HaIconButtonPrev","_decorate","customElement","_initialize","_LitElement","F","constructor","args","d","kind","decorators","property","attribute","key","value","type","Boolean","state","mainWindow","_this$hass","html","_t","_","this","disabled","label","hass","localize","_icon","LitElement"],"mappings":"qQAQA,IACaA,GAAgBC,EAAAA,EAAAA,GAAA,EAD5BC,EAAAA,EAAAA,IAAc,yBAAsB,SAAAC,EAAAC,GAoBpC,OAAAC,EApBD,cAC6BD,EAAoBE,WAAAA,IAAAC,GAAA,SAAAA,GAAAJ,EAAA,QAApBK,EAAA,EAAAC,KAAA,QAAAC,WAAA,EAC1BC,EAAAA,EAAAA,IAAS,CAAEC,WAAW,KAAQC,IAAA,OAAAC,WAAA,IAAAL,KAAA,QAAAC,WAAA,EAE9BC,EAAAA,EAAAA,IAAS,CAAEI,KAAMC,WAAUH,IAAA,WAAAC,KAAAA,GAAA,OAAmB,CAAK,IAAAL,KAAA,QAAAC,WAAA,EAEnDC,EAAAA,EAAAA,OAAUE,IAAA,QAAAC,WAAA,IAAAL,KAAA,QAAAC,WAAA,EAEVO,EAAAA,EAAAA,OAAOJ,IAAA,QAAAC,KAAAA,GAAA,MACsB,QAA5BI,EAAAA,EAAAA,SAAAA,I,6HAAoE,IAAAT,KAAA,SAAAI,IAAA,SAAAC,MAEtE,WAAmC,IAAAK,EACjC,OAAOC,EAAAA,EAAAA,IAAIC,IAAAA,EAAAC,CAAA,mFAEKC,KAAKC,SACRD,KAAKE,QAAkB,QAAbN,EAAII,KAAKG,YAAI,IAAAP,OAAA,EAATA,EAAWQ,SAAS,oBAAqB,OACxDJ,KAAKK,MAGnB,IAAC,GAlBmCC,EAAAA,G"}
|
||||
-3
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
-1
File diff suppressed because one or more lines are too long
-2
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
-1
File diff suppressed because one or more lines are too long
-2
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user