initial commit
This commit is contained in:
841
custom_components/openhasp/__init__.py
Normal file
841
custom_components/openhasp/__init__.py
Normal file
@@ -0,0 +1,841 @@
|
||||
"""HASP components module."""
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import jsonschema
|
||||
|
||||
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN
|
||||
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN
|
||||
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
|
||||
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||
from homeassistant.const import CONF_NAME, STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import TemplateError
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.event import TrackTemplate, async_track_template_result
|
||||
from homeassistant.helpers.network import get_url
|
||||
from homeassistant.helpers.reload import async_integration_yaml_config
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.service import async_call_from_config
|
||||
from homeassistant.util import slugify
|
||||
import voluptuous as vol
|
||||
|
||||
from .common import HASP_IDLE_SCHEMA
|
||||
from .const import (
|
||||
ATTR_CONFIG_SUBMODULE,
|
||||
ATTR_HEIGHT,
|
||||
ATTR_IDLE,
|
||||
ATTR_IMAGE,
|
||||
ATTR_OBJECT,
|
||||
ATTR_PAGE,
|
||||
ATTR_PATH,
|
||||
ATTR_COMMAND_KEYWORD,
|
||||
ATTR_COMMAND_PARAMETERS,
|
||||
ATTR_CONFIG_PARAMETERS,
|
||||
ATTR_WIDTH,
|
||||
CONF_COMPONENT,
|
||||
CONF_EVENT,
|
||||
CONF_HWID,
|
||||
CONF_OBJECTS,
|
||||
CONF_OBJID,
|
||||
CONF_PAGES,
|
||||
CONF_PAGES_PATH,
|
||||
CONF_PLATE,
|
||||
CONF_PROPERTIES,
|
||||
CONF_TOPIC,
|
||||
CONF_TRACK,
|
||||
DATA_IMAGES,
|
||||
DATA_LISTENER,
|
||||
DISCOVERED_MANUFACTURER,
|
||||
DISCOVERED_MODEL,
|
||||
DISCOVERED_URL,
|
||||
DISCOVERED_VERSION,
|
||||
DOMAIN,
|
||||
EVENT_HASP_PLATE_OFFLINE,
|
||||
EVENT_HASP_PLATE_ONLINE,
|
||||
HASP_EVENT,
|
||||
HASP_EVENT_DOWN,
|
||||
HASP_EVENT_RELEASE,
|
||||
HASP_EVENT_UP,
|
||||
HASP_EVENTS,
|
||||
HASP_LWT,
|
||||
HASP_NUM_PAGES,
|
||||
HASP_ONLINE,
|
||||
HASP_VAL,
|
||||
MAJOR,
|
||||
MINOR,
|
||||
SERVICE_CLEAR_PAGE,
|
||||
SERVICE_LOAD_PAGE,
|
||||
SERVICE_PAGE_CHANGE,
|
||||
SERVICE_PAGE_NEXT,
|
||||
SERVICE_PAGE_PREV,
|
||||
SERVICE_PUSH_IMAGE,
|
||||
SERVICE_WAKEUP,
|
||||
SERVICE_COMMAND,
|
||||
SERVICE_CONFIG,
|
||||
)
|
||||
from .image import ImageServeView, image_to_rgb565
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [
|
||||
LIGHT_DOMAIN,
|
||||
SWITCH_DOMAIN,
|
||||
BINARY_SENSOR_DOMAIN,
|
||||
NUMBER_DOMAIN,
|
||||
BUTTON_DOMAIN,
|
||||
]
|
||||
|
||||
|
||||
def hasp_object(value):
|
||||
"""Validade HASP-LVGL object format."""
|
||||
if re.match("p[0-9]+b[0-9]+", value):
|
||||
return value
|
||||
raise vol.Invalid("Not an HASP-LVGL object p#b#")
|
||||
|
||||
|
||||
# Configuration YAML schemas
|
||||
EVENT_SCHEMA = cv.schema_with_slug_keys([cv.SERVICE_SCHEMA])
|
||||
|
||||
PROPERTY_SCHEMA = cv.schema_with_slug_keys(cv.template)
|
||||
|
||||
OBJECT_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_OBJID): hasp_object,
|
||||
vol.Optional(CONF_TRACK, default=None): vol.Any(cv.entity_id, None),
|
||||
vol.Optional(CONF_PROPERTIES, default={}): PROPERTY_SCHEMA,
|
||||
vol.Optional(CONF_EVENT, default={}): EVENT_SCHEMA,
|
||||
}
|
||||
)
|
||||
|
||||
PLATE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_OBJECTS): vol.All(cv.ensure_list, [OBJECT_SCHEMA]),
|
||||
},
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{DOMAIN: vol.Schema({cv.slug: PLATE_SCHEMA})}, extra=vol.ALLOW_EXTRA
|
||||
)
|
||||
|
||||
# JSON Messages from HASP schemas
|
||||
HASP_VAL_SCHEMA = vol.Schema(
|
||||
{vol.Required(HASP_VAL): vol.All(int, vol.Range(min=0, max=1))},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
HASP_EVENT_SCHEMA = vol.Schema(
|
||||
{vol.Required(HASP_EVENT): vol.Any(*HASP_EVENTS)}, extra=vol.ALLOW_EXTRA
|
||||
)
|
||||
|
||||
HASP_STATUSUPDATE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("node"): cv.string,
|
||||
vol.Required("version"): cv.string,
|
||||
vol.Required("uptime"): int,
|
||||
vol.Required("canUpdate"): cv.boolean,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
HASP_LWT_SCHEMA = vol.Schema(vol.Any(*HASP_LWT))
|
||||
|
||||
HASP_PAGE_SCHEMA = vol.Schema(vol.All(vol.Coerce(int), vol.Range(min=0, max=12)))
|
||||
|
||||
PUSH_IMAGE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_IMAGE): vol.Any(cv.url, cv.isfile),
|
||||
vol.Required(ATTR_OBJECT): hasp_object,
|
||||
vol.Optional(ATTR_WIDTH): cv.positive_int,
|
||||
vol.Optional(ATTR_HEIGHT): cv.positive_int,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup(hass, config):
|
||||
"""Set up the MQTT async example component."""
|
||||
conf = config.get(DOMAIN)
|
||||
|
||||
if conf is None:
|
||||
# We still depend in YAML so we must fail
|
||||
_LOGGER.error(
|
||||
"openHASP requires you to setup your plate objects in your YAML configuration."
|
||||
)
|
||||
return False
|
||||
|
||||
hass.data[DOMAIN] = {CONF_PLATE: {}}
|
||||
|
||||
component = hass.data[DOMAIN][CONF_COMPONENT] = EntityComponent(
|
||||
_LOGGER, DOMAIN, hass
|
||||
)
|
||||
|
||||
component.async_register_entity_service(SERVICE_WAKEUP, {}, "async_wakeup")
|
||||
component.async_register_entity_service(
|
||||
SERVICE_PAGE_NEXT, {}, "async_change_page_next"
|
||||
)
|
||||
component.async_register_entity_service(
|
||||
SERVICE_PAGE_PREV, {}, "async_change_page_prev"
|
||||
)
|
||||
component.async_register_entity_service(
|
||||
SERVICE_PAGE_CHANGE, {vol.Required(ATTR_PAGE): int}, "async_change_page"
|
||||
)
|
||||
component.async_register_entity_service(
|
||||
SERVICE_LOAD_PAGE, {vol.Required(ATTR_PATH): cv.isfile}, "async_load_page"
|
||||
)
|
||||
component.async_register_entity_service(
|
||||
SERVICE_CLEAR_PAGE, {vol.Optional(ATTR_PAGE): int}, "async_clearpage"
|
||||
)
|
||||
component.async_register_entity_service(
|
||||
SERVICE_COMMAND,
|
||||
{
|
||||
vol.Required(ATTR_COMMAND_KEYWORD): cv.string,
|
||||
vol.Optional(ATTR_COMMAND_PARAMETERS, default=""): cv.string,
|
||||
},
|
||||
"async_command_service",
|
||||
)
|
||||
|
||||
component.async_register_entity_service(
|
||||
SERVICE_CONFIG,
|
||||
{
|
||||
vol.Required(ATTR_CONFIG_SUBMODULE): cv.string,
|
||||
vol.Required(ATTR_CONFIG_PARAMETERS): cv.string,
|
||||
},
|
||||
"async_config_service",
|
||||
)
|
||||
|
||||
component.async_register_entity_service(
|
||||
SERVICE_PUSH_IMAGE, PUSH_IMAGE_SCHEMA, "async_push_image"
|
||||
)
|
||||
|
||||
hass.data[DOMAIN][DATA_IMAGES] = dict()
|
||||
hass.http.register_view(ImageServeView)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_update_options(hass, entry):
|
||||
"""Handle options update."""
|
||||
_LOGGER.debug("Reloading")
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_setup_entry(hass, entry) -> bool:
|
||||
"""Set up OpenHASP via a config entry."""
|
||||
plate = entry.data[CONF_NAME]
|
||||
_LOGGER.debug("Setup %s", plate)
|
||||
|
||||
hass_config = await async_integration_yaml_config(hass, DOMAIN)
|
||||
|
||||
if DOMAIN not in hass_config or slugify(plate) not in hass_config[DOMAIN]:
|
||||
_LOGGER.error(
|
||||
"No YAML configuration for %s, \
|
||||
please create an entry under 'openhasp' with the slug: %s",
|
||||
plate,
|
||||
slugify(plate),
|
||||
)
|
||||
return False
|
||||
|
||||
config = hass_config[DOMAIN][slugify(plate)]
|
||||
|
||||
# Register Plate device
|
||||
device_registry = dr.async_get(hass)
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, entry.data[CONF_HWID])},
|
||||
manufacturer=entry.data[DISCOVERED_MANUFACTURER],
|
||||
model=entry.data[DISCOVERED_MODEL],
|
||||
sw_version=entry.data[DISCOVERED_VERSION],
|
||||
configuration_url=entry.data.get(DISCOVERED_URL),
|
||||
name=plate,
|
||||
)
|
||||
|
||||
# Add entity to component
|
||||
component = hass.data[DOMAIN][CONF_COMPONENT]
|
||||
plate_entity = SwitchPlate(hass, config, entry)
|
||||
await component.async_add_entities([plate_entity])
|
||||
hass.data[DOMAIN][CONF_PLATE][plate] = plate_entity
|
||||
|
||||
for domain in PLATFORMS:
|
||||
hass.async_create_task(
|
||||
hass.config_entries.async_forward_entry_setup(entry, domain)
|
||||
)
|
||||
|
||||
listener = entry.add_update_listener(async_update_options)
|
||||
entry.async_on_unload(listener)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass, entry):
|
||||
"""Remove a config entry."""
|
||||
plate = entry.data[CONF_NAME]
|
||||
|
||||
_LOGGER.debug("Unload entry for plate %s", plate)
|
||||
|
||||
for domain in PLATFORMS:
|
||||
await hass.config_entries.async_forward_entry_unload(entry, domain)
|
||||
|
||||
component = hass.data[DOMAIN][CONF_COMPONENT]
|
||||
await component.async_remove_entity(hass.data[DOMAIN][CONF_PLATE][plate].entity_id)
|
||||
|
||||
# Remove Plate entity
|
||||
del hass.data[DOMAIN][CONF_PLATE][plate]
|
||||
|
||||
return True
|
||||
|
||||
async def async_remove_entry(hass, entry):
|
||||
plate = entry.data[CONF_NAME]
|
||||
|
||||
# Only remove services if it is the last
|
||||
if len(hass.data[DOMAIN][CONF_PLATE]) == 1:
|
||||
_LOGGER.debug("removing services")
|
||||
hass.services.async_remove(DOMAIN, SERVICE_WAKEUP)
|
||||
hass.services.async_remove(DOMAIN, SERVICE_PAGE_NEXT)
|
||||
hass.services.async_remove(DOMAIN, SERVICE_PAGE_PREV)
|
||||
hass.services.async_remove(DOMAIN, SERVICE_PAGE_CHANGE)
|
||||
hass.services.async_remove(DOMAIN, SERVICE_LOAD_PAGE)
|
||||
hass.services.async_remove(DOMAIN, SERVICE_CLEAR_PAGE)
|
||||
hass.services.async_remove(DOMAIN, SERVICE_COMMAND)
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
dev = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, entry.data[CONF_HWID])}
|
||||
)
|
||||
if entry.entry_id in dev.config_entries:
|
||||
_LOGGER.debug("Removing device %s", dev)
|
||||
device_registry.async_remove_device(dev.id)
|
||||
|
||||
# Component does not remove entity from entity_registry, so we must do it
|
||||
registry = await entity_registry.async_get_registry(hass)
|
||||
registry.async_remove(hass.data[DOMAIN][CONF_PLATE][plate].entity_id)
|
||||
|
||||
# pylint: disable=R0902
|
||||
class SwitchPlate(RestoreEntity):
|
||||
"""Representation of an openHASP Plate."""
|
||||
|
||||
def __init__(self, hass, config, entry):
|
||||
"""Initialize a plate."""
|
||||
super().__init__()
|
||||
self._entry = entry
|
||||
self._topic = entry.data[CONF_TOPIC]
|
||||
self._pages_jsonl = entry.options.get(
|
||||
CONF_PAGES_PATH, entry.data.get(CONF_PAGES_PATH)
|
||||
)
|
||||
|
||||
self._objects = []
|
||||
for obj in config[CONF_OBJECTS]:
|
||||
new_obj = HASPObject(hass, self._topic, obj)
|
||||
|
||||
self._objects.append(new_obj)
|
||||
self._statusupdate = {HASP_NUM_PAGES: entry.data[CONF_PAGES]}
|
||||
self._available = False
|
||||
self._page = 1
|
||||
|
||||
self._subscriptions = []
|
||||
|
||||
with open(
|
||||
pathlib.Path(__file__).parent.joinpath("pages_schema.json"), "r"
|
||||
) as schema_file:
|
||||
self.json_schema = json.load(schema_file)
|
||||
|
||||
self._attr_unique_id = entry.data[CONF_HWID]
|
||||
self._attr_name = entry.data[CONF_NAME]
|
||||
self._attr_icon = "mdi:gesture-tap-box"
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state of the component."""
|
||||
return self._page
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
"""Return if entity is available."""
|
||||
return self._available
|
||||
|
||||
async def async_will_remove_from_hass(self):
|
||||
"""Run before entity is removed."""
|
||||
_LOGGER.debug("Remove plate %s", self._entry.data[CONF_NAME])
|
||||
|
||||
for obj in self._objects:
|
||||
await obj.disable_object()
|
||||
|
||||
for subscription in self._subscriptions:
|
||||
subscription()
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Run when entity about to be added."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
state = await self.async_get_last_state()
|
||||
if state and state.state not in [STATE_UNAVAILABLE, STATE_UNKNOWN, None]:
|
||||
self._page = int(state.state)
|
||||
|
||||
@callback
|
||||
async def page_update_received(msg):
|
||||
"""Process page state."""
|
||||
try:
|
||||
self._page = HASP_PAGE_SCHEMA(msg.payload)
|
||||
_LOGGER.debug("Page changed to %s", self._page)
|
||||
self.async_write_ha_state()
|
||||
except vol.error.Invalid as err:
|
||||
_LOGGER.error("%s in %s", err, msg.payload)
|
||||
|
||||
self._subscriptions.append(
|
||||
await self.hass.components.mqtt.async_subscribe(
|
||||
f"{self._topic}/state/page", page_update_received
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
async def statusupdate_message_received(msg):
|
||||
"""Process statusupdate."""
|
||||
|
||||
try:
|
||||
message = HASP_STATUSUPDATE_SCHEMA(json.loads(msg.payload))
|
||||
|
||||
major, minor, _ = message["version"].split(".")
|
||||
if (major, minor) != (MAJOR, MINOR):
|
||||
self.hass.components.persistent_notification.create(
|
||||
f"You require firmware version {MAJOR}.{MINOR}.x \
|
||||
in plate {self._entry.data[CONF_NAME]} \
|
||||
for this component to work properly.\
|
||||
<br>Some features will simply not work!",
|
||||
title="openHASP Firmware mismatch",
|
||||
notification_id="openhasp_firmware_notification",
|
||||
)
|
||||
_LOGGER.error(
|
||||
"%s firmware mismatch %s <> %s",
|
||||
self._entry.data[CONF_NAME],
|
||||
(major, minor),
|
||||
(MAJOR, MINOR),
|
||||
)
|
||||
self._available = True
|
||||
self._statusupdate = message
|
||||
|
||||
self._page = message[ATTR_PAGE]
|
||||
self.async_write_ha_state()
|
||||
|
||||
# Update Plate device information
|
||||
device_registry = dr.async_get(self.hass)
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=self._entry.entry_id,
|
||||
identifiers={(DOMAIN, self._entry.data[CONF_HWID])},
|
||||
manufacturer=self._entry.data[DISCOVERED_MANUFACTURER],
|
||||
model=self._entry.data[DISCOVERED_MODEL],
|
||||
configuration_url=self._entry.data.get(DISCOVERED_URL),
|
||||
sw_version=message["version"],
|
||||
name=self._entry.data[CONF_NAME],
|
||||
)
|
||||
|
||||
except vol.error.Invalid as err:
|
||||
_LOGGER.error("While processing status update: %s", err)
|
||||
|
||||
self._subscriptions.append(
|
||||
await self.hass.components.mqtt.async_subscribe(
|
||||
f"{self._topic}/state/statusupdate", statusupdate_message_received
|
||||
)
|
||||
)
|
||||
await self.hass.components.mqtt.async_publish(
|
||||
self.hass, f"{self._topic}/command", "statusupdate", qos=0, retain=False
|
||||
)
|
||||
|
||||
@callback
|
||||
async def idle_message_received(msg):
|
||||
"""Process idle message."""
|
||||
try:
|
||||
self._statusupdate[ATTR_IDLE] = HASP_IDLE_SCHEMA(msg.payload)
|
||||
self.async_write_ha_state()
|
||||
except vol.error.Invalid as err:
|
||||
_LOGGER.error("While processing idle message: %s", err)
|
||||
|
||||
self._subscriptions.append(
|
||||
await self.hass.components.mqtt.async_subscribe(
|
||||
f"{self._topic}/state/idle", idle_message_received
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
async def lwt_message_received(msg):
|
||||
"""Process LWT."""
|
||||
_LOGGER.debug("Received LWT = %s", msg.payload)
|
||||
try:
|
||||
message = HASP_LWT_SCHEMA(msg.payload)
|
||||
|
||||
if message == HASP_ONLINE:
|
||||
self._available = True
|
||||
self.hass.bus.async_fire(
|
||||
EVENT_HASP_PLATE_ONLINE,
|
||||
{CONF_PLATE: self._entry.data[CONF_HWID]},
|
||||
)
|
||||
if self._pages_jsonl:
|
||||
await self.async_load_page(self._pages_jsonl)
|
||||
else:
|
||||
await self.refresh()
|
||||
|
||||
for obj in self._objects:
|
||||
await obj.enable_object()
|
||||
else:
|
||||
self._available = False
|
||||
self.hass.bus.async_fire(
|
||||
EVENT_HASP_PLATE_OFFLINE,
|
||||
{CONF_PLATE: self._entry.data[CONF_HWID]},
|
||||
)
|
||||
for obj in self._objects:
|
||||
await obj.disable_object()
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
except vol.error.Invalid as err:
|
||||
_LOGGER.error("While processing LWT: %s", err)
|
||||
|
||||
self._subscriptions.append(
|
||||
await self.hass.components.mqtt.async_subscribe(
|
||||
f"{self._topic}/LWT", lwt_message_received
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
attributes = {}
|
||||
|
||||
if self._statusupdate:
|
||||
attributes = {**attributes, **self._statusupdate}
|
||||
|
||||
if ATTR_PAGE in attributes:
|
||||
del attributes[
|
||||
ATTR_PAGE
|
||||
] # Page is tracked in the state, don't confuse users
|
||||
|
||||
return attributes
|
||||
|
||||
async def async_wakeup(self):
|
||||
"""Wake up the display."""
|
||||
cmd_topic = f"{self._topic}/command"
|
||||
_LOGGER.warning("Wakeup will be deprecated in 0.8.0") # remove in version 0.8.0
|
||||
await self.hass.components.mqtt.async_publish(
|
||||
self.hass, cmd_topic, "wakeup", qos=0, retain=False
|
||||
)
|
||||
|
||||
async def async_change_page_next(self):
|
||||
"""Change page to next one."""
|
||||
cmd_topic = f"{self._topic}/command/page"
|
||||
_LOGGER.warning(
|
||||
"page next service will be deprecated in 0.8.0"
|
||||
) # remove in version 0.8.0
|
||||
|
||||
await self.hass.components.mqtt.async_publish(
|
||||
self.hass, cmd_topic, "page next", qos=0, retain=False
|
||||
)
|
||||
|
||||
async def async_change_page_prev(self):
|
||||
"""Change page to previous one."""
|
||||
cmd_topic = f"{self._topic}/command/page"
|
||||
_LOGGER.warning(
|
||||
"page prev service will be deprecated in 0.8.0"
|
||||
) # remove in version 0.8.0
|
||||
|
||||
await self.hass.components.mqtt.async_publish(
|
||||
self.hass, cmd_topic, "page prev", qos=0, retain=False
|
||||
)
|
||||
|
||||
async def async_clearpage(self, page="all"):
|
||||
"""Clear page."""
|
||||
cmd_topic = f"{self._topic}/command"
|
||||
|
||||
await self.hass.components.mqtt.async_publish(
|
||||
self.hass, cmd_topic, f"clearpage {page}", qos=0, retain=False
|
||||
)
|
||||
|
||||
if page == "all":
|
||||
await self.hass.components.mqtt.async_publish(
|
||||
self.hass, cmd_topic, "page 1", qos=0, retain=False
|
||||
)
|
||||
|
||||
async def async_change_page(self, page):
|
||||
"""Change page to number."""
|
||||
cmd_topic = f"{self._topic}/command/page"
|
||||
|
||||
if self._statusupdate:
|
||||
num_pages = self._statusupdate[HASP_NUM_PAGES]
|
||||
|
||||
if page <= 0 or page > num_pages:
|
||||
_LOGGER.error(
|
||||
"Can't change to %s, available pages are 1 to %s", page, num_pages
|
||||
)
|
||||
return
|
||||
|
||||
self._page = page
|
||||
|
||||
_LOGGER.debug("Change page %s", self._page)
|
||||
await self.hass.components.mqtt.async_publish(
|
||||
self.hass, cmd_topic, self._page, qos=0, retain=False
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_command_service(self, keyword, parameters):
|
||||
"""Send commands directly to the plate entity."""
|
||||
await self.hass.components.mqtt.async_publish(
|
||||
self.hass,
|
||||
f"{self._topic}/command",
|
||||
f"{keyword} {parameters}".strip(),
|
||||
qos=0,
|
||||
retain=False,
|
||||
)
|
||||
|
||||
async def async_config_service(self, submodule, parameters):
|
||||
"""Send configuration commands to plate entity."""
|
||||
await self.hass.components.mqtt.async_publish(
|
||||
self.hass,
|
||||
f"{self._topic}/config/{submodule}",
|
||||
f"{parameters}".strip(),
|
||||
qos=0,
|
||||
retain=False,
|
||||
)
|
||||
|
||||
async def async_push_image(self, image, obj, width=None, height=None):
|
||||
"""Update object image."""
|
||||
|
||||
image_id = hashlib.md5(image.encode("utf-8")).hexdigest()
|
||||
|
||||
rgb_image = await self.hass.async_add_executor_job(
|
||||
image_to_rgb565, image, (width, height)
|
||||
)
|
||||
|
||||
self.hass.data[DOMAIN][DATA_IMAGES][image_id] = rgb_image
|
||||
|
||||
cmd_topic = f"{self._topic}/command/{obj}.src"
|
||||
|
||||
rgb_image_url = (
|
||||
f"{get_url(self.hass, allow_external=False)}/api/openhasp/serve/{image_id}"
|
||||
)
|
||||
|
||||
_LOGGER.debug("Push %s with %s", cmd_topic, rgb_image_url)
|
||||
|
||||
await self.hass.components.mqtt.async_publish(
|
||||
self.hass, cmd_topic, rgb_image_url, qos=0, retain=False
|
||||
)
|
||||
|
||||
async def refresh(self):
|
||||
"""Refresh objects in the SwitchPlate."""
|
||||
|
||||
_LOGGER.info("Refreshing %s", self._entry.data[CONF_NAME])
|
||||
for obj in self._objects:
|
||||
await obj.refresh()
|
||||
|
||||
await self.async_change_page(self._page)
|
||||
|
||||
async def async_load_page(self, path):
|
||||
"""Load pages file on the SwitchPlate, existing pages will not be cleared."""
|
||||
cmd_topic = f"{self._topic}/command"
|
||||
_LOGGER.info("Load page %s to %s", path, cmd_topic)
|
||||
|
||||
if not self.hass.config.is_allowed_path(path):
|
||||
_LOGGER.error("'%s' is not an allowed directory", path)
|
||||
return
|
||||
|
||||
async def send_lines(lines):
|
||||
mqtt_payload_buffer = ""
|
||||
for line in lines:
|
||||
if len(mqtt_payload_buffer) + len(line) > 1000:
|
||||
await self.hass.components.mqtt.async_publish(
|
||||
self.hass,
|
||||
f"{cmd_topic}/jsonl",
|
||||
mqtt_payload_buffer,
|
||||
qos=0,
|
||||
retain=False,
|
||||
)
|
||||
mqtt_payload_buffer = line
|
||||
else:
|
||||
mqtt_payload_buffer = mqtt_payload_buffer + line
|
||||
await self.hass.components.mqtt.async_publish(
|
||||
self.hass,
|
||||
f"{cmd_topic}/jsonl",
|
||||
mqtt_payload_buffer,
|
||||
qos=0,
|
||||
retain=False,
|
||||
)
|
||||
|
||||
try:
|
||||
with open(path, "r") as pages_file:
|
||||
if path.endswith(".json"):
|
||||
json_data = json.load(pages_file)
|
||||
jsonschema.validate(instance=json_data, schema=self.json_schema)
|
||||
lines = []
|
||||
for item in json_data:
|
||||
if isinstance(item, dict):
|
||||
lines.append(json.dumps(item) + "\n")
|
||||
await send_lines(lines)
|
||||
else:
|
||||
await send_lines(pages_file)
|
||||
await self.refresh()
|
||||
|
||||
except (IndexError, FileNotFoundError, IsADirectoryError, UnboundLocalError):
|
||||
_LOGGER.error(
|
||||
"File or data not present at the moment: %s",
|
||||
os.path.basename(path),
|
||||
)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
_LOGGER.error(
|
||||
"Error decoding .json file: %s",
|
||||
os.path.basename(path),
|
||||
)
|
||||
|
||||
except jsonschema.ValidationError as e:
|
||||
_LOGGER.error(
|
||||
"Schema check failed for %s. Validation Error: %s",
|
||||
os.path.basename(path),
|
||||
e.message,
|
||||
)
|
||||
|
||||
|
||||
# pylint: disable=R0902
|
||||
class HASPObject:
|
||||
"""Representation of an HASP-LVGL object."""
|
||||
|
||||
def __init__(self, hass, plate_topic, config):
|
||||
"""Initialize an object."""
|
||||
|
||||
self.hass = hass
|
||||
self.obj_id = config[CONF_OBJID]
|
||||
self.command_topic = f"{plate_topic}/command/{self.obj_id}."
|
||||
self.state_topic = f"{plate_topic}/state/{self.obj_id}"
|
||||
self.cached_properties = {}
|
||||
|
||||
self.properties = config.get(CONF_PROPERTIES)
|
||||
self.event_services = config.get(CONF_EVENT)
|
||||
self._tracked_property_templates = []
|
||||
self._freeze_properties = []
|
||||
self._subscriptions = []
|
||||
|
||||
async def enable_object(self):
|
||||
"""Initialize object events and properties subscriptions."""
|
||||
|
||||
if self.event_services:
|
||||
_LOGGER.debug("Setup event_services for '%s'", self.obj_id)
|
||||
self._subscriptions.append(await self.async_listen_hasp_events())
|
||||
|
||||
for _property, template in self.properties.items():
|
||||
self._tracked_property_templates.append(
|
||||
await self.async_set_property(_property, template)
|
||||
)
|
||||
|
||||
async def disable_object(self):
|
||||
"""Remove subscriptions and event tracking."""
|
||||
_LOGGER.debug("Disabling HASPObject %s", self.obj_id)
|
||||
for subscription in self._subscriptions:
|
||||
subscription()
|
||||
self._subscriptions = []
|
||||
|
||||
for tracked_template in self._tracked_property_templates:
|
||||
tracked_template.async_remove()
|
||||
self._tracked_property_templates = []
|
||||
|
||||
async def async_set_property(self, _property, template):
|
||||
"""Set HASP Object property to template value."""
|
||||
|
||||
@callback
|
||||
async def _async_template_result_changed(event, updates):
|
||||
track_template_result = updates.pop()
|
||||
template = track_template_result.template
|
||||
result = track_template_result.result
|
||||
|
||||
if isinstance(result, TemplateError) or result is None:
|
||||
entity = event and event.data.get("entity_id")
|
||||
_LOGGER.error(
|
||||
"TemplateError('%s') "
|
||||
"while processing template '%s' "
|
||||
"in entity '%s'",
|
||||
result,
|
||||
template,
|
||||
entity,
|
||||
)
|
||||
return
|
||||
|
||||
self.cached_properties[_property] = result
|
||||
if _property in self._freeze_properties:
|
||||
# Skip update to plate to avoid feedback loops
|
||||
return
|
||||
|
||||
_LOGGER.debug(
|
||||
"%s.%s - %s changed, updating with: %s",
|
||||
self.obj_id,
|
||||
_property,
|
||||
template,
|
||||
result,
|
||||
)
|
||||
|
||||
await self.hass.components.mqtt.async_publish(
|
||||
self.hass, self.command_topic + _property, result
|
||||
)
|
||||
|
||||
property_template = async_track_template_result(
|
||||
self.hass,
|
||||
[TrackTemplate(template, None)],
|
||||
_async_template_result_changed,
|
||||
)
|
||||
property_template.async_refresh()
|
||||
|
||||
return property_template
|
||||
|
||||
async def refresh(self):
|
||||
"""Refresh based on cached values."""
|
||||
for _property, result in self.cached_properties.items():
|
||||
_LOGGER.debug("Refresh object %s.%s = %s", self.obj_id, _property, result)
|
||||
await self.hass.components.mqtt.async_publish(
|
||||
self.hass, self.command_topic + _property, result
|
||||
)
|
||||
|
||||
async def async_listen_hasp_events(self):
|
||||
"""Listen to messages on MQTT for HASP events."""
|
||||
|
||||
@callback
|
||||
async def message_received(msg):
|
||||
"""Process object state MQTT message."""
|
||||
try:
|
||||
message = HASP_EVENT_SCHEMA(json.loads(msg.payload))
|
||||
|
||||
if message[HASP_EVENT] == HASP_EVENT_DOWN:
|
||||
# store properties that shouldn't be updated while button pressed
|
||||
self._freeze_properties = message.keys()
|
||||
elif message[HASP_EVENT] in [HASP_EVENT_UP, HASP_EVENT_RELEASE]:
|
||||
self._freeze_properties = []
|
||||
|
||||
for event in self.event_services:
|
||||
if event in message[HASP_EVENT]:
|
||||
_LOGGER.debug(
|
||||
"Service call for '%s' triggered by '%s' on '%s' with variables %s",
|
||||
event,
|
||||
msg.payload,
|
||||
msg.topic,
|
||||
message,
|
||||
)
|
||||
for service in self.event_services[event]:
|
||||
await async_call_from_config(
|
||||
self.hass,
|
||||
service,
|
||||
validate_config=False,
|
||||
variables=message,
|
||||
)
|
||||
except vol.error.Invalid:
|
||||
_LOGGER.debug(
|
||||
"Could not handle openHASP event: '%s' on '%s'",
|
||||
msg.payload,
|
||||
msg.topic,
|
||||
)
|
||||
except json.decoder.JSONDecodeError as err:
|
||||
_LOGGER.error(
|
||||
"Error decoding received JSON message: %s on %s", err.doc, msg.topic
|
||||
)
|
||||
|
||||
_LOGGER.debug("Subscribe to '%s' events on '%s'", self.obj_id, self.state_topic)
|
||||
return await self.hass.components.mqtt.async_subscribe(
|
||||
self.state_topic, message_received
|
||||
)
|
||||
BIN
custom_components/openhasp/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
custom_components/openhasp/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
custom_components/openhasp/__pycache__/button.cpython-310.pyc
Normal file
BIN
custom_components/openhasp/__pycache__/button.cpython-310.pyc
Normal file
Binary file not shown.
BIN
custom_components/openhasp/__pycache__/common.cpython-310.pyc
Normal file
BIN
custom_components/openhasp/__pycache__/common.cpython-310.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
custom_components/openhasp/__pycache__/const.cpython-310.pyc
Normal file
BIN
custom_components/openhasp/__pycache__/const.cpython-310.pyc
Normal file
Binary file not shown.
BIN
custom_components/openhasp/__pycache__/image.cpython-310.pyc
Normal file
BIN
custom_components/openhasp/__pycache__/image.cpython-310.pyc
Normal file
Binary file not shown.
BIN
custom_components/openhasp/__pycache__/light.cpython-310.pyc
Normal file
BIN
custom_components/openhasp/__pycache__/light.cpython-310.pyc
Normal file
Binary file not shown.
BIN
custom_components/openhasp/__pycache__/number.cpython-310.pyc
Normal file
BIN
custom_components/openhasp/__pycache__/number.cpython-310.pyc
Normal file
Binary file not shown.
BIN
custom_components/openhasp/__pycache__/switch.cpython-310.pyc
Normal file
BIN
custom_components/openhasp/__pycache__/switch.cpython-310.pyc
Normal file
Binary file not shown.
111
custom_components/openhasp/binary_sensor.py
Normal file
111
custom_components/openhasp/binary_sensor.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Allows to configure a binary sensor using GPIO."""
|
||||
import json
|
||||
import logging
|
||||
from typing import Callable
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||
|
||||
# pylint: disable=R0801
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
import voluptuous as vol
|
||||
|
||||
from .common import HASPEntity
|
||||
from .const import CONF_HWID, CONF_INPUT, CONF_TOPIC
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
HASP_BINARY_INPUT_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("state"): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: Callable
|
||||
):
|
||||
"""Set up Plate Relays as switch based on a config entry."""
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
HASPBinarySensor(
|
||||
entry.data[CONF_NAME],
|
||||
entry.data[CONF_HWID],
|
||||
entry.data[CONF_TOPIC],
|
||||
gpio,
|
||||
device_class,
|
||||
)
|
||||
for device_class in entry.data[CONF_INPUT]
|
||||
for gpio in entry.data[CONF_INPUT][device_class]
|
||||
]
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class HASPBinarySensor(HASPEntity, BinarySensorEntity):
|
||||
"""Representation of an openHASP relay."""
|
||||
|
||||
# pylint: disable=R0913
|
||||
def __init__(self, name, hwid, topic, gpio, dev_class):
|
||||
"""Initialize the relay."""
|
||||
super().__init__(name, hwid, topic, gpio)
|
||||
self._device_class = dev_class
|
||||
self._attr_name = f"{name} binary_sensor {self._gpio}"
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if device is on."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the device class."""
|
||||
return self._device_class
|
||||
|
||||
async def refresh(self):
|
||||
"""Force sync of plate state back to binary sensor."""
|
||||
await self.hass.components.mqtt.async_publish(
|
||||
self.hass,
|
||||
f"{self._topic}/command/input{self._gpio}",
|
||||
"",
|
||||
qos=0,
|
||||
retain=False,
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Run when entity about to be added."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
@callback
|
||||
async def state_message_received(msg):
|
||||
"""Process State."""
|
||||
|
||||
try:
|
||||
self._available = True
|
||||
message = HASP_BINARY_INPUT_SCHEMA(json.loads(msg.payload))
|
||||
_LOGGER.debug("%s state = %s", self.name, message)
|
||||
|
||||
self._state = message["state"]
|
||||
self.async_write_ha_state()
|
||||
|
||||
except vol.error.Invalid as err:
|
||||
_LOGGER.error(err)
|
||||
|
||||
self._subscriptions.append(
|
||||
await self.hass.components.mqtt.async_subscribe(
|
||||
f"{self._topic}/state/input{self._gpio}", state_message_received
|
||||
)
|
||||
)
|
||||
|
||||
await self.hass.components.mqtt.async_publish(
|
||||
self.hass,
|
||||
f"{self._topic}/command/input{self._gpio}",
|
||||
"",
|
||||
qos=0,
|
||||
retain=False,
|
||||
)
|
||||
62
custom_components/openhasp/button.py
Normal file
62
custom_components/openhasp/button.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Support for current page numbers."""
|
||||
import logging
|
||||
|
||||
from homeassistant.components.button import (
|
||||
ButtonDeviceClass,
|
||||
ButtonEntity,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
|
||||
|
||||
from .common import HASPEntity
|
||||
from .const import CONF_HWID, CONF_TOPIC
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities
|
||||
):
|
||||
"""Set up Plate Relays as switch based on a config entry."""
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
HASPRestartButton(
|
||||
entry.data[CONF_NAME],
|
||||
entry.data[CONF_HWID],
|
||||
entry.data[CONF_TOPIC],
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class HASPRestartButton(HASPEntity, ButtonEntity):
|
||||
"""Representation of page number."""
|
||||
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_device_class = ButtonDeviceClass.RESTART
|
||||
|
||||
def __init__(self, name, hwid, topic) -> None:
|
||||
"""Initialize the Restart Button."""
|
||||
super().__init__(name, hwid, topic, "restart")
|
||||
self._attr_name = f"{name} restart"
|
||||
self._available = True
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Handle the button press."""
|
||||
await self.hass.components.mqtt.async_publish(
|
||||
self.hass,
|
||||
f"{self._topic}/command/restart",
|
||||
"",
|
||||
qos=0,
|
||||
retain=False,
|
||||
)
|
||||
|
||||
async def refresh(self):
|
||||
"""Sync local state back to plate."""
|
||||
self.async_write_ha_state()
|
||||
105
custom_components/openhasp/common.py
Normal file
105
custom_components/openhasp/common.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""HASP-LVGL Commonalities."""
|
||||
import logging
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.entity import Entity, ToggleEntity
|
||||
import voluptuous as vol
|
||||
|
||||
from .const import (
|
||||
CONF_PLATE,
|
||||
DOMAIN,
|
||||
EVENT_HASP_PLATE_OFFLINE,
|
||||
EVENT_HASP_PLATE_ONLINE,
|
||||
HASP_IDLE_STATES,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
HASP_IDLE_SCHEMA = vol.Schema(vol.Any(*HASP_IDLE_STATES))
|
||||
|
||||
|
||||
class HASPEntity(Entity):
|
||||
"""Generic HASP entity (base class)."""
|
||||
|
||||
def __init__(self, name, hwid: str, topic: str, part=None) -> None:
|
||||
"""Initialize the HASP entity."""
|
||||
super().__init__()
|
||||
self._name = name
|
||||
self._hwid = hwid
|
||||
self._topic = topic
|
||||
self._state = None
|
||||
self._available = False
|
||||
self._subscriptions = []
|
||||
self._attr_unique_id = f"{self._hwid}.{part}"
|
||||
self._attr_device_info = {
|
||||
"identifiers": {(DOMAIN, self._hwid)},
|
||||
}
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
"""Return if entity is available."""
|
||||
return self._available
|
||||
|
||||
async def refresh(self):
|
||||
"""Sync local state back to plate."""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Run when entity about to be added."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
@callback
|
||||
async def online(event):
|
||||
if event.data[CONF_PLATE] == self._hwid:
|
||||
self._available = True
|
||||
if self._state:
|
||||
await self.refresh()
|
||||
else:
|
||||
self.async_write_ha_state() # Just to update availability
|
||||
_LOGGER.debug("%s is available, %s", self.entity_id, "refresh" if self._state else "stale")
|
||||
|
||||
self._subscriptions.append(
|
||||
self.hass.bus.async_listen(EVENT_HASP_PLATE_ONLINE, online)
|
||||
)
|
||||
|
||||
@callback
|
||||
async def offline(event):
|
||||
if event.data[CONF_PLATE] == self._hwid:
|
||||
self._available = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
self._subscriptions.append(
|
||||
self.hass.bus.async_listen(EVENT_HASP_PLATE_OFFLINE, offline)
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self):
|
||||
"""Run when entity about to be removed."""
|
||||
await super().async_will_remove_from_hass()
|
||||
|
||||
for subscription in self._subscriptions:
|
||||
subscription()
|
||||
|
||||
|
||||
class HASPToggleEntity(HASPEntity, ToggleEntity):
|
||||
"""Representation of HASP ToggleEntity."""
|
||||
|
||||
def __init__(self, name, hwid, topic, gpio):
|
||||
"""Initialize the relay."""
|
||||
super().__init__(name, hwid, topic, gpio)
|
||||
self._gpio = gpio
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if device is on."""
|
||||
return self._state
|
||||
|
||||
async def async_turn_on(self, **kwargs):
|
||||
"""Turn on."""
|
||||
self._state = True
|
||||
await self.refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs):
|
||||
"""Turn off."""
|
||||
self._state = False
|
||||
await self.refresh()
|
||||
235
custom_components/openhasp/config_flow.py
Normal file
235
custom_components/openhasp/config_flow.py
Normal file
@@ -0,0 +1,235 @@
|
||||
"""Config flow to configure OpenHASP component."""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
from homeassistant import config_entries, data_entry_flow, exceptions
|
||||
from homeassistant.components.mqtt import valid_subscribe_topic
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import callback
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
import voluptuous as vol
|
||||
|
||||
from .const import (
|
||||
CONF_DIMLIGHTS,
|
||||
CONF_HWID,
|
||||
CONF_IDLE_BRIGHTNESS,
|
||||
CONF_INPUT,
|
||||
CONF_LIGHTS,
|
||||
CONF_NODE,
|
||||
CONF_PAGES,
|
||||
CONF_PAGES_PATH,
|
||||
CONF_RELAYS,
|
||||
CONF_TOPIC,
|
||||
DEFAULT_IDLE_BRIGHNESS,
|
||||
DISCOVERED_DIM,
|
||||
DISCOVERED_HWID,
|
||||
DISCOVERED_INPUT,
|
||||
DISCOVERED_LIGHT,
|
||||
DISCOVERED_MANUFACTURER,
|
||||
DISCOVERED_MODEL,
|
||||
DISCOVERED_NODE,
|
||||
DISCOVERED_PAGES,
|
||||
DISCOVERED_POWER,
|
||||
DISCOVERED_URL,
|
||||
DISCOVERED_VERSION,
|
||||
DOMAIN,
|
||||
MAJOR,
|
||||
MINOR,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def validate_jsonl(path):
|
||||
"""Validate that the value is an existing file."""
|
||||
if path is None:
|
||||
raise InvalidJSONL()
|
||||
file_in = os.path.expanduser(str(path))
|
||||
|
||||
if not os.path.isfile(file_in):
|
||||
raise InvalidJSONL("not a file")
|
||||
if not os.access(file_in, os.R_OK):
|
||||
raise InvalidJSONL("file not readable")
|
||||
return file_in
|
||||
|
||||
|
||||
@config_entries.HANDLERS.register(DOMAIN)
|
||||
class OpenHASPFlowHandler(config_entries.ConfigFlow):
|
||||
"""Config flow for OpenHASP component."""
|
||||
|
||||
VERSION = 1
|
||||
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
|
||||
|
||||
def __init__(self):
|
||||
"""Init OpenHASPFlowHandler."""
|
||||
self._errors = {}
|
||||
self.config_data = {
|
||||
DISCOVERED_MANUFACTURER: "openHASP",
|
||||
DISCOVERED_MODEL: None,
|
||||
CONF_RELAYS: [],
|
||||
}
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""Handle a flow initialized by User."""
|
||||
_LOGGER.error("Discovery Only")
|
||||
|
||||
await self.hass.components.mqtt.async_publish(
|
||||
self.hass,
|
||||
"hasp/broadcast/command/discovery",
|
||||
"discovery",
|
||||
qos=0,
|
||||
retain=False,
|
||||
)
|
||||
|
||||
return self.async_abort(reason="discovery_only")
|
||||
|
||||
async def async_step_mqtt(self, discovery_info=None):
|
||||
"""Handle a flow initialized by MQTT discovery."""
|
||||
_discovered = json.loads(discovery_info.payload)
|
||||
_LOGGER.debug("Discovered: %s", _discovered)
|
||||
|
||||
await self.async_set_unique_id(_discovered[DISCOVERED_HWID], raise_on_progress=False)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
version = _discovered.get(DISCOVERED_VERSION)
|
||||
if version.split(".")[0:2] != [MAJOR, MINOR]:
|
||||
_LOGGER.error(
|
||||
"Version mismatch! Your plate: %s - openHASP Component: %s",
|
||||
version,
|
||||
f"{MAJOR}.{MINOR}.x",
|
||||
)
|
||||
raise data_entry_flow.AbortFlow("mismatch_version")
|
||||
|
||||
self.config_data[DISCOVERED_VERSION] = version
|
||||
|
||||
self.config_data[CONF_HWID] = _discovered[DISCOVERED_HWID]
|
||||
self.config_data[CONF_NODE] = self.config_data[CONF_NAME] = _discovered[
|
||||
DISCOVERED_NODE
|
||||
]
|
||||
self.config_data[
|
||||
CONF_TOPIC
|
||||
] = f"{discovery_info.topic.split('/')[0]}/{self.config_data[CONF_NODE]}"
|
||||
|
||||
self.config_data[DISCOVERED_URL] = _discovered.get(DISCOVERED_URL)
|
||||
self.config_data[DISCOVERED_MANUFACTURER] = _discovered.get(
|
||||
DISCOVERED_MANUFACTURER
|
||||
)
|
||||
self.config_data[DISCOVERED_MODEL] = _discovered.get(DISCOVERED_MODEL)
|
||||
self.config_data[CONF_PAGES] = _discovered.get(DISCOVERED_PAGES)
|
||||
self.config_data[CONF_RELAYS] = _discovered.get(DISCOVERED_POWER)
|
||||
self.config_data[CONF_LIGHTS] = _discovered.get(DISCOVERED_LIGHT)
|
||||
self.config_data[CONF_DIMLIGHTS] = _discovered.get(DISCOVERED_DIM)
|
||||
self.config_data[CONF_INPUT] = _discovered.get(DISCOVERED_INPUT)
|
||||
|
||||
return await self.async_step_personalize()
|
||||
|
||||
async def async_step_personalize(self, user_input=None):
|
||||
"""Handle a flow initialized by the user."""
|
||||
self._errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
self.config_data = {**self.config_data, **user_input}
|
||||
|
||||
if self.config_data[
|
||||
CONF_NAME
|
||||
] not in self.hass.config_entries.async_entries(DOMAIN):
|
||||
# Remove / from base topic
|
||||
if user_input[CONF_TOPIC].endswith("/"):
|
||||
user_input[CONF_TOPIC] = user_input[CONF_TOPIC][:-1]
|
||||
|
||||
try:
|
||||
valid_subscribe_topic(self.config_data[CONF_TOPIC])
|
||||
|
||||
if CONF_PAGES_PATH in user_input:
|
||||
self.config_data[CONF_PAGES_PATH] = validate_jsonl(
|
||||
user_input[CONF_PAGES_PATH]
|
||||
)
|
||||
|
||||
await self.async_set_unique_id(self.config_data[CONF_HWID])
|
||||
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_NAME], data=self.config_data
|
||||
)
|
||||
|
||||
except vol.Invalid:
|
||||
return self.async_abort(reason="invalid_discovery_info")
|
||||
|
||||
except InvalidJSONL:
|
||||
self._errors[CONF_PAGES_PATH] = "invalid_jsonl_path"
|
||||
else:
|
||||
self._errors[CONF_NAME] = "name_exists"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="personalize",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_TOPIC, default=self.config_data.get(CONF_TOPIC, "hasp")
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_NAME, default=self.config_data.get(CONF_NAME)
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_IDLE_BRIGHTNESS, default=DEFAULT_IDLE_BRIGHNESS
|
||||
): vol.All(int, vol.Range(min=0, max=255)),
|
||||
vol.Optional(CONF_PAGES_PATH): str,
|
||||
}
|
||||
),
|
||||
errors=self._errors,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry):
|
||||
"""Set the OptionsFlowHandler."""
|
||||
return OpenHASPOptionsFlowHandler(config_entry)
|
||||
|
||||
|
||||
class OpenHASPOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
"""ConfigOptions flow for openHASP."""
|
||||
|
||||
def __init__(self, config_entry):
|
||||
"""Initialize openHASP options flow."""
|
||||
self.config_entry = config_entry
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
"""Manage the options."""
|
||||
if user_input is not None:
|
||||
# Actually check path is a file
|
||||
|
||||
try:
|
||||
if len(user_input[CONF_PAGES_PATH]):
|
||||
user_input[CONF_PAGES_PATH] = validate_jsonl(
|
||||
user_input[CONF_PAGES_PATH]
|
||||
)
|
||||
except InvalidJSONL:
|
||||
return self.async_abort(reason="invalid_jsonl_path")
|
||||
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_IDLE_BRIGHTNESS,
|
||||
default=self.config_entry.options.get(
|
||||
CONF_IDLE_BRIGHTNESS,
|
||||
self.config_entry.data[CONF_IDLE_BRIGHTNESS],
|
||||
),
|
||||
): vol.All(int, vol.Range(min=0, max=255)),
|
||||
vol.Optional(
|
||||
CONF_PAGES_PATH,
|
||||
default=self.config_entry.options.get(
|
||||
CONF_PAGES_PATH,
|
||||
self.config_entry.data.get(CONF_PAGES_PATH, ""),
|
||||
),
|
||||
): cv.string,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class InvalidJSONL(exceptions.HomeAssistantError):
|
||||
"""Error to indicate we cannot load JSONL."""
|
||||
109
custom_components/openhasp/const.py
Normal file
109
custom_components/openhasp/const.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Constants for HASP Open Hardware Edition custom component."""
|
||||
|
||||
# Version
|
||||
MAJOR = "0"
|
||||
MINOR = "6"
|
||||
|
||||
DOMAIN = "openhasp"
|
||||
|
||||
CONF_COMPONENT = "component"
|
||||
CONF_OBJID = "obj"
|
||||
CONF_PROPERTIES = "properties"
|
||||
CONF_EVENT = "event"
|
||||
CONF_TRACK = "track"
|
||||
CONF_TOPIC = "topic"
|
||||
CONF_PAGES = "pages"
|
||||
CONF_PAGES_PATH = "path"
|
||||
CONF_OBJECTS = "objects"
|
||||
CONF_VAL = "val"
|
||||
CONF_IDLE_BRIGHTNESS = "idle_brightness"
|
||||
CONF_AWAKE_BRIGHTNESS = "awake_brightness"
|
||||
CONF_PLATE = "plate"
|
||||
CONF_RELAYS = "relay"
|
||||
CONF_LIGHTS = "light"
|
||||
CONF_DIMLIGHTS = "dimlight"
|
||||
CONF_LEDS = "led"
|
||||
CONF_PWMS = "pwm"
|
||||
CONF_GPIO = "gpio"
|
||||
CONF_NODE = "node"
|
||||
CONF_HWID = "hwid"
|
||||
CONF_INPUT = "input"
|
||||
|
||||
DATA_LISTENER = "listener"
|
||||
DATA_IMAGES = "images"
|
||||
|
||||
DEFAULT_TOPIC = "hasp"
|
||||
DEFAULT_PATH = "pages.jsonl"
|
||||
DEFAULT_IDLE_BRIGHNESS = 25
|
||||
|
||||
DISCOVERED_NODE = "node"
|
||||
DISCOVERED_MODEL = "mdl"
|
||||
DISCOVERED_MANUFACTURER = "mf"
|
||||
DISCOVERED_HWID = "hwid"
|
||||
DISCOVERED_PAGES = "pages"
|
||||
DISCOVERED_INPUT = "input"
|
||||
DISCOVERED_POWER = "power"
|
||||
DISCOVERED_LIGHT = "light"
|
||||
DISCOVERED_DIM = "dim"
|
||||
DISCOVERED_VERSION = "sw"
|
||||
DISCOVERED_INPUT = "input"
|
||||
DISCOVERED_URL = "uri"
|
||||
|
||||
HASP_NUM_PAGES = "numPages"
|
||||
HASP_VAL = "val"
|
||||
HASP_EVENT = "event"
|
||||
HASP_EVENT_ON = "on"
|
||||
HASP_EVENT_OFF = "off"
|
||||
HASP_EVENT_DOWN = "down"
|
||||
HASP_EVENT_UP = "up"
|
||||
HASP_EVENT_SHORT = "short"
|
||||
HASP_EVENT_LONG = "long"
|
||||
HASP_EVENT_CHANGED = "changed"
|
||||
HASP_EVENT_RELEASE = "release"
|
||||
HASP_EVENT_HOLD = "hold"
|
||||
HASP_EVENTS = (
|
||||
HASP_EVENT_ON,
|
||||
HASP_EVENT_OFF,
|
||||
HASP_EVENT_DOWN,
|
||||
HASP_EVENT_UP,
|
||||
HASP_EVENT_SHORT,
|
||||
HASP_EVENT_LONG,
|
||||
HASP_EVENT_CHANGED,
|
||||
HASP_EVENT_RELEASE,
|
||||
HASP_EVENT_HOLD,
|
||||
)
|
||||
HASP_IDLE_OFF = "off"
|
||||
HASP_IDLE_SHORT = "short"
|
||||
HASP_IDLE_LONG = "long"
|
||||
HASP_IDLE_STATES = (HASP_IDLE_OFF, HASP_IDLE_SHORT, HASP_IDLE_LONG)
|
||||
HASP_ONLINE = "online"
|
||||
HASP_OFFLINE = "offline"
|
||||
HASP_LWT = (HASP_ONLINE, HASP_OFFLINE)
|
||||
|
||||
ATTR_PAGE = "page"
|
||||
ATTR_CURRENT_DIM = "dim"
|
||||
ATTR_IDLE = "idle"
|
||||
ATTR_PATH = "path"
|
||||
ATTR_AWAKE_BRIGHTNESS = "awake brightness"
|
||||
ATTR_IDLE_BRIGHTNESS = "idle brightness"
|
||||
ATTR_COMMAND_KEYWORD = "keyword"
|
||||
ATTR_COMMAND_PARAMETERS = "parameters"
|
||||
ATTR_CONFIG_SUBMODULE = "submodule"
|
||||
ATTR_CONFIG_PARAMETERS = "parameters"
|
||||
ATTR_IMAGE = "image"
|
||||
ATTR_OBJECT = "obj"
|
||||
ATTR_WIDTH = "width"
|
||||
ATTR_HEIGHT = "height"
|
||||
|
||||
SERVICE_WAKEUP = "wakeup"
|
||||
SERVICE_CLEAR_PAGE = "clear_page"
|
||||
SERVICE_LOAD_PAGE = "load_pages"
|
||||
SERVICE_PAGE_CHANGE = "change_page"
|
||||
SERVICE_PAGE_NEXT = "next_page"
|
||||
SERVICE_PAGE_PREV = "prev_page"
|
||||
SERVICE_COMMAND = "command"
|
||||
SERVICE_CONFIG = "config"
|
||||
SERVICE_PUSH_IMAGE = "push_image"
|
||||
|
||||
EVENT_HASP_PLATE_ONLINE = "openhasp_plate_online"
|
||||
EVENT_HASP_PLATE_OFFLINE = "openhasp_plate_offline"
|
||||
80
custom_components/openhasp/image.py
Normal file
80
custom_components/openhasp/image.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Image processing and serving functions."""
|
||||
|
||||
import logging
|
||||
import struct
|
||||
import tempfile
|
||||
|
||||
from PIL import Image
|
||||
from aiohttp import hdrs, web
|
||||
from homeassistant.components.http.static import CACHE_HEADERS
|
||||
from homeassistant.components.http.view import HomeAssistantView
|
||||
import requests
|
||||
|
||||
from .const import DATA_IMAGES, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def image_to_rgb565(in_image, size):
|
||||
"""Transform image to rgb565 format according to LVGL requirements."""
|
||||
try:
|
||||
if in_image.startswith("http"):
|
||||
im = Image.open(requests.get(in_image, stream=True).raw)
|
||||
else:
|
||||
im = Image.open(in_image)
|
||||
except Exception:
|
||||
_LOGGER.error("Failed to open %s", in_image)
|
||||
return None
|
||||
|
||||
original_width, original_height = im.size
|
||||
width, height = size
|
||||
|
||||
width = min(w for w in [width, original_width] if w is not None and w > 0)
|
||||
height = min(h for h in [height, original_height] if h is not None and h > 0)
|
||||
|
||||
im.thumbnail((height, width), Image.ANTIALIAS)
|
||||
width, height = im.size # actual size after resize
|
||||
|
||||
out_image = tempfile.NamedTemporaryFile(mode="w+b")
|
||||
|
||||
out_image.write(struct.pack("I", height << 21 | width << 10 | 4))
|
||||
|
||||
img = im.convert("RGB")
|
||||
|
||||
for pix in img.getdata():
|
||||
r = (pix[0] >> 3) & 0x1F
|
||||
g = (pix[1] >> 2) & 0x3F
|
||||
b = (pix[2] >> 3) & 0x1F
|
||||
out_image.write(struct.pack("H", (r << 11) | (g << 5) | b))
|
||||
|
||||
_LOGGER.debug("image_to_rgb565 out_image: %s", out_image.name)
|
||||
|
||||
out_image.flush()
|
||||
|
||||
return out_image
|
||||
|
||||
|
||||
class ImageServeView(HomeAssistantView):
|
||||
"""View to download images."""
|
||||
|
||||
url = "/api/openhasp/serve/{image_id}"
|
||||
name = "api:openhasp:serve"
|
||||
requires_auth = False
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize image serve view."""
|
||||
|
||||
async def get(self, request: web.Request, image_id: str):
|
||||
"""Serve image."""
|
||||
|
||||
hass = request.app["hass"]
|
||||
target_file = hass.data[DOMAIN][DATA_IMAGES].get(image_id)
|
||||
if target_file is None:
|
||||
_LOGGER.error("Unknown image_id %s", image_id)
|
||||
return web.HTTPNotFound()
|
||||
|
||||
_LOGGER.debug("Get Image %s form %s", image_id, target_file.name)
|
||||
|
||||
return web.FileResponse(
|
||||
target_file.name, headers={**CACHE_HEADERS, hdrs.CONTENT_TYPE: "image/bmp"}
|
||||
)
|
||||
496
custom_components/openhasp/light.py
Normal file
496
custom_components/openhasp/light.py
Normal file
@@ -0,0 +1,496 @@
|
||||
"""Support for HASP LVGL moodlights."""
|
||||
import json
|
||||
import logging
|
||||
from typing import Callable
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_HS_COLOR,
|
||||
SUPPORT_BRIGHTNESS,
|
||||
SUPPORT_COLOR,
|
||||
LightEntity,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
import homeassistant.util.color as color_util
|
||||
import voluptuous as vol
|
||||
|
||||
from .common import HASP_IDLE_SCHEMA, HASPToggleEntity
|
||||
from .const import (
|
||||
ATTR_AWAKE_BRIGHTNESS,
|
||||
ATTR_IDLE_BRIGHTNESS,
|
||||
CONF_DIMLIGHTS,
|
||||
CONF_HWID,
|
||||
CONF_IDLE_BRIGHTNESS,
|
||||
CONF_LIGHTS,
|
||||
CONF_TOPIC,
|
||||
HASP_IDLE_LONG,
|
||||
HASP_IDLE_OFF,
|
||||
HASP_IDLE_SHORT,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
HASP_MOODLIGHT_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("state"): cv.boolean,
|
||||
vol.Required("r"): vol.All(int, vol.Range(min=0, max=255)),
|
||||
vol.Required("g"): vol.All(int, vol.Range(min=0, max=255)),
|
||||
vol.Required("b"): vol.All(int, vol.Range(min=0, max=255)),
|
||||
vol.Required("brightness"): vol.All(int, vol.Range(min=0, max=255)),
|
||||
vol.Optional("color"): str,
|
||||
},
|
||||
)
|
||||
|
||||
HASP_BACKLIGHT_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("state"): cv.boolean,
|
||||
vol.Required("brightness"): vol.All(int, vol.Range(min=0, max=255)),
|
||||
}
|
||||
)
|
||||
|
||||
HASP_LIGHT_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("state"): cv.boolean,
|
||||
vol.Optional("brightness"): vol.All(int, vol.Range(min=0, max=255)),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# pylint: disable=R0801, W0613
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: Callable
|
||||
):
|
||||
"""Set up Plate Light sensors based on a config entry."""
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
HASPBackLight(
|
||||
entry.data[CONF_NAME],
|
||||
entry.data[CONF_HWID],
|
||||
entry.data[CONF_TOPIC],
|
||||
entry.options.get(
|
||||
CONF_IDLE_BRIGHTNESS, entry.data[CONF_IDLE_BRIGHTNESS]
|
||||
),
|
||||
),
|
||||
HASPMoodLight(
|
||||
entry.data[CONF_NAME], entry.data[CONF_HWID], entry.data[CONF_TOPIC]
|
||||
),
|
||||
]
|
||||
+ [
|
||||
HASPLight(
|
||||
entry.data[CONF_NAME],
|
||||
entry.data[CONF_HWID],
|
||||
entry.data[CONF_TOPIC],
|
||||
gpio,
|
||||
)
|
||||
for gpio in entry.data[CONF_LIGHTS]
|
||||
]
|
||||
+ [
|
||||
HASPDimmableLight(
|
||||
entry.data[CONF_NAME],
|
||||
entry.data[CONF_HWID],
|
||||
entry.data[CONF_TOPIC],
|
||||
gpio,
|
||||
)
|
||||
for gpio in entry.data[CONF_DIMLIGHTS]
|
||||
]
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class HASPLight(HASPToggleEntity, LightEntity):
|
||||
"""Representation of openHASP Light."""
|
||||
|
||||
def __init__(self, name, hwid, topic, gpio):
|
||||
"""Initialize the light."""
|
||||
super().__init__(name, hwid, topic, gpio)
|
||||
self._attr_name = f"{name} light {gpio}"
|
||||
|
||||
async def refresh(self):
|
||||
"""Sync local state back to plate."""
|
||||
await self.hass.components.mqtt.async_publish(
|
||||
self.hass,
|
||||
f"{self._topic}/command/output{self._gpio}",
|
||||
json.dumps(HASP_LIGHT_SCHEMA({"state": int(self._state)})),
|
||||
qos=0,
|
||||
retain=False,
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Run when entity about to be added."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
@callback
|
||||
async def light_state_message_received(msg):
|
||||
"""Process State."""
|
||||
|
||||
try:
|
||||
self._available = True
|
||||
message = HASP_LIGHT_SCHEMA(json.loads(msg.payload))
|
||||
_LOGGER.debug("received light %s: %s", self.name, message)
|
||||
|
||||
self._state = message["state"]
|
||||
self.async_write_ha_state()
|
||||
|
||||
except vol.error.Invalid as err:
|
||||
_LOGGER.error(err)
|
||||
|
||||
self._subscriptions.append(
|
||||
await self.hass.components.mqtt.async_subscribe(
|
||||
f"{self._topic}/state/output{self._gpio}", light_state_message_received
|
||||
)
|
||||
)
|
||||
|
||||
# Force immediatable state update from plate
|
||||
await self.hass.components.mqtt.async_publish(
|
||||
self.hass,
|
||||
f"{self._topic}/command/output{self._gpio}",
|
||||
"",
|
||||
qos=0,
|
||||
retain=False,
|
||||
)
|
||||
|
||||
|
||||
class HASPDimmableLight(HASPToggleEntity, LightEntity):
|
||||
"""Representation of openHASP Light."""
|
||||
|
||||
def __init__(self, name, hwid, topic, gpio):
|
||||
"""Initialize the dimmable light."""
|
||||
super().__init__(name, hwid, topic, gpio)
|
||||
self._brightness = None
|
||||
self._attr_supported_features = SUPPORT_BRIGHTNESS
|
||||
self._gpio = gpio
|
||||
self._attr_name = f"{name} dimmable light {self._gpio}"
|
||||
|
||||
@property
|
||||
def brightness(self):
|
||||
"""Return the brightness of this light between 0..255."""
|
||||
return self._brightness
|
||||
|
||||
async def refresh(self):
|
||||
"""Sync local state back to plate."""
|
||||
_LOGGER.debug(
|
||||
"refresh dim %s state = %s, brightness = %s",
|
||||
self.name,
|
||||
self._state,
|
||||
self._brightness,
|
||||
)
|
||||
|
||||
await self.hass.components.mqtt.async_publish(
|
||||
self.hass,
|
||||
f"{self._topic}/command/output{self._gpio}",
|
||||
json.dumps(
|
||||
HASP_LIGHT_SCHEMA(
|
||||
{"state": self._state, "brightness": self._brightness}
|
||||
)
|
||||
),
|
||||
qos=0,
|
||||
retain=False,
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Run when entity about to be added."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
@callback
|
||||
async def dimmable_light_message_received(msg):
|
||||
"""Process State."""
|
||||
|
||||
try:
|
||||
self._available = True
|
||||
message = HASP_LIGHT_SCHEMA(json.loads(msg.payload))
|
||||
_LOGGER.debug("received dimmable light %s: %s", self.name, message)
|
||||
|
||||
self._state = message["state"]
|
||||
self._brightness = message["brightness"]
|
||||
self.async_write_ha_state()
|
||||
|
||||
except vol.error.Invalid as err:
|
||||
_LOGGER.error(err)
|
||||
|
||||
self._subscriptions.append(
|
||||
await self.hass.components.mqtt.async_subscribe(
|
||||
f"{self._topic}/state/output{self._gpio}",
|
||||
dimmable_light_message_received,
|
||||
)
|
||||
)
|
||||
|
||||
# Force immediatable state update from plate
|
||||
await self.hass.components.mqtt.async_publish(
|
||||
self.hass,
|
||||
f"{self._topic}/command/output{self._gpio}",
|
||||
"",
|
||||
qos=0,
|
||||
retain=False,
|
||||
)
|
||||
|
||||
async def async_turn_on(self, **kwargs):
|
||||
"""Turn on the dimmable light."""
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
self._brightness = kwargs[ATTR_BRIGHTNESS]
|
||||
self._state = True
|
||||
await self.refresh()
|
||||
|
||||
|
||||
class HASPBackLight(HASPToggleEntity, LightEntity, RestoreEntity):
|
||||
"""Representation of HASP LVGL Backlight."""
|
||||
|
||||
def __init__(self, name, hwid, topic, brightness):
|
||||
"""Initialize the light."""
|
||||
super().__init__(name, hwid, topic, "backlight")
|
||||
self._awake_brightness = 255
|
||||
self._brightness = None
|
||||
self._idle_brightness = brightness
|
||||
self._attr_supported_features = SUPPORT_BRIGHTNESS
|
||||
self._attr_name = f"{name} backlight"
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
attributes = {
|
||||
ATTR_AWAKE_BRIGHTNESS: self._awake_brightness,
|
||||
ATTR_IDLE_BRIGHTNESS: self._idle_brightness,
|
||||
}
|
||||
|
||||
return attributes
|
||||
|
||||
@property
|
||||
def brightness(self):
|
||||
"""Return the brightness of this light between 0..255."""
|
||||
return self._brightness
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Run when entity about to be added."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
state = await self.async_get_last_state()
|
||||
if state:
|
||||
self._state = state.state
|
||||
self._brightness = state.attributes.get(ATTR_BRIGHTNESS)
|
||||
self._awake_brightness = state.attributes.get(ATTR_AWAKE_BRIGHTNESS, 255)
|
||||
_LOGGER.debug(
|
||||
"Restoring %s self.brigthness = %s; awake_brightness = %s",
|
||||
self.name,
|
||||
self._brightness,
|
||||
self._awake_brightness,
|
||||
)
|
||||
if not self._brightness:
|
||||
self._brightness = self._awake_brightness
|
||||
|
||||
await self.async_listen_idleness()
|
||||
|
||||
cmd_topic = f"{self._topic}/command"
|
||||
state_topic = f"{self._topic}/state/backlight"
|
||||
|
||||
@callback
|
||||
async def backlight_message_received(msg):
|
||||
"""Process Backlight State."""
|
||||
|
||||
try:
|
||||
self._available = True
|
||||
message = HASP_BACKLIGHT_SCHEMA(json.loads(msg.payload))
|
||||
_LOGGER.debug("received backlight %s: %s", self.name, message)
|
||||
|
||||
self._state = message["state"]
|
||||
self._brightness = message["brightness"]
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
except vol.error.Invalid as err:
|
||||
_LOGGER.error(
|
||||
"While proccessing backlight: %s, original message was: %s",
|
||||
err,
|
||||
msg,
|
||||
)
|
||||
|
||||
self._subscriptions.append(
|
||||
await self.hass.components.mqtt.async_subscribe(
|
||||
state_topic, backlight_message_received
|
||||
)
|
||||
)
|
||||
|
||||
await self.hass.components.mqtt.async_publish(
|
||||
self.hass, cmd_topic, "backlight", qos=0, retain=False
|
||||
)
|
||||
|
||||
async def async_listen_idleness(self):
|
||||
"""Listen to messages on MQTT for HASP idleness."""
|
||||
|
||||
@callback
|
||||
async def idle_message_received(msg):
|
||||
"""Process MQTT message from plate."""
|
||||
message = HASP_IDLE_SCHEMA(msg.payload)
|
||||
|
||||
if message == HASP_IDLE_OFF:
|
||||
brightness = self._awake_brightness
|
||||
backlight = 1
|
||||
elif message == HASP_IDLE_SHORT:
|
||||
brightness = self._idle_brightness
|
||||
backlight = 1
|
||||
elif message == HASP_IDLE_LONG:
|
||||
brightness = self._awake_brightness
|
||||
backlight = 0
|
||||
else:
|
||||
return
|
||||
|
||||
_LOGGER.debug(
|
||||
"Idle state for %s is %s - Dimming to %s; Backlight to %s",
|
||||
self.name,
|
||||
message,
|
||||
brightness,
|
||||
backlight,
|
||||
)
|
||||
|
||||
new_state = {"state": backlight, "brightness": brightness}
|
||||
|
||||
await self.hass.components.mqtt.async_publish(
|
||||
self.hass,
|
||||
f"{self._topic}/command",
|
||||
f"backlight {json.dumps(new_state)}",
|
||||
qos=0,
|
||||
retain=False,
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
self._subscriptions.append(
|
||||
await self.hass.components.mqtt.async_subscribe(
|
||||
f"{self._topic}/state/idle", idle_message_received
|
||||
)
|
||||
)
|
||||
|
||||
async def refresh(self):
|
||||
"""Sync local state back to plate."""
|
||||
cmd_topic = f"{self._topic}/command"
|
||||
|
||||
new_state = {"state": self._state, "brightness": self._brightness}
|
||||
|
||||
_LOGGER.debug("refresh(%s) backlight - %s", self.name, new_state)
|
||||
|
||||
await self.hass.components.mqtt.async_publish(
|
||||
self.hass,
|
||||
cmd_topic,
|
||||
f"backlight {json.dumps(new_state)}",
|
||||
qos=0,
|
||||
retain=False,
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_on(self, **kwargs):
|
||||
"""Turn on the backlight."""
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
self._brightness = kwargs[ATTR_BRIGHTNESS]
|
||||
self._awake_brightness = (
|
||||
self._brightness
|
||||
) # save this value for later recall
|
||||
self._state = True
|
||||
await self.refresh()
|
||||
|
||||
|
||||
class HASPMoodLight(HASPToggleEntity, LightEntity, RestoreEntity):
|
||||
"""Representation of HASP LVGL Moodlight."""
|
||||
|
||||
def __init__(self, name, hwid, topic):
|
||||
"""Initialize the light."""
|
||||
super().__init__(name, hwid, topic, "moodlight")
|
||||
self._hs = None
|
||||
self._brightness = None
|
||||
self._attr_supported_features = SUPPORT_COLOR | SUPPORT_BRIGHTNESS
|
||||
self._attr_name = f"{name} moodlight"
|
||||
|
||||
@property
|
||||
def hs_color(self):
|
||||
"""Return the color property."""
|
||||
return self._hs
|
||||
|
||||
@property
|
||||
def brightness(self):
|
||||
"""Return the brightness of this light between 0..255."""
|
||||
return self._brightness
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Run when entity about to be added."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
state = await self.async_get_last_state()
|
||||
if state:
|
||||
self._state = state.state
|
||||
self._brightness = state.attributes.get(ATTR_BRIGHTNESS)
|
||||
self._hs = state.attributes.get(ATTR_HS_COLOR)
|
||||
_LOGGER.debug(
|
||||
"Restoring %s self.brigthness = %s; hs_color = %s",
|
||||
self.name,
|
||||
self._brightness,
|
||||
self._hs,
|
||||
)
|
||||
|
||||
@callback
|
||||
async def moodlight_message_received(msg):
|
||||
"""Process Moodlight State."""
|
||||
|
||||
try:
|
||||
self._available = True
|
||||
message = HASP_MOODLIGHT_SCHEMA(json.loads(msg.payload))
|
||||
_LOGGER.debug("received moodlight %s: %s", self.name, message)
|
||||
|
||||
self._state = message["state"]
|
||||
self._hs = color_util.color_RGB_to_hs(
|
||||
message["r"], message["g"], message["b"]
|
||||
)
|
||||
self._brightness = message["brightness"]
|
||||
self.async_write_ha_state()
|
||||
|
||||
except vol.error.Invalid as err:
|
||||
_LOGGER.error("While proccessing moodlight: %s", err)
|
||||
|
||||
self._subscriptions.append(
|
||||
await self.hass.components.mqtt.async_subscribe(
|
||||
f"{self._topic}/state/moodlight", moodlight_message_received
|
||||
)
|
||||
)
|
||||
|
||||
await self.hass.components.mqtt.async_publish(
|
||||
self.hass, f"{self._topic}/command", "moodlight", qos=0, retain=False
|
||||
)
|
||||
|
||||
async def refresh(self):
|
||||
"""Sync local state back to plate."""
|
||||
cmd_topic = f"{self._topic}/command"
|
||||
|
||||
new_state = {"state": self._state}
|
||||
if self._hs:
|
||||
rgb = color_util.color_hs_to_RGB(*self._hs)
|
||||
new_state = {**new_state, **dict(zip("rgb", rgb))}
|
||||
if self._brightness:
|
||||
new_state["brightness"] = self._brightness
|
||||
|
||||
_LOGGER.debug("refresh(%s) moodlight - %s", self.name, new_state)
|
||||
await self.hass.components.mqtt.async_publish(
|
||||
self.hass,
|
||||
cmd_topic,
|
||||
f"moodlight {json.dumps(new_state)}",
|
||||
qos=0,
|
||||
retain=False,
|
||||
)
|
||||
|
||||
async def async_turn_on(self, **kwargs):
|
||||
"""Turn on the moodlight."""
|
||||
if ATTR_HS_COLOR in kwargs:
|
||||
self._hs = kwargs[ATTR_HS_COLOR]
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
self._brightness = kwargs[ATTR_BRIGHTNESS]
|
||||
|
||||
self._state = True
|
||||
_LOGGER.debug(
|
||||
"Turn on %s - %s - %s",
|
||||
self._topic,
|
||||
color_util.color_hs_to_RGB(*self._hs) if self._hs else None,
|
||||
self._brightness,
|
||||
)
|
||||
await self.refresh()
|
||||
13
custom_components/openhasp/manifest.json
Normal file
13
custom_components/openhasp/manifest.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"domain": "openhasp",
|
||||
"name": "openHASP",
|
||||
"documentation": "https://haswitchplate.github.io/openHASP-docs/",
|
||||
"issue_tracker": "https://github.com/HASwitchPlate/openHASP-custom-component/issues",
|
||||
"dependencies": ["mqtt", "http"],
|
||||
"requirements": ["jsonschema>=3.2.0"],
|
||||
"version": "0.6.5",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_push",
|
||||
"codeowners": ["@dgomes"],
|
||||
"mqtt": ["hasp/discovery"]
|
||||
}
|
||||
128
custom_components/openhasp/number.py
Normal file
128
custom_components/openhasp/number.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Support for current page numbers."""
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from homeassistant.components.number import NumberEntity, NumberEntityDescription
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
from .common import HASPEntity
|
||||
from .const import CONF_HWID, CONF_TOPIC
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HASPNumberDescriptionMixin:
|
||||
"""Mixin to describe a HASP Number entity."""
|
||||
|
||||
command_topic: str
|
||||
state_topic: str
|
||||
|
||||
@dataclass
|
||||
class HASPNumberDescription(NumberEntityDescription, HASPNumberDescriptionMixin):
|
||||
"""Class to describe an HASP Number Entity."""
|
||||
|
||||
|
||||
NUMBERS = [
|
||||
HASPNumberDescription(
|
||||
key="Page Number",
|
||||
name="Page Number",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
icon="mdi:numeric-1-box-multiple-outline",
|
||||
native_min_value=1,
|
||||
native_max_value=12,
|
||||
command_topic="/command/page",
|
||||
state_topic="/state/page",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities
|
||||
):
|
||||
"""Set up Plate Relays as switch based on a config entry."""
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
HASPNumber(
|
||||
entry.data[CONF_NAME],
|
||||
entry.data[CONF_HWID],
|
||||
entry.data[CONF_TOPIC],
|
||||
desc,
|
||||
)
|
||||
for desc in NUMBERS
|
||||
]
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class HASPNumber(HASPEntity, NumberEntity, RestoreEntity):
|
||||
"""Representation of HASP number."""
|
||||
|
||||
def __init__(self, name, hwid, topic, description) -> None:
|
||||
"""Initialize the number."""
|
||||
super().__init__(name, hwid, topic, description.key)
|
||||
self.entity_description = description
|
||||
self._number = None
|
||||
self._attr_name = f"{name} {self.entity_description.name}"
|
||||
|
||||
async def refresh(self):
|
||||
"""Sync local state back to plate."""
|
||||
await self.hass.components.mqtt.async_publish(
|
||||
self.hass,
|
||||
f"{self._topic}{self.entity_description.command_topic}",
|
||||
"" if self._number is None else self._number,
|
||||
qos=0,
|
||||
retain=False,
|
||||
)
|
||||
_LOGGER.debug("refresh %s with <%s>", self.entity_id, self._number)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Run when entity about to be added."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
@callback
|
||||
async def page_state_message_received(msg):
|
||||
"""Process State."""
|
||||
|
||||
self._available = True
|
||||
_LOGGER.debug("%s current value = %s", self.entity_id, msg.payload)
|
||||
|
||||
self._number = int(msg.payload)
|
||||
self.async_write_ha_state()
|
||||
|
||||
self._subscriptions.append(
|
||||
await self.hass.components.mqtt.async_subscribe(
|
||||
f"{self._topic}{self.entity_description.state_topic}",
|
||||
page_state_message_received,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
state = await self.async_get_last_state()
|
||||
if state:
|
||||
self._number = int(state.state)
|
||||
except Exception:
|
||||
_LOGGER.error("Could not restore page number for %s", self.entity_id)
|
||||
|
||||
await self.refresh()
|
||||
|
||||
@property
|
||||
def native_value(self) -> int:
|
||||
"""Return the current number."""
|
||||
return self._number
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set the perfume amount."""
|
||||
if not value.is_integer():
|
||||
raise ValueError(
|
||||
f"Can't set the {self.entity_description.name} to {value}. {self.entity_description.name} must be an integer."
|
||||
)
|
||||
self._number = int(value)
|
||||
await self.refresh()
|
||||
32
custom_components/openhasp/pages_schema.json
Normal file
32
custom_components/openhasp/pages_schema.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 12
|
||||
},
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 254
|
||||
},
|
||||
"obj": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
135
custom_components/openhasp/services.yaml
Normal file
135
custom_components/openhasp/services.yaml
Normal file
@@ -0,0 +1,135 @@
|
||||
load_pages:
|
||||
name: Load pages.jsonl
|
||||
description: Loads new design from pages.jsonl file from full path. The file must be located in an authorised location defined by allowlist_external_dirs in Home Assistant's main configuration.
|
||||
target:
|
||||
fields:
|
||||
path:
|
||||
name: Path
|
||||
description: Path to the file containing the plate layout in JSONL format
|
||||
required: true
|
||||
example: "/config/pages.jsonl"
|
||||
selector:
|
||||
text:
|
||||
|
||||
wakeup:
|
||||
name: Wakeup
|
||||
description: This is helpful e.g. when you want to wake up the display when an external event has occurred, like a presence or PIR motion sensor.
|
||||
target:
|
||||
|
||||
next_page:
|
||||
name: Next Page
|
||||
description: Changes plate to the next page
|
||||
target:
|
||||
|
||||
prev_page:
|
||||
name: Previous Page
|
||||
description: Changes plate to the previous page
|
||||
target:
|
||||
|
||||
clear_page:
|
||||
name: Clear Page
|
||||
description: Clears the contents of the specified page number.
|
||||
target:
|
||||
fields:
|
||||
page:
|
||||
name: Page
|
||||
description: Page number to clear (if not specified, clear all pages)
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 12
|
||||
|
||||
change_page:
|
||||
name: Change Page
|
||||
description: Changes plate directly to the specified page number.
|
||||
target:
|
||||
fields:
|
||||
page:
|
||||
name: Page
|
||||
description: Page number to change to
|
||||
required: true
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 12
|
||||
|
||||
command:
|
||||
name: Command
|
||||
description: Sends commands directly to the plate entity (as a wrapper for MQTT commands sent to hasp/<nodename>/command)
|
||||
target:
|
||||
fields:
|
||||
keyword:
|
||||
name: Keyword
|
||||
description: Command keyword.
|
||||
required: true
|
||||
example: "backlight"
|
||||
selector:
|
||||
text:
|
||||
parameters:
|
||||
name: Parameters
|
||||
description: The parameters of the command.
|
||||
required: false
|
||||
example: "off"
|
||||
selector:
|
||||
text:
|
||||
|
||||
config:
|
||||
name: Configuration
|
||||
description: Sends configuration commands to plate entity (as a wrapper for MQTT commands sent to hasp/<nodename>/config/submodule)
|
||||
target:
|
||||
fields:
|
||||
submodule:
|
||||
name: submodule
|
||||
description: The submodule we intend to configure.
|
||||
required: true
|
||||
example: 'gui'
|
||||
selector:
|
||||
text:
|
||||
parameters:
|
||||
name: Parameters
|
||||
description: The parameters of the configuration setting.
|
||||
required: true
|
||||
example: '{"idle2":180}'
|
||||
selector:
|
||||
text:
|
||||
|
||||
push_image:
|
||||
name: Push Image
|
||||
description: Change the src image of an img object.
|
||||
target:
|
||||
fields:
|
||||
image:
|
||||
name: Image
|
||||
description: URL or Full Path of an image
|
||||
required: true
|
||||
example: "https://people.sc.fsu.edu/~jburkardt/data/jpg/lena.jpg"
|
||||
selector:
|
||||
text:
|
||||
obj:
|
||||
name: Object
|
||||
description: Object ID in the format p#b##
|
||||
required: true
|
||||
example: "p1b10"
|
||||
selector:
|
||||
text:
|
||||
width:
|
||||
name: Width
|
||||
description: Resize to width
|
||||
required: false
|
||||
example: "128"
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 1280
|
||||
mode: box
|
||||
height:
|
||||
name: Height
|
||||
description: Resize to height
|
||||
required: false
|
||||
example: "128"
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 1024
|
||||
mode: box
|
||||
40
custom_components/openhasp/strings.json
Normal file
40
custom_components/openhasp/strings.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"personalize": {
|
||||
"title": "Setup your OpenHASP Plate",
|
||||
"description": "Don't forget to add your plate to YAML configuration",
|
||||
"data": {
|
||||
"name": "Name of the plate",
|
||||
"topic": "OpenHASP MQTT base topic",
|
||||
"idle_brightness": "Brightness level when plate is idle",
|
||||
"path": "Full path to the JSONL file"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"name_exists": "A Plate with that name is already configured",
|
||||
"invalid_discovery_info": "Invalid plate topic",
|
||||
"invalid_jsonl_path": "Invalid path to a JSONL file"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Plate already configured",
|
||||
"mismatch_version": "Plate firmware mismatch",
|
||||
"discovery_only": "Configuration is done through discovery"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "openHASP Plate Options",
|
||||
"data": {
|
||||
"idle_brightness": "Brightness level when plate is idle",
|
||||
"path": "Full path to the JSONL file"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort":{
|
||||
"invalid_jsonl_path": "Invalid path to a JSONL file"
|
||||
}
|
||||
}
|
||||
}
|
||||
169
custom_components/openhasp/switch.py
Normal file
169
custom_components/openhasp/switch.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""Allows to configure a switch using GPIO."""
|
||||
import json
|
||||
import logging
|
||||
from typing import Callable
|
||||
|
||||
# pylint: disable=R0801
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_NAME, STATE_ON, STATE_OFF
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
import voluptuous as vol
|
||||
|
||||
from .common import HASPToggleEntity
|
||||
from .const import CONF_HWID, CONF_RELAYS, CONF_TOPIC
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
HASP_RELAY_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("state"): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# pylint: disable=R0801, W0613
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: Callable
|
||||
):
|
||||
"""Set up Plate Relays as switch based on a config entry."""
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
HASPSwitch(
|
||||
entry.data[CONF_NAME],
|
||||
entry.data[CONF_HWID],
|
||||
entry.data[CONF_TOPIC],
|
||||
gpio,
|
||||
)
|
||||
for gpio in entry.data[CONF_RELAYS]
|
||||
]
|
||||
+ [
|
||||
HASPAntiBurn(
|
||||
entry.data[CONF_NAME],
|
||||
entry.data[CONF_HWID],
|
||||
entry.data[CONF_TOPIC],
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class HASPSwitch(HASPToggleEntity):
|
||||
"""Representation of an openHASP relay."""
|
||||
|
||||
def __init__(self, name, hwid, topic, gpio):
|
||||
"""Initialize the relay."""
|
||||
super().__init__(name, hwid, topic, gpio)
|
||||
self._attr_name = f"{name} switch {self._gpio}"
|
||||
|
||||
async def refresh(self):
|
||||
"""Sync local state back to plate."""
|
||||
if self._state is None:
|
||||
# Don't do anything before we know the state
|
||||
return
|
||||
|
||||
await self.hass.components.mqtt.async_publish(
|
||||
self.hass,
|
||||
f"{self._topic}/command/output{self._gpio}",
|
||||
json.dumps(HASP_RELAY_SCHEMA({"state": int(self._state)})),
|
||||
qos=0,
|
||||
retain=False,
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Run when entity about to be added."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
@callback
|
||||
async def relay_state_message_received(msg):
|
||||
"""Process State."""
|
||||
|
||||
try:
|
||||
self._available = True
|
||||
message = HASP_RELAY_SCHEMA(json.loads(msg.payload))
|
||||
_LOGGER.debug("%s state = %s", self.name, message)
|
||||
|
||||
self._state = message["state"]
|
||||
self.async_write_ha_state()
|
||||
|
||||
except vol.error.Invalid as err:
|
||||
_LOGGER.error(err)
|
||||
|
||||
self._subscriptions.append(
|
||||
await self.hass.components.mqtt.async_subscribe(
|
||||
f"{self._topic}/state/output{self._gpio}", relay_state_message_received
|
||||
)
|
||||
)
|
||||
|
||||
await self.hass.components.mqtt.async_publish(
|
||||
self.hass,
|
||||
f"{self._topic}/command/output{self._gpio}",
|
||||
"",
|
||||
qos=0,
|
||||
retain=False,
|
||||
)
|
||||
|
||||
|
||||
class HASPAntiBurn(HASPToggleEntity):
|
||||
"""Configuration switch of an openHASP antiburn feature."""
|
||||
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_icon = "mdi:progress-wrench"
|
||||
|
||||
def __init__(self, name, hwid, topic):
|
||||
"""Initialize the protection."""
|
||||
super().__init__(name, hwid, topic, None)
|
||||
self._attr_name = f"{self._name} antiburn"
|
||||
|
||||
async def refresh(self):
|
||||
"""Sync local state back to plate."""
|
||||
await self.hass.components.mqtt.async_publish(
|
||||
self.hass,
|
||||
f"{self._topic}/command/antiburn",
|
||||
int(self._state),
|
||||
# json.dumps(HASP_RELAY_SCHEMA({"state": int(self._state)})),
|
||||
qos=0,
|
||||
retain=False,
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Run when entity about to be added."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
@callback
|
||||
async def antiburn_state_message_received(msg):
|
||||
"""Process State."""
|
||||
|
||||
try:
|
||||
self._available = True
|
||||
message = HASP_RELAY_SCHEMA(json.loads(msg.payload))
|
||||
_LOGGER.debug("%s state = %s", self.name, message)
|
||||
|
||||
self._state = message["state"]
|
||||
self.async_write_ha_state()
|
||||
|
||||
except vol.error.Invalid as err:
|
||||
_LOGGER.error(err)
|
||||
|
||||
self._subscriptions.append(
|
||||
await self.hass.components.mqtt.async_subscribe(
|
||||
f"{self._topic}/state/antiburn", antiburn_state_message_received
|
||||
)
|
||||
)
|
||||
|
||||
self._state = False
|
||||
self._available = True
|
||||
|
||||
await self.hass.components.mqtt.async_publish(
|
||||
self.hass,
|
||||
f"{self._topic}/command/antiburn",
|
||||
int(self._state),
|
||||
qos=0,
|
||||
retain=False,
|
||||
)
|
||||
40
custom_components/openhasp/translations/en.json
Normal file
40
custom_components/openhasp/translations/en.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"personalize": {
|
||||
"title": "Setup your OpenHASP Plate",
|
||||
"description": "Don't forget to add your plate to YAML configuration",
|
||||
"data": {
|
||||
"name": "Name of the plate",
|
||||
"topic": "OpenHASP MQTT base topic",
|
||||
"idle_brightness": "Brightness level when plate is idle",
|
||||
"path": "Full path to the JSONL file"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"name_exists": "A Plate with that name is already configured",
|
||||
"invalid_discovery_info": "Invalid plate topic",
|
||||
"invalid_jsonl_path": "Invalid path to a JSONL file"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Plate already configured",
|
||||
"mismatch_version": "Plate firmware mismatch",
|
||||
"discovery_only": "Configuration is done through discovery"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "openHASP Plate Options",
|
||||
"data": {
|
||||
"idle_brightness": "Brightness level when plate is idle",
|
||||
"path": "Full path to the JSONL file"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort":{
|
||||
"invalid_jsonl_path": "Invalid path to a JSONL file"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user