crosspad-mcp-server 9.1.0 → 9.2.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 (43) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/dist/config.d.ts +7 -0
  3. package/dist/config.js +15 -0
  4. package/dist/config.js.map +1 -1
  5. package/dist/index.js +129 -29
  6. package/dist/index.js.map +1 -1
  7. package/dist/tools/stm-build.d.ts +23 -0
  8. package/dist/tools/stm-build.js +78 -0
  9. package/dist/tools/stm-build.js.map +1 -0
  10. package/dist/tools/stm-flash.d.ts +36 -0
  11. package/dist/tools/stm-flash.js +112 -0
  12. package/dist/tools/stm-flash.js.map +1 -0
  13. package/dist/tools/trace-session.d.ts +12 -0
  14. package/dist/tools/trace-session.js +69 -0
  15. package/dist/tools/trace-session.js.map +1 -1
  16. package/dist/tools/trace-webui.d.ts +14 -7
  17. package/dist/tools/trace-webui.js +77 -33
  18. package/dist/tools/trace-webui.js.map +1 -1
  19. package/dist/tools/trace-write.d.ts +9 -0
  20. package/dist/tools/trace-write.js +32 -0
  21. package/dist/tools/trace-write.js.map +1 -0
  22. package/dist/utils/userConfig.d.ts +9 -0
  23. package/dist/utils/userConfig.js.map +1 -1
  24. package/package.json +2 -1
  25. package/skills/swd-tracer/SKILL.md +48 -0
  26. package/tracer/PROTOCOL.md +69 -0
  27. package/tracer/swd_tracer.py +460 -15
  28. package/tracer/ui/config.js +40 -0
  29. package/tracer/ui/connection.js +170 -0
  30. package/tracer/ui/index.html +50 -798
  31. package/tracer/ui/main.js +30 -0
  32. package/tracer/ui/plot.js +337 -0
  33. package/tracer/ui/signals.js +39 -0
  34. package/tracer/ui/state.js +57 -0
  35. package/tracer/ui/stats.js +52 -0
  36. package/tracer/ui/style.css +97 -0
  37. package/tracer/ui/symbols.js +80 -0
  38. package/tracer/ui/toolbar.js +95 -0
  39. package/tracer/ui/util.js +62 -0
  40. package/tracer/ui/watchlist.js +179 -0
  41. package/vscode-extension/README.md +32 -0
  42. package/vscode-extension/extension.js +56 -0
  43. package/vscode-extension/package.json +14 -0
@@ -52,11 +52,19 @@ def _iter_addr_globals(dwarf):
52
52
  info_die = die
53
53
  name = die.attributes.get("DW_AT_name")
54
54
  if name is None:
55
- spec = die.attributes.get("DW_AT_specification") \
56
- or die.attributes.get("DW_AT_abstract_origin")
55
+ spec = (die.attributes.get("DW_AT_specification")
56
+ or die.attributes.get("DW_AT_abstract_origin"))
57
57
  if spec is not None:
58
+ # The reference form decides the offset basis:
59
+ # DW_FORM_ref_addr is an absolute .debug_info offset, so it
60
+ # must NOT have cu_offset added; the CU-relative forms
61
+ # (ref1/2/4/8/ref_udata) are relative to the CU and do.
58
62
  try:
59
- info_die = cu.dwarfinfo.get_DIE_from_refaddr(spec.value + cu.cu_offset)
63
+ if spec.form == "DW_FORM_ref_addr":
64
+ refaddr = spec.value
65
+ else:
66
+ refaddr = spec.value + cu.cu_offset
67
+ info_die = cu.dwarfinfo.get_DIE_from_refaddr(refaddr)
60
68
  name = info_die.attributes.get("DW_AT_name")
61
69
  except Exception:
62
70
  pass
@@ -687,6 +695,328 @@ def _expand_spec(spec, table):
687
695
  specs = [_spec_name(base, p) for p in parts_lists]
688
696
  return specs, len(specs)
689
697
 
