#!/usr/bin/env python3
"""
esp32_worker.py — Standalone ESP32 QEMU subprocess worker.

Runs as a child process of esp32_lib_manager.  Loads libqemu-xtensa in its
own process address space so multiple instances can coexist without DLL state
conflicts.

stdin  line 1 : JSON config
               {"lib_path": "...", "firmware_b64": "...", "machine": "..."}
stdin  line 2+: JSON commands
               {"cmd": "set_pin",          "pin": N,       "value": V}
               {"cmd": "set_adc",          "channel": N,   "millivolts": V}
               {"cmd": "set_adc_raw",      "channel": N,   "raw": V}
               {"cmd": "uart_send",        "uart": N,      "data": "<base64>"}
               {"cmd": "set_i2c_response", "addr": N,      "response": V}
               {"cmd": "set_spi_response", "response": V}
               {"cmd": "stop"}

stdout        : JSON event lines (one per line, flushed immediately)
               {"type": "system",       "event": "booted"}
               {"type": "system",       "event": "crash",  "reason": "...", ...}
               {"type": "system",       "event": "reboot", "count": N}
               {"type": "gpio_change",  "pin": N,  "state": V}
               {"type": "gpio_dir",     "pin": N,  "dir": V}
               {"type": "uart_tx",      "uart": N, "byte": V}
               {"type": "ledc_update",  "channel": N, "duty": V, "duty_pct": F, "gpio": N|-1}
               {"type": "rmt_event",    "channel": N, ...}
               {"type": "ws2812_update","channel": N, "pixels": [...]}
               {"type": "i2c_event",    "bus": N, "addr": N, "event": N, "response": N}
               {"type": "spi_event",    "bus": N, "event": N, "response": N}
               {"type": "error",        "message": "..."}

stderr        : debug logs (never part of the JSON protocol)
"""
import base64
import ctypes
import json
import os
import sys
import tempfile
import threading
import time

# ─── stdout helpers ──────────────────────────────────────────────────────────

_stdout_lock = threading.Lock()


def _emit(obj: dict) -> None:
    """Write one JSON event line to stdout (thread-safe, always flushed)."""
    with _stdout_lock:
        sys.stdout.write(json.dumps(obj) + '\n')
        sys.stdout.flush()


def _log(msg: str) -> None:
    """Write a debug message to stderr (invisible to parent's stdout reader)."""
    sys.stderr.write(f'[esp32_worker] {msg}\n')
    sys.stderr.flush()


# ─── GPIO pinmap (identity: slot i → GPIO i-1) ──────────────────────────────
# ESP32 has 40 GPIOs (0-39), ESP32-C3 only has 22 (0-21).
# The pinmap is rebuilt after reading config (see main()), defaulting to ESP32.

_GPIO_COUNT = 40
_PINMAP = (ctypes.c_int16 * (_GPIO_COUNT + 1))(
    _GPIO_COUNT,
    *range(_GPIO_COUNT),
)


def _build_pinmap(gpio_count: int):
    """Build a pinmap array for the given GPIO count."""
    global _GPIO_COUNT, _PINMAP
    _GPIO_COUNT = gpio_count
    _PINMAP = (ctypes.c_int16 * (gpio_count + 1))(
        gpio_count,
        *range(gpio_count),
    )

# ─── ctypes callback types ───────────────────────────────────────────────────

_WRITE_PIN = ctypes.CFUNCTYPE(None,            ctypes.c_int,   ctypes.c_int)
_DIR_PIN   = ctypes.CFUNCTYPE(None,            ctypes.c_int,   ctypes.c_int)
_I2C_EVENT = ctypes.CFUNCTYPE(ctypes.c_int,    ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint16)
_SPI_EVENT = ctypes.CFUNCTYPE(ctypes.c_uint8,  ctypes.c_uint8, ctypes.c_uint16)
_UART_TX   = ctypes.CFUNCTYPE(None,            ctypes.c_uint8, ctypes.c_uint8)
_RMT_EVENT = ctypes.CFUNCTYPE(None,            ctypes.c_uint8, ctypes.c_uint32, ctypes.c_uint32)


class _CallbacksT(ctypes.Structure):
    _fields_ = [
        ('picsimlab_write_pin',     _WRITE_PIN),
        ('picsimlab_dir_pin',       _DIR_PIN),
        ('picsimlab_i2c_event',     _I2C_EVENT),
        ('picsimlab_spi_event',     _SPI_EVENT),
        ('picsimlab_uart_tx_event', _UART_TX),
        ('pinmap',                  ctypes.c_void_p),
        ('picsimlab_rmt_event',     _RMT_EVENT),
    ]


