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