Skip to content

Commit 0629568

Browse files
committed
feat(cli): add --since cursor to serial-read for incremental reads
- serial_read() now accepts 'since' parameter, forwarded to server_proxy.serial_read(raw_since=since) - Output JSON includes 'raw_next' field for cursor-based polling - Add --since CLI argument to serial-read subcommand - Add TestSerialReadSinceCursor test class (5 cases) covering full read, incremental read, beyond-cursor, and workflow
1 parent 3fe4204 commit 0629568

2 files changed

Lines changed: 163 additions & 3 deletions

File tree

Tools/WebServer/cli/fpb_cli.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -948,19 +948,23 @@ def serial_send(
948948
except Exception as e:
949949
self.output_error(f"Serial send failed: {str(e)}", e)
950950

951-
def serial_read(self, timeout: float = 1.0, lines: int = 50) -> None:
951+
def serial_read(
952+
self, timeout: float = 1.0, lines: int = 50, since: int = 0
953+
) -> None:
952954
"""Read recent serial output from device."""
953955
try:
954956
if self._proxy:
955-
log_resp = self._proxy.serial_read(raw_since=0)
957+
log_resp = self._proxy.serial_read(raw_since=since)
956958
raw = log_resp.get("raw_data", "")
959+
raw_next = log_resp.get("raw_next", 0)
957960
log_lines = [ln for ln in raw.split("\n") if ln.strip()][-lines:]
958961
self.output_json(
959962
{
960963
"success": True,
961964
"log": log_lines,
962965
"log_count": len(log_lines),
963966
"raw_data": raw,
967+
"raw_next": raw_next,
964968
}
965969
)
966970
return
@@ -1337,6 +1341,12 @@ def main():
13371341
default=50,
13381342
help="Max number of log lines to return (default: 50)",
13391343
)
1344+
serial_read_parser.add_argument(
1345+
"--since",
1346+
type=int,
1347+
default=0,
1348+
help="Cursor from previous raw_next for incremental reads (default: 0)",
1349+
)
13401350

