Deep architectural traces spanning kit management, schema registry, file save/open pipelines, live device connection, DASP transport, SOX protocol commands, and invoke/action slot flow. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
97 KiB
Sedona Editor - Architecture Traces
Deep architectural traces of the Sedona Editor codebase, each following a specific feature or subsystem from UI entry point through every layer to its final effect. These traces were produced through systematic analysis of the code graph and source files.
Traces 1-6 (Prior Session)
The following traces were produced in an earlier session:
- Trace 1 -- SoxClient Bridge Analysis (from prior session)
- Trace 2 -- Live Property Edit Flow (from prior session)
- Trace 3 -- COV Event Flow (from prior session)
- Trace 4 -- Live Add/Delete Component Flow (from prior session)
- Trace 5 -- Wire Routing Pathfinding (from prior session)
- Trace 6 -- Undo Architecture (from prior session)
Trace 7 — Kit Management Flow
The kit management system spans 5 communities in the graph: the KitsPanel UI (manage_kits.py), the schema DOM layer (sax_file.py), the palette resolution layer (palette.py), the manifest/dependency layer (schema.py), and the undo system (undo.py). Its job is to keep the <schema> block of a .sax file in sync with the kits its components actually need — a constraint that matters because a Sedona device won't run an app whose schema doesn't declare every kit in its dependency closure.
Layer 1 — The Palette: What's Installed
Everything starts at palette.py, which reads the local Sedona home directory. It has two filesystem sources:
manifests/<kit>/<kit>-<checksum>.xml— the XML manifest describing a kit's types and their slotskits/<kit>/<kit>-<checksum>-<version>.kit— the binary kit file whose filename carries the version number
resolve_manifest_path() (palette.py:76) picks which manifest describes a kit. It follows a three-tier preference: (1) the checksum the open file's <schema> pins, (2) the highest version number from .kit filenames, (3) the last manifest alphabetically. This is how the palette always shows types from the build the app is actually using, not the newest one.
kit_builds() (palette.py:120) enumerates every build of a kit (every manifest under manifests/<kit>/), cross-referencing the .kit filenames for version labels, and sorts newest-first. resolve_checksum() (palette.py:178) returns the checksum of whichever manifest resolve_manifest_path chose.
The caches (_versions_cache, _manifest_cache, _builds_cache) are module-level dicts. invalidate() clears all of them, forcing the next access to re-read the filesystem — called after any schema change so the palette reflects the new pins.
Layer 2 — Dependency Resolution: What's Required
schema.py handles manifest parsing and dependency tracking. read_kit_dependencies() (schema.py:47) parses a manifest's <depends> block and returns the kit names it was built against (e.g., <depend on="sys 1+"/> yields "sys"). Results are cached by manifest path.
required_kit_names() (sax_file.py:421) performs a BFS over the dependency graph: it starts with used_kit_names() (every kit named by a <comp> element's type attribute), then iteratively expands through read_kit_dependencies() until no new names appear. This transitive closure is what the schema block must declare — not just the kits components name, but every kit those kits depend on.
Layer 3 — The Deferred Declaration Pattern
When the user drags a component from the palette onto the wiresheet, note_kit_used() (sax_file.py:388) records the kit without touching the DOM:
app_state.pending_schema_kits[kit_name] = palette.resolve_checksum(kit_name)
This is a deliberate design choice. The schema block isn't updated on every placement because: (a) the user might undo the placement, (b) multiple components from the same kit might be added in quick succession, and (c) interrupting the user with a kit dialog on every drag-and-drop would be hostile.
The pending list only supplies checksums. The DOM is the authority on what kits are actually needed — used_kit_names() (sax_file.py:408) walks every <comp> in the tree, so an undone placement can't leave a phantom kit behind.
Layer 4 — Save-Time Reconciliation
When the user saves, reconcile_schema_kits_before_save() (sax_file.py:728) runs as a gate before the file is written:
- Snapshot the
<schema>block's children (for rollback) - Call
declare_used_kits()(sax_file.py:444), which computes the full transitive closure viarequired_kit_names(), diffs it against what the schema already declares, and inserts<kit>elements for anything missing — using the checksum frompending_schema_kitsor falling back topalette.resolve_checksum() - If nothing was added, return
True(save proceeds) - If kits were added, build a report via
describe_schema_kits()(sax_file.py:554) showing what was added and the full post-save kit list, then show a confirmation dialog - If the user cancels:
restore_children()puts the schema block back,active_schema_kitsentries are rolled back, and the save is abandoned —return False - If the user confirms: clear the pending entries and
return True— the save writes the file with the new kits
This is the all-or-nothing pattern seen throughout the codebase: snapshot, mutate, validate, rollback-on-reject.
Layer 5 — The Manage Kits Dialog
KitsPanel (manage_kits.py:223) is the explicit management UI, opened from the menu. It presents a Treeview table with four groups of rows, built by collect_rows() (manage_kits.py:131):
- Installed kits — declared and required (checkbox greyed, can't uncheck)
- Installed but not used — declared but no component needs them (can uncheck to remove)
- Available on computer — in Sedona home but not in schema (can check to add)
- Not supported — incompatible kits (shown with a cross, not actionable)
Each row carries an Action dropdown built by action_options() (manage_kits.py:85). The first option is always a no-op ("Keep at…"), and subsequent options are labeled "Upgrade to", "Downgrade to", "Add at", or "Set to" depending on version comparison.
The panel has two commit paths:
"Upgrade all" (manage_kits.py:509): scans every declared kit, finds those where latest_checksum != current_checksum, shows a preview, and on confirmation calls _apply_changes() with the full selection list.
"Update kits" (manage_kits.py:538): reads the table state — unchecked declared kits become removals, checked kits with non-default action choices become selections — and passes both to _apply_changes().
Both paths funnel through _apply_changes() (manage_kits.py:478), which calls sax_file.apply_kit_selections().
Layer 6 — Atomic Apply with Rollback
apply_kit_selections() (sax_file.py:623) is the transactional core. It:
- Snapshots the schema block's children
- Walks selections: inserts missing
<kit>elements, updates checksums on existing ones - Walks removals: removes
<kit>elements - If nothing actually changed, returns
([], None) - Invalidates the palette cache (so
resolve_manifest_pathre-reads with new pins) - Recompiles the schema registry via
schema.load_schema_kits_and_manifests() - Validates — if a manifest is missing (
MissingManifestError), callsabandon()which restores the snapshot and reloads the registry: returns([], reason) - Validates — if any component types became unresolvable (a repin dropped a type the app uses), same rollback
- Clears pending entries for the changed kits
- Pushes a
SchemaBlockCommandonto the undo stack with the before/after snapshots - Marks dirty and refreshes the treeview and wiresheet
The abandon() closure (sax_file.py:690) is elegant: restore_children(schema_elem, before) + undo.reload_schema_registry(), then return the error message. The caller gets ([], reason) and can show the error without needing to know anything about the rollback internals.
Layer 7 — Undo Integration
SchemaBlockCommand (from undo.py) stores before/after snapshots of the <schema> element's children. Its undo() restores the before-snapshot and recompiles the registry; its redo() restores the after-snapshot and recompiles. This is the same snapshot/restore mechanism that apply_kit_selections uses for its own rollback — the undo system is literally replaying the same operation the transactional validation used.
The Full Loop
User drags component → note_kit_used() → pending_schema_kits grows
(no DOM change)
User saves → reconcile_…_before_save()
→ declare_used_kits()
→ required_kit_names() (BFS transitive closure)
→ insert_kit_element() (DOM mutation)
→ describe_schema_kits() (report)
→ user confirms or cancels (rollback if cancel)
User opens Manage Kits → collect_rows() → reads DOM + palette + dependencies
→ _upgrade_all() or _update_marked()
→ _apply_changes()
→ apply_kit_selections()
→ snapshot → mutate → invalidate palette
→ recompile registry → validate
→ rollback on error, or push undo + mark dirty
The key insight: there are two reconciliation moments — passive (save-time, for kits that crept in through component placement) and active (the Manage Kits dialog, for explicit version management) — but both converge on the same atomic apply-or-rollback mechanism.
Trace 8 — Schema Registry Compilation
The schema registry is the central type system of the editor. It maps every kit::Type string to a flat list of resolved slots — the properties, inputs, and outputs a component of that type exposes. Every box on the wiresheet, every row in the properties panel, every wire endpoint, and every live device interaction depends on this registry being correct and current. It lives in app_state.final_components_registry, a plain dict populated by schema.py and read everywhere else.
Entry Points — When Does Compilation Happen?
There are three distinct entry points, each reaching the registry through a different path:
1. File open (sax_file.py:175): schema.load_schema_kits_and_manifests() is called right after ET.parse() loads the XML. This is the primary path — it reads the <schema> block's <kit> entries, resolves each to a manifest file, and compiles the full registry. A MissingManifestError here aborts the file open entirely.
2. Live device connect (main.py:2077): _load_live_schema() takes a different route. Instead of reading <kit> elements from a DOM, it reads ds.sox_client.schema_parts — the kit names and checksums the device reported over SOX. It populates active_schema_kits from those, finds manifest paths itself, and calls compile_schema_registry() directly (not through load_schema_kits_and_manifests). Missing kits here are tolerated — a warning in the status bar, not an error — because the device may run kits the local machine doesn't have manifests for.
3. Kit changes (multiple callers): apply_kit_selections() (sax_file.py:699), add_component() (sax_file.py:792, sax_file.py:803), and SchemaBlockCommand.undo()/redo() (undo.py:112) all recompile after mutating the <schema> block. Each calls through load_schema_kits_and_manifests() after invalidating the palette cache.
Phase 1 — Manifest Discovery
load_schema_kits_and_manifests() (schema.py:239) starts by clearing active_schema_kits and rebuilding it from the DOM:
for kit in schema_elem.findall("kit"):
app_state.active_schema_kits[name] = kit.get("checksum")
Then it merges in pending kits — those recorded by note_kit_used() when components were placed but the schema hasn't been saved yet:
kits_to_load = dict(app_state.active_schema_kits)
for name, checksum in app_state.pending_schema_kits.items():
kits_to_load.setdefault(name, checksum)
The setdefault is important: a declared kit's checksum always wins over its pending checksum. Pending kits only fill gaps.
For each kit, find_manifest_path() (schema.py:202) locates the XML manifest under SEDONA_HOME/manifests/<kit>/<kit>-<checksum>.xml. If the declared checksum matches a file, that file is used. If not — because the checksum is absent or points to a build this machine doesn't hold — it falls back to the last manifest alphabetically. This tolerance is what lets files with no checksums (or checksums from another machine) still open.
Missing manifests are collected, not failed on immediately. The loop finishes, then raises a single MissingManifestError naming every missing kit. This matters: fixing one-at-a-time is painful when five kits are missing.
Phase 2 — Type Extraction
compile_schema_registry() (schema.py:167) receives the resolved manifest paths and builds the type index:
kit_schemas = {}
for path in manifest_paths:
root = ET.parse(path).getroot()
kit_name = root.get("name")
for type_tag in root.findall("type"):
type_name = f"{kit_name}::{type_tag.get('name')}"
kit_schemas[type_name] = type_tag
This produces a flat namespace of "kit::Type" → XML element mappings. Every type from every loaded manifest goes in, regardless of whether any component in the app uses it. The index is cheap (it's just element references) and having the full set is essential for inheritance resolution — a type in kit A may inherit from a type in kit B.
Phase 3 — Slot Inheritance Resolution
The heart of the system. get_slots_with_inheritance() (schema.py:136) resolves each type's complete slot list by walking the base chain:
def get_slots_with_inheritance(type_name, kit_schemas):
type_element = kit_schemas[type_name]
base_type = type_element.get("base")
resolved_slots = []
if base_type:
resolved_slots = get_slots_with_inheritance(base_type, kit_schemas)
current_slots = [... for slot in type_element.findall("slot")]
current_slots.sort(key=lambda s: s["declared_id"])
resolved_slots.extend(current_slots)
return resolved_slots
This is classic recursive inheritance: walk up to the root of the type hierarchy, collect that ancestor's slots, then append each descendant's slots on the way back down. The declared_id sort within each level preserves the manifest's own ordering. The final list is the complete set of slots a component of that type has, from the most ancestral (e.g., sys::Component's meta slot) through every intermediate type down to the concrete one.
If the base type references a kit that isn't loaded — say control::Ramp inherits from sys::Component but sys wasn't in the schema — a MissingManifestError is raised with the unresolved_type field set. The error message then explains not just "kit X is missing" but "type Y inherits from kit X, so its slots can't be resolved."
Each slot is extracted with its full metadata:
name,type,default,flags— from the<slot>attributesdeclared_id— the manifest's own slot numbering (used for wire endpoint alignment)readonly— from the@readonlyfacetsummary— from the@summaryfacet (controls wiresheet visibility)facets— the complete facet dict (read_all_facets()at schema.py:90) carrying unit, precision, min, max, trueText/falseText, range
Phase 4 — Flattening and ID Assignment
Back in compile_schema_registry(), the inherited slot list gets re-indexed:
for sequence_id, slot_data in enumerate(flat_slots):
resolved_type_structure.append({
"slot_id": sequence_id,
...
})
app_state.final_components_registry[type_name] = resolved_type_structure
The slot_id is a zero-based sequence number across the full inheritance chain, not the manifest's declared_id. This is the ID used everywhere downstream: it's the SOX slot index for live property reads/writes, the row number for wire endpoint anchoring, and the key for property panel rendering. The registry stores both — declared_id for display formatting (the "00. meta", "01. status" labels on wiresheet boxes), slot_id for protocol-level addressing.
The Three Consumers
The registry serves three different views, each calling a different accessor:
Wiresheet boxes call get_slots_for_type() (schema.py:303) at wiresheet.py:166. This is the most filtered view: it hides operator-flagged slots ("o" in flags), config-only slots ("c" in flags) unless they have a wire, and slots with @summary false. The result is the rows painted inside each box.
Properties panel calls get_full_slots_for_type() (schema.py:292) at properties.py:512. This shows everything except nameless slots — config slots need to be editable somewhere, and this is that somewhere. It returns copies (dict(slot)) so the panel can annotate them without corrupting the registry.
Wire creation calls get_all_slots_for_type() (schema.py:281) at main.py:1133. This returns (name, type, flags, readonly) tuples for both sides of a potential link, used to match compatible slot types.
Recompilation Safety
Every recompilation path follows the same discipline:
- Invalidate palette (
palette.invalidate()) — soresolve_manifest_path()picks up the new checksum pins - Call
load_schema_kits_and_manifests()— which clearsactive_schema_kits, re-reads the DOM, and callscompile_schema_registry() - Handle failure — the caller decides the policy:
- File open: abort
apply_kit_selections(): rollback the DOM and return the error- Undo/redo (
reload_schema_registry()at undo.py:84): swallow the error — the state was valid once, so a failure means Sedona home changed underneath the session, and the next user action will report it
The compile_schema_registry() call itself clears final_components_registry before populating it (schema.py:183), so there's never stale data mixed with fresh data — it's always a complete rebuild, not a merge.
The Full Data Flow
<schema> block pending_schema_kits
↓ ↓
load_schema_kits_and_manifests()
↓ merge
kits_to_load (name → checksum)
↓ find_manifest_path() per kit
manifest_paths[]
↓
compile_schema_registry()
↓ ET.parse() each manifest
kit_schemas {"kit::Type" → <type> element}
↓ get_slots_with_inheritance() per type
↓ (recursive base-chain walk)
flat_slots[] [inherited..., own...]
↓ re-index with sequence slot_id
final_components_registry {"kit::Type" → [{slot_id, name, type, ...}]}
↓ ↓ ↓
get_slots_for_type() get_full_slots_for_type() get_all_slots_for_type()
↓ ↓ ↓
wiresheet boxes properties panel wire creation
The key architectural insight: the registry is a compiled cache, not a persistent store. It's always derivable from the schema block + pending list + Sedona home manifests, and it's rebuilt from scratch on every change. This makes it safe to clear and recompile freely — there's no migration, no partial update, no versioning. The cost is parsing a few dozen XML files, which is negligible compared to the UI work that follows.
Trace 9 — File Save Pipeline
The save pipeline crosses 4 communities: the main controller (main.py), the SAX file layer (sax_file.py), the kit reconciliation system (sax_file.py + schema.py), and the undo/dirty tracking layer (undo.py + app_state.py). There are three save entry points that converge on the same core sequence, plus a live-mode variant that bypasses most of it.
Entry Points
Ctrl+S binds to do_save() (main.py:430), which forks immediately:
def do_save(event=None):
if app_state.live_mode:
do_download_to_sax()
return "break"
sax_file.execute_direct_overwrite_save()
return "break"
In offline mode, it calls execute_direct_overwrite_save(). In live mode, it calls do_download_to_sax() — a simpler path that prompts for a filename (since there's no existing file) and writes without kit reconciliation.
Save As is wired directly from the menu to sax_file.save_file_as() (sax_file.py:285). It optionally stamps the filename with a timestamp (controlled by app_state.ADD_TIMESTAMP), then runs the same core pipeline as the direct save.
Live download (do_download_to_sax() at main.py:438) writes the current device-sourced DOM to a user-chosen path via write_sax_to_path() — no kit reconciliation, no undo tracking, no dirty state. It's a snapshot export, not a document save.
The Core Pipeline
Both execute_direct_overwrite_save() and save_file_as() follow the same 5-phase sequence:
Phase 1 — Metadata Sync
sync_live_metadata_to_dom() (sax_file.py:202) reconciles the canvas state back into the XML DOM. This is necessary because component positions (the packed meta property) may have been updated only in the in-memory element references held by canvas_comp_map, not yet written back to the DOM tree that the file parser originally produced.
It does two things in one recursive walk (sync_and_prune_tree):
-
Prunes dead components: for every
<comp>in the DOM, it checks whether the component is still "alive" inactive_components_list. If a component was rendered on the canvas at some point (is_previously_rendered) but is no longer alive, it's removed from the DOM. This catches components that were deleted through undo/redo cycles where the DOM element was restored but the canvas state wasn't — a safety net, not the primary deletion mechanism. -
Syncs meta values: for components that are alive, it copies the
metaproperty value from the canvas-tracked element into the DOM element. The meta property packs the component's grid position (x, y) and group flags into a single 32-bit integer (sax_dom.py:57), so a component that was dragged on the wiresheet gets its new position written back here.
This sync is called at the start of every save path, including write_sax_to_path() for live downloads. It's the bridge between the "live editing state" (canvas positions, active component list) and the "document state" (the XML tree).
Phase 2 — Kit Reconciliation Gate
reconcile_schema_kits_before_save() (sax_file.py:728) checks whether any components were placed from kits the schema doesn't declare. This was traced in detail in Trace 7, but its role in the save pipeline is as a gate: it returns True to proceed or False to abort.
if not reconcile_schema_kits_before_save():
return # user cancelled — save is abandoned
If kits need adding, the user sees a confirmation dialog listing the new kits and the full post-save schema. If they cancel, the schema block is rolled back to its pre-save state and the save is abandoned — no file is written, no dirty state changes. This is the only point in the save pipeline where the user can back out.
Phase 3 — XML Serialization
The actual write is two lines:
with open(path, "wb") as f:
f.write(b'<?xml version="1.0" encoding="UTF-8"?>\n')
ET.ElementTree(app_state.xml_root_element).write(
f, encoding="utf-8", xml_declaration=False
)
The XML declaration is written manually because Python's ElementTree.write() with xml_declaration=True produces <?xml version='1.0' encoding='utf-8'?> (single quotes, lowercase encoding) — a harmless difference to a parser but a noisy diff to a human comparing files. The manual line uses double quotes and uppercase UTF-8, matching the Sedona convention.
The file is opened in binary mode ("wb") and encoding="utf-8" is passed to ET.write(), which handles the encoding internally. The xml_declaration=False suppresses the auto-generated one since the manual one already covers it.
There is no temporary file or atomic rename. The write goes directly to the target path. If the process crashes mid-write, the file is truncated. This is a conscious simplicity trade-off — .sax files are small (typically a few KB), the write is fast, and the editor's target environment (industrial automation workstations) values predictable paths over crash resilience.
Phase 4 — State Cleanup
After a successful write:
app_state.mark_clean()
undo.mark_saved()
mark_clean() (app_state.py:201) clears is_dirty, tints the canvas background back to CLEAN_TINT (from the yellow-ish DIRTY_TINT that signals unsaved changes), and calls refresh_dirty_display() which updates the window title (removing any unsaved marker).
mark_saved() (undo.py:70) pins the current undo stack depth:
def mark_saved():
global _saved_depth
_saved_depth = len(_undo_stack)
This is how undo knows whether the document matches what's on disk. Every _refresh() call after an undo or redo compares len(_undo_stack) against _saved_depth — when they diverge, the document is dirty; when they match again (after enough undos to reach the saved state), the document transitions back to clean. This depth tracking is what makes "undo back to the saved state" automatically clear the dirty indicator without re-reading the file.
Phase 5 — UI Refresh
refresh_treeview_from_dom(app_state.xml_root_element)
refresh_treeview_from_dom() (sax_file.py:53) clears the navigation tree and rebuilds it from the DOM. This picks up any structural changes the kit reconciliation made (new <kit> entries in the schema). It also ensures the tree reflects the pruning that sync_live_metadata_to_dom() did — if dead components were removed from the DOM, they disappear from the tree here.
Finally, both save paths show a success dialog and post a status bar message.
The Three Paths Compared
| Step | execute_direct_overwrite_save |
save_file_as |
do_download_to_sax |
|---|---|---|---|
| Metadata sync | yes | yes | yes (via write_sax_to_path) |
| Kit reconciliation | yes (gate) | yes (gate) | no |
| Path selection | uses current_file_path |
prompts with optional timestamp | always prompts |
| XML write | to existing path | to chosen path | to chosen path |
| mark_clean | yes | yes | no (not a "document" save) |
| mark_saved (undo) | yes | yes | no |
| Tree refresh | yes | yes | no |
| Success dialog | yes | yes | status bar only |
The Live Mode Variant
write_sax_to_path() (sax_file.py:273) is the minimal path: sync metadata, write XML, done. No kit reconciliation because the device's schema is the authority in live mode — there's nothing to reconcile. No dirty/undo tracking because live mode doesn't use the undo system (hardware writes can't be reversed). It's a pure export: "write what the device looks like right now."
Failure Modes
The pipeline has exactly two abort points:
-
Kit reconciliation cancelled — the user sees what kits would be added and says no. The schema block is rolled back, nothing is written, no state changes. The document remains dirty.
-
Write exception — the
try/exceptaround the whole pipeline catches any I/O error (permissions, full disk, locked file) and shows it in an error dialog. Because the dirty/clean state hasn't been changed yet (those lines come after the write), the document correctly remains marked as unsaved.
There is no abort for "the DOM is inconsistent" or "the metadata sync failed" — those operations manipulate in-memory data structures and are trusted to succeed. If they corrupt the DOM (a bug, not a runtime error), the corrupted XML is written to disk. The next open would either fail to parse or load with wrong positions, but the editor doesn't validate the DOM against any schema before writing.
The Full Sequence
Ctrl+S or File → Save
↓
do_save() ──live?──→ do_download_to_sax()
↓ offline ↓
execute_direct_overwrite_save() write_sax_to_path(path)
↓ ↓
sync_live_metadata_to_dom() sync_live_metadata_to_dom()
↓ ↓
├── prune dead comps open(path, "wb")
└── copy canvas meta→DOM ↓
↓ ET.write() → done
reconcile_schema_kits_before_save()
↓ user cancels? → rollback, return
↓ user confirms
open(path, "wb")
↓
b'<?xml version="1.0"...?>\n' + ET.write()
↓
mark_clean() → canvas bg → CLEAN_TINT
mark_saved() → _saved_depth = len(_undo_stack)
↓
refresh_treeview_from_dom()
↓
status bar + success dialog
The key design insight: the save pipeline is not just serialization. It's the reconciliation point where deferred state (pending kits, canvas positions, pruned components) gets committed to the DOM before writing. The file on disk is always a complete, self-consistent snapshot — the in-flight editing state that lives in canvas_comp_map, pending_schema_kits, and active_components_list never leaks into the file partially.
Trace 10 — File Open Pipeline
The file open pipeline crosses 5 communities: the file layer (sax_file.py), the schema registry (schema.py), the palette (palette.py), the wiresheet renderer (wiresheet.py), and the main controller (main.py). It transforms a .sax file on disk into a fully interactive editing session in seven phases.
Entry Point
open_file() (sax_file.py:126) is the sole entry point, wired to the File menu. It gates on two preconditions before anything happens:
Gate 1 — Sedona home (sax_file.py:127): if SEDONA_HOME isn't configured, a warning fires and the function returns. Without it, no manifests can be found, so no types can be resolved, and the file would be useless.
Gate 2 — Live mode (sax_file.py:131): if a device connection is active, the user is asked whether to disconnect. This requires a deferred import of do_device_disconnect from main.py to break a circular dependency — sax_file imports main at call time, not at module load time.
Phase 1 — Teardown
Before the new file loads, the old session is dismantled completely (sax_file.py:154–170):
app_state.current_file_path = file_path
app_state.active_components_list.clear()
app_state.pending_schema_kits.clear()
app_state.selected_canvas_tag = None
app_state.current_sheet_parent = None
undo.reset()
app_state.mark_clean()
app_state.tree_element_map.clear()
app_state.canvas_comp_map.clear()
app_state.canvas_tag_by_element.clear()
for item in tree.get_children():
tree.delete(item)
canvas.delete("all")
wiresheet.draw_grid()
This clears seven categories of state: selection state, component tracking maps, the undo history, the dirty flag, the treeview contents, and the canvas. draw_grid() puts the background grid lines back on the now-empty canvas so the user sees a blank sheet rather than a blank white rectangle.
The file path is set before parsing, not after. This means a failed parse leaves current_file_path pointing at the file that couldn't be opened — which reset_to_startup_state() cleans up in the exception handler.
Phase 2 — XML Parsing
xml_tree = ET.parse(file_path)
app_state.xml_root_element = xml_tree.getroot()
Two lines. ElementTree.parse() reads the file, validates well-formedness, and builds the in-memory DOM. The root element (<sedonaApp> typically) becomes the single source of truth for the entire session — every component, link, and property is a live reference into this tree. No copy is made; edits throughout the session mutate this same tree, and saving writes it back.
If the file is malformed XML, ET.parse() raises ParseError, caught by the outer handler which calls reset_to_startup_state().
Phase 3 — Schema Registry Compilation
schema.load_schema_kits_and_manifests()
This is the critical step (traced in detail in Trace 8). It reads the <schema> block's <kit> entries, resolves each kit to a manifest file under SEDONA_HOME/manifests/, and compiles the full type registry into final_components_registry. Every kit::Type string in the file becomes a list of resolved slots — inherited, sorted, with facets.
This is the one step that can reject a file after parsing succeeds. If any declared kit has no manifest on this machine, MissingManifestError is raised. The handler at sax_file.py:189 shows all missing kit names in one dialog, calls reset_to_startup_state(), and the file is not opened. The error message points the user at the Sedona home folder — the fix is to install the missing manifests there, not to edit the file.
Phase 4 — Navigation Tree Construction
file_name = os.path.basename(file_path)
root_node = tree.insert("", "end", text=file_name, open=True)
app_element = app_state.xml_root_element.find("app")
if app_element is not None:
parse_xml_to_tree(tree, root_node, app_element)
else:
parse_xml_to_tree(tree, root_node, app_state.xml_root_element)
The tree is rooted at the filename, with the <app> element as the real starting point. parse_xml_to_tree() (sax_file.py:34) recursively walks the DOM, inserting a Treeview row for every <comp> element. Each row is mapped in tree_element_map[treeview_id] → xml_element, which is how clicking a tree node later resolves to a DOM element.
The display text is "name [id: N]" when the component has an ID, or just "name" otherwise. Components that are folders (containing child components) automatically become expandable nodes because their children are recursively inserted.
Phase 5 — Post-Tree Callback
if _tree_filled is not None:
_tree_filled()
This calls on_tree_filled() (main.py:1699), registered at startup via set_tree_filled_callback(). It's a decoupling seam — sax_file.py doesn't import main.py at module level (that would be circular), so it calls through a function pointer instead.
on_tree_filled() does five things:
-
Closes stale editor panels (main.py:1710): if a properties panel or link panel is showing a component that no longer exists in the DOM, it's force-closed. This matters more for rebuilds after undo/delete than for fresh opens, but the same path runs in both cases.
-
Refreshes dirty display (main.py:1715): applies or removes the red-name tag on the tree's root row. On a fresh open this clears it (the file was just loaded, so it's clean).
-
Fits tree column (main.py:1716): auto-sizes the tree column width to fit the longest visible node text, so component names aren't truncated.
-
Refreshes the palette (main.py:1717):
refresh_palette_kits()(main.py:2502) invalidates the palette cache, readsactive_schema_kits(populated in Phase 3), and rebuilds the kit dropdown in the palette panel. Only kits declared in the file's schema appear — the palette shows what this app uses, not everything installed. -
Detects incomplete checksums (main.py:1719): if the file is newly opened (
current_file_pathchanged) and any declared kit is missing a checksum, the Manage Kits dialog opens automatically viashow_kits(). This nudges the user to pin versions rather than leaving the schema ambiguous. If checksums are incomplete, the palette is left empty (palette_kit_names = []) to prevent placing components from an ambiguous build.
Phase 6 — First Wiresheet Render (Deferred)
The open pipeline itself does not render the wiresheet. The wiresheet is painted when the user clicks a tree node, which fires on_tree_select() (main.py:621). On a fresh open, this happens in one of two ways:
- The tree auto-selects the root node after insertion, generating a
<<TreeviewSelect>>event - The user clicks a component node in the tree
Either way, on_tree_select() reads the selected element from tree_element_map, sets current_sheet_parent, and calls wiresheet.render_components() (wiresheet.py:941) with the element's children.
render_components() does the actual painting:
- Clears the canvas of component and link tags (the grid stays)
- Builds linked-slot index — which slots have wires, so they're visible even if they'd normally be hidden
- Calculates meta scale — components positioned beyond the normal 0–255 meta range get a coarser grid step
- For each component: reads the
metaproperty, unpacks the packed x/y coordinates, callsmeasure_box()to compute dimensions from slot count and text widths, then draws the box with header, type label, and slot rows - Draws links (
draw_links()) — the wire routing system traced in Trace 5, connecting slot endpoints across boxes
Phase 7 — View History Reset
Inside on_tree_filled(), the view history is reset (main.py:1719–1721) when the file path changes:
if app_state.current_file_path != _history_file_path:
_history_file_path = app_state.current_file_path
reset_view_history()
reset_view_history() (main.py:1587) clears the back/forward stack and disables the navigation buttons. The old file's navigation trail is discarded — you can't go "back" to a sheet in a file that's no longer loaded.
Error Recovery
The try/except wrapping Phases 1–5 catches two failure modes:
MissingManifestError (sax_file.py:189): Phase 3 failed — the file parsed fine but needs kits this machine doesn't have. The error dialog names every missing kit and points at the Sedona home folder. reset_to_startup_state() unwinds everything back to a clean "no file open" state.
General Exception (sax_file.py:197): anything else — malformed XML, I/O error, an unexpected crash in tree construction. Same recovery: show the error, reset to startup state.
reset_to_startup_state() (sax_file.py:82) is thorough: it clears current_file_path, xml_root_element, every selection, every map, the undo stack, the dirty flag, the tree, and the canvas. The app returns to the exact state it was in before any file was opened — a blank slate.
The Full Sequence
File → Open
↓
open_file()
↓
Gate: SEDONA_HOME set? ──no──→ warning, return
↓ yes
Gate: live mode? ──yes──→ ask disconnect ──no──→ return
↓ no ↓ yes
do_device_disconnect()
↓
filedialog.askopenfilename() ──cancelled──→ return
↓ path chosen
┌─ try ──────────────────────────────────────────────┐
│ │
│ Phase 1: Teardown │
│ clear all maps, selections, undo, dirty flag │
│ delete tree rows, clear canvas, redraw grid │
│ │
│ Phase 2: XML Parse │
│ ET.parse(path) → xml_root_element │
│ │
│ Phase 3: Schema Registry │
│ load_schema_kits_and_manifests() │
│ → read <kit> entries from <schema> │
│ → find_manifest_path() per kit │
│ → compile_schema_registry() │
│ → parse manifests, resolve inheritance │
│ → populate final_components_registry │
│ │
│ Phase 4: Navigation Tree │
│ insert root node (filename) │
│ parse_xml_to_tree() recursively │
│ → one row per <comp>, mapped in tree_element_map│
│ │
│ Phase 5: Post-Tree Callback │
│ on_tree_filled() │
│ → close stale panels │
│ → refresh dirty display (clean) │
│ → fit tree column widths │
│ → refresh palette (kit dropdown) │
│ → auto-open Manage Kits if checksums missing │
│ → reset view history │
│ │
└─────────────────────────────────────────────────────┘
↓ exception?
├── MissingManifestError → dialog naming missing kits
└── Exception → dialog with error message
↓ either
reset_to_startup_state() (back to blank)
↓ success (deferred)
<<TreeviewSelect>> event
↓
on_tree_select()
↓
Phase 6: Wiresheet Render
render_components()
→ measure boxes (from registry slots)
→ draw boxes at meta-decoded positions
→ route and draw wires (draw_links)
Phase 7: View History
reset_view_history() (new file, fresh trail)
Key Design Insight
The open pipeline is two-phase by design: the synchronous part (Phases 1–5) builds the data structures, and the wiresheet render (Phase 6) is deferred to the tree selection event. This means the file is "open" — the tree is populated, the registry is compiled, the palette is loaded — before any boxes are drawn. A file with 200 components across 15 folders doesn't pay the cost of rendering all of them on open; it renders only the sheet the user clicks into.
The separation also means the wiresheet renderer never needs to know whether it's painting for a fresh open, an undo, a delete, or a navigation — it always gets a list of component elements and paints them. The caller decides which elements to hand it.
Trace 11 — Live Device Connection Pipeline
The live connection pipeline crosses 6 communities: the connect dialog (connect_dialog.py), the SOX protocol client (sox.py), the DASP transport layer (also sox.py), the device session bridge (device_session.py), the schema registry (schema.py), and the main controller (main.py). It replaces the file-backed DOM with one synthesized from a live Sedona device, then keeps it updated through COV polling.
Entry Points
Two menu items funnel through the same connection callback:
Device > Connect calls do_device_connect() (main.py:1999), which opens ConnectDialog — a manual host/port/user/password form.
Device > Discover calls do_device_discover() (main.py:2020), which opens DiscoverDialog — a DASP broadcast scan that lists responding devices, then prompts for credentials when one is selected.
Both pass _connect_to_device as the callback.
Phase 0 — Teardown of Offline Session
Before the dialog opens, both entry points handle the current file (main.py:2001–2015):
if app_state.current_file_path and not app_state.live_mode:
if app_state.is_dirty:
answer = messagebox.askyesnocancel(...)
if answer is None: return
if answer: sax_file.execute_direct_overwrite_save()
sax_file.reset_to_startup_state()
sax_file.refresh_treeview_from_dom(None)
wiresheet.render_components([])
If a file is open with unsaved changes, the user gets a Save/Don't Save/Cancel choice. Then the entire offline session is torn down — the same reset_to_startup_state() that file-open uses — before the dialog appears. The editor goes to its blank startup state.
Phase 1 — Dialog and Background Thread
ConnectDialog.__init__() (connect_dialog.py:24) builds a modal dialog with host, port, username, and password fields. It pre-fills from the last successful connection (persisted in sedona_editor.ini via _load_last_connection()).
When the user clicks Connect, _do_connect() (connect_dialog.py:87) validates inputs and spawns a daemon background thread:
self._thread = threading.Thread(
target=self._connect_worker,
args=(host, port_int, user, password),
daemon=True
)
self._thread.start()
self._poll_connect()
The connection must happen off the main thread because DASP handshaking involves UDP round-trips that can take seconds. The dialog polls every 100ms via _poll_connect() (connect_dialog.py:129) using Tkinter's .after(), checking whether the thread finished.
_connect_worker() (connect_dialog.py:118) calls the callback — _connect_to_device() — and stores the result as ("ok", result, ...) or ("error", msg, ...). If the user cancels during the connection, _do_cancel() (connect_dialog.py:146) sets _cancelled = True and spawns a cleanup thread that joins the worker and disconnects if a session was already established.
Phase 2 — DASP + SOX Connection
_connect_to_device() (main.py:2041) creates a DeviceSession and calls ds.connect() (device_session.py:44), which does five things in sequence on the background thread:
Step 2a — DASP handshake: SoxClient.__init__() stores credentials. SoxClient.connect() (sox.py:375) creates a DaspSocket, calls self._dasp_socket.connect(host, port, user, password) which performs the DASP (Datagram Authenticated Session Protocol) challenge-response over UDP: HELLO → CHALLENGE → AUTHENTICATE → WELCOME. This establishes an encrypted authenticated session.
Step 2b — Receiver thread: _SoxReceiver (sox.py:960) is started — a daemon thread that loops calling DaspSession.receive(), dispatching incoming datagrams as either responses (correlated to pending requests) or events (COV notifications). This thread runs for the lifetime of the connection.
Step 2c — Read schema: self.sox_client.read_schema() (sox.py:408) sends the SOX 'v' command and parses the 'V' response into a list of KitPart(name, checksum) — the kits installed in the device's scode. This also calls read_version() to get the platform ID, scode flags, and kit version strings.
Step 2d — Build slot registry: _load_slot_registry(parts) (device_session.py:87) builds a per-device slot registry that maps (kit_id, type_id) to slot lists. This is separate from final_components_registry — it's the SOX-level slot map used for protocol operations (subscribe, read, write). For each kit part:
- Find the manifest on disk via
schema.find_manifest(kit_name, checksum) - If the kit is
sys, extractComponent's base slots (meta, status, etc.) - Register each type's slots keyed by
(kit_id, type_id) - Map
(kit_id, type_id) → "kit::TypeName"intype_name_map - Update any already-cached components'
type_name
After all kits, base slots from sys::Component are prepended to every type and slot IDs are renumbered to match the inherited-chain order. This renumbering is critical — SOX links use absolute slot IDs across the full inheritance chain, not the per-type local IDs the manifest declares.
Step 2e — Load root component: self.sox_client.load(0) (sox.py:458) reads component 0 (the app root) from the device. This sends the SOX 't' (tree read) command for comp_id 0. The response is parsed by _apply_to_cache(), which creates a SoxComponent in the 1024-slot cache array with the component's name, kit_id, type_id, parent, children list, and subscription state.
Then load_children(root_comp.id) loads all direct children of the root, and update(children, CONFIG | RUNTIME) reads their config and runtime slot values. The device session returns root_comp to the caller.
Phase 3 — Thread-to-UI Crossing
Back in _connect_to_device():
app_state.device_session = ds
app_state.live_mode = True
app_state.connection_status = "connected"
app_state.window.after(0, lambda: _on_connected(ds, root_comp))
The state flags are set (these are safe from a background thread — they're simple Python assignments). Then window.after(0, ...) schedules _on_connected() on the main Tkinter thread. This is the thread-crossing point — everything from here runs on the main thread where Tk operations are safe.
Phase 4 — DOM Synthesis
_on_connected() (main.py:2056) runs on the main thread:
Step 4a — Window title: switches to the live-mode title showing host:port.
Step 4b — Menu state: disables Connect/Discover, enables Disconnect and Download to SAX.
Step 4c — Build DOM tree: ds.build_dom_tree(root_comp) (device_session.py:272) synthesizes an xml.etree.ElementTree from the SOX cache — the same data structure a .sax file parse would produce:
sax_root = ET.Element("sedonaApp")
schema_elem = ET.SubElement(sax_root, "schema")
# add <kit> entries from schema_parts
app_elem = ET.SubElement(sax_root, "app")
# recursively build <comp> elements from cache
_build_comp_element() (device_session.py:298) creates each <comp> with name, id, type attributes and <prop> sub-elements for every non-action slot that has a cached value. Float values are formatted to the precision declared in the slot's facets. Children are built recursively; uncached children get placeholder <comp name="..."> elements so the tree shows an expand arrow.
Step 4d — Build links: ds.build_links_elements(sax_root) (device_session.py:334) scans every cached component's links and creates <link from="..." to="..."> elements under a <links> container. Link endpoints are resolved from SOX (comp_id, slot_id) pairs to "/path/to/comp.slotName" strings — the same format .sax files use. Duplicate links are deduplicated via a seen set.
Slot name resolution (_slot_name_by_id at device_session.py:369) prefers final_components_registry (which has correctly resolved absolute slot IDs from inheritance) over the per-device slot_registry (which has local IDs that are wrong for inherited types). This two-tier fallback keeps things working even before the schema registry is fully compiled.
app_state.xml_root_element = sax_root — from this point on, the editor's DOM is the synthesized tree, indistinguishable to the rest of the codebase from one loaded from a file.
Step 4e — Fake file path: app_state.current_file_path = f"device://{host}" — a synthetic path so the window title and tree root display something meaningful, and so current_file_path being non-None signals "a session is active."
Phase 5 — Schema Registry Compilation (Live Variant)
_load_live_schema(ds) (main.py:2077) compiles the editor-side schema registry from the device's kit info. Unlike the file-open path that calls load_schema_kits_and_manifests(), this path:
- Populates
active_schema_kitsdirectly fromschema_parts, converting integer checksums to hex strings - Collects manifest paths itself, tolerating missing kits (status bar warning, not an error)
- Calls
compile_schema_registry()directly with whatever manifests were found
This tolerance is the key difference from file-open. A device might run kits the local machine doesn't have manifests for (a custom industrial kit, for example). File-open refuses — the editor can't describe types it can't resolve. Live mode proceeds gracefully — the wiresheet shows boxes for components whose types resolve, and any that don't resolve simply have no slots painted.
Phase 6 — Tree and Wiresheet
sax_file.refresh_treeview_from_dom(sax_root)
The standard tree rebuild from Trace 10's Phase 4. Since the synthesized DOM has the same structure as a parsed .sax, the same parse_xml_to_tree() recursion populates the navigation tree identically.
This triggers on_tree_filled(), which refreshes the palette, resets view history, and auto-opens Manage Kits if any kit lacks a checksum. The wiresheet renders when the user clicks a tree node, exactly as in the file-open path.
Phase 7 — COV Polling Startup
undo.reset()
app_state.mark_clean()
_start_cov_polling()
Undo is reset (live mode doesn't use undo — hardware writes can't be reversed) and the document is marked clean.
_start_cov_polling() (main.py:2136) starts the 100ms Tkinter .after() loop traced in Trace 3:
def poll():
ds = app_state.device_session
if ds is None or not ds.is_connected:
do_device_disconnect()
return
events = ds.poll_events()
if events:
for comp_id, what_char in events:
ds.update_element_from_cache(comp_id, app_state.xml_root_element)
wiresheet.render_current_sheet()
properties_panel.draw() # if open
_cov_poll_id = app_state.window.after(100, poll)
This is the heartbeat of live mode: every 100ms, drain the thread-safe event queue (fed by _SoxReceiver), copy updated slot values from the SOX cache into the synthesized DOM, and repaint. Connection loss is detected when ds.is_connected returns False, triggering automatic disconnect.
Disconnect
do_device_disconnect() (main.py:2106) is the inverse:
- Cancel the COV poll timer
ds.disconnect()— unsubscribes all components, closes the DASP session, kills the receiver thread, clears the cache- Clear all live-mode state flags
- Reset window title and menu states
reset_to_startup_state()— back to blank editor
The Full Sequence
Device > Connect (or Discover)
↓
Phase 0: Teardown offline session
save if dirty → reset_to_startup_state()
↓
ConnectDialog / DiscoverDialog
↓ user clicks Connect
Phase 1: Background thread
↓
Phase 2: _connect_to_device() (background thread)
↓
2a. DaspSocket.connect() → UDP HELLO/CHALLENGE/AUTH/WELCOME
2b. _SoxReceiver.start() → daemon thread for incoming packets
2c. read_schema() → SOX 'v' → kit names + checksums
2d. _load_slot_registry() → parse manifests → slot_registry
2e. load(0) + load_children → SOX 't'/'c'/'r' → cache populated
↓
Phase 3: Thread crossing
app_state.live_mode = True
window.after(0, _on_connected)
↓
Phase 4: _on_connected() (main thread)
↓
4a. Window title → "Live Editor — host:port"
4b. Menu states → disable Connect, enable Disconnect
4c. build_dom_tree() → SoxCache → ET.Element tree
4d. build_links_elements() → link records → <link> elements
4e. xml_root_element = sax_root
↓
Phase 5: _load_live_schema()
active_schema_kits from schema_parts
compile_schema_registry() (tolerates missing kits)
↓
Phase 6: refresh_treeview_from_dom()
parse_xml_to_tree() → navigation tree
on_tree_filled() → palette, history, dirty display
↓
Phase 7: _start_cov_polling()
100ms .after() loop: queue → DOM → repaint
The Two Registries
The live connection maintains two parallel slot registries, and understanding why is key to understanding the whole pipeline:
slot_registry |
final_components_registry |
|
|---|---|---|
| Location | sox_client.slot_registry |
app_state.final_components_registry |
| Keyed by | (kit_id, type_id) |
"kit::TypeName" string |
| Slot IDs | Local per-type (from manifest) | Absolute across inheritance chain |
| Built by | _load_slot_registry() |
compile_schema_registry() |
| Used for | SOX protocol operations (subscribe, write_prop, invoke) | UI rendering (wiresheet boxes, properties panel, wire endpoints) |
| Inheritance | Base slots prepended, IDs renumbered | Recursive get_slots_with_inheritance() |
The SOX protocol addresses slots by (comp_id, slot_id) where slot_id is the position in the flattened inheritance chain. The UI addresses slots by name within a kit::Type. Both registries resolve the same types from the same manifests, but structure them for their respective consumers. _slot_name_by_id() is the bridge — it translates protocol-level (kit_id, type_id, slot_id) into a slot name using whichever registry is available, preferring final_components_registry for its correct absolute IDs.
Trace 12 — DASP Transport Layer
DASP (Datagram Authenticated Session Protocol) is the entire bottom of the stack — everything beneath SOX. It provides reliable, authenticated, full-duplex communication over UDP, ported directly from the Sedona Java sources. The implementation lives entirely in dasp.py (1034 lines) with three classes: DaspMsg (wire format), DaspSession (session state machine), and DaspSocket (multiplexer and I/O threads).
Architecture Overview
┌──────────────────────────────────────────────────┐
│ SOX (sox.py) │
│ SoxClient.request() / .requests() │
│ _SoxReceiver thread │
├──────────────────────────────────────────────────┤
│ DASP Session (dasp.py : DaspSession) │
│ send() / receive() │
│ handshake, keepalive, reliable delivery │
│ SendWindow / ReceiveWindow │
├──────────────────────────────────────────────────┤
│ DASP Socket (dasp.py : DaspSocket) │
│ _receiver_loop thread (UDP recv → dispatch) │
│ _housekeeping_loop thread (100ms tick) │
│ _send_msg (encode → UDP send) │
├──────────────────────────────────────────────────┤
│ UDP (socket.SOCK_DGRAM) │
└──────────────────────────────────────────────────┘
There are four threads in a live connection:
- Main thread — Tkinter UI + COV poll
- DASP Receiver —
DaspSocket._receiver_loop— UDP recv, decode, dispatch - DASP Housekeeping —
DaspSocket._housekeeping_loop— 100ms tick for keepalive/retries/timeouts - SOX Receiver —
_SoxReceiver.run()— pulls fromDaspSession._receive_queue, dispatches events vs responses
Layer 1 — Wire Format (DaspMsg)
DaspMsg (dasp.py:123) is the binary codec. Every DASP datagram has this structure:
Bytes 0-1: session_id (u16)
Bytes 2-3: seq_num (u16)
Byte 4: msg_type (high nibble) | num_fields (low nibble)
Bytes 5+: header fields, then payload
There are 8 message types: DISCOVER (0), HELLO (1), CHALLENGE (2), AUTHENTICATE (3), WELCOME (4), KEEPALIVE (5), DATAGRAM (6), CLOSE (7). Only DATAGRAM carries application-layer (SOX) payload; the rest are control messages.
Header fields are self-describing TLV entries. Each field has a 1-byte ID whose low 2 bits encode the type: NIL (0), U2 (1, 2-byte unsigned), STR (2, null-terminated), BYTES (3, length-prefixed). The decoder walks fields sequentially, matching IDs to named attributes:
if fid == HDR_VERSION: self.version = u2_val
elif fid == HDR_REMOTE_ID: self.remote_id = u2_val
elif fid == HDR_NONCE: self.nonce = bytes_val
elif fid == HDR_DIGEST: self.digest = bytes_val
...
The encoder backpatches two things: byte 4 (msg_type | num_fields, counted during encoding) and HDR_MESSAGE_LEN (the total datagram length, an Ontrol extension). The include_message_len flag controls whether this Ontrol extension is emitted — the Sedona spec doesn't have it, but Ontrol devices expect it.
Layer 2 — The Handshake
DaspSession.connect() (dasp.py:636) runs the 3-step handshake:
Step 1 — HELLO (dasp.py:657):
msg.msg_type = HELLO
msg.session_id = 0xFFFF # broadcast — no session yet
msg.remote_id = self.id # "this is my local session ID"
msg.version = 0x0100 # protocol version 1.0
msg.ideal_max = 512 # preferred max datagram size
msg.abs_max = 512 # absolute max datagram size
msg.receive_max = 31 # max outstanding unacked msgs
msg.receive_timeout = 30000 # ms before declaring timeout
Sent up to 3 times with connect_timeout / 3 between attempts. If no response comes, "No response from hello" is raised. The response is either CHALLENGE (proceed to auth), WELCOME (no auth required), or CLOSE (rejected).
Step 2 — AUTHENTICATE (dasp.py:679):
The device sends a CHALLENGE containing a nonce (random bytes) and a digest_algorithm ("SHA-1" or "SHA-256"). Authentication is a two-layer hash:
# Inner: always SHA-1, regardless of negotiated algorithm
cred = SHA1("user:password")
# Outer: uses negotiated algorithm
digest = algo(cred + nonce)
The inner hash is always SHA-1 — this is the Sedona credential format, not a negotiation result. The outer hash uses whatever the device requested. The AUTHENTICATE message carries the username and the digest bytes. Again sent up to 3 times, dropping duplicate CHALLENGEs (which arrive when multiple HELLOs crossed in flight).
Step 3 — WELCOME (or CLOSE):
A WELCOME response means the device accepted the credentials. _tune() (dasp.py:714) negotiates session parameters by taking the minimum of local and remote values for ideal_max and abs_max, the maximum for receive_timeout, and initializes the receive window from the WELCOME's seq_num. The send window size is set to min(remote_receive_max, ack_more_byte_limit).
Layer 3 — Reliable Delivery
DASP provides TCP-like reliability over UDP through two sliding window mechanisms:
SendWindow (dasp.py:422)
A linked list of _Packet objects, each tracking:
seq_num— 16-bit wrapping sequence numberpayload— the raw bytesacked— whether the remote has acknowledged this packetsent_time,send_attempts— for retry timingenqueued_time— for total-timeout detection
send() (dasp.py:460) assigns the next sequence number, enqueues the packet, and transmits immediately. If the window is full (_size >= send_size, typically 7–8 packets), _enqueue() blocks on a Condition until the remote acks something. This is the backpressure mechanism — a fast sender can't overwhelm a slow device.
check_ack_headers() (dasp.py:505) processes incoming ACKs. DASP uses cumulative ACK + selective bitmask:
msg.ack— the highest sequence number received in order (cumulative)msg.ack_more— a bitmask of additional packets received out of order
The _is_acked() method (dasp.py:546) checks both: a packet is acked if its seq_num is below the cumulative ACK or its bit is set in ack_more. Acked packets are dequeued from the head of the linked list.
Retransmission has two triggers:
-
Timer-based (
send_retries()at dasp.py:565): called every 100ms by housekeeping. Each unacked packet's retry interval uses exponential backoff:send_retry * 2^(attempts-1), capped atmax_retry_backoff(8 seconds). Default base retry is 1 second. -
Fast retransmit (
_track_dup_acks()at dasp.py:533): if the same ACK number arrives twice (2 duplicate ACKs), the head-of-line unacked packet is immediately retransmitted — classic TCP-style fast recovery without waiting for the timer.
ReceiveWindow (dasp.py:318)
Manages incoming sequence numbers. receive() (dasp.py:354) accepts a seq_num and decides whether it's in the valid window of 31 packets:
- Next in sequence (
num == start + 1): slides the window forward. If the next packet after that was already received out of order (bit set in_seq_more), the window keeps sliding until it hits a gap — this is how out-of-order arrivals are coalesced. - Within window but out of order: sets the corresponding bit in
_seq_more. - Outside window (duplicate or too far ahead): rejected, returns
False.
set_ack_headers() (dasp.py:342) piggybacks ACK information on outgoing messages: it sets msg.ack to the current window edge and msg.ack_more to the selective bitmask, only when there's room in the datagram (checked against ideal_max). This piggyback is why keepalives carry ack information too — they're an opportunity to acknowledge data even when no application response is pending.
Layer 4 — DaspSocket (Multiplexer)
DaspSocket (dasp.py:854) owns the UDP socket and multiplexes sessions on it:
Construction (dasp.py:860): creates a UDP socket, binds to an ephemeral port, and starts two daemon threads:
Receiver thread (_receiver_loop at dasp.py:940): infinite loop of recvfrom_into() with a 1-second timeout. Each datagram is decoded into a DaspMsg, then _dispatch() routes it:
def _dispatch(self, msg, addr, raw=None):
# DISCOVER responses go to the discovery list
if msg.msg_type == DISCOVER and self._discover_active:
self._discovered.append((addr[0], msg.platform_id))
return
session = self._sessions.get(msg.session_id)
if session is None: return # unknown session
if session.host != addr[0]: return # wrong source IP
if session.port != addr[1]: return # wrong source port
session.dispatch(msg)
The session ID in bytes 0–1 of the datagram is the routing key. The IP+port check is a basic security measure — packets from unexpected sources are silently dropped.
Housekeeping thread (_housekeeping_loop at dasp.py:1015): 100ms tick that calls session.housekeeping() on every active session:
def housekeeping(self):
# Receive timeout: no packet from remote in too long
if _ticks() - self.last_receive > self.receive_timeout:
self.close(ERR_TIMEOUT, "receive timeout")
return
# Send timeout: oldest unacked packet waited too long
oldest = self.send_window.send_retries()
if oldest > self.receive_timeout:
self.close(ERR_TIMEOUT, "unacked send timeout")
return
# Keepalive: unacked data to piggyback, or idle too long
if (self.receive_window.unacked() or
_ticks() - self.last_send > self.receive_timeout / 3):
self._keepalive()
This does three things: (1) checks for receive timeout (device went silent), (2) triggers send retries and checks for send timeout (device isn't acking), (3) sends keepalives — either to piggyback pending ACKs or to prevent idle timeout (every receive_timeout / 3 seconds, so the remote never sees a gap longer than 1/3 of its timeout).
Layer 5 — Session Dispatch
DaspSession.dispatch() (dasp.py:758) is called on the DASP receiver thread for every incoming datagram:
def dispatch(self, msg):
self.num_received += 1
self.last_receive = _ticks() # resets receive timeout
if msg.ack >= 0:
self.last_ack = msg.ack # for SendWindow backpressure
self.send_window.check_ack_headers(msg) # process ACKs
if msg.msg_type == DATAGRAM:
if not self.receive_window.receive(msg.seq_num):
return # duplicate or out of window
self._enqueue(msg) # accepted → deliver to SOX
elif msg.msg_type in (CHALLENGE, WELCOME):
self._enqueue(msg) # handshake responses
elif msg.msg_type == CLOSE:
self._enqueue(msg)
self._shutdown(...) # remote closed
elif msg.msg_type == KEEPALIVE:
pass # ACK processing already done above
Every message updates last_receive and processes ACK headers. Only DATAGRAMs go through the receive window's sequence-number check. Accepted messages are enqueued into _receive_queue (a queue.Queue(maxsize=2000)).
Layer 6 — SOX Receiver Thread
_SoxReceiver (sox.py:960) bridges DASP to SOX. It loops calling session.receive(timeout=1.0), which blocks on the _receive_queue:
def run(self):
while self._alive and not client.is_closed:
dasp_msg = client._session.receive(timeout=1.0)
if dasp_msg is None: continue
payload = dasp_msg.payload
msg = SoxMsg(payload)
cmd = payload[0]
if cmd == ord('e'):
client._receive_event(msg) # COV event
else:
client._receive_response(msg) # request response
The first byte of the SOX payload is the command character. 'e' is an event (COV notification from the device); everything else is a response to a pending request.
Layer 7 — Request/Response Correlation
SoxClient.requests() (sox.py:878) implements batched request/response correlation:
def requests(self, reqs):
with self._lock: # one batch at a time
for i, req in enumerate(reqs):
req.reply_num = i # tag each request
self._responses = [None] * len(reqs) # preallocate response slots
self._requests = reqs
for req in reqs:
self._session.send(req.to_bytes()) # send all immediately
# wait for all responses, up to 30 seconds
while not all(r is not None for r in self._responses):
self._req_event.wait(timeout=0.5)
if self.is_closed:
raise SoxException("Session closed")
return list(self._responses)
Each SOX request carries a reply_num (0–254). When the device responds, _receive_response() (sox.py:926) matches the response's reply_num to the corresponding request and fills that slot. The _req_event (a threading.Event) wakes the waiting thread every time a response arrives.
For batches larger than 255 requests, _chunk_requests() splits them into 255-request chunks and runs them sequentially.
Discovery
DaspSocket.discover() (dasp.py:957) sends a DISCOVER broadcast and collects replies:
msg.msg_type = DISCOVER
msg.session_id = 0xFFFF
msg.seq_num = 0xFFFF
self._sock.sendto(data, ("255.255.255.255", port)) # broadcast
self._sock.sendto(data, ("239.255.18.76", port)) # Sedona multicast
time.sleep(wait_time) # 3 seconds default
During the wait, the receiver thread collects DISCOVER responses via the _discover_active flag, accumulating (ip_address, platform_id) tuples. After the timeout, results are deduplicated by IP and returned.
Session Close
DaspSession.close() (dasp.py:814):
def close(self, error_code=-1, cause="close()"):
close_msg.msg_type = CLOSE
close_msg.session_id = self.remote_id
self._send_raw(close_msg)
if error_code < 0: # clean close (not error)
time.sleep(0.05)
self._send_raw(close_msg) # send twice for reliability
self._shutdown(cause)
A clean close sends the CLOSE message twice with a 50ms gap — since DASP is over UDP and there's no guarantee of delivery, the double-send improves the chance the device sees it. An error close sends only once (the session is already broken, no point retrying).
_shutdown() (dasp.py:830) kills the send window (waking any blocked senders), puts None into the receive queue (waking the SOX receiver), calls on_close callback, and frees the session from the socket's session map.
The Full Thread Architecture
Main Thread DASP Receiver Thread
│ │
│ SoxClient.requests() │ DaspSocket._receiver_loop()
│ ├─ session.send(payload) │ ├─ recvfrom_into(buf)
│ │ └─ SendWindow.send() │ ├─ DaspMsg.decode(buf)
│ │ ├─ enqueue (blocks │ └─ session.dispatch(msg)
│ │ │ if window full) │ ├─ check_ack_headers
│ │ └─ _send_raw(msg) │ │ └─ dequeue acked
│ │ └─ UDP sendto │ │ notify senders
│ │ │ └─ _enqueue(msg)
│ └─ _req_event.wait() │ └─ receive_queue.put
│ (woken by response) │
│ │
SOX Receiver Thread DASP Housekeeping Thread
│ │
│ _SoxReceiver.run() │ DaspSocket._housekeeping_loop()
│ ├─ session.receive() │ └─ every 100ms:
│ │ └─ receive_queue.get │ session.housekeeping()
│ └─ dispatch: │ ├─ check receive timeout
│ ├─ 'e' → _receive_event │ ├─ send_retries() + backoff
│ │ └─ _apply_to_cache │ └─ keepalive if idle/unacked
│ │ └─ on_event(id,ch) │
│ └─ other → _receive_response │
│ └─ _responses[i] = msg │
│ _req_event.set() │
Key Design Insights
UDP over TCP: DASP exists because Sedona runs on microcontrollers with 50KB of RAM. TCP's sliding window, congestion control, and connection state are too heavy. DASP implements the minimum viable subset — sequence numbers, ack bitmasks, retransmission, keepalive — in a protocol small enough to fit in an embedded device's memory. The tradeoff is that the desktop client carries the complexity of reimplementing reliable delivery.
Piggyback ACKs: every outgoing message (including keepalives) carries acknowledgement headers. This eliminates dedicated ACK packets — data responses ack data requests, keepalives ack data that arrived during an idle period. The set_ack_headers() call in _to_msg() and _keepalive() is the mechanism.
Single-lock batching: SoxClient.requests() holds self._lock for the entire batch. This means only one SOX operation (which may be many underlying requests) can be in flight at a time. The SOX receiver thread fills _responses[] and signals _req_event; the requesting thread wakes, checks completeness, and either waits again or returns. Events arriving during a request batch are dispatched separately by the SOX receiver and don't interfere with the response correlation.
Graceful degradation: the ack_more_byte_limit (7 by default) caps how far ahead of the ACK frontier the sender can get. If the device's receive window is small, the sender blocks. If datagrams are lost, exponential backoff retries up to 8 seconds. If nothing is heard for receive_timeout (30 seconds), the session closes and the editor's COV poll detects the disconnect.
Trace 13: SOX Protocol Commands
The SOX (Sedona Object Exchange) protocol is the application-layer command set that rides atop DASP. Where Trace 12 showed how bytes are reliably delivered, this trace shows what those bytes mean — a cursor-based binary RPC system with 13 distinct command verbs, a 1024-slot component cache, request/response correlation, and a COV event pipeline.
Layer 1 — The Binary Wire Format: SoxMsg
Every SOX interaction is a SoxMsg — a cursor-based big-endian binary buffer (sox.py:46). It wraps a bytearray with a read cursor (_pos) and provides typed accessors: u1(), u2(), i4(), str() for reading, and write_u1(), write_u2(), write_i4(), write_str() for writing. The first two bytes of every message are always the command byte and the reply number — stored as msg.command and msg.reply_num during decode.
The reply number is the correlation key. When a client sends a batch of N requests, each gets reply_num = 0..N-1 (sox.py:887). The receiver thread matches each response back to its slot in the _responses array by reply_num & 0xFF (sox.py:934). This gives a maximum batch size of 255; larger batches are chunked automatically (sox.py:918).
Layer 2 — The Value Codec
Nine Sedona types cross the wire, each with a numeric type ID (sox.py:27):
| ID | Type | Wire size | Codec |
|---|---|---|---|
| 0 | bool | 1 byte | u1(), truthy check |
| 1 | byte | 1 byte | u1() |
| 2 | short | 2 bytes | u2() signed |
| 3 | int | 4 bytes | i4() |
| 4 | long | 8 bytes | struct.unpack('>q') |
| 5 | float | 4 bytes | struct.unpack('>f') |
| 6 | double | 8 bytes | struct.unpack('>d') |
| 7 | buf | 2-byte length + N bytes | raw bytes |
| 8 | str | null-terminated | str() |
decode_value() (sox.py:193) and encode_value() (sox.py:222) are the universal codec pair. Every property read, write, and COV event goes through them. The encode path has a subtle detail: float values that arrive as Python int are cast to float before packing, because struct.pack('>f', 0) requires a float, not an int.
Layer 3 — The Command Table
SOX uses single-character command codes. Request letters are lowercase; responses are uppercase (the correlation check at sox.py:940 verifies cmd == (req_cmd & ~0x20) — i.e., the response is the uppercase of the request, or '!' for error):
| Request | Response | Verb | Purpose |
|---|---|---|---|
'v' |
'V' |
readSchema | Read kit manifest (names + checksums) |
'y' |
'Y' |
readVersion | Platform ID, kit versions, properties |
'c' |
'C' |
update | Read component tree/config/runtime/links |
'r' |
'R' |
readProp | Read a single property value |
'w' |
'W' |
writeProp | Write a single property value |
'i' |
'I' |
invoke | Invoke an action slot |
'a' |
'A' |
add | Add a child component |
'd' |
'D' |
delete | Delete a component |
'n' |
'N' |
rename | Rename a component |
'o' |
'O' |
reorder | Reorder children |
'l' |
'L' |
link/unlink | Add or remove a wire link |
's' |
'S' |
subscribe | Subscribe to COV events |
'u' |
— | unsubscribe | Cancel a subscription |
'e' |
— | event | Unsolicited COV event (device → editor) |
Layer 4 — Schema Discovery: read_schema() and read_version()
Connection begins with schema discovery. read_schema() (sox.py:408) sends 'v' and parses the 'V' response: a count byte, then N (name, checksum) pairs as KitPart objects. This tells the editor which kits the device runs and at what build checksum. The result is cached in self.schema_parts — a second call returns instantly.
read_version() (sox.py:423) goes further: it sends 'y' to get the platform ID, scode flags, per-kit version strings, and key-value properties. The critical property is soxVer — the SOX protocol version. Its presence (checked via self.sox_version at sox.py:446) gates whether the client uses batch subscribe/unsubscribe (SOX v2+) or the legacy per-component approach. This is the version fork that runs through every subscribe/unsubscribe call.
Layer 5 — Component Loading: load() → load_ids() → _apply_to_cache()
Loading a component means requesting its tree record from the device. load(comp_id) (sox.py:458) checks the cache first, then delegates to load_ids(). load_ids() (sox.py:467) is the batch loader: it filters out already-cached IDs, sends 'c' requests with what='t' (tree) for the rest, then feeds every 'C' response through _apply_to_cache().
_apply_to_cache() (sox.py:767) is the central cache mutation point — every response and every event converges here. It reads comp_id and what from the message, then dispatches:
-
what == 't'(tree): If uncached,_apply_tree_new()creates a freshSoxComponentwith kit_id, type_id, name, parent, permissions, and children list. If cached,_apply_tree_cached()updates the existing object. Both call_apply_tree_body()(sox.py:808), which does something critical: it compares old vs. new children sets and recursively removes any children that disappeared (cache_remove()at sox.py:757 walks the subtree). -
what == 'c'or'C'(config):_apply_props()walks the type's slot registry, decoding each config property value from the message. Uppercase'C'means operator-only — a filtered view. -
what == 'r'or'R'(runtime): Same as config but for runtime slots. Thewhat_char == 'r'check at sox.py:838 skips config slots;'c'skips runtime. This is how the same_apply_props()method serves both. -
what == 'l'(links):_apply_links()readsLinktuples (from_comp, from_slot, to_comp, to_slot) until the 0xFFFF sentinel, and replacescomp.links.
The slot-walking in _apply_props() (sox.py:824) deserves attention. It calls self._get_slots(kit_id, type_id) to get the type's slot list from slot_registry, then iterates in declaration order, decoding one value per slot. This is positional — the protocol sends values in slot order with no slot-ID prefix. If the registry is wrong or out of sync, every subsequent value in the message decodes from the wrong offset. The try/except at sox.py:843 catches the resulting struct errors and breaks the loop, silently dropping remaining values rather than corrupting them.
Layer 6 — Property Read/Write
Read: read_prop() (sox.py:604) sends 'r' with comp_id and slot_id. The response echoes both IDs plus the type ID, then the value. This is used for on-demand single-property reads.
Write: write_prop() (sox.py:617) sends 'w' with comp_id, slot_id, and the encoded value. The 'W' response is a bare acknowledgment — no echo. This is the hot path for the Properties panel's live editing: user changes a field → properties.py calls device_session.write_property() → which calls sox_client.write_prop().
Layer 7 — Structural Mutations: add, delete, rename, reorder
Add (sox.py:638) sends 'a' with parent_id, kit_id, type_id, name, and optional config values. The response returns the new component's ID. The client then creates a local SoxComponent, resolves its type name via _resolve_type_name() (sox.py:859), adds it to the cache, and appends it to the parent's children list. This is optimistic local update — the cache reflects the addition before any tree event arrives.
Delete (sox.py:666) sends 'd' with comp_id. After the 'D' acknowledgment, the client unlinks the component from its parent's children list and calls cache_remove() — which recursively removes all descendants. Again optimistic: the cache is pruned immediately.
Rename (sox.py:681) sends 'n' with comp_id and new_name. After 'N', the cached component's name is updated locally.
Reorder (sox.py:693) sends 'o' with comp_id and the new children ID sequence. After 'O', comp.children is replaced with the new order. This is used when the user drags components in the tree to reorder them.
Layer 8 — Wire Links: link, unlink, read_links
Link operations use the 'l' command with a sub-command byte — ord('a') for add-link, ord('d') for delete-link (sox.py:707). The _link_cmd() helper (sox.py:713) packs: sub-command + from_comp_id + from_slot_id + to_comp_id + to_slot_id. The 'L' response is a bare acknowledgment.
read_links() (sox.py:725) takes a different approach — it sends a 'c' (update) request with what='l' (links), not the 'l' (link) command. The response is a 'C' containing Link tuples terminated by from_comp == 0xFFFF. This overloading — 'c' for reads, 'l' for mutations — reflects the protocol's dual use of the 'c' command as a general "read component data" verb.
Layer 9 — Subscriptions and COV Events
Subscriptions tell the device to push change-of-value (COV) events to the editor. The subscription mask uses four bits: TREE=0x01, CONFIG=0x02, RUNTIME=0x04, LINKS=0x08 (sox.py:20).
SOX v2+ batch subscribe (sox.py:524): sends 's' with mask + count + comp_id list in a single message. Efficient — one round-trip for many components.
Legacy subscribe (sox.py:538): sends separate 's' requests per component per mask bit. Four components × 3 mask bits = 12 requests. The requests() batching still sends them in one round-trip, but the server processes them individually.
Tree-wide subscription (sox.py:560): subscribe_all_tree_events() subscribes to ALL tree events across the entire component hierarchy. On SOX v2+, this sends mask=0xFF + count=0 (a special sentinel). On legacy, it subscribes comp 0 with mask 'a' (all). After this, all components in the cache get their TREE subscription bit set.
COV event dispatch: When the _SoxReceiver thread (sox.py:960) sees cmd == 'e', it calls _receive_event() (sox.py:947), which skips the command and reply bytes, then calls _apply_to_cache(msg, is_event=True). The is_event=True flag triggers two additional behaviors: (1) auto-set the subscription mask bit via _what_to_mask() (sox.py:1027), and (2) invoke self.on_event(comp_id, what_char) — the callback that device_session.py wires to a queue, which main.py drains on its 100ms Tkinter poll loop.
Layer 10 — Request/Response Correlation
The requests() method (sox.py:878) is the single networking funnel. It:
- Guards against a closed session (raises
SoxException). - Chunks batches > 255 via
_chunk_requests(). - Assigns
reply_num = 0..N-1to each request underself._lock. - Sends all requests via
self._session.send()(DASP reliable delivery). - Waits up to 30 seconds, polling every 500ms via
self._req_event(athreading.Event). - Correlates responses:
_receive_response()(sox.py:926) matches each incoming message'sreply_numto the response slot, verifying that the response command is the uppercase of the request command (or'!'for error). - Returns the completed response list, or raises
SoxException("Request timeout").
The _SoxReceiver daemon thread (sox.py:960) runs the dispatch loop: receive a DASP message → wrap payload in SoxMsg → route 'e' to _receive_event, 'k'/'z' (file transfer) to discard, everything else to _receive_response. It catches DaspException (session closed) and generic Exception (malformed message) separately — the former breaks the loop, the latter continues.
Layer 11 — Cache Architecture
The cache is a flat array of 1024 SoxComponent slots (sox.py:353), indexed by component ID — O(1) lookup. cache_add() (sox.py:751) grows the array by doubling when a component ID exceeds the current size. cache_remove() (sox.py:757) recursively nullifies the component and all its descendants — a subtree wipe.
Each SoxComponent (sox.py:284) holds: id, kit_id, type_id, type_name, name, parent, children, permissions, values (dict of slot_name → value), links (list of Link), and subscription (bitmask of active COV subscriptions). The values dict is the live truth — updated by _apply_props() on every response and event.
Architectural Insight
SOX is a positional binary protocol with optimistic local caching. The critical design tension is between the positional value encoding (which demands a correct slot registry to decode) and the optimistic cache updates (which apply mutations locally before the server echoes them back as events). The slot registry acts as the Rosetta Stone: without it, _apply_props() cannot decode a single property value, because there are no field separators or type tags in the wire format — just raw bytes in slot-declaration order. This is why device_session._load_slot_registry() runs before any property read, and why a schema mismatch between editor and device corrupts silently rather than failing loudly.
Trace 14: Invoke / Action Slot Flow
This trace follows a user interaction in live mode from two distinct entry points — the tree's root context menu and the Properties panel — through the live_hooks interception layer, down to the SOX 'i' command on the wire.
Entry Point A — App-Level Actions: The Tree Root Context Menu
Right-clicking the root node of the navigation tree while connected to a live device triggers _on_tree_right_click() at main.py:910. When the clicked item has no parent (parent_id == ""), the code recognizes it as the tree root and populates a context menu with five app-level actions from _APP_ACTIONS (main.py:487): save, hibernate, quit, restart, reboot. Each menu entry is wired with a lambda that captures the action name and calls _invoke_app_action(a).
_invoke_app_action() (main.py:457) is a self-contained pipeline:
- Guard: Checks
device_sessionis connected. - Cache lookup: Fetches component 0 (the Sedona
appobject) fromds.sox_client.cache_get(0). - Slot registry lookup: Reads
slot_registry[(comp.kit_id, comp.type_id)]— the device-side slot list for the app component's type. - Slot search: Finds the slot by name and
is_actionflag:next((s for s in slots if s["name"] == slot_name and s.get("is_action")), None). - Invoke: Calls
ds.sox_client.invoke(0, slot["id"])— comp_id is always 0 for the app component. - Post-invoke side effects: If the action was
"save", callsapp_state.mark_clean()(the device persisted everything, so the editor's dirty flag clears). If the action was"hibernate","quit","restart", or"reboot", callsdo_device_disconnect()— the device is about to go away, so the editor tears down the live session preemptively.
Entry Point B — Component-Level Actions: live_hooks.live_invoke()
Any component's action slots can be invoked through live_invoke() at live_hooks.py:176. This follows the same pattern as every other live_hooks function:
- Guard:
is_live()checks three conditions —app_state.live_mode,device_session is not None, anddevice_session.is_connected(live_hooks.py:12). - Component ID resolution:
_comp_id(element)(live_hooks.py:200) maps the DOM element to a numeric comp_id. An<app>tag is always 0; everything else reads theidattribute. - Slot resolution:
_find_slot(ds, comp_id, slot_name)(live_hooks.py:212) resolves the slot name to its numeric ID through a two-tier lookup: first the UI-levelfinal_components_registry(keyed by type name), then the protocol-levelslot_registry(keyed by(kit_id, type_id)tuple). The registry entry is returned as a dict withname,id, andtype_name. - Invoke:
ds.sox_client.invoke(comp_id, slot_info["id"]).
The Two-Tier Slot Resolution
The _find_slot() helper at live_hooks.py:212 is the critical bridge between the editor's world (slot names like "reset") and the protocol's world (slot IDs like 5). It first queries final_components_registry — the UI-level registry compiled from kit manifests by schema.py. If the component's type name is resolved via type_name_map[(kit_id, type_id)] and found in the registry, it returns an entry with slot_id from the manifest. Only if that fails does it fall back to slot_registry[(kit_id, type_id)] — the protocol-level registry built by device_session._load_slot_registry() during connection.
This preference order matters: the UI registry comes from the manifest files on disk (more complete metadata, including facets and type information), while the protocol registry is synthesized from the SOX schema read (just names and IDs). For action slots, the distinction is subtle — both know the slot's name and numeric ID — but the UI registry also carries the is_action flag, the type (always "void" for pure actions), and facets like @hidden.
The Wire: sox.invoke()
SoxClient.invoke() (sox.py:627) builds the SOX 'i' command:
byte 0: 'i' (0x69) — command
byte 1: reply_num — correlation ID
byte 2-3: comp_id — big-endian u16
byte 4: slot_id — u8
[optional: encoded argument value]
The optional argument (type_id + arg) supports action slots that take a parameter — not common in Sedona, but the protocol allows it. encode_value() packs the argument in the slot's declared type format.
The response is a bare 'I' acknowledgment — no payload beyond the command byte and reply number. check_response('I') verifies the uppercase echo; if the device returns '!' (error), check_response raises SoxException.
The Properties Panel: Actions as Read-Only Display
Notably, the Properties panel does not render action slots as invoke buttons. get_full_slots_for_type() (schema.py:292) returns all slots including actions, and _build_slot_row() (properties.py:753) builds a row for each one. But action slots have type "void", so display_text() (properties.py:351) returns the string "action" — and since they are not editable (is_editable() checks for the 'c' config flag, which actions lack), the Entry widget is set to readonly state. The user sees "action" in a greyed-out field with no way to interact.
The @hidden facet (properties.py:247) further suppresses most action slots from the panel entirely — the manifest marks internal actions as hidden, and the panel's checkbox footer controls whether hidden rows appear.
The live_hooks Interception Pattern
live_hooks.py (live_hooks.py:1) is the write-through interception layer for all live-mode mutations. It exposes seven functions, each following the same pattern:
| Function | SOX Command | Wire Code |
|---|---|---|
live_set_prop_value() |
write_prop() |
'w' → 'W' |
live_add_component() |
add() |
'a' → 'A' |
live_delete_component() |
delete() |
'd' → 'D' |
live_rename_component() |
rename() |
'n' → 'N' |
live_add_link() |
link() |
'l' → 'L' |
live_delete_link() |
unlink() |
'l' → 'L' |
live_invoke() |
invoke() |
'i' → 'I' |
Each function: guards on is_live() → resolves comp_id from DOM element → resolves slot/type info from the two-tier registry → calls the SoxClient method → returns True/False. Callers use the boolean to decide what to show in the status bar.
The property write path (live_hooks.py:20) has an extra step: _coerce_value() (live_hooks.py:248) converts the string from the Properties panel's Entry widget into the Python type the SOX codec expects — bool, int, float, or str — based on the slot's type ID. Without this, encode_value() would try to struct.pack('>f', "3.14") and crash.
The Save Button's Dual Path
The Properties panel's save() method (properties.py:1102) collects all edited rows, validates them via parse_text(), then writes each change to both the DOM (set_prop_value()) and the device (live_hooks.live_set_prop_value()). This is write-through: the DOM is the primary store, the device is a side-channel that may fail independently. If the device write fails, the DOM still has the value, and the status bar reports which properties failed on the device while succeeding locally.
Architectural Insight
The invoke/action flow reveals a deliberate asymmetry in the editor's live mode architecture. Property writes are dual-tracked (DOM + device), with the DOM as the authoritative copy and the device as a write-through target that may fail. But action invocations are device-only — there is no DOM representation of "invoke was called." The _invoke_app_action() path doesn't touch the DOM at all; it sends the SOX command and reacts to the consequences (mark clean after save, disconnect after reboot). This is the correct model: an action is an imperative command to the device, not a state change to be persisted, and the editor's undo system has nothing to track for it.