Erster Stand der Hintergrundprozesse, die auf der Synology unter /volume1/homes/wagner/SolarManager laufen: der Manager selbst, die Sammler je Geraet, die MQTT-Bruecke, der Wecker und - neu hinzugezogen - der AutoAction-Runner, der als Hintergrundprozess hierher gehoert und nicht ins Web-Verzeichnis. Zugangsdaten stehen nicht mehr im Quelltext, sondern in config.ini, die nicht mit eingecheckt wird. Vorlage ist config.ini.example, gelesen wird sie von konfig.py. Betroffen waren solarManager.py (Datenbank und Wattpilot), zeit.py, gatherWaterData.py, wecker.py und skoda_testdaten.py, das sich das Passwort bisher aus dem Quelltext eines anderen Moduls herausgesucht hat. Die Kia-Anbindung ist mit dem Fahrzeug entfallen: kiaTest.py, gatherCarData.py und hyundai_kia_connect_api sind nicht mehr dabei, ebenso gatherInverterData.py, auf das nur noch eine auskommentierte Zeile zeigte. Die mitgelieferten Bibliotheken bleiben im Repository - die NAS hat kein pip, sie muessen neben den Skripten liegen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
423 lines
12 KiB
Python
423 lines
12 KiB
Python
"""DTU communication implementation for Hoymiles WiFi."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import struct
|
|
import time
|
|
from datetime import datetime
|
|
from enum import Enum, IntEnum
|
|
from typing import Any
|
|
|
|
from crcmod import mkCrcFun
|
|
|
|
from hoymiles_wifi import logger
|
|
from hoymiles_wifi.const import (
|
|
CMD_ACTION_ALARM_LIST,
|
|
CMD_ACTION_DTU_REBOOT,
|
|
CMD_ACTION_DTU_UPGRADE,
|
|
CMD_ACTION_LIMIT_POWER,
|
|
CMD_ACTION_MI_SHUTDOWN,
|
|
CMD_ACTION_MI_START,
|
|
CMD_APP_GET_HIST_POWER_RES,
|
|
CMD_APP_INFO_DATA_RES_DTO,
|
|
CMD_CLOUD_COMMAND_RES_DTO,
|
|
CMD_COMMAND_RES_DTO,
|
|
CMD_GET_CONFIG,
|
|
CMD_HB_RES_DTO,
|
|
CMD_HEADER,
|
|
CMD_NETWORK_INFO_RES,
|
|
CMD_REAL_DATA_RES_DTO,
|
|
CMD_REAL_RES_DTO,
|
|
CMD_SET_CONFIG,
|
|
DEV_DTU,
|
|
DTU_FIRMWARE_URL_00_01_11,
|
|
DTU_PORT,
|
|
OFFSET,
|
|
)
|
|
from hoymiles_wifi.hoymiles import convert_inverter_serial_number
|
|
from hoymiles_wifi.protobuf import (
|
|
AppGetHistPower_pb2,
|
|
APPHeartbeatPB_pb2,
|
|
APPInfomationData_pb2,
|
|
CommandPB_pb2,
|
|
GetConfig_pb2,
|
|
InfomationData_pb2,
|
|
NetworkInfo_pb2,
|
|
RealData_pb2,
|
|
RealDataNew_pb2,
|
|
SetConfig_pb2,
|
|
)
|
|
from hoymiles_wifi.utils import initialize_set_config
|
|
|
|
|
|
class NetmodeSelect(IntEnum):
|
|
"""Network mode selection."""
|
|
|
|
WIFI = 1
|
|
SIM = 2
|
|
LAN = 3
|
|
|
|
|
|
class NetworkState(Enum):
|
|
"""Network state."""
|
|
|
|
Unknown = 0
|
|
Online = 1
|
|
Offline = 2
|
|
|
|
|
|
class DTU:
|
|
"""DTU class."""
|
|
|
|
def __init__(self, host: str):
|
|
"""Initialize DTU class."""
|
|
|
|
self.host = host
|
|
self.state = NetworkState.Unknown
|
|
self.sequence = 0
|
|
self.mutex = asyncio.Lock()
|
|
|
|
def get_state(self) -> NetworkState:
|
|
"""Get DTU state."""
|
|
|
|
return self.state
|
|
|
|
def set_state(self, new_state: NetworkState):
|
|
"""Set DTU state."""
|
|
|
|
if self.state != new_state:
|
|
self.state = new_state
|
|
logger.debug(f"DTU is {new_state}")
|
|
|
|
async def async_get_real_data(self) -> RealData_pb2.RealDataResDTO | None:
|
|
"""Get real data."""
|
|
|
|
request = RealData_pb2.RealDataResDTO()
|
|
request.time_ymd_hms = (
|
|
datetime.now().strftime("%Y-%m-%d %H:%M:%S").encode("utf-8")
|
|
)
|
|
request.time = int(time.time())
|
|
request.offset = OFFSET
|
|
request.error_code = 0
|
|
|
|
command = CMD_REAL_DATA_RES_DTO
|
|
return await self.async_send_request(
|
|
command, request, RealData_pb2.RealDataReqDTO
|
|
)
|
|
|
|
async def async_get_real_data_new(self) -> RealDataNew_pb2.RealDataNewResDTO | None:
|
|
"""Get real data new."""
|
|
|
|
request = RealDataNew_pb2.RealDataNewResDTO()
|
|
request.time_ymd_hms = (
|
|
datetime.now().strftime("%Y-%m-%d %H:%M:%S").encode("utf-8")
|
|
)
|
|
request.offset = OFFSET
|
|
request.time = int(time.time())
|
|
command = CMD_REAL_RES_DTO
|
|
return await self.async_send_request(
|
|
command, request, RealDataNew_pb2.RealDataNewReqDTO
|
|
)
|
|
|
|
async def async_get_config(self) -> GetConfig_pb2.GetConfigResDTO | None:
|
|
"""Get config."""
|
|
|
|
request = GetConfig_pb2.GetConfigResDTO()
|
|
request.offset = OFFSET
|
|
request.time = int(time.time()) - 60
|
|
command = CMD_GET_CONFIG
|
|
return await self.async_send_request(
|
|
command,
|
|
request,
|
|
GetConfig_pb2.GetConfigReqDTO,
|
|
)
|
|
|
|
async def async_network_info(self) -> NetworkInfo_pb2.NetworkInfoResDTO | None:
|
|
"""Get network info."""
|
|
|
|
request = NetworkInfo_pb2.NetworkInfoResDTO()
|
|
request.offset = OFFSET
|
|
request.time = int(time.time())
|
|
command = CMD_NETWORK_INFO_RES
|
|
return await self.async_send_request(
|
|
command, request, NetworkInfo_pb2.NetworkInfoReqDTO
|
|
)
|
|
|
|
async def async_app_information_data(
|
|
self,
|
|
) -> APPInfomationData_pb2.APPInfoDataResDTO:
|
|
"""Get app information data."""
|
|
request = APPInfomationData_pb2.APPInfoDataResDTO()
|
|
request.time_ymd_hms = (
|
|
datetime.now().strftime("%Y-%m-%d %H:%M:%S").encode("utf-8")
|
|
)
|
|
request.offset = OFFSET
|
|
request.time = int(time.time())
|
|
command = CMD_APP_INFO_DATA_RES_DTO
|
|
return await self.async_send_request(
|
|
command, request, APPInfomationData_pb2.APPInfoDataReqDTO
|
|
)
|
|
|
|
async def async_app_get_hist_power(
|
|
self,
|
|
) -> AppGetHistPower_pb2.AppGetHistPowerResDTO | None:
|
|
"""Get historical power."""
|
|
|
|
request = AppGetHistPower_pb2.AppGetHistPowerResDTO()
|
|
request.control_point = 0
|
|
request.offset = OFFSET
|
|
request.requested_time = int(time.time())
|
|
request.requested_day = 0
|
|
command = CMD_APP_GET_HIST_POWER_RES
|
|
return await self.async_send_request(
|
|
command,
|
|
request,
|
|
AppGetHistPower_pb2.AppGetHistPowerReqDTO,
|
|
)
|
|
|
|
async def async_set_power_limit(
|
|
self,
|
|
power_limit: int,
|
|
) -> CommandPB_pb2.CommandResDTO | None:
|
|
"""Set power limit."""
|
|
if power_limit < 0 or power_limit > 100:
|
|
logger.error("Error. Invalid power limit!")
|
|
return
|
|
|
|
power_limit = power_limit * 10
|
|
|
|
request = CommandPB_pb2.CommandResDTO()
|
|
request.time = int(time.time())
|
|
request.action = CMD_ACTION_LIMIT_POWER
|
|
request.package_nub = 1
|
|
request.tid = int(time.time())
|
|
request.data = f"A:{power_limit},B:0,C:0\r".encode()
|
|
|
|
command = CMD_COMMAND_RES_DTO
|
|
|
|
return await self.async_send_request(
|
|
command, request, CommandPB_pb2.CommandReqDTO
|
|
)
|
|
|
|
async def async_set_wifi(
|
|
self, ssid: str, password: str
|
|
) -> SetConfig_pb2.SetConfigResDTO | None:
|
|
"""Set wifi."""
|
|
|
|
get_config_req = await self.async_get_config()
|
|
|
|
if get_config_req is None:
|
|
logger.error("Failed to get config")
|
|
return None
|
|
|
|
request = initialize_set_config(get_config_req)
|
|
|
|
request.time = int(time.time())
|
|
request.offset = OFFSET
|
|
request.app_page = 1
|
|
request.netmode_select = NetmodeSelect.WIFI
|
|
request.wifi_ssid = ssid.encode("utf-8")
|
|
request.wifi_password = password.encode("utf-8")
|
|
|
|
command = CMD_SET_CONFIG
|
|
return await self.async_send_request(
|
|
command, request, SetConfig_pb2.SetConfigReqDTO
|
|
)
|
|
|
|
async def async_update_dtu_firmware(
|
|
self,
|
|
firmware_url: str = DTU_FIRMWARE_URL_00_01_11,
|
|
) -> CommandPB_pb2.CommandResDTO | None:
|
|
"""Update DTU firmware."""
|
|
|
|
request = CommandPB_pb2.CommandResDTO()
|
|
request.action = CMD_ACTION_DTU_UPGRADE
|
|
request.package_nub = 1
|
|
request.tid = int(time.time())
|
|
request.data = (firmware_url + "\r").encode("utf-8")
|
|
|
|
command = CMD_CLOUD_COMMAND_RES_DTO
|
|
return await self.async_send_request(
|
|
command, request, CommandPB_pb2.CommandReqDTO
|
|
)
|
|
|
|
async def async_restart_dtu(self) -> CommandPB_pb2.CommandResDTO | None:
|
|
"""Restart DTU."""
|
|
|
|
request = CommandPB_pb2.CommandResDTO()
|
|
request.action = CMD_ACTION_DTU_REBOOT
|
|
request.package_nub = 1
|
|
request.tid = int(time.time())
|
|
|
|
command = CMD_CLOUD_COMMAND_RES_DTO
|
|
return await self.async_send_request(
|
|
command, request, CommandPB_pb2.CommandReqDTO
|
|
)
|
|
|
|
async def async_turn_on_inverter(
|
|
self, inverter_serial: str
|
|
) -> CommandPB_pb2.CommandResDTO | None:
|
|
"""Turn on Inverter."""
|
|
|
|
inverter_serial_int = convert_inverter_serial_number(inverter_serial)
|
|
|
|
request = CommandPB_pb2.CommandResDTO()
|
|
request.action = CMD_ACTION_MI_START
|
|
request.package_nub = 1
|
|
request.dev_kind = DEV_DTU
|
|
request.tid = int(time.time())
|
|
request.mi_to_sn.extend([inverter_serial_int])
|
|
|
|
command = CMD_CLOUD_COMMAND_RES_DTO
|
|
|
|
return await self.async_send_request(
|
|
command, request, CommandPB_pb2.CommandReqDTO
|
|
)
|
|
|
|
async def async_turn_off_inverter(
|
|
self, inverter_serial: str
|
|
) -> CommandPB_pb2.CommandResDTO | None:
|
|
"""Turn off Inverter."""
|
|
|
|
inverter_serial_int = convert_inverter_serial_number(inverter_serial)
|
|
|
|
request = CommandPB_pb2.CommandResDTO()
|
|
request.action = CMD_ACTION_MI_SHUTDOWN
|
|
request.package_nub = 1
|
|
request.dev_kind = DEV_DTU
|
|
request.tid = int(time.time())
|
|
request.mi_to_sn.extend([inverter_serial_int])
|
|
|
|
command = CMD_CLOUD_COMMAND_RES_DTO
|
|
|
|
return await self.async_send_request(
|
|
command, request, CommandPB_pb2.CommandReqDTO
|
|
)
|
|
|
|
async def async_get_information_data(
|
|
self,
|
|
) -> InfomationData_pb2.InfoDataResDTO | None:
|
|
"""Get information data."""
|
|
|
|
request = InfomationData_pb2.InfoDataResDTO()
|
|
request.time_ymd_hms = (
|
|
datetime.now().strftime("%Y-%m-%d %H:%M:%S").encode("utf-8")
|
|
)
|
|
request.offset = OFFSET
|
|
request.time = int(time.time())
|
|
command = CMD_APP_INFO_DATA_RES_DTO
|
|
return await self.async_send_request(
|
|
command, request, InfomationData_pb2.InfoDataReqDTO
|
|
)
|
|
|
|
async def async_heartbeat(self) -> APPHeartbeatPB_pb2.HBReqDTO | None:
|
|
"""Request heartbeat."""
|
|
|
|
request = APPHeartbeatPB_pb2.HBResDTO()
|
|
request.time_ymd_hms = (
|
|
datetime.now().strftime("%Y-%m-%d %H:%M:%S").encode("utf-8")
|
|
)
|
|
request.offset = OFFSET
|
|
request.time = int(time.time())
|
|
|
|
command = CMD_HB_RES_DTO
|
|
return await self.async_send_request(
|
|
command, request, APPHeartbeatPB_pb2.HBReqDTO
|
|
)
|
|
|
|
async def async_get_alarm_list(self) -> CommandPB_pb2.CommandResDTO | None:
|
|
"""Turn off DTU."""
|
|
|
|
request = CommandPB_pb2.CommandResDTO()
|
|
request.action = CMD_ACTION_ALARM_LIST
|
|
request.package_nub = 1
|
|
request.dev_kind = 0
|
|
request.tid = int(time.time())
|
|
|
|
command = CMD_COMMAND_RES_DTO
|
|
return await self.async_send_request(
|
|
command, request, CommandPB_pb2.CommandReqDTO
|
|
)
|
|
|
|
async def async_send_request(
|
|
self,
|
|
command: bytes,
|
|
request: Any,
|
|
response_type: Any,
|
|
dtu_port: int = DTU_PORT,
|
|
):
|
|
"""Send request to DTU."""
|
|
|
|
self.sequence = (self.sequence + 1) & 0xFFFF
|
|
|
|
request_as_bytes = request.SerializeToString()
|
|
crc16 = mkCrcFun(0x18005, rev=True, initCrc=0xFFFF, xorOut=0x0000)(
|
|
request_as_bytes
|
|
)
|
|
length = len(request_as_bytes) + 10
|
|
|
|
# compose request message
|
|
header = CMD_HEADER + command
|
|
message = (
|
|
header
|
|
+ struct.pack(">HHH", self.sequence, crc16, length)
|
|
+ request_as_bytes
|
|
)
|
|
|
|
address = (self.host, dtu_port)
|
|
|
|
async with self.mutex:
|
|
try:
|
|
reader, writer = await asyncio.open_connection(*address)
|
|
|
|
writer.write(message)
|
|
await writer.drain()
|
|
|
|
buf = await asyncio.wait_for(reader.read(1024), timeout=5)
|
|
except (OSError, asyncio.TimeoutError) as e:
|
|
logger.debug(f"{e}")
|
|
self.set_state(NetworkState.Offline)
|
|
return None
|
|
finally:
|
|
try:
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
except Exception as e:
|
|
logger.debug(f"Error closing writer: {e}")
|
|
|
|
try:
|
|
if len(buf) < 10:
|
|
raise ValueError("Buffer is too short for unpacking")
|
|
|
|
crc16_target, read_length = struct.unpack(">HH", buf[6:10])
|
|
|
|
logger.debug(f"Read length: {read_length}")
|
|
|
|
if len(buf) != read_length:
|
|
raise ValueError("Buffer is incomplete")
|
|
|
|
response_as_bytes = buf[10:read_length]
|
|
|
|
crc16_response = mkCrcFun(0x18005, rev=True, initCrc=0xFFFF, xorOut=0x0000)(
|
|
response_as_bytes
|
|
)
|
|
|
|
if crc16_response != crc16_target:
|
|
logger.debug(
|
|
f"CRC16 mismatch: {hex(crc16_response)} != {hex(crc16_target)}"
|
|
)
|
|
raise ValueError("CRC16 mismatch")
|
|
|
|
parsed = response_type.FromString(response_as_bytes)
|
|
|
|
if not parsed:
|
|
raise ValueError("Parsing resulted in an empty or falsy value")
|
|
except Exception as e:
|
|
logger.debug(f"Failed to parse response: {e}")
|
|
self.set_state(NetworkState.Unknown)
|
|
return None
|
|
|
|
self.set_state(NetworkState.Online)
|
|
return parsed
|