13411351
# file-list command (requires device)
13421352
file_list_parser = subparsers.add_parser(
@@ -1475,7 +1485,7 @@ def main():
14751485
elif args.command == "serial-send":
14761486
cli.serial_send(args.data, not args.no_read, args.timeout)
14771487
elif args.command == "serial-read":
1478-
cli.serial_read(args.timeout, args.lines)
1488+
cli.serial_read(args.timeout, args.lines, args.since)
14791489
elif args.command == "file-list":
14801490
cli.file_list(args.path)
14811491
elif args.command == "file-stat":

Tools/WebServer/tests/test_cli_coexistence.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,5 +393,155 @@ def test_server_url_arg_passed(self, mock_cli_cls):
393393
self.assertEqual(call_kwargs.kwargs.get("server_url"), "http://myhost:9000")
394394

395395

396+
class _CursorMockHandler(http.server.BaseHTTPRequestHandler):
397+
"""Mock HTTP handler that respects raw_since query parameter."""
398+
399+
# Simulated log entries: list of {"id": int, "data": str}
400+
log_entries = []
401+
402+
def do_GET(self):
403+
from urllib.parse import urlparse, parse_qs
404+
405+
parsed = urlparse(self.path)
406+
path = parsed.path
407+
qs = parse_qs(parsed.query)
408+
409+
if path == "/api/status":
410+
body = json.dumps(
411+
{"success": True, "connected": True, "port": "/dev/ttyACM0"}
412+
).encode()
413+
self.send_response(200)
414+
self.send_header("Content-Type", "application/json")
415+
self.end_headers()
416+
self.wfile.write(body)
417+
elif path == "/api/logs":
418+
raw_since = int(qs.get("raw_since", [0])[0])
419+
entries = [e for e in self.log_entries if e["id"] >= raw_since]
420+
raw_data = "".join(e["data"] for e in entries)
421+
raw_next = (
422+
max(e["id"] for e in self.log_entries) + 1 if self.log_entries else 0
423+
)
424+
body = json.dumps({"raw_data": raw_data, "raw_next": raw_next}).encode()
425+
self.send_response(200)
426+
self.send_header("Content-Type", "application/json")
427+
self.end_headers()
428+
self.wfile.write(body)
429+
else:
430+
self.send_response(404)
431+
self.end_headers()
432+
433+
def do_POST(self):
434+
content_length = int(self.headers.get("Content-Length", 0))
435+
self.rfile.read(content_length) if content_length else b""
436+
self.do_GET()
437+
438+
def log_message(self, format, *args):
439+
pass
440+
441+
442+
class TestSerialReadSinceCursor(unittest.TestCase):
443+
"""Test serial_read --since cursor for incremental reads."""
444+
445+
@classmethod
446+
def setUpClass(cls):
447+
_CursorMockHandler.log_entries = [
448+
{"id": 0, "data": "line0\n"},
449+
{"id": 1, "data": "line1\n"},
450+
{"id": 2, "data": "line2\n"},
451+
]
452+
cls.server = http.server.HTTPServer(("127.0.0.1", 0), _CursorMockHandler)
453+
cls.port = cls.server.server_address[1]
454+
cls.server_url = f"http://127.0.0.1:{cls.port}"
455+
cls.server_thread = threading.Thread(target=cls.server.serve_forever)
456+
cls.server_thread.daemon = True
457+
cls.server_thread.start()
458+
459+
@classmethod
460+
def tearDownClass(cls):
461+
cls.server.shutdown()
462+
463+
def _make_cli(self):
464+
return FPBCLI(port="/dev/ttyACM0", server_url=self.server_url)
465+
466+
def test_serial_read_since_zero_returns_all(self):
467+
"""serial_read(since=0) returns all log entries."""
468+
cli = self._make_cli()
469+
buf = io.StringIO()
470+
with redirect_stdout(buf):
471+
cli.serial_read(since=0)
472+
result = json.loads(buf.getvalue())
473+
self.assertTrue(result["success"])
474+
self.assertIn("line0", result["raw_data"])
475+
self.assertIn("line1", result["raw_data"])
476+
self.assertIn("line2", result["raw_data"])
477+
self.assertEqual(result["raw_next"], 3)
478+
cli.cleanup()
479+
480+
def test_serial_read_since_skips_old(self):
481+
"""serial_read(since=2) returns only entries with id >= 2."""
482+
cli = self._make_cli()
483+
buf = io.StringIO()
484+
with redirect_stdout(buf):
485+
cli.serial_read(since=2)
486+
result = json.loads(buf.getvalue())
487+
self.assertTrue(result["success"])
488+
self.assertNotIn("line0", result["raw_data"])
489+
self.assertNotIn("line1", result["raw_data"])
490+
self.assertIn("line2", result["raw_data"])
491+
self.assertEqual(result["raw_next"], 3)
492+
cli.cleanup()
493+
494+
def test_serial_read_since_beyond_returns_empty(self):
495+
"""serial_read(since=raw_next) returns empty raw_data."""
496+
cli = self._make_cli()
497+
buf = io.StringIO()
498+
with redirect_stdout(buf):
499+
cli.serial_read(since=3)
500+
result = json.loads(buf.getvalue())
501+
self.assertTrue(result["success"])
502+
self.assertEqual(result["raw_data"], "")
503+
self.assertEqual(result["raw_next"], 3)
504+
cli.cleanup()
505+
506+
def test_serial_read_raw_next_in_output(self):
507+
"""serial_read output always contains raw_next field."""
508+
cli = self._make_cli()
509+
buf = io.StringIO()
510+
with redirect_stdout(buf):
511+
cli.serial_read()
512+
result = json.loads(buf.getvalue())
513+
self.assertIn("raw_next", result)
514+
self.assertIsInstance(result["raw_next"], int)
515+
cli.cleanup()
516+
517+
def test_serial_read_incremental_workflow(self):
518+
"""Simulate incremental read: first read all, then only new."""
519+
cli = self._make_cli()
520+
521+
# First read: get everything
522+
buf = io.StringIO()
523+
with redirect_stdout(buf):
524+
cli.serial_read(since=0)
525+
r1 = json.loads(buf.getvalue())
526+
cursor = r1["raw_next"]
527+
self.assertEqual(cursor, 3)
528+
529+
# Add new entry
530+
_CursorMockHandler.log_entries.append({"id": 3, "data": "line3\n"})
531+
532+
# Second read: only new data
533+
buf = io.StringIO()
534+
with redirect_stdout(buf):
535+
cli.serial_read(since=cursor)
536+
r2 = json.loads(buf.getvalue())
537+
self.assertIn("line3", r2["raw_data"])
538+
self.assertNotIn("line0", r2["raw_data"])
539+
self.assertEqual(r2["raw_next"], 4)
540+
541+
# Restore
542+
_CursorMockHandler.log_entries.pop()
543+
cli.cleanup()
544+
545+
396546
if __name__ == "__main__":
397547
unittest.main()

0 commit comments

Comments
 (0)