698
+ # Raw absolute-address spec (PROTOCOL §1.2): read MCU memory straight by address
699
+ # with no DWARF symbol — for peripheral registers / arbitrary RAM. Because there
700
+ # is no type to infer size/encoding from, the type tag is explicit (default u32).
701
+ # @0x40021000 -> u32 at 0x40021000
702
+ # @0x40021000:u16 -> u8|u16|u32 / i8|i16|i32 / f32
703
+ # @0x20000000:u8[16] -> 16 consecutive elements (expands like an array)
704
+ _RAW_RE = re.compile(
705
+ r"^@(0[xX][0-9A-Fa-f]+|\d+)" # 1: address (hex 0x… or decimal)
706
+ r"(?::([uif](?:8|16|32)))?" # 2: optional type tag
707
+ r"(?:\[(\d+)\])?$") # 3: optional element count
708
+ _RAW_TYPE = {
709
+ "u8": ("uint", 1), "u16": ("uint", 2), "u32": ("uint", 4),
710
+ "i8": ("int", 1), "i16": ("int", 2), "i32": ("int", 4),
711
+ "f32": ("float", 4),
712
+ }
713
+
714
+ def _resolve_raw(spec):
715
+ """Resolve a raw @address spec.
716
+
717
+ Returns None when `spec` is not a raw spec at all (no leading '@'), so the
718
+ caller falls through to DWARF symbol resolution. Otherwise returns
719
+ {"n": count, "elems": [{name,address,size,encoding}, ...]} — a malformed raw
720
+ spec yields n=0/elems=[] (reported unresolved by the caller).
721
+ """
722
+ if not spec.startswith("@"):
723
+ return None
724
+ m = _RAW_RE.match(spec)
725
+ if not m:
726
+ return {"n": 0, "elems": []} # looks raw but malformed
727
+ addr = int(m.group(1), 0)
728
+ tname = m.group(2) or "u32"
729
+ enc, size = _RAW_TYPE[tname]
730
+ count = int(m.group(3)) if m.group(3) else 1
731
+ elems = []
732
+ for i in range(count):
733
+ a = addr + i * size
734
+ # Single element keeps the user's spec verbatim as its name; an expanded
735
+ # block re-renders each element's concrete address so names stay unique.
736
+ nm = spec if count == 1 else "@0x%X:%s" % (a, tname)
737
+ elems.append({"name": nm, "address": a, "size": size, "encoding": enc})
738
+ return {"n": count, "elems": elems}
739
+
740
+ def _split_transform(spec):
741
+ """Peel a trailing bit/mask transform suffix (#... or &...) off a spec.
742
+
743
+ Base specs never contain '#' or '&', so the earliest occurrence of either
744
+ starts the suffix. Returns (base, suffix) with the sigil kept on the suffix,
745
+ or (spec, None) when there is no transform.
746
+ """
747
+ idxs = [i for i in (spec.find("#"), spec.find("&")) if i >= 0]
748
+ if not idxs:
749
+ return spec, None
750
+ i = min(idxs)
751
+ return spec[:i], spec[i:]
752
+
753
+ def _parse_transform(suffix, size):
754
+ """Validate a transform suffix against the base byte size; return a transform
755
+ dict or None. bits = size*8. '#N' bit, '#hi:lo' range (inclusive, hi>=lo),
756
+ '&0xMASK' mask (normalized to the lowest set bit)."""
757
+ bits = size * 8
758
+ if suffix.startswith("#"):
759
+ body = suffix[1:]
760
+ if ":" in body:
761
+ parts = body.split(":")
762
+ if len(parts) != 2:
763
+ return None
764
+ try:
765
+ hi, lo = int(parts[0]), int(parts[1])
766
+ except ValueError:
767
+ return None
768
+ if lo < 0 or hi < lo or hi >= bits:
769
+ return None
770
+ return {"kind": "range", "hi": hi, "lo": lo}
771
+ try:
772
+ n = int(body)
773
+ except ValueError:
774
+ return None
775
+ if n < 0 or n >= bits:
776
+ return None
777
+ return {"kind": "bit", "n": n}
778
+ if suffix.startswith("&"):
779
+ try:
780
+ mask = int(suffix[1:], 0)
781
+ except ValueError:
782
+ return None
783
+ if mask <= 0 or mask >= (1 << bits):
784
+ return None
785
+ shift = (mask & -mask).bit_length() - 1 # ffs: index of lowest set bit
786
+ return {"kind": "mask", "mask": mask, "shift": shift}
787
+ return None
788
+
789
+ def _parse_value(text, encoding, size):
790
+ """Parse the RHS literal per the target encoding. int: hex 0x.. or decimal
791
+ (range-checked to the byte size, two's-complement for signed); float: f32."""
792
+ text = text.strip()
793
+ if encoding == "float":
794
+ return float(text)
795
+ v = int(text, 0) # 0x.. hex or decimal
796
+ bits = size * 8
797
+ if encoding in ("int", "char"):
798
+ lo, hi = -(1 << (bits - 1)), (1 << (bits - 1)) - 1
799
+ if not (lo <= v <= hi):
800
+ raise ValueError("value out of range for i%d" % bits)
801
+ if v < 0:
802
+ v &= (1 << bits) - 1 # store two's-complement
803
+ else:
804
+ if not (0 <= v < (1 << bits)):
805
+ raise ValueError("value out of range for u%d" % bits)
806
+ return v
807
+
808
+ def _parse_write_spec(spec, table):
809
+ """'target=value' -> resolved write descriptor (see Interfaces)."""
810
+ if "=" not in spec:
811
+ return {"ok": False, "spec": spec, "error": "missing '=' (use target=value)"}
812
+ left, right = spec.split("=", 1)
813
+ left, right = left.strip(), right.strip()
814
+ # Transforms (#bit / &mask) are read-only, not supported on write targets.
815
+ base, suffix = _split_transform(left)
816
+ if suffix is not None:
817
+ return {"ok": False, "spec": spec,
818
+ "error": "transforms (#bit / &mask) are read-only, not supported on write targets"}
819
+ # Resolve the LHS address/size/encoding using the read resolvers.
820
+ raw = _resolve_raw(left)
821
+ if raw is not None:
822
+ if raw["n"] != 1:
823
+ return {"ok": False, "spec": spec,
824
+ "error": "block write unsupported; write one element"}
825
+ d = raw["elems"][0]
826
+ else:
827
+ d = _resolve_spec(left, table)
828
+ if not d:
829
+ return {"ok": False, "spec": spec, "error": "unknown target: %s" % left}
830
+ try:
831
+ val = _parse_value(right, d["encoding"], d["size"])
832
+ except ValueError as e:
833
+ return {"ok": False, "spec": spec, "error": str(e)}
834
+ return {"ok": True, "name": left, "address": d["address"],
835
+ "size": d["size"], "encoding": d["encoding"], "value": val}
836
+
837
+ # Cortex-M architectural memory map (ARMv6/7/8-M) — identical across STM32 series.
838
+ _SRAM = (0x20000000, 0x3FFFFFFF)
839
+ _ARCH_REGIONS = [
840
+ _SRAM, # SRAM
841
+ (0x40000000, 0x5FFFFFFF), # Peripheral + IOPORT
842
+ (0xE0000000, 0xE00FFFFF), # PPB / SCS (NVIC, SCB, DWT...)
843
+ ]
844
+
845
+ def _check_allowed(addr, size, ram_regions=None):
846
+ """Return None if [addr, addr+size) is a permitted write target, else an
847
+ error string. Code region 0x0..0x1FFFFFFF (flash/system/option) is blocked."""
848
+ if size not in (1, 2, 4):
849
+ return "bad access size %r" % size
850
+ if addr % size != 0:
851
+ return "0x%08X not %d-byte aligned" % (addr, size)
852
+ end = addr + size - 1
853
+ for lo, hi in _ARCH_REGIONS:
854
+ if lo <= addr and end <= hi:
855
+ # SRAM refinement: if pack RAM regions are known, require containment.
856
+ if (lo, hi) == _SRAM and ram_regions:
857
+ if not any(rl <= addr and end <= rh for rl, rh in ram_regions):
858
+ return "0x%08X outside mapped SRAM" % addr
859
+ return None
860
+ return "0x%08X outside write allowlist (Code region / unmapped is blocked)" % addr
861
+
862
+ def _ram_regions_from(session):
863
+ """Best-effort (lo,hi) inclusive RAM ranges from the pyOCD memory map; None if
864
+ the (generic) target exposes none."""
865
+ try:
866
+ mm = session.target.get_memory_map()
867
+ rams = [(r.start, r.end) for r in mm if r.type.name.lower() == "ram"]
868
+ return rams or None
869
+ except Exception:
870
+ return None
871
+
872
+ _WSIZE = {1: "write8", 2: "write16", 4: "write32"}
873
+ _RSIZE = {1: "read8", 2: "read16", 4: "read32"}
874
+
875
+ def do_write(target, descriptors, ram_regions):
876
+ """Apply each resolved write non-halting; read-back old/new for confirmation."""
877
+ out = []
878
+ for d in descriptors:
879
+ if not d.get("ok"):
880
+ out.append({"name": d.get("name", d.get("spec")), "ok": False,
881
+ "error": d["error"]})
882
+ continue
883
+ addr, size = d["address"], d["size"]
884
+ guard = _check_allowed(addr, size, ram_regions)
885
+ if guard:
886
+ out.append({"name": d["name"], "address": addr, "size": size,
887
+ "ok": False, "error": "write blocked: " + guard})
888
+ continue
889
+ try:
890
+ old = getattr(target, _RSIZE[size])(addr)
891
+ getattr(target, _WSIZE[size])(addr, d["value"] & ((1 << (size * 8)) - 1))
892
+ new = getattr(target, _RSIZE[size])(addr)
893
+ out.append({"name": d["name"], "address": addr, "size": size,
894
+ "ok": True, "old": old, "new": new})
895
+ except Exception as e:
896
+ out.append({"name": d["name"], "address": addr, "size": size,
897
+ "ok": False, "error": str(e)})
898
+ return out
899
+
900
+ def cmd_write(args):
901
+ specs = [s for s in args.writes.split(";") if s.strip()]
902
+ try:
903
+ table = build_symbol_table(args.elf)
904
+ except Exception as e:
905
+ print(json.dumps({"type": "error", "error": "ELF/DWARF error: %s" % e}), flush=True)
906
+ return
907
+ descriptors = [_parse_write_spec(s, table) for s in specs]
908
+ session, cerr, _ = _try_open_session(args.probe, args.target, args.connect_timeout)
909
+ if session is None:
910
+ print(json.dumps({"type": "write_result", "ok": False, "error": cerr,
911
+ "results": []}), flush=True)
912
+ return
913
+ try:
914
+ results = do_write(session.target, descriptors, _ram_regions_from(session))
915
+ finally:
916
+ try: session.close()
917
+ except Exception: pass
918
+ print(json.dumps({"type": "write_result",
919
+ "ok": all(r["ok"] for r in results) if results else False,
920
+ "results": results}), flush=True)
921
+
922
+ def _resolve_func(elf_path, name):
923
+ """Entry address of a function symbol from the ELF .symtab (STT_FUNC)."""
924
+ from elftools.elf.elffile import ELFFile
925
+ with open(elf_path, "rb") as f:
926
+ elf = ELFFile(f)
927
+ symtab = elf.get_section_by_name(".symtab")
928
+ if symtab is None:
929
+ return None
930
+ for sym in symtab.iter_symbols():
931
+ if sym.name == name and sym["st_info"]["type"] == "STT_FUNC":
932
+ return sym["st_value"] & ~1 # clear Thumb bit
933
+ return None
934
+
935
+ _CALL_CTX = ["r0","r1","r2","r3","r4","r5","r6","r7","r8","r9","r10","r11","r12",
936
+ "sp","lr","pc","xpsr","primask"]
937
+
938
+ def do_call(target, entry, args, ret_type, timeout_s):
939
+ """AAPCS function-call thunk. Halts the core, runs func(args), restores full
940
+ context, resumes. Returns {ok,r0,decoded?} or {ok:False,error}."""
941
+ from pyocd.core.target import Target
942
+ if len(args) > 4:
943
+ return {"ok": False, "error": "max 4 args (r0-r3); got %d" % len(args)}
944
+ saved = {}
945
+ trap = None
946
+ try:
947
+ target.halt()
948
+ for r in _CALL_CTX:
949
+ saved[r] = target.read_core_register(r)
950
+ # Return trap = reset-handler entry from the ACTIVE vector table (VTOR),
951
+ # so the call thunk is MCU-agnostic (not tied to the STM32 flash alias at
952
+ # 0x08000000). VTOR low bits are reserved/zero; mask for safe alignment.
953
+ # VTOR=0 after reset aliases the table at 0x00000000.
954
+ vtor = target.read32(0xE000ED08) & 0xFFFFFF00
955
+ trap = target.read32(vtor + 4) & ~1
956
+ target.set_breakpoint(trap, Target.BreakpointType.HW)
957
+ for i, a in enumerate(args):
958
+ target.write_core_register("r%d" % i, a & 0xFFFFFFFF)
959
+ target.write_core_register("primask", 1) # mask ISRs during thunk
960
+ target.write_core_register("lr", trap | 1) # Thumb return
961
+ target.write_core_register("pc", entry)
962
+ xpsr = saved["xpsr"] | (1 << 24) # ensure Thumb (T) bit
963
+ target.write_core_register("xpsr", xpsr)
964
+ target.resume()
965
+ deadline = time.monotonic() + timeout_s
966
+ while time.monotonic() < deadline:
967
+ if target.get_state() == Target.State.HALTED:
968
+ break
969
+ time.sleep(0.002)
970
+ else:
971
+ target.halt()
972
+ return {"ok": False, "error":
973
+ "call timed out after %gms (function did not return)" % (timeout_s * 1000)}
974
+ r0 = target.read_core_register("r0")
975
+ res = {"ok": True, "r0": r0}
976
+ if ret_type and ret_type != "u32":
977
+ res["decoded"] = _decode(r0.to_bytes(4, "little"), 0, *_RAW_TYPE[ret_type])
978
+ return res
979
+ except Exception as e:
980
+ return {"ok": False, "error": str(e)}
981
+ finally:
982
+ try:
983
+ if trap is not None:
984
+ target.remove_breakpoint(trap)
985
+ except Exception:
986
+ pass
987
+ for r, v in saved.items():
988
+ try:
989
+ target.write_core_register(r, v) # full context restore
990
+ except Exception:
991
+ pass
992
+ try:
993
+ target.resume() # firmware continues pre-call
994
+ except Exception:
995
+ pass
996
+
997
+ def cmd_call(args):
998
+ if not args.confirm:
999
+ print(json.dumps({"type": "call_result", "ok": False,
1000
+ "error": "call requires --confirm"}), flush=True)
1001
+ return
1002
+ entry = _resolve_func(args.elf, args.func_name)
1003
+ if entry is None:
1004
+ print(json.dumps({"type": "call_result", "ok": False,
1005
+ "error": "unknown function: %s" % args.func_name}), flush=True)
1006
+ return
1007
+ argv = [int(a, 0) for a in args.args.split(",") if a.strip()] if args.args else []
1008
+ session, cerr, _ = _try_open_session(args.probe, args.target, args.connect_timeout)
1009
+ if session is None:
1010
+ print(json.dumps({"type": "call_result", "ok": False, "error": cerr}), flush=True)
1011
+ return
1012
+ try:
1013
+ res = do_call(session.target, entry, argv, args.ret_type, args.timeout)
1014
+ finally:
1015
+ try: session.close()
1016
+ except Exception: pass
1017
+ res["type"] = "call_result"
1018
+ print(json.dumps(res), flush=True)
1019
+
690
1020
  def _resolve_specs(specs, table):
