This commit is contained in:
unknown
2023-09-09 00:14:41 +08:00
parent 34c8e06cd8
commit 10d127c940
17 changed files with 690 additions and 125 deletions

View File

@@ -1,6 +1,11 @@
import logging
import os
import base64
from homeassistant.util.json import load_json
try:
from homeassistant.helpers.json import save_json
except ImportError:
from homeassistant.util.json import save_json
from homeassistant.core import HomeAssistant
from homeassistant.const import (
Platform,
@@ -11,19 +16,53 @@ from homeassistant.const import (
CONF_DEVICE_ID,
CONF_PROTOCOL,
CONF_TOKEN,
CONF_NAME
CONF_NAME,
CONF_DEVICE,
CONF_ENTITIES
)
from .device_map.device_mapping import DEVICE_MAPPING
from .core.device import MiedaDevice
from .const import (
DOMAIN,
DEVICES,
CONFIG_PATH,
CONF_KEY,
CONF_ACCOUNT,
)
ALL_PLATFORM = [
Platform.BINARY_SENSOR,
Platform.SENSOR,
Platform.SWITCH,
Platform.CLIMATE,
]
_LOGGER = logging.getLogger(__name__)
def load_device_config(hass, device_type, sn8):
os.makedirs(hass.config.path(CONFIG_PATH), exist_ok=True)
config_file = hass.config.path(f"{CONFIG_PATH}/{sn8}.json")
json_data = load_json(config_file, default={})
d_type = "0x%02X" % device_type
if len(json_data) >0:
json_data = json_data.get(sn8)
elif d_type in DEVICE_MAPPING:
if sn8 in DEVICE_MAPPING[d_type]:
json_data = DEVICE_MAPPING[d_type][sn8]
save_data = {sn8: json_data}
save_json(config_file, save_data)
elif "default" in DEVICE_MAPPING[d_type]:
json_data = DEVICE_MAPPING[d_type]["default"]
save_data = {sn8: json_data}
save_json(config_file, save_data)
return json_data
async def update_listener(hass, config_entry):
pass
async def async_setup(hass: HomeAssistant, hass_config: dict):
hass.data.setdefault(DOMAIN, {})
cjson = os.getcwd() + "/cjson.lua"
@@ -56,10 +95,10 @@ async def async_setup_entry(hass: HomeAssistant, config_entry):
port = config_entry.data.get(CONF_PORT)
model = config_entry.data.get(CONF_MODEL)
protocol = config_entry.data.get(CONF_PROTOCOL)
subtype = config_entry.data.get("subtype")
sn = config_entry.data.get("sn")
sn8 = config_entry.data.get("sn8")
lua_file = config_entry.data.get("lua_file")
_LOGGER.error(f"lua_file = {lua_file}")
if protocol == 3 and (key is None or key is None):
_LOGGER.error("For V3 devices, the key and the token is required.")
return False
@@ -73,6 +112,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry):
key=key,
protocol=protocol,
model=model,
subtype=subtype,
sn=sn,
sn8=sn8,
lua_file=lua_file,
@@ -83,10 +123,38 @@ async def async_setup_entry(hass: HomeAssistant, config_entry):
hass.data[DOMAIN] = {}
if DEVICES not in hass.data[DOMAIN]:
hass.data[DOMAIN][DEVICES] = {}
hass.data[DOMAIN][DEVICES][device_id] = device
for platform in [Platform.BINARY_SENSOR]:
hass.data[DOMAIN][DEVICES][device_id] = {}
hass.data[DOMAIN][DEVICES][device_id][CONF_DEVICE] = device
hass.data[DOMAIN][DEVICES][device_id][CONF_ENTITIES] = {}
config = load_device_config(hass, device_type, sn8)
if config is not None and len(config) > 0:
queries = config.get("queries")
if queries is not None and isinstance(queries, list):
device.queries = queries
centralized = config.get("centralized")
if centralized is not None and isinstance(centralized, list):
device.centralized = centralized
hass.data[DOMAIN][DEVICES][device_id]["manufacturer"] = config.get("manufacturer")
hass.data[DOMAIN][DEVICES][device_id][CONF_ENTITIES] = config.get(CONF_ENTITIES)
for platform in ALL_PLATFORM:
hass.async_create_task(hass.config_entries.async_forward_entry_setup(
config_entry, platform))
#config_entry.add_update_listener(update_listener)
config_entry.add_update_listener(update_listener)
return True
return False
async def async_unload_entry(hass: HomeAssistant, config_entry):
device_id = config_entry.data.get(CONF_DEVICE_ID)
lua_file = config_entry.data.get("lua_file")
os.remove(lua_file)
if device_id is not None:
device = hass.data[DOMAIN][DEVICES][device_id][CONF_DEVICE]
if device is not None:
config_file = hass.config.path(f"{CONFIG_PATH}/{device.sn8}.json")
os.remove(config_file)
device.close()
hass.data[DOMAIN][DEVICES].pop(device_id)
for platform in ALL_PLATFORM:
await hass.config_entries.async_forward_entry_unload(config_entry, platform)
return True

View File

