Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
3d4ae9b
feat(ui): add proxy monitoring view
11cookies11 Jan 11, 2026
b4c5062
fix(ui): align proxy modal theme and focus
11cookies11 Jan 11, 2026
6f11ac8
fix(ui): lock background interaction for modals
11cookies11 Jan 11, 2026
af7e74f
fix(ui): preserve page scroll during window move
11cookies11 Jan 11, 2026
1b5838f
fix(ui): harden scroll and dropdown stability
11cookies11 Jan 11, 2026
1acf86c
feat(ui): refine proxy monitor modal and logging
11cookies11 Jan 13, 2026
866e0e9
fix(ui): remove channels view from navigation
11cookies11 Jan 13, 2026
5d6a020
refactor(ui): rename ui/web-ui to desktop/frontend
11cookies11 Jan 22, 2026
180af10
fix(ui): remove garbled strings in desktop host
11cookies11 Jan 22, 2026
159af3d
fix(ui): localize proxy monitor to Chinese
11cookies11 Jan 23, 2026
515e07d
feat(ui): migrate proxy capture modal to tailwind layout
11cookies11 Jan 23, 2026
5fafd5d
feat(ui): make capture modal data-driven with mocks
11cookies11 Jan 23, 2026
a3ba4f0
feat(runtime): add streaming packet analysis engine
11cookies11 Jan 23, 2026
e599783
feat(ui): wire capture stream into proxy monitor
11cookies11 Jan 23, 2026
298feca
feat(ui): replace proxy monitor placeholders with actions
11cookies11 Jan 23, 2026
b8af9e1
feat(runtime): add proxy pair storage and bridge APIs
11cookies11 Jan 23, 2026
d956cfa
feat(runtime): add capture start/stop handling
11cookies11 Jan 23, 2026
470c725
feat(ui): reduce confirm dialog size
11cookies11 Jan 23, 2026
c56d5d6
feat(ui): fix proxy toggle and serial config fields
11cookies11 Jan 23, 2026
84115f9
feat(ui): remove protocol placeholders in capture modal
11cookies11 Jan 23, 2026
1d986fb
feat(protocols): replace protocol placeholders with CRUD
11cookies11 Jan 23, 2026
8617be2
feat(ui): polish protocol detail modal layout
11cookies11 Jan 23, 2026
c97a2a5
feat(ui-kit): add Stitch componentized library
11cookies11 Jan 23, 2026
142048c
feat(ui): add yaml ui runtime renderer
11cookies11 Jan 24, 2026
28c2486
feat(ui): wire YAML bridge parsing and labels
11cookies11 Jan 24, 2026
d9da13b
feat(ui): merge YAML UI into scripts with run modal
11cookies11 Jan 24, 2026
f669b2f
chore(config): add UI YAML sample
11cookies11 Jan 24, 2026
5357106
chore(ui): update protocols label to 4 chars
11cookies11 Jan 24, 2026
348aa77
chore(ui): align proxy header layout
11cookies11 Jan 24, 2026
763bcf2
chore(ui): match proxy filter tabs to protocols
11cookies11 Jan 24, 2026
2b566ec
chore(ui): align manual header layout
11cookies11 Jan 24, 2026
ac4d953
chore(ui): load app version from backend
11cookies11 Jan 24, 2026
a273b41
feat(ui): expand i18n coverage across views
11cookies11 Jan 24, 2026
33049de
fix(ui): unblock vue build and fix scripts header button
11cookies11 Jan 24, 2026
67dd052
fix(ui): guard proxy monitor lists against empty items
11cookies11 Jan 24, 2026
3324408
fix(ui): default proxy filter tab safely
11cookies11 Jan 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions config/ui_yaml_sample.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
ui:
widgets:
- id: input_device
type: input.text
props:
label: "Device Name"
hint: "e.g. Sensor-A"
placeholder: "Enter device label"
default: "Sensor-A"
- id: input_baud
type: input.number
props:
label: "Baud Rate"
hint: "Serial port speed"
default: 115200
- id: input_enabled
type: input.switch
props:
label: "Enable Relay"
hint: "Toggle relay forwarding"
default: true
- id: input_notes
type: input.textarea
props:
label: "Notes"
placeholder: "Optional remarks"
- id: input_protocol
type: input.select
props:
label: "Protocol"
options:
- label: "Modbus RTU"
value: "modbus_rtu"
- label: "Modbus TCP"
value: "modbus_tcp"
- label: "Custom"
value: "custom"
default: "modbus_rtu"
- id: action_start
type: action.button
emit: "script.start"
props:
label: "Start"
payload:
mode: "run"
- id: action_stop
type: action.button
emit: "script.stop"
props:
label: "Stop"
payload:
mode: "stop"
- id: log_events
type: log.viewer
bind: "ui.events"
props:
title: "Event Log"
- id: inspector
type: inspector.json
props:
title: "Selected Widget"
layout:
type: split
orientation: horizontal
children:
- type: split
orientation: vertical
children:
- type: leaf
title: "Connection"
widgets:
- input_device
- input_baud
- input_enabled
- input_protocol
- type: leaf
title: "Notes"
widgets:
- input_notes
- type: split
orientation: vertical
children:
- type: leaf
title: "Controls"
widgets:
- action_start
- action_stop
- type: leaf
title: "Logs"
widgets:
- log_events
- inspector
179 changes: 179 additions & 0 deletions core/packet_engine.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
"""Packet analysis engine for streaming capture frames.

Subscribes to comm.rx/comm.tx and publishes structured frame data to the bus.
"""

