This commit is contained in:
Andrey
2020-12-28 22:23:56 +03:00
commit c4b597d019
19 changed files with 1444 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
import asyncio
from bs4 import BeautifulSoup
import aiohttp
host = '192.168.88.14/sec'
# page = '''
# <html><head><style>input,select{margin:1px}</style></head><body><a href="/sec/?cf=3">Back</a><br>P7/ON<br><a href="/sec/?pt=7&amp;cmd=7:1">ON</a> <a href="/sec/?pt=7&amp;cmd=7:0">OFF</a><br><form action="/sec/"><input type="hidden" name="pn" value="7">Type <select name="pty"><option value="255">NC</option><option value="0">In</option><option value="1" selected="">Out</option><option value="3">DSen</option><option value="4">I2C</option></select><br>Default: <select name="d"><option value="0" selected="">0</option><option value="1">1</option></select><br>Mode <select name="m"><option value="0" selected="">SW</option><option value="3">SW LINK</option><option value="2">DS2413</option></select><br>Group <input name="grp" size="2" value=""><br><input type="submit" value="Save"></form></body></html>
# <head><style>input,select{margin:1px}</style></head>
# <body><a href="/sec/?cf=3">Back</a><br>P7/ON<br><a href="/sec/?pt=7&amp;cmd=7:1">ON</a> <a href="/sec/?pt=7&amp;cmd=7:0">OFF</a><br><form action="/sec/"><input type="hidden" name="pn" value="7">Type <select name="pty"><option value="255">NC</option><option value="0">In</option><option value="1" selected="">Out</option><option value="3">DSen</option><option value="4">I2C</option></select><br>Default: <select name="d"><option value="0" selected="">0</option><option value="1">1</option></select><br>Mode <select name="m"><option value="0" selected="">SW</option><option value="3">SW LINK</option><option value="2">DS2413</option></select><br>Group <input name="grp" size="2" value=""><br><input type="submit" value="Save"></form></body>
# <a href="/sec/?cf=3">Back</a>
# <br>
# P7/ON
# <br>
# <a href="/sec/?pt=7&amp;cmd=7:1">ON</a>
# <a href="/sec/?pt=7&amp;cmd=7:0">OFF</a>
# <br>
# <form action="/sec/"><input type="hidden" name="pn" value="7">Type <select name="pty"><option value="255">NC</option><option value="0">In</option><option value="1" selected="">Out</option><option value="3">DSen</option><option value="4">I2C</option></select><br>Default: <select name="d"><option value="0" selected="">0</option><option value="1">1</option></select><br>Mode <select name="m"><option value="0" selected="">SW</option><option value="3">SW LINK</option><option value="2">DS2413</option></select><br>Group <input name="grp" size="2" value=""><br><input type="submit" value="Save"></form>
# <body><a href="/sec/?cf=3">Back</a><br>P7/ON<br><a href="/sec/?pt=7&amp;cmd=7:1">ON</a> <a href="/sec/?pt=7&amp;cmd=7:0">OFF</a><br><form action="/sec/"><input type="hidden" name="pn" value="7">Type <select name="pty"><option value="255">NC</option><option value="0">In</option><option value="1" selected="">Out</option><option value="3">DSen</option><option value="4">I2C</option></select><br>Default: <select name="d"><option value="0" selected="">0</option><option value="1">1</option></select><br>Mode <select name="m"><option value="0" selected="">SW</option><option value="3">SW LINK</option><option value="2">DS2413</option></select><br>Group <input name="grp" size="2" value=""><br><input type="submit" value="Save"></form></body>
# <html><head><style>input,select{margin:1px}</style></head><body><a href="/sec/?cf=3">Back</a><br>P7/ON<br><a href="/sec/?pt=7&amp;cmd=7:1">ON</a> <a href="/sec/?pt=7&amp;cmd=7:0">OFF</a><br><form action="/sec/"><input type="hidden" name="pn" value="7">Type <select name="pty"><option value="255">NC</option><option value="0">In</option><option value="1" selected="">Out</option><option value="3">DSen</option><option value="4">I2C</option></select><br>Default: <select name="d"><option value="0" selected="">0</option><option value="1">1</option></select><br>Mode <select name="m"><option value="0" selected="">SW</option><option value="3">SW LINK</option><option value="2">DS2413</option></select><br>Group <input name="grp" size="2" value=""><br><input type="submit" value="Save"></form></body></html>
# '''
# 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)
)

View File

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ChangeListManager">
<list default="true" id="4e5226d4-ea76-41c3-866f-a293e2a36ee1" name="Default Changelist" comment="">
<change beforePath="$PROJECT_DIR$/readme.md" beforeDir="false" afterPath="$PROJECT_DIR$/readme.md" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="ProjectId" id="1m1ke0h9bWkC4NQvoBSN4CpZPQ8" />
<component name="ProjectLevelVcsManager" settingsEditedManually="true" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent">
<property name="RunOnceActivity.OpenProjectViewOnStart" value="true" />
<property name="RunOnceActivity.ShowReadmeOnStart" value="true" />
<property name="WebServerToolWindowFactoryState" value="true" />
<property name="last_opened_file_path" value="$PROJECT_DIR$" />
<property name="node.js.detected.package.eslint" value="true" />
<property name="node.js.path.for.package.eslint" value="project" />
<property name="node.js.selected.package.eslint" value="(autodetect)" />
<property name="nodejs_interpreter_path.stuck_in_default_project" value="undefined stuck path" />
<property name="nodejs_npm_path_reset_for_default_project" value="true" />
<property name="settings.editor.selected.configurable" value="com.jetbrains.python.configuration.PyActiveSdkModuleConfigurable" />
</component>
<component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$" />
</key>
</component>
<component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="application-level" UseSingleDictionary="true" transferred="true" />
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="4e5226d4-ea76-41c3-866f-a293e2a36ee1" name="Default Changelist" comment="" />
<created>1608668575985</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1608668575985</updated>
<workItem from="1608668577233" duration="464000" />
</task>
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
</project>

View File

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

View File

@@ -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'

View File

@@ -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

View File

@@ -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'

View File

@@ -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

View File

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

View File

@@ -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.<id> becomes online else mega.<id> 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]

View File

@@ -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'

View File

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

View File

@@ -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}')

View File

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

View File

@@ -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%]"
}
}
}
}
}

View File

@@ -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'

View File

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

View File

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

8
hacs.json Normal file
View File

@@ -0,0 +1,8 @@
{
"name": "MegaD",
"country": "RU",
"domains": ["mega"],
"persistent_directory": "userfiles",
"iot_class": ["Assumed State", "Local Push"],
"render_readme": true
}

100
readme.md Normal file
View File

@@ -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.<id>
## Отладка
Если возникают проблемы, можно включить детальный лог, для этого в конфиг добавить:
```yaml
logger:
default: info
logs:
custom_components.mega: debug
```