@@ -1,48 +1,45 @@
import logging
from homeassistant.const import (
CONF_DEVICE_ID,
STATE_ON,
STATE_OFF
from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorDeviceClass
)
from homeassistant.const import (
Platform,
CONF_DEVICE_ID,
CONF_DEVICE,
CONF_ENTITIES
)
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
from .midea_entities import MideaEntity
from .const import (
DOMAIN,
DEVICES,
DEVICES
)
_LOGGER = logging.getLogger(__name__)
from .midea_entities import MideaBinaryBaseEntity
async def async_setup_entry(hass, config_entry, async_add_entities):
device_id = config_entry.data.get(CONF_DEVICE_ID)
device = hass.data[DOMAIN][DEVICES].get(device_id)
binary_sensors = []
sensor = MideaDeviceStatusSensor(device, "status")
binary_sensors.append(sensor)
async_add_entities(binary_sensors)
device = hass.data[DOMAIN][DEVICES][device_id].get(CONF_DEVICE)
manufacturer = hass.data[DOMAIN][DEVICES][device_id].get("manufacturer")
entities = hass.data[DOMAIN][DEVICES][device_id].get(CONF_ENTITIES).get(Platform.BINARY_SENSOR)
devs = [MideaDeviceStatusSensorEntity(device, manufacturer,"Status", {})]
if entities is not None:
for entity_key, config in entities.items():
devs.append(MideaBinarySensorEntity(device, manufacturer, entity_key, config))
async_add_entities(devs)
class MideaDeviceStatusSensor(MideaEntity):
class MideaDeviceStatusSensorEntity(MideaBinaryBaseEntity, BinarySensorEntity):
@property
def device_class(self):
return BinarySensorDeviceClass.CONNECTIVITY
@property
def state(self):
return STATE_ON if self._device.connected else STATE_OFF
@property
def name(self):
return f"{self._device_name} Status"
@property
def icon(self):
return "mdi:devices"
@property
def is_on(self):
return self.state == STATE_ON
return self._device.connected
@property
def available(self):
@@ -57,3 +54,7 @@ class MideaDeviceStatusSensor(MideaEntity):
self.schedule_update_ha_state()
except Exception as e:
pass
class MideaBinarySensorEntity(MideaBinaryBaseEntity, BinarySensorEntity):
pass

View File

@@ -0,0 +1,191 @@
from homeassistant.components.climate import *
from homeassistant.const import (
Platform,
CONF_DEVICE_ID,
CONF_ENTITIES,
CONF_DEVICE,
)
from .const import (
DOMAIN,
DEVICES
)
from .core.logger import MideaLogger
from .midea_entities import MideaEntity, Rationale
async def async_setup_entry(hass, config_entry, async_add_entities):
device_id = config_entry.data.get(CONF_DEVICE_ID)
device = hass.data[DOMAIN][DEVICES][device_id].get(CONF_DEVICE)
manufacturer = hass.data[DOMAIN][DEVICES][device_id].get("manufacturer")
entities = hass.data[DOMAIN][DEVICES][device_id].get(CONF_ENTITIES).get(Platform.CLIMATE)
devs = []
if entities is not None:
for entity_key, config in entities.items():
devs.append(MideaClimateEntity(device, manufacturer, entity_key, config))
async_add_entities(devs)
class MideaClimateEntity(MideaEntity, ClimateEntity):
def __init__(self, device, manufacturer, entity_key, config):
super().__init__(device, manufacturer, entity_key, config)
self._key_power = self._config.get("power")
self._key_hvac_modes = self._config.get("hvac_modes")
self._key_preset_modes = self._config.get("preset_modes")
self._key_aux_heat = self._config.get("aux_heat")
self._key_swing_modes = self._config.get("swing_modes")
self._key_fan_modes = self._config.get("fan_modes")
self._key_current_temperature_low = self._config.get("current_temperature_low")
self._key_min_temp = self._config.get("min_temp")
self._key_max_temp = self._config.get("max_temp")
self._key_target_temperature = self._config.get("target_temperature")
self._attr_temperature_unit = self._config.get("temperature_unit")
self._attr_precision = self._config.get("precision")
@property
def state(self):
return self.hvac_mode
@property
def supported_features(self):
features = 0
if self._key_target_temperature is not None:
features |= ClimateEntityFeature.TARGET_TEMPERATURE
if self._key_preset_modes is not None:
features |= ClimateEntityFeature.PRESET_MODE
if self._key_aux_heat is not None:
features |= ClimateEntityFeature.AUX_HEAT
if self._key_swing_modes is not None:
features |= ClimateEntityFeature.SWING_MODE
if self._key_fan_modes is not None:
features |= ClimateEntityFeature.FAN_MODE
return features
@property
def current_temperature(self):
return self._device.get_attribute("indoor_temperature")
@property
def target_temperature(self):
if isinstance(self._key_target_temperature, list):
temp_int = self._device.get_attribute(self._key_target_temperature[0])
tem_dec = self._device.get_attribute(self._key_target_temperature[1])
if temp_int is not None and tem_dec is not None:
return temp_int + tem_dec
return None
else:
return self._device.get_attribute(self._key_target_temperature)
@property
def min_temp(self):
if isinstance(self._key_min_temp, str):
return float(self._device.get_attribute(self._key_min_temp))
else:
return float(self._key_min_temp)
@property
def max_temp(self):
if isinstance(self._key_max_temp, str):
return float(self._device.get_attribute(self._key_max_temp))
else:
return float(self._key_max_temp)
@property
def target_temperature_low(self):
return self.min_temp
@property
def target_temperature_high(self):
return self.max_temp
@property
def preset_modes(self):
return list(self._key_preset_modes.keys())
@property
def preset_mode(self):
return self.get_mode(self._key_preset_modes)
@property
def fan_modes(self):
return list(self._key_fan_modes.keys())
@property
def fan_mode(self):
return self.get_mode(self._key_fan_modes, Rationale.LESS)
@property
def swing_modes(self):
return list(self._key_swing_modes.keys())
@property
def swing_mode(self):
return self.get_mode(self._key_swing_modes)
@property
def is_on(self) -> bool:
return self.hvac_mode != HVACMode.OFF
@property
def hvac_mode(self):
return self.get_mode(self._key_hvac_modes)
@property
def hvac_modes(self):
return list(self._key_hvac_modes.keys())
@property
def is_aux_heat(self):
return self._device.get_attribute(self._key_aux_heat) == "on"
def turn_on(self):
self._device.set_attribute(attribute=self._key_power, value="on")
def turn_off(self):
self._device.set_attribute(attribute=self._key_power, value="off")
def set_temperature(self, **kwargs):
if ATTR_TEMPERATURE not in kwargs:
return
temperature = kwargs.get(ATTR_TEMPERATURE)
temp_int, temp_dec = divmod(temperature, 1)
temp_int = int(temp_int)
hvac_mode = kwargs.get(ATTR_HVAC_MODE)
if hvac_mode is not None:
new_status = self._key_hvac_modes.get(hvac_mode)
else:
new_status = {}
if isinstance(self._key_target_temperature, list):
new_status[self._key_target_temperature[0]] = temp_int
new_status[self._key_target_temperature[1]] = temp_dec
else:
new_status[self._key_target_temperature] = temperature
MideaLogger.error(new_status)
self._device.set_attributes(new_status)
def set_fan_mode(self, fan_mode: str):
new_statis = self._key_fan_modes.get(fan_mode)
self._device.set_attributes(new_statis)
def set_preset_mode(self, preset_mode: str):
new_statis = self._key_preset_modes.get(preset_mode)
self._device.set_attributes(new_statis)
def set_hvac_mode(self, hvac_mode: str):
new_status = self._key_hvac_modes.get(hvac_mode)
self._device.set_attributes(new_status)
def set_swing_mode(self, swing_mode: str):
new_status = self._key_swing_modes.get(swing_mode)
self._device.set_attributes(new_status)
def turn_aux_heat_on(self) -> None:
self._device.set_attribute(attr=self._key_aux_heat, value="on")
def turn_aux_heat_off(self) -> None:
self._device.set_attribute(attr=self._key_aux_heat, value="off")
def update_state(self, status):
try:
self.schedule_update_ha_state()
except Exception as e:
pass

