initial commit

This commit is contained in:
2022-12-20 21:26:47 +01:00
commit 2962a6db69
722 changed files with 63886 additions and 0 deletions

View 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
)

View 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,
)

View 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()

View 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()

View 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."""

View 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"

View 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"}
)

View 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()

View 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"]
}

View 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()

View 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"
]
}
]
}
}

View 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

View 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"
}
}
}

View 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,
)

View 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"
}
}
}