#!/usr/bin/env python3 """ SC-F001 Log Tool Usage: logtool.py [options] *.bin file path → read local file anything else → treated as hostname/URL: sc.local → http://sc.local/log 192.168.4.1 → http://192.168.4.1/log http://host/log → used as-is Output files are always written: .bin raw bytes received (HTTP response or file contents) .txt stdout capture (table / summary) Default basename: 04MAR2026_1052 (date+time of invocation). Specify with --out . Options: --gui Show matplotlib plots instead of CLI table --stream Poll for new entries (HTTP only) --type fsm|bat|crash Filter entry type --tail Start from a specific flash offset (HTTP POST mode) --interval Polling interval in seconds (default: 2.0) --fw Path to firmware main/ directory (for state names) --summary Print summary statistics only --out Output file basename (no extension) """ import sys import io import argparse from datetime import datetime from pathlib import Path # Ensure logtool directory is on the path sys.path.insert(0, str(Path(__file__).parent)) import parser as prs import source as src import cli_view import gui_view def _default_basename() -> str: return datetime.now().strftime('%d%b%Y_%H%M').upper() def _normalize_source(raw: str) -> tuple: """ Returns (is_http: bool, resolved: str). *.bin → (False, path) http(s)://... → (True, url as-is) anything else → (True, http:///log) """ if raw.endswith('.bin'): return False, raw if raw.startswith('http://') or raw.startswith('https://'): return True, raw return True, f'http://{raw}/log' class _Tee: """Write to two streams simultaneously (stdout + file).""" def __init__(self, primary, secondary): self.primary = primary self.secondary = secondary def write(self, data): self.primary.write(data) self.secondary.write(data) return len(data) def flush(self): self.primary.flush() self.secondary.flush() def __getattr__(self, name): return getattr(self.primary, name) def main(): ap = argparse.ArgumentParser( description='SC-F001 flash log viewer', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__ ) ap.add_argument('source', help='*.bin file, hostname, or full URL') ap.add_argument('--gui', action='store_true', help='Show matplotlib GUI') ap.add_argument('--stream', action='store_true', help='Live streaming mode (HTTP only)') ap.add_argument('--type', choices=['fsm', 'bat', 'crash'], dest='entry_type', help='Filter by entry type') ap.add_argument('--tail', type=int, default=None, help='Start from flash offset (HTTP only)') ap.add_argument('--interval', type=float, default=2.0, help='Polling interval in seconds (default: 2.0)') ap.add_argument('--fw', default=None, help='Path to firmware main/ directory (default: ../main)') ap.add_argument('--summary', action='store_true', help='Print summary statistics only') ap.add_argument('--out', default=None, metavar='BASENAME', help='Output file basename (default: 04MAR2026_1052 style)') args = ap.parse_args() # Resolve source is_http, resolved = _normalize_source(args.source) # Output file basename basename = args.out or _default_basename() bin_path = Path(basename + '.bin') txt_path = Path(basename + '.txt') # Tee stdout → .txt file txt_file = txt_path.open('w', encoding='utf-8') sys.stdout = _Tee(sys.__stdout__, txt_file) try: _run(args, is_http, resolved, bin_path, basename) finally: sys.stdout = sys.__stdout__ txt_file.close() print(f"Saved: {bin_path} {txt_path}") def _run(args, is_http, resolved, bin_path, basename): # Load FSM state names from firmware source fw_path = args.fw or (Path(__file__).parent.parent / 'main') fsm_states = prs.load_fsm_states(fw_path) # ── Streaming mode ──────────────────────────────────────────────────────── if args.stream: if not is_http: print("Error: --stream requires an HTTP source", file=sys.stderr) sys.exit(1) if args.gui: print(f"Starting live GUI from {resolved} ...") # GUI handles its own data fetching; raw bytes aren't easily capturable bin_path.write_bytes(b'') gui_view.live_plot(resolved, interval_s=args.interval) return print(f"Streaming from {resolved} (Ctrl-C to stop)\n") print(' '.join(cli_view._HEADERS)) print(' '.join('-' * len(h) for h in cli_view._HEADERS)) accumulated_bin = io.BytesIO() def on_batch(entries, meta, is_first): if args.entry_type: tf = args.entry_type.lower() if tf == 'fsm': entries = [e for e in entries if 0 <= e.get('entry_type', -1) <= 12] elif tf == 'bat': entries = [e for e in entries if e.get('entry_type') == prs.LOG_TYPE_BAT] elif tf == 'crash': entries = [e for e in entries if e.get('entry_type') == prs.LOG_TYPE_CRASH] if entries: cli_view.append_rows(entries) def _patched_stream(): """Like source.stream but also captures raw bytes.""" blob = src.fetch_full(resolved) accumulated_bin.write(blob) meta, tail, head, entries = prs.autodetect_and_parse(blob, fsm_states) on_batch(entries, meta, is_first=True) current_tail = head or 0 import time while True: time.sleep(args.interval) try: binary, new_head = src.fetch_incremental(resolved, current_tail) if binary: accumulated_bin.write(binary) new_entries = prs.parse_entries(binary, fsm_states) if new_entries: on_batch(new_entries, None, is_first=False) current_tail = new_head except Exception as exc: print(f"[stream] poll error: {exc}") try: _patched_stream() except KeyboardInterrupt: print("\nStopped.") finally: bin_path.write_bytes(accumulated_bin.getvalue()) return # ── One-shot mode ───────────────────────────────────────────────────────── if is_http: print(f"Fetching {resolved} ...") if args.tail is not None: binary, new_head = src.fetch_incremental(resolved, args.tail) blob = binary entries = prs.parse_entries(binary, fsm_states) meta = None print(f"Incremental fetch: new_head={new_head} entries={len(entries)}") else: blob = src.fetch_full(resolved) meta, tail, head, entries = prs.parse_response(blob, fsm_states) print(f"Log offsets: tail={tail} head={head} entries={len(entries)}") else: print(f"Reading {resolved} ...") blob = src.read_file(resolved) meta, tail, head, entries = prs.autodetect_and_parse(blob, fsm_states) if head is not None: print(f"Log offsets: tail={tail} head={head}") print(f"Parsed {len(entries)} entries") # Save raw binary bin_path.write_bytes(blob or b'') if meta: ver = meta.get('version', '?') t = meta.get('time', '?') print(f"Device: version={ver} time={t}") if args.summary: cli_view.print_summary(entries) elif args.gui: title = f"SC-F001 Log — {args.source}" gui_view.show_plots(entries, title=title) else: cli_view.print_table(entries, type_filter=args.entry_type) print() cli_view.print_summary(entries) if __name__ == '__main__': main()