691
1021
  """Resolve a list of specs, applying §1.1 array/vector/matrix expansion.
692
1022
 
@@ -700,22 +1030,55 @@ def _resolve_specs(specs, table):
700
1030
  """
701
1031
  resolved, unresolved = [], []
702
1032
  for spec in specs:
703
- elems, n = _expand_spec(spec, table)
1033
+ base, suffix = _split_transform(spec)
1034
+ # §1.2: raw @address specs bypass DWARF entirely.
1035
+ raw = _resolve_raw(base)
1036
+ if raw is not None:
1037
+ if raw["n"] > EXPAND_CAP:
1038
+ unresolved.append("%s (expands to %d > %d)" % (spec, raw["n"], EXPAND_CAP))
1039
+ elif not raw["elems"]:
1040
+ unresolved.append(spec)
1041
+ elif suffix is not None:
1042
+ if raw["n"] != 1:
1043
+ unresolved.append("%s (transform on expanding spec unsupported)" % spec)
1044
+ else:
1045
+ t = _parse_transform(suffix, raw["elems"][0]["size"])
1046
+ if t is None:
1047
+ unresolved.append(spec)
1048
+ else:
1049
+ e = dict(raw["elems"][0]); e["transform"] = t; e["name"] = spec
1050
+ resolved.append(e)
1051
+ else:
1052
+ resolved.extend(raw["elems"])
1053
+ continue
1054
+ elems, n = _expand_spec(base, table)
704
1055
  if n > EXPAND_CAP:
