Compare commits

...
118 Commits
Author SHA1 Message Date
ArdaandClaude Opus 5.5 6dd01b13b9 DASP trace: color send/receive, decode header fields, hex+ASCII dump
Sent lines are yellow and received lines green when stdout is a console;
pipes and files stay plain. Each message lists the header fields it
carries by name and dumps header and payload separately, 16 bytes a row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:38:52 +03:00
ArdaandClaude Opus 5.5 0dddea53f8 Wiresheet: keep off-sheet input knobs on the sheet
A box with a link arriving from another sheet draws a knob on its left
edge. Tidy Layout moves the first column one grid step in when any of its
boxes has one, and dragging and arrow nudges stop such a box one step
inside the margin, so the knob never sits out in the grey.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:34:27 +03:00
ArdaandClaude Opus 5.5 0d5e5115d9 Wiresheet: arrow nudges keep the selection and the sheet's focus
Ctrl+A hands the sheet the keyboard, and repainting the same sheet keeps
whatever of the selection is still on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:40:07 +03:00
ArdaandClaude Opus 5.5 1a4c84f9d5 Wiresheet: arrow keys nudge selected boxes one grid step
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:35:34 +03:00
ArdaandClaude Opus 5.5 75df6d0a6e Wiresheet: left-drag rectangle selection, group move, Ctrl+A select all
Rectangle selection moves to the left button on bare sheet (Ctrl adds);
right-click is the menu again. Dragging a selected box by its title bar
moves the whole selection as one undoable step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:29:13 +03:00
ArdaandClaude Opus 5.5 81c4a1d31d Wiresheet: right-drag rectangle selects boxes, Ctrl adds to selection
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:21:56 +03:00
ArdaandClaude Opus 5.5 0c69f18046 Tidy Layout straightens wires; router avoids crossings and box edges
Tidy Layout
- Box heights are settled in left-to-right and right-to-left sweeps so each
  box lines its slot up with the other end of its wires; gaps above the
  minimum are free, and columns are re-sorted to untangle crossings.
- A wire that skips columns takes a placeholder in each column it crosses,
  so boxes are ordered and spaced around it.
- A box nothing feeds is held back far enough left that wires from further
  left always enter higher slots of the same target.

Wire routing
- Only the short stubs at a wire's two slots may pass close to a box; the
  rest of every run keeps its clearance, so no wire grazes a box edge. A link
  with no clear route is shown as knobs.
- Lanes that cross no other wire are tried first; climbing wires route top
  source first, falling ones bottom first, and a deferred wire defers the
  rest of its gap so lanes keep that order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:17:04 +03:00
ArdaandClaude Opus 5.5 3d9db54956 Wiresheet: grey margin strip, saved-state tint on and off the sheet
- The top/left margin no box may enter is grey and ungridded, so the white
  sheet starts where boxes can actually be placed.
- Saving/unsaved state no longer resets the canvas to white: the sheet goes
  white/pink and the area around it grey/greyish pink.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:18:58 +03:00
ArdaandClaude Opus 5.5 b3d96d96b6 Tidy Layout as signal flow; fixed-size wiresheet; signed Sox int writes
- Tidy Layout lays linked boxes out left to right by signal flow, in as few
  columns as the longest chain allows; feedback loops are cut where they run
  backwards, and sources sit just before the boxes they feed.
- Boxes with no link on the open sheet go to the bottom of the first column,
  alphabetically.
- A box that wires skipping its column would cross is pushed down below them.
- The wiresheet is fixed at meta's 255-step reach; outside it is light grey.
  Dragging keeps a box's top-left corner on the sheet.
- Sox write_i4 wraps to signed, so meta with x >= 128 reaches the device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 10:15:22 +03:00
ArdaandClaude Opus 5.5 160f65c95e Preferences: toggle slot numbers on wiresheet, off by default
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 08:39:01 +03:00
Arda b8af483890 WIP: auto-sync from AA-ZENBOOK 2026-10-15 10:14 2026-10-15 10:14:33 +03:00
Arda 6365d1f74d WIP: auto-sync from AA-LENOVOHOME 2026-10-11 14:21 2026-10-11 14:21:00 +03:00
ArdaandClaude Opus 5.5 71effdc160 Merge test/sab-seed-benchmark: optional app.sab read on connect
Preference 'Lazy loading on connect?' (on by default). Off reads the whole app
from app.sab when connecting: 8 s instead of 116 s on a P-ION, 0.9 s instead of
16 s on an Orion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:14:42 +03:00
ArdaandClaude Opus 5.5 d905d25145 DASP trace: sent lines yellow, received green on a console
Plain text when stdout is a pipe or file. Also remembers localhost as the last device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:14:41 +03:00
ArdaandClaude Opus 5.5 7ff2479736 Preferences: lazy loading on connect, on by default
Off reads the whole app from app.sab when connecting. Read at each connect, so a
change takes effect on the next one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:13:41 +03:00
ArdaandClaude Opus 5.5 cb0196caa7 Connect: read the whole app from app.sab instead of component by component
Saves the app on the device, reads app.sab and fills the Sox cache with the tree,
config and links in one go: 8 s instead of 116 s for 230 components on a P-ION,
0.9 s instead of 16 s for 194 on an Orion. Sox keeps the cache current from
there as before. On any failure, or when app.sab does not match the running
tree, it falls back to lazy loading. A sheet's first open reads only runtime
values; a tree expand with nothing to load no longer rebuilds the DOM.

bench_sab_seed.py times both ways against a device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:01:32 +03:00
ArdaandClaude Opus 5.5 db7b6632af Restart from the tree no longer hangs for 30 s on disconnect
With a wiresheet open, disconnecting first unsubscribed its components and
waited for the reply, which a restarting device never sends, so the editor
froze until the request timed out. After restart, reboot, quit or hibernate
the session is now closed without asking the device anything; a plain
Disconnect still unsubscribes first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:53:22 +03:00
ArdaandClaude Opus 5.5 f2b18923b7 Disconnect at once after restart, reboot, quit or hibernate
The device goes down without answering, so the editor waited out the 30 s
Sox timeout before dropping the session. The invoke now waits 3 s (time for
DASP to resend it once) and the editor disconnects either way; an error
reply from the device is still reported and keeps the connection. Covers the
App right-click actions and the restart Put with Files offers.

- sox: request/requests/invoke take a timeout

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:42:36 +03:00
ArdaandClaude Opus 5.5 e070bd0f1e Put with Files: choose kits, as Manage Kits does, and rebuild kits.scode
A kits step after the zip is chosen, laid out as the Manage Kits panel:
installed kits (what the app needs, greyed), installed but not used, kits
available on the computer, not supported; the Action cell offers Keep at /
Upgrade to / Downgrade to / Add at / REMOVE; Upgrade all kits to latest
version. Kits not in the backup can be added, with their dependencies.

The ticked kits make app.sab's schema (encode_sax takes the kit list, as
Ontrol's PutApp rewrites it). When they are not the device's kits, or
Rebuild kits.scode is ticked, kits.scode is built for them with Ontrol's
sedonac from the Niagara install, after checking the natives the kits call
against the device's platform manifest (PutApp.checkNativeCompatibility).

- kit_build.py: platform manifest lookup, native check, sedonac run
- palette.kit_versions: a checksum released more than once stands for its
  highest version (ontrolPlatform 1.0.1, not 1.0.0)
- palette.unsupported_kits takes the kit list to judge against
- sab_app: a type from a kit the app does not list is a clear error

Tested on a P-ION: a rebuild for its own kits is byte-identical to the one
it runs; adding and then removing the logic kit both come back running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:37:08 +03:00
ArdaandClaude Opus 5.5 08b4dec532 Tree: Reorder... for a component's children
Right-click a component (or the app) with two or more children to move them
up and down; OK applies the new order, which is the order they run in. On a
device the order goes there first via Sox reorder, checked against the
device's children, and the file is left alone if it is refused. Undoable.

Also: device host in editor.properties.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:39:06 +03:00
ArdaandClaude Opus 5.5 232eea99f4 App > Put with Files: restore a backup zip onto the connected device
Follows Ontrol's Workbench tool (BPutWithFilesTool / PutApp): the backup's
app.sax is encoded for the device's kits and put as app.sab.writing, renamed
to app.sab.stage; the platform service's deleteFile clears fonts/* and
images/*, then images, fonts (from Sedona home's bank, in the device's cut)
and the boot logo go. The zip is checked against the device first: same
platform (Orion, Rion or P-ION, from the platform id), and kits the device
can load. Falls back to the backup's app.sab, with its kits.scode if needed.

- sab_app.encode_sax: .sax -> .sab, ported from Py_sab2sax with Java's value
  parsing (fromLiteral, nullbool/nullfloat, Integer.decode); byte-identical
  to sedonac on the saved backups
- app_backup.native_image: port of OntOxImageUtil's png -> native .bin
- sox.put_file / rename_file
- Get with Files: Orion's boot logo is system/logo.jpg
- A restart that goes unanswered is taken as the device going down

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:37:45 +03:00
ArdaandClaude Opus 5.5 1deb31f3b3 Bump version to 0.0.0.164
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:29:13 +03:00
ArdaandClaude Opus 5.5 1f3b841dc0 Rename App > Get with File to Get with Files
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:28:31 +03:00
Arda 9e8d737fac WIP: auto-sync from AA-DELLDESKTOP 2026-10-07 13:45 2026-10-07 13:45:20 +03:00
ArdaandClaude Opus 5.5 67fbfe03dd Wiresheet: Ctrl+click to select several links, delete them together
Ctrl+click toggles a link in the selection; Del or the link popup deletes
every selected link after one confirmation, as a single undoable step.
On a device, links the device refuses stay and are named in one warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:55:18 +03:00
ArdaandClaude Opus 5.5 d0454688b3 Toolbar: Undo button next to Tidy Layout
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:45:55 +03:00
ArdaandClaude Opus 5.5 8a0051380a Wiresheet wires: rounded bends, hops, junction dots and grid spacing
Wires now round their bends, hop over the wires they cross, and show a dot
where wires from one output split. Wires sharing a pin may share a trunk.

Routes keep one grid square from a box's top and bottom and two from its
sides, never turn closer than two squares beside a box, and keep half a
square from other wires. The runs pinned to every slot are claimed before
any wire is routed, so an early wire no longer lays its run along a row a
later slot has to leave by. A wire that cannot fit the grid spacing falls
back to the old tighter one rather than turning into knobs.

The styling geometry lives in wire_style.py; draw_links is split into
collecting, routing and painting around a WireJob dataclass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:37:35 +03:00
Arda 0c846fcc96 WIP: auto-sync from AA-LENOVOHOME 2026-10-06 23:45 2026-10-06 23:45:27 +03:00
ArdaandClaude Opus 5.5 91d92d6525 Back and Forward on a device: match history entries by component id
Opening a sheet on a device rebuilds the DOM, so every element is new and
the history, which matched by identity, pruned each sheet as soon as it was
recorded. On a device entries now match by id, and Back reopens a view on
the current element rather than the snapshot it was recorded with.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:34:21 +03:00
ArdaandClaude Opus 5.5 b074531795 History comment: list the oxPage and User Manager views
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:50:06 +03:00
ArdaandClaude Opus 5.5 f5880d1b1a Wiresheet: mouse wheel scrolls the sheet, Shift + wheel scrolls sideways
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:02:38 +03:00
ArdaandClaude Opus 5.5 d036ee206d Duplicate components from the sheet and tree popups, with links between them
Ctrl+click picks out several boxes on the sheet, and Ctrl/Shift+click several
rows in the tree. Copies are named on from their original (Add1 -> Add2), sit
one step right and down, and keep the links that ran between the copied
components. One undo step; on a device the originals are read back in full
and the copies made there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:00:49 +03:00
ArdaandClaude Opus 5.5 4ab4b4d588 Palette: drag onto the tree to add inside a component; hover opens branches
A palette drag dropped on a tree row adds the component inside that row's
component, placed under its lowest child. The row under the pointer is
highlighted, and resting on a collapsed branch opens it, loading it from
the device when live without moving the selection or the open sheet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:39:31 +03:00
ArdaandClaude Opus 5.5 bc4e28d3f7 Palette: reopen the last chosen kit instead of starting empty
The chosen kit is remembered in editor.properties [Palette]; a file without it
opens on its first kit. Saving preferences now keeps the other sections.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:14:30 +03:00
ArdaandClaude Opus 5.5 b63655462f Remove redo; undo rename on a device
Redo is gone in both file and device mode: the Edit menu entry, Ctrl/Cmd+Y
and Ctrl/Cmd+Shift+Z, and the redo stack. An undone step is dropped.
Undoing past the last save now leaves the saved depth unreachable, so a
new edit cannot be mistaken for the saved state.

Renames on a device are undoable: LiveRenameCommand renames back on the
device first, and warns and clears history if the device refuses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:51:02 +03:00
ArdaandClaude Opus 5.5 8b21c5f389 Tree popup: act on the component as it is now, so tree delete is undoable on a device
Right-clicking a tree row selects it, and on a device that reopens the
sheet and rebuilds the DOM after the popup captured the row's element.
Delete then ran on a stale element: the device delete went through but
no undo step was recorded. Popup actions now look the component up by
id when they run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:14:51 +03:00
ArdaandClaude Opus 5.5 8411f0c9ae User Manager: undo/redo user add and delete on a device
Uses LiveComponentCommand, so undoing a delete puts the user back on
the device with its exact cred, perm and prov.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:07:24 +03:00
ArdaandClaude Opus 5.5 b98fe828da Live undo/redo: add/delete component and link, move, property edits
Undo and redo now work against a connected device; each step is sent
there through Sox. Deleting a component first reads its whole subtree
back (config, unloaded children, links, place among siblings) so undo
can recreate it; recreated components take new device ids, which the
DOM elements pick up so later steps still reach them. Binary Buf
config (a user's cred) is read with read_prop, since the cache decodes
every Buf as a Str. Moves, tidy layout, and Properties / page editor /
user edits record only what the device accepted.

A step that does not fully reach the device warns and clears the
history. History is also cleared on every view change in live mode,
as opening a sheet rebuilds the DOM from the device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:03:26 +03:00
ArdaandClaude Opus 5.5 fd22eb314d Wiresheet: make the last wire turn 1.5 grid before the target arrow
The final vertical run into an input slot now stands at least
1.5 * GRID_SIZE in front of the box, so the arrowhead sits on a straight
run instead of on the bend. Column gaps in tidy layout widen to match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:45:39 +03:00
ArdaandClaude Opus 5.5 96a703c5d5 App tree: keep expanded folders open; only the - sign closes one
Rebuilding the tree (live child loads, edits) now restores every row that
was expanded, matched by component id or name path. Double-click no longer
toggles a row through ttk's default binding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:25:44 +03:00
ArdaandClaude Opus 5.5 05ca7e9035 Default device host: 192.168.1.252
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:03:48 +03:00
ArdaandClaude Opus 5.5 c8c68f7b31 Note why the message-length header is kept off the P-ION
Tested on a P-ION (fw 1.32) at 192.168.1.252 with everything else as
in 5acca52: with the 0x81 header forced on, the app save is answered
but the app.sab file open never is, and the device drops off the
network. Without it, the same Get with File works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:57:39 +03:00
ArdaandClaude Opus 5.5 5acca522bf DASP: drop the P-ION resend overrides, use standard 512-byte packets
The save and file-open requests used their own fixed resend intervals
(5 s / 15 s) and the hello asked for 256-byte packets. Ontrol's Sedona
Java stack does neither and works with the P-ION: the standard backoff
(1, 2, 4, 8 s) sends a busy device a few copies with the same seqNum,
which its receive window drops. Both are removed.

Also fixes a race in SendWindow.send(): the packet entered the window
before its payload and sent_time were set, so housekeeping could resend
it with an empty payload, and the device would then drop the real one
as a duplicate.

Tested on a P-ION at 192.168.1.252, App > Get with File: save 3.2 s
(2 resends), app.sab + kits.scode in 11.3 s with 500-byte chunks
(was 14.6 s at 256), device stayed up, files identical to a 256-byte
backup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:52:18 +03:00
ArdaandClaude Opus 5.5 2880c2a6f2 Get with File on P-ION: connect and transfer the way Ontrol's Niagara driver does
A real P-ION (fw 1.32) restarted on every Sox file get. Ontrol's Niagara 4
driver gets app.sab from the same device, so its settings are copied:
- DASP hello asks for 256-byte packets (idealMax/absMax), so file chunks
  are 238 bytes
- the message-length header (0x81) goes to Orion only, decided once the
  platform id is read
- the app is saved on the device before app.sab is read, the save resent
  every 5 s rather than 1 s (a P-ION save takes 2-3+ s)
- the file open is resent every 15 s; DASP gains a per-packet resend
  interval for this

Tested on a P-ION at 192.168.1.252: save 3.2 s, app.sab + kits.scode +
app.sax in 15.8 s, no resends, device stayed up. The unsaved-changes
question became a notice, since the app is now always saved first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:15:12 +03:00
ArdaandClaude Opus 5.5 6b23bafffc Discover dialog: 2s default wait, Enter starts discovery
Focus the dialog on open so Enter works without clicking first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:39:24 +03:00
ArdaandClaude Opus 5.5 894fdb7eb5 Add App > Get with File: full app backup zip from the device
Reads app.sab and kits.scode over Sox file transfer, decodes app.sab to
app.sax (byte-identical to sedonac), and on Orion/Rion also fetches the
images and fonts the app names, with their sources from Sedona home's
banks. Offers to save the app on the device first when it is dirty, and
asks where to save with <Sedona home>/store/apps and
app-<device>-<app>-<timestamp>.zip filled in.

DASP trace lines are now printed by their own thread: printing from the
network threads to an unread stdout pipe blocked the receiver and made
file transfers time out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:25:05 +03:00
ArdaandClaude Opus 5.5 9b1d029b14 Connect to another device without disconnecting first
Connect and Discover stay enabled while a device is connected. Pressing
Connect in the dialog drops the current device before the new one is
tried; cancelling the dialog leaves the connection alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:49:25 +03:00
ArdaandClaude Opus 5.5 dbeeb78916 Disconnect from the device when opening a file or closing the app
Opening a file now drops the live connection once a file is chosen, without
asking. File > Exit and the window close box go through quit_app, which
closes the Sox session properly instead of leaving it for the device to
time out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:45:12 +03:00
ArdaandClaude Opus 5.5 85b6fa62f3 Drag links out of slots on the wiresheet; hide @palette=false types
Hovering a slot row highlights the half under the pointer and shows a
direction cursor. Pressing on a slot drags a rubber band; dropping on
another slot links them, dropping on a title bar opens the Link dialog
with the dragged slot preselected, and a no-entry cursor marks a slot
that would refuse the link. A right click that never moves still opens
the context menu.

The palette now leaves out types whose facets carry @palette=false, and
kits with nothing placeable left.

Also points the default device host at 192.168.1.251.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:15:47 +03:00
ArdaandClaude Opus 5.5 f22d4852c9 Fix Manage Kits for apps read from a device
The schema built from a Sox session spelled checksums as signed decimals,
so no kit matched a manifest: Installed showed raw checksums and Action
read "Keep at checksum ...". They are now 8 hex digits like a .sax file,
and checksums compare regardless of zero-padding.

A device session only loads folders as they are opened, so only sys
counted as used. Used kits now come from the whole component tree, read
one request at a time so a small controller is not flooded into dropping
the session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:10:53 +03:00
ArdaandClaude Opus 5.5 088fe69964 Add Rename to the component right-click menu
Rename is offered from both the canvas and the navigation tree. Names are
checked against Sedona's Component.checkName rules (1-7 ASCII chars, a
letter first, then letters, digits or '_') and must be unique among
siblings. In file mode link paths through the old name are rewritten and
the rename is one undo step; in live mode it goes to the device via Sox
and warns if the device rejects it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:22:49 +03:00
ArdaandClaude Opus 5.5 a2d4be6ea8 Make every live edit reach the device, and warn when it doesn't
In live mode the device is now written first and the document only takes
what the device accepted. Every live_hooks failure records a reason, and
warn_failure() shows it in a dialog and the status bar instead of failing
silently.

- Move, properties, OX page editor: rejected values are rolled back and listed
- Add/remove link: no longer edits the DOM alone when an end can't be resolved
- Add/delete component: report why the device refused
- Tidy Layout: sends each new position to the device, reverts refusals,
  marks the document dirty, and pushes no undo step in live mode
- User Manager: add (one Sox add carrying cred/perm/prov), edit and delete
  now go to the device
- Binary Buf slots (e.g. User.cred) are sent as Buf bytes, not as Str text
- Bump version to 0.0.0.136

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:16:00 +03:00
Arda f15fc42363 WIP: auto-sync from AA-DELLDESKTOP 2026-09-30 13:55 2026-09-30 13:55:07 +03:00
Arda 35ab61cefa WIP: auto-sync from AA-LENOVOHOME 2026-09-29 22:06 2026-09-29 22:06:49 +03:00
ArdaandClaude Opus 5.5 73d168fd87 Add a Properties view for the app root
The root tree row now names the app component, "app (<host:port>)" or
"app (<file>)", and offers Properties from its context menu and on
double-click. The live DOM's <app> element now carries its type and slot
values, and the refresh-by-id lookup finds it, so the view shows real
sys::App values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:04:03 +03:00
ArdaandClaude Opus 5.5 a9593de065 Recover kit checksums from a truncated Sox schema reply
The device builds the schema and version replies in a fixed 512-byte
packet and drops whatever does not fit. Read strings defensively, recover
a cut-off checksum from the one local manifest matching its prefix, and
fail with a clear message when the kit list cannot be recovered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:04:03 +03:00
Arda fd35e5c9bb WIP: auto-sync from AA-LENOVOHOME 2026-09-27 22:17 2026-09-27 22:17:46 +03:00
ArdaandClaude Opus 5.5 6a52603951 Add a "No image" entry to the Page Editor's image dropdown
It heads the list and empties imageFileName; a widget with no image shows
it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 22:10:18 +03:00
ArdaandClaude Opus 5.5 c01a192365 Pick Page Editor fonts and images from the platform's banks
The font and image rows are now dropdowns filled from <sedona home>/ontrol:
fontBank/imageBank on Rion, fontBankXL/imageBankXL on Orion. Orion lists
.oxfl fonts in landscape and .oxfp fonts in portrait, read from the
platform service's screenOrientation (loaded from the device in live mode
when the tree has not reached it). Images show their file extension while
the slot keeps the bare name. The font dialog is gone.

In portrait the Page Editor draws a 480x800 screen in
frame_orion_portrait.png, and the canvas renders text with the .oxfp
fonts, whose glyphs are stored a quarter turn anticlockwise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 21:15:43 +03:00
Arda 138d9927be WIP: auto-sync from AA-DELLDESKTOP 2026-09-25 13:59 2026-09-25 13:59:33 +03:00
Arda 691bedd542 WIP: auto-sync from AA-LENOVOHOME 2026-09-24 19:38 2026-09-24 19:38:10 +03:00
ArdaandClaude Opus 5.5 7b8c48f7c2 Add Page Editor toolbar toggles and widen the default window
Edit mode, snap to grid and the widgets/properties pane are now toggles on
the main toolbar while the Page Editor is open. Outside edit mode the page
and the property sheet are read-only; drags snap to the 10px grid when
snapping is on. The default window grows by the right pane's width so the
canvas is not squeezed when the pane shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:31:20 +03:00
ArdaandClaude Opus 5.5 e5cb04b8cf Render Ox widgets on the Page Editor canvas as the device draws them
The Page Editor canvas now paints widgets WYSIWYG instead of as
labelled placeholder boxes, following the ontrolOx draw() code and the
R-ION firmware:

- text drawn with the device's own bitmap fonts (fontBank .bin /
  fontBankXL .oxfl), Windows font fallback; alignment, clipping at the
  box edge, half-size subscript digits, transparent vs filled boxes;
  the line is centred on the box even when the font is taller
- each widget shows the text its `in` selects: NumericLabel formats,
  lookup items, Boolean true/false text and colours, date and time
- images from the local image banks, centred or cropped; missing ones
  drawn as the device's red/white checker; page background image
- 10x10 pixel grid over the page colour, with a Grid toggle
- widgets hit-tested by layout box so transparent labels stay
  clickable; hidden widgets shown as a dashed outline
- layout row gets an X/Y/Width/Height dialog
- scaled device frame cached per zoom (redraws ~15 ms)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:16:51 +03:00
ArdaandClaude Opus 5.5 43d4849533 Add Workbench-style property sheet to the Page Editor
The Page Editor's right pane now shows the selected widget's properties
under the widget tree, laid out like Workbench's Ox editor rather than
as raw Sedona slots:

- layout (x, y, width, height), font (name, size, style)
- alignment / transparent / subscriptDigits from the attributes bits
- shape, RGB565 colours with a swatch and picker
- DateLabelSet date options split out of dateFormatOptions
- lookup labelText editor (prefix, items, suffix) and NumericLabel
  printf-format editor, min/max as one row
- image name picker, slot link chooser for in/blink/visibility/touch
- Buf text decoded from base64 (Latin-1), plain text in live mode

Edits commit on Enter/focus-out/selection as one undo step and are
written to the device in live mode. Canvas drag-moves now use the same
path, so they are undoable and reach the device too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 13:52:01 +03:00
ArdaandClaude Opus 5.5 f241448a5b Show device host:port on tree root row in live mode
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 10:32:25 +03:00
ArdaandClaude Opus 5.5 f3446f32ad Open Page Editor at a per-device default zoom (Rion 100%)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 10:26:57 +03:00
ArdaandClaude Opus 5.5 7ca029e3ea Tint Page Editor canvas while the document has unsaved changes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 10:06:22 +03:00
ArdaandClaude Opus 5.5 26f9abb0dd Fix Page Editor hang on canvas click (TreeviewSelect feedback loop)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 10:01:13 +03:00
ArdaandClaude Opus 5.5 b0e769075f Flatten SOX slot registry through full base-type chain
Types with intermediate parents (e.g. ontrolOx::PushButtonImage ->
Widget -> Service -> Component) only got Component + own slots, so
config/runtime streams were decoded against the wrong slot list and
x/y/imageFileName came out wrong.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 09:01:59 +03:00
Arda fd951f5b82 WIP: auto-sync from AA-DELLDESKTOP 2026-09-23 15:20 2026-09-23 15:20:28 +03:00
ArdaandClaude Opus 4.6 d60b349cac Clear screen area in Rion frame image to transparent
Makes the 320x240 screen cutout at offset (76,62) transparent,
matching the Orion frame style so the page background color
shows through correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-23 10:22:09 +03:00
ArdaandClaude Opus 4.6 b0eb859f32 Add zoom support to OxPage editor with 50% default
Toolbar zoom buttons (in/out/reset/fit) and Ctrl+MouseWheel now
control the page editor canvas when it is active. Frame image is
scaled via PIL for smooth rendering at any zoom level.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-22 23:19:13 +03:00
ArdaandClaude Opus 4.6 74c169a401 Move widget tree pane to the right side of Page Editor
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-22 21:35:57 +03:00
ArdaandClaude Opus 4.6 cbf1010c16 Rename frame_rion_portrait.png to frame_orion_portrait.png
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-22 21:12:01 +03:00
ArdaandClaude Opus 4.6 9e2c063880 Add device frame images for Orion, Rion and Rion portrait
Three PNG frames in assets/ with transparent screen cutouts:
- frame_orion.png (1144x849, screen at 191,169 = 800x480)
- frame_rion.png (433x396, screen centered at 76,62 = 320x240)
- frame_rion_portrait.png (856x1134, screen at 190,170 = 480x800)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-22 21:10:37 +03:00
ArdaandClaude Opus 4.6 16edd144e9 Add OxPage WYSIWYG editor panel for ontrolOx HMI pages
New ox_page_view.py with OxPageViewPanel: canvas rendering at device
resolution (800x480 Orion, 320x240 Rion), RGB565 color conversion,
widget tree, selection handles, drag-move, device frame image support.
Wired into main.py with context menu, history, panel lifecycle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-22 21:06:56 +03:00
ArdaandClaude Opus 4.6 d5ef8eea6f Improve User Manager: Workbench-style permissions dialog, live mode support, double-click to open
- Permissions dialog now uses two-level UI: compact Edit User with >> buttons
  opening a separate grid dialog (Groups x Operator/Admin columns) with
  Clear All / Set All and auto-cascade (write implies read, admin implies operator)
- Provisioning has its own >> popup dialog
- Removed live mode restrictions for add/edit/delete user operations
- Broadened type detection and added safe int conversion for live mode values
- Double-click on tree node or canvas box opens special view (User Manager for
  UserService, Properties for other components)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-22 20:57:49 +03:00
ArdaandClaude Opus 4.6 43549046e5 Add User Manager panel for sys::UserService components
New user_service_view.py with table of sys::User children, add/edit/delete
dialogs, SHA-1 credential hashing, 32-bit permission bitmask (4 groups x 7
flags), 8-bit provisioning byte, and undo support. Wired into main.py with
context menu entry, panel management, history navigation, and live-mode DOM
resolution for correct display on connected devices.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-22 20:10:03 +03:00
ArdaandClaude Opus 4.6 190ab79bdc Add TODO.md for task tracking
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-22 12:54:42 +03:00
ArdaandClaude Opus 4.6 a60907e996 Update graphify config to Python 3.14 and set watch root to raw/
Bump version to 0.0.0.115.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-21 13:45:44 +03:00
Arda b74e4fa373 WIP: auto-sync from AA-LENOVOHOME 2026-09-20 22:16 2026-09-20 22:16:01 +03:00
ArdaandClaude Opus 4.6 42c93bdea7 Add architecture traces document covering 14 codebase flows
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>
2026-09-20 14:45:32 +03:00
ArdaandClaude Opus 4.6 abdafef7cb Use manifest defaults when adding components and refresh properties from device
- Populate prop elements with manifest default values when adding
  components in both offline and live mode
- Send config values from manifest in SOX add command for live devices
- Refresh button in properties panel now fetches fresh values from
  the connected device before re-reading the DOM
- Properties panel fetches device values on open in live mode

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-20 00:14:52 +03:00
ArdaandClaude Opus 4.6 2530bbd92c Implement live device add/delete component and app actions
Fix add component from palette in live mode: resolve <app> element
as comp_id 0, fix type_id lookup to use type_name_map, set type_name
on newly added SoxComponent, sync device values into DOM after add,
write meta position back to device, and subscribe new component to COV.

Fix delete component in live mode: clean up subscriptions for deleted
component and all its descendants.

Add tree root context menu with device app actions (save, hibernate,
quit, restart, reboot) in live mode. Device save clears dirty state.

Clean up connection lost handling to do a full disconnect.
Fix slot registry to copy base slots and renumber IDs correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-19 16:30:42 +03:00
ArdaandClaude Opus 4.6 9a483a0b0e Add descriptive hints to DASP connection errors
Authentication failures now show "check username and password", busy
devices say "device is busy", etc.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-19 14:19:24 +03:00
ArdaandClaude Opus 4.6 021361f836 Allow empty password in Connect and Discover dialogs
Some devices have no password configured, so only username is required.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-19 14:17:15 +03:00
ArdaandClaude Opus 4.6 05d0ac8c03 Validate username and password in Connect dialog too
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-19 13:51:34 +03:00
ArdaandClaude Opus 4.6 dd236dd28a Validate username and password in discovery credentials dialog
Show an error if either field is empty before attempting to connect.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-19 13:49:26 +03:00
ArdaandClaude Opus 4.6 55f76d60df Add Device > Discover menu with standalone discovery dialog
Discover dialog shows a device list with Port, Wait Time fields and a
Discover button. Selecting a device and clicking Connect (or double-
clicking) opens a credentials prompt for username/password, then
connects. The Connect dialog is reverted to its original simple form.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-19 13:42:18 +03:00
ArdaandClaude Opus 4.6 ef5437ed36 Add DASP device discovery to the connect dialog
Send DISCOVER broadcast (type 0) to 255.255.255.255 and the Sedona
multicast address 239.255.18.76 to find devices on the network.
Responses are listed in a Treeview showing IP and platform ID.
Selecting a device fills the Host field; double-click connects.
Wait time is configurable (default 10s, range 1-60s).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-19 13:28:17 +03:00
ArdaandClaude Opus 4.6 f3692e4587 Fix DASP hostname resolution so localhost and hostnames work
The dispatch check compared the raw hostname string against the UDP
source IP, silently dropping responses when they differed (e.g.
"localhost" vs "127.0.0.1"). Resolve hostname to IP at session init.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-19 12:19:41 +03:00
Arda 96f3be841d WIP: auto-sync from AA-LENOVOHOME 2026-09-13 21:50 2026-09-13 21:50:42 +03:00
ArdaandClaude Opus 4.6 60c022828d Fix live-mode link navigation not selecting the target link
In live mode the DOM is rebuilt from the device when switching sheets,
creating new element objects. The pending link from the old tree could
not be found by identity in canvas_link_map. Re-resolve it by from/to
attributes after the rebuild so the selection matches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 22:15:48 +03:00
ArdaandClaude Opus 4.6 7dea3d744f Improve DASP trace: use S/R labels, add timestamp and full hex datagram
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 22:08:12 +03:00
ArdaandClaude Opus 4.6 483edc117e Update editor.properties with device defaults and trace setting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 21:52:52 +03:00
ArdaandClaude Opus 4.6 c59cd22153 Fix live-device slot ID resolution, add print and tighten wire routing
_slot_name_by_id() now resolves SOX link slot IDs against the
inheritance-flattened final_components_registry instead of the
per-type slot_registry whose local IDs were wrong for types with
intermediate parents (e.g. RegFloat→Register→Component). This
produced incorrect link endpoints, inflated linked-slot visibility,
and different box sizes between file and live modes.

Also: tighten routing constants for denser wiring, add wiresheet
PNG print/export (Ctrl+P), sort link routing jobs by gap distance,
remove debug route logging, and fix live tree-expand re-selection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 21:51:22 +03:00
ArdaandClaude Opus 4.6 6cd030c7ea Fix live wiresheet config/runtime slot alignment and cap float display to 2 decimals
Slots with no flags (sensor, controlSignal, out_*) were not skipped
during config property reads, consuming bytes that the device never
sent and misaligning every subsequent config value (setpoint,
proportionalBand, etc.) into tiny denormalized floats like 2.36875e-41.

Changed the filter in _apply_props from "skip if runtime" to "skip if
not config" so only explicitly config-flagged slots are read from config
messages. Also switched iter("slot") to findall("slot") in the slot
registry builder to avoid picking up nested descendants.

Float display now defaults to 2 decimal places everywhere (wiresheet
and properties panel), honouring the precision facet when one exists.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 15:46:44 +03:00
ArdaandClaude Opus 4.6 48671585dd Ensure live float values always display with a decimal point
Adds _fmt_float() helper that formats 32-bit floats with :.6g precision
but appends .0 when the result has no decimal point, matching Niagara
and .sax file display conventions (e.g. 175.0 not 175, 0.0 not 0).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 14:10:35 +03:00
ArdaandClaude Opus 4.6 ef80bf9a37 Format live float values to 6 significant digits matching 32-bit precision
Sox reads 32-bit floats that Python promotes to 64-bit doubles, producing
display artifacts like 22.011558532714844. Using :.6g format gives clean
values matching .sax file precision (22.0116, -19.9574, 175, 14).
Applied to both initial DOM build and COV update paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 14:03:04 +03:00
ArdaandClaude Opus 4.6 e33a6bc5d8 Guard file open and device connect against conflicting state
Opening a file while connected to a device prompts to disconnect first.
Connecting to a device while a file is open prompts to save unsaved
changes, then closes the file before showing the connect dialog.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 13:58:55 +03:00
ArdaandClaude Opus 4.6 89aab50948 Fix live wiresheet links and DOM structure to match .sax format
- Build root children directly under <app> instead of wrapping them in
  a <comp name="app"> element. This matches the .sax DOM structure
  where <app> is the container, so component paths like /AHU/TmpCont/X
  match between link references and get_component_path.

- Fix _comp_path to produce /AHU/TmpCont/X format (skip root id 0,
  leading /) matching the .sax link path convention.

- Format float property values to 4 decimal places instead of full
  Python float precision (22.0116 vs 22.011558532714844).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 13:55:12 +03:00
ArdaandClaude Opus 4.6 4e38847f91 Fix live wiresheet: resolve type names and load schema gracefully
- Add type_name_map on SoxClient so components loaded after connect
  resolve proper names (e.g. control::And2) instead of kit::typeN.
  This lets the slot registry and final_components_registry match,
  so component boxes show their slots and property values.

- Load live schema gracefully: skip missing kit manifests instead of
  aborting, compile the kits we have, and warn about missing ones in
  the status bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 13:43:44 +03:00
ArdaandClaude Opus 4.6 4c90c1c241 Apply offline wiresheet rules to live mode: meta positions and links
Three fixes so live wiresheets render identically to offline .sax files:

1. Inherit sys::Component base slots (meta) into every type's slot
   registry so component x/y positions are read from the device.

2. Place <link> elements under a <links> container element instead of
   directly under <app>, matching the .sax structure that draw_links
   and build_linked_slot_index expect.

3. Deduplicate links (each link appears on both endpoints in Sox) so
   wires are not drawn twice.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 13:14:02 +03:00
ArdaandClaude Opus 4.6 599370f60c Put Device menu first in menu bar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 13:05:23 +03:00
ArdaandClaude Opus 4.6 a0e5ec1a31 Move Device menu after File in the menu bar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 13:04:15 +03:00
ArdaandClaude Opus 4.6 b4a504e57d Fix connect dialog: button order and cancel-during-connection cleanup
Swap Connect/Cancel button order so Connect is the default (leftmost).
Guard background thread against cancelled state so a cancelled connect
does not leave the app in live mode. Cancel during connection now
disconnects the session cleanly in a cleanup thread.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 09:41:21 +03:00
ArdaandClaude Opus 4.6 0674f32637 Pre-load root children on connect and add placeholders for uncached nodes
On connect, also loads root's immediate children so the first tree level
shows real names. For deeper levels, inserts placeholder <comp> elements
for uncached children so the Treeview shows + expand arrows for folders
that have children on the device.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 09:20:49 +03:00
ArdaandClaude Opus 4.6 d32285abe8 Fix tree expand in live mode: use focused item and run IO in background
_on_tree_open used tree.selection() which may differ from the opened
node; switched to tree.focus(). Network calls in _live_tree_expand now
run in a daemon thread so the UI stays responsive during lazy loading.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 09:11:14 +03:00
ArdaandClaude Opus 4.6 67c403339c Fix live tree selection lost after wiresheet DOM rebuild
After open_wiresheet rebuilds the DOM and tree, re-find the target
element by its comp id (not the old tree item ID which no longer
exists) and re-select it with the restoring flag to prevent recursion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-12 00:24:10 +03:00
ArdaandClaude Opus 4.6 1fd6e72b8c Fix DASP close deadlock and kit checksum sign display
Use RLock instead of Lock for _close_lock so close() can call
_shutdown() without deadlocking — previously the session never
marked itself closed, causing infinite keepalive sends.

Mask kit checksums to unsigned 32-bit for hex display so negative
i4 values render as proper 8-digit hex (e.g. d3984c51 not -2c67b3af).

Tested against live device at 192.168.1.251 — full handshake,
schema read, and component tree load confirmed working.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-11 23:57:19 +03:00
ArdaandClaude Opus 4.6 643bcdc952 Add Trace DASP preference to print protocol transactions to terminal
New checkbox in Preferences (default off). When enabled, every DASP
send (>>>) and receive (<<<) prints message type, seq/ack numbers,
payload size, and remote address to stdout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-11 23:48:10 +03:00
ArdaandClaude Opus 4.6 4431f11d83 Fix crash on tree select when item no longer exists after DOM rebuild
Guard tree.get_children() with tree.exists() so a stale item ID from
a COV or lazy-load rebuild does not raise TclError.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-11 23:43:22 +03:00
ArdaandClaude Opus 4.6 dafebcebda Live Editor: add DASP/Sox device connection with full CRUD and COV
Port the DASP transport and Sox protocol stack from sedonaOntrol Java
to Python, enabling live device editing alongside the existing offline
SAX editor. Lazy-loads components on navigation, caches them, and
subscribes to COV events for the active wiresheet.

New files: dasp.py, sox.py, device_session.py, connect_dialog.py,
live_hooks.py. Modified: app_state.py (live-mode state), main.py
(Device menu, COV polling, lazy tree expand), properties.py (Sox
write-through), sax_file.py (structural mutations route through
live_hooks), schema.py (find_manifest helper).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-11 23:31:36 +03:00
ArdaandClaude Opus 4.6 48dfd82832 Kit Manager: detect unsupported kits via platform database
Scan sedona/platforms/db/ to find which native kits are compiled for
the SAX's platform.  Kits with native code for other platforms, kits
providing a PlatformService for a different device, and their
transitive dependents are placed in a "Not supported" section with a
red-cross checkbox that cannot be toggled.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-11 14:21:00 +03:00
ArdaandClaude Opus 4.6 f1cc15f70f Export XLS: add timestamp to default filename
The save dialog now suggests e.g. normal_kits_20260911_143200.xlsx
so successive exports never collide.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-11 13:17:39 +03:00
ArdaandClaude Opus 4.6 d3d141cca8 Kit Manager: add Export as XLS button
Saves the kit table to an Excel file with section headers, check
marks, and all four data columns. Default filename is based on the
open SAX file.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-11 13:06:30 +03:00
ArdaandClaude Opus 4.6 db8472371e Kit Manager: rename section to "Kits available on the computer"
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-09 08:26:40 +03:00
Arda 3fde5bdcc1 Revert "Kit Manager: split available kits by dependency compatibility"
This reverts commit 61888396a5.
2026-09-09 08:25:18 +03:00
62 changed files with 53677 additions and 624 deletions
File diff suppressed because it is too large Load Diff
+13 -9
View File
@@ -47,7 +47,7 @@ not inside the window. Tk always hands it to the system menu bar.
| `app_state.py` | Preferences, runtime state, widget handles, `mark_dirty`/`mark_clean` | no | | `app_state.py` | Preferences, runtime state, widget handles, `mark_dirty`/`mark_clean` | no |
| `sax_dom.py` | `meta` bit-packing codec, component path resolution | no | | `sax_dom.py` | `meta` bit-packing codec, component path resolution | no |
| `schema.py` | Kit manifest loading, slot inheritance, slot visibility rules | no | | `schema.py` | Kit manifest loading, slot inheritance, slot visibility rules | no |
| `undo.py` | Undo/redo stack of reversible DOM commands | no | | `undo.py` | Undo stack of reversible commands (DOM, and the device in live mode) | no |
| `wiresheet.py` | Grid, component boxes, link routing and painting | yes | | `wiresheet.py` | Grid, component boxes, link routing and painting | yes |
| `properties.py` | The Properties view: every slot of one component, read and written | yes | | `properties.py` | The Properties view: every slot of one component, read and written | yes |
| `link_view.py` | The Link View: every link touching one component, drawn around it | yes | | `link_view.py` | The Link View: every link touching one component, drawn around it | yes |
@@ -74,7 +74,16 @@ invisible to every other module. Every mutation of "the document changed" goes t
(which also removes the `<kit>` entry placing it may have added) and (which also removes the `<kit>` entry placing it may have added) and
`DeleteComponentCommand` (which restores the removed `<link>` entries at their original `DeleteComponentCommand` (which restores the removed `<link>` entries at their original
indices, reinserting in ascending order so the indices stay valid) and `SlotValueCommand` indices, reinserting in ascending order so the indices stay valid) and `SlotValueCommand`
(one Save from the Properties view, however many slots it changed). Nothing in (one Save from the Properties view, however many slots it changed). There is no redo.
On a device the same steps are undoable, and each undo is sent there: `undo.py` has
live subclasses for moves, slot edits and renames, and `sax_file` has
`LiveComponentCommand` and `LiveLinkCommand`. Deleting a component first reads its
whole subtree back from the device (config, children, links, place among its
siblings), so undo can recreate it; the device hands out new ids, which the DOM
elements take on. An undo that does not fully reach the device warns and clears the
history, and so does every view change, since opening a sheet rebuilds the DOM from the
device. Nothing in
`undo.py` touches Tk; `main` registers a refresh callback at startup, which is what keeps `undo.py` touches Tk; `main` registers a refresh callback at startup, which is what keeps
`sax_file` and `wiresheet` free to push commands without an import cycle. `sax_file` and `wiresheet` free to push commands without an import cycle.
@@ -84,7 +93,6 @@ Undoing back to the depth of the last save clears the dirty tint rather than lea
|---|---| |---|---|
| `Ctrl`/`Cmd` + `S` | Save (direct overwrite) | | `Ctrl`/`Cmd` + `S` | Save (direct overwrite) |
| `Ctrl`/`Cmd` + `Z` | Undo | | `Ctrl`/`Cmd` + `Z` | Undo |
| `Ctrl`/`Cmd` + `Y` or `Ctrl`/`Cmd` + `Shift` + `Z` | Redo |
| `Del` / `Backspace` | Delete selected component | | `Del` / `Backspace` | Delete selected component |
| `Ctrl`/`Cmd` + `B` | Show or hide the tree/palette column | | `Ctrl`/`Cmd` + `B` | Show or hide the tree/palette column |
| `Ctrl` + `W` | Tidy Layout: re-flow the open sheet | | `Ctrl` + `W` | Tidy Layout: re-flow the open sheet |
@@ -633,12 +641,8 @@ And two things worth knowing about the manifests themselves rather than the code
## Known gaps ## Known gaps
- The yellow square in each box header is drawn but wired to nothing; it is the natural - The yellow square in each box header is a type-icon placeholder that is drawn but not yet
place for a per-box collapse/expand toggle. functional; no icon set has been wired to it.
- Undo covers box moves, adds and deletes. Nothing else pushes a command, so a change made
outside those three is not reversible.
- `on_canvas_release` snaps using the rounded-rectangle polygon's first coordinate,
which is `x1 + radius` rather than `x1`, so snapping is offset by the corner radius.
- Slot values are edited from the Properties view only; a wiresheet box row is still - Slot values are edited from the Properties view only; a wiresheet box row is still
display-only. display-only.
- The Link View stacks each side in one column, so a component with a dozen peers makes a - The Link View stacks each side in one column, so a component with a dozen peers makes a
+16
View File
@@ -0,0 +1,16 @@
# TODO List
## High Priority
- [ ]
## Medium Priority
- [ ]
## Low Priority
- [ ]
## Done
+908
View File
@@ -0,0 +1,908 @@
"""App > Get with Files / Put with Files: a full backup of the device's app as one
zip, and putting such a zip back onto a device.
The zip follows the layout Ontrol's tools read back on a restore:
device/app.sab the files as the device holds them
device/kits.scode
device/images/<name>.bin
device/fonts/<font file>
device/system/logo.jpg Orion's boot logo; Rion's is system/logo.bin
app.sax app.sab decoded, as sedonac would write it
imageBank[XL]/<name>.png the sources of the device's images and fonts, taken from
fontBank[XL]/<font file> Sedona home's banks (XL for Orion's 800x480 screen)
system/logo.png
The device cannot list its files, so which images and fonts to fetch is read from the
app itself: every image name and font its Ox widgets and pages name.
A put stages app.sab and kits.scode for the VM to take up on its next start; see
put_with_files for the order it goes in.
"""
import datetime
import os
import re
import zipfile
import app_state
import kit_build
import palette
import sab_app
import schema
import sox
from ox_widget_properties import IMAGE_EXTENSIONS, ORION_KIT, ORION_PLATFORM_TYPE, RION_KIT
APP_SAB = "app.sab"
KITS_SCODE = "kits.scode"
_IMAGE_SLOTS = ("imageFileName", "trueImageFileName", "falseImageFileName")
_SOURCE_IMAGE_EXTENSIONS = tuple(e for e in IMAGE_EXTENSIONS if e != ".bin")
_UNSAFE_FILE_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
class _Platform:
def __init__(self, name, kit, service_type, image_bank=None, font_bank=None,
font_extensions=(), logo_uri=None, screen=None):
self.name = name
self.kit = kit # the kit only this platform's apps hold
self.service_type = service_type # its platform service
self.platform_id = "ontrol-" + name.lower() # the device's id starts so
self.image_bank = image_bank
self.font_bank = font_bank
self.font_extensions = font_extensions
self.logo_uri = logo_uri
self.screen = screen # (width, height) in landscape; None: none
# Orion keeps a landscape (.oxfl) and a portrait (.oxfp) cut of each font, and its boot
# logo as a jpg; Rion's is a native image (Ontrol's BGetWithFilesTool). A P-ION has no
# screen: nothing but the app goes to it.
_ORION = _Platform("ORION", ORION_KIT, ORION_PLATFORM_TYPE, "imageBankXL", "fontBankXL",
(".oxfl", ".oxfp"), "system/logo.jpg", (800, 480))
_RION = _Platform("RION", RION_KIT, "ontrolRion::RionPlatformService", "imageBank",
"fontBank", (".bin",), "system/logo.bin", (320, 240))
_PION = _Platform("PION", "ontrolPion", "ontrolPion::PionPlatformService")
_PLATFORMS = (_ORION, _RION, _PION)
def platform_of(app):
return platform_of_kits(app.kit_names())
def platform_of_kits(kit_names):
kit_names = set(kit_names)
return next((p for p in _PLATFORMS if p.kit in kit_names), None)
def platform_label(platform):
return platform.name if platform else "no platform this editor knows"
def backup_folder():
return os.path.join(app_state.SEDONA_HOME, "store", "apps")
def backup_file_name(device_name, app_name, when=None):
"""'app-<device name>-<app name>-<yyyyMMdd-HHmmss>.zip', made safe for Windows."""
stamp = (when or datetime.datetime.now()).strftime("%Y%m%d-%H%M%S")
parts = [_UNSAFE_FILE_CHARS.sub("_", p).strip(" .") or "unnamed"
for p in (device_name, app_name)]
return f"app-{parts[0]}-{parts[1]}-{stamp}.zip"
class MediaFile:
"""One image or font the app names: where it lives on the device and in the bank.
`required` is False for guesses -- a bold font's plain cut, Orion's other font
orientation, an indexed page image -- whose absence is not worth reporting.
`group` ties the alternatives of one need together: the need is met when any of
them is found.
"""
def __init__(self, device_uri, bank_folder, bank_name, group, required=True):
self.device_uri = device_uri
self.bank_folder = bank_folder
self.bank_name = bank_name
self.group = group
self.required = required
def _font_stem(app, comp):
slot = app.slot(comp, "fontName")
names = [n.strip() for n in (slot.facets.get("range") or "Tahoma").split(",")] if slot else ["Tahoma"]
index = app.prop(comp, "fontName", 0) or 0
family = names[index] if 0 <= index < len(names) else names[0]
size = app.prop(comp, "fontSize", 16)
bold = app.prop(comp, "fontAttributes", 0) == 1
return f"{size}pt {family}", bold
def media_files(app, platform):
"""The images and fonts the app names, each with its device path and bank source."""
files, seen = [], set()
if platform.screen is None:
return files
def add(uri, bank_folder, bank_name, group, required=True):
if uri not in seen:
seen.add(uri)
files.append(MediaFile(uri, bank_folder, bank_name, group, required))
def add_image(name, required=True):
if name:
add(f"images/{name}.bin", platform.image_bank, name, f"image {name}", required)
def add_font(stem, group, required):
for i, ext in enumerate(platform.font_extensions):
add(f"fonts/{stem}{ext}", platform.font_bank, stem + ext, group,
required and i == 0)
for comp in app.comps.values():
for slot_name in _IMAGE_SLOTS:
add_image(app.prop(comp, slot_name, ""))
prefix = app.prop(comp, "imageFileNamePrefix", "")
if prefix:
for i in range((app.prop(comp, "maxIndex", 1) or 0) + 1):
add_image(f"{prefix}{i}")
page_image = app.prop(comp, "imageFileName", "")
if page_image:
for i in range(1, (app.prop(comp, "maxImageId", 0) or 0) + 1):
add_image(f"{page_image}{i}", required=False)
if app.slot(comp, "fontSize") is not None:
stem, bold = _font_stem(app, comp)
group = f"font {'bold ' if bold else ''}{stem}"
if bold:
add_font(f"bold {stem}", group, True)
add_font(stem, group, not bold)
add(platform.logo_uri, platform.image_bank, "logo", "logo", required=False)
return files
def _bank_source(bank_folder, bank_name, is_image):
"""The local bank file for a device file, or None."""
folder = os.path.join(app_state.SEDONA_HOME, "ontrol", bank_folder)
if not is_image:
path = os.path.join(folder, bank_name)
return path if os.path.isfile(path) else None
for ext in _SOURCE_IMAGE_EXTENSIONS + (".bin",):
path = os.path.join(folder, bank_name + ext)
if os.path.isfile(path):
return path
return None
def _source_entry(media, source_path):
"""Where the bank file goes in the zip: next to its bank's name, the logo in system/."""
file_name = os.path.basename(source_path)
if media.device_uri.startswith("system/"):
return "system/logo" + os.path.splitext(file_name)[1]
return f"{media.bank_folder}/{file_name}"
def save_app(client):
"""Invokes App.save on the device and waits for it to finish.
Ontrol's Niagara driver does this before every app.sab get.
"""
comp = client.cache_get(0) or client.load(0)
slots = client.slot_registry.get((comp.kit_id, comp.type_id)) or []
slot = next((s for s in slots if s["name"] == "save" and s.get("is_action")), None)
client.invoke(0, slot["id"] if slot else 0)
class BackupResult:
def __init__(self):
self.path = None
self.entries = []
self.missing_on_device = [] # media the app names that the device lacks
self.missing_in_bank = [] # device files with no source in Sedona home
self.sax_error = None # why app.sax could not be written
def get_with_file(client, path, status=None):
"""Reads the app and its media off the device and writes the backup zip to `path`.
The app is saved on the device first, so app.sab holds what is running. Runs off
the main thread; status(text, fraction) reports progress. Returns a BackupResult;
a failure to save, or to read app.sab or kits.scode, raises.
"""
def report(text, fraction=None):
if status:
status(text, fraction)
def fetch(uri, base, span):
def progress(done, total):
report(f"Reading {uri} ...", base + span * (done / total if total else 1.0))
report(f"Reading {uri} ...", base)
try:
return client.get_file(uri, progress)
except Exception as e:
raise RuntimeError(f"Reading {uri}: {e}") from e
result = BackupResult()
entries = [] # (zip name, bytes or local path)
report("Saving the app on the device ...", 0.0)
try:
save_app(client)
except Exception as e:
raise RuntimeError(f"Saving the app on the device: {e}") from e
sab = fetch(APP_SAB, 0.0, 0.15)
entries.append(("device/" + APP_SAB, sab))
entries.append(("device/" + KITS_SCODE, fetch(KITS_SCODE, 0.15, 0.35)))
app = None
try:
app = sab_app.SabApp(sab)
entries.append(("app.sax", app.to_sax().encode("ascii")))
except Exception as e:
result.sax_error = str(e)
platform = platform_of(app) if app else None
media = media_files(app, platform) if platform else []
found_groups, wanted = set(), {}
for i, m in enumerate(media):
report(f"Reading {m.device_uri} ...", 0.5 + 0.45 * i / max(1, len(media)))
if m.required:
wanted.setdefault(m.group, m.device_uri)
try:
data = client.get_file(m.device_uri)
except Exception:
if client.is_closed:
raise
continue
found_groups.add(m.group)
entries.append(("device/" + m.device_uri, data))
source = _bank_source(m.bank_folder, m.bank_name, m.device_uri.startswith(("images/", "system/")))
if source:
entries.append((_source_entry(m, source), source))
else:
result.missing_in_bank.append(f"{m.bank_folder}/{m.bank_name}")
result.missing_on_device = [uri for group, uri in wanted.items() if group not in found_groups]
report("Writing the zip ...", 0.97)
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
partial = path + ".part"
try:
with zipfile.ZipFile(partial, "w", zipfile.ZIP_DEFLATED) as z:
for name, content in entries:
if isinstance(content, bytes):
z.writestr(name, content)
else:
z.write(content, name)
os.replace(partial, path)
finally:
if os.path.exists(partial):
os.remove(partial)
result.path = path
result.entries = [name for name, _ in entries]
report("Done", 1.0)
return result
# --- Put with Files ---------------------------------------------------------------------
#
# Follows Ontrol's Workbench tool (ontrolSedonaOx BPutWithFilesTool, ontrolSedonanet
# PutApp): the backup's app.sax is encoded for the chosen kit builds, kits.scode built
# for them with sedonac when they are not the device's (kit_build); both go as
# '.writing' renamed to '.stage'; the platform service's deleteFile clears fonts/* and
# images/*; then the images, the fonts and the boot logo go. Fonts come from Sedona
# home's bank in the cut the device draws with, images in the form its screen takes.
_FONT_FOLDERS = ("device/fonts/", "fonts/", "fontBank/", "fontBankXL/")
_JPG = (".jpg", ".jpeg")
def _checksum(value):
"""A kit checksum as an unsigned int, from a signed Sox int or a .sab hex string."""
return int(value, 16) if isinstance(value, str) else value & 0xFFFFFFFF
def kits_fit(app_kits, device_kits):
"""Whether the device's kits.scode can load an app.sab of `app_kits`.
App.loadSchema's rule: the app's kits must appear among the device's, in the same
order, each with the same checksum; the device may hold more.
"""
device = iter((n, _checksum(c)) for n, c in device_kits)
return all(any(d == (n, _checksum(c)) for d in device) for n, c in app_kits)
class DeviceTarget:
"""What a put needs to know of the connected device."""
def __init__(self, platform, kits, name=None, portrait=False,
platform_comp_id=None, delete_slot_id=None, platform_id=""):
self.platform = platform # one of _PLATFORMS, or None
self.platform_id = platform_id # e.g. "ontrol-pion-b427a5b9"
self.kits = kits # [(name, checksum)] in kit-id order
self.name = name # the app's deviceName
self.portrait = portrait # Orion's screenOrientation
self.platform_comp_id = platform_comp_id
self.delete_slot_id = delete_slot_id
@property
def font_extension(self):
if self.platform is _ORION:
return ".oxfp" if self.portrait else ".oxfl"
return ".bin"
@property
def screen(self):
"""(width, height) as the device is set up, as Ontrol's ScreenInfo has it."""
w, h = self.platform.screen
return (h, w) if self.portrait else (w, h)
def device_target(ds):
"""Reads the platform, kits, deviceName and platform service off the device.
The platform comes from the platform id, as Ontrol's tools read it, the kits
standing in for a device that does not give one.
"""
client = ds.sox_client
kits = [(p.name, p.checksum) for p in client.read_schema()]
kit_names = {n for n, _ in kits}
platform_id = (client.read_version().platform_id or "").lower()
platform = (next((p for p in _PLATFORMS if platform_id.startswith(p.platform_id)), None)
or next((p for p in _PLATFORMS if p.kit in kit_names), None))
target = DeviceTarget(platform, kits, platform_id=client.read_version().platform_id)
comp = client.cache_get(0)
if comp is not None:
client.update([comp], sox.CONFIG)
target.name = comp.values.get("deviceName") or None
if platform is not None and platform.screen is not None:
plat = ds.find_component_of_type(platform.service_type, "screenOrientation")
if plat is not None:
target.platform_comp_id = plat.id
target.portrait = (platform is _ORION and
str(plat.values.get("screenOrientation")).lower() == "true")
slots = client.slot_registry.get((plat.kit_id, plat.type_id)) or []
target.delete_slot_id = next((s["id"] for s in slots if s["name"] == "deleteFile"
and s.get("is_action")), None)
return target
# --- images in the device's form --------------------------------------------------------
def native_image(data, name, target):
"""A png (or any image PIL reads) as the device's native .bin.
Ported from OntOxImageUtil.convertImageToNativeFormat: a 32-byte name, a 32-byte
header (flags, width, height), then the pixels. Orion draws BGR(A), turned for a
portrait screen, with alpha unless the image fills the screen; Rion RGB565, with
alpha only for a translucent image smaller than the screen.
"""
import io
from PIL import Image
img = Image.open(io.BytesIO(data))
img.load()
width, height = img.size
translucent = img.mode in ("RGBA", "LA", "PA") or (
img.mode == "P" and "transparency" in img.info and
any(0 < a < 255 for a in img.convert("RGBA").getchannel("A").getdata()))
background = (width, height) == target.screen
orion = target.platform is _ORION
rgb_mode = use_alpha = orion and not background
if not orion:
use_alpha = translucent and not background
rotate = orion and target.portrait
rgba = img.convert("RGBA")
if rotate: # the Java walks columns right to left: a quarter turn anticlockwise
rgba = rgba.transpose(Image.Transpose.ROTATE_90)
w, h = rgba.size
out = bytearray(name.encode("ascii", errors="replace"))
out += bytes(max(0, 32 - len(out)))
out += bytes([(4 if rotate else 0) + (2 if rgb_mode else 0) + (1 if use_alpha else 0),
w & 0xFF, w >> 8, h & 0xFF, h >> 8]) + bytes(27)
pixels = rgba.tobytes()
if rgb_mode:
for i in range(0, len(pixels), 4):
r, g, b, a = pixels[i:i + 4]
out += bytes((b, g, r, a)) if use_alpha else bytes((b, g, r))
else:
for i in range(0, len(pixels), 4):
r, g, b, a = pixels[i:i + 4]
rgb565 = (r >> 3) << 11 | (g >> 2) << 5 | (b >> 3)
if use_alpha:
out.append(a)
out += bytes((rgb565 & 0xFF, rgb565 >> 8))
return bytes(out)
def device_jpg(data, target):
"""A jpg as the device takes it: turned a quarter anticlockwise for a portrait Orion
(OntOxImageUtil.rotateJpg), otherwise as it is."""
if not (target.platform is _ORION and target.portrait):
return data
import io
from PIL import Image
img = Image.open(io.BytesIO(data))
buf = io.BytesIO()
img.transpose(Image.Transpose.ROTATE_90).save(buf, "JPEG", quality=75)
return buf.getvalue()
# --- the plan ---------------------------------------------------------------------------
class PutFile:
def __init__(self, device_uri, data, source):
self.device_uri = device_uri
self.data = data
self.source = source # where the bytes come from, for the summary
class PutPlan:
"""What a put of the zip would do, and whether the zip fits the device."""
def __init__(self, path):
self.path = path
self.problems = [] # any of these stops the put
self.warnings = [] # worth confirming, but the put can go on
self.app_source = "" # how app.sab was come by, for the summary
self.kit_changes = [] # each chosen kit build that is not the device's
self.scode_source = "" # where kits.scode comes from, when it goes
self.app_files = [] # PutFile to '<name>.writing', renamed to '.stage'
self.media = [] # PutFile: images, fonts, logo
self.converted = 0 # images made from the backup's image bank
self.images_missing = [] # images neither in device form nor as a source
self.fonts_from_zip = [] # fonts the bank lacks; the backup's copy goes
self.fonts_missing = [] # fonts the app names that neither has
self.staged = False # set once the device holds app.sab.stage
self.has_screen = True # False for a P-ION: the app is all that goes
@property
def ok(self):
return not self.problems
def _app_portrait(app):
comp = next((c for c in app.comps.values() if c["type"] == ORION_PLATFORM_TYPE), None)
return bool(comp and app.prop(comp, "screenOrientation", False))
class KitRow:
"""One kit in the kits step of a put: the device's build, the backup's, and what to put.
`included` rows are what goes: into app.sab's schema and, when it is rebuilt, into
kits.scode. A `required` row -- a kit the app's components name, or one an included
kit depends on -- is always included.
"""
def __init__(self, name, device, backup, builds, used=False, locked=False,
unsupported=False):
self.name = name
self.device = device # checksum hex the device runs, or None
self.backup = backup # checksum hex the backup's schema names, or None
self.builds = builds # [(version, checksum hex)] Sedona home has, newest first
self.used = used # the app's components name a type of it
self.locked = locked # the backup holds only app.sab: nothing can change
self.unsupported = unsupported # cannot run on the device's platform
self.required = used
self.included = used or (device is not None and not unsupported) or \
(backup is not None and not unsupported)
self.use = self._default() # the chosen checksum hex
def _default(self):
"""The device's build, when Sedona home can describe it; then the backup's; then
the newest."""
known = {c for _, c in self.builds}
if self.locked:
return self.backup
for checksum in (self.device, self.backup):
if checksum in known:
return checksum
return self.builds[0][1] if self.builds else self.device or self.backup
@property
def latest(self):
return self.builds[0][1] if self.builds else None
def _hex(checksum):
return f"{_checksum(checksum):08x}"
def _app_source(z, names):
"""(sax entry or None, sab entry or None) of the backup's app."""
sax = "app.sax" if "app.sax" in names else next(
(n for n in sorted(names) if n.endswith(".sax") and "/" not in n), None)
sab = "device/" + APP_SAB
return sax, sab if sab in names else None
def _backup_kits(path):
"""([(kit, checksum or None)] the backup's schema names, {kits its components use},
whether it holds only app.sab). Raises ValueError when the zip holds no app."""
import xml.etree.ElementTree as ET
with zipfile.ZipFile(path) as z:
names = {n for n in z.namelist() if not n.endswith("/")}
sax_name, sab_name = _app_source(z, names)
if sax_name:
root = ET.fromstring(z.read(sax_name))
schema_elem = root.find("schema")
kits = [(k.get("name"), k.get("checksum"))
for k in (schema_elem.findall("kit") if schema_elem is not None else ())]
used = {c.get("type", "").split("::")[0] for c in root.iter("comp")}
return kits, used | {"sys"}, False
if sab_name:
app = sab_app.SabApp(z.read(sab_name))
used = {c["type"].split("::")[0] for c in app.comps.values()}
return app.kit_parts, used, True
raise ValueError("The zip holds no app: neither app.sax nor device/app.sab.")
def kit_rows(path, target):
"""Every kit the kits step lists, in Manage Kits' groups (see KitRow.group).
The kits the backup's app needs, those its schema or the device holds without the
app needing them, and every other kit Sedona home has. Each defaults to the device's
build. Raises ValueError when the zip holds no readable app.
"""
kits, used, locked = _backup_kits(path)
device = {n: _hex(c) for n, c in target.kits}
backup = {n: _hex(c) if c else None for n, c in kits}
names = list(dict.fromkeys([n for n, _ in kits] + list(device) + palette.list_kits()))
unsupported = palette.unsupported_kits(set(backup) | set(device))
rows = []
for name in names:
builds = [(v, _hex(c)) for v, c in palette.kit_builds(name)]
if locked and name not in backup:
continue # nothing can be added to a bare app.sab
rows.append(KitRow(name, device.get(name), backup.get(name), builds,
used=name in used, locked=locked,
unsupported=name in unsupported and name not in used))
settle_required(rows)
return rows
def settle_required(rows):
"""Marks every kit an included kit depends on as required -- and so included, at its
default build -- all the way down, as Manage Kits counts dependencies."""
by_name = {r.name: r for r in rows}
for row in rows:
row.required = row.used
pending = [r for r in rows if r.included or r.used]
while pending:
row = pending.pop()
for dep in schema.read_kit_dependencies(row.name, row.use):
dep_row = by_name.get(dep)
if dep_row is not None and not dep_row.required:
dep_row.required = dep_row.included = True
pending.append(dep_row)
for row in rows:
if row.used:
row.included = True
KIT_GROUPS = ("Installed kits", "Installed but not used kits",
"Kits available on the computer", "Not supported")
def group(row):
"""The Manage Kits section a row is listed under: the app needs it; the device or
the backup holds it without the app needing it; Sedona home has it; or it cannot run
on the device."""
if row.unsupported:
return KIT_GROUPS[3]
if row.required:
return KIT_GROUPS[0]
if row.device or row.backup:
return KIT_GROUPS[1]
return KIT_GROUPS[2]
def chosen_kits(rows):
"""[(name, checksum hex)] of the included rows."""
return [(r.name, r.use) for r in rows if r.included]
def plan_put(path, target, rows=None, rebuild=False):
"""Reads the zip at `path` and checks it against the device `target`.
rows are kit_rows() as the kits step left them; None takes the defaults. The
included kits make app.sab's schema; kits.scode is built for them when they are not
the device's kits, or when `rebuild` asks for it anyway.
"""
plan = PutPlan(path)
try:
z = zipfile.ZipFile(path)
except (OSError, zipfile.BadZipFile) as e:
plan.problems.append(f"Not a readable zip: {e}")
return plan
if rows is None:
try:
rows = kit_rows(path, target)
except Exception as e:
plan.problems.append(str(e))
return plan
with z:
names = {n for n in z.namelist() if not n.endswith("/")}
app, backup_portrait = _plan_app(plan, z, names, target, rows, rebuild)
if app is None:
return plan
platform = platform_of(app)
if platform is None or platform is not target.platform:
plan.problems.append(f"The backup is from {platform_label(platform)}; the device "
f"is {platform_label(target.platform)}.")
return plan
zip_name = app.prop(app.app, "deviceName", "") or ""
if target.name and zip_name and zip_name != target.name:
plan.warnings.append(f"The backup was taken from '{zip_name}'; this device is "
f"'{target.name}'.")
if platform.screen is None:
plan.has_screen = False # no images or fonts to put
return plan
if target.delete_slot_id is None:
plan.warnings.append("The device's platform service has no deleteFile action, "
"so its old fonts and images stay.")
same_screen = platform is _RION or backup_portrait == target.portrait
if not same_screen:
plan.warnings.append(
f"The backup is from a {'portrait' if backup_portrait else 'landscape'} "
f"Orion; this one is {'portrait' if target.portrait else 'landscape'}. Its "
"images are made again from the backup's image bank.")
_plan_images(plan, z, names, platform, target, same_screen)
_plan_fonts(plan, z, names, app, platform, target.font_extension)
_plan_logo(plan, z, names, platform, target, same_screen)
return plan
def _plan_app(plan, z, names, target, rows, rebuild):
"""Settles app.sab and kits.scode, as Ontrol's PutApp does.
The backup's app.sax is encoded for the chosen kit builds (a backup with only
device/app.sab goes as it is). When the device's kits.scode cannot load that app, or
a rebuild is asked for, kits.scode is built for the chosen builds with sedonac after
a check of their natives against the device's VM; without sedonac, the backup's own
kits.scode can stand in when the chosen builds are the backup's. Returns (decoded
app, whether the backup came from a portrait screen).
"""
sax_name, sab_name = _app_source(z, names)
chosen = chosen_kits(rows)
original = None
if sab_name:
try:
original = sab_app.SabApp(z.read(sab_name))
except Exception as e:
if not sax_name:
plan.problems.append(f"{sab_name} cannot be read: {e}")
return None, False
if sax_name:
try:
sab = sab_app.encode_sax(z.read(sax_name), kits=chosen)
except Exception as e:
plan.problems.append(f"{sax_name} cannot be encoded for the chosen kits: {e}")
return None, False
plan.app_source = f"{sax_name}, encoded for the chosen kits"
source = sax_name
else:
sab = z.read(sab_name)
plan.app_source = sab_name
source = sab_name
app = sab_app.SabApp(sab)
app_kits = app.kit_parts
plan.kit_changes = [line for line in map(kit_change, rows) if line]
device_kits = {(n, _checksum(c)) for n, c in target.kits}
if sax_name:
same = {(n, _checksum(c)) for n, c in app_kits} == device_kits
else:
same = kits_fit(app_kits, target.kits)
if rebuild or not same:
scode = _plan_scode(plan, z, names, target, app_kits)
if scode is None:
return None, False
plan.app_files.append(scode)
plan.app_files.append(PutFile(APP_SAB, sab, source))
return app, _app_portrait(original or app)
def kit_change(row):
"""How a row changes the device's kits, or None."""
def label(checksum):
version = palette.version_for_checksum(row.name, checksum)
return f"{version} ({checksum})" if version else checksum
if row.device is None:
return f"{row.name}: added at {label(row.use)}" if row.included else None
if not row.included:
return f"{row.name}: removed"
if row.use != row.device:
return f"{row.name}: {label(row.device)} -> {label(row.use)}"
return None
def _plan_scode(plan, z, names, target, app_kits):
"""kits.scode for the app's kits: built by sedonac, or the backup's own."""
manifest = kit_build.platform_manifest(target.platform_id)
backup_scode = "device/" + KITS_SCODE
backup_kits = None
if "device/" + APP_SAB in names:
try:
backup_kits = sab_app.SabApp(z.read("device/" + APP_SAB)).kit_parts
except Exception:
pass
why = None
if manifest is None:
why = (f"Sedona home has no platform manifest for {target.platform_id or 'the device'}"
" (platforms/db), so kits.scode cannot be built or checked for it.")
else:
problems = kit_build.native_problems(app_kits, manifest)
if problems:
plan.problems.append("The chosen kits call native methods the device's VM "
"does not have:")
plan.problems += [" " + p for p in problems]
return None
try:
data = kit_build.build_kits_scode(app_kits, manifest)
except kit_build.BuildError as e:
why = str(e)
else:
plan.scode_source = "built by sedonac for the chosen kits"
return PutFile(KITS_SCODE, data, "sedonac")
if backup_kits is not None and backup_scode in names and \
[(n, _checksum(c)) for n, c in backup_kits] == \
[(n, _checksum(c)) for n, c in app_kits]:
plan.warnings.append(why + " The backup's own kits.scode goes instead.")
plan.scode_source = "the backup's, unchecked against the device's VM"
return PutFile(KITS_SCODE, z.read(backup_scode), backup_scode)
plan.problems.append(why)
return None
def _plan_images(plan, z, names, platform, target, same_screen):
"""Every image the backup holds, in the form the device's screen takes.
The backup's device copy goes when it was taken off the same kind of screen;
otherwise the image bank's source is made over, as Ontrol's tool always does.
"""
native, source = {}, {}
for name in names:
folder, _, file_name = name.rpartition("/")
stem, ext = os.path.splitext(file_name)
if folder == "device/images":
native[stem] = name
elif folder == platform.image_bank and ext.lower() in _SOURCE_IMAGE_EXTENSIONS:
source[stem] = name
for stem in sorted(set(native) | set(source), key=str.lower):
if same_screen and stem in native:
name = native[stem]
plan.media.append(PutFile("images/" + name.rpartition("/")[2], z.read(name),
"backup"))
elif stem in source:
name = source[stem]
try:
if name.lower().endswith(_JPG):
plan.media.append(PutFile(f"images/{stem}.jpg",
device_jpg(z.read(name), target), name))
else:
plan.media.append(PutFile(f"images/{stem}.bin",
native_image(z.read(name), stem, target), name))
plan.converted += 1
except Exception as e:
plan.images_missing.append(f"{stem} ({name}: {e})")
else:
plan.images_missing.append(stem)
def _plan_logo(plan, z, names, platform, target, same_screen):
"""The boot logo: the device's copy, or the backup's source made over (Ontrol's
tool sends it into images/ -- system/ is where the device looks for it)."""
copy = "device/" + platform.logo_uri
if same_screen and copy in names:
plan.media.append(PutFile(platform.logo_uri, z.read(copy), "backup"))
return
source = next((n for n in sorted(names) if n.rpartition("/")[0] == "system" and
os.path.splitext(n)[0].endswith("logo")), None)
if source is None:
return
try:
data = z.read(source)
if platform is _ORION:
if not source.lower().endswith(_JPG):
return
plan.media.append(PutFile(platform.logo_uri, device_jpg(data, target), source))
else:
plan.media.append(PutFile(platform.logo_uri, native_image(data, "logo", target),
source))
except Exception as e:
plan.warnings.append(f"The boot logo could not be made from {source}: {e}")
def _plan_fonts(plan, z, names, app, platform, ext):
"""Every font the backup holds or the app names, in the device's cut `ext`.
As Ontrol's tool does, the bank's file goes rather than the backup's; the backup's
goes only for a font the bank lacks.
"""
in_zip = {} # stem -> {extension: entry}
for name in names:
folder = next((f for f in _FONT_FOLDERS if name.startswith(f)), None)
if folder and "/" not in name[len(folder):]:
stem, file_ext = os.path.splitext(name[len(folder):])
in_zip.setdefault(stem, {}).setdefault(file_ext.lower(), name)
required = {}
for m in media_files(app, platform):
if m.device_uri.startswith("fonts/"):
stem = os.path.splitext(m.device_uri[len("fonts/"):])[0]
required[stem] = required.get(stem, False) or m.required
for stem in sorted(set(in_zip) | set(required)):
bank = _bank_source(platform.font_bank, stem + ext, False)
if bank:
with open(bank, "rb") as f:
plan.media.append(PutFile("fonts/" + stem + ext, f.read(),
f"{platform.font_bank}/{stem}{ext}"))
elif ext in in_zip.get(stem, {}):
plan.media.append(PutFile("fonts/" + stem + ext,
z.read(in_zip[stem][ext]), "backup"))
plan.fonts_from_zip.append(stem + ext)
elif required.get(stem) or stem in in_zip:
plan.fonts_missing.append(stem + ext)
def put_with_files(client, plan, target, status=None):
"""Puts the plan on the device; the device takes the app up when it restarts.
Runs off the main thread; status(text, fraction) reports progress. A failure
raises, naming the step. Once app.sab.stage is in place the app is committed: a
later failure leaves the new app staged with some of its media missing.
"""
def report(text, fraction=None):
if status:
status(text, fraction)
total = sum(len(f.data) for f in plan.app_files + plan.media) or 1
done = 0
def put(uri, f):
nonlocal done
base = done
def progress(sent, size):
report(f"Writing {uri} ...", (base + sent) / total)
report(f"Writing {uri} ...", base / total)
try:
client.put_file(uri, f.data, progress)
except Exception as e:
raise RuntimeError(f"Writing {uri}: {e}") from e
done += len(f.data)
for f in plan.app_files:
put(f.device_uri + ".writing", f)
for f in plan.app_files:
writing, stage = f.device_uri + ".writing", f.device_uri + ".stage"
report(f"Renaming {writing} to {stage} ...", done / total)
try:
client.rename_file(writing, stage)
except Exception as e:
raise RuntimeError(f"Renaming {writing} to {stage}: {e}") from e
plan.staged = True
if target.delete_slot_id is not None:
for pattern in ("fonts/*", "images/*"):
report(f"Deleting {pattern} on the device ...", done / total)
try:
client.invoke(target.platform_comp_id, target.delete_slot_id,
sox.STR_ID, pattern) # an asStr Buf goes as a Str
except Exception as e:
raise RuntimeError(f"Deleting {pattern}: {e}") from e
for f in plan.media:
put(f.device_uri, f)
report("Done", 1.0)
+93 -9
View File
@@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it.
import configparser import configparser
import os import os
VERSION = "0.0.0.069" VERSION = "0.0.0.182"
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties") PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
DEFAULT_GRID_SIZE = 10 DEFAULT_GRID_SIZE = 10
DEFAULT_ADD_TIMESTAMP = True DEFAULT_ADD_TIMESTAMP = True
@@ -41,6 +41,9 @@ status_label = None # the window's bottom status bar
# colour wherever the change was made. # colour wherever the change was made.
DIRTY_TINT = "#ffe6e6" DIRTY_TINT = "#ffe6e6"
CLEAN_TINT = "white" CLEAN_TINT = "white"
# The wiresheet canvas around the sheet itself, clean and with unsaved changes.
OFF_SHEET_TINT = "#f3f3f3"
OFF_SHEET_DIRTY_TINT = "#f0dcdc"
# The file name on the tree's root row turns this colour while anything is unwritten, # The file name on the tree's root row turns this colour while anything is unwritten,
# which is the one place a file name appears on screen -- a Tk window title cannot be # which is the one place a file name appears on screen -- a Tk window title cannot be
@@ -54,6 +57,9 @@ CLEAN_TINT = "white"
DIRTY_NAME_COLOUR = "#c00000" DIRTY_NAME_COLOUR = "#c00000"
DIRTY_NAME_TAG = "unsaved_file" DIRTY_NAME_TAG = "unsaved_file"
# Marks the tree row a palette drag would drop into.
TREE_DROP_TAG = "palette_drop_target"
# A themed Treeview maps the selected state to SystemHighlightText, and that mapping beats # A themed Treeview maps the selected state to SystemHighlightText, and that mapping beats
# a row tag's own foreground -- so the file name turned white-on-blue the moment its row # a row tag's own foreground -- so the file name turned white-on-blue the moment its row
# was selected, which is exactly when the root sheet is the one being looked at. The # was selected, which is exactly when the root sheet is the one being looked at. The
@@ -79,9 +85,12 @@ is_dirty = False
current_file_path = None current_file_path = None
active_components_list = [] # Tracks what components are actively painted active_components_list = [] # Tracks what components are actively painted
selected_canvas_tag = None # Tracks the currently clicked/highlighted canvas component tag selected_canvas_tag = None # Tracks the currently clicked/highlighted canvas component tag
selected_canvas_tags = set() # Every selected box, the one above included; Ctrl+click adds
selected_tree_item_id = None # Tracks the currently right-clicked Treeview item selected_tree_item_id = None # Tracks the currently right-clicked Treeview item
link_mark_element = None # Component marked as a link endpoint via "Link Mark" link_mark_element = None # Component marked as a link endpoint via "Link Mark"
selected_link_tag = None # Canvas tag of the currently selected (red-highlighted) link wire selected_link_tag = None # Canvas tag of the currently selected (red-highlighted) link wire
selected_link_tags = [] # Every selected link wire, in picking order; Ctrl+click adds.
# selected_link_tag is the last of these.
unroutable_link_count = 0 # Links drawn as knobs because no wire could avoid a component unroutable_link_count = 0 # Links drawn as knobs because no wire could avoid a component
meta_scale = DEFAULT_GRID_SIZE # Pixels per meta coordinate step on the painted sheet meta_scale = DEFAULT_GRID_SIZE # Pixels per meta coordinate step on the painted sheet
zoom = 1.0 # Scale the open sheet is painted at; 1.0 is one pixel per pixel zoom = 1.0 # Scale the open sheet is painted at; 1.0 is one pixel per pixel
@@ -111,6 +120,9 @@ def load_preferences():
box_width = DEFAULT_BOX_WIDTH box_width = DEFAULT_BOX_WIDTH
left_pane_width = DEFAULT_LEFT_PANE_WIDTH left_pane_width = DEFAULT_LEFT_PANE_WIDTH
sedona_home = "" sedona_home = ""
trace_dasp = False
lazy_loading = True
show_slot_numbers = False
if os.path.exists(PROPERTIES_FILE): if os.path.exists(PROPERTIES_FILE):
try: try:
@@ -122,6 +134,9 @@ def load_preferences():
"Editor", "left_pane_width", fallback=DEFAULT_LEFT_PANE_WIDTH "Editor", "left_pane_width", fallback=DEFAULT_LEFT_PANE_WIDTH
) )
sedona_home = config.get("Editor", "sedona.home", fallback="").strip() sedona_home = config.get("Editor", "sedona.home", fallback="").strip()
trace_dasp = config.getboolean("Editor", "trace_dasp", fallback=False)
lazy_loading = config.getboolean("Editor", "lazy_loading", fallback=True)
show_slot_numbers = config.getboolean("Editor", "show_slot_numbers", fallback=False)
except Exception: except Exception:
pass pass
else: else:
@@ -142,23 +157,60 @@ def load_preferences():
left_pane_width = max( left_pane_width = max(
MIN_ALLOWED_LEFT_PANE_WIDTH, min(MAX_ALLOWED_LEFT_PANE_WIDTH, left_pane_width) MIN_ALLOWED_LEFT_PANE_WIDTH, min(MAX_ALLOWED_LEFT_PANE_WIDTH, left_pane_width)
) )
return sedona_home, grid_size, add_timestamp, box_width, left_pane_width return (sedona_home, grid_size, add_timestamp, box_width, left_pane_width, trace_dasp,
lazy_loading, show_slot_numbers)
def save_preferences(): def save_preferences():
"""Writes the current preference values back to editor.properties.""" """Writes the current preference values back to editor.properties.
The file is read first so the sections other code owns ([Device], [Palette]) survive.
"""
config = configparser.ConfigParser() config = configparser.ConfigParser()
try:
config.read(PROPERTIES_FILE)
except Exception:
pass
config["Editor"] = { config["Editor"] = {
"sedona.home": SEDONA_HOME, "sedona.home": SEDONA_HOME,
"grid_size": str(GRID_SIZE), "grid_size": str(GRID_SIZE),
"add_timestamp": str(ADD_TIMESTAMP), "add_timestamp": str(ADD_TIMESTAMP),
"box_width": str(BOX_WIDTH), "box_width": str(BOX_WIDTH),
"left_pane_width": str(LEFT_PANE_WIDTH) "left_pane_width": str(LEFT_PANE_WIDTH),
"trace_dasp": str(TRACE_DASP),
"lazy_loading": str(LAZY_LOADING),
"show_slot_numbers": str(SHOW_SLOT_NUMBERS),
} }
with open(PROPERTIES_FILE, "w") as configfile: with open(PROPERTIES_FILE, "w") as configfile:
config.write(configfile) config.write(configfile)
def load_last_palette_kit():
"""The kit the palette dropdown showed last, or "" when none was ever chosen."""
config = configparser.ConfigParser()
try:
config.read(PROPERTIES_FILE)
return config.get("Palette", "kit", fallback="").strip()
except Exception:
return ""
def save_last_palette_kit(kit_name):
"""Remembers the palette's kit so the next start, or a kit refresh, reopens it."""
global LAST_PALETTE_KIT
LAST_PALETTE_KIT = kit_name
config = configparser.ConfigParser()
try:
config.read(PROPERTIES_FILE)
if "Palette" not in config:
config["Palette"] = {}
config["Palette"]["kit"] = kit_name
with open(PROPERTIES_FILE, "w") as configfile:
config.write(configfile)
except Exception:
pass
def base_window_title(): def base_window_title():
"""The window title with no file context, which handlers extend with their own suffix.""" """The window title with no file context, which handlers extend with their own suffix."""
return f"Sedona SAX Offline Program Editor [v{VERSION}]" return f"Sedona SAX Offline Program Editor [v{VERSION}]"
@@ -190,8 +242,7 @@ def mark_dirty():
global is_dirty global is_dirty
if not is_dirty: if not is_dirty:
is_dirty = True is_dirty = True
if canvas is not None: paint_sheet_tint()
canvas.config(bg=DIRTY_TINT)
refresh_dirty_display() refresh_dirty_display()
@@ -199,9 +250,42 @@ def mark_clean():
"""Clears the unsaved-state marker after a successful save or a fresh load.""" """Clears the unsaved-state marker after a successful save or a fresh load."""
global is_dirty global is_dirty
is_dirty = False is_dirty = False
if canvas is not None: paint_sheet_tint()
canvas.config(bg=CLEAN_TINT)
refresh_dirty_display() refresh_dirty_display()
SEDONA_HOME, GRID_SIZE, ADD_TIMESTAMP, BOX_WIDTH, LEFT_PANE_WIDTH = load_preferences() def sheet_tints():
"""(sheet, off-sheet) colours for the wiresheet in the current saved/unsaved state."""
if is_dirty:
return DIRTY_TINT, OFF_SHEET_DIRTY_TINT
return CLEAN_TINT, OFF_SHEET_TINT
def paint_sheet_tint():
"""Recolours the sheet (the "sheet_bg" rectangle) and the canvas around it."""
if canvas is None:
return
sheet, off_sheet = sheet_tints()
canvas.config(bg=off_sheet)
canvas.itemconfig("sheet_bg", fill=sheet)
# ── Live device connection state ────────────────────────────────
live_mode = False
device_session = None # device_session.DeviceSession instance
device_host = ""
device_port = 1876
connection_status = "offline" # offline | connecting | connected | error
def base_window_title_live():
"""Window title for live mode."""
return f"Sedona Live Editor [v{VERSION}] — {device_host}:{device_port}"
# LAZY_LOADING: on connect, read components as folders are opened (True) or the whole
# app from app.sab first (False). Read at each connect, so a change takes effect on the next.
# SHOW_SLOT_NUMBERS: prefix wiresheet slot rows with their slot id ("03. out").
(SEDONA_HOME, GRID_SIZE, ADD_TIMESTAMP, BOX_WIDTH, LEFT_PANE_WIDTH, TRACE_DASP,
LAZY_LOADING, SHOW_SLOT_NUMBERS) = load_preferences()
LAST_PALETTE_KIT = load_last_palette_kit()
Binary file not shown.

After

Width:  |  Height:  |  Size: 444 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 461 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+162
View File
@@ -0,0 +1,162 @@
"""Benchmark: lazy Sox loading vs seeding the cache from app.sab.
python bench_sab_seed.py HOST [--port 1876] [--user admin] [--password PW]
Each method runs on a fresh connection, so it starts from an empty cache.
lazy-seq every component's tree, one request at a time (as used_kit_names does)
lazy-tree the tree folder by folder, each folder's children in one window
lazy-full lazy-tree plus config and links of every component: what app.sab holds
lazy-full-seq lazy-seq plus config and links, one request at a time
sab App.save, get app.sab, decode it
"""
import argparse
import time
import app_state # noqa: F401 loads Sedona home
import schema
from device_session import DeviceSession
from sox import CONFIG
import app_backup
import sab_app
class Counter:
"""Counts Sox requests sent through the client."""
def __init__(self, client):
self.n = 0
orig_requests = client.requests
def requests(reqs, *a, **k):
self.n += len(reqs)
return orig_requests(reqs, *a, **k)
client.requests = requests
def connect(args):
ds = DeviceSession()
# DeviceSession.connect also loads the root's children and their values; time
# only the bare session here, so every method starts from the same place.
from sox import SoxClient
ds.sox_client = SoxClient(args.host, args.port, args.user, args.password)
ds.sox_client.checksum_resolver = schema.resolve_truncated_checksum
ds.sox_client.connect()
parts = ds.sox_client.read_schema()
ds.sox_client.read_version()
ds._load_slot_registry(parts)
return ds
def lazy_seq(ds):
client = ds.sox_client
pending, n = [0], 0
while pending:
comp = client.load_ids([pending.pop()], checked=False)[0]
if comp is not None:
n += 1
pending.extend(comp.children)
return n
def lazy_tree(ds):
client = ds.sox_client
client.load(0)
pending, comps = [0], []
while pending:
cid = pending.pop()
comps.append(client.cache_get(cid))
for child in ds.load_children(cid):
if child.children:
pending.append(child.id)
else:
comps.append(child)
return [c for c in comps if c is not None]
def lazy_full(ds):
client = ds.sox_client
comps = lazy_tree(ds)
# In folder-sized windows, as open_wiresheet sends them.
for i in range(0, len(comps), 16):
client.update(comps[i:i + 16], CONFIG)
for c in comps:
c.links = client.read_links(c.id)
return len(comps)
def lazy_full_seq(ds):
"""lazy-seq plus config and links, every request alone: a P-ION's pace."""
client = ds.sox_client
pending, n = [0], 0
while pending:
comp = client.load_ids([pending.pop()], checked=False)[0]
if comp is None:
continue
n += 1
pending.extend(comp.children)
client.update([comp], CONFIG)
comp.links = client.read_links(comp.id)
return n
def sab(ds, timing):
client = ds.sox_client
t = time.perf_counter()
app_backup.save_app(client)
timing["save"] = time.perf_counter() - t
t = time.perf_counter()
data = client.get_file(app_backup.APP_SAB)
timing["get"] = time.perf_counter() - t
timing["bytes"] = len(data)
t = time.perf_counter()
app = sab_app.SabApp(data)
timing["decode"] = time.perf_counter() - t
timing["links"] = len(app.links)
return len(app.comps)
def run(name, args, fn):
try:
ds = connect(args)
except Exception as e:
print(f"{name:10s} connect failed: {e}")
return
counter = Counter(ds.sox_client)
timing = {}
t = time.perf_counter()
try:
result = fn(ds, timing) if name == "sab" else fn(ds)
elapsed = time.perf_counter() - t
n = result if isinstance(result, int) else len(result)
extra = " ".join(f"{k}={v:.2f}s" if isinstance(v, float) else f"{k}={v}"
for k, v in timing.items())
print(f"{name:10s} {elapsed:7.2f}s comps={n:4d} requests={counter.n:5d} {extra}")
except Exception as e:
print(f"{name:10s} FAILED after {time.perf_counter() - t:.2f}s "
f"({counter.n} requests): {e}")
finally:
ds.disconnect()
time.sleep(1.0)
def main():
p = argparse.ArgumentParser()
p.add_argument("host")
p.add_argument("--port", type=int, default=1876)
p.add_argument("--user", default="admin")
p.add_argument("--password", default="")
p.add_argument("--only", nargs="*",
default=["lazy-seq", "lazy-tree", "lazy-full", "sab"])
args = p.parse_args()
fns = {"lazy-seq": lazy_seq, "lazy-tree": lazy_tree,
"lazy-full": lazy_full, "lazy-full-seq": lazy_full_seq, "sab": sab}
print(f"== {args.host}:{args.port}")
for name in args.only:
run(name, args, fns[name])
if __name__ == "__main__":
main()
+514
View File
@@ -0,0 +1,514 @@
"""
Connection and discovery dialogs for live Sedona devices.
ConnectDialog — manual host/port/user/password entry (Device > Connect).
DiscoverDialog — DASP broadcast discovery with device list; selecting a
device opens a credentials prompt, then connects (Device > Discover).
"""
import configparser
import threading
import tkinter as tk
from tkinter import ttk
import app_state
# ────────────────────────────────────────────────────────────────────
# ConnectDialog (Device > Connect…)
# ────────────────────────────────────────────────────────────────────
class ConnectDialog:
"""Modal dialog for entering device connection parameters."""
def __init__(self, parent, on_connect_callback, on_before_connect=None):
self._callback = on_connect_callback
self._before_connect = on_before_connect
self._thread = None
self._cancelled = False
self._dlg = tk.Toplevel(parent)
self._dlg.title("Connect to Device")
self._dlg.resizable(False, False)
self._dlg.transient(parent)
self._dlg.grab_set()
host, port, user = _load_last_connection()
frame = ttk.Frame(self._dlg, padding=16)
frame.pack(fill=tk.BOTH, expand=True)
row = 0
ttk.Label(frame, text="Host:").grid(row=row, column=0, sticky="e", padx=(0, 8), pady=4)
self._host_var = tk.StringVar(value=host)
self._host_entry = ttk.Entry(frame, textvariable=self._host_var, width=28)
self._host_entry.grid(row=row, column=1, columnspan=2, sticky="ew", pady=4)
row += 1
ttk.Label(frame, text="Port:").grid(row=row, column=0, sticky="e", padx=(0, 8), pady=4)
self._port_var = tk.StringVar(value=port)
ttk.Entry(frame, textvariable=self._port_var, width=8).grid(
row=row, column=1, sticky="w", pady=4)
row += 1
ttk.Label(frame, text="Username:").grid(row=row, column=0, sticky="e", padx=(0, 8), pady=4)
self._user_var = tk.StringVar(value=user)
ttk.Entry(frame, textvariable=self._user_var, width=28).grid(
row=row, column=1, columnspan=2, sticky="ew", pady=4)
row += 1
ttk.Label(frame, text="Password:").grid(row=row, column=0, sticky="e", padx=(0, 8), pady=4)
self._pass_var = tk.StringVar()
ttk.Entry(frame, textvariable=self._pass_var, width=28, show="*").grid(
row=row, column=1, columnspan=2, sticky="ew", pady=4)
row += 1
self._status_label = ttk.Label(frame, text="", foreground="#666666")
self._status_label.grid(row=row, column=0, columnspan=3, sticky="w", pady=(8, 4))
row += 1
btn_frame = ttk.Frame(frame)
btn_frame.grid(row=row, column=0, columnspan=3, sticky="e", pady=(8, 0))
self._btn_cancel = ttk.Button(btn_frame, text="Cancel", command=self._do_cancel)
self._btn_cancel.pack(side=tk.RIGHT)
self._btn_connect = ttk.Button(btn_frame, text="Connect", command=self._do_connect)
self._btn_connect.pack(side=tk.RIGHT, padx=(0, 8))
self._host_entry.focus_set()
self._dlg.bind("<Return>", lambda e: self._do_connect())
self._dlg.bind("<Escape>", lambda e: self._do_cancel())
self._dlg.update_idletasks()
x = parent.winfo_x() + (parent.winfo_width() - self._dlg.winfo_width()) // 2
y = parent.winfo_y() + (parent.winfo_height() - self._dlg.winfo_height()) // 2
self._dlg.geometry(f"+{x}+{y}")
def _do_connect(self):
host = self._host_var.get().strip()
port = self._port_var.get().strip()
user = self._user_var.get().strip()
password = self._pass_var.get()
if not host:
self._status_label.config(text="Host is required", foreground="#c00000")
return
if not user:
self._status_label.config(text="Username is required", foreground="#c00000")
return
try:
port_int = int(port)
except ValueError:
self._status_label.config(text="Invalid port number", foreground="#c00000")
return
# Runs here, on the main thread, rather than in the worker: it is where a
# device already connected is let go, and that takes the UI down with it.
if self._before_connect is not None:
self._before_connect()
self._btn_connect.config(state="disabled")
self._status_label.config(text="Connecting...", foreground="#666666")
self._cancelled = False
self._thread = threading.Thread(
target=self._connect_worker,
args=(host, port_int, user, password),
daemon=True
)
self._thread.start()
self._poll_connect()
def _connect_worker(self, host, port, user, password):
try:
result = self._callback(host, port, user, password)
if self._cancelled:
return
self._result = ("ok", result, host, port, user)
except Exception as e:
if self._cancelled:
return
self._result = ("error", str(e), host, port, user)
def _poll_connect(self):
if self._cancelled:
return
if self._thread is not None and self._thread.is_alive():
self._dlg.after(100, self._poll_connect)
return
status, *rest = self._result
if status == "ok":
_, host, port, user = rest
_save_last_connection(host, str(port), user)
self._dlg.destroy()
else:
msg = rest[0]
self._status_label.config(text=f"Error: {msg}", foreground="#c00000")
self._btn_connect.config(state="normal")
def _do_cancel(self):
self._cancelled = True
if self._thread is not None and self._thread.is_alive():
import app_state as _as
def _cleanup():
self._thread.join(timeout=5)
if _as.device_session is not None and _as.live_mode:
try:
_as.device_session.disconnect()
except Exception:
pass
_as.live_mode = False
_as.device_session = None
_as.connection_status = "offline"
threading.Thread(target=_cleanup, daemon=True).start()
self._dlg.destroy()
# ────────────────────────────────────────────────────────────────────
# DiscoverDialog (Device > Discover…)
# ────────────────────────────────────────────────────────────────────
class DiscoverDialog:
"""Modal dialog that broadcasts DASP DISCOVER, lists responding
devices, and lets the user pick one to connect to."""
def __init__(self, parent, on_connect_callback, on_before_connect=None):
self._parent = parent
self._callback = on_connect_callback
self._before_connect = on_before_connect
self._discover_thread = None
self._dlg = tk.Toplevel(parent)
self._dlg.title("Discover Devices")
self._dlg.resizable(False, False)
self._dlg.transient(parent)
self._dlg.grab_set()
frame = ttk.Frame(self._dlg, padding=16)
frame.pack(fill=tk.BOTH, expand=True)
# ── Header row: label + wait + discover button ─────────────
row = 0
hdr = ttk.Frame(frame)
hdr.grid(row=row, column=0, sticky="ew", pady=(0, 8))
ttk.Label(hdr, text="Discovered Devices:").pack(side=tk.LEFT)
self._btn_discover = ttk.Button(
hdr, text="Discover", command=self._do_discover, width=10)
self._btn_discover.pack(side=tk.RIGHT)
self._wait_var = tk.StringVar(value="2")
ttk.Entry(hdr, textvariable=self._wait_var, width=4, justify="center").pack(
side=tk.RIGHT, padx=(0, 4))
ttk.Label(hdr, text="Wait (s):").pack(side=tk.RIGHT, padx=(8, 2))
_, port, _ = _load_last_connection()
self._port_var = tk.StringVar(value=port)
ttk.Entry(hdr, textvariable=self._port_var, width=6, justify="center").pack(
side=tk.RIGHT, padx=(0, 4))
ttk.Label(hdr, text="Port:").pack(side=tk.RIGHT, padx=(8, 2))
# ── Device list ────────────────────────────────────────────
row += 1
list_frame = ttk.Frame(frame)
list_frame.grid(row=row, column=0, sticky="nsew", pady=(0, 4))
frame.rowconfigure(row, weight=1)
columns = ("ip", "platform")
self._dev_tree = ttk.Treeview(
list_frame, columns=columns, show="headings", height=6, selectmode="browse")
self._dev_tree.heading("ip", text="IP Address")
self._dev_tree.heading("platform", text="Platform ID")
self._dev_tree.column("ip", width=150, minwidth=100)
self._dev_tree.column("platform", width=220, minwidth=120)
self._dev_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=self._dev_tree.yview)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
self._dev_tree.configure(yscrollcommand=scrollbar.set)
self._dev_tree.bind("<Double-1>", lambda e: self._do_connect_selected())
# ── Status + buttons ───────────────────────────────────────
row += 1
self._status_label = ttk.Label(frame, text="", foreground="#666666")
self._status_label.grid(row=row, column=0, sticky="w", pady=(8, 4))
row += 1
btn_frame = ttk.Frame(frame)
btn_frame.grid(row=row, column=0, sticky="e", pady=(8, 0))
ttk.Button(btn_frame, text="Cancel", command=self._dlg.destroy).pack(side=tk.RIGHT)
self._btn_connect = ttk.Button(
btn_frame, text="Connect", command=self._do_connect_selected)
self._btn_connect.pack(side=tk.RIGHT, padx=(0, 8))
self._dlg.bind("<Escape>", lambda e: self._dlg.destroy())
self._dlg.bind("<Return>", self._on_return)
self._dlg.update_idletasks()
x = parent.winfo_x() + (parent.winfo_width() - self._dlg.winfo_width()) // 2
y = parent.winfo_y() + (parent.winfo_height() - self._dlg.winfo_height()) // 2
self._dlg.geometry(f"+{x}+{y}")
self._dlg.lift()
self._dlg.focus_force()
self._btn_discover.focus_set()
# ── Discovery ──────────────────────────────────────────────────
def _on_return(self, event=None):
if str(self._btn_discover.cget("state")) != "disabled":
self._do_discover()
return "break"
def _do_discover(self):
try:
port_int = int(self._port_var.get().strip())
except ValueError:
port_int = 1876
try:
wait_time = max(1, min(60, int(self._wait_var.get().strip())))
except ValueError:
wait_time = 2
self._btn_discover.config(state="disabled")
self._status_label.config(text="Discovering devices...", foreground="#666666")
for item in self._dev_tree.get_children():
self._dev_tree.delete(item)
self._discover_thread = threading.Thread(
target=self._discover_worker, args=(port_int, wait_time), daemon=True)
self._discover_thread.start()
self._poll_discover()
def _discover_worker(self, port, wait_time):
from dasp import DaspSocket
sock = None
try:
sock = DaspSocket()
devices = sock.discover(port=port, wait_time=wait_time)
self._discover_result = ("ok", devices)
except Exception as e:
self._discover_result = ("error", str(e))
finally:
if sock is not None:
try:
sock.close()
except Exception:
pass
def _poll_discover(self):
if self._discover_thread is not None and self._discover_thread.is_alive():
self._dlg.after(100, self._poll_discover)
return
status, data = self._discover_result
self._btn_discover.config(state="normal")
if status == "ok":
if data:
for ip, platform_id in data:
self._dev_tree.insert("", tk.END, values=(ip, platform_id))
self._status_label.config(
text=f"Found {len(data)} device(s)", foreground="#006600")
else:
self._status_label.config(text="No devices found", foreground="#666666")
else:
self._status_label.config(
text=f"Discovery error: {data}", foreground="#c00000")
# ── Connect selected device ────────────────────────────────────
def _do_connect_selected(self):
sel = self._dev_tree.selection()
if not sel:
self._status_label.config(text="Select a device first", foreground="#c00000")
return
values = self._dev_tree.item(sel[0], "values")
host = values[0]
port = self._port_var.get().strip()
self._dlg.destroy()
_CredentialsDialog(self._parent, self._callback, host, port, self._before_connect)
# ────────────────────────────────────────────────────────────────────
# _CredentialsDialog (username / password prompt after discovery)
# ────────────────────────────────────────────────────────────────────
class _CredentialsDialog:
"""Small modal that asks for username and password, then connects."""
def __init__(self, parent, on_connect_callback, host, port, on_before_connect=None):
self._callback = on_connect_callback
self._before_connect = on_before_connect
self._host = host
self._port = port
self._thread = None
self._cancelled = False
_, _, last_user = _load_last_connection()
self._dlg = tk.Toplevel(parent)
self._dlg.title(f"Connect to {host}")
self._dlg.resizable(False, False)
self._dlg.transient(parent)
self._dlg.grab_set()
frame = ttk.Frame(self._dlg, padding=16)
frame.pack(fill=tk.BOTH, expand=True)
row = 0
ttk.Label(frame, text="Host:").grid(row=row, column=0, sticky="e", padx=(0, 8), pady=4)
ttk.Label(frame, text=f"{host}:{port}").grid(row=row, column=1, sticky="w", pady=4)
row += 1
ttk.Label(frame, text="Username:").grid(row=row, column=0, sticky="e", padx=(0, 8), pady=4)
self._user_var = tk.StringVar(value=last_user)
user_entry = ttk.Entry(frame, textvariable=self._user_var, width=24)
user_entry.grid(row=row, column=1, sticky="ew", pady=4)
row += 1
ttk.Label(frame, text="Password:").grid(row=row, column=0, sticky="e", padx=(0, 8), pady=4)
self._pass_var = tk.StringVar()
ttk.Entry(frame, textvariable=self._pass_var, width=24, show="*").grid(
row=row, column=1, sticky="ew", pady=4)
row += 1
self._status_label = ttk.Label(frame, text="", foreground="#666666")
self._status_label.grid(row=row, column=0, columnspan=2, sticky="w", pady=(8, 4))
row += 1
btn_frame = ttk.Frame(frame)
btn_frame.grid(row=row, column=0, columnspan=2, sticky="e", pady=(8, 0))
ttk.Button(btn_frame, text="Cancel", command=self._do_cancel).pack(side=tk.RIGHT)
self._btn_connect = ttk.Button(btn_frame, text="Connect", command=self._do_connect)
self._btn_connect.pack(side=tk.RIGHT, padx=(0, 8))
user_entry.focus_set()
self._dlg.bind("<Return>", lambda e: self._do_connect())
self._dlg.bind("<Escape>", lambda e: self._do_cancel())
self._dlg.update_idletasks()
x = parent.winfo_x() + (parent.winfo_width() - self._dlg.winfo_width()) // 2
y = parent.winfo_y() + (parent.winfo_height() - self._dlg.winfo_height()) // 2
self._dlg.geometry(f"+{x}+{y}")
def _do_connect(self):
user = self._user_var.get().strip()
password = self._pass_var.get()
if not user:
self._status_label.config(text="Username is required", foreground="#c00000")
return
try:
port_int = int(self._port)
except ValueError:
self._status_label.config(text="Invalid port", foreground="#c00000")
return
# Runs here, on the main thread, rather than in the worker: it is where a
# device already connected is let go, and that takes the UI down with it.
if self._before_connect is not None:
self._before_connect()
self._btn_connect.config(state="disabled")
self._status_label.config(text="Connecting...", foreground="#666666")
self._cancelled = False
self._thread = threading.Thread(
target=self._connect_worker,
args=(self._host, port_int, user, password),
daemon=True
)
self._thread.start()
self._poll_connect()
def _connect_worker(self, host, port, user, password):
try:
result = self._callback(host, port, user, password)
if self._cancelled:
return
self._result = ("ok", result, host, port, user)
except Exception as e:
if self._cancelled:
return
self._result = ("error", str(e), host, port, user)
def _poll_connect(self):
if self._cancelled:
return
if self._thread is not None and self._thread.is_alive():
self._dlg.after(100, self._poll_connect)
return
status, *rest = self._result
if status == "ok":
_, host, port, user = rest
_save_last_connection(host, str(port), user)
self._dlg.destroy()
else:
msg = rest[0]
self._status_label.config(text=f"Error: {msg}", foreground="#c00000")
self._btn_connect.config(state="normal")
def _do_cancel(self):
self._cancelled = True
if self._thread is not None and self._thread.is_alive():
import app_state as _as
def _cleanup():
self._thread.join(timeout=5)
if _as.device_session is not None and _as.live_mode:
try:
_as.device_session.disconnect()
except Exception:
pass
_as.live_mode = False
_as.device_session = None
_as.connection_status = "offline"
threading.Thread(target=_cleanup, daemon=True).start()
self._dlg.destroy()
# ────────────────────────────────────────────────────────────────────
# Persistence helpers
# ────────────────────────────────────────────────────────────────────
def _load_last_connection():
"""Reads last connection params from editor.properties."""
config = configparser.ConfigParser()
host = "192.168.1.100"
port = "1876"
user = "admin"
try:
if app_state.PROPERTIES_FILE:
config.read(app_state.PROPERTIES_FILE)
host = config.get("Device", "host", fallback=host)
port = config.get("Device", "port", fallback=port)
user = config.get("Device", "user", fallback=user)
except Exception:
pass
return host, port, user
def _save_last_connection(host, port, user):
"""Saves connection params to editor.properties."""
config = configparser.ConfigParser()
try:
config.read(app_state.PROPERTIES_FILE)
if "Device" not in config:
config["Device"] = {}
config["Device"]["host"] = host
config["Device"]["port"] = port
config["Device"]["user"] = user
with open(app_state.PROPERTIES_FILE, "w") as f:
config.write(f)
except Exception:
pass
+1158
View File
File diff suppressed because it is too large Load Diff
+620
View File
@@ -0,0 +1,620 @@
"""
DeviceSession — bridges SoxClient to the editor's ET.Element-based UI.
Handles lazy loading, DOM synthesis from cached SoxComponents,
COV event routing, and subscription lifecycle.
"""
import queue
import xml.etree.ElementTree as ET
import app_state
import schema
from sox import SoxClient, SoxComponent, TREE, CONFIG, RUNTIME, LINKS, ALL
def _fmt_float(val, precision=2):
"""Format a float for display with *precision* decimal places (default 2)."""
return f"{val:.{precision}f}"
def _slot_precision(type_name, slot_name):
"""Looks up the precision facet for a slot, returning 2 as the default."""
reg = app_state.final_components_registry.get(type_name)
if reg:
for s in reg:
if s["name"] == slot_name:
p = s.get("facets", {}).get("precision")
if isinstance(p, int) and 0 <= p <= 12:
return p
break
return 2
def _as_sox_value(slot, val):
"""An app.sab config value as a Sox config read leaves it in the cache."""
if "s" in slot.flags:
return val
if slot.type in ("sys::Buf", "buf"):
# Sox reads every Buf as a Str: its bytes but the last, as text.
return val[:-1].decode("utf-8", errors="replace") if val else ""
if slot.type == "bool":
return val is not False # Sox reads a bool as u1 != 0, so null is true
return val
class DeviceSession:
"""High-level session manager for a live Sedona device connection."""
def __init__(self):
self.sox_client = None
self._event_queue = queue.Queue()
self._current_subs = set() # comp_ids we subscribed for the open wiresheet
# Components whose config and links came from app.sab and have not been read
# since: their first sheet open skips reading those again.
self._seeded = set()
self.seed_info = "" # how the cache was filled, for the status bar
# ── Connect / Disconnect ────────────────────────────────────
def connect(self, host, port, user, password):
"""DASP connect → read schema → load root. Called from background thread."""
self.sox_client = SoxClient(host, int(port), user, password)
self.sox_client.on_event = self._on_sox_event
self.sox_client.checksum_resolver = schema.resolve_truncated_checksum
self.sox_client.connect()
parts = self.sox_client.read_schema()
vi = self.sox_client.read_version()
self._load_slot_registry(parts)
self.sox_client.load(0)
if app_state.LAZY_LOADING:
self.seed_info = "lazy loading"
else:
try:
count = self.seed_from_sab()
self.seed_info = f"app read from app.sab: {count} components"
except Exception as e:
self.seed_info = f"app.sab not used: {e}"
root_comp = self.sox_client.cache_get(0)
if root_comp and root_comp.children:
children = self.load_children(root_comp.id)
if children:
self.sox_client.update(children, self._values_to_read(children))
return root_comp
def disconnect(self, unsubscribe=True):
"""Closes the session. unsubscribe=False skips telling the device first -- for a
device going down, which would leave the request to time out."""
if self.sox_client is None:
return
try:
if unsubscribe and self._current_subs:
comps = [self.sox_client.cache_get(cid)
for cid in self._current_subs]
comps = [c for c in comps if c is not None]
if comps:
self.sox_client.unsubscribe(comps, ALL)
except Exception:
pass
self._current_subs.clear()
try:
self.sox_client.close()
except Exception:
pass
self.sox_client = None
@property
def is_connected(self):
return self.sox_client is not None and not self.sox_client.is_closed
# ── Schema → Slot Registry ──────────────────────────────────
def _load_slot_registry(self, parts):
"""Builds slot_registry from kit manifests for the device's schema."""
registry = {}
own_slots = {} # "kit::Type" -> declared slots
bases = {} # "kit::Type" -> "kit::Base" or None
for kit_id, part in enumerate(parts):
kit_name = part.name
checksum = part.checksum
manifest = schema.find_manifest(kit_name, checksum)
if manifest is None:
continue
self._register_kit_types(registry, kit_id, kit_name, manifest,
own_slots, bases)
# Flatten each type's slots through its whole base chain (e.g.
# Component -> Service -> ontrolOx::Widget -> PushButtonImage), then
# renumber IDs to match the wire slot index (positional in the
# flattened inherited + own list — see sedona Type.java resolveSlots).
for key in registry:
qname = self.sox_client.type_name_map[key]
chain = []
seen = set()
t = qname
while t and t not in seen and t in own_slots:
seen.add(t)
chain.append(t)
t = bases.get(t)
flat = []
for t in reversed(chain):
flat.extend(dict(s) for s in own_slots[t])
for i, slot in enumerate(flat):
slot["id"] = i
registry[key] = flat
self.sox_client.slot_registry = registry
def _register_kit_types(self, registry, kit_id, kit_name, manifest,
own_slots, bases):
root = manifest
for type_elem in root.iter("type"):
type_name = type_elem.get("name", "")
type_id_val = int(type_elem.get("id", "-1"))
if type_id_val < 0:
continue
slots = []
for slot_elem in type_elem.findall("slot"):
slot_name = slot_elem.get("name", "")
slot_id = int(slot_elem.get("id", "-1"))
slot_type = slot_elem.get("type", "int")
flags_str = slot_elem.get("flags", "")
is_config = "c" in flags_str or "p" in flags_str
is_runtime = "r" in flags_str
is_action = "a" in flags_str
is_operator = "o" in flags_str
is_as_str = slot_type == "Str" or slot_elem.get("asStr") == "true"
slots.append({
"name": slot_name,
"id": slot_id,
"type_name": slot_type,
"is_config": is_config,
"is_runtime": is_runtime,
"is_action": is_action,
"is_operator": is_operator,
"is_as_str": is_as_str,
"is_prop": not is_action,
})
registry[(kit_id, type_id_val)] = slots
full_name = f"{kit_name}::{type_name}"
own_slots[full_name] = slots
bases[full_name] = type_elem.get("base") or None
self.sox_client.type_name_map[(kit_id, type_id_val)] = full_name
# update type_name on any cached component of this type
for c in self.sox_client.cache:
if c is not None and c.kit_id == kit_id and c.type_id == type_id_val:
c.type_name = full_name
# ── Seeding from app.sab ────────────────────────────────────
def seed_from_sab(self):
"""Fills the cache with the whole app — tree, config and links — from app.sab.
Reading component by component costs a round trip each, and a device answers one
request per pass of its loop: 230 components took 116 s on a P-ION, where saving
the app and reading app.sab took 8 s. The app is saved first, so app.sab holds
what is running. From here on Sox keeps the cache current, as it does for a
lazily loaded one; runtime values are still read when a sheet opens.
Nothing goes into the cache unless the whole file decodes and its tree matches the
device's, so on any failure the caller falls back to lazy loading. Returns the
number of components.
"""
import app_backup
import sab_app
from sox import Link
client = self.sox_client
root = client.cache_get(0) or client.load(0)
app_backup.save_app(client)
app = sab_app.SabApp(client.get_file(app_backup.APP_SAB))
# app.sab numbers kits by the app's schema and Sox by kits.scode's; the two can
# differ, so types are matched by name, and a kit only when its checksum agrees.
device_kits = {p.name: f"{p.checksum & 0xFFFFFFFF:08x}" for p in client.schema_parts}
for name, checksum in app.kit_parts:
if device_kits.get(name) != checksum:
raise ValueError(f"kit {name} in app.sab is not the device's")
type_key = {qname: key for key, qname in client.type_name_map.items()}
if [k["id"] for k in app.app["kids"]] != root.children:
raise ValueError("app.sab does not match the running app")
comps = {}
for c in app.comps.values():
key = type_key.get(c["type"])
if key is None:
raise ValueError(f"no manifest for {c['type']}")
comp = SoxComponent(c["id"], key[0], key[1], c["type"])
comp.name = c["name"]
comp.parent = c["parentId"]
comp.children = [k["id"] for k in c["kids"]]
comp.permissions = root.permissions
for slot, val in c["props"]:
comp.values[slot.name] = _as_sox_value(slot, val)
comps[comp.id] = comp
comps[0].subscription = root.subscription
# Sox reads a component's links both ways, into it and out of it.
for from_c, from_slot, to_c, to_slot in app.links:
link = Link(from_c["id"], from_slot, to_c["id"], to_slot)
comps[from_c["id"]].links.append(link)
if to_c["id"] != from_c["id"]:
comps[to_c["id"]].links.append(link)
for comp in comps.values():
client.cache_add(comp)
self._seeded = set(comps)
return len(comps)
def _values_to_read(self, comps):
"""What to read for `comps` on a sheet open: config too, unless app.sab gave it."""
if all(c.id in self._seeded for c in comps):
return RUNTIME
return CONFIG | RUNTIME
# ── Lazy Loading ────────────────────────────────────────────
def load_children(self, comp_id):
"""Loads direct children of a component (cache-aware). Returns list of SoxComponent."""
parent = self.sox_client.cache_get(comp_id)
if parent is None:
parent = self.sox_client.load(comp_id)
if parent is None:
return []
uncached = [cid for cid in parent.children
if self.sox_client.cache_get(cid) is None]
if uncached:
self.sox_client.load_ids(uncached, checked=False)
return [self.sox_client.cache_get(cid)
for cid in parent.children
if self.sox_client.cache_get(cid) is not None]
def used_kit_names(self):
"""Every kit a component on the device belongs to, loading the whole tree to see.
The DOM only holds what has been opened, so it cannot answer this on its own. Only
the tree is read — no config or runtime.
Requests go one at a time. A small controller handles a single folder's burst fine,
but a back-to-back stream of them fills its receive buffer; it stops acking, and
DASP closes the whole session ("unacked send timeout"). Waiting for each reply
before sending the next keeps it at the pace the device sets. Anything already
cached costs nothing, so only the first call is slow.
"""
client = self.sox_client
if client is None:
return set()
pending = [0]
loaded = 0
while pending:
comp_id = pending.pop()
comp = client.cache_get(comp_id)
if comp is None:
comp = client.load_ids([comp_id], checked=False)[0]
loaded += 1
if loaded % 20 == 0:
app_state.set_status(f"Reading components from the device… {loaded}")
if app_state.status_label is not None:
app_state.status_label.update_idletasks()
if comp is not None:
pending.extend(comp.children)
parts = client.schema_parts or []
return {parts[c.kit_id].name for c in client.cache
if c is not None and 0 <= c.kit_id < len(parts)}
def find_component_of_type(self, type_name, slot_name):
"""A component of `type_name` with `slot_name` read, loading where services live.
Nothing is loaded until a folder is opened, so a platform service the user never
expanded to is not cached yet. It sits one or two levels down (/service/plat), and
those are loaded to find it rather than the whole app.
"""
client = self.sox_client
if client is None:
return None
def cached():
return next((c for c in client.cache
if c is not None and c.type_name == type_name), None)
comp = cached()
if comp is None:
for top in self.load_children(0):
if top.name == "service" or top.type_name == type_name:
for child in self.load_children(top.id):
if child.children and child.type_name != type_name:
self.load_children(child.id)
comp = cached()
if comp is not None and slot_name not in comp.values:
client.update([comp], CONFIG)
return comp
# ── Wiresheet Open / Close ──────────────────────────────────
def open_wiresheet(self, folder_comp_id):
"""Full wiresheet load: children, links, remote comps, config+runtime, subscribe.
Returns list of SoxComponent for the folder's children.
"""
children = self.load_children(folder_comp_id)
if not children:
return []
all_link_remote_ids = set()
for child in children:
if child.id not in self._seeded:
child.links = self.sox_client.read_links(child.id)
for lnk in child.links:
all_link_remote_ids.add(lnk.from_comp_id)
all_link_remote_ids.add(lnk.to_comp_id)
child_ids = {c.id for c in children}
child_ids.add(folder_comp_id)
remote_ids = [rid for rid in all_link_remote_ids
if rid not in child_ids
and self.sox_client.cache_get(rid) is None]
if remote_ids:
self.sox_client.load_ids(remote_ids, checked=False)
seeded = [c for c in children if c.id in self._seeded]
others = [c for c in children if c.id not in self._seeded]
if seeded:
self.sox_client.update(seeded, RUNTIME)
if others:
self.sox_client.update(others, CONFIG | RUNTIME)
# Subscribed from here on, so Sox keeps them current; a later open reads again.
self._seeded -= child_ids
folder_comp = self.sox_client.cache_get(folder_comp_id)
to_subscribe = [folder_comp] + children if folder_comp else children
to_subscribe = [c for c in to_subscribe if c is not None]
try:
self.sox_client.subscribe(to_subscribe, ALL)
except Exception:
pass
self._current_subs = {c.id for c in to_subscribe}
return children
def close_wiresheet(self):
"""Unsubscribe from previously subscribed components."""
if not self._current_subs or not self.is_connected:
self._current_subs.clear()
return
comps = [self.sox_client.cache_get(cid)
for cid in self._current_subs]
comps = [c for c in comps if c is not None]
if comps:
try:
self.sox_client.unsubscribe(comps, ALL)
except Exception:
pass
self._current_subs.clear()
# ── Post-add / Post-delete bookkeeping ─────────────────────
def after_add(self, comp_id):
"""Subscribes a newly added component and reads its initial values."""
comp = self.sox_client.cache_get(comp_id)
if comp is None or not self.is_connected:
return
try:
self.sox_client.update([comp], CONFIG | RUNTIME)
except Exception:
pass
try:
self.sox_client.subscribe([comp], ALL)
self._current_subs.add(comp_id)
except Exception:
pass
def after_delete(self, comp_id, descendant_ids=None):
"""Removes a deleted component (and its descendants) from the subscription set."""
self._current_subs.discard(comp_id)
if descendant_ids:
for did in descendant_ids:
self._current_subs.discard(did)
# ── DOM Synthesis ───────────────────────────────────────────
def build_dom_tree(self, root_comp):
"""Builds an ET.Element tree from cached SoxComponents, for the editor UI.
Only builds from what is currently in the cache (lazy-loaded components).
Returns a root <sedonaApp> element.
"""
sax_root = ET.Element("sedonaApp")
schema_elem = ET.SubElement(sax_root, "schema")
if self.sox_client and self.sox_client.schema_parts:
for part in self.sox_client.schema_parts:
# Sox sends the checksum as a signed int; .sax files and manifest names
# spell it as 8 hex digits, and nothing matches unless this does too.
ET.SubElement(schema_elem, "kit",
name=part.name, checksum=f"{part.checksum & 0xFFFFFFFF:08x}")
app_elem = ET.SubElement(sax_root, "app")
if root_comp is not None:
app_elem.set("id", str(root_comp.id))
# The app is a component like any other, so the Properties view needs its
# type and slot values too. A .sax file leaves both implied; here they are not.
app_elem.set("type", root_comp.type_name)
self._add_prop_elements(app_elem, root_comp)
for child_id in root_comp.children:
child_comp = self.sox_client.cache_get(child_id)
if child_comp is not None:
self._build_comp_element(app_elem, child_comp)
else:
ET.SubElement(app_elem, "comp",
name="...", id=str(child_id), type="")
return sax_root
def _build_comp_element(self, parent_elem, sox_comp):
type_qname = sox_comp.type_name
attribs = {
"name": sox_comp.name,
"id": str(sox_comp.id),
"type": type_qname,
}
comp_elem = ET.SubElement(parent_elem, "comp", **attribs)
self._add_prop_elements(comp_elem, sox_comp)
for child_id in sox_comp.children:
child_comp = self.sox_client.cache_get(child_id)
if child_comp is not None:
self._build_comp_element(comp_elem, child_comp)
else:
# Placeholder so the tree shows a + expand arrow for uncached children
ET.SubElement(comp_elem, "comp",
name="...", id=str(child_id), type="")
return comp_elem
def _add_prop_elements(self, comp_elem, sox_comp):
"""Writes a <prop> for every cached non-action slot value of `sox_comp`."""
slots = self.sox_client.slot_registry.get(
(sox_comp.kit_id, sox_comp.type_id))
if not slots:
return
for slot in slots:
if slot.get("is_action"):
continue
name = slot["name"]
if name in sox_comp.values:
val = sox_comp.values[name]
if isinstance(val, float):
val_str = _fmt_float(val, _slot_precision(sox_comp.type_name, name))
else:
val_str = str(val)
ET.SubElement(comp_elem, "prop", name=name, val=val_str)
def build_links_elements(self, sax_root):
"""Adds <link> elements under a <links> container, matching .sax structure."""
links_elem = sax_root.find("links")
if links_elem is None:
links_elem = ET.SubElement(sax_root, "links")
seen = set()
for c in self.sox_client.cache:
if c is None:
continue
for lnk in c.links:
link_key = (lnk.from_comp_id, lnk.from_slot_id,
lnk.to_comp_id, lnk.to_slot_id)
if link_key in seen:
continue
seen.add(link_key)
from_comp = self.sox_client.cache_get(lnk.from_comp_id)
to_comp = self.sox_client.cache_get(lnk.to_comp_id)
if from_comp is None or to_comp is None:
continue
from_slot_name = self._slot_name_by_id(
from_comp.kit_id, from_comp.type_id, lnk.from_slot_id)
to_slot_name = self._slot_name_by_id(
to_comp.kit_id, to_comp.type_id, lnk.to_slot_id)
from_path = self._comp_path(lnk.from_comp_id)
to_path = self._comp_path(lnk.to_comp_id)
if from_path and to_path and from_slot_name and to_slot_name:
ET.SubElement(links_elem, "link",
**{"from": f"{from_path}.{from_slot_name}",
"to": f"{to_path}.{to_slot_name}"})
def _slot_name_by_id(self, kit_id, type_id_val, slot_id):
# SOX links use absolute slot IDs across the full inheritance chain.
type_name = self.sox_client.type_name_map.get((kit_id, type_id_val))
if type_name and type_name in app_state.final_components_registry:
for s in app_state.final_components_registry[type_name]:
if s["slot_id"] == slot_id:
return s["name"]
# Fallback to slot_registry (flattened through the base chain, so
# its IDs are absolute too)
slots = self.sox_client.slot_registry.get((kit_id, type_id_val))
if slots:
for s in slots:
if s["id"] == slot_id:
return s["name"]
return None
def _comp_path(self, comp_id):
parts = []
cid = comp_id
while cid >= 0:
c = self.sox_client.cache_get(cid)
if c is None:
break
if c.id == 0:
break
parts.append(c.name)
cid = c.parent
if not parts:
return None
parts.reverse()
return "/" + "/".join(parts)
# ── Update DOM from COV ─────────────────────────────────────
def update_element_from_cache(self, comp_id, sax_root):
"""Updates a single comp element in the DOM from cached SoxComponent values."""
sox_comp = self.sox_client.cache_get(comp_id)
if sox_comp is None:
return None
comp_elem = self._find_element_by_id(sax_root, comp_id)
if comp_elem is None:
return None
type_name = sox_comp.type_name
slots = self.sox_client.slot_registry.get(
(sox_comp.kit_id, sox_comp.type_id))
if slots:
existing_props = {p.get("name"): p for p in comp_elem.findall("prop")}
for slot in slots:
if slot.get("is_action"):
continue
name = slot["name"]
if name in sox_comp.values:
raw = sox_comp.values[name]
if isinstance(raw, float):
val = _fmt_float(raw, _slot_precision(type_name, name))
else:
val = str(raw)
if name in existing_props:
existing_props[name].set("val", val)
else:
ET.SubElement(comp_elem, "prop", name=name, val=val)
return comp_elem
@staticmethod
def _find_element_by_id(root, comp_id):
target = str(comp_id)
app_elem = root.find("app")
if app_elem is not None and app_elem.get("id") == target:
return app_elem
for elem in root.iter("comp"):
if elem.get("id") == target:
return elem
return None
# ── Event queue (thread-safe) ───────────────────────────────
def _on_sox_event(self, comp_id, what_char):
self._event_queue.put((comp_id, what_char))
def poll_events(self):
"""Drains the event queue. Returns list of (comp_id, what_char) tuples."""
events = []
try:
while True:
events.append(self._event_queue.get_nowait())
except queue.Empty:
pass
return events
+11
View File
@@ -4,4 +4,15 @@ grid_size = 10
add_timestamp = True add_timestamp = True
box_width = 250 box_width = 250
left_pane_width = 230 left_pane_width = 230
trace_dasp = True
lazy_loading = True
show_slot_numbers = False
[Device]
host = 192.168.0.26
port = 1876
user = admin
[Palette]
kit = control
+1
View File
@@ -0,0 +1 @@
{"0": "SOX Client Protocol", "1": "Link View Rendering", "2": "Kit Palette & Manifests", "3": "App State & File IO", "4": "SAX File Kit Management", "5": "Live Device Hooks", "6": "Property Value Logic", "7": "DOM & Undo Core", "8": "Kit Manager Panel", "9": "Connection Dialogs", "10": "Module Imports & Config", "11": "README Documentation", "12": "Main UI & Zoom", "13": "Properties Panel State", "14": "SOX Message Codec", "15": "DASP Send Window", "16": "Main Panel Management", "17": "DASP & SOX Imports", "18": "Wiresheet Layout Engine", "19": "Schema Kit Resolution", "20": "Wire Route Pathfinding", "21": "DASP Session Transport", "22": "Device Session Manager", "23": "Link Dialog & Slots", "24": "Context Menu & Navigation", "25": "DASP Socket Layer", "26": "View History Navigation", "27": "Schema Registry & Facets", "28": "Properties Header & Filter", "29": "Link Context Menu", "30": "Properties Row Building", "31": "Tree Selection Restore", "32": "Wiresheet Box Drawing", "33": "Preferences Dialog", "34": "Wiresheet Box Sizing", "35": "Schema Slot Inheritance", "36": "Device Connect & Load", "37": "Tree Scrollbar & Palette", "38": "Device DOM Builder", "39": "Canvas Click & Link Select", "40": "Canvas Drag & Redraw", "41": "Meta Property Editor", "42": "Wire Knob & Anchor", "43": "DASP Receive Window", "44": "Component Deletion", "45": "File Save Pipeline", "46": "Zoom Fit & Bounds", "47": "Slot Value Undo", "48": "Bool & Null Display", "49": "Sheet Zoom & Grid", "50": "Tooltip Widget", "51": "SOX Connection & Receiver", "52": "Add Component Undo", "53": "Add Link Undo", "54": "Delete Component Undo", "55": "Delete Link Undo", "56": "Schema Compilation", "57": "Property Label Humanise", "58": "Run Entry Point"}
+1
View File
@@ -0,0 +1 @@
C:\Users\aa\AppData\Local\Python\pythoncore-3.14-64\python.exe
+1
View File
@@ -0,0 +1 @@
C:\Users\aa\_aaSourceCodes\sedona_editor\raw
+332
View File
@@ -0,0 +1,332 @@
# Graph Report - sedona_editor (2026-09-20)
## Corpus Check
- 18 files · ~55,766 words
- Verdict: corpus is large enough that graph structure adds value.
- Unclassified: 3 file(s) not represented in the graph (top: (none) 1, .properties 1, .sax 1)
## Summary
- 1033 nodes · 2000 edges · 59 communities (52 shown, 7 thin omitted)
- Extraction: 98% EXTRACTED · 2% INFERRED · 0% AMBIGUOUS · INFERRED: 50 edges (avg confidence: 0.87)
- Token cost: 0 input · 0 output
## Community Hubs (Navigation)
- SOX Client Protocol
- Link View Rendering
- Kit Palette & Manifests
- App State & File IO
- SAX File Kit Management
- Live Device Hooks
- Property Value Logic
- DOM & Undo Core
- Kit Manager Panel
- Connection Dialogs
- Module Imports & Config
- README Documentation
- Main UI & Zoom
- Properties Panel State
- SOX Message Codec
- DASP Send Window
- Main Panel Management
- DASP & SOX Imports
- Wiresheet Layout Engine
- Schema Kit Resolution
- Wire Route Pathfinding
- DASP Session Transport
- Device Session Manager
- Link Dialog & Slots
- Context Menu & Navigation
- DASP Socket Layer
- View History Navigation
- Schema Registry & Facets
- Properties Header & Filter
- Link Context Menu
- Properties Row Building
- Tree Selection Restore
- Wiresheet Box Drawing
- Preferences Dialog
- Wiresheet Box Sizing
- Schema Slot Inheritance
- Device Connect & Load
- Tree Scrollbar & Palette
- Device DOM Builder
- Canvas Click & Link Select
- Canvas Drag & Redraw
- Meta Property Editor
- Wire Knob & Anchor
- DASP Receive Window
- Component Deletion
- File Save Pipeline
- Zoom Fit & Bounds
- Slot Value Undo
- Bool & Null Display
- Sheet Zoom & Grid
- Tooltip Widget
- SOX Connection & Receiver
- Add Component Undo
- Add Link Undo
- Delete Component Undo
- Delete Link Undo
- Schema Compilation
- Property Label Humanise
- Run Entry Point
## God Nodes (most connected - your core abstractions)
1. `SoxClient` - 51 edges
2. `PropertiesPanel` - 37 edges
3. `LinkViewPanel` - 32 edges
4. `set_status()` - 30 edges
5. `SoxMsg` - 27 edges
6. `DeviceSession` - 25 edges
7. `add_component()` - 22 edges
8. `DaspSocket` - 20 edges
9. `KitsPanel` - 20 edges
10. `_prepare_request()` - 19 edges
## Surprising Connections (you probably didn't know these)
- `DiscoverDialog` --uses--> `DaspSocket` [INFERRED]
connect_dialog.py → dasp.py
- `_SoxReceiver` --uses--> `DaspException` [INFERRED]
sox.py → dasp.py
- `SoxClient` --uses--> `DaspSocket` [INFERRED]
sox.py → dasp.py
- `DeviceSession` --uses--> `SoxClient` [INFERRED]
device_session.py → sox.py
- `verify_startup_configurations()` --calls--> `save_preferences()` [EXTRACTED]
main.py → app_state.py
## Import Cycles
- None detected.
## Communities (59 total, 7 thin omitted)
### Community 0 - "SOX Client Protocol"
Cohesion: 0.07
Nodes (14): decode_value(), encode_value(), KitPart, Link, _make_subscribe_req(), _make_unsubscribe_req(), _make_update_req(), _prepare_request() (+6 more)
### Community 1 - "Link View Rendering"
Cohesion: 0.06
Nodes (26): build_link_view_panel(), column_gap(), group_by_peer(), LinkViewPanel, Collapses the entries of one side onto one box per peer. A component wired…, Where a peer belongs in its column: the first focus slot its links land on. A…, One component's links, drawn around it on a canvas of this view's own. Built…, Moves a scrollbar's thumb, and hides the bar when it spans the whole. (+18 more)
### Community 2 - "Kit Palette & Manifests"
Cohesion: 0.07
Nodes (44): action_options(), collect_rows(), column_label(), Every kit worth listing, as row dicts in the order they should appear. A row…, How a build is named in the Installed and Latest columns, which are narrow., The Action dropdown for one kit: (label, checksum to apply) pairs, no-op first.…, _find_type(), invalidate() (+36 more)
### Community 3 - "App State & File IO"
Cohesion: 0.09
Nodes (40): base_window_title(), clear_status(), mark_clean(), The window title with no file context, which handlers extend with their own…, Posts a one-line note to the status bar. A no-op before the UI exists., Empties the status bar, for when the note no longer describes anything on…, Clears the unsaved-state marker after a successful save or a fresh load., set_status() (+32 more)
### Community 4 - "SAX File Kit Management"
Cohesion: 0.08
Nodes (37): _coerce_value(), The value a slot holds when neither the file nor the manifest names one., type_zero(), Copies an element's children and leading text, so it can be put back wholesale.…, snapshot_children(), add_component(), allocate_component_id(), _build_config_values() (+29 more)
### Community 5 - "Live Device Hooks"
Cohesion: 0.10
Nodes (34): mark_dirty(), Nudges whatever shows 'there is something unwritten'. A no-op before the UI…, Single funnel for 'the document changed', including the unsaved-state tint., refresh_dirty_display(), _comp_id(), _find_slot(), is_live(), _kit_id() (+26 more)
### Community 6 - "Property Value Logic"
Cohesion: 0.09
Nodes (30): allows_null(), bool_texts(), build_properties_panel(), display_text(), effective_value(), enum_range(), is_editable(), null_text() (+22 more)
### Community 7 - "DOM & Undo Core"
Cohesion: 0.10
Nodes (21): copy, on_canvas_release(), find_parent_element(), Document-level helpers for a Sedona app: the packed 'meta' property and…, Rewrites only meta's four user-group bits, leaving the packed position alone.…, Traverses the XML DOM tree recursively to find the parent element of a given…, Drops a component's <prop>, for undoing a value that the file never carried., Parses 32-bit meta property integer fields via structured byte extraction rules. (+13 more)
### Community 8 - "Kit Manager Panel"
Cohesion: 0.13
Nodes (11): build_kits_panel(), build_label(), describe_changes(), KitsPanel, commit(), make_checkbox_image(), The applied-changes report, one kit per line, old build to new., The kit table, built as an embedded panel in the editor pane. (+3 more)
### Community 9 - "Connection Dialogs"
Cohesion: 0.11
Nodes (11): ConnectDialog, _cleanup(), _CredentialsDialog, DiscoverDialog, _load_last_connection(), Modal dialog that broadcasts DASP DISCOVER, lists responding devices, and lets…, Modal dialog for entering device connection parameters., Small modal that asks for username and password, then connects. (+3 more)
### Community 10 - "Module Imports & Config"
Cohesion: 0.10
Nodes (22): base_window_title_live(), load_preferences(), Shared configuration, runtime state and widget handles. Every other module…, Loads application configurations from editor.properties or initializes defaults., Writes the current preference values back to editor.properties., Window title for live mode., save_preferences(), configparser (+14 more)
### Community 11 - "README Documentation"
Cohesion: 0.10
Nodes (26): app_state.py Module, Back/Forward Navigation History, Dirty State Display Logic, Editor Pane Three-View Architecture, Link Routing and Wire Layout, link_view.py Module, Link View Design, main.py Module (+18 more)
### Community 12 - "Main UI & Zoom"
Cohesion: 0.11
Nodes (22): clamp_left_pane_split(), _destroy_drag_ghost(), do_tidy_layout(), do_zoom_in(), do_zoom_out(), do_zoom_reset(), on_canvas_wheel_zoom(), on_palette_press() (+14 more)
### Community 13 - "Properties Panel State"
Cohesion: 0.12
Nodes (12): PropertiesPanel, Writes the edited rows back, as one undoable step. Nothing is written until…, An undo can take the component out of the document while this is on screen., Asks before pending edits are thrown away. True means it is safe to leave.…, Hands back to whoever put this on screen, once pending edits are settled.…, Tears the panel's widgets down, for a caller that is done with it., One component's slots, in a scrolling list of rows, with Save, Refresh and…, Whether any row has moved away from what was last loaded or saved. Read from… (+4 more)
### Community 14 - "SOX Message Codec"
Cohesion: 0.11
Nodes (3): setter, Big-endian binary buffer with cursor, mirroring sedona.Buf., SoxMsg
### Community 15 - "DASP Send Window"
Cohesion: 0.14
Nodes (5): _Packet, Monotonic time in seconds (float)., Manages the outgoing datagram queue with reliable delivery., SendWindow, _ticks()
### Community 16 - "Main Panel Management"
Cohesion: 0.12
Nodes (22): anything_unwritten(), close_editor_panel(), dismiss_kits(), dismiss_properties(), _give_editor_pane_back(), on_tree_filled(), Whether there is a change no one has written yet, in the document or in a…, Puts the file name on the tree's root row in red, or back to its usual colour.… (+14 more)
### Community 17 - "DASP & SOX Imports"
Cohesion: 0.14
Nodes (14): DaspMsg, _put_bytes(), _put_str(), _put_u2(), DASP (Datagram Authenticated Session Protocol) transport layer. Ported from…, Binary encoder/decoder for DASP messages., hashlib, random (+6 more)
### Community 18 - "Wiresheet Layout Engine"
Cohesion: 0.13
Nodes (20): Rebuilds tree and canvas from the DOM. Registered as undo/redo's view refresh., refresh_all_views(), build_layout_items(), build_linked_slot_index(), plan_columns(), print_sheet(), Repaints whichever sheet is open, re-reading children from the DOM. Undo of a…, Walks the columns at `scale` px per meta step, without touching the DOM.… (+12 more)
### Community 19 - "Schema Kit Resolution"
Cohesion: 0.13
Nodes (17): Puts back what snapshot_children captured, whitespace and all., restore_children(), apply_kit_selections(), abandon(), Takes a <kit> entry out of the block, leaving its line breaks tidy. Whitespace…, Types placed in the app that the current registry cannot describe, sorted.…, Repins, adds and drops <kit> entries in one go. ``selections`` is an iterable…, remove_kit_element() (+9 more)
### Community 20 - "Wire Route Pathfinding"
Cohesion: 0.13
Nodes (20): channel_candidates(), free_lane(), free_rows(), hug_lane(), path_is_clear(), True if an axis-aligned segment overlaps any box rectangle., Smallest horizontal distance from the vertical run at cx to any box it passes.…, Vertical channel positions to try, best first. A run hugs the box it leaves or… (+12 more)
### Community 21 - "DASP Session Transport"
Cohesion: 0.22
Nodes (4): DaspException, DaspSession, Exception, Client endpoint of a DASP session. Handles the handshake, keepalive, reliable…
### Community 22 - "Device Session Manager"
Cohesion: 0.12
Nodes (7): DeviceSession, Unsubscribe from previously subscribed components., Subscribes a newly added component and reads its initial values., Removes a deleted component (and its descendants) from the subscription set., Adds <link> elements under a <links> container, matching .sax structure., High-level session manager for a live Sedona device connection., Drains the event queue. Returns list of (comp_id, what_char) tuples.
### Community 23 - "Link Dialog & Slots"
Cohesion: 0.17
Nodes (18): collect_links(), Every link with an end on `comp`, in the order the file lists them. Each entry…, open_link_dialog(), on_ok(), _on_src_change(), on_swap(), _on_tgt_change(), populate_panel() (+10 more)
### Community 24 - "Context Menu & Navigation"
Cohesion: 0.13
Nodes (18): dismiss_link_view(), fill_component_popup(), on_tree_right_click(), open_link_from_link_view(), Fills a popup with the actions that apply to one component. Shared by the…, Adds a freshly opened view to the history, dropping anything ahead of it. Every…, Hides the sheet so a panel can have the pane. The canvas is only unpacked,…, Puts one component's slots in the editor pane, in place of the wiresheet. (+10 more)
### Community 25 - "DASP Socket Layer"
Cohesion: 0.14
Nodes (4): DaspSocket, Manages a UDP socket shared by DASP sessions. Provides receiver and…, Send DASP DISCOVER broadcast and collect responding devices. Returns a list of…, _trace()
### Community 26 - "View History Navigation"
Cohesion: 0.15
Nodes (17): _component_in_document(), _entry_is_reachable(), go_back(), go_forward(), _prune_view_history(), The tree item whose sheet is this element's children, or None if it is gone. A…, Whether a history entry still names something that could be shown. A sheet…, Drops entries that can no longer be shown, keeping the position on screen. (+9 more)
### Community 27 - "Schema Registry & Facets"
Cohesion: 0.15
Nodes (14): DeviceSession — bridges SoxClient to the editor's ET.Element-based UI. Handles…, queue, find_manifest(), find_manifest_path(), get_all_slots_for_type(), get_full_slots_for_type(), Kit manifest loading, slot inheritance, and which slots are worth showing., The manifest file describing a kit, or None when Sedona home holds none. A… (+6 more)
### Community 28 - "Properties Header & Filter"
Cohesion: 0.15
Nodes (7): is_hidden(), Re-reads every value from the DOM, discarding edits that were not saved. In…, Reads fresh config+runtime values from the device into the DOM element., The '@hidden' facet: a slot a kit keeps for itself. niagara.html puts it…, Takes hidden rows out of the grid, or puts them back. grid_remove keeps each…, The checkbox: remember the answer for the session, then re-filter the rows., The component's absolute path, as <link> references write it.
### Community 29 - "Link Context Menu"
Cohesion: 0.16
Nodes (14): What to call the wiresheet an element sits on. A folder is named by itself; the…, sheet_display_name(), go_to_link_sheet(), go_to_sheet_with_link(), link_endpoint_components(), link_target_sheet(), Rebuilds the link popup, naming the sheet the far end of the link is on. A link…, The two components a link joins, resolved from its from/to references. (+6 more)
### Community 30 - "Properties Row Building"
Cohesion: 0.18
Nodes (8): qualified_type(), Moves a scrollbar's thumb, and hides the bar entirely when it spans the whole., int' -> 'sys::int', the way a slot sheet names it. A manifest writes the…, Registers a widget to follow the tint, and hands it straight back., A canvas-scrolled frame; ttk has no scrollable container of its own. The rows…, Names the columns. Deliberately quiet -- the same size as the rows, in grey,…, The small ring each row is tagged with, drawn rather than set in a glyph font., meta is one integer holding a position and four group bits, so it gets its own…
### Community 31 - "Tree Selection Restore"
Cohesion: 0.17
Nodes (13): _clear_restoring_flag(), _bg(), _ui(), on_tree_select(), Puts the tree back on the sheet that is actually showing, after a refused…, Triggered when a branch node is clicked in the Navigation view., Scrolls the sheet so the tagged items sit in the middle of the view., Selects whichever wire or knob now stands for this link, and scrolls it into… (+5 more)
### Community 32 - "Wiresheet Box Drawing"
Cohesion: 0.18
Nodes (12): math, sys, tkinter_font, column_gap_for(), create_round_rectangle(), draw_component_box(), fit_text(), Canvas painting: the grid, component boxes, and the routed link wires. (+4 more)
### Community 33 - "Preferences Dialog"
Cohesion: 0.17
Nodes (11): apply_left_column_visibility(), apply_left_pane_width(), open_preferences_dialog(), apply_preferences(), Pins the tree/palette column to the configured width. Setting the sash before…, Shows or hides the tree/palette column to match the View menu's checkbutton.…, Keyboard route to the same toggle. The menu flips the variable for itself., Update the toggle button icon to reflect the pane state. (+3 more)
### Community 34 - "Wiresheet Box Sizing"
Cohesion: 0.17
Nodes (12): Checks for configuration gaps, triggers an alert, and opens setup if empty., verify_startup_configurations(), get_slots_for_type(), Returns the slots worth painting for a given type, prefixed with double-digit…, measure_box(), ontrolControl::HvacControl' -> 'HvacControl'. The kit prefix alone cost ~40px., Works out a component's box without drawing it. Returns (width, height,…, Smallest multiple of grid_size that is >= value; grid lines sit on multiples.… (+4 more)
### Community 35 - "Schema Slot Inheritance"
Cohesion: 0.17
Nodes (11): get_slots_with_inheritance(), MissingManifestError, Exception, A kit the file needs has no manifest under the Sedona home folder. Raised…, Reads a slot's '@readonly' facet. Absent means writable., Recursively resolves the slots for a given type by traversing its base chain., Reads a slot's '@summary' facet. Absent means summary, matching manifest…, Every facet on a slot, as {name: value} with each one typed by its own tag. The… (+3 more)
### Community 36 - "Device Connect & Load"
Cohesion: 0.18
Nodes (5): Extracts base slots from sys::Component that all types inherit., Loads direct children of a component (cache-aware). Returns list of…, Full wiresheet load: children, links, remote comps, config+runtime, subscribe.…, DASP connect → read schema → load root. Called from background thread., Builds slot_registry from kit manifests for the device's schema.
### Community 37 - "Tree Scrollbar & Palette"
Cohesion: 0.20
Nodes (10): attach_tree_scrollbars(), _on_tree_open(), fit_tree_column(), measure(), _live_tree_expand(), populate_palette_types(), Sizes a Treeview's tree column to its widest visible row. ttk derives the…, Puts a Treeview in its container with scrollbars that show only when they help.… (+2 more)
### Community 38 - "Device DOM Builder"
Cohesion: 0.22
Nodes (6): _fmt_float(), Format a float for display with *precision* decimal places (default 2)., Looks up the precision facet for a slot, returning 2 as the default., Builds an ET.Element tree from cached SoxComponents, for the editor UI. Only…, Updates a single comp element in the DOM from cached SoxComponent values., _slot_precision()
### Community 39 - "Canvas Click & Link Select"
Cohesion: 0.28
Nodes (9): on_canvas_click(), on_canvas_right_click(), Detects right-clicks on nodes to instantly show context-sensitive action…, deselect_link(), Pulls both ends of a knob-to-knob line back to the knob rims. Drawn centre to…, Highlights a link in red, remembering what each of its items looked like. The…, Puts the selected link back exactly as it was drawn., select_link() (+1 more)
### Community 40 - "Canvas Drag & Redraw"
Cohesion: 0.22
Nodes (9): on_canvas_drag(), get_box_origin(), layout_margin(), Brings the cached knob-to-knob ends onto the painted scale. They are recorded…, Re-routes the wires of a sheet that is already painted, at whatever zoom it is…, The top/left edge offset a box may not encroach on, snapped onto the grid., Exact (left, top) of a component's box, or None if it is not on the canvas., redraw_links() (+1 more)
### Community 41 - "Meta Property Editor"
Cohesion: 0.25
Nodes (7): group_label(), meta_summary(), apply_meta(), read_coordinate(), The 'Group [1,3]' text, from the four user-group bits in order., meta unpacked into what its row and its editor need: (label, groups, (x, y))., The two things meta actually holds, side by side: the groups and the position.…
### Community 42 - "Wire Knob & Anchor"
Cohesion: 0.25
Nodes (8): draw_knob(), draw_links(), get_box_bounds(), The rectangle a component's box was actually drawn at, valid after drags.…, Returns (box_bounds, y) for a slot row, centring on the box when the row is…, Marks a slot whose links cannot be shown as a visible wire. Painted in the wire…, Paints wires between slots on this sheet and knobs for links leaving it., resolve_slot_anchor()
### Community 43 - "DASP Receive Window"
Cohesion: 0.25
Nodes (3): Manages incoming message sequence numbers and acknowledgements., ReceiveWindow, _to_ack_more()
### Community 44 - "Component Deletion"
Cohesion: 0.25
Nodes (8): delete_component(), delete_selected_component(), delete_selected_link(), editor_panel_showing(), Deletes a component, taking a view of it down with it if one is showing. Every…, Whether something other than the wiresheet has the editor pane., Removes the currently selected link from the DOM and repaints., Deletes the highlighted canvas component (or selected link) and all its…
### Community 45 - "File Save Pipeline"
Cohesion: 0.25
Nodes (8): do_download_to_sax(), do_save(), In live mode, saves the current device DOM snapshot as a .sax file., Internal helper to clean up deleted elements and sync coordinates into the root…, Writes the current DOM to a .sax file at the given path., sync_live_metadata_to_dom(), sync_and_prune_tree(), write_sax_to_path()
### Community 46 - "Zoom Fit & Bounds"
Cohesion: 0.29
Nodes (8): do_zoom_fit(), Puts the painted scale on the toolbar as a percentage., Zoom so the whole sheet is visible, and scroll it into view. The fitted scale…, update_zoom_label(), fit_zoom_level(), The 1:1 rectangle the painted sheet occupies, or None if nothing is painted.…, The scale at which the whole painted sheet fits the visible canvas, or None.…, sheet_content_bounds()
### Community 47 - "Slot Value Undo"
Cohesion: 0.32
Nodes (4): Writes a component's <prop>, creating it in the block the other props sit in. A…, set_prop_value(), One Save from the Properties dialog: every slot it changed, as one undoable…, SlotValueCommand
### Community 48 - "Bool & Null Display"
Cohesion: 0.33
Nodes (5): bool_state(), is_null(), Which of the three a value is in: 'true', 'false' or 'null'., Whether a value is Sedona's null, as an absent prop or as the literal it writes., The fill a row's field takes: a bool's state, or its editability.
### Community 49 - "Sheet Zoom & Grid"
Cohesion: 0.33
Nodes (6): apply_sheet_zoom(), draw_grid(), Repaints the open sheet at this scale. Returns the level actually applied., Draws a grid onto the Canvas based on the scrollable region. Drawn at the…, Scales the finished sheet to app_state.zoom and sizes the canvas to match.…, set_zoom()
### Community 56 - "Schema Compilation"
Cohesion: 0.50
Nodes (4): _load_live_schema(), Loads schema kits from the device's schema parts into the editor registry., compile_schema_registry(), Loads and maps types across multiple kit files, ensuring all dependencies exist.
### Community 57 - "Property Label Humanise"
Cohesion: 0.50
Nodes (3): humanise(), controlSignalForMin_1' -> 'Control Signal For Min _1', the label style the rows…, 14. Proportional Band' — the manifest slot id, so rows match the box's…
## Knowledge Gaps
- **3 isolated node(s):** `run.py Entry Point`, `SAX File Format`, `Tkinter GUI Framework`
These have ≤1 connection - possible missing edges or undocumented components. (Counts symbols only; 441 node(s) total have ≤1 connection when file, concept and rationale nodes are included.)
- **7 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **Why does `LinkViewPanel` connect `Link View Rendering` to `Module Imports & Config`?**
_High betweenness centrality (0.109) - this node is a cross-community bridge._
- **Why does `PropertiesPanel` connect `Properties Panel State` to `Property Value Logic`, `Meta Property Editor`, `Bool & Null Display`, `Property Label Humanise`, `Properties Header & Filter`, `Properties Row Building`?**
_High betweenness centrality (0.085) - this node is a cross-community bridge._
- **Why does `SoxClient` connect `SOX Client Protocol` to `Device Connect & Load`, `DASP & SOX Imports`, `SOX Connection & Receiver`, `Device Session Manager`, `DASP Socket Layer`, `Schema Registry & Facets`?**
_High betweenness centrality (0.078) - this node is a cross-community bridge._
- **Are the 2 inferred relationships involving `SoxClient` (e.g. with `DeviceSession` and `DaspSocket`) actually correct?**
_`SoxClient` has 2 INFERRED edges - model-reasoned connections that need verification._
- **What connects `run.py Entry Point`, `SAX File Format`, `Tkinter GUI Framework` to the rest of the system?**
_3 weakly-connected nodes found - possible documentation gaps or missing edges._
- **Should `SOX Client Protocol` be split into smaller, more focused modules?**
_Cohesion score 0.07067307692307692 - nodes in this community are weakly interconnected._
- **Should `Link View Rendering` be split into smaller, more focused modules?**
_Cohesion score 0.05519480519480519 - nodes in this community are weakly interconnected._
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "$graphify-root$_run_py", "label": "run.py", "file_type": "code", "source_file": "run.py", "source_location": "L1"}, {"id": "$graphify-root$_run_rationale_1", "label": "Entry point. Run this to start the editor: cd sedona_editor\u2026", "file_type": "rationale", "source_file": "run.py", "source_location": "L1"}], "edges": [{"source": "$graphify-root$_run_py", "target": "main", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "run.py", "source_location": "L7", "weight": 1.0}, {"source": "$graphify-root$_run_rationale_1", "target": "$graphify-root$_run_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "run.py", "source_location": "L1", "weight": 1.0}], "raw_calls": []}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
1789885668.073165
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{"README.md":{"size":38029,"mtime_ns":1789207627368739100,"indexed_at_ns":1789883955303060000,"word_count":6506,"hashes":{"readme.md":"b7a613b47df2c61767fc8d886ec32aa3e1d0ac3f6da0206daca19ba2a6ed99b4"}},"app_state.py":{"size":9571,"mtime_ns":1789851139833784500,"indexed_at_ns":1789883951449195400,"word_count":1175,"hashes":{"app_state.py":"791852fb4837e590ef426f9abce9429a6346706b708de102d41f9c4dab866911"}},"connect_dialog.py":{"size":20263,"mtime_ns":1789816628226091600,"indexed_at_ns":1789883951451225900,"word_count":1303,"hashes":{"connect_dialog.py":"b5c6e3294defe12d1c53d4b8c76552d8bf6f38a6e204a1963ed2c7f3a83bcc2a"}},"dasp.py":{"size":38310,"mtime_ns":1789816759608783800,"indexed_at_ns":1789883951452569700,"word_count":3186,"hashes":{"dasp.py":"2d4d1af35c6bc9ef11ecb68b9f3a73a13aca45ca5dc41190cebe19edda562fdc"}},"device_session.py":{"size":19193,"mtime_ns":1789845314830797500,"indexed_at_ns":1789883951454302400,"word_count":1491,"hashes":{"device_session.py":"d6914c06960e42c8d3a5a9f8dc6c03d196091806d3ac7be50379cee9061c21e3"}},"link_view.py":{"size":42505,"mtime_ns":1788797603117476700,"indexed_at_ns":1789883951455805600,"word_count":5281,"hashes":{"link_view.py":"59253dabb1373764731835d88e413f18b457efa6090be9e30518fa34cce5da71"}},"live_hooks.py":{"size":8666,"mtime_ns":1789842974776334300,"indexed_at_ns":1789883951457207800,"word_count":763,"hashes":{"live_hooks.py":"438073d492b5db25195c193e30ab87ca64f9de3a704628990ea37f2fc9fff857"}},"main.py":{"size":103415,"mtime_ns":1789833846648041800,"indexed_at_ns":1789883951458596800,"word_count":9837,"hashes":{"main.py":"269e122ee65a0b878df028b878c2e484ae6d2308f10e11d1d5c0880d408f047f"}},"manage_kits.py":{"size":22083,"mtime_ns":1789125426796493100,"indexed_at_ns":1789883951460107200,"word_count":2109,"hashes":{"manage_kits.py":"6bd531d476672ea3885194f97400246fb1f78abe6637117d616d0e68f426d593"}},"palette.py":{"size":14334,"mtime_ns":1789125197416496800,"indexed_at_ns":1789883951461413400,"word_count":1379,"hashes":{"palette.py":"2cf5e6d8720b940f6b3af4bb23e3fc8a09f4f6505d53ba45edcec4088a7f4f5c"}},"properties.py":{"size":55199,"mtime_ns":1789851137229094800,"indexed_at_ns":1789883951462723600,"word_count":5705,"hashes":{"properties.py":"3ce59cd34a35b1faf50cf7030d297f12e63c296e9e60d05e76bc6163c64d18a1"}},"run.py":{"size":206,"mtime_ns":1786651536896245400,"indexed_at_ns":1789883951464114400,"word_count":29,"hashes":{"run.py":"e44ca204fa54bf4a1c595c6521561d5d659d0b7d4a7c6af70e46c1b46260d5d5"}},"sax_dom.py":{"size":6246,"mtime_ns":1788633481822201000,"indexed_at_ns":1789883951465359100,"word_count":757,"hashes":{"sax_dom.py":"28a6dad3abd45f43c38e91665e722a12e97f08c18700b431e0b61fd000f45840"}},"sax_file.py":{"size":38816,"mtime_ns":1789850168933008700,"indexed_at_ns":1789883951466630500,"word_count":3547,"hashes":{"sax_file.py":"1f01e07194406808dbbfd58bae7a1285b3a0211c0620b6a90669aab1e9890a22"}},"schema.py":{"size":12967,"mtime_ns":1789207615670902300,"indexed_at_ns":1789883951467970000,"word_count":1289,"hashes":{"schema.py":"2f8c0c4be49e18f5db16b6bf14bc03a43e94223d2fa08ff70c7ef59531664067"}},"sox.py":{"size":37209,"mtime_ns":1789845325490420400,"indexed_at_ns":1789883951469441700,"word_count":2786,"hashes":{"sox.py":"93489b69fc8dc751b2074435dc68dfb25bb6be6c3c9fe9b947824d71a692129c"}},"undo.py":{"size":7951,"mtime_ns":1788633491738614700,"indexed_at_ns":1789883951470831500,"word_count":790,"hashes":{"undo.py":"928e8b02554620bf0315b3c1a3144d6bb922f44d21a6ff92e9b8ff65ff94080e"}},"wiresheet.py":{"size":63434,"mtime_ns":1789237260350999800,"indexed_at_ns":1789883951472387100,"word_count":7833,"hashes":{"wiresheet.py":"fe92f9a17ba675e43f8bf92dd10b0750044e13b02f30cfd1796875a4a9bbb1fb"}}}
+12
View File
@@ -0,0 +1,12 @@
{
"runs": [
{
"date": "2026-09-20T06:03:44.075478+00:00",
"input_tokens": 0,
"output_tokens": 0,
"files": 18
}
],
"total_input_tokens": 0,
"total_output_tokens": 0
}
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+110
View File
@@ -0,0 +1,110 @@
{
"app_state.py": {
"mtime": 1789851139.8337846,
"seen": 1789884224.0655024,
"ast_hash": "437802315be3d28e6cbc41f1d5a95876",
"semantic_hash": "437802315be3d28e6cbc41f1d5a95876"
},
"connect_dialog.py": {
"mtime": 1789816628.2260916,
"seen": 1789884224.0655105,
"ast_hash": "c2d9a0b738557e6c72f1dea4d44d02ea",
"semantic_hash": "c2d9a0b738557e6c72f1dea4d44d02ea"
},
"dasp.py": {
"mtime": 1789816759.6087837,
"seen": 1789884224.0655322,
"ast_hash": "d3d63a00cc8eb08e5dc82ee2cfd1365c",
"semantic_hash": "d3d63a00cc8eb08e5dc82ee2cfd1365c"
},
"device_session.py": {
"mtime": 1789845314.8307974,
"seen": 1789884224.0655339,
"ast_hash": "eeb732ccf012badb72f93850955b6c8a",
"semantic_hash": "eeb732ccf012badb72f93850955b6c8a"
},
"link_view.py": {
"mtime": 1788797603.1174767,
"seen": 1789884224.0655358,
"ast_hash": "7f341dc2d6c8598c2fc978d5a0b95f02",
"semantic_hash": "7f341dc2d6c8598c2fc978d5a0b95f02"
},
"live_hooks.py": {
"mtime": 1789842974.7763343,
"seen": 1789884224.0655372,
"ast_hash": "a9a092c511390c8d64f5bc50e08627a1",
"semantic_hash": "a9a092c511390c8d64f5bc50e08627a1"
},
"main.py": {
"mtime": 1789833846.6480417,
"seen": 1789884224.065539,
"ast_hash": "8bc836c383a496c8ad30d0da462a8d61",
"semantic_hash": "8bc836c383a496c8ad30d0da462a8d61"
},
"manage_kits.py": {
"mtime": 1789125426.796493,
"seen": 1789884224.06554,
"ast_hash": "8e9135cf8c5ddcb34f6f7886e1fa85e0",
"semantic_hash": "8e9135cf8c5ddcb34f6f7886e1fa85e0"
},
"palette.py": {
"mtime": 1789125197.4164968,
"seen": 1789884224.0655417,
"ast_hash": "f1b78c1843fb98d44720d60281314b98",
"semantic_hash": "f1b78c1843fb98d44720d60281314b98"
},
"properties.py": {
"mtime": 1789851137.2290947,
"seen": 1789884224.065543,
"ast_hash": "a1edfcee7af5fe436576776e7bebb95d",
"semantic_hash": "a1edfcee7af5fe436576776e7bebb95d"
},
"run.py": {
"mtime": 1786651536.8962455,
"seen": 1789884224.0655444,
"ast_hash": "d798ee28ae1eeb09d6d12d46fb7fe9be",
"semantic_hash": "d798ee28ae1eeb09d6d12d46fb7fe9be"
},
"sax_dom.py": {
"mtime": 1788633481.822201,
"seen": 1789884224.0655463,
"ast_hash": "afbcd877bba75c075fc98eccf7fb71f2",
"semantic_hash": "afbcd877bba75c075fc98eccf7fb71f2"
},
"sax_file.py": {
"mtime": 1789850168.9330087,
"seen": 1789884224.0655475,
"ast_hash": "226e49aa7b5206b80e6f77b2fa1ff58f",
"semantic_hash": "226e49aa7b5206b80e6f77b2fa1ff58f"
},
"schema.py": {
"mtime": 1789207615.6709023,
"seen": 1789884224.0655491,
"ast_hash": "d2878c3e573f7fdea07c22347f7eed0a",
"semantic_hash": "d2878c3e573f7fdea07c22347f7eed0a"
},
"sox.py": {
"mtime": 1789845325.4904203,
"seen": 1789884224.0655506,
"ast_hash": "2e6fb50d0f1153ee41bf44a05d73e10b",
"semantic_hash": "2e6fb50d0f1153ee41bf44a05d73e10b"
},
"undo.py": {
"mtime": 1788633491.7386148,
"seen": 1789884224.0655518,
"ast_hash": "52c86b6138566209df75ea8006499596",
"semantic_hash": "52c86b6138566209df75ea8006499596"
},
"wiresheet.py": {
"mtime": 1789237260.3509998,
"seen": 1789884224.0655534,
"ast_hash": "9e3fa2735688770ef38f61ea4b045fcf",
"semantic_hash": "9e3fa2735688770ef38f61ea4b045fcf"
},
"README.md": {
"mtime": 1789207627.3687391,
"seen": 1789884224.0655546,
"ast_hash": "8b472abda0e7ab506fc251a0382458b8",
"semantic_hash": "8b472abda0e7ab506fc251a0382458b8"
}
}
+190
View File
@@ -0,0 +1,190 @@
"""Building a device's kits.scode, as Ontrol's PutApp does it with sedonac.
The editor carries no Sedona compiler: Ontrol's own sedonac is run through Java, from
the Niagara install next to Sedona home (Niagara 4's ontrolSedonanet jars, or Niagara
AX's sedona/sedonac jars -- both build the same image). Its input is a kits.xml naming
each kit at its version, with the target platform's endian, block and ref sizes taken
from the platform manifest in Sedona home's platform db.
Before a build the kits' calls into native code are checked against the platform's
SVM, as PutApp.checkNativeCompatibility does: an scode calling a native the device's
VM lacks would not start.
"""
import glob
import os
import re
import shutil
import subprocess
import tempfile
import xml.etree.ElementTree as ET
import zipfile
import app_state
import palette
class BuildError(Exception):
pass
def _home():
return app_state.SEDONA_HOME
# --- kits -------------------------------------------------------------------------------
def kit_file(name, checksum):
"""(path, version) of the .kit for a kit build; the highest version when one checksum
was released more than once. None when Sedona home has no such .kit."""
want = int(checksum, 16) if isinstance(checksum, str) else checksum & 0xFFFFFFFF
pattern = re.compile(rf"^{re.escape(name)}-([0-9a-fA-F]+)-(.+)\.kit$")
found = []
for path in glob.glob(os.path.join(_home(), "kits", name, f"{name}-*.kit")):
match = pattern.match(os.path.basename(path))
if match and int(match.group(1), 16) == want:
found.append((palette.version_sort_key(match.group(2)), path, match.group(2)))
if not found:
return None
_, path, version = max(found)
return path, version
# --- the target platform ----------------------------------------------------------------
def platform_manifest(platform_id):
"""The platformManifest.xml in Sedona home's platform db for a platform id, or None."""
if not platform_id:
return None
# os.walk, not glob: the manifests sit in hidden .par folders glob skips.
for folder, _, files in os.walk(os.path.join(_home(), "platforms", "db")):
if "platformManifest.xml" not in files:
continue
path = os.path.join(folder, "platformManifest.xml")
try:
if ET.parse(path).getroot().get("platformId") == platform_id:
return path
except ET.ParseError:
continue
return None
_CLASS = re.compile(r"^\s*(?:[a-z]+\s+)*class\s+(\w+)", re.M)
_NATIVE = re.compile(r"\bnative\b[^=\n]*?\b(\w+)\s*\([^)]*\)\s*=\s*(\d+::\d+)")
_CALL = re.compile(r"\bCallNative\w*\s+(\w+::\w+\.\w+)")
def native_problems(kits, manifest_path):
"""Lines naming each native the kits call that the platform's SVM does not provide.
kits is [(name, checksum)]. Read from the kits' .ir files: a native's id from its
declaration, the natives in use from the CallNative ops.
"""
svm = {e.get("id"): e.get("qname")
for e in ET.parse(manifest_path).getroot().iter("nativeMethod")}
declared, called = {}, set()
for name, checksum in kits:
found = kit_file(name, checksum)
if found is None:
continue # reported by the build
with zipfile.ZipFile(found[0]) as z:
for entry in z.namelist():
if not entry.endswith(".ir"):
continue
text = z.read(entry).decode("utf-8", "replace")
classes = [(m.start(), m.group(1)) for m in _CLASS.finditer(text)]
for m in _NATIVE.finditer(text):
owner = [c for pos, c in classes if pos < m.start()]
if owner:
declared[f"{name}::{owner[-1]}.{m.group(1)}"] = m.group(2)
called.update(_CALL.findall(text))
problems = []
for qname in sorted(called):
nid = declared.get(qname)
if nid is None or nid not in svm:
problems.append(f"{qname} ({nid or 'not declared'}) is not in the device's VM")
elif svm[nid] != qname:
problems.append(f"{qname} ({nid}) is {svm[nid]} in the device's VM")
return problems
# --- the compiler -----------------------------------------------------------------------
def _java():
found = shutil.which("java")
if found:
return found
candidates = []
if os.environ.get("JAVA_HOME"):
candidates.append(os.path.join(os.environ["JAVA_HOME"], "bin", "java.exe"))
niagara_root = os.path.dirname(os.path.abspath(_home()))
candidates += sorted(glob.glob(os.path.join(niagara_root, "Niagara-*", "jre", "bin",
"java.exe")), reverse=True)
for root in (os.environ.get("ProgramFiles", r"C:\Program Files"),):
candidates += sorted(glob.glob(os.path.join(root, "*", "*", "bin", "java.exe")),
reverse=True)
return next((c for c in candidates if os.path.isfile(c)), None)
def _classpath():
"""sedonac and the sedona runtime from the Niagara install beside Sedona home."""
niagara_root = os.path.dirname(os.path.abspath(_home()))
for niagara in sorted(glob.glob(os.path.join(niagara_root, "Niagara-*")), reverse=True):
modules = os.path.join(niagara, "modules")
for jars in (("ontrolSedonanet-rt.jar", "ontrolSedonanet-wb.jar"),
("sedona.jar", "sedonac.jar")):
paths = [os.path.join(modules, j) for j in jars]
if all(os.path.isfile(p) for p in paths):
return os.pathsep.join(paths)
return None
def toolchain():
"""(java, classpath) for running sedonac; raises BuildError saying what is missing."""
java, classpath = _java(), _classpath()
if not java:
raise BuildError("No Java runtime was found (java on the PATH, JAVA_HOME, or "
"Niagara's jre).")
if not classpath:
raise BuildError("sedonac was not found: no Niagara install beside Sedona home "
f"({os.path.dirname(os.path.abspath(_home()))}) has Ontrol's "
"ontrolSedonanet jars or sedona.jar + sedonac.jar.")
return java, classpath
def build_kits_scode(kits, manifest_path):
"""Compiles kits.scode for [(name, checksum)] on the platform; returns its bytes."""
plat = ET.parse(manifest_path).getroot()
lines = ["<sedonaCode"]
for attr in ("endian", "blockSize", "refSize", "debug", "test", "armDouble"):
lines.append(f' {attr}="{plat.get(attr)}"')
lines += [' main="sys::Sys.main"', ">"]
missing = []
for name, checksum in kits:
found = kit_file(name, checksum)
if found is None:
missing.append(f"{name} {checksum}")
else:
lines.append(f' <depend on="{name} {found[1]}=" />')
if missing:
raise BuildError("Sedona home has no .kit file for " + ", ".join(missing))
lines.append("</sedonaCode>")
java, classpath = toolchain()
with tempfile.TemporaryDirectory(prefix="sedona_editor_scode_") as work:
with open(os.path.join(work, "kits.xml"), "w", encoding="utf-8") as f:
f.write("\n".join(lines) + "\n")
try:
run = subprocess.run(
[java, f"-Dsedona.home={_home()}", "-cp", classpath, "sedonac.Main",
"kits.xml"],
cwd=work, capture_output=True, text=True, timeout=180,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
except (OSError, subprocess.TimeoutExpired) as e:
raise BuildError(f"sedonac could not run: {e}") from e
scode = os.path.join(work, "kits.scode")
if run.returncode != 0 or not os.path.isfile(scode):
tail = "\n".join((run.stdout + run.stderr).strip().splitlines()[-12:])
raise BuildError(f"sedonac failed:\n{tail}")
with open(scode, "rb") as f:
return f.read()
+584
View File
@@ -0,0 +1,584 @@
"""Linking on the wiresheet with the mouse: slot hover, drag-out rubber band, drop.
Hovering a slot row highlights the half of it the pointer is in and swaps the cursor for
a horizontal arrow: the right half of a row is where a link leaves a slot, so it points
right, and the left half is where one arrives, so it points left. Pressing either
button on a slot and dragging pulls a rubber-band line out of that side; letting go on
another slot makes the link, and letting go on a component's title bar opens the Link
dialog for the two components with the dragged slot already picked. Started on the right half, the slot under the pointer is
the target; started on the left half, it is the source -- so a link can be drawn from
either end. While dragging, the cursor points along the flow of the link over a slot that
would take it, and turns into a no-entry sign over one that would not, so a bad drop is
visible before the button is released.
A right press that never moves still opens the component's context menu. A left drag
that starts on bare sheet draws a selection rectangle, and selects the boxes wholly
inside it on release; with Ctrl held they join the current selection. A left press
anywhere on a box selects it, but only a press on its title bar moves it; on a slot row
it draws a link instead, as in Workbench.
"""
import os
import struct
import tempfile
import tkinter as tk
import app_state
import sax_file
import wiresheet
from sax_dom import get_component_path
NO_ENTRY_PNG = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"assets", "cursors", "no_entry.png")
DRAG_THRESHOLD = 4 # pixels the pointer must travel before a press is a drag
HOVER_COLOR = "#1f4e79"
DROP_OK_COLOR = "#2e9d3a"
DROP_BAD_COLOR = "#d62828"
RUBBER_COLOR = "#2f6fba"
MARQUEE_COLOR = "#2f6fba"
CTRL_MASK = 0x0004
_cursors = {} # role -> Tk cursor spec, built on first use
_press = None # pending right press: {"hit", "x", "y", "event"}
_drag = None # live link drag: {"hit", "anchor"}
_marquee = None # left press on bare sheet: {"x", "y", "cx", "cy", "state", "active"}
_menu_callback = None # opens the ordinary right-click menu for an event
_select_boxes = None # makes a list of comp tags the box selection
_link_dialog = None # opens the Link dialog: (source, target, src_slot, tgt_slot)
# --------------------------------------------------------------------------- cursors
def _write_cur(path, pixels, hotspot):
"""Writes a 32x32 32-bit Windows .cur from rows of (r, g, b, a) tuples."""
size = 32
xor = bytearray()
for row in reversed(pixels): # DIBs are stored bottom-up
for r, g, b, a in row:
xor += bytes((b, g, r, a))
mask = bytearray()
for row in reversed(pixels):
bits = 0
for x, (_, _, _, a) in enumerate(row):
if a == 0:
bits |= 1 << (31 - x)
mask += struct.pack(">I", bits)
header = struct.pack("<IiiHHIIiiII", 40, size, size * 2, 1, 32, 0,
len(xor) + len(mask), 0, 0, 0, 0)
image = header + bytes(xor) + bytes(mask)
icondir = struct.pack("<HHH", 0, 2, 1)
entry = struct.pack("<BBBBHHII", size, size, 0, 0, hotspot[0], hotspot[1],
len(image), 6 + 16)
with open(path, "wb") as f:
f.write(icondir + entry + image)
def _inside_polygon(px, py, poly):
inside = False
j = len(poly) - 1
for i in range(len(poly)):
xi, yi = poly[i]
xj, yj = poly[j]
if (yi > py) != (yj > py) and px < (xj - xi) * (py - yi) / (yj - yi) + xi:
inside = not inside
j = i
return inside
def _arrow_pixels(pointing_right):
"""A hollow block arrow: white body, black rim -- the Workbench link cursor."""
poly = [(9, 14), (16, 14), (16, 10), (23, 16), (16, 22), (16, 18), (9, 18)]
if not pointing_right:
poly = [(32 - x, y) for x, y in poly]
inside = [[_inside_polygon(x + 0.5, y + 0.5, poly) for x in range(32)]
for y in range(32)]
rows = []
for y in range(32):
row = []
for x in range(32):
if not inside[y][x]:
row.append((0, 0, 0, 0))
continue
rim = any(not (0 <= x + dx < 32 and 0 <= y + dy < 32 and inside[y + dy][x + dx])
for dx in (-1, 0, 1) for dy in (-1, 0, 1))
row.append((0, 0, 0, 255) if rim else (255, 255, 255, 255))
rows.append(row)
return rows
def _no_entry_pixels():
"""assets/cursors/no_entry.png, centred on a 32x32 cursor; None if unreadable.
Read with PIL when it is installed, which keeps the image's soft edges; otherwise
with Tk's own PNG reader, which only knows whether a pixel is transparent or not.
"""
if not os.path.exists(NO_ENTRY_PNG):
return None
try:
from PIL import Image
image = Image.open(NO_ENTRY_PNG).convert("RGBA")
width, height = image.size
def pixel(x, y):
return image.getpixel((x, y))
except ImportError:
image = tk.PhotoImage(file=NO_ENTRY_PNG)
width, height = image.width(), image.height()
def pixel(x, y):
if image.transparency_get(x, y):
return (0, 0, 0, 0)
return tuple(image.get(x, y)) + (255,)
if width > 32 or height > 32:
return None
left, top = 16 - width // 2, 16 - height // 2
rows = [[(0, 0, 0, 0)] * 32 for _ in range(32)]
for y in range(height):
for x in range(width):
rows[top + y][left + x] = pixel(x, y)
return rows
def _cursor(role):
"""The Tk cursor for 'right', 'left' or 'no', drawn once and cached."""
if role in _cursors:
return _cursors[role]
fallback = {"right": "sb_right_arrow", "left": "sb_left_arrow", "no": "X_cursor"}[role]
spec = fallback
if os.name == "nt":
try:
folder = os.path.join(tempfile.gettempdir(), "sedona_editor_cursors")
os.makedirs(folder, exist_ok=True)
path = os.path.join(folder, f"link_{role}.cur")
if role == "right":
_write_cur(path, _arrow_pixels(True), (23, 16))
elif role == "left":
_write_cur(path, _arrow_pixels(False), (9, 16))
else:
pixels = _no_entry_pixels()
if pixels is None:
raise OSError("no_entry.png missing or unreadable")
_write_cur(path, pixels, (16, 16))
candidate = "{@" + path.replace("\\", "/") + "}"
app_state.canvas.configure(cursor=candidate) # proves Tk can load it
spec = candidate
except (OSError, tk.TclError):
spec = "no" if role == "no" else fallback
_cursors[role] = spec
return spec
def _set_cursor(role):
canvas = app_state.canvas
try:
canvas.configure(cursor=_cursor(role) if role else "")
except tk.TclError:
canvas.configure(cursor="")
# --------------------------------------------------------------------------- hit test
def slot_at(cx, cy):
"""The slot row under a canvas point, as a dict, or None.
Picks the topmost box under the point, so a box lying over another wins, and says
which half of the row the point is in.
"""
canvas = app_state.canvas
for item in reversed(canvas.find_overlapping(cx, cy, cx, cy)):
tags = canvas.gettags(item)
if "component" not in tags:
continue
comp_tag = next((t for t in tags if t.startswith("comp_group_")), None)
if comp_tag is None:
continue
box = wiresheet.get_box_bounds(comp_tag)
if box is None:
return None
zoom = app_state.zoom
half_row = wiresheet.SLOT_ROW_HEIGHT * zoom / 2
for slot_name, offset in app_state.component_slot_offsets.get(comp_tag, {}).items():
row_y = box[1] + offset * zoom
if row_y - half_row <= cy < row_y + half_row:
return {
"comp_tag": comp_tag,
"comp": app_state.canvas_comp_map.get(comp_tag),
"slot": slot_name,
"side": "right" if cx >= (box[0] + box[2]) / 2 else "left",
"box": box,
"row_y": row_y,
"half_row": half_row,
}
return None # over a header or footer, not a slot
return None
def title_at(cx, cy):
"""The comp tag whose title bar is topmost under a canvas point, or None."""
canvas = app_state.canvas
for item in reversed(canvas.find_overlapping(cx, cy, cx, cy)):
tags = canvas.gettags(item)
if "component" not in tags:
continue
comp_tag = next((t for t in tags if t.startswith("comp_group_")), None)
if comp_tag is None:
continue
box = wiresheet.get_box_bounds(comp_tag)
if box and box[1] <= cy <= box[1] + wiresheet.HEADER_HEIGHT * app_state.zoom:
return comp_tag
return None
return None
def _slot_info(comp, slot_name):
for slot in app_state.final_components_registry.get(comp.get("type", ""), []):
if slot["name"] == slot_name:
return slot
return None
def _check_link(source, target):
"""Returns (ok, reason) for a link from one hit slot to another."""
if source["comp"] is None or target["comp"] is None:
return False, "No component there."
if source["comp"] is target["comp"] and source["slot"] == target["slot"]:
return False, "A slot cannot be linked to itself."
src = _slot_info(source["comp"], source["slot"])
tgt = _slot_info(target["comp"], target["slot"])
if src is None or tgt is None:
return False, "Slot not found in the kit manifest."
for slot in (src, tgt):
if "a" in slot["flags"] or slot["type"] == "void":
return False, f"'{slot['name']}' is an action and cannot be linked."
if tgt["readonly"]:
return False, f"'{tgt['name']}' is read-only and cannot be a link target."
if src["type"] != tgt["type"]:
return False, (f"Type mismatch: {source['slot']} is {src['type']}, "
f"{target['slot']} is {tgt['type']}.")
from_ref, to_ref = _refs(source, target)
links = app_state.xml_root_element.find("links")
if links is not None:
for link in links.findall("link"):
if link.get("from") == from_ref and link.get("to") == to_ref:
return False, "That link already exists."
return True, f"Link {from_ref} → {to_ref}"
def _refs(source, target):
root = app_state.xml_root_element
return (f"{get_component_path(source['comp'], root)}.{source['slot']}",
f"{get_component_path(target['comp'], root)}.{target['slot']}")
# --------------------------------------------------------------------------- drawing
def _highlight(hit, color):
"""Rings the half of a slot row the pointer is over, with a bar on its outer edge."""
canvas = app_state.canvas
canvas.delete("slot_hover")
if hit is None:
return
left, _, right, _ = hit["box"]
mid = (left + right) / 2
top, bottom = hit["row_y"] - hit["half_row"], hit["row_y"] + hit["half_row"]
x1, x2 = (mid, right - 1) if hit["side"] == "right" else (left + 1, mid)
canvas.create_rectangle(x1, top, x2, bottom, outline=color, width=2,
tags=("slot_hover",))
edge = right - 2 if hit["side"] == "right" else left + 2
canvas.create_line(edge, top, edge, bottom, fill=color, width=4, tags=("slot_hover",))
canvas.tag_raise("slot_hover")
def _highlight_title(comp_tag, color):
"""Rings a box's title bar: the drop that opens the Link dialog for that box."""
canvas = app_state.canvas
canvas.delete("slot_hover")
left, top, right, _ = wiresheet.get_box_bounds(comp_tag)
canvas.create_rectangle(left + 1, top + 1, right - 1,
top + wiresheet.HEADER_HEIGHT * app_state.zoom,
outline=color, width=2, tags=("slot_hover",))
canvas.tag_raise("slot_hover")
def _anchor(hit):
left, _, right, _ = hit["box"]
return (right if hit["side"] == "right" else left), hit["row_y"]
def clear():
"""Drops any hover ring, rubber band and drag state; called on every repaint."""
global _press, _drag, _marquee
canvas = app_state.canvas
if canvas is None:
return
canvas.delete("slot_hover")
canvas.delete("link_rubber")
canvas.delete("marquee")
_press = None
_drag = None
_marquee = None
_set_cursor(None)
# --------------------------------------------------------------------------- events
def on_motion(event):
"""Plain pointer movement: ring the slot half under the pointer."""
if _drag is not None or app_state.drag_data.get("tag"):
return
canvas = app_state.canvas
hit = slot_at(canvas.canvasx(event.x), canvas.canvasy(event.y))
_highlight(hit, HOVER_COLOR)
_set_cursor(hit["side"] if hit else None)
def on_leave(_event):
if _drag is None:
app_state.canvas.delete("slot_hover")
_set_cursor(None)
def _start_press(event):
"""Records a press on a slot row as a link drag in waiting; None if not on a slot."""
global _press
canvas = app_state.canvas
hit = slot_at(canvas.canvasx(event.x), canvas.canvasy(event.y))
if hit is None or hit["comp"] is None:
_press = None
return None
_press = {"hit": hit, "x": event.x, "y": event.y}
return _press
def _drag_motion(event):
"""Pulls the rubber band to the pointer and judges the slot under it, if any."""
global _drag
canvas = app_state.canvas
if _drag is None:
if _press is None:
return
if abs(event.x - _press["x"]) < DRAG_THRESHOLD and abs(event.y - _press["y"]) < DRAG_THRESHOLD:
return
_drag = {"hit": _press["hit"], "anchor": _anchor(_press["hit"])}
app_state.set_status("Drop on a slot to link it; release elsewhere to cancel.")
cx, cy = canvas.canvasx(event.x), canvas.canvasy(event.y)
start = _drag["hit"]
ax, ay = _drag["anchor"]
canvas.delete("link_rubber")
canvas.create_line(ax, ay, cx, cy, fill=RUBBER_COLOR, width=2, dash=(4, 2),
tags=("link_rubber",))
canvas.tag_raise("link_rubber")
outgoing = start["side"] == "right"
hit = slot_at(cx, cy)
if hit is None:
title_tag = title_at(cx, cy)
if title_tag is not None:
# Its own title too: a component may link one of its slots to another.
_highlight_title(title_tag, DROP_OK_COLOR)
_set_cursor("right" if outgoing else "left")
app_state.set_status("Drop to pick the other slot in the Link dialog.")
return
if hit is None or (hit["comp_tag"] == start["comp_tag"] and hit["slot"] == start["slot"]):
canvas.delete("slot_hover")
_set_cursor(None)
return
ok, reason = _check_link(start, hit) if outgoing else _check_link(hit, start)
# Ring the side of the drop slot the link would attach to, not the side under the
# pointer: a link always arrives on the left of a row and leaves from its right.
shown = dict(hit, side="left" if outgoing else "right")
_highlight(shown, DROP_OK_COLOR if ok else DROP_BAD_COLOR)
_set_cursor(("right" if outgoing else "left") if ok else "no")
app_state.set_status(reason, warn=not ok)
def _finish(event):
"""Ends a press. Returns 'link' after a drag (made or refused), 'click' after a
press that never moved, None if no press on a slot was pending."""
global _press, _drag
canvas = app_state.canvas
press, drag = _press, _drag
_press = _drag = None
canvas.delete("link_rubber")
canvas.delete("slot_hover")
_set_cursor(None)
if drag is None:
return "click" if press is not None else None
cx, cy = canvas.canvasx(event.x), canvas.canvasy(event.y)
hit = slot_at(cx, cy)
start = drag["hit"]
title_tag = title_at(cx, cy) if hit is None else None
if title_tag is not None:
other = app_state.canvas_comp_map.get(title_tag)
if other is None:
app_state.set_status("Link cancelled.")
elif start["side"] == "right":
_link_dialog(start["comp"], other, start["slot"], None)
else:
_link_dialog(other, start["comp"], None, start["slot"])
return "link"
if hit is None or (hit["comp_tag"] == start["comp_tag"] and hit["slot"] == start["slot"]):
app_state.set_status("Link cancelled.")
return "link"
source, target = (start, hit) if start["side"] == "right" else (hit, start)
ok, reason = _check_link(source, target)
if not ok:
app_state.set_status(f"Cannot link: {reason}", warn=True)
return "link"
from_ref, to_ref = _refs(source, target)
sax_file.add_link(from_ref, to_ref)
app_state.set_status(f"Linked {from_ref} → {to_ref}")
return "link"
# Right button: on a slot it drags a link, and a press that never moves is the menu.
def on_right_press(event):
if _start_press(event) is None:
_menu_callback(event)
def on_right_motion(event):
_drag_motion(event)
def on_right_release(event):
if _finish(event) == "click":
_menu_callback(event)
# Left button on bare sheet: dragging pulls out a selection rectangle that selects every
# box wholly inside it on release (Ctrl adds them to the current selection).
def _on_bare_sheet(cx, cy):
"""True if no box or link lies under a canvas point."""
canvas = app_state.canvas
for item in canvas.find_overlapping(cx - 3, cy - 3, cx + 3, cy + 3):
tags = canvas.gettags(item)
if "component" in tags or "link" in tags:
return False
return True
def _marquee_motion(event):
"""Draws the selection rectangle once the pointer has moved far enough."""
global _marquee
if _marquee is None:
return
if not _marquee["active"]:
if abs(event.x - _marquee["x"]) < DRAG_THRESHOLD and \
abs(event.y - _marquee["y"]) < DRAG_THRESHOLD:
return
_marquee["active"] = True
app_state.canvas.delete("slot_hover")
canvas = app_state.canvas
canvas.delete("marquee")
canvas.create_rectangle(_marquee["cx"], _marquee["cy"],
canvas.canvasx(event.x), canvas.canvasy(event.y),
outline=MARQUEE_COLOR, width=1, dash=(4, 2),
tags=("marquee",))
canvas.tag_raise("marquee")
def _marquee_finish(event):
"""Ends a rectangle drag. Returns True if it selected, False if it was a click."""
global _marquee
marquee, _marquee = _marquee, None
canvas = app_state.canvas
canvas.delete("marquee")
if marquee is None or not marquee["active"]:
return False
cx, cy = canvas.canvasx(event.x), canvas.canvasy(event.y)
left, right = sorted((marquee["cx"], cx))
top, bottom = sorted((marquee["cy"], cy))
inside = []
for comp_tag in app_state.canvas_comp_map:
box = wiresheet.get_box_bounds(comp_tag)
if box and left <= box[0] and top <= box[1] and box[2] <= right and box[3] <= bottom:
inside.append(comp_tag)
adding = (marquee["state"] | event.state) & CTRL_MASK
wiresheet.deselect_link() # links and boxes are never selected together
if adding:
current = [t for t in app_state.selected_canvas_tags if t not in inside]
_select_boxes(current + inside)
else:
_select_boxes(inside)
count = len(app_state.selected_canvas_tags)
app_state.set_status(f"{count} component{'s' if count != 1 else ''} selected.")
return True
# Left button: anywhere on a box selects it. Only the title bar moves it; a slot row
# drags out a link, and the rest of the box does nothing more than select.
def _on_title_bar(comp_tag, cy):
box = wiresheet.get_box_bounds(comp_tag)
return box is not None and box[1] <= cy <= box[1] + wiresheet.HEADER_HEIGHT * app_state.zoom
def on_left_press(event):
global _marquee
_marquee = None
canvas = app_state.canvas
cx, cy = canvas.canvasx(event.x), canvas.canvasy(event.y)
if _on_bare_sheet(cx, cy):
app_state.drag_data["tag"] = None
if not event.state & CTRL_MASK:
# A plain click on bare sheet still clears the selection, drag or not.
wiresheet.deselect_link()
_select_boxes([])
_marquee = {"x": event.x, "y": event.y, "cx": cx, "cy": cy,
"state": event.state, "active": False}
return
_box_handlers["press"](event) # selection, and arms a box move
if _start_press(event) is not None:
app_state.canvas.delete("slot_hover")
comp_tag = app_state.drag_data.get("tag")
if comp_tag and not _on_title_bar(comp_tag, app_state.canvas.canvasy(event.y)):
app_state.drag_data["tag"] = None # selected, but not picked up
def on_left_motion(event):
if _marquee is not None:
_marquee_motion(event)
elif _press is not None or _drag is not None:
_drag_motion(event)
else:
_box_handlers["motion"](event)
def on_left_release(event):
if _marquee is not None:
_marquee_finish(event)
elif _finish(event) in (None, "click"):
_box_handlers["release"](event)
_box_handlers = {}
def install(canvas, menu_callback, box_press, box_motion, box_release, link_dialog,
select_boxes):
"""Binds hover, link dragging on both buttons, and the box handlers it wraps.
menu_callback opens the context menu; box_* are the sheet's own left-button
select-and-move handlers, which still run whenever a press is not on a slot row;
link_dialog opens the Link dialog when a link is dropped on a title bar;
select_boxes makes a list of comp tags the selection after a rectangle drag.
"""
global _menu_callback, _link_dialog, _select_boxes
_menu_callback = menu_callback
_link_dialog = link_dialog
_select_boxes = select_boxes
_box_handlers.update(press=box_press, motion=box_motion, release=box_release)
canvas.bind("<Motion>", on_motion)
canvas.bind("<Leave>", on_leave, add="+")
canvas.bind("<ButtonPress-1>", on_left_press)
canvas.bind("<B1-Motion>", on_left_motion)
canvas.bind("<ButtonRelease-1>", on_left_release)
canvas.bind("<ButtonPress-3>", on_right_press)
canvas.bind("<B3-Motion>", on_right_motion)
canvas.bind("<ButtonRelease-3>", on_right_release)
+615
View File
@@ -0,0 +1,615 @@
"""
Write-through interception layer for live device mode.
Checks app_state.live_mode and routes mutations through DeviceSession/SoxClient.
In offline mode, falls through to the original DOM-only code paths.
"""
import base64
import binascii
from tkinter import messagebox
import app_state
from sox import BUF_ID, CONFIG, STR_ID, type_id_for_slot
# Why the last live_* call failed, in words the user can act on. Every failing call sets
# it, so a caller can put it in front of the user with warn_failure().
last_error = ""
def is_live():
return (app_state.live_mode
and app_state.device_session is not None
and app_state.device_session.is_connected)
def _fail(reason):
global last_error
last_error = reason
return None
def _session():
"""The connected DeviceSession, or None with last_error saying why not."""
if not is_live():
_fail("The device is not connected.")
return None
return app_state.device_session
def warn_failure(title, what, detail=None):
"""Tells the user, in a dialog and the status bar, that `what` did not reach the device.
`detail` defaults to last_error. Nothing retries a write that failed, so the dialog
is the only record the user gets that the editor and the device may now disagree.
"""
reason = detail if detail is not None else last_error
app_state.set_status(f"{what}: {reason}", warn=True)
messagebox.showwarning(title, f"{what}.\n\n{reason}", parent=app_state.window)
# ── Property Write ──────────────────────────────────────────────
def live_set_prop_value(element, slot_name, value_str):
"""In live mode, sends a Sox write to the device. Returns True on success."""
ds = _session()
if ds is None:
return False
comp_id = _comp_id(element)
if comp_id is None:
_fail(f"'{element.get('name', '?')}' has no component id.")
return False
slot_info = _find_slot(ds, comp_id, slot_name)
if slot_info is None:
return False
type_id = slot_info["type_id"]
try:
coerced = _coerce_value(type_id, value_str)
except (TypeError, ValueError, binascii.Error) as e:
_fail(f"'{value_str}' is not a valid value for {slot_name}: {e}")
return False
try:
ds.sox_client.write_prop(comp_id, slot_info["id"], type_id, coerced)
return True
except Exception as e:
_fail(f"The device rejected the write to {slot_name}: {e}")
return False
# ── Add Component ───────────────────────────────────────────────
def live_add_component(parent_element, kit_name, type_name, comp_name, config_values=None):
"""In live mode, sends a Sox add to the device. Returns new comp_id or None."""
ds = _session()
if ds is None:
return None
parent_id = _comp_id(parent_element)
if parent_id is None:
return _fail(f"The parent '{parent_element.get('name', '?')}' has no component id.")
kit_id = _kit_id(ds, kit_name)
if kit_id is None:
return _fail(f"Kit '{kit_name}' is not installed on the device.")
type_id_val = _type_id(ds, kit_id, type_name)
if type_id_val is None:
return _fail(f"The device's '{kit_name}' kit has no type '{type_name}'.")
try:
new_comp = ds.sox_client.add(parent_id, kit_id, type_id_val, comp_name,
list(config_values or []))
ds.after_add(new_comp.id)
return new_comp.id
except Exception as e:
return _fail(f"The device rejected the add: {e}")
# ── Delete Component ────────────────────────────────────────────
def live_delete_component(element):
"""In live mode, sends a Sox delete to the device. Returns True on success."""
ds = _session()
if ds is None:
return False
comp_id = _comp_id(element)
if comp_id is None:
_fail(f"'{element.get('name', '?')}' has no component id.")
return False
descendant_ids = []
for desc in element.iter():
if desc.tag == "comp" and desc is not element:
did = _comp_id(desc)
if did is not None:
descendant_ids.append(did)
try:
ds.sox_client.delete(comp_id)
ds.after_delete(comp_id, descendant_ids)
return True
except Exception as e:
_fail(f"The device rejected the delete: {e}")
return False
# ── Rename Component ────────────────────────────────────────────
def live_rename_component(element, new_name):
"""In live mode, sends a Sox rename to the device. Returns True on success."""
ds = _session()
if ds is None:
return False
comp_id = _comp_id(element)
if comp_id is None:
_fail(f"'{element.get('name', '?')}' has no component id.")
return False
try:
ds.sox_client.rename(comp_id, new_name)
return True
except Exception as e:
_fail(f"The device rejected the rename: {e}")
return False
# ── Reorder Children ────────────────────────────────────────────
def live_reorder_children(parent_element, ordered_children):
"""In live mode, sends a Sox reorder to the device. Returns True on success.
The device takes the order only when it names every child it has, no more and no
fewer, so the list is checked against what the device last said before it is sent.
"""
ds = _session()
if ds is None:
return False
parent_id = _comp_id(parent_element)
if parent_id is None:
_fail(f"'{parent_element.get('name', '?')}' has no component id.")
return False
child_ids = []
for child in ordered_children:
cid = _comp_id(child)
if cid is None:
_fail(f"'{child.get('name', '?')}' has no component id.")
return False
child_ids.append(cid)
client = ds.sox_client
cached = client.cache_get(parent_id)
if cached is not None and set(cached.children) != set(child_ids):
_fail("The editor's list of children no longer matches the device's. "
"Reconnect to read the device again.")
return False
try:
client.reorder(parent_id, child_ids)
return True
except Exception as e:
_fail(f"The device rejected the reorder: {e}")
return False
# ── Link / Unlink ───────────────────────────────────────────────
def _link_ends(ds, from_element, from_slot_name, to_element, to_slot_name):
"""(from_id, from_slot_id, to_id, to_slot_id), or None with last_error set."""
if from_element is None or to_element is None:
return _fail("One end of the link is not a component in the loaded tree.")
from_id = _comp_id(from_element)
to_id = _comp_id(to_element)
if from_id is None or to_id is None:
return _fail("One end of the link has no component id.")
from_slot = _find_slot(ds, from_id, from_slot_name)
if from_slot is None:
return None
to_slot = _find_slot(ds, to_id, to_slot_name)
if to_slot is None:
return None
return from_id, from_slot["id"], to_id, to_slot["id"]
def live_add_link(from_element, from_slot_name, to_element, to_slot_name):
"""In live mode, sends a Sox link command. Returns True on success."""
ds = _session()
if ds is None:
return False
ends = _link_ends(ds, from_element, from_slot_name, to_element, to_slot_name)
if ends is None:
return False
try:
ds.sox_client.link(*ends)
return True
except Exception as e:
_fail(f"The device rejected the link: {e}")
return False
def live_delete_link(from_element, from_slot_name, to_element, to_slot_name):
"""In live mode, sends a Sox unlink command. Returns True on success."""
ds = _session()
if ds is None:
return False
ends = _link_ends(ds, from_element, from_slot_name, to_element, to_slot_name)
if ends is None:
return False
try:
ds.sox_client.unlink(*ends)
return True
except Exception as e:
_fail(f"The device rejected the unlink: {e}")
return False
# ── Invoke Action ───────────────────────────────────────────────
def live_invoke(element, slot_name, arg=None):
"""In live mode, sends a Sox invoke. Returns True on success."""
ds = _session()
if ds is None:
return False
comp_id = _comp_id(element)
if comp_id is None:
_fail(f"'{element.get('name', '?')}' has no component id.")
return False
slot_info = _find_slot(ds, comp_id, slot_name)
if slot_info is None:
return False
try:
ds.sox_client.invoke(comp_id, slot_info["id"])
return True
except Exception as e:
_fail(f"The device rejected the invoke of {slot_name}: {e}")
return False
# ── Undo: read a subtree back, put it back ──────────────────────
def read_subtree(element):
"""Everything it takes to put `element` and all it holds back on the device.
Read from the device, not the DOM: the DOM only holds what has been opened, and a
placeholder child carries neither config nor children of its own. Requests go one
component at a time, at the pace the device sets (see used_kit_names).
Returns {"tree", "links", "siblings"}, or None with last_error set. A node is
{"id", "kit_id", "type_id", "type_name", "name", "values", "element", "children"},
where "element" is the DOM element showing it, if any, so a new id can reach it.
A link end, and each of "siblings" (the parent's children, in order), is ("node",
node), ("elem", element) or ("id", comp_id): a component outside the subtree is
followed by its element when it has one, because its id changes if it is itself
deleted and put back.
"""
ds = _session()
if ds is None:
return None
comp_id = _comp_id(element)
if comp_id is None:
return _fail(f"'{element.get('name', '?')}' has no component id.")
client = ds.sox_client
root = app_state.xml_root_element
elements_by_id = {}
if root is not None:
for elem in root.iter("comp"):
if elem.get("id") is not None:
elements_by_id.setdefault(elem.get("id"), elem)
nodes_by_id = {}
def read(comp):
if comp.type_name not in app_state.final_components_registry:
raise _SnapshotError(f"No kit manifest describes {comp.type_name}, so "
f"'{comp.name}' could not be put back as it was.")
children = ds.load_children(comp.id)
if len(children) != len(comp.children):
raise _SnapshotError(f"Not every child of '{comp.name}' could be read.")
client.update([comp], CONFIG)
values = dict(comp.values)
# The cache reads every Buf as a Str, which loses bytes of a binary one (a
# user's cred); those are read again as what they are.
for slot in app_state.final_components_registry[comp.type_name]:
if ("c" in slot["flags"] and slot["type"] != "void"
and _wire_type(slot["type"], slot["facets"].get("asStr") is True) == BUF_ID):
values[slot["name"]] = client.read_prop(comp.id, slot["slot_id"], BUF_ID)
node = {
"id": comp.id,
"kit_id": comp.kit_id,
"type_id": comp.type_id,
"type_name": comp.type_name,
"name": comp.name,
"values": values,
"element": elements_by_id.get(str(comp.id)),
"children": [],
}
nodes_by_id[comp.id] = node
node["children"] = [read(child) for child in children]
return node
try:
top = client.load(comp_id)
if top is None:
return _fail(f"'{element.get('name', '?')}' could not be read from the device.")
tree = read(top)
links = []
seen = set()
for cid in list(nodes_by_id):
for lnk in client.read_links(cid):
key = (lnk.from_comp_id, lnk.from_slot_id, lnk.to_comp_id, lnk.to_slot_id)
if key in seen:
continue
seen.add(key)
links.append((_link_end(lnk.from_comp_id, nodes_by_id, elements_by_id),
lnk.from_slot_id,
_link_end(lnk.to_comp_id, nodes_by_id, elements_by_id),
lnk.to_slot_id))
except _SnapshotError as e:
return _fail(str(e))
except Exception as e:
return _fail(f"'{element.get('name', '?')}' could not be read from the device: {e}")
parent = client.cache_get(top.parent)
siblings = [_link_end(cid, nodes_by_id, elements_by_id)
for cid in (parent.children if parent is not None else [])]
return {"tree": tree, "links": links, "siblings": siblings}
class _SnapshotError(Exception):
pass
def _link_end(comp_id, nodes_by_id, elements_by_id):
if comp_id in nodes_by_id:
return ("node", nodes_by_id[comp_id])
elem = elements_by_id.get(str(comp_id))
if elem is not None:
return ("elem", elem)
return ("id", comp_id)
def _link_end_id(end):
kind, ref = end
if kind == "node":
return ref["id"]
if kind == "elem":
return _comp_id(ref)
return ref
def recreate_subtree(parent_element, snapshot, build_config):
"""Adds a subtree from read_subtree back under `parent_element`, with its links.
The device hands out new ids. Each node, and the DOM element showing it, takes its
new id, so whatever refers to them later still finds them. The component goes back
to its old place among its siblings.
`build_config(type_name, device_values=...)` makes an add command's config values.
Returns (top_created, complete); when not complete, last_error says what is missing.
"""
ds = _session()
if ds is None:
return False, False
parent_id = _comp_id(parent_element)
if parent_id is None:
_fail(f"The parent '{parent_element.get('name', '?')}' has no component id.")
return False, False
client = ds.sox_client
tree = snapshot["tree"]
def create(node, under_id):
config = build_config(node["type_name"], device_values=node["values"])
comp = client.add(under_id, node["kit_id"], node["type_id"], node["name"], config)
node["id"] = comp.id
if node["element"] is not None:
node["element"].set("id", str(comp.id))
def create_children(node):
for child in node["children"]:
create(child, node["id"])
create_children(child)
try:
create(tree, parent_id)
except Exception as e:
_fail(f"The device rejected the add of '{tree['name']}': {e}")
return False, False
try:
create_children(tree)
for from_end, from_slot, to_end, to_slot in snapshot["links"]:
client.link(_link_end_id(from_end), from_slot, _link_end_id(to_end), to_slot)
# The add put it last; the device's order is the order execution runs in.
parent = client.cache_get(parent_id)
wanted = [_link_end_id(end) for end in snapshot["siblings"]]
if (parent is not None and set(wanted) == set(parent.children)
and wanted != parent.children):
client.reorder(parent_id, wanted)
complete = True
except Exception as e:
_fail(f"'{tree['name']}' is back on the device, but not all of it: {e}")
complete = False
ds.after_add(tree["id"])
return True, complete
def duplicate_subtrees(plans, links, build_config):
"""Adds copies of read_subtree trees to the device, then the links among them.
`plans` is a list of (parent_element, node, new_name, new_meta): the node is a
read_subtree tree, copied under `parent_element` with its top renamed and moved to
`new_meta` (None keeps it where it was). Children keep their names, which stay
unique because their parent is new. `links` holds (from_id, from_slot, to_id,
to_slot) between components in the trees, by their original ids.
Nothing in a node is changed; the device hands out new ids for the copies.
Returns (created, new_ids, complete): `created` lists (parent_element, new_top_id)
for every top the device took, `new_ids` maps each original id to its copy's, and
when not complete, last_error says what is missing.
"""
ds = _session()
if ds is None:
return [], {}, False
client = ds.sox_client
new_ids = {}
created = []
def create(node, under_id, name, meta):
values = dict(node["values"])
if meta is not None:
values["meta"] = meta
config = build_config(node["type_name"], device_values=values)
comp = client.add(under_id, node["kit_id"], node["type_id"], name, config)
new_ids[node["id"]] = comp.id
for child in node["children"]:
create(child, comp.id, child["name"], None)
complete = True
for parent_element, node, new_name, new_meta in plans:
parent_id = _comp_id(parent_element)
if parent_id is None:
_fail(f"The parent '{parent_element.get('name', '?')}' has no component id.")
complete = False
break
try:
create(node, parent_id, new_name, new_meta)
except Exception as e:
if node["id"] in new_ids:
created.append((parent_element, new_ids[node["id"]]))
_fail(f"'{new_name}' was added to the device, but not all of it: {e}")
else:
_fail(f"The device rejected the add of '{new_name}': {e}")
complete = False
break
created.append((parent_element, new_ids[node["id"]]))
if complete:
for from_id, from_slot, to_id, to_slot in links:
try:
client.link(new_ids[from_id], from_slot, new_ids[to_id], to_slot)
except Exception as e:
_fail(f"The copies are on the device, but not every link between them: {e}")
complete = False
break
top_ids = {top_id for _, top_id in created}
for top_id in top_ids:
ds.after_add(top_id)
# after_add reads only the top; the children's values are read too, so the DOM
# built from the cache carries their slots.
children = [client.cache_get(i) for i in new_ids.values() if i not in top_ids]
children = [c for c in children if c is not None]
if children:
try:
client.update(children, CONFIG)
except Exception:
pass
return created, new_ids, complete
def wire_value(type_id, raw):
"""A value as the device sent it, in the form encode_value wants for `type_id`."""
if type_id == BUF_ID:
return bytes(raw) if isinstance(raw, (bytes, bytearray)) else str(raw).encode("utf-8")
if type_id == STR_ID:
if isinstance(raw, (bytes, bytearray)):
return bytes(raw).decode("utf-8", errors="replace").rstrip("\0")
return str(raw)
return _coerce_value(type_id, str(raw))
# ── Helpers ─────────────────────────────────────────────────────
def _comp_id(element):
if element.tag == "app":
return 0
cid = element.get("id")
if cid is None:
return None
try:
return int(cid)
except ValueError:
return None
def _find_slot(ds, comp_id, slot_name):
"""{name, id, type_id} for a slot of a cached component, or None with last_error set.
A Buf goes over the wire as a Buf only when it is binary; an asStr Buf travels as a
Str, which is how the rest of the editor already reads and writes it.
"""
comp = ds.sox_client.cache_get(comp_id)
if comp is None:
return _fail(f"Component {comp_id} is not loaded from the device.")
type_name = ds.sox_client.type_name_map.get((comp.kit_id, comp.type_id))
if type_name and type_name in app_state.final_components_registry:
for s in app_state.final_components_registry[type_name]:
if s["name"] == slot_name:
slot_type = s.get("type") or "int"
as_str = (s.get("facets") or {}).get("asStr") is True
return {"name": s["name"], "id": s["slot_id"],
"type_id": _wire_type(slot_type, as_str)}
return _fail(f"{type_name} has no slot '{slot_name}'.")
slots = ds.sox_client.slot_registry.get((comp.kit_id, comp.type_id))
if slots is None:
return _fail(f"No kit manifest describes {type_name or 'this component'}'s slots, "
"so the slot cannot be addressed on the device.")
for s in slots:
if s["name"] == slot_name:
return {"name": s["name"], "id": s["id"],
"type_id": _wire_type(s.get("type_name") or "int", s.get("is_as_str"))}
return _fail(f"{type_name or 'This component'} has no slot '{slot_name}'.")
def _wire_type(slot_type, as_str):
if slot_type in ("sys::Buf", "Buf", "buf") and not as_str:
return BUF_ID
return type_id_for_slot(slot_type)
def _kit_id(ds, kit_name):
if ds.sox_client.schema_parts:
for i, part in enumerate(ds.sox_client.schema_parts):
if part.name == kit_name:
return i
return None
def _type_id(ds, kit_id, type_name):
if kit_id is None:
return None
for (kid, tid), full_name in ds.sox_client.type_name_map.items():
if kid == kit_id and full_name.endswith(f"::{type_name}"):
return tid
return None
def _coerce_value(type_id, value_str):
"""The Python value encode_value wants for `value_str` in document form.
Raises ValueError (or binascii.Error) when the text does not fit the type.
"""
from sox import BOOL_ID, BYTE_ID, SHORT_ID, INT_ID, LONG_ID, FLOAT_ID, DOUBLE_ID, STR_ID
if type_id == BOOL_ID:
return value_str.lower() in ("true", "1", "yes")
elif type_id in (BYTE_ID, SHORT_ID, INT_ID, LONG_ID):
try:
return int(value_str)
except ValueError:
return int(value_str, 0) # meta and flags can be written as 0x...
elif type_id in (FLOAT_ID, DOUBLE_ID):
return float(value_str)
elif type_id == STR_ID:
return str(value_str)
elif type_id == BUF_ID:
if isinstance(value_str, (bytes, bytearray)):
return bytes(value_str)
return base64.b64decode(value_str or "", validate=True) # the .sax form
return value_str
+1763 -158
View File
File diff suppressed because it is too large Load Diff
+106 -46
View File
@@ -15,20 +15,20 @@ declared can be unticked, which turns its Action into REMOVE. An installed kit t
declared starts unticked, and ticking it adds it at its newest build. declared starts unticked, and ticking it adds it at its newest build.
""" """
import os
from datetime import datetime
import tkinter as tk import tkinter as tk
from tkinter import messagebox, ttk from tkinter import filedialog, messagebox, ttk
import app_state import app_state
import palette import palette
import sax_file import sax_file
import schema
NOT_DECLARED = "—" # em dash, for a column with nothing to report NOT_DECLARED = "—" # em dash, for a column with nothing to report
NO_CHECKSUM = "(no checksum)" NO_CHECKSUM = "(no checksum)"
REMOVE_ACTION = "REMOVE from app" REMOVE_ACTION = "REMOVE from app"
SECTION_TAG = "section" SECTION_TAG = "section"
UNAVAILABLE_TAG = "unavailable"
CHECKBOX_SIZE = 13 CHECKBOX_SIZE = 13
@@ -37,8 +37,14 @@ TICK_BLOCKS = [
(2, 5), (3, 6), (4, 7), (5, 6), (6, 5), (7, 4), (8, 3), (9, 2), (2, 5), (3, 6), (4, 7), (5, 6), (6, 5), (7, 4), (8, 3), (9, 2),
] ]
# The cross (X), as filled 2x2 blocks: two diagonal strokes.
CROSS_BLOCKS = [
(2, 2), (3, 3), (4, 4), (5, 5), (6, 6), (7, 7), (8, 8), (9, 9),
(9, 2), (8, 3), (7, 4), (6, 5), (5, 6), (4, 7), (3, 8), (2, 9),
]
def make_checkbox_image(checked, enabled):
def make_checkbox_image(checked, enabled, cross=False):
"""A checkbox drawn pixel by pixel, so a locked tick can be greyed out. """A checkbox drawn pixel by pixel, so a locked tick can be greyed out.
Tk has no disabled state for a cell, and a row tag would grey the whole row including Tk has no disabled state for a cell, and a row tag would grey the whole row including
@@ -56,7 +62,10 @@ def make_checkbox_image(checked, enabled):
image.put(border, to=(0, 0, 1, CHECKBOX_SIZE)) image.put(border, to=(0, 0, 1, CHECKBOX_SIZE))
image.put(border, to=(CHECKBOX_SIZE - 1, 0, CHECKBOX_SIZE, CHECKBOX_SIZE)) image.put(border, to=(CHECKBOX_SIZE - 1, 0, CHECKBOX_SIZE, CHECKBOX_SIZE))
if checked: if cross:
for x, y in CROSS_BLOCKS:
image.put("#b04040", to=(x, y, x + 2, y + 2))
elif checked:
for x, y in TICK_BLOCKS: for x, y in TICK_BLOCKS:
image.put(ink, to=(x, y, x + 2, y + 2)) image.put(ink, to=(x, y, x + 2, y + 2))
@@ -73,6 +82,11 @@ def column_label(version, checksum):
return version if version else checksum return version if version else checksum
def same_checksum(a, b):
"""Whether two checksums name the same build, whatever their zero-padding."""
return bool(a) and bool(b) and a.lower().lstrip("0") == b.lower().lstrip("0")
def action_options(kit_name, current_checksum, is_declared, builds): def action_options(kit_name, current_checksum, is_declared, builds):
"""The Action dropdown for one kit: (label, checksum to apply) pairs, no-op first. """The Action dropdown for one kit: (label, checksum to apply) pairs, no-op first.
@@ -82,7 +96,7 @@ def action_options(kit_name, current_checksum, is_declared, builds):
""" """
installed_version = None installed_version = None
for version, checksum in builds: for version, checksum in builds:
if checksum == current_checksum: if same_checksum(checksum, current_checksum):
installed_version = version installed_version = version
break break
@@ -100,7 +114,7 @@ def action_options(kit_name, current_checksum, is_declared, builds):
installed_rank = palette.version_sort_key(installed_version) if installed_version else None installed_rank = palette.version_sort_key(installed_version) if installed_version else None
for version, checksum in builds: for version, checksum in builds:
if is_declared and checksum == current_checksum: if is_declared and same_checksum(checksum, current_checksum):
continue continue
label = build_label(version, checksum) label = build_label(version, checksum)
@@ -130,16 +144,13 @@ def collect_rows():
required = sax_file.required_kit_names() required = sax_file.required_kit_names()
installed = palette.list_kits() installed = palette.list_kits()
# A kit the schema declares but Sedona home does not hold still gets a row: it is the
# clearest place to see that the file points at something missing.
others = [name for name in installed if name not in declared_names] others = [name for name in installed if name not in declared_names]
suitable = [] bad = palette.unsupported_kits()
unsuitable = [] compatible = [name for name in others if name not in bad]
for name in others: incompatible = [name for name in others if name in bad]
deps = schema.read_kit_dependencies(name)
if all(d in declared_names for d in deps):
suitable.append(name)
else:
unsuitable.append(name)
groups = [ groups = [
("Installed kits", [ ("Installed kits", [
@@ -148,7 +159,8 @@ def collect_rows():
("Installed but not used kits", [ ("Installed but not used kits", [
(name, checksum) for name, checksum in declared if name not in required (name, checksum) for name, checksum in declared if name not in required
]), ]),
("Kits available on the computer", [(name, None) for name in suitable]), ("Kits available on the computer", [(name, None) for name in compatible]),
("Not supported", [(name, None) for name in incompatible]),
] ]
rows = [] rows = []
@@ -160,6 +172,7 @@ def collect_rows():
builds = palette.kit_builds(name) builds = palette.kit_builds(name)
latest = builds[0] if builds else None latest = builds[0] if builds else None
is_declared = name in declared_names is_declared = name in declared_names
is_unsupported = name in bad
options = action_options(name, checksum, is_declared, builds) options = action_options(name, checksum, is_declared, builds)
if not is_declared: if not is_declared:
@@ -168,13 +181,14 @@ def collect_rows():
installed_text = NO_CHECKSUM installed_text = NO_CHECKSUM
else: else:
installed_text = column_label( installed_text = column_label(
next((v for v, c in builds if c == checksum), ""), checksum next((v for v, c in builds if same_checksum(c, checksum)), ""), checksum
) )
rows.append({ rows.append({
"kit": name, "kit": name,
"declared": is_declared, "declared": is_declared,
"required": name in required, "required": name in required,
"unsupported": is_unsupported,
"checksum": checksum, "checksum": checksum,
"builds": builds, "builds": builds,
"latest": latest, "latest": latest,
@@ -183,29 +197,7 @@ def collect_rows():
"options": options, "options": options,
"choice": 0, "choice": 0,
"checked": is_declared, "checked": is_declared,
"unavailable": False,
}) })
if unsuitable:
rows.append({"section": "Other kits on the computer (dependencies not in app)"})
for name in unsuitable:
builds = palette.kit_builds(name)
latest = builds[0] if builds else None
rows.append({
"kit": name,
"declared": False,
"required": False,
"checksum": None,
"builds": builds,
"latest": latest,
"installed_text": NOT_DECLARED,
"latest_text": column_label(*latest) if latest else NOT_DECLARED,
"options": [("Not available", None)],
"choice": 0,
"checked": False,
"unavailable": True,
})
return rows return rows
@@ -269,7 +261,6 @@ class KitsPanel:
self.table.pack(fill=tk.BOTH, expand=True) self.table.pack(fill=tk.BOTH, expand=True)
self.table.tag_configure(SECTION_TAG, background="#dcdcdc") self.table.tag_configure(SECTION_TAG, background="#dcdcdc")
self.table.tag_configure(UNAVAILABLE_TAG, foreground="#999999")
self.boxes = { self.boxes = {
(True, True): make_checkbox_image(True, True), (True, True): make_checkbox_image(True, True),
@@ -277,6 +268,7 @@ class KitsPanel:
(True, False): make_checkbox_image(True, False), (True, False): make_checkbox_image(True, False),
(False, False): make_checkbox_image(False, False), (False, False): make_checkbox_image(False, False),
} }
self.cross_box = make_checkbox_image(False, False, cross=True)
hint = ttk.Label( hint = ttk.Label(
self.frame, self.frame,
@@ -298,6 +290,9 @@ class KitsPanel:
button_row, text="Update kits", command=self._update_marked, button_row, text="Update kits", command=self._update_marked,
).pack(side=tk.LEFT, padx=6) ).pack(side=tk.LEFT, padx=6)
ttk.Button(button_row, text="Close", command=self.close).pack(side=tk.RIGHT) ttk.Button(button_row, text="Close", command=self.close).pack(side=tk.RIGHT)
ttk.Button(
button_row, text="Export as XLS", command=self._export_xls,
).pack(side=tk.RIGHT, padx=6)
self.rows = [] self.rows = []
self.row_by_item = {} self.row_by_item = {}
@@ -317,6 +312,71 @@ class KitsPanel:
self._close_editor() self._close_editor()
self.frame.destroy() self.frame.destroy()
def _export_xls(self):
try:
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
except ImportError:
messagebox.showerror("Export", "openpyxl is not installed.")
return
default_name = ""
if app_state.current_file_path:
base = os.path.splitext(os.path.basename(app_state.current_file_path))[0]
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
default_name = f"{base}_kits_{stamp}.xlsx"
path = filedialog.asksaveasfilename(
title="Export Kit Table",
defaultextension=".xlsx",
initialfile=default_name,
filetypes=[("Excel Files", "*.xlsx")],
)
if not path:
return
wb = Workbook()
ws = wb.active
ws.title = "Kits"
headers = ["", "Kit name", "Installed", "Latest", "Action"]
header_font = Font(bold=True)
section_fill = PatternFill(start_color="DCDCDC", end_color="DCDCDC", fill_type="solid")
for col, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col, value=header)
cell.font = header_font
row_num = 2
for row in self.rows:
if "section" in row:
cell = ws.cell(row=row_num, column=2, value=row["section"])
cell.font = Font(bold=True)
for col in range(1, 6):
ws.cell(row=row_num, column=col).fill = section_fill
row_num += 1
continue
check = "✓" if row["checked"] else ""
ws.cell(row=row_num, column=1, value=check).alignment = Alignment(horizontal="center")
ws.cell(row=row_num, column=2, value=row["kit"])
ws.cell(row=row_num, column=3, value=row["installed_text"])
ws.cell(row=row_num, column=4, value=row["latest_text"])
ws.cell(row=row_num, column=5, value=self._action_text(row))
row_num += 1
ws.column_dimensions["A"].width = 4
ws.column_dimensions["B"].width = 28
ws.column_dimensions["C"].width = 16
ws.column_dimensions["D"].width = 16
ws.column_dimensions["E"].width = 30
try:
wb.save(path)
app_state.set_status(f"Kit table exported to {os.path.basename(path)}")
except Exception as e:
messagebox.showerror("Export", f"Could not save file:\n{e}")
def _close_editor(self): def _close_editor(self):
if self._editor_widget is not None: if self._editor_widget is not None:
self._editor_widget.destroy() self._editor_widget.destroy()
@@ -331,6 +391,8 @@ class KitsPanel:
return (row["kit"], row["installed_text"], row["latest_text"], self._action_text(row)) return (row["kit"], row["installed_text"], row["latest_text"], self._action_text(row))
def _row_image(self, row): def _row_image(self, row):
if row.get("unsupported"):
return self.cross_box
return self.boxes[(row["checked"], not row["required"])] return self.boxes[(row["checked"], not row["required"])]
def _rebuild_table(self): def _rebuild_table(self):
@@ -346,10 +408,8 @@ class KitsPanel:
"", "end", values=(row["section"], "", "", ""), tags=(SECTION_TAG,), "", "end", values=(row["section"], "", "", ""), tags=(SECTION_TAG,),
) )
continue continue
tags = (UNAVAILABLE_TAG,) if row.get("unavailable") else ()
item = self.table.insert( item = self.table.insert(
"", "end", image=self._row_image(row), values=self._row_values(row), "", "end", image=self._row_image(row), values=self._row_values(row),
tags=tags,
) )
self.row_by_item[item] = row self.row_by_item[item] = row
@@ -366,7 +426,7 @@ class KitsPanel:
def _toggle_check(self, item): def _toggle_check(self, item):
row = self.row_by_item[item] row = self.row_by_item[item]
if row["required"]: if row["required"] or row.get("unsupported"):
return return
row["checked"] = not row["checked"] row["checked"] = not row["checked"]
@@ -383,6 +443,8 @@ class KitsPanel:
def _edit_action(self, item): def _edit_action(self, item):
row = self.row_by_item[item] row = self.row_by_item[item]
if row.get("unsupported"):
return
cell = self.table.bbox(item, "action") cell = self.table.bbox(item, "action")
if not cell: if not cell:
return return
@@ -411,8 +473,6 @@ class KitsPanel:
item = self.table.identify_row(event.y) item = self.table.identify_row(event.y)
if item not in self.row_by_item: if item not in self.row_by_item:
return return
if self.row_by_item[item].get("unavailable"):
return
column = self.table.identify_column(event.x) column = self.table.identify_column(event.x)
if column == "#0": if column == "#0":
@@ -457,7 +517,7 @@ class KitsPanel:
if "section" in row or not row["declared"] or not row["latest"]: if "section" in row or not row["declared"] or not row["latest"]:
continue continue
latest_checksum = row["latest"][1] latest_checksum = row["latest"][1]
if latest_checksum != row["checksum"]: if not same_checksum(latest_checksum, row["checksum"]):
selections.append((row["kit"], latest_checksum)) selections.append((row["kit"], latest_checksum))
if selections: if selections:
+786
View File
@@ -0,0 +1,786 @@
"""WYSIWYG HMI page editor for ontrolOx::OxPage components.
Renders the device screen canvas at its native resolution and places the child
widgets (ontrolOx::Widget subclasses) by their x, y, width, height slots.
Supports selection, drag-move, and a synced widget tree.
Device resolution is detected from the schema kits:
ontrolOrion → 800×480, or 480×800 when its platform service's screenOrientation
is portrait (drawn in frame_orion_portrait.png)
ontrolRion → 320×240
A device frame image (the physical bezel) sits behind the screen area when the
corresponding PNG is found in the assets/ directory next to this module.
"""
import os
import tkinter as tk
from tkinter import font as tkfont, ttk
try:
from PIL import Image, ImageTk
_HAS_PIL = True
except ImportError:
_HAS_PIL = False
import app_state
import live_hooks
import undo
from ox_widget_properties import ORION_KIT, OxWidgetPropertiesPanel, orion_portrait
from ox_widget_render import WidgetPainter
from sax_dom import set_prop_value
HEADER_BG = "#f2f2f2"
_ASSETS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets")
DEVICE_PROFILES = {
"ontrolOrion": {
"screen": (800, 480),
"frame_file": "frame_orion.png",
"screen_offset": (191, 169),
"default_zoom": 0.5,
},
"ontrolRion": {
"screen": (320, 240),
"frame_file": "frame_rion.png",
"screen_offset": (76, 62),
"default_zoom": 1.0,
},
}
ORION_PORTRAIT_PROFILE = {
"screen": (480, 800),
"frame_file": "frame_orion_portrait.png",
"screen_offset": (190, 170),
"default_zoom": 0.5,
}
DEFAULT_SCREEN = (800, 480)
DEFAULT_ZOOM = 0.5
ZOOM_LEVELS = (0.25, 0.33, 0.5, 0.67, 0.8, 1.0, 1.25, 1.5, 2.0, 3.0)
CANVAS_PADDING = 16
# About what the widget tree / properties pane asks for (300px sheet + scrollbar).
RIGHT_PANE_WIDTH = 320
WIDGET_OUTLINE_WIDTH = 1
SELECTED_OUTLINE = "#1f4e79"
SELECTED_OUTLINE_WIDTH = 2
HANDLE_SIZE = 6
HANDLE_COLOUR = "#00b050"
_SHORT_TYPE_NAMES = {
"NumericLabel": "NL",
"NumericLabelSet": "NLS",
"PushButtonLabel": "PBL",
"PushButtonImage": "PBI",
"BooleanLabel": "BL",
"BooleanImage": "BI",
"Geometry": "Geo",
"CalibrationButton": "Cal",
"NumericSelectImage": "NSI",
"NumericSelectLabel": "NSL",
"DateLabelSet": "DLS",
"TimeLabelSet": "TLS",
"ScheduleLabelSet": "ScL",
}
def _rgb565_to_hex(val):
try:
v = int(val)
except (TypeError, ValueError):
return "#ffffff"
if v < 0:
v &= 0xFFFF
r = ((v >> 11) & 0x1F) * 255 // 31
g = ((v >> 5) & 0x3F) * 255 // 63
b = (v & 0x1F) * 255 // 31
return f"#{r:02x}{g:02x}{b:02x}"
def _get_prop(comp, name, default=None):
prop = comp.find(f"prop[@name='{name}']")
return prop.get("val", default) if prop is not None else default
def _short_type(full_type):
short = full_type.rsplit("::", 1)[-1] if "::" in full_type else full_type
return _SHORT_TYPE_NAMES.get(short, short)
def _detect_device_profile():
for kit_name, profile in DEVICE_PROFILES.items():
if kit_name in app_state.active_schema_kits:
if kit_name == ORION_KIT and orion_portrait():
return ORION_PORTRAIT_PROFILE
return profile
return None
def _detect_screen_size():
profile = _detect_device_profile()
return profile["screen"] if profile else DEFAULT_SCREEN
def _load_frame_image(profile):
if profile is None:
return None, None
path = os.path.join(_ASSETS_DIR, profile["frame_file"])
if not os.path.isfile(path):
return None, None
if _HAS_PIL:
try:
pil_img = Image.open(path).convert("RGBA")
tk_img = ImageTk.PhotoImage(pil_img)
return pil_img, tk_img
except Exception:
return None, None
try:
return None, tk.PhotoImage(file=path)
except tk.TclError:
return None, None
def _is_ox_widget(comp):
comp_type = comp.get("type", "")
return comp_type.startswith("ontrolOx::") and comp_type != "ontrolOx::OxPage"
def _get_widget_children(page_comp):
return [c for c in page_comp if c.tag == "comp" and _is_ox_widget(c)]
CANVAS_BG = "#e0e0e0"
GRID_STEP = 10 # device pixels
GRID_MIN_SPACING = 4 # canvas pixels; a denser grid is left out rather than drawn solid
GRID_SHADE = 0.82 # grid line = page colour scaled by this (lifted on dark pages)
# Page Editor toggles, held for the session like the Sedona sheet's: whether the page
# can be changed (edit mode, which is also when the 10x10 grid is drawn), whether a
# drag lands on the grid, and whether the tree/properties pane shows.
edit_mode = False
snap_to_grid = True
show_right_pane = True
def _grid_colour(page_hex):
r, g, b = (int(page_hex[i:i + 2], 16) for i in (1, 3, 5))
if (r * 299 + g * 587 + b * 114) / 1000 < 60:
r, g, b = (min(255, v + 45) for v in (r, g, b))
else:
r, g, b = (int(v * GRID_SHADE) for v in (r, g, b))
return f"#{r:02x}{g:02x}{b:02x}"
class OxPageViewPanel:
def __init__(self, parent, comp, on_change=None, on_close=None):
self.comp = comp
self.on_change = on_change
self.on_close = on_close
self.dirty = False
self.frame = tk.Frame(parent)
profile = _detect_device_profile()
self._screen_w, self._screen_h = _detect_screen_size()
self._frame_pil, self._frame_image = _load_frame_image(profile)
self._scaled_frame = None
if self._frame_image and profile:
self._screen_ox = profile["screen_offset"][0]
self._screen_oy = profile["screen_offset"][1]
frame_w = self._frame_image.width()
frame_h = self._frame_image.height()
self._frame_ox = CANVAS_PADDING
self._frame_oy = CANVAS_PADDING
self._canvas_w = frame_w + CANVAS_PADDING * 2
self._canvas_h = frame_h + CANVAS_PADDING * 2
else:
self._screen_ox = 0
self._screen_oy = 0
self._frame_ox = 0
self._frame_oy = 0
self._canvas_w = self._screen_w + CANVAS_PADDING * 2
self._canvas_h = self._screen_h + CANVAS_PADDING * 2
self._zoom = profile.get("default_zoom", DEFAULT_ZOOM) if profile else DEFAULT_ZOOM
self._selected_widget = None
self._widget_items = {}
self._tag_to_comp = {}
self._widget_boxes = {}
self._drag = {"tag": None, "x": 0, "y": 0}
self._build_header()
self._build_body()
self._build_footer()
self._apply_right_pane()
self.refresh()
@property
def _screen_canvas_x(self):
if self._frame_image:
return self._frame_ox + self._screen_ox
return CANVAS_PADDING
@property
def _screen_canvas_y(self):
if self._frame_image:
return self._frame_oy + self._screen_oy
return CANVAS_PADDING
def _build_header(self):
header = tk.Frame(self.frame, padx=8, pady=6, bg=HEADER_BG)
header.pack(fill=tk.X)
name_font = tkfont.Font(family="Segoe UI", size=9, weight="bold")
plain_font = tkfont.Font(family="Segoe UI", size=9)
tk.Label(header, text="Page Editor", font=name_font,
bg=HEADER_BG).pack(side=tk.LEFT)
page_name = self.comp.get("name", "?")
tk.Label(header, text=f"— {page_name} ({self._screen_w}×{self._screen_h})",
font=plain_font, fg="#555555", bg=HEADER_BG).pack(side=tk.LEFT,
padx=(8, 0))
self._widget_count_var = tk.StringVar(value="")
tk.Label(header, textvariable=self._widget_count_var, font=plain_font,
fg="#555555", bg=HEADER_BG).pack(side=tk.RIGHT)
self._edit_var = tk.BooleanVar(value=edit_mode)
self._snap_var = tk.BooleanVar(value=snap_to_grid)
self._right_pane_var = tk.BooleanVar(value=show_right_pane)
def _build_body(self):
body = ttk.PanedWindow(self.frame, orient=tk.HORIZONTAL)
body.pack(fill=tk.BOTH, expand=True)
canvas_frame = ttk.Frame(body)
self._build_canvas(canvas_frame)
body.add(canvas_frame, weight=1)
# Workbench's layout: the widget tree above, the selected widget's sheet below.
side = ttk.PanedWindow(body, orient=tk.VERTICAL)
body.add(side, weight=0)
self._body = body
self._side = side
tree_frame = ttk.Frame(side)
self._build_widget_tree(tree_frame)
side.add(tree_frame, weight=1)
self._props = OxWidgetPropertiesPanel(side, self._commit_props, self._repaint_all)
self._props.set_editable(self._edit_var.get())
side.add(self._props.frame, weight=3)
def _build_widget_tree(self, parent):
ttk.Label(parent, text="Widgets", font=("TkDefaultFont", 9, "bold")).pack(
anchor="w", padx=4, pady=(4, 2))
tree_container = ttk.Frame(parent)
tree_container.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)
self._tree = ttk.Treeview(tree_container, show="tree", selectmode="browse",
height=8)
vscroll = ttk.Scrollbar(tree_container, orient=tk.VERTICAL,
command=self._tree.yview)
self._tree.configure(yscrollcommand=vscroll.set)
tree_container.columnconfigure(0, weight=1)
tree_container.rowconfigure(0, weight=1)
self._tree.grid(row=0, column=0, sticky="nsew")
vscroll.grid(row=0, column=1, sticky="ns")
self._tree.bind("<<TreeviewSelect>>", self._on_tree_select)
self._tree_items = {}
def _build_canvas(self, parent):
vscroll = ttk.Scrollbar(parent, orient=tk.VERTICAL)
vscroll.pack(side=tk.RIGHT, fill=tk.Y)
hscroll = ttk.Scrollbar(parent, orient=tk.HORIZONTAL)
hscroll.pack(side=tk.BOTTOM, fill=tk.X)
self._canvas = tk.Canvas(
parent, bg=self._background(), highlightthickness=0,
xscrollcommand=hscroll.set, yscrollcommand=vscroll.set,
scrollregion=(0, 0, int(self._canvas_w * self._zoom), int(self._canvas_h * self._zoom)),
)
self._canvas.pack(fill=tk.BOTH, expand=True)
vscroll.config(command=self._canvas.yview)
hscroll.config(command=self._canvas.xview)
self._canvas.bind("<ButtonPress-1>", self._on_canvas_click)
self._canvas.bind("<B1-Motion>", self._on_canvas_drag)
self._canvas.bind("<ButtonRelease-1>", self._on_canvas_release)
self._canvas.bind("<ButtonPress-3>", self._on_canvas_right_click)
self._canvas.bind("<Control-MouseWheel>", self._on_wheel_zoom)
def _render_page(self):
c = self._canvas
c.delete("all")
self._widget_items.clear()
self._tag_to_comp.clear()
self._widget_boxes.clear()
z = self._zoom
sx = self._screen_canvas_x * z
sy = self._screen_canvas_y * z
bg_val = _get_prop(self.comp, "backgroundColor", "65535")
bg_colour = _rgb565_to_hex(bg_val)
c.create_rectangle(sx, sy, sx + self._screen_w * z, sy + self._screen_h * z,
fill=bg_colour, outline="",
tags=("screen_bg",))
self._painter = WidgetPainter(c, sx, sy, z, bg_colour)
self._painter.page_image(_get_prop(self.comp, "imageFileName", ""),
self._screen_w, self._screen_h)
if self._edit_var.get():
self._draw_grid(sx, sy, _grid_colour(bg_colour))
if self._frame_image:
if z != 1.0 and self._frame_pil is not None:
# Rescaled once per zoom level; every edit redraws the page.
if getattr(self, "_scaled_frame_zoom", None) != z:
orig_w, orig_h = self._frame_pil.size
zw = max(1, int(orig_w * z))
zh = max(1, int(orig_h * z))
resized = self._frame_pil.resize((zw, zh), Image.LANCZOS)
self._scaled_frame = ImageTk.PhotoImage(resized)
self._scaled_frame_zoom = z
c.create_image(self._frame_ox * z, self._frame_oy * z, anchor="nw",
image=self._scaled_frame, tags=("device_frame",))
else:
c.create_image(self._frame_ox, self._frame_oy, anchor="nw",
image=self._frame_image, tags=("device_frame",))
else:
c.create_rectangle(sx, sy, sx + self._screen_w * z, sy + self._screen_h * z,
fill="", outline="#333333", width=2,
tags=("screen_border",))
widgets = _get_widget_children(self.comp)
for widget in widgets:
self._draw_widget(widget)
self._widget_count_var.set(
f"{len(widgets)} widget{'' if len(widgets) == 1 else 's'}")
self._populate_tree()
def _draw_grid(self, sx, sy, colour):
"""The 10x10 device-pixel grid over the page background, under the widgets."""
c = self._canvas
z = self._zoom
if GRID_STEP * z < GRID_MIN_SPACING:
return
right = sx + self._screen_w * z
bottom = sy + self._screen_h * z
for gx in range(GRID_STEP, self._screen_w, GRID_STEP):
c.create_line(sx + gx * z, sy, sx + gx * z, bottom, fill=colour, tags=("grid",))
for gy in range(GRID_STEP, self._screen_h, GRID_STEP):
c.create_line(sx, sy + gy * z, right, sy + gy * z, fill=colour, tags=("grid",))
def _on_edit_toggled(self):
global edit_mode
edit_mode = self._edit_var.get()
if not edit_mode:
self._props.flush_edits()
self._props.set_editable(edit_mode)
self.refresh()
app_state.set_status("Page Editor: edit mode — the page can be changed."
if edit_mode else "Page Editor: view mode — nothing can be changed.")
def _on_snap_toggled(self):
global snap_to_grid
snap_to_grid = self._snap_var.get()
def _on_right_pane_toggled(self):
global show_right_pane
show_right_pane = self._right_pane_var.get()
self._apply_right_pane()
def _apply_right_pane(self):
"""Takes the widget tree and properties sheet out of the split, or puts them back."""
showing = str(self._side) in self._body.panes()
if self._right_pane_var.get() and not showing:
self._body.add(self._side, weight=0)
elif not self._right_pane_var.get() and showing:
self._props.flush_edits()
self._body.forget(self._side)
# ── main toolbar ──────────────────────────────────────────────
def toolbar_tools(self):
"""The Page Editor's own toolbar buttons: (icon, tooltip, variable, command).
`main` lays them out on its toolbar while this editor is open and takes them
down with it. A button with a variable is a toggle; add new tools here.
"""
return [
("✏", "Edit Mode", self._edit_var, self._on_edit_toggled),
("⌗", "Snap to Grid", self._snap_var, self._on_snap_toggled),
("◨", "Widgets / Properties Pane", self._right_pane_var,
self._on_right_pane_toggled),
]
def _draw_widget(self, widget):
"""Paints the widget as the device would, then lays an empty box over it.
The box is what selection outlines and what a drag's bbox is read from, so a
transparent label with no text can still be found and moved.
"""
tag = f"ox_widget_{id(widget)}"
tags = (tag, "ox_widget")
x, y, w, h = self._painter.draw(widget, tags, _short_type(widget.get("type", "")))
z = self._zoom
cx = (self._screen_canvas_x + x) * z
cy = (self._screen_canvas_y + y) * z
self._canvas.create_rectangle(cx, cy, cx + w * z, cy + h * z, fill="", outline="",
width=WIDGET_OUTLINE_WIDTH,
tags=(tag, "ox_widget", "ox_widget_rect"))
self._widget_items[widget] = tag
self._tag_to_comp[tag] = widget
self._widget_boxes[widget] = (x, y, w, h)
def _widget_at(self, cx, cy):
"""The topmost widget whose box holds canvas point (cx, cy): the last one drawn."""
z = self._zoom
px = cx / z - self._screen_canvas_x
py = cy / z - self._screen_canvas_y
for widget in reversed(_get_widget_children(self.comp)):
box = self._widget_boxes.get(widget)
if box and box[0] <= px <= box[0] + box[2] and box[1] <= py <= box[1] + box[3]:
return widget
return None
def _populate_tree(self):
for item in self._tree.get_children():
self._tree.delete(item)
self._tree_items.clear()
page_name = self.comp.get("name", "?")
page_iid = self._tree.insert("", tk.END, text=f"\U0001f4c4 {page_name}",
open=True)
widgets = _get_widget_children(self.comp)
for widget in widgets:
name = widget.get("name", "?")
short = _short_type(widget.get("type", ""))
iid = self._tree.insert(page_iid, tk.END, text=f"{name} [{short}]")
self._tree_items[iid] = widget
def _select_widget(self, widget):
c = self._canvas
c.itemconfig("ox_widget_rect", outline="", width=WIDGET_OUTLINE_WIDTH)
c.delete("selection_handle")
self._selected_widget = widget
# With nothing selected the sheet shows the page itself, as Workbench does.
self._props.show(widget if widget is not None else self.comp)
if widget is None:
return
tag = self._widget_items.get(widget)
if tag is None:
return
# Only the outline comes forward: raising the widget itself would paint it over
# the ones the device draws later.
c.itemconfig(f"{tag} && ox_widget_rect", outline=SELECTED_OUTLINE,
width=SELECTED_OUTLINE_WIDTH)
c.tag_raise(f"{tag} && ox_widget_rect")
bbox = c.bbox(f"{tag} && ox_widget_rect")
if bbox:
self._draw_handles(bbox)
for iid, comp in self._tree_items.items():
if comp is widget:
# selection_set always fires <<TreeviewSelect>>, which calls
# back into _select_widget — only set it when it changes.
if self._tree.selection() != (iid,):
self._tree.selection_set(iid)
self._tree.see(iid)
break
name = widget.get("name", "?")
comp_type = widget.get("type", "")
x = _get_prop(widget, "x", "0")
y = _get_prop(widget, "y", "0")
w = _get_prop(widget, "width", "0")
h = _get_prop(widget, "height", "0")
app_state.set_status(
f"{name} ({comp_type}) x={x} y={y} w={w} h={h}")
def _draw_handles(self, bbox):
c = self._canvas
x1, y1, x2, y2 = bbox
hs = HANDLE_SIZE // 2
points = [
(x1, y1), ((x1 + x2) / 2, y1), (x2, y1),
(x1, (y1 + y2) / 2), (x2, (y1 + y2) / 2),
(x1, y2), ((x1 + x2) / 2, y2), (x2, y2),
]
for px, py in points:
c.create_rectangle(px - hs, py - hs, px + hs, py + hs,
fill=HANDLE_COLOUR, outline=HANDLE_COLOUR,
tags=("selection_handle",))
def _repaint_all(self):
"""Every view redrawn from the document, this one included."""
if self.on_change is not None:
self.on_change()
else:
self.refresh()
def _commit_props(self, comp, values):
"""Writes {prop: raw} to `comp` as one undoable step, and to the device when live.
Returns whether anything changed; a change repaints every view, this one included.
"""
changes = []
for name, after in values.items():
prop = comp.find(f"prop[@name='{name}']")
before = prop.get("val") if prop is not None else None
if before != after:
changes.append((comp, name, before, after))
if not changes:
return False
# Live: the device first, and the page only shows what the device accepted.
live_failures = []
applied = []
for change in changes:
element, name, _before, after = change
if app_state.live_mode and not live_hooks.live_set_prop_value(element, name, after):
live_failures.append(f"{name}: {live_hooks.last_error}")
continue
set_prop_value(element, name, after)
applied.append(change)
if app_state.live_mode:
if applied:
undo.push(undo.LiveSlotValueCommand(applied))
else:
undo.push(undo.SlotValueCommand(changes))
app_state.mark_dirty()
self._repaint_all()
who = comp.get("name", "?")
if live_failures:
live_hooks.warn_failure(
"Page Editor",
f"{len(live_failures)} of {len(changes)} change(s) to {who} did not reach "
"the device and were not applied",
"\n".join(f" • {failure}" for failure in live_failures),
)
return len(live_failures) < len(changes)
else:
app_state.set_status(
f"{who}: {', '.join(name for _, name, _, _ in changes)} changed.")
return True
def _on_canvas_click(self, event):
c = self._canvas
cx = c.canvasx(event.x)
cy = c.canvasy(event.y)
# Hit-tested by box, not by canvas item: a transparent label is mostly grid.
widget = self._widget_at(cx, cy)
if widget is not None:
self._select_widget(widget)
if not self._edit_var.get():
self._drag["tag"] = None
return
box = self._widget_boxes[widget]
self._drag.update(tag=self._widget_items[widget], x=cx, y=cy,
origin=(box[0], box[1]), pos=(box[0], box[1]))
else:
self._select_widget(None)
self._drag["tag"] = None
def _on_canvas_drag(self, event):
if not self._drag["tag"]:
return
c = self._canvas
z = self._zoom
cx = c.canvasx(event.x)
cy = c.canvasy(event.y)
# Worked out from where the drag began rather than step by step, so snapping
# never accumulates rounding and the widget lands exactly where it is drawn.
ox, oy = self._drag["origin"]
new_x = ox + (cx - self._drag["x"]) / z
new_y = oy + (cy - self._drag["y"]) / z
if self._snap_var.get():
new_x = round(new_x / GRID_STEP) * GRID_STEP
new_y = round(new_y / GRID_STEP) * GRID_STEP
new_x = int(round(max(0, min(new_x, self._screen_w))))
new_y = int(round(max(0, min(new_y, self._screen_h))))
px, py = self._drag["pos"]
if (new_x, new_y) != (px, py):
c.move(self._drag["tag"], (new_x - px) * z, (new_y - py) * z)
self._drag["pos"] = (new_x, new_y)
c.delete("selection_handle")
def _on_canvas_release(self, event):
tag = self._drag["tag"]
if not tag:
return
widget = self._tag_to_comp.get(tag)
if widget is None:
self._drag["tag"] = None
return
self._drag["tag"] = None
moved = False
new_x, new_y = self._drag["pos"]
if (new_x, new_y) != self._drag["origin"]:
moved = self._commit_props(widget, {"x": str(new_x), "y": str(new_y)})
# A commit re-renders through refresh(); a drag that went nowhere still has to
# put back the handles the motion took down.
if not moved:
self.refresh()
def _on_canvas_right_click(self, event):
c = self._canvas
cx = c.canvasx(event.x)
cy = c.canvasy(event.y)
widget = self._widget_at(cx, cy)
if widget is None:
return
self._select_widget(widget)
menu = tk.Menu(self._canvas, tearoff=0)
menu.add_command(label="Properties",
command=lambda: self._open_properties(widget))
menu.post(event.x_root, event.y_root)
def _on_tree_select(self, event):
sel = self._tree.selection()
if not sel:
return
widget = self._tree_items.get(sel[0])
if widget is not None:
self._select_widget(widget)
def _open_properties(self, widget):
pass
def _on_wheel_zoom(self, event):
if not event.delta:
return "break"
step = 1 if event.delta > 0 else -1
level = self.zoom_step(step)
anchor = (event.x, event.y)
self._zoom_with_anchor(level, anchor)
from main import update_zoom_label
update_zoom_label()
return "break"
def _zoom_with_anchor(self, level, anchor):
c = self._canvas
before = self._zoom
sheet_x = c.canvasx(anchor[0]) / before
sheet_y = c.canvasy(anchor[1]) / before
after = self.set_zoom(level)
if after == before:
return
region = str(c.cget("scrollregion")).split()
if len(region) == 4:
span_x = float(region[2]) or 1.0
span_y = float(region[3]) or 1.0
c.xview_moveto(max(0.0, (sheet_x * after - anchor[0]) / span_x))
c.yview_moveto(max(0.0, (sheet_y * after - anchor[1]) / span_y))
# ── Zoom ──────────────────────────────────────────────────────
@property
def zoom(self):
return self._zoom
def zoom_step(self, direction):
idx = 0
for i, lvl in enumerate(ZOOM_LEVELS):
if lvl <= self._zoom:
idx = i
target = idx + direction
target = max(0, min(len(ZOOM_LEVELS) - 1, target))
return ZOOM_LEVELS[target]
def set_zoom(self, level):
level = max(ZOOM_LEVELS[0], min(ZOOM_LEVELS[-1], level))
self._zoom = level
self._apply_zoom()
return level
def fit_zoom_level(self):
c = self._canvas
view_w = c.winfo_width() - 4
view_h = c.winfo_height() - 4
if view_w <= 0 or view_h <= 0:
return None
fit_x = view_w / self._canvas_w
fit_y = view_h / self._canvas_h
return max(ZOOM_LEVELS[0], min(ZOOM_LEVELS[-1], min(fit_x, fit_y)))
def _apply_zoom(self):
z = self._zoom
zw = int(self._canvas_w * z)
zh = int(self._canvas_h * z)
self._canvas.config(scrollregion=(0, 0, zw, zh))
self.refresh()
def refresh(self):
"""Redraws from the document, keeping the selection when the widget survives."""
selected = self._selected_widget
self._render_page()
if selected is not None and selected not in _get_widget_children(self.comp):
selected = None
self._select_widget(selected)
def _background(self):
return app_state.DIRTY_TINT if app_state.is_dirty else CANVAS_BG
def refresh_dirty_tint(self):
"""Wears the wiresheet's unsaved-state tint while the document has unwritten changes."""
self._canvas.config(bg=self._background())
def close(self):
if self.on_close is not None:
self.on_close()
return True
def destroy(self):
if self.frame.winfo_exists():
self.frame.destroy()
def _build_footer(self):
footer = tk.Frame(self.frame, padx=8, pady=6)
footer.pack(side=tk.BOTTOM, fill=tk.X)
ttk.Button(footer, text="Close", width=9,
command=self.close).pack(side=tk.RIGHT)
def is_ox_page(comp):
return comp is not None and comp.get("type", "") == "ontrolOx::OxPage"
def build_ox_page_view_panel(parent, comp, on_change=None, on_close=None):
if comp is None or parent is None:
return None
return OxPageViewPanel(parent, comp, on_change, on_close)
File diff suppressed because it is too large Load Diff
+532
View File
@@ -0,0 +1,532 @@
"""Draws Ox widgets on the Page Editor canvas the way the device's firmware draws them.
The rules are the R-ION firmware's (graphics.c, ontrolXx.c) and each widget's draw():
text boxes drawn with the device's own bitmap fonts from Sedona home's fontBank
(a Windows font of the same size when the file is not there). The
line is centred vertically, aligned by attributes bits 4-5 when the
box is wider than the text, and cut at the character that would cross
the right edge. A box whose draw-background bit (3) is clear is
transparent. The last `subscriptDigits` characters are drawn half size.
images centred in a box larger than the image, cropped to a smaller one; a
file the editor cannot find is drawn as the device draws a missing
one, a red and white checker.
values each widget shows the text its `in` currently selects, so a live
page reads as the device does and an offline one shows the defaults.
Everything is drawn in canvas units: device pixels times the zoom.
"""
import datetime
import os
import tkinter as tk
from tkinter import font as tkfont
try:
from PIL import Image, ImageTk
_HAS_PIL = True
except ImportError:
_HAS_PIL = False
import app_state
import properties
from ox_widget_properties import (_Model, BACKGROUND_BIT, ALIGN_MASK, SUBSCRIPT_MASK,
DATE_HIDE_YEAR, DATE_TWO_DIGIT_YEAR, DATE_LEADING_ZERO,
DATE_MONTH_FIRST, IMAGE_EXTENSIONS, PORTRAIT_FONT_EXTENSION,
bank_folders, font_extensions, font_names,
parse_lookup, parse_numeric_format, rgb565_to_hex)
PLACEHOLDER_FILL = "#dce6f2"
PLACEHOLDER_OUTLINE = "#7f9db9"
HIDDEN_OUTLINE = "#9a9a9a"
CHECKER_COLOURS = ((232, 60, 60), (255, 255, 255)) # the firmware's RED, WHITE
CHECKER_CELL = 4
SEPARATORS = {0: "/", 1: ".", 2: "-", 3: " "}
FALLBACK_FAMILY = "Segoe UI"
_font_cache = {}
_families = None
_text_cache = {} # rendered lines, so a redraw does not re-decode every glyph
TEXT_CACHE_SIZE = 400
def _font(family, pixels, style):
"""A Tk font `pixels` tall; style is fontAttributes' 0 normal, 1 bold, 2 italic."""
global _families
if _families is None:
_families = {f.lower() for f in tkfont.families()}
if family.lower() not in _families:
family = FALLBACK_FAMILY
key = (family, pixels, style)
if key not in _font_cache:
_font_cache[key] = tkfont.Font(
family=family, size=-max(1, pixels),
weight="bold" if style == 1 else "normal",
slant="italic" if style == 2 else "roman")
return _font_cache[key]
# --- the device's own bitmap fonts ----------------------------------------------------
class BitmapFont:
"""A device font file: fontBank/'32pt Tahoma.bin', or fontBankXL/'.oxfl' (same layout).
Orion's portrait cut, fontBankXL/'.oxfp', has the same header and table but stores
each glyph turned a quarter turn anticlockwise: `height` pixels to a row, `width`
rows. glyph_image turns it back, so every caller sees the upright glyph.
0..63 header; u16 character count at 32, line height at 34
64.. 10 bytes per character: code (u8, or u16 above 191), data offset (u24
at 2), width, height, x offset (signed), y offset, x advance
data 4-bit grey, two pixels a byte, high nibble first, row by row
"""
def __init__(self, path):
data = open(path, "rb").read()
self.data = data
self.rotated = path.lower().endswith(PORTRAIT_FONT_EXTENSION)
self.height = data[34]
count = data[32] | data[33] << 8
self.glyphs = {}
for index in range(count):
e = data[64 + index * 10:74 + index * 10]
if len(e) < 10:
break
code = e[0] if e[0] < 192 and e[1] == 0 else e[0] | e[1] << 8
self.glyphs[code] = (e[2] | e[3] << 8 | e[4] << 16, e[5], e[6],
e[7] - 256 if e[7] > 127 else e[7], e[8], e[9])
def metrics(self, code):
"""(printWidth, xOffset) the way Calculate_Text_Width works them out."""
_, width, _, x_off, _, advance = self.glyphs[code]
x_offset = 1 if x_off < 0 else x_off + 1
print_width = max(advance, width, x_offset + width)
return print_width, x_offset
def glyph_image(self, code):
offset, width, height, _, _, _ = self.glyphs[code]
stored = (height, width) if self.rotated else (width, height)
img = Image.new("L", (max(1, stored[0]), max(1, stored[1])))
if width and height:
raw = self.data[offset:offset + (width * height + 1) // 2]
pixels = bytearray(width * height)
for i in range(width * height):
byte = raw[i >> 1] if (i >> 1) < len(raw) else 0
pixels[i] = ((byte >> 4) if i % 2 == 0 else (byte & 0x0F)) * 17
img.putdata(pixels)
return img.transpose(Image.ROTATE_270) if self.rotated else img
_bitmap_fonts = {}
def find_bitmap_font(family, size, style):
"""The device font for fontName/fontSize/fontAttributes, or None when none is local.
get_Font_File_Name's naming: '<size>pt <Name>', prefixed 'bold ' for bold. There is
no italic cut, so italic falls back to the regular one as the device's lookup would
find nothing better. The file comes from the platform's own bank, and on Orion in
the cut its screen orientation draws with.
"""
if not _HAS_PIL:
return None
stems = [f"{'bold ' if style == 1 else ''}{size}pt {family}"]
if style == 1:
stems.append(f"{size}pt {family}")
candidates = [os.path.join(folder, stem + ext) for stem in stems
for folder in bank_folders("font") for ext in font_extensions()]
for path in candidates:
if path in _bitmap_fonts:
return _bitmap_fonts[path]
if os.path.isfile(path):
try:
_bitmap_fonts[path] = BitmapFont(path)
except (OSError, IndexError):
_bitmap_fonts[path] = None
return _bitmap_fonts[path]
return None
def render_bitmap_text(font, text, box_width, subscript):
"""(mask, text width): the line as Draw_Text_Box lays it out, in device pixels.
Characters the font lacks are skipped, the last `subscript` drawn ones are halved
in both directions and sit on the lower half of the line (Stretch_Char), and the
line stops at the first character that would cross the box's right edge.
"""
codes = [ord(ch) for ch in text if ord(ch) in font.glyphs]
stretch_from = len(codes) - subscript if 0 < subscript <= len(codes) else len(codes)
layout, width = [], 0
for n, code in enumerate(codes):
print_width, x_offset = font.metrics(code)
stretched = n >= stretch_from
if stretched:
print_width, x_offset = print_width >> 1, x_offset >> 1
if width + print_width > box_width:
break
layout.append((code, width + x_offset, stretched))
width += print_width
mask = Image.new("L", (max(1, width), max(1, font.height)))
for code, x, stretched in layout:
glyph = font.glyph_image(code)
_, _, height, _, y_offset, _ = font.glyphs[code]
if stretched:
glyph = glyph.resize((max(1, glyph.width >> 1), max(1, glyph.height >> 1)),
Image.BOX)
y_offset += height >> 1
mask.paste(glyph, (x, y_offset), glyph)
return mask, width
def find_image_file(name):
"""The local file for a device image name, looked for in Sedona home's image banks."""
if not name:
return None
for folder in bank_folders("image"):
for ext in IMAGE_EXTENSIONS:
path = os.path.join(folder, name + ext)
if os.path.isfile(path):
return path
return None
def _format_value(fmt, value):
"""sprintf(fmt, value) for the %f formats NumericLabel carries."""
parts = parse_numeric_format(fmt)
if parts is not None:
prefix, show, precision, suffix = parts
return f"{prefix}{value:.{precision}f}{suffix}" if show else prefix
try:
return fmt % value
except (TypeError, ValueError):
return fmt
def _lookup_text(text, index):
"""Process_Text: prefix + the index'th item + suffix, or '<n>:Error'."""
prefix, items, suffix = parse_lookup(text)
if 0 <= index < len(items):
return f"{prefix}{items[index]}{suffix}"
return f"{index}:Error"
def _date_text(options, separator):
"""Today's date as DateLabelSet.prepareDateString lays it out."""
today = datetime.date.today()
no_zero = bool(options & DATE_LEADING_ZERO) # the firmware drops the zero when set
day = str(today.day) if no_zero else f"{today.day:02d}"
month = str(today.month) if no_zero else f"{today.month:02d}"
sep = SEPARATORS.get(separator, "/")
first, second = (month, day) if options & DATE_MONTH_FIRST else (day, month)
text = f"{first}{sep}{second}"
if not options & DATE_HIDE_YEAR:
year = today.year % 100 if options & DATE_TWO_DIGIT_YEAR else today.year
text += f"{sep}{year:02d}"
return text
def _time_text(prefix, minutes, am_pm):
"""drawTimeTextBox with digits 0: prefix + HH:MM, optionally 12-hour."""
minutes = int(minutes) % 1440
hour, minute = divmod(minutes, 60)
if not am_pm:
return f"{prefix}{hour:02d}:{minute:02d}"
suffix = " AM" if hour < 12 else " PM"
hour = 12 if hour == 0 else hour - 12 if hour > 12 else hour
return f"{prefix}{hour:02d}:{minute:02d}{suffix}"
def _as_float(raw, fallback=0.0):
try:
return float(raw)
except (TypeError, ValueError):
return fallback
def _bool_state(raw):
return properties.bool_state(raw)
class WidgetPainter:
"""Paints one page's widgets; holds the PhotoImages the canvas needs kept alive."""
def __init__(self, canvas, origin_x, origin_y, zoom, page_colour):
self.c = canvas
self.ox = origin_x
self.oy = origin_y
self.z = zoom
self.page_colour = page_colour
self.images = []
# ── primitives ────────────────────────────────────────────────
def _xy(self, x, y):
return self.ox + x * self.z, self.oy + y * self.z
def _shape(self, x, y, w, h, fill, shape, tags):
x1, y1 = self._xy(x, y)
x2, y2 = self._xy(x + w, y + h)
maker = self.c.create_oval if shape else self.c.create_rectangle
maker(x1, y1, x2, y2, fill=fill, outline="", tags=tags)
def text_box(self, m, text, x, y, w, h, text_colour, box_colour, tags,
attributes=None, shape=None):
"""A label's box and text.
The line box (the font's full height) is centred on the widget box even when
it is the taller of the two, so the glyphs sit in the box as Workbench shows
them rather than hanging below a short one.
"""
attributes = m.int("attributes", 0x08) if attributes is None else attributes
shape = m.int("shape") if shape is None else shape
names = font_names(m) if "fontName" in m.slots else ("Tahoma",)
family_index = m.int("fontName")
family = names[family_index] if 0 <= family_index < len(names) else names[0]
size = m.int("fontSize", 16)
style = m.int("fontAttributes")
if attributes & BACKGROUND_BIT:
self._shape(x, y, w, h, rgb565_to_hex(box_colour), shape, tags)
if not text:
return
digits = attributes & SUBSCRIPT_MASK
bitmap = find_bitmap_font(family, size, style)
if bitmap is not None:
self._bitmap_text(bitmap, text, x, y, w, h, attributes, digits, text_colour, tags)
else:
self._tk_text(family, size, style, text, x, y, w, h, attributes, digits,
text_colour, tags)
@staticmethod
def _left_space(attributes, box_width, text_width):
if box_width <= text_width:
return 0
alignment = attributes & ALIGN_MASK
if alignment == 0x20:
return box_width - text_width
if alignment == 0x10:
return (box_width - text_width) / 2
return 0
def _bitmap_text(self, font, text, x, y, w, h, attributes, digits, text_colour, tags):
key = (id(font), text, w, digits, text_colour, self.z)
cached = _text_cache.get(key)
if cached is None:
mask, width = render_bitmap_text(font, text, w, digits)
if width == 0:
return
size = (max(1, round(mask.width * self.z)), max(1, round(mask.height * self.z)))
if size != mask.size:
# Up close the device's pixels are shown as pixels; below 1:1 they blend.
mask = mask.resize(size, Image.NEAREST if self.z >= 2 else Image.LANCZOS)
r, g, b = (int(rgb565_to_hex(text_colour)[i:i + 2], 16) for i in (1, 3, 5))
img = Image.new("RGBA", mask.size, (r, g, b, 0))
img.putalpha(mask)
if len(_text_cache) > TEXT_CACHE_SIZE:
_text_cache.clear()
cached = _text_cache[key] = (ImageTk.PhotoImage(img), width)
photo, width = cached
self.images.append(photo)
left = self._left_space(attributes, w, width)
top = (h - font.height) / 2
self.c.create_image(*self._xy(x + left, y + top), anchor="nw", image=photo, tags=tags)
def _tk_text(self, family, size, style, text, x, y, w, h, attributes, digits,
text_colour, tags):
"""The fallback when no device font file is found: a Windows font of the size."""
z = self.z
main_font = _font(family, round(size * z), style)
half_font = _font(family, round(size * z / 2), style)
split = len(text) - digits if 0 < digits < len(text) else len(text)
chars = [(ch, main_font if i < split else half_font) for i, ch in enumerate(text)]
limit = w * z
drawn, width = [], 0.0
for ch, fnt in chars:
advance = fnt.measure(ch)
if width + advance > limit + 0.5:
break
drawn.append((ch, fnt))
width += advance
bx, by = self._xy(x, y)
cursor = bx + self._left_space(attributes, limit, width)
top = by + (h * z - main_font.metrics("linespace")) / 2
colour = rgb565_to_hex(text_colour)
main_text = "".join(ch for ch, fnt in drawn if fnt is main_font)
tail_text = "".join(ch for ch, fnt in drawn if fnt is half_font)
if main_text:
self.c.create_text(cursor, top, text=main_text, anchor="nw", fill=colour,
font=main_font, tags=tags)
cursor += main_font.measure(main_text)
if tail_text:
baseline = top + main_font.metrics("ascent") - half_font.metrics("ascent")
self.c.create_text(cursor, baseline, text=tail_text, anchor="nw", fill=colour,
font=half_font, tags=tags)
def image_box(self, name, x, y, w, h, tags):
path = find_image_file(name)
if not _HAS_PIL:
self._placeholder(x, y, w, h, name or "image", tags)
return
if path is None:
self._checker(x, y, w, h, tags)
return
try:
img = Image.open(path).convert("RGBA")
except Exception:
self._checker(x, y, w, h, tags)
return
iw, ih = img.size
crop_w, crop_h = min(iw, w), min(ih, h)
img = img.crop((0, 0, crop_w, crop_h))
left = (w - iw) // 2 if w > iw else 0
top = (h - ih) // 2 if h > ih else 0
size = (max(1, round(crop_w * self.z)), max(1, round(crop_h * self.z)))
if size != img.size:
img = img.resize(size, Image.LANCZOS)
photo = ImageTk.PhotoImage(img)
self.images.append(photo)
self.c.create_image(*self._xy(x + left, y + top), anchor="nw", image=photo, tags=tags)
def _checker(self, x, y, w, h, tags):
cw, ch = max(1, round(w * self.z)), max(1, round(h * self.z))
cell = max(1, round(CHECKER_CELL * self.z))
img = Image.new("RGB", (cw, ch))
pixels = img.load()
for py in range(ch):
row = (py // cell) & 1
for px in range(cw):
pixels[px, py] = CHECKER_COLOURS[((px // cell) & 1) ^ row]
photo = ImageTk.PhotoImage(img)
self.images.append(photo)
self.c.create_image(*self._xy(x, y), anchor="nw", image=photo, tags=tags)
def _placeholder(self, x, y, w, h, label, tags):
x1, y1 = self._xy(x, y)
x2, y2 = self._xy(x + w, y + h)
self.c.create_rectangle(x1, y1, x2, y2, fill=PLACEHOLDER_FILL,
outline=PLACEHOLDER_OUTLINE, tags=tags)
self.c.create_text((x1 + x2) / 2, (y1 + y2) / 2, text=label, fill="#000000",
font=(FALLBACK_FAMILY, max(7, min(10, int(min(x2 - x1, y2 - y1) // 8)))),
tags=tags)
def page_image(self, name, width, height):
"""OxPage.imageFileName, drawn over the page colour from the top-left corner."""
if not name or find_image_file(name) is None:
return
self.image_box(name, 0, 0, width, height, ("page_image",))
# ── widgets ───────────────────────────────────────────────────
def draw(self, widget, tags, short_label):
"""Paints `widget`; returns its box (x, y, w, h) in device pixels."""
m = _Model(widget)
x, y = m.int("x"), m.int("y")
w, h = max(1, m.int("width", 80)), max(1, m.int("height", 80))
kind = widget.get("type", "").rsplit("::", 1)[-1]
if _bool_state(m.raw("visibility")) == "false":
x1, y1 = self._xy(x, y)
x2, y2 = self._xy(x + w, y + h)
self.c.create_rectangle(x1, y1, x2, y2, outline=HIDDEN_OUTLINE, dash=(3, 3),
tags=tags)
return x, y, w, h
painter = getattr(self, f"_draw_{kind}", None)
if painter is None:
self._placeholder(x, y, w, h, f"{widget.get('name', '?')}\n({short_label})", tags)
else:
painter(m, x, y, w, h, tags)
return x, y, w, h
def _draw_PushButtonLabel(self, m, x, y, w, h, tags):
self.text_box(m, m.text("text"), x, y, w, h, m.int("textColor"),
m.int("backgroundColor", 0xFF00), tags)
def _draw_NumericLabel(self, m, x, y, w, h, tags):
fmt = m.text("text")
value = _as_float(m.raw("in"))
tab = m.int("valueTabPosition")
split = fmt.find("%")
if tab == 0 or split <= 0:
self.text_box(m, _format_value(fmt, value), x, y, w, h, m.int("textColor"),
m.int("backgroundColor", 0xFF00), tags)
return
attributes = m.int("attributes", 0x08)
self.text_box(m, fmt[:split].replace("%%", "%"), x, y, tab, h, m.int("textColor"),
m.int("backgroundColor", 0xFF00), tags, shape=0)
self.text_box(m, _format_value(": " + fmt[split:], value), x + tab, y, w - tab, h,
m.int("textColor"), m.int("backgroundColor", 0xFF00), tags,
attributes=attributes & 0xCF, shape=0)
_draw_NumericLabelSet = _draw_NumericLabel
def _draw_NumericSelectLabel(self, m, x, y, w, h, tags):
text = m.text("text")
if not text:
return
self.text_box(m, _lookup_text(text, m.int("in")), x, y, w, h, m.int("textColor"),
m.int("backgroundColor", 0xFF00), tags)
def _draw_BooleanLabel(self, m, x, y, w, h, tags):
state = _bool_state(m.raw("in"))
if state == "true":
text, fg, bg = m.text("trueText"), m.int("trueTextColor"), m.int("trueBackgroundColor")
else:
text = m.text("falseText") if state == "false" else "Null"
fg, bg = m.int("falseTextColor"), m.int("falseBackgroundColor")
attributes = m.int("attributes", 0x08)
if not text:
attributes &= 0xF7
self.text_box(m, text, x, y, w, h, fg, bg, tags, attributes=attributes)
def _draw_ScheduleLabelSet(self, m, x, y, w, h, tags):
state = _bool_state(m.raw("in"))
if state == "true":
img, fg, bg, index = (m.text("trueImageFileName"), m.int("trueTextColor"),
m.int("trueBackgroundColor"), 1)
else:
img = m.text("falseImageFileName") if state == "false" else ""
fg, bg = m.int("falseTextColor"), m.int("falseBackgroundColor")
index = 0 if state == "false" else 2
if img:
self.image_box(img, x, y, w, h, tags)
elif m.text("text"):
self.text_box(m, _lookup_text(m.text("text"), index), x, y, w, h, fg, bg, tags)
def _draw_DateLabelSet(self, m, x, y, w, h, tags):
text = _date_text(m.int("dateFormatOptions"), m.int("dateSeparatorCharacter"))
self.text_box(m, text, x, y, w, h, m.int("textColor"),
m.int("backgroundColor", 0xFF00), tags)
def _draw_TimeLabelSet(self, m, x, y, w, h, tags):
am_pm = _bool_state(m.raw("amPmFormat")) == "true"
text = _time_text(m.text("text"), m.int("in"), am_pm)
self.text_box(m, text, x, y, w, h, m.int("textColor"),
m.int("backgroundColor", 0xFF00), tags,
attributes=m.int("attributes", 0x08) & 0xF8)
def _draw_Geometry(self, m, x, y, w, h, tags):
if _bool_state(m.raw("transparent")) != "true":
self._shape(x, y, w, h, rgb565_to_hex(m.int("backgroundColor", 0xFF00)),
m.int("shape"), tags)
def _draw_CalibrationButton(self, m, x, y, w, h, tags):
self._shape(x, y, w, h, rgb565_to_hex(m.int("backgroundColor", 0xFF00)),
m.int("shape"), tags)
def _draw_PushButtonImage(self, m, x, y, w, h, tags):
self.image_box(m.text("imageFileName"), x, y, w, h, tags)
def _draw_BooleanImage(self, m, x, y, w, h, tags):
state = _bool_state(m.raw("in"))
if state == "null":
return
name = m.text("trueImageFileName" if state == "true" else "falseImageFileName")
self.image_box(name, x, y, w, h, tags)
def _draw_NumericSelectImage(self, m, x, y, w, h, tags):
self.image_box(f"{m.text('imageFileNamePrefix')}{m.int('in')}", x, y, w, h, tags)
+181 -3
View File
@@ -67,7 +67,12 @@ def kit_versions(kit_name):
for entry in os.listdir(kit_dir): for entry in os.listdir(kit_dir):
match = pattern.match(entry) match = pattern.match(entry)
if match: if match:
versions[_norm_checksum(match.group(1))] = match.group(2) # A checksum released more than once (ontrolPlatform 1.0.0 and 1.0.1)
# stands for its highest version, as the devices report it.
key, version = _norm_checksum(match.group(1)), match.group(2)
if (key not in versions or
version_sort_key(version) > version_sort_key(versions[key])):
versions[key] = version
_versions_cache[kit_name] = versions _versions_cache[kit_name] = versions
return versions return versions
@@ -227,11 +232,25 @@ def _is_component(qualified_name, seen=None):
return result return result
def _hidden_from_palette(type_element):
"""True when the type's own facets carry @palette=false.
Only the type-level <facets> counts; slots have <facets> of their own nested deeper.
"""
facets = type_element.find("facets")
if facets is None:
return False
for facet in facets.findall("bool"):
if facet.get("name") == "palette" and facet.get("val") == "false":
return True
return False
def list_component_types(kit_name): def list_component_types(kit_name):
"""Concrete component types in a kit, as (type name, base) pairs. """Concrete component types in a kit, as (type name, base) pairs.
Primitives (no base) and abstract types (the 'a' flag) are left out — neither can be Primitives (no base), abstract types (the 'a' flag) and types marked @palette=false
placed in an application. are left out — none of them belong in the palette.
""" """
_, types = _load_kit(kit_name) _, types = _load_kit(kit_name)
@@ -239,6 +258,8 @@ def list_component_types(kit_name):
for type_name, type_element in types.items(): for type_name, type_element in types.items():
if "a" in (type_element.get("flags") or ""): if "a" in (type_element.get("flags") or ""):
continue continue
if _hidden_from_palette(type_element):
continue
if not _is_component(f"{kit_name}::{type_name}"): if not _is_component(f"{kit_name}::{type_name}"):
continue continue
entries.append((type_name, type_element.get("base") or "")) entries.append((type_name, type_element.get("base") or ""))
@@ -246,9 +267,166 @@ def list_component_types(kit_name):
return sorted(entries) return sorted(entries)
_unsupported_cache = {} # frozenset(sax kit names) -> set of unsupported kit names
def _platforms_db():
return os.path.join(app_state.SEDONA_HOME, "platforms", "db")
def _platform_native_kits(sax_names=None):
"""Return (matching, all_platforms) — two sets of kit names.
*matching* is the union of nativeKit names from platform builds that include
every PlatformService-providing kit the SAX declares. *all_platforms* is the
union across every build in the database regardless of match.
A kit that appears in *all_platforms* but not in *matching* has native code
compiled for a different device and cannot work with this SAX.
sax_names stands in for the open file's kits when given.
"""
sax_names = set(app_state.active_schema_kits.keys() if sax_names is None else sax_names)
db = _platforms_db()
if not os.path.isdir(db):
return None, None
sax_platform_kits = set()
for name in sax_names:
kit_dir = os.path.join(_manifests_dir(), name)
if not os.path.isdir(kit_dir):
continue
for entry in os.listdir(kit_dir):
if not (entry.startswith(f"{name}-") and entry.endswith(".xml")):
continue
try:
tree = ET.parse(os.path.join(kit_dir, entry))
except ET.ParseError:
continue
for t in tree.getroot().findall(".//type"):
text = t.get("name", "") + " " + t.get("base", "")
if "PlatformService" in text:
sax_platform_kits.add(name)
break
if not sax_platform_kits:
return None, None
matching = set()
all_platforms = set()
found = False
for vendor in os.listdir(db):
vd = os.path.join(db, vendor)
if not os.path.isdir(vd):
continue
for device in os.listdir(vd):
dd = os.path.join(vd, device)
if not os.path.isdir(dd):
continue
for build in os.listdir(dd):
mf = os.path.join(dd, build, ".par", "platformManifest.xml")
if not os.path.isfile(mf):
continue
try:
tree = ET.parse(mf)
except ET.ParseError:
continue
nk_names = set()
for nk in tree.getroot().findall(".//nativeKit"):
nk_names.add(nk.get("depend", "").split()[0])
all_platforms.update(nk_names)
if sax_platform_kits <= nk_names:
matching.update(nk_names)
found = True
return (matching if found else None), all_platforms
def unsupported_kits(sax_names=None):
"""Kit names that cannot work with the open file's platform -- or with the platform
of the kits in `sax_names`, when given.
A kit is unsupported when:
- it has native code (hasNatives or listed as nativeKit on some platform)
but is not compiled into any build matching the SAX's device, or
- it provides a PlatformService type for a different device.
Kits that depend on an unsupported kit are also flagged.
"""
sax_names = frozenset(app_state.active_schema_kits.keys() if sax_names is None
else sax_names)
if sax_names in _unsupported_cache:
return _unsupported_cache[sax_names]
matching_natives, all_natives = _platform_native_kits(sax_names)
if matching_natives is None:
_unsupported_cache[sax_names] = set()
return set()
unsupported = set()
all_kits = list_kits()
for name in all_kits:
if name in sax_names:
continue
# Native on another platform but not on the matching one.
if name in all_natives and name not in matching_natives:
unsupported.add(name)
continue
kit_dir = os.path.join(_manifests_dir(), name)
if not os.path.isdir(kit_dir):
continue
for entry in os.listdir(kit_dir):
if not (entry.startswith(f"{name}-") and entry.endswith(".xml")):
continue
try:
tree = ET.parse(os.path.join(kit_dir, entry))
except ET.ParseError:
continue
root = tree.getroot()
has_natives = root.get("hasNatives") == "true"
has_platform_type = any(
"PlatformService" in (t.get("name", "") + " " + t.get("base", ""))
for t in root.findall(".//type")
)
if (has_natives or has_platform_type) and name not in matching_natives:
unsupported.add(name)
break
# Transitive: kits that depend on an unsupported kit
changed = True
while changed:
changed = False
for name in all_kits:
if name in unsupported or name in sax_names:
continue
kit_dir = os.path.join(_manifests_dir(), name)
if not os.path.isdir(kit_dir):
continue
for entry in os.listdir(kit_dir):
if not (entry.startswith(f"{name}-") and entry.endswith(".xml")):
continue
try:
tree = ET.parse(os.path.join(kit_dir, entry))
except ET.ParseError:
continue
for dep in tree.getroot().findall(".//depend"):
dep_name = dep.get("on", "").split()[0]
if dep_name in unsupported:
unsupported.add(name)
changed = True
break
_unsupported_cache[sax_names] = unsupported
return unsupported
def invalidate(): def invalidate():
"""Clears every cache, for when Sedona home changes or a kit is repinned.""" """Clears every cache, for when Sedona home changes or a kit is repinned."""
_versions_cache.clear() _versions_cache.clear()
_manifest_cache.clear() _manifest_cache.clear()
_component_cache.clear() _component_cache.clear()
_builds_cache.clear() _builds_cache.clear()
_unsupported_cache.clear()
+91 -8
View File
@@ -32,9 +32,10 @@ import tkinter as tk
from tkinter import font as tkfont, messagebox, ttk from tkinter import font as tkfont, messagebox, ttk
import app_state import app_state
import live_hooks
import schema import schema
import undo import undo
from sax_dom import (get_component_path, read_meta_property, set_prop_value, from sax_dom import (get_component_path, read_meta_property, remove_prop, set_prop_value,
update_meta_groups, update_meta_property) update_meta_groups, update_meta_property)
# Sedona slot types, grouped by how a value of that type is parsed and bounded. # Sedona slot types, grouped by how a value of that type is parsed and bounded.
@@ -373,7 +374,7 @@ def display_text(slot, raw):
precision = slot["facets"].get("precision") precision = slot["facets"].get("precision")
if isinstance(precision, int) and 0 <= precision <= 12: if isinstance(precision, int) and 0 <= precision <= 12:
return f"{number:.{precision}f}" return f"{number:.{precision}f}"
return str(number) return f"{number:.2f}"
if slot_type in INTEGER_TYPES: if slot_type in INTEGER_TYPES:
try: try:
@@ -507,7 +508,9 @@ class PropertiesPanel:
# the view would open already claiming unsaved edits. # the view would open already claiming unsaved edits.
self._loading = False self._loading = False
comp_type = comp.get("type", "sys::Component") # A .sax file's <app> carries no type attribute: it is always a sys::App.
default_type = "sys::App" if comp.tag == "app" else "sys::Component"
comp_type = comp.get("type") or default_type
self.slots = schema.get_full_slots_for_type(comp_type) self.slots = schema.get_full_slots_for_type(comp_type)
self.frame = tk.Frame(parent) self.frame = tk.Frame(parent)
@@ -517,6 +520,8 @@ class PropertiesPanel:
# sibling nothing at all. # sibling nothing at all.
self._build_header(comp_type) self._build_header(comp_type)
self._build_footer() self._build_footer()
if app_state.live_mode and app_state.device_session is not None:
self._refresh_from_device()
self._loading = True self._loading = True
self._build_rows_area() self._build_rows_area()
self._loading = False self._loading = False
@@ -621,7 +626,8 @@ class PropertiesPanel:
name_font = tkfont.Font(family="Segoe UI", size=9, weight="bold") name_font = tkfont.Font(family="Segoe UI", size=9, weight="bold")
type_font = tkfont.Font(family="Segoe UI", size=9) type_font = tkfont.Font(family="Segoe UI", size=9)
self._tinted(tk.Label(header, text=self.comp.get("name", "?"), font=name_font, default_name = "App" if self.comp.tag == "app" else "?"
self._tinted(tk.Label(header, text=self.comp.get("name", default_name), font=name_font,
bg=HEADER_BG), HEADER_BG).pack(side=tk.LEFT) bg=HEADER_BG), HEADER_BG).pack(side=tk.LEFT)
self._tinted(tk.Label(header, text=f"({comp_type})", font=type_font, self._tinted(tk.Label(header, text=f"({comp_type})", font=type_font,
fg="#555555", bg=HEADER_BG), fg="#555555", bg=HEADER_BG),
@@ -1055,7 +1061,13 @@ class PropertiesPanel:
# -- the two buttons -- # -- the two buttons --
def refresh(self): def refresh(self):
"""Re-reads every value from the DOM, discarding edits that were not saved.""" """Re-reads every value from the DOM, discarding edits that were not saved.
In live mode, fetches fresh values from the device first.
"""
if app_state.live_mode and app_state.device_session is not None:
self._refresh_from_device()
self._loading = True self._loading = True
for row in self.rows: for row in self.rows:
self._load_row(row) self._load_row(row)
@@ -1065,8 +1077,31 @@ class PropertiesPanel:
self._load_meta_row() self._load_meta_row()
self._load_path() self._load_path()
self._update_tint() self._update_tint()
if app_state.live_mode:
self.status_var.set("Refreshed from device.")
else:
self.status_var.set("Refreshed from the document.") self.status_var.set("Refreshed from the document.")
def _refresh_from_device(self):
"""Reads fresh config+runtime values from the device into the DOM element."""
from sox import CONFIG, RUNTIME
ds = app_state.device_session
comp_id = self.comp.get("id")
if comp_id is None:
return
try:
comp_id = int(comp_id)
except ValueError:
return
sox_comp = ds.sox_client.cache_get(comp_id)
if sox_comp is None:
return
try:
ds.sox_client.update([sox_comp], CONFIG | RUNTIME)
except Exception:
pass
ds.update_element_from_cache(comp_id, app_state.xml_root_element)
def save(self): def save(self):
"""Writes the edited rows back, as one undoable step. """Writes the edited rows back, as one undoable step.
@@ -1132,10 +1167,31 @@ class PropertiesPanel:
self.status_var.set("No changes to save.") self.status_var.set("No changes to save.")
return return
for element, prop_name, _before, after in changes: # In live mode the device is written first and a value only lands in the document
# once the device took it, so the panel never shows what the device does not hold.
live_failures = []
applied = []
for change in changes:
element, prop_name, before, after = change
if app_state.live_mode and not live_hooks.live_set_prop_value(
element, prop_name, after):
live_failures.append(f"{prop_name}: {live_hooks.last_error}")
if prop_name == "meta": # applied above, ahead of the write
if before is None:
remove_prop(element, "meta")
else:
set_prop_value(element, "meta", before)
continue
if prop_name != "meta": if prop_name != "meta":
set_prop_value(element, prop_name, after) set_prop_value(element, prop_name, after)
applied.append(change)
written = len(applied)
if app_state.live_mode:
# Only what the device took; undo writes it back there.
if applied:
undo.push(undo.LiveSlotValueCommand(applied))
else:
undo.push(undo.SlotValueCommand(list(changes))) undo.push(undo.SlotValueCommand(list(changes)))
app_state.mark_dirty() app_state.mark_dirty()
@@ -1143,7 +1199,25 @@ class PropertiesPanel:
if self.on_change is not None: if self.on_change is not None:
self.on_change() self.on_change()
count = len(changes) count = written
name = self.comp.get('name', '?')
if live_failures:
self.status_var.set(
f"{written} written, {len(live_failures)} rejected by the device — "
"those still show the device's value."
)
live_hooks.warn_failure(
"Properties",
f"{len(live_failures)} of {len(changes)} change(s) to {name} did not reach "
"the device and were not applied",
"\n".join(f" • {failure}" for failure in live_failures),
)
return
elif app_state.live_mode:
self.status_var.set(
f"{count} slot{'' if count == 1 else 's'} written to device."
)
else:
self.status_var.set( self.status_var.set(
f"{count} slot{'' if count == 1 else 's'} changed. " f"{count} slot{'' if count == 1 else 's'} changed. "
"Ctrl+S writes the file." "Ctrl+S writes the file."
@@ -1159,7 +1233,16 @@ class PropertiesPanel:
root = app_state.xml_root_element root = app_state.xml_root_element
if root is None: if root is None:
return False return False
return any(element is self.comp for element in root.iter()) if any(element is self.comp for element in root.iter()):
return True
if app_state.live_mode:
comp_id = self.comp.get("id")
if comp_id is not None:
for element in root.iter():
if element.get("id") == comp_id:
self.comp = element
return True
return False
def _sync_bar(self, bar, first, last): def _sync_bar(self, bar, first, last):
"""Moves a scrollbar's thumb, and hides the bar entirely when it spans the whole.""" """Moves a scrollbar's thumb, and hides the bar entirely when it spans the whole."""
+242
View File
@@ -0,0 +1,242 @@
"""The kits step of App > Put with Files, laid out as the Manage Kits panel is.
The same groups (kits the app needs, kits declared without being used, kits available,
kits not supported), the same tick column and the same Action cell: "Keep at",
"Upgrade to", "Downgrade to", "Add at", "REMOVE". Installed is what the device runs;
Backup is what the backup's app was saved with.
What is ticked goes: into app.sab's schema and, when that is not the device's kits,
into a kits.scode built for them (Ontrol's PutApp does the same). "Rebuild kits.scode"
forces the build when nothing changed.
"""
import tkinter as tk
from tkinter import messagebox, ttk
import app_backup
import manage_kits
from manage_kits import NOT_DECLARED, SECTION_TAG, column_label, make_checkbox_image
REMOVE_ACTION = "REMOVE from device"
def _options(row):
"""The Action choices for a row, as (label, checksum) pairs."""
if row.device:
return [(label, checksum or row.device) for label, checksum in
manage_kits.action_options(row.name, row.device, True, row.builds)]
return [(f"Add at {manage_kits.build_label(v, c)}", c) for v, c in row.builds]
def _build_text(row, checksum):
if not checksum:
return NOT_DECLARED
version = next((v for v, c in row.builds if c == checksum), "")
return column_label(version, checksum)
class _Dialog:
def __init__(self, parent, rows, title):
self.rows = rows
self.result = None
self.original = [(r.included, r.use) for r in rows]
self.top = top = tk.Toplevel(parent)
top.title(f"{title} - Kits")
top.transient(parent)
top.grab_set()
top.geometry(f"+{parent.winfo_x() + 60}+{parent.winfo_y() + 40}")
frame = ttk.Frame(top, padding=10)
frame.pack(fill=tk.BOTH, expand=True)
columns = ("name", "installed", "backup", "latest", "action")
table_frame = ttk.Frame(frame)
table_frame.pack(fill=tk.BOTH, expand=True)
self.table = ttk.Treeview(table_frame, columns=columns, show="tree headings",
selectmode="browse", height=20)
self.table.heading("#0", text="")
self.table.column("#0", width=32, minwidth=32, stretch=False, anchor="center")
for column, heading, width in [("name", "Kit name", 190),
("installed", "Installed", 105),
("backup", "Backup", 105),
("latest", "Latest", 105),
("action", "Action", 215)]:
self.table.heading(column, text=heading, anchor="w")
self.table.column(column, width=width, minwidth=width, anchor="w", stretch=False)
vscroll = ttk.Scrollbar(table_frame, orient=tk.VERTICAL, command=self.table.yview)
self.table.configure(yscrollcommand=vscroll.set)
vscroll.pack(side=tk.RIGHT, fill=tk.Y)
self.table.pack(fill=tk.BOTH, expand=True)
self.table.tag_configure(SECTION_TAG, background="#dcdcdc")
self.boxes = {(checked, enabled): make_checkbox_image(checked, enabled)
for checked in (True, False) for enabled in (True, False)}
self.cross_box = make_checkbox_image(False, False, cross=True)
locked = any(r.locked for r in rows)
hint = ("The backup holds no app.sax, so its app goes with the kits it was saved "
"with." if locked else
"A tick means the kit goes to the device. Grey ticks are needed by the app "
"and cannot be removed; untick another to remove it, tick one to add it. "
"Click the Action cell to choose a build. Installed is the device's build. "
"Kits other than the device's mean a new kits.scode.")
ttk.Label(frame, text=hint, foreground="#666666", wraplength=780
).pack(anchor="w", pady=(8, 0))
self.rebuild = tk.BooleanVar(value=False)
self.rebuild_check = ttk.Checkbutton(frame, text="Rebuild kits.scode",
variable=self.rebuild)
self.rebuild_check.pack(anchor="w", pady=(6, 0))
button_row = ttk.Frame(frame)
button_row.pack(fill=tk.X, pady=(10, 0))
upgrade = ttk.Button(button_row, text="Upgrade all kits to latest version",
command=self._upgrade_all)
upgrade.pack(side=tk.LEFT)
if locked:
upgrade.state(["disabled"])
ttk.Button(button_row, text="Cancel", command=lambda: self._close(False)
).pack(side=tk.RIGHT)
ttk.Button(button_row, text="Next", command=lambda: self._close(True)
).pack(side=tk.RIGHT, padx=6)
self.row_by_item = {}
self._editor = None
self.table.bind("<Button-1>", self._on_click)
self.table.bind("<MouseWheel>", lambda _e: self._close_editor(), add="+")
top.protocol("WM_DELETE_WINDOW", lambda: self._close(False))
self._rebuild_table()
# --- the table ---
def _action_text(self, row):
if row.unsupported:
return "Not supported"
if not row.included:
return REMOVE_ACTION if row.device else NOT_DECLARED
return next((label for label, c in _options(row) if c == row.use),
_build_text(row, row.use))
def _values(self, row):
latest = row.builds[0] if row.builds else None
return (row.name, _build_text(row, row.device), _build_text(row, row.backup),
column_label(*latest) if latest else NOT_DECLARED, self._action_text(row))
def _image(self, row):
if row.unsupported:
return self.cross_box
return self.boxes[(row.included, not (row.required or row.locked))]
def _rebuild_table(self):
self._close_editor()
top = self.table.yview()[0]
self.table.delete(*self.table.get_children())
self.row_by_item.clear()
for title in app_backup.KIT_GROUPS:
members = [r for r in self.rows if app_backup.group(r) == title]
if not members:
continue
self.table.insert("", "end", values=(title, "", "", "", ""), tags=(SECTION_TAG,))
for row in members:
item = self.table.insert("", "end", image=self._image(row),
values=self._values(row))
self.row_by_item[item] = row
self.table.yview_moveto(top)
# Kits other than the device's cannot run on its kits.scode.
changed = any(app_backup.kit_change(r) for r in self.rows)
if changed:
self.rebuild.set(True)
self.rebuild_check.state(["disabled"])
else:
self.rebuild_check.state(["!disabled"])
def _changed(self):
app_backup.settle_required(self.rows)
self._rebuild_table()
# --- editing ---
def _close_editor(self):
if self._editor is not None:
self._editor.destroy()
self._editor = None
def _on_click(self, event):
self._close_editor()
if self.table.identify_region(event.x, event.y) not in ("tree", "cell"):
return
item = self.table.identify_row(event.y)
row = self.row_by_item.get(item)
if row is None or row.locked or row.unsupported:
return
column = self.table.identify_column(event.x)
if column == "#0":
self._toggle(row)
elif column == "#5":
self._edit_action(item, row)
def _toggle(self, row):
if row.required:
return
if not row.included and not row.builds:
messagebox.showwarning("Put with Files", f"Sedona home holds no build of "
f"'{row.name}' to add it at.", parent=self.top)
return
row.included = not row.included
if row.included and not row.use:
row.use = row.latest
self._changed()
def _edit_action(self, item, row):
options = _options(row)
if not options:
return
cell = self.table.bbox(item, "action")
if not cell:
return
labels = [label for label, _ in options]
variable = tk.StringVar(value=self._action_text(row))
combo = ttk.Combobox(self.table, textvariable=variable, values=labels,
state="readonly")
combo.place(x=cell[0], y=cell[1], width=cell[2], height=cell[3])
self._editor = combo
def commit(_event=None):
if variable.get() in labels:
row.use = options[labels.index(variable.get())][1]
row.included = True
self._close_editor()
self._changed()
combo.bind("<<ComboboxSelected>>", commit)
combo.bind("<Escape>", lambda _e: self._close_editor())
combo.focus_set()
combo.after_idle(lambda: combo.event_generate("<Down>"))
def _upgrade_all(self):
for row in self.rows:
if row.included and row.latest and not row.locked:
row.use = row.latest
self._changed()
def _close(self, ok):
self._close_editor()
if ok:
self.result = bool(self.rebuild.get())
else:
for row, (included, use) in zip(self.rows, self.original):
row.included, row.use = included, use
app_backup.settle_required(self.rows)
self.top.grab_release()
self.top.destroy()
def ask(parent, rows, title="Put with Files"):
"""Shows the kits step; returns whether to rebuild kits.scode, or None when cancelled.
The rows are updated in place with what was chosen.
"""
dialog = _Dialog(parent, rows, title)
dialog.top.wait_window()
return dialog.result
+629
View File
@@ -0,0 +1,629 @@
"""Reads a device's app.sab and writes it back out as app.sax, and encodes an app.sax
into app.sab (encode_sax).
Ported from Py_sab2sax (sax.py/sab.py), itself a port of sedona.offline.OfflineApp
(decodeAppBinary + encodeAppXml); its output matches `sedonac app.sab` byte for byte,
down to the `<!-- /path -->` comments and Java's float formatting. Kit manifests come
from Sedona home, found the way the editor finds them everywhere else.
A .sab holds config props only, so runtime values cannot come back: like sedonac, the
manifest default stands in for them, and a value equal to its default is not written.
"""
import base64
import math
import struct
import xml.etree.ElementTree as ET
import schema
class SabError(Exception):
pass
class _Reader:
def __init__(self, data):
self.b, self.p = data, 0
def take(self, n):
if self.p + n > len(self.b):
raise SabError(f"truncated sab at offset {self.p}")
v = self.b[self.p:self.p + n]
self.p += n
return v
def u1(self): return self.take(1)[0]
def u2(self): return struct.unpack(">H", self.take(2))[0]
def i4(self): return struct.unpack(">i", self.take(4))[0]
def i8(self): return struct.unpack(">q", self.take(8))[0]
def f4(self): return struct.unpack(">f", self.take(4))[0]
def f8(self): return struct.unpack(">d", self.take(8))[0]
def str_(self):
end = self.b.index(0, self.p)
s = self.b[self.p:end].decode("ascii")
self.p = end + 1
return s
# --- schema ---------------------------------------------------------------------------
class Slot:
__slots__ = ("name", "type", "flags", "default", "facets")
def __init__(self, elem):
self.name = elem.get("name")
self.type = elem.get("type")
self.flags = elem.get("flags", "")
self.default = elem.get("default")
self.facets = {f.get("name"): f.get("val") for f in elem.iter()
if f is not elem and f.get("name") is not None}
def _load_kits(parts):
"""[(kitName, checksumHex)] in kit-id order -> (types by kit, types by qname)."""
kits, types_by_q = [], {}
for name, cks in parts:
path = schema.find_manifest_path(name, cks)
if path is None:
raise SabError(f"no manifest for kit '{name}' in Sedona home")
mroot = ET.parse(path).getroot()
tlist = sorted(mroot.findall("type"), key=lambda t: int(t.get("id")))
for t in tlist:
types_by_q[f"{name}::{t.get('name')}"] = t
kits.append(tlist)
return kits, types_by_q
def _resolve_slots(qname, types_by_q, cache):
"""The type's slots, base chain first, indexed by slot id."""
if qname in cache:
return cache[qname]
if qname not in types_by_q:
raise SabError(f"type {qname} is not in the app's kits -- a kit build that "
f"needs kit '{qname.split('::')[0]}', which the app does not list?")
t = types_by_q[qname]
base = t.get("base")
out = list(_resolve_slots(base, types_by_q, cache)) if base else []
by_name = {s.name: i for i, s in enumerate(out)}
for elem in sorted(t.findall("slot"), key=lambda s: int(s.get("id"))):
slot = Slot(elem)
if slot.name in by_name: # an action override keeps the inherited id
out[by_name[slot.name]] = slot
else:
by_name[slot.name] = len(out)
out.append(slot)
cache[qname] = out
return out
# --- values ---------------------------------------------------------------------------
def _int(raw): # Integer.decode: supports 0x
if raw in (None, "", "null"):
return 0
return int(raw, 0) if raw.lower().startswith(("0x", "-0x")) else int(raw)
def _flt(raw):
if raw == "null":
return float("nan") # explicit null only
if raw in (None, ""):
return 0.0 # Value.defaultForType -> zero
return float(raw)
def _decode_value(r, vtype, flags):
if "s" in flags: # asStr Buf: u2 len (incl NUL)
n = r.u2()
return r.take(n).split(b"\0")[0].decode("ascii", errors="replace")
if vtype == "bool":
v = r.u1()
return None if v == 2 else (v == 1)
if vtype == "byte": return r.u1()
if vtype == "short": return r.u2()
if vtype == "int": return r.i4()
if vtype == "long": return r.i8()
if vtype == "float": return r.f4()
if vtype == "double": return r.f8()
if vtype in ("sys::Buf", "buf"):
return bytes(r.take(r.u2()))
raise SabError(f"unhandled slot type {vtype}")
def _default_value(slot):
"""The manifest default, or Value.defaultForType's zero."""
vtype, flags, raw = slot.type, slot.flags, slot.default
if "s" in flags:
return raw or ""
if vtype == "bool":
return None if raw == "null" else raw in ("true", "1")
if vtype in ("byte", "short", "int", "long"):
return _int(raw)
if vtype == "float":
# the default is a 32-bit float too: 0.1 as a double is not the file's 0.1
return _to_f32(_flt(raw))
if vtype == "double":
return _flt(raw)
if vtype in ("sys::Buf", "buf"):
return base64.b64decode(raw) if raw else b""
raise SabError(f"unhandled slot type {vtype}")
def _same(a, b):
if isinstance(a, float) and isinstance(b, float):
return (math.isnan(a) and math.isnan(b)) or a == b
return type(a) is type(b) and a == b
# --- Java number formatting -----------------------------------------------------------
_F32_MAX = 3.4028234663852886e38
_F32_INF_EDGE = 3.4028235677973366e38 # midpoint to 2**128: below rounds to F32_MAX
def _to_f32(v):
try:
return struct.unpack(">f", struct.pack(">f", v))[0]
except OverflowError:
if abs(v) < _F32_INF_EDGE:
return math.copysign(_F32_MAX, v)
return math.copysign(math.inf, v)
def _shortest(a, single):
"""Fewest digits that still round-trip -- what Java's toString picks."""
for prec in range(0, 17):
s = "%.*e" % (prec, a)
v = float(s)
if single:
v = _to_f32(v)
if v == a:
break
mant, exp = s.split("e")
digits = mant.replace(".", "").rstrip("0") or "0"
return digits, int(exp)
def _java_float(v, single=True):
"""Float/Double.toString: always a decimal point, E-form outside [1e-3, 1e7)."""
if math.isnan(v):
return "NaN"
if math.isinf(v):
return "-Infinity" if v < 0 else "Infinity"
neg = math.copysign(1.0, v) < 0
a = abs(v)
if a == 0.0:
return "-0.0" if neg else "0.0"
digits, exp = _shortest(a, single)
if -3 <= exp < 7:
if exp >= 0:
head = digits[:exp + 1].ljust(exp + 1, "0")
tail = digits[exp + 1:] or "0"
else:
head, tail = "0", "0" * (-exp - 1) + digits
out = head + "." + tail
else:
out = digits[0] + "." + (digits[1:] or "0") + "E" + str(exp)
return "-" + out if neg else out
def _encode_string(val, slot):
"""Value.encodeString()."""
vtype, flags = slot.type, slot.flags
if "s" in flags:
return _literal(val)
if vtype == "bool":
return "null" if val is None else ("true" if val else "false")
if vtype == "float":
return "null" if math.isnan(val) else _java_float(val, True)
if vtype == "double":
return "null" if math.isnan(val) else _java_float(val, False)
if vtype in ("sys::Buf", "buf"):
return base64.b64encode(val).decode("ascii")
return str(val)
def _literal(s):
"""TextUtil.toLiteral."""
return "".join({"\0": "\\0", "\n": "\\n", "\r": "\\r", "\t": "\\t",
"\\": "\\\\", '"': '\\"', "$": "\\$"}.get(c, c) for c in s)
def _safe(s):
"""XWriter.safe: escape markup, quotes, and anything outside printable ASCII."""
out = []
for c in s:
o = ord(c)
if o < 0x20 or o > 0x7e or c in "'\"":
out.append("&#x%x;" % o)
elif c == "<":
out.append("&lt;")
elif c == ">":
out.append("&gt;")
elif c == "&":
out.append("&amp;")
else:
out.append(c)
return "".join(out)
# --- the app --------------------------------------------------------------------------
class SabApp:
"""A decoded app.sab: kits, components by id, and links.
Each component is a dict: id, type, name, parent, kids, and props -- a list of
(Slot, value) for every config prop, defaults included.
"""
def __init__(self, data):
r = _Reader(data)
if r.i4() != 0x73617070:
raise SabError("not a .sab (bad magic)")
ver = r.i4()
if ver != 0x0003:
raise SabError(f"unsupported sab version 0x{ver:x}")
self.kit_parts = []
for _ in range(r.u1()):
name = r.str_()
self.kit_parts.append((name, f"{r.i4() & 0xFFFFFFFF:08x}"))
kits, self._types_by_q = _load_kits(self.kit_parts)
self._cache = {}
r.u2() # maxId, recomputed on the way out
qname_of = {}
for ki, ((kname, _), tlist) in enumerate(zip(self.kit_parts, kits)):
for t in tlist:
qname_of[(ki, int(t.get("id")))] = f"{kname}::{t.get('name')}"
by_id = {}
while True:
cid = r.u2()
if cid == 0xFFFF:
break
ki, ti = r.u1(), r.u1()
if (ki, ti) not in qname_of:
raise SabError(f"comp {cid}: no type {ti} in kit {ki}")
qname = qname_of[(ki, ti)]
c = {"id": cid, "type": qname, "name": r.str_(), "kids": [], "props": []}
c["parentId"], c["childId"], c["siblingId"] = r.u2(), r.u2(), r.u2()
for slot in self.slots(qname):
if "a" in slot.flags or "c" not in slot.flags:
continue # config props only, in slot-id order
c["props"].append((slot, _decode_value(r, slot.type, slot.flags)))
if r.u1() != ord(";"):
raise SabError(f"corrupted component {cid} {c['name']}")
if cid in by_id:
raise SabError(f"duplicate id: {cid}")
by_id[cid] = c
def must(cid, what, owner):
if cid not in by_id:
raise SabError(f"comp {owner['id']} {owner['name']}: missing {what} {cid}")
return by_id[cid]
for c in by_id.values():
c["parent"] = must(c["parentId"], "parent", c) if c["parentId"] != 0xFFFF else None
if c["childId"] != 0xFFFF:
kid = must(c["childId"], "child", c)
while True:
c["kids"].append(kid)
if kid["siblingId"] == 0xFFFF:
break
kid = must(kid["siblingId"], "nextSibling", kid)
self.links = []
while True:
fc = r.u2()
if fc == 0xFFFF:
break
fs, tc, ts = r.u1(), r.u2(), r.u1()
for cid in (fc, tc):
if cid not in by_id:
raise SabError(f"link references unknown comp id {cid}")
self.links.append((by_id[fc], fs, by_id[tc], ts))
if r.u1() != ord("."):
raise SabError("invalid app end marker")
if 0 not in by_id:
raise SabError("no app component (id 0)")
self.comps = by_id
self.app = by_id[0]
def slots(self, qname):
return _resolve_slots(qname, self._types_by_q, self._cache)
def kit_names(self):
return [name for name, _ in self.kit_parts]
def prop(self, comp, name, default=None):
for slot, val in comp["props"]:
if slot.name == name:
return val
return default
def slot(self, comp, name):
return next((s for s in self.slots(comp["type"]) if s.name == name), None)
# --- writing app.sax ---
def to_sax(self):
out = []
w = out.append
w("<?xml version='1.0'?>\n")
w("<sedonaApp>\n")
w("<schema>\n")
for name, cks in self.kit_parts:
w(f' <kit name="{_safe(name)}" checksum="{cks}" />\n')
w("</schema>\n")
w("<app>\n")
self._write_props(w, self.app, 2)
for kid in self.app["kids"]:
self._write_comp(w, kid, 2)
w("</app>\n")
w("<links>\n")
for fc, fs, tc, ts in self.links:
w(' <link from="%s.%s" to="%s.%s"/>\n' % (
_safe(_path_of(fc)), _safe(self._slot_name(fc, fs)),
_safe(_path_of(tc)), _safe(self._slot_name(tc, ts))))
w("</links>\n")
w("</sedonaApp>\n")
return "".join(out)
def _slot_name(self, comp, slot_id):
slots = self.slots(comp["type"])
if slot_id >= len(slots):
raise SabError(f"{comp['type']} has no slot id {slot_id}")
return slots[slot_id].name
def _write_props(self, w, c, indent):
"""Only props whose value differs from the slot default get written."""
n = 0
for slot, val in c["props"]:
if _same(val, _default_value(slot)):
continue
w('%s<prop name="%s" val="%s"/>\n' % (
" " * indent, _safe(slot.name), _safe(_encode_string(val, slot))))
n += 1
return n
def _write_comp(self, w, c, indent):
pad = " " * indent
w(f"{pad}<!-- {_path_of(c)} -->\n")
w(f'{pad}<comp name="{_safe(c["name"])}" id="{c["id"]}" type="{_safe(c["type"])}"')
body = []
n = self._write_props(body.append, c, indent + 2)
for kid in c["kids"]:
self._write_comp(body.append, kid, indent + 2)
if not n and not c["kids"]:
w("/>\n")
else:
w(">\n")
w("".join(body))
w(f"{pad}</comp>\n")
def _path_of(c):
parts = []
while c["parent"] is not None:
parts.append(c["name"])
c = c["parent"]
return "/" + "/".join(reversed(parts)) if parts else "/"
# --- encoding app.sax to app.sab ------------------------------------------------------
#
# Ported from Py_sab2sax's sab.py (sedona.offline.OfflineApp decodeAppXml +
# encodeAppBinary), with the value parsing brought in line with the Java Value classes:
# asStr text is unescaped as TextUtil.fromLiteral does, "nullbool"/"nullfloat" are
# read, and integers take Integer.decode's '#' hex and leading-zero octal.
def _decode_int(raw):
"""Integer.decode."""
if raw in (None, "", "null"):
return 0
s = raw.strip()
neg = s.startswith("-")
if s[:1] in "+-":
s = s[1:]
if s[:2].lower() == "0x":
v = int(s[2:], 16)
elif s.startswith("#"):
v = int(s[1:], 16)
elif len(s) > 1 and s.startswith("0"):
v = int(s[1:], 8)
else:
v = int(s)
return -v if neg else v
def _from_literal(s):
"""TextUtil.fromLiteral."""
if "\\" not in s:
return s
escapes = {"0": "\0", "n": "\n", "r": "\r", "t": "\t", "\\": "\\", '"': '"', "$": "$"}
out, i = [], 0
while i < len(s):
c = s[i]
if c == "\\":
i += 1
if i >= len(s) or s[i] not in escapes:
raise SabError(f"unknown escape sequence in {s!r}")
c = escapes[s[i]]
out.append(c)
i += 1
return "".join(out)
class _Writer:
def __init__(self):
self.b = bytearray()
def u1(self, v): self.b.append(v & 0xFF)
def u2(self, v): self.b += struct.pack(">H", v & 0xFFFF)
def i4(self, v): self.b += struct.pack(">i", ((v + 2**31) % 2**32) - 2**31)
def i8(self, v): self.b += struct.pack(">q", v)
def f4(self, v): self.b += struct.pack(">f", _to_f32(v))
def f8(self, v): self.b += struct.pack(">d", v)
def str_(self, s):
self.b += s.encode("ascii")
self.u1(0)
def _encode_value(out, slot, raw):
"""One config prop, from its sax text (or the manifest default when absent)."""
vtype, flags = slot.type, slot.flags
if "s" in flags: # asStr Buf: u2 len (incl NUL)
data = _from_literal(raw or "").encode("ascii")
out.u2(len(data) + 1)
out.b += data
out.u1(0)
elif vtype == "bool":
if raw in ("null", "nullbool"):
out.u1(2)
elif raw in (None, "", "false", "true"):
out.u1(1 if raw == "true" else 0)
else:
raise SabError(f"invalid bool {raw!r}")
elif vtype == "byte":
out.u1(_decode_int(raw))
elif vtype == "short":
out.u2(_decode_int(raw))
elif vtype == "int":
out.i4(_decode_int(raw))
elif vtype == "long":
out.i8(_decode_int(raw))
elif vtype in ("float", "double"):
v = float("nan") if raw in ("null", "nullfloat", "nulldouble") else _flt(raw)
(out.f4 if vtype == "float" else out.f8)(v)
elif vtype in ("sys::Buf", "buf"):
data = base64.b64decode(raw) if raw else b""
out.u2(len(data))
out.b += data
else:
raise SabError(f"unhandled slot type {vtype}")
def encode_sax(sax, checksums=None, kits=None):
"""app.sax (text or bytes) -> app.sab bytes, as `sedonac app.sax` writes it.
checksums, {kit name: checksum hex}, stands in for the sax's own -- how an app goes
to a device whose kits are other builds than the ones it was saved with. kits,
[(name, checksum hex)], replaces the schema altogether, as Ontrol's PutApp rewrites
it for the kits chosen to put. Every kit's manifest must be in Sedona home under
the checksum used.
"""
root = ET.fromstring(sax)
schema_elem, app_elem = root.find("schema"), root.find("app")
if schema_elem is None or app_elem is None:
raise SabError("not a Sedona app: no <schema> or <app>")
if kits is None:
kits = [(k.get("name"), (checksums or {}).get(k.get("name")) or k.get("checksum"))
for k in schema_elem.findall("kit")]
parts = []
for name, cks in kits:
if not cks:
raise SabError(f"no checksum for kit '{name}'")
parts.append((name, f"{int(cks, 16):08x}"))
parts.sort(key=lambda p: ("" if p[0] == "sys" else "\x01" + p[0])) # sortKits
for name, cks in parts:
path = schema.find_manifest_path(name, cks)
if path is None or not path.endswith(f"-{cks}.xml"):
raise SabError(f"no manifest for kit '{name}' {cks} in Sedona home")
kits, types_by_q = _load_kits(parts)
type_ids = {}
for ki, ((kname, _), tlist) in enumerate(zip(parts, kits)):
for t in tlist:
type_ids[f"{kname}::{t.get('name')}"] = (ki, int(t.get("id")))
cache = {}
comps = []
def walk(elem, qtype, parent):
if qtype not in type_ids:
raise SabError(f"unknown type '{qtype}' ({elem.get('name')})")
c = {"elem": elem, "type": qtype, "parent": parent, "kids": [],
"id": int(elem.get("id")) if elem.get("id") else None}
comps.append(c)
if parent is not None:
parent["kids"].append(c)
for kid in elem.findall("comp"):
walk(kid, kid.get("type"), c)
return c
app = walk(app_elem, "sys::App", None)
app["id"] = 0
used = {c["id"] for c in comps if c["id"] is not None}
next_id = 1
for c in comps: # ids for comps the sax gives none
if c["id"] is None:
while next_id in used:
next_id += 1
c["id"] = next_id
used.add(next_id)
by_id = {}
for c in comps:
if c["id"] in by_id:
raise SabError(f"duplicate id: {c['id']}")
by_id[c["id"]] = c
out = _Writer()
out.i4(0x73617070) # "sapp"
out.i4(0x0003) # version 0.3
out.u1(len(parts))
for name, cks in parts:
out.str_(name)
out.i4(int(cks, 16))
out.u2(max(by_id))
for cid in sorted(by_id):
c = by_id[cid]
ki, ti = type_ids[c["type"]]
out.u2(cid)
out.u1(ki)
out.u1(ti)
out.str_(c["elem"].get("name", "app") if cid else "app")
out.u2(c["parent"]["id"] if c["parent"] else 0xFFFF)
out.u2(c["kids"][0]["id"] if c["kids"] else 0xFFFF)
sibling = 0xFFFF
if c["parent"]:
kids = c["parent"]["kids"]
i = kids.index(c)
if i + 1 < len(kids):
sibling = kids[i + 1]["id"]
out.u2(sibling)
props = {p.get("name"): p.get("val") for p in c["elem"].findall("prop")}
for slot in _resolve_slots(c["type"], types_by_q, cache):
if "a" in slot.flags or "c" not in slot.flags:
continue # config props only
_encode_value(out, slot, props.get(slot.name, slot.default))
out.u1(ord(";"))
out.u2(0xFFFF)
def ref(text):
path, slot_name = text.rsplit(".", 1)
cur = app
for part in (p for p in path.split("/") if p):
cur = next((k for k in cur["kids"] if k["elem"].get("name") == part), None)
if cur is None:
raise SabError(f"link to a missing component: {text}")
names = [s.name for s in _resolve_slots(cur["type"], types_by_q, cache)]
if slot_name not in names:
raise SabError(f"link to a missing slot: {text}")
return cur["id"], names.index(slot_name)
links = root.find("links")
for link in (links.findall("link") if links is not None else ()):
fc, fs = ref(link.get("from"))
tc, ts = ref(link.get("to"))
out.u2(fc)
out.u1(fs)
out.u2(tc)
out.u1(ts)
out.u2(0xFFFF)
out.u1(ord("."))
return bytes(out.b)
+833 -44
View File
File diff suppressed because it is too large Load Diff
+40 -3
View File
@@ -224,6 +224,41 @@ def find_manifest_path(kit_name, checksum):
return None return None
def resolve_truncated_checksum(kit_name, prefix):
"""The checksum of the one local manifest whose leading bytes match ``prefix``.
Used when a device's schema reply cut a kit's checksum short. Returns the
checksum as a signed 32-bit int (as read off the wire), or None when no
manifest, or more than one, matches.
"""
manifest_dir = os.path.join(app_state.SEDONA_HOME, "manifests", kit_name)
if not os.path.isdir(manifest_dir):
return None
want = prefix.hex()
matches = set()
for f in os.listdir(manifest_dir):
if f.startswith(f"{kit_name}-") and f.endswith(".xml"):
hex_part = f[len(kit_name) + 1:-4].zfill(8).lower()
if hex_part.startswith(want):
matches.add(hex_part)
if len(matches) != 1:
return None
value = int(matches.pop(), 16)
return value - (1 << 32) if value & 0x80000000 else value
def find_manifest(kit_name, checksum):
"""Returns the parsed ET root of a kit manifest, or None."""
checksum_hex = f"{checksum & 0xFFFFFFFF:08x}" if isinstance(checksum, int) else checksum
path = find_manifest_path(kit_name, checksum_hex)
if path is None:
return None
try:
return ET.parse(path).getroot()
except Exception:
return None
def load_schema_kits_and_manifests(): def load_schema_kits_and_manifests():
"""Loads the manifests for the file's <schema> kits, plus any kit still only pending. """Loads the manifests for the file's <schema> kits, plus any kit still only pending.
@@ -318,9 +353,11 @@ def get_slots_for_type(type_str, linked_slots=()):
if "c" in slot_data["flags"]: if "c" in slot_data["flags"]:
continue continue
# Format to double digits (e.g., 00, 01, 02) # Format to double digits (e.g., 00, 01, 02), unless turned off in Preferences
padded_id = f"{slot_data['slot_id']:02d}" if app_state.SHOW_SLOT_NUMBERS:
display_label = f"{padded_id}. {slot_name}" display_label = f"{slot_data['slot_id']:02d}. {slot_name}"
else:
display_label = slot_name
# Keep structural name unchanged for XML lookup inside comp, but pass display label # Keep structural name unchanged for XML lookup inside comp, but pass display label
slots_list.append((slot_name, display_label)) slots_list.append((slot_name, display_label))
+1276
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+191 -20
View File
@@ -1,23 +1,25 @@
"""Undo/redo as a stack of reversible commands. """Undo as a stack of reversible commands. There is no redo: an undone step is gone.
Commands own the DOM mutation in both directions. Nothing here touches Tk: the view is Commands own the DOM mutation in both directions -- redo() is how a command is first
applied, undo() takes it back. In live mode they also carry it to the device, and raise
LiveUndoError when it does not get there. Nothing here touches Tk: the view is
refreshed through a callback that main registers at startup, which also keeps the refreshed through a callback that main registers at startup, which also keeps the
import graph free of cycles (sax_file and wiresheet both push commands here). import graph free of cycles (sax_file and wiresheet both push commands here).
""" """
import app_state import app_state
import live_hooks
import palette import palette
import schema import schema
from sax_dom import remove_prop, restore_children, set_prop_value, update_meta_property from sax_dom import remove_prop, restore_children, set_prop_value, update_meta_property
_undo_stack = [] _undo_stack = []
_redo_stack = []
_saved_depth = 0 # len(_undo_stack) as of the last save _saved_depth = 0 # len(_undo_stack) as of the last save
_refresh_view = None _refresh_view = None
def set_refresh_callback(callback): def set_refresh_callback(callback):
"""Registers how the view should be rebuilt after an undo or redo.""" """Registers how the view should be rebuilt after an undo."""
global _refresh_view global _refresh_view
_refresh_view = callback _refresh_view = callback
@@ -34,39 +36,49 @@ def _refresh():
def push(command): def push(command):
"""Records an already-applied command. Any redo history becomes unreachable.""" """Records an already-applied command."""
global _saved_depth
# Undone past the save, the saved state can no longer be reached by undoing.
if len(_undo_stack) < _saved_depth:
_saved_depth = -1
_undo_stack.append(command) _undo_stack.append(command)
_redo_stack.clear()
def can_undo(): def can_undo():
return bool(_undo_stack) return bool(_undo_stack)
def can_redo(): class LiveUndoError(Exception):
return bool(_redo_stack) """A live command's undo did not fully reach the device.
Raised with what did not happen; live_hooks.last_error holds why.
"""
def undo(): def undo():
if not _undo_stack: if not _undo_stack:
return None return None
command = _undo_stack.pop() command = _undo_stack.pop()
try:
command.undo() command.undo()
_redo_stack.append(command) except LiveUndoError as failure:
_refresh() _abandon_history("Undo", failure)
return command.label
def redo():
if not _redo_stack:
return None return None
command = _redo_stack.pop()
command.redo()
_undo_stack.append(command)
_refresh() _refresh()
return command.label return command.label
def _abandon_history(title, failure):
"""After a step that did not reach the device, the history no longer matches it.
The device may hold part of the step, so the steps below it cannot be trusted to land
where they say. History is dropped rather than replayed against a state it never saw.
"""
reset()
_refresh()
live_hooks.warn_failure(title, f"{failure}. Undo history was cleared")
def mark_saved(): def mark_saved():
"""Pins the current stack depth as the on-disk state.""" """Pins the current stack depth as the on-disk state."""
global _saved_depth global _saved_depth
@@ -77,14 +89,13 @@ def reset():
"""Drops all history, for when a different file is loaded.""" """Drops all history, for when a different file is loaded."""
global _saved_depth global _saved_depth
_undo_stack.clear() _undo_stack.clear()
_redo_stack.clear()
_saved_depth = 0 _saved_depth = 0
def reload_schema_registry(): def reload_schema_registry():
"""Recompiles the slot registry after the <schema> block changed. """Recompiles the slot registry after the <schema> block changed.
A failure is swallowed: undo and redo only ever land on a state that resolved once A failure is swallowed: undo only ever lands on a state that resolved once
already, so a missing manifest here means Sedona home moved underneath the session, already, so a missing manifest here means Sedona home moved underneath the session,
which the next action reports in its own words. which the next action reports in its own words.
""" """
@@ -137,6 +148,44 @@ class MetaMoveCommand:
update_meta_property(element, old_val, new_x, new_y) update_meta_property(element, old_val, new_x, new_y)
def _meta_val(element):
meta_prop = element.find("prop[@name='meta']")
return meta_prop.get("val") if meta_prop is not None else None
class LiveMetaMoveCommand(MetaMoveCommand):
"""A move on the device: each new position is written there after the DOM takes it.
A box the device refuses goes back to where the device still has it.
"""
def undo(self):
before = [(element, _meta_val(element)) for element, *_ in self.changes]
super().undo()
self._send(before)
def redo(self):
before = [(element, _meta_val(element)) for element, *_ in self.changes]
super().redo()
self._send(before)
@staticmethod
def _send(before):
failures = []
for element, old_val in before:
new_val = _meta_val(element)
if new_val is None or new_val == old_val:
continue
if not live_hooks.live_set_prop_value(element, "meta", new_val):
failures.append(element.get("name", "?"))
if old_val is None:
remove_prop(element, "meta")
else:
set_prop_value(element, "meta", old_val)
if failures:
raise LiveUndoError(f"{', '.join(failures)} could not be moved on the device")
class SlotValueCommand: class SlotValueCommand:
"""One Save from the Properties dialog: every slot it changed, as one undoable step. """One Save from the Properties dialog: every slot it changed, as one undoable step.
@@ -167,6 +216,30 @@ class SlotValueCommand:
self._apply(3) self._apply(3)
class LiveSlotValueCommand(SlotValueCommand):
"""Slot edits on the device: each value is written there first, and only a value the
device took lands in the DOM, so the views never show what the device does not hold.
A DOM read from the device carries every slot, so a None side does not come up; one
that did would have nothing to send, and only the DOM would change.
"""
def _apply(self, value_index):
failures = []
for change in self.changes:
element, prop_name = change[0], change[1]
value = change[value_index]
if value is None:
remove_prop(element, prop_name)
continue
if not live_hooks.live_set_prop_value(element, prop_name, value):
failures.append(f"{element.get('name', '?')}.{prop_name}")
continue
set_prop_value(element, prop_name, value)
if failures:
raise LiveUndoError(f"{', '.join(failures)} could not be written to the device")
class AddComponentCommand: class AddComponentCommand:
"""Placing a component. """Placing a component.
@@ -190,6 +263,25 @@ class AddComponentCommand:
self.parent.insert(self.index, self.element) self.parent.insert(self.index, self.element)
class CompositeCommand:
"""Several commands taken as one step, such as a duplicate: its copies and its links.
Undone last-first, so links go before the components they run between.
"""
def __init__(self, commands, label="edit"):
self.commands = list(commands)
self.label = label
def undo(self):
for command in reversed(self.commands):
command.undo()
def redo(self):
for command in self.commands:
command.redo()
class AddLinkCommand: class AddLinkCommand:
"""Adding a single link to the <links> section.""" """Adding a single link to the <links> section."""
@@ -248,3 +340,82 @@ class DeleteComponentCommand:
links_parent.remove(link) links_parent.remove(link)
if self.element in list(self.parent): if self.element in list(self.parent):
self.parent.remove(self.element) self.parent.remove(self.element)
class RenameComponentCommand:
"""A rename, plus every link whose path ran through the component's old name."""
def __init__(self, element, old_name, new_name, link_changes, label="rename"):
# link_changes: list of (link_element, attr, old_value, new_value)
self.element = element
self.old_name = old_name
self.new_name = new_name
self.link_changes = link_changes
self.label = label
def undo(self):
self.element.set("name", self.old_name)
for link, attr, old_value, _ in self.link_changes:
link.set(attr, old_value)
def redo(self):
self.element.set("name", self.new_name)
for link, attr, _, new_value in self.link_changes:
link.set(attr, new_value)
class LiveRenameCommand(RenameComponentCommand):
"""A rename on the device: the old name goes back there before the DOM takes it."""
def undo(self):
if not live_hooks.live_rename_component(self.element, self.old_name):
raise LiveUndoError(f"{self.new_name} was not renamed back to {self.old_name} "
"on the device")
super().undo()
def redo(self):
if not live_hooks.live_rename_component(self.element, self.new_name):
raise LiveUndoError(f"{self.old_name} was not renamed to {self.new_name} "
"on the device")
super().redo()
def apply_child_order(parent, ordered_children):
"""Puts `parent`'s <comp> children in the given order; its other children stay put."""
children = list(parent)
comp_slots = [i for i, child in enumerate(children) if child.tag == "comp"]
for slot, child in zip(comp_slots, ordered_children):
children[slot] = child
parent[:] = children
class ReorderChildrenCommand:
"""A new order for a component's children."""
def __init__(self, parent, old_order, new_order, label="reorder"):
self.parent = parent
self.old_order = list(old_order)
self.new_order = list(new_order)
self.label = label
def undo(self):
apply_child_order(self.parent, self.old_order)
def redo(self):
apply_child_order(self.parent, self.new_order)
class LiveReorderChildrenCommand(ReorderChildrenCommand):
"""A reorder on the device: the old order goes back there before the DOM takes it."""
def undo(self):
if not live_hooks.live_reorder_children(self.parent, self.old_order):
raise LiveUndoError(f"The old order of {self.parent.get('name', 'app')} "
"could not be put back on the device")
super().undo()
def redo(self):
if not live_hooks.live_reorder_children(self.parent, self.new_order):
raise LiveUndoError(f"{self.parent.get('name', 'app')} was not reordered "
"on the device")
super().redo()
+690
View File
@@ -0,0 +1,690 @@
"""The User Manager: a table of sys::User components under a sys::UserService.
Like the other panels it takes the editor pane over from the canvas rather than
floating above it. `main` is what hides the canvas and puts this in its place;
this module knows only the frame it was handed.
Each sys::User component carries three properties:
cred — SHA-1 of "username:password" (20 bytes, base64 in the SAX)
perm — 32-bit int: four groups × 7 permission flags per group
prov — 8-bit byte: app/kits/svm provisioning flags
"""
import base64
import hashlib
import tkinter as tk
from tkinter import font as tkfont, messagebox, ttk
import xml.etree.ElementTree as ET
import app_state
import live_hooks
import sax_file
import undo
from sax_dom import set_prop_value
HEADER_BG = "#f2f2f2"
PERM_FLAGS = ["or", "ow", "oi", "ar", "aw", "ai", "ua"]
PERM_LABELS = "rwIRWIu"
PROV_FLAGS = [("app", 0x01), ("kits", 0x02), ("svm", 0x04)]
def _hash_credentials(username, password):
"""SHA-1 of 'username:password', the Sedona credential algorithm."""
return base64.b64encode(
hashlib.sha1(f"{username}:{password}".encode("utf-8")).digest()
).decode("ascii")
def _decode_perm(perm_val):
"""32-bit int -> list of four 7-element bool lists."""
try:
val = int(perm_val)
except (TypeError, ValueError):
val = 0
groups = []
for g in range(4):
byte = (val >> (g * 8)) & 0xFF
groups.append([(byte >> bit) & 1 == 1 for bit in range(7)])
return groups
def _encode_perm(groups):
"""List of four 7-element bool lists -> 32-bit int."""
val = 0
for g, flags in enumerate(groups):
byte = 0
for bit, on in enumerate(flags):
if on:
byte |= (1 << bit)
val |= (byte << (g * 8))
return val
def _format_perm(perm_val):
"""Human-readable: '1=rwIRWIu, 2=rwIRWIu, ...'"""
groups = _decode_perm(perm_val)
parts = []
for g, flags in enumerate(groups):
chars = "".join(PERM_LABELS[i] if on else "-" for i, on in enumerate(flags))
parts.append(f"{g + 1}={chars}")
return ", ".join(parts)
def _decode_prov(prov_val):
"""8-bit byte -> dict of flag name -> bool."""
try:
val = int(prov_val)
except (TypeError, ValueError):
val = 0
return {name: bool(val & mask) for name, mask in PROV_FLAGS}
def _encode_prov(flags_dict):
val = 0
for name, mask in PROV_FLAGS:
if flags_dict.get(name):
val |= mask
return val
def _format_prov(prov_val):
"""Human-readable: 'app,kits,svm' for set flags."""
flags = _decode_prov(prov_val)
active = [name for name, on in flags.items() if on]
return ",".join(active) if active else "(none)"
def _get_prop(comp, name):
prop = comp.find(f"prop[@name='{name}']")
return prop.get("val") if prop is not None else None
def _safe_int(val):
if val is None:
return 0
try:
return int(val)
except (TypeError, ValueError):
try:
return int(float(val))
except (TypeError, ValueError):
return 0
def _get_users(service_comp):
"""Returns child comp elements, excluding uncached placeholders."""
return [c for c in service_comp
if c.tag == "comp" and c.get("name", "") != "..."]
def _resolve_comp(comp):
"""Re-locate a comp element in the current DOM by id (live mode rebuilds the tree)."""
root = app_state.xml_root_element
if root is None:
return comp
comp_id = comp.get("id")
if comp_id is None:
return comp
for elem in root.iter("comp"):
if elem.get("id") == comp_id:
return elem
return comp
def is_user_service(comp):
"""Whether a component is a sys::UserService (or looks like one in live mode)."""
if comp is None:
return False
type_name = comp.get("type", "")
if "UserService" in type_name:
return True
for child in comp:
if child.tag == "comp" and "User" in child.get("type", ""):
return True
return False
class _PermissionsDialog:
"""Workbench-style permissions grid: Groups x Operator/Admin columns."""
def __init__(self, parent, perm_val):
self.result = None
groups = _decode_perm(perm_val)
self._dlg = tk.Toplevel(parent)
self._dlg.title("Permissions")
self._dlg.transient(parent)
self._dlg.grab_set()
self._dlg.resizable(False, False)
grid = tk.Frame(self._dlg, padx=8, pady=8)
grid.pack(fill=tk.BOTH, expand=True)
COL_HEADERS = [
("Operator", ["Read", "Write", "Invoke"]),
("Admin", ["Read", "Write", "Invoke", "User"]),
]
# header row 0: "Groups" + category spans
tk.Label(grid, text="Groups", relief=tk.GROOVE, bg="#e8e8e8",
padx=4, pady=2).grid(row=0, column=0, rowspan=2, sticky=tk.NSEW)
col = 1
for cat_name, sub_cols in COL_HEADERS:
span = len(sub_cols)
tk.Label(grid, text=cat_name, relief=tk.GROOVE, bg="#d8e8f0",
font=("TkDefaultFont", 8, "bold"),
padx=4, pady=2).grid(row=0, column=col, columnspan=span,
sticky=tk.NSEW)
col += span
# header row 1: sub-column names
col = 1
for _, sub_cols in COL_HEADERS:
for sub in sub_cols:
tk.Label(grid, text=sub, relief=tk.GROOVE, bg="#e8e8e8",
font=("TkDefaultFont", 7), padx=4, pady=1).grid(
row=1, column=col, sticky=tk.NSEW)
col += 1
# data rows
self._perm_vars = []
for g in range(4):
r = g + 2
tk.Label(grid, text=f"Group {g + 1}", relief=tk.GROOVE,
bg="#f4f4f4", padx=4, pady=1).grid(row=r, column=0,
sticky=tk.NSEW)
gvars = []
for f_idx in range(7):
var = tk.BooleanVar(value=groups[g][f_idx])
var.trace_add("write", lambda *_a, g=g, f=f_idx: self._on_perm_toggle(g, f))
ttk.Checkbutton(grid, variable=var).grid(
row=r, column=f_idx + 1, padx=6, pady=1)
gvars.append(var)
self._perm_vars.append(gvars)
# Clear All / Set All
action_frame = tk.Frame(grid)
action_frame.grid(row=6, column=0, columnspan=8, sticky=tk.E, pady=(6, 0))
ttk.Button(action_frame, text="Clear All", width=8,
command=self._clear_all).pack(side=tk.LEFT, padx=2)
ttk.Button(action_frame, text="Set All", width=8,
command=self._set_all).pack(side=tk.LEFT, padx=2)
# OK / Cancel
btn_frame = tk.Frame(self._dlg, pady=6)
btn_frame.pack()
ttk.Button(btn_frame, text="OK", width=10, command=self._on_ok).pack(
side=tk.LEFT, padx=4)
ttk.Button(btn_frame, text="Cancel", width=10, command=self._on_cancel).pack(
side=tk.LEFT, padx=4)
self._dlg.protocol("WM_DELETE_WINDOW", self._on_cancel)
self._dlg.wait_window()
def _on_perm_toggle(self, group, flag_idx):
"""Auto-cascade: write implies read, admin implies operator."""
gvars = self._perm_vars[group]
if not gvars[flag_idx].get():
return
# Flags: 0=or, 1=ow, 2=oi, 3=ar, 4=aw, 5=ai, 6=ua
# Write (1) → Read (0); Admin Write (4) → Admin Read (3)
if flag_idx in (1, 4):
gvars[flag_idx - 1].set(True)
# Admin flag (3,4,5) → same Operator flag (0,1,2)
if 3 <= flag_idx <= 5:
gvars[flag_idx - 3].set(True)
def _clear_all(self):
for gvars in self._perm_vars:
for v in gvars:
v.set(False)
def _set_all(self):
for gvars in self._perm_vars:
for v in gvars:
v.set(True)
def _on_ok(self):
groups = [[v.get() for v in gvars] for gvars in self._perm_vars]
self.result = _encode_perm(groups)
self._dlg.destroy()
def _on_cancel(self):
self._dlg.destroy()
class _ProvisioningDialog:
"""Provisioning flags popup: app, kits, svm checkboxes."""
def __init__(self, parent, prov_val):
self.result = None
flags = _decode_prov(prov_val)
self._dlg = tk.Toplevel(parent)
self._dlg.title("Provisioning Permissions")
self._dlg.transient(parent)
self._dlg.grab_set()
self._dlg.resizable(False, False)
main = ttk.Frame(self._dlg, padding=10)
main.pack(fill=tk.BOTH, expand=True)
self._prov_vars = {}
for name, _ in PROV_FLAGS:
var = tk.BooleanVar(value=flags.get(name, False))
ttk.Checkbutton(main, text=name, variable=var).pack(
anchor=tk.W, pady=2)
self._prov_vars[name] = var
btn_frame = ttk.Frame(main)
btn_frame.pack(pady=(10, 0))
ttk.Button(btn_frame, text="OK", width=10, command=self._on_ok).pack(
side=tk.LEFT, padx=4)
ttk.Button(btn_frame, text="Cancel", width=10, command=self._on_cancel).pack(
side=tk.LEFT, padx=4)
self._dlg.protocol("WM_DELETE_WINDOW", self._on_cancel)
self._dlg.wait_window()
def _on_ok(self):
self.result = _encode_prov(
{name: var.get() for name, var in self._prov_vars.items()})
self._dlg.destroy()
def _on_cancel(self):
self._dlg.destroy()
class _UserDialog:
"""Compact Edit/Add User dialog with >> buttons opening Permissions/Provisioning popups."""
def __init__(self, parent, service_comp, user_elem, on_done):
self._service_comp = service_comp
self._user_elem = user_elem
self._on_done = on_done
self._is_edit = user_elem is not None
self._perm_val = _safe_int(_get_prop(user_elem, "perm")) if self._is_edit else 0
self._prov_val = _safe_int(_get_prop(user_elem, "prov")) if self._is_edit else 0
self._dlg = tk.Toplevel(parent)
self._dlg.title("Edit User" if self._is_edit else "Add User")
self._dlg.transient(parent)
self._dlg.grab_set()
self._dlg.resizable(False, False)
main = ttk.Frame(self._dlg, padding=10)
main.pack(fill=tk.BOTH, expand=True)
row = 0
ttk.Label(main, text="Username").grid(row=row, column=0, sticky=tk.W, pady=2)
self._name_var = tk.StringVar(
value=user_elem.get("name", "") if self._is_edit else "")
name_entry = ttk.Entry(main, textvariable=self._name_var, width=28)
name_entry.grid(row=row, column=1, columnspan=2, sticky=tk.EW, pady=2)
if self._is_edit:
name_entry.config(state="disabled")
row += 1
ttk.Label(main, text="Password").grid(row=row, column=0, sticky=tk.W, pady=2)
self._pass_var = tk.StringVar()
ttk.Entry(main, textvariable=self._pass_var, show="*", width=28).grid(
row=row, column=1, columnspan=2, sticky=tk.EW, pady=2)
row += 1
ttk.Label(main, text="Confirm").grid(row=row, column=0, sticky=tk.W, pady=2)
self._confirm_var = tk.StringVar()
ttk.Entry(main, textvariable=self._confirm_var, show="*", width=28).grid(
row=row, column=1, columnspan=2, sticky=tk.EW, pady=2)
# permissions summary + >> button
row += 1
ttk.Separator(main, orient=tk.HORIZONTAL).grid(
row=row, column=0, columnspan=3, sticky=tk.EW, pady=6)
row += 1
ttk.Label(main, text="Permissions").grid(row=row, column=0, sticky=tk.W, pady=2)
self._perm_label = tk.StringVar(value=_format_perm(self._perm_val))
ttk.Label(main, textvariable=self._perm_label,
font=("TkDefaultFont", 8)).grid(
row=row, column=1, sticky=tk.W, pady=2)
ttk.Button(main, text="»", width=3,
command=self._open_permissions).grid(
row=row, column=2, sticky=tk.E, pady=2)
# provisioning summary + >> button
row += 1
ttk.Label(main, text="Provisioning Permissions").grid(
row=row, column=0, sticky=tk.W, pady=2)
self._prov_label = tk.StringVar(value=_format_prov(self._prov_val))
ttk.Label(main, textvariable=self._prov_label,
font=("TkDefaultFont", 8)).grid(
row=row, column=1, sticky=tk.W, pady=2)
ttk.Button(main, text="»", width=3,
command=self._open_provisioning).grid(
row=row, column=2, sticky=tk.E, pady=2)
# OK / Cancel
row += 1
btn_frame = ttk.Frame(main)
btn_frame.grid(row=row, column=0, columnspan=3, pady=(10, 0))
ttk.Button(btn_frame, text="OK", width=10, command=self._on_ok).pack(
side=tk.LEFT, padx=4)
ttk.Button(btn_frame, text="Cancel", width=10, command=self._on_cancel).pack(
side=tk.LEFT, padx=4)
self._dlg.protocol("WM_DELETE_WINDOW", self._on_cancel)
name_entry.focus_set()
self._dlg.wait_window()
def _open_permissions(self):
dlg = _PermissionsDialog(self._dlg, self._perm_val)
if dlg.result is not None:
self._perm_val = dlg.result
self._perm_label.set(_format_perm(self._perm_val))
def _open_provisioning(self):
dlg = _ProvisioningDialog(self._dlg, self._prov_val)
if dlg.result is not None:
self._prov_val = dlg.result
self._prov_label.set(_format_prov(self._prov_val))
def _on_ok(self):
username = self._name_var.get().strip()
password = self._pass_var.get()
confirm = self._confirm_var.get()
if not username:
messagebox.showwarning("Validation", "Username is required.", parent=self._dlg)
return
if not self._is_edit and not password:
messagebox.showwarning("Validation", "Password is required for new users.",
parent=self._dlg)
return
if password and password != confirm:
messagebox.showwarning("Validation", "Passwords do not match.", parent=self._dlg)
return
if not self._is_edit:
for c in _get_users(self._service_comp):
if c.get("name", "") == username:
messagebox.showwarning("Validation",
f"User '{username}' already exists.",
parent=self._dlg)
return
if self._is_edit:
self._apply_edit(username, password)
else:
self._apply_add(username, password)
self._dlg.destroy()
if self._on_done:
self._on_done()
def _apply_add(self, username, password):
elem = ET.Element("comp", name=username, type="sys::User")
cred = _hash_credentials(username, password)
values = {"cred": cred, "perm": str(self._perm_val), "prov": str(self._prov_val)}
if app_state.live_mode:
# One Sox add carrying every config value, so the user never exists on the
# device without its password and rights.
try:
config = sax_file.build_config_values("sys::User", values)
except (ValueError, TypeError) as e:
live_hooks.warn_failure("Add User", f"User '{username}' was not added to the device",
f"A value does not fit its slot: {e}")
return
new_id = live_hooks.live_add_component(
self._service_comp, "sys", "User", username, config)
if new_id is None:
live_hooks.warn_failure("Add User", f"User '{username}' was not added to the device")
return
elem.set("id", str(new_id))
for name, val in values.items():
ET.SubElement(elem, "prop", name=name, val=val)
if app_state.live_mode:
index = len(list(self._service_comp))
self._service_comp.append(elem)
undo.push(sax_file.LiveComponentCommand(
self._service_comp, index, elem, present=True, label="add user"))
app_state.mark_dirty()
app_state.set_status(f"User '{username}' added to device.")
return
index = len(list(self._service_comp))
cmd = undo.AddComponentCommand(self._service_comp, index, elem, label="add user")
cmd.redo()
undo.push(cmd)
app_state.mark_dirty()
def _apply_edit(self, username, password):
user = self._user_elem
changes = []
new_perm = str(self._perm_val)
old_perm = _get_prop(user, "perm")
if new_perm != old_perm:
changes.append((user, "perm", old_perm, new_perm))
new_prov = str(self._prov_val)
old_prov = _get_prop(user, "prov")
if new_prov != old_prov:
changes.append((user, "prov", old_prov, new_prov))
if password:
new_cred = _hash_credentials(username, password)
old_cred = _get_prop(user, "cred")
if new_cred != old_cred:
changes.append((user, "cred", old_cred, new_cred))
if not changes:
return
if app_state.live_mode:
failures = []
applied = []
for change in changes:
elem, prop, _old, new = change
if live_hooks.live_set_prop_value(elem, prop, new):
set_prop_value(elem, prop, new)
applied.append(change)
app_state.mark_dirty()
else:
failures.append(f"{prop}: {live_hooks.last_error}")
if applied:
undo.push(undo.LiveSlotValueCommand(applied, label="edit user"))
if failures:
live_hooks.warn_failure(
"Edit User",
f"{len(failures)} of {len(changes)} change(s) to user '{username}' did "
"not reach the device and were not applied",
"\n".join(f" • {failure}" for failure in failures),
)
else:
app_state.set_status(f"User '{username}' updated on device.")
return
cmd = undo.SlotValueCommand(changes, label="edit user")
cmd.redo()
undo.push(cmd)
app_state.mark_dirty()
def _on_cancel(self):
self._dlg.destroy()
class UserServicePanel:
"""Table of users under a sys::UserService, with Add/Edit/Delete."""
def __init__(self, parent, comp, on_change=None, on_close=None):
self.comp = comp
self.on_change = on_change
self.on_close = on_close
self.dirty = False
self.frame = tk.Frame(parent)
self._build_header()
self._build_table()
self._build_footer()
self._refresh_table()
def _build_header(self):
header = tk.Frame(self.frame, padx=8, pady=6, bg=HEADER_BG)
header.pack(fill=tk.X)
name_font = tkfont.Font(family="Segoe UI", size=9, weight="bold")
plain_font = tkfont.Font(family="Segoe UI", size=9)
tk.Label(header, text="User Manager", font=name_font,
bg=HEADER_BG).pack(side=tk.LEFT)
tk.Label(header, text=f"— {self.comp.get('name', '?')}",
font=plain_font, fg="#555555", bg=HEADER_BG).pack(side=tk.LEFT,
padx=(8, 0))
self.count_var = tk.StringVar(value="")
tk.Label(header, textvariable=self.count_var, font=plain_font, fg="#555555",
bg=HEADER_BG).pack(side=tk.RIGHT)
def _build_table(self):
area = tk.Frame(self.frame)
area.pack(fill=tk.BOTH, expand=True, padx=8, pady=(4, 0))
columns = ("username", "permissions", "provisioning")
self.table = ttk.Treeview(area, columns=columns, show="headings",
selectmode="browse")
self.table.heading("username", text="Username")
self.table.heading("permissions", text="Group Permissions")
self.table.heading("provisioning", text="Provisioning")
self.table.column("username", width=120, minwidth=80)
self.table.column("permissions", width=320, minwidth=200)
self.table.column("provisioning", width=120, minwidth=80)
vscroll = ttk.Scrollbar(area, orient=tk.VERTICAL, command=self.table.yview)
self.table.configure(yscrollcommand=vscroll.set)
self.table.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
vscroll.pack(side=tk.RIGHT, fill=tk.Y)
self.table.bind("<Double-1>", lambda _e: self._on_edit())
self._user_elements = {}
def _build_footer(self):
footer = tk.Frame(self.frame, padx=8, pady=6)
footer.pack(side=tk.BOTTOM, fill=tk.X)
ttk.Button(footer, text="Close", width=9,
command=self.close).pack(side=tk.RIGHT)
ttk.Button(footer, text="Delete User", width=12,
command=self._on_delete).pack(side=tk.RIGHT, padx=(0, 6))
ttk.Button(footer, text="Edit User", width=12,
command=self._on_edit).pack(side=tk.RIGHT, padx=(0, 6))
ttk.Button(footer, text="Add User", width=12,
command=self._on_add).pack(side=tk.RIGHT, padx=(0, 6))
def _refresh_table(self):
for item in self.table.get_children():
self.table.delete(item)
self._user_elements.clear()
self.comp = _resolve_comp(self.comp)
users = _get_users(self.comp)
for user_elem in users:
name = user_elem.get("name", "?")
perm = _get_prop(user_elem, "perm")
prov = _get_prop(user_elem, "prov")
iid = self.table.insert("", tk.END, values=(
name,
_format_perm(perm),
_format_prov(prov),
))
self._user_elements[iid] = user_elem
self.count_var.set(f"{len(users)} user{'' if len(users) == 1 else 's'}")
def refresh(self):
self._refresh_table()
def _selected_user(self):
sel = self.table.selection()
if not sel:
return None, None
return sel[0], self._user_elements.get(sel[0])
def _after_change(self):
self._refresh_table()
if self.on_change:
self.on_change()
def _on_add(self):
_UserDialog(self.frame.winfo_toplevel(), self.comp, None, self._after_change)
def _on_edit(self):
iid, user_elem = self._selected_user()
if user_elem is None:
messagebox.showinfo("Edit User", "Select a user first.",
parent=self.frame.winfo_toplevel())
return
_UserDialog(self.frame.winfo_toplevel(), self.comp, user_elem, self._after_change)
def _on_delete(self):
iid, user_elem = self._selected_user()
if user_elem is None:
messagebox.showinfo("Delete User", "Select a user first.",
parent=self.frame.winfo_toplevel())
return
name = user_elem.get("name", "?")
if not messagebox.askyesno("Delete User", f"Delete user '{name}'?",
parent=self.frame.winfo_toplevel()):
return
if app_state.live_mode:
# Read before it goes: once deleted, the device has nothing left to read back.
snapshot = live_hooks.read_subtree(user_elem)
unreadable = live_hooks.last_error if snapshot is None else ""
if not live_hooks.live_delete_component(user_elem):
live_hooks.warn_failure("Delete User", f"User '{name}' was not deleted from the device")
return
index = list(self.comp).index(user_elem)
self.comp.remove(user_elem)
app_state.mark_dirty()
if snapshot is not None:
undo.push(sax_file.LiveComponentCommand(
self.comp, index, user_elem, present=False, snapshot=snapshot,
label="delete user"))
app_state.set_status(f"User '{name}' deleted from device.")
else:
app_state.set_status(
f"User '{name}' was deleted from the device, but this delete cannot be "
f"undone: {unreadable}", warn=True)
self._after_change()
return
index = list(self.comp).index(user_elem)
cmd = undo.AddComponentCommand(self.comp, index, user_elem, label="delete user")
cmd.undo()
undo.push(cmd)
app_state.mark_dirty()
self._after_change()
def close(self):
if self.on_close is not None:
self.on_close()
return True
def destroy(self):
if self.frame.winfo_exists():
self.frame.destroy()
def build_user_service_panel(parent, comp, on_change=None, on_close=None):
if comp is None or parent is None:
return None
return UserServicePanel(parent, comp, on_change=on_change, on_close=on_close)
+199
View File
@@ -0,0 +1,199 @@
"""How a routed wire looks: rounded bends, hops where wires cross, dots where they split.
The router in wiresheet.py hands over orthogonal polylines and nothing else. Everything
here is pure geometry on those point lists -- no canvas, no app state -- so the styling
can change without touching how wires find their way around the boxes.
"""
import math
CORNER_RADIUS = 6 # bend radius, shrunk on short segments so two bends never overlap
CORNER_STEPS = 5 # points per bend; enough to read as a curve at every zoom level
HOP_HALF_WIDTH = 4 # a hop spans this far either side of the wire it jumps over
HOP_HEIGHT = 4
HOP_STEPS = 6
HOP_MERGE_GAP = 2 # hops closer than this run together into one wider hop
HOP_END_MARGIN = 10 # no hop this close to a target, where the arrowhead sits
JUNCTION_RADIUS = 3
EPS = 0.5
def simplify(points):
"""Drops repeated points and the middle of any straight run.
The router emits a fixed number of points per route shape, so a path often carries a
"bend" that does not turn at all. Rounding one of those would put a kink in a
straight line.
"""
out = []
for point in points:
if out and _same(out[-1], point):
continue
if len(out) >= 2 and _collinear(out[-2], out[-1], point):
out[-1] = point
else:
out.append(point)
return out
def find_hops(paths):
"""Where each wire has to hop: one dict per path, segment index -> crossing xs.
A horizontal run hops over a vertical run of a different wire it crosses. Only true
crossings count: a wire touching another at a bend or an end is a junction, and a
hop there would hide it.
"""
horizontals, verticals = [], []
for path_index, points in enumerate(paths):
for seg_index, (a, b) in enumerate(zip(points, points[1:])):
if abs(a[1] - b[1]) < EPS:
horizontals.append((path_index, seg_index, a[1],
min(a[0], b[0]), max(a[0], b[0])))
else:
verticals.append((path_index, a[0], min(a[1], b[1]), max(a[1], b[1])))
hops = [{} for _ in paths]
for path_index, seg_index, y, x1, x2 in horizontals:
for other, x, y1, y2 in verticals:
if other == path_index:
continue
if x1 + EPS < x < x2 - EPS and y1 + EPS < y < y2 - EPS:
hops[path_index].setdefault(seg_index, []).append(x)
return hops
def fan_out_junctions(paths_by_owner):
"""Points where wires leaving one output pin go their separate ways.
Every pair of wires from the same pin shares a run out of it, and some share a trunk
further on. Where the two stop running together is a branch, and a branch without a
dot reads as two wires merely crossing.
"""
points = set()
for paths in paths_by_owner.values():
for i, first in enumerate(paths):
for second in paths[i + 1:]:
split = _divergence(first, second)
if split is None:
continue
if any(_same(split, end) for end in (first[0], first[-1], second[-1])):
continue
points.add((round(split[0], 1), round(split[1], 1)))
return points
def styled_points(points, hops=None):
"""The points to draw for a simplified orthogonal path, bends rounded and hops in."""
hops = hops or {}
count = len(points)
if count < 3:
return list(points)
lengths = [_length(a, b) for a, b in zip(points, points[1:])]
radii = [0.0] * count
for i in range(1, count - 1):
radii[i] = min(CORNER_RADIUS, lengths[i - 1] / 2, lengths[i] / 2)
out = [points[0]]
for i in range(count - 1):
a, b = points[i], points[i + 1]
ux, uy = _unit(a, b)
start = (a[0] + ux * radii[i], a[1] + uy * radii[i])
end = (b[0] - ux * radii[i + 1], b[1] - uy * radii[i + 1])
if i in hops:
end_margin = HOP_END_MARGIN if i == count - 2 else 0
out.extend(_hop_points(start, end, hops[i], end_margin))
out.append(end)
if i + 1 < count - 1:
out.extend(_corner_points(end, b, points[i + 2], radii[i + 1]))
return out
# --- helpers ---
def _same(p, q):
return abs(p[0] - q[0]) < EPS and abs(p[1] - q[1]) < EPS
def _collinear(p, q, r):
return ((abs(p[0] - q[0]) < EPS and abs(q[0] - r[0]) < EPS)
or (abs(p[1] - q[1]) < EPS and abs(q[1] - r[1]) < EPS))
def _length(a, b):
return abs(b[0] - a[0]) + abs(b[1] - a[1]) # orthogonal, so one term is zero
def _unit(a, b):
length = _length(a, b)
if length < EPS:
return 0.0, 0.0
return (b[0] - a[0]) / length, (b[1] - a[1]) / length
def _corner_points(entry, corner, after, radius):
"""A bend from `entry` round `corner` towards `after`, as a quadratic curve."""
if radius < EPS:
return []
vx, vy = _unit(corner, after)
leave = (corner[0] + vx * radius, corner[1] + vy * radius)
out = []
for step in range(1, CORNER_STEPS + 1):
t = step / CORNER_STEPS
s = 1 - t
out.append((s * s * entry[0] + 2 * s * t * corner[0] + t * t * leave[0],
s * s * entry[1] + 2 * s * t * corner[1] + t * t * leave[1]))
return out
def _hop_points(start, end, xs, end_margin):
"""The bumps along a horizontal run from `start` to `end`, in the order travelled."""
y = start[1]
direction = 1 if end[0] >= start[0] else -1
lo = min(start[0], end[0]) + HOP_HALF_WIDTH + 1
hi = max(start[0], end[0]) - HOP_HALF_WIDTH - 1
if direction > 0:
hi -= end_margin
else:
lo += end_margin
ordered = sorted((x for x in xs if lo <= x <= hi), key=lambda x: x * direction)
groups = []
for x in ordered:
if groups and abs(x - groups[-1][-1]) < 2 * HOP_HALF_WIDTH + HOP_MERGE_GAP:
groups[-1].append(x)
else:
groups.append([x])
out = []
for group in groups:
x_from = group[0] - direction * HOP_HALF_WIDTH
x_to = group[-1] + direction * HOP_HALF_WIDTH
for step in range(HOP_STEPS + 1):
t = step / HOP_STEPS
out.append((x_from + (x_to - x_from) * t, y - HOP_HEIGHT * math.sin(math.pi * t)))
return out
def _divergence(first, second):
"""The last point two paths from the same start share before they part, or None."""
if not _same(first[0], second[0]):
return None
pos = first[0]
i = j = 0
while i < len(first) - 1 and j < len(second) - 1:
d1, d2 = _unit(pos, first[i + 1]), _unit(pos, second[j + 1])
if _same(pos, first[i + 1]):
i += 1
continue
if _same(pos, second[j + 1]):
j += 1
continue
if not _same(d1, d2):
return pos
step = min(_length(pos, first[i + 1]), _length(pos, second[j + 1]))
pos = (pos[0] + d1[0] * step, pos[1] + d1[1] * step)
return pos
+1193 -275
View File
File diff suppressed because it is too large Load Diff