from __future__ import annotations

import queue
import threading
import time
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Optional, Tuple

from core.event_bus import EventBus
from core.protocol_loader import crc16_modbus


@dataclass
class _ChannelInfo:
channel: str = ""
port: Optional[str] = None
baud: Optional[int] = None
host: Optional[str] = None
address: Optional[str] = None


class PacketAnalysisEngine:
"""Streaming packet parser that emits capture.frame events."""

def __init__(self, bus: EventBus) -> None:
self._bus = bus
self._queue: "queue.Queue[Tuple[str, bytes, float]]" = queue.Queue()
self._channel = _ChannelInfo()
self._enabled = False
self._target_channel: Optional[str] = None
self._counter = 0
self._stop = threading.Event()
self._worker = threading.Thread(target=self._run, daemon=True)
self._worker.start()
self._bus.subscribe("comm.rx", self._on_rx)
self._bus.subscribe("comm.tx", self._on_tx)
self._bus.subscribe("comm.connected", self._on_connected)
self._bus.subscribe("comm.disconnected", self._on_disconnected)
self._bus.subscribe("capture.control", self._on_control)

def _on_rx(self, payload: Any) -> None:
if not self._enabled:
return
data = self._to_bytes(payload)
if data:
if self._target_channel and self._channel.channel and self._channel.channel != self._target_channel:
return
self._queue.put(("RX", data, time.time()))

def _on_tx(self, payload: Any) -> None:
if not self._enabled:
return
data = self._to_bytes(payload)
if data:
if self._target_channel and self._channel.channel and self._channel.channel != self._target_channel:
return
self._queue.put(("TX", data, time.time()))

def _on_connected(self, payload: Any) -> None:
if isinstance(payload, dict):
self._channel.port = payload.get("port")
self._channel.baud = payload.get("baud")
self._channel.host = payload.get("host")
self._channel.address = payload.get("address")
if payload.get("type") == "serial" and self._channel.port:
self._channel.channel = str(self._channel.port)
elif payload.get("type") == "tcp-client":
self._channel.channel = f"{self._channel.host}:{self._channel.address}" if self._channel.host else ""

def _on_disconnected(self, payload: Any) -> None:
self._channel = _ChannelInfo()

def _on_control(self, payload: Any) -> None:
if not isinstance(payload, dict):
return
action = payload.get("action")
if action == "start":
self._enabled = True
channel = payload.get("channel")
self._target_channel = str(channel) if channel else None
elif action == "stop":
self._enabled = False
self._target_channel = None

def _run(self) -> None:
while not self._stop.is_set():
try:
direction, data, ts = self._queue.get(timeout=0.2)
except queue.Empty:
continue
frame = self._build_frame(direction, data, ts)
self._bus.publish("capture.frame", frame)
self._queue.task_done()

def _build_frame(self, direction: str, data: bytes, ts: float) -> Dict[str, Any]:
self._counter += 1
hex_bytes = [f"{b:02X}" for b in data]
ascii_str = "".join(chr(b) if 32 <= b <= 126 else "." for b in data)
ascii_lines = self._split_ascii(ascii_str, 8)
protocol_name, protocol_unknown, summary, tree_rows, errors = self._parse_protocol(data)
channel = self._channel.channel or ""
frame_id = f"{direction.lower()}-{int(ts * 1000)}-{self._counter}"
return {
"id": frame_id,
"timestamp": ts,
"direction": direction,
"channel": channel,
"baud": self._channel.baud,
"length": len(data),
"raw_hex": " ".join(hex_bytes),
"ascii": ascii_str,
"protocol": {
"name": protocol_name,
"unknown": protocol_unknown,
"confidence": 0.9 if not protocol_unknown else 0.2,
},
"summary": summary,
"hex_dump": {
"bytes": hex_bytes,
"ascii_lines": ascii_lines,
"size": len(data),
},
"tree": tree_rows,
"errors": errors,
}

def _parse_protocol(
self, data: bytes
) -> Tuple[str, bool, str, List[Dict[str, str]], List[Dict[str, str]]]:
if len(data) < 2:
return "Unknown", True, "Too short", [], []

addr = data[0]
func = data[1]
summary = f"addr=0x{addr:02X} func=0x{func:02X} len={len(data)}"
tree = [
{"label": "Address", "raw": f"{addr:02X}", "value": str(addr)},
{"label": "Function", "raw": f"{func:02X}", "value": f"0x{func:02X}"},
]

if len(data) >= 4:
crc_ok = self._check_modbus_crc(data)
tree.append(
{
"label": "CRC16",
"raw": " ".join(f"{b:02X}" for b in data[-2:]),
"value": "valid" if crc_ok else "invalid",
}
)
if crc_ok:
return "Modbus RTU", False, summary, tree, []

errors = [{"code": "UNKNOWN_PROTOCOL", "message": "No known signature"}]
return "Unknown", True, summary, tree, errors

@staticmethod
def _check_modbus_crc(data: bytes) -> bool:
if len(data) < 3:
return False
body = data[:-2]
expected = int.from_bytes(data[-2:], "little")
return crc16_modbus(body) == expected

@staticmethod
def _split_ascii(text: str, width: int) -> List[str]:
return [text[i : i + width] for i in range(0, len(text), width)] or [""]

@staticmethod
def _to_bytes(payload: Any) -> bytes:
if isinstance(payload, (bytes, bytearray)):
return bytes(payload)
if isinstance(payload, str):
return payload.encode(errors="ignore")
return b""
File renamed without changes.
Loading