705
1056
  unresolved.append("%s (expands to %d > %d)" % (spec, n, EXPAND_CAP))
706
1057
  continue
707
1058
  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").
1059
+ if suffix is not None and n != 1:
1060
+ unresolved.append("%s (transform on expanding spec unsupported)" % spec)
1061
+ continue
711
1062
  for e in elems:
712
1063
  r = _resolve_spec(e, table)
713
- if r:
714
- resolved.append(r)
1064
+ if not r:
1065
+ continue
1066
+ if suffix is not None:
1067
+ t = _parse_transform(suffix, r["size"])
1068
+ if t is None:
1069
+ unresolved.append(spec); continue
1070
+ r = dict(r); r["transform"] = t; r["name"] = spec
1071
+ resolved.append(r)
715
1072
  continue
716
1073
  # Not expandable — try as a plain concrete scalar spec.
717
- r = _resolve_spec(spec, table)
1074
+ r = _resolve_spec(base, table)
718
1075
  if r:
1076
+ if suffix is not None:
1077
+ t = _parse_transform(suffix, r["size"])
1078
+ if t is None:
1079
+ unresolved.append(spec)
1080
+ continue
1081
+ r = dict(r); r["transform"] = t; r["name"] = spec
719
1082
  resolved.append(r)
720
1083
  else:
