"""
dtu_ble_mqtt.py — MicroPython ESP32-S3
Polls Hoymiles DTU via BLE GATT, decodes HM-framed real data,
publishes to MQTT on fred (192.168.1.195:1883).

DTU BLE MAC: 24:19:72:61:9D:62
Write char:  53300001 (vh=18)
Notify char: 53300004 (vh=20)  <- responses come here
CCCD:                 (vh=21)  <- write 0x0100 to enable notify
"""

import bluetooth
import struct
import time
import network
import socket

# ── Config ───────────────────────────────────────────────────────────────────
WIFI_SSID    = "AhoySmartHome"
WIFI_PASS    = "106granada"
MQTT_HOST    = "192.168.1.195"
MQTT_PORT    = 1883
MQTT_CLIENT  = "dtu_ble_bridge"

# DTU BLE (public address, LSB-first for MicroPython gap_connect)
DTU_BLE_ADDR = bytes([0x62, 0x9D, 0x61, 0x72, 0x19, 0x24])
DTU_ADDR_TYPE = 0  # 0=public

VH_WRITE  = 18
VH_NOTIFY = 20
VH_CCCD   = 21

POLL_INTERVAL = 30

# ── CRC-16/ARC ───────────────────────────────────────────────────────────────
def crc16(data):
    crc = 0xFFFF
    for b in data:
        crc ^= b
        for _ in range(8):
            crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
    return crc

# ── Minimal protobuf helpers ─────────────────────────────────────────────────
def pb_varint(n):
    out = []
    while True:
        b = n & 0x7F; n >>= 7
        out.append(b | 0x80 if n else b)
        if not n: break
    return bytes(out)

def pb_string(field, val): return bytes([(field<<3)|2]) + pb_varint(len(val)) + val
def pb_int32(field, val):  return bytes([(field<<3)|0]) + pb_varint(val & 0xFFFFFFFF)

# ── Build RealDataResDTO request payload ──────────────────────────────────────
def build_real_data_payload():
    now = time.localtime()
    ts = "{:04d}-{:02d}-{:02d} {:02d}:{:02d}:{:02d}".format(*now[:6]).encode()
    p  = pb_string(1, ts)
    p += pb_int32(4, int(time.time()))
    p += pb_int32(5, 28800)   # offset
    p += pb_int32(6, 0)       # error_code
    return p

# ── HM frame ─────────────────────────────────────────────────────────────────
_seq = 0
CMD_REAL_DATA = b"\xa3\x03"
CMD_HB        = b"\xa3\x02"

def build_frame(cmd, payload=b""):
    global _seq
    _seq = (_seq + 1) & 0xFFFF
    crc  = crc16(payload)
    hdr  = b"HM" + cmd + struct.pack(">HHH", _seq, crc, len(payload) + 10)
    return hdr + payload

# ── Minimal protobuf scanner for response ────────────────────────────────────
def scan_pb(data):
    """Extract known fields from RealDataReqDTO."""
    result = {}
    i = 0
    pv_power = []
    pv_v = []
    pv_i = []
    while i < len(data):
        try:
            tag = data[i]; i += 1
            fn  = tag >> 3; wt = tag & 7
            if wt == 0:
                v = 0; s = 0
                while True:
                    b = data[i]; i += 1
                    v |= (b & 0x7F) << s; s += 7
                    if not (b & 0x80): break
                if fn == 11: result['power_w'] = v / 10.0
                elif fn == 5: result['today_kwh'] = v
                elif fn == 9: result['total_kwh'] = v
            elif wt == 2:
                l = 0; s = 0
                while True:
                    b = data[i]; i += 1
                    l |= (b & 0x7F) << s; s += 7
                    if not (b & 0x80): break
                sub = data[i:i+l]; i += l
                if fn == 1: result['sn'] = sub.decode('utf-8','ignore')
                elif fn == 2:  # port_pv sub-message
                    pv = scan_pb_pv(sub)
                    if 'p' in pv: pv_power.append(pv['p'])
                    if 'u' in pv: pv_v.append(pv['u'])
                    if 'i' in pv: pv_i.append(pv['i'])
            elif wt == 5:
                raw = data[i:i+4]; i += 4
                v = struct.unpack('<f', raw)[0]
                if fn == 5: result['today_kwh'] = v
                elif fn == 9: result['total_kwh'] = v
            else:
                break
        except:
            break
    if pv_power and 'power_w' not in result:
        result['power_w'] = sum(pv_power) / 10.0
    if pv_v:   result['voltage_v']  = (sum(pv_v) / len(pv_v)) / 10.0
    if pv_i:   result['current_a']  = (sum(pv_i) / len(pv_i)) / 100.0
    return result

def scan_pb_pv(data):
    result = {}; i = 0
    while i < len(data):
        try:
            tag = data[i]; i += 1
            fn = tag >> 3; wt = tag & 7
            if wt == 0:
                v = 0; s = 0
                while True:
                    b = data[i]; i += 1
                    v |= (b & 0x7F) << s; s += 7
                    if not (b & 0x80): break
                if fn == 2: result['u'] = v
                elif fn == 3: result['i'] = v
                elif fn == 4: result['p'] = v
            elif wt == 2:
                l = 0; s = 0
                while True:
                    b = data[i]; i += 1
                    l |= (b & 0x7F) << s; s += 7
                    if not (b & 0x80): break
                i += l
            elif wt == 5: i += 4
            else: break
        except: break
    return result

