diff --git a/.experiment.py b/.experiment.py index cdbe35f..4419e7a 100644 --- a/.experiment.py +++ b/.experiment.py @@ -1,43 +1,121 @@ -import re +from urllib.parse import urlparse, parse_qsl + +from bs4 import BeautifulSoup + +page = ''' +Back
0x15 - T67XX
0x40 - HTU21D/PCA9685/HM3301
0x4a - MAX44009
+ +''' + +from urllib.parse import parse_qsl, urlparse + +from bs4 import BeautifulSoup + +from homeassistant.const import ( + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_PRESSURE, +) -PATT_FW = re.compile(r'fw:\s(.+)\)') -data = """ -MegaD-2561 by ab-log.ru (fw: 4.48b7)
Config
-- MODS --
XP1
XP2
-- XT2 --
P30 - OUT
P31 - OUT
P32 - IN
P33 - I2C/SCL
P34 - DS
P35 - NC
-- XP5/6 --
P36 - ADC
P37 - NC - -MegaD-2561 by ab-log.ru (fw: 4.48b7)
Config
-- MODS --
XP1
XP2
-- XT2 --
P30 - OUT
P31 - OUT
P32 - IN
P33 - I2C/SCL
P34 - DS
P35 - NC
-- XP5/6 --
P36 - ADC
P37 - NC -MegaD-2561 by -ab-log.ru - (fw: 4.48b7) -
-Config -
--- MODS -- -
-XP1 -
-XP2 -
--- XT2 -- -
-P30 - OUT -
-P31 - OUT -
-P32 - IN -
-P33 - I2C/SCL -
-P34 - DS -
-P35 - NC -
--- XP5/6 -- -
-P36 - ADC -
-P37 - NC -MegaD-2561 by ab-log.ru (fw: 4.48b7)
Config
-- MODS --
XP1
XP2
-- XT2 --
P30 - OUT
P31 - OUT
P32 - IN
P33 - I2C/SCL
P34 - DS
P35 - NC
-- XP5/6 --
P36 - ADC
P37 - NC -MegaD-2561 by ab-log.ru (fw: 4.48b7)
Config
-- MODS --
XP1
XP2
-- XT2 --
P30 - OUT
P31 - OUT
P32 - IN
P33 - I2C/SCL
P34 - DS
P35 - NC
-- XP5/6 --
P36 - ADC
P37 - NC -""" -print(PATT_FW.search(data).groups()[0]) \ No newline at end of file +def parse_scan_page(page: str): + ret = [] + req = [] + page = BeautifulSoup(page, features="lxml") + for x in page.find_all('a'): + params = x.get('href') + if params is None: + continue + params = dict(parse_qsl(urlparse(params).query)) + if 'i2c_dev' in params: + dev = params['i2c_dev'] + classes = i2c_classes.get(dev, []) + for i, c in enumerate(classes): + if c is Skip: + continue + elif c is Request: + req.append(params) + continue + elif isinstance(c, tuple): + suffix, c = c + elif isinstance(c, str): + suffix = c + else: + suffix = '' + if 'addr' in params: + suffix += f"_{params['addr']}" if suffix else str(params['addr']) + if suffix: + _dev = f'{dev}_{suffix}' + else: + _dev = dev + params = params.copy() + if i > 0: + params['i2c_par'] = i + ret.append({ + 'id_suffix': _dev, + 'device_class': c, + 'params': params, + }) + req.append(params) + return req, ret + + +class Skip: + pass + + +class Request: + pass + + +i2c_classes = { + 'htu21d': [ + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + ], + 'sht31': [ + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + ], + 'max44009': [ + DEVICE_CLASS_ILLUMINANCE + ], + 'bh1750': [ + DEVICE_CLASS_ILLUMINANCE + ], + 'tsl2591': [ + DEVICE_CLASS_ILLUMINANCE + ], + 'bmp180': [ + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + ], + 'bmx280': [ + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_HUMIDITY + ], + 'mlx90614': [ + Skip, + ('temp', DEVICE_CLASS_TEMPERATURE), + ('object', DEVICE_CLASS_TEMPERATURE), + ], + 'ptsensor': [ + Request, # запрос на измерение + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + ], + 'mcp9600': [ + DEVICE_CLASS_TEMPERATURE, # термопара + DEVICE_CLASS_TEMPERATURE, # сенсор встроенный в микросхему + ], + 't67xx': [ + None # для co2 нет класса в HA + ], + 'tmp117': [ + DEVICE_CLASS_TEMPERATURE, + ] +} + +print(parse_scan_page(page)) \ No newline at end of file diff --git a/custom_components/mega/__init__.py b/custom_components/mega/__init__.py index 57b53e1..4a36cf0 100644 --- a/custom_components/mega/__init__.py +++ b/custom_components/mega/__init__.py @@ -7,7 +7,7 @@ import voluptuous as vol from homeassistant.const import ( CONF_SCAN_INTERVAL, CONF_ID, CONF_NAME, CONF_DOMAIN, - CONF_UNIT_OF_MEASUREMENT, CONF_HOST, CONF_VALUE_TEMPLATE + CONF_UNIT_OF_MEASUREMENT, CONF_HOST, CONF_VALUE_TEMPLATE, CONF_DEVICE_CLASS ) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers.service import bind_hass @@ -34,6 +34,10 @@ CUSTOMIZE_PORT = { vol.Any(str, { vol.Required(str): str }), + vol.Optional(CONF_DEVICE_CLASS): + vol.Any(str, { + vol.Required(str): str + }), vol.Optional( CONF_RESPONSE_TEMPLATE, description='шаблон ответа когда на этот порт приходит' diff --git a/custom_components/mega/config_flow.py b/custom_components/mega/config_flow.py index f6bbd38..aa03c78 100644 --- a/custom_components/mega/config_flow.py +++ b/custom_components/mega/config_flow.py @@ -63,7 +63,7 @@ async def validate_input(hass: core.HomeAssistant, data): class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for mega.""" - VERSION = 17 + VERSION = 18 CONNECTION_CLASS = config_entries.CONN_CLASS_ASSUMED async def async_step_user(self, user_input=None): diff --git a/custom_components/mega/config_parser.py b/custom_components/mega/config_parser.py index 602cddf..49860c2 100644 --- a/custom_components/mega/config_parser.py +++ b/custom_components/mega/config_parser.py @@ -25,6 +25,7 @@ class Config: inta: str = field(compare=False, hash=False, default=None) misc: str = field(compare=False, hash=False, default=None) eact: str = field(compare=False, hash=False, default=None) + src: BeautifulSoup = field(compare=False, hash=False, default=None) def parse_config(page: str): @@ -43,7 +44,7 @@ def parse_config(page: str): v = page.find('input', attrs={'name': x}) if v: ret[x] = v['value'] - return Config(**ret) + return Config(**ret, src=page) DIGITAL_IN = Config(pty="0") diff --git a/custom_components/mega/entities.py b/custom_components/mega/entities.py index 4884aef..015cb2b 100644 --- a/custom_components/mega/entities.py +++ b/custom_components/mega/entities.py @@ -318,7 +318,7 @@ class MegaOutPort(MegaPushEntity): _cmd = {"cmd": f"{self.cmd_port}:{cmd}"} if self.addr: _cmd['addr'] = self.addr - await self.mega.request(**_cmd) + await self.mega.request(**_cmd, priority=-1) if self.index is not None: # обновление текущего стейта для ds2413 await self.mega.get_port( @@ -342,7 +342,7 @@ class MegaOutPort(MegaPushEntity): _cmd = {"cmd": f"{self.cmd_port}:{cmd}"} if self.addr: _cmd['addr'] = self.addr - await self.mega.request(**_cmd) + await self.mega.request(**_cmd, priority=-1) if self.index is not None: # обновление текущего стейта для ds2413 await self.mega.get_port( diff --git a/custom_components/mega/hub.py b/custom_components/mega/hub.py index af296a4..5b43d75 100644 --- a/custom_components/mega/hub.py +++ b/custom_components/mega/hub.py @@ -24,7 +24,8 @@ from .const import ( ) from .entities import set_events_off, BaseMegaEntity, MegaOutPort from .exceptions import CannotConnect, NoPort -from .tools import make_ints, int_ignore +from .i2c import parse_scan_page +from .tools import make_ints, int_ignore, PriorityLock TEMP_PATT = re.compile(r'temp:([01234567890\.]+)') HUM_PATT = re.compile(r'hum:([01234567890\.]+)') @@ -83,6 +84,7 @@ class MegaD: extenders=None, ext_in=None, ext_acts=None, + i2c_sensors=None, **kwargs, ): """Initialize.""" @@ -100,6 +102,7 @@ class MegaD: self.extenders = extenders or [] self.ext_in = ext_in or {} self.ext_act = ext_acts or {} + self.i2c_sensors = i2c_sensors or [] self.poll_outs = poll_outs self.update_all = update_all if update_all is not None else True self.nports = nports @@ -113,7 +116,7 @@ class MegaD: self.id = id self.lck = asyncio.Lock() self.last_long = {} - self._http_lck = asyncio.Lock() + self._http_lck = PriorityLock() self._notif_lck = asyncio.Lock() self.cnd = asyncio.Condition() self.online = True @@ -245,6 +248,12 @@ class MegaD: Polling ports """ self.lg.debug('poll') + for x in self.i2c_sensors: + if not isinstance(x, dict): + continue + ret = await self._update_i2c(x) + if isinstance(ret, dict): + self.values.update(ret) for x in self.extenders: ret = await self._update_extender(x) if not isinstance(ret, dict): @@ -281,13 +290,13 @@ class MegaD: async def send_command(self, port=None, cmd=None): return await self.request(pt=port, cmd=cmd) - async def request(self, **kwargs): + async def request(self, priority=0, **kwargs): cmd = '&'.join([f'{k}={v}' for k, v in kwargs.items() if v is not None]) url = f"http://{self.host}/{self.sec}" if cmd: url = f"{url}/?{cmd}" self.lg.debug('request: %s', url) - async with self._http_lck: + async with self._http_lck(priority): 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()) @@ -514,12 +523,24 @@ class MegaD: ret[f'{port}e{i}'] = x return ret + async def _update_i2c(self, params): + """ + Обновление портов i2c + :param params: параметры url + :return: + """ + _params = tuple(params.items()) + return { + _params: await self.request(**params) + } + async def get_config(self, nports=37): ret = defaultdict(lambda: defaultdict(list)) ret['mqtt_id'] = await self.get_mqtt_id() ret['extenders'] = extenders = [] ret['ext_in'] = ext_int = {} ret['ext_acts'] = ext_acts = {} + ret['i2c_sensors'] = i2c_sensors = [] async for port, cfg in self.scan_ports(nports): if cfg.pty == "0": ret['binary_sensor'][port].append({}) @@ -559,6 +580,16 @@ class MegaD: values = values.split(';') for n in range(len(values)): ret['light'][f'{port}e{n}'].append({'dimmer': True, 'dimmer_scale': 16}) + elif cfg.pty == '4' and cfg.gr == '0': + # i2c в режиме ANY + scan = cfg.src.find('a', text='I2C Scan') + self.lg.debug(f'find scan link: %s', scan) + if scan is not None: + page = await self.request(pt=port, cmd='scan') + req, parsed = parse_scan_page(page) + self.lg.debug(f'scan results: %s', (req, parsed)) + ret['i2c'][port].append(parsed) + i2c_sensors.extend(req) elif cfg.pty in ('3', '2', '4'): http_cmd = 'get' if cfg.d == '5' and cfg.pty == '3': diff --git a/custom_components/mega/i2c.py b/custom_components/mega/i2c.py new file mode 100644 index 0000000..752dfec --- /dev/null +++ b/custom_components/mega/i2c.py @@ -0,0 +1,108 @@ +from urllib.parse import parse_qsl, urlparse +from bs4 import BeautifulSoup +from homeassistant.const import ( + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_PRESSURE, +) + + +def parse_scan_page(page: str): + ret = [] + req = [] + page = BeautifulSoup(page, features="lxml") + for x in page.find_all('a'): + params = x.get('href') + if params is None: + continue + params = dict(parse_qsl(urlparse(params).query)) + if 'i2c_dev' in params: + dev = params['i2c_dev'] + classes = i2c_classes.get(dev, []) + for i, c in enumerate(classes): + if c is Skip: + continue + elif c is Request: + req.append(params) + continue + elif isinstance(c, tuple): + suffix, c = c + elif isinstance(c, str): + suffix = c + else: + suffix = '' + if 'addr' in params: + suffix += f"_{params['addr']}" if suffix else str(params['addr']) + if suffix: + _dev = f'{dev}_{suffix}' + else: + _dev = dev + params = params.copy() + if i > 0: + params['i2c_par'] = i + ret.append({ + 'id_suffix': _dev, + 'device_class': c, + 'params': params, + }) + req.append(params) + return req, ret + + +class Skip: + pass + + +class Request: + pass + + +i2c_classes = { + 'htu21d': [ + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + ], + 'sht31': [ + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + ], + 'max44009': [ + DEVICE_CLASS_ILLUMINANCE + ], + 'bh1750': [ + DEVICE_CLASS_ILLUMINANCE + ], + 'tsl2591': [ + DEVICE_CLASS_ILLUMINANCE + ], + 'bmp180': [ + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + ], + 'bmx280': [ + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_HUMIDITY + ], + 'mlx90614': [ + Skip, + ('temp', DEVICE_CLASS_TEMPERATURE), + ('object', DEVICE_CLASS_TEMPERATURE), + ], + 'ptsensor': [ + Request, # запрос на измерение + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + ], + 'mcp9600': [ + DEVICE_CLASS_TEMPERATURE, # термопара + DEVICE_CLASS_TEMPERATURE, # сенсор встроенный в микросхему + ], + 't67xx': [ + None # для co2 нет класса в HA + ], + 'tmp117': [ + DEVICE_CLASS_TEMPERATURE, + ] +} diff --git a/custom_components/mega/manifest.json b/custom_components/mega/manifest.json index e33c76b..2e58e32 100644 --- a/custom_components/mega/manifest.json +++ b/custom_components/mega/manifest.json @@ -15,5 +15,5 @@ "@andvikt" ], "issue_tracker": "https://github.com/andvikt/mega_hacs/issues", - "version": "v0.5.1b2" + "version": "v0.6.1b1" } \ No newline at end of file diff --git a/custom_components/mega/sensor.py b/custom_components/mega/sensor.py index a25e9df..613dc25 100644 --- a/custom_components/mega/sensor.py +++ b/custom_components/mega/sensor.py @@ -14,6 +14,7 @@ from homeassistant.const import ( CONF_UNIQUE_ID, CONF_ID, CONF_TYPE, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, + CONF_DEVICE_CLASS, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.template import Template @@ -82,21 +83,40 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, asyn mid = config_entry.data[CONF_ID] hub: MegaD = hass.data['mega'][mid] devices = [] - for port, cfg in config_entry.data.get('sensor', {}).items(): - port = int_ignore(port) - for data in cfg: - hub.lg.debug(f'add sensor on port %s with data %s', port, data) - sensor = Mega1WSensor( - mega=hub, - port=port, - config_entry=config_entry, - **data, - ) - devices.append(sensor) + for tp in ['sensor', 'i2c']: + for port, cfg in config_entry.data.get(tp, {}).items(): + port = int_ignore(port) + for data in cfg: + hub.lg.debug(f'add sensor on port %s with data %s', port, data) + sensor = _constructors[tp]( + mega=hub, + port=port, + config_entry=config_entry, + **data, + ) + devices.append(sensor) async_add_devices(devices) +class MegaI2C(MegaPushEntity): + + def __init__(self, *args, device_class: str, params: dict, **kwargs): + self._device_class = device_class + self._params = tuple(params.items()) + super().__init__(*args, **kwargs) + + def device_class(self): + return self._device_class + + def state(self): + return self.mega.values[self._params] + + @property + def device_class(self): + return self._device_class + + class Mega1WSensor(MegaPushEntity): def __init__( @@ -141,7 +161,15 @@ class Mega1WSensor(MegaPushEntity): @property def device_class(self): - return self._device_class + _u = self.customize.get(CONF_DEVICE_CLASS, None) + if _u is None: + return self._device_class + elif isinstance(_u, str): + return _u + elif isinstance(_u, dict) and self.key in _u: + return _u[self.key] + else: + return self._device_class @property def state(self): @@ -177,4 +205,10 @@ class Mega1WSensor(MegaPushEntity): c = self.customize.get(CONF_NAME, {}) if isinstance(c, dict): c = c.get(self.key) - return c or n \ No newline at end of file + return c or n + + +_constructors = { + 'sensor': Mega1WSensor, + 'i2c': MegaI2C, +} \ No newline at end of file diff --git a/custom_components/mega/tools.py b/custom_components/mega/tools.py index 43973ea..bdc7e2a 100644 --- a/custom_components/mega/tools.py +++ b/custom_components/mega/tools.py @@ -1,3 +1,9 @@ +import asyncio +import itertools +from heapq import heappush +from contextlib import asynccontextmanager + + _params = ['m', 'click', 'cnt', 'pt'] @@ -17,4 +23,95 @@ def int_ignore(x): try: return int(x) except (TypeError, ValueError): - return x \ No newline at end of file + return x + + +class PriorityLock(asyncio.Lock): + """ + You can acquire lock with some kind of priority in mind, so that locks with higher priority will be released first. + priority can be set with lck.acquire(1) + or by using context manager: + >>> lck = PriorityLock() + ... async with lck(1): + ... # do something + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._cnt = itertools.count() + + def __call__(self, priority=0): + return self._with_priority(priority) + + @asynccontextmanager + async def _with_priority(self, p): + await self.acquire(p) + try: + yield + finally: + self.release() + + async def acquire(self, priority=0) -> bool: + """Acquire a lock. + + This method blocks until the lock is unlocked, then sets it to + locked and returns True. + """ + if (not self._locked and (self._waiters is None or + all(w.cancelled() for _, w in self._waiters))): + self._locked = True + return True + + if self._waiters is None: + self._waiters = [] + + fut = self._loop.create_future() + cnt = next(self._cnt) + heappush(self._waiters, (priority, cnt, fut)) + + # Finally block should be called before the CancelledError + # handling as we don't want CancelledError to call + # _wake_up_first() and attempt to wake up itself. + try: + try: + await fut + finally: + self._waiters.remove((priority, cnt, fut)) + except asyncio.exceptions.CancelledError: + if not self._locked: + self._wake_up_first() + raise + + self._locked = True + return True + + def release(self): + """Release a lock. + + When the lock is locked, reset it to unlocked, and return. + If any other coroutines are blocked waiting for the lock to become + unlocked, allow exactly one of them to proceed. + + When invoked on an unlocked lock, a RuntimeError is raised. + + There is no return value. + """ + if self._locked: + self._locked = False + self._wake_up_first() + else: + raise RuntimeError('Lock is not acquired.') + + def _wake_up_first(self): + """Wake up the first waiter if it isn't done.""" + if not self._waiters: + return + try: + _, _, fut = self._waiters[0] + except IndexError: + return + + # .done() necessarily means that a waiter will wake up later on and + # either take the lock, or, if it was cancelled and lock wasn't + # taken already, will hit this again and wake up a new waiter. + if not fut.done(): + fut.set_result(True) diff --git a/readme.md b/readme.md index 89d85f1..644a073 100644 --- a/readme.md +++ b/readme.md @@ -30,6 +30,8 @@ выполнении предыдущей. - поддержка [ds2413](https://www.ab-log.ru/smart-house/ethernet/megad-2w) (начиная с версии 0.4.1) - поддержка MCP23008/MCP23017/PCA9685 (начиная с версии 0.5.1) +- поддержка всех возможных датчиков в режиме I2C-ANY, полный список поддерживаемых датчиков датчиков + [по ссылке](https://github.com/andvikt/mega_hacs/wiki/ш2с) ## Установка Рекомендованный способ с поддержкой обновлений - [HACS](https://hacs.xyz/docs/installation/installation):