View File

@@ -129,6 +129,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"sn8": device.get("sn8"),
"sn": device.get("sn"),
"model": device.get("productModel"),
"subtype": int(device.get("modelNumber")) if device.get("modelNumber") is not None else 0,
"enterprise_code": device.get("enterpriseCode"),
"online": device.get("onlineStatus") == "1"
}
@@ -181,6 +182,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
key=key,
protocol=3,
model=None,
subtype = None,
sn=None,
sn8=None,
lua_file=None
@@ -202,6 +204,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
key=None,
protocol=2,
model=None,
subtype=None,
sn=None,
sn8=None,
lua_file=None
@@ -220,19 +223,20 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
CONF_PROTOCOL: current_device.get("protocol"),
CONF_IP_ADDRESS: current_device.get("ip_address"),
CONF_PORT: current_device.get("port"),
CONF_MODEL: self._device.get("model"),
CONF_TOKEN: use_token,
CONF_KEY: use_key,
"lua_file": file,
CONF_MODEL: self._device.get("model"),
"subtype": self._device.get("subtype"),
"sn": self._device.get("sn"),
"sn8": self._device.get("sn8"),
"lua_file": file,
})
else:
return await self.async_step_discover(error="invalid_input")
return self.async_show_form(
step_id="discover",
data_schema=vol.Schema({
vol.Required(CONF_IP_ADDRESS): str
vol.Required(CONF_IP_ADDRESS, default="auto"): str
}),
errors={"base": error} if error else None
)

View File

@@ -1,5 +1,6 @@
DOMAIN = "midea_meiju_codec"
STORAGE_PATH = f".storage/{DOMAIN}"
STORAGE_PATH = f".storage/{DOMAIN}/lua"
CONFIG_PATH = f".storage/{DOMAIN}/config"
DEVICES = "DEVICES"
CONF_ACCOUNT = "account"
CONF_HOME = "home"

View File