# ─── RMT / WS2812 NeoPixel decoder ───────────────────────────────────────────

_WS2812_HIGH_THRESHOLD = 48  # RMT ticks; high pulse > threshold → bit 1


def _decode_rmt_item(value: int) -> tuple[int, int, int, int]:
    """Unpack a 32-bit RMT item → (level0, duration0, level1, duration1)."""
    level0    = (value >> 31) & 1
    duration0 = (value >> 16) & 0x7FFF
    level1    = (value >> 15) & 1
    duration1 =  value        & 0x7FFF
    return level0, duration0, level1, duration1


class _RmtDecoder:
    """Accumulate RMT items for one channel; flush complete WS2812 frames."""

    def __init__(self, channel: int):
        self.channel  = channel
        self._bits:   list[int] = []
        self._pixels: list[dict] = []

    @staticmethod
    def _bits_to_byte(bits: list[int], offset: int) -> int:
        val = 0
        for i in range(8):
            val = (val << 1) | bits[offset + i]
        return val

    def feed(self, value: int) -> list[dict] | None:
        """
        Process one RMT item.
        Returns a list of {r, g, b} pixel dicts on end-of-frame, else None.
        """
        level0, dur0, _, dur1 = _decode_rmt_item(value)

        # Reset pulse (both durations zero) signals end of frame
        if dur0 == 0 and dur1 == 0:
            pix = list(self._pixels)
            self._pixels.clear()
            self._bits.clear()
            return pix or None

        # Classify the high pulse → bit 1 or bit 0
        if level0 == 1 and dur0 > 0:
            self._bits.append(1 if dur0 > _WS2812_HIGH_THRESHOLD else 0)

        # Every 24 bits → one GRB pixel → convert to RGB
        while len(self._bits) >= 24:
            g = self._bits_to_byte(self._bits, 0)
            r = self._bits_to_byte(self._bits, 8)
            b = self._bits_to_byte(self._bits, 16)
            self._pixels.append({'r': r, 'g': g, 'b': b})
            self._bits = self._bits[24:]

        return None


# ─── Main ─────────────────────────────────────────────────────────────────────