721
1084
  unresolved.append(spec)
@@ -742,7 +1105,8 @@ def _resolve_type_from(die, cu):
742
1105
  return "uint", (bs.value if bs else 4)
743
1106
 
744
1107
  def _coalesce(sigs):
745
- """sigs: list of {name,address,size,encoding}. Returns [(start,length,[(name,off,size,enc)])]."""
1108
+ """sigs: list of {name,address,size,encoding,transform?}.
1109
+ Returns [(start,length,[(name,off,size,enc,transform)])]."""
746
1110
  items = sorted(sigs, key=lambda s: s["address"])
747
1111
  ranges = []
748
1112
  for s in items:
@@ -750,9 +1114,10 @@ def _coalesce(sigs):
750
1114
  if ranges and a <= ranges[-1][0] + ranges[-1][1] + 4: # merge if within 4 bytes of prev end
751
1115
  start, length, members = ranges[-1]
752
1116
  new_end = max(start + length, a + ln)
753
- ranges[-1] = (start, new_end - start, members + [(s["name"], a - start, ln, s["encoding"])])
1117
+ ranges[-1] = (start, new_end - start,
1118
+ members + [(s["name"], a - start, ln, s["encoding"], s.get("transform"))])
754
1119
  else:
755
- ranges.append((a, ln, [(s["name"], 0, ln, s["encoding"])]))
1120
+ ranges.append((a, ln, [(s["name"], 0, ln, s["encoding"], s.get("transform"))]))
756
1121
  return ranges