@@ -1,18 +1,18 @@
from aiohttp import ClientSession
from secrets import token_hex, token_urlsafe
from .security import CloudSecurity
from threading import Lock
import datetime
import logging
import time
import json
_LOGGER = logging.getLogger(__name__)
import logging
from aiohttp import ClientSession
from secrets import token_hex, token_urlsafe
from threading import Lock
from .security import CloudSecurity
CLIENT_TYPE = 1 # Android
FORMAT = 2 # JSON
APP_KEY = "4675636b"
_LOGGER = logging.getLogger(__name__)
class MideaCloudBase:
LANGUAGE = "en_US"
@@ -76,7 +76,7 @@ class MideaCloudBase:
response = json.loads(raw)
break
except Exception as e:
_LOGGER.debug(f"Cloud error: {repr(e)}")
_LOGGER.error(f"Cloud error: {repr(e)}")
if int(response["code"]) == 0 and "data" in response:
return response["data"]
return None
@@ -127,7 +127,7 @@ class MideaCloudBase:
udpid = CloudSecurity.get_udpid(device_id.to_bytes(6, "big"))
else:
udpid = CloudSecurity.get_udpid(device_id.to_bytes(6, "little"))
_LOGGER.debug(f"The udpid of deivce [{device_id}] generated "
_LOGGER.error(f"The udpid of deivce [{device_id}] generated "
f"with byte order '{'big' if byte_order_big else 'little'}': {udpid}")
response = await self.api_request(
"/v1/iot/secure/getToken",

View File

@@ -1,14 +1,11 @@
import threading
import socket
import time
from enum import IntEnum
from .security import LocalSecurity, MSGTYPE_HANDSHAKE_REQUEST, MSGTYPE_ENCRYPTED_REQUEST
from .packet_builder import PacketBuilder
from .lua_runtime import MideaCodec
import socket
import logging
import json
import time
_LOGGER = logging.getLogger(__name__)
from .logger import MideaLogger
class AuthException(Exception):
@@ -40,6 +37,7 @@ class MiedaDevice(threading.Thread):
key: str | None,
protocol: int,
model: str | None,
subtype: int | None,
sn: str | None,
sn8: str | None,
lua_file: str | None):
@@ -57,17 +55,21 @@ class MiedaDevice(threading.Thread):
self._protocol = protocol
self._model = model
self._updates = []
self._unsupported_protocol = []
self._is_run = False
self._device_protocol_version = 0
self._sub_type = None
self._subtype = subtype
self._sn = sn
self._sn8 = sn8
self._attributes = {}
self._attributes = {
"sn": sn,
"sn8": sn8,
"subtype": subtype
}
self._refresh_interval = 30
self._heartbeat_interval = 10
self._default_refresh_interval = 30
self._connected = False
self._lua_runtime = MideaCodec(lua_file, sn=sn) if lua_file is not None else None
self._queries = [{}]
self._centralized = []
self._lua_runtime = MideaCodec(lua_file, sn=sn, subtype=subtype) if lua_file is not None else None
@property
def device_name(self):
@@ -93,6 +95,10 @@ class MiedaDevice(threading.Thread):
def sn8(self):
return self._sn8
@property
def subtype(self):
return self._subtype
@property
def attributes(self):
return self._attributes
@@ -101,6 +107,50 @@ class MiedaDevice(threading.Thread):
def connected(self):
return self._connected
def set_refresh_interval(self, refresh_interval):
self._refresh_interval = refresh_interval
@property
def queries(self):
return self._queries
@queries.setter
def queries(self, queries: list):
self._queries = queries
@property
def centralized(self):
return self._centralized
@centralized.setter
def centralized(self, centralized: list):
self._centralized = centralized
def get_attribute(self, attribute):
return self._attributes.get(attribute)
def set_attribute(self, attribute, value):
if attribute in self._attributes.keys():
new_status = {}
for attr in self._centralized:
new_status[attr] = self._attributes.get(attr)
new_status[attribute] = value
set_cmd = self._lua_runtime.build_control(new_status)
self.build_send(set_cmd)
def set_attributes(self, attributes):
new_status = {}
for attr in self._centralized:
new_status[attr] = self._attributes.get(attr)
has_new = False
for attribute, value in attributes.items():
if attribute in self._attributes.keys():
has_new = True
new_status[attribute] = value
if has_new:
set_cmd = self._lua_runtime.build_control(new_status)
self.build_send(set_cmd)
@staticmethod
def fetch_v2_message(msg):
result = []
@@ -120,28 +170,28 @@ class MiedaDevice(threading.Thread):
try:
self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._socket.settimeout(10)
_LOGGER.debug(f"[{self._device_id}] Connecting to {self._ip_address}:{self._port}")
MideaLogger.debug(f"Connecting to {self._ip_address}:{self._port}", self._device_id)
self._socket.connect((self._ip_address, self._port))
_LOGGER.debug(f"[{self._device_id}] Connected")
MideaLogger.debug(f"Connected", self._device_id)
if self._protocol == 3:
self.authenticate()
_LOGGER.debug(f"[{self._device_id}] Authentication success")
MideaLogger.debug(f"Authentication success", self._device_id)
self.device_connected(True)
if refresh:
self.refresh_status()
return True
except socket.timeout:
_LOGGER.debug(f"[{self._device_id}] Connection timed out")
MideaLogger.debug(f"Connection timed out", self._device_id)
except socket.error:
_LOGGER.debug(f"[{self._device_id}] Connection error")
MideaLogger.debug(f"Connection error", self._device_id)
except AuthException:
_LOGGER.debug(f"[{self._device_id}] Authentication failed")
MideaLogger.debug(f"Authentication failed", self._device_id)
except ResponseException:
_LOGGER.debug(f"[{self._device_id}] Unexpected response received")
MideaLogger.debug(f"Unexpected response received", self._device_id)
except RefreshFailed:
_LOGGER.debug(f"[{self._device_id}] Refresh status is timed out")
MideaLogger.debug(f"Refresh status is timed out", self._device_id)
except Exception as e:
_LOGGER.error(f"[{self._device_id}] Unknown error: {e.__traceback__.tb_frame.f_globals['__file__']}, "
MideaLogger.error(f"Unknown error: {e.__traceback__.tb_frame.f_globals['__file__']}, "
f"{e.__traceback__.tb_lineno}, {repr(e)}")
self.device_connected(False)
return False
@@ -149,7 +199,7 @@ class MiedaDevice(threading.Thread):
def authenticate(self):
request = self._security.encode_8370(
self._token, MSGTYPE_HANDSHAKE_REQUEST)
_LOGGER.debug(f"[{self._device_id}] Handshaking")
MideaLogger.debug(f"Handshaking")
self._socket.send(request)
response = self._socket.recv(512)
if len(response) < 20:
@@ -167,20 +217,21 @@ class MiedaDevice(threading.Thread):
if self._socket is not None:
self._socket.send(data)
else:
_LOGGER.debug(f"[{self._device_id}] Send failure, device disconnected, data: {data.hex()}")
MideaLogger.debug(f"Send failure, device disconnected, data: {data.hex()}")
def send_message_v3(self, data, msg_type=MSGTYPE_ENCRYPTED_REQUEST):
data = self._security.encode_8370(data, msg_type)
self.send_message_v2(data)
def build_send(self, cmd):
_LOGGER.debug(f"[{self._device_id}] Sending: {cmd}")
MideaLogger.debug(f"Sending: {cmd}")
bytes_cmd = bytes.fromhex(cmd)
msg = PacketBuilder(self._device_id, bytes_cmd).finalize()
self.send_message(msg)
def refresh_status(self):
query_cmd = self._lua_runtime.build_query()
for query in self._queries:
query_cmd = self._lua_runtime.build_query(query)
self.build_send(query_cmd)
def parse_message(self, msg):
@@ -202,10 +253,10 @@ class MiedaDevice(threading.Thread):
cryptographic = message[40:-16]
if payload_len % 16 == 0:
decrypted = self._security.aes_decrypt(cryptographic)
_LOGGER.debug(f"[{self._device_id}] Received: {decrypted.hex()}")
MideaLogger.debug(f"Received: {decrypted.hex()}")
# 这就是最终消息
status = self._lua_runtime.decode_status(decrypted.hex())
_LOGGER.debug(f"[{self._device_id}] Decoded: {status}")
MideaLogger.debug(f"Decoded: {status}")
new_status = {}
for single in status.keys():
value = status.get(single)
@@ -229,7 +280,7 @@ class MiedaDevice(threading.Thread):
self._updates.append(update)
def update_all(self, status):
_LOGGER.debug(f"[{self._device_id}] Status update: {status}")
MideaLogger.debug(f"Status update: {status}")
for update in self._updates:
update(status)
@@ -241,17 +292,17 @@ class MiedaDevice(threading.Thread):
def close(self):
if self._is_run:
self._is_run = False
self._lua_runtime = None
self.close_socket()
def close_socket(self):
self._unsupported_protocol = []
self._buffer = b""
if self._socket:
self._socket.close()
self._socket = None
def set_ip_address(self, ip_address):
_LOGGER.debug(f"[{self._device_id}] Update IP address to {ip_address}")
MideaLogger.debug(f"Update IP address to {ip_address}")
self._ip_address = ip_address
self.close_socket()
@@ -283,7 +334,7 @@ class MiedaDevice(threading.Thread):
raise socket.error("Connection closed by peer")
result = self.parse_message(msg)
if result == ParseMessageResult.ERROR:
_LOGGER.debug(f"[{self._device_id}] Message 'ERROR' received")
MideaLogger.debug(f"Message 'ERROR' received")
self.close_socket()
break
elif result == ParseMessageResult.SUCCESS:
@@ -291,15 +342,15 @@ class MiedaDevice(threading.Thread):
except socket.timeout:
timeout_counter = timeout_counter + 1
if timeout_counter >= 120:
_LOGGER.debug(f"[{self._device_id}] Heartbeat timed out")
MideaLogger.debug(f"Heartbeat timed out")
self.close_socket()
break
except socket.error as e:
_LOGGER.debug(f"[{self._device_id}] Socket error {repr(e)}")
MideaLogger.debug(f"Socket error {repr(e)}")
self.close_socket()
break
except Exception as e:
_LOGGER.error(f"[{self._device_id}] Unknown error :{e.__traceback__.tb_frame.f_globals['__file__']}, "
MideaLogger.error(f"Unknown error :{e.__traceback__.tb_frame.f_globals['__file__']}, "
f"{e.__traceback__.tb_lineno}, {repr(e)}")
self.close_socket()
break

View File

@@ -1,14 +1,13 @@
import logging
import socket
import ifaddr
from ipaddress import IPv4Network
from .security import LocalSecurity
from .logger import MideaLogger
try:
import xml.etree.cElementTree as ET
except ImportError:
import xml.etree.ElementTree as ET
_LOGGER = logging.getLogger(__name__)
BROADCAST_MSG = bytearray([
0x5a, 0x5a, 0x01, 0x11, 0x48, 0x00, 0x92, 0x00,
@@ -34,7 +33,7 @@ DEVICE_INFO_MSG = bytearray([
def discover(discover_type=None, ip_address=None):
_LOGGER.debug(f"Begin discover, type: {discover_type}, ip_address: {ip_address}")
MideaLogger.debug(f"Begin discover, type: {discover_type}, ip_address: {ip_address}")
if discover_type is None:
discover_type = []
security = LocalSecurity()
@@ -55,7 +54,7 @@ def discover(discover_type=None, ip_address=None):
try:
data, addr = sock.recvfrom(512)
ip = addr[0]
_LOGGER.debug(f"Received broadcast from {addr}: {data.hex()}")
MideaLogger.debug(f"Received broadcast from {addr}: {data.hex()}")
if len(data) >= 104 and (data[:2].hex() == "5a5a" or data[8:10].hex() == "5a5a"):
if data[:2].hex() == "5a5a":
protocol = 2
@@ -70,7 +69,7 @@ def discover(discover_type=None, ip_address=None):
continue
encrypt_data = data[40:-16]
reply = security.aes_decrypt(encrypt_data)
_LOGGER.debug(f"Declassified reply: {reply.hex()}")
MideaLogger.debug(f"Declassified reply: {reply.hex()}")
ssid = reply[41:41 + reply[40]].decode("utf-8")
device_type = ssid.split("_")[1]
port = bytes2port(reply[4:8])
@@ -105,13 +104,13 @@ def discover(discover_type=None, ip_address=None):
}
if len(discover_type) == 0 or device.get("type") in discover_type:
found_devices[device_id] = device
_LOGGER.debug(f"Found a supported device: {device}")
MideaLogger.debug(f"Found a supported device: {device}")
else:
_LOGGER.debug(f"Found a unsupported device: {device}")
MideaLogger.debug(f"Found a unsupported device: {device}")
except socket.timeout:
break
except socket.error as e:
_LOGGER.debug(f"Socket error: {repr(e)}")
MideaLogger.debug(f"Socket error: {repr(e)}")
return found_devices
@@ -147,15 +146,15 @@ def get_device_info(device_ip, device_port: int):
sock.settimeout(8)
device_address = (device_ip, device_port)
sock.connect(device_address)
_LOGGER.debug(f"Sending to {device_ip}:{device_port} {DEVICE_INFO_MSG.hex()}")
MideaLogger.debug(f"Sending to {device_ip}:{device_port} {DEVICE_INFO_MSG.hex()}")
sock.sendall(DEVICE_INFO_MSG)
response = sock.recv(512)
except socket.timeout:
_LOGGER.warning(f"Connect the device {device_ip}:{device_port} timed out for 8s. "
MideaLogger.warning(f"Connect the device {device_ip}:{device_port} timed out for 8s. "
f"Don't care about a small amount of this. if many maybe not support."
)
except socket.error:
_LOGGER.warning(f"Can't connect to Device {device_ip}:{device_port}")
MideaLogger.warning(f"Can't connect to Device {device_ip}:{device_port}")
return response

View File

@@ -0,0 +1,36 @@
import inspect
import logging
from enum import IntEnum
class MideaLogType(IntEnum):
DEBUG = 1
WARN = 2
ERROR = 3
class MideaLogger:
@staticmethod
def _log(log_type, log, device_id):
frm = inspect.stack()[2]
mod = inspect.getmodule(frm[0])
if device_id is not None:
log = f"[{device_id}] {log}"
if log_type == MideaLogType.DEBUG:
logging.getLogger(mod.__name__).debug(log)
elif log_type == MideaLogType.WARN:
logging.getLogger(mod.__name__).warning(log)
elif log_type == MideaLogType.ERROR:
logging.getLogger(mod.__name__).error(log)
@staticmethod
def debug(log, device_id=None):
MideaLogger._log(MideaLogType.DEBUG, log, device_id)
@staticmethod
def warning(log, device_id=None):
MideaLogger._log(MideaLogType.WARN, log, device_id)
@staticmethod
def error(log, device_id=None):
MideaLogger._log(MideaLogType.ERROR, log, device_id)

View File

@@ -1,10 +1,7 @@
import lupa
import logging
import threading
import json
_LOGGER = logging.getLogger(__name__)
class LuaRuntime:
def __init__(self, file):
@@ -27,17 +24,17 @@ class LuaRuntime:
class MideaCodec(LuaRuntime):
def __init__(self, file, sn=None, sub_type=None):
def __init__(self, file, sn=None, subtype=None):
super().__init__(file)
self._sn = sn
self._sub_type = sub_type
self._subtype = subtype
def _build_base_dict(self):
device_info ={}
if self._sn is not None:
device_info["deviceSN"] = self._sn
if self._sub_type is not None:
device_info["deviceSubType"] = self._sub_type
if self._subtype is not None:
device_info["deviceSubType"] = self._subtype
base_dict = {
"deviceinfo": device_info
}

View File

@@ -6,7 +6,6 @@ class PacketBuilder:
def __init__(self, device_id: int, command):
self.command = None
self.security = LocalSecurity()
# aa20ac00000000000003418100ff03ff000200000000000000000000000006f274
# Init the packet with the header data.
self.packet = bytearray([
# 2 bytes - StaicHeader

View File

@@ -1,20 +1,18 @@
import hmac
import logging
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from Crypto.Util.strxor import strxor
from Crypto.Random import get_random_bytes
from hashlib import md5, sha256
from urllib.parse import urlparse
import hmac
import urllib
_LOGGER = logging.getLogger(__name__)
MSGTYPE_HANDSHAKE_REQUEST = 0x0
MSGTYPE_HANDSHAKE_RESPONSE = 0x1
MSGTYPE_ENCRYPTED_RESPONSE = 0x3
MSGTYPE_ENCRYPTED_REQUEST = 0x6
_LOGGER = logging.getLogger(__name__)
class CloudSecurity:

View File

@@ -0,0 +1,106 @@
from homeassistant.const import *
from homeassistant.components.sensor import SensorStateClass, SensorDeviceClass
from homeassistant.components.switch import SwitchDeviceClass
from homeassistant.components.climate import (
HVACMode,
PRESET_NONE,
PRESET_ECO,
PRESET_COMFORT,
PRESET_SLEEP,
PRESET_BOOST,
SWING_OFF,
SWING_BOTH,
SWING_VERTICAL,
SWING_HORIZONTAL,
FAN_AUTO,
FAN_LOW,
FAN_MEDIUM,
FAN_HIGH,
)
DEVICE_MAPPING = {
"0xAC": {
"default": {
"manufacturer": "美的",
"queries": [{}, {"query_type": "prevent_straight_wind"}],
"centralized": ["power", "temperature", "small_temperature", "mode", "eco", "comfort_power_save",
"comfort_sleep", "strong_wind", "wind_swing_lr", "wind_swing_lr", "wind_speed",
"ptc", "dry"],
"entities": {
Platform.CLIMATE: {
"thermostat": {
"name": "Thermostat",
"power": "power",
"target_temperature": ["temperature", "small_temperature"],
"hvac_modes": {
HVACMode.OFF: {"power": "off"},
HVACMode.HEAT: {"power": "on", "mode": "heat"},
HVACMode.COOL: {"power": "on", "mode": "cool"},
HVACMode.AUTO: {"power": "on", "mode": "auto"},
HVACMode.DRY: {"power": "on", "mode": "dry"},
HVACMode.FAN_ONLY: {"power": "on", "mode": "fan"}
},
"preset_modes": {
PRESET_NONE: {
"eco": "off",
"comfort_power_save": "off",
"comfort_sleep": "off",
"strong_wind": "off"
},
PRESET_ECO: {"eco": "on"},
PRESET_COMFORT: {"comfort_power_save": "on"},
PRESET_SLEEP: {"comfort_sleep": "on"},
PRESET_BOOST: {"strong_wind": "on"}
},
"swing_modes": {
SWING_OFF: {"wind_swing_lr": "off", "wind_swing_ud": "off"},
SWING_BOTH: {"wind_swing_lr": "on", "wind_swing_ud": "on"},
SWING_HORIZONTAL: {"wind_swing_lr": "on", "wind_swing_ud": "off"},
SWING_VERTICAL: {"wind_swing_lr": "off", "wind_swing_ud": "on"},
},
"fan_modes": {
"silent": {"wind_speed": 20},
FAN_LOW: {"wind_speed": 40},
FAN_MEDIUM: {"wind_speed": 60},
FAN_HIGH: {"wind_speed": 80},
"full": {"wind_speed": 100},
FAN_AUTO: {"wind_speed": 102}
},
"current_temperature": "indoor_temperature",
"aux_heat": "ptc",
"min_temp": 17,
"max_temp": 30,
"temperature_unit": TEMP_CELSIUS,
"precision": PRECISION_HALVES,
}
},
Platform.SWITCH: {
"dry": {
"name": "Dry",
"device_class": SwitchDeviceClass.SWITCH,
},
"prevent_straight_wind": {
"binary_rationale": [1, 2]
}
},
Platform.SENSOR: {
"indoor_temperature": {
"name": "室内温度",
"device_class": SensorDeviceClass.TEMPERATURE,
"unit": TEMP_CELSIUS,
"state_class": SensorStateClass.MEASUREMENT
},
"outdoor_temperature": {
"name": "室外机温度",
"device_class": SensorDeviceClass.TEMPERATURE,
"unit": TEMP_CELSIUS,
"state_class": SensorStateClass.MEASUREMENT
},
},
Platform.BINARY_SENSOR: {
"power": {}
}
}
}
},
}

View File

@@ -9,5 +9,5 @@
"iot_class": "local_polling",
"issue_tracker": "https://github.com/georgezhao2010/midea-meiju-codec/issues",
"requirements": ["lupa>=2.0"],
"version": "v0.0.2"
"version": "v0.0.3"
}

View File

@@ -1,32 +1,43 @@
from enum import IntEnum
from homeassistant.helpers.entity import Entity
from homeassistant.const import (
STATE_ON,
STATE_OFF
)
from .const import DOMAIN
from .core.logger import MideaLogger
class Rationale(IntEnum):
EQUALLY = 0
GREATER = 1
LESS = 2
class MideaEntity(Entity):
def __init__(self, device, entity_key: str):
def __init__(self, device, manufacturer: str | None, entity_key: str, config: dict):
self._device = device
self._device.register_update(self.update_state)
self._entity_key = entity_key
self._unique_id = f"{DOMAIN}.{self._device.device_id}_{entity_key}"
self.entity_id = self._unique_id
self._config = config
self._device_name = self._device.device_name
@property
def device(self):
return self._device
@property
def device_info(self):
return {
"manufacturer": "Midea",
"model": f"{self._device.model} ({self._device.sn8})",
self._attr_native_unit_of_measurement = self._config.get("unit")
self._attr_device_class = self._config.get("device_class")
self._attr_state_class = self._config.get("state_class")
self._attr_unit_of_measurement = self._config.get("unit")
self._attr_icon = self._config.get("icon")
self._attr_unique_id = f"{DOMAIN}.{self._device.device_id}_{self._entity_key}"
MideaLogger.debug(self._attr_unique_id)
self._attr_device_info = {
"manufacturer": "Midea" if manufacturer is None else manufacturer,
"model": f"{self._device.model}",
"identifiers": {(DOMAIN, self._device.device_id)},
"name": self._device_name
}
@property
def unique_id(self):
return self._unique_id
name = self._config.get("name")
if name is None:
name = self._entity_key.replace("_", " ").title()
self._attr_name = f"{self._device_name} {name}"
self.entity_id = self._attr_unique_id
@property
def should_poll(self):
@@ -34,15 +45,52 @@ class MideaEntity(Entity):
@property
def state(self):
return self._device.get_attribute(self._entity_key)
raise NotImplementedError
@property
def available(self):
return self._device.connected
def get_mode(self, key_of_modes, rationale: Rationale = Rationale.EQUALLY):
for mode, status in key_of_modes.items():
match = True
for attr, value in status.items():
state_value = self._device.get_attribute(attr)
if state_value is None:
match = False
break
if rationale is Rationale.EQUALLY and state_value != value:
match = False
break
if rationale is Rationale.GREATER and state_value < value:
match = False
break
if rationale is Rationale.LESS and state_value > value:
match = False
break
if match:
return mode
return None
def update_state(self, status):
if self._entity_key in status or "connected" in status:
try:
self.schedule_update_ha_state()
except Exception as e:
pass
class MideaBinaryBaseEntity(MideaEntity):
def __init__(self, device, manufacturer: str | None, entity_key: str, config: dict):
super().__init__(device, manufacturer, entity_key, config)
binary_rationale = config.get("binary_rationale")
self._binary_rationale = binary_rationale if binary_rationale is not None else ["off", "on"]
@property
def state(self):
return STATE_ON if self.is_on else STATE_OFF
@property
def is_on(self):
return self._device.get_attribute(self._entity_key) == self._binary_rationale[1]

View File

@@ -0,0 +1,31 @@
from homeassistant.components.sensor import SensorEntity
from homeassistant.const import (
Platform,
CONF_DEVICE_ID,
CONF_DEVICE,
CONF_ENTITIES
)
from .const import (
DOMAIN,
DEVICES
)
from .midea_entities import MideaEntity
async def async_setup_entry(hass, config_entry, async_add_entities):
device_id = config_entry.data.get(CONF_DEVICE_ID)
device = hass.data[DOMAIN][DEVICES][device_id].get(CONF_DEVICE)
manufacturer = hass.data[DOMAIN][DEVICES][device_id].get("manufacturer")
entities = hass.data[DOMAIN][DEVICES][device_id].get(CONF_ENTITIES).get(Platform.SENSOR)
devs = []
if entities is not None:
for entity_key, config in entities.items():
devs.append(MideaSensorEntity(device, manufacturer, entity_key, config))
async_add_entities(devs)
class MideaSensorEntity(MideaEntity, SensorEntity):
@property
def state(self):
return self._device.get_attribute(self._entity_key)

View File

@@ -0,0 +1,35 @@
from homeassistant.components.switch import SwitchEntity
from homeassistant.const import (
Platform,
CONF_DEVICE_ID,
CONF_DEVICE,
CONF_ENTITIES,
STATE_ON,
STATE_OFF
)
from .const import (
DOMAIN,
DEVICES
)
from .midea_entities import MideaBinaryBaseEntity
async def async_setup_entry(hass, config_entry, async_add_entities):
device_id = config_entry.data.get(CONF_DEVICE_ID)
device = hass.data[DOMAIN][DEVICES][device_id].get(CONF_DEVICE)
manufacturer = hass.data[DOMAIN][DEVICES][device_id].get("manufacturer")
entities = hass.data[DOMAIN][DEVICES][device_id].get(CONF_ENTITIES).get(Platform.SWITCH)
devs = []
if entities is not None:
for entity_key, config in entities.items():
devs.append(MideaSwitchEntity(device, manufacturer, entity_key, config))
async_add_entities(devs)
class MideaSwitchEntity(MideaBinaryBaseEntity, SwitchEntity):
def turn_on(self):
self._device.set_attribute(attribute=self._entity_key, value=self._binary_rationale[1])
def turn_off(self):
self._device.set_attribute(attribute=self._entity_key, value=self._binary_rationale[0])