#!/usr/bin/env python3
"""
kamper-elm-bridge.py — most ELM327 ↔ przeglądarka (kamper.inpi.pl/diagnostyka/)

Pozwala odpalić diagnostykę OBD-II z poziomu strony WWW gdy adapter ELM327
to klasyczny Bluetooth SPP (czego Web Bluetooth nie obsługuje) lub WiFi
(czego browser nie umie raw TCP).

Uruchamiasz lokalnie (na laptopie/macOS/Linuxie albo Termux na Androidzie).
Skrypt:
  - łączy się z ELM327 jak natywna apka (rfcomm BT SPP, /dev/cu.* serial,
    TCP do adaptera WiFi, lub BLE GATT przez bleak)
  - wystawia WebSocket na localhost:8765
  - przeglądarka (Chrome/Safari/Edge — wszędzie!) łączy się przez
    ws://localhost:8765 z tej samej maszyny

Użycie:
    pip install websockets
    # opcjonalnie zależnie od trybu:
    pip install pyserial bleak

    # 1) BT classic SPP po sparowaniu (Linux):
    sudo rfcomm bind 0 <MAC>            # raz
    python3 elm_bridge.py --serial /dev/rfcomm0

    # 1b) BT SPP macOS — sparuj w Bluetooth, sprawdź /dev/cu.OBDII-Port
    python3 elm_bridge.py --serial /dev/cu.OBDII-Port

    # 2) WiFi ELM327 (po połączeniu z AP "WiFi_OBDII"):
    python3 elm_bridge.py --tcp 192.168.0.10:35000

    # 3) BLE (alternatywa do Web Bluetooth, np. na iOS via macOS bridge):
    python3 elm_bridge.py --ble

Następnie otwórz: https://kamper.inpi.pl/diagnostyka/ → tryb "WebSocket bridge"
"""
import argparse
import asyncio
import json
import logging
import re
import socket
import sys
from typing import Optional

try:
    import websockets
except ImportError:
    print("Brak websockets. Zainstaluj: pip install websockets", file=sys.stderr)
    sys.exit(1)

logging.basicConfig(
    format="%(asctime)s [%(levelname)s] %(message)s",
    level=logging.INFO,
    datefmt="%H:%M:%S",
)
log = logging.getLogger("elm-bridge")

# ============================================================================
# Transports — adaptery do różnych typów połączeń ELM327
# ============================================================================

class Transport:
    """Bazowy interfejs transportu."""
    async def open(self): ...
    async def close(self): ...
    async def write(self, data: bytes): ...
    async def read_loop(self, on_data): ...

class SerialTransport(Transport):
    """BT classic SPP via rfcomm0 (Linux) lub /dev/cu.* (macOS) lub COMx (Windows)."""
    def __init__(self, port: str, baud: int = 38400):
        self.port = port
        self.baud = baud
        self.reader = None
        self.writer = None

    async def open(self):
        try:
            import serial_asyncio
        except ImportError:
            print("Brak pyserial-asyncio. Zainstaluj: pip install pyserial pyserial-asyncio", file=sys.stderr)
            sys.exit(1)
        log.info(f"Serial open: {self.port} @ {self.baud}")
        self.reader, self.writer = await serial_asyncio.open_serial_connection(
            url=self.port, baudrate=self.baud
        )

    async def close(self):
        if self.writer:
            self.writer.close()

    async def write(self, data: bytes):
        if self.writer:
            self.writer.write(data)
            await self.writer.drain()

    async def read_loop(self, on_data):
        while True:
            try:
                chunk = await self.reader.read(256)
                if not chunk:
                    log.warning("Serial EOF — koniec połączenia")
                    return
                await on_data(chunk)
            except Exception as e:
                log.error(f"Serial read error: {e}")
                return


class TCPTransport(Transport):
    """WiFi ELM327 — TCP socket (np. 192.168.0.10:35000)."""
    def __init__(self, host: str, port: int):
        self.host = host
        self.port = port
        self.reader = None
        self.writer = None

    async def open(self):
        log.info(f"TCP open: {self.host}:{self.port}")
        self.reader, self.writer = await asyncio.open_connection(self.host, self.port)

    async def close(self):
        if self.writer:
            self.writer.close()
            try: await self.writer.wait_closed()
            except: pass

    async def write(self, data: bytes):
        if self.writer:
            self.writer.write(data)
            await self.writer.drain()

    async def read_loop(self, on_data):
        while True:
            try:
                chunk = await self.reader.read(256)
                if not chunk:
                    log.warning("TCP EOF — koniec połączenia")
                    return
                await on_data(chunk)
            except Exception as e:
                log.error(f"TCP read error: {e}")
                return