757
1122
 
758
1123
  def _decode(buf, off, size, enc):
@@ -764,6 +1129,20 @@ def _decode(buf, off, size, enc):
764
1129
  signed = enc in ("int", "char")
765
1130
  return int.from_bytes(raw, "little", signed=signed)
766
1131
 
1132
+ def _apply_transform(value, size, transform):
1133
+ """Apply a bit/range/mask transform to a decoded value. The value is
1134
+ reinterpreted UNSIGNED (masked to size*8 bits) so a signed encoding does not
1135
+ corrupt bit extraction."""
1136
+ u = value & ((1 << (size * 8)) - 1)
1137
+ kind = transform["kind"]
1138
+ if kind == "bit":
1139
+ return (u >> transform["n"]) & 1
1140
+ if kind == "range":
1141
+ lo, hi = transform["lo"], transform["hi"]
1142
+ return (u >> lo) & ((1 << (hi - lo + 1)) - 1)
1143
+ # mask
1144
+ return (u & transform["mask"]) >> transform["shift"]
1145
+
767
1146
  def cmd_trace(args):
768
1147
  names = [n for n in args.signals.split(",") if n]
769
1148
  # §11.3 ELF / DWARF guard: a bad/missing ELF must surface as an error frame,
@@ -790,6 +1169,8 @@ def cmd_trace(args):
790
1169
  sigset = {s["name"]: s for s in resolved}