def parse_frame(raw):
    if len(raw) < 10 or raw[:2] != b"HM": return None, None
    cmd     = raw[2:4]
    length  = struct.unpack(">H", raw[8:10])[0]
    payload = raw[10:10 + length - 10] if length > 10 else b""
    return cmd, payload

# ── Minimal MQTT over raw socket ──────────────────────────────────────────────
def mqtt_connect():
    sock = socket.socket()
    sock.connect((MQTT_HOST, MQTT_PORT))
    cid = MQTT_CLIENT.encode()
    vh  = b"\x00\x04MQTT\x04\x02\x00\x3c"
    pl  = struct.pack(">H", len(cid)) + cid
    sock.send(b"\x10" + bytes([len(vh)+len(pl)]) + vh + pl)
    ack = sock.recv(4)
    if ack[3] != 0: raise Exception("MQTT CONNACK " + str(ack[3]))
    return sock

def mqtt_pub(sock, topic, value):
    t = topic.encode(); v = str(value).encode()
    pkt = struct.pack(">H", len(t)) + t + v
    sock.send(bytes([0x30, len(pkt)]) + pkt)

# ── BLE state ─────────────────────────────────────────────────────────────────
ble = bluetooth.BLE()
ble.active(True)
ble.config(mtu=512)

_conn   = None
_buf    = bytearray()
_ready  = False

def _irq(event, data):
    global _conn, _buf, _ready
    if event == 3:    # GAP_CONNECT
        conn_h, _, _, _ = data
        _conn = conn_h
        print("BLE connected, handle =", conn_h)
    elif event == 4:  # GAP_DISCONNECT
        _conn = None
        print("BLE disconnected")
    elif event == 5:  # GATTC_NOTIFY
        _, _, notify_data = data
        _buf += bytes(notify_data)
        if len(_buf) >= 10 and _buf[:2] == b"HM":
            fl = struct.unpack(">H", _buf[8:10])[0]
            if len(_buf) >= fl:
                _ready = True

ble.irq(_irq)

def ble_ensure_connected():
    global _buf, _ready
    if _conn is not None: return
    _buf = bytearray(); _ready = False
    print("BLE: connecting...")
    ble.gap_connect(DTU_ADDR_TYPE, DTU_BLE_ADDR)
    t = time.ticks_ms()
    while _conn is None:
        if time.ticks_diff(time.ticks_ms(), t) > 10000:
            raise Exception("BLE connect timeout")
        time.sleep_ms(50)
    time.sleep_ms(500)
    # Enable notifications on CCCD
    ble.gattc_write(_conn, VH_CCCD, struct.pack("<H", 0x0001), 1)
    time.sleep_ms(300)

def ble_poll():
    global _buf, _ready
    _buf = bytearray(); _ready = False
    frame = build_frame(CMD_REAL_DATA, build_real_data_payload())
    print("TX {} bytes: {}".format(len(frame), frame[:8].hex()))
    ble.gattc_write(_conn, VH_WRITE, frame, 1)  # write with response
    t = time.ticks_ms()
    while not _ready:
        if time.ticks_diff(time.ticks_ms(), t) > 10000:
            return None
        time.sleep_ms(50)
    return bytes(_buf)

# ── WiFi ──────────────────────────────────────────────────────────────────────
def wifi_up():
    w = network.WLAN(network.STA_IF)
    w.active(True)
    if not w.isconnected():
        w.connect(WIFI_SSID, WIFI_PASS)
        t = time.ticks_ms()
        while not w.isconnected():
            if time.ticks_diff(time.ticks_ms(), t) > 20000:
                raise Exception("WiFi timeout")
            time.sleep_ms(200)
    print("WiFi:", w.ifconfig()[0])

# ── Main ──────────────────────────────────────────────────────────────────────
wifi_up()
mqtt = mqtt_connect()
print("MQTT OK")

while True:
    try:
        ble_ensure_connected()
        raw = ble_poll()
        if raw:
            cmd, pb = parse_frame(raw)
            print("RX cmd={} pb={} bytes".format(cmd.hex() if cmd else "?", len(pb) if pb else 0))
            if pb:
                d = scan_pb(pb)
                print("Parsed:", d)
                if 'power_w'    in d: mqtt_pub(mqtt, "solar/dtu/power_w",           round(d['power_w'], 1))
                if 'today_kwh'  in d: mqtt_pub(mqtt, "solar/dtu/energy_today_kwh",  round(d['today_kwh'], 3))
                if 'total_kwh'  in d: mqtt_pub(mqtt, "solar/dtu/energy_total_kwh",  round(d['total_kwh'], 3))
                if 'voltage_v'  in d: mqtt_pub(mqtt, "solar/dtu/voltage",           round(d['voltage_v'], 1))
                if 'current_a'  in d: mqtt_pub(mqtt, "solar/dtu/current",           round(d['current_a'], 2))
            else:
                # Dump raw so we can see what came back
                mqtt_pub(mqtt, "solar/dtu/raw_debug", raw[:40].hex())
        else:
            print("No BLE response")
            mqtt_pub(mqtt, "solar/dtu/status", "no_ble_response")
    except Exception as e:
        print("ERR:", e)
        _conn = None
        try: mqtt.close()
        except: pass
        time.sleep(5)
        try: mqtt = mqtt_connect()
        except: pass

    time.sleep(POLL_INTERVAL)
