crosspad-mcp-server 8.1.2 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +14 -0
  3. package/.mcp.json +9 -0
  4. package/README.md +95 -0
  5. package/dist/config.d.ts +3 -0
  6. package/dist/config.js +8 -0
  7. package/dist/config.js.map +1 -1
  8. package/dist/index.d.ts +1 -0
  9. package/dist/index.js +369 -49
  10. package/dist/index.js.map +1 -1
  11. package/dist/tools/idf-flash.js +2 -2
  12. package/dist/tools/idf-flash.js.map +1 -1
  13. package/dist/tools/idf-monitor.d.ts +3 -1
  14. package/dist/tools/idf-monitor.js +19 -3
  15. package/dist/tools/idf-monitor.js.map +1 -1
  16. package/dist/tools/midi.js +20 -16
  17. package/dist/tools/midi.js.map +1 -1
  18. package/dist/tools/symbols.d.ts +3 -1
  19. package/dist/tools/symbols.js +31 -1
  20. package/dist/tools/symbols.js.map +1 -1
  21. package/dist/tools/trace-buffer.d.ts +40 -0
  22. package/dist/tools/trace-buffer.js +74 -0
  23. package/dist/tools/trace-buffer.js.map +1 -0
  24. package/dist/tools/trace-device.d.ts +10 -0
  25. package/dist/tools/trace-device.js +26 -0
  26. package/dist/tools/trace-device.js.map +1 -0
  27. package/dist/tools/trace-doctor.d.ts +43 -0
  28. package/dist/tools/trace-doctor.js +150 -0
  29. package/dist/tools/trace-doctor.js.map +1 -0
  30. package/dist/tools/trace-export.d.ts +4 -0
  31. package/dist/tools/trace-export.js +14 -0
  32. package/dist/tools/trace-export.js.map +1 -0
  33. package/dist/tools/trace-session.d.ts +118 -0
  34. package/dist/tools/trace-session.js +346 -0
  35. package/dist/tools/trace-session.js.map +1 -0
  36. package/dist/tools/trace-symbols.d.ts +24 -0
  37. package/dist/tools/trace-symbols.js +44 -0
  38. package/dist/tools/trace-symbols.js.map +1 -0
  39. package/dist/tools/trace-webui.d.ts +53 -0
  40. package/dist/tools/trace-webui.js +222 -0
  41. package/dist/tools/trace-webui.js.map +1 -0
  42. package/dist/utils/device.d.ts +5 -0
  43. package/dist/utils/device.js +43 -15
  44. package/dist/utils/device.js.map +1 -1
  45. package/dist/utils/exec.js +26 -0
  46. package/dist/utils/exec.js.map +1 -1
  47. package/dist/utils/userConfig.d.ts +13 -0
  48. package/dist/utils/userConfig.js +43 -0
  49. package/dist/utils/userConfig.js.map +1 -0
  50. package/package.json +12 -4
  51. package/skills/crosspad/SKILL.md +58 -0
  52. package/skills/crosspad/reference/faq.md +40 -0
  53. package/skills/crosspad/reference/install.md +84 -0
  54. package/skills/crosspad/reference/repos.md +29 -0
  55. package/skills/crosspad/reference/role-contributor.md +64 -0
  56. package/skills/crosspad/reference/role-fw-dev.md +44 -0
  57. package/skills/crosspad/reference/role-user.md +49 -0
  58. package/skills/crosspad/reference/tools.md +68 -0
  59. package/skills/crosspad/scripts/doctor.sh +65 -0
  60. package/skills/crosspad/scripts/setup.sh +53 -0
  61. package/skills/swd-tracer/SKILL.md +135 -0
  62. package/skills/swd-tracer/reference/signals.md +42 -0
  63. package/skills/swd-tracer/scripts/detect-env.sh +61 -0
  64. package/skills/swd-tracer/scripts/install-udev-rules.sh +25 -0
  65. package/skills/swd-tracer/scripts/setup-venv.sh +26 -0
  66. package/tracer/PROTOCOL.md +260 -0
  67. package/tracer/README.md +327 -0
  68. package/tracer/swd_tracer.py +1066 -0
  69. package/tracer/ui/index.html +834 -0