791
1170
  state = {"dirty": True, "ranges": _coalesce(list(sigset.values())),
792
1171
  "unresolved": initial_unresolved, "signals": list(sigset.values())}
1172
+ pending_cmds = [] # list of dict msgs from stdin (write/call), drained in loop
1173
+ cmd_lock = threading.Lock()
793
1174
 
794
1175
  def _signals_frame():
795
1176
  """Build the {"type":"signals",...} frame from the current poll set."""
@@ -866,6 +1247,12 @@ def cmd_trace(args):
866
1247
  sigs = msg.get("signals")
867
1248
  if isinstance(sigs, list):
868
1249
  _apply_remove([s for s in sigs if isinstance(s, str)])
1250
+ elif cmd == "write" and isinstance(msg.get("writes"), list):
1251
+ with cmd_lock:
1252
+ pending_cmds.append(msg)
1253
+ elif cmd == "call":
1254
+ with cmd_lock:
1255
+ pending_cmds.append(msg)
869
1256
  threading.Thread(target=stdin_reader, daemon=True).start()
870
1257
 
871
1258
  log(f"connecting probe (serial={args.probe or 'auto'}, target={args.target}, "
@@ -887,6 +1274,7 @@ def cmd_trace(args):
887
1274
  os._exit(3 if "no debug probe" in cerr else 1)
888
1275
 
889
1276
  target = session.target
1277
+ ram_regions = _ram_regions_from(session)
890
1278
  log("connected; polling (non-halting)")
891
1279
  # §11.2 persistent-fault tracking: a single read fault = stop_suspected, but
892
1280
  # faults lasting longer than --lost-timeout mean the probe/target is gone.
@@ -911,6 +1299,39 @@ def cmd_trace(args):
911
1299
  state["ranges"] = _coalesce(list(state["signals"]))
912
1300
  state["dirty"] = False
913
1301
  print(json.dumps(_signals_frame()), flush=True)
1302
+ drained = []
1303
+ with cmd_lock:
1304
+ if pending_cmds:
1305
+ drained = pending_cmds[:]; pending_cmds.clear()
1306
+ for msg in drained:
1307
+ rid = msg.get("id")
1308
+ kind = "write_result" if msg.get("cmd") == "write" else "call_result"
1309
+ try:
1310
+ if msg.get("cmd") == "write":
1311
+ descs = [_parse_write_spec(s, table)
1312
+ for s in msg["writes"] if isinstance(s, str)]
1313
+ results = do_write(target, descs, ram_regions)
1314
+ print(json.dumps({"type": "write_result", "id": rid,
1315
+ "ok": all(r["ok"] for r in results) if results else False,
1316
+ "results": results}), flush=True)
1317
+ else: # call
1318
+ if not msg.get("confirm"):
1319
+ print(json.dumps({"type": "call_result", "id": rid,
1320
+ "ok": False, "error": "call requires confirm:true"}), flush=True)
1321
+ continue
1322
+ entry = _resolve_func(args.elf, msg.get("func", ""))
1323
+ if entry is None:
1324
+ print(json.dumps({"type": "call_result", "id": rid, "ok": False,
1325
+ "error": "unknown function: %s" % msg.get("func")}), flush=True)
1326
+ continue
1327
+ res = do_call(target, entry,
1328
+ [int(a) for a in msg.get("args", [])],
1329
+ msg.get("ret_type", "u32"), float(msg.get("timeout", 2.0)))
1330
+ res.update({"type": "call_result", "id": rid})
1331
+ print(json.dumps(res), flush=True)
1332
+ except Exception as e:
1333
+ print(json.dumps({"type": kind, "id": rid, "ok": False,
1334
+ "error": "command failed: %s" % e}), flush=True)
914
1335
  ranges = state["ranges"]
915
1336
  values, in_stop = {}, False
916
1337
  for (start, length, members) in ranges:
@@ -919,8 +1340,11 @@ def cmd_trace(args):
919
1340
  except Exception:
920
1341
  in_stop = True
921
1342
  break
922
- for (name, off, size, enc) in members:
923
- values[name] = _decode(data, off, size, enc)
1343
+ for (name, off, size, enc, transform) in members:
1344
+ v = _decode(data, off, size, enc)
1345
+ if transform is not None:
1346
+ v = _apply_transform(v, size, transform)
1347
+ values[name] = v
924
1348
  t = time.monotonic() - t0
925
1349
  if in_stop:
926
1350
  now = time.monotonic()
@@ -1076,6 +1500,27 @@ def main():
1076
1500
  "read. Reports inaccessible on timeout/no-probe. Default 6.")
