362 lines
13 KiB
Python
362 lines
13 KiB
Python
"""Deye inverter writer and live register reader."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
import asyncpg
|
|
|
|
from services.control.deye_helpers import (
|
|
BATT_VOLTAGE_V,
|
|
DEYE_CLOCK_DRIFT_OK_SEC,
|
|
DEYE_CLOCK_REGS,
|
|
DEYE_CLOCK_RESYNC_INTERVAL_HOURS,
|
|
DEYE_TOU_INACTIVE_HHMM,
|
|
PRAGUE_TZ,
|
|
REG178_MI_EXPORT_DISABLE,
|
|
REG178_MI_EXPORT_ENABLE,
|
|
REG178_MI_EXPORT_MASK,
|
|
REG178_PASSIVE,
|
|
REG178_SELL,
|
|
REG178_VERIFY_MASK,
|
|
REG178_VERIFY_MASK_COMBINED,
|
|
_DEYE_INACTIVE_TOU_REGISTERS,
|
|
_deye_should_skip_time_sync_after_read,
|
|
_prague_minute_start_utc,
|
|
current_slot_hhmm,
|
|
next_slot_hhmm,
|
|
)
|
|
from services.control.modbus_journal import (
|
|
_drop_registers_matching_last_verified,
|
|
_fetch_last_verified_inverter_registers,
|
|
create_modbus_commands,
|
|
execute_modbus_commands,
|
|
)
|
|
from services.control.models import ControlSetpoints
|
|
from services.control.repository import _get_current_soc, _load_inverter_config
|
|
from services.control.setpoints import (
|
|
_deye_reg143_export_w,
|
|
_deye_system_time_register_rows,
|
|
_deye_time_point_rows,
|
|
_deye_tou_min_soc_pct,
|
|
_deye_tou_params,
|
|
_deye_tou_reserve_soc_pct,
|
|
deye_battery_charge_discharge_amps,
|
|
get_deye_mode,
|
|
)
|
|
from services.modbus_client import get_modbus_client
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
async def write_inverter_setpoints(
|
|
site_id: int,
|
|
setpoints_now: ControlSetpoints,
|
|
setpoints_next: ControlSetpoints | None,
|
|
db: asyncpg.Connection,
|
|
planning_run_id: int | None = None,
|
|
) -> str:
|
|
inv = await _load_inverter_config(site_id, db)
|
|
if inv is None:
|
|
return "FAIL inverter: no controllable Modbus endpoint"
|
|
|
|
unit_id = int(inv.unit_id if inv.unit_id is not None else 1)
|
|
raw_bat = setpoints_now.battery_w
|
|
grid_w = int(setpoints_now.grid_setpoint_w or 0)
|
|
no_export = inv.no_export
|
|
export_lim = _deye_reg143_export_w(no_export, inv.max_export_power_w)
|
|
max_batt_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V)
|
|
tp_discharge_w = 0 if setpoints_now.lock_battery else max_batt_w_discharge
|
|
tou_min_pct = _deye_tou_min_soc_pct(inv)
|
|
tou_reserve_pct = _deye_tou_reserve_soc_pct(inv)
|
|
|
|
try:
|
|
soc_telemetry = await _get_current_soc(site_id, db)
|
|
deye_mode = get_deye_mode(setpoints_now)
|
|
|
|
bat_w = int(raw_bat) if raw_bat is not None else 0
|
|
charge_a, discharge_a = deye_battery_charge_discharge_amps(
|
|
lock_battery=setpoints_now.lock_battery,
|
|
deye_mode=deye_mode,
|
|
self_sustain_local_use=setpoints_now.self_sustain_local_use,
|
|
bat_w=bat_w,
|
|
grid_w=grid_w,
|
|
max_charge_a=int(inv.max_charge_a),
|
|
max_discharge_a=int(inv.max_discharge_a),
|
|
)
|
|
|
|
zero_exp_mode = int(inv.deye_zero_export_mode or 1)
|
|
selling_mode = 0 if deye_mode == "SELL" else zero_exp_mode
|
|
solar_sell = 0 if (setpoints_now.export_ban and deye_mode != "SELL") else 1
|
|
export_limit = export_lim
|
|
reg178_val = REG178_SELL if deye_mode == "SELL" else REG178_PASSIVE
|
|
|
|
logger.info(
|
|
f"[control] site={site_id} fyzický režim Deye: {deye_mode} | "
|
|
f"battery_w={raw_bat!r} grid_w={grid_w} | "
|
|
f"charge_a={charge_a} discharge_a={discharge_a} | "
|
|
f"reg142={selling_mode} reg145={solar_sell} reg143={export_limit}W reg178={reg178_val}"
|
|
)
|
|
|
|
now_cet, time_rows = _deye_system_time_register_rows()
|
|
skip_time = False
|
|
try:
|
|
mb_clock = await get_modbus_client(inv.host, inv.port)
|
|
tvals = await mb_clock.read_holding_registers(
|
|
62, 3, int(inv.unit_id if inv.unit_id is not None else 1)
|
|
)
|
|
if len(tvals) == 3:
|
|
skip_time = _deye_should_skip_time_sync_after_read(
|
|
inv, int(tvals[0]), int(tvals[1]), int(tvals[2])
|
|
)
|
|
else:
|
|
logger.warning(
|
|
"Deye clock read: expected 3 registers, got %s; will sync 62-64",
|
|
len(tvals),
|
|
)
|
|
except Exception as e:
|
|
logger.warning("Deye clock read failed (will sync 62-64): %s", e)
|
|
|
|
if skip_time:
|
|
logger.info(
|
|
"Deye clock 62-64 skipped (drift <= %ss, last sync < %sh ago): %s CET",
|
|
DEYE_CLOCK_DRIFT_OK_SEC,
|
|
DEYE_CLOCK_RESYNC_INTERVAL_HOURS,
|
|
now_cet.strftime("%Y-%m-%d %H:%M:%S"),
|
|
)
|
|
else:
|
|
logger.info("Deye time will sync: %s CET", now_cet.strftime("%Y-%m-%d %H:%M:%S"))
|
|
|
|
registers: list[tuple[int, str, int]] = [] if skip_time else list(time_rows)
|
|
|
|
sp_tp2 = setpoints_next if setpoints_next is not None else setpoints_now
|
|
hh_cur = current_slot_hhmm()
|
|
hh_nxt = next_slot_hhmm()
|
|
p1, s1, g1 = _deye_tou_params(setpoints_now, inv)
|
|
p2, s2, g2 = _deye_tou_params(sp_tp2, inv)
|
|
registers.extend(_deye_time_point_rows(0, hh_cur, p1, s1, g1))
|
|
registers.extend(_deye_time_point_rows(1, hh_nxt, p2, s2, g2))
|
|
|
|
prague_date = datetime.now(PRAGUE_TZ).date()
|
|
inactive_sig = (
|
|
f"{DEYE_TOU_INACTIVE_HHMM}|{tou_min_pct}|{tou_reserve_pct}|{tp_discharge_w}"
|
|
)
|
|
need_inactive_tou = (
|
|
inv.deye_last_tou_inactive_write_prague_date != prague_date
|
|
or inv.deye_tou_inactive_signature != inactive_sig
|
|
)
|
|
if need_inactive_tou:
|
|
for idx in range(2, 6):
|
|
registers.extend(
|
|
_deye_time_point_rows(
|
|
idx, DEYE_TOU_INACTIVE_HHMM, tp_discharge_w, tou_min_pct, False
|
|
)
|
|
)
|
|
else:
|
|
logger.debug(
|
|
"Deye TOU rows 3-6 skipped (already written today, signature unchanged)"
|
|
)
|
|
|
|
registers.extend(
|
|
[
|
|
(108, "", charge_a),
|
|
(109, "", discharge_a),
|
|
(141, "energy_mode (0)", 0),
|
|
(142, "limit_control", selling_mode),
|
|
(143, "", export_limit),
|
|
(145, "solar_sell", solar_sell),
|
|
]
|
|
)
|
|
|
|
if (
|
|
bool(inv.deye_reg340_pv_a_control_enabled)
|
|
and int(inv.pv_a_cap_w) > 0
|
|
and setpoints_now.pv_a_allowed_w is not None
|
|
):
|
|
registers.append((340, "max_solar_power_w", int(setpoints_now.pv_a_allowed_w)))
|
|
|
|
try:
|
|
mb178 = await get_modbus_client(inv.host, inv.port)
|
|
r178 = await mb178.read_holding_registers(178, 1, unit_id)
|
|
if not r178 or len(r178) < 1:
|
|
raise OSError(f"reg178 read returned {len(r178) if r178 is not None else None} values")
|
|
current_178 = int(r178[0])
|
|
peak_bits = int(reg178_val) & int(REG178_VERIFY_MASK)
|
|
if inv.deye_gen_microinverter_cutoff_enabled:
|
|
want_cutoff = bool(setpoints_now.deye_gen_cutoff_enabled) and deye_mode != "SELL"
|
|
mi_bits = REG178_MI_EXPORT_ENABLE if want_cutoff else REG178_MI_EXPORT_DISABLE
|
|
else:
|
|
mi_bits = int(current_178) & int(REG178_MI_EXPORT_MASK)
|
|
|
|
new_178 = (
|
|
(int(current_178) & ~int(REG178_VERIFY_MASK_COMBINED))
|
|
| int(peak_bits)
|
|
| int(mi_bits)
|
|
)
|
|
registers.append((178, "control_board_special_1", int(new_178)))
|
|
logger.info(
|
|
"[control] %s: reg178 (control_board_special_1) old=%s new=%s peak_bits=0x%04X mi_bits=%s",
|
|
inv.code,
|
|
current_178,
|
|
new_178,
|
|
int(peak_bits),
|
|
int(mi_bits),
|
|
)
|
|
except Exception as e:
|
|
logger.warning("[control] %s: reg178 RMW failed (skip reg178 write): %s", inv.code, e)
|
|
|
|
logger.info(
|
|
"[control] %s: deye_mode=%s charge=%sA discharge=%sA "
|
|
"reg142=%s reg145=%s export=%sW "
|
|
"tp1=%s tp2=%s soc=%s%% (batt=%r grid=%sW)",
|
|
inv.code,
|
|
deye_mode,
|
|
charge_a,
|
|
discharge_a,
|
|
selling_mode,
|
|
solar_sell,
|
|
export_limit,
|
|
hh_cur,
|
|
hh_nxt,
|
|
soc_telemetry,
|
|
raw_bat,
|
|
grid_w,
|
|
)
|
|
|
|
last_verified = await _fetch_last_verified_inverter_registers(site_id, inv.id, db)
|
|
registers, skipped_unchanged = _drop_registers_matching_last_verified(
|
|
registers, last_verified
|
|
)
|
|
if skipped_unchanged:
|
|
logger.info(
|
|
"[control] %s: skip %s registers (value equals last verified): %s",
|
|
inv.code,
|
|
len(skipped_unchanged),
|
|
skipped_unchanged[:24],
|
|
)
|
|
if not registers:
|
|
logger.info(
|
|
"[control] %s: all Deye holding regs match last verified, no Modbus write",
|
|
inv.code,
|
|
)
|
|
if need_inactive_tou:
|
|
await db.execute(
|
|
"""
|
|
UPDATE ems.asset_inverter
|
|
SET deye_last_tou_inactive_write_prague_date = $1,
|
|
deye_tou_inactive_signature = $2
|
|
WHERE id = $3
|
|
""",
|
|
prague_date,
|
|
inactive_sig,
|
|
inv.id,
|
|
)
|
|
return (
|
|
f"OK inverter: batt_w={raw_bat!r} (no changes vs last verified Modbus snapshot)"
|
|
)
|
|
|
|
will_write_inactive = any(
|
|
int(r) in _DEYE_INACTIVE_TOU_REGISTERS for r, _, _ in registers
|
|
)
|
|
|
|
cmd_ids = await create_modbus_commands(
|
|
site_id,
|
|
planning_run_id,
|
|
"inverter",
|
|
inv.id,
|
|
inv.code,
|
|
inv.host,
|
|
inv.port,
|
|
inv.unit_id,
|
|
registers,
|
|
db,
|
|
deye_physical_mode=deye_mode,
|
|
)
|
|
if not await execute_modbus_commands(cmd_ids, db):
|
|
return f"FAIL inverter: {inv.code}: Modbus write failed (see modbus_command)"
|
|
logger.info("[control] Inverter %s journal write OK", inv.code)
|
|
|
|
will_write_time = any(int(r) in DEYE_CLOCK_REGS for r, _, _ in registers)
|
|
if will_write_time:
|
|
await db.execute(
|
|
"""
|
|
UPDATE ems.asset_inverter
|
|
SET deye_last_system_time_sync_minute = $1,
|
|
deye_last_system_time_sync_at = now()
|
|
WHERE id = $2
|
|
""",
|
|
_prague_minute_start_utc(),
|
|
inv.id,
|
|
)
|
|
|
|
if need_inactive_tou or will_write_inactive:
|
|
await db.execute(
|
|
"""
|
|
UPDATE ems.asset_inverter
|
|
SET deye_last_tou_inactive_write_prague_date = $1,
|
|
deye_tou_inactive_signature = $2
|
|
WHERE id = $3
|
|
""",
|
|
prague_date,
|
|
inactive_sig,
|
|
inv.id,
|
|
)
|
|
except Exception as e:
|
|
return f"FAIL inverter: {inv.code}: {e}"
|
|
|
|
return (
|
|
f"OK inverter: batt_w={raw_bat!r} "
|
|
f"(time points + FC 0x10: 108/109/141/142/178/143/145/340 dle plánu)"
|
|
)
|
|
|
|
|
|
async def read_deye_registers_live(site_id: int, db: asyncpg.Connection) -> dict[str, Any]:
|
|
"""
|
|
Živé čtení holding registrů Deye 108, 109, 141-145, 178, 191 a volitelně 340.
|
|
"""
|
|
inv = await _load_inverter_config(site_id, db)
|
|
if inv is None:
|
|
raise ValueError("no controllable Modbus inverter for site")
|
|
|
|
uid = int(inv.unit_id)
|
|
client = await get_modbus_client(inv.host, inv.port)
|
|
read_at = datetime.now(timezone.utc)
|
|
try:
|
|
async with client.batch(uid) as mb:
|
|
b108 = await mb.read_holding_registers(108, 2)
|
|
b141 = await mb.read_holding_registers(141, 5)
|
|
r178 = await mb.read_holding_registers(178, 1)
|
|
r191 = await mb.read_holding_registers(191, 1)
|
|
if inv.deye_reg340_pv_a_control_enabled:
|
|
r340 = await mb.read_holding_registers(340, 1)
|
|
else:
|
|
r340 = None
|
|
r108, r109 = b108[0], b108[1]
|
|
r141, r142, r143 = b141[0], b141[1], b141[2]
|
|
r145 = b141[4]
|
|
r178 = r178[0]
|
|
r191 = r191[0]
|
|
r340v = int(r340[0]) if r340 is not None and len(r340) >= 1 else None
|
|
except Exception:
|
|
logger.exception("read_deye_registers_live site=%s failed", site_id)
|
|
raise
|
|
|
|
return {
|
|
"reg108_charge_a": int(r108),
|
|
"reg109_discharge_a": int(r109),
|
|
"reg141_energy_mode": int(r141),
|
|
"reg142_limit_control": int(r142),
|
|
"reg143_export_limit_w": int(r143),
|
|
"reg145_solar_sell": int(r145),
|
|
"reg178_peak_shaving_switch": int(r178),
|
|
"reg178_control_board_special_1": int(r178),
|
|
"reg178_mi_export_cutoff_bits": int(r178) & int(REG178_MI_EXPORT_MASK),
|
|
"reg178_mi_export_cutoff_is_on": (int(r178) & int(REG178_MI_EXPORT_MASK))
|
|
== int(REG178_MI_EXPORT_ENABLE),
|
|
"reg191_peak_shaving_w": int(r191),
|
|
"reg340_max_solar_power_w": r340v,
|
|
"read_at": read_at.isoformat(),
|
|
}
|