mirror of
https://github.com/sususweet/midea-meiju-codec.git
synced 2025-09-27 18:22:41 +00:00
v0.0.5
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -155,3 +155,5 @@ cython_debug/
|
||||
|
||||
test.py
|
||||
*.lua
|
||||
|
||||
time.py
|
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import base64
|
||||
import voluptuous as vol
|
||||
from importlib import import_module
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.util.json import load_json
|
||||
@@ -8,7 +9,10 @@ try:
|
||||
except ImportError:
|
||||
from homeassistant.util.json import save_json
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
ServiceCall
|
||||
)
|
||||
from homeassistant.const import (
|
||||
Platform,
|
||||
CONF_TYPE,
|
||||
@@ -27,9 +31,14 @@ from .core.device import MiedaDevice
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
DEVICES,
|
||||
CONF_REFRESH_INTERVAL,
|
||||
CONFIG_PATH,
|
||||
CONF_KEY,
|
||||
CONF_ACCOUNT,
|
||||
CONF_SN8,
|
||||
CONF_SN,
|
||||
CONF_MODEL_NUMBER,
|
||||
CONF_LUA_FILE
|
||||
)
|
||||
|
||||
ALL_PLATFORM = [
|
||||
@@ -82,8 +91,71 @@ def load_device_config(hass: HomeAssistant, device_type, sn8):
|
||||
return json_data
|
||||
|
||||
|
||||
def register_services(hass: HomeAssistant):
|
||||
|
||||
async def async_set_attributes(service: ServiceCall):
|
||||
device_id = service.data.get("device_id")
|
||||
attributes = service.data.get("attributes")
|
||||
MideaLogger.debug(f"Service called: set_attributes, device_id: {device_id}, attributes: {attributes}")
|
||||
try:
|
||||
device: MiedaDevice = hass.data[DOMAIN][DEVICES][device_id].get(CONF_DEVICE)
|
||||
except KeyError:
|
||||
MideaLogger.error(f"Failed to call service set_attributes: the device {device_id} isn't exist.")
|
||||
return
|
||||
if device:
|
||||
device.set_attributes(attributes)
|
||||
|
||||
async def async_send_command(service: ServiceCall):
|
||||
device_id = service.data.get("device_id")
|
||||
cmd_type = service.data.get("cmd_type")
|
||||
cmd_body = service.data.get("cmd_body")
|
||||
try:
|
||||
cmd_body = bytearray.fromhex(cmd_body)
|
||||
except ValueError:
|
||||
MideaLogger.error(f"Failed to call service set_attributes: invalid cmd_body, a hexadecimal string required")
|
||||
return
|
||||
try:
|
||||
device: MiedaDevice = hass.data[DOMAIN][DEVICES][device_id].get(CONF_DEVICE)
|
||||
except KeyError:
|
||||
MideaLogger.error(f"Failed to call service set_attributes: the device {device_id} isn't exist.")
|
||||
return
|
||||
if device:
|
||||
device.send_command(cmd_type, cmd_body)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"set_attributes",
|
||||
async_set_attributes,
|
||||
schema=vol.Schema({
|
||||
vol.Required("device_id"): vol.Coerce(int),
|
||||
vol.Required("attributes"): vol.Any(dict)
|
||||
})
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN, "send_command", async_send_command,
|
||||
schema=vol.Schema({
|
||||
vol.Required("device_id"): vol.Coerce(int),
|
||||
vol.Required("cmd_type"): vol.In([2, 3]),
|
||||
vol.Required("cmd_body"): str
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry):
|
||||
pass
|
||||
device_id = config_entry.data.get(CONF_DEVICE_ID)
|
||||
if device_id is not None:
|
||||
ip_address = config_entry.options.get(
|
||||
CONF_IP_ADDRESS, None
|
||||
)
|
||||
refresh_interval = config_entry.options.get(
|
||||
CONF_REFRESH_INTERVAL, None
|
||||
)
|
||||
device: MiedaDevice = hass.data[DOMAIN][DEVICES][device_id][CONF_DEVICE]
|
||||
if device:
|
||||
if ip_address is not None:
|
||||
device.set_ip_address(ip_address)
|
||||
if refresh_interval is not None:
|
||||
device.set_refresh_interval(refresh_interval)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType):
|
||||
@@ -100,6 +172,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType):
|
||||
bit_lua = base64.b64decode(BIT_LUA.encode("utf-8")).decode("utf-8")
|
||||
with open(bit, "wt") as fp:
|
||||
fp.write(bit_lua)
|
||||
|
||||
register_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
@@ -115,13 +189,14 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry):
|
||||
ip_address = config_entry.options.get(CONF_IP_ADDRESS, None)
|
||||
if not ip_address:
|
||||
ip_address = config_entry.data.get(CONF_IP_ADDRESS)
|
||||
refresh_interval = config_entry.options.get(CONF_REFRESH_INTERVAL)
|
||||
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")
|
||||
subtype = config_entry.data.get(CONF_MODEL_NUMBER)
|
||||
sn = config_entry.data.get(CONF_SN)
|
||||
sn8 = config_entry.data.get(CONF_SN8)
|
||||
lua_file = config_entry.data.get(CONF_LUA_FILE)
|
||||
if protocol == 3 and (key is None or key is None):
|
||||
MideaLogger.error("For V3 devices, the key and the token is required.")
|
||||
return False
|
||||
@@ -140,37 +215,41 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry):
|
||||
sn8=sn8,
|
||||
lua_file=lua_file,
|
||||
)
|
||||
if device:
|
||||
device.open()
|
||||
if DOMAIN not in hass.data:
|
||||
hass.data[DOMAIN] = {}
|
||||
if DEVICES not in hass.data[DOMAIN]:
|
||||
hass.data[DOMAIN][DEVICES] = {}
|
||||
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)
|
||||
return True
|
||||
return False
|
||||
if refresh_interval is not None:
|
||||
device.set_refresh_interval(refresh_interval)
|
||||
device.open()
|
||||
if DOMAIN not in hass.data:
|
||||
hass.data[DOMAIN] = {}
|
||||
if DEVICES not in hass.data[DOMAIN]:
|
||||
hass.data[DOMAIN][DEVICES] = {}
|
||||
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.set_queries(queries)
|
||||
centralized = config.get("centralized")
|
||||
if centralized is not None and isinstance(centralized, list):
|
||||
device.set_centralized(centralized)
|
||||
calculate = config.get("calculate")
|
||||
if calculate is not None and isinstance(calculate, dict):
|
||||
device.set_calculate(calculate)
|
||||
hass.data[DOMAIN][DEVICES][device_id]["manufacturer"] = config.get("manufacturer")
|
||||
hass.data[DOMAIN][DEVICES][device_id]["rationale"] = config.get("rationale")
|
||||
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)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry):
|
||||
device_id = config_entry.data.get(CONF_DEVICE_ID)
|
||||
if device_id is not None:
|
||||
device = hass.data[DOMAIN][DEVICES][device_id][CONF_DEVICE]
|
||||
device: MiedaDevice = hass.data[DOMAIN][DEVICES][device_id][CONF_DEVICE]
|
||||
if device is not None:
|
||||
if get_sn8_used(hass, device.sn8) == 1:
|
||||
lua_file = config_entry.data.get("lua_file")
|
||||
|
@@ -4,11 +4,10 @@ import os
|
||||
import ipaddress
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.const import (
|
||||
CONF_TYPE,
|
||||
CONF_USERNAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_DEVICE,
|
||||
CONF_PORT,
|
||||
CONF_MODEL,
|
||||
CONF_IP_ADDRESS,
|
||||
@@ -17,19 +16,31 @@ from homeassistant.const import (
|
||||
CONF_TOKEN,
|
||||
CONF_NAME
|
||||
)
|
||||
from .core.cloud import MeijuCloudExtend
|
||||
from . import remove_device_config, load_device_config
|
||||
from .core.cloud import get_midea_cloud
|
||||
from .core.discover import discover
|
||||
from .core.device import MiedaDevice
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
CONF_REFRESH_INTERVAL,
|
||||
STORAGE_PATH,
|
||||
CONF_ACCOUNT,
|
||||
CONF_SERVER,
|
||||
CONF_HOME,
|
||||
CONF_KEY
|
||||
CONF_KEY,
|
||||
CONF_SN8,
|
||||
CONF_SN,
|
||||
CONF_MODEL_NUMBER,
|
||||
CONF_LUA_FILE
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
servers = {
|
||||
1: "MSmartHome",
|
||||
2: "美的美居",
|
||||
}
|
||||
|
||||
|
||||
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
_session = None
|
||||
@@ -38,11 +49,16 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
_device_list = {}
|
||||
_device = None
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry):
|
||||
return OptionsFlowHandler(config_entry)
|
||||
|
||||
def _get_configured_account(self):
|
||||
for entry in self._async_current_entries():
|
||||
if entry.data.get(CONF_TYPE) == CONF_ACCOUNT:
|
||||
return entry.data.get(CONF_USERNAME), entry.data.get(CONF_PASSWORD)
|
||||
return None, None
|
||||
return entry.data.get(CONF_ACCOUNT), entry.data.get(CONF_PASSWORD), entry.data.get(CONF_SERVER)
|
||||
return None, None, None
|
||||
|
||||
def _device_configured(self, device_id):
|
||||
for entry in self._async_current_entries():
|
||||
@@ -61,24 +77,35 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
async def async_step_user(self, user_input=None, error=None):
|
||||
if self._session is None:
|
||||
self._session = async_create_clientsession(self.hass)
|
||||
username, password = self._get_configured_account()
|
||||
if username is not None and password is not None:
|
||||
account, password, server = self._get_configured_account()
|
||||
if account is not None and password is not None:
|
||||
if self._cloud is None:
|
||||
self._cloud = MeijuCloudExtend(self._session, username, password)
|
||||
self._cloud = get_midea_cloud(
|
||||
session=self._session,
|
||||
cloud_name=servers[server],
|
||||
account=account,
|
||||
password=password
|
||||
)
|
||||
if await self._cloud.login():
|
||||
return await self.async_step_home()
|
||||
else:
|
||||
return await self.async_step_user(error="account_invalid")
|
||||
if user_input is not None:
|
||||
if self._cloud is None:
|
||||
self._cloud = MeijuCloudExtend(self._session, user_input[CONF_USERNAME], user_input[CONF_PASSWORD])
|
||||
self._cloud = get_midea_cloud(
|
||||
session=self._session,
|
||||
cloud_name=servers[user_input[CONF_SERVER]],
|
||||
account=user_input[CONF_ACCOUNT],
|
||||
password=user_input[CONF_PASSWORD]
|
||||
)
|
||||
if await self._cloud.login():
|
||||
return self.async_create_entry(
|
||||
title=f"{user_input[CONF_USERNAME]}",
|
||||
title=f"{user_input[CONF_ACCOUNT]}",
|
||||
data={
|
||||
CONF_TYPE: CONF_ACCOUNT,
|
||||
CONF_USERNAME: user_input[CONF_USERNAME],
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD]
|
||||
CONF_ACCOUNT: user_input[CONF_ACCOUNT],
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
CONF_SERVER: user_input[CONF_SERVER]
|
||||
})
|
||||
else:
|
||||
self._cloud = None
|
||||
@@ -86,8 +113,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema({
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str
|
||||
vol.Required(CONF_ACCOUNT): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
vol.Required(CONF_SERVER, default=1): vol.In(servers)
|
||||
}),
|
||||
errors={"base": error} if error else None
|
||||
)
|
||||
@@ -96,15 +124,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if user_input is not None:
|
||||
self._current_home = user_input[CONF_HOME]
|
||||
return await self.async_step_device()
|
||||
homes = await self._cloud.get_homegroups()
|
||||
home_list = {}
|
||||
for home in homes:
|
||||
home_list[int(home.get("homegroupId"))] = home.get("name")
|
||||
homes = await self._cloud.list_home()
|
||||
if homes is None or len(homes) == 0:
|
||||
return await self.async_step_device(error="no_home")
|
||||
return self.async_show_form(
|
||||
step_id="home",
|
||||
data_schema=vol.Schema({
|
||||
vol.Required(CONF_HOME, default=list(home_list.keys())[0]):
|
||||
vol.In(home_list),
|
||||
vol.Required(CONF_HOME, default=list(homes.keys())[0]):
|
||||
vol.In(homes),
|
||||
}),
|
||||
errors={"base": error} if error else None
|
||||
)
|
||||
@@ -113,39 +140,39 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if user_input is not None:
|
||||
# 下载lua
|
||||
# 本地尝试连接设备
|
||||
self._device = self._device_list[user_input[CONF_DEVICE]]
|
||||
if not self._device.get("online"):
|
||||
self._device = self._device_list[user_input[CONF_DEVICE_ID]]
|
||||
if self._device.get("online") is not True:
|
||||
return await self.async_step_device(error="offline_error")
|
||||
return await self.async_step_discover()
|
||||
devices = await self._cloud.get_devices(self._current_home)
|
||||
appliances = await self._cloud.list_appliances(self._current_home)
|
||||
self._device_list = {}
|
||||
device_list = {}
|
||||
for device in devices:
|
||||
if not self._device_configured(int(device.get("applianceCode"))):
|
||||
for appliance_code, appliance_info in appliances.items():
|
||||
if not self._device_configured(appliance_code):
|
||||
try:
|
||||
subtype = int(device.get("modelNumber")) if device.get("modelNumber") is not None else 0
|
||||
model_number = int(appliance_info.get("model_number")) if appliance_info.get("model_number") is not None else 0
|
||||
except ValueError:
|
||||
subtype = 0
|
||||
self._device_list[int(device.get("applianceCode"))] = {
|
||||
"device_id": int(device.get("applianceCode")),
|
||||
"name": device.get("name"),
|
||||
"type": int(device.get("type"), 16),
|
||||
"sn8": device.get("sn8", "00000000"),
|
||||
"sn": device.get("sn"),
|
||||
"model": device.get("productModel", "0"),
|
||||
"subtype": subtype,
|
||||
"enterprise_code": device.get("enterpriseCode","0000"),
|
||||
"online": device.get("onlineStatus") == "1"
|
||||
model_number = 0
|
||||
self._device_list[appliance_code] = {
|
||||
CONF_DEVICE_ID: appliance_code,
|
||||
CONF_NAME: appliance_info.get("name"),
|
||||
CONF_TYPE: appliance_info.get("type"),
|
||||
CONF_SN8: appliance_info.get("sn8", "00000000"),
|
||||
CONF_SN: appliance_info.get("sn"),
|
||||
CONF_MODEL: appliance_info.get("model", "0"),
|
||||
CONF_MODEL_NUMBER: model_number,
|
||||
"manufacturer_code": appliance_info.get("manufacturer_code","0000"),
|
||||
"online": appliance_info.get("online")
|
||||
}
|
||||
device_list[int(device.get("applianceCode"))] = \
|
||||
f"{device.get('name')} ({'在线' if device.get('onlineStatus') == '1' else '离线'})"
|
||||
device_list[appliance_code] = \
|
||||
f"{appliance_info.get('name')} ({'online' if appliance_info.get('online') is True else 'offline'})"
|
||||
|
||||
if len(self._device_list) == 0:
|
||||
return await self.async_step_device(error="no_new_devices")
|
||||
return self.async_show_form(
|
||||
step_id="device",
|
||||
data_schema=vol.Schema({
|
||||
vol.Required(CONF_DEVICE, default=list(device_list.keys())[0]):
|
||||
vol.Required(CONF_DEVICE_ID, default=list(device_list.keys())[0]):
|
||||
vol.In(device_list),
|
||||
}),
|
||||
errors={"base": error} if error else None
|
||||
@@ -157,46 +184,55 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
ip_address = None
|
||||
if self._is_valid_ip_address(user_input[CONF_IP_ADDRESS]):
|
||||
ip_address = user_input[CONF_IP_ADDRESS]
|
||||
discover_devices = discover([self._device["type"]], ip_address)
|
||||
discover_devices = discover([self._device[CONF_TYPE]], ip_address)
|
||||
_LOGGER.debug(discover_devices)
|
||||
if discover_devices is None or len(discover_devices) == 0:
|
||||
return await self.async_step_discover(error="discover_failed")
|
||||
current_device = discover_devices.get(self._device["device_id"])
|
||||
current_device = discover_devices.get(self._device[CONF_DEVICE_ID])
|
||||
if current_device is None:
|
||||
return await self.async_step_discover(error="discover_failed")
|
||||
os.makedirs(self.hass.config.path(STORAGE_PATH), exist_ok=True)
|
||||
path = self.hass.config.path(STORAGE_PATH)
|
||||
file = await self._cloud.get_lua(self._device["sn"], self._device["type"], path, self._device["enterprise_code"])
|
||||
file = await self._cloud.download_lua(
|
||||
path=path,
|
||||
device_type=self._device[CONF_TYPE],
|
||||
sn=self._device[CONF_SN],
|
||||
model_number=self._device[CONF_MODEL_NUMBER],
|
||||
manufacturer_code=self._device["manufacturer_code"]
|
||||
)
|
||||
if file is None:
|
||||
return await self.async_step_discover(error="download_lua_failed")
|
||||
use_token = None
|
||||
use_key = None
|
||||
connected = False
|
||||
if current_device.get("protocol") == 3:
|
||||
for byte_order_big in [False, True]:
|
||||
token, key = await self._cloud.get_token(self._device.get("device_id"), byte_order_big=byte_order_big)
|
||||
if token and key:
|
||||
dm = MiedaDevice(
|
||||
name=self._device.get("name"),
|
||||
device_id=self._device.get("device_id"),
|
||||
device_type=current_device.get(CONF_TYPE),
|
||||
ip_address=current_device.get(CONF_IP_ADDRESS),
|
||||
port=current_device.get(CONF_PORT),
|
||||
token=token,
|
||||
key=key,
|
||||
protocol=3,
|
||||
model=None,
|
||||
subtype = None,
|
||||
sn=None,
|
||||
sn8=None,
|
||||
lua_file=None
|
||||
)
|
||||
if dm.connect():
|
||||
use_token = token
|
||||
use_key = key
|
||||
connected = True
|
||||
else:
|
||||
return await self.async_step_discover(error="cant_get_token")
|
||||
if current_device.get(CONF_PROTOCOL) == 3:
|
||||
keys = await self._cloud.get_keys(self._device.get(CONF_DEVICE_ID))
|
||||
for method, key in keys.items():
|
||||
dm = MiedaDevice(
|
||||
name="",
|
||||
device_id=self._device.get(CONF_DEVICE_ID),
|
||||
device_type=current_device.get(CONF_TYPE),
|
||||
ip_address=current_device.get(CONF_IP_ADDRESS),
|
||||
port=current_device.get(CONF_PORT),
|
||||
token=key["token"],
|
||||
key=key["key"],
|
||||
protocol=3,
|
||||
model=None,
|
||||
subtype = None,
|
||||
sn=None,
|
||||
sn8=None,
|
||||
lua_file=None
|
||||
)
|
||||
_LOGGER.debug(
|
||||
f"Successful to take token and key, token: {key['token']},"
|
||||
f" key: { key['key']}, method: {method}"
|
||||
)
|
||||
if dm.connect():
|
||||
use_token = key["token"]
|
||||
use_key = key["key"]
|
||||
dm.disconnect()
|
||||
connected = True
|
||||
break
|
||||
else:
|
||||
dm = MiedaDevice(
|
||||
name=self._device.get("name"),
|
||||
@@ -214,26 +250,26 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
lua_file=None
|
||||
)
|
||||
if dm.connect():
|
||||
dm.disconnect()
|
||||
connected = True
|
||||
|
||||
if not connected:
|
||||
return await self.async_step_discover(error="connect_error")
|
||||
return self.async_create_entry(
|
||||
title=self._device.get("name"),
|
||||
data={
|
||||
CONF_NAME: self._device.get("name"),
|
||||
CONF_DEVICE_ID: self._device.get("device_id"),
|
||||
CONF_TYPE: current_device.get("type"),
|
||||
CONF_PROTOCOL: current_device.get("protocol"),
|
||||
CONF_IP_ADDRESS: current_device.get("ip_address"),
|
||||
CONF_PORT: current_device.get("port"),
|
||||
CONF_NAME: self._device.get(CONF_NAME),
|
||||
CONF_DEVICE_ID: self._device.get(CONF_DEVICE_ID),
|
||||
CONF_TYPE: current_device.get(CONF_TYPE),
|
||||
CONF_PROTOCOL: current_device.get(CONF_PROTOCOL),
|
||||
CONF_IP_ADDRESS: current_device.get(CONF_IP_ADDRESS),
|
||||
CONF_PORT: current_device.get(CONF_PORT),
|
||||
CONF_TOKEN: use_token,
|
||||
CONF_KEY: use_key,
|
||||
CONF_MODEL: self._device.get("model"),
|
||||
"subtype": self._device.get("subtype"),
|
||||
"sn": self._device.get("sn"),
|
||||
"sn8": self._device.get("sn8"),
|
||||
"lua_file": file,
|
||||
CONF_MODEL: self._device.get(CONF_MODEL),
|
||||
CONF_MODEL_NUMBER: self._device.get(CONF_MODEL_NUMBER),
|
||||
CONF_SN: self._device.get(CONF_SN),
|
||||
CONF_SN8: self._device.get(CONF_SN8),
|
||||
CONF_LUA_FILE: file,
|
||||
})
|
||||
else:
|
||||
return await self.async_step_discover(error="invalid_input")
|
||||
@@ -248,4 +284,67 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
class OptionsFlowHandler(config_entries.OptionsFlow):
|
||||
def __init__(self, config_entry: config_entries.ConfigEntry):
|
||||
pass
|
||||
self._config_entry = config_entry
|
||||
|
||||
async def async_step_init(self, user_input=None, error=None):
|
||||
if self._config_entry.data.get(CONF_TYPE) == CONF_ACCOUNT:
|
||||
return self.async_abort(reason="account_unsupport_config")
|
||||
if user_input is not None:
|
||||
if user_input.get("option") == 1:
|
||||
return await self.async_step_configure()
|
||||
else:
|
||||
return await self.async_step_reset()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=vol.Schema({
|
||||
vol.Required("option", default=1):
|
||||
vol.In({1: "Options", 2: "Reset device configuration"})
|
||||
|
||||
}),
|
||||
errors={"base": error} if error else None
|
||||
)
|
||||
|
||||
async def async_step_reset(self, user_input=None):
|
||||
if user_input is not None:
|
||||
if user_input["check"]:
|
||||
remove_device_config(self.hass, self._config_entry.data.get(CONF_SN8))
|
||||
load_device_config(
|
||||
self.hass,
|
||||
self._config_entry.data.get(CONF_TYPE),
|
||||
self._config_entry.data.get(CONF_SN8))
|
||||
return self.async_abort(reason="reset_success")
|
||||
return self.async_show_form(
|
||||
step_id="reset",
|
||||
data_schema=vol.Schema({
|
||||
vol.Required("check", default=False): bool
|
||||
})
|
||||
)
|
||||
|
||||
async def async_step_configure(self, user_input=None):
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
ip_address = self._config_entry.options.get(
|
||||
CONF_IP_ADDRESS, None
|
||||
)
|
||||
if ip_address is None:
|
||||
ip_address = self._config_entry.data.get(
|
||||
CONF_IP_ADDRESS, None
|
||||
)
|
||||
refresh_interval = self._config_entry.options.get(
|
||||
CONF_REFRESH_INTERVAL, 30
|
||||
)
|
||||
data_schema = vol.Schema({
|
||||
vol.Required(
|
||||
CONF_IP_ADDRESS,
|
||||
default=ip_address
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_REFRESH_INTERVAL,
|
||||
default=refresh_interval
|
||||
): int
|
||||
})
|
||||
return self.async_show_form(
|
||||
step_id="configure",
|
||||
data_schema=data_schema
|
||||
)
|
File diff suppressed because one or more lines are too long
@@ -1,204 +1,465 @@
|
||||
import datetime
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
from aiohttp import ClientSession
|
||||
from secrets import token_hex, token_urlsafe
|
||||
import time
|
||||
import datetime
|
||||
import json
|
||||
import base64
|
||||
from threading import Lock
|
||||
from .security import CloudSecurity
|
||||
|
||||
CLIENT_TYPE = 1 # Android
|
||||
FORMAT = 2 # JSON
|
||||
APP_KEY = "4675636b"
|
||||
from aiohttp import ClientSession
|
||||
from secrets import token_hex
|
||||
from .security import CloudSecurity, MeijuCloudSecurity, MSmartCloudSecurity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
clouds = {
|
||||
"美的美居": {
|
||||
"class_name": "MeijuCloud",
|
||||
"app_key": "46579c15",
|
||||
"login_key": "ad0ee21d48a64bf49f4fb583ab76e799",
|
||||
"iot_key": bytes.fromhex(format(9795516279659324117647275084689641883661667, 'x')).decode(),
|
||||
"hmac_key": bytes.fromhex(format(117390035944627627450677220413733956185864939010425, 'x')).decode(),
|
||||
"api_url": "https://mp-prod.smartmidea.net/mas/v5/app/proxy?alias=",
|
||||
},
|
||||
"MSmartHome": {
|
||||
"class_name": "MSmartHomeCloud",
|
||||
"app_key": "ac21b9f9cbfe4ca5a88562ef25e2b768",
|
||||
"iot_key": bytes.fromhex(format(7882822598523843940, 'x')).decode(),
|
||||
"hmac_key": bytes.fromhex(format(117390035944627627450677220413733956185864939010425, 'x')).decode(),
|
||||
"api_url": "https://mp-prod.appsmb.com/mas/v5/app/proxy?alias=",
|
||||
},
|
||||
}
|
||||
|
||||
class MideaCloudBase:
|
||||
LANGUAGE = "en_US"
|
||||
APP_ID = "1010"
|
||||
SRC = "1010"
|
||||
LOGIN_KEY = None
|
||||
IOT_KEY = None
|
||||
DEVICE_ID = int(time.time() * 100000)
|
||||
default_keys = {
|
||||
99: {
|
||||
"token": "ee755a84a115703768bcc7c6c13d3d629aa416f1e2fd798beb9f78cbb1381d09"
|
||||
"1cc245d7b063aad2a900e5b498fbd936c811f5d504b2e656d4f33b3bbc6d1da3",
|
||||
"key": "ed37bd31558a4b039aaf4e7a7a59aa7a75fd9101682045f69baf45d28380ae5c"
|
||||
}
|
||||
}
|
||||
|
||||
def __init__(self, session: ClientSession, security, username: str, password: str, server: str = None):
|
||||
self.session = session
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.server = None
|
||||
self.login_id = None
|
||||
self.access_token = ""
|
||||
self.key = None
|
||||
|
||||
class MideaCloud:
|
||||
def __init__(
|
||||
self,
|
||||
session: ClientSession,
|
||||
security: CloudSecurity,
|
||||
app_key: str,
|
||||
account: str,
|
||||
password: str,
|
||||
api_url: str
|
||||
):
|
||||
self._device_id = CloudSecurity.get_deviceid(account)
|
||||
self._session = session
|
||||
self._security = security
|
||||
self._api_lock = Lock()
|
||||
self.login_session = None
|
||||
self.security = security
|
||||
self.server = server
|
||||
self._app_key = app_key
|
||||
self._account = account
|
||||
self._password = password
|
||||
self._api_url = api_url
|
||||
self._access_token = None
|
||||
self._login_id = None
|
||||
|
||||
async def api_request(self, endpoint, args=None, data=None) -> dict | None:
|
||||
args = args or {}
|
||||
headers = {}
|
||||
if data is None:
|
||||
data = {
|
||||
"appId": self.APP_ID,
|
||||
"format": FORMAT,
|
||||
"clientType": CLIENT_TYPE,
|
||||
"language": self.LANGUAGE,
|
||||
"src": self.SRC,
|
||||
"stamp": datetime.datetime.now().strftime("%Y%m%d%H%M%S"),
|
||||
"deviceId": self.DEVICE_ID,
|
||||
}
|
||||
data.update(args)
|
||||
def _make_general_data(self):
|
||||
return {
|
||||
}
|
||||
|
||||
async def _api_request(self, endpoint: str, data: dict, header=None) -> dict | None:
|
||||
header = header or {}
|
||||
if not data.get("reqId"):
|
||||
data.update({
|
||||
"reqId": token_hex(16),
|
||||
"reqId": token_hex(16)
|
||||
})
|
||||
if not data.get("stamp"):
|
||||
data.update({
|
||||
"stamp": datetime.datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
})
|
||||
|
||||
url = self.server + endpoint
|
||||
random = str(int(time.time()))
|
||||
|
||||
sign = self.security.sign(json.dumps(data), random)
|
||||
headers.update({
|
||||
"Content-Type": "application/json",
|
||||
url = self._api_url + endpoint
|
||||
dump_data = json.dumps(data)
|
||||
sign = self._security.sign(dump_data, random)
|
||||
header.update({
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"secretVersion": "1",
|
||||
"sign": sign,
|
||||
"random": random,
|
||||
"accessToken": self.access_token
|
||||
})
|
||||
response = {"code": -1}
|
||||
if self._access_token is not None:
|
||||
header.update({
|
||||
"accesstoken": self._access_token
|
||||
})
|
||||
response:dict = {"code": -1}
|
||||
for i in range(0, 3):
|
||||
try:
|
||||
with self._api_lock:
|
||||
r = await self.session.request("POST", url, headers=headers, data=json.dumps(data), timeout=10)
|
||||
r = await self._session.request("POST", url, headers=header, data=dump_data, timeout=10)
|
||||
raw = await r.read()
|
||||
_LOGGER.debug(f"Endpoint: {endpoint}, Response: {str(raw)}")
|
||||
_LOGGER.debug(f"Midea cloud API url: {url}, data: {data}, response: {raw}")
|
||||
response = json.loads(raw)
|
||||
break
|
||||
except Exception as e:
|
||||
_LOGGER.error(f"Cloud error: {repr(e)}")
|
||||
pass
|
||||
if int(response["code"]) == 0 and "data" in response:
|
||||
return response["data"]
|
||||
print(response)
|
||||
return None
|
||||
|
||||
async def get_login_id(self):
|
||||
response = await self.api_request(
|
||||
"/v1/user/login/id/get",
|
||||
args={"loginAccount": self.username}
|
||||
)
|
||||
if response:
|
||||
self.login_id = response["loginId"]
|
||||
return True
|
||||
return False
|
||||
async def _get_login_id(self) -> str | None:
|
||||
data = self._make_general_data()
|
||||
data.update({
|
||||
"loginAccount": f"{self._account}"
|
||||
})
|
||||
if response := await self._api_request(
|
||||
endpoint="/v1/user/login/id/get",
|
||||
data=data
|
||||
):
|
||||
return response.get("loginId")
|
||||
return None
|
||||
|
||||
async def login(self):
|
||||
result = await self.get_login_id()
|
||||
if result:
|
||||
response = await self.api_request(
|
||||
"/mj/user/login",
|
||||
data={
|
||||
"data": {
|
||||
"appKey": APP_KEY,
|
||||
"platform": FORMAT,
|
||||
"deviceId": self.DEVICE_ID
|
||||
},
|
||||
"iotData": {
|
||||
"appId": self.APP_ID,
|
||||
"clientType": CLIENT_TYPE,
|
||||
"iampwd": self.security.encrypt_iam_password(self.login_id, self.password),
|
||||
"loginAccount": self.username,
|
||||
"password": self.security.encrypt_password(self.login_id, self.password),
|
||||
"pushToken": token_urlsafe(120),
|
||||
"reqId": token_hex(16),
|
||||
"src": self.SRC,
|
||||
"stamp": datetime.time().strftime("%Y%m%d%H%M%S"),
|
||||
},
|
||||
}
|
||||
async def login(self) -> bool:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def get_keys(self, appliance_id: int):
|
||||
result = {}
|
||||
for method in [1, 2]:
|
||||
udp_id = self._security.get_udp_id(appliance_id, method)
|
||||
data = self._make_general_data()
|
||||
data.update({
|
||||
"udpid": udp_id
|
||||
})
|
||||
response = await self._api_request(
|
||||
endpoint="/v1/iot/secure/getToken",
|
||||
data=data
|
||||
)
|
||||
if response:
|
||||
self.access_token = response["mdata"]["accessToken"]
|
||||
if "key" in response:
|
||||
self.key = CloudSecurity.decrypt(bytes.fromhex(response["key"]))
|
||||
if response and "tokenlist" in response:
|
||||
for token in response["tokenlist"]:
|
||||
if token["udpId"] == udp_id:
|
||||
result[method] = {
|
||||
"token": token["token"].lower(),
|
||||
"key": token["key"].lower()
|
||||
}
|
||||
result.update(default_keys)
|
||||
return result
|
||||
|
||||
async def list_home(self) -> dict | None:
|
||||
return {1: "My home"}
|
||||
|
||||
async def list_appliances(self, home_id) -> dict | None:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def download_lua(
|
||||
self, path: str,
|
||||
device_type: str,
|
||||
sn: str,
|
||||
model_number: str | None,
|
||||
manufacturer_code: str = "0000",
|
||||
):
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class MeijuCloud(MideaCloud):
|
||||
APP_ID = "900"
|
||||
APP_VERSION = "8.20.0.2"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cloud_name: str,
|
||||
session: ClientSession,
|
||||
account: str,
|
||||
password: str,
|
||||
):
|
||||
super().__init__(
|
||||
session=session,
|
||||
security=MeijuCloudSecurity(
|
||||
login_key=clouds[cloud_name]["login_key"],
|
||||
iot_key=clouds[cloud_name]["iot_key"],
|
||||
hmac_key=clouds[cloud_name]["hmac_key"],
|
||||
),
|
||||
app_key=clouds[cloud_name]["app_key"],
|
||||
account=account,
|
||||
password=password,
|
||||
api_url=clouds[cloud_name]["api_url"]
|
||||
)
|
||||
|
||||
async def login(self) -> bool:
|
||||
if login_id := await self._get_login_id():
|
||||
self._login_id = login_id
|
||||
stamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
data = {
|
||||
"iotData": {
|
||||
"clientType": 1,
|
||||
"deviceId": self._device_id,
|
||||
"iampwd": self._security.encrypt_iam_password(self._login_id, self._password),
|
||||
"iotAppId": self.APP_ID,
|
||||
"loginAccount": self._account,
|
||||
"password": self._security.encrypt_password(self._login_id, self._password),
|
||||
"reqId": token_hex(16),
|
||||
"stamp": stamp
|
||||
},
|
||||
"data": {
|
||||
"appKey": self._app_key,
|
||||
"deviceId": self._device_id,
|
||||
"platform": 2
|
||||
},
|
||||
"timestamp": stamp,
|
||||
"stamp": stamp
|
||||
}
|
||||
if response := await self._api_request(
|
||||
endpoint="/mj/user/login",
|
||||
data=data
|
||||
):
|
||||
self._access_token = response["mdata"]["accessToken"]
|
||||
self._security.set_aes_keys(
|
||||
self._security.aes_decrypt_with_fixed_key(
|
||||
response["key"]
|
||||
), None
|
||||
)
|
||||
|
||||
return True
|
||||
return False
|
||||
|
||||
async def get_token(self, device_id: int, byte_order_big=False):
|
||||
if byte_order_big:
|
||||
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 "
|
||||
f"with byte order '{'big' if byte_order_big else 'little'}': {udpid}")
|
||||
response = await self.api_request(
|
||||
"/v1/iot/secure/getToken",
|
||||
args={"udpid": udpid}
|
||||
)
|
||||
if response and "tokenlist" in response:
|
||||
for token in response["tokenlist"]:
|
||||
if token["udpId"] == udpid:
|
||||
return token["token"].upper(), token["key"].upper()
|
||||
return None, None
|
||||
async def list_home(self):
|
||||
if response := await self._api_request(
|
||||
endpoint="/v1/homegroup/list/get",
|
||||
data={}
|
||||
):
|
||||
homes = {}
|
||||
for home in response["homeList"]:
|
||||
homes.update({
|
||||
int(home["homegroupId"]): home["name"]
|
||||
})
|
||||
return homes
|
||||
return None
|
||||
|
||||
async def list_appliances(self, home_id) -> dict | None:
|
||||
data = {
|
||||
"homegroupId": home_id
|
||||
}
|
||||
if response := await self._api_request(
|
||||
endpoint="/v1/appliance/home/list/get",
|
||||
data=data
|
||||
):
|
||||
appliances = {}
|
||||
for home in response.get("homeList") or []:
|
||||
for room in home.get("roomList") or []:
|
||||
for appliance in room.get("applianceList"):
|
||||
device_info = {
|
||||
"name": appliance.get("name"),
|
||||
"type": int(appliance.get("type"), 16),
|
||||
"sn": self._security.aes_decrypt(appliance.get("sn")) if appliance.get("sn") else "",
|
||||
"sn8": appliance.get("sn8", "00000000"),
|
||||
"model_number": appliance.get("modelNumber", "0"),
|
||||
"manufacturer_code":appliance.get("enterpriseCode", "0000"),
|
||||
"model": appliance.get("productModel"),
|
||||
"online": appliance.get("onlineStatus") == "1",
|
||||
}
|
||||
if device_info.get("sn8") is None or len(device_info.get("sn8")) == 0:
|
||||
device_info["sn8"] = "00000000"
|
||||
if device_info.get("model") is None or len(device_info.get("model")) == 0:
|
||||
device_info["model"] = device_info["sn8"]
|
||||
appliances[int(appliance["applianceCode"])] = device_info
|
||||
return appliances
|
||||
return None
|
||||
|
||||
class MeijuCloudExtend(MideaCloudBase):
|
||||
LANGUAGE = "zh_CN"
|
||||
LOGIN_KEY = "ad0ee21d48a64bf49f4fb583ab76e799"
|
||||
IOT_KEY = "prod_secret123@muc"
|
||||
SERVER = "https://mp-prod.smartmidea.net/mas/v5/app/proxy?alias="
|
||||
|
||||
def __init__(self, session: ClientSession, username: str, password: str):
|
||||
super().__init__(session=session,
|
||||
security=CloudSecurity(self.IOT_KEY, self.LOGIN_KEY),
|
||||
username=username,
|
||||
password=password,
|
||||
server=self.SERVER)
|
||||
|
||||
async def get_homegroups(self):
|
||||
response = await self.api_request("/v1/homegroup/list/get", args={})
|
||||
return response.get("homeList")
|
||||
|
||||
async def get_devices(self, homegroupID=None):
|
||||
if homegroupID is None:
|
||||
homes = []
|
||||
homegroups = await self.get_homegroups()
|
||||
if homegroups:
|
||||
for home in homegroups:
|
||||
homes.append(home["homegroupId"])
|
||||
else:
|
||||
homes = [homegroupID]
|
||||
devices = []
|
||||
for home in homes:
|
||||
response = await self.api_request("/v1/appliance/home/list/get", args={
|
||||
'homegroupId': home
|
||||
})
|
||||
if response:
|
||||
for h in response.get("homeList") or []:
|
||||
for r in h.get("roomList") or []:
|
||||
for a in r.get("applianceList"):
|
||||
a["sn"] = CloudSecurity.decrypt(bytes.fromhex(a["sn"]), self.key).decode()
|
||||
devices.append(a)
|
||||
return devices
|
||||
|
||||
async def get_lua(self, sn, device_type, path, enterprise_code=None):
|
||||
response = await self.api_request(
|
||||
"/v1/appliance/protocol/lua/luaGet",
|
||||
data={
|
||||
"applianceSn": sn,
|
||||
"applianceType": "0x%02X" % device_type,
|
||||
"applianceMFCode": enterprise_code if enterprise_code else "0000",
|
||||
'version': "0",
|
||||
"iotAppId": "900",
|
||||
"stamp": datetime.datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
}
|
||||
)
|
||||
async def download_lua(
|
||||
self, path: str,
|
||||
device_type: int,
|
||||
sn: str,
|
||||
model_number: str | None,
|
||||
manufacturer_code: str = "0000",
|
||||
):
|
||||
data = {
|
||||
"applianceSn": sn,
|
||||
"applianceType": "0x%02X" % device_type,
|
||||
"applianceMFCode": manufacturer_code,
|
||||
'version': "0",
|
||||
"iotAppId": self.APP_ID,
|
||||
}
|
||||
fnm = None
|
||||
if response:
|
||||
res = await self.session.get(response["url"])
|
||||
if response := await self._api_request(
|
||||
endpoint="/v1/appliance/protocol/lua/luaGet",
|
||||
data=data
|
||||
):
|
||||
res = await self._session.get(response["url"])
|
||||
if res.status == 200:
|
||||
lua = await res.text()
|
||||
if lua:
|
||||
stream = 'local bit = require "bit" ' + CloudSecurity.decrypt(bytes.fromhex(lua)).decode()
|
||||
stream = ('local bit = require "bit"\n' +
|
||||
self._security.aes_decrypt_with_fixed_key(lua))
|
||||
stream = stream.replace("\r\n", "\n")
|
||||
fnm = f"{path}/{response['fileName']}"
|
||||
with open(fnm, "w") as fp:
|
||||
fp.write(stream)
|
||||
return fnm
|
||||
|
||||
|
||||
class MSmartHomeCloud(MideaCloud):
|
||||
APP_ID = "1010"
|
||||
SRC = "10"
|
||||
APP_VERSION = "3.0.2"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cloud_name: str,
|
||||
session: ClientSession,
|
||||
account: str,
|
||||
password: str,
|
||||
):
|
||||
super().__init__(
|
||||
session=session,
|
||||
security=MSmartCloudSecurity(
|
||||
login_key=clouds[cloud_name]["app_key"],
|
||||
iot_key=clouds[cloud_name]["iot_key"],
|
||||
hmac_key=clouds[cloud_name]["hmac_key"],
|
||||
),
|
||||
app_key=clouds[cloud_name]["app_key"],
|
||||
account=account,
|
||||
password=password,
|
||||
api_url=clouds[cloud_name]["api_url"]
|
||||
)
|
||||
self._auth_base = base64.b64encode(
|
||||
f"{self._app_key}:{clouds['MSmartHome']['iot_key']}".encode("ascii")
|
||||
).decode("ascii")
|
||||
self._uid = ""
|
||||
|
||||
def _make_general_data(self):
|
||||
return {
|
||||
"appVersion": self.APP_VERSION,
|
||||
"src": self.SRC,
|
||||
"format": "2",
|
||||
"stamp": datetime.datetime.now().strftime("%Y%m%d%H%M%S"),
|
||||
"platformId": "1",
|
||||
"deviceId": self._device_id,
|
||||
"reqId": token_hex(16),
|
||||
"uid": self._uid,
|
||||
"clientType": "1",
|
||||
"appId": self.APP_ID,
|
||||
}
|
||||
|
||||
async def _api_request(self, endpoint: str, data: dict, header=None) -> dict | None:
|
||||
header = header or {}
|
||||
header.update({
|
||||
"x-recipe-app": self.APP_ID,
|
||||
"authorization": f"Basic {self._auth_base}"
|
||||
})
|
||||
if len(self._uid) > 0:
|
||||
header.update({
|
||||
"uid": self._uid
|
||||
})
|
||||
return await super()._api_request(endpoint, data, header)
|
||||
|
||||
async def _re_route(self):
|
||||
data = self._make_general_data()
|
||||
data.update({
|
||||
"userType": "0",
|
||||
"userName": f"{self._account}"
|
||||
})
|
||||
if response := await self._api_request(
|
||||
endpoint="/v1/multicloud/platform/user/route",
|
||||
data=data
|
||||
):
|
||||
if api_url := response.get("masUrl"):
|
||||
self._api_url = api_url
|
||||
|
||||
async def login(self) -> bool:
|
||||
await self._re_route()
|
||||
if login_id := await self._get_login_id():
|
||||
self._login_id = login_id
|
||||
iot_data = self._make_general_data()
|
||||
iot_data.pop("uid")
|
||||
stamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
iot_data.update({
|
||||
"iampwd": self._security.encrypt_iam_password(self._login_id, self._password),
|
||||
"loginAccount": self._account,
|
||||
"password": self._security.encrypt_password(self._login_id, self._password),
|
||||
"stamp": stamp
|
||||
})
|
||||
data = {
|
||||
"iotData": iot_data,
|
||||
"data": {
|
||||
"appKey": self._app_key,
|
||||
"deviceId": self._device_id,
|
||||
"platform": "2"
|
||||
},
|
||||
"stamp": stamp
|
||||
}
|
||||
if response := await self._api_request(
|
||||
endpoint="/mj/user/login",
|
||||
data=data
|
||||
):
|
||||
self._uid = response["uid"]
|
||||
self._access_token = response["mdata"]["accessToken"]
|
||||
self._security.set_aes_keys(response["accessToken"], response["randomData"])
|
||||
return True
|
||||
return False
|
||||
|
||||
async def list_appliances(self, home_id=None) -> dict | None:
|
||||
data = self._make_general_data()
|
||||
if response := await self._api_request(
|
||||
endpoint="/v1/appliance/user/list/get",
|
||||
data=data
|
||||
):
|
||||
appliances = {}
|
||||
for appliance in response["list"]:
|
||||
device_info = {
|
||||
"name": appliance.get("name"),
|
||||
"type": int(appliance.get("type"), 16),
|
||||
"sn": self._security.aes_decrypt(appliance.get("sn")) if appliance.get("sn") else "",
|
||||
"sn8": "",
|
||||
"model_number": appliance.get("modelNumber", "0"),
|
||||
"manufacturer_code":appliance.get("enterpriseCode", "0000"),
|
||||
"model": "",
|
||||
"online": appliance.get("onlineStatus") == "1",
|
||||
}
|
||||
device_info["sn8"] = device_info.get("sn")[9:17] if len(device_info["sn"]) > 17 else ""
|
||||
device_info["model"] = device_info.get("sn8")
|
||||
appliances[int(appliance["id"])] = device_info
|
||||
return appliances
|
||||
return None
|
||||
|
||||
async def download_lua(
|
||||
self, path: str,
|
||||
device_type: int,
|
||||
sn: str,
|
||||
model_number: str | None,
|
||||
manufacturer_code: str = "0000",
|
||||
):
|
||||
data = {
|
||||
"clientType": "1",
|
||||
"appId": self.APP_ID,
|
||||
"format": "2",
|
||||
"deviceId": self._device_id,
|
||||
"iotAppId": self.APP_ID,
|
||||
"applianceMFCode": manufacturer_code,
|
||||
"applianceType": "0x%02X" % device_type,
|
||||
"modelNumber": model_number,
|
||||
"applianceSn": self._security.aes_encrypt_with_fixed_key(sn.encode("ascii")).hex(),
|
||||
"version": "0",
|
||||
"encryptedType ": "2"
|
||||
}
|
||||
fnm = None
|
||||
if response := await self._api_request(
|
||||
endpoint="/v2/luaEncryption/luaGet",
|
||||
data=data
|
||||
):
|
||||
res = await self._session.get(response["url"])
|
||||
if res.status == 200:
|
||||
lua = await res.text()
|
||||
if lua:
|
||||
stream = ('local bit = require "bit"\n' +
|
||||
self._security.aes_decrypt_with_fixed_key(lua))
|
||||
stream = stream.replace("\r\n", "\n")
|
||||
fnm = f"{path}/{response['fileName']}"
|
||||
with open(fnm, "w") as fp:
|
||||
fp.write(stream)
|
||||
return fnm
|
||||
|
||||
|
||||
def get_midea_cloud(cloud_name: str, session: ClientSession, account: str, password: str) -> MideaCloud | None:
|
||||
cloud = None
|
||||
if cloud_name in clouds.keys():
|
||||
cloud = globals()[clouds[cloud_name]["class_name"]](
|
||||
cloud_name=cloud_name,
|
||||
session=session,
|
||||
account=account,
|
||||
password=password
|
||||
)
|
||||
return cloud
|
||||
|
@@ -5,6 +5,7 @@ from enum import IntEnum
|
||||
from .security import LocalSecurity, MSGTYPE_HANDSHAKE_REQUEST, MSGTYPE_ENCRYPTED_REQUEST
|
||||
from .packet_builder import PacketBuilder
|
||||
from .lua_runtime import MideaCodec
|
||||
from .message import MessageQuestCustom
|
||||
from .logger import MideaLogger
|
||||
|
||||
|
||||
@@ -70,6 +71,8 @@ class MiedaDevice(threading.Thread):
|
||||
self._connected = False
|
||||
self._queries = [{}]
|
||||
self._centralized = []
|
||||
self._calculate_get = []
|
||||
self._calculate_set = []
|
||||
self._lua_runtime = MideaCodec(lua_file, sn=sn, subtype=subtype) if lua_file is not None else None
|
||||
|
||||
@property
|
||||
@@ -111,22 +114,18 @@ class MiedaDevice(threading.Thread):
|
||||
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):
|
||||
def set_queries(self, queries: list):
|
||||
self._queries = queries
|
||||
|
||||
@property
|
||||
def centralized(self):
|
||||
return self._centralized
|
||||
|
||||
@centralized.setter
|
||||
def centralized(self, centralized: list):
|
||||
def set_centralized(self, centralized: list):
|
||||
self._centralized = centralized
|
||||
|
||||
def set_calculate(self, calculate: dict):
|
||||
values_get = calculate.get("get")
|
||||
values_set = calculate.get("set")
|
||||
self._calculate_get = values_get if values_get else []
|
||||
self._calculate_set = values_set if values_set else []
|
||||
|
||||
def get_attribute(self, attribute):
|
||||
return self._attributes.get(attribute)
|
||||
|
||||
@@ -137,7 +136,7 @@ class MiedaDevice(threading.Thread):
|
||||
new_status[attr] = self._attributes.get(attr)
|
||||
new_status[attribute] = value
|
||||
if set_cmd := self._lua_runtime.build_control(new_status):
|
||||
self.build_send(set_cmd)
|
||||
self._build_send(set_cmd)
|
||||
|
||||
def set_attributes(self, attributes):
|
||||
new_status = {}
|
||||
@@ -150,22 +149,26 @@ class MiedaDevice(threading.Thread):
|
||||
new_status[attribute] = value
|
||||
if has_new:
|
||||
if set_cmd := self._lua_runtime.build_control(new_status):
|
||||
self.build_send(set_cmd)
|
||||
self._build_send(set_cmd)
|
||||
|
||||
@staticmethod
|
||||
def fetch_v2_message(msg):
|
||||
result = []
|
||||
while len(msg) > 0:
|
||||
factual_msg_len = len(msg)
|
||||
if factual_msg_len < 6:
|
||||
break
|
||||
alleged_msg_len = msg[4] + (msg[5] << 8)
|
||||
if factual_msg_len >= alleged_msg_len:
|
||||
result.append(msg[:alleged_msg_len])
|
||||
msg = msg[alleged_msg_len:]
|
||||
else:
|
||||
break
|
||||
return result, msg
|
||||
def set_ip_address(self, ip_address):
|
||||
MideaLogger.debug(f"Update IP address to {ip_address}")
|
||||
self._ip_address = ip_address
|
||||
self.close_socket()
|
||||
|
||||
def send_command(self, cmd_type, cmd_body: bytearray):
|
||||
cmd = MessageQuestCustom(self._device_type, cmd_type, cmd_body)
|
||||
try:
|
||||
self._build_send(cmd.serialize().hex())
|
||||
except socket.error as e:
|
||||
MideaLogger.debug(
|
||||
f"Interface send_command failure, {repr(e)}, "
|
||||
f"cmd_type: {cmd_type}, cmd_body: {cmd_body.hex()}",
|
||||
self._device_id
|
||||
)
|
||||
|
||||
def register_update(self, update):
|
||||
self._updates.append(update)
|
||||
|
||||
def connect(self, refresh=False):
|
||||
try:
|
||||
@@ -175,11 +178,11 @@ class MiedaDevice(threading.Thread):
|
||||
self._socket.connect((self._ip_address, self._port))
|
||||
MideaLogger.debug(f"Connected", self._device_id)
|
||||
if self._protocol == 3:
|
||||
self.authenticate()
|
||||
self._authenticate()
|
||||
MideaLogger.debug(f"Authentication success", self._device_id)
|
||||
self.device_connected(True)
|
||||
self._device_connected(True)
|
||||
if refresh:
|
||||
self.refresh_status()
|
||||
self._refresh_status()
|
||||
return True
|
||||
except socket.timeout:
|
||||
MideaLogger.debug(f"Connection timed out", self._device_id)
|
||||
@@ -194,10 +197,34 @@ class MiedaDevice(threading.Thread):
|
||||
except Exception as e:
|
||||
MideaLogger.error(f"Unknown error: {e.__traceback__.tb_frame.f_globals['__file__']}, "
|
||||
f"{e.__traceback__.tb_lineno}, {repr(e)}")
|
||||
self.device_connected(False)
|
||||
if refresh:
|
||||
self._device_connected(False)
|
||||
self._socket = None
|
||||
return False
|
||||
|
||||
def authenticate(self):
|
||||
|
||||
def disconnect(self):
|
||||
self._buffer = b""
|
||||
if self._socket:
|
||||
self._socket.close()
|
||||
self._socket = None
|
||||
|
||||
@staticmethod
|
||||
def _fetch_v2_message(msg):
|
||||
result = []
|
||||
while len(msg) > 0:
|
||||
factual_msg_len = len(msg)
|
||||
if factual_msg_len < 6:
|
||||
break
|
||||
alleged_msg_len = msg[4] + (msg[5] << 8)
|
||||
if factual_msg_len >= alleged_msg_len:
|
||||
result.append(msg[:alleged_msg_len])
|
||||
msg = msg[alleged_msg_len:]
|
||||
else:
|
||||
break
|
||||
return result, msg
|
||||
|
||||
def _authenticate(self):
|
||||
request = self._security.encode_8370(
|
||||
self._token, MSGTYPE_HANDSHAKE_REQUEST)
|
||||
MideaLogger.debug(f"Handshaking")
|
||||
@@ -208,34 +235,34 @@ class MiedaDevice(threading.Thread):
|
||||
response = response[8: 72]
|
||||
self._security.tcp_key(response, self._key)
|
||||
|
||||
def send_message(self, data):
|
||||
def _send_message(self, data):
|
||||
if self._protocol == 3:
|
||||
self.send_message_v3(data, msg_type=MSGTYPE_ENCRYPTED_REQUEST)
|
||||
self._send_message_v3(data, msg_type=MSGTYPE_ENCRYPTED_REQUEST)
|
||||
else:
|
||||
self.send_message_v2(data)
|
||||
self._send_message_v2(data)
|
||||
|
||||
def send_message_v2(self, data):
|
||||
def _send_message_v2(self, data):
|
||||
if self._socket is not None:
|
||||
self._socket.send(data)
|
||||
else:
|
||||
MideaLogger.debug(f"Send failure, device disconnected, data: {data.hex()}")
|
||||
MideaLogger.debug(f"Command send failure, device disconnected, data: {data.hex()}")
|
||||
|
||||
def send_message_v3(self, data, msg_type=MSGTYPE_ENCRYPTED_REQUEST):
|
||||
def _send_message_v3(self, data, msg_type=MSGTYPE_ENCRYPTED_REQUEST):
|
||||
data = self._security.encode_8370(data, msg_type)
|
||||
self.send_message_v2(data)
|
||||
self._send_message_v2(data)
|
||||
|
||||
def build_send(self, cmd):
|
||||
MideaLogger.debug(f"Sending: {cmd}")
|
||||
def _build_send(self, cmd: str):
|
||||
MideaLogger.debug(f"Sending: {cmd.lower()}")
|
||||
bytes_cmd = bytes.fromhex(cmd)
|
||||
msg = PacketBuilder(self._device_id, bytes_cmd).finalize()
|
||||
self.send_message(msg)
|
||||
self._send_message(msg)
|
||||
|
||||
def refresh_status(self):
|
||||
def _refresh_status(self):
|
||||
for query in self._queries:
|
||||
if query_cmd := self._lua_runtime.build_query(query):
|
||||
self.build_send(query_cmd)
|
||||
self._build_send(query_cmd)
|
||||
|
||||
def parse_message(self, msg):
|
||||
def _parse_message(self, msg):
|
||||
if self._protocol == 3:
|
||||
messages, self._buffer = self._security.decode_8370(self._buffer + msg)
|
||||
else:
|
||||
@@ -254,8 +281,7 @@ class MiedaDevice(threading.Thread):
|
||||
cryptographic = message[40:-16]
|
||||
if payload_len % 16 == 0:
|
||||
decrypted = self._security.aes_decrypt(cryptographic)
|
||||
MideaLogger.debug(f"Received: {decrypted.hex()}")
|
||||
# 这就是最终消息
|
||||
MideaLogger.debug(f"Received: {decrypted.hex().lower()}")
|
||||
if status := self._lua_runtime.decode_status(decrypted.hex()):
|
||||
MideaLogger.debug(f"Decoded: {status}")
|
||||
new_status = {}
|
||||
@@ -265,22 +291,44 @@ class MiedaDevice(threading.Thread):
|
||||
self._attributes[single] = value
|
||||
new_status[single] = value
|
||||
if len(new_status) > 0:
|
||||
self.update_all(new_status)
|
||||
for c in self._calculate_get:
|
||||
lvalue = c.get("lvalue")
|
||||
rvalue = c.get("rvalue")
|
||||
if lvalue and rvalue:
|
||||
calculate = False
|
||||
for s, v in new_status.items():
|
||||
if rvalue.find(f"[{s}]") >= 0:
|
||||
calculate = True
|
||||
break
|
||||
if calculate:
|
||||
calculate_str1 = \
|
||||
(f"{lvalue.replace('[', 'self._attributes[')} = "
|
||||
f"{rvalue.replace('[', 'self._attributes[')}") \
|
||||
.replace("[","[\"").replace("]","\"]")
|
||||
calculate_str2 = \
|
||||
(f"{lvalue.replace('[', 'new_status[')} = "
|
||||
f"{rvalue.replace('[', 'self._attributes[')}") \
|
||||
.replace("[","[\"").replace("]","\"]")
|
||||
try:
|
||||
exec(calculate_str1)
|
||||
exec(calculate_str2)
|
||||
except Exception:
|
||||
MideaLogger.warning(
|
||||
f"Calculation Error: {lvalue} = {rvalue}", self._device_id
|
||||
)
|
||||
self._update_all(new_status)
|
||||
return ParseMessageResult.SUCCESS
|
||||
|
||||
def send_heartbeat(self):
|
||||
def _send_heartbeat(self):
|
||||
msg = PacketBuilder(self._device_id, bytearray([0x00])).finalize(msg_type=0)
|
||||
self.send_message(msg)
|
||||
self._send_message(msg)
|
||||
|
||||
def device_connected(self, connected=True):
|
||||
def _device_connected(self, connected=True):
|
||||
self._connected = connected
|
||||
status = {"connected": connected}
|
||||
self.update_all(status)
|
||||
self._update_all(status)
|
||||
|
||||
def register_update(self, update):
|
||||
self._updates.append(update)
|
||||
|
||||
def update_all(self, status):
|
||||
def _update_all(self, status):
|
||||
MideaLogger.debug(f"Status update: {status}")
|
||||
for update in self._updates:
|
||||
update(status)
|
||||
@@ -294,18 +342,7 @@ class MiedaDevice(threading.Thread):
|
||||
if self._is_run:
|
||||
self._is_run = False
|
||||
self._lua_runtime = None
|
||||
self.close_socket()
|
||||
|
||||
def close_socket(self):
|
||||
self._buffer = b""
|
||||
if self._socket:
|
||||
self._socket.close()
|
||||
self._socket = None
|
||||
|
||||
def set_ip_address(self, ip_address):
|
||||
MideaLogger.debug(f"Update IP address to {ip_address}")
|
||||
self._ip_address = ip_address
|
||||
self.close_socket()
|
||||
self.disconnect()
|
||||
|
||||
def run(self):
|
||||
while self._is_run:
|
||||
@@ -313,7 +350,7 @@ class MiedaDevice(threading.Thread):
|
||||
if self.connect(refresh=True) is False:
|
||||
if not self._is_run:
|
||||
return
|
||||
self.close_socket()
|
||||
self.disconnect()
|
||||
time.sleep(5)
|
||||
timeout_counter = 0
|
||||
start = time.time()
|
||||
@@ -323,20 +360,20 @@ class MiedaDevice(threading.Thread):
|
||||
while True:
|
||||
try:
|
||||
now = time.time()
|
||||
if now - previous_refresh >= self._refresh_interval:
|
||||
self.refresh_status()
|
||||
if 0 < self._refresh_interval <= now - previous_refresh:
|
||||
self._refresh_status()
|
||||
previous_refresh = now
|
||||
if now - previous_heartbeat >= self._heartbeat_interval:
|
||||
self.send_heartbeat()
|
||||
self._send_heartbeat()
|
||||
previous_heartbeat = now
|
||||
msg = self._socket.recv(512)
|
||||
msg_len = len(msg)
|
||||
if msg_len == 0:
|
||||
raise socket.error("Connection closed by peer")
|
||||
result = self.parse_message(msg)
|
||||
result = self._parse_message(msg)
|
||||
if result == ParseMessageResult.ERROR:
|
||||
MideaLogger.debug(f"Message 'ERROR' received")
|
||||
self.close_socket()
|
||||
self.disconnect()
|
||||
break
|
||||
elif result == ParseMessageResult.SUCCESS:
|
||||
timeout_counter = 0
|
||||
@@ -344,16 +381,16 @@ class MiedaDevice(threading.Thread):
|
||||
timeout_counter = timeout_counter + 1
|
||||
if timeout_counter >= 120:
|
||||
MideaLogger.debug(f"Heartbeat timed out")
|
||||
self.close_socket()
|
||||
self.disconnect()
|
||||
break
|
||||
except socket.error as e:
|
||||
MideaLogger.debug(f"Socket error {repr(e)}")
|
||||
self.close_socket()
|
||||
self.disconnect()
|
||||
break
|
||||
except Exception as e:
|
||||
MideaLogger.error(f"Unknown error :{e.__traceback__.tb_frame.f_globals['__file__']}, "
|
||||
f"{e.__traceback__.tb_lineno}, {repr(e)}")
|
||||
self.close_socket()
|
||||
self.disconnect()
|
||||
break
|
||||
|
||||
|
||||
|
@@ -107,6 +107,8 @@ def discover(discover_type=None, ip_address=None):
|
||||
MideaLogger.debug(f"Found a supported device: {device}")
|
||||
else:
|
||||
MideaLogger.debug(f"Found a unsupported device: {device}")
|
||||
if ip_address is not None:
|
||||
break
|
||||
except socket.timeout:
|
||||
break
|
||||
except socket.error as e:
|
||||
|
@@ -2,7 +2,7 @@ import lupa
|
||||
import threading
|
||||
import json
|
||||
from .logger import MideaLogger
|
||||
lupa.LuaMemoryError
|
||||
|
||||
|
||||
class LuaRuntime:
|
||||
def __init__(self, file):
|
||||
@@ -13,14 +13,15 @@ class LuaRuntime:
|
||||
self._json_to_data = self._runtimes.eval("function(param) return jsonToData(param) end")
|
||||
self._data_to_json = self._runtimes.eval("function(param) return dataToJson(param) end")
|
||||
|
||||
def json_to_data(self, json):
|
||||
def json_to_data(self, json_value):
|
||||
with self._lock:
|
||||
result = self._json_to_data(json)
|
||||
result = self._json_to_data(json_value)
|
||||
|
||||
return result
|
||||
|
||||
def data_to_json(self, data):
|
||||
def data_to_json(self, data_value):
|
||||
with self._lock:
|
||||
result = self._data_to_json(data)
|
||||
result = self._data_to_json(data_value)
|
||||
return result
|
||||
|
||||
|
||||
|
@@ -1,85 +1,170 @@
|
||||
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
|
||||
import hmac
|
||||
|
||||
|
||||
MSGTYPE_HANDSHAKE_REQUEST = 0x0
|
||||
MSGTYPE_HANDSHAKE_RESPONSE = 0x1
|
||||
MSGTYPE_ENCRYPTED_RESPONSE = 0x3
|
||||
MSGTYPE_ENCRYPTED_REQUEST = 0x6
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CloudSecurity:
|
||||
|
||||
def __init__(self, iotKey, loginKey):
|
||||
self._hmackey = "PROD_VnoClJI9aikS8dyy"
|
||||
self._iotkey = iotKey
|
||||
self._loginKey = loginKey
|
||||
def __init__(self, login_key, iot_key, hmac_key, fixed_key=None, fixed_iv=None):
|
||||
self._login_key = login_key
|
||||
self._iot_key = iot_key
|
||||
self._hmac_key = hmac_key
|
||||
self._aes_key = None
|
||||
self._aes_iv = None
|
||||
self._fixed_key = format(fixed_key, 'x').encode("ascii") if fixed_key else None
|
||||
self._fixed_iv = format(fixed_iv, 'x').encode("ascii") if fixed_iv else None
|
||||
|
||||
def sign(self, data: str, random: str) -> str:
|
||||
msg = self._iotkey
|
||||
if data:
|
||||
msg += data
|
||||
msg = self._iot_key
|
||||
msg += data
|
||||
msg += random
|
||||
sign = hmac.new(self._hmackey.encode("ascii"), msg.encode("ascii"), sha256)
|
||||
sign = hmac.new(self._hmac_key.encode("ascii"), msg.encode("ascii"), sha256)
|
||||
return sign.hexdigest()
|
||||
|
||||
def encrypt_password(self, loginId, data):
|
||||
def encrypt_password(self, login_id, data):
|
||||
m = sha256()
|
||||
m.update(data.encode("ascii"))
|
||||
login_hash = loginId + m.hexdigest() + self._loginKey
|
||||
login_hash = login_id + m.hexdigest() + self._login_key
|
||||
m = sha256()
|
||||
m.update(login_hash.encode("ascii"))
|
||||
return m.hexdigest()
|
||||
|
||||
def encrypt_iam_password(self, login_id, data) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
def encrypt_iam_password(self, loginId, data) -> str:
|
||||
@staticmethod
|
||||
def get_deviceid(username):
|
||||
return md5(f"Hello, {username}!".encode("ascii")).digest().hex()[:16]
|
||||
|
||||
@staticmethod
|
||||
def get_udp_id(appliance_id, method=0):
|
||||
if method == 0:
|
||||
bytes_id = bytes(reversed(appliance_id.to_bytes(8, "big")))
|
||||
elif method == 1:
|
||||
bytes_id = appliance_id.to_bytes(6, "big")
|
||||
elif method == 2:
|
||||
bytes_id = appliance_id.to_bytes(6, "little")
|
||||
else:
|
||||
return None
|
||||
data = bytearray(sha256(bytes_id).digest())
|
||||
for i in range(0, 16):
|
||||
data[i] ^= data[i + 16]
|
||||
return data[0: 16].hex()
|
||||
|
||||
def set_aes_keys(self, key, iv):
|
||||
if isinstance(key, str):
|
||||
key = key.encode("ascii")
|
||||
if isinstance(iv, str):
|
||||
iv = iv.encode("ascii")
|
||||
self._aes_key = key
|
||||
self._aes_iv = iv
|
||||
|
||||
def aes_encrypt_with_fixed_key(self, data):
|
||||
return self.aes_encrypt(data, self._fixed_key, self._fixed_iv)
|
||||
|
||||
def aes_decrypt_with_fixed_key(self, data):
|
||||
return self.aes_decrypt(data, self._fixed_key, self._fixed_iv)
|
||||
|
||||
def aes_encrypt(self, data, key=None, iv=None):
|
||||
if key is not None:
|
||||
aes_key = key
|
||||
aes_iv = iv
|
||||
else:
|
||||
aes_key = self._aes_key
|
||||
aes_iv = self._aes_iv
|
||||
if aes_key is None:
|
||||
raise ValueError("Encrypt need a key")
|
||||
if isinstance(data, str):
|
||||
data = bytes.fromhex(data)
|
||||
if aes_iv is None: # ECB
|
||||
return AES.new(aes_key, AES.MODE_ECB).encrypt(pad(data, 16))
|
||||
else: # CBC
|
||||
return AES.new(aes_key, AES.MODE_CBC, iv=aes_iv).encrypt(pad(data, 16))
|
||||
|
||||
def aes_decrypt(self, data, key=None, iv=None):
|
||||
if key is not None:
|
||||
aes_key = key
|
||||
aes_iv = iv
|
||||
else:
|
||||
aes_key = self._aes_key
|
||||
aes_iv = self._aes_iv
|
||||
if aes_key is None:
|
||||
raise ValueError("Encrypt need a key")
|
||||
if isinstance(data, str):
|
||||
data = bytes.fromhex(data)
|
||||
if aes_iv is None: # ECB
|
||||
return unpad(AES.new(aes_key, AES.MODE_ECB).decrypt(data), len(aes_key)).decode()
|
||||
else: # CBC
|
||||
return unpad(AES.new(aes_key, AES.MODE_CBC, iv=aes_iv).decrypt(data), len(aes_key)).decode()
|
||||
|
||||
|
||||
class MeijuCloudSecurity(CloudSecurity):
|
||||
def __init__(self, login_key, iot_key, hmac_key):
|
||||
super().__init__(login_key, iot_key, hmac_key,
|
||||
10864842703515613082)
|
||||
|
||||
def encrypt_iam_password(self, login_id, data) -> str:
|
||||
md = md5()
|
||||
md.update(data.encode("ascii"))
|
||||
md_second = md5()
|
||||
md_second.update(md.hexdigest().encode("ascii"))
|
||||
return md_second.hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def get_udpid(data):
|
||||
data = bytearray(sha256(data).digest())
|
||||
for i in range(0, 16):
|
||||
data[i] ^= data[i + 16]
|
||||
return data[0: 16].hex()
|
||||
|
||||
@staticmethod
|
||||
def decrypt(data:bytes, key:bytes="96c7acdfdb8af79a".encode()):
|
||||
return unpad(AES.new(key, AES.MODE_ECB).decrypt(data), 16)
|
||||
class MSmartCloudSecurity(CloudSecurity):
|
||||
def __init__(self, login_key, iot_key, hmac_key):
|
||||
super().__init__(login_key, iot_key, hmac_key,
|
||||
13101328926877700970,
|
||||
16429062708050928556)
|
||||
|
||||
@staticmethod
|
||||
def encrypt(data:bytes, key:bytes="96c7acdfdb8af79a".encode()):
|
||||
return AES.new(key, AES.MODE_ECB).encrypt(pad(data, 16))
|
||||
def encrypt_iam_password(self, login_id, data) -> str:
|
||||
md = md5()
|
||||
md.update(data.encode("ascii"))
|
||||
md_second = md5()
|
||||
md_second.update(md.hexdigest().encode("ascii"))
|
||||
login_hash = login_id + md_second.hexdigest() + self._login_key
|
||||
sha = sha256()
|
||||
sha.update(login_hash.encode("ascii"))
|
||||
return sha.hexdigest()
|
||||
|
||||
def set_aes_keys(self, encrypted_key, encrypted_iv):
|
||||
key_digest = sha256(self._login_key.encode("ascii")).hexdigest()
|
||||
tmp_key = key_digest[:16].encode("ascii")
|
||||
tmp_iv = key_digest[16:32].encode("ascii")
|
||||
self._aes_key = self.aes_decrypt(encrypted_key, tmp_key, tmp_iv).encode('ascii')
|
||||
self._aes_iv = self.aes_decrypt(encrypted_iv, tmp_key, tmp_iv).encode('ascii')
|
||||
|
||||
|
||||
class LocalSecurity:
|
||||
def __init__(self):
|
||||
self.blockSize = 16
|
||||
self.iv = b"\0" * 16
|
||||
self.aes_key = bytes.fromhex("6a92ef406bad2f0359baad994171ea6d")
|
||||
self.salt = bytes.fromhex("78686469776a6e6368656b6434643531326368646a783564386534633339344432443753")
|
||||
self.aes_key = bytes.fromhex(
|
||||
format(141661095494369103254425781617665632877, 'x')
|
||||
)
|
||||
self.salt = bytes.fromhex(
|
||||
format(233912452794221312800602098970898185176935770387238278451789080441632479840061417076563, 'x')
|
||||
)
|
||||
self._tcp_key = None
|
||||
self._request_count = 0
|
||||
self._response_count = 0
|
||||
|
||||
def aes_decrypt(self, raw):
|
||||
try:
|
||||
return unpad(AES.new(self.aes_key, AES.MODE_ECB).decrypt(bytearray(raw)), self.blockSize)
|
||||
return unpad(AES.new(self.aes_key, AES.MODE_ECB).decrypt(bytearray(raw)), 16)
|
||||
except ValueError as e:
|
||||
_LOGGER.error(f"Error in aes_decrypt: {repr(e)} - data: {raw.hex()}")
|
||||
return bytearray(0)
|
||||
return bytearray(0)
|
||||
|
||||
def aes_encrypt(self, raw):
|
||||
return AES.new(self.aes_key, AES.MODE_ECB).encrypt(bytearray(pad(raw, self.blockSize)))
|
||||
return AES.new(self.aes_key, AES.MODE_ECB).encrypt(bytearray(pad(raw, 16)))
|
||||
|
||||
def aes_cbc_decrypt(self, raw, key):
|
||||
return AES.new(key=key, mode=AES.MODE_CBC, iv=self.iv).decrypt(raw)
|
||||
|
@@ -1,15 +1,17 @@
|
||||
from homeassistant.const import *
|
||||
from homeassistant.components.sensor import SensorStateClass, SensorDeviceClass
|
||||
# from homeassistant.components.binary_sensor import BinarySensorDeviceClass
|
||||
from homeassistant.components.switch import SwitchDeviceClass
|
||||
|
||||
DEVICE_MAPPING = {
|
||||
"default": {
|
||||
"manufacturer": "美的",
|
||||
"rationale": ["off", "on"],
|
||||
"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"],
|
||||
"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": {
|
||||
@@ -60,42 +62,37 @@ DEVICE_MAPPING = {
|
||||
},
|
||||
Platform.SWITCH: {
|
||||
"dry": {
|
||||
"name": "干燥",
|
||||
"device_class": SwitchDeviceClass.SWITCH,
|
||||
},
|
||||
"prevent_straight_wind": {
|
||||
"name": "防直吹",
|
||||
"device_class": SwitchDeviceClass.SWITCH,
|
||||
"rationale": [1, 2]
|
||||
},
|
||||
"aux_heat": {
|
||||
"name": "电辅热",
|
||||
"device_class": SwitchDeviceClass.SWITCH,
|
||||
}
|
||||
},
|
||||
Platform.SENSOR: {
|
||||
"indoor_temperature": {
|
||||
"name": "室内温度",
|
||||
"device_class": SensorDeviceClass.TEMPERATURE,
|
||||
"unit_of_measurement": TEMP_CELSIUS,
|
||||
"unit_of_measurement": UnitOfTemperature.CELSIUS,
|
||||
"state_class": SensorStateClass.MEASUREMENT
|
||||
},
|
||||
"outdoor_temperature": {
|
||||
"name": "室外机温度",
|
||||
"device_class": SensorDeviceClass.TEMPERATURE,
|
||||
"unit_of_measurement": TEMP_CELSIUS,
|
||||
"unit_of_measurement": UnitOfTemperature.CELSIUS,
|
||||
"state_class": SensorStateClass.MEASUREMENT
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
"22012227": {
|
||||
"manufacturer": "美的",
|
||||
"rationale": ["off", "on"],
|
||||
"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",
|
||||
"comfort_sleep", "strong_wind", "wind_swing_lr", "wind_swing_ud", "wind_speed",
|
||||
"ptc", "dry"],
|
||||
|
||||
"entities": {
|
||||
Platform.CLIMATE: {
|
||||
"thermostat": {
|
||||
@@ -140,7 +137,7 @@ DEVICE_MAPPING = {
|
||||
"aux_heat": "ptc",
|
||||
"min_temp": 17,
|
||||
"max_temp": 30,
|
||||
"temperature_unit": TEMP_CELSIUS,
|
||||
"temperature_unit": UnitOfTemperature.CELSIUS,
|
||||
"precision": PRECISION_HALVES,
|
||||
}
|
||||
},
|
||||
@@ -160,82 +157,12 @@ DEVICE_MAPPING = {
|
||||
}
|
||||
},
|
||||
Platform.SENSOR: {
|
||||
"indoor_temperature": {
|
||||
"name": "室内温度",
|
||||
"device_class": SensorDeviceClass.TEMPERATURE,
|
||||
"unit_of_measurement": TEMP_CELSIUS,
|
||||
"state_class": SensorStateClass.MEASUREMENT
|
||||
},
|
||||
"outdoor_temperature": {
|
||||
"name": "室外机温度",
|
||||
"device_class": SensorDeviceClass.TEMPERATURE,
|
||||
"unit_of_measurement": TEMP_CELSIUS,
|
||||
"unit_of_measurement": UnitOfTemperature.CELSIUS,
|
||||
"state_class": SensorStateClass.MEASUREMENT
|
||||
},
|
||||
},
|
||||
Platform.BINARY_SENSOR: {
|
||||
"power": {}
|
||||
},
|
||||
Platform.SELECT: {
|
||||
"preset_modes": {
|
||||
"options": {
|
||||
"none": {
|
||||
"eco": "off",
|
||||
"comfort_power_save": "off",
|
||||
"comfort_sleep": "off",
|
||||
"strong_wind": "off"
|
||||
},
|
||||
"eco": {"eco": "on"},
|
||||
"comfort": {"comfort_power_save": "on"},
|
||||
"sleep": {"comfort_sleep": "on"},
|
||||
"boost": {"strong_wind": "on"}
|
||||
}
|
||||
},
|
||||
"hvac_modes": {
|
||||
"options": {
|
||||
"off": {"power": "off"},
|
||||
"heat": {"power": "on", "mode": "heat"},
|
||||
"cool": {"power": "on", "mode": "cool"},
|
||||
"auto": {"power": "on", "mode": "auto"},
|
||||
"dry": {"power": "on", "mode": "dry"},
|
||||
"fan_only": {"power": "on", "mode": "fan"}
|
||||
}
|
||||
}
|
||||
},
|
||||
Platform.WATER_HEATER:{
|
||||
"water_heater": {
|
||||
"name": "热水器",
|
||||
"power": "power",
|
||||
"operation_list": {
|
||||
"off": {"power": "off"},
|
||||
"heat": {"power": "on", "mode": "heat"},
|
||||
"cool": {"power": "on", "mode": "cool"},
|
||||
"auto": {"power": "on", "mode": "auto"},
|
||||
"dry": {"power": "on", "mode": "dry"},
|
||||
"fan_only": {"power": "on", "mode": "fan"}
|
||||
},
|
||||
"target_temperature": ["temperature", "small_temperature"],
|
||||
"current_temperature": "indoor_temperature",
|
||||
"min_temp": 17,
|
||||
"max_temp": 30,
|
||||
"temperature_unit": TEMP_CELSIUS,
|
||||
"precision": PRECISION_HALVES,
|
||||
}
|
||||
},
|
||||
Platform.FAN: {
|
||||
"fan": {
|
||||
"power": "power",
|
||||
"preset_modes": {
|
||||
"off": {"power": "off"},
|
||||
"heat": {"power": "on", "mode": "heat"},
|
||||
"cool": {"power": "on", "mode": "cool"},
|
||||
"auto": {"power": "on", "mode": "auto"},
|
||||
"dry": {"power": "on", "mode": "dry"},
|
||||
"fan_only": {"power": "on", "mode": "fan"}
|
||||
},
|
||||
"oscillate": "wind_swing_lr",
|
||||
"speeds": list({"wind_speed": value + 1} for value in range(0, 100)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
177
custom_components/midea_meiju_codec/device_mapping/T0xEA.py
Normal file
177
custom_components/midea_meiju_codec/device_mapping/T0xEA.py
Normal file
@@ -0,0 +1,177 @@
|
||||
from homeassistant.const import *
|
||||
from homeassistant.components.sensor import SensorStateClass, SensorDeviceClass
|
||||
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
|
||||
|
||||
DEVICE_MAPPING = {
|
||||
"default": {
|
||||
"rationale": [0, 1],
|
||||
"calculate": {
|
||||
"get": [
|
||||
{
|
||||
"lvalue": "[remaining_time]",
|
||||
"rvalue": "[left_time_hour] * 60 + [left_time_min]"
|
||||
},
|
||||
{
|
||||
"lvalue": "[warming_time]",
|
||||
"rvalue": "[warm_time_hour] * 60 + [warm_time_min]"
|
||||
},
|
||||
{
|
||||
"lvalue": "[delay_time]",
|
||||
"rvalue": "[order_time_hour] * 60 + [order_time_min]",
|
||||
}
|
||||
],
|
||||
"set": {
|
||||
}
|
||||
},
|
||||
"entities": {
|
||||
Platform.SENSOR: {
|
||||
"work_stage": {},
|
||||
"voltage": {
|
||||
"device_class": SensorDeviceClass.VOLTAGE,
|
||||
"unit_of_measurement": UnitOfElectricPotential.VOLT,
|
||||
"state_class": SensorStateClass.MEASUREMENT
|
||||
},
|
||||
"top_temperature": {
|
||||
"device_class": SensorDeviceClass.TEMPERATURE,
|
||||
"unit_of_measurement": UnitOfTemperature.CELSIUS,
|
||||
"state_class": SensorStateClass.MEASUREMENT
|
||||
},
|
||||
"bottom_temperature": {
|
||||
"device_class": SensorDeviceClass.TEMPERATURE,
|
||||
"unit_of_measurement": UnitOfTemperature.CELSIUS,
|
||||
"state_class": SensorStateClass.MEASUREMENT
|
||||
},
|
||||
"remaining_time": {
|
||||
"unit_of_measurement": UnitOfTime.MINUTES
|
||||
},
|
||||
"warming_time": {
|
||||
"unit_of_measurement": UnitOfTime.MINUTES
|
||||
},
|
||||
"delay_time": {
|
||||
"unit_of_measurement": UnitOfTime.MINUTES
|
||||
},
|
||||
},
|
||||
Platform.BINARY_SENSOR: {
|
||||
"top_hot": {
|
||||
"device_class": BinarySensorDeviceClass.RUNNING
|
||||
},
|
||||
"flank_hot": {
|
||||
"device_class": BinarySensorDeviceClass.RUNNING
|
||||
},
|
||||
"bottom_hot": {
|
||||
"device_class": BinarySensorDeviceClass.RUNNING
|
||||
}
|
||||
},
|
||||
Platform.SELECT: {
|
||||
"mode": {
|
||||
"options": {
|
||||
"Rice": {"mode": "essence_rice", "work_status": "cooking"},
|
||||
"Porridge": {"mode": "gruel", "work_status": "cooking"},
|
||||
"热饭": {"mode": "heat_rice", "work_status": "cooking"},
|
||||
"Congee": {"mode": "boil_congee", "work_status": "cooking"},
|
||||
"Soup": {"mode": "cook_soup", "work_status": "cooking"},
|
||||
"Steam": {"mode": "stewing", "work_status": "cooking"},
|
||||
}
|
||||
},
|
||||
"rice_type": {
|
||||
"options": {
|
||||
"None": {"rice_type": "none"},
|
||||
"Northeast rice": {"rice_type": "northeast"},
|
||||
"Long-grain rice": {"rice_type": "longrain"},
|
||||
"Fragrant rice": {"rice_type": "fragrant"},
|
||||
"Wuchang rice": {"rice_type": "five"},
|
||||
}
|
||||
},
|
||||
"work_status": {
|
||||
"options": {
|
||||
"Stop": {"work_status": "cancel"},
|
||||
"Cooking": {"work_status": "cooking"},
|
||||
"Warming": {"work_status": "keep_warm"},
|
||||
"Soaking": {"work_status": "awakening_rice"},
|
||||
"Delay": {"work_status": "schedule"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"61001527": {
|
||||
"rationale": [0, 1],
|
||||
"calculate": {
|
||||
"get": [
|
||||
{
|
||||
"lvalue": "[remaining_time]",
|
||||
"rvalue": "[left_time_hour] * 60 + [left_time_min]"
|
||||
},
|
||||
{
|
||||
"lvalue": "[warming_time]",
|
||||
"rvalue": "[warm_time_hour] * 60 + [warm_time_min]"
|
||||
},
|
||||
{
|
||||
"lvalue": "[delay_time]",
|
||||
"rvalue": "[order_time_hour] * 60 + [order_time_min]",
|
||||
}
|
||||
],
|
||||
"set": {
|
||||
}
|
||||
},
|
||||
"entities": {
|
||||
Platform.SENSOR: {
|
||||
"work_stage": {},
|
||||
"voltage": {
|
||||
"device_class": SensorDeviceClass.VOLTAGE,
|
||||
"unit_of_measurement": UnitOfElectricPotential.VOLT,
|
||||
"state_class": SensorStateClass.MEASUREMENT
|
||||
},
|
||||
"top_temperature": {
|
||||
"device_class": SensorDeviceClass.TEMPERATURE,
|
||||
"unit_of_measurement": UnitOfTemperature.CELSIUS,
|
||||
"state_class": SensorStateClass.MEASUREMENT
|
||||
},
|
||||
"bottom_temperature": {
|
||||
"device_class": SensorDeviceClass.TEMPERATURE,
|
||||
"unit_of_measurement": UnitOfTemperature.CELSIUS,
|
||||
"state_class": SensorStateClass.MEASUREMENT
|
||||
},
|
||||
"remaining_time": {
|
||||
"unit_of_measurement": UnitOfTime.MINUTES
|
||||
},
|
||||
"warming_time": {
|
||||
"unit_of_measurement": UnitOfTime.MINUTES
|
||||
},
|
||||
"delay_time": {
|
||||
"unit_of_measurement": UnitOfTime.MINUTES
|
||||
},
|
||||
},
|
||||
Platform.SELECT: {
|
||||
"mode": {
|
||||
"options": {
|
||||
"精华饭": {"mode": "essence_rice", "work_status": "cooking"},
|
||||
"稀饭": {"mode": "gruel", "work_status": "cooking"},
|
||||
"热饭": {"mode": "heat_rice", "work_status": "cooking"},
|
||||
"煮粥": {"mode": "boil_congee", "work_status": "cooking"},
|
||||
"煲汤": {"mode": "cook_soup", "work_status": "cooking"},
|
||||
"蒸煮": {"mode": "stewing", "work_status": "cooking"},
|
||||
}
|
||||
},
|
||||
"rice_type": {
|
||||
"options": {
|
||||
"无": {"rice_type": "none"},
|
||||
"东北大米": {"rice_type": "northeast"},
|
||||
"长粒米": {"rice_type": "longrain"},
|
||||
"香米": {"rice_type": "fragrant"},
|
||||
"五常大米": {"rice_type": "five"},
|
||||
}
|
||||
},
|
||||
"work_status": {
|
||||
"options": {
|
||||
"停止": {"work_status": "cancel"},
|
||||
"烹饪": {"work_status": "cooking"},
|
||||
"保温": {"work_status": "keep_warm"},
|
||||
"醒米": {"work_status": "awakening_rice"},
|
||||
"预约": {"work_status": "schedule"},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
267
custom_components/midea_meiju_codec/device_mapping/example.py
Normal file
267
custom_components/midea_meiju_codec/device_mapping/example.py
Normal file
@@ -0,0 +1,267 @@
|
||||
from homeassistant.const import *
|
||||
from homeassistant.components.sensor import SensorStateClass, SensorDeviceClass
|
||||
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
|
||||
from homeassistant.components.switch import SwitchDeviceClass
|
||||
|
||||
DEVICE_MAPPING = {
|
||||
"default": {
|
||||
"manufacturer": "小天鹅",
|
||||
"rationale": ["off", "on"],
|
||||
"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"
|
||||
],
|
||||
"calculate": {
|
||||
"get": [
|
||||
{
|
||||
"lvalue": "[target_temperature_new]",
|
||||
"rvalue": "[target_temperature] / 2"
|
||||
},
|
||||
{
|
||||
"lvalue": "[current_temperature_new]",
|
||||
"rvalue": "[current_temperature] / 2"
|
||||
}
|
||||
],
|
||||
"set": {
|
||||
{
|
||||
"lvalue": "[target_temperature]",
|
||||
"rvalue": "[target_temperature_new] * 2"
|
||||
},
|
||||
{
|
||||
"lvalue": "[current_temperature]",
|
||||
"rvalue": "[current_temperature_new] * 2"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entities": {
|
||||
Platform.CLIMATE: {
|
||||
"thermostat": {
|
||||
"name": "Thermostat",
|
||||
"power": "power",
|
||||
"hvac_modes": {
|
||||
"off": {"power": "off"},
|
||||
"heat": {"power": "on", "mode": "heat"},
|
||||
"cool": {"power": "on", "mode": "cool"},
|
||||
"auto": {"power": "on", "mode": "auto"},
|
||||
"dry": {"power": "on", "mode": "dry"},
|
||||
"fan_only": {"power": "on", "mode": "fan"}
|
||||
},
|
||||
"preset_modes": {
|
||||
"none": {
|
||||
"eco": "off",
|
||||
"comfort_power_save": "off",
|
||||
"comfort_sleep": "off",
|
||||
"strong_wind": "off"
|
||||
},
|
||||
"eco": {"eco": "on"},
|
||||
"comfort": {"comfort_power_save": "on"},
|
||||
"sleep": {"comfort_sleep": "on"},
|
||||
"boost": {"strong_wind": "on"}
|
||||
},
|
||||
"swing_modes": {
|
||||
"off": {"wind_swing_lr": "off", "wind_swing_ud": "off"},
|
||||
"both": {"wind_swing_lr": "on", "wind_swing_ud": "on"},
|
||||
"horizontal": {"wind_swing_lr": "on", "wind_swing_ud": "off"},
|
||||
"vertical": {"wind_swing_lr": "off", "wind_swing_ud": "on"},
|
||||
},
|
||||
"fan_modes": {
|
||||
"silent": {"wind_speed": 20},
|
||||
"low": {"wind_speed": 40},
|
||||
"medium": {"wind_speed": 60},
|
||||
"high": {"wind_speed": 80},
|
||||
"full": {"wind_speed": 100},
|
||||
"auto": {"wind_speed": 102}
|
||||
},
|
||||
"target_temperature": ["temperature", "small_temperature"],
|
||||
"current_temperature": "indoor_temperature",
|
||||
"aux_heat": "ptc",
|
||||
"min_temp": 17,
|
||||
"max_temp": 30,
|
||||
"temperature_unit": TEMP_CELSIUS,
|
||||
"precision": PRECISION_HALVES,
|
||||
}
|
||||
},
|
||||
Platform.SWITCH: {
|
||||
"dry": {
|
||||
"device_class": SwitchDeviceClass.SWITCH,
|
||||
},
|
||||
"prevent_straight_wind": {
|
||||
"device_class": SwitchDeviceClass.SWITCH,
|
||||
"rationale": [1, 2]
|
||||
},
|
||||
"aux_heat": {
|
||||
"device_class": SwitchDeviceClass.SWITCH,
|
||||
}
|
||||
},
|
||||
Platform.SENSOR: {
|
||||
"indoor_temperature": {
|
||||
"device_class": SensorDeviceClass.TEMPERATURE,
|
||||
"unit_of_measurement": UnitOfTemperature.CELSIUS,
|
||||
"state_class": SensorStateClass.MEASUREMENT
|
||||
},
|
||||
"outdoor_temperature": {
|
||||
"device_class": SensorDeviceClass.TEMPERATURE,
|
||||
"unit_of_measurement": UnitOfTemperature.CELSIUS,
|
||||
"state_class": SensorStateClass.MEASUREMENT
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
"22012227": {
|
||||
"manufacturer": "TOSHIBA",
|
||||
"rationale": ["off", "on"],
|
||||
"queries": [],
|
||||
"centralized": ["power", "temperature", "small_temperature", "mode", "eco", "comfort_power_save",
|
||||
"comfort_sleep", "strong_wind", "wind_swing_lr", "wind_swing_ud", "wind_speed",
|
||||
"ptc", "dry"],
|
||||
"entities": {
|
||||
Platform.CLIMATE: {
|
||||
"thermostat": {
|
||||
"name": "Thermostat",
|
||||
"power": "power",
|
||||
"hvac_modes": {
|
||||
"off": {"power": "off"},
|
||||
"heat": {"power": "on", "mode": "heat"},
|
||||
"cool": {"power": "on", "mode": "cool"},
|
||||
"auto": {"power": "on", "mode": "auto"},
|
||||
"dry": {"power": "on", "mode": "dry"},
|
||||
"fan_only": {"power": "on", "mode": "fan"}
|
||||
},
|
||||
"preset_modes": {
|
||||
"none": {
|
||||
"eco": "off",
|
||||
"comfort_power_save": "off",
|
||||
"comfort_sleep": "off",
|
||||
"strong_wind": "off"
|
||||
},
|
||||
"eco": {"eco": "on"},
|
||||
"comfort": {"comfort_power_save": "on"},
|
||||
"sleep": {"comfort_sleep": "on"},
|
||||
"boost": {"strong_wind": "on"}
|
||||
},
|
||||
"swing_modes": {
|
||||
"off": {"wind_swing_lr": "off", "wind_swing_ud": "off"},
|
||||
"both": {"wind_swing_lr": "on", "wind_swing_ud": "on"},
|
||||
"horizontal": {"wind_swing_lr": "on", "wind_swing_ud": "off"},
|
||||
"vertical": {"wind_swing_lr": "off", "wind_swing_ud": "on"},
|
||||
},
|
||||
"fan_modes": {
|
||||
"silent": {"wind_speed": 20},
|
||||
"low": {"wind_speed": 40},
|
||||
"medium": {"wind_speed": 60},
|
||||
"high": {"wind_speed": 80},
|
||||
"full": {"wind_speed": 100},
|
||||
"auto": {"wind_speed": 102}
|
||||
},
|
||||
"target_temperature": "target_temperature_new",
|
||||
"current_temperature": "current_temperature_new",
|
||||
"aux_heat": "ptc",
|
||||
"min_temp": 17,
|
||||
"max_temp": 30,
|
||||
"temperature_unit": UnitOfTemperature.CELSIUS,
|
||||
"precision": PRECISION_HALVES,
|
||||
}
|
||||
},
|
||||
Platform.WATER_HEATER:{
|
||||
"water_heater": {
|
||||
"name": "Gas Water Heater",
|
||||
"power": "power",
|
||||
"operation_list": {
|
||||
"off": {"power": "off"},
|
||||
"heat": {"power": "on", "mode": "heat"},
|
||||
"cool": {"power": "on", "mode": "cool"},
|
||||
"auto": {"power": "on", "mode": "auto"},
|
||||
"dry": {"power": "on", "mode": "dry"},
|
||||
"fan_only": {"power": "on", "mode": "fan"}
|
||||
},
|
||||
"target_temperature": ["temperature", "small_temperature"],
|
||||
"current_temperature": "indoor_temperature",
|
||||
"min_temp": 17,
|
||||
"max_temp": 30,
|
||||
"temperature_unit": UnitOfTemperature.CELSIUS,
|
||||
"precision": PRECISION_HALVES,
|
||||
}
|
||||
},
|
||||
Platform.FAN: {
|
||||
"fan": {
|
||||
"power": "power",
|
||||
"preset_modes": {
|
||||
"off": {"power": "off"},
|
||||
"heat": {"power": "on", "mode": "heat"},
|
||||
"cool": {"power": "on", "mode": "cool"},
|
||||
"auto": {"power": "on", "mode": "auto"},
|
||||
"dry": {"power": "on", "mode": "dry"},
|
||||
"fan_only": {"power": "on", "mode": "fan"}
|
||||
},
|
||||
"oscillate": "wind_swing_lr",
|
||||
"speeds": list({"wind_speed": value + 1} for value in range(0, 100)),
|
||||
}
|
||||
},
|
||||
Platform.SWITCH: {
|
||||
"dry": {
|
||||
"name": "干燥",
|
||||
"device_class": SwitchDeviceClass.SWITCH,
|
||||
},
|
||||
"prevent_straight_wind": {
|
||||
"name": "防直吹",
|
||||
"device_class": SwitchDeviceClass.SWITCH,
|
||||
"rationale": [1, 2]
|
||||
},
|
||||
"aux_heat": {
|
||||
"name": "电辅热",
|
||||
"device_class": SwitchDeviceClass.SWITCH,
|
||||
}
|
||||
},
|
||||
Platform.SENSOR: {
|
||||
"indoor_temperature": {
|
||||
"name": "室内温度",
|
||||
"device_class": SensorDeviceClass.TEMPERATURE,
|
||||
"unit_of_measurement": UnitOfTemperature.CELSIUS,
|
||||
"state_class": SensorStateClass.MEASUREMENT
|
||||
},
|
||||
"outdoor_temperature": {
|
||||
"name": "室外温度",
|
||||
"device_class": SensorDeviceClass.TEMPERATURE,
|
||||
"unit_of_measurement": UnitOfTemperature.CELSIUS,
|
||||
"state_class": SensorStateClass.MEASUREMENT
|
||||
},
|
||||
},
|
||||
Platform.BINARY_SENSOR: {
|
||||
"dust_full": {
|
||||
"icon": "mdi:air-filter",
|
||||
"name": "滤网尘满",
|
||||
"device_class": BinarySensorDeviceClass.PROBLEM
|
||||
},
|
||||
"move_detect": {}
|
||||
},
|
||||
Platform.SELECT: {
|
||||
"preset_modes": {
|
||||
"options": {
|
||||
"none": {
|
||||
"eco": "off",
|
||||
"comfort_power_save": "off",
|
||||
"comfort_sleep": "off",
|
||||
"strong_wind": "off"
|
||||
},
|
||||
"eco": {"eco": "on"},
|
||||
"comfort": {"comfort_power_save": "on"},
|
||||
"sleep": {"comfort_sleep": "on"},
|
||||
"boost": {"strong_wind": "on"}
|
||||
}
|
||||
},
|
||||
"hvac_modes": {
|
||||
"options": {
|
||||
"off": {"power": "off"},
|
||||
"heat": {"power": "on", "mode": "heat"},
|
||||
"cool": {"power": "on", "mode": "cool"},
|
||||
"auto": {"power": "on", "mode": "auto"},
|
||||
"dry": {"power": "on", "mode": "dry"},
|
||||
"fan_only": {"power": "on", "mode": "fan"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -22,9 +22,8 @@ class MideaEntity(Entity):
|
||||
self._config = config
|
||||
self._device_name = self._device.device_name
|
||||
self._rationale = rationale
|
||||
rationale = config.get("rationale")
|
||||
if rationale:
|
||||
self._rationale = rationale
|
||||
if rationale_local := config.get("rationale"):
|
||||
self._rationale = rationale_local
|
||||
if self._rationale is None:
|
||||
self._rationale = ["off", "on"]
|
||||
self._attr_native_unit_of_measurement = self._config.get("unit_of_measurement")
|
||||
@@ -44,6 +43,10 @@ class MideaEntity(Entity):
|
||||
self._attr_name = f"{self._device_name} {name}"
|
||||
self.entity_id = self._attr_unique_id
|
||||
|
||||
@property
|
||||
def device(self):
|
||||
return self._device
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
return False
|
||||
|
18
custom_components/midea_meiju_codec/services.yaml
Normal file
18
custom_components/midea_meiju_codec/services.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
set_attributes:
|
||||
fields:
|
||||
device_id:
|
||||
example: "1234567890"
|
||||
attributes:
|
||||
example:
|
||||
"power": "on"
|
||||
"mode": "cool"
|
||||
|
||||
set_mode:
|
||||
send_command:
|
||||
fields:
|
||||
device_id:
|
||||
example: "1234567890"
|
||||
cmd_type:
|
||||
example: 2
|
||||
cmd_body:
|
||||
example: "B0FF01370E0000A500"
|
@@ -1,43 +1,104 @@
|
||||
{
|
||||
"config": {
|
||||
"error": {
|
||||
"account_invalid": "登录美居失败,是否已修改过密码",
|
||||
"invalid_input": "无效的输入,请输入有效IP地址或auto",
|
||||
"login_failed": "无法登录到美居,请检查用户名或密码",
|
||||
"offline_error": "只能配置在线设备",
|
||||
"download_lua_failed": "下载设备协议脚本失败",
|
||||
"discover_failed": "无法在本地搜索到该设备",
|
||||
"no_new_devices": "没有可用的设备",
|
||||
"cant_get_token": "无法连接美的云获取设备关键信息(Token和Key)",
|
||||
"config_incorrect": "配置信息不正确, 请检查后重新输入",
|
||||
"connect_error": "无法连接到指定设备"
|
||||
"no_home": "No available home",
|
||||
"account_invalid": "Failed to authenticate on Midea cloud, the password may be changed",
|
||||
"invalid_input": "Illegal input, IP address or 'auto' needed",
|
||||
"login_failed": "Failed to login, wrong account or password",
|
||||
"offline_error": "Only the online appliance can be configured",
|
||||
"download_lua_failed": "Failed to download lua script of appliance",
|
||||
"discover_failed": "The appliance can't be found on the local network",
|
||||
"no_new_devices": "No any new available can be found in your home",
|
||||
"connect_error": "Can't connect to the appliance"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"username": "用户名(手机号)",
|
||||
"password": "密码"
|
||||
"account": "Account",
|
||||
"password": "Password"
|
||||
},
|
||||
"description": "登录并保存你的美居账号及密码",
|
||||
"title": "登录"
|
||||
"description": "Login and save storage your account",
|
||||
"title": "Login"
|
||||
},
|
||||
"home": {
|
||||
"title": "家庭",
|
||||
"title": "Home",
|
||||
"data": {
|
||||
"home": "选择设备所在家庭"
|
||||
"home": "Choose a location where your appliance in"
|
||||
}
|
||||
},
|
||||
"device": {
|
||||
"title": "设备",
|
||||
"title": "Appliances",
|
||||
"data": {
|
||||
"device": "选择要添加的设备"
|
||||
"device_id": "Choice a appliance to add"
|
||||
}
|
||||
},
|
||||
"discover": {
|
||||
"description": "获取设备信息,设备必须位于本地局域网内",
|
||||
"title": "设备信息",
|
||||
"description": "Discover the appliance, it must in the local area work",
|
||||
"title": "Appliance info",
|
||||
"data": {
|
||||
"ip_address": "设备地址(输入auto自动搜索设备)"
|
||||
"ip_address": "IP address('auto' for discovery automatic)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"option": "Option"
|
||||
},
|
||||
"title": "Configure"
|
||||
},
|
||||
"reset":{
|
||||
"title": "Reset the configuration of appliance",
|
||||
"description": "Remove the old configuration and make a new configuration use template\nIf your configuration was modified, the changes will lost\nIf your appliance type or model not in template, then the new configuration won't be made",
|
||||
"data":{
|
||||
"check": "I know that, do it"
|
||||
}
|
||||
},
|
||||
"configure": {
|
||||
"data": {
|
||||
"ip_address": "IP address",
|
||||
"refresh_interval": "Refresh interval(0 means not refreshing actively)"
|
||||
},
|
||||
"title": "Option"
|
||||
}
|
||||
},
|
||||
"abort":{
|
||||
"reset_success": "Reset done",
|
||||
"account_unsupport_config": "Doesn't support this operation"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"set_attribute": {
|
||||
"name": "set the attributes",
|
||||
"description": "Set the attributes of appliance in a dict",
|
||||
"fields" : {
|
||||
"device_id": {
|
||||
"name": "Appliance code",
|
||||
"description": "Appliance code (Device ID)"
|
||||
},
|
||||
"attributes": {
|
||||
"name": "Attributes",
|
||||
"description": "Attributes to set"
|
||||
}
|
||||
}
|
||||
},
|
||||
"send_command": {
|
||||
"name": "Custom command",
|
||||
"description": "Send a custom command to appliance",
|
||||
"fields" : {
|
||||
"device_id": {
|
||||
"name": "Appliance code",
|
||||
"description": "Appliance code (Device ID)"
|
||||
},
|
||||
"cmd_type": {
|
||||
"name": "Type of command",
|
||||
"description": "It can be 2 (query) or 3 (control)"
|
||||
},
|
||||
"cmd_body": {
|
||||
"name": "Body of command",
|
||||
"description": "The body of command without the MSmart protocol head and the checksum at the end"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,21 +1,20 @@
|
||||
{
|
||||
"config": {
|
||||
"error": {
|
||||
"account_invalid": "登录美居失败,是否已修改过密码",
|
||||
"no_home": "未找到可用家庭",
|
||||
"account_invalid": "登录美的云服务器失败,是否已修改过密码",
|
||||
"invalid_input": "无效的输入,请输入有效IP地址或auto",
|
||||
"login_failed": "无法登录到美居,请检查用户名或密码",
|
||||
"login_failed": "无法登录到选择的美的云服务器,请检查用户名或密码",
|
||||
"offline_error": "只能配置在线设备",
|
||||
"download_lua_failed": "下载设备协议脚本失败",
|
||||
"discover_failed": "无法在本地搜索到该设备",
|
||||
"no_new_devices": "没有可用的设备",
|
||||
"cant_get_token": "无法连接美的云获取设备关键信息(Token和Key)",
|
||||
"config_incorrect": "配置信息不正确, 请检查后重新输入",
|
||||
"connect_error": "无法连接到指定设备"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"username": "用户名(手机号)",
|
||||
"account": "用户名",
|
||||
"password": "密码"
|
||||
},
|
||||
"description": "登录并保存你的美居账号及密码",
|
||||
@@ -30,7 +29,7 @@
|
||||
"device": {
|
||||
"title": "设备",
|
||||
"data": {
|
||||
"device": "选择要添加的设备"
|
||||
"device_id": "选择要添加的设备"
|
||||
}
|
||||
},
|
||||
"discover": {
|
||||
@@ -41,5 +40,67 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"option": "操作"
|
||||
},
|
||||
"title": "选项"
|
||||
},
|
||||
"reset":{
|
||||
"title": "重置配置文件",
|
||||
"description": "移除已有的设备配置,并使用标准模板重新生成设备配置\n如果你的设备配置json文件进行过修改,重置之后修改将丢失\n如果标准模板中没有该设备类型,则不会生成设备配置",
|
||||
"data":{
|
||||
"check": "我知道了,重置吧"
|
||||
}
|
||||
},
|
||||
"configure": {
|
||||
"data": {
|
||||
"ip_address": "IP地址",
|
||||
"refresh_interval": "刷新间隔(设0为不进行主动刷新)"
|
||||
},
|
||||
"title": "配置"
|
||||
}
|
||||
},
|
||||
"abort":{
|
||||
"reset_success": "重置完成,已尝试生成新的配置",
|
||||
"account_unsupport_config": "账户配置不支持该操作"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"set_attribute": {
|
||||
"name": "设置属性",
|
||||
"description": "设置设备的属性值(可多属性一起设置)",
|
||||
"fields" : {
|
||||
"device_id": {
|
||||
"name": "设备编码",
|
||||
"description": "设备编码(Device ID)"
|
||||
},
|
||||
"attributes": {
|
||||
"name": "属性集合",
|
||||
"description": "要设置的属性"
|
||||
}
|
||||
}
|
||||
},
|
||||
"send_command": {
|
||||
"name": "自定义命令",
|
||||
"description": "向设备发送一个自定义命令",
|
||||
"fields" : {
|
||||
"device_id": {
|
||||
"name": "设备编码",
|
||||
"description": "设备编码(Device ID)"
|
||||
},
|
||||
"cmd_type": {
|
||||
"name": "命令类型",
|
||||
"description": "命令类型,可以为2(查询)或3(设置)"
|
||||
},
|
||||
"cmd_body": {
|
||||
"name": "命令体",
|
||||
"description": "命令的消息体(不包括前部的MSmart协议头及后部的校验码)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user