Initial commit.
@@ -0,0 +1,43 @@
|
||||
*.csr
|
||||
*.crt
|
||||
*.key
|
||||
*.pem
|
||||
secrets.yaml
|
||||
ip_bans.yaml
|
||||
known_devices.yaml
|
||||
google_calendars.yaml
|
||||
entity_registry.yaml
|
||||
*.json
|
||||
*.conf
|
||||
*.log
|
||||
*.txt
|
||||
*.sh
|
||||
*.json
|
||||
*.xml
|
||||
*.sqlite
|
||||
ozw_config/
|
||||
tts/
|
||||
deps/
|
||||
n.jpg
|
||||
r.jpg
|
||||
p.jpg
|
||||
*.json
|
||||
cox_usage.json
|
||||
cox_usage.py
|
||||
*.pyc
|
||||
*.log.*
|
||||
blueprints/
|
||||
govee_learning.yaml
|
||||
zigbee.db*
|
||||
timelapse/*
|
||||
www/winlab/*
|
||||
www/timelapse/*
|
||||
www/community/lovelace-card-mod/card-mod.js
|
||||
www/community/lovelace-card-mod/card-mod.js.gz
|
||||
json
|
||||
**addons*/./image/58647060c161423a19fa911c0b16bf1f/original
|
||||
./image/58647060c161423a19fa911c0b16bf1f/512x512
|
||||
./image/04fe2e85a69341a59a718100477569b1/original
|
||||
./image/04fe2e85a69341a59a718100477569b1/512x512
|
||||
./www/mail_and_packages/ra_0_mailerProvidedImage0
|
||||
./www/mail_and_packages/mailerProvidedImage0
|
||||
@@ -0,0 +1,13 @@
|
||||
#============================#
|
||||
# ALEXBELGIUM'S ENV INJECTOR #
|
||||
#============================#
|
||||
#
|
||||
# All env variables set in this file will be enabled in the app
|
||||
# This allows enabling more options that normally available in the addon options
|
||||
# This file must be filled according to the yaml format.
|
||||
# If the format is invalid, the addon will note an error.
|
||||
# To validate your yaml, you can use the free online tool http://www.yamllint.com/
|
||||
|
||||
# EXAMPLE of the format (you need to remove the # for it to become active)
|
||||
# TZ: Europe/Paris
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
#============================#
|
||||
# ALEXBELGIUM'S ENV INJECTOR #
|
||||
#============================#
|
||||
#
|
||||
# All env variables set in this file will be enabled in the app
|
||||
# This allows enabling more options that normally available in the addon options
|
||||
# This file must be filled according to the yaml format.
|
||||
# If the format is invalid, the addon will note an error.
|
||||
# To validate your yaml, you can use the free online tool http://www.yamllint.com/
|
||||
|
||||
# EXAMPLE of the format (you need to remove the # for it to become active)
|
||||
# TZ: Europe/Paris
|
||||
|
||||
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 321 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 64 KiB |
@@ -0,0 +1,51 @@
|
||||
- id: '1654749081487'
|
||||
alias: Vacuum schedule
|
||||
description: ''
|
||||
trigger:
|
||||
- platform: time
|
||||
at: '11:00:00'
|
||||
condition: []
|
||||
action:
|
||||
- parallel:
|
||||
- device_id: 4f4ac1838a8d32748da9d2e5aa54c748
|
||||
domain: vacuum
|
||||
entity_id: vacuum.johnny_5
|
||||
type: clean
|
||||
- service: vacuum.set_fan_speed
|
||||
data:
|
||||
fan_speed: Eco
|
||||
target:
|
||||
device_id: 4f4ac1838a8d32748da9d2e5aa54c748
|
||||
entity_id: vacuum.johnny_5
|
||||
mode: single
|
||||
- id: '1687209973222'
|
||||
alias: After Pool
|
||||
description: Night lights
|
||||
trigger:
|
||||
- platform: state
|
||||
entity_id:
|
||||
- binary_sensor.front_door_sensor_opening
|
||||
from: 'off'
|
||||
to: 'on'
|
||||
condition:
|
||||
- condition: and
|
||||
conditions:
|
||||
- condition: device
|
||||
device_id: 8381d589cc257ba882fb80036224e53d
|
||||
domain: device_tracker
|
||||
entity_id: device_tracker.pixel_7_3
|
||||
type: is_home
|
||||
- condition: sun
|
||||
before: sunrise
|
||||
after: sunset
|
||||
- condition: time
|
||||
weekday:
|
||||
- mon
|
||||
- tue
|
||||
- thu
|
||||
action:
|
||||
- service: scene.turn_on
|
||||
target:
|
||||
entity_id: scene.night_time
|
||||
metadata: {}
|
||||
mode: single
|
||||
@@ -0,0 +1,21 @@
|
||||
# Configure a default setup of Home Assistant (frontend, api, etc)
|
||||
default_config:
|
||||
|
||||
# Text to speech
|
||||
tts:
|
||||
- platform: google_translate
|
||||
|
||||
automation: !include automations.yaml
|
||||
script: !include scripts.yaml
|
||||
scene: !include scenes.yaml
|
||||
|
||||
device_tracker:
|
||||
- platform: quantum_gateway
|
||||
host: 192.168.1.1
|
||||
password: !secret fios_gateway_password
|
||||
|
||||
http:
|
||||
use_x_forwarded_for: true
|
||||
trusted_proxies: 192.168.1.254
|
||||
|
||||
browser:
|
||||
@@ -0,0 +1,27 @@
|
||||
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
|
||||
@@ -0,0 +1,76 @@
|
||||
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,
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
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"
|
||||
@@ -0,0 +1,249 @@
|
||||
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)
|
||||
@@ -0,0 +1,38 @@
|
||||
"""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
|
||||
@@ -0,0 +1,123 @@
|
||||
"""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
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
"""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."""
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Constants for the Cync Room Lights integration."""
|
||||
|
||||
DOMAIN = "cync_lights"
|
||||
@@ -0,0 +1,870 @@
|
||||
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"""
|
||||
@@ -0,0 +1,104 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,253 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,82 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,41 @@
|
||||
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)
|
||||
)
|
||||
@@ -0,0 +1,2 @@
|
||||
DOMAIN = "dwains_dashboard"
|
||||
VERSION = "3.8.0"
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
src/*
|
||||
!src/translations.js
|
||||
dwains-dashboard.js.map
|
||||
@@ -0,0 +1,40 @@
|
||||
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"
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
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)
|
||||
@@ -0,0 +1,19 @@
|
||||
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)]
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
# 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/
|
||||
@@ -0,0 +1,6 @@
|
||||
- title: Home
|
||||
icon: mdi:home
|
||||
path: home
|
||||
type: custom:dwains-dashboard-layout
|
||||
cards:
|
||||
- type: custom:homepage-card
|
||||
@@ -0,0 +1,6 @@
|
||||
- title: Devices
|
||||
icon: mdi:format-list-bulleted-type
|
||||
path: devices
|
||||
type: custom:dwains-dashboard-layout
|
||||
cards:
|
||||
- type: custom:devices-card
|
||||
@@ -0,0 +1,19 @@
|
||||
# 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 %}
|
||||
@@ -0,0 +1,7 @@
|
||||
- title: More page
|
||||
path: more_page
|
||||
icon: mdi:dots-horizontal
|
||||
visible: true
|
||||
type: custom:dwains-dashboard-layout
|
||||
cards:
|
||||
- type: custom:more-pages-card
|
||||
@@ -0,0 +1,221 @@
|
||||
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
|
||||
@@ -0,0 +1,213 @@
|
||||
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")
|
||||
@@ -0,0 +1,100 @@
|
||||
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))
|
||||
@@ -0,0 +1,26 @@
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
"""foobar2000 media player custom component init file"""
|
||||
@@ -0,0 +1,329 @@
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,229 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,225 @@
|
||||
"""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))
|
||||
@@ -0,0 +1,294 @@
|
||||
"""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",
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,98 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""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",))
|
||||
@@ -0,0 +1,143 @@
|
||||
"""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.
|
||||
"""
|
||||
@@ -0,0 +1,71 @@
|
||||
"""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"
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Custom Exceptions for HACS."""
|
||||
|
||||
|
||||
class HacsException(Exception):
|
||||
"""Super basic."""
|
||||
|
||||
|
||||
class HacsRepositoryArchivedException(HacsException):
|
||||
"""For repositories that are archived."""
|
||||
|
||||
|
||||
class HacsNotModifiedException(HacsException):
|
||||
"""For responses that are not modified."""
|
||||
|
||||
|
||||
class HacsExpectedException(HacsException):
|
||||
"""For stuff that are expected."""
|
||||
|
||||
|
||||
class HacsRepositoryExistException(HacsException):
|
||||
"""For repositories that are already exist."""
|
||||
|
||||
|
||||
class HacsExecutionStillInProgress(HacsException):
|
||||
"""Exception to raise if execution is still in progress."""
|
||||
|
||||
|
||||
class AddonRepositoryException(HacsException):
|
||||
"""Exception to raise when user tries to add add-on repository."""
|
||||
|
||||
exception_message = (
|
||||
"The repository does not seem to be a integration, "
|
||||
"but an add-on repository. HACS does not manage add-ons."
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(self.exception_message)
|
||||
|
||||
|
||||
class HomeAssistantCoreRepositoryException(HacsException):
|
||||
"""Exception to raise when user tries to add the home-assistant/core repository."""
|
||||
|
||||
exception_message = (
|
||||
"You can not add homeassistant/core, to use core integrations "
|
||||
"check the Home Assistant documentation for how to add them."
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(self.exception_message)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,5 @@
|
||||
"""HACS Frontend"""
|
||||
from .version import VERSION
|
||||
|
||||
def locate_dir():
|
||||
return __path__[0]
|
||||
@@ -0,0 +1 @@
|
||||
!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")}}()
|
||||
@@ -0,0 +1 @@
|
||||
!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")}}()
|
||||
@@ -0,0 +1,2 @@
|
||||
(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
|
||||