diff --git a/README-zh.md b/README-zh.md index 8dc15cf..00d6147 100644 --- a/README-zh.md +++ b/README-zh.md @@ -65,18 +65,25 @@ _完整的實體清單請見 [可用的實體](#可用的實體)_ ### 可用的實體 -| 裝置類型 | 實體類型 | 備註 | -| -------- | ------------- | ---------------------- | -| 冷氣 | climate | | -| | number | 定時開機(若裝置支援) | -| | number | 定時關機 | -| | sensor | 室外溫度偵測器 | -| 除濕機 | humidifier | | -| | number | 定時開機(若裝置支援) | -| | number | 定時關機 | -| | sensor | 環境溼度偵測器 | -| | sensor | PM2.5 偵測器 | -| | binary_sensor | 水箱滿水偵測器 | +| 裝置類型 | 實體類型 | 備註 | +| -------- | ------------- | -------------- | +| 冷氣 | climate | | +| | number | 定時開機\* | +| | number | 定時關機 | +| | sensor | 室外溫度偵測器 | +| | switch | nanoe 開關\* | +| | switch | ECONAVI 開關\* | +| | switch | 操控聲音開關\* | +| | switch | 急速模式開關\* | +| 除濕機 | humidifier | | +| | number | 定時開機\* | +| | number | 定時關機 | +| | select | 風量設定 | +| | sensor | 環境溼度偵測器 | +| | sensor | PM2.5 偵測器 | +| | binary_sensor | 水箱滿水偵測器 | + +\*僅在裝置支援的情況下可用 更多實體支援請至 [Issue](https://github.com/osk2/panasonic_smart_app/issues) 頁面許願,也歡迎發送 PR 💪 diff --git a/README.md b/README.md index afbc3fc..e80d9a4 100644 --- a/README.md +++ b/README.md @@ -69,18 +69,25 @@ Feel free to report working device by opening an [issue](https://github.com/osk2 ### Available Entities -| Device Type | Entity Type | Note | -| ------------ | ------------- | ---------------------------- | -| AC | climate | | -| | number | On timer (Only if supported) | -| | number | Off timer | -| | sensor | Outdoor temperature sensor | -| Dehumidifier | humidifier | | -| | number | On timer (Only if supported) | -| | number | Off timer | -| | sensor | Environment humidity sensor | -| | sensor | PM2.5 sensor | -| | binary_sensor | Water tank status sensor | +| Device Type | Entity Type | Note | +| ------------ | ------------- | --------------------------- | +| AC | climate | | +| | number | On timer\* | +| | number | Off timer | +| | sensor | Outdoor temperature sensor | +| | switch | nanoe switch\* | +| | switch | ECONAVI swtich\* | +| | switch | Buzzer switch\* | +| | switch | Turbo mode switch\* | +| Dehumidifier | humidifier | | +| | number | On timer\* | +| | number | Off timer | +| | select | Fan mode | +| | sensor | Environment humidity sensor | +| | sensor | PM2.5 sensor | +| | binary_sensor | Water tank status sensor | + +\*Only available if feature is supported. For missing entities, open an issue or submit a PR 💪 diff --git a/custom_components/panasonic_smart_app/__init__.py b/custom_components/panasonic_smart_app/__init__.py index af514e1..9b9cabb 100644 --- a/custom_components/panasonic_smart_app/__init__.py +++ b/custom_components/panasonic_smart_app/__init__.py @@ -1,5 +1,4 @@ import asyncio -import async_timeout from datetime import timedelta import logging @@ -14,10 +13,11 @@ from .const import ( DATA_CLIENT, DATA_COORDINATOR, + DEFAULT_UPDATE_INTERVAL, DOMAIN, + CONF_UPDATE_INTERVAL, DEFAULT_NAME, PLATFORMS, - UPDATE_INTERVAL, DEVICE_STATUS_CODES, ) @@ -44,24 +44,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_update_data(): try: _LOGGER.info("Updating device info...") - devices = await client.get_devices() - for device in devices: - device_type = int(device["Devices"][0]["DeviceType"]) - if device_type in DEVICE_STATUS_CODES.keys(): - status_codes = DEVICE_STATUS_CODES[device_type] - device["status"] = await client.get_device_info( - device["auth"], status_codes - ) - return devices - except: - raise UpdateFailed("Failed on initialize") + return await client.get_device_with_info(DEVICE_STATUS_CODES) + except BaseException as ex: + _LOGGER.error(ex) + raise UpdateFailed("Failed while updating device status") coordinator = DataUpdateCoordinator( hass, _LOGGER, name=DEFAULT_NAME, update_method=async_update_data, - update_interval=timedelta(seconds=UPDATE_INTERVAL), + update_interval=timedelta(seconds=entry.data.get(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL)), ) await coordinator.async_refresh() diff --git a/custom_components/panasonic_smart_app/binary_sensor.py b/custom_components/panasonic_smart_app/binary_sensor.py index 79d85d2..84ccda8 100644 --- a/custom_components/panasonic_smart_app/binary_sensor.py +++ b/custom_components/panasonic_smart_app/binary_sensor.py @@ -5,7 +5,6 @@ from .entity import PanasonicBaseEntity from .const import ( DOMAIN, - UPDATE_INTERVAL, DEVICE_TYPE_DEHUMIDIFIER, DATA_CLIENT, DATA_COORDINATOR, @@ -14,7 +13,6 @@ ) _LOGGER = logging.getLogger(__package__) -SCAN_INTERVAL = timedelta(seconds=UPDATE_INTERVAL) async def async_setup_entry(hass, entry, async_add_entities) -> bool: @@ -24,7 +22,7 @@ async def async_setup_entry(hass, entry, async_add_entities) -> bool: sensors = [] for index, device in enumerate(devices): - if int(device["Devices"][0]["DeviceType"]) == DEVICE_TYPE_DEHUMIDIFIER: + if int(device.get("DeviceType")) == DEVICE_TYPE_DEHUMIDIFIER: sensors.append( PanasonoicTankSensor( coordinator, diff --git a/custom_components/panasonic_smart_app/climate.py b/custom_components/panasonic_smart_app/climate.py index 9c293cc..77ca08d 100644 --- a/custom_components/panasonic_smart_app/climate.py +++ b/custom_components/panasonic_smart_app/climate.py @@ -16,7 +16,6 @@ from .const import ( DOMAIN, DEVICE_TYPE_AC, - UPDATE_INTERVAL, DATA_CLIENT, DATA_COORDINATOR, CLIMATE_AVAILABLE_MODE, @@ -28,7 +27,6 @@ ) _LOGGER = logging.getLogger(__package__) -SCAN_INTERVAL = timedelta(seconds=UPDATE_INTERVAL) def getKeyFromDict(targetDict, mode_name): @@ -46,7 +44,7 @@ async def async_setup_entry(hass, entry, async_add_entities) -> bool: climate = [] for index, device in enumerate(devices): - if int(device["Devices"][0]["DeviceType"]) == DEVICE_TYPE_AC: + if int(device.get("DeviceType")) == DEVICE_TYPE_AC: climate.append( PanasonicClimate( coordinator, @@ -62,6 +60,11 @@ async def async_setup_entry(hass, entry, async_add_entities) -> bool: class PanasonicClimate(PanasonicBaseEntity, ClimateEntity): + @property + def available(self) -> bool: + status = self.coordinator.data[self.index]["status"] + return status.get("0x00") != None + @property def label(self) -> str: return f"{self.nickname} {LABEL_CLIMATE}" diff --git a/custom_components/panasonic_smart_app/config_flow.py b/custom_components/panasonic_smart_app/config_flow.py index 5fe4ec0..2d793ce 100644 --- a/custom_components/panasonic_smart_app/config_flow.py +++ b/custom_components/panasonic_smart_app/config_flow.py @@ -11,7 +11,11 @@ from .smartApp import SmartApp from .smartApp.exceptions import PanasonicExceedRateLimit -from .const import DOMAIN +from .const import ( + DOMAIN, + CONF_UPDATE_INTERVAL, + DEFAULT_UPDATE_INTERVAL, +) class SmartAppFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -59,6 +63,7 @@ async def _show_config_form( { vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_UPDATE_INTERVAL, default=DEFAULT_UPDATE_INTERVAL): int, } ), errors=self._errors, diff --git a/custom_components/panasonic_smart_app/const.py b/custom_components/panasonic_smart_app/const.py index 0d26b01..05c64be 100644 --- a/custom_components/panasonic_smart_app/const.py +++ b/custom_components/panasonic_smart_app/const.py @@ -16,17 +16,22 @@ "number", "binary_sensor", "climate", + "switch", + "select", ] MANUFACTURER = "Panasonic" DEFAULT_NAME = "Panasonic Smart Application" DEVICE_TYPE_AC = 1 +DEVICE_TYPE_WASHING_MACHINE = 3 DEVICE_TYPE_DEHUMIDIFIER = 4 DATA_CLIENT = "client" DATA_COORDINATOR = "coordinator" -UPDATE_INTERVAL = 90 +CONF_UPDATE_INTERVAL = "update_interval" + +DEFAULT_UPDATE_INTERVAL = 180 DEVICE_STATUS_CODES = { DEVICE_TYPE_AC: [ @@ -39,19 +44,25 @@ "0x21", "0x0b", "0x0c", + "0x08", + "0x1b", + "0x1e", + "0x1a", ], DEVICE_TYPE_DEHUMIDIFIER: [ - "0x00", - "0x01", + "0x00", # Dehumidifier online status + "0x01", # Dehumidifier operation mode + "0x02", # Dehumidifier off timer + "0x07", # Dehumidifier humidity sensor + "0x09", # Dehumidifier fan direction + "0x0D", # Dehumidifier nanoe "0x50", - "0x0a", - "0x04", - "0x0e", - "0x09", - "0x55", - "0x02", - "0x53", - "0x07", + "0x18", # Dehumidifier buzzer + "0x53", # Dehumidifier PM2.5 + "0x55", # Dehumidifier on timer + "0x0A", # Dehumidifier tank status + "0x04", # Dehumidifier target humidity + "0x0E", # Dehumidifier fan mode ], } @@ -102,6 +113,12 @@ ICON_OFF_TIMER = "mdi:alarm-snooze" ICON_THERMOMETER = "mdi:thermometer" ICON_PM25 = "mdi:dots-hexagon" +ICON_NANOE = "mdi:atom" +ICON_ECONAVI = "mdi:leaf" +ICON_BUZZER = "mdi:volume-high" +ICON_TURBO = "mdi:clock-fast" +ICON_FAN = "mdi:fan" +ICON_ENERGY = "mdi:flash" LABEL_DEHUMIDIFIER = "" @@ -110,11 +127,17 @@ LABEL_HUMIDITY = "環境溼度" LABEL_DEHUMIDIFIER_ON_TIMER = "定時開機" LABEL_DEHUMIDIFIER_OFF_TIMER = "定時關機" +LABEL_DEHUMIDIFIER_FAN_MODE = "風量設定" LABEL_CLIMATE_ON_TIMER = "定時開機(分)" LABEL_CLIMATE_ON_TIMER = "定時開機" LABEL_CLIMATE_OFF_TIMER = "定時關機" LABEL_OUTDOOR_TEMPERATURE = "室外溫度" LABEL_PM25 = "PM2.5" +LABEL_NANOE = "nanoe" +LABEL_ECONAVI = "ECONAVI" +LABEL_BUZZER = "操作提示音" +LABEL_TURBO = "急速" +LABEL_ENERGY = "本月耗電量" UNIT_HOUR = "小時" UNIT_MINUTE = "分鐘" diff --git a/custom_components/panasonic_smart_app/entity.py b/custom_components/panasonic_smart_app/entity.py index 8971d53..ef227ff 100644 --- a/custom_components/panasonic_smart_app/entity.py +++ b/custom_components/panasonic_smart_app/entity.py @@ -5,11 +5,8 @@ from .const import ( DOMAIN, MANUFACTURER, - UPDATE_INTERVAL, ) -SCAN_INTERVAL = timedelta(seconds=UPDATE_INTERVAL) - class PanasonicBaseEntity(CoordinatorEntity, ABC): def __init__( self, @@ -31,7 +28,7 @@ def label(self) -> str: @property def current_device_info(self) -> dict: - return self.device["Devices"][0] + return self.device @property def nickname(self) -> str: @@ -57,7 +54,7 @@ def name(self) -> str: @property def auth(self) -> str: - return self.device["auth"] + return self.device["Auth"] @property def unique_id(self) -> str: diff --git a/custom_components/panasonic_smart_app/humidifier.py b/custom_components/panasonic_smart_app/humidifier.py index c30303d..d4e0031 100644 --- a/custom_components/panasonic_smart_app/humidifier.py +++ b/custom_components/panasonic_smart_app/humidifier.py @@ -9,7 +9,6 @@ from .entity import PanasonicBaseEntity from .const import ( DOMAIN, - UPDATE_INTERVAL, DEVICE_TYPE_DEHUMIDIFIER, DATA_CLIENT, DATA_COORDINATOR, @@ -20,7 +19,6 @@ ) _LOGGER = logging.getLogger(__package__) -SCAN_INTERVAL = timedelta(seconds=UPDATE_INTERVAL) def getKeyFromDict(targetDict, mode_name): @@ -38,7 +36,7 @@ async def async_setup_entry(hass, entry, async_add_entities) -> bool: humidifiers = [] for index, device in enumerate(devices): - if int(device["Devices"][0]["DeviceType"]) == DEVICE_TYPE_DEHUMIDIFIER: + if int(device.get("DeviceType")) == DEVICE_TYPE_DEHUMIDIFIER: humidifiers.append( PanasonicDehumidifier( coordinator, @@ -54,12 +52,10 @@ async def async_setup_entry(hass, entry, async_add_entities) -> bool: class PanasonicDehumidifier(PanasonicBaseEntity, HumidifierEntity): - @property def available(self) -> bool: status = self.coordinator.data[self.index]["status"] - _is_on_status = bool(int(status.get("0x00") or 0)) - return _is_on_status + return status.get("0x00") != None @property def label(self) -> str: @@ -68,9 +64,7 @@ def label(self) -> str: @property def target_humidity(self) -> int: status = self.coordinator.data[self.index]["status"] - _target_humidity = DEHUMIDIFIER_AVAILABLE_HUMIDITY[ - int(status.get("0x04") or 0) - ] + _target_humidity = DEHUMIDIFIER_AVAILABLE_HUMIDITY[int(status.get("0x04", 0))] _LOGGER.debug(f"[{self.label}] target_humidity: {_target_humidity}") return _target_humidity @@ -90,7 +84,7 @@ def mode(self) -> str: )[0]["Parameters"] target_mode = list( filter(lambda m: m[1] == int(status.get("0x01") or 0), raw_mode_list) - ) + )[0] _mode = target_mode[0] if len(target_mode) > 0 else "" _LOGGER.debug(f"[{self.label}] _mode: {_mode}") return _mode diff --git a/custom_components/panasonic_smart_app/number.py b/custom_components/panasonic_smart_app/number.py index 1e861be..dac2172 100644 --- a/custom_components/panasonic_smart_app/number.py +++ b/custom_components/panasonic_smart_app/number.py @@ -5,7 +5,6 @@ from .entity import PanasonicBaseEntity from .const import ( DOMAIN, - UPDATE_INTERVAL, DEVICE_TYPE_AC, DEVICE_TYPE_DEHUMIDIFIER, DATA_CLIENT, @@ -29,7 +28,6 @@ ) _LOGGER = logging.getLogger(__package__) -SCAN_INTERVAL = timedelta(seconds=UPDATE_INTERVAL) async def async_setup_entry(hass, entry, async_add_entities) -> bool: @@ -44,10 +42,10 @@ async def async_setup_entry(hass, entry, async_add_entities) -> bool: current_device_commands = [ command for command in commands - if command["ModelType"] == device["Devices"][0]["ModelType"] + if command["ModelType"] == device.get("ModelType") ] - if int(device["Devices"][0]["DeviceType"]) == DEVICE_TYPE_DEHUMIDIFIER: + if int(device.get("DeviceType")) == DEVICE_TYPE_DEHUMIDIFIER: numbers.append( PanasonicDehumidifierOffTimer( coordinator, @@ -79,7 +77,7 @@ async def async_setup_entry(hass, entry, async_add_entities) -> bool: ) ) - if int(device["Devices"][0]["DeviceType"]) == DEVICE_TYPE_AC: + if int(device.get("DeviceType")) == DEVICE_TYPE_AC: numbers.append( PanasonicACOffTimer( @@ -118,11 +116,10 @@ async def async_setup_entry(hass, entry, async_add_entities) -> bool: class PanasonicDehumidifierOnTimer(PanasonicBaseEntity, NumberEntity): - @property def available(self) -> bool: status = self.coordinator.data[self.index]["status"] - _is_on_status = int(status.get("0x00") or -1) == 0 + _is_on_status = int(status.get("0x00", -1)) == 0 return _is_on_status @property @@ -159,11 +156,10 @@ async def async_set_value(self, value: float) -> None: class PanasonicDehumidifierOffTimer(PanasonicBaseEntity, NumberEntity): - @property def available(self) -> bool: status = self.coordinator.data[self.index]["status"] - _is_on_status = bool(int(status.get("0x00") or 0)) + _is_on_status = bool(int(status.get("0x00", 0))) return _is_on_status @property @@ -200,11 +196,13 @@ async def async_set_value(self, value: float) -> None: class PanasonicACOnTimer(PanasonicBaseEntity, NumberEntity): - @property def available(self) -> bool: status = self.coordinator.data[self.index]["status"] - _is_on_status = int(status.get("0x00") or -1) == 0 + if status.get("0x00") == None: + return False + + _is_on_status = int(status.get("0x00")) == 0 return _is_on_status @property @@ -241,11 +239,10 @@ async def async_set_value(self, value: float) -> None: class PanasonicACOffTimer(PanasonicBaseEntity, NumberEntity): - @property def available(self) -> bool: status = self.coordinator.data[self.index]["status"] - _is_on_status = bool(int(status.get("0x00") or 0)) + _is_on_status = bool(int(status.get("0x00", 0))) return _is_on_status @property diff --git a/custom_components/panasonic_smart_app/select.py b/custom_components/panasonic_smart_app/select.py new file mode 100644 index 0000000..aa3fbd4 --- /dev/null +++ b/custom_components/panasonic_smart_app/select.py @@ -0,0 +1,90 @@ +from datetime import timedelta +import logging +from homeassistant.components.select import SelectEntity + +from .entity import PanasonicBaseEntity +from .const import ( + DOMAIN, + DEVICE_TYPE_DEHUMIDIFIER, + DATA_CLIENT, + DATA_COORDINATOR, + LABEL_DEHUMIDIFIER_FAN_MODE, + ICON_FAN, +) + +_LOGGER = logging.getLogger(__package__) + + +async def async_setup_entry(hass, entry, async_add_entities) -> bool: + client = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] + coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] + devices = coordinator.data + sensors = [] + + for index, device in enumerate(devices): + if int(device.get("DeviceType")) == DEVICE_TYPE_DEHUMIDIFIER: + sensors.append( + PanasonoicFanModeSensor( + coordinator, + index, + client, + device, + ) + ) + + async_add_entities(sensors, True) + + return True + + +class PanasonoicFanModeSensor(PanasonicBaseEntity, SelectEntity): + @property + def available(self) -> bool: + status = self.coordinator.data[self.index]["status"] + _is_on_status = bool(int(status.get("0x00", 0))) + return _is_on_status + + @property + def label(self) -> str: + return f"{self.nickname} {LABEL_DEHUMIDIFIER_FAN_MODE}" + + @property + def icon(self) -> str: + return ICON_FAN + + @property + def options(self) -> list: + raw_mode_list = list( + filter(lambda c: c["CommandType"] == "0x0E", self.commands) + )[0]["Parameters"] + + def mode_extractor(mode): + return mode[0] + + mode_list = list(map(mode_extractor, raw_mode_list)) + return mode_list + + @property + def current_option(self) -> bool: + status = self.coordinator.data[self.index]["status"] + raw_mode_list = list( + filter(lambda c: c["CommandType"] == "0x0E", self.commands) + )[0]["Parameters"] + target_option = list( + filter(lambda m: m[1] == int(status.get("0x0e") or 0), raw_mode_list) + )[0] + _current_option = target_option[0] if len(target_option) > 0 else "" + _LOGGER.debug(f"[{self.label}] current_option: {_current_option}") + return _current_option + + async def async_select_option(self, option: str) -> None: + raw_mode_list = list( + filter(lambda c: c["CommandType"] == "0x0E", self.commands) + )[0]["Parameters"] + target_option = list(filter(lambda m: m[0] == option, raw_mode_list)) + if len(target_option) > 0: + _LOGGER.debug(f"[{self.label}] Set fan mode to {option}") + await self.client.set_command(self.auth, 142, target_option[0]) + await self.coordinator.async_request_refresh() + else: + return diff --git a/custom_components/panasonic_smart_app/sensor.py b/custom_components/panasonic_smart_app/sensor.py index 963a789..af3afce 100644 --- a/custom_components/panasonic_smart_app/sensor.py +++ b/custom_components/panasonic_smart_app/sensor.py @@ -5,7 +5,9 @@ STATE_UNAVAILABLE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_ENERGY, TEMP_CELSIUS, + ENERGY_KILO_WATT_HOUR, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, PERCENTAGE, ) @@ -13,7 +15,6 @@ from .entity import PanasonicBaseEntity from .const import ( DOMAIN, - UPDATE_INTERVAL, DEVICE_TYPE_DEHUMIDIFIER, DEVICE_TYPE_AC, DATA_CLIENT, @@ -21,13 +22,14 @@ LABEL_PM25, LABEL_HUMIDITY, LABEL_OUTDOOR_TEMPERATURE, + LABEL_ENERGY, ICON_PM25, ICON_THERMOMETER, ICON_HUMIDITY, + ICON_ENERGY, ) _LOGGER = logging.getLogger(__package__) -SCAN_INTERVAL = timedelta(seconds=UPDATE_INTERVAL) async def async_setup_entry(hass, entry, async_add_entities) -> bool: @@ -37,7 +39,16 @@ async def async_setup_entry(hass, entry, async_add_entities) -> bool: sensors = [] for index, device in enumerate(devices): - device_type = int(device["Devices"][0]["DeviceType"]) + device_type = int(device.get("DeviceType")) + + sensors.append( + PanasonicEnergySensor( + coordinator, + index, + client, + device, + ) + ) if device_type == DEVICE_TYPE_DEHUMIDIFIER: sensors.append( @@ -113,7 +124,7 @@ def icon(self) -> str: @property def state(self) -> int: status = self.coordinator.data[self.index]["status"] - _pm25 = int(status.get("0x53") or -1) + _pm25 = float(status.get("0x53") or -1) _LOGGER.debug(f"[{self.label}] state: {_pm25}") return _pm25 if _pm25 >= 0 else STATE_UNAVAILABLE @@ -142,12 +153,34 @@ def state(self) -> int: status = self.coordinator.data[self.index]["status"] _outside_temperature = float(status.get("0x21") or -1) _LOGGER.debug(f"[{self.label}] state: {_outside_temperature}") - return ( - _outside_temperature - if _outside_temperature >= 0 - else STATE_UNAVAILABLE - ) + return _outside_temperature if _outside_temperature >= 0 else STATE_UNAVAILABLE @property def unit_of_measurement(self) -> str: return TEMP_CELSIUS + + +class PanasonicEnergySensor(PanasonicBaseEntity, SensorEntity): + """ Panasonic energy sensor """ + + @property + def label(self) -> str: + return f"{self.nickname} {LABEL_ENERGY}" + + @property + def icon(self) -> str: + return ICON_ENERGY + + @property + def device_class(self) -> str: + return DEVICE_CLASS_ENERGY + + @property + def state(self) -> int: + energy = self.coordinator.data[self.index]["energy"] + _LOGGER.debug(f"[{self.label}] state: {energy}") + return energy if energy >= 0 else STATE_UNAVAILABLE + + @property + def unit_of_measurement(self) -> str: + return ENERGY_KILO_WATT_HOUR diff --git a/custom_components/panasonic_smart_app/smartApp/__init__.py b/custom_components/panasonic_smart_app/smartApp/__init__.py index 4f15159..b5b9c81 100644 --- a/custom_components/panasonic_smart_app/smartApp/__init__.py +++ b/custom_components/panasonic_smart_app/smartApp/__init__.py @@ -1,5 +1,6 @@ """ Panasonic Smart App API """ from typing import Literal +from datetime import datetime import asyncio import logging @@ -17,11 +18,13 @@ USER_AGENT, SECONDS_BETWEEN_REQUEST, REQUEST_TIMEOUT, + COMMANDS_PER_REQUEST, HTTP_EXPECTATION_FAILED, HTTP_TOO_MANY_REQUESTS, EXCEPTION_COMMAND_NOT_FOUND, EXCEPTION_INVALID_REFRESH_TOKEN, ) +from .utils import chunks from . import urls _LOGGER = logging.getLogger(__name__) @@ -37,8 +40,12 @@ async def wrapper_call(*args, **kwargs): except (PanasonicInvalidRefreshToken, PanasonicLoginFailed): await args[0].login() return await func(*args, **kwargs) - except (PanasonicDeviceOffline, Exception) as exception: - _LOGGER.info(exception.message) + except ( + PanasonicDeviceOffline, + PanasonicExceedRateLimit, + Exception, + ) as exception: + _LOGGER.info(exception) return {} return wrapper_call @@ -92,7 +99,7 @@ async def get_devices(self): method="GET", headers=headers, endpoint=urls.get_devices() ) - self._devices = response["GWList"] + self._devices = response["GwList"] self._commands = response["CommandList"] return self._devices @@ -116,13 +123,73 @@ async def get_device_info( data=[commands], ) result = {} - for device in response["devices"]: - for info in device.get("Info"): + device = response.get("devices")[0] + for info in device.get("Info"): + command = info.get("CommandType") + status = info.get("status") + result[command] = status + return result + + async def get_device_overview(self): + headers = {"cptoken": self._cp_token} + response = await self.request( + method="GET", + headers=headers, + endpoint=urls.get_device_overview(), + ) + result = {} + for device in response.get("GwList"): + for info in device.get("List"): command = info.get("CommandType") - status = info.get("status") + status = info.get("Status") result[command] = status return result + async def get_device_with_info(self, status_code_mapping: dict): + + devices = await self.get_devices() + energy_report = await self.get_energy_report() + device_overview = await self.get_device_overview() + + for device in devices: + device_type = int(device.get("DeviceType")) + device["energy"] = energy_report.get(device.get("GWID")) + if device_type in status_code_mapping.keys(): + status_codes = chunks( + status_code_mapping[device_type], COMMANDS_PER_REQUEST + ) + device["status"] = {} + for codes in status_codes: + try: + device["status"].update( + await self.get_device_info(device.get("Auth"), codes) + ) + except PanasonicExceedRateLimit: + _LOGGER.info("超量使用 API,目前功能將受限制") + device["status"].update(device_overview.get(device.get("GWID"))) + break + + return devices + + async def get_energy_report(self): + headers = {"cptoken": self._cp_token} + payload = { + "name": "Power", + "from": datetime.today().replace(day=1).strftime("%Y/%m/%d"), + "unit": "day", + "max_num": 31, + } + response = await self.request( + method="POST", + headers=headers, + endpoint=urls.get_energy_report(), + data=payload, + ) + result = {} + for device in response.get("GwList"): + result[device.get("GwID")] = float(device.get("Total_kwh")) + return result + @tryApiStatus async def set_command(self, deviceId=None, command=0, value=0): headers = {"cptoken": self._cp_token, "auth": deviceId} @@ -146,7 +213,9 @@ async def request( resp = None headers["user-agent"] = USER_AGENT - _LOGGER.debug(f"Making request to {endpoint} with headers {headers}") + _LOGGER.debug( + f"Making request to {endpoint} with headers {headers} and data {data}" + ) try: response = await self._session.request( method, @@ -157,7 +226,7 @@ async def request( timeout=REQUEST_TIMEOUT, ) except: - auth = headers["auth"] or None + auth = headers.get("auth") if auth: device = list( filter(lambda device: device["auth"] == auth, self._devices) @@ -177,7 +246,7 @@ async def request( resp = await response.json() if resp.get("StateMsg") == EXCEPTION_COMMAND_NOT_FOUND: - auth = headers["auth"] + auth = headers.get("auth") if auth: device = list( filter(lambda device: device["auth"] == auth, self._devices) @@ -188,7 +257,7 @@ async def request( else: raise PanasonicDeviceOffline elif resp.get("StateMsg") == EXCEPTION_INVALID_REFRESH_TOKEN: - raise PanasonicTokenExpired + raise PanasonicTokenExpired else: _LOGGER.error( "Failed to access API. Returned" " %d: %s", diff --git a/custom_components/panasonic_smart_app/smartApp/const.py b/custom_components/panasonic_smart_app/smartApp/const.py index 5cdbd55..92bb873 100644 --- a/custom_components/panasonic_smart_app/smartApp/const.py +++ b/custom_components/panasonic_smart_app/smartApp/const.py @@ -1,9 +1,10 @@ BASE_URL = "https://ems2.panasonic.com.tw/api" APP_TOKEN = "D8CBFF4C-2824-4342-B22D-189166FEF503" -USER_AGENT = "Dalvik/2.1.0 (Linux; U; Android 7.1.2; SM-G930K Build/N2G48H)" +USER_AGENT = "okhttp/4.9.1" SECONDS_BETWEEN_REQUEST = 2 -REQUEST_TIMEOUT = 20 +REQUEST_TIMEOUT = 30 +COMMANDS_PER_REQUEST = 6 HTTP_EXPECTATION_FAILED = 417 HTTP_TOO_MANY_REQUESTS = 429 @@ -11,3 +12,4 @@ EXCEPTION_COMMAND_NOT_FOUND = "無法透過CommandId取得Commmand" EXCEPTION_TOKEN_EXPIRED = "無法依據您的CPToken,auth取得相關資料" EXCEPTION_INVALID_REFRESH_TOKEN = "無效RefreshToken" +EXCEPTION_REACH_RATE_LIMIT = "系統檢測您當前超量使用" \ No newline at end of file diff --git a/custom_components/panasonic_smart_app/smartApp/urls.py b/custom_components/panasonic_smart_app/smartApp/urls.py index 3ea8715..5bf9978 100644 --- a/custom_components/panasonic_smart_app/smartApp/urls.py +++ b/custom_components/panasonic_smart_app/smartApp/urls.py @@ -4,12 +4,12 @@ def login(): - url = f"{BASE_URL}/Userlogin1" + url = f"{BASE_URL}/userlogin1" return url def get_devices(): - url = f"{BASE_URL}/UserGetRegisteredGWList1" + url = f"{BASE_URL}/UserGetRegisteredGwList2" return url @@ -18,6 +18,16 @@ def get_device_info(): return url +def get_energy_report(): + url = f"{BASE_URL}/UserGetInfo" + return url + + +def get_device_overview(): + url = f"{BASE_URL}/UserGetDeviceStatus" + return url + + def set_command(): url = f"{BASE_URL}/DeviceSetCommand" return url diff --git a/custom_components/panasonic_smart_app/smartApp/utils.py b/custom_components/panasonic_smart_app/smartApp/utils.py new file mode 100644 index 0000000..d550d39 --- /dev/null +++ b/custom_components/panasonic_smart_app/smartApp/utils.py @@ -0,0 +1,2 @@ +def chunks(L, n): + return [L[x : x + n] for x in range(0, len(L), n)] \ No newline at end of file diff --git a/custom_components/panasonic_smart_app/switch.py b/custom_components/panasonic_smart_app/switch.py new file mode 100644 index 0000000..b47e10b --- /dev/null +++ b/custom_components/panasonic_smart_app/switch.py @@ -0,0 +1,250 @@ +from datetime import timedelta +import logging +from homeassistant.components.switch import SwitchEntity +from homeassistant.const import STATE_UNAVAILABLE + +from .entity import PanasonicBaseEntity +from .const import ( + DOMAIN, + DEVICE_TYPE_AC, + DATA_CLIENT, + DATA_COORDINATOR, + LABEL_NANOE, + LABEL_ECONAVI, + LABEL_BUZZER, + LABEL_TURBO, + ICON_NANOE, + ICON_ECONAVI, + ICON_BUZZER, + ICON_TURBO, +) + +_LOGGER = logging.getLogger(__package__) + + +async def async_setup_entry(hass, entry, async_add_entities) -> bool: + client = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] + coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] + devices = coordinator.data + commands = client.get_commands() + switches = [] + + for index, device in enumerate(devices): + device_type = int(device.get("DeviceType")) + current_device_commands = [ + command + for command in commands + if command["ModelType"] == device.get("ModelType") + ][0]["JSON"][0]["list"] + command_types = list(map(lambda c: c["CommandType"].lower(), current_device_commands)) + + if device_type == DEVICE_TYPE_AC: + + if "0x08" in command_types: + switches.append( + PanasonicACNanoe( + coordinator, + index, + client, + device, + ) + ) + if "0x1b" in command_types: + switches.append( + PanasonicACEconavi( + coordinator, + index, + client, + device, + ) + ) + if "0x1f" in command_types: + switches.append( + PanasonicACBuzzer( + coordinator, + index, + client, + device, + ) + ) + if "0x1a" in command_types: + switches.append( + PanasonicACTurbo( + coordinator, + index, + client, + device, + ) + ) + + async_add_entities(switches, True) + + return True + + +class PanasonicACNanoe(PanasonicBaseEntity, SwitchEntity): + """ Panasonic AC nanoe switch """ + + @property + def available(self) -> bool: + status = self.coordinator.data[self.index]["status"] + _is_on_status = bool(int(status.get("0x00", 0))) + return _is_on_status + + @property + def label(self): + return f"{self.nickname} {LABEL_NANOE}" + + @property + def icon(self) -> str: + return ICON_NANOE + + @property + def device_class(self) -> str: + return "switch" + + @property + def is_on(self) -> int: + status = self.coordinator.data[self.index]["status"] + _nanoe_status = status.get("0x08") + if _nanoe_status == None: + return STATE_UNAVAILABLE + _is_on = bool(int(_nanoe_status)) + _LOGGER.debug(f"[{self.label}] is_on: {_is_on}") + return _is_on + + async def async_turn_on(self) -> None: + _LOGGER.debug(f"[{self.label}] Turning on nanoe") + await self.client.set_command(self.auth, 136, 1) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self) -> None: + _LOGGER.debug(f"[{self.label}] Turning off nanoe") + await self.client.set_command(self.auth, 136, 0) + await self.coordinator.async_request_refresh() + + +class PanasonicACEconavi(PanasonicBaseEntity, SwitchEntity): + """ Panasonic AC ECONAVI switch """ + + @property + def available(self) -> bool: + status = self.coordinator.data[self.index]["status"] + _is_on_status = bool(int(status.get("0x00", 0))) + return _is_on_status + + @property + def label(self): + return f"{self.nickname} {LABEL_ECONAVI}" + + @property + def icon(self) -> str: + return ICON_ECONAVI + + @property + def device_class(self) -> str: + return "switch" + + @property + def is_on(self) -> int: + status = self.coordinator.data[self.index]["status"] + _nanoe_status = status.get("0x1b") + if _nanoe_status == None: + return STATE_UNAVAILABLE + _is_on = bool(int(_nanoe_status)) + _LOGGER.debug(f"[{self.label}] is_on: {_is_on}") + return _is_on + + async def async_turn_on(self) -> None: + _LOGGER.debug(f"[{self.label}] Turning on ECONAVI") + await self.client.set_command(self.auth, 155, 1) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self) -> None: + _LOGGER.debug(f"[{self.label}] Turning off ECONAVI") + await self.client.set_command(self.auth, 155, 0) + await self.coordinator.async_request_refresh() + + +class PanasonicACBuzzer(PanasonicBaseEntity, SwitchEntity): + """ Panasonic AC buzzer switch """ + + @property + def available(self) -> bool: + status = self.coordinator.data[self.index]["status"] + _is_on_status = bool(int(status.get("0x00", 0))) + return _is_on_status + + @property + def label(self): + return f"{self.nickname} {LABEL_BUZZER}" + + @property + def icon(self) -> str: + return ICON_BUZZER + + @property + def device_class(self) -> str: + return "switch" + + @property + def is_on(self) -> int: + status = self.coordinator.data[self.index]["status"] + _nanoe_status = status.get("0x1e") + if _nanoe_status == None: + return STATE_UNAVAILABLE + _is_on = bool(int(_nanoe_status)) + _LOGGER.debug(f"[{self.label}] is_on: {_is_on}") + return _is_on + + async def async_turn_on(self) -> None: + _LOGGER.debug(f"[{self.label}] Turning on buzzer") + await self.client.set_command(self.auth, 158, 0) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self) -> None: + _LOGGER.debug(f"[{self.label}] Turning off buzzer") + await self.client.set_command(self.auth, 158, 1) + await self.coordinator.async_request_refresh() + + +class PanasonicACTurbo(PanasonicBaseEntity, SwitchEntity): + """ Panasonic AC turbo switch """ + + @property + def available(self) -> bool: + status = self.coordinator.data[self.index]["status"] + _is_on_status = bool(int(status.get("0x00", 0))) + return _is_on_status + + @property + def label(self): + return f"{self.nickname} {LABEL_TURBO}" + + @property + def icon(self) -> str: + return ICON_TURBO + + @property + def device_class(self) -> str: + return "switch" + + @property + def is_on(self) -> int: + status = self.coordinator.data[self.index]["status"] + _nanoe_status = status.get("0x1a") + if _nanoe_status == None: + return STATE_UNAVAILABLE + _is_on = bool(int(_nanoe_status)) + _LOGGER.debug(f"[{self.label}] is_on: {_is_on}") + return _is_on + + async def async_turn_on(self) -> None: + _LOGGER.debug(f"[{self.label}] Turning on buzzer") + await self.client.set_command(self.auth, 154, 1) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self) -> None: + _LOGGER.debug(f"[{self.label}] Turning off buzzer") + await self.client.set_command(self.auth, 154, 0) + await self.coordinator.async_request_refresh() diff --git a/custom_components/panasonic_smart_app/translations/en.json b/custom_components/panasonic_smart_app/translations/en.json index 8d35338..1f92ca9 100644 --- a/custom_components/panasonic_smart_app/translations/en.json +++ b/custom_components/panasonic_smart_app/translations/en.json @@ -6,7 +6,8 @@ "description": "Login with your Panasonic Smart App credentials.", "data": { "username": "Panasonic Smart App username", - "password": "Panasonic Smart App password" + "password": "Panasonic Smart App password", + "update_interval": "Update interval (second)" } } }, diff --git a/custom_components/panasonic_smart_app/translations/zh-Hant.json b/custom_components/panasonic_smart_app/translations/zh-Hant.json index adb6fac..454f02e 100644 --- a/custom_components/panasonic_smart_app/translations/zh-Hant.json +++ b/custom_components/panasonic_smart_app/translations/zh-Hant.json @@ -6,7 +6,8 @@ "description": "登入您的 Panasonic Smart App 帳戶", "data": { "username": "Panasonic Smart App 帳號", - "password": "Panasonic Smart App 密碼" + "password": "Panasonic Smart App 密碼", + "update_interval": "更新時間間隔(秒)" } } }, diff --git a/info.md b/info.md index c3f2360..b8d84a3 100644 --- a/info.md +++ b/info.md @@ -45,18 +45,25 @@ Feel free to report working device by opening an [issue](https://github.com/osk2 ### Available Entities -| Device Type | Entity Type | Note | -| ------------ | ------------- | ---------------------------- | -| AC | climate | | -| | number | On timer (Only if supported) | -| | number | Off timer | -| | sensor | Outdoor temperature sensor | -| Dehumidifier | humidifier | | -| | number | On timer (Only if supported) | -| | number | Off timer | -| | sensor | Environment humidity sensor | -| | sensor | PM2.5 sensor | -| | binary_sensor | Water tank status sensor | +| Device Type | Entity Type | Note | +| ------------ | ------------- | --------------------------- | +| AC | climate | | +| | number | On timer\* | +| | number | Off timer | +| | sensor | Outdoor temperature sensor | +| | switch | nanoe switch\* | +| | switch | ECONAVI swtich\* | +| | switch | Buzzer switch\* | +| | switch | Turbo mode switch\* | +| Dehumidifier | humidifier | | +| | number | On timer\* | +| | number | Off timer | +| | select | Fan mode | +| | sensor | Environment humidity sensor | +| | sensor | PM2.5 sensor | +| | binary_sensor | Water tank status sensor | + +\*Only available if feature is supported. For missing entities, open an [issue](https://github.com/osk2/panasonic_smart_app/issues) or submit a PR 💪