"""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_hw = _deye_reg143_export_w(no_export, inv.max_export_power_w) export_lim = export_lim_hw if int(setpoints_now.grid_export_limit or 0) > 0: export_lim = min(export_lim_hw, int(setpoints_now.grid_export_limit)) 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), export_mode=setpoints_now.export_mode, export_ban=bool(setpoints_now.export_ban), ) 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(), }