second version
This commit is contained in:
@@ -7,148 +7,23 @@ import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import asyncpg
|
||||
from pymodbus.client import AsyncModbusTcpClient
|
||||
from pymodbus.exceptions import ConnectionException, ModbusIOException
|
||||
from app.ws_manager import manager
|
||||
|
||||
from services.modbus_client import get_modbus_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _to_signed_i16(value: int) -> int:
|
||||
v = value & 0xFFFF
|
||||
if v >= 0x8000:
|
||||
return v - 0x10000
|
||||
return v
|
||||
|
||||
|
||||
class ModbusDevice:
|
||||
def __init__(self, host: str, port: int, unit_id: int, device_name: str) -> None:
|
||||
self._host = host
|
||||
self._port = int(port) if port else 502
|
||||
self._unit_id = int(unit_id) if unit_id is not None else 1
|
||||
self._device_name = device_name
|
||||
self._client: AsyncModbusTcpClient | None = None
|
||||
self._error_count = 0
|
||||
|
||||
def _log_prefix(self) -> str:
|
||||
return f"[{self._device_name}]"
|
||||
|
||||
def _note_communication_failure(self, exc: BaseException | None) -> None:
|
||||
self._error_count += 1
|
||||
if isinstance(exc, ConnectionError):
|
||||
logger.warning("%s ConnectionError: %s", self._log_prefix(), exc)
|
||||
else:
|
||||
logger.warning(
|
||||
"%s komunikace selhala: %s",
|
||||
self._log_prefix(),
|
||||
exc if exc is not None else "neznámá chyba",
|
||||
)
|
||||
if self._error_count >= 3:
|
||||
logger.error("%s Opakované chyby komunikace", self._log_prefix())
|
||||
if self._error_count >= 10 and self._error_count % 10 == 0:
|
||||
logger.critical(
|
||||
"%s Opakované chyby komunikace, pokus o reconnect",
|
||||
self._log_prefix(),
|
||||
)
|
||||
|
||||
def _reset_error_count(self) -> None:
|
||||
self._error_count = 0
|
||||
|
||||
async def _ensure_connected(self) -> bool:
|
||||
if self._client is None:
|
||||
self._client = AsyncModbusTcpClient(self._host, port=self._port)
|
||||
if not self._client.connected:
|
||||
try:
|
||||
ok = await self._client.connect()
|
||||
except ConnectionError as e:
|
||||
self._note_communication_failure(e)
|
||||
return False
|
||||
except OSError as e:
|
||||
self._note_communication_failure(e)
|
||||
return False
|
||||
if not ok:
|
||||
self._note_communication_failure(ConnectionError("Modbus connect() returned False"))
|
||||
return False
|
||||
return True
|
||||
|
||||
async def _reconnect(self) -> None:
|
||||
if self._client is not None:
|
||||
self._client.close()
|
||||
self._client = None
|
||||
self._client = AsyncModbusTcpClient(self._host, port=self._port)
|
||||
try:
|
||||
await self._client.connect()
|
||||
except (ConnectionError, OSError) as e:
|
||||
logger.warning("%s reconnect selhal: %s", self._log_prefix(), e)
|
||||
|
||||
async def read_register(self, address: int) -> int:
|
||||
"""Čte jeden holding register. Vrátí 0 při chybě."""
|
||||
try:
|
||||
if not await self._ensure_connected():
|
||||
if self._error_count >= 10 and self._error_count % 10 == 0:
|
||||
await self._reconnect()
|
||||
return 0
|
||||
assert self._client is not None
|
||||
resp = await self._client.read_holding_registers(
|
||||
address, count=1, device_id=self._unit_id
|
||||
)
|
||||
if resp.isError() or not getattr(resp, "registers", None):
|
||||
self._note_communication_failure(
|
||||
ConnectionException(f"read_holding_registers@{address:#x}: {resp!r}")
|
||||
)
|
||||
if self._error_count >= 10 and self._error_count % 10 == 0:
|
||||
await self._reconnect()
|
||||
return 0
|
||||
self._reset_error_count()
|
||||
return int(resp.registers[0])
|
||||
except ConnectionError as e:
|
||||
self._note_communication_failure(e)
|
||||
if self._error_count >= 10 and self._error_count % 10 == 0:
|
||||
await self._reconnect()
|
||||
return 0
|
||||
except (OSError, ModbusIOException, ConnectionException) as e:
|
||||
self._note_communication_failure(e)
|
||||
if self._error_count >= 10 and self._error_count % 10 == 0:
|
||||
await self._reconnect()
|
||||
return 0
|
||||
|
||||
async def read_register_signed(self, address: int) -> int:
|
||||
"""Čte signed int16 (pro výkony které mohou být záporné)."""
|
||||
u = await self.read_register(address)
|
||||
return _to_signed_i16(u)
|
||||
|
||||
async def write_register(self, address: int, value: int) -> bool:
|
||||
"""Zapíše jeden holding register. Vrátí False při chybě."""
|
||||
try:
|
||||
if not await self._ensure_connected():
|
||||
if self._error_count >= 10 and self._error_count % 10 == 0:
|
||||
await self._reconnect()
|
||||
return False
|
||||
assert self._client is not None
|
||||
resp = await self._client.write_register(address, value, device_id=self._unit_id)
|
||||
if resp.isError():
|
||||
self._note_communication_failure(
|
||||
ConnectionException(f"write_register@{address:#x}: {resp!r}")
|
||||
)
|
||||
if self._error_count >= 10 and self._error_count % 10 == 0:
|
||||
await self._reconnect()
|
||||
return False
|
||||
self._reset_error_count()
|
||||
return True
|
||||
except ConnectionError as e:
|
||||
self._note_communication_failure(e)
|
||||
if self._error_count >= 10 and self._error_count % 10 == 0:
|
||||
await self._reconnect()
|
||||
return False
|
||||
except (OSError, ModbusIOException, ConnectionException) as e:
|
||||
self._note_communication_failure(e)
|
||||
if self._error_count >= 10 and self._error_count % 10 == 0:
|
||||
await self._reconnect()
|
||||
return False
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._client is not None:
|
||||
self._client.close()
|
||||
self._client = None
|
||||
# Deye SUN – holding registry (decimal adresa = přímo pro read_holding_registers)
|
||||
DEYE_REG_RUN_STATE = 500
|
||||
DEYE_REG_BATT_CHARGE_TODAY = 514
|
||||
DEYE_REG_BATT_DISCHARGE_TODAY = 515
|
||||
DEYE_REG_BATTERY_SOC = 588
|
||||
DEYE_REG_BATTERY_POWER_FLOW = 590
|
||||
DEYE_REG_GRID_TOTAL_POWER = 625
|
||||
DEYE_REG_GEN_PORT_POWER = 667
|
||||
DEYE_REG_LOAD_TOTAL_POWER = 653
|
||||
DEYE_REG_PV1_POWER = 672
|
||||
DEYE_REG_PV2_POWER = 673
|
||||
|
||||
|
||||
async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
|
||||
@@ -169,34 +44,43 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
|
||||
inv_id = row["id"]
|
||||
code = row["code"]
|
||||
host = row["host"]
|
||||
port = row["port"] or 502
|
||||
unit_id = row["unit_id"] if row["unit_id"] is not None else 1
|
||||
dev = ModbusDevice(host, port, unit_id, f"inverter:{code}")
|
||||
port = int(row["port"] or 502)
|
||||
unit_id = int(row["unit_id"] if row["unit_id"] is not None else 1)
|
||||
try:
|
||||
pv_power_w = await dev.read_register(0x0215)
|
||||
battery_soc = await dev.read_register(0x0103)
|
||||
battery_power = await dev.read_register_signed(0x0105)
|
||||
battery_voltage = (await dev.read_register(0x0101)) / 10.0
|
||||
grid_power = await dev.read_register_signed(0x0169)
|
||||
grid_voltage = (await dev.read_register(0x016F)) / 10.0
|
||||
load_power = await dev.read_register(0x0213)
|
||||
inv_temp = (await dev.read_register(0x0220)) / 10.0
|
||||
op_mode = await dev.read_register(0x0168)
|
||||
fault_code = await dev.read_register(0x0180)
|
||||
client = await get_modbus_client(host, port, unit_id)
|
||||
async with client.batch() as mb:
|
||||
run_state = await mb.read_register(DEYE_REG_RUN_STATE)
|
||||
battery_soc = await mb.read_register(DEYE_REG_BATTERY_SOC)
|
||||
battery_power = await mb.read_register_signed(DEYE_REG_BATTERY_POWER_FLOW)
|
||||
batt_charge_today = await mb.read_register(DEYE_REG_BATT_CHARGE_TODAY)
|
||||
batt_discharge_today = await mb.read_register(DEYE_REG_BATT_DISCHARGE_TODAY)
|
||||
gen_port_power = await mb.read_register(DEYE_REG_GEN_PORT_POWER)
|
||||
grid_power = await mb.read_register_signed(DEYE_REG_GRID_TOTAL_POWER)
|
||||
load_power = await mb.read_register(DEYE_REG_LOAD_TOTAL_POWER)
|
||||
pv1_power = await mb.read_register(DEYE_REG_PV1_POWER)
|
||||
pv2_power = await mb.read_register(DEYE_REG_PV2_POWER)
|
||||
# Celková výroba FVE na této instalaci = stringy PV + výkon přes GEN port.
|
||||
pv_power_w = int(pv1_power) + int(pv2_power) + int(gen_port_power)
|
||||
|
||||
logger.debug("inverter:%s Deye run_state raw=%s", code, run_state)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO ems.telemetry_inverter (
|
||||
site_id, inverter_id, measured_at,
|
||||
pv_power_w, battery_soc_percent, battery_power_w, battery_voltage_v,
|
||||
grid_power_w, grid_voltage_v, load_power_w,
|
||||
inverter_temp_c, operating_mode, fault_code
|
||||
pv_power_w, pv1_power_w, pv2_power_w, gen_port_power_w,
|
||||
battery_soc_percent, battery_power_w,
|
||||
batt_charge_today_wh, batt_discharge_today_wh,
|
||||
grid_power_w, load_power_w,
|
||||
run_state
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3,
|
||||
$4, $5, $6, $7,
|
||||
$8, $9, $10,
|
||||
$11, $12, $13
|
||||
$8, $9,
|
||||
$10, $11,
|
||||
$12, $13,
|
||||
$14
|
||||
)
|
||||
ON CONFLICT (inverter_id, measured_at) DO NOTHING
|
||||
""",
|
||||
@@ -204,20 +88,34 @@ async def poll_inverter(site_id: int, db: asyncpg.Connection) -> None:
|
||||
inv_id,
|
||||
measured_at,
|
||||
pv_power_w,
|
||||
battery_soc,
|
||||
pv1_power,
|
||||
pv2_power,
|
||||
gen_port_power,
|
||||
float(battery_soc),
|
||||
battery_power,
|
||||
battery_voltage,
|
||||
batt_charge_today,
|
||||
batt_discharge_today,
|
||||
grid_power,
|
||||
grid_voltage,
|
||||
load_power,
|
||||
inv_temp,
|
||||
str(op_mode),
|
||||
fault_code,
|
||||
run_state,
|
||||
)
|
||||
inv_temp: float | None = None
|
||||
await manager.broadcast_telemetry(
|
||||
{
|
||||
"type": "telemetry",
|
||||
"site_id": site_id,
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"pv_power_w": pv_power_w,
|
||||
"battery_soc_pct": float(battery_soc),
|
||||
"battery_power_w": battery_power,
|
||||
"grid_power_w": grid_power,
|
||||
"load_power_w": load_power,
|
||||
"gen_port_power_w": gen_port_power,
|
||||
"inverter_temp_c": inv_temp,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("poll_inverter site=%s inverter=%s: %s", site_id, code, e)
|
||||
finally:
|
||||
await dev.close()
|
||||
|
||||
|
||||
async def poll_ev_chargers(site_id: int, db: asyncpg.Connection) -> None:
|
||||
@@ -233,23 +131,112 @@ async def poll_ev_chargers(site_id: int, db: asyncpg.Connection) -> None:
|
||||
site_id,
|
||||
)
|
||||
measured_at = datetime.now(timezone.utc)
|
||||
connector_id = 1
|
||||
for row in rows:
|
||||
code = row["code"]
|
||||
charger_id = row["id"]
|
||||
logger.info("TODO: EV charger Modbus registry pending | %s", code)
|
||||
# Placeholder až do mapování Modbus: status zůstává available (bez falešných přechodů).
|
||||
current_status = "available"
|
||||
|
||||
previous_status = await db.fetchval(
|
||||
"""
|
||||
SELECT status
|
||||
FROM ems.telemetry_ev_charger
|
||||
WHERE charger_id = $1 AND connector_id = $2
|
||||
ORDER BY measured_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
charger_id,
|
||||
connector_id,
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO ems.telemetry_ev_charger (
|
||||
site_id, charger_id, measured_at, connector_id,
|
||||
status, power_w, energy_kwh
|
||||
)
|
||||
VALUES ($1, $2, $3, 1, 'available', 0, 0)
|
||||
VALUES ($1, $2, $3, $4, $5, 0, 0)
|
||||
ON CONFLICT (charger_id, connector_id, measured_at) DO NOTHING
|
||||
""",
|
||||
site_id,
|
||||
row["id"],
|
||||
charger_id,
|
||||
measured_at,
|
||||
connector_id,
|
||||
current_status,
|
||||
)
|
||||
|
||||
if previous_status is not None:
|
||||
if previous_status == "available" and current_status != "available":
|
||||
vehicle_id = await db.fetchval(
|
||||
"""
|
||||
SELECT av.id
|
||||
FROM ems.asset_vehicle av
|
||||
WHERE av.site_id = $1
|
||||
AND av.default_charger_id = $2
|
||||
AND av.active = true
|
||||
ORDER BY av.id
|
||||
LIMIT 1
|
||||
""",
|
||||
site_id,
|
||||
charger_id,
|
||||
)
|
||||
await db.execute(
|
||||
"SELECT ems.fn_update_ev_arrival_stats($1, $2, $3, $4)",
|
||||
site_id,
|
||||
charger_id,
|
||||
vehicle_id,
|
||||
measured_at,
|
||||
)
|
||||
logger.info("EV arrival detected on charger %s", code)
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO ems.ev_session (
|
||||
site_id, charger_id, vehicle_id, session_start,
|
||||
target_soc_pct, target_deadline
|
||||
)
|
||||
SELECT
|
||||
ac.site_id,
|
||||
ac.id,
|
||||
av.id,
|
||||
now(),
|
||||
av.default_target_soc_pct,
|
||||
CASE
|
||||
WHEN av.default_deadline_hour IS NOT NULL THEN
|
||||
(
|
||||
(timezone('Europe/Prague', now()))::date + interval '1 day'
|
||||
+ make_interval(hours => av.default_deadline_hour)
|
||||
)::timestamp AT TIME ZONE 'Europe/Prague'
|
||||
END
|
||||
FROM ems.asset_ev_charger ac
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT v.id, v.default_target_soc_pct, v.default_deadline_hour
|
||||
FROM ems.asset_vehicle v
|
||||
WHERE v.default_charger_id = ac.id
|
||||
AND v.site_id = ac.site_id
|
||||
AND v.active = true
|
||||
ORDER BY v.id
|
||||
LIMIT 1
|
||||
) av ON true
|
||||
WHERE ac.id = $1 AND ac.site_id = $2
|
||||
ON CONFLICT (charger_id) WHERE session_end IS NULL DO NOTHING
|
||||
""",
|
||||
charger_id,
|
||||
site_id,
|
||||
)
|
||||
|
||||
if previous_status != "available" and current_status == "available":
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE ems.ev_session
|
||||
SET session_end = now()
|
||||
WHERE charger_id = $1 AND session_end IS NULL
|
||||
""",
|
||||
charger_id,
|
||||
)
|
||||
logger.info("EV departure detected on charger %s", code)
|
||||
|
||||
|
||||
async def poll_heat_pump(site_id: int, db: asyncpg.Connection) -> None:
|
||||
rows = await db.fetch(
|
||||
|
||||
Reference in New Issue
Block a user