commit c4b597d01921de5c8120409061393afb1198b0a7 Author: Andrey Date: Mon Dec 28 22:23:56 2020 +0300 initial diff --git a/custom_components/mega/.experiment.py b/custom_components/mega/.experiment.py new file mode 100644 index 0000000..485f324 --- /dev/null +++ b/custom_components/mega/.experiment.py @@ -0,0 +1,36 @@ +import asyncio +from bs4 import BeautifulSoup +import aiohttp + +host = '192.168.88.14/sec' + + + + +# page = ''' +# Back
P7/ON
ON OFF
Type
Default:
Mode
Group
+# +# Back
P7/ON
ON OFF
Type
Default:
Mode
Group
+# Back +#
+# P7/ON +#
+# ON +# OFF +#
+#
Type
Default:
Mode
Group
+# Back
P7/ON
ON OFF
Type
Default:
Mode
Group
+# Back
P7/ON
ON OFF
Type
Default:
Mode
Group
+# ''' +# tree = BeautifulSoup(page, features="lxml") +# pty = tree.find('select', attrs={'name': 'pty'}).find(selected=True)['value'] +# m = tree.find('select', attrs={'name': 'm'}) +# if m: +# m = m.find(selected=True)['value'] +# +# print(pty, m) + +if __name__ == '__main__': + asyncio.get_event_loop().run_until_complete( + scan_port(0) + ) \ No newline at end of file diff --git a/custom_components/mega/.idea/workspace.xml b/custom_components/mega/.idea/workspace.xml new file mode 100644 index 0000000..b459572 --- /dev/null +++ b/custom_components/mega/.idea/workspace.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1608668575985 + + + + + + \ No newline at end of file diff --git a/custom_components/mega/__init__.py b/custom_components/mega/__init__.py new file mode 100644 index 0000000..08113f2 --- /dev/null +++ b/custom_components/mega/__init__.py @@ -0,0 +1,126 @@ +"""The mega integration.""" +import asyncio +import logging +import typing +from functools import partial + +import voluptuous as vol +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PLATFORM, CONF_SCAN_INTERVAL, CONF_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.service import bind_hass +from homeassistant.components import mqtt +from homeassistant.config_entries import ConfigEntry +from .const import DOMAIN, CONF_INVERT +from .hub import MegaD + + +_LOGGER = logging.getLogger(__name__) +CONF_MQTT_ID = "mqtt_id" +CONF_PORT_TO_SCAN = 'port_to_scan' + +MEGA = { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_MQTT_ID, default=""): str, + vol.Optional(CONF_SCAN_INTERVAL, default=60): int, + vol.Optional(CONF_PORT_TO_SCAN, default=0): int, + } +MEGA_MAPPED = {str: MEGA} + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Any(MEGA, MEGA_MAPPED) + }, + extra=vol.ALLOW_EXTRA, +) + +PLATFORMS = [ + "light", + "binary_sensor", + "sensor", +] +ALIVE_STATE = 'alive' +DEF_ID = 'def' +_POLL_TASKS = {} +_hubs = {} +_subs = {} + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the mega component.""" + conf = config.get(DOMAIN) + hass.data[DOMAIN] = {} + if conf is None: + return True + if CONF_HOST in conf: + conf = {DEF_ID: conf} + for id, data in conf.items(): + await _add_mega(hass, id, data) + hass.services.async_register( + DOMAIN, 'save', _save_service, + ) + return True + + +async def _add_mega(hass: HomeAssistant, id, data: dict): + data.update(id=id) + _mqtt = hass.data.get(mqtt.DOMAIN) + if _mqtt is None: + raise Exception('mqtt not configured, please configure mqtt first') + hass.data[DOMAIN][id] = hub = MegaD(hass, **data, mqtt=_mqtt, lg=_LOGGER) + if not await hub.authenticate(): + raise Exception("not authentificated") + mid = await hub.get_mqtt_id() + hub.mqtt_id = mid + _POLL_TASKS[id] = asyncio.create_task(hub.poll()) + return hub + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + print(entry.entry_id) + id = entry.data.get('id', entry.entry_id) + hub = await _add_mega(hass, id, dict(entry.data)) + _hubs[entry.entry_id] = hub + _subs[entry.entry_id] = entry.add_update_listener(update) + for platform in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup( + entry, platform + ) + ) + return True + + +async def update(hass: HomeAssistant, entry: ConfigEntry): + hub: MegaD = hass.data[DOMAIN][entry.data[CONF_ID]] + hub.poll_interval = entry.options[CONF_SCAN_INTERVAL] + hub.port_to_scan = entry.options[CONF_PORT_TO_SCAN] + # hub.inverted = map(lambda x: x.strip(), ( + # entry.options.get(CONF_INVERT, '').split(',') + # ) + return True + + +async def async_remove_entry(hass, entry) -> None: + """Handle removal of an entry.""" + id = entry.data.get('id', entry.entry_id) + hass.data[DOMAIN][id].unsubscribe_all() + task: asyncio.Task = _POLL_TASKS.pop(id) + task.cancel() + _hubs.pop(entry.entry_id) + unsub = _subs.pop(entry.entry_id) + unsub() + return True + + +@bind_hass +async def _save_service(hass: HomeAssistant, mega_id='def'): + hub: MegaD = hass.data[DOMAIN][mega_id] + await hub.save() + + +async def _is_alive(cond: asyncio.Condition, msg): + async with cond: + cond.notify_all() + diff --git a/custom_components/mega/binary_sensor.py b/custom_components/mega/binary_sensor.py new file mode 100644 index 0000000..4845c79 --- /dev/null +++ b/custom_components/mega/binary_sensor.py @@ -0,0 +1,88 @@ +"""Platform for light integration.""" +import asyncio +import json +import logging + +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + PLATFORM_SCHEMA as SENSOR_SCHEMA, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_NAME, + CONF_PLATFORM, + CONF_PORT, + CONF_UNIQUE_ID, + CONF_ID +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.restore_state import RestoreEntity +from .entities import BaseMegaEntity + +from .hub import MegaD + +lg = logging.getLogger(__name__) + + +# Validation of the user's configuration +_EXTENDED = { + vol.Required(CONF_PORT): int, + vol.Optional(CONF_NAME): str, + vol.Optional(CONF_UNIQUE_ID): str, +} +_ITEM = vol.Any(int, _EXTENDED) +PLATFORM_SCHEMA = SENSOR_SCHEMA.extend( + { + vol.Optional(str, description="mega id"): [_ITEM] + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup_platform(hass, config, add_entities, discovery_info=None): + config.pop(CONF_PLATFORM) + ents = [] + for mid, _config in config.items(): + for x in _config: + if isinstance(x, int): + ent = MegaBinarySensor( + mega_id=mid, port=x + ) + else: + ent = MegaBinarySensor( + mega_id=mid, port=x[CONF_PORT], name=x[CONF_NAME] + ) + ents.append(ent) + add_entities(ents) + return True + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_devices): + mid = config_entry.data[CONF_ID] + hub: MegaD = hass.data['mega'][mid] + devices = [] + async for port, pty, m in hub.scan_ports(): + if pty == "0": + sensor = MegaBinarySensor(mega_id=mid, port=port) + devices.append(sensor) + + async_add_devices(devices) + + +class MegaBinarySensor(BinarySensorEntity, BaseMegaEntity): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._is_on = None + + @property + def is_on(self) -> bool: + if self._is_on is not None: + return self._is_on + return self._state == 'ON' + + def _update(self, payload: dict): + val = payload.get("value") + self._is_on = val == 'ON' \ No newline at end of file diff --git a/custom_components/mega/config_flow.py b/custom_components/mega/config_flow.py new file mode 100644 index 0000000..5389ada --- /dev/null +++ b/custom_components/mega/config_flow.py @@ -0,0 +1,111 @@ +"""Пока не сделано""" + +import logging + +import voluptuous as vol + +from homeassistant import config_entries, core +from homeassistant.components import mqtt +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_ID, CONF_PASSWORD, CONF_SCAN_INTERVAL +from homeassistant.core import callback +from .const import DOMAIN, CONF_PORT_TO_SCAN, CONF_RELOAD, CONF_INVERT # pylint:disable=unused-import +from .hub import MegaD +from . import exceptions + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_ID, default='def'): str, + vol.Required(CONF_HOST, default="192.168.0.14"): str, + vol.Required(CONF_PASSWORD, default="sec"): str, + vol.Optional(CONF_SCAN_INTERVAL, default=60): int, + vol.Optional(CONF_PORT_TO_SCAN, default=0): int, + }, +) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + if data[CONF_ID] in hass.data.get(DOMAIN, []): + raise exceptions.DuplicateId('duplicate_id') + _mqtt = hass.data.get(mqtt.DOMAIN) + if not isinstance(_mqtt, mqtt.MQTT): + raise exceptions.MqttNotConfigured("mqtt must be configured first") + hub = MegaD(hass, **data, lg=_LOGGER, mqtt=_mqtt) + if not await hub.authenticate(): + raise exceptions.InvalidAuth + + return hub + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for mega.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_ASSUMED + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors = {} + + try: + await validate_input(self.hass, user_input) + except exceptions.CannotConnect: + errors["base"] = "cannot_connect" + except exceptions.InvalidAuth: + errors["base"] = "invalid_auth" + except exceptions.DuplicateId: + errors["base"] = "duplicate_id" + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors[CONF_ID] = str(exc) + else: + return self.async_create_entry( + title=user_input.get(CONF_ID, user_input[CONF_HOST]), + data=user_input, + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + + def __init__(self, config_entry: ConfigEntry): + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the options.""" + if user_input is not None: + return self.async_create_entry( + title='', + data={**user_input, **{CONF_ID: self.config_entry.data[CONF_ID]}}, + ) + e = self.config_entry.data + ret = self.async_show_form( + step_id="init", + data_schema=vol.Schema({ + vol.Optional(CONF_SCAN_INTERVAL, default=e[CONF_SCAN_INTERVAL]): int, + vol.Optional(CONF_PORT_TO_SCAN, default=e.get(CONF_PORT_TO_SCAN, 0)): int, + vol.Optional(CONF_RELOAD, default=False): bool, + vol.Optional(CONF_INVERT): vol.Set(), + }), + ) + print(ret) + return ret \ No newline at end of file diff --git a/custom_components/mega/const.py b/custom_components/mega/const.py new file mode 100644 index 0000000..e446ace --- /dev/null +++ b/custom_components/mega/const.py @@ -0,0 +1,14 @@ +"""Constants for the mega integration.""" + +DOMAIN = "mega" +CONF_MEGA_ID = "mega_id" +CONF_DIMMER = "dimmer" +CONF_SWITCH = "switch" +CONF_KEY = 'key' +TEMP = 'temp' +HUM = 'hum' +W1 = 'w1' +W1BUS = 'w1bus' +CONF_PORT_TO_SCAN = 'port_to_scan' +CONF_RELOAD = 'reload' +CONF_INVERT = 'invert' \ No newline at end of file diff --git a/custom_components/mega/entities.py b/custom_components/mega/entities.py new file mode 100644 index 0000000..55ed21f --- /dev/null +++ b/custom_components/mega/entities.py @@ -0,0 +1,74 @@ +import asyncio + +import json +import logging + +from homeassistant.core import State +from .hub import MegaD +from homeassistant.helpers.restore_state import RestoreEntity +from .const import DOMAIN + + +class BaseMegaEntity(RestoreEntity): + """ + Base Mega's entity. It is responsible for storing reference to mega hub + Also provides some basic entity information: unique_id, name, availiability + It also makes subscription to port states + """ + def __init__( + self, + mega_id: str, + port: int, + id_suffix=None, + name=None, + unique_id=None + ): + self._state: State = None + self.port = port + self._name = name + self._mega_id = mega_id + self._lg = None + self._unique_id = unique_id or f"mega_{mega_id}_{port}" + \ + (f"_{id_suffix}" if id_suffix else "") + + @property + def lg(self) -> logging.Logger: + if self._lg is None: + self._lg = self.mega.lg.getChild(self._name or self.unique_id) + return self._lg + + @property + def mega(self) -> MegaD: + return self.hass.data[DOMAIN][self._mega_id] + + @property + def available(self) -> bool: + return self.mega.online + + @property + def name(self): + return self._name or f"{self.mega.id}_p{self.port}" + + @property + def unique_id(self): + return self._unique_id + + async def async_added_to_hass(self) -> None: + await self.mega.subscribe(self.port, callback=self.__update) + self._state = await self.async_get_last_state() + await asyncio.sleep(0.1) + await self.mega.get_port(self.port) + + def __update(self, msg): + try: + value = json.loads(msg.payload) + except Exception as exc: + self.lg.warning(f'could not parse json ({msg.payload}): {exc}') + return + self._update(value) + self.hass.async_create_task(self.async_update_ha_state()) + self.lg.debug(f'state after update %s', self.state) + return + + def _update(self, payload: dict): + raise NotImplementedError diff --git a/custom_components/mega/exceptions.py b/custom_components/mega/exceptions.py new file mode 100644 index 0000000..9165b10 --- /dev/null +++ b/custom_components/mega/exceptions.py @@ -0,0 +1,17 @@ +from homeassistant import exceptions + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class MqttNotConfigured(exceptions.HomeAssistantError): + """Error to indicate mqtt is not configured""" + + +class DuplicateId(exceptions.HomeAssistantError): + """Error to indicate duplicate id""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/custom_components/mega/hub.py b/custom_components/mega/hub.py new file mode 100644 index 0000000..b73eaca --- /dev/null +++ b/custom_components/mega/hub.py @@ -0,0 +1,259 @@ +import asyncio +import json +import logging +from functools import wraps + +import aiohttp +import typing +from bs4 import BeautifulSoup + +from homeassistant.components import mqtt +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity +from .exceptions import CannotConnect + + +class MegaD: + """MegaD Hub""" + + def __init__( + self, + hass: HomeAssistant, + host: str, + password: str, + mqtt: mqtt.MQTT, + lg:logging.Logger, + id: str, + mqtt_id: str = None, + scan_interval=60, + port_to_scan=0, + inverted:typing.List[int] = None, + **kwargs, + ): + """Initialize.""" + self.hass = hass + self.host = host + self.sec = password + self.mqtt = mqtt + self.id = id + self.lck = asyncio.Lock() + self.is_alive = asyncio.Condition() + self.online = True + self.entities: typing.List[Entity] = [] + self.poll_interval = scan_interval + self.subscriptions = [] + self.lg: logging.Logger = lg.getChild(self.id) + self._scanned = {} + self.sensors = [] + self.port_to_scan = port_to_scan + self.inverted = inverted or [] + if not mqtt_id: + _id = host.split(".")[-1] + self.mqtt_id = f"megad/{_id}" + else: + self.mqtt_id = mqtt_id + self._loop: asyncio.AbstractEventLoop = None + + async def add_entity(self, ent): + async with self.lck: + self.entities.append(ent) + + async def get_sensors(self): + _ports = {x.port for x in self.sensors} + for x in _ports: + await self.get_port(x) + await asyncio.sleep(self.poll_interval) + + async def poll(self): + """ + Send get port 0 every poll_interval. When answer is received, mega. becomes online else mega. becomes + offline + """ + self._loop = asyncio.get_event_loop() + if self.sensors: + await self.subscribe(self.sensors[0].port, callback=self._notify) + else: + await self.subscribe(self.port_to_scan, callback=self._notify) + while True: + async with self.is_alive: + if len(self.sensors) > 0: + await self.get_sensors() + else: + await self.get_port(self.port_to_scan) + + try: + await asyncio.wait_for(self.is_alive.wait(), timeout=5) + self.hass.states.async_set( + f'mega.{self.id}', + 'online', + ) + self.online = True + except asyncio.TimeoutError: + self.online = False + self.hass.states.async_set( + f'mega.{self.id}', + 'offline', + ) + for x in self.entities: + try: + await x.async_update_ha_state() + except RuntimeError: + pass + await asyncio.sleep(self.poll_interval) + + async def _async_notify(self): + async with self.is_alive: + self.is_alive.notify_all() + + def _notify(self, *args): + asyncio.run_coroutine_threadsafe(self._async_notify(), self._loop) + + async def get_mqtt_id(self): + async with aiohttp.request( + 'get', f'http://{self.host}/{self.sec}/?cf=2' + ) as req: + data = await req.text() + data = BeautifulSoup(data, features="lxml") + _id = data.find(attrs={'name': 'mdid'}) + if _id: + _id = _id['value'] + return _id or 'megad/' + self.host.split('.')[-1] + + async def send_command(self, port=None, cmd=None): + if port: + url = f"http://{self.host}/{self.sec}/?pt={port}&cmd={cmd}" + else: + url = f"http://{self.host}/{self.sec}/?cmd={cmd}" + self.lg.debug('run command: %s', url) + async with self.lck: + async with aiohttp.request("get", url=url) as req: + if req.status != 200: + self.lg.warning('%s returned %s (%s)', url, req.status, await req.text()) + return False + else: + return True + + async def save(self): + await self.send_command(cmd='s') + + async def get_port(self, port, get_value=False): + if get_value: + ftr = asyncio.get_event_loop().create_future() + + def cb(msg): + try: + ftr.set_result(json.loads(msg.payload).get('value')) + except Exception as exc: + self.lg.warning(f'could not parse {msg.payload}: {exc}') + unsub = await self.mqtt.async_subscribe( + topic=f'{self.mqtt_id}/{port}', + msg_callback=cb, + qos=1, + ) + + self.lg.debug( + f'get port: %s', port + ) + async with self.lck: + await self.mqtt.async_publish( + topic=f'{self.mqtt_id}/cmd', + payload=f'get:{port}', + qos=0, + retain=False, + ) + await asyncio.sleep(0.1) + + if get_value: + try: + return await asyncio.wait_for(ftr, timeout=2) + except asyncio.TimeoutError: + self.lg.warning(f'timeout on port {port}') + finally: + unsub() + + async def get_all_ports(self): + for x in range(37): + await self.get_port(x) + + async def reboot(self, save=True): + await self.save() + # await self.send_command(cmd=) + + async def subscribe(self, port, callback): + + @wraps(callback) + def wrapper(msg): + self.lg.debug( + 'process incomming message: %s', msg + ) + return callback(msg) + + self.lg.debug( + f'subscribe %s %s', port, wrapper + ) + subs = await self.mqtt.async_subscribe( + topic=f"{self.mqtt_id}/{port}", + msg_callback=wrapper, + qos=0, + ) + self.subscriptions.append(subs) + + def unsubscribe_all(self): + self.lg.info('unsubscribe') + for x in self.subscriptions: + self.lg.debug('unsubscribe %s', x) + x() + + async def authenticate(self) -> bool: + """Test if we can authenticate with the host.""" + async with aiohttp.request("get", url=f"http://{self.host}/{self.sec}") as req: + if "Unauthorized" in await req.text(): + return False + else: + if req.status != 200: + raise CannotConnect + return True + + async def get_port_page(self, port): + url = f'http://{self.host}/{self.sec}/?pt={port}' + self.lg.debug(f'get page for port {port} {url}') + async with aiohttp.request('get', url) as req: + return await req.text() + + async def scan_port(self, port): + if port in self._scanned: + return self._scanned[port] + url = f'http://{self.host}/{self.sec}/?pt={port}' + self.lg.debug( + f'scan port %s: %s', port, url + ) + async with aiohttp.request('get', url) as req: + html = await req.text() + tree = BeautifulSoup(html, features="lxml") + pty = tree.find('select', attrs={'name': 'pty'}) + if pty is None: + return + else: + pty = pty.find(selected=True) + if pty: + pty = pty['value'] + else: + return + if pty in ['0', '1']: + m = tree.find('select', attrs={'name': 'm'}) + if m: + m = m.find(selected=True)['value'] + self._scanned[port] = (pty, m) + return pty, m + elif pty == '3': + m = tree.find('select', attrs={'name': 'd'}) + if m: + m = m.find(selected=True)['value'] + self._scanned[port] = (pty, m) + return pty, m + + async def scan_ports(self,): + for x in range(37): + ret = await self.scan_port(x) + if ret: + yield [x, *ret] diff --git a/custom_components/mega/light.py b/custom_components/mega/light.py new file mode 100644 index 0000000..3ad5825 --- /dev/null +++ b/custom_components/mega/light.py @@ -0,0 +1,154 @@ +"""Platform for light integration.""" +import asyncio +import json +import logging + +import voluptuous as vol + +from homeassistant.components.light import ( + PLATFORM_SCHEMA as LIGHT_SCHEMA, + SUPPORT_BRIGHTNESS, + LightEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_NAME, + CONF_PLATFORM, + CONF_PORT, + CONF_UNIQUE_ID, + CONF_ID +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.restore_state import RestoreEntity +from .entities import BaseMegaEntity + +from .hub import MegaD +from .const import CONF_DIMMER, CONF_SWITCH + + +lg = logging.getLogger(__name__) + + +# Validation of the user's configuration +_EXTENDED = { + vol.Required(CONF_PORT): int, + vol.Optional(CONF_NAME): str, + vol.Optional(CONF_UNIQUE_ID): str, +} +_ITEM = vol.Any(int, _EXTENDED) +DIMMER = {vol.Required(CONF_DIMMER): [_ITEM]} +SWITCH = {vol.Required(CONF_SWITCH): [_ITEM]} +PLATFORM_SCHEMA = LIGHT_SCHEMA.extend( + { + vol.Optional(str, description="mega id"): { + vol.Optional("dimmer", default=[]): [_ITEM], + vol.Optional("switch", default=[]): [_ITEM], + } + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup_platform(hass, config, add_entities, discovery_info=None): + config.pop(CONF_PLATFORM) + ents = [] + for mid, _config in config.items(): + for x in _config["dimmer"]: + if isinstance(x, int): + ent = MegaLight( + mega_id=mid, port=x, dimmer=True) + else: + ent = MegaLight( + mega_id=mid, port=x[CONF_PORT], name=x[CONF_NAME], dimmer=True + ) + ents.append(ent) + for x in _config["switch"]: + if isinstance(x, int): + ent = MegaLight( + mega_id=mid, port=x, dimmer=False + ) + else: + ent = MegaLight( + mega_id=mid, port=x[CONF_PORT], name=x[CONF_NAME], dimmer=False + ) + ents.append(ent) + add_entities(ents) + return True + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_devices): + mid = config_entry.data[CONF_ID] + hub: MegaD = hass.data['mega'][mid] + devices = [] + async for port, pty, m in hub.scan_ports(): + if pty == "1" and m in ['0', '1']: + light = MegaLight(mega_id=mid, port=port, dimmer=m == '1') + devices.append(light) + async_add_devices(devices) + + +class MegaLight(LightEntity, BaseMegaEntity): + + def __init__( + self, + dimmer=False, + *args, **kwargs + ): + super().__init__( + *args, **kwargs + ) + self._brightness = None + self._is_on = None + self.dimmer = dimmer + + @property + def brightness(self): + if self._brightness is not None: + return self._brightness + if self._state: + return self._state.attributes.get("brightness") + + @property + def supported_features(self): + return SUPPORT_BRIGHTNESS if self.dimmer else 0 + + @property + def is_on(self) -> bool: + if self._is_on is not None: + return self._is_on + return self._state == 'ON' + + async def async_turn_on(self, brightness=None, **kwargs) -> None: + brightness = brightness or self.brightness + if self.dimmer and brightness == 0: + cmd = 255 + elif self.dimmer: + cmd = brightness + else: + cmd = 1 + if await self.mega.send_command(self.port, f"{self.port}:{cmd}"): + self._is_on = True + self._brightness = brightness + await self.async_update_ha_state() + + async def async_turn_off(self, **kwargs) -> None: + + cmd = "0" + + if await self.mega.send_command(self.port, f"{self.port}:{cmd}"): + self._is_on = False + await self.async_update_ha_state() + + def _update(self, payload: dict): + val = payload.get("value") + try: + val = int(val) + except Exception: + pass + if isinstance(val, int): + self._is_on = val + if val > 0: + self._brightness = val + else: + self._is_on = val == 'ON' + diff --git a/custom_components/mega/manifest.json b/custom_components/mega/manifest.json new file mode 100644 index 0000000..69623e5 --- /dev/null +++ b/custom_components/mega/manifest.json @@ -0,0 +1,19 @@ +{ + "domain": "mega", + "name": "mega", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/mega", + "requirements": [ + "beautifulsoup4", + "lxml" + ], + "ssdp": [], + "zeroconf": [], + "homekit": {}, + "dependencies": [ + "mqtt" + ], + "codeowners": [ + "@andvikt" + ] +} \ No newline at end of file diff --git a/custom_components/mega/sensor.py b/custom_components/mega/sensor.py new file mode 100644 index 0000000..5303c91 --- /dev/null +++ b/custom_components/mega/sensor.py @@ -0,0 +1,179 @@ +"""Platform for light integration.""" +import logging + +import typing +import voluptuous as vol + +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA as SENSOR_SCHEMA, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_HUMIDITY +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_NAME, + CONF_PLATFORM, + CONF_PORT, + CONF_UNIQUE_ID, + CONF_ID, + CONF_TYPE, +) +from homeassistant.core import HomeAssistant +from .entities import BaseMegaEntity +from .const import CONF_KEY, TEMP, HUM, W1, W1BUS +from .hub import MegaD +import re + +lg = logging.getLogger(__name__) +TEMP_PATT = re.compile(r'temp:([01234567890\.]+)') +HUM_PATT = re.compile(r'hum:([01234567890\.]+)') +PATTERNS = { + TEMP: TEMP_PATT, + HUM: HUM_PATT, +} + +UNITS = { + TEMP: '°C', + HUM: '%' +} +CLASSES = { + TEMP: DEVICE_CLASS_TEMPERATURE, + HUM: DEVICE_CLASS_HUMIDITY +} +# Validation of the user's configuration +_ITEM = { + vol.Required(CONF_PORT): int, + vol.Optional(CONF_NAME): str, + vol.Optional(CONF_UNIQUE_ID): str, + vol.Required(CONF_TYPE): vol.Any( + W1, + W1BUS, + ), + vol.Optional(CONF_KEY, default=''): str, +} +PLATFORM_SCHEMA = SENSOR_SCHEMA.extend( + { + vol.Optional(str, description="mega id"): [_ITEM] + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup_platform(hass, config, add_entities, discovery_info=None): + config.pop(CONF_PLATFORM) + ents = [] + for mid, _config in config.items(): + for x in _config: + ent = _make_entity(mid, **x) + ents.append(ent) + add_entities(ents) + return True + + +def _make_entity(mid: str, port: int, conf: dict): + key = conf[CONF_KEY] + return Mega1WSensor( + key=key, + mega_id=mid, + port=port, + patt=PATTERNS.get(key), + unit_of_measurement=UNITS.get(key, UNITS[TEMP]), # TODO: make other units, make options in config flow + device_class=CLASSES.get(key, CLASSES[TEMP]), + id_suffix=key + ) + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_devices): + mid = config_entry.data[CONF_ID] + hub: MegaD = hass.data['mega'][mid] + devices = [] + async for port, pty, m in hub.scan_ports(): + if pty == "3": + values = await hub.get_port(port, get_value=True) + lg.debug(f'values: %s', values) + if values is None: + continue + if not isinstance(values, dict): + values = {None: values} + for key in values: + hub.lg.debug(f'add sensor {W1}:{key}') + sensor = _make_entity( + mid=mid, + port=port, + conf={ + CONF_TYPE: W1, + CONF_KEY: key, + }) + devices.append(sensor) + + async_add_devices(devices) + + +class Mega1WSensor(BaseMegaEntity): + + def __init__( + self, + unit_of_measurement, + device_class, + patt=None, + key=None, + *args, + **kwargs + ): + """ + 1-wire sensor entity + + :param key: key to get value from mega's json + :param patt: pattern to extract value, must have at least one group that will contain parsed value + """ + super().__init__(*args, **kwargs) + self._value = None + self.key = key + self.patt = patt + self._device_class = device_class + self._unit_of_measurement = unit_of_measurement + + async def async_added_to_hass(self) -> None: + + await super(Mega1WSensor, self).async_added_to_hass() + self.mega.sensors.append(self) + + @property + def unit_of_measurement(self): + return self._unit_of_measurement + + @property + def unique_id(self): + if self.key: + return super().unique_id + f'_{self.key}' + else: + return super(Mega1WSensor, self).unique_id + + @property + def device_class(self): + return self._device_class + + @property + def should_poll(self): + return False + + @property + def state(self): + if self._value is None and self._state is not None: + return self._state.state + return self._value + + def _update(self, payload: dict): + val = payload.get('value', '') + if isinstance(val, str): + val = self.patt.findall(val) + if val: + self._value = val[0] + else: + self.lg.warning(f'could not parse: {payload}') + elif isinstance(val, dict) and self.key is not None: + self._value = val.get(self.key) + elif isinstance(val, (float, int)): + self._value = val + else: + self.lg.warning(f'could not parse: {payload}') diff --git a/custom_components/mega/services.yaml b/custom_components/mega/services.yaml new file mode 100644 index 0000000..3adeab5 --- /dev/null +++ b/custom_components/mega/services.yaml @@ -0,0 +1,13 @@ + + +save: + # Description of the service + description: Сохраняет текущее состояние портов (?cmd=s) + # Different fields that your service accepts + fields: + # Key of the field + mega_id: + # Description of the field + description: ID меги + # Example value that can be passed for this field + example: "def" \ No newline at end of file diff --git a/custom_components/mega/strings.json b/custom_components/mega/strings.json new file mode 100644 index 0000000..1388da7 --- /dev/null +++ b/custom_components/mega/strings.json @@ -0,0 +1,40 @@ +{ + "title": "mega", + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "id": "[%key:common::config_flow::data::password%]", + "mqtt_id": "[%key:common::config_flow::data::mqtt_id%]", + "scan_interval": "[%key:common::config_flow::data::mqtt_id%]", + "port_to_scan": "[%key:common::config_flow::data::port_to_scan%]", + "invert": "[%key:common::config_flow::data::invert%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "duplicate_id": "[%key:common::config_flow::error::duplicate_id%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "[%key:common::config_flow::data::scan_interval%]", + "port_to_scan": "[%key:common::config_flow::data::port_to_scan%]", + "reload": "[%key:common::config_flow::data::reload%]", + "invert": "[%key:common::config_flow::data::invert%]" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/mega/switch.py b/custom_components/mega/switch.py new file mode 100644 index 0000000..56626ad --- /dev/null +++ b/custom_components/mega/switch.py @@ -0,0 +1,87 @@ +"""Platform for light integration.""" +import json +import logging + +import voluptuous as vol + +from homeassistant.components.switch import ( + PLATFORM_SCHEMA as LIGHT_SCHEMA, + SwitchEntity, +) +from homeassistant.const import ( + CONF_NAME, + CONF_PLATFORM, + CONF_PORT, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.restore_state import RestoreEntity +from .entities import BaseMegaEntity + +from .hub import MegaD +from .const import CONF_DIMMER, CONF_SWITCH + +_LOGGER = logging.getLogger(__name__) + + +# Validation of the user's configuration +_EXTENDED = { + vol.Required(CONF_PORT): int, + vol.Optional(CONF_NAME): str, +} +_ITEM = vol.Any(int, _EXTENDED) +DIMMER = {vol.Required(CONF_DIMMER): [_ITEM]} +SWITCH = {vol.Required(CONF_SWITCH): [_ITEM]} +PLATFORM_SCHEMA = LIGHT_SCHEMA.extend( + { + vol.Optional(str, description="mega id"): [_ITEM], + }, + extra=vol.ALLOW_EXTRA, +) + +async def async_setup_platform(hass, config, add_entities, discovery_info=None): + config.pop(CONF_PLATFORM) + ents = [] + for mid, _config in config.items(): + mega = hass.data["mega"][mid] + for x in _config: + if isinstance(x, int): + ent = MegaSwitch(hass, mega=mega, port=x) + else: + ent = MegaSwitch( + hass, mega=mega, port=x[CONF_PORT], name=x[CONF_NAME] + ) + ents.append(ent) + + add_entities(ents) + return True + + +class MegaSwitch(SwitchEntity, BaseMegaEntity): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._is_on = None + + @property + def is_on(self) -> bool: + if self._is_on is not None: + return self._is_on + return self._state == 'ON' + + async def async_turn_on(self, **kwargs) -> None: + cmd = 1 + if await self.mega.send_command(self.port, f"{self.port}:{cmd}"): + self._is_on = True + await self.async_update_ha_state() + + async def async_turn_off(self, **kwargs) -> None: + + cmd = "0" + + if await self.mega.send_command(self.port, f"{self.port}:{cmd}"): + self._is_on = False + await self.async_update_ha_state() + + def _update(self, payload: dict): + val = payload.get("value") + self._is_on = val == 'ON' diff --git a/custom_components/mega/translations/en.json b/custom_components/mega/translations/en.json new file mode 100644 index 0000000..880213b --- /dev/null +++ b/custom_components/mega/translations/en.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error", + "duplicate_id": "Duplicate ID" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Password", + "username": "Username", + "id": "ID", + "mqtt_id": "MQTT id", + "scan_interval": "Scan interval (sec) (used for aliveness and sensors)", + "port_to_scan": "Port to poll aliveness (needed only if no sensors used)" + } + } + } + }, + "title": "mega" +} \ No newline at end of file diff --git a/custom_components/mega/translations/ru.json b/custom_components/mega/translations/ru.json new file mode 100644 index 0000000..8fbb080 --- /dev/null +++ b/custom_components/mega/translations/ru.json @@ -0,0 +1,39 @@ +{ + "config": { + "abort": { + "already_configured": "Уже настроено" + }, + "error": { + "cannot_connect": "Невозможно подключиться", + "invalid_auth": "Неправильный пароль", + "unknown": "Неизвестная ошибка", + "duplicate_id": "Дубликат ID" + }, + "step": { + "user": { + "data": { + "host": "Хост", + "password": "Пароль", + "username": "Пользователь", + "id": "ID", + "mqtt_id": "MQTT id", + "scan_interval": "Периодичность обновлений (сек.)", + "port_to_scan": "Порт, который сканируется когда нет датчиков" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Периодичность обновлений (сек.)", + "port_to_scan": "Порт, который сканируется когда нет датчиков", + "reload": "Обновить объекты", + "invert": "Список портов (через ,) с инвертированной логикой" + } + } + } + }, + "title": "mega" +} \ No newline at end of file diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..2f01e89 --- /dev/null +++ b/hacs.json @@ -0,0 +1,8 @@ +{ + "name": "MegaD", + "country": "RU", + "domains": ["mega"], + "persistent_directory": "userfiles", + "iot_class": ["Assumed State", "Local Push"], + "render_readme": true +} \ No newline at end of file diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..050fa92 --- /dev/null +++ b/readme.md @@ -0,0 +1,100 @@ +# MegaD HomeAssistant custom component + +Интеграция с [MegaD-2561](https://www.ab-log.ru/smart-house/ethernet/megad-2561) + +## Основные особенности: +- Настройка как из yaml так и из веб-интерфейса +- При настройки из веба все порты автоматически добавляются как устройства (для обычных релейных выходов создается + `light`, для шим - `light` с поддержкой яркости, для цифровых входов `binary_sensor`, для температурных датчиков + `sensor`) +- Возможность работы с несколькими megad +- Обратная связь по mqtt +- Команды выполняются друг за другом без конкурентного доступа к ресурсам megad +## Устройства +Поддерживаются устройства: light, switch, binary_sensor, sensor. light может работать как диммер +## Установка +В папке config/custom_components выполнить: + ```shell + git clone https://github.com/andvikt/mega.git + ``` + Обновление: + ```shell + git pull + ``` +Перезагрузить HA +## Зависимости +Перед использованием необходимо настроить интеграцию mqtt в HomeAssistant + +## Настройка из веб-интерфейса +`Настройки` -> `Интеграции` -> `Добавить интеграцию` в поиске ищем mega + +## Пример настройки с помощью yaml: +```yaml +mega: + mega1: + host: 192.168.0.14 + name: hello + password: sec + mqtt_id: mega # это id в конфиге меги + +light: + - platform: mega + mega1: + switch: + - 1 # можно просто перечислить порты + - 2 + - 3 + dimmer: + - port: 7 + name: hello # можно использовать расширенный вариант с названиями + - 9 + - 10 + +binary_sensor: + - platform: mega + mega1: + - port: 16 + name: sensor1 + - port: 18 + name: sensor2 + +sensor: + - platform: mega + mega1: + - port: 10 + name: some temp + type: w1 + key: temp + - port: 10 + name: some hum + type: w1 + key: hum + +switch: + - platform: mega + mega1: + - 11 + +``` + +## Сервисы +Интеграция предоставляет сервис сохранения состояния портов: `mega.save` +```yaml +action: + service: mega.save + data: + mega_id: def +``` + +## Состояния +Так же каждое устройство megad опрашивается на предмет работоспособности, текущий статус +хранится в mega. + +## Отладка +Если возникают проблемы, можно включить детальный лог, для этого в конфиг добавить: +```yaml +logger: + default: info + logs: + custom_components.mega: debug +``` \ No newline at end of file