logtool
This commit is contained in:
234
logtool/logtool.py
Normal file
234
logtool/logtool.py
Normal file
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
SC-F001 Log Tool
|
||||
|
||||
Usage:
|
||||
logtool.py <source> [options]
|
||||
|
||||
<source> *.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:
|
||||
<basename>.bin raw bytes received (HTTP response or file contents)
|
||||
<basename>.txt stdout capture (table / summary)
|
||||
|
||||
Default basename: 04MAR2026_1052 (date+time of invocation).
|
||||
Specify with --out <basename>.
|
||||
|
||||
Options:
|
||||
--gui Show matplotlib plots instead of CLI table
|
||||
--stream Poll for new entries (HTTP only)
|
||||
--type fsm|bat|crash Filter entry type
|
||||
--tail <offset> Start from a specific flash offset (HTTP POST mode)
|
||||
--interval <s> Polling interval in seconds (default: 2.0)
|
||||
--fw <path> Path to firmware main/ directory (for state names)
|
||||
--summary Print summary statistics only
|
||||
--out <basename> 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://<raw>/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()
|
||||
Reference in New Issue
Block a user