def main() -> None:  # noqa: C901  (complexity OK for inline worker)
    # ── 1. Read config from stdin ─────────────────────────────────────────────
    raw_cfg = sys.stdin.readline()
    if not raw_cfg.strip():
        _log('No config received on stdin — exiting')
        os._exit(1)
    try:
        cfg = json.loads(raw_cfg)
    except Exception as exc:
        _log(f'Bad config JSON: {exc}')
        os._exit(1)

    lib_path       = cfg['lib_path']
    firmware_b64   = cfg['firmware_b64']
    machine        = cfg.get('machine', 'esp32-picsimlab')
    initial_sensors = cfg.get('sensors', [])

    # Adjust GPIO pinmap based on chip: ESP32-C3 has only 22 GPIOs
    if 'c3' in machine:
        _build_pinmap(22)

    # ── 2. Load DLL ───────────────────────────────────────────────────────────
    _MINGW64_BIN = r'C:\msys64\mingw64\bin'
    if os.name == 'nt' and os.path.isdir(_MINGW64_BIN):
        os.add_dll_directory(_MINGW64_BIN)
    try:
        lib = ctypes.CDLL(lib_path)
    except Exception as exc:
        _emit({'type': 'error', 'message': f'Cannot load DLL: {exc}'})
        os._exit(1)
    lib.qemu_picsimlab_get_internals.restype = ctypes.c_void_p

    # ── 3. Write firmware to a temp file ──────────────────────────────────────
    try:
        fw_bytes = base64.b64decode(firmware_b64)
        tmp = tempfile.NamedTemporaryFile(suffix='.bin', delete=False)
        tmp.write(fw_bytes)
        tmp.close()
        firmware_path: str | None = tmp.name
    except Exception as exc:
        _emit({'type': 'error', 'message': f'Firmware decode error: {exc}'})
        os._exit(1)

    rom_dir   = os.path.dirname(lib_path).encode()
    args_list = [
        b'qemu',
        b'-M', machine.encode(),
        b'-nographic',
        b'-L', rom_dir,
        b'-drive', f'file={firmware_path},if=mtd,format=raw'.encode(),
    ]
    argc = len(args_list)
    argv = (ctypes.c_char_p * argc)(*args_list)

    # ── 4. Shared mutable state ───────────────────────────────────────────────
    _stopped       = threading.Event()      # set on "stop" command
    _init_done     = threading.Event()      # set when qemu_init() returns
    _sensors_ready = threading.Event()      # set after pre-registering initial sensors
    _i2c_responses: dict[int, int] = {}     # 7-bit addr → response byte
    _spi_response   = [0xFF]                # MISO byte for SPI transfers
    _rmt_decoders:  dict[int, _RmtDecoder] = {}
    _uart0_buf      = bytearray()           # accumulate UART0 for crash detection
    _reboot_count   = [0]
    _crashed        = [False]
    _CRASH_STR      = b'Cache disabled but cached memory region accessed'
    _REBOOT_STR     = b'Rebooting...'
    # LEDC channel → GPIO pin (populated from GPIO out_sel sync events)
    # ESP32 signal indices: 72-79 = LEDC HS ch 0-7, 80-87 = LEDC LS ch 0-7
    _ledc_gpio_map: dict[int, int] = {}

    def _refresh_ledc_gpio_map() -> None:
        """Scan gpio_out_sel[40] registers and update _ledc_gpio_map.

        Called eagerly from the 0x5000 LEDC duty callback on cache miss,
        and periodically from the LEDC polling thread.
        """
        try:
            out_sel_ptr = lib.qemu_picsimlab_get_internals(2)
            if not out_sel_ptr:
                return
            out_sel = (ctypes.c_uint32 * 40).from_address(out_sel_ptr)
            for gpio_pin in range(40):
                signal = int(out_sel[gpio_pin]) & 0xFF
                if 72 <= signal <= 87:
                    ledc_ch = signal - 72
                    if _ledc_gpio_map.get(ledc_ch) != gpio_pin:
                        _ledc_gpio_map[ledc_ch] = gpio_pin
        except Exception:
            pass

    # Sensor state: gpio_pin → {type, properties..., saw_low, responding}
    _sensors: dict[int, dict] = {}
    _sensors_lock = threading.Lock()

    def _busy_wait_us(us: int) -> None:
        """Busy-wait for the given number of microseconds using perf_counter_ns."""
        end = time.perf_counter_ns() + us * 1000
        while time.perf_counter_ns() < end:
            pass

    def _dht22_build_payload(temperature: float, humidity: float) -> list[int]:
        """Build 5-byte DHT22 data payload: [hum_H, hum_L, temp_H, temp_L, checksum]."""
        hum = round(humidity * 10)
        tmp = round(temperature * 10)
        h_H = (hum >> 8) & 0xFF
        h_L = hum & 0xFF
        raw_t = ((-tmp) & 0x7FFF) | 0x8000 if tmp < 0 else tmp & 0x7FFF
        t_H = (raw_t >> 8) & 0xFF
        t_L = raw_t & 0xFF
        chk = (h_H + h_L + t_H + t_L) & 0xFF
        return [h_H, h_L, t_H, t_L, chk]

    # ── DHT22 sync-based response ────────────────────────────────────────────
    # Instead of driving pins from a separate Python thread (which requires
    # cross-thread qemu_picsimlab_set_pin calls — unsafe because the QEMU
    # iothread mutex is commented out), we drive pins synchronously from
    # within the GPIO_IN read sync callback.
    #
    # Every digitalRead() in the firmware's expectPulse() loop triggers a
    # sync event: _on_dir_change(slot=-1, direction=-1).  By counting these
    # syncs and toggling the pin at the right moments, we inject the DHT22
    # waveform perfectly synchronized with QEMU execution.
    #
    # The Adafruit DHT library decodes bits by comparing highCycles vs
    # lowCycles counts — only the RATIO matters, not absolute values.
    # So we use the raw µs values as sync counts (e.g. 80 syncs for 80µs),
    # which preserves the correct ratios.

    _dht22_sync: list[dict | None] = [None]  # mutable container for nonlocal access

    def _dht22_build_sync_phases(payload: list[int]) -> list[tuple[int, int]]:
        """Build list of (sync_count, pin_value) phase transitions for DHT22.

        Each entry means: after sync_count digitalRead() calls in this phase,
        drive the pin to pin_value and advance to the next phase.

        The Adafruit DHT library decodes bits by comparing
        highCycles > lowCycles — only RATIOS matter, not absolute values.
        We use the raw µs values as sync counts to preserve correct ratios.

        After the last data bit (40th bit HIGH→LOW), the firmware's
        expectPulse() loop ends — no more syncs will arrive.  So we do
        NOT add a trailing phase; cleanup happens immediately after the
        last phase transition fires.
        """
        phases: list[tuple[int, int]] = []
        # Preamble: LOW 80 syncs → drive HIGH
        phases.append((80, 1))
        # Preamble: HIGH 80 syncs → drive LOW
        phases.append((80, 0))
        # 40 data bits: LOW 50 syncs → HIGH, then HIGH (26 or 70) → LOW
        for byte_val in payload:
            for b in range(7, -1, -1):
                bit = (byte_val >> b) & 1
                phases.append((50, 1))              # LOW phase → drive HIGH
                phases.append((70 if bit else 26, 0))  # HIGH phase → drive LOW
        return phases

    def _dht22_sync_step() -> None:
        """Advance the DHT22 sync response by one GPIO_IN read.

        Called on the QEMU thread from the GPIO_IN read sync handler.
        Each call corresponds to one digitalRead() in the firmware's
        expectPulse() loop.
        """
        state = _dht22_sync[0]
        if state is None:
            return

        state['count'] += 1
        phase_idx = state['phase_idx']
        phases = state['phases']

        if phase_idx >= len(phases):
            # All phases done — clean up immediately
            _dht22_sync_cleanup(state)
            return

        target, pin_value = phases[phase_idx]
        if state['count'] >= target:
            lib.qemu_picsimlab_set_pin(state['slot'], pin_value)
            state['total_syncs'] += state['count']
            state['count'] = 0
            state['phase_idx'] += 1
            # If that was the last phase, clean up now — the firmware's
            # expectPulse() loop ends after the last data bit, so no more
            # syncs will arrive to trigger cleanup later.
            if state['phase_idx'] >= len(phases):
                _dht22_sync_cleanup(state)

    def _dht22_sync_cleanup(state: dict) -> None:
        """Clean up after DHT22 sync response completes."""
        gpio_pin = state['gpio']
        total = state['total_syncs']
        with _sensors_lock:
            sensor = _sensors.get(gpio_pin)
            if sensor:
                sensor['responding'] = False
        _dht22_sync[0] = None
        _log(f'DHT22 sync respond done gpio={gpio_pin} '
             f'total_syncs={total} phases={len(state["phases"])}')
        _emit({'type': 'system', 'event': 'dht22_diag',
               'gpio': gpio_pin, 'status': 'ok',
               'total_syncs': total})

    def _hcsr04_respond(trig_pin: int, echo_pin: int, distance_cm: float) -> None:
        """Thread function: inject the HC-SR04 echo pulse via qemu_picsimlab_set_pin."""
        echo_slot = echo_pin + 1  # identity pinmap: slot = gpio + 1
        # Echo pulse width = distance_cm * 58 µs (speed of sound round trip)
        echo_us = max(100, int(distance_cm * 58))

        try:
            # Wait for TRIG pulse to finish + propagation delay (~600 µs)
            _busy_wait_us(600)
            # Drive ECHO HIGH
            lib.qemu_picsimlab_set_pin(echo_slot, 1)
            # Hold ECHO HIGH for distance-proportional duration
            _busy_wait_us(echo_us)
            # Drive ECHO LOW
            lib.qemu_picsimlab_set_pin(echo_slot, 0)
        except Exception as exc:
            _log(f'HC-SR04 respond error on TRIG {trig_pin} ECHO {echo_pin}: {exc}')
        finally:
            with _sensors_lock:
                sensor = _sensors.get(trig_pin)
                if sensor:
                    sensor['responding'] = False

    # ── 5. ctypes callbacks (called from QEMU thread) ─────────────────────────

    def _on_pin_change(slot: int, value: int) -> None:
        if _stopped.is_set():
            return
        gpio = int(_PINMAP[slot]) if 1 <= slot <= _GPIO_COUNT else slot
        _emit({'type': 'gpio_change', 'pin': gpio, 'state': value})

        # Sensor protocol dispatch by type
        with _sensors_lock:
            sensor = _sensors.get(gpio)
        if sensor is None:
            return

        stype = sensor.get('type', '')

        if stype == 'dht22':
            # Record that the firmware drove the pin LOW (start signal).
            # The actual response is triggered from _on_dir_change when the
            # firmware switches the pin to INPUT mode.
            if value == 0 and not sensor.get('responding', False):
                sensor['saw_low'] = True

        elif stype == 'hc-sr04':
            # HC-SR04: detect TRIG going HIGH (firmware sends 10µs pulse)
            if value == 1 and not sensor.get('responding', False):
                sensor['responding'] = True
                echo_pin = int(sensor.get('echo_pin', gpio + 1))
                distance = float(sensor.get('distance', 40.0))
                threading.Thread(
                    target=_hcsr04_respond,
                    args=(gpio, echo_pin, distance),
                    daemon=True,
                    name=f'hcsr04-gpio{gpio}',
                ).start()

    def _on_dir_change(slot: int, direction: int) -> None:
        if _stopped.is_set():
            return

        # ── GPIO_IN read sync (slot == -1, direction == -1) ──────────────
        # Every digitalRead() in the firmware triggers this sync.  We use
        # it to drive DHT22 pin transitions synchronously on the QEMU
        # thread, perfectly synchronized with the firmware's expectPulse()
        # loop iterations.
        if slot == -1:
            if direction == -1:
                # GPIO_IN read sync — advance DHT22 response if active
                if _dht22_sync[0] is not None:
                    _dht22_sync_step()
                return  # always return for GPIO_IN syncs (fast path)
            marker = direction & 0xF000
            if marker == 0x5000:  # LEDC duty change (from esp32_ledc.c)
                ledc_ch = (direction >> 8) & 0x0F
                intensity = direction & 0xFF  # 0-100 percentage
                gpio = _ledc_gpio_map.get(ledc_ch, -1)
                if gpio == -1:
                    _refresh_ledc_gpio_map()
                    gpio = _ledc_gpio_map.get(ledc_ch, -1)
                _emit({'type': 'ledc_update', 'channel': ledc_ch,
                       'duty': intensity,
                       'duty_pct': intensity,
                       'gpio': gpio})
            return

        # ── DHT22: track direction changes + trigger sync response ───────
        if slot >= 1:
            gpio = int(_PINMAP[slot]) if slot <= _GPIO_COUNT else slot
            with _sensors_lock:
                sensor = _sensors.get(gpio)
            if sensor is not None and sensor.get('type') == 'dht22':
                if direction == 1:
                    # OUTPUT mode — record timestamp for diagnostics
                    sensor['dir_out_ns'] = time.perf_counter_ns()
                elif direction == 0:
                    # INPUT mode — trigger DHT22 sync-based response
                    if sensor.get('saw_low', False) and not sensor.get('responding', False):
                        sensor['saw_low'] = False
                        sensor['responding'] = True

                        # Build the response waveform phases
                        temp = sensor.get('temperature', 25.0)
                        hum = sensor.get('humidity', 50.0)
                        payload = _dht22_build_payload(temp, hum)
                        phases = _dht22_build_sync_phases(payload)

                        # Drive pin LOW synchronously — firmware sees LOW
                        # at its first digitalRead() in expectPulse().
                        lib.qemu_picsimlab_set_pin(slot, 0)

                        # Arm the sync-based response state machine
                        _dht22_sync[0] = {
                            'gpio': gpio,
                            'slot': slot,
                            'phases': phases,
                            'phase_idx': 0,
                            'count': 0,
                            'total_syncs': 0,
                        }
                        _log(f'DHT22 sync armed gpio={gpio} '
                             f'temp={temp} hum={hum} '
                             f'phases={len(phases)} payload={payload}')
        gpio = int(_PINMAP[slot]) if 1 <= slot <= _GPIO_COUNT else slot
        _emit({'type': 'gpio_dir', 'pin': gpio, 'dir': direction})

    def _on_uart_tx(uart_id: int, byte_val: int) -> None:
        if _stopped.is_set():
            return
        _emit({'type': 'uart_tx', 'uart': uart_id, 'byte': byte_val})
        # Crash / reboot detection on UART0 only
        if uart_id == 0:
            _uart0_buf.append(byte_val)
            if byte_val == ord('\n') or len(_uart0_buf) >= 512:
                chunk = bytes(_uart0_buf)
                _uart0_buf.clear()
                if _CRASH_STR in chunk and not _crashed[0]:
                    _crashed[0] = True
                    _emit({'type': 'system', 'event': 'crash',
                           'reason': 'cache_error', 'reboot': _reboot_count[0]})
                if _REBOOT_STR in chunk:
                    _crashed[0] = False
                    _reboot_count[0] += 1
                    _emit({'type': 'system', 'event': 'reboot',
                           'count': _reboot_count[0]})

    def _on_rmt_event(channel: int, config0: int, value: int) -> None:
        if _stopped.is_set():
            return
        level0, dur0, level1, dur1 = _decode_rmt_item(value)
        _emit({'type': 'rmt_event', 'channel': channel, 'config0': config0,
               'value': value, 'level0': level0, 'dur0': dur0,
               'level1': level1, 'dur1': dur1})
        if channel not in _rmt_decoders:
            _rmt_decoders[channel] = _RmtDecoder(channel)
        pixels = _rmt_decoders[channel].feed(value)
        if pixels:
            _emit({'type': 'ws2812_update', 'channel': channel, 'pixels': pixels})

    def _on_i2c_event(bus_id: int, addr: int, event: int) -> int:
        """Synchronous — must return immediately; called from QEMU thread."""
        resp = _i2c_responses.get(addr, 0)
        if not _stopped.is_set():
            _emit({'type': 'i2c_event', 'bus': bus_id, 'addr': addr,
                   'event': event, 'response': resp})
        return resp

    def _on_spi_event(bus_id: int, event: int) -> int:
        """Synchronous — must return immediately; called from QEMU thread."""
        resp = _spi_response[0]
        if not _stopped.is_set():
            _emit({'type': 'spi_event', 'bus': bus_id, 'event': event, 'response': resp})
        return resp

    # Keep callback struct alive (prevent GC from freeing ctypes closures)
    _cbs_ref = _CallbacksT(
        picsimlab_write_pin     = _WRITE_PIN(_on_pin_change),
        picsimlab_dir_pin       = _DIR_PIN(_on_dir_change),
        picsimlab_i2c_event     = _I2C_EVENT(_on_i2c_event),
        picsimlab_spi_event     = _SPI_EVENT(_on_spi_event),
        picsimlab_uart_tx_event = _UART_TX(_on_uart_tx),
        pinmap                  = ctypes.cast(_PINMAP, ctypes.c_void_p).value,
        picsimlab_rmt_event     = _RMT_EVENT(_on_rmt_event),
    )
    lib.qemu_picsimlab_register_callbacks(ctypes.byref(_cbs_ref))

    # ── 6. QEMU thread ────────────────────────────────────────────────────────

    def _qemu_thread() -> None:
        try:
            lib.qemu_init(argc, argv, None)
        except Exception as exc:
            _emit({'type': 'error', 'message': f'qemu_init failed: {exc}'})
        finally:
            _init_done.set()
        # Wait for initial sensors to be pre-registered before executing firmware.
        # This prevents race conditions where the firmware tries to read a sensor
        # (e.g. DHT22 pulseIn) before the sensor handler is registered.
        _sensors_ready.wait(timeout=5.0)
        lib.qemu_main_loop()

    # With -nographic, qemu_init registers the stdio mux chardev which reads
    # from fd 0.  If we leave fd 0 as the JSON-command pipe from the parent,
    # QEMU's mux will consume those bytes and forward them to UART0 RX,
    # corrupting user-sent serial data.  Redirect fd 0 to /dev/null before
    # qemu_init runs so the mux gets EOF and leaves our command pipe alone.
    # Save the original pipe fd for the command loop below.
    _orig_stdin_fd = os.dup(0)
    _nul = os.open(os.devnull, os.O_RDONLY)
    os.dup2(_nul, 0)
    os.close(_nul)

    qemu_t = threading.Thread(target=_qemu_thread, daemon=True, name=f'qemu-{machine}')
    qemu_t.start()

    if not _init_done.wait(timeout=30.0):
        _emit({'type': 'error', 'message': 'qemu_init timed out after 30 s'})
        os._exit(1)

    # Pre-register initial sensors before letting QEMU execute firmware.
    for s in initial_sensors:
        gpio = int(s.get('pin', 0))
        sensor_type = s.get('sensor_type', '')
        with _sensors_lock:
            _sensors[gpio] = {
                'type': sensor_type,
                **{k: v for k, v in s.items() if k not in ('sensor_type', 'pin')},
                'saw_low': False,
                'responding': False,
            }
        _log(f'Pre-registered sensor {sensor_type} on GPIO {gpio}')
    _sensors_ready.set()

    _emit({'type': 'system', 'event': 'booted'})
    _log(f'QEMU started: machine={machine} firmware={firmware_path}')

    # ── 7. LEDC polling thread (100 ms interval) ──────────────────────────────

    def _ledc_poll_thread() -> None:
        # Track last-emitted duty to avoid flooding identical updates
        _last_duty = [0.0] * 16
        while not _stopped.wait(0.1):
            try:
                ptr = lib.qemu_picsimlab_get_internals(6)  # LEDC_CHANNEL_DUTY
                if ptr is None or ptr == 0:
                    continue
                arr = (ctypes.c_float * 16).from_address(ptr)
                _refresh_ledc_gpio_map()
                for ch in range(16):
                    duty_pct = float(arr[ch])
                    if abs(duty_pct - _last_duty[ch]) < 0.01:
                        continue
                    _last_duty[ch] = duty_pct
                    if duty_pct > 0:
                        gpio = _ledc_gpio_map.get(ch, -1)
                        _emit({'type': 'ledc_update', 'channel': ch,
                               'duty': round(duty_pct, 2),
                               'duty_pct': round(duty_pct, 2),
                               'gpio': gpio})
            except Exception:
                pass

    threading.Thread(target=_ledc_poll_thread, daemon=True, name='ledc-poll').start()

    # ── 8. Command loop (main thread reads original stdin pipe) ───────────────

    for raw_line in os.fdopen(_orig_stdin_fd, 'r'):
        raw_line = raw_line.strip()
        if not raw_line:
            continue
        try:
            cmd = json.loads(raw_line)
        except Exception:
            continue

        c = cmd.get('cmd', '')

        if c == 'set_pin':
            # Identity pinmap: slot = gpio_num + 1
            lib.qemu_picsimlab_set_pin(int(cmd['pin']) + 1, int(cmd['value']))

        elif c == 'set_adc':
            raw_v = int(int(cmd['millivolts']) * 4095 / 3300)
            ch = int(cmd['channel'])
            clamped = max(0, min(4095, raw_v))
            lib.qemu_picsimlab_set_apin(ch, clamped)

        elif c == 'set_adc_raw':
            lib.qemu_picsimlab_set_apin(int(cmd['channel']),
                                        max(0, min(4095, int(cmd['raw']))))

        elif c == 'uart_send':
            data = base64.b64decode(cmd['data'])
            buf  = (ctypes.c_uint8 * len(data))(*data)
            lib.qemu_picsimlab_uart_receive(int(cmd.get('uart', 0)), buf, len(data))

        elif c == 'set_i2c_response':
            _i2c_responses[int(cmd['addr'])] = int(cmd['response']) & 0xFF

        elif c == 'set_spi_response':
            _spi_response[0] = int(cmd['response']) & 0xFF

        elif c == 'sensor_attach':
            gpio = int(cmd['pin'])
            sensor_type = cmd.get('sensor_type', '')
            with _sensors_lock:
                _sensors[gpio] = {
                    'type': sensor_type,
                    **{k: v for k, v in cmd.items()
                       if k not in ('cmd', 'pin', 'sensor_type')},
                    'saw_low': False,
                    'responding': False,
                }
            _log(f'Sensor {sensor_type} attached on GPIO {gpio}')

        elif c == 'sensor_update':
            gpio = int(cmd['pin'])
            with _sensors_lock:
                sensor = _sensors.get(gpio)
                if sensor:
                    for k, v in cmd.items():
                        if k not in ('cmd', 'pin'):
                            sensor[k] = v

        elif c == 'sensor_detach':
            gpio = int(cmd['pin'])
            with _sensors_lock:
                _sensors.pop(gpio, None)
            _log(f'Sensor detached from GPIO {gpio}')

        elif c == 'stop':
            _stopped.set()
            # Signal QEMU to shut down. The assertion that fires on Windows
            # ("Bail out!") is non-fatal — glib just logs it and continues.
            try:
                lib.qemu_cleanup()
            except Exception:
                pass
            qemu_t.join(timeout=5.0)
            # Clean up temp firmware file
            if firmware_path:
                try:
                    os.unlink(firmware_path)
                except OSError:
                    pass
            os._exit(0)


if __name__ == '__main__':
    main()
