Compare commits
43
Commits
2880c2a6f2
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6dd01b13b9 | ||
|
|
0dddea53f8 | ||
|
|
0d5e5115d9 | ||
|
|
1a4c84f9d5 | ||
|
|
75df6d0a6e | ||
|
|
81c4a1d31d | ||
|
|
0c69f18046 | ||
|
|
3d9db54956 | ||
|
|
b3d96d96b6 | ||
|
|
160f65c95e | ||
|
|
b8af483890 | ||
|
|
6365d1f74d | ||
|
|
71effdc160 | ||
|
|
d905d25145 | ||
|
|
7ff2479736 | ||
|
|
cb0196caa7 | ||
|
|
db7b6632af | ||
|
|
f2b18923b7 | ||
|
|
e070bd0f1e | ||
|
|
08b4dec532 | ||
|
|
232eea99f4 | ||
|
|
1deb31f3b3 | ||
|
|
1f3b841dc0 | ||
|
|
9e8d737fac | ||
|
|
67fbfe03dd | ||
|
|
d0454688b3 | ||
|
|
8a0051380a | ||
|
|
0c846fcc96 | ||
|
|
91d92d6525 | ||
|
|
b074531795 | ||
|
|
f5880d1b1a | ||
|
|
d036ee206d | ||
|
|
4ab4b4d588 | ||
|
|
bc4e28d3f7 | ||
|
|
b63655462f | ||
|
|
8b21c5f389 | ||
|
|
8411f0c9ae | ||
|
|
b98fe828da | ||
|
|
fd22eb314d | ||
|
|
96a703c5d5 | ||
|
|
05ca7e9035 | ||
|
|
c8c68f7b31 | ||
|
|
5acca522bf |
@@ -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 |
|
||||||
|
|||||||
+664
-20
@@ -1,4 +1,5 @@
|
|||||||
"""App > Get with File: a full backup of the connected device's app as one zip.
|
"""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:
|
The zip follows the layout Ontrol's tools read back on a restore:
|
||||||
|
|
||||||
@@ -6,7 +7,7 @@ The zip follows the layout Ontrol's tools read back on a restore:
|
|||||||
device/kits.scode
|
device/kits.scode
|
||||||
device/images/<name>.bin
|
device/images/<name>.bin
|
||||||
device/fonts/<font file>
|
device/fonts/<font file>
|
||||||
device/system/logo.bin
|
device/system/logo.jpg Orion's boot logo; Rion's is system/logo.bin
|
||||||
app.sax app.sab decoded, as sedonac would write it
|
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
|
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)
|
fontBank[XL]/<font file> Sedona home's banks (XL for Orion's 800x480 screen)
|
||||||
@@ -14,6 +15,9 @@ The zip follows the layout Ontrol's tools read back on a restore:
|
|||||||
|
|
||||||
The device cannot list its files, so which images and fonts to fetch is read from the
|
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.
|
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 datetime
|
||||||
@@ -22,13 +26,15 @@ import re
|
|||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
import app_state
|
import app_state
|
||||||
|
import kit_build
|
||||||
|
import palette
|
||||||
import sab_app
|
import sab_app
|
||||||
|
import schema
|
||||||
import sox
|
import sox
|
||||||
from ox_widget_properties import IMAGE_EXTENSIONS, ORION_KIT, RION_KIT
|
from ox_widget_properties import IMAGE_EXTENSIONS, ORION_KIT, ORION_PLATFORM_TYPE, RION_KIT
|
||||||
|
|
||||||
APP_SAB = "app.sab"
|
APP_SAB = "app.sab"
|
||||||
KITS_SCODE = "kits.scode"
|
KITS_SCODE = "kits.scode"
|
||||||
LOGO_URI = "system/logo.bin"
|
|
||||||
|
|
||||||
_IMAGE_SLOTS = ("imageFileName", "trueImageFileName", "falseImageFileName")
|
_IMAGE_SLOTS = ("imageFileName", "trueImageFileName", "falseImageFileName")
|
||||||
_SOURCE_IMAGE_EXTENSIONS = tuple(e for e in IMAGE_EXTENSIONS if e != ".bin")
|
_SOURCE_IMAGE_EXTENSIONS = tuple(e for e in IMAGE_EXTENSIONS if e != ".bin")
|
||||||
@@ -36,25 +42,41 @@ _UNSAFE_FILE_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
|||||||
|
|
||||||
|
|
||||||
class _Platform:
|
class _Platform:
|
||||||
def __init__(self, name, image_bank, font_bank, font_extensions):
|
def __init__(self, name, kit, service_type, image_bank=None, font_bank=None,
|
||||||
|
font_extensions=(), logo_uri=None, screen=None):
|
||||||
self.name = name
|
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.image_bank = image_bank
|
||||||
self.font_bank = font_bank
|
self.font_bank = font_bank
|
||||||
self.font_extensions = font_extensions
|
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.
|
# Orion keeps a landscape (.oxfl) and a portrait (.oxfp) cut of each font, and its boot
|
||||||
_ORION = _Platform("ORION", "imageBankXL", "fontBankXL", (".oxfl", ".oxfp"))
|
# logo as a jpg; Rion's is a native image (Ontrol's BGetWithFilesTool). A P-ION has no
|
||||||
_RION = _Platform("RION", "imageBank", "fontBank", (".bin",))
|
# 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):
|
def platform_of(app):
|
||||||
kits = app.kit_names()
|
return platform_of_kits(app.kit_names())
|
||||||
if ORION_KIT in kits:
|
|
||||||
return _ORION
|
|
||||||
if RION_KIT in kits:
|
def platform_of_kits(kit_names):
|
||||||
return _RION
|
kit_names = set(kit_names)
|
||||||
return None
|
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():
|
def backup_folder():
|
||||||
@@ -99,6 +121,8 @@ def _font_stem(app, comp):
|
|||||||
def media_files(app, platform):
|
def media_files(app, platform):
|
||||||
"""The images and fonts the app names, each with its device path and bank source."""
|
"""The images and fonts the app names, each with its device path and bank source."""
|
||||||
files, seen = [], set()
|
files, seen = [], set()
|
||||||
|
if platform.screen is None:
|
||||||
|
return files
|
||||||
|
|
||||||
def add(uri, bank_folder, bank_name, group, required=True):
|
def add(uri, bank_folder, bank_name, group, required=True):
|
||||||
if uri not in seen:
|
if uri not in seen:
|
||||||
@@ -135,7 +159,7 @@ def media_files(app, platform):
|
|||||||
add_font(f"bold {stem}", group, True)
|
add_font(f"bold {stem}", group, True)
|
||||||
add_font(stem, group, not bold)
|
add_font(stem, group, not bold)
|
||||||
|
|
||||||
add(LOGO_URI, platform.image_bank, "logo", "logo", required=False)
|
add(platform.logo_uri, platform.image_bank, "logo", "logo", required=False)
|
||||||
return files
|
return files
|
||||||
|
|
||||||
|
|
||||||
@@ -155,7 +179,7 @@ def _bank_source(bank_folder, bank_name, is_image):
|
|||||||
def _source_entry(media, source_path):
|
def _source_entry(media, source_path):
|
||||||
"""Where the bank file goes in the zip: next to its bank's name, the logo in system/."""
|
"""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)
|
file_name = os.path.basename(source_path)
|
||||||
if media.device_uri == LOGO_URI:
|
if media.device_uri.startswith("system/"):
|
||||||
return "system/logo" + os.path.splitext(file_name)[1]
|
return "system/logo" + os.path.splitext(file_name)[1]
|
||||||
return f"{media.bank_folder}/{file_name}"
|
return f"{media.bank_folder}/{file_name}"
|
||||||
|
|
||||||
@@ -163,14 +187,12 @@ def _source_entry(media, source_path):
|
|||||||
def save_app(client):
|
def save_app(client):
|
||||||
"""Invokes App.save on the device and waits for it to finish.
|
"""Invokes App.save on the device and waits for it to finish.
|
||||||
|
|
||||||
Ontrol's Niagara driver does this before every app.sab get, resending the request
|
Ontrol's Niagara driver does this before every app.sab get.
|
||||||
only every 5 s: a P-ION saving a large app takes seconds, and 1 s resends of the
|
|
||||||
request while it is busy are what knocked it over.
|
|
||||||
"""
|
"""
|
||||||
comp = client.cache_get(0) or client.load(0)
|
comp = client.cache_get(0) or client.load(0)
|
||||||
slots = client.slot_registry.get((comp.kit_id, comp.type_id)) or []
|
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)
|
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, send_retry=sox.SAVE_SEND_RETRY)
|
client.invoke(0, slot["id"] if slot else 0)
|
||||||
|
|
||||||
|
|
||||||
class BackupResult:
|
class BackupResult:
|
||||||
@@ -262,3 +284,625 @@ def get_with_file(client, path, status=None):
|
|||||||
result.entries = [name for name, _ in entries]
|
result.entries = [name for name, _ in entries]
|
||||||
report("Done", 1.0)
|
report("Done", 1.0)
|
||||||
return result
|
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)
|
||||||
|
|||||||
+76
-8
@@ -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.144"
|
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
|
||||||
@@ -112,6 +121,8 @@ def load_preferences():
|
|||||||
left_pane_width = DEFAULT_LEFT_PANE_WIDTH
|
left_pane_width = DEFAULT_LEFT_PANE_WIDTH
|
||||||
sedona_home = ""
|
sedona_home = ""
|
||||||
trace_dasp = False
|
trace_dasp = False
|
||||||
|
lazy_loading = True
|
||||||
|
show_slot_numbers = False
|
||||||
|
|
||||||
if os.path.exists(PROPERTIES_FILE):
|
if os.path.exists(PROPERTIES_FILE):
|
||||||
try:
|
try:
|
||||||
@@ -124,6 +135,8 @@ def load_preferences():
|
|||||||
)
|
)
|
||||||
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)
|
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:
|
||||||
@@ -144,12 +157,20 @@ 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, trace_dasp
|
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),
|
||||||
@@ -157,11 +178,39 @@ def save_preferences():
|
|||||||
"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),
|
"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}]"
|
||||||
@@ -193,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()
|
||||||
|
|
||||||
|
|
||||||
@@ -202,11 +250,26 @@ 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()
|
||||||
|
|
||||||
|
|
||||||
|
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 device connection state ────────────────────────────────
|
||||||
live_mode = False
|
live_mode = False
|
||||||
device_session = None # device_session.DeviceSession instance
|
device_session = None # device_session.DeviceSession instance
|
||||||
@@ -220,4 +283,9 @@ def base_window_title_live():
|
|||||||
return f"Sedona Live Editor [v{VERSION}] — {device_host}:{device_port}"
|
return f"Sedona Live Editor [v{VERSION}] — {device_host}:{device_port}"
|
||||||
|
|
||||||
|
|
||||||
SEDONA_HOME, GRID_SIZE, ADD_TIMESTAMP, BOX_WIDTH, LEFT_PANE_WIDTH, TRACE_DASP = load_preferences()
|
# 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()
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -92,13 +92,87 @@ def _trace(direction, msg, datagram=None):
|
|||||||
import datetime
|
import datetime
|
||||||
ts = datetime.datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
ts = datetime.datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
||||||
name = _MSG_TYPE_NAMES.get(msg.msg_type, f"?{msg.msg_type}")
|
name = _MSG_TYPE_NAMES.get(msg.msg_type, f"?{msg.msg_type}")
|
||||||
seq = f" seq={msg.seq_num}" if msg.seq_num >= 0 else ""
|
arrow = {"S": ">> SEND", "R": "<< RECV"}.get(direction, direction)
|
||||||
ack = f" ack={msg.ack}" if msg.ack >= 0 else ""
|
payload = msg.payload or b""
|
||||||
plen = len(msg.payload) if msg.payload else 0
|
size = f" ({len(datagram)} bytes)" if datagram else ""
|
||||||
hexdump = ""
|
lines = [f"[DASP] {ts} {arrow} {name:<12} {_trace_fields(msg)}{size}"]
|
||||||
if datagram:
|
if datagram:
|
||||||
hexdump = " [" + datagram.hex(" ") + "]"
|
hdr_len = len(datagram) - len(payload)
|
||||||
_trace_out(f"[DASP] {ts} {direction} {name}{seq}{ack} payload={plen}{hexdump}")
|
lines.extend(_hexdump(datagram[:hdr_len], f"header {hdr_len}"))
|
||||||
|
if payload:
|
||||||
|
lines.extend(_hexdump(payload, f"payload {len(payload)}"))
|
||||||
|
color = _TRACE_COLORS.get(direction)
|
||||||
|
if color and _console_colors():
|
||||||
|
lines = [f"{color}{ln}\033[0m" for ln in lines]
|
||||||
|
_trace_out("\n".join(lines))
|
||||||
|
|
||||||
|
|
||||||
|
def _trace_fields(msg):
|
||||||
|
"""The message's header fields as name=value pairs, only those it carries."""
|
||||||
|
f = []
|
||||||
|
if msg.session_id >= 0: f.append(f"session=0x{msg.session_id:04x}")
|
||||||
|
if msg.seq_num >= 0: f.append(f"seq={msg.seq_num}")
|
||||||
|
if msg.ack >= 0: f.append(f"ack={msg.ack}")
|
||||||
|
if msg.ack_more: f.append(f"ackMore={msg.ack_more.hex()}")
|
||||||
|
if msg.version >= 0: f.append(f"version=0x{msg.version:04x}")
|
||||||
|
if msg.remote_id >= 0: f.append(f"remoteId=0x{msg.remote_id:04x}")
|
||||||
|
if msg.username: f.append(f"user={msg.username}")
|
||||||
|
if msg.digest_algorithm: f.append(f"digestAlg={msg.digest_algorithm}")
|
||||||
|
if msg.nonce: f.append(f"nonce=<{len(msg.nonce)} bytes>")
|
||||||
|
if msg.digest: f.append(f"digest=<{len(msg.digest)} bytes>")
|
||||||
|
if msg.ideal_max >= 0: f.append(f"idealMax={msg.ideal_max}")
|
||||||
|
if msg.abs_max >= 0: f.append(f"absMax={msg.abs_max}")
|
||||||
|
if msg.receive_max >= 0: f.append(f"receiveMax={msg.receive_max}")
|
||||||
|
if msg.receive_timeout >= 0: f.append(f"receiveTimeout={msg.receive_timeout // 1000}s")
|
||||||
|
if msg.platform_id: f.append(f"platform={msg.platform_id}")
|
||||||
|
if msg.error_code >= 0:
|
||||||
|
hint = _ERROR_HINTS.get(msg.error_code, "")
|
||||||
|
f.append(f"error=0x{msg.error_code:02x}{hint}")
|
||||||
|
return " ".join(f)
|
||||||
|
|
||||||
|
|
||||||
|
def _hexdump(data, label):
|
||||||
|
"""Hex and ASCII, 16 bytes a row, the first row headed by label."""
|
||||||
|
out = []
|
||||||
|
for off in range(0, len(data), 16):
|
||||||
|
chunk = data[off:off + 16]
|
||||||
|
hexs = chunk.hex(" ").ljust(47)
|
||||||
|
text = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
|
||||||
|
head = label if off == 0 else ""
|
||||||
|
out.append(f" {head:<11} {off:04x} {hexs} |{text}|")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# Sent lines yellow, received lines green, so the two directions read apart at a glance.
|
||||||
|
_TRACE_COLORS = {"S": "\033[93m", "R": "\033[92m"}
|
||||||
|
_colors_ok = None
|
||||||
|
|
||||||
|
|
||||||
|
def _console_colors():
|
||||||
|
"""True when stdout is a console that understands ANSI colour codes.
|
||||||
|
|
||||||
|
Windows consoles need virtual-terminal processing switched on first; a pipe or
|
||||||
|
file gets plain text so captured logs stay free of escape codes.
|
||||||
|
"""
|
||||||
|
global _colors_ok
|
||||||
|
if _colors_ok is None:
|
||||||
|
import sys
|
||||||
|
stream = sys.__stdout__
|
||||||
|
try:
|
||||||
|
_colors_ok = stream is not None and stream.isatty()
|
||||||
|
except (OSError, ValueError):
|
||||||
|
_colors_ok = False
|
||||||
|
if _colors_ok and os.name == "nt":
|
||||||
|
try:
|
||||||
|
import ctypes
|
||||||
|
kernel32 = ctypes.windll.kernel32
|
||||||
|
handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE
|
||||||
|
mode = ctypes.c_uint32()
|
||||||
|
_colors_ok = bool(kernel32.GetConsoleMode(handle, ctypes.byref(mode))
|
||||||
|
and kernel32.SetConsoleMode(handle, mode.value | 0x0004))
|
||||||
|
except (OSError, AttributeError):
|
||||||
|
_colors_ok = False
|
||||||
|
return _colors_ok
|
||||||
|
|
||||||
|
|
||||||
# Trace lines are printed by a thread of their own. The send and receive threads must
|
# Trace lines are printed by a thread of their own. The send and receive threads must
|
||||||
@@ -456,7 +530,7 @@ def _to_ack_more(seq_more, max_bytes):
|
|||||||
|
|
||||||
class _Packet:
|
class _Packet:
|
||||||
__slots__ = ("seq_num", "payload", "acked", "enqueued_time",
|
__slots__ = ("seq_num", "payload", "acked", "enqueued_time",
|
||||||
"sent_time", "send_attempts", "send_retry", "next")
|
"sent_time", "send_attempts", "next")
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.seq_num = 0
|
self.seq_num = 0
|
||||||
@@ -465,7 +539,6 @@ class _Packet:
|
|||||||
self.enqueued_time = 0.0
|
self.enqueued_time = 0.0
|
||||||
self.sent_time = 0.0
|
self.sent_time = 0.0
|
||||||
self.send_attempts = 1
|
self.send_attempts = 1
|
||||||
self.send_retry = None # seconds; None = the window's own interval
|
|
||||||
self.next = None
|
self.next = None
|
||||||
|
|
||||||
|
|
||||||
@@ -507,17 +580,17 @@ class SendWindow:
|
|||||||
self._tail = None
|
self._tail = None
|
||||||
self._cond.notify_all()
|
self._cond.notify_all()
|
||||||
|
|
||||||
def send(self, payload, send_retry=None):
|
def send(self, payload):
|
||||||
p = _Packet()
|
p = _Packet()
|
||||||
p.send_retry = send_retry
|
|
||||||
p.enqueued_time = _ticks()
|
p.enqueued_time = _ticks()
|
||||||
|
p.payload = payload
|
||||||
with self._cond:
|
with self._cond:
|
||||||
p.seq_num = self._seq_num & 0xFFFF
|
p.seq_num = self._seq_num & 0xFFFF
|
||||||
self._seq_num = (self._seq_num + 1) & 0xFFFF
|
self._seq_num = (self._seq_num + 1) & 0xFFFF
|
||||||
|
|
||||||
|
# sent_time is stamped inside the lock: send_retries() must not see the
|
||||||
|
# packet in the window as never sent and resend it before its first send.
|
||||||
self._enqueue(p)
|
self._enqueue(p)
|
||||||
p.payload = payload
|
|
||||||
p.sent_time = _ticks()
|
|
||||||
self._session._send_raw(self._to_msg(p))
|
self._session._send_raw(self._to_msg(p))
|
||||||
|
|
||||||
def _enqueue(self, p):
|
def _enqueue(self, p):
|
||||||
@@ -531,6 +604,7 @@ class SendWindow:
|
|||||||
else:
|
else:
|
||||||
self._tail.next = p
|
self._tail.next = p
|
||||||
self._tail = p
|
self._tail = p
|
||||||
|
p.sent_time = _ticks()
|
||||||
self._size += 1
|
self._size += 1
|
||||||
self._cond.notify_all()
|
self._cond.notify_all()
|
||||||
|
|
||||||
@@ -620,15 +694,9 @@ class SendWindow:
|
|||||||
p = self._head
|
p = self._head
|
||||||
while p is not None:
|
while p is not None:
|
||||||
if not p.acked:
|
if not p.acked:
|
||||||
if p.send_retry is not None:
|
backoff_shift = min(p.send_attempts - 1, 32)
|
||||||
# A request the device is slow to take -- a P-ION saving its
|
interval = min(self.send_retry * (1 << backoff_shift),
|
||||||
# app, opening a file -- is resent at its own steady interval:
|
self.max_retry_backoff)
|
||||||
# the 1 s default resends it while the device is still busy.
|
|
||||||
interval = p.send_retry
|
|
||||||
else:
|
|
||||||
backoff_shift = min(p.send_attempts - 1, 32)
|
|
||||||
interval = min(self.send_retry * (1 << backoff_shift),
|
|
||||||
self.max_retry_backoff)
|
|
||||||
if now - p.sent_time >= interval:
|
if now - p.sent_time >= interval:
|
||||||
self._resend(p)
|
self._resend(p)
|
||||||
oldest = max(oldest, now - p.enqueued_time)
|
oldest = max(oldest, now - p.enqueued_time)
|
||||||
@@ -784,11 +852,10 @@ class DaspSession:
|
|||||||
|
|
||||||
# ── Send / Receive ──────────────────────────────────────────
|
# ── Send / Receive ──────────────────────────────────────────
|
||||||
|
|
||||||
def send(self, payload, send_retry=None):
|
def send(self, payload):
|
||||||
"""Queues a datagram. send_retry (seconds) overrides the resend interval."""
|
|
||||||
if self.is_closed:
|
if self.is_closed:
|
||||||
raise DaspException(f"Session closed: {self.close_cause}")
|
raise DaspException(f"Session closed: {self.close_cause}")
|
||||||
self.send_window.send(payload, send_retry)
|
self.send_window.send(payload)
|
||||||
|
|
||||||
def receive(self, timeout=1.0):
|
def receive(self, timeout=1.0):
|
||||||
if self.is_closed:
|
if self.is_closed:
|
||||||
|
|||||||
+112
-11
@@ -10,7 +10,6 @@ import xml.etree.ElementTree as ET
|
|||||||
|
|
||||||
import app_state
|
import app_state
|
||||||
import schema
|
import schema
|
||||||
import sox
|
|
||||||
from sox import SoxClient, SoxComponent, TREE, CONFIG, RUNTIME, LINKS, ALL
|
from sox import SoxClient, SoxComponent, TREE, CONFIG, RUNTIME, LINKS, ALL
|
||||||
|
|
||||||
|
|
||||||
@@ -32,6 +31,18 @@ def _slot_precision(type_name, slot_name):
|
|||||||
return 2
|
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:
|
class DeviceSession:
|
||||||
"""High-level session manager for a live Sedona device connection."""
|
"""High-level session manager for a live Sedona device connection."""
|
||||||
|
|
||||||
@@ -39,13 +50,16 @@ class DeviceSession:
|
|||||||
self.sox_client = None
|
self.sox_client = None
|
||||||
self._event_queue = queue.Queue()
|
self._event_queue = queue.Queue()
|
||||||
self._current_subs = set() # comp_ids we subscribed for the open wiresheet
|
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 ────────────────────────────────────
|
# ── Connect / Disconnect ────────────────────────────────────
|
||||||
|
|
||||||
def connect(self, host, port, user, password):
|
def connect(self, host, port, user, password):
|
||||||
"""DASP connect → read schema → load root. Called from background thread."""
|
"""DASP connect → read schema → load root. Called from background thread."""
|
||||||
self.sox_client = SoxClient(host, int(port), user, password,
|
self.sox_client = SoxClient(host, int(port), user, password)
|
||||||
ideal_max=sox.IDEAL_MAX, abs_max=sox.ABS_MAX)
|
|
||||||
self.sox_client.on_event = self._on_sox_event
|
self.sox_client.on_event = self._on_sox_event
|
||||||
self.sox_client.checksum_resolver = schema.resolve_truncated_checksum
|
self.sox_client.checksum_resolver = schema.resolve_truncated_checksum
|
||||||
self.sox_client.connect()
|
self.sox_client.connect()
|
||||||
@@ -55,18 +69,30 @@ class DeviceSession:
|
|||||||
|
|
||||||
self._load_slot_registry(parts)
|
self._load_slot_registry(parts)
|
||||||
|
|
||||||
root_comp = self.sox_client.load(0)
|
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:
|
if root_comp and root_comp.children:
|
||||||
children = self.load_children(root_comp.id)
|
children = self.load_children(root_comp.id)
|
||||||
if children:
|
if children:
|
||||||
self.sox_client.update(children, CONFIG | RUNTIME)
|
self.sox_client.update(children, self._values_to_read(children))
|
||||||
return root_comp
|
return root_comp
|
||||||
|
|
||||||
def disconnect(self):
|
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:
|
if self.sox_client is None:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
if self._current_subs:
|
if unsubscribe and self._current_subs:
|
||||||
comps = [self.sox_client.cache_get(cid)
|
comps = [self.sox_client.cache_get(cid)
|
||||||
for cid in self._current_subs]
|
for cid in self._current_subs]
|
||||||
comps = [c for c in comps if c is not None]
|
comps = [c for c in comps if c is not None]
|
||||||
@@ -161,6 +187,74 @@ class DeviceSession:
|
|||||||
if c is not None and c.kit_id == kit_id and c.type_id == type_id_val:
|
if c is not None and c.kit_id == kit_id and c.type_id == type_id_val:
|
||||||
c.type_name = full_name
|
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 ────────────────────────────────────────────
|
# ── Lazy Loading ────────────────────────────────────────────
|
||||||
|
|
||||||
def load_children(self, comp_id):
|
def load_children(self, comp_id):
|
||||||
@@ -253,9 +347,9 @@ class DeviceSession:
|
|||||||
|
|
||||||
all_link_remote_ids = set()
|
all_link_remote_ids = set()
|
||||||
for child in children:
|
for child in children:
|
||||||
links = self.sox_client.read_links(child.id)
|
if child.id not in self._seeded:
|
||||||
child.links = links
|
child.links = self.sox_client.read_links(child.id)
|
||||||
for lnk in links:
|
for lnk in child.links:
|
||||||
all_link_remote_ids.add(lnk.from_comp_id)
|
all_link_remote_ids.add(lnk.from_comp_id)
|
||||||
all_link_remote_ids.add(lnk.to_comp_id)
|
all_link_remote_ids.add(lnk.to_comp_id)
|
||||||
|
|
||||||
@@ -267,7 +361,14 @@ class DeviceSession:
|
|||||||
if remote_ids:
|
if remote_ids:
|
||||||
self.sox_client.load_ids(remote_ids, checked=False)
|
self.sox_client.load_ids(remote_ids, checked=False)
|
||||||
|
|
||||||
self.sox_client.update(children, CONFIG | RUNTIME)
|
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)
|
folder_comp = self.sox_client.cache_get(folder_comp_id)
|
||||||
to_subscribe = [folder_comp] + children if folder_comp else children
|
to_subscribe = [folder_comp] + children if folder_comp else children
|
||||||
|
|||||||
+6
-1
@@ -5,9 +5,14 @@ add_timestamp = True
|
|||||||
box_width = 250
|
box_width = 250
|
||||||
left_pane_width = 230
|
left_pane_width = 230
|
||||||
trace_dasp = True
|
trace_dasp = True
|
||||||
|
lazy_loading = True
|
||||||
|
show_slot_numbers = False
|
||||||
|
|
||||||
[Device]
|
[Device]
|
||||||
host = 192.168.1.251
|
host = 192.168.0.26
|
||||||
port = 1876
|
port = 1876
|
||||||
user = admin
|
user = admin
|
||||||
|
|
||||||
|
[Palette]
|
||||||
|
kit = control
|
||||||
|
|
||||||
|
|||||||
+190
@@ -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()
|
||||||
+96
-7
@@ -11,7 +11,9 @@ either end. While dragging, the cursor points along the flow of the link over a
|
|||||||
would take it, and turns into a no-entry sign over one that would not, so a bad drop is
|
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.
|
visible before the button is released.
|
||||||
|
|
||||||
A right press that never moves still opens the component's context menu. A left press
|
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
|
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.
|
it draws a link instead, as in Workbench.
|
||||||
"""
|
"""
|
||||||
@@ -33,11 +35,15 @@ HOVER_COLOR = "#1f4e79"
|
|||||||
DROP_OK_COLOR = "#2e9d3a"
|
DROP_OK_COLOR = "#2e9d3a"
|
||||||
DROP_BAD_COLOR = "#d62828"
|
DROP_BAD_COLOR = "#d62828"
|
||||||
RUBBER_COLOR = "#2f6fba"
|
RUBBER_COLOR = "#2f6fba"
|
||||||
|
MARQUEE_COLOR = "#2f6fba"
|
||||||
|
CTRL_MASK = 0x0004
|
||||||
|
|
||||||
_cursors = {} # role -> Tk cursor spec, built on first use
|
_cursors = {} # role -> Tk cursor spec, built on first use
|
||||||
_press = None # pending right press: {"hit", "x", "y", "event"}
|
_press = None # pending right press: {"hit", "x", "y", "event"}
|
||||||
_drag = None # live link drag: {"hit", "anchor"}
|
_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
|
_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)
|
_link_dialog = None # opens the Link dialog: (source, target, src_slot, tgt_slot)
|
||||||
|
|
||||||
|
|
||||||
@@ -301,14 +307,16 @@ def _anchor(hit):
|
|||||||
|
|
||||||
def clear():
|
def clear():
|
||||||
"""Drops any hover ring, rubber band and drag state; called on every repaint."""
|
"""Drops any hover ring, rubber band and drag state; called on every repaint."""
|
||||||
global _press, _drag
|
global _press, _drag, _marquee
|
||||||
canvas = app_state.canvas
|
canvas = app_state.canvas
|
||||||
if canvas is None:
|
if canvas is None:
|
||||||
return
|
return
|
||||||
canvas.delete("slot_hover")
|
canvas.delete("slot_hover")
|
||||||
canvas.delete("link_rubber")
|
canvas.delete("link_rubber")
|
||||||
|
canvas.delete("marquee")
|
||||||
_press = None
|
_press = None
|
||||||
_drag = None
|
_drag = None
|
||||||
|
_marquee = None
|
||||||
_set_cursor(None)
|
_set_cursor(None)
|
||||||
|
|
||||||
|
|
||||||
@@ -442,6 +450,67 @@ def on_right_release(event):
|
|||||||
_menu_callback(event)
|
_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
|
# 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.
|
# drags out a link, and the rest of the box does nothing more than select.
|
||||||
|
|
||||||
@@ -451,6 +520,19 @@ def _on_title_bar(comp_tag, cy):
|
|||||||
|
|
||||||
|
|
||||||
def on_left_press(event):
|
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
|
_box_handlers["press"](event) # selection, and arms a box move
|
||||||
if _start_press(event) is not None:
|
if _start_press(event) is not None:
|
||||||
app_state.canvas.delete("slot_hover")
|
app_state.canvas.delete("slot_hover")
|
||||||
@@ -460,30 +542,37 @@ def on_left_press(event):
|
|||||||
|
|
||||||
|
|
||||||
def on_left_motion(event):
|
def on_left_motion(event):
|
||||||
if _press is not None or _drag is not None:
|
if _marquee is not None:
|
||||||
|
_marquee_motion(event)
|
||||||
|
elif _press is not None or _drag is not None:
|
||||||
_drag_motion(event)
|
_drag_motion(event)
|
||||||
else:
|
else:
|
||||||
_box_handlers["motion"](event)
|
_box_handlers["motion"](event)
|
||||||
|
|
||||||
|
|
||||||
def on_left_release(event):
|
def on_left_release(event):
|
||||||
if _finish(event) is None:
|
if _marquee is not None:
|
||||||
|
_marquee_finish(event)
|
||||||
|
elif _finish(event) in (None, "click"):
|
||||||
_box_handlers["release"](event)
|
_box_handlers["release"](event)
|
||||||
|
|
||||||
|
|
||||||
_box_handlers = {}
|
_box_handlers = {}
|
||||||
|
|
||||||
|
|
||||||
def install(canvas, menu_callback, box_press, box_motion, box_release, link_dialog):
|
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.
|
"""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
|
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;
|
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.
|
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
|
global _menu_callback, _link_dialog, _select_boxes
|
||||||
_menu_callback = menu_callback
|
_menu_callback = menu_callback
|
||||||
_link_dialog = link_dialog
|
_link_dialog = link_dialog
|
||||||
|
_select_boxes = select_boxes
|
||||||
_box_handlers.update(press=box_press, motion=box_motion, release=box_release)
|
_box_handlers.update(press=box_press, motion=box_motion, release=box_release)
|
||||||
canvas.bind("<Motion>", on_motion)
|
canvas.bind("<Motion>", on_motion)
|
||||||
canvas.bind("<Leave>", on_leave, add="+")
|
canvas.bind("<Leave>", on_leave, add="+")
|
||||||
|
|||||||
+298
-3
@@ -10,7 +10,7 @@ import binascii
|
|||||||
from tkinter import messagebox
|
from tkinter import messagebox
|
||||||
|
|
||||||
import app_state
|
import app_state
|
||||||
from sox import BUF_ID, type_id_for_slot
|
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
|
# Why the last live_* call failed, in words the user can act on. Every failing call sets
|
||||||
@@ -41,8 +41,8 @@ def _session():
|
|||||||
def warn_failure(title, what, detail=None):
|
def warn_failure(title, what, detail=None):
|
||||||
"""Tells the user, in a dialog and the status bar, that `what` did not reach the device.
|
"""Tells the user, in a dialog and the status bar, that `what` did not reach the device.
|
||||||
|
|
||||||
`detail` defaults to last_error. Live mode has no undo, so the dialog is the only
|
`detail` defaults to last_error. Nothing retries a write that failed, so the dialog
|
||||||
record the user gets that the editor and the device may now disagree.
|
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
|
reason = detail if detail is not None else last_error
|
||||||
app_state.set_status(f"{what}: {reason}", warn=True)
|
app_state.set_status(f"{what}: {reason}", warn=True)
|
||||||
@@ -155,6 +155,43 @@ def live_rename_component(element, new_name):
|
|||||||
return False
|
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 ───────────────────────────────────────────────
|
# ── Link / Unlink ───────────────────────────────────────────────
|
||||||
|
|
||||||
def _link_ends(ds, from_element, from_slot_name, to_element, to_slot_name):
|
def _link_ends(ds, from_element, from_slot_name, to_element, to_slot_name):
|
||||||
@@ -230,6 +267,264 @@ def live_invoke(element, slot_name, arg=None):
|
|||||||
return False
|
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 ─────────────────────────────────────────────────────
|
# ── Helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _comp_id(element):
|
def _comp_id(element):
|
||||||
|
|||||||
+8
-2
@@ -554,13 +554,19 @@ class OxPageViewPanel:
|
|||||||
|
|
||||||
# Live: the device first, and the page only shows what the device accepted.
|
# Live: the device first, and the page only shows what the device accepted.
|
||||||
live_failures = []
|
live_failures = []
|
||||||
for element, name, _before, after in changes:
|
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):
|
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}")
|
live_failures.append(f"{name}: {live_hooks.last_error}")
|
||||||
continue
|
continue
|
||||||
set_prop_value(element, name, after)
|
set_prop_value(element, name, after)
|
||||||
|
applied.append(change)
|
||||||
|
|
||||||
if not app_state.live_mode:
|
if app_state.live_mode:
|
||||||
|
if applied:
|
||||||
|
undo.push(undo.LiveSlotValueCommand(applied))
|
||||||
|
else:
|
||||||
undo.push(undo.SlotValueCommand(changes))
|
undo.push(undo.SlotValueCommand(changes))
|
||||||
app_state.mark_dirty()
|
app_state.mark_dirty()
|
||||||
|
|
||||||
|
|||||||
+16
-7
@@ -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
|
||||||
@@ -269,7 +274,7 @@ def _platforms_db():
|
|||||||
return os.path.join(app_state.SEDONA_HOME, "platforms", "db")
|
return os.path.join(app_state.SEDONA_HOME, "platforms", "db")
|
||||||
|
|
||||||
|
|
||||||
def _platform_native_kits():
|
def _platform_native_kits(sax_names=None):
|
||||||
"""Return (matching, all_platforms) — two sets of kit names.
|
"""Return (matching, all_platforms) — two sets of kit names.
|
||||||
|
|
||||||
*matching* is the union of nativeKit names from platform builds that include
|
*matching* is the union of nativeKit names from platform builds that include
|
||||||
@@ -278,8 +283,10 @@ def _platform_native_kits():
|
|||||||
|
|
||||||
A kit that appears in *all_platforms* but not in *matching* has native code
|
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.
|
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())
|
sax_names = set(app_state.active_schema_kits.keys() if sax_names is None else sax_names)
|
||||||
db = _platforms_db()
|
db = _platforms_db()
|
||||||
if not os.path.isdir(db):
|
if not os.path.isdir(db):
|
||||||
return None, None
|
return None, None
|
||||||
@@ -335,8 +342,9 @@ def _platform_native_kits():
|
|||||||
return (matching if found else None), all_platforms
|
return (matching if found else None), all_platforms
|
||||||
|
|
||||||
|
|
||||||
def unsupported_kits():
|
def unsupported_kits(sax_names=None):
|
||||||
"""Kit names that cannot work with the open file's platform.
|
"""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:
|
A kit is unsupported when:
|
||||||
- it has native code (hasNatives or listed as nativeKit on some platform)
|
- it has native code (hasNatives or listed as nativeKit on some platform)
|
||||||
@@ -345,11 +353,12 @@ def unsupported_kits():
|
|||||||
|
|
||||||
Kits that depend on an unsupported kit are also flagged.
|
Kits that depend on an unsupported kit are also flagged.
|
||||||
"""
|
"""
|
||||||
sax_names = frozenset(app_state.active_schema_kits.keys())
|
sax_names = frozenset(app_state.active_schema_kits.keys() if sax_names is None
|
||||||
|
else sax_names)
|
||||||
if sax_names in _unsupported_cache:
|
if sax_names in _unsupported_cache:
|
||||||
return _unsupported_cache[sax_names]
|
return _unsupported_cache[sax_names]
|
||||||
|
|
||||||
matching_natives, all_natives = _platform_native_kits()
|
matching_natives, all_natives = _platform_native_kits(sax_names)
|
||||||
if matching_natives is None:
|
if matching_natives is None:
|
||||||
_unsupported_cache[sax_names] = set()
|
_unsupported_cache[sax_names] = set()
|
||||||
return set()
|
return set()
|
||||||
|
|||||||
+10
-3
@@ -1170,7 +1170,9 @@ class PropertiesPanel:
|
|||||||
# In live mode the device is written first and a value only lands in the document
|
# 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.
|
# once the device took it, so the panel never shows what the device does not hold.
|
||||||
live_failures = []
|
live_failures = []
|
||||||
for element, prop_name, before, after in changes:
|
applied = []
|
||||||
|
for change in changes:
|
||||||
|
element, prop_name, before, after = change
|
||||||
if app_state.live_mode and not live_hooks.live_set_prop_value(
|
if app_state.live_mode and not live_hooks.live_set_prop_value(
|
||||||
element, prop_name, after):
|
element, prop_name, after):
|
||||||
live_failures.append(f"{prop_name}: {live_hooks.last_error}")
|
live_failures.append(f"{prop_name}: {live_hooks.last_error}")
|
||||||
@@ -1182,9 +1184,14 @@ class PropertiesPanel:
|
|||||||
continue
|
continue
|
||||||
if prop_name != "meta":
|
if prop_name != "meta":
|
||||||
set_prop_value(element, prop_name, after)
|
set_prop_value(element, prop_name, after)
|
||||||
written = len(changes) - len(live_failures)
|
applied.append(change)
|
||||||
|
written = len(applied)
|
||||||
|
|
||||||
if not app_state.live_mode:
|
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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
+220
-1
@@ -1,4 +1,5 @@
|
|||||||
"""Reads a device's app.sab and writes it back out as app.sax.
|
"""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
|
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,
|
(decodeAppBinary + encodeAppXml); its output matches `sedonac app.sab` byte for byte,
|
||||||
@@ -79,6 +80,9 @@ def _resolve_slots(qname, types_by_q, cache):
|
|||||||
"""The type's slots, base chain first, indexed by slot id."""
|
"""The type's slots, base chain first, indexed by slot id."""
|
||||||
if qname in cache:
|
if qname in cache:
|
||||||
return cache[qname]
|
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]
|
t = types_by_q[qname]
|
||||||
base = t.get("base")
|
base = t.get("base")
|
||||||
out = list(_resolve_slots(base, types_by_q, cache)) if base else []
|
out = list(_resolve_slots(base, types_by_q, cache)) if base else []
|
||||||
@@ -408,3 +412,218 @@ def _path_of(c):
|
|||||||
parts.append(c["name"])
|
parts.append(c["name"])
|
||||||
c = c["parent"]
|
c = c["parent"]
|
||||||
return "/" + "/".join(reversed(parts)) if parts else "/"
|
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)
|
||||||
|
|||||||
+618
-64
@@ -1,5 +1,6 @@
|
|||||||
"""Opening, saving and structural editing of a Sedona app file, plus the navigation tree."""
|
"""Opening, saving and structural editing of a Sedona app file, plus the navigation tree."""
|
||||||
|
|
||||||
|
import copy
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import os
|
import os
|
||||||
from tkinter import filedialog, messagebox
|
from tkinter import filedialog, messagebox
|
||||||
@@ -16,6 +17,7 @@ from sax_dom import (
|
|||||||
find_component_by_path,
|
find_component_by_path,
|
||||||
find_parent_element,
|
find_parent_element,
|
||||||
get_component_path,
|
get_component_path,
|
||||||
|
read_meta_property,
|
||||||
restore_children,
|
restore_children,
|
||||||
snapshot_children,
|
snapshot_children,
|
||||||
update_meta_property,
|
update_meta_property,
|
||||||
@@ -59,9 +61,53 @@ def parse_xml_to_tree(treeview, parent_node, xml_element):
|
|||||||
parse_xml_to_tree(treeview, node_id, child)
|
parse_xml_to_tree(treeview, node_id, child)
|
||||||
|
|
||||||
|
|
||||||
|
def _expansion_key(treeview, item, parent_key):
|
||||||
|
"""Names a tree row in a way that survives a rebuild of the tree.
|
||||||
|
|
||||||
|
A component's id is stable across edits and across a fresh DOM read from the
|
||||||
|
device; a row without one (the root, or an id-less file component) falls back to
|
||||||
|
its name under its parent's key.
|
||||||
|
"""
|
||||||
|
elem = app_state.tree_element_map.get(item)
|
||||||
|
comp_id = elem.get("id") if elem is not None else None
|
||||||
|
if comp_id is not None and treeview.parent(item) != "":
|
||||||
|
return ("id", comp_id)
|
||||||
|
name = elem.get("name", "") if elem is not None else treeview.item(item, "text")
|
||||||
|
return ("path", parent_key, name)
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_open_keys(treeview, parent="", parent_key=None, found=None):
|
||||||
|
"""Returns the keys of every row that is expanded right now."""
|
||||||
|
if found is None:
|
||||||
|
found = set()
|
||||||
|
for item in treeview.get_children(parent):
|
||||||
|
key = _expansion_key(treeview, item, parent_key)
|
||||||
|
if treeview.item(item, "open"):
|
||||||
|
found.add(key)
|
||||||
|
_collect_open_keys(treeview, item, key, found)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _reopen_keys(treeview, open_keys, parent="", parent_key=None):
|
||||||
|
"""Expands again the rows that were expanded before the rebuild.
|
||||||
|
|
||||||
|
Setting the open flag in code does not raise <<TreeviewOpen>>, so this never
|
||||||
|
starts a live child load of its own.
|
||||||
|
"""
|
||||||
|
for item in treeview.get_children(parent):
|
||||||
|
key = _expansion_key(treeview, item, parent_key)
|
||||||
|
if key in open_keys:
|
||||||
|
treeview.item(item, open=True)
|
||||||
|
_reopen_keys(treeview, open_keys, item, key)
|
||||||
|
|
||||||
|
|
||||||
def refresh_treeview_from_dom(xml_root):
|
def refresh_treeview_from_dom(xml_root):
|
||||||
"""Rebuilds navigation tree structures using current XML DOM trees."""
|
"""Rebuilds navigation tree structures using current XML DOM trees.
|
||||||
|
|
||||||
|
Rows the user had expanded stay expanded; only the user's own "-" closes one.
|
||||||
|
"""
|
||||||
tree = app_state.tree
|
tree = app_state.tree
|
||||||
|
open_keys = _collect_open_keys(tree)
|
||||||
app_state.tree_element_map.clear()
|
app_state.tree_element_map.clear()
|
||||||
|
|
||||||
for item in tree.get_children():
|
for item in tree.get_children():
|
||||||
@@ -88,6 +134,8 @@ def refresh_treeview_from_dom(xml_root):
|
|||||||
else:
|
else:
|
||||||
parse_xml_to_tree(tree, root_node, xml_root)
|
parse_xml_to_tree(tree, root_node, xml_root)
|
||||||
|
|
||||||
|
_reopen_keys(tree, open_keys)
|
||||||
|
|
||||||
if _tree_filled is not None:
|
if _tree_filled is not None:
|
||||||
_tree_filled()
|
_tree_filled()
|
||||||
|
|
||||||
@@ -103,9 +151,11 @@ def reset_to_startup_state():
|
|||||||
app_state.xml_root_element = None
|
app_state.xml_root_element = None
|
||||||
app_state.current_sheet_parent = None
|
app_state.current_sheet_parent = None
|
||||||
app_state.selected_canvas_tag = None
|
app_state.selected_canvas_tag = None
|
||||||
|
app_state.selected_canvas_tags.clear()
|
||||||
app_state.selected_tree_item_id = None
|
app_state.selected_tree_item_id = None
|
||||||
app_state.link_mark_element = None
|
app_state.link_mark_element = None
|
||||||
app_state.selected_link_tag = None
|
app_state.selected_link_tag = None
|
||||||
|
app_state.selected_link_tags.clear()
|
||||||
|
|
||||||
app_state.active_components_list.clear()
|
app_state.active_components_list.clear()
|
||||||
app_state.tree_element_map.clear()
|
app_state.tree_element_map.clear()
|
||||||
@@ -130,7 +180,7 @@ def reset_to_startup_state():
|
|||||||
|
|
||||||
if app_state.canvas is not None:
|
if app_state.canvas is not None:
|
||||||
app_state.canvas.delete("all")
|
app_state.canvas.delete("all")
|
||||||
wiresheet.draw_grid()
|
wiresheet.fit_sheet_region()
|
||||||
|
|
||||||
if app_state.window is not None:
|
if app_state.window is not None:
|
||||||
app_state.window.title(app_state.base_window_title())
|
app_state.window.title(app_state.base_window_title())
|
||||||
@@ -162,6 +212,7 @@ def open_file():
|
|||||||
app_state.pending_schema_kits.clear()
|
app_state.pending_schema_kits.clear()
|
||||||
app_state.clear_status()
|
app_state.clear_status()
|
||||||
app_state.selected_canvas_tag = None
|
app_state.selected_canvas_tag = None
|
||||||
|
app_state.selected_canvas_tags.clear()
|
||||||
app_state.current_sheet_parent = None
|
app_state.current_sheet_parent = None
|
||||||
undo.reset()
|
undo.reset()
|
||||||
app_state.mark_clean()
|
app_state.mark_clean()
|
||||||
@@ -172,7 +223,7 @@ def open_file():
|
|||||||
for item in tree.get_children():
|
for item in tree.get_children():
|
||||||
tree.delete(item)
|
tree.delete(item)
|
||||||
canvas.delete("all")
|
canvas.delete("all")
|
||||||
wiresheet.draw_grid()
|
wiresheet.fit_sheet_region()
|
||||||
|
|
||||||
xml_tree = ET.parse(file_path)
|
xml_tree = ET.parse(file_path)
|
||||||
app_state.xml_root_element = xml_tree.getroot()
|
app_state.xml_root_element = xml_tree.getroot()
|
||||||
@@ -416,11 +467,12 @@ def rename_component(comp_element, new_name):
|
|||||||
for link, attr, _, new_ref in link_changes:
|
for link, attr, _, new_ref in link_changes:
|
||||||
link.set(attr, new_ref)
|
link.set(attr, new_ref)
|
||||||
|
|
||||||
if not app_state.live_mode:
|
command_class = (undo.LiveRenameCommand if app_state.live_mode
|
||||||
undo.push(undo.RenameComponentCommand(
|
else undo.RenameComponentCommand)
|
||||||
comp_element, old_name, new_name, link_changes,
|
undo.push(command_class(
|
||||||
label=f"rename {old_name} to {new_name}"
|
comp_element, old_name, new_name, link_changes,
|
||||||
))
|
label=f"rename {old_name} to {new_name}"
|
||||||
|
))
|
||||||
|
|
||||||
app_state.mark_dirty()
|
app_state.mark_dirty()
|
||||||
refresh_treeview_from_dom(root)
|
refresh_treeview_from_dom(root)
|
||||||
@@ -431,6 +483,43 @@ def rename_component(comp_element, new_name):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def reorder_children(parent, new_order):
|
||||||
|
"""Gives `parent`'s child components a new order, which is the order they run in.
|
||||||
|
|
||||||
|
Returns True when the order changed. On a device the new order goes there first,
|
||||||
|
and the file is left alone if it does not.
|
||||||
|
"""
|
||||||
|
root = app_state.xml_root_element
|
||||||
|
if root is None:
|
||||||
|
return False
|
||||||
|
old_order = [child for child in parent if child.tag == "comp"]
|
||||||
|
new_order = list(new_order)
|
||||||
|
if new_order == old_order:
|
||||||
|
return False
|
||||||
|
if (len(new_order) != len(old_order)
|
||||||
|
or {id(c) for c in new_order} != {id(c) for c in old_order}):
|
||||||
|
return False
|
||||||
|
|
||||||
|
name = parent.get("name", "app")
|
||||||
|
if app_state.live_mode:
|
||||||
|
if not live_hooks.live_reorder_children(parent, new_order):
|
||||||
|
live_hooks.warn_failure("Reorder", f"{name} was not reordered on the device")
|
||||||
|
return False
|
||||||
|
|
||||||
|
undo.apply_child_order(parent, new_order)
|
||||||
|
command_class = (undo.LiveReorderChildrenCommand if app_state.live_mode
|
||||||
|
else undo.ReorderChildrenCommand)
|
||||||
|
undo.push(command_class(parent, old_order, new_order, label=f"reorder {name}"))
|
||||||
|
|
||||||
|
app_state.mark_dirty()
|
||||||
|
refresh_treeview_from_dom(root)
|
||||||
|
wiresheet.render_current_sheet()
|
||||||
|
|
||||||
|
where = " on the device" if app_state.live_mode else ""
|
||||||
|
app_state.set_status(f"{name} reordered{where}.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def allocate_component_id():
|
def allocate_component_id():
|
||||||
"""Lowest unused id, matching how this app's ids are dense with no gaps."""
|
"""Lowest unused id, matching how this app's ids are dense with no gaps."""
|
||||||
used = set()
|
used = set()
|
||||||
@@ -465,6 +554,23 @@ def unique_child_name(parent, base_name):
|
|||||||
return base_name[:MAX_NAME_LENGTH]
|
return base_name[:MAX_NAME_LENGTH]
|
||||||
|
|
||||||
|
|
||||||
|
def duplicate_name(name, taken):
|
||||||
|
"""A name for a copy of `name`: its stem with the next free number, Add1 -> Add2.
|
||||||
|
|
||||||
|
Counting on from a trailing number, rather than appending another, keeps a run of
|
||||||
|
copies reading Add1, Add2, Add3 instead of Add1, Add11, Add111.
|
||||||
|
"""
|
||||||
|
stem = name.rstrip("0123456789") or "c"
|
||||||
|
digits = name[len(stem):]
|
||||||
|
counter = int(digits) + 1 if digits else 1
|
||||||
|
while True:
|
||||||
|
suffix = str(counter)
|
||||||
|
candidate = stem[:max(1, MAX_NAME_LENGTH - len(suffix))] + suffix
|
||||||
|
if candidate not in taken:
|
||||||
|
return candidate
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
|
||||||
def current_sheet_container():
|
def current_sheet_container():
|
||||||
"""The element a newly placed component belongs to: the open folder, or the app."""
|
"""The element a newly placed component belongs to: the open folder, or the app."""
|
||||||
if app_state.xml_root_element is None:
|
if app_state.xml_root_element is None:
|
||||||
@@ -859,13 +965,15 @@ def reconcile_schema_kits_before_save():
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def build_config_values(full_type, overrides=None):
|
def build_config_values(full_type, overrides=None, device_values=None):
|
||||||
"""Builds SOX config_values list from manifest defaults for an add command.
|
"""Builds SOX config_values list from manifest defaults for an add command.
|
||||||
|
|
||||||
`overrides` maps slot name to the document-form value to send instead of the default.
|
`overrides` maps slot name to the document-form value to send instead of the default.
|
||||||
|
`device_values` maps slot name to a value as the device sent it, for putting back a
|
||||||
|
component exactly as it was; it wins over both.
|
||||||
Raises ValueError when a value does not fit its slot's type.
|
Raises ValueError when a value does not fit its slot's type.
|
||||||
"""
|
"""
|
||||||
from live_hooks import _coerce_value, _wire_type
|
from live_hooks import _coerce_value, _wire_type, wire_value
|
||||||
|
|
||||||
slots = app_state.final_components_registry.get(full_type, [])
|
slots = app_state.final_components_registry.get(full_type, [])
|
||||||
config_values = []
|
config_values = []
|
||||||
@@ -874,19 +982,51 @@ def build_config_values(full_type, overrides=None):
|
|||||||
continue
|
continue
|
||||||
if slot["type"] == "void":
|
if slot["type"] == "void":
|
||||||
continue
|
continue
|
||||||
|
tid = _wire_type(slot["type"], slot["facets"].get("asStr") is True)
|
||||||
|
if device_values and slot["name"] in device_values:
|
||||||
|
config_values.append((tid, wire_value(tid, device_values[slot["name"]])))
|
||||||
|
continue
|
||||||
val = (overrides or {}).get(slot["name"], slot["default"])
|
val = (overrides or {}).get(slot["name"], slot["default"])
|
||||||
if val is None:
|
if val is None:
|
||||||
val = properties.type_zero(slot["type"])
|
val = properties.type_zero(slot["type"])
|
||||||
if val is None:
|
if val is None:
|
||||||
continue
|
continue
|
||||||
tid = _wire_type(slot["type"], slot["facets"].get("asStr") is True)
|
|
||||||
config_values.append((tid, _coerce_value(tid, val)))
|
config_values.append((tid, _coerce_value(tid, val)))
|
||||||
return config_values
|
return config_values
|
||||||
|
|
||||||
|
|
||||||
def add_component(kit_name, type_name, grid_x, grid_y):
|
def grid_position_below_children(parent):
|
||||||
"""Places a new component of kit::type at a grid position. Returns the element."""
|
"""A spot for a component added without a drop point: under the parent's lowest child.
|
||||||
parent = current_sheet_container()
|
|
||||||
|
Used when a component is placed from the tree, where there is no point on the sheet
|
||||||
|
to drop it at. Lining it up on the left margin below everything already there keeps
|
||||||
|
it clear of the existing boxes in the common case; Tidy sorts out the rest.
|
||||||
|
"""
|
||||||
|
min_step = wiresheet.layout_margin(app_state.meta_scale) // app_state.meta_scale
|
||||||
|
lowest = None
|
||||||
|
for child in parent:
|
||||||
|
if child.tag != "comp":
|
||||||
|
continue
|
||||||
|
meta_prop = child.find("prop[@name='meta']")
|
||||||
|
y = read_meta_property(meta_prop.get("val") if meta_prop is not None else None)[1]
|
||||||
|
lowest = y if lowest is None else max(lowest, y)
|
||||||
|
|
||||||
|
if lowest is None:
|
||||||
|
return min_step, min_step
|
||||||
|
return min_step, max(min_step, min(wiresheet.META_COORD_MAX, lowest + TREE_DROP_GAP))
|
||||||
|
|
||||||
|
|
||||||
|
# Meta steps between the lowest existing box and one added under it from the tree.
|
||||||
|
TREE_DROP_GAP = 8
|
||||||
|
|
||||||
|
|
||||||
|
def add_component(kit_name, type_name, grid_x, grid_y, parent=None):
|
||||||
|
"""Places a new component of kit::type at a grid position. Returns the element.
|
||||||
|
|
||||||
|
parent is the component it goes inside; None means the sheet that is open.
|
||||||
|
"""
|
||||||
|
if parent is None:
|
||||||
|
parent = current_sheet_container()
|
||||||
if parent is None:
|
if parent is None:
|
||||||
messagebox.showwarning("Add Component", "Open a SAX file first.")
|
messagebox.showwarning("Add Component", "Open a SAX file first.")
|
||||||
return None
|
return None
|
||||||
@@ -969,7 +1109,12 @@ def add_component(kit_name, type_name, grid_x, grid_y):
|
|||||||
position_failed = not live_hooks.live_set_prop_value(
|
position_failed = not live_hooks.live_set_prop_value(
|
||||||
element, "meta", meta_prop.get("val", "0"))
|
element, "meta", meta_prop.get("val", "0"))
|
||||||
|
|
||||||
if not app_state.live_mode:
|
if app_state.live_mode:
|
||||||
|
undo.push(LiveComponentCommand(
|
||||||
|
parent, index, element, present=True,
|
||||||
|
label=f"add {comp_name}"
|
||||||
|
))
|
||||||
|
else:
|
||||||
undo.push(undo.AddComponentCommand(
|
undo.push(undo.AddComponentCommand(
|
||||||
parent, index, element,
|
parent, index, element,
|
||||||
label=f"add {comp_name}"
|
label=f"add {comp_name}"
|
||||||
@@ -1001,6 +1146,15 @@ def add_component(kit_name, type_name, grid_x, grid_y):
|
|||||||
|
|
||||||
def remove_link(link_element):
|
def remove_link(link_element):
|
||||||
"""Removes a <link> element from the <links> section and repaints."""
|
"""Removes a <link> element from the <links> section and repaints."""
|
||||||
|
remove_links([link_element])
|
||||||
|
|
||||||
|
|
||||||
|
def remove_links(link_elements):
|
||||||
|
"""Removes several <link> elements as one undoable step, and repaints.
|
||||||
|
|
||||||
|
On a device each link is unlinked there first; one the device refuses stays in the
|
||||||
|
DOM, and the rest still go, with the refusals named in a single warning.
|
||||||
|
"""
|
||||||
if app_state.xml_root_element is None:
|
if app_state.xml_root_element is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1008,34 +1162,51 @@ def remove_link(link_element):
|
|||||||
if links_elem is None:
|
if links_elem is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
from_ref = link_element.get("from", "")
|
commands = []
|
||||||
to_ref = link_element.get("to", "")
|
failures = []
|
||||||
|
for link_element in link_elements:
|
||||||
|
if link_element not in list(links_elem):
|
||||||
|
continue
|
||||||
|
from_ref = link_element.get("from", "")
|
||||||
|
to_ref = link_element.get("to", "")
|
||||||
|
|
||||||
if app_state.live_mode:
|
if app_state.live_mode:
|
||||||
from_comp_path, from_slot_name = from_ref.rsplit(".", 1) if "." in from_ref else ("", from_ref)
|
if not live_hooks.live_delete_link(*link_ends(from_ref, to_ref)):
|
||||||
to_comp_path, to_slot_name = to_ref.rsplit(".", 1) if "." in to_ref else ("", to_ref)
|
failures.append(f"{from_ref} → {to_ref}")
|
||||||
from_elem = find_component_by_path(app_state.xml_root_element, from_comp_path)
|
continue
|
||||||
to_elem = find_component_by_path(app_state.xml_root_element, to_comp_path)
|
|
||||||
if not live_hooks.live_delete_link(from_elem, from_slot_name, to_elem, to_slot_name):
|
|
||||||
live_hooks.warn_failure("Delete Link",
|
|
||||||
f"The link {from_ref} → {to_ref} was not removed from the device")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
index = list(links_elem).index(link_element)
|
index = list(links_elem).index(link_element)
|
||||||
links_elem.remove(link_element)
|
links_elem.remove(link_element)
|
||||||
except ValueError:
|
|
||||||
|
from_slot = from_ref.rsplit(".", 1)[-1] if "." in from_ref else from_ref
|
||||||
|
to_slot = to_ref.rsplit(".", 1)[-1] if "." in to_ref else to_ref
|
||||||
|
label = f"delete link {from_slot}→{to_slot}"
|
||||||
|
if app_state.live_mode:
|
||||||
|
commands.append(LiveLinkCommand(links_elem, index, link_element,
|
||||||
|
present=False, label=label))
|
||||||
|
else:
|
||||||
|
commands.append(undo.DeleteLinkCommand(links_elem, index, link_element,
|
||||||
|
label=label))
|
||||||
|
|
||||||
|
# Removed in order, each index taken after the ones before it went, so the composite
|
||||||
|
# puts them back last-first and every one lands where it was.
|
||||||
|
if len(commands) == 1:
|
||||||
|
undo.push(commands[0])
|
||||||
|
elif commands:
|
||||||
|
undo.push(undo.CompositeCommand(commands, label=f"delete {len(commands)} links"))
|
||||||
|
|
||||||
|
if failures:
|
||||||
|
noun = "link" if len(failures) == 1 else "links"
|
||||||
|
verb = "was" if len(failures) == 1 else "were"
|
||||||
|
live_hooks.warn_failure(
|
||||||
|
"Delete Link",
|
||||||
|
f"The {noun} {', '.join(failures)} {verb} not removed from the device")
|
||||||
|
|
||||||
|
if not commands:
|
||||||
return
|
return
|
||||||
|
|
||||||
from_slot = from_ref.rsplit(".", 1)[-1] if "." in from_ref else from_ref
|
|
||||||
to_slot = to_ref.rsplit(".", 1)[-1] if "." in to_ref else to_ref
|
|
||||||
if not app_state.live_mode:
|
|
||||||
undo.push(undo.DeleteLinkCommand(
|
|
||||||
links_elem, index, link_element,
|
|
||||||
label=f"delete link {from_slot}→{to_slot}"
|
|
||||||
))
|
|
||||||
|
|
||||||
app_state.selected_link_tag = None
|
app_state.selected_link_tag = None
|
||||||
|
app_state.selected_link_tags.clear()
|
||||||
app_state.mark_dirty()
|
app_state.mark_dirty()
|
||||||
wiresheet.render_current_sheet()
|
wiresheet.render_current_sheet()
|
||||||
|
|
||||||
@@ -1050,11 +1221,7 @@ def add_link(from_path_slot, to_path_slot):
|
|||||||
links_elem = ET.SubElement(app_state.xml_root_element, "links")
|
links_elem = ET.SubElement(app_state.xml_root_element, "links")
|
||||||
|
|
||||||
if app_state.live_mode:
|
if app_state.live_mode:
|
||||||
from_comp_path, from_slot_name = from_path_slot.rsplit(".", 1) if "." in from_path_slot else ("", from_path_slot)
|
if not live_hooks.live_add_link(*link_ends(from_path_slot, to_path_slot)):
|
||||||
to_comp_path, to_slot_name = to_path_slot.rsplit(".", 1) if "." in to_path_slot else ("", to_path_slot)
|
|
||||||
from_elem = find_component_by_path(app_state.xml_root_element, from_comp_path)
|
|
||||||
to_elem = find_component_by_path(app_state.xml_root_element, to_comp_path)
|
|
||||||
if not live_hooks.live_add_link(from_elem, from_slot_name, to_elem, to_slot_name):
|
|
||||||
live_hooks.warn_failure("Add Link",
|
live_hooks.warn_failure("Add Link",
|
||||||
f"The link {from_path_slot} → {to_path_slot} was not made on the device")
|
f"The link {from_path_slot} → {to_path_slot} was not made on the device")
|
||||||
return
|
return
|
||||||
@@ -1065,7 +1232,12 @@ def add_link(from_path_slot, to_path_slot):
|
|||||||
|
|
||||||
from_slot = from_path_slot.rsplit(".", 1)[-1] if "." in from_path_slot else from_path_slot
|
from_slot = from_path_slot.rsplit(".", 1)[-1] if "." in from_path_slot else from_path_slot
|
||||||
to_slot = to_path_slot.rsplit(".", 1)[-1] if "." in to_path_slot else to_path_slot
|
to_slot = to_path_slot.rsplit(".", 1)[-1] if "." in to_path_slot else to_path_slot
|
||||||
if not app_state.live_mode:
|
if app_state.live_mode:
|
||||||
|
undo.push(LiveLinkCommand(
|
||||||
|
links_elem, list(links_elem).index(link_elem), link_elem, present=True,
|
||||||
|
label=f"add link {from_slot}→{to_slot}"
|
||||||
|
))
|
||||||
|
else:
|
||||||
undo.push(undo.AddLinkCommand(
|
undo.push(undo.AddLinkCommand(
|
||||||
links_elem, link_elem,
|
links_elem, link_elem,
|
||||||
label=f"add link {from_slot}→{to_slot}"
|
label=f"add link {from_slot}→{to_slot}"
|
||||||
@@ -1075,6 +1247,239 @@ def add_link(from_path_slot, to_path_slot):
|
|||||||
wiresheet.render_current_sheet()
|
wiresheet.render_current_sheet()
|
||||||
|
|
||||||
|
|
||||||
|
# Meta steps a copy sits right of and below its original, (x, y).
|
||||||
|
DUPLICATE_OFFSET = (1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _duplicate_tops(elements):
|
||||||
|
"""The components to copy, in document order, without any held by another of them.
|
||||||
|
|
||||||
|
A component inside one that is copied already comes along with it; copying it again
|
||||||
|
would put a second copy next to the original.
|
||||||
|
"""
|
||||||
|
root = app_state.xml_root_element
|
||||||
|
order = {element: i for i, element in enumerate(root.iter())}
|
||||||
|
chosen = []
|
||||||
|
for element in elements:
|
||||||
|
# The app cannot be copied, and a "..." stand-in is not a component at all.
|
||||||
|
if element.tag != "comp" or not element.get("type") or element not in order:
|
||||||
|
continue
|
||||||
|
if element not in chosen:
|
||||||
|
chosen.append(element)
|
||||||
|
tops = [e for e in chosen
|
||||||
|
if not any(other is not e and e in other.iter() for other in chosen)]
|
||||||
|
return sorted(tops, key=order.get)
|
||||||
|
|
||||||
|
|
||||||
|
def _meta_val(element):
|
||||||
|
meta_prop = element.find("prop[@name='meta']")
|
||||||
|
return meta_prop.get("val") if meta_prop is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def _moved_meta(meta_str, dx, dy):
|
||||||
|
"""meta moved by (dx, dy) steps, clamped to the sheet, as an unsigned int."""
|
||||||
|
x, y = read_meta_property(meta_str)[:2]
|
||||||
|
scratch = ET.Element("comp")
|
||||||
|
update_meta_property(scratch, meta_str,
|
||||||
|
min(wiresheet.META_COORD_MAX, x + dx),
|
||||||
|
min(wiresheet.META_COORD_MAX, y + dy))
|
||||||
|
return int(scratch.find("prop").get("val"))
|
||||||
|
|
||||||
|
|
||||||
|
def _ref_inside(ref, path):
|
||||||
|
return ref.startswith(path + ".") or ref.startswith(path + "/")
|
||||||
|
|
||||||
|
|
||||||
|
def duplicate_components(elements):
|
||||||
|
"""Copies components, with whatever they hold, next to the originals.
|
||||||
|
|
||||||
|
Links running between the copied components are copied too, so a group of linked
|
||||||
|
blocks comes out as a second working group. Links to anything left behind are not:
|
||||||
|
the copy would otherwise drive, or be driven by, the original's neighbours. Each
|
||||||
|
copy is named after its original with the next free number, and the whole duplicate
|
||||||
|
is one undo step. Returns the new top elements.
|
||||||
|
"""
|
||||||
|
root = app_state.xml_root_element
|
||||||
|
if root is None:
|
||||||
|
return []
|
||||||
|
tops = _duplicate_tops(elements)
|
||||||
|
if not tops:
|
||||||
|
return []
|
||||||
|
|
||||||
|
parents = [find_parent_element(root, e) for e in tops]
|
||||||
|
taken = {}
|
||||||
|
names = []
|
||||||
|
for parent, element in zip(parents, tops):
|
||||||
|
if id(parent) not in taken:
|
||||||
|
taken[id(parent)] = {c.get("name") for c in parent if c.tag == "comp"}
|
||||||
|
name = duplicate_name(element.get("name", "c"), taken[id(parent)])
|
||||||
|
taken[id(parent)].add(name)
|
||||||
|
names.append(name)
|
||||||
|
dx, dy = DUPLICATE_OFFSET
|
||||||
|
|
||||||
|
if app_state.live_mode:
|
||||||
|
copies = _duplicate_on_device(tops, parents, names, dx, dy)
|
||||||
|
else:
|
||||||
|
copies = _duplicate_in_file(tops, parents, names, dx, dy)
|
||||||
|
if not copies:
|
||||||
|
return []
|
||||||
|
|
||||||
|
app_state.mark_dirty()
|
||||||
|
refresh_treeview_from_dom(root)
|
||||||
|
wiresheet.render_current_sheet()
|
||||||
|
return copies
|
||||||
|
|
||||||
|
|
||||||
|
def _duplicate_label(tops):
|
||||||
|
if len(tops) == 1:
|
||||||
|
return f"duplicate {tops[0].get('name', '?')}"
|
||||||
|
return f"duplicate {len(tops)} components"
|
||||||
|
|
||||||
|
|
||||||
|
def _duplicate_in_file(tops, parents, names, dx, dy):
|
||||||
|
root = app_state.xml_root_element
|
||||||
|
path_map = []
|
||||||
|
commands = []
|
||||||
|
copies = []
|
||||||
|
for parent, element, name in zip(parents, tops, names):
|
||||||
|
old_path = get_component_path(element, root)
|
||||||
|
copy_elem = copy.deepcopy(element)
|
||||||
|
copy_elem.set("name", name)
|
||||||
|
x, y = read_meta_property(_meta_val(element))[:2]
|
||||||
|
update_meta_property(copy_elem, _meta_val(copy_elem),
|
||||||
|
min(wiresheet.META_COORD_MAX, x + dx),
|
||||||
|
min(wiresheet.META_COORD_MAX, y + dy))
|
||||||
|
index = len(list(parent))
|
||||||
|
parent.append(copy_elem)
|
||||||
|
# In the document first, so each new id is checked against the ones before it.
|
||||||
|
# The id a copy still carries is its original's, which stays taken either way.
|
||||||
|
for comp in copy_elem.iter("comp"):
|
||||||
|
comp.set("id", str(allocate_component_id()))
|
||||||
|
path_map.append((old_path, get_component_path(copy_elem, root)))
|
||||||
|
commands.append(undo.AddComponentCommand(parent, index, copy_elem))
|
||||||
|
copies.append(copy_elem)
|
||||||
|
|
||||||
|
def mapped(ref):
|
||||||
|
for old_path, new_path in path_map:
|
||||||
|
if _ref_inside(ref, old_path):
|
||||||
|
return new_path + ref[len(old_path):]
|
||||||
|
return None
|
||||||
|
|
||||||
|
links_elem = root.find("links")
|
||||||
|
if links_elem is not None:
|
||||||
|
for link in list(links_elem):
|
||||||
|
if link.tag != "link":
|
||||||
|
continue
|
||||||
|
new_from = mapped(link.get("from", ""))
|
||||||
|
new_to = mapped(link.get("to", ""))
|
||||||
|
if new_from is None or new_to is None:
|
||||||
|
continue
|
||||||
|
new_link = ET.SubElement(links_elem, "link")
|
||||||
|
new_link.set("from", new_from)
|
||||||
|
new_link.set("to", new_to)
|
||||||
|
commands.append(undo.AddLinkCommand(links_elem, new_link))
|
||||||
|
|
||||||
|
undo.push(undo.CompositeCommand(commands, label=_duplicate_label(tops)))
|
||||||
|
link_count = len(commands) - len(copies)
|
||||||
|
app_state.set_status(f"Duplicated as {', '.join(names)}"
|
||||||
|
+ (f", with {link_count} link(s)." if link_count else "."))
|
||||||
|
return copies
|
||||||
|
|
||||||
|
|
||||||
|
def _duplicate_on_device(tops, parents, names, dx, dy):
|
||||||
|
"""The device makes the copies from what it holds, not from the DOM.
|
||||||
|
|
||||||
|
The DOM only knows what has been opened: a folder's children may be "..." stand-ins
|
||||||
|
with no config of their own. Each original is read back in full, as a delete reads
|
||||||
|
it to be undone, and the copies are added from that.
|
||||||
|
"""
|
||||||
|
snapshots = []
|
||||||
|
for element in tops:
|
||||||
|
snapshot = live_hooks.read_subtree(element)
|
||||||
|
if snapshot is None:
|
||||||
|
live_hooks.warn_failure("Duplicate",
|
||||||
|
f"{element.get('name', '?')} could not be read from the device")
|
||||||
|
return []
|
||||||
|
snapshots.append(snapshot)
|
||||||
|
|
||||||
|
copied_ids = set()
|
||||||
|
|
||||||
|
def collect(node):
|
||||||
|
copied_ids.add(node["id"])
|
||||||
|
for child in node["children"]:
|
||||||
|
collect(child)
|
||||||
|
|
||||||
|
for snapshot in snapshots:
|
||||||
|
collect(snapshot["tree"])
|
||||||
|
|
||||||
|
links = []
|
||||||
|
for snapshot in snapshots:
|
||||||
|
for from_end, from_slot, to_end, to_slot in snapshot["links"]:
|
||||||
|
ends = (live_hooks._link_end_id(from_end), from_slot,
|
||||||
|
live_hooks._link_end_id(to_end), to_slot)
|
||||||
|
if ends[0] in copied_ids and ends[2] in copied_ids and ends not in links:
|
||||||
|
links.append(ends)
|
||||||
|
|
||||||
|
plans = []
|
||||||
|
for parent, snapshot, name in zip(parents, snapshots, names):
|
||||||
|
meta = snapshot["tree"]["values"].get("meta")
|
||||||
|
new_meta = None
|
||||||
|
if meta is not None:
|
||||||
|
new_meta = _moved_meta(str(meta), dx, dy)
|
||||||
|
if new_meta >= 1 << 31:
|
||||||
|
new_meta -= 1 << 32 # an int slot, as the device sent it: signed
|
||||||
|
plans.append((parent, snapshot["tree"], name, new_meta))
|
||||||
|
|
||||||
|
created, new_ids, complete = live_hooks.duplicate_subtrees(plans, links,
|
||||||
|
build_config_values)
|
||||||
|
if not created:
|
||||||
|
live_hooks.warn_failure("Duplicate", "Nothing was duplicated on the device")
|
||||||
|
return []
|
||||||
|
|
||||||
|
ds = app_state.device_session
|
||||||
|
client = ds.sox_client
|
||||||
|
commands = []
|
||||||
|
copies = []
|
||||||
|
for parent, new_id in created:
|
||||||
|
comp = client.cache_get(new_id)
|
||||||
|
if comp is None:
|
||||||
|
continue
|
||||||
|
copy_elem = ds._build_comp_element(parent, comp)
|
||||||
|
commands.append(LiveComponentCommand(
|
||||||
|
parent, list(parent).index(copy_elem), copy_elem, present=True))
|
||||||
|
copies.append(copy_elem)
|
||||||
|
|
||||||
|
root = app_state.xml_root_element
|
||||||
|
links_elem = root.find("links")
|
||||||
|
if links_elem is None:
|
||||||
|
links_elem = ET.SubElement(root, "links")
|
||||||
|
if complete:
|
||||||
|
for from_id, from_slot, to_id, to_slot in links:
|
||||||
|
from_comp = client.cache_get(new_ids[from_id])
|
||||||
|
to_comp = client.cache_get(new_ids[to_id])
|
||||||
|
if from_comp is None or to_comp is None:
|
||||||
|
continue
|
||||||
|
from_name = ds._slot_name_by_id(from_comp.kit_id, from_comp.type_id, from_slot)
|
||||||
|
to_name = ds._slot_name_by_id(to_comp.kit_id, to_comp.type_id, to_slot)
|
||||||
|
from_path = ds._comp_path(from_comp.id)
|
||||||
|
to_path = ds._comp_path(to_comp.id)
|
||||||
|
if from_name and to_name and from_path and to_path:
|
||||||
|
ET.SubElement(links_elem, "link",
|
||||||
|
**{"from": f"{from_path}.{from_name}",
|
||||||
|
"to": f"{to_path}.{to_name}"})
|
||||||
|
|
||||||
|
# Deleting a copy from the device takes its links with it, so the copies alone
|
||||||
|
# undo the whole duplicate.
|
||||||
|
undo.push(undo.CompositeCommand(commands, label=_duplicate_label(tops)))
|
||||||
|
|
||||||
|
if not complete:
|
||||||
|
live_hooks.warn_failure("Duplicate", "The duplicate did not fully reach the device")
|
||||||
|
else:
|
||||||
|
app_state.set_status(f"Duplicated as {', '.join(names)} on the device"
|
||||||
|
+ (f", with {len(links)} link(s)." if links else "."))
|
||||||
|
return copies
|
||||||
|
|
||||||
|
|
||||||
def execute_cascading_deletion(comp_element):
|
def execute_cascading_deletion(comp_element):
|
||||||
"""Shared application logic to wipe an element, its children, and related links from DOM and runtime lists."""
|
"""Shared application logic to wipe an element, its children, and related links from DOM and runtime lists."""
|
||||||
comp_name = comp_element.get("name", "Unknown")
|
comp_name = comp_element.get("name", "Unknown")
|
||||||
@@ -1091,37 +1496,21 @@ def execute_cascading_deletion(comp_element):
|
|||||||
if not confirm:
|
if not confirm:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
snapshot = None
|
||||||
if app_state.live_mode:
|
if app_state.live_mode:
|
||||||
|
# Read before it goes: once deleted, the device has nothing left to read back.
|
||||||
|
snapshot = live_hooks.read_subtree(comp_element)
|
||||||
|
unreadable = live_hooks.last_error if snapshot is None else ""
|
||||||
if not live_hooks.live_delete_component(comp_element):
|
if not live_hooks.live_delete_component(comp_element):
|
||||||
live_hooks.warn_failure("Delete Component", f"{comp_name} was not deleted from the device")
|
live_hooks.warn_failure("Delete Component", f"{comp_name} was not deleted from the device")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
comp_path = get_component_path(comp_element, app_state.xml_root_element)
|
|
||||||
|
|
||||||
all_elements_to_prune = [comp_element] + descendant_comps
|
all_elements_to_prune = [comp_element] + descendant_comps
|
||||||
for elem in all_elements_to_prune:
|
for elem in all_elements_to_prune:
|
||||||
if elem in app_state.active_components_list:
|
if elem in app_state.active_components_list:
|
||||||
app_state.active_components_list.remove(elem)
|
app_state.active_components_list.remove(elem)
|
||||||
|
|
||||||
# Positions are recorded before anything is removed, so the delete can be reversed.
|
link_records = detach_links_touching(comp_element)
|
||||||
link_records = []
|
|
||||||
if app_state.xml_root_element is not None:
|
|
||||||
links_elem = app_state.xml_root_element.find("links")
|
|
||||||
if links_elem is not None:
|
|
||||||
for index, link in enumerate(list(links_elem)):
|
|
||||||
if link.tag != "link":
|
|
||||||
continue
|
|
||||||
l_from = link.get("from", "")
|
|
||||||
l_to = link.get("to", "")
|
|
||||||
|
|
||||||
from_match = l_from.startswith(comp_path + ".") or l_from.startswith(comp_path + "/")
|
|
||||||
to_match = l_to.startswith(comp_path + ".") or l_to.startswith(comp_path + "/")
|
|
||||||
|
|
||||||
if from_match or to_match:
|
|
||||||
link_records.append((links_elem, index, link))
|
|
||||||
|
|
||||||
for links_parent, _, link in link_records:
|
|
||||||
links_parent.remove(link)
|
|
||||||
|
|
||||||
xml_parent = None
|
xml_parent = None
|
||||||
comp_index = 0
|
comp_index = 0
|
||||||
@@ -1131,7 +1520,18 @@ def execute_cascading_deletion(comp_element):
|
|||||||
comp_index = list(xml_parent).index(comp_element)
|
comp_index = list(xml_parent).index(comp_element)
|
||||||
xml_parent.remove(comp_element)
|
xml_parent.remove(comp_element)
|
||||||
|
|
||||||
if xml_parent is not None and not app_state.live_mode:
|
if xml_parent is not None and app_state.live_mode:
|
||||||
|
if snapshot is not None:
|
||||||
|
undo.push(LiveComponentCommand(
|
||||||
|
xml_parent, comp_index, comp_element, present=False,
|
||||||
|
snapshot=snapshot, link_records=link_records,
|
||||||
|
label=f"delete {comp_name}"
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
app_state.set_status(
|
||||||
|
f"{comp_name} was deleted from the device, but this delete cannot be "
|
||||||
|
f"undone: {unreadable}", warn=True)
|
||||||
|
elif xml_parent is not None:
|
||||||
undo.push(undo.DeleteComponentCommand(
|
undo.push(undo.DeleteComponentCommand(
|
||||||
xml_parent, comp_index, comp_element, link_records,
|
xml_parent, comp_index, comp_element, link_records,
|
||||||
label=f"delete {comp_name}"
|
label=f"delete {comp_name}"
|
||||||
@@ -1140,6 +1540,7 @@ def execute_cascading_deletion(comp_element):
|
|||||||
target_canvas_tag = app_state.canvas_tag_by_element.get(comp_element)
|
target_canvas_tag = app_state.canvas_tag_by_element.get(comp_element)
|
||||||
if target_canvas_tag and target_canvas_tag == app_state.selected_canvas_tag:
|
if target_canvas_tag and target_canvas_tag == app_state.selected_canvas_tag:
|
||||||
app_state.selected_canvas_tag = None
|
app_state.selected_canvas_tag = None
|
||||||
|
app_state.selected_canvas_tags.discard(target_canvas_tag)
|
||||||
|
|
||||||
if target_canvas_tag:
|
if target_canvas_tag:
|
||||||
app_state.canvas.delete(target_canvas_tag)
|
app_state.canvas.delete(target_canvas_tag)
|
||||||
@@ -1149,3 +1550,156 @@ def execute_cascading_deletion(comp_element):
|
|||||||
refresh_treeview_from_dom(app_state.xml_root_element)
|
refresh_treeview_from_dom(app_state.xml_root_element)
|
||||||
wiresheet.render_components(app_state.active_components_list)
|
wiresheet.render_components(app_state.active_components_list)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def link_ends(from_ref, to_ref):
|
||||||
|
"""(from_elem, from_slot, to_elem, to_slot) for a link's "path.slot" ends."""
|
||||||
|
from_path, from_slot = from_ref.rsplit(".", 1) if "." in from_ref else ("", from_ref)
|
||||||
|
to_path, to_slot = to_ref.rsplit(".", 1) if "." in to_ref else ("", to_ref)
|
||||||
|
root = app_state.xml_root_element
|
||||||
|
return (find_component_by_path(root, from_path), from_slot,
|
||||||
|
find_component_by_path(root, to_path), to_slot)
|
||||||
|
|
||||||
|
|
||||||
|
def detach_links_touching(comp_element):
|
||||||
|
"""Takes every <link> into or out of `comp_element`'s subtree out of the DOM.
|
||||||
|
|
||||||
|
Returns (links_parent, index, link) records. Positions are recorded before anything
|
||||||
|
is removed, so putting them back in ascending order lands each on its old index.
|
||||||
|
"""
|
||||||
|
root = app_state.xml_root_element
|
||||||
|
if root is None:
|
||||||
|
return []
|
||||||
|
links_elem = root.find("links")
|
||||||
|
if links_elem is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
comp_path = get_component_path(comp_element, root)
|
||||||
|
link_records = []
|
||||||
|
for index, link in enumerate(list(links_elem)):
|
||||||
|
if link.tag != "link":
|
||||||
|
continue
|
||||||
|
l_from = link.get("from", "")
|
||||||
|
l_to = link.get("to", "")
|
||||||
|
from_match = l_from.startswith(comp_path + ".") or l_from.startswith(comp_path + "/")
|
||||||
|
to_match = l_to.startswith(comp_path + ".") or l_to.startswith(comp_path + "/")
|
||||||
|
if from_match or to_match:
|
||||||
|
link_records.append((links_elem, index, link))
|
||||||
|
|
||||||
|
for links_parent, _, link in link_records:
|
||||||
|
links_parent.remove(link)
|
||||||
|
return link_records
|
||||||
|
|
||||||
|
|
||||||
|
class LiveComponentCommand:
|
||||||
|
"""A component added to or deleted from the device, undone and redone through Sox.
|
||||||
|
|
||||||
|
Taking it away reads its whole subtree back first (config, children, links) and
|
||||||
|
then deletes it; putting it back adds all of that again. The device gives every
|
||||||
|
component a new id when it comes back, and the DOM elements take those ids on, so
|
||||||
|
later commands holding the same elements still reach the right components.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, parent, index, element, present, snapshot=None, link_records=(),
|
||||||
|
label="add"):
|
||||||
|
# present: whether the component is on the device now. snapshot: from
|
||||||
|
# live_hooks.read_subtree, taken when it was last taken away.
|
||||||
|
self.parent = parent
|
||||||
|
self.index = index
|
||||||
|
self.element = element
|
||||||
|
self.present = present
|
||||||
|
self.snapshot = snapshot
|
||||||
|
self.link_records = list(link_records)
|
||||||
|
self.label = label
|
||||||
|
|
||||||
|
def undo(self):
|
||||||
|
self._flip()
|
||||||
|
|
||||||
|
def redo(self):
|
||||||
|
self._flip()
|
||||||
|
|
||||||
|
def _flip(self):
|
||||||
|
if self.present:
|
||||||
|
self._take_away()
|
||||||
|
else:
|
||||||
|
self._put_back()
|
||||||
|
|
||||||
|
def _take_away(self):
|
||||||
|
name = self.element.get("name", "?")
|
||||||
|
snapshot = live_hooks.read_subtree(self.element)
|
||||||
|
if snapshot is None:
|
||||||
|
raise undo.LiveUndoError(f"{name} was not deleted from the device")
|
||||||
|
if not live_hooks.live_delete_component(self.element):
|
||||||
|
raise undo.LiveUndoError(f"{name} was not deleted from the device")
|
||||||
|
|
||||||
|
self.snapshot = snapshot
|
||||||
|
self.present = False
|
||||||
|
self.link_records = detach_links_touching(self.element)
|
||||||
|
if self.element in list(self.parent):
|
||||||
|
self.index = list(self.parent).index(self.element)
|
||||||
|
self.parent.remove(self.element)
|
||||||
|
|
||||||
|
def _put_back(self):
|
||||||
|
name = self.element.get("name", "?")
|
||||||
|
created, complete = live_hooks.recreate_subtree(
|
||||||
|
self.parent, self.snapshot, build_config_values)
|
||||||
|
if not created:
|
||||||
|
raise undo.LiveUndoError(f"{name} was not put back on the device")
|
||||||
|
|
||||||
|
self.present = True
|
||||||
|
if self.element not in list(self.parent):
|
||||||
|
self.parent.insert(self.index, self.element)
|
||||||
|
for links_parent, index, link in sorted(self.link_records, key=lambda r: r[1]):
|
||||||
|
if link not in list(links_parent):
|
||||||
|
links_parent.insert(index, link)
|
||||||
|
|
||||||
|
ds = app_state.device_session
|
||||||
|
if ds is not None:
|
||||||
|
ds.update_element_from_cache(live_hooks._comp_id(self.element),
|
||||||
|
app_state.xml_root_element)
|
||||||
|
if not complete:
|
||||||
|
raise undo.LiveUndoError(f"{name} was not put back on the device in full")
|
||||||
|
|
||||||
|
|
||||||
|
class LiveLinkCommand:
|
||||||
|
"""A link made or removed on the device, undone and redone through Sox."""
|
||||||
|
|
||||||
|
def __init__(self, links_parent, index, link_element, present, label="add link"):
|
||||||
|
self.links_parent = links_parent
|
||||||
|
self.index = index
|
||||||
|
self.link_element = link_element
|
||||||
|
self.present = present
|
||||||
|
self.label = label
|
||||||
|
|
||||||
|
def undo(self):
|
||||||
|
self._flip()
|
||||||
|
|
||||||
|
def redo(self):
|
||||||
|
self._flip()
|
||||||
|
|
||||||
|
def _flip(self):
|
||||||
|
if self.present:
|
||||||
|
self._unlink()
|
||||||
|
else:
|
||||||
|
self._link()
|
||||||
|
|
||||||
|
def _describe(self):
|
||||||
|
return f"{self.link_element.get('from', '')} → {self.link_element.get('to', '')}"
|
||||||
|
|
||||||
|
def _ends(self):
|
||||||
|
return link_ends(self.link_element.get("from", ""), self.link_element.get("to", ""))
|
||||||
|
|
||||||
|
def _unlink(self):
|
||||||
|
if not live_hooks.live_delete_link(*self._ends()):
|
||||||
|
raise undo.LiveUndoError(f"The link {self._describe()} was not removed from the device")
|
||||||
|
self.present = False
|
||||||
|
if self.link_element in list(self.links_parent):
|
||||||
|
self.index = list(self.links_parent).index(self.link_element)
|
||||||
|
self.links_parent.remove(self.link_element)
|
||||||
|
|
||||||
|
def _link(self):
|
||||||
|
if not live_hooks.live_add_link(*self._ends()):
|
||||||
|
raise undo.LiveUndoError(f"The link {self._describe()} was not made on the device")
|
||||||
|
self.present = True
|
||||||
|
if self.link_element not in list(self.links_parent):
|
||||||
|
self.links_parent.insert(self.index, self.link_element)
|
||||||
|
|||||||
@@ -353,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))
|
||||||
|
|||||||
@@ -35,18 +35,6 @@ BUF_ID = 7
|
|||||||
STR_ID = 8
|
STR_ID = 8
|
||||||
|
|
||||||
|
|
||||||
# DASP resend intervals (seconds) for requests a device can be slow to take, as Ontrol's
|
|
||||||
# Niagara driver sets them: a P-ION saving a large app takes 2-3+ s, and a file open can
|
|
||||||
# take 10+ s on some devices. Resent every second instead, the device is sent copies
|
|
||||||
# while it is still busy with the first.
|
|
||||||
SAVE_SEND_RETRY = 5.0
|
|
||||||
FILE_OPEN_SEND_RETRY = 15.0
|
|
||||||
|
|
||||||
# The DASP packet sizes Ontrol's Niagara driver asks for in its hello.
|
|
||||||
IDEAL_MAX = 256
|
|
||||||
ABS_MAX = 256
|
|
||||||
|
|
||||||
|
|
||||||
class SoxException(Exception):
|
class SoxException(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -146,7 +134,9 @@ class SoxMsg:
|
|||||||
|
|
||||||
def write_i4(self, v):
|
def write_i4(self, v):
|
||||||
self._grow(4)
|
self._grow(4)
|
||||||
struct.pack_into(">i", self._buf, self._pos, v)
|
# Wrapped to signed: meta packs x into the top byte, so x >= 128 reads as an
|
||||||
|
# unsigned 32-bit value although the device stores the same bits as sys::int.
|
||||||
|
struct.pack_into(">i", self._buf, self._pos, ((v + 2**31) % 2**32) - 2**31)
|
||||||
self._pos += 4
|
self._pos += 4
|
||||||
|
|
||||||
def write_i8(self, v):
|
def write_i8(self, v):
|
||||||
@@ -396,7 +386,7 @@ class SoxClient:
|
|||||||
# file transfer: one at a time; its chunks arrive unasked, so the receiver
|
# file transfer: one at a time; its chunks arrive unasked, so the receiver
|
||||||
# hands them to whichever transfer is open
|
# hands them to whichever transfer is open
|
||||||
self._xfer_lock = threading.Lock()
|
self._xfer_lock = threading.Lock()
|
||||||
self._xfer = None # _FileGet while a get is open
|
self._xfer = None # _FileGet or _FilePut while a transfer is open
|
||||||
|
|
||||||
# ── Connect / Disconnect ────────────────────────────────────
|
# ── Connect / Disconnect ────────────────────────────────────
|
||||||
|
|
||||||
@@ -476,8 +466,9 @@ class SoxClient:
|
|||||||
# Same 512-byte limit as read_schema: whatever is cut off is left
|
# Same 512-byte limit as read_schema: whatever is cut off is left
|
||||||
# blank rather than failing the connect.
|
# blank rather than failing the connect.
|
||||||
platform_id = res.try_str() or ""
|
platform_id = res.try_str() or ""
|
||||||
# Ontrol's message-length header is for Orion only; the Niagara driver never
|
# Ontrol's message-length header is for Orion only, as in the Niagara driver.
|
||||||
# sends it to a P-ION or R-ION.
|
# A P-ION on firmware 1.32 takes it on most requests, but a file open sent with
|
||||||
|
# it goes unanswered and the device drops off the network for ~20 s.
|
||||||
if self._session is not None:
|
if self._session is not None:
|
||||||
self._session.include_message_len = platform_id.startswith("ontrol-orion")
|
self._session.include_message_len = platform_id.startswith("ontrol-orion")
|
||||||
scode_flags = res.u1() if res.remaining > 0 else 0
|
scode_flags = res.u1() if res.remaining > 0 else 0
|
||||||
@@ -678,13 +669,13 @@ class SoxClient:
|
|||||||
|
|
||||||
# ── Invoke Action ───────────────────────────────────────────
|
# ── Invoke Action ───────────────────────────────────────────
|
||||||
|
|
||||||
def invoke(self, comp_id, slot_id, type_id=None, arg=None, send_retry=None):
|
def invoke(self, comp_id, slot_id, type_id=None, arg=None, timeout=30.0):
|
||||||
req = _prepare_request(ord('i'))
|
req = _prepare_request(ord('i'))
|
||||||
req.write_u2(comp_id)
|
req.write_u2(comp_id)
|
||||||
req.write_u1(slot_id)
|
req.write_u1(slot_id)
|
||||||
if arg is not None and type_id is not None:
|
if arg is not None and type_id is not None:
|
||||||
encode_value(req, type_id, arg)
|
encode_value(req, type_id, arg)
|
||||||
res = self.request(req, send_retry)
|
res = self.request(req, timeout)
|
||||||
res.check_response('I')
|
res.check_response('I')
|
||||||
|
|
||||||
# ── Add Component ───────────────────────────────────────────
|
# ── Add Component ───────────────────────────────────────────
|
||||||
@@ -950,7 +941,7 @@ class SoxClient:
|
|||||||
opened = closed = False
|
opened = closed = False
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
res = self.request(req, FILE_OPEN_SEND_RETRY)
|
res = self.request(req)
|
||||||
except SoxException as e:
|
except SoxException as e:
|
||||||
# A P-ION on firmware 1.32 never answers a file get -- it drops off
|
# A P-ION on firmware 1.32 never answers a file get -- it drops off
|
||||||
# the network for ~20 s, as a restart would -- even for a file it
|
# the network for ~20 s, as a restart would -- even for a file it
|
||||||
@@ -1000,23 +991,90 @@ class SoxClient:
|
|||||||
progress(file_size, file_size)
|
progress(file_size, file_size)
|
||||||
return bytes(data)
|
return bytes(data)
|
||||||
|
|
||||||
|
def put_file(self, uri, data, progress=None):
|
||||||
|
"""Writes `data` to the device as `uri`.
|
||||||
|
|
||||||
|
Ported from FileTransfer.putFile, as Ontrol's BSoxFilePut does it: an 'f'
|
||||||
|
request opens the transfer, the chunks go out as 'k' messages -- the DASP send
|
||||||
|
window paces them -- and the device answers the last with an unsolicited 'z'
|
||||||
|
once the file is closed. progress(done_bytes, total_bytes) is called from this
|
||||||
|
thread.
|
||||||
|
"""
|
||||||
|
if self.is_closed:
|
||||||
|
raise SoxException("SoxClient closed")
|
||||||
|
with self._xfer_lock:
|
||||||
|
# 21, not FileTransfer's 18: room for Orion's message-length header too.
|
||||||
|
chunk_size = max(16, self._session.ideal_max - 21)
|
||||||
|
req = _prepare_request(ord('f'))
|
||||||
|
req.write_str("p")
|
||||||
|
req.write_str(uri)
|
||||||
|
req.write_i4(len(data))
|
||||||
|
req.write_u2(chunk_size)
|
||||||
|
req.write_u1(0) # end of headers
|
||||||
|
|
||||||
|
xfer = _FilePut()
|
||||||
|
self._xfer = xfer
|
||||||
|
opened = False
|
||||||
|
try:
|
||||||
|
res = self.request(req)
|
||||||
|
res.check_response('F')
|
||||||
|
opened = True
|
||||||
|
file_size = res.i4()
|
||||||
|
chunk_size = res.u2()
|
||||||
|
if file_size != len(data) or chunk_size <= 0:
|
||||||
|
raise SoxException(
|
||||||
|
f"The device opened {uri} for {file_size} bytes in chunks of "
|
||||||
|
f"{chunk_size}; {len(data)} bytes were offered")
|
||||||
|
num_chunks = max(1, -(-file_size // chunk_size))
|
||||||
|
for num in range(num_chunks):
|
||||||
|
if self.is_closed:
|
||||||
|
raise SoxException(f"Session closed while writing {uri}")
|
||||||
|
chunk = data[num * chunk_size:(num + 1) * chunk_size]
|
||||||
|
msg = _prepare_request(ord('k'), 0)
|
||||||
|
msg.write_u2(num)
|
||||||
|
msg.write_u2(len(chunk))
|
||||||
|
msg.write_bytes(chunk)
|
||||||
|
self._session.send(msg.to_bytes())
|
||||||
|
if progress:
|
||||||
|
progress(min(file_size, (num + 1) * chunk_size), file_size)
|
||||||
|
|
||||||
|
timeout = getattr(self._session, "receive_timeout", 30.0) or 30.0
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while not xfer.closed.wait(0.5):
|
||||||
|
if self.is_closed:
|
||||||
|
raise SoxException(f"Session closed while writing {uri}")
|
||||||
|
if time.monotonic() > deadline:
|
||||||
|
raise SoxException(f"Timed out waiting for the device to close {uri}")
|
||||||
|
opened = False
|
||||||
|
finally:
|
||||||
|
self._xfer = None
|
||||||
|
# Left open, the transfer keeps the device answering "busy" to the next.
|
||||||
|
if opened and not self.is_closed:
|
||||||
|
try:
|
||||||
|
self.request(_prepare_request(ord('z')))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def rename_file(self, from_uri, to_uri):
|
||||||
|
"""Renames a file on the device ('b'), as SoxClient.renameFile."""
|
||||||
|
req = _prepare_request(ord('b'))
|
||||||
|
req.write_str(from_uri)
|
||||||
|
req.write_str(to_uri)
|
||||||
|
self.request(req).check_response('B')
|
||||||
|
|
||||||
# ── Networking ──────────────────────────────────────────────
|
# ── Networking ──────────────────────────────────────────────
|
||||||
|
|
||||||
def request(self, req, send_retry=None):
|
def request(self, req, timeout=30.0):
|
||||||
results = self.requests([req], send_retry)
|
results = self.requests([req], timeout)
|
||||||
return results[0]
|
return results[0]
|
||||||
|
|
||||||
def requests(self, reqs, send_retry=None):
|
def requests(self, reqs, timeout=30.0):
|
||||||
"""Sends the batch and waits for every reply.
|
"""Sends the batch and waits for every reply."""
|
||||||
|
|
||||||
send_retry (seconds) sets the DASP resend interval of these datagrams, for a
|
|
||||||
request the device is known to be slow to take.
|
|
||||||
"""
|
|
||||||
if self.is_closed:
|
if self.is_closed:
|
||||||
raise SoxException("SoxClient closed")
|
raise SoxException("SoxClient closed")
|
||||||
|
|
||||||
if len(reqs) > 255:
|
if len(reqs) > 255:
|
||||||
return self._chunk_requests(reqs, send_retry)
|
return self._chunk_requests(reqs, timeout)
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
for i, req in enumerate(reqs):
|
for i, req in enumerate(reqs):
|
||||||
@@ -1027,9 +1085,9 @@ class SoxClient:
|
|||||||
self._req_event.clear()
|
self._req_event.clear()
|
||||||
|
|
||||||
for req in reqs:
|
for req in reqs:
|
||||||
self._session.send(req.to_bytes(), send_retry)
|
self._session.send(req.to_bytes())
|
||||||
|
|
||||||
deadline = time.monotonic() + 30.0
|
deadline = time.monotonic() + timeout
|
||||||
while True:
|
while True:
|
||||||
remaining = deadline - time.monotonic()
|
remaining = deadline - time.monotonic()
|
||||||
if remaining <= 0:
|
if remaining <= 0:
|
||||||
@@ -1051,11 +1109,11 @@ class SoxClient:
|
|||||||
self._responses = None
|
self._responses = None
|
||||||
raise SoxException("Request timeout")
|
raise SoxException("Request timeout")
|
||||||
|
|
||||||
def _chunk_requests(self, reqs, send_retry=None):
|
def _chunk_requests(self, reqs, timeout=30.0):
|
||||||
results = []
|
results = []
|
||||||
for i in range(0, len(reqs), 255):
|
for i in range(0, len(reqs), 255):
|
||||||
chunk = reqs[i:i + 255]
|
chunk = reqs[i:i + 255]
|
||||||
results.extend(self.requests(chunk, send_retry))
|
results.extend(self.requests(chunk, timeout))
|
||||||
return results
|
return results
|
||||||
|
|
||||||
# called by receiver thread
|
# called by receiver thread
|
||||||
@@ -1126,6 +1184,13 @@ class _FileGet:
|
|||||||
# SoxReceiver — daemon thread
|
# SoxReceiver — daemon thread
|
||||||
# ────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _FilePut:
|
||||||
|
"""The put in progress; `closed` is set when the device's 'z' arrives."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.closed = threading.Event()
|
||||||
|
|
||||||
|
|
||||||
class _SoxReceiver(threading.Thread):
|
class _SoxReceiver(threading.Thread):
|
||||||
def __init__(self, client):
|
def __init__(self, client):
|
||||||
super().__init__(name="SoxReceiver", daemon=True)
|
super().__init__(name="SoxReceiver", daemon=True)
|
||||||
@@ -1151,10 +1216,12 @@ class _SoxReceiver(threading.Thread):
|
|||||||
client._receive_event(msg)
|
client._receive_event(msg)
|
||||||
elif cmd == ord('k'):
|
elif cmd == ord('k'):
|
||||||
xfer = client._xfer
|
xfer = client._xfer
|
||||||
if xfer is not None:
|
if isinstance(xfer, _FileGet):
|
||||||
xfer.receive_chunk(msg)
|
xfer.receive_chunk(msg)
|
||||||
elif cmd == ord('z'):
|
elif cmd == ord('z'):
|
||||||
pass # close of a put -- the editor only gets files
|
xfer = client._xfer # the device closing a put
|
||||||
|
if isinstance(xfer, _FilePut):
|
||||||
|
xfer.closed.set()
|
||||||
else:
|
else:
|
||||||
client._receive_response(msg)
|
client._receive_response(msg)
|
||||||
except DaspException:
|
except DaspException:
|
||||||
|
|||||||
@@ -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()
|
||||||
command.undo()
|
try:
|
||||||
_redo_stack.append(command)
|
command.undo()
|
||||||
_refresh()
|
except LiveUndoError as failure:
|
||||||
return command.label
|
_abandon_history("Undo", failure)
|
||||||
|
|
||||||
|
|
||||||
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."""
|
||||||
|
|
||||||
@@ -270,3 +362,60 @@ class RenameComponentCommand:
|
|||||||
self.element.set("name", self.new_name)
|
self.element.set("name", self.new_name)
|
||||||
for link, attr, _, new_value in self.link_changes:
|
for link, attr, _, new_value in self.link_changes:
|
||||||
link.set(attr, new_value)
|
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()
|
||||||
|
|||||||
+22
-2
@@ -449,7 +449,10 @@ class _UserDialog:
|
|||||||
ET.SubElement(elem, "prop", name=name, val=val)
|
ET.SubElement(elem, "prop", name=name, val=val)
|
||||||
|
|
||||||
if app_state.live_mode:
|
if app_state.live_mode:
|
||||||
|
index = len(list(self._service_comp))
|
||||||
self._service_comp.append(elem)
|
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.mark_dirty()
|
||||||
app_state.set_status(f"User '{username}' added to device.")
|
app_state.set_status(f"User '{username}' added to device.")
|
||||||
return
|
return
|
||||||
@@ -485,12 +488,17 @@ class _UserDialog:
|
|||||||
|
|
||||||
if app_state.live_mode:
|
if app_state.live_mode:
|
||||||
failures = []
|
failures = []
|
||||||
for elem, prop, _old, new in changes:
|
applied = []
|
||||||
|
for change in changes:
|
||||||
|
elem, prop, _old, new = change
|
||||||
if live_hooks.live_set_prop_value(elem, prop, new):
|
if live_hooks.live_set_prop_value(elem, prop, new):
|
||||||
set_prop_value(elem, prop, new)
|
set_prop_value(elem, prop, new)
|
||||||
|
applied.append(change)
|
||||||
app_state.mark_dirty()
|
app_state.mark_dirty()
|
||||||
else:
|
else:
|
||||||
failures.append(f"{prop}: {live_hooks.last_error}")
|
failures.append(f"{prop}: {live_hooks.last_error}")
|
||||||
|
if applied:
|
||||||
|
undo.push(undo.LiveSlotValueCommand(applied, label="edit user"))
|
||||||
if failures:
|
if failures:
|
||||||
live_hooks.warn_failure(
|
live_hooks.warn_failure(
|
||||||
"Edit User",
|
"Edit User",
|
||||||
@@ -638,12 +646,24 @@ class UserServicePanel:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if app_state.live_mode:
|
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):
|
if not live_hooks.live_delete_component(user_elem):
|
||||||
live_hooks.warn_failure("Delete User", f"User '{name}' was not deleted from the device")
|
live_hooks.warn_failure("Delete User", f"User '{name}' was not deleted from the device")
|
||||||
return
|
return
|
||||||
|
index = list(self.comp).index(user_elem)
|
||||||
self.comp.remove(user_elem)
|
self.comp.remove(user_elem)
|
||||||
app_state.mark_dirty()
|
app_state.mark_dirty()
|
||||||
app_state.set_status(f"User '{name}' deleted from device.")
|
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()
|
self._after_change()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
+199
@@ -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
|
||||||
+1010
-302
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user