1077
1501
  dp.set_defaults(func=cmd_device_state)
1078
1502
 
1503
+ wp = sub.add_parser("write")
1504
+ wp.add_argument("--elf", required=True)
1505
+ wp.add_argument("--writes", required=True, help="';'-separated target=value specs")
1506
+ wp.add_argument("--probe", default=None)
1507
+ wp.add_argument("--target", default="cortex_m")
1508
+ wp.add_argument("--connect-timeout", type=float, default=6.0)
1509
+ wp.set_defaults(func=cmd_write)
1510
+
1511
+ cp = sub.add_parser("call")
1512
+ cp.add_argument("--elf", required=True)
1513
+ cp.add_argument("--func", required=True, dest="func_name")
1514
+ cp.add_argument("--args", default=None, help="comma-separated ints (hex 0x.. or dec)")
1515
+ cp.add_argument("--confirm", action="store_true")
1516
+ cp.add_argument("--ret-type", dest="ret_type", default="u32",
1517
+ choices=list(_RAW_TYPE.keys()))
1518
+ cp.add_argument("--timeout", type=float, default=2.0)
1519
+ cp.add_argument("--probe", default=None)
1520
+ cp.add_argument("--target", default="cortex_m")
1521
+ cp.add_argument("--connect-timeout", type=float, default=6.0)
1522
+ cp.set_defaults(func=cmd_call)
1523
+
1079
1524
  args = ap.parse_args()
1080
1525
  args.func(args)
1081
1526
 
@@ -0,0 +1,40 @@
1
+ /* Config panel wiring (client-side only). Mirrors the side-panel controls into
2
+ the shared cfg object. toggleFull is exported for the keyboard shortcut. */
3
+
4
+ import { sigs, cfg } from "./state.js";
5
+
6
+ let cfgWindow, cfgFull, cfgMax, cfgYMode;
7
+
8
+ function applyFull(){
9
+ cfg.full=cfgFull.checked;
10
+ cfgWindow.disabled=cfg.full; // trailing window is meaningless in full-history
11
+ }
12
+
13
+ export function toggleFull(){
14
+ if(!cfgFull)return;
15
+ cfgFull.checked=!cfgFull.checked;
16
+ applyFull();
17
+ }
18
+
19
+ /* Reflect a new visible window width (e.g. from wheel-zoom) into cfg + the input
20
+ so the "Trailing window" field never goes stale while zooming. */
21
+ export function setWindow(sec){
22
+ if(!(sec>0))return;
23
+ cfg.windowSec=sec;
24
+ if(cfgWindow) cfgWindow.value = sec<10 ? sec.toFixed(1) : String(Math.round(sec));
25
+ }
26
+
27
+ export function initConfig(){
28
+ cfgWindow=document.getElementById("cfgWindow");
29
+ cfgFull=document.getElementById("cfgFull");
30
+ cfgMax=document.getElementById("cfgMax");
31
+ cfgYMode=document.getElementById("cfgYMode");
32
+
33
+ cfgWindow.onchange=()=>{ const v=parseFloat(cfgWindow.value); if(v>0) cfg.windowSec=v; };
34
+ cfgFull.onchange=applyFull;
35
+ cfgMax.onchange=()=>{
36
+ const v=parseInt(cfgMax.value,10);
37
+ if(v>=100){ cfg.maxSamples=v; for(const[,s]of sigs) while(s.data.length>v) s.data.shift(); }
38
+ };
39
+ cfgYMode.onchange=()=>{ cfg.yMode=cfgYMode.value; };
40
+ }