diff --git a/.github/workflows/label.yml b/.github/workflows/label.yml new file mode 100644 index 0000000..4613569 --- /dev/null +++ b/.github/workflows/label.yml @@ -0,0 +1,22 @@ +# This workflow will triage pull requests and apply a label based on the +# paths that are modified in the pull request. +# +# To use this workflow, you will need to set up a .github/labeler.yml +# file with configuration. For more information, see: +# https://github.com/actions/labeler + +name: Labeler +on: [pull_request_target] + +jobs: + label: + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + + steps: + - uses: actions/labeler@v4 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index b4cdf3c..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: GitHub Action Tag Release - -on: - push: - branches: - - main - -jobs: - setup-build-deploy: - name: Setup, Build, and Deploy - runs-on: ubuntu-latest - permissions: - packages: write - contents: write - id-token: write - - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Get the commit message - id: get_commit_message - run: echo "COMMIT_MSG=$(git log -1 --pretty=%B)" >> $GITHUB_ENV - - - name: Bump version and push tag - id: tag_version - uses: mathieudutour/github-tag-action@v6.1 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - - - name: Create a GitHub release - uses: ncipollo/release-action@v1 - with: - tag: ${{ steps.tag_version.outputs.new_tag }} - name: Release ${{ steps.tag_version.outputs.new_tag }} - body: ${{ steps.tag_version.outputs.changelog }} - prerelease: ${{ contains(steps.get_commit_message.outputs.COMMIT_MSG, 'beta') }} \ No newline at end of file diff --git a/README.md b/README.md index a92d34b..231ef2a 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,9 @@ BESTIN 월패드 1.0/2.0 사용자들을 위한 통합 1. **기기 및 서비스** 메뉴에서 **통합구성요소 추가하기**를 클릭합니다. 2. **브랜드 이름 검색** 탭에 `BESTIN`을 입력하고 검색 결과에서 클릭합니다. 3. 아래 설명에 따라 설정을 진행합니다: + - **설정 전에 로컬 통신 스마트홈 연동 둘 다 진행하는 경우 로컬 통신 설정 이후에 스마트홈 + 연동을 진행해 주세요.** + - 스마트홈 연동을 먼저 하는 경우 엔트리가 잘못 설정되어 제대로 기기가 생성되지 않습니다. 버그로 추정되며 수정전까지 위 방법으로 설정해 주세요. #### 1. 로컬 통신 설정 - **IP 주소** 입력: @@ -42,20 +45,21 @@ BESTIN 월패드 1.0/2.0 사용자들을 위한 통합 - 통신기 `EW11 or USB to 485` 설치 (자세한 내용은 [여기](./guide/install.md) 참조) - 정상적으로 연결되었는지 확인하려면 시리얼 포트몬 프로그램을 통해 시리얼 데이터 확인. BESTIN 월패드의 경우 02로 시작하며 [예시 데이터](./guide/packet_dump.txt) 참조 + - 디밍 세대의 경우 [해당](./guide/dimming_packet_dump.txt) 데이터 참조 ## 기능 ![추가된 기기](./images/added_devices.png) -| 기기 | 지원 | 속성 | -| --------- | ----- | -------------------------- | -| 콘센트 | O | 실시간 사용량, 대기전력자동차단 | -| 도어락 | O | | -| 엘리베이터 | O | 2.0의 경우 지원 | -| HEMS | O | 실시간, 총합 사용량 | -| 환기 | O | 프리셋 (자연풍) | -| 가스 | O | | -| 조명 | O | 디밍 조명 (지원 X) | -| 난방 | O | | +| 기기 | 지원 | 속성 | +|-----------|------|-------------------------------| +| 콘센트 | O | 실시간 사용량, 대기전력자동차단 | +| 조명 | O | 디밍, 색온도 | +| 엘리베이터 | O | 2.0의 경우 지원 | +| HEMS | O | 실시간, 총합 사용량 | +| 환기 | O | 프리셋 (자연풍) | +| 가스 | O | | +| 도어락 | O | | +| 난방 | O | | - 추가 기기나 속성이 필요하면 이슈 탭에 등록해 주세요. - 1.2.0 버전 이후부터 IPARK 스마트홈 연동을 지원합니다. diff --git a/custom_components/bestin/__init__.py b/custom_components/bestin/__init__.py index 5be74f8..390a23f 100644 --- a/custom_components/bestin/__init__.py +++ b/custom_components/bestin/__init__.py @@ -18,9 +18,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: LOGGER.debug(f"entry_data: {entry.data}, unique_id: {entry.unique_id}") if "version" not in entry.data: + # Serial connect if not await hub.connect(): - LOGGER.debug(f"Hub connection failed: {hub.hub_id}") - await hub.shutdown() + LOGGER.warning(f"Hub connection failed: {hub.hub_id}") + await hub.async_close() + hass.data[DOMAIN].pop(entry.entry_id) return False @@ -29,7 +31,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: else: await hub.async_initialize_center() - entry.async_on_unload(hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, hub.shutdown)) + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, hub.shutdown) + ) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/custom_components/bestin/api.py b/custom_components/bestin/center.py similarity index 64% rename from custom_components/bestin/api.py rename to custom_components/bestin/center.py index 87eddad..0a5991a 100644 --- a/custom_components/bestin/api.py +++ b/custom_components/bestin/center.py @@ -1,18 +1,13 @@ -import os import hashlib import base64 import asyncio -import aiofiles import xmltodict import xml.etree.ElementTree as ET -import ssl import aiohttp import json -#import requests -#import sseclient from datetime import datetime, timedelta -from typing import Any, Callable, Optional +from typing import Any, Callable from homeassistant.config_entries import ConfigEntry from homeassistant.components.climate import HVACMode @@ -27,37 +22,39 @@ from homeassistant.core import HomeAssistant, callback from .const import ( - DOMAIN, LOGGER, + BRAND_PREFIX, DEFAULT_SCAN_INTERVAL, + VERSION_1, + VERSION_2, SPEED_STR_LOW, SPEED_STR_MEDIUM, SPEED_STR_HIGH, - DEVICE_CTR_TYPE_MAP, - DEVICE_CTR_PLATFORM_MAP, - Device, + MAIN_DEVICES, + CTR_SIGNAL_MAP, + CTR_DOMAIN_MAP, + DeviceProfile, DeviceInfo, ) +V2_SESSION_HEADER = { + "Content-Type": "application/json", + "User-Agent": "mozilla/5.0 (windows nt 10.0; win64; x64) applewebkit/537.36 (khtml, like gecko) chrome/78.0.3904.70 safari/537.36", +} -class BestinAPIv2: +class CenterAPIv2: """Bestin HDC Smarthome API v2 Class.""" - def __init__(self, hass, entry) -> None: + def __init__(self, hass, entry) -> None: """API initialization.""" - self.elevator_count = entry.data.get("elevator_count", 1) self.elevator_arrived = False + self.elevator_number = entry.data.get("elevator_number", 1) self.features_list: list = [] self.elevator_data: dict = {} - def setup_elevators(self): - for count in range(1, self.elevator_count + 1): - self.setup_device("elevator", 1, str(count), False) - self.setup_device("elevator", 1, f"floor_{str(count)}", "대기 층") - self.setup_device("elevator", 1, f"direction_{str(count)}", "대기") - - async def _v2_device_status(self, args=None): + async def _version2_device_status(self, args=None): + """Updates the version 2 device status asynchronously.""" if args is not None: LOGGER.debug(f"Task execution started with argument: {args}") self.last_update_time = args @@ -67,14 +64,15 @@ async def _v2_device_status(self, args=None): else: await self.fetch_feature_list() + for i in range(1, self.elevator_number + 1): + self._elevator_registration(str(i)) + async def _v2_refresh_session(self) -> None: """Refresh session for version 2.""" url = "https://center.hdc-smart.com/v3/auth/login" - headers = { - "Content-Type": "application/json", - "Authorization": self.entry.data[CONF_UUID], - "User-Agent": "mozilla/5.0 (windows nt 10.0; win64; x64) applewebkit/537.36 (khtml, like gecko) chrome/78.0.3904.70 safari/537.36" - } + headers = V2_SESSION_HEADER.copy() + headers["Authorization"] = self.entry.data[CONF_UUID] + try: async with self.session.post(url=url, headers=headers, timeout=5) as response: response_data = await response.json() @@ -89,7 +87,6 @@ async def _v2_refresh_session(self) -> None: LOGGER.debug(f"Session refreshed: {response_data}") self.hass.config_entries.async_update_entry( entry=self.entry, - title=self.entry.data[self.version_identifier], data={**self.entry.data, self.version: response_data}, ) await asyncio.sleep(1) @@ -99,12 +96,12 @@ async def _v2_refresh_session(self) -> None: async def elevator_call_request(self) -> None: """Elevator call request.""" url = f"{self.entry.data[self.version]['url']}/v2/admin/elevators/home/apply" - headers = { - "Content-Type": "application/json", - "Authorization": self.entry.data[CONF_UUID], - "User-Agent": "mozilla/5.0 (windows nt 10.0; win64; x64) applewebkit/537.36 (khtml, like gecko) chrome/78.0.3904.70 safari/537.36" + headers = V2_SESSION_HEADER.copy() + headers["Authorization"] = self.entry.data[CONF_UUID] + data = { + "address": self.entry.data[CONF_IP_ADDRESS], + "direction": "down" } - data = {"address": self.entry.data[CONF_IP_ADDRESS], "direction": "down"} try: async with self.session.post(url=url, headers=headers, json=data) as response: @@ -114,8 +111,6 @@ async def elevator_call_request(self) -> None: if response.status == 200 and result_status == "ok": LOGGER.info(f"Just a central server elevator request successful") - for count in range(1, self.elevator_count + 1): - self.setup_device("elevator", 1, str(count), False) self.hass.create_task(self.fetch_elevator_status()) else: LOGGER.error(f"Only central server elevator request failed: {response_data}") @@ -143,7 +138,7 @@ async def fetch_elevator_status(self) -> None: except Exception as ex: LOGGER.error(f"Fetch elevator status error occurred: {ex}") - async def handle_message_info(self, message) -> None: + async def handle_message_info(self, message: str) -> None: """Handle message info for elevator status monitoring.""" data = json.loads(message) @@ -153,35 +148,38 @@ async def handle_message_info(self, message) -> None: self.elevator_data = {serial: move_info} self.elevator_data = dict(sorted(self.elevator_data.items())) - LOGGER.debug(f"Elevator data: {self.elevator_data}") + LOGGER.debug("Elevator data: %s", self.elevator_data) + if len(self.elevator_data) >= 2: for idx, (serial, info) in enumerate(self.elevator_data.items(), start=1): - floor = info["move_info"]["Floor"] + floor = f"{info["move_info"]["Floor"]} 층" move_dir = info["move_info"]["MoveDir"] - self.setup_device("elevator", 1, f"floor_{str(idx)}", floor) - self.setup_device("elevator", 1, f"direction_{str(idx)}", move_dir) + self.set_device("elevator", 1, f"floor_{str(idx)}", floor) + self.set_device("elevator", 1, f"direction_{str(idx)}", move_dir) else: - self.setup_device("elevator", 1, f"floor_1", move_info["Floor"]) - self.setup_device("elevator", 1, f"direction_1", move_info["MoveDir"]) + self.set_device("elevator", 1, f"floor_1", move_info["Floor"]) + self.set_device("elevator", 1, f"direction_1", move_info["MoveDir"]) else: - for count in range(1, self.elevator_count + 1): - self.setup_device("elevator", 1, f"floor_{str(count)}", "도착 층") - self.setup_device("elevator", 1, f"direction_{str(count)}", "도착") + for idx in range(1, self.elevator_number + 1): + self.set_device("elevator", 1, f"floor_{str(idx)}", "도착 층") + self.set_device("elevator", 1, f"direction_{str(idx)}", "도착") + self.elevator_arrived = True - async def request_feature_command(self, device_type: str, room_id: int, unit: str, value: str) -> None: + async def request_feature_command(self, device_type: str, room_id: int, unit: str, value: str | dict) -> None: """Request feature command.""" url = f"{self.entry.data[self.version]['url']}/v2/api/features/{device_type}/{room_id}/apply" - headers = { - "Content-Type": "application/json", - "Authorization": self.entry.data[CONF_UUID], - "User-Agent": "mozilla/5.0 (windows nt 10.0; win64; x64) applewebkit/537.36 (khtml, like gecko) chrome/78.0.3904.70 safari/537.36" - } + headers = V2_SESSION_HEADER.copy() + headers["access-token"] = self.entry.data[self.version]["access-token"] data = {"unit": unit, "state": value} + if device_type == "ventil": data.update({"unit": unit[:-1], "mode": "", "unit_mode": ""}) - + if device_type == "smartlight": + data.update({"unit": unit[-1], **value}) + LOGGER.debug(data) + try: async with self.session.put(url=url, headers=headers, json=data) as response: response.raise_for_status() @@ -198,13 +196,11 @@ async def request_feature_command(self, device_type: str, room_id: int, unit: st except Exception as ex: LOGGER.error(f"Error setting {device_type} in room {room_id}: {ex}") - async def fetch_feature_status(self, feature_name: str, room_id: str) -> None: + async def fetch_feature_status(self, feature_name: str, room_id: int) -> None: """Fetch feature status.""" url = f"{self.entry.data[self.version]['url']}/v2/api/features/{feature_name}/{room_id}/apply" - headers = { - "User-Agent": "mozilla/5.0 (windows nt 10.0; win64; x64) applewebkit/537.36 (khtml, like gecko) chrome/78.0.3904.70 safari/537.36", - "access-token": self.entry.data[self.version]["access-token"] - } + headers = V2_SESSION_HEADER.copy() + headers["access-token"] = self.entry.data[self.version]["access-token"] try: async with self.session.get(url=url, headers=headers) as response: @@ -213,18 +209,39 @@ async def fetch_feature_status(self, feature_name: str, room_id: str) -> None: result_status = self.result_after_request(response_data) if response.status == 200 and result_status == "ok": - for unit in response_data["units"]: - unit_last = unit["unit"][-1] - unit_state = unit["state"] - if feature_name in ["light", "livinglight", "gas", "doorlock"]: - self._parse_common_status(feature_name, room_id, unit_last, unit_state) + LOGGER.debug(f"Fetched feature status: {response_data}") + if feature_name == "smartlight": + units = [ + unit for map in response_data["map"] + if map["units"] is not None + for unit in map["units"] + ] + else: + units = response_data["units"] + + for unit in units: + if feature_name == "smartlight": + unit_last = unit["unit"] + unit_state = { + "is_on": True if unit["state"] == "on" else False, + "brightness": int(unit["dimming"]) * 10 if unit["dimming"] != "null" else None, + "color_temp": int(unit["color"]) * 10 if unit["color"] != "null" else None, + } + else: + unit_last = unit["unit"][-1] + unit_state = unit["state"] + + if feature_name in ["light", "smartlight", "livinglight", "gas", "doorlock"]: + self._parse_common_status( + feature_name, room_id, unit_last, unit_state + ) if hasattr(self, name := f"_parse_{feature_name}_status"): getattr(self, name)(room_id, unit_last, unit_state) else: LOGGER.error(f"Failed to get {feature_name} status: {response_data}") except Exception as ex: LOGGER.error(f"Error getting {feature_name} status: {ex}") - + async def process_features(self, features: list) -> None: """Process features.""" for feature in features: @@ -248,10 +265,8 @@ async def process_features(self, features: list) -> None: async def fetch_feature_list(self) -> None: """Fetch feature list.""" url = f"{self.entry.data[self.version]['url']}/v2/api/features/apply" - headers = { - "User-Agent": "Mozilla/5.0", - "access-token": self.entry.data[self.version]["access-token"] - } + headers = V2_SESSION_HEADER.copy() + headers["access-token"] = self.entry.data[self.version]["access-token"] try: async with self.session.get(url=url, headers=headers) as response: @@ -269,42 +284,36 @@ async def fetch_feature_list(self) -> None: LOGGER.error(f"Error fetching feature list: {ex}") -class BestinAPI(BestinAPIv2): +class BestinCenterAPI(CenterAPIv2): """Bestin HDC Smarthome API Class.""" def __init__( self, hass: HomeAssistant, entry: ConfigEntry, - entities: dict, - hub_id: str, - version: str, - version_identifier: str, - elevator_registration: bool, - async_add_device: Callable + entity_groups, + hub_id, + version, + version_identifier, + elevator_registration, + add_device_callback: Callable ) -> None: - """API initialization.""" + """Initialize API and setup session.""" super().__init__(hass, entry) self.hass = hass self.entry = entry - self.entities = entities + self.entity_groups = entity_groups self.hub_id = hub_id self.version = version self.version_identifier = version_identifier self.elevator_registration = elevator_registration - self.async_add_device = async_add_device - - ssl_context = ssl.create_default_context() - ssl_context.check_hostname = False - ssl_context.verify_mode = ssl.CERT_NONE + self.add_device_callback = add_device_callback - connector = aiohttp.TCPConnector(ssl=ssl_context) + connector = aiohttp.TCPConnector() self.session = aiohttp.ClientSession(connector=connector) - + self.tasks: list = [] self.remove_callbacks: list = [] - self.stop_event = asyncio.Event() - self.devices: dict = {} self.last_update_time = datetime.now() @@ -314,37 +323,32 @@ def get_short_hash(self, id: str) -> str: return base64.urlsafe_b64encode(hash_object)[:8].decode("utf-8").upper() async def start(self) -> None: - """Start main loop with asyncio.""" - self.stop_event.clear() + """Start main loop for status updates and session refresh.""" self.tasks.append(asyncio.create_task(self.schedule_session_refresh())) await asyncio.sleep(1) - - if self.elevator_registration: - LOGGER.debug("Setting up elevator") - self.setup_elevators() - v_key = getattr(self, f"_v{self.version[7:8]}_device_status") + + version_func = getattr(self, f"_{self.version[:-2]}_device_status") scan_interval = self.entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) interval = timedelta(minutes=scan_interval) - self.hass.create_task(v_key()) + self.hass.create_task(version_func()) self.remove_callbacks.append( - async_track_time_interval(self.hass, v_key, interval) + async_track_time_interval(self.hass, version_func, interval) ) async def stop(self) -> None: """Stop main loop and cancel all tasks.""" - self.stop_event.set() for task in self.tasks: task.cancel() for callback in self.remove_callbacks: callback() - async def _v1_device_status(self, args=None): - """Updates the v1 device status asynchronously.""" + async def _version1_device_status(self, args=None): + """Update device status for version 1 asynchronously.""" if args is not None: LOGGER.debug(f"Task execution started with argument: {args}") self.last_update_time = args - + await asyncio.gather( *[self.get_light_status(i) for i in range(6)], *[self.get_electric_status(i) for i in range(1, 6)], @@ -359,19 +363,15 @@ async def _v1_refresh_session(self) -> None: url = f"http://{self.entry.data[CONF_IP_ADDRESS]}/webapp/data/getLoginWebApp.php" params = { "login_ide": self.entry.data[CONF_USERNAME], - "login_pwd": self.entry.data[CONF_PASSWORD], + "login_pwd": self.entry.data[CONF_PASSWORD] } try: async with self.session.get(url=url, params=params, timeout=5) as response: response_data = await response.json(content_type="text/html") - - if response.status != 200: - LOGGER.error(f"Login failed: {response.status} {response_data}") - return - if "_fair" in response_data: - LOGGER.error(f"Session refresh failed: {response_data['msg']}") + if response.status != 200 or "_fair" in response_data: + LOGGER.error(f"Session refresh failed: {response_data.get('msg', 'Unknown error')}") return - + cookies = response.cookies new_cookie = { "PHPSESSID": cookies.get("PHPSESSID").value if cookies.get("PHPSESSID") else None, @@ -381,7 +381,6 @@ async def _v1_refresh_session(self) -> None: LOGGER.debug(f"Session refreshed: {response_data}, Cookie: {new_cookie}") self.hass.config_entries.async_update_entry( entry=self.entry, - title=self.entry.data[self.version_identifier], data={**self.entry.data, self.version: new_cookie}, ) await asyncio.sleep(1) @@ -389,9 +388,11 @@ async def _v1_refresh_session(self) -> None: LOGGER.error(f"Exception during session refresh: {type(ex).__name__}: {ex}") @callback - async def on_command(self, unique_id: str, value: Any, **kwargs: Optional[dict]): - """Handle commands to the devices.""" - parts = unique_id.split("-")[0].split("_") + async def enqueue_command( + self, device_id: str, value: Any, **kwargs: dict | None + ) -> None: + """Handle commands to devices based on type and room.""" + parts = device_id.split("_") device_type = parts[1] room_id = int(parts[2]) pos_id = 0 @@ -408,100 +409,79 @@ async def on_command(self, unique_id: str, value: Any, **kwargs: Optional[dict]) if kwargs: sub_type, value = next(iter(kwargs.items())) - #LOGGER.debug( - # "parsed values - device_type: %s, room_id: %s, pos_id: %s, sub_type: %s, value: %s", - # device_type, room_id, pos_id, sub_type, value - #) - if self.version == "version1.0": - LOGGER.warning(f"For version 1, we don't support the command yet. If you can help, please register the issue") + if self.version == VERSION_1: + pass else: if device_type == "elevator": await self.elevator_call_request() else: unit_id = f"{sub_type}{pos_id or room_id}" if kwargs else f"{device_type}1" - #LOGGER.debug(f"Created unit_id: {unit_id}") await self.request_feature_command(device_type, room_id, unit_id, value) - def convert_unique_id(self, unique_id: str) -> tuple[str, Optional[str]]: - """Convert device_id, sub_id from unique_id.""" - parts = unique_id.split("-")[0].split("_") - if len(parts) > 3: - unit_id = "_".join(parts[3:]) - device_id = "_".join(parts[1:3]) - else: - unit_id = None - device_id = "_".join(parts[1:3]) - - return device_id, unit_id - - def get_devices_from_domain(self, domain: str) -> list[dict]: + def get_devices_from_domain(self, domain: str) -> list: """Retrieve devices associated with a specific domain.""" - entity_list = self.entities.get(domain, []) + entity_list = self.entity_groups.get(domain, set()) return [self.devices.get(uid, {}) for uid in entity_list] - def initialize_device(self, device_id: str, unit_id: Optional[str], state: Any) -> dict: - """Initialize devices using a unique_id derived from device_id and unit_id.""" + def initial_device(self, device_id: str, sub_id: str | None, state: Any) -> dict: + """Initialize a device and add it to the devices list.""" device_type, device_room = device_id.split("_") - - base_unique_id = f"bestin_{device_id}" - unique_id = f"{base_unique_id}_{unit_id}" if unit_id else base_unique_id - full_unique_id = f"{unique_id}-{self.get_short_hash(self.hub_id)}" - - if device_type != "energy" and unit_id and not unit_id.isdigit(): - letter_unit_id = ''.join(filter(str.isalpha, unit_id)) - device_type = f"{device_type}:{letter_unit_id}" - - platform = DEVICE_CTR_PLATFORM_MAP.get(device_type) - if not platform: - raise ValueError(f"Unsupported platform type for device: {device_type}") + + did_suffix = f"_{sub_id}" if sub_id else "" + device_id = f"{BRAND_PREFIX}_{device_id}{did_suffix}" + if sub_id: + sub_id_parts = sub_id.split("_") + device_name = f"{device_type} {device_room} {' '.join(sub_id_parts)}".title() + else: + device_name = f"{device_type} {device_room}".title() - if full_unique_id not in self.devices: + if device_type not in MAIN_DEVICES: + uid_suffix = f"-{self.get_short_hash(self.hub_id)}" + else: + uid_suffix = "" + unique_id = f"{device_id}{uid_suffix}" + + if device_id not in self.devices: device_info = DeviceInfo( - unique_id=full_unique_id, device_type=device_type, - name=unique_id, + name=device_name, room=device_room, state=state, - sub_type=unit_id or "", - colon_id=device_type if ":" in device_type else "" + device_id=device_id, ) - device = Device( + self.devices[device_id] = DeviceProfile( + enqueue_command=self.enqueue_command, + domain=CTR_DOMAIN_MAP[device_type], + unique_id=unique_id, info=device_info, - domain=platform, - on_command=self.on_command, - callbacks=set() ) - self.devices[full_unique_id] = device - - return self.devices[full_unique_id] + return self.devices[device_id] - def setup_device( - self, device_type: str, device_number: int, unit_id: Optional[str], status: Any + def set_device( + self, device_type: str, device_number: int, unit_id: str | None, status: Any ) -> None: - """Set up device with specified state.""" - #LOGGER.debug( - # f"Setting up {device_type} device number {device_number}, unit {unit_id}, status {status}" - #) - - if device_type not in DEVICE_CTR_TYPE_MAP: + """Set up device with specified state.""" + if device_type not in CTR_DOMAIN_MAP: raise ValueError(f"Unsupported device type: {device_type}") - device_id = f"{device_type}_{device_number}" - device = self.initialize_device(device_id, unit_id, status) + device = self.initial_device(device_id, unit_id, status) - if device_type != "energy" and unit_id and not unit_id.isdigit(): + if unit_id and not unit_id.isdigit(): letter_unit_id = ''.join(filter(str.isalpha, unit_id)) - device_key = f"{device_type}:{letter_unit_id}" - _device_type = DEVICE_CTR_TYPE_MAP[device_key] + letter_device_type = f"{device_type}:{letter_unit_id}" + domain = CTR_DOMAIN_MAP[letter_device_type] else: - _device_type = DEVICE_CTR_TYPE_MAP[device_type] - self.async_add_device(_device_type, device) + domain = CTR_DOMAIN_MAP[device_type] + signal = CTR_SIGNAL_MAP[domain] + + device_uid = device.unique_id + device_info = device.info + if device_uid not in self.entity_groups.get(domain): + self.add_device_callback(signal, device) - if device.info.state != status: - device.info.state = status - for callback in device.callbacks: - assert callable(callback), "Callback should be callable" - callback() + if device_info.state != status: + device_info.state = status + device.update_callbacks() def parse_xml_response(self, response: str) -> str: """Parse XML response.""" @@ -523,7 +503,7 @@ def result_after_request(self, response: str | dict) -> str: async def _v1_fetch_status( self, url: str, params: dict, device_type: str, device_number: int ) -> None: - """fetch device status for version 1.""" + """Fetch device status for version 1.""" cookies = self.entry.data[self.version] try: async with self.session.get(url=url, cookies=cookies, params=params) as response: @@ -538,58 +518,66 @@ async def _v1_fetch_status( except ET.ParseError as e: LOGGER.error(f"XML parsing error for {device_type}: {e}") return - + status_infos = root.findall(".//status_info") if not status_infos: LOGGER.warning(f"No status info found for {device_type}") return - unit_statuses = [ (info.attrib["unit_num"], info.attrib["unit_status"]) for info in status_infos ] for unit_num, unit_status in unit_statuses: - if device_type in ["light", "livinglight" "gas", "doorlock"]: - self._parse_common_status(device_type, device_number, unit_num[-1], unit_status) + if device_type in ["light", "livinglight", "gas", "doorlock"]: + self._parse_common_status( + device_type, device_number, unit_num[-1], unit_status + ) if hasattr(self, name := f"_parse_{device_type}_status"): getattr(self, name)(device_number, unit_num[-1], unit_status) except Exception as ex: LOGGER.error(f"Error getting status for {device_type}: {ex}") + def _elevator_registration(self, id: str) -> None: + """Register an elevator with the given ID.""" + self.set_device("elevator", 1, id, False) + self.set_device("elevator", 1, f"floor_{id}", "대기 층") + self.set_device("elevator", 1, f"direction_{id}", "대기") + def _parse_common_status( - self, device_type: str, device_number: int, unit_num: str, unit_status: str + self, device_type: str, device_number: int, unit_num: str, unit_status: str | dict ) -> None: - """Parse common status for devices with on/off state.""" - status_value = unit_status in ["on", "open"] - self.setup_device(device_type, device_number, unit_num, status_value) + if isinstance(unit_status, dict): + status_value = unit_status + else: + status_value = unit_status in ["on", "open"] + self.set_device(device_type, device_number, unit_num, status_value) def _parse_electric_status( self, device_number: int, unit_num: str, unit_status: str ) -> None: - """Parse the unit status for the electric.""" status_parts = unit_status.split("/") for status_key in status_parts: is_cutoff = status_key in ["set", "unset"] conv_unit_num = f"cutoff_{unit_num}" if is_cutoff else unit_num status_value = status_key in ["set", "on"] - self.setup_device("electric", device_number, conv_unit_num, status_value) + self.set_device("electric", device_number, conv_unit_num, status_value) def _parse_thermostat_status( self, device_number: int, unit_num: str, unit_status: str ) -> None: - """Parse the unit status for the thermostat.""" + """Parse thermostat status.""" status_parts = unit_status.split("/") status_value = { "hvac_mode": HVACMode.HEAT if status_parts[0] == "on" else HVACMode.OFF, "target_temperature": float(status_parts[1]), "current_temperature": float(status_parts[2]) } - self.setup_device("thermostat", unit_num, None, status_value) + self.set_device("thermostat", unit_num, None, status_value) def _parse_ventil_status( self, device_number: int, unit_num: str, unit_status: str ) -> None: - """Parse the unit status for the fan.""" + """Parse fan status.""" is_off = unit_status == "off" speed_list = [SPEED_STR_LOW, SPEED_STR_MEDIUM, SPEED_STR_HIGH] status_value = { @@ -598,18 +586,22 @@ def _parse_ventil_status( "speed_list": speed_list, "preset_mode": None, } - self.setup_device("ventil", device_number, None, status_value) + self.set_device("ventil", device_number, None, status_value) async def get_light_status(self, device_number: int) -> None: """Get light/livinglight status.""" params = { - "req_name": "remote_access_light" if device_number != 0 else "remote_access_livinglight", + "req_name": "remote_access_light" + if device_number != 0 else "remote_access_livinglight", "req_action": "status" } if device_number != 0: params["req_dev_num"] = device_number + url = f"http://{self.entry.data[CONF_IP_ADDRESS]}/mobilehome/data/getHomeDevice.php" - await self._v1_fetch_status(url, params, params["req_name"].split("_")[2], device_number) + await self._v1_fetch_status( + url, params, params["req_name"].split("_")[2], device_number + ) async def get_electric_status(self, device_number: int) -> None: """Get electric status.""" @@ -622,7 +614,7 @@ async def get_electric_status(self, device_number: int) -> None: await self._v1_fetch_status(url, params, "electric", device_number) async def get_temper_status(self, device_number: int) -> None: - """Get temper status.""" + """Get temperature status.""" params = { "req_name": "remote_access_temper", "req_action": "status", @@ -631,38 +623,38 @@ async def get_temper_status(self, device_number: int) -> None: url = f"http://{self.entry.data[CONF_IP_ADDRESS]}/mobilehome/data/getHomeDevice.php" await self._v1_fetch_status(url, params, "temper", device_number) - async def get_gas_status(self, device_number: int=1) -> None: + async def get_gas_status(self, device_number: int = 1) -> None: """Get gas status.""" params = { "req_name": "remote_access_gas", - "req_action": "status", + "req_action": "status" } url = f"http://{self.entry.data[CONF_IP_ADDRESS]}/mobilehome/data/getHomeDevice.php" await self._v1_fetch_status(url, params, "gas", device_number) - async def get_ventil_status(self, device_number: int=1) -> None: + async def get_ventil_status(self, device_number: int = 1) -> None: """Get fan status.""" params = { "req_name": "remote_access_ventil", - "req_action": "status", + "req_action": "status" } url = f"http://{self.entry.data[CONF_IP_ADDRESS]}/mobilehome/data/getHomeDevice.php" await self._v1_fetch_status(url, params, "ventil", device_number) - async def get_doorlock_status(self, device_number: int=1) -> None: - """Get doorlock status.""" + async def get_doorlock_status(self, device_number: int = 1) -> None: + """Get door lock status.""" params = { "req_name": "remote_access_doorlock", - "req_action": "status", + "req_action": "status" } url = f"http://{self.entry.data[CONF_IP_ADDRESS]}/mobilehome/data/getHomeDevice.php" await self._v1_fetch_status(url, params, "doorlock", device_number) async def schedule_session_refresh(self) -> None: - """Schedule for periodic session refresh.""" + """Schedule periodic session refresh.""" while True: - if self.version == "version1.0": + if self.version == VERSION_1: await self._v1_refresh_session() else: await self._v2_refresh_session() - await asyncio.sleep(60 * 60) + await asyncio.sleep(60 * 55) diff --git a/custom_components/bestin/climate.py b/custom_components/bestin/climate.py index e117ffb..c9b0835 100644 --- a/custom_components/bestin/climate.py +++ b/custom_components/bestin/climate.py @@ -2,7 +2,7 @@ from __future__ import annotations -from homeassistant.components.climate import DOMAIN, ClimateEntity +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN, ClimateEntity from homeassistant.components.climate.const import ( ClimateEntityFeature, HVACMode, @@ -15,7 +15,7 @@ from .const import NEW_CLIMATE from .device import BestinDevice -from .hub import load_hub +from .hub import BestinHub async def async_setup_entry( @@ -24,24 +24,24 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> bool: """Setup climate platform.""" - hub = load_hub(hass, entry) - hub.entities[DOMAIN] = set() + hub: BestinHub = BestinHub.get_hub(hass, entry) + hub.entity_groups[CLIMATE_DOMAIN] = set() @callback def async_add_climate(devices=None): if devices is None: - devices = hub.api.get_devices_from_domain(DOMAIN) + devices = hub.api.get_devices_from_domain(CLIMATE_DOMAIN) entities = [ BestinClimate(device, hub) for device in devices - if device.info.unique_id not in hub.entities[DOMAIN] + if device.unique_id not in hub.entity_groups[CLIMATE_DOMAIN] ] if entities: async_add_entities(entities) - hub.listeners.append( + entry.async_on_unload( async_dispatcher_connect( hass, hub.async_signal_new_device(NEW_CLIMATE), async_add_climate ) @@ -51,11 +51,11 @@ def async_add_climate(devices=None): class BestinClimate(BestinDevice, ClimateEntity): """Defined the Climate.""" - TYPE = DOMAIN + TYPE = CLIMATE_DOMAIN _enable_turn_on_off_backwards_compatibility = False - def __init__(self, device, hub): + def __init__(self, device, hub: BestinHub): """Initialize the climate.""" super().__init__(device, hub) self._supported_features = ( @@ -77,7 +77,7 @@ def hvac_mode(self) -> HVACMode: Need to be one of HVAC_MODE_*. """ - return self._device.info.state["hvac_mode"] + return self._device_info.state["hvac_mode"] @property def hvac_modes(self) -> list[HVACMode]: @@ -97,10 +97,10 @@ async def async_set_hvac_mode(self, hvac_mode: str) -> None: if self._version_exists: is_on = "on" if hvac_mode == HVACMode.HEAT else "off" - await self._on_command(room=f"{is_on}/{self.target_temperature}") + await self.enqueue_command(room=f"{is_on}/{self.target_temperature}") else: is_on = True if hvac_mode == HVACMode.HEAT else False - await self._on_command(mode=is_on) + await self.enqueue_command(mode=is_on) @property def preset_mode(self): @@ -122,12 +122,12 @@ def hvac_action(self): @property def current_temperature(self) -> float: """Return the current temperature.""" - return self._device.info.state["current_temperature"] + return self._device_info.state["current_temperature"] @property def target_temperature(self) -> float: """Return the target temperature.""" - return self._device.info.state["target_temperature"] + return self._device_info.state["target_temperature"] async def async_set_temperature(self, **kwargs) -> None: """Set new target temperature.""" @@ -137,9 +137,9 @@ async def async_set_temperature(self, **kwargs) -> None: temperature = float(kwargs[ATTR_TEMPERATURE]) if self._version_exists: - await self._on_command(room=f"on/{temperature}/{self.current_temperature}") + await self.enqueue_command(room=f"on/{temperature}/{self.current_temperature}") else: - await self._on_command(set_temperature=temperature) + await self.enqueue_command(set_temperature=temperature) @property def temperature_unit(self) -> UnitOfTemperature: diff --git a/custom_components/bestin/config_flow.py b/custom_components/bestin/config_flow.py index 21dff65..d73f08a 100644 --- a/custom_components/bestin/config_flow.py +++ b/custom_components/bestin/config_flow.py @@ -31,7 +31,6 @@ DOMAIN, LOGGER, DEFAULT_PORT, - DEFAULT_ELEVATOR_COUNT, DEFAULT_SCAN_INTERVAL, DEFAULT_MAX_TRANSMISSION, DEFAULT_PACKET_VIEWER, @@ -190,7 +189,7 @@ async def async_step_center_v1( session = async_create_clientsession(self.hass) response, error_message = await self._v1_server_login(session) - + if error_message: errors["base"] = error_message[0] description_placeholders = {"err": error_message[1]} @@ -237,8 +236,8 @@ async def async_step_center_v2( return self.async_create_entry(title=user_input[self.config_identifier], data=self.data) data_schema = vol.Schema({ + vol.Required("elevator_number", default=1): ConfigFlow.int_between(1, 3), vol.Optional(CONF_IP_ADDRESS): cv.string, - vol.Required("elevator_count", default=DEFAULT_ELEVATOR_COUNT): ConfigFlow.int_between(1, 3), vol.Required(CONF_UUID): cv.string, }) diff --git a/custom_components/bestin/const.py b/custom_components/bestin/const.py index dd8ac51..287a0f7 100644 --- a/custom_components/bestin/const.py +++ b/custom_components/bestin/const.py @@ -1,6 +1,6 @@ import logging -from typing import Callable, Any, Optional, Set +from typing import Callable, Any, Set from dataclasses import dataclass, field from homeassistant.components.sensor import SensorDeviceClass @@ -14,7 +14,7 @@ DOMAIN = "bestin" NAME = "BESTIN" -VERSION = "1.1.1" +VERSION = "1.3.3" PLATFORMS: list[Platform] = [ Platform.CLIMATE, @@ -27,12 +27,16 @@ LOGGER: logging.Logger = logging.getLogger(__package__) DEFAULT_PORT: int = 8899 -DEFAULT_ELEVATOR_COUNT: int = 1 -DEFAULT_SCAN_INTERVAL: int = 15 +DEFAULT_SCAN_INTERVAL: int = 30 DEFAULT_MAX_TRANSMISSION: int = 10 DEFAULT_PACKET_VIEWER: bool = False +BRAND_PREFIX = "bestin" + +VERSION_1 = "version1.0" +VERSION_2 = "version2.0" + NEW_CLIMATE = "climates" NEW_FAN = "fans" NEW_LIGHT = "lights" @@ -49,57 +53,48 @@ "elevator", ] -DEVICE_TYPE_MAP: dict[str] = { - "thermostat": NEW_CLIMATE, - "fan": NEW_FAN, - "light": NEW_LIGHT, - "outlet:consumption": NEW_SENSOR, - "energy": NEW_SENSOR, - "outlet": NEW_SWITCH, - "outlet:cutoff": NEW_SWITCH, - "gas": NEW_SWITCH, - "doorlock": NEW_SWITCH, +SIGNAL_MAP: dict[str, Platform] = { + Platform.CLIMATE: NEW_CLIMATE, + Platform.FAN: NEW_FAN, + Platform.LIGHT: NEW_LIGHT, + Platform.SENSOR: NEW_SENSOR, + Platform.SWITCH: NEW_SWITCH, } -DEVICE_PLATFORM_MAP: dict[str, Platform] = { - "thermostat": Platform.CLIMATE, - "fan": Platform.FAN, - "light": Platform.LIGHT, - "outlet:consumption": Platform.SENSOR, - "energy": Platform.SENSOR, - "outlet": Platform.SWITCH, - "outlet:cutoff": Platform.SWITCH, - "gas": Platform.SWITCH, - "doorlock": Platform.SWITCH, +DOMAIN_MAP: dict[str, Platform] = { + "thermostat": Platform.CLIMATE.value, + "fan": Platform.FAN.value, + "light": Platform.LIGHT.value, + "outlet:consumption": Platform.SENSOR.value, + "energy": Platform.SENSOR.value, + "outlet": Platform.SWITCH.value, + "outlet:cutoff": Platform.SWITCH.value, + "gas": Platform.SWITCH.value, + "doorlock": Platform.SWITCH.value, } # Center -DEVICE_CTR_TYPE_MAP: dict[str] = { - "temper": NEW_CLIMATE, - "thermostat": NEW_CLIMATE, - "ventil": NEW_FAN, - "light": NEW_LIGHT, - "livinglight": NEW_LIGHT, - "elevator:direction": NEW_SENSOR, - "elevator:floor": NEW_SENSOR, - "electric": NEW_SWITCH, - "electric:cutoff": NEW_SWITCH, - "gas": NEW_SWITCH, - "elevator": NEW_SWITCH, +CTR_SIGNAL_MAP: dict[Platform, str] = { + Platform.CLIMATE: NEW_CLIMATE, + Platform.FAN: NEW_FAN, + Platform.LIGHT: NEW_LIGHT, + Platform.SENSOR: NEW_SENSOR, + Platform.SWITCH: NEW_SWITCH, } -DEVICE_CTR_PLATFORM_MAP: dict[str, Platform] = { - "temper": Platform.CLIMATE, - "thermostat": Platform.CLIMATE, - "ventil": Platform.FAN, - "light": Platform.LIGHT, - "livinglight": Platform.LIGHT, - "elevator:direction": Platform.SENSOR, - "elevator:floor": Platform.SENSOR, - "electric": Platform.SWITCH, - "electric:cutoff": Platform.SWITCH, - "gas": Platform.SWITCH, - "elevator": Platform.SWITCH, +CTR_DOMAIN_MAP: dict[str, Platform] = { + "temper": Platform.CLIMATE.value, + "thermostat": Platform.CLIMATE.value, + "ventil": Platform.FAN.value, + "light": Platform.LIGHT.value, + "smartlight": Platform.LIGHT.value, + "livinglight": Platform.LIGHT.value, + "elevator:direction": Platform.SENSOR.value, + "elevator:floor": Platform.SENSOR.value, + "electric": Platform.SWITCH.value, + "electric:cutoff": Platform.SWITCH.value, + "gas": Platform.SWITCH.value, + "elevator": Platform.SWITCH.value, } # Fan (Ventil) @@ -175,27 +170,32 @@ @dataclass class DeviceInfo: - """Represents the basic information of a device.""" - unique_id: str + """Set device information.""" device_type: str name: str room: str state: Any - colon_id: Optional[str] = None - sub_type: Optional[str] = None + device_id: str @dataclass -class Device: - """Represents a device with callbacks and update functionalities.""" - info: DeviceInfo +class DeviceProfile: + """Set the device profile.""" + enqueue_command: Callable[..., None] domain: str - on_command: Callable - callbacks: Set[Callable] = field(default_factory=set) - - def add_callback(self, callback: Callable): - """Add a callback to the set of callbacks.""" + unique_id: str + info: DeviceInfo + callbacks: Set[Callable[..., None]] = field(default_factory=set) + + def add_callback(self, callback: Callable[..., None]) -> None: + """Add a callback..""" self.callbacks.add(callback) - def remove_callback(self, callback: Callable): - """Remove a callback from the set of callbacks, if it exists.""" + def remove_callback(self, callback: Callable[..., None]) -> None: + """Remove the callback.""" self.callbacks.discard(callback) + + def update_callbacks(self) -> None: + """Updates the registered callback.""" + for callback in self.callbacks: + assert callable(callback), "Callback should be callable" + callback() diff --git a/custom_components/bestin/controller.py b/custom_components/bestin/controller.py index d690df2..7b3d584 100644 --- a/custom_components/bestin/controller.py +++ b/custom_components/bestin/controller.py @@ -1,26 +1,25 @@ -import re import asyncio -import traceback -from typing import Any, Optional, Callable +from typing import Any, Callable from homeassistant.components.climate import HVACMode from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from .const import ( - DOMAIN, LOGGER, DEFAULT_MAX_TRANSMISSION, DEFAULT_PACKET_VIEWER, + BRAND_PREFIX, PRESET_NATURAL_VENTILATION, PRESET_NONE, ELEMENT_BYTE_RANGE, - DEVICE_TYPE_MAP, - DEVICE_PLATFORM_MAP, + MAIN_DEVICES, + SIGNAL_MAP, + DOMAIN_MAP, SPEED_INT_LOW, SPEED_INT_MEDIUM, SPEED_INT_HIGH, - Device, + DeviceProfile, DeviceInfo, ) @@ -59,17 +58,17 @@ def __init__( self, hass: HomeAssistant, entry: ConfigEntry, - entities: dict, - hub_id: str, - communicator, - async_add_device: Callable, + entity_groups, + hub_id, + connection, + add_device_callback: Callable, ) -> None: self.hass = hass self.entry = entry - self.entities = entities + self.entity_groups = entity_groups self.hub_id = hub_id - self.communicator = communicator - self.async_add_device = async_add_device + self.connection = connection + self.add_device_callback = add_device_callback self.gateway_type: str = entry.data["gateway_mode"][0] self.room_to_command: dict[bytes] = entry.data["gateway_mode"][1] @@ -80,30 +79,29 @@ def __init__( async def start(self) -> None: """Start main loop with asyncio.""" - self.stop_event.clear() - await asyncio.sleep(1) self.tasks.append(asyncio.create_task(self.process_incoming_data())) + self.tasks.append(asyncio.create_task(self.process_queue_data())) + await asyncio.sleep(1) async def stop(self) -> None: """Stop main loop and cancel all tasks.""" - self.stop_event.set() for task in self.tasks: task.cancel() @property def is_alive(self) -> bool: - """Check if the communicator is alive""" - return self.communicator.is_connected() + """Check if the connection is alive""" + return self.connection.is_connected() async def receive_data(self) -> bytes: - """Receive data from communicator.""" + """Receive data from connection.""" if self.is_alive: - return await self.communicator.receive() + return await self.connection.receive() async def send_data(self, packet: bytearray) -> None: - """Send packet data to the communicator.""" + """Send packet data to the connection.""" if self.is_alive: - await self.communicator.send(packet) + await self.connection.send(packet) def calculate_checksum(self, packet: bytearray) -> int: """Compute checksum from packet data.""" @@ -124,21 +122,9 @@ def verify_checksum(self, packet: bytes) -> bool: checksum = (checksum + 1) & 0xFF return checksum == packet[-1] - def convert_unique_id(self, unique_id: str) -> tuple[str, Optional[str]]: - """Convert device_id, sub_id from unique_id.""" - parts = unique_id.split("-")[0].split("_") - if len(parts) > 3: - sub_id = "_".join(parts[3:]) - device_id = "_".join(parts[1:3]) - else: - sub_id = None - device_id = "_".join(parts[1:3]) - - return device_id, sub_id - def get_devices_from_domain(self, domain: str) -> list[dict]: """Retrieve devices associated with a specific domain.""" - entity_list = self.entities.get(domain, []) + entity_list = self.entity_groups.get(domain, []) return [self.devices.get(uid, {}) for uid in entity_list] def make_light_packet( @@ -255,13 +241,14 @@ def make_fan_packet( return packet @callback - async def on_command(self, unique_id: str, value: Any, **kwargs: Optional[dict]): - """Queue a command for the device identified by unique_id.""" - parts = unique_id.split("-")[0].split("_") + async def enqueue_command(self, device_id: str, value: Any, **kwargs: dict | None): + """Queue a command for the device identified by device_id.""" + parts = device_id.split("_") device_type = parts[1] room_id = int(parts[2]) pos_id = 0 sub_type = None + timestamp = 0 if kwargs: sub_type, value = next(iter(kwargs.items())) @@ -273,79 +260,95 @@ async def on_command(self, unique_id: str, value: Any, **kwargs: Optional[dict]) pos_id = int(parts[4]) sub_type = parts[3] + def coomand_func(): + packet_func = getattr(self, f"make_{device_type}_packet", None) + if packet_func is None: + LOGGER.error(f"Unknown 'make_{device_type}_packet' method") + return None + + nonlocal timestamp + if device_type in ["gas", "fan", "doorlock"]: + timestamp = self.timestamp2 + else: + timestamp = self.timestamp + timestamp += 1 + return packet_func(timestamp, room_id, pos_id, sub_type, value) + queue_task = { "transmission": 1, - "timestamp": self.timestamp, + "timestamp": timestamp, "device_type": device_type, "room_id": room_id, "pos_id": pos_id, "sub_type": sub_type, "value": value, - "command": getattr(self, f"make_{device_type}_packet"), + "command": coomand_func, "response": None, } LOGGER.debug(f"Create queue task: {queue_task}") await self.queue.put(queue_task) - def initialize_device(self, device_id: str, sub_id: Optional[str], state: Any) -> dict: - """Initialize devices using a unique_id derived from device_id and sub_id.""" + def initial_device(self, device_id: str, sub_id: str | None, state: Any) -> dict: + """Initialize a device and add it to the devices list.""" device_type, device_room = device_id.split("_") - - base_unique_id = f"bestin_{device_id}" - unique_id = f"{base_unique_id}_{sub_id}" if sub_id else base_unique_id - full_unique_id = f"{unique_id}-{self.hub_id}" - - if device_type != "energy" and sub_id and not sub_id.isdigit(): - letter_sub_id = ''.join(filter(str.isalpha, sub_id)) - device_type = f"{device_type}:{letter_sub_id}" - - platform = DEVICE_PLATFORM_MAP.get(device_type) - if not platform: - raise ValueError(f"Unsupported platform type for device: {device_type}") + + did_suffix = f"_{sub_id}" if sub_id else "" + device_id = f"{BRAND_PREFIX}_{device_id}{did_suffix}" + if sub_id: + sub_id_parts = sub_id.split("_") + device_name = f"{device_type} {device_room} {' '.join(sub_id_parts)}".title() + else: + device_name = f"{device_type} {device_room}".title() + + if device_type not in MAIN_DEVICES: + uid_suffix = f"-{self.hub_id}" + else: + uid_suffix = "" + unique_id = f"{device_id}{uid_suffix}" - if full_unique_id not in self.devices: + if device_id not in self.devices: device_info = DeviceInfo( - unique_id=full_unique_id, device_type=device_type, - name=unique_id, + name=device_name, room=device_room, state=state, - sub_type=sub_id or "", - colon_id=device_type if ":" in device_type else "" + device_id=device_id, ) - device = Device( + self.devices[device_id] = DeviceProfile( + enqueue_command=self.enqueue_command, + domain=DOMAIN_MAP[device_type], + unique_id=unique_id, info=device_info, - domain=platform, - on_command=self.on_command, - callbacks=set() ) - self.devices[full_unique_id] = device - - return self.devices[full_unique_id] + return self.devices[device_id] - def setup_device(self, device_id: str, state: Any, is_sub=False) -> None: - """Set up device with specified state.""" - device_type = device_id.split("_")[0] - if device_type not in DEVICE_TYPE_MAP: + def set_device(self, device_id: str, state: Any, is_sub: bool = False) -> None: + """Set up device with specified state.""" + device_type, device_room = device_id.split("_") + + if device_type not in DOMAIN_MAP: raise ValueError(f"Unsupported device type: {device_type}") - final_states = state.items() if is_sub else [(None, state)] - for sub_id, sub_state in final_states: - device = self.initialize_device(device_id, sub_id, sub_state) + sub_states = state.items() if is_sub else [(None, state)] + for sub_id, sub_state in sub_states: + device = self.initial_device(device_id, sub_id, sub_state) if device_type != "energy" and sub_id and not sub_id.isdigit(): letter_sub_id = ''.join(filter(str.isalpha, sub_id)) - device_key = f"{device_type}:{letter_sub_id}" - _device_type = DEVICE_TYPE_MAP[device_key] + letter_device_type = f"{device_type}:{letter_sub_id}" + domain = DOMAIN_MAP[letter_device_type] else: - _device_type = DEVICE_TYPE_MAP[device_type] - self.async_add_device(_device_type, device) - - if device.info.state != sub_state: - device.info.state = sub_state - for callback in device.callbacks: - assert callable(callback), "Callback should be callable" - callback() + domain = DOMAIN_MAP[device_type] + signal = SIGNAL_MAP[domain] + + device_uid = device.unique_id + device_info = device.info + if device_uid not in self.entity_groups.get(domain, set()): + self.add_device_callback(signal, device) + + if device_info.state != sub_state: + device_info.state = sub_state + device.update_callbacks() def make_common_packet( self, @@ -412,9 +415,9 @@ def parse_state_General(self, packet: bytearray) -> tuple[int, dict]: state_general = {"light": {}, "outlet": {}} room_id = packet[5] & 0x0F if room_id == 1: - iterations = 4, 3 + iterations = (4, 3) else: - iterations = 2, 2 + iterations = (2, 2) for i in range(iterations[0]): light_state = bool(packet[6] & (0x01 << i)) @@ -439,6 +442,45 @@ def parse_state_General(self, packet: bytearray) -> tuple[int, dict]: return room_id, state_general + def parse_state_Gen2(self, packet: bytearray) -> tuple[int, dict]: + """Energy state Gen2-gateway parse from packet data.""" + state_gen2 = {"light": {}, "outlet": {}} + + room_id = packet[1] & 0x0F + if room_id % 2 == 0: + lcnt, ocnt = packet[10] & 0x0F, packet[11] + base_cnt = ocnt + else: + lcnt, ocnt = packet[10], packet[11] + base_cnt = lcnt + + lsidx = 18 + osidx = lsidx + (base_cnt * 13) + + for i in range(lcnt): + light_state = { + "is_on": packet[lsidx] == 0x01, + "brightness": packet[lsidx + 1], + "color_temp": packet[lsidx + 2], + } + state_gen2["light"][str(i)] = light_state + lsidx += 13 + + for i in range(ocnt): + idx = osidx + 8 + idx2 = osidx + 10 + + outlet_state = bool(packet[osidx] & 0x01) + outlet_cutoff = bool(packet[osidx] & 0x10) + outlet_consumption = int.from_bytes(packet[idx:idx2], byteorder="big") / 10 + + state_gen2["outlet"][str(i)] = outlet_state + state_gen2["outlet"][f"cutoff_{str(i)}"] = outlet_cutoff + state_gen2["outlet"][f"consumption_{str(i)}"] = outlet_consumption + osidx += 14 + + return room_id, state_gen2 + def parse_state_AIO(self, packet: bytearray) -> tuple[int, dict]: """Energy state AIO(all-in-one)-gateway parse from packet data.""" state_aio = {"light": {}, "outlet": {}} @@ -466,7 +508,7 @@ def parse_energy(self, packet: bytearray) -> dict: """Energy parse from packet data.""" index = 13 energy_state = {} - element_offset = 1 if self.gateway_type == "AIO" else 0 + element_offset = 1 if self.gateway_type == "AIO" or len(packet) == 34 else 0 if element_offset == 1: elements = ["electric", "water", "gas"] @@ -486,9 +528,7 @@ def check_command_response_packet(self, packet: bytes, queue: dict) -> None: """Check the response packet after the command.""" try: general_gateway = self.gateway_type == "General" - command = queue["command"]( - queue["timestamp"], queue["room_id"], queue["pos_id"], queue["sub_type"], queue["value"] - ) + command = queue["command"]() header_byte = command[1] offset = 2 if general_gateway and len(command) == 10 else 3 @@ -505,51 +545,53 @@ def check_command_response_packet(self, packet: bytes, queue: dict) -> None: async def send_packet_queue(self, queue: dict) -> None: """Sends queued command packet data.""" try: - queue["timestamp"] += 1 - command = queue["command"]( - queue["timestamp"], queue["room_id"], queue["pos_id"], queue["sub_type"], queue["value"] - ) - + if (command := queue["command"]) is None: + return + LOGGER.info( "Sending %s command for %s device. Command Packet: %s, attempts: %d", - queue["value"], queue["device_type"], command.hex(), queue["transmission"] + queue["value"], queue["device_type"], command().hex(), queue["transmission"] ) queue["transmission"] += 1 - await self.send_data(command) + await self.send_data(command()) except Exception as e: LOGGER.error("Error in send_packet_queue: %s", e) def handle_device_packet(self, packet: bytes) -> None: """Parse and process an incoming device packet.""" - header = packet[1] packet_len = len(packet) + header = packet[1] + command = packet[2] if packet_len == 10 else packet[3] room_id = device_state = device_id = None - self.timestamp = 0x00 - - if packet_len == 10: - command = packet[2] - #self.timestamp = packet[3] - else: - command = packet[3] - #self.timestamp = packet[4] - - if packet_len != 10 and command in [0x81, 0x82, 0x91, 0x92, 0xB2]: + self.timestamp = self.timestamp2 = 0 + + if packet_len >= 20 or packet_len in [7, 8]: + # energy + self.timestamp = packet[4] + elif packet_len == 10: + # control + self.timestamp2 = packet[3] + #LOGGER.debug(f"timestamp: {self.timestamp}, timestamp2: {self.timestamp2}") + + if packet_len != 10 and command in [0x81, 0x82, 0x91, 0x92, 0xB2]: # response if header == 0x28: room_id, device_state = self.parse_thermostat(packet) device_id = f"thermostat_{room_id}" - self.setup_device(device_id, device_state) - elif header == 0x31 or packet_len in [20, 22]: + self.set_device(device_id, device_state) + elif ((self.gateway_type == "General" and packet_len == 30) + or (self.gateway_type == "AIO" and packet_len in [20, 22]) + or (self.gateway_type == "Gen2" and packet_len in [59, 72, 98]) + ): room_id, device_state = getattr(self, f"parse_state_{self.gateway_type}")(packet) for device, state in device_state.items(): device_id = f"{device}_{room_id}" - self.setup_device(device_id, state, is_sub=True) + self.set_device(device_id, state, is_sub=True) elif header == 0xD1: device_state = self.parse_energy(packet) for room_id, state in device_state.items(): device_id = f"energy_{room_id}" - self.setup_device(device_id, state, is_sub=True) - - elif packet_len == 10 and command != 0x00: + self.set_device(device_id, state, is_sub=True) + elif packet_len == 10 and command != 0x00: # response parser_mapping = { 0x31: (self.parse_gas, "gas"), 0x41: (self.parse_doorlock, "doorlock"), @@ -559,8 +601,11 @@ def handle_device_packet(self, packet: bytes) -> None: parse_func, device_type = parser_mapping[header] room_id, device_state = parse_func(packet) device_id = f"{device_type}_{room_id}" - self.setup_device(device_id, device_state) - + self.set_device(device_id, device_state) + elif command not in [0x00, 0x11, 0x21, 0xA1]: # query + pass + #LOGGER.warning(f"Unknown device packet: {packet.hex()}") + async def handle_packet_queue(self, queue: dict) -> None: """Processes the queued command packet data.""" try: @@ -584,20 +629,35 @@ async def handle_packet_queue(self, queue: dict) -> None: async def process_incoming_data(self) -> None: """Handles incoming data, processes it if valid, and manages a task queue.""" - try: - while True: - if self.is_alive: - received_data = await self.receive_data() - else: - received_data = None + while True: + if not self.is_alive: + await asyncio.sleep(0.1) + continue + + try: + received_data = await self.receive_data() if received_data and self.verify_checksum(received_data): if self.entry.options.get("packet_viewer", DEFAULT_PACKET_VIEWER): - LOGGER.debug("Received data: %s", received_data) + LOGGER.debug( + "Packet viewer: %s", + ' '.join(f'{byte:02X}' for byte in received_data) + ) self.handle_device_packet(received_data) - + if await self.queue.size() > 0: queue_item = await self.queue.get() - await self.handle_packet_queue(queue_item) self.check_command_response_packet(received_data, queue_item) - except Exception as e: - LOGGER.error("Error in process_incoming_data: %s", e) + except Exception as e: + LOGGER.error(f"Error in process_incoming_data: {e}") + + async def process_queue_data(self) -> None: + """Processes items in the task queue.""" + while True: + try: + if await self.queue.size() > 0: + queue_item = await self.queue.get() + await self.handle_packet_queue(queue_item) + else: + await asyncio.sleep(0.1) + except Exception as e: + LOGGER.error(f"Error in process_queue_data: {e}") diff --git a/custom_components/bestin/device.py b/custom_components/bestin/device.py index 9ce53e5..0a47a66 100644 --- a/custom_components/bestin/device.py +++ b/custom_components/bestin/device.py @@ -8,6 +8,7 @@ from homeassistant.core import callback from .const import DOMAIN, MAIN_DEVICES +from .until import formatted_name class BestinBase: @@ -16,46 +17,40 @@ class BestinBase: def __init__(self, device, hub): """Set up device and update callbacks.""" self._device = device + self._device_info = device.info self.hub = hub + + async def enqueue_command(self, data: Any = None, **kwargs): + """Send commands to the device.""" + await self._device.enqueue_command( + self._device_info.device_id, data, **kwargs + ) @property def unique_id(self) -> str: """Return a unique ID.""" - return self._device.info.unique_id - - @property - def device_type_name(self) -> str: - """Returns the formatted device type name.""" - device_type = self._device.info.device_type - return (device_type.split(":")[0].title() - if ":" in device_type else device_type.title()) + return self._device.unique_id @property def device_info(self) -> DeviceInfo: """Return device registry information for this entity.""" - if self._device.info.device_type in MAIN_DEVICES: - return DeviceInfo( - connections={(self.hub.hub_id, self.unique_id)}, - identifiers={(DOMAIN, f"{self.hub.wp_version}_{self.hub.model}")}, - manufacturer="HDC Labs Co., Ltd.", - model=self.hub.wp_version, - name=self.hub.name, - sw_version=self.hub.sw_version, - via_device=(DOMAIN, self.hub.hub_id), - ) - return DeviceInfo( - connections={(self.hub.hub_id, self.unique_id)}, - identifiers={(DOMAIN, f"{self.hub.wp_version}_{self.device_type_name}")}, - manufacturer="HDC Labs Co., Ltd.", - model=self.hub.wp_version, - name=f"{self.hub.name} {self.device_type_name}", - sw_version=self.hub.sw_version, - via_device=(DOMAIN, self.hub.hub_id), - ) - - async def _on_command(self, data: Any = None, **kwargs): - """Send commands to the device.""" - await self._device.on_command(self.unique_id, data, **kwargs) + device_info = { + "connections": {(self.hub.hub_id, self.unique_id)}, + "identifiers": {(DOMAIN, f"{self.hub.wp_version}_{self.hub.model}")}, + "manufacturer": "HDC Labs Co., Ltd.", + "model": self.hub.wp_version, + "name": self.hub.name, + "sw_version": self.hub.sw_version, + "via_device": (DOMAIN, self.hub.hub_id), + } + if (device_type := self._device_info.device_type) not in MAIN_DEVICES: + formatted_device_type = formatted_name(device_type) + device_info["identifiers"] = { + (DOMAIN, f"{self.hub.wp_version}_{formatted_device_type}") + } + device_info["name"] = f"{self.hub.name} {formatted_device_type}" + + return DeviceInfo(**device_info) class BestinDevice(BestinBase, Entity): @@ -66,7 +61,9 @@ class BestinDevice(BestinBase, Entity): def __init__(self, device, hub): """Set up device and update callbacks.""" super().__init__(device, hub) - self.hub.entities[self.TYPE].add(self.unique_id) + self.hub.entity_groups[self.TYPE].add(self.unique_id) + self._attr_name = self._device_info.name + self._attr_has_entity_name = True @property def entity_registry_enabled_default(self): @@ -76,11 +73,14 @@ def entity_registry_enabled_default(self): async def async_added_to_hass(self): """Subscribe to device events.""" self._device.add_callback(self.async_update_callback) + self.hub.entity_to_id[self.entity_id] = self._device_info.device_id self.schedule_update_ha_state() async def async_will_remove_from_hass(self) -> None: """Disconnect device object when removed.""" self._device.remove_callback(self.async_update_callback) + del self.hub.entity_to_id[self.entity_id] + self.hub.entity_groups[self.TYPE].remove(self.unique_id) @callback def async_restore_last_state(self, last_state) -> None: @@ -97,11 +97,6 @@ def available(self) -> bool: """Return True if device is available.""" return self.hub.available - @property - def name(self) -> str: - """Return the name of the device.""" - return self._device.info.name - @property def should_poll(self) -> bool: """Determine if the device should be polled.""" @@ -112,9 +107,10 @@ def extra_state_attributes(self) -> dict: """Return the state attributes of the sensor.""" attributes = { "unique_id": self.unique_id, - "device_room": self._device.info.room, - "device_type": self._device.info.device_type, + "device_type": self._device_info.device_type, + "device_room": self._device_info.room, } - if self.should_poll: + if self.should_poll and not attributes["device_type"].startswith("elevator"): attributes["last_update_time"] = self.hub.api.last_update_time + return attributes diff --git a/custom_components/bestin/fan.py b/custom_components/bestin/fan.py index ed79e74..103cb8e 100644 --- a/custom_components/bestin/fan.py +++ b/custom_components/bestin/fan.py @@ -5,7 +5,7 @@ from typing import Any, Optional from homeassistant.components.fan import ( - DOMAIN, + DOMAIN as FAN_DOMAIN, FanEntity, FanEntityFeature, ) @@ -20,7 +20,7 @@ from .const import NEW_FAN, PRESET_NONE from .device import BestinDevice -from .hub import load_hub +from .hub import BestinHub async def async_setup_entry( @@ -29,24 +29,24 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> bool: """Setup fan platform.""" - hub = load_hub(hass, entry) - hub.entities[DOMAIN] = set() + hub: BestinHub = BestinHub.get_hub(hass, entry) + hub.entity_groups[FAN_DOMAIN] = set() @callback def async_add_fan(devices=None): if devices is None: - devices = hub.api.get_devices_from_domain(DOMAIN) + devices = hub.api.get_devices_from_domain(FAN_DOMAIN) entities = [ BestinFan(device, hub) for device in devices - if device.info.unique_id not in hub.entities[DOMAIN] + if device.unique_id not in hub.entity_groups[FAN_DOMAIN] ] if entities: async_add_entities(entities) - hub.listeners.append( + entry.async_on_unload( async_dispatcher_connect( hass, hub.async_signal_new_device(NEW_FAN), async_add_fan ) @@ -56,7 +56,7 @@ def async_add_fan(devices=None): class BestinFan(BestinDevice, FanEntity): """Defined the Fan.""" - TYPE = DOMAIN + TYPE = FAN_DOMAIN def __init__(self, device, hub) -> None: """Initialize the fan.""" @@ -64,8 +64,8 @@ def __init__(self, device, hub) -> None: self._supported_features = FanEntityFeature.SET_SPEED self._supported_features |= FanEntityFeature.TURN_ON self._supported_features |= FanEntityFeature.TURN_OFF - self._speed_list = device.info.state.get("speed_list") - self._preset_modes = device.info.state.get("preset_modes") + self._speed_list = self._device_info.state.get("speed_list") + self._preset_modes = self._device_info.state.get("preset_modes") self._version_exists = getattr(hub.api, "version", False) if self._preset_modes: @@ -74,7 +74,7 @@ def __init__(self, device, hub) -> None: @property def is_on(self) -> bool: """Return true if fan is on.""" - return self._device.info.state["is_on"] + return self._device_info.state["is_on"] @property def supported_features(self) -> FanEntityFeature: @@ -84,7 +84,7 @@ def supported_features(self) -> FanEntityFeature: @property def percentage(self) -> Optional[int]: """Return the current speed percentage.""" - speed = self._device.info.state["speed"] + speed = self._device_info.state["speed"] if speed == "off": return 0 return ordered_list_item_to_percentage(self._speed_list, speed) @@ -97,15 +97,15 @@ def speed_count(self) -> int: async def async_set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" if percentage == 0: - await self._on_command("off" if self._version_exists else False) + await self.enqueue_command("off" if self._version_exists else False) else: speed = percentage_to_ordered_list_item(self._speed_list, percentage) - await self._on_command(speed=speed) + await self.enqueue_command(speed=speed) @property def preset_mode(self) -> str: """Return the preset mode.""" - return self._device.info.state["preset_mode"] + return self._device_info.state["preset_mode"] @property def preset_modes(self) -> list: @@ -114,7 +114,7 @@ def preset_modes(self) -> list: async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" - await self._on_command( + await self.enqueue_command( preset=False if preset_mode == PRESET_NONE else preset_mode ) @@ -126,8 +126,8 @@ async def async_turn_on( **kwargs: Any, ) -> None: """Turn on fan.""" - await self._on_command("low" if self._version_exists else True) + await self.enqueue_command("low" if self._version_exists else True) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off fan.""" - await self._on_command("off" if self._version_exists else False) + await self.enqueue_command("off" if self._version_exists else False) diff --git a/custom_components/bestin/hub.py b/custom_components/bestin/hub.py index f49b55a..9f7db36 100644 --- a/custom_components/bestin/hub.py +++ b/custom_components/bestin/hub.py @@ -1,22 +1,16 @@ -import os +"""Hub class.""" import re -import json import time import asyncio -import aiofiles import serial_asyncio import socket import traceback -from typing import Optional, Callable, cast +from typing import cast from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT from homeassistant.core import HomeAssistant, Event, callback -from homeassistant.helpers import ( - device_registry as dr, - entity_registry as er -) from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( @@ -29,27 +23,32 @@ NEW_LIGHT, NEW_SENSOR, NEW_SWITCH, + VERSION_2 ) -from .api import BestinAPI +from .center import BestinCenterAPI from .controller import BestinController +from .until import check_ip_or_serial -class SerialSocketCommunicator: - def __init__(self, conn_str): +class ConnectionManager: + """Manage connections to the hub.""" + + def __init__(self, conn_str: str) -> None: self.conn_str = conn_str self.is_serial = False self.is_socket = False - self.reader = None - self.writer = None - self.reconnect_attempts = 0 - self.last_reconnect_attempt = None - self.next_attempt_time = None + self.reader: asyncio.StreamReader = None + self.writer: asyncio.StreamWriter = None + self.reconnect_attempts: int = 0 + self.last_reconnect_attempt: float = None + self.next_attempt_time: float = None self.chunk_size = 64 # Serial standard self.constant_packet_length = 10 self._parse_conn_str() - def _parse_conn_str(self): + def _parse_conn_str(self) -> bool: + """Parse the connection string.""" if re.match(r"COM\d+|/dev/tty\w+", self.conn_str): self.is_serial = True elif re.match(r"\d+\.\d+\.\d+\.\d+:\d+", self.conn_str): @@ -57,28 +56,38 @@ def _parse_conn_str(self): else: raise ValueError("Invalid connection string") - async def connect(self): + async def connect(self, timeout: int = 5) -> None: + """ + Attempt to connect for each communication; + failure to connect within a timeout is considered a failure. + """ try: if self.is_serial: - await self._connect_serial() + await asyncio.wait_for(self._connect_serial(), timeout=timeout) elif self.is_socket: - await self._connect_socket() + await asyncio.wait_for(self._connect_socket(), timeout=timeout) self.reconnect_attempts = 0 LOGGER.info("Connection established successfully.") + except asyncio.TimeoutError: + LOGGER.error(f"Connection timed out.") + raise TimeoutError() except Exception as e: LOGGER.error(f"Connection failed: {e}") await self.reconnect() - async def _connect_serial(self): + async def _connect_serial(self) -> None: + """Attempt to connect a serial.""" self.reader, self.writer = await serial_asyncio.open_serial_connection(url=self.conn_str, baudrate=9600) LOGGER.info(f"Serial connection established on {self.conn_str}") - async def _connect_socket(self): + async def _connect_socket(self) -> None: + """Attempt to connect a socket.""" host, port = self.conn_str.split(":") self.reader, self.writer = await asyncio.open_connection(host, int(port)) LOGGER.info(f"Socket connection established to {host}:{port}") - def is_connected(self): + def is_connected(self) -> bool: + """Verify that the connection is maintained.""" try: if self.is_serial: return self.writer is not None and not self.writer.transport.is_closing() @@ -87,7 +96,11 @@ def is_connected(self): except Exception: return False - async def reconnect(self): + async def reconnect(self) -> bool | None: + """ + If the connection is disconnected, try to reconnect. + It is flexible so that it does not show off in the same amount of time. + """ if self.writer is not None: self.writer.close() await self.writer.wait_closed() @@ -109,16 +122,18 @@ async def reconnect(self): self.reconnect_attempts = 0 self.next_attempt_time = None - async def send(self, packet): + async def send(self, packet: bytearray) -> None: + """Send packet data.""" try: self.writer.write(packet) await self.writer.drain() - await asyncio.sleep(0.1) + await asyncio.sleep(0.12) except Exception as e: LOGGER.error(f"Failed to send packet data: {e}") await self.reconnect() - async def receive(self, size=64): + async def receive(self, size: int = 64) -> bytes | None: + """Receive data.""" try: if self.chunk_size == size: return await self._receive_socket() @@ -131,8 +146,11 @@ async def receive(self, size=64): await self.reconnect() return None - async def _receive_socket(self): + async def _receive_socket(self) -> bytes: + """Receives and processes socket data.""" + async def recv_exactly(n): + """Creates a buffer by a specific length.""" data = b'' while len(data) < n: chunk = await self.reader.read(n - len(data)) @@ -159,6 +177,7 @@ async def recv_exactly(n): if ( packet[1] not in [0x28, 0x31, 0x41, 0x42, 0x61, 0xD1] and packet[1] & 0xF0 != 0x50 # all-in-one(AIO) 0x51-0x55 + and packet[1] & 0x30 != 0x30 # gen2 0x31-0x36 ): return b'' @@ -186,7 +205,8 @@ async def recv_exactly(n): return b'' - async def close(self): + async def close(self) -> None: + """Terminate the connection.""" if self.writer: LOGGER.info("Connection closed.") self.writer.close() @@ -194,22 +214,6 @@ async def close(self): self.writer = None -@callback -def load_hub(hass: HomeAssistant, entry: ConfigEntry) -> BestinAPI | BestinController: - """Return gateway with a matching entry_id.""" - return hass.data[DOMAIN][entry.entry_id] - -def check_ip_or_serial(id: str) -> bool: - """Verify that the string is an IP address or serial device path.""" - ip_pattern = re.compile(r"^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$") - serial_pattern = re.compile(r"/dev/tty(USB|AMA)\d+") - - if ip_pattern.match(id) or serial_pattern.match(id): - return True - else: - return False - - class BestinHub: """Bestin Hub Class.""" @@ -217,11 +221,16 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Hub initialization.""" self.hass = hass self.entry = entry - self.api: BestinAPI | BestinController = None - self.communicator: SerialSocketCommunicator = None - self.gateway_mode: tuple[str, Optional[dict[bytes]]] = None - self.entities: dict[str, set[str]] = {} - self.listeners: list[Callable] = [] + self.api: BestinCenterAPI | BestinController = None + self.connection: ConnectionManager = None + self.gateway_mode: tuple[str, dict[bytes] | None] = None + self.entity_groups: dict[str, set[str]] = {} + self.entity_to_id: dict[str, str] = {} + + @staticmethod + def get_hub(hass: HomeAssistant, entry: ConfigEntry) -> BestinCenterAPI | BestinController: + """Return gateway with a matching entry_id.""" + return hass.data[DOMAIN][entry.entry_id] @property def hub_id(self) -> str: @@ -251,8 +260,8 @@ def version(self) -> str: @property def available(self) -> bool: """Return the communication connection status.""" - if self.communicator: - return self.communicator.is_connected() + if self.connection: + return self.connection.is_connected() return True @property @@ -284,13 +293,13 @@ def wp_version(self) -> str: if check_ip_or_serial(self.hub_id): return f"{self.gateway_mode[0]}-generation" else: - return f"Center-v{self.version[7:8]}" + return self.version @property def conn_str(self) -> str: """Generate the connection string based on the host and port.""" if not re.match(r"/dev/tty(USB|AMA)\d+", self.hub_id): - conn_str = f"{self.hub_id}:{self.port}" + conn_str = f"{self.hub_id}:{str(self.port)}" else: conn_str = self.hub_id return conn_str @@ -299,9 +308,10 @@ async def determine_gateway_mode(self) -> None: """The gateway mode is determined by the received data.""" chunk_storage: list = [] aio_data: dict = {} + gen2_data: dict = {} try: while len(b''.join(chunk_storage)) < 1024: - received_data = await self.communicator._receive_socket() + received_data = await self.connection._receive_socket() if not received_data: break chunk_storage.append(received_data) @@ -319,10 +329,15 @@ async def determine_gateway_mode(self) -> None: ) if (chunk_length in [20, 22] and command_byte in [0x91, 0xB2]): aio_data[room_byte] = command_byte + elif (chunk_length in [59, 72, 98] and command_byte == 0x91): + gen2_data[room_byte] = command_byte if aio_data: self.gateway_mode = ("AIO", aio_data) LOGGER.debug(f"AIO mode set with data: {aio_data}") + elif gen2_data: + self.gateway_mode = ("Gen2", gen2_data) + LOGGER.debug(f"Gen2 mode set with data: {gen2_data}") else: self.gateway_mode = ("General", None) LOGGER.debug("General mode set") @@ -350,11 +365,13 @@ def async_add_device_callback( self, device_type: str, device=None, force: bool = False ) -> None: """Add device callback if not already registered.""" - domain = device.domain.value - unique_id = device.info.unique_id + domain = device.domain + unique_id = device.unique_id + device_info = device.info - if (unique_id in self.entities.get(domain, []) - or unique_id in self.hass.data[DOMAIN]): + if (unique_id in self.entity_groups.get(domain, set()) + or device_info.device_id in self.entity_to_id + ): return args = [] @@ -368,65 +385,58 @@ def async_add_device_callback( ) async def connect(self) -> bool: - """Establish a connection to the serial socket communicator.""" - if self.communicator is None or not self.available: - self.communicator = SerialSocketCommunicator(self.conn_str) + """Establish a connection to the serial socket connection.""" + if not self.connection or not self.available: + self.connection = ConnectionManager(self.conn_str) else: - await self.communicator.close() - await self.communicator.connect() + await self.connection.close() + + await self.connection.connect() return self.available async def async_close(self) -> None: """Asynchronously close the connection and clean up.""" - LOGGER.debug( - "Starting to remove registered entities and listeners." - ) - - entity_registry = er.async_get(self.hass) - to_remove = { - f"{domain}.{unique_id.split('-')[0]}" - for domain, unique_ids in self.entities.items() - for unique_id in unique_ids - } - for entity_id in to_remove: - if entity_id in entity_registry.entities: - entity_registry.async_remove(entity_id) - LOGGER.debug("Removed entity from registry: %s", entity_id) - self.listeners.clear() - - await self.api.stop() - if self.communicator and self.available: - await self.communicator.close() + if self.api: + await self.api.stop() + if self.connection and self.available: + await self.connection.close() + if self.gateway_mode: + self.gateway_mode = None @callback async def shutdown(self, event: Event) -> None: """Handle shutdown event asynchronously.""" - await self.api.stop() - if self.communicator and self.available: - await self.communicator.close() - + if self.api: + await self.api.stop() + if self.connection and self.available: + await self.connection.close() + if self.gateway_mode: + self.gateway_mode = None + async def async_initialize_serial(self) -> None: """ Asynchronously initialize the Bestin Controller for serial communication. """ try: - await self.determine_gateway_mode() + if self.gateway_mode is None: + await self.determine_gateway_mode() self.hass.config_entries.async_update_entry( entry=self.entry, - title=self.hub_id, data={**self.entry.data, "gateway_mode": self.gateway_mode}, ) self.api = BestinController( self.hass, self.entry, - self.entities, + self.entity_groups, self.hub_id, - self.communicator, + self.connection, self.async_add_device_callback, ) - await asyncio.sleep(1) - await self.api.start() + if isinstance(self.api, BestinController): + await self.api.start() + else: + raise Exception("API is not an instance of BestinController") except Exception as ex: self.api = None raise RuntimeError( @@ -439,18 +449,20 @@ async def async_initialize_center(self) -> None: Asynchronously initialize the Bestin API for IPARK Smarthome. """ try: - self.api = BestinAPI( + self.api = BestinCenterAPI( self.hass, self.entry, - self.entities, + self.entity_groups, self.hub_id, self.version, self.identifier, - self.version == "version2.0" and self.ip_address, + self.version == VERSION_2 and self.ip_address, self.async_add_device_callback, ) - await self.api.start() - + if isinstance(self.api, BestinCenterAPI): + await self.api.start() + else: + raise Exception("API is not an instance of BestinCenterAPI") except Exception as ex: self.api = None raise RuntimeError( diff --git a/custom_components/bestin/light.py b/custom_components/bestin/light.py index e9ae2d8..8a9062d 100644 --- a/custom_components/bestin/light.py +++ b/custom_components/bestin/light.py @@ -2,19 +2,31 @@ from __future__ import annotations +import math +from typing import Optional + from homeassistant.components.light import ( ColorMode, - DOMAIN, + DOMAIN as LIGHT_DOMAIN, LightEntity, + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import callback, HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.color import value_to_brightness +from homeassistant.util.percentage import percentage_to_ranged_value from .const import NEW_LIGHT from .device import BestinDevice -from .hub import load_hub +from .hub import BestinHub + +BRIGHTNESS_SCALE = (1, 100) + +COLOR_TEMP_SCALE = (3000, 5700) # Kelvin values for the 10 steps +COLOR_TEMP_LEVELS = list(range(COLOR_TEMP_SCALE[0], COLOR_TEMP_SCALE[1]+1, 300)) async def async_setup_entry( @@ -23,24 +35,24 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> bool: """Setup light platform.""" - hub = load_hub(hass, entry) - hub.entities[DOMAIN] = set() + hub: BestinHub = BestinHub.get_hub(hass, entry) + hub.entity_groups[LIGHT_DOMAIN] = set() @callback def async_add_light(devices=None): if devices is None: - devices = hub.api.get_devices_from_domain(DOMAIN) + devices = hub.api.get_devices_from_domain(LIGHT_DOMAIN) entities = [ BestinLight(device, hub) for device in devices - if device.info.unique_id not in hub.entities[DOMAIN] + if device.unique_id not in hub.entity_groups[LIGHT_DOMAIN] ] if entities: async_add_entities(entities) - hub.listeners.append( + entry.async_on_unload( async_dispatcher_connect( hass, hub.async_signal_new_device(NEW_LIGHT), async_add_light ) @@ -49,21 +61,26 @@ def async_add_light(devices=None): class BestinLight(BestinDevice, LightEntity): - """Defined the Light.""" - TYPE = DOMAIN + """Define the Light.""" + TYPE = LIGHT_DOMAIN def __init__(self, device, hub): """Initialize the light.""" super().__init__(device, hub) + self._has_smartlight = False self._color_mode = ColorMode.ONOFF self._supported_color_modes = {ColorMode.ONOFF} + + self._max_color_temp_kelvin = COLOR_TEMP_SCALE[1] # 5700K + self._min_color_temp_kelvin = COLOR_TEMP_SCALE[0] # 3000K + self._version_exists = getattr(hub.api, "version", False) @property def color_mode(self) -> ColorMode: """Return the color mode of the light.""" return self._color_mode - + @property def supported_color_modes(self) -> set[ColorMode]: """Return the list of supported color modes.""" @@ -72,18 +89,116 @@ def supported_color_modes(self) -> set[ColorMode]: @property def is_on(self) -> bool: """Return true if switch is on.""" - return self._device.info.state + state = self._device_info.state + if isinstance(state, dict): + self._has_smartlight = True + state = self.state_parse(state) + + return state + + def state_parse(self, state: dict) -> bool: + """State parse for smartlight.""" + brightness = state["brightness"] + color_temp = state["color_temp"] + + if brightness: + self._color_mode = ColorMode.BRIGHTNESS + self._supported_color_modes = {ColorMode.BRIGHTNESS} + if brightness and color_temp: + self._color_mode = ColorMode.COLOR_TEMP + self._supported_color_modes = {ColorMode.COLOR_TEMP} + + return state["is_on"] + + def convert_brightness( + self, + brightness: int, + reverse: bool = False + ) -> int: + """Convert the brightness value.""" + if reverse: + result = (brightness - 1) * (100 - 1) / (255 - 1) + 1 + return round(result / 10) * 10 + else: + return int((brightness - 1) * (255 - 1) / (100 - 1) + 1) + + def convert_color_temp( + self, + color_temp: int, + reverse: bool = False + ) -> int: + """Convert the color temperature value.""" + if reverse: + for i, temp in enumerate(COLOR_TEMP_LEVELS): + if color_temp < temp: + return i * 10 + return 100 + else: + return COLOR_TEMP_LEVELS[(color_temp // 10) - 1] + + def set_light_command( + self, + state: str, + brightness: int | None, + kelvin: int | None + ) -> dict: + brightness_value = kelvin_value = "null" + if brightness is not None: + brightness_value = str(self.convert_brightness(brightness, reverse=True)) + if kelvin is not None: + kelvin_value = str(self.convert_color_temp(kelvin, reverse=True)) + + light_command = { + "state": state, + "dimming": brightness_value, + "color": kelvin_value, + } + return light_command + + @property + def brightness(self) -> Optional[int]: + """Return the current brightness.""" + brightness_value = self._device_info.state["brightness"] + return self.convert_brightness(brightness_value) + + @property + def color_temp_kelvin(self) -> Optional[int]: + """The current color temperature in Kelvin.""" + color_temp_step = self._device_info.state["color_temp"] + return self.convert_color_temp(color_temp_step) + + @property + def max_color_temp_kelvin(self) -> int: + """The highest supported color temperature in Kelvin.""" + return self._max_color_temp_kelvin + + @property + def min_color_temp_kelvin(self) -> int: + """The lowest supported color temperature in Kelvin.""" + return self._min_color_temp_kelvin async def async_turn_on(self, **kwargs): """Turn on light.""" if self._version_exists: - await self._on_command(switch="on") + brightness = kwargs.get(ATTR_BRIGHTNESS) + kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN) + + light_command = self.set_light_command("on", brightness, kelvin) + + switch = light_command if self._has_smartlight else "on" + await self.enqueue_command(switch=switch) else: - await self._on_command(True) + await self.enqueue_command(True) async def async_turn_off(self, **kwargs): """Turn off light.""" if self._version_exists: - await self._on_command(switch="off") + brightness = kwargs.get(ATTR_BRIGHTNESS) + kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN) + + light_command = self.set_light_command("off", brightness, kelvin) + + switch = light_command if self._has_smartlight else "off" + await self.enqueue_command(switch=switch) else: - await self._on_command(False) + await self.enqueue_command(False) diff --git a/custom_components/bestin/manifest.json b/custom_components/bestin/manifest.json index 9dc7b1f..2c70421 100644 --- a/custom_components/bestin/manifest.json +++ b/custom_components/bestin/manifest.json @@ -13,5 +13,5 @@ "aiofiles", "xmltodict" ], - "version": "1.1.1" + "version": "1.3.3" } \ No newline at end of file diff --git a/custom_components/bestin/sensor.py b/custom_components/bestin/sensor.py index 055df49..d5403e4 100644 --- a/custom_components/bestin/sensor.py +++ b/custom_components/bestin/sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations -from homeassistant.components.sensor import DOMAIN +from homeassistant.components.sensor import DOMAIN as DOMAIN_SENSOR from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -15,12 +15,12 @@ ELEMENT_UNIT, ) from .device import BestinDevice -from .hub import load_hub +from .hub import BestinHub def extract_and_transform(identifier: str) -> str: if "energy_" in identifier: - extracted_segment = identifier.split("energy_")[1].split("-")[0] + extracted_segment = identifier.split("energy_")[1] else: extracted_segment = ':'.join([identifier.split("_")[1], identifier.split("_")[3]]) @@ -34,24 +34,24 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> bool: """Setup sensor platform.""" - hub = load_hub(hass, entry) - hub.entities[DOMAIN] = set() + hub: BestinHub = BestinHub.get_hub(hass, entry) + hub.entity_groups[DOMAIN_SENSOR] = set() @callback def async_add_sensor(devices=None): if devices is None: - devices = hub.api.get_devices_from_domain(DOMAIN) + devices = hub.api.get_devices_from_domain(DOMAIN_SENSOR) entities = [ BestinSensor(device, hub) for device in devices - if device.info.unique_id not in hub.entities[DOMAIN] + if device.unique_id not in hub.entity_groups[DOMAIN_SENSOR] ] if entities: async_add_entities(entities) - hub.listeners.append( + entry.async_on_unload( async_dispatcher_connect( hass, hub.async_signal_new_device(NEW_SENSOR), async_add_sensor ) @@ -61,14 +61,14 @@ def async_add_sensor(devices=None): class BestinSensor(BestinDevice): """Defined the Sensor.""" - TYPE = DOMAIN + TYPE = DOMAIN_SENSOR def __init__(self, device, hub) -> None: """Initialize the sensor.""" super().__init__(device, hub) - self._attr_id = extract_and_transform(self.unique_id) + self._attr_id = extract_and_transform(self._device_info.device_id) self._is_general = hub.wp_version == "General" - + @property def state(self): """Return the state of the sensor.""" @@ -76,10 +76,10 @@ def state(self): raise ValueError(f"Invalid attribute ID: {self._attr_id}") factor = ELEMENT_VALUE_CONVERSION[self._attr_id] - if isinstance(factor, list) and len(factor) == 2: factor = factor[0] if self._is_general else factor[1] - return factor(self._device.info.state) + + return factor(self._device_info.state) @property def device_class(self): diff --git a/custom_components/bestin/switch.py b/custom_components/bestin/switch.py index 3291baa..4f1e950 100644 --- a/custom_components/bestin/switch.py +++ b/custom_components/bestin/switch.py @@ -2,7 +2,7 @@ from __future__ import annotations -from homeassistant.components.switch import DOMAIN, SwitchEntity +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -10,7 +10,7 @@ from .const import NEW_SWITCH from .device import BestinDevice -from .hub import load_hub +from .hub import BestinHub async def async_setup_entry( @@ -19,24 +19,24 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> bool: """Setup switch platform.""" - hub = load_hub(hass, entry) - hub.entities[DOMAIN] = set() + hub: BestinHub = BestinHub.get_hub(hass, entry) + hub.entity_groups[SWITCH_DOMAIN] = set() @callback def async_add_switch(devices=None): if devices is None: - devices = hub.api.get_devices_from_domain(DOMAIN) + devices = hub.api.get_devices_from_domain(SWITCH_DOMAIN) entities = [ BestinSwitch(device, hub) for device in devices - if device.info.unique_id not in hub.entities[DOMAIN] + if device.unique_id not in hub.entity_groups[SWITCH_DOMAIN] ] if entities: async_add_entities(entities) - hub.listeners.append( + entry.async_on_unload( async_dispatcher_connect( hass, hub.async_signal_new_device(NEW_SWITCH), async_add_switch ) @@ -46,35 +46,43 @@ def async_add_switch(devices=None): class BestinSwitch(BestinDevice, SwitchEntity): """Defined the Switch.""" - TYPE = DOMAIN + TYPE = SWITCH_DOMAIN - def __init__(self, device, hub): + def __init__(self, device, hub: BestinHub): """Initialize the switch.""" super().__init__(device, hub) - self._is_gas = device.info.device_type == "gas" + # center + self._is_gas = self._device_info.device_type == "gas" + self._is_cutoff = self._device_info.device_type == "electric:cutoff" + self._version_exists = getattr(hub.api, "version", False) @property def is_on(self) -> bool: """Return true if switch is on.""" - return self._device.info.state + return self._device_info.state async def async_turn_on(self, **kwargs): """Turn on light.""" if self._version_exists: if self._is_gas: - await self._on_command("open") + await self.enqueue_command("open") + elif self._is_cutoff: + await self.enqueue_command(switch="set") else: - await self._on_command(switch="on") + await self.enqueue_command(switch="on") else: - await self._on_command(True) + await self.enqueue_command(True) async def async_turn_off(self, **kwargs): """Turn off light.""" if self._version_exists: if self._is_gas: - await self._on_command("close") + await self.enqueue_command("close") + elif self._is_cutoff: + await self.enqueue_command(switch="unset") else: - await self._on_command(switch="off") + await self.enqueue_command(switch="off") else: - await self._on_command(False) + await self.enqueue_command(False) + \ No newline at end of file diff --git a/custom_components/bestin/translations/en.json b/custom_components/bestin/translations/en.json index 6dbcd88..76adb10 100644 --- a/custom_components/bestin/translations/en.json +++ b/custom_components/bestin/translations/en.json @@ -33,8 +33,8 @@ }, "center_v2": { "data": { + "elevator_number": "Elevator number", "ip_address": "IP address", - "elevator_count": "Elevator count", "uuid": "UUID" }, "data_description": { diff --git a/custom_components/bestin/translations/ko.json b/custom_components/bestin/translations/ko.json index f1ed63b..5820e7f 100644 --- a/custom_components/bestin/translations/ko.json +++ b/custom_components/bestin/translations/ko.json @@ -33,8 +33,8 @@ }, "center_v2": { "data": { + "elevator_number": "엘리베이터 수", "ip_address": "IP 주소", - "elevator_count": "엘리베이터 수", "uuid": "UUID" }, "data_description": { diff --git a/custom_components/bestin/until.py b/custom_components/bestin/until.py new file mode 100644 index 0000000..d7c0307 --- /dev/null +++ b/custom_components/bestin/until.py @@ -0,0 +1,21 @@ +import re + +def check_ip_or_serial(id: str) -> bool: + """ + Verify that the string is an IP address or serial device path. + """ + ip_pattern = re.compile(r"^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$") + serial_pattern = re.compile(r"/dev/tty(USB|AMA)\d+") + + if ip_pattern.match(id) or serial_pattern.match(id): + return True + else: + return False + +def formatted_name(name: str) -> str: + """ + Format a given name by capitalizing the first part before a colon, if present. + """ + if ':' in name: + return name.split(":")[0].title() + return name.title() diff --git a/guide/center.md b/guide/center.md index c7b5f77..ace95d2 100644 --- a/guide/center.md +++ b/guide/center.md @@ -22,6 +22,9 @@ - 원격 주소 로그인 아이디 / 비밀번호 입력 또는 스마트홈 앱 로그인 계정 #### version2.0 +- 사이트 주소 + - UUID 등록 시에 인자로 사용됩니다. + - [위 주소](https://center.hdc-smart.com/v3/auth/valley)에서 본인 아파트 명을 찾은 뒤 'code', 'url'를 기억해 두세요. - IP 주소 (선택) - 서버를 통한 REST 엘리베이터 호출이 필요하신 분들에 한하여 세대 내 월패드 IP 주소를 입력하면 호출이 가능합니 다. (층수 및 방향 센서 지원) diff --git a/guide/dimming_packet_dump.txt b/guide/dimming_packet_dump.txt index 7566dd1..f1d71e6 100644 --- a/guide/dimming_packet_dump.txt +++ b/guide/dimming_packet_dump.txt @@ -1,87 +1,96 @@ -02 61 06 11 2B 59 -02 61 23 91 2B 81 FF FF 11 FF FF FF 01 B4 FF 00 01 D4 0D 46 FF FF FF FF FF FF FF FF FF FF FF FF FF FF 46 -02 62 06 11 2C 57 -02 62 23 91 2C 81 FF FF 12 79 FF FF 01 56 FF 00 01 6A 0C 84 FF FF FF FF 05 00 FF FF FF FF FF FF FF FF A5 -02 61 06 11 2E 5E -02 61 23 91 2E 81 FF FF 11 FE FF FF 01 B5 FF 00 01 D4 0D 46 FF FF FF FF FF FF FF FF FF FF FF FF FF FF 57 -02 62 06 11 2F 56 -02 62 23 91 2F 81 FF FF 12 7A FF FF 01 56 FF 00 01 6A 0C 83 FF FF FF FF 04 FF FF FF FF FF FF FF FF FF BE -02 61 06 11 31 43 -02 61 23 91 31 81 FF FF 11 FF FF FF 01 B5 FF 00 01 D4 0D 47 FF FF FF FF FF FF FF FF FF FF FF FF FF FF 54 -02 62 06 11 32 49 -02 62 23 91 32 81 FF FF 12 7B FF FF 01 56 FF 00 01 6A 0C 84 FF FF FF FF 05 0A FF FF FF FF FF FF FF FF 6B -02 61 06 11 34 48 -02 61 23 91 34 81 FF FF 11 FF FF FF 01 B6 FF 00 01 D5 0D 47 FF FF FF FF FF FF FF FF FF FF FF FF FF FF 57 -02 62 06 11 35 50 -02 62 23 91 35 81 FF FF 12 79 FF FF 01 56 FF 00 01 6A 0C 83 FF FF FF FF 05 0C FF FF FF FF FF FF FF FF 6D -02 61 06 11 37 45 -02 61 23 91 37 81 FF FF 11 FE FF FF 01 B6 FF 00 01 D5 0D 47 FF FF FF FF FF FF FF FF FF FF FF FF FF FF 4B -02 62 06 11 38 43 -02 62 23 91 38 81 FF FF 12 79 FF FF 01 56 FF 00 01 6A 0C 83 FF FF FF FF 05 0C FF FF FF FF FF FF FF FF 50 -02 61 06 11 3A 4A -02 61 23 91 3A 81 FF FF 11 FF FF FF 01 B6 FF 00 01 D5 0D 47 FF FF FF FF FF FF FF FF FF FF FF FF FF FF 59 -02 62 06 11 3B 42 -02 62 23 91 3B 81 FF FF 12 7B FF FF 01 57 FF 00 01 6B 0C 85 FF FF FF FF 05 0A FF FF FF FF FF FF FF FF 49 -02 61 06 11 3D 4F -02 61 23 91 3D 81 FF FF 11 FD FF FF 01 B7 FF 00 01 D5 0D 46 FF FF FF FF FF FF FF FF FF FF FF FF FF FF 54 -02 62 06 11 3E 45 -02 62 23 91 3E 81 FF FF 12 79 FF FF 01 57 FF 00 01 6B 0C 81 FF FF FF FF 05 07 FF FF FF FF FF FF FF FF 51 -02 61 06 11 40 34 -02 61 23 91 40 81 FF FF 11 FF FF FF 01 B7 FF 00 01 D6 0D 48 FF FF FF FF FF FF FF FF FF FF FF FF FF FF 54 -02 62 06 11 41 3C -02 62 23 91 41 81 FF FF 12 79 FF FF 01 57 FF 00 01 6B 0C 83 FF FF FF FF 05 07 FF FF FF FF FF FF FF FF 40 -02 61 06 11 43 31 -02 61 23 91 43 81 FF FF 11 FF FF FF 01 B7 FF 00 01 D6 0D 47 FF FF FF FF FF FF FF FF FF FF FF FF FF FF 26 +02 35 06 11 10 40 +02 35 3B 91 10 00 01 66 12 E3 01 02 00 00 00 00 19 01 02 64 64 12 F3 00 00 00 01 0D FF FF 01 22 00 00 00 00 00 00 64 00 00 00 00 00 02 22 00 00 00 00 00 00 64 00 00 00 00 0C E1 +02 3F 06 11 11 39 +02 3F 89 91 11 00 01 48 13 69 07 02 00 00 00 21 D6 01 02 46 32 12 98 00 00 00 01 CB 12 98 02 02 46 32 12 93 00 00 00 01 BA 12 95 83 00 00 00 00 00 00 00 00 00 00 00 00 84 00 00 00 00 00 00 00 00 00 00 00 00 85 00 00 00 00 00 00 00 00 00 00 00 00 86 00 00 00 00 00 00 00 00 00 00 00 00 07 02 46 32 12 97 00 00 00 01 1A FF FF 81 12 00 00 00 00 00 00 00 00 00 00 00 00 82 12 00 00 00 00 00 00 00 00 00 00 00 00 2A +02 31 06 11 12 32 +02 31 63 91 12 00 01 64 12 F2 03 03 00 00 00 00 73 01 02 0A 0A 12 CB 00 00 00 01 A0 FF FF 02 02 0A 0A 12 91 00 00 00 01 8D FF FF 03 02 64 28 12 DE 00 00 00 FF FF FF FF 01 22 00 00 00 03 35 00 00 00 00 00 00 00 02 21 00 00 00 00 AE 00 64 00 17 00 0B B8 03 22 00 00 00 00 00 00 00 00 00 00 95 A6 4D +02 32 06 11 13 3A +02 32 3A 91 13 00 01 50 12 F2 41 01 00 00 00 01 21 01 02 14 14 12 65 00 00 00 01 14 FF FF 05 02 28 32 12 83 21 26 FE 01 32 FF FF 01 21 00 13 00 00 0B 00 66 00 00 00 00 00 08 +02 33 06 11 14 32 +02 33 48 91 14 00 01 60 12 B1 41 02 00 00 00 00 41 01 02 0A 0A 12 B7 00 00 00 00 FC FF FF 05 02 0A 0A 12 C6 11 26 FE 01 70 FF FF 01 22 00 00 00 00 00 00 64 00 00 00 01 B6 02 22 00 00 00 00 00 00 00 00 00 00 01 66 88 +02 34 06 11 15 32 +02 34 3B 91 15 00 01 86 12 C7 01 02 01 90 00 15 C9 01 01 64 64 12 CB 00 00 00 01 79 FF FF 81 12 00 00 00 00 00 00 00 00 00 00 00 00 02 21 00 A4 00 01 BE 00 64 00 1A 00 0E AD 55 +02 35 06 11 16 3A +02 35 3B 91 16 00 01 66 12 E3 01 02 00 00 00 00 19 01 02 64 64 12 F3 00 00 00 01 0D FF FF 01 22 00 00 00 00 00 00 64 00 00 00 00 00 02 22 00 00 00 00 00 00 64 00 00 00 00 0C F3 +02 3F 06 11 17 3F +02 3F 89 91 17 00 01 48 13 67 07 02 00 00 00 21 D6 01 02 46 32 12 98 00 00 00 01 CB 12 98 02 02 46 32 12 93 00 00 00 01 BA 12 95 83 00 00 00 00 00 00 00 00 00 00 00 00 84 00 00 00 00 00 00 00 00 00 00 00 00 85 00 00 00 00 00 00 00 00 00 00 00 00 86 00 00 00 00 00 00 00 00 00 00 00 00 07 02 46 32 12 97 00 00 00 01 1A FF FF 81 12 00 00 00 00 00 00 00 00 00 00 00 00 82 12 00 00 00 00 00 00 00 00 00 00 00 00 EE +02 31 06 11 18 3C +02 31 63 91 18 00 01 64 12 F2 03 03 00 00 00 00 73 01 02 0A 0A 12 CB 00 00 00 01 A0 FF FF 02 02 0A 0A 12 91 00 00 00 01 8D FF FF 03 02 64 28 12 DE 00 00 00 FF FF FF FF 01 22 00 00 00 03 35 00 00 00 00 00 00 00 02 21 00 00 00 00 AE 00 64 00 17 00 0B B8 03 22 00 00 00 00 00 00 00 00 00 00 95 A6 57 +02 32 06 11 19 34 +02 32 3A 91 19 00 01 50 12 F2 41 01 00 00 00 01 21 01 02 14 14 12 65 00 00 00 01 14 FF FF 05 02 28 32 12 83 21 26 FE 01 32 FF FF 01 21 00 13 00 00 0B 00 66 00 00 00 00 00 02 +02 33 06 11 1A 40 +02 33 48 91 1A 00 01 60 12 B1 41 02 00 00 00 00 41 01 02 0A 0A 12 B7 00 00 00 00 FC FF FF 05 02 0A 0A 12 C6 11 26 FE 01 70 FF FF 01 22 00 00 00 00 00 00 64 00 00 00 01 B6 02 22 00 00 00 00 00 00 00 00 00 00 01 66 3E +02 34 06 11 1B 40 +02 34 3B 91 1B 00 01 86 12 C7 01 02 01 90 00 15 C9 01 01 64 64 12 CB 00 00 00 01 79 FF FF 81 12 00 00 00 00 00 00 00 00 00 00 00 00 02 21 00 A4 00 01 BE 00 64 00 1A 00 0E AD 8F +02 35 06 11 1C 34 +02 35 3B 91 1C 00 01 66 12 E3 01 02 00 00 00 00 19 01 02 64 64 12 F3 00 00 00 01 0D FF FF 01 22 00 00 00 00 00 00 64 00 00 00 00 00 02 22 00 00 00 00 00 00 64 00 00 00 00 0C CD +02 3F 06 11 1D 35 +02 3F 89 91 1D 00 01 48 13 60 07 02 00 00 00 21 D6 01 02 46 32 12 98 00 00 00 01 CB 12 98 02 02 46 32 12 93 00 00 00 01 BA 12 95 83 00 00 00 00 00 00 00 00 00 00 00 00 84 00 00 00 00 00 00 00 00 00 00 00 00 85 00 00 00 00 00 00 00 00 00 00 00 00 86 00 00 00 00 00 00 00 00 00 00 00 00 07 02 46 32 12 97 00 00 00 01 1A FF FF 81 12 00 00 00 00 00 00 00 00 00 00 00 00 82 12 00 00 00 00 00 00 00 00 00 00 00 00 75 +02 31 06 11 1E 3E +02 31 63 91 1E 00 01 64 12 F2 03 03 00 00 00 00 73 01 02 0A 0A 12 CB 00 00 00 01 A0 FF FF 02 02 0A 0A 12 91 00 00 00 01 8D FF FF 03 02 64 28 12 DE 00 00 00 FF FF FF FF 01 22 00 00 00 03 35 00 00 00 00 00 00 00 02 21 00 00 00 00 AE 00 64 00 17 00 0B B8 03 22 00 00 00 00 00 00 00 00 00 00 95 A6 89 +02 32 06 11 1F 36 +02 32 3A 91 1F 00 01 50 12 F9 41 01 00 00 00 01 21 01 02 14 14 12 65 00 00 00 01 14 FF FF 05 02 28 32 12 83 21 26 FE 01 32 FF FF 01 21 00 13 00 00 0B 00 66 00 00 00 00 00 BB +02 33 06 11 20 06 +02 33 48 91 20 00 01 60 12 B1 41 02 00 00 00 00 41 01 02 0A 0A 12 B7 00 00 00 00 FC FF FF 05 02 0A 0A 12 C6 11 26 FE 01 70 FF FF 01 22 00 00 00 00 00 00 64 00 00 00 01 B6 02 22 00 00 00 00 00 00 00 00 00 00 01 66 2C +02 34 06 11 21 06 +02 34 3B 91 21 00 01 86 12 C7 01 02 01 90 00 15 C9 01 01 64 64 12 CB 00 00 00 01 79 FF FF 81 12 00 00 00 00 00 00 00 00 00 00 00 00 02 21 00 A4 00 01 BE 00 64 00 1A 00 0E AD E9 +02 35 06 11 22 0E +02 35 3B 91 22 00 01 66 12 E3 01 02 00 00 00 00 19 01 02 64 64 12 F3 00 00 00 01 0D FF FF 01 22 00 00 00 00 00 00 64 00 00 00 00 00 02 22 00 00 00 00 00 00 64 00 00 00 00 0C 4F +02 3F 06 11 23 0B +02 3F 89 91 23 00 01 48 13 61 07 02 00 00 00 21 D6 01 02 46 32 12 98 00 00 00 01 CB 12 98 02 02 46 32 12 93 00 00 00 01 BA 12 95 83 00 00 00 00 00 00 00 00 00 00 00 00 84 00 00 00 00 00 00 00 00 00 00 00 00 85 00 00 00 00 00 00 00 00 00 00 00 00 86 00 00 00 00 00 00 00 00 00 00 00 00 07 02 46 32 12 97 00 00 00 01 1A FF FF 81 12 00 00 00 00 00 00 00 00 00 00 00 00 82 12 00 00 00 00 00 00 00 00 00 00 00 00 28 +02 31 06 11 24 08 +02 31 63 91 24 00 01 64 12 F2 03 03 00 00 00 00 73 01 02 0A 0A 12 CB 00 00 00 01 A0 FF FF 02 02 0A 0A 12 91 00 00 00 01 8D FF FF 03 02 64 28 12 DE 00 00 00 FF FF FF FF 01 22 00 00 00 03 35 00 00 00 00 00 00 00 02 21 00 00 00 00 AE 00 64 00 17 00 0B B8 03 22 00 00 00 00 00 00 00 00 00 00 95 A6 B3 -02 21 11 10 D6 00 00 00 0D 06 00 00 EB 00 00 00 ED -02 21 11 90 D6 00 00 00 0D 06 00 00 EB 00 FE 06 91 -02 22 11 10 D7 00 00 00 0D 06 00 00 FA 00 00 00 10 -02 22 11 90 D7 00 00 00 0D 06 00 00 FA 01 0E 00 85 -02 23 11 10 D8 00 00 00 0D 06 00 00 F5 00 00 00 FF -02 23 11 90 D8 00 00 00 0D 06 00 00 F5 00 F0 00 8F -02 24 11 10 D9 00 00 00 0D 06 00 00 D2 00 00 00 D4 -02 24 11 90 D9 00 00 00 0D 06 00 00 D2 00 EE 00 BE -02 25 11 10 DA FF 00 FC 0D 06 00 00 F0 00 00 00 13 -02 25 11 90 DA FF 00 FC 0D 06 00 00 F0 01 27 00 B7 -02 26 11 10 DB 00 00 00 0D 06 00 00 CD 00 00 00 C7 -02 26 11 90 DB 00 00 00 0D 06 00 00 CD 01 16 00 52 -02 12 11 01 DC 00 00 06 1C 1C 1A 1A 1A 1C 00 00 E5 -02 21 11 10 DD 00 00 00 0D 06 00 00 EB 00 00 00 22 -02 21 11 90 DD 00 00 00 0D 06 00 00 EB 00 FE 06 5A -02 22 11 10 DE 00 00 00 0D 06 00 00 FA 00 00 00 F5 -02 22 11 90 DE 00 00 00 0D 06 00 00 FA 01 0E 00 7C -02 23 11 10 DF 00 00 00 0D 06 00 00 F5 00 00 00 04 -02 23 11 90 DF 00 00 00 0D 06 00 00 F5 00 F0 00 74 -02 24 11 10 E0 00 00 00 0D 06 00 00 D2 00 00 00 19 -02 24 11 90 E0 00 00 00 0D 06 00 00 D2 00 EE 00 7B -02 25 11 10 E1 FF 00 FC 0D 06 00 00 F0 00 00 00 38 -02 25 11 90 E1 FF 00 FC 0D 06 00 00 F0 01 27 00 94 -02 26 11 10 E2 00 00 00 0D 06 00 00 CD 00 00 00 12 -02 26 11 90 E2 00 00 00 0D 06 00 00 CD 01 16 00 9B -02 12 11 01 E3 00 00 06 1C 1C 1A 1A 1A 1C 00 00 F2 -02 21 11 10 E4 00 00 00 0D 06 00 00 EB 00 00 00 3F -02 21 11 90 E4 00 00 00 0D 06 00 00 EB 00 FE 06 43 -02 22 11 10 E5 00 00 00 0D 06 00 00 FA 00 00 00 42 -02 22 11 90 E5 00 00 00 0D 06 00 00 FA 01 0E 00 B3 -02 23 11 10 E6 00 00 00 0D 06 00 00 F5 00 00 00 29 -02 23 11 90 E6 00 00 00 0D 06 00 00 F5 00 F0 00 59 -02 24 11 10 E7 00 00 00 0D 06 00 00 D2 00 00 00 12 -02 24 11 90 E7 00 00 00 0D 06 00 00 D2 00 EE 00 80 -02 25 11 10 E8 FF 00 FC 0D 06 00 00 F0 00 00 00 2D -02 25 11 90 E8 FF 00 FC 0D 06 00 00 F0 01 27 00 8D -02 26 11 10 E9 00 00 00 0D 06 00 00 CD 00 00 00 15 -02 26 11 90 E9 00 00 00 0D 06 00 00 CD 01 16 00 84 -02 12 11 01 EA 00 00 06 1C 1C 1A 1A 1A 1C 00 00 EB -02 21 11 10 EB 00 00 00 0D 06 00 00 EB 00 00 00 34 -02 21 11 90 EB 00 00 00 0D 06 00 00 EB 00 FE 06 4C -02 22 11 10 EC 00 00 00 0D 06 00 00 FA 00 00 00 17 -02 22 11 90 EC 00 00 00 0D 06 00 00 FA 01 0E 00 9A -02 23 11 10 ED 00 00 00 0D 06 00 00 F5 00 00 00 36 -02 23 11 90 ED 00 00 00 0D 06 00 00 F5 00 F0 00 46 -02 24 11 10 EE 00 00 00 0D 06 00 00 D2 00 00 00 07 -02 24 11 90 EE 00 00 00 0D 06 00 00 D2 00 EE 00 6D -02 25 11 10 EF FF 00 FC 0D 06 00 00 F0 00 00 00 42 -02 25 11 90 EF FF 00 FC 0D 06 00 00 F0 01 27 00 9A -02 26 11 10 F0 00 00 00 0D 06 00 00 CD 00 00 00 30 -02 26 11 90 F0 00 00 00 0D 06 00 00 CD 01 16 00 BD -02 12 11 01 F1 00 00 06 1C 1C 1A 1A 1A 1C 00 00 00 + +02 33 06 11 BD 99 +02 33 48 91 BD 00 01 60 12 B1 41 02 00 00 00 00 41 01 02 0A 0A 12 B7 00 00 00 00 FC FF FF 05 02 0A 0A 12 C6 11 26 FE 01 70 FF FF 01 22 00 00 00 00 00 00 64 00 00 00 01 B6 02 22 00 00 00 00 00 00 00 00 00 00 01 66 17 +02 34 06 11 BE 9B +02 34 3B 91 BE 00 01 86 12 C7 01 02 01 90 00 15 CB 01 01 64 64 12 CB 00 00 00 01 79 FF FF 81 12 00 00 00 00 00 00 00 00 00 00 00 00 02 21 00 A5 00 01 BF 00 64 00 1A 00 0E AD EA +02 35 06 11 BF 91 +02 35 3B 91 BF 00 01 66 12 E3 01 02 00 00 00 00 19 01 02 64 64 12 F3 00 00 00 01 0D FF FF 01 22 00 00 00 00 00 00 64 00 00 00 00 00 02 22 00 00 00 00 00 00 64 00 00 00 00 0C 26 +02 3F 06 11 C0 EA +02 3F 89 91 C0 00 01 47 13 6C 07 02 00 00 00 21 D6 01 02 46 32 12 98 00 00 00 01 CB 12 98 02 02 46 32 12 93 00 00 00 01 BA 12 95 83 00 00 00 00 00 00 00 00 00 00 00 00 84 00 00 00 00 00 00 00 00 00 00 00 00 85 00 00 00 00 00 00 00 00 00 00 00 00 86 00 00 00 00 00 00 00 00 00 00 00 00 07 02 46 32 12 97 00 00 00 01 1A FF FF 81 12 00 00 00 00 00 00 00 00 00 00 00 00 82 12 00 00 00 00 00 00 00 00 00 00 00 00 81 +02 31 06 11 C1 E3 +02 31 63 91 C1 00 01 64 12 F2 03 03 00 00 00 00 73 01 02 0A 0A 12 CB 00 00 00 01 A0 FF FF 02 02 0A 0A 12 91 00 00 00 01 8D FF FF 03 02 64 28 12 DE 00 00 00 FF FF FF FF 01 22 00 00 00 03 35 00 00 00 00 00 00 00 02 21 00 00 00 00 AE 00 64 00 17 00 0B B8 03 22 00 00 00 00 00 00 00 00 00 00 95 A6 EA +02 32 06 11 C2 E9 +02 32 3A 91 C2 00 01 50 12 F2 41 01 00 00 00 01 21 01 02 14 14 12 65 00 00 00 01 14 FF FF 05 02 28 32 12 82 21 26 FE 01 32 FF FF 01 21 00 12 00 00 0B 00 66 00 00 00 00 00 BD +02 33 06 11 C3 E7 +02 33 48 91 C3 00 01 60 12 B1 41 02 00 00 00 00 41 01 02 0A 0A 12 B7 00 00 00 00 FC FF FF 05 02 0A 0A 12 C6 11 26 FE 01 70 FF FF 01 22 00 00 00 00 00 00 64 00 00 00 01 B6 02 22 00 00 00 00 00 00 00 00 00 00 01 66 2D +02 34 06 11 C4 E1 +02 34 3B 91 C4 00 01 86 12 C7 01 02 01 90 00 15 CB 01 01 64 64 12 CB 00 00 00 01 79 FF FF 81 12 00 00 00 00 00 00 00 00 00 00 00 00 02 21 00 A5 00 01 BF 00 64 00 1A 00 0E AD 70 02 30 0D 05 C5 18 08 1D 10 04 38 04 D1 02 30 0D 05 C6 18 08 1D 10 04 38 04 C2 02 30 0D 05 C7 18 08 1D 10 04 38 04 DF 02 35 06 11 C8 E8 02 35 3B 91 C8 00 01 66 12 E3 01 02 00 00 00 00 19 01 02 64 64 12 F3 00 00 00 01 0D FF FF 01 22 00 00 00 00 00 00 64 00 00 00 00 00 02 22 00 00 00 00 00 00 64 00 00 00 00 0C 29 +02 3F 06 11 C9 E1 +02 3F 89 91 C9 00 01 47 13 66 07 02 00 00 00 21 D6 01 02 46 32 12 98 00 00 00 01 CB 12 98 02 02 46 32 12 93 00 00 00 01 BA 12 95 83 00 00 00 00 00 00 00 00 00 00 00 00 84 00 00 00 00 00 00 00 00 00 00 00 00 85 00 00 00 00 00 00 00 00 00 00 00 00 86 00 00 00 00 00 00 00 00 00 00 00 00 07 02 46 32 12 97 00 00 00 01 1A FF FF 81 12 00 00 00 00 00 00 00 00 00 00 00 00 82 12 00 00 00 00 00 00 00 00 00 00 00 00 82 +02 31 06 11 CA EA +02 31 63 91 CA 00 01 64 12 F2 03 03 00 00 00 00 73 01 02 0A 0A 12 CB 00 00 00 01 A0 FF FF 02 02 0A 0A 12 91 00 00 00 01 8D FF FF 03 02 64 28 12 DE 00 00 00 FF FF FF FF 01 22 00 00 00 03 35 00 00 00 00 00 00 00 02 21 00 00 00 00 AE 00 64 00 17 00 0B B8 03 22 00 00 00 00 00 00 00 00 00 00 95 A6 45 +02 32 06 11 CB E2 +02 32 3A 91 CB 00 01 50 12 F9 41 01 00 00 00 01 21 01 02 14 14 12 65 00 00 00 01 14 FF FF 05 02 28 32 12 83 21 26 FE 01 32 FF FF 01 21 00 12 00 00 0B 00 66 00 00 00 00 00 DC +02 33 06 11 CC EA +02 33 48 91 CC 00 01 60 12 B1 41 02 00 00 00 00 41 01 02 0A 0A 12 B7 00 00 00 00 FC FF FF 05 02 0A 0A 12 C6 11 26 FE 01 70 FF FF 01 22 00 00 00 00 00 00 64 00 00 00 01 B6 02 22 00 00 00 00 00 00 00 00 00 00 01 66 50 +02 34 06 11 CD EA +02 34 3B 91 CD 00 01 86 12 C7 01 02 01 90 00 15 CB 01 01 64 64 12 CB 00 00 00 01 79 FF FF 81 12 00 00 00 00 00 00 00 00 00 00 00 00 02 21 00 A5 00 01 BF 00 64 00 1A 00 0E AD B5 +02 35 06 11 CE E2 +02 35 3B 91 CE 00 01 66 12 E3 01 02 00 00 00 00 19 01 02 64 64 12 F3 00 00 00 01 0D FF FF 01 22 00 00 00 00 00 00 64 00 00 00 00 00 02 22 00 00 00 00 00 00 64 00 00 00 00 0C 5B +02 3F 06 11 CF E7 +02 3F 89 91 CF 00 01 47 13 63 07 02 00 00 00 21 D6 01 02 46 32 12 98 00 00 00 01 CB 12 98 02 02 46 32 12 93 00 00 00 01 BA 12 95 83 00 00 00 00 00 00 00 00 00 00 00 00 84 00 00 00 00 00 00 00 00 00 00 00 00 85 00 00 00 00 00 00 00 00 00 00 00 00 86 00 00 00 00 00 00 00 00 00 00 00 00 07 02 46 32 12 97 00 00 00 01 1A FF FF 81 12 00 00 00 00 00 00 00 00 00 00 00 00 82 12 00 00 00 00 00 00 00 00 00 00 00 00 77 +02 31 06 11 D0 F4 +02 31 63 91 D0 00 01 64 12 F2 03 03 00 00 00 00 73 01 02 0A 0A 12 CB 00 00 00 01 A0 FF FF 02 02 0A 0A 12 91 00 00 00 01 8D FF FF 03 02 64 28 12 DE 00 00 00 FF FF FF FF 01 22 00 00 00 03 35 00 00 00 00 00 00 00 02 21 00 00 00 00 AE 00 64 00 17 00 0B B8 03 22 00 00 00 00 00 00 00 00 00 00 95 A6 4F +02 32 06 11 D1 FC +02 32 3A 91 D1 00 01 50 12 F9 41 01 00 00 00 01 21 01 02 14 14 12 65 00 00 00 01 14 FF FF 05 02 28 32 12 83 21 26 FE 01 32 FF FF 01 21 00 12 00 00 0B 00 66 00 00 00 00 00 22 +02 33 06 11 D2 F8 +02 33 48 91 D2 00 01 60 12 B1 41 02 00 00 00 00 41 01 02 0A 0A 12 B7 00 00 00 00 FC FF FF 05 02 0A 0A 12 C6 11 26 FE 01 70 FF FF 01 22 00 00 00 00 00 00 64 00 00 00 01 B6 02 22 00 00 00 00 00 00 00 00 00 00 01 66 66 +02 34 06 11 D3 F8 +02 34 3B 91 D3 00 01 86 12 C7 01 02 01 90 00 15 CB 01 01 64 64 12 CB 00 00 00 01 79 FF FF 81 12 00 00 00 00 00 00 00 00 00 00 00 00 02 21 00 A5 00 01 BF 00 64 00 1A 00 0E AD 9B +02 35 06 11 D4 FC +02 35 3B 91 D4 00 01 66 12 E3 01 02 00 00 00 00 19 01 02 64 64 12 F3 00 00 00 01 0D FF FF 01 22 00 00 00 00 00 00 64 00 00 00 00 00 02 22 00 00 00 00 00 00 64 00 00 00 00 0C 55 +02 3F 06 11 D5 FD +02 3F 89 91 D5 00 01 48 13 62 07 02 00 00 00 21 D6 01 02 46 32 12 98 00 00 00 01 CB 12 98 02 02 46 32 12 93 00 00 00 01 BA 12 95 83 00 00 00 00 00 00 00 00 00 00 00 00 84 00 00 00 00 00 00 00 00 00 00 00 00 85 00 00 00 00 00 00 00 00 00 00 00 00 86 00 00 00 00 00 00 00 00 00 00 00 00 07 02 46 32 12 97 00 00 00 01 1A FF FF 81 12 00 00 00 00 00 00 00 00 00 00 00 00 82 12 00 00 00 00 00 00 00 00 00 00 00 00 0F +02 31 06 11 D6 F6 +02 31 63 91 D6 00 01 64 12 F2 03 03 00 00 00 00 73 01 02 0A 0A 12 CB 00 00 00 01 A0 FF FF 02 02 0A 0A 12 91 00 00 00 01 8D FF FF 03 02 64 28 12 DE 00 00 00 FF FF FF FF 01 22 00 00 00 03 35 00 00 00 00 00 00 00 02 21 00 00 00 00 AE 00 64 00 17 00 0B B8 03 22 00 00 00 00 00 00 00 00 00 00 95 A6 C1 +02 32 06 11 D7 FE +02 32 3A 91 D7 00 01 50 12 F9 41 01 00 00 00 01 21 01 02 14 14 12 65 00 00 00 01 14 FF FF 05 02 28 32 12 83 21 26 FE 01 32 FF FF 01 21 00 12 00 00 0B 00 66 00 00 00 00 00 C0 +02 33 06 11 D8 FE +02 33 48 91 D8 00 01 60 12 B1 41 02 00 00 00 00 41 01 02 0A 0A 12 B7 00 00 00 00 FC FF FF 05 02 0A 0A 12 C6 11 26 FE 01 70 FF FF 01 22 00 00 00 00 00 00 64 00 00 00 01 B6 02 22 00 00 00 00 00 00 00 00 00 00 01 66 94 +02 34 06 11 D9 FE +02 34 3B 91 D9 00 01 86 12 C7 01 02 01 90 00 15 CB 01 01 64 64 12 CB 00 00 00 01 79 FF FF 81 12 00 00 00 00 00 00 00 00 00 00 00 00 02 21 00 A4 00 01 BF 00 64 00 1A 00 0E AD 92 +02 35 06 11 DA F6 +02 35 3B 91 DA 00 01 66 12 E3 01 02 00 00 00 00 19 01 02 64 64 12 F3 00 00 00 01 0D FF FF 01 22 00 00 00 00 00 00 64 00 00 00 00 00 02 22 00 00 00 00 00 00 64 00 00 00 00 0C D7 diff --git a/guide/install.md b/guide/install.md index 23e97cb..aef8e56 100644 --- a/guide/install.md +++ b/guide/install.md @@ -32,11 +32,11 @@ ![Gateway 2.0 기본 연결](/images/gateway2.0_default_connect.png) ### Gateway 2.0 신형 버전 -- 디밍 세대의 경우, 신형 버전으로 간주됩니다. 신형 버전은 아직 완전히 대응되지 않았으며, 패킷 데이터가 불분명하여 확인이 어렵습니다. +- 1.3.3 버전부터 디밍 세대에 대한 지원을 시작합니다. 아직 모든 데이터가 완전히 분석되지 않았습니다. + - 사진을 참고하여 디밍 세대인지 확인해 보세요. + - 추가적인 패킷 데이터에 대한 제보를 기다립니다. + - [디밍 테스트](/tests/dimming_test.py) 코드를 사용하여 좀 더 세부적으로 분석이 가능합니다. -- [해당](https://cafe.naver.com/stsmarthome?iframe_url_utf8=%2FArticleRead.nhn%253Fclubid%3D29087792%2526articleid%3D30641) 링크의 데이터 포맷이 맞는 것으로 보이지만, 데이터 라인이 명확하지 않습니다. +![Gateway 2.0 디밍](/images/gateway2.0_dimming.png) - **2.0 기본 버전**과 달리 에너지 포트 부분에서는 유효한 데이터를 얻기 어려웠습니다. [해당](https://cafe.naver.com/stsmarthome?iframe_url_utf8=%2FArticleRead.nhn%253Fclubid%3D29087792%2526articleid%3D30641) 링크의 경우 방 소형 월패드에서 데이터를 얻은 것으로 - 보이지만, 확실한 검증이 필요해 보입니다. - -- 사진은 따로 없지만, **2.0 기본 버전** 게이트웨이보다 가로로 긴 형태입니다. 결선은 2.0 기본 버전과 비슷합니다. +![Gateway 2.0 에너지 컨트롤러](/images/gateway2.0_energy_controller.png) diff --git a/images/gateway2.0_dimming.png b/images/gateway2.0_dimming.png new file mode 100644 index 0000000..d837c0c Binary files /dev/null and b/images/gateway2.0_dimming.png differ diff --git a/images/gateway2.0_energy_controller.png b/images/gateway2.0_energy_controller.png new file mode 100644 index 0000000..dbcb5f9 Binary files /dev/null and b/images/gateway2.0_energy_controller.png differ diff --git a/tests/dimming_test.py b/tests/dimming_test.py new file mode 100644 index 0000000..0bd3907 --- /dev/null +++ b/tests/dimming_test.py @@ -0,0 +1,157 @@ +import socket +import logging + +logging.basicConfig(level=logging.ERROR) + +class SocketClient: + def __init__(self, server_address, port): + self.server_address = server_address + self.port = port + self.sock = None + self.constant_packet_length = 10 + + def connect(self): + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + self.sock.connect((self.server_address, self.port)) + print(f"Connected to {self.server_address} on port {self.port}") + except socket.error as e: + logging.error(f"Connection error: {e}") + self.sock = None + + def _receive_socket(self): + def recv_exactly(n): + data = b'' + while len(data) < n: + chunk = self.sock.recv(n - len(data)) + if not chunk: + raise socket.error("Connection closed") + data += chunk + return data + + packet = b'' + try: + while True: + while True: + initial_data = self.sock.recv(1) + if not initial_data: + return b'' + packet += initial_data + if 0x02 in packet: + start_index = packet.index(0x02) + packet = packet[start_index:] + break + + if len(packet) < 3: + packet += recv_exactly(3 - len(packet)) + + if ( + packet[1] not in [0x31, 0x32, 0x33, 0x34] + and packet[1] & 0xF0 != 0x50 + ): + return b'' + + if ( + (packet[1] == 0x31 and packet[2] in [0x00, 0x02, 0x80, 0x82]) + or packet[1] == 0x61 + or packet[1] == 0x17 + ): + packet_length = self.constant_packet_length + else: + packet_length = packet[2] + + if packet_length <= 0: + logging.error("Invalid packet length in packet.") + return b'' + + packet += recv_exactly(packet_length - len(packet)) + + if len(packet) >= packet_length: + return packet[:packet_length] + + except socket.error as e: + logging.error(f"Socket error: {e}") + self.reconnect() + + return b'' + + def reconnect(self): + if self.sock: + self.sock.close() + self.connect() + + def checksum(self, data): + checksum = 3 + for byte in data[:-1]: + checksum ^= byte + checksum = (checksum + 1) & 0xFF + return checksum == data[-1] + + def receive_data(self): + try: + while True: + data = self._receive_socket() + try: + if not self.checksum(data): + continue + + if data[1] == 0x33 and data[3] == 0x91: + self.parse_data(data) + pass + + if data[1] == 0x34 and data[3] == 0x91: + pass + #print(' '.join(f'{byte:02X}' for byte in data)) + #print(f"[34] LIGHT:: \n 1. IS_ON: {data[18] == 0x01}, DIMMING_LEVEL: {data[19]}, COLOR_TEMPERATURE: {data[20]}") + #print(f"[34] OUTLET:: \n 1. IS_ON: {data[44] == 0x21}, CURRENT_POWER: {int.from_bytes(data[52:54], byteorder="big")/10} \n 2. IS_ON: {data[58] == 0x21}, CURRENT_POWER: {int.from_bytes(data[66:68], byteorder="big")/10}") + if data[1] == 0x33 and data[3] == 0x91: + pass + #print(' '.join(f'{byte:02X}' for byte in data)) + #print(f"[33] LIGHT:: \n 1. IS_ON: {data[18] == 0x01}, DIMMING_LEVEL: {data[19]}, COLOR_TEMPERATURE: {data[20]}") + #print(f"[33] OUTLET:: \n 1. IS_ON: {data[31] == 0x21}, CURRENT_POWER: {int.from_bytes(data[39:41], byteorder="big")/10} \n 2. IS_ON: {data[45] == 0x21}, CURRENT_POWER: {int.from_bytes(data[53:55], byteorder="big")/10}") + if data[1] == 0x31 and data[3] == 0x91: + pass + #print(' '.join(f'{byte:02X}' for byte in data)) + except IndexError: + continue + except KeyboardInterrupt: + logging.warning("Interrupted by user.") + #finally: + # if self.sock: + # self.sock.close() + + def parse_data(self, data): + print(' '.join(f'{byte:02X}' for byte in data)) + rid = data[1] & 0x0F + + if rid % 2 == 0: + lcnt, ocnt = data[10] & 0x0F, data[11] + base_cnt = ocnt + else: + lcnt, ocnt = data[10], data[11] + base_cnt = lcnt + #print(f"[2] LIGHT COUNT: {lcnt}, OUTLET_COUNT: {ocnt}") + + lsi = 18 + osi = lsi + (base_cnt * 13) + #print(f"[2] LIGHT_START_INDEX: {lsi}, OUTLET_START_INDEX: {osi}") + + for i in range(lcnt): + is_on = data[lsi] == 0x01 + dimming_level = data[lsi + 1] + color_temperature = data[lsi + 2] + print(f"LIGHT:: \n {rid}/{i}. IS_ON: {is_on}, DIMMING_LEVEL: {dimming_level}, COLOR_TEMPERATURE: {color_temperature}") + lsi += 13 + + for i in range(ocnt): + is_on = data[osi] == 0x21 + idx = osi + 8 + idx2 = osi + 10 + current_power = int.from_bytes(data[idx:idx2], byteorder="big") / 10 + print(f"OUTLET:: \n {rid}/{i}. IS_ON: {is_on}, CURRENT_POWER: {current_power}") + osi += 14 + +if __name__ == "__main__": + client = SocketClient('192.168.0.27', 8899) + client.connect() + client.receive_data()