class BLETransport(Transport):
    """ELM327 BLE 4.0+ via bleak (gdy chcesz mostkować BLE z innego komputera)."""
    UUIDS = [
        ("0000ffe0-0000-1000-8000-00805f9b34fb", "0000ffe1-0000-1000-8000-00805f9b34fb"),
        ("0000fff0-0000-1000-8000-00805f9b34fb", "0000fff1-0000-1000-8000-00805f9b34fb"),
        ("0000ffe5-0000-1000-8000-00805f9b34fb", "0000ffe9-0000-1000-8000-00805f9b34fb"),
    ]

    def __init__(self, address: Optional[str] = None):
        self.address = address
        self.client = None
        self.notify_uuid = None
        self.write_uuid = None
        self.on_data_cb = None

    async def open(self):
        try:
            from bleak import BleakClient, BleakScanner
        except ImportError:
            print("Brak bleak. Zainstaluj: pip install bleak", file=sys.stderr)
            sys.exit(1)
        if not self.address:
            log.info("BLE scan 5s — szukam urządzeń OBD…")
            devices = await BleakScanner.discover(timeout=5.0)
            for d in devices:
                if d.name and any(k in d.name.upper() for k in ["OBD", "ELM", "VLINK", "ICAR"]):
                    log.info(f"Znalazłem: {d.name} @ {d.address}")
                    self.address = d.address
                    break
            if not self.address:
                log.error("Nie znaleziono adaptera ELM327 BLE.")
                sys.exit(1)
        from bleak import BleakClient
        self.client = BleakClient(self.address)
        await self.client.connect()
        log.info(f"BLE connected: {self.address}")
        # Find compatible service+characteristic
        for svc_uuid, char_uuid in self.UUIDS:
            for service in self.client.services:
                if service.uuid.lower() == svc_uuid:
                    for c in service.characteristics:
                        if c.uuid.lower() == char_uuid:
                            self.notify_uuid = char_uuid
                            self.write_uuid = char_uuid
                            log.info(f"BLE service: {svc_uuid[4:8]} char: {char_uuid[4:8]}")
                            return
        log.error("Nie znaleziono pasującego service/characteristic BLE.")
        sys.exit(1)

    async def close(self):
        if self.client and self.client.is_connected:
            await self.client.disconnect()

    async def write(self, data: bytes):
        if self.client and self.write_uuid:
            # BLE pakuje po 20B
            for i in range(0, len(data), 20):
                await self.client.write_gatt_char(self.write_uuid, data[i:i+20], response=False)

    async def read_loop(self, on_data):
        self.on_data_cb = on_data
        def handler(_, data: bytearray):
            asyncio.create_task(on_data(bytes(data)))
        await self.client.start_notify(self.notify_uuid, handler)
        # Pętla utrzymująca połączenie
        while self.client.is_connected:
            await asyncio.sleep(0.5)


# ============================================================================
# WebSocket server — most do przeglądarki
# ============================================================================

class Bridge:
    def __init__(self, transport: Transport):
        self.transport = transport
        self.clients = set()
        self.lock = asyncio.Lock()

    async def start(self, host: str, port: int):
        await self.transport.open()
        # Uruchom pętlę czytającą z transportu
        asyncio.create_task(self.transport.read_loop(self.broadcast_to_clients))
        log.info(f"WebSocket listen: ws://{host}:{port}")
        log.info("Otwórz https://kamper.inpi.pl/diagnostyka/ → tryb WebSocket bridge")
        async with websockets.serve(self.handle_client, host, port, max_size=2**20):
            await asyncio.Future()  # keep running

    async def broadcast_to_clients(self, data: bytes):
        if not self.clients:
            return
        text = data.decode("ascii", errors="replace")
        msg = json.dumps({"type": "rx", "data": text})
        # Send do wszystkich (zwykle 1 klient)
        dead = set()
        for ws in self.clients:
            try:
                await ws.send(msg)
            except websockets.ConnectionClosed:
                dead.add(ws)
        self.clients -= dead

    async def handle_client(self, ws):
        peer = f"{ws.remote_address[0]}:{ws.remote_address[1]}"
        log.info(f"Klient WS connect: {peer}")
        self.clients.add(ws)
        try:
            await ws.send(json.dumps({"type": "info", "data": "Bridge ready"}))
            async for raw in ws:
                try:
                    msg = json.loads(raw)
                except json.JSONDecodeError:
                    msg = {"type": "tx", "data": raw}
                if msg.get("type") == "tx":
                    cmd = msg.get("data", "")
                    if isinstance(cmd, str):
                        # Standardowy ELM CR-terminator
                        if not cmd.endswith("\r"):
                            cmd += "\r"
                        async with self.lock:
                            await self.transport.write(cmd.encode("ascii"))
                elif msg.get("type") == "ping":
                    await ws.send(json.dumps({"type": "pong"}))
        except websockets.ConnectionClosed:
            pass
        finally:
            self.clients.discard(ws)
            log.info(f"Klient WS disconnect: {peer}")


# ============================================================================
# Entry point
# ============================================================================

def parse_args():
    p = argparse.ArgumentParser(description="ELM327 ↔ WebSocket bridge dla kamper.inpi.pl")
    grp = p.add_mutually_exclusive_group(required=True)
    grp.add_argument("--serial", metavar="PORT",
                     help="Port szeregowy (BT SPP rfcomm/COM/cu.*) np. /dev/rfcomm0")
    grp.add_argument("--tcp", metavar="HOST:PORT",
                     help="WiFi ELM327, np. 192.168.0.10:35000")
    grp.add_argument("--ble", action="store_true",
                     help="BLE 4.0+ (skanowanie + auto-connect)")
    p.add_argument("--ble-address", help="Konkretny adres BLE (pomija skan)")
    p.add_argument("--baud", type=int, default=38400, help="Baudrate dla --serial (domyślnie 38400)")
    p.add_argument("--listen", default="127.0.0.1", help="Host nasłuchu WS (default 127.0.0.1)")
    p.add_argument("--port", type=int, default=8765, help="Port WS (default 8765)")
    return p.parse_args()


def main():
    args = parse_args()
    if args.serial:
        transport = SerialTransport(args.serial, args.baud)
    elif args.tcp:
        m = re.match(r"^([^:]+):(\d+)$", args.tcp)
        if not m:
            print("Format --tcp: host:port", file=sys.stderr); sys.exit(2)
        transport = TCPTransport(m.group(1), int(m.group(2)))
    elif args.ble:
        transport = BLETransport(args.ble_address)
    else:
        sys.exit(2)
    bridge = Bridge(transport)
    try:
        asyncio.run(bridge.start(args.listen, args.port))
    except KeyboardInterrupt:
        log.info("Przerwano (Ctrl-C)")


if __name__ == "__main__":
    main()
