Auto Merge of PR 2 - swag-config-additions_2025-08-31T07-14-27

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

Some files were not shown because too many files have changed in this diff Show More