@@ -0,0 +1,1066 @@
1
+ #!/usr/bin/env python3
2
+ """CrossPad SWD real-time tracer daemon (pyOCD).
3
+
4
+ Subcommands:
5
+ symbols --elf PATH [--query STR] -> prints JSON {symbols:[...]} and exits
6
+ trace --elf PATH --signals NAMES [--rate HZ] [--out FILE] [--probe UID]
7
+ -> NDJSON frames on stdout until stdin {"cmd":"stop"}
8
+ Optional SWO/ITM:
9
+ --swo PORT:NAME[,...] -> EXPERIMENTAL: decode ITM stimulus ports onto named
10
+ signals. Requires firmware that emits ITM data on the
11
+ SWO pin — the current CrossPad firmware does NOT do this,
12
+ so this path is UNTESTED against a real ITM source.
13
+ If SWV initialisation fails the daemon continues with
14
+ plain RAM polling (fail-soft).
15
+ --cpu-hz HZ -> core clock for SWO baud derivation (default 64000000).
16
+ --swo-hz HZ -> desired SWO baud (must match firmware TPIU config,
17
+ default 2000000).
18
+
19
+ Output contract: machine JSON/NDJSON on stdout, human logs on stderr ONLY.
20
+ """
21
+ import argparse, json, sys
22
+
23
+ def log(*a):
24
+ print(*a, file=sys.stderr, flush=True)
25
+
26
+ # DWARF DW_AT_encoding -> our tag.
27
+ _ENC = {1: "address", 2: "bool", 4: "float", 5: "int", 7: "uint", 8: "uchar", 6: "char"}
28
+
29
+ def _iter_addr_globals(dwarf):
30
+ """Yield (name, address, die, cu) for every fixed-address global/static.
31
+
32
+ A fixed-address variable has a DW_AT_location of the form
33
+ DW_OP_addr (0x03) + 4-byte little-endian address. Shared by both the flat
34
+ `symbols` listing and the symbol-table the spec resolver walks from.
35
+ """
36
+ for cu in dwarf.iter_CUs():
37
+ for die in cu.iter_DIEs():
38
+ if die.tag != "DW_TAG_variable":
39
+ continue
40
+ name = die.attributes.get("DW_AT_name")
41
+ loc = die.attributes.get("DW_AT_location")
42
+ if not name or not loc:
43
+ continue
44
+ expr = loc.value
45
+ if not isinstance(expr, list) or len(expr) != 5 or expr[0] != 0x03:
46
+ continue
47
+ addr = expr[1] | (expr[2] << 8) | (expr[3] << 16) | (expr[4] << 24)
48
+ nm = name.value.decode("utf-8", "replace")
49
+ yield nm, addr, die, cu
50
+
51
+ def resolve_symbols(elf_path, query=None):
52
+ from elftools.elf.elffile import ELFFile
53
+ out = []
54
+ with open(elf_path, "rb") as f:
55
+ elf = ELFFile(f)
56
+ if not elf.has_dwarf_info():
57
+ raise RuntimeError("ELF has no DWARF info (build Debug with -g).")
58
+ dwarf = elf.get_dwarf_info()
59
+ for nm, addr, die, cu in _iter_addr_globals(dwarf):
60
+ if query and query.lower() not in nm.lower():
61
+ continue
62
+ enc, size = _resolve_type(die, cu)
63
+ sym = {"name": nm, "address": addr, "encoding": enc, "size": size}
64
+ # §8: best-effort richer metadata for UI autocomplete. Never fatal —
65
+ # on any DWARF surprise we fall back to the back-compat fields only.
66
+ try:
67
+ _enrich_symbol(sym, die, cu)
68
+ except Exception:
69
+ pass
70
+ out.append(sym)
71
+ # De-dup by (name,address); stable order by address.
72
+ seen, uniq = set(), []
73
+ for s in sorted(out, key=lambda x: x["address"]):
74
+ k = (s["name"], s["address"])
75
+ if k not in seen:
76
+ seen.add(k); uniq.append(s)
77
+ return uniq
78
+
79
+ def build_symbol_table(elf_path):
80
+ """Map base name -> {address, type_die, cu} for every fixed-address global.
81
+
82
+ `type_die` is the resolved DW_AT_type DIE (or None) — the entry point for the
83
+ spec resolver's DWARF walk. Like resolve_symbols this de-dups by name (first
84
+ occurrence by address wins) so plain-name lookups stay deterministic.
85
+ """
86
+ from elftools.elf.elffile import ELFFile
87
+ table = {}
88
+ with open(elf_path, "rb") as f:
89
+ elf = ELFFile(f)
90
+ if not elf.has_dwarf_info():
91
+ raise RuntimeError("ELF has no DWARF info (build Debug with -g).")
92
+ dwarf = elf.get_dwarf_info()
93
+ # Collect then sort by address so the de-dup winner is stable (lowest addr).
94
+ rows = sorted(_iter_addr_globals(dwarf), key=lambda r: r[1])
95
+ for nm, addr, die, cu in rows:
96
+ if nm in table:
97
+ continue
98
+ t = die.attributes.get("DW_AT_type")
99
+ type_die = cu.dwarfinfo.get_DIE_from_refaddr(t.value + cu.cu_offset) if t else None
100
+ table[nm] = {"address": addr, "type_die": type_die, "cu": cu}
101
+ return table
102
+
103
+ def _resolve_type(die, cu):
104
+ """Walk DW_AT_type to a base type, returning (encoding_tag, byte_size).
105
+
106
+ For arrays/pointers we return the underlying base-type encoding and the
107
+ *total* byte_size of the outermost type that carries DW_AT_byte_size.
108
+ """
109
+ enc, size = "uint", 4
110
+ size_set = False
111
+ t = die.attributes.get("DW_AT_type")
112
+ depth = 0
113
+ while t is not None and depth < 16:
114
+ depth += 1
115
+ ref = cu.dwarfinfo.get_DIE_from_refaddr(t.value + cu.cu_offset)
116
+ bs = ref.attributes.get("DW_AT_byte_size")
117
+ if bs and not size_set:
118
+ size = bs.value
119
+ size_set = True
120
+ if ref.tag == "DW_TAG_base_type":
121
+ e = ref.attributes.get("DW_AT_encoding")
122
+ if e:
123
+ enc = _ENC.get(e.value, "uint")
124
+ return enc, size
125
+ if ref.tag == "DW_TAG_pointer_type":
126
+ return "uint", (size if size_set else 4)
127
+ t = ref.attributes.get("DW_AT_type")
128
+ return enc, size
129
+
130
+ def _enrich_symbol(sym, var_die, cu):
131
+ """Annotate a symbol dict in place with §8 metadata.
132
+
133
+ Classifies the variable's type DIE (typedef/cv-stripped) into a `kind`:
134
+ scalar base/pointer/enum → (no extra fields)
135
+ array DW_TAG_array_type → dims,count,elem_size,elem_encoding
136
+ struct DW_TAG_structure_type → members[]
137
+ union DW_TAG_union_type → members[]
138
+ other anything else → (no extra fields)
139
+ Best-effort: leaves `sym` untouched (kind="other") if the type can't be
140
+ classified. Uses the shared _array_dim_counts / _resolve_type_from helpers.
141
+ """
142
+ t = var_die.attributes.get("DW_AT_type")
143
+ if t is None:
144
+ sym["kind"] = "other"
145
+ return
146
+ die = cu.dwarfinfo.get_DIE_from_refaddr(t.value + cu.cu_offset)
147
+ die = _strip_cv_typedef(die, cu)
148
+ if die is None:
149
+ sym["kind"] = "other"
150
+ return
151
+ tag = die.tag
152
+ if tag in ("DW_TAG_base_type", "DW_TAG_pointer_type", "DW_TAG_enumeration_type"):
153
+ sym["kind"] = "scalar"
154
+ elif tag == "DW_TAG_array_type":
155
+ sym["kind"] = "array"
156
+ counts = _array_dim_counts(die, cu)
157
+ et = die.attributes.get("DW_AT_type")
158
+ elem = cu.dwarfinfo.get_DIE_from_refaddr(et.value + cu.cu_offset) if et else None
159
+ elem = _strip_cv_typedef(elem, cu)
160
+ if counts:
161
+ prod = 1
162
+ for c in counts:
163
+ prod *= c
164
+ sym["dims"] = counts
165
+ sym["count"] = prod
166
+ if elem is not None and elem.tag in (
167
+ "DW_TAG_base_type", "DW_TAG_pointer_type", "DW_TAG_enumeration_type"):
168
+ e_enc, e_size = _resolve_type_from(elem, cu)
169
+ sym["elem_size"] = e_size
170
+ sym["elem_encoding"] = e_enc
171
+ else:
172
+ es = _type_byte_size(elem, cu)
173
+ if es is not None:
174
+ sym["elem_size"] = es
175
+ elif tag in ("DW_TAG_structure_type", "DW_TAG_union_type"):
176
+ sym["kind"] = "struct" if tag == "DW_TAG_structure_type" else "union"
177
+ members = []
178
+ for child in die.iter_children():
179
+ if child.tag != "DW_TAG_member":
180
+ continue
181
+ nm = child.attributes.get("DW_AT_name")
182
+ if nm:
183
+ members.append(nm.value.decode("utf-8", "replace"))
184
+ if members:
185
+ sym["members"] = members
186
+ else:
187
+ sym["kind"] = "other"
188
+
189
+ def cmd_symbols(args):
190
+ syms = resolve_symbols(args.elf, args.query)
191
+ print(json.dumps({"symbols": syms}))
192
+
193
+ # --- trace mode ---------------------------------------------------------------
194
+ import io, os, re, struct, threading, time
195
+
196
+ def _try_open_session(probe, target, timeout_s):
197
+ """Open a pyOCD session with a hard timeout and no-probe fast-fail.
198
+
199
+ Returns (session_or_None, error_str_or_None, timed_out_bool). The connect
200
+ work (USB enumeration + session.open()) runs in a daemon worker thread so a
201
+ libusb wedge cannot hang the process forever — if the worker doesn't finish
202
+ within timeout_s it is abandoned (uninterruptible C) and we report a timeout.
203
+
204
+ `blocking=False, return_first=True` makes a MISSING probe return immediately
205
+ instead of pyOCD blocking forever waiting for one to be plugged in.
206
+ """
207
+ from pyocd.core.helpers import ConnectHelper
208
+ holder = {}
209
+
210
+ def opener():
211
+ # pyOCD prints "No connected debug probes" (and possibly probe-selection
212
+ # chatter) directly to stdout via print() — NOT through logging. stdout is
213
+ # our machine-JSON channel, so redirect it to stderr for the duration of
214
+ # the connect. Safe: main() is blocked in th.join() here, so nothing else
215
+ # writes stdout concurrently; we restore it before returning.
216
+ saved_stdout = sys.stdout
217
+ sys.stdout = sys.stderr
218
+ try:
219
+ s = ConnectHelper.session_with_chosen_probe(
220
+ blocking=False, return_first=True,
221
+ unique_id=probe or None,
222
+ # connect_mode='attach' = do NOT halt the core (pyOCD default
223
+ # 'halt' freezes RAM so every poll reads stale values).
224
+ options={"target_override": target, "connect_mode": "attach"})
225
+ if s is None:
226
+ holder["noprobe"] = True
227
+ return
228
+ s.open()
229
+ holder["session"] = s
230
+ except BaseException as e: # capture everything from the worker thread
231
+ holder["error"] = e
232
+ finally:
233
+ sys.stdout = saved_stdout
234
+
235
+ th = threading.Thread(target=opener, daemon=True)
236
+ th.start()
237
+ th.join(timeout_s)
238
+ if th.is_alive():
239
+ return None, "connect timeout after %gs (probe wedged? replug ST-Link)" % timeout_s, True
240
+ if holder.get("noprobe"):
241
+ return None, "no debug probe detected (replug ST-Link)", False
242
+ if "error" in holder:
243
+ return None, str(holder["error"]), False
244
+ return holder["session"], None, False
245
+
246
+ # ---------------------------------------------------------------------------
247
+ # EXPERIMENTAL: SWO / ITM sink (only used when --swo is passed)
248
+ # ---------------------------------------------------------------------------
249
+
250
+ class _ITMValueSink:
251
+ """Collects the latest ITM stimulus-port word.
252
+
253
+ Implements the TraceEventSink interface (receive(event)) without
254
+ subclassing so that the import of pyocd.trace.sink is deferred to the
255
+ --swo code path and never touched on the negative (plain-polling) path.
256
+
257
+ Thread-safe-ish: dict writes are GIL-guarded on CPython.
258
+ """
259
+ def __init__(self):
260
+ self.latest = {} # port:int -> value:int
261
+
262
+ def receive(self, event):
263
+ try:
264
+ from pyocd.trace.events import TraceITMEvent
265
+ if isinstance(event, TraceITMEvent):
266
+ self.latest[event.port] = event.data
267
+ except Exception:
268
+ pass # never crash the probe thread
269
+
270
+
271
+ def _setup_swo(session, cpu_hz, swo_hz):
272
+ """Wire up SWVReader with our custom ITM sink.
273
+
274
+ Strategy: call SWVReader.init() with a dummy StringIO console (so the
275
+ standard SWVEventSink is constructed), then immediately replace the
276
+ parser's connected sink with our own _ITMValueSink. This is the only
277
+ way to inject a custom sink given pyOCD 0.44's SWVReader.init() API
278
+ (signature: init(sys_clock, swo_clock, console:TextIO) -> bool).
279
+
280
+ Returns (reader, sink) on success, (None, None) on any failure.
281
+ The function is fail-soft: it logs to stderr and never raises.
282
+ """
283
+ try:
284
+ from pyocd.trace.swv import SWVReader
285
+ sink = _ITMValueSink()
286
+ reader = SWVReader(session, 0)
287
+ dummy_console = io.StringIO()
288
+ ok = reader.init(cpu_hz, swo_hz, dummy_console)
289
+ if not ok:
290
+ # init() already printed a pyOCD warning; add our own context.
291
+ log("[swo] SWVReader.init() returned False (probe/target may lack SWO support); "
292
+ "continuing with plain RAM polling only.")
293
+ return None, None
294
+ # Redirect the parser's downstream sink to ours.
295
+ # reader._parser is a SWOParser; SWOParser.connect(sink) replaces _sink.
296
+ reader._parser.connect(sink)
297
+ log(f"[swo] SWV reader started (cpu_hz={cpu_hz}, swo_hz={swo_hz}); "
298
+ "ITM data will be merged into sample frames when available.")
299
+ return reader, sink
300
+ except Exception as e:
301
+ log(f"[swo] setup failed, continuing with polling only: {e}")
302
+ return None, None
303
+
304
+ _NP = struct.Struct("<I") # little-endian u32 for the file header length prefix
305
+
306
+ # Spec grammar (PROTOCOL.md §1 + §1.1):
307
+ # base ( .member | [int] | [*] | [a:b] )*
308
+ # Base name first, then an ordered list of accessors. The wildcard/slice forms
309
+ # ([*], [a:b]) and a bare/trailing array dimension drive expansion (§1.1).
310
+ _BASE_RE = re.compile(r"^([A-Za-z_]\w*)")
311
+ # Order matters: try the wildcard/slice forms before the plain [int] alternative.
312
+ _ACCESS_RE = re.compile(
313
+ r"\.([A-Za-z_]\w*)" # 1: .member
314
+ r"|\[(\*)\]" # 2: [*] (whole dimension)
315
+ r"|\[(\d+):(\d+)\]" # 3,4: [a:b] (half-open slice)
316
+ r"|\[(\d+)\]") # 5: [int]
317
+
318
+ def _tokenize_spec(spec):
319
+ """Split a spec into (base, [accessors]).
320
+
321
+ Returns (base:str, accessors:list) where each accessor is one of:
322
+ ('member', name) .member
323
+ ('index', int) [i]
324
+ ('all',) [*] — whole dimension (expansion)
325
+ ('slice', a, b) [a:b] — half-open slice (expansion)
326
+ Returns None on any syntax error (stray characters, empty base, malformed
327
+ accessor).
328
+ """
329
+ m = _BASE_RE.match(spec)
330
+ if not m:
331
+ return None
332
+ base = m.group(1)
333
+ pos = m.end()
334
+ accessors = []
335
+ while pos < len(spec):
336
+ am = _ACCESS_RE.match(spec, pos)
337
+ if not am:
338
+ return None
339
+ if am.group(1) is not None:
340
+ accessors.append(("member", am.group(1)))
341
+ elif am.group(2) is not None:
342
+ accessors.append(("all",))
343
+ elif am.group(3) is not None:
344
+ accessors.append(("slice", int(am.group(3)), int(am.group(4))))
345
+ else:
346
+ accessors.append(("index", int(am.group(5))))
347
+ pos = am.end()
348
+ return base, accessors
349
+
350
+ def _strip_cv_typedef(die, cu):
351
+ """Follow DW_TAG_typedef / const / volatile / restrict wrappers transparently.
352
+
353
+ Returns the first underlying DIE that is not one of those wrappers (or None
354
+ if the chain dead-ends without a DW_AT_type).
355
+ """
356
+ transparent = ("DW_TAG_typedef", "DW_TAG_const_type",
357
+ "DW_TAG_volatile_type", "DW_TAG_restrict_type")
358
+ depth = 0
359
+ while die is not None and die.tag in transparent and depth < 16:
360
+ depth += 1
361
+ t = die.attributes.get("DW_AT_type")
362
+ if t is None:
363
+ return None
364
+ die = cu.dwarfinfo.get_DIE_from_refaddr(t.value + cu.cu_offset)
365
+ return die
366
+
367
+ def _type_byte_size(die, cu):
368
+ """Best-effort total byte size of a type DIE (follows wrappers / pointers)."""
369
+ depth = 0
370
+ while die is not None and depth < 16:
371
+ depth += 1
372
+ bs = die.attributes.get("DW_AT_byte_size")
373
+ if bs:
374
+ return bs.value
375
+ if die.tag == "DW_TAG_pointer_type":
376
+ return 4
377
+ t = die.attributes.get("DW_AT_type")
378
+ if t is None:
379
+ return None
380
+ die = cu.dwarfinfo.get_DIE_from_refaddr(t.value + cu.cu_offset)
381
+ return None
382
+
383
+ def _member_offset(member_die):
384
+ """DW_AT_data_member_location -> int byte offset.
385
+
386
+ Handles the common encodings: a plain integer, or a DWARF location
387
+ expression of the form [DW_OP_plus_uconst (0x23), N]. Returns None if the
388
+ form is unrecognised.
389
+ """
390
+ loc = member_die.attributes.get("DW_AT_data_member_location")
391
+ if loc is None:
392
+ return 0 # absent => offset 0 (e.g. first member / union member)
393
+ v = loc.value
394
+ if isinstance(v, int):
395
+ return v
396
+ if isinstance(v, list) and len(v) >= 2 and v[0] == 0x23:
397
+ return v[1]
398
+ return None
399
+
400
+ def _find_member(struct_die, name):
401
+ """Return the DW_TAG_member child of a struct/union DIE matching `name`."""
402
+ for child in struct_die.iter_children():
403
+ if child.tag != "DW_TAG_member":
404
+ continue
405
+ nm = child.attributes.get("DW_AT_name")
406
+ if nm and nm.value.decode("utf-8", "replace") == name:
407
+ return child
408
+ return None
409
+
410
+ def _resolve_spec(spec, table):
411
+ """Resolve a full spec against build_symbol_table() output.
412
+
413
+ Walks DWARF from the base type DIE applying each accessor:
414
+ [i] consumes one DW_TAG_array_type dimension (subrange child),
415
+ stride = element type byte size; multi-dim arrays expressed as
416
+ multiple DW_TAG_subrange_type children are consumed left-to-right.
417
+ .member consumes a DW_TAG_structure_type / DW_TAG_union_type member,
418
+ offset from DW_AT_data_member_location.
419
+ The final node must resolve to a scalar (base type / pointer / enum);
420
+ otherwise the spec is unresolved. Returns {name,address,size,encoding} or None.
421
+ """
422
+ tok = _tokenize_spec(spec)
423
+ if tok is None:
424
+ return None
425
+ base, accessors = tok
426
+ entry = table.get(base)
427
+ if not entry:
428
+ return None
429
+ addr = entry["address"]
430
+ cu = entry["cu"]
431
+ cur = _strip_cv_typedef(entry["type_die"], cu)
432
+
433
+ # Pending array dimensions: when an array_type is reached we expand its
434
+ # subrange children into a list of (stride, count) consumed by successive [i].
435
+ pending_dims = [] # list of (stride_bytes, count) tuples, outer-to-inner
436
+ # count==0 means the bound is unknown (no validation possible)
437
+ pending_elem = None # element type DIE once the dim list is exhausted
438
+
439
+ def _load_array(arr_die):
440
+ """Populate pending_dims/pending_elem from a DW_TAG_array_type."""
441
+ nonlocal pending_dims, pending_elem
442
+ et = arr_die.attributes.get("DW_AT_type")
443
+ elem = cu.dwarfinfo.get_DIE_from_refaddr(et.value + cu.cu_offset) if et else None
444
+ elem = _strip_cv_typedef(elem, cu)
445
+ subranges = [c for c in arr_die.iter_children()
446
+ if c.tag == "DW_TAG_subrange_type"]
447
+ elem_size = _type_byte_size(elem, cu)
448
+ if elem_size is None:
449
+ return False
450
+ # Strides: innermost dimension has stride=elem_size; each outer dimension
451
+ # multiplies by the inner dimension's element count.
452
+ counts = []
453
+ for sr in subranges:
454
+ ub = sr.attributes.get("DW_AT_upper_bound")
455
+ cnt = sr.attributes.get("DW_AT_count")
456
+ if cnt is not None and isinstance(cnt.value, int):
457
+ counts.append(cnt.value)
458
+ elif ub is not None and isinstance(ub.value, int):
459
+ counts.append(ub.value + 1)
460
+ else:
461
+ counts.append(0) # unknown bound; stride math below still works
462
+ if not subranges:
463
+ counts = [0]
464
+ strides = [0] * len(counts)
465
+ acc = elem_size
466
+ for i in range(len(counts) - 1, -1, -1):
467
+ strides[i] = acc
468
+ acc *= counts[i] if counts[i] else 1
469
+ pending_dims = list(zip(strides, counts))
470
+ pending_elem = elem
471
+ return True
472
+
473
+ for acc in accessors:
474
+ if cur is None and not pending_dims:
475
+ return None
476
+ if acc[0] == "index":
477
+ # Need an array dimension to consume.
478
+ if not pending_dims:
479
+ cur = _strip_cv_typedef(cur, cu)
480
+ if cur is None or cur.tag != "DW_TAG_array_type":
481
+ return None
482
+ if not _load_array(cur):
483
+ return None
484
+ stride, count = pending_dims.pop(0)
485
+ # Reject out-of-bounds indices when the bound is known (count>0) — a
486
+ # firmware-symbol tracer must not silently read adjacent RAM. With an
487
+ # unknown bound (count==0) we keep the raw stride arithmetic.
488
+ if count and not (0 <= acc[1] < count):
489
+ return None
490
+ addr += acc[1] * stride
491
+ if not pending_dims:
492
+ cur = pending_elem
493
+ pending_elem = None
494
+ else: # member
495
+ if pending_dims:
496
+ return None # can't take .member with array dims still pending
497
+ cur = _strip_cv_typedef(cur, cu)
498
+ if cur is None or cur.tag not in ("DW_TAG_structure_type", "DW_TAG_union_type"):
499
+ return None
500
+ found = None
501
+ for child in cur.iter_children():
502
+ if child.tag != "DW_TAG_member":
503
+ continue
504
+ nm = child.attributes.get("DW_AT_name")
505
+ if nm and nm.value.decode("utf-8", "replace") == acc[1]:
506
+ found = child
507
+ break
508
+ if found is None:
509
+ return None
510
+ off = _member_offset(found)
511
+ if off is None:
512
+ return None
513
+ addr += off
514
+ mt = found.attributes.get("DW_AT_type")
515
+ cur = cu.dwarfinfo.get_DIE_from_refaddr(mt.value + cu.cu_offset) if mt else None
516
+ cur = _strip_cv_typedef(cur, cu)
517
+
518
+ if pending_dims:
519
+ return None # spec stopped mid-array → still an aggregate
520
+ cur = _strip_cv_typedef(cur, cu)
521
+ if cur is None:
522
+ return None
523
+ # Final node must be a scalar.
524
+ if cur.tag not in ("DW_TAG_base_type", "DW_TAG_pointer_type", "DW_TAG_enumeration_type"):
525
+ return None
526
+ enc, size = _resolve_type_from(cur, cu)
527
+ return {"name": spec, "address": addr, "size": size, "encoding": enc}
528
+
529
+ EXPAND_CAP = 256 # PROTOCOL §1.1: expansions larger than this are skipped.
530
+
531
+ def _array_dim_counts(arr_die, cu):
532
+ """Per-dimension element counts of a DW_TAG_array_type (outer-to-inner).
533
+
534
+ Returns [] if any bound is unknown / unbounded (count==0) so callers can
535
+ refuse to expand an array whose size DWARF doesn't pin down.
536
+ """
537
+ counts = []
538
+ for sr in arr_die.iter_children():
539
+ if sr.tag != "DW_TAG_subrange_type":
540
+ continue
541
+ ub = sr.attributes.get("DW_AT_upper_bound")
542
+ cnt = sr.attributes.get("DW_AT_count")
543
+ if cnt is not None and isinstance(cnt.value, int):
544
+ counts.append(cnt.value)
545
+ elif ub is not None and isinstance(ub.value, int):
546
+ counts.append(ub.value + 1)
547
+ else:
548
+ return [] # unknown bound → not expandable
549
+ return counts
550
+
551
+ def _spec_name(base, parts):
552
+ """Re-render a base name + concrete accessor parts into a spec string.
553
+
554
+ `parts` items are ('index', i) or ('member', name); rendered as
555
+ base[i][j].member ... matching the §1.1 concrete-element form.
556
+ """
557
+ s = base
558
+ for p in parts:
559
+ s += ("[%d]" % p[1]) if p[0] == "index" else ("." + p[1])
560
+ return s
561
+
562
+ def _expand_spec(spec, table):
563
+ """Expand a (possibly array-bearing) spec into concrete scalar specs.
564
+
565
+ Walks the DWARF type chain following the tokenized accessors. Whenever a
566
+ dimension must be materialised — an explicit `[*]`/`[a:b]`, a bare/trailing
567
+ array, or an array dim sitting in front of a `.member` — it enumerates the
568
+ selected indices and recurses, producing concrete `name[i]` / `name[i][j]`
569
+ forms. Returns (specs:list[str], count_estimate:int).
570
+
571
+ `count_estimate` is the total number of concrete elements the spec would
572
+ yield (used by the caller for the §1.1 256-cap report). On any
573
+ unexpandable / malformed input returns ([], 0) — the caller then treats the
574
+ spec as a plain unresolved scalar.
575
+ """
576
+ tok = _tokenize_spec(spec)
577
+ if tok is None:
578
+ return [], 0
579
+ base, accessors = tok
580
+ entry = table.get(base)
581
+ if not entry:
582
+ return [], 0
583
+ cu = entry["cu"]
584
+
585
+ def _idxs_for(acc, n):
586
+ """Resolve one index/all/slice accessor against a dimension of size n.
587
+
588
+ Returns the list of concrete indices, or None if `acc` is an out-of-range
589
+ index (a hard failure for that branch).
590
+ """
591
+ if acc[0] == "index":
592
+ return [acc[1]] if 0 <= acc[1] < n else None
593
+ if acc[0] == "all":
594
+ return list(range(n))
595
+ # slice a:b — half-open, clamped to [0, n).
596
+ a, b = max(0, acc[1]), min(n, acc[2])
597
+ return list(range(a, b)) if b > a else []
598
+
599
+ # Walk the type chain. `cur` is the current type DIE (cv/typedef-stripped),
600
+ # `ai` the next accessor to apply, `parts` the concrete accessor prefix built
601
+ # so far. Returns a list of concrete accessor-part lists, or None on a hard
602
+ # mismatch (the spec is unexpandable / out of range).
603
+ def walk(cur, ai, parts):
604
+ cur = _strip_cv_typedef(cur, cu)
605
+ if cur is None:
606
+ return None
607
+
608
+ if cur.tag == "DW_TAG_array_type":
609
+ # Enumerate this array's dimensions (it may carry several subranges).
610
+ counts = _array_dim_counts(cur, cu)
611
+ if not counts:
612
+ return None
613
+ et = cur.attributes.get("DW_AT_type")
614
+ elem = cu.dwarfinfo.get_DIE_from_refaddr(et.value + cu.cu_offset) if et else None
615
+ return walk_dims(counts, 0, elem, ai, parts)
616
+
617
+ if ai < len(accessors):
618
+ acc = accessors[ai]
619
+ if acc[0] != "member":
620
+ return None # an index/all/slice on a non-array → mismatch
621
+ if cur.tag not in ("DW_TAG_structure_type", "DW_TAG_union_type"):
622
+ return None
623
+ member = _find_member(cur, acc[1])
624
+ if member is None:
625
+ return None
626
+ mt = member.attributes.get("DW_AT_type")
627
+ mtd = cu.dwarfinfo.get_DIE_from_refaddr(mt.value + cu.cu_offset) if mt else None
628
+ return walk(mtd, ai + 1, parts + [("member", acc[1])])
629
+
630
+ # No more accessors and not an array → scalar element or dead end.
631
+ if cur.tag in ("DW_TAG_base_type", "DW_TAG_pointer_type", "DW_TAG_enumeration_type"):
632
+ return [parts]
633
+ return None # struct/union with no trailing array → unresolved
634
+
635
+ def walk_dims(counts, dim, elem, ai, parts):
636
+ """Enumerate dimension `dim` of an array (counts = per-dim sizes).
637
+
638
+ A following index/all/slice accessor selects the indices for this dim and
639
+ is consumed (ai advances); otherwise the whole dimension is enumerated and
640
+ ai is preserved (so a trailing `.member` applies after the dims are gone).
641
+ Recurses into inner dims, then back into walk() for the element type.
642
+ """
643
+ n = counts[dim]
644
+ if n == 0:
645
+ return None
646
+ if ai < len(accessors) and accessors[ai][0] in ("index", "all", "slice"):
647
+ idxs = _idxs_for(accessors[ai], n)
648
+ if idxs is None:
649
+ return None
650
+ ai2 = ai + 1
651
+ else:
652
+ idxs = list(range(n))
653
+ ai2 = ai
654
+ out = []
655
+ for i in idxs:
656
+ p = parts + [("index", i)]
657
+ if dim + 1 < len(counts):
658
+ sub = walk_dims(counts, dim + 1, elem, ai2, p)
659
+ else:
660
+ sub = walk(elem, ai2, p)
661
+ if sub is None:
662
+ return None
663
+ out.extend(sub)
664
+ return out
665
+
666
+ cur0 = _strip_cv_typedef(entry["type_die"], cu)
667
+ parts_lists = walk(cur0, 0, [])
668
+ if not parts_lists:
669
+ return [], 0
670
+ specs = [_spec_name(base, p) for p in parts_lists]
671
+ return specs, len(specs)
672
+
673
+ def _resolve_specs(specs, table):
674
+ """Resolve a list of specs, applying §1.1 array/vector/matrix expansion.
675
+
676
+ For each spec: first try the expander. If it yields >1 element (or exactly
677
+ one but via a wildcard/slice/trailing array) those concrete element specs
678
+ are resolved individually. A plain scalar spec falls through to
679
+ _resolve_spec. Returns (resolved:list[dict], unresolved:list[str]).
680
+
681
+ Cap (§1.1): an expansion exceeding EXPAND_CAP elements is skipped and
682
+ reported as "<spec> (expands to <N> > 256)".
683
+ """
684
+ resolved, unresolved = [], []
685
+ for spec in specs:
686
+ elems, n = _expand_spec(spec, table)
687
+ if n > EXPAND_CAP:
688
+ unresolved.append("%s (expands to %d > %d)" % (spec, n, EXPAND_CAP))
689
+ continue
690
+ if elems:
691
+ # Expanded (possibly to a single concrete element). Resolve each;
692
+ # any element that fails to resolve is silently dropped (the spec as
693
+ # a whole produced concrete names, so it isn't "unresolved").
694
+ for e in elems:
695
+ r = _resolve_spec(e, table)
696
+ if r:
697
+ resolved.append(r)
698
+ continue
699
+ # Not expandable — try as a plain concrete scalar spec.
700
+ r = _resolve_spec(spec, table)
701
+ if r:
702
+ resolved.append(r)
703
+ else:
704
+ unresolved.append(spec)
705
+ return resolved, unresolved
706
+
707
+ def _resolve_type_from(die, cu):
708
+ """Encoding/size of a scalar type DIE (base/pointer/enum).
709
+
710
+ Mirrors _resolve_type but starts from an already-resolved type DIE rather
711
+ than from a variable DIE's DW_AT_type.
712
+ """
713
+ if die.tag == "DW_TAG_pointer_type":
714
+ bs = die.attributes.get("DW_AT_byte_size")
715
+ return "uint", (bs.value if bs else 4)
716
+ if die.tag == "DW_TAG_base_type":
717
+ e = die.attributes.get("DW_AT_encoding")
718
+ enc = _ENC.get(e.value, "uint") if e else "uint"
719
+ bs = die.attributes.get("DW_AT_byte_size")
720
+ return enc, (bs.value if bs else 4)
721
+ if die.tag == "DW_TAG_enumeration_type":
722
+ bs = die.attributes.get("DW_AT_byte_size")
723
+ return "int", (bs.value if bs else 4)
724
+ bs = die.attributes.get("DW_AT_byte_size")
725
+ return "uint", (bs.value if bs else 4)
726
+
727
+ def _coalesce(sigs):
728
+ """sigs: list of {name,address,size,encoding}. Returns [(start,length,[(name,off,size,enc)])]."""
729
+ items = sorted(sigs, key=lambda s: s["address"])
730
+ ranges = []
731
+ for s in items:
732
+ a, ln = s["address"], s["size"]
733
+ if ranges and a <= ranges[-1][0] + ranges[-1][1] + 4: # merge if within 4 bytes of prev end
734
+ start, length, members = ranges[-1]
735
+ new_end = max(start + length, a + ln)
736
+ ranges[-1] = (start, new_end - start, members + [(s["name"], a - start, ln, s["encoding"])])
737
+ else:
738
+ ranges.append((a, ln, [(s["name"], 0, ln, s["encoding"])]))
739
+ return ranges
740
+
741
+ def _decode(buf, off, size, enc):
742
+ raw = bytes(buf[off:off + size])
743
+ if enc == "float" and size == 4:
744
+ return struct.unpack("<f", raw)[0]
745
+ if enc == "float" and size == 8:
746
+ return struct.unpack("<d", raw)[0]
747
+ signed = enc in ("int", "char")
748
+ return int.from_bytes(raw, "little", signed=signed)
749
+
750
+ def cmd_trace(args):
751
+ names = [n for n in args.signals.split(",") if n]
752
+ # §11.3 ELF / DWARF guard: a bad/missing ELF must surface as an error frame,
753
+ # not a raw traceback to stderr that leaves the TS layer guessing.
754
+ try:
755
+ table = build_symbol_table(args.elf)
756
+ except Exception as e:
757
+ print(json.dumps({"type": "error", "error": "ELF/DWARF error: %s" % e}), flush=True)
758
+ return
759
+ # §1.1: expand whole-array / vector / matrix specs into concrete elements.
760
+ resolved, missing = _resolve_specs(names, table)
761
+ if not resolved:
762
+ # Nothing resolved at all → hard error (matches the old all-missing path).
763
+ print(json.dumps({"type": "error", "error": "unknown symbols: " + ",".join(missing)}), flush=True)
764
+ return
765
+ # Any specs that failed to resolve/expand are reported via the live "signals"
766
+ # frame's `unresolved`, not as a fatal error (mirrors the add path).
767
+ initial_unresolved = list(missing)
768
+
769
+ # Live poll set, mutated by the stdin reader thread (add/remove). Guarded by
770
+ # a lock; the poll loop re-coalesces ranges whenever `dirty` is set.
771
+ state_lock = threading.Lock()
772
+ # name -> {name,address,size,encoding}; ordered insertion preserves request order.
773
+ sigset = {s["name"]: s for s in resolved}
774
+ state = {"dirty": True, "ranges": _coalesce(list(sigset.values())),
775
+ "unresolved": initial_unresolved, "signals": list(sigset.values())}
776
+
777
+ def _signals_frame():
778
+ """Build the {"type":"signals",...} frame from the current poll set."""
779
+ with state_lock:
780
+ sigs = [{"name": s["name"], "address": s["address"],
781
+ "size": s["size"], "encoding": s["encoding"]} for s in state["signals"]]
782
+ unres = list(state["unresolved"])
783
+ return {"type": "signals", "signals": sigs, "unresolved": unres}
784
+
785
+ # --- EXPERIMENTAL: parse --swo mapping (negative path: swo_map is empty) ---
786
+ swo_map = {} # port:int -> signal_name:str
787
+ if args.swo:
788
+ for spec in args.swo.split(","):
789
+ spec = spec.strip()
790
+ if not spec:
791
+ continue
792
+ try:
793
+ port_str, sig_name = spec.split(":", 1)
794
+ swo_map[int(port_str)] = sig_name
795
+ except (ValueError, TypeError):
796
+ log(f"[swo] ignoring malformed port:name spec: {spec!r}")
797
+ if swo_map:
798
+ log(f"[swo] EXPERIMENTAL: ITM port mapping: {swo_map} "
799
+ "(requires firmware that emits ITM on SWO — NOT present in current CrossPad firmware)")
800
+
801
+ # NOTE: the .cptrace file header is written ONCE here with the initial set.
802
+ # add/remove on a live trace change the live poll set (and the NDJSON
803
+ # "signals" frames) but deliberately do NOT rewrite this on-disk header — the
804
+ # header reflects the trace's *initial* signal set only.
805
+ fh = open(args.out, "wb") if args.out else None
806
+ if fh:
807
+ hdr = json.dumps({"signals": [{"name": s["name"], "encoding": s["encoding"], "size": s["size"]} for s in resolved]}).encode()
808
+ fh.write(b"CPTR"); fh.write(_NP.pack(len(hdr))); fh.write(hdr)
809
+
810
+ target_dt = 1.0 / args.rate if args.rate > 0 else 0.0
811
+ stop = {"v": False}
812
+
813
+ def _apply_add(specs):
814
+ # §1.1: expansion applies to live add too, so the emitted "signals"
815
+ # frame lists the concrete expanded element names.
816
+ added, unres = _resolve_specs(specs, table)
817
+ with state_lock:
818
+ for r in added:
819
+ sigset[r["name"]] = r # replace/insert
820
+ state["signals"] = list(sigset.values())
821
+ state["unresolved"] = unres
822
+ state["dirty"] = True
823
+
824
+ def _apply_remove(specs):
825
+ with state_lock:
826
+ for spec in specs:
827
+ sigset.pop(spec, None) # no-op if absent
828
+ state["signals"] = list(sigset.values())
829
+ state["dirty"] = True
830
+
831
+ def stdin_reader():
832
+ for line in sys.stdin:
833
+ line = line.strip()
834
+ if not line:
835
+ continue
836
+ try:
837
+ msg = json.loads(line)
838
+ except Exception:
839
+ continue
840
+ cmd = msg.get("cmd")
841
+ if cmd == "stop":
842
+ stop["v"] = True
843
+ return
844
+ elif cmd == "add":
845
+ sigs = msg.get("signals")
846
+ if isinstance(sigs, list):
847
+ _apply_add([s for s in sigs if isinstance(s, str)])
848
+ elif cmd == "remove":
849
+ sigs = msg.get("signals")
850
+ if isinstance(sigs, list):
851
+ _apply_remove([s for s in sigs if isinstance(s, str)])
852
+ threading.Thread(target=stdin_reader, daemon=True).start()
853
+
854
+ log(f"connecting probe (serial={args.probe or 'auto'}, target={args.target}, "
855
+ f"connect_timeout={args.connect_timeout}s)...")
856
+ n = 0
857
+
858
+ # §11.1 connect with hard timeout + no-probe fast-fail. A wedged libusb open()
859
+ # cannot be interrupted, so on timeout we flush the error frame and os._exit —
860
+ # the OS reclaims the abandoned worker thread. Never hang here.
861
+ session, cerr, timed_out = _try_open_session(args.probe, args.target, args.connect_timeout)
862
+ if session is None:
863
+ log(f"trace connect failed: {cerr}")
864
+ print(json.dumps({"type": "error", "error": cerr}), flush=True)
865
+ if fh:
866
+ fh.close()
867
+ sys.stdout.flush(); sys.stderr.flush()
868
+ if timed_out:
869
+ os._exit(2) # worker wedged in C — only safe recovery
870
+ os._exit(3 if "no debug probe" in cerr else 1)
871
+
872
+ target = session.target
873
+ log("connected; polling (non-halting)")
874
+ # §11.2 persistent-fault tracking: a single read fault = stop_suspected, but
875
+ # faults lasting longer than --lost-timeout mean the probe/target is gone.
876
+ fault_since = None
877
+ lost = False
878
+
879
+ # --- EXPERIMENTAL: set up SWV reader (only when --swo was given) ---
880
+ swo_reader, swo_sink = None, None
881
+ if swo_map:
882
+ swo_reader, swo_sink = _setup_swo(session, args.cpu_hz, args.swo_hz)
883
+ # If _setup_swo failed, swo_reader/swo_sink are both None and the loop
884
+ # below behaves identically to the no-swo path.
885
+
886
+ t0 = time.monotonic()
887
+ try:
888
+ while not stop["v"]:
889
+ cyc = time.monotonic()
890
+ # Re-coalesce when the poll set changed, and (re)emit the "signals"
891
+ # frame. dirty is preset True so this fires ONCE right after connect.
892
+ if state["dirty"]:
893
+ with state_lock:
894
+ state["ranges"] = _coalesce(list(state["signals"]))
895
+ state["dirty"] = False
896
+ print(json.dumps(_signals_frame()), flush=True)
897
+ ranges = state["ranges"]
898
+ values, in_stop = {}, False
899
+ for (start, length, members) in ranges:
900
+ try:
901
+ data = target.read_memory_block8(start, length)
902
+ except Exception:
903
+ in_stop = True
904
+ break
905
+ for (name, off, size, enc) in members:
906
+ values[name] = _decode(data, off, size, enc)
907
+ t = time.monotonic() - t0
908
+ if in_stop:
909
+ now = time.monotonic()
910
+ if fault_since is None:
911
+ fault_since = now
912
+ elif now - fault_since > args.lost_timeout:
913
+ # §11.2 persistent read fault → probe/target lost; stop looping.
914
+ msg = "probe/target lost (persistent read fault %.1fs)" % (now - fault_since)
915
+ log(msg)
916
+ print(json.dumps({"type": "status", "device_state": "probe_lost", "t": round(t, 6)}), flush=True)
917
+ print(json.dumps({"type": "error", "error": msg}), flush=True)
918
+ lost = True
919
+ break
920
+ print(json.dumps({"type": "status", "device_state": "stop_suspected", "t": round(t, 6)}), flush=True)
921
+ time.sleep(0.2)
922
+ continue
923
+ fault_since = None # a successful read clears the fault timer
924
+
925
+ # --- EXPERIMENTAL: merge ITM values (no-op when swo_sink is None) ---
926
+ if swo_sink is not None:
927
+ for port, sig_name in swo_map.items():
928
+ v = swo_sink.latest.get(port)
929
+ if v is not None:
930
+ values[sig_name] = v
931
+
932
+ print(json.dumps({"type": "sample", "t": round(t, 6), "values": values}), flush=True)
933
+ if fh:
934
+ fh.write(json.dumps({"t": round(t, 6), "v": values}).encode() + b"\n")
935
+ n += 1
936
+ if target_dt:
937
+ slp = target_dt - (time.monotonic() - cyc)
938
+ if slp > 0:
939
+ time.sleep(slp)
940
+ except Exception as e:
941
+ log(f"trace run failed: {e}")
942
+ print(json.dumps({"type": "error", "error": str(e)}), flush=True)
943
+ finally:
944
+ if swo_reader is not None:
945
+ try:
946
+ swo_reader.stop()
947
+ log("[swo] SWV reader stopped.")
948
+ except Exception as e:
949
+ log(f"[swo] error stopping SWV reader (ignored): {e}")
950
+ try:
951
+ session.close()
952
+ except Exception:
953
+ pass
954
+ if fh:
955
+ fh.close()
956
+ if not lost:
957
+ log(f"stopped after {n} samples")
958
+ print(json.dumps({"type": "status", "device_state": "stopped", "samples": n}), flush=True)
959
+
960
+ # --- device-state mode -------------------------------------------------------
961
+
962
+ _REGS = {
963
+ "PWR_CR1": 0x40007000,
964
+ "PWR_SR1": 0x40007010,
965
+ "RCC_CR": 0x40021000,
966
+ "RCC_CFGR": 0x40021008,
967
+ "SCB_SCR": 0xE000ED10,
968
+ "DBGMCU_CR": 0x40015804,
969
+ }
970
+
971
+ def cmd_device_state(args):
972
+ out = {"type": "device_state", "regs": {}, "decoded": {}, "accessible": True}
973
+ # §11.1: same hard-timeout / no-probe fast-fail as trace, but graceful — a
974
+ # one-shot read reports inaccessible instead of os._exit. The wedged worker
975
+ # (if any) is a daemon thread, abandoned on process exit.
976
+ session, cerr, _timed_out = _try_open_session(args.probe, args.target, args.connect_timeout)
977
+ if session is None:
978
+ out["accessible"] = False
979
+ out["error"] = cerr
980
+ print(json.dumps(out), flush=True)
981
+ return
982
+ try:
983
+ t = session.target
984
+ for name, addr in _REGS.items():
985
+ try:
986
+ out["regs"][name] = t.read32(addr)
987
+ except Exception:
988
+ out["regs"][name] = None
989
+ out["accessible"] = False
990
+ scr = out["regs"].get("SCB_SCR") or 0
991
+ out["decoded"]["SLEEPDEEP"] = bool(scr & (1 << 2))
992
+ cr1 = out["regs"].get("PWR_CR1") or 0
993
+ out["decoded"]["LPMS"] = cr1 & 0x7 # low-power mode select (STM32G0 PWR_CR1[2:0])
994
+ out["decoded"]["interpretation"] = (
995
+ "STOP/low-power likely" if out["decoded"]["SLEEPDEEP"] else "run/sleep")
996
+ except Exception as e:
997
+ out["accessible"] = False
998
+ out["error"] = str(e)
999
+ finally:
1000
+ try:
1001
+ session.close()
1002
+ except Exception:
1003
+ pass
1004
+ print(json.dumps(out), flush=True)
1005
+
1006
+ def main():
1007
+ # Claim the root logger with a stderr handler BEFORE pyOCD is imported, so
1008
+ # pyOCD's own logging (e.g. the coloured "No connected debug probes" notice)
1009
+ # cannot leak onto stdout — the stdout channel is machine JSON ONLY (§ output
1010
+ # contract). level=ERROR also mutes pyOCD's routine warnings.
1011
+ import logging
1012
+ logging.basicConfig(stream=sys.stderr, level=logging.ERROR)
1013
+
1014
+ ap = argparse.ArgumentParser()
1015
+ sub = ap.add_subparsers(dest="cmd", required=True)
1016
+ sp = sub.add_parser("symbols")
1017
+ sp.add_argument("--elf", required=True)
1018
+ sp.add_argument("--query", default=None)
1019
+ sp.set_defaults(func=cmd_symbols)
1020
+
1021
+ tp = sub.add_parser("trace")
1022
+ tp.add_argument("--elf", required=True)
1023
+ tp.add_argument("--signals", required=True)
1024
+ tp.add_argument("--rate", type=float, default=0.0) # 0 = as fast as possible
1025
+ tp.add_argument("--out", default=None)
1026
+ tp.add_argument("--probe", default=None)
1027
+ tp.add_argument("--target", default="cortex_m",
1028
+ help="pyOCD target_override. Default 'cortex_m' (generic; no CMSIS pack needed, "
1029
+ "sufficient for RAM polling). For part-specific features install the pack "
1030
+ "(pyocd pack install stm32g0b1) and pass e.g. --target stm32g0b1retx.")
1031
+ tp.add_argument("--swo", default=None,
1032
+ help="EXPERIMENTAL: comma list of port:name mappings for ITM stimulus ports, "
1033
+ "e.g. '0:phase,1:isr_us'. Requires firmware that emits ITM data on the "
1034
+ "SWO pin (NOT present in current CrossPad firmware — UNTESTED against real "
1035
+ "ITM). Omit for plain RAM polling. If SWV init fails the daemon continues "
1036
+ "polling (fail-soft).")
1037
+ tp.add_argument("--cpu-hz", type=int, default=64_000_000,
1038
+ help="Core clock frequency in Hz for SWO baud derivation (SWO path only). "
1039
+ "Default 64000000 (STM32G0 max). Must match the actual firmware clock — "
1040
+ "CrossPad r20 runs at 64 MHz but confirm in the .ioc/.clock config.")
1041
+ tp.add_argument("--swo-hz", type=int, default=2_000_000,
1042
+ help="Desired SWO output baud in Hz (SWO path only). Default 2000000. "
1043
+ "Must match the TPIU_ACPR configuration in the firmware.")
1044
+ tp.add_argument("--connect-timeout", type=float, default=8.0,
1045
+ help="§11.1: hard cap (s) on probe connect. A missing probe fails fast; "
1046
+ "a wedged libusb open() past this triggers an error frame + os._exit. "
1047
+ "Never hang. Default 8.")
1048
+ tp.add_argument("--lost-timeout", type=float, default=10.0,
1049
+ help="§11.2: after this many seconds of continuous read faults the "
1050
+ "probe/target is declared lost (error frame + exit) instead of "
1051
+ "looping stop_suspected forever. Default 10.")
1052
+ tp.set_defaults(func=cmd_trace)
1053
+
1054
+ dp = sub.add_parser("device-state")
1055
+ dp.add_argument("--probe", default=None)
1056
+ dp.add_argument("--target", default="cortex_m")
1057
+ dp.add_argument("--connect-timeout", type=float, default=6.0,
1058
+ help="§11.1: hard cap (s) on probe connect for the one-shot device-state "
1059
+ "read. Reports inaccessible on timeout/no-probe. Default 6.")
1060
+ dp.set_defaults(func=cmd_device_state)
1061
+
1062
+ args = ap.parse_args()
1063
+ args.func(args)
1064
+
1065
+ if __name__ == "__main__":
1066
+ main()