Compare commits
52
Commits
f15fc42363
..
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 | ||
|
|
2880c2a6f2 | ||
|
|
6b23bafffc | ||
|
|
894fdb7eb5 | ||
|
|
9b1d029b14 | ||
|
|
dbeeb78916 | ||
|
|
85b6fa62f3 | ||
|
|
f22d4852c9 | ||
|
|
088fe69964 | ||
|
|
a2d4be6ea8 |
@@ -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 |
|
||||
| `sax_dom.py` | `meta` bit-packing codec, component path resolution | 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 |
|
||||
| `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 |
|
||||
@@ -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
|
||||
`DeleteComponentCommand` (which restores the removed `<link>` entries at their original
|
||||
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
|
||||
`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` + `Z` | Undo |
|
||||
| `Ctrl`/`Cmd` + `Y` or `Ctrl`/`Cmd` + `Shift` + `Z` | Redo |
|
||||
| `Del` / `Backspace` | Delete selected component |
|
||||
| `Ctrl`/`Cmd` + `B` | Show or hide the tree/palette column |
|
||||
| `Ctrl` + `W` | Tidy Layout: re-flow the open sheet |
|
||||
|
||||
+908
@@ -0,0 +1,908 @@
|
||||
"""App > Get with Files / Put with Files: a full backup of the device's app as one
|
||||
zip, and putting such a zip back onto a device.
|
||||
|
||||
The zip follows the layout Ontrol's tools read back on a restore:
|
||||
|
||||
device/app.sab the files as the device holds them
|
||||
device/kits.scode
|
||||
device/images/<name>.bin
|
||||
device/fonts/<font file>
|
||||
device/system/logo.jpg Orion's boot logo; Rion's is system/logo.bin
|
||||
app.sax app.sab decoded, as sedonac would write it
|
||||
imageBank[XL]/<name>.png the sources of the device's images and fonts, taken from
|
||||
fontBank[XL]/<font file> Sedona home's banks (XL for Orion's 800x480 screen)
|
||||
system/logo.png
|
||||
|
||||
The device cannot list its files, so which images and fonts to fetch is read from the
|
||||
app itself: every image name and font its Ox widgets and pages name.
|
||||
|
||||
A put stages app.sab and kits.scode for the VM to take up on its next start; see
|
||||
put_with_files for the order it goes in.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import os
|
||||
import re
|
||||
import zipfile
|
||||
|
||||
import app_state
|
||||
import kit_build
|
||||
import palette
|
||||
import sab_app
|
||||
import schema
|
||||
import sox
|
||||
from ox_widget_properties import IMAGE_EXTENSIONS, ORION_KIT, ORION_PLATFORM_TYPE, RION_KIT
|
||||
|
||||
APP_SAB = "app.sab"
|
||||
KITS_SCODE = "kits.scode"
|
||||
|
||||
_IMAGE_SLOTS = ("imageFileName", "trueImageFileName", "falseImageFileName")
|
||||
_SOURCE_IMAGE_EXTENSIONS = tuple(e for e in IMAGE_EXTENSIONS if e != ".bin")
|
||||
_UNSAFE_FILE_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
||||
|
||||
|
||||
class _Platform:
|
||||
def __init__(self, name, kit, service_type, image_bank=None, font_bank=None,
|
||||
font_extensions=(), logo_uri=None, screen=None):
|
||||
self.name = name
|
||||
self.kit = kit # the kit only this platform's apps hold
|
||||
self.service_type = service_type # its platform service
|
||||
self.platform_id = "ontrol-" + name.lower() # the device's id starts so
|
||||
self.image_bank = image_bank
|
||||
self.font_bank = font_bank
|
||||
self.font_extensions = font_extensions
|
||||
self.logo_uri = logo_uri
|
||||
self.screen = screen # (width, height) in landscape; None: none
|
||||
|
||||
|
||||
# Orion keeps a landscape (.oxfl) and a portrait (.oxfp) cut of each font, and its boot
|
||||
# logo as a jpg; Rion's is a native image (Ontrol's BGetWithFilesTool). A P-ION has no
|
||||
# screen: nothing but the app goes to it.
|
||||
_ORION = _Platform("ORION", ORION_KIT, ORION_PLATFORM_TYPE, "imageBankXL", "fontBankXL",
|
||||
(".oxfl", ".oxfp"), "system/logo.jpg", (800, 480))
|
||||
_RION = _Platform("RION", RION_KIT, "ontrolRion::RionPlatformService", "imageBank",
|
||||
"fontBank", (".bin",), "system/logo.bin", (320, 240))
|
||||
_PION = _Platform("PION", "ontrolPion", "ontrolPion::PionPlatformService")
|
||||
_PLATFORMS = (_ORION, _RION, _PION)
|
||||
|
||||
|
||||
def platform_of(app):
|
||||
return platform_of_kits(app.kit_names())
|
||||
|
||||
|
||||
def platform_of_kits(kit_names):
|
||||
kit_names = set(kit_names)
|
||||
return next((p for p in _PLATFORMS if p.kit in kit_names), None)
|
||||
|
||||
|
||||
def platform_label(platform):
|
||||
return platform.name if platform else "no platform this editor knows"
|
||||
|
||||
|
||||
def backup_folder():
|
||||
return os.path.join(app_state.SEDONA_HOME, "store", "apps")
|
||||
|
||||
|
||||
def backup_file_name(device_name, app_name, when=None):
|
||||
"""'app-<device name>-<app name>-<yyyyMMdd-HHmmss>.zip', made safe for Windows."""
|
||||
stamp = (when or datetime.datetime.now()).strftime("%Y%m%d-%H%M%S")
|
||||
parts = [_UNSAFE_FILE_CHARS.sub("_", p).strip(" .") or "unnamed"
|
||||
for p in (device_name, app_name)]
|
||||
return f"app-{parts[0]}-{parts[1]}-{stamp}.zip"
|
||||
|
||||
|
||||
class MediaFile:
|
||||
"""One image or font the app names: where it lives on the device and in the bank.
|
||||
|
||||
`required` is False for guesses -- a bold font's plain cut, Orion's other font
|
||||
orientation, an indexed page image -- whose absence is not worth reporting.
|
||||
`group` ties the alternatives of one need together: the need is met when any of
|
||||
them is found.
|
||||
"""
|
||||
|
||||
def __init__(self, device_uri, bank_folder, bank_name, group, required=True):
|
||||
self.device_uri = device_uri
|
||||
self.bank_folder = bank_folder
|
||||
self.bank_name = bank_name
|
||||
self.group = group
|
||||
self.required = required
|
||||
|
||||
|
||||
def _font_stem(app, comp):
|
||||
slot = app.slot(comp, "fontName")
|
||||
names = [n.strip() for n in (slot.facets.get("range") or "Tahoma").split(",")] if slot else ["Tahoma"]
|
||||
index = app.prop(comp, "fontName", 0) or 0
|
||||
family = names[index] if 0 <= index < len(names) else names[0]
|
||||
size = app.prop(comp, "fontSize", 16)
|
||||
bold = app.prop(comp, "fontAttributes", 0) == 1
|
||||
return f"{size}pt {family}", bold
|
||||
|
||||
|
||||
def media_files(app, platform):
|
||||
"""The images and fonts the app names, each with its device path and bank source."""
|
||||
files, seen = [], set()
|
||||
if platform.screen is None:
|
||||
return files
|
||||
|
||||
def add(uri, bank_folder, bank_name, group, required=True):
|
||||
if uri not in seen:
|
||||
seen.add(uri)
|
||||
files.append(MediaFile(uri, bank_folder, bank_name, group, required))
|
||||
|
||||
def add_image(name, required=True):
|
||||
if name:
|
||||
add(f"images/{name}.bin", platform.image_bank, name, f"image {name}", required)
|
||||
|
||||
def add_font(stem, group, required):
|
||||
for i, ext in enumerate(platform.font_extensions):
|
||||
add(f"fonts/{stem}{ext}", platform.font_bank, stem + ext, group,
|
||||
required and i == 0)
|
||||
|
||||
for comp in app.comps.values():
|
||||
for slot_name in _IMAGE_SLOTS:
|
||||
add_image(app.prop(comp, slot_name, ""))
|
||||
|
||||
prefix = app.prop(comp, "imageFileNamePrefix", "")
|
||||
if prefix:
|
||||
for i in range((app.prop(comp, "maxIndex", 1) or 0) + 1):
|
||||
add_image(f"{prefix}{i}")
|
||||
|
||||
page_image = app.prop(comp, "imageFileName", "")
|
||||
if page_image:
|
||||
for i in range(1, (app.prop(comp, "maxImageId", 0) or 0) + 1):
|
||||
add_image(f"{page_image}{i}", required=False)
|
||||
|
||||
if app.slot(comp, "fontSize") is not None:
|
||||
stem, bold = _font_stem(app, comp)
|
||||
group = f"font {'bold ' if bold else ''}{stem}"
|
||||
if bold:
|
||||
add_font(f"bold {stem}", group, True)
|
||||
add_font(stem, group, not bold)
|
||||
|
||||
add(platform.logo_uri, platform.image_bank, "logo", "logo", required=False)
|
||||
return files
|
||||
|
||||
|
||||
def _bank_source(bank_folder, bank_name, is_image):
|
||||
"""The local bank file for a device file, or None."""
|
||||
folder = os.path.join(app_state.SEDONA_HOME, "ontrol", bank_folder)
|
||||
if not is_image:
|
||||
path = os.path.join(folder, bank_name)
|
||||
return path if os.path.isfile(path) else None
|
||||
for ext in _SOURCE_IMAGE_EXTENSIONS + (".bin",):
|
||||
path = os.path.join(folder, bank_name + ext)
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def _source_entry(media, source_path):
|
||||
"""Where the bank file goes in the zip: next to its bank's name, the logo in system/."""
|
||||
file_name = os.path.basename(source_path)
|
||||
if media.device_uri.startswith("system/"):
|
||||
return "system/logo" + os.path.splitext(file_name)[1]
|
||||
return f"{media.bank_folder}/{file_name}"
|
||||
|
||||
|
||||
def save_app(client):
|
||||
"""Invokes App.save on the device and waits for it to finish.
|
||||
|
||||
Ontrol's Niagara driver does this before every app.sab get.
|
||||
"""
|
||||
comp = client.cache_get(0) or client.load(0)
|
||||
slots = client.slot_registry.get((comp.kit_id, comp.type_id)) or []
|
||||
slot = next((s for s in slots if s["name"] == "save" and s.get("is_action")), None)
|
||||
client.invoke(0, slot["id"] if slot else 0)
|
||||
|
||||
|
||||
class BackupResult:
|
||||
def __init__(self):
|
||||
self.path = None
|
||||
self.entries = []
|
||||
self.missing_on_device = [] # media the app names that the device lacks
|
||||
self.missing_in_bank = [] # device files with no source in Sedona home
|
||||
self.sax_error = None # why app.sax could not be written
|
||||
|
||||
|
||||
def get_with_file(client, path, status=None):
|
||||
"""Reads the app and its media off the device and writes the backup zip to `path`.
|
||||
|
||||
The app is saved on the device first, so app.sab holds what is running. Runs off
|
||||
the main thread; status(text, fraction) reports progress. Returns a BackupResult;
|
||||
a failure to save, or to read app.sab or kits.scode, raises.
|
||||
"""
|
||||
def report(text, fraction=None):
|
||||
if status:
|
||||
status(text, fraction)
|
||||
|
||||
def fetch(uri, base, span):
|
||||
def progress(done, total):
|
||||
report(f"Reading {uri} ...", base + span * (done / total if total else 1.0))
|
||||
report(f"Reading {uri} ...", base)
|
||||
try:
|
||||
return client.get_file(uri, progress)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Reading {uri}: {e}") from e
|
||||
|
||||
result = BackupResult()
|
||||
entries = [] # (zip name, bytes or local path)
|
||||
|
||||
report("Saving the app on the device ...", 0.0)
|
||||
try:
|
||||
save_app(client)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Saving the app on the device: {e}") from e
|
||||
sab = fetch(APP_SAB, 0.0, 0.15)
|
||||
entries.append(("device/" + APP_SAB, sab))
|
||||
entries.append(("device/" + KITS_SCODE, fetch(KITS_SCODE, 0.15, 0.35)))
|
||||
|
||||
app = None
|
||||
try:
|
||||
app = sab_app.SabApp(sab)
|
||||
entries.append(("app.sax", app.to_sax().encode("ascii")))
|
||||
except Exception as e:
|
||||
result.sax_error = str(e)
|
||||
|
||||
platform = platform_of(app) if app else None
|
||||
media = media_files(app, platform) if platform else []
|
||||
found_groups, wanted = set(), {}
|
||||
for i, m in enumerate(media):
|
||||
report(f"Reading {m.device_uri} ...", 0.5 + 0.45 * i / max(1, len(media)))
|
||||
if m.required:
|
||||
wanted.setdefault(m.group, m.device_uri)
|
||||
try:
|
||||
data = client.get_file(m.device_uri)
|
||||
except Exception:
|
||||
if client.is_closed:
|
||||
raise
|
||||
continue
|
||||
found_groups.add(m.group)
|
||||
entries.append(("device/" + m.device_uri, data))
|
||||
source = _bank_source(m.bank_folder, m.bank_name, m.device_uri.startswith(("images/", "system/")))
|
||||
if source:
|
||||
entries.append((_source_entry(m, source), source))
|
||||
else:
|
||||
result.missing_in_bank.append(f"{m.bank_folder}/{m.bank_name}")
|
||||
result.missing_on_device = [uri for group, uri in wanted.items() if group not in found_groups]
|
||||
|
||||
report("Writing the zip ...", 0.97)
|
||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||
partial = path + ".part"
|
||||
try:
|
||||
with zipfile.ZipFile(partial, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
for name, content in entries:
|
||||
if isinstance(content, bytes):
|
||||
z.writestr(name, content)
|
||||
else:
|
||||
z.write(content, name)
|
||||
os.replace(partial, path)
|
||||
finally:
|
||||
if os.path.exists(partial):
|
||||
os.remove(partial)
|
||||
|
||||
result.path = path
|
||||
result.entries = [name for name, _ in entries]
|
||||
report("Done", 1.0)
|
||||
return result
|
||||
|
||||
|
||||
# --- Put with Files ---------------------------------------------------------------------
|
||||
#
|
||||
# Follows Ontrol's Workbench tool (ontrolSedonaOx BPutWithFilesTool, ontrolSedonanet
|
||||
# PutApp): the backup's app.sax is encoded for the chosen kit builds, kits.scode built
|
||||
# for them with sedonac when they are not the device's (kit_build); both go as
|
||||
# '.writing' renamed to '.stage'; the platform service's deleteFile clears fonts/* and
|
||||
# images/*; then the images, the fonts and the boot logo go. Fonts come from Sedona
|
||||
# home's bank in the cut the device draws with, images in the form its screen takes.
|
||||
|
||||
_FONT_FOLDERS = ("device/fonts/", "fonts/", "fontBank/", "fontBankXL/")
|
||||
_JPG = (".jpg", ".jpeg")
|
||||
|
||||
|
||||
def _checksum(value):
|
||||
"""A kit checksum as an unsigned int, from a signed Sox int or a .sab hex string."""
|
||||
return int(value, 16) if isinstance(value, str) else value & 0xFFFFFFFF
|
||||
|
||||
|
||||
def kits_fit(app_kits, device_kits):
|
||||
"""Whether the device's kits.scode can load an app.sab of `app_kits`.
|
||||
|
||||
App.loadSchema's rule: the app's kits must appear among the device's, in the same
|
||||
order, each with the same checksum; the device may hold more.
|
||||
"""
|
||||
device = iter((n, _checksum(c)) for n, c in device_kits)
|
||||
return all(any(d == (n, _checksum(c)) for d in device) for n, c in app_kits)
|
||||
|
||||
|
||||
class DeviceTarget:
|
||||
"""What a put needs to know of the connected device."""
|
||||
|
||||
def __init__(self, platform, kits, name=None, portrait=False,
|
||||
platform_comp_id=None, delete_slot_id=None, platform_id=""):
|
||||
self.platform = platform # one of _PLATFORMS, or None
|
||||
self.platform_id = platform_id # e.g. "ontrol-pion-b427a5b9"
|
||||
self.kits = kits # [(name, checksum)] in kit-id order
|
||||
self.name = name # the app's deviceName
|
||||
self.portrait = portrait # Orion's screenOrientation
|
||||
self.platform_comp_id = platform_comp_id
|
||||
self.delete_slot_id = delete_slot_id
|
||||
|
||||
@property
|
||||
def font_extension(self):
|
||||
if self.platform is _ORION:
|
||||
return ".oxfp" if self.portrait else ".oxfl"
|
||||
return ".bin"
|
||||
|
||||
@property
|
||||
def screen(self):
|
||||
"""(width, height) as the device is set up, as Ontrol's ScreenInfo has it."""
|
||||
w, h = self.platform.screen
|
||||
return (h, w) if self.portrait else (w, h)
|
||||
|
||||
|
||||
def device_target(ds):
|
||||
"""Reads the platform, kits, deviceName and platform service off the device.
|
||||
|
||||
The platform comes from the platform id, as Ontrol's tools read it, the kits
|
||||
standing in for a device that does not give one.
|
||||
"""
|
||||
client = ds.sox_client
|
||||
kits = [(p.name, p.checksum) for p in client.read_schema()]
|
||||
kit_names = {n for n, _ in kits}
|
||||
platform_id = (client.read_version().platform_id or "").lower()
|
||||
platform = (next((p for p in _PLATFORMS if platform_id.startswith(p.platform_id)), None)
|
||||
or next((p for p in _PLATFORMS if p.kit in kit_names), None))
|
||||
|
||||
target = DeviceTarget(platform, kits, platform_id=client.read_version().platform_id)
|
||||
comp = client.cache_get(0)
|
||||
if comp is not None:
|
||||
client.update([comp], sox.CONFIG)
|
||||
target.name = comp.values.get("deviceName") or None
|
||||
if platform is not None and platform.screen is not None:
|
||||
plat = ds.find_component_of_type(platform.service_type, "screenOrientation")
|
||||
if plat is not None:
|
||||
target.platform_comp_id = plat.id
|
||||
target.portrait = (platform is _ORION and
|
||||
str(plat.values.get("screenOrientation")).lower() == "true")
|
||||
slots = client.slot_registry.get((plat.kit_id, plat.type_id)) or []
|
||||
target.delete_slot_id = next((s["id"] for s in slots if s["name"] == "deleteFile"
|
||||
and s.get("is_action")), None)
|
||||
return target
|
||||
|
||||
|
||||
# --- images in the device's form --------------------------------------------------------
|
||||
|
||||
def native_image(data, name, target):
|
||||
"""A png (or any image PIL reads) as the device's native .bin.
|
||||
|
||||
Ported from OntOxImageUtil.convertImageToNativeFormat: a 32-byte name, a 32-byte
|
||||
header (flags, width, height), then the pixels. Orion draws BGR(A), turned for a
|
||||
portrait screen, with alpha unless the image fills the screen; Rion RGB565, with
|
||||
alpha only for a translucent image smaller than the screen.
|
||||
"""
|
||||
import io
|
||||
from PIL import Image
|
||||
|
||||
img = Image.open(io.BytesIO(data))
|
||||
img.load()
|
||||
width, height = img.size
|
||||
translucent = img.mode in ("RGBA", "LA", "PA") or (
|
||||
img.mode == "P" and "transparency" in img.info and
|
||||
any(0 < a < 255 for a in img.convert("RGBA").getchannel("A").getdata()))
|
||||
background = (width, height) == target.screen
|
||||
orion = target.platform is _ORION
|
||||
rgb_mode = use_alpha = orion and not background
|
||||
if not orion:
|
||||
use_alpha = translucent and not background
|
||||
rotate = orion and target.portrait
|
||||
|
||||
rgba = img.convert("RGBA")
|
||||
if rotate: # the Java walks columns right to left: a quarter turn anticlockwise
|
||||
rgba = rgba.transpose(Image.Transpose.ROTATE_90)
|
||||
w, h = rgba.size
|
||||
|
||||
out = bytearray(name.encode("ascii", errors="replace"))
|
||||
out += bytes(max(0, 32 - len(out)))
|
||||
out += bytes([(4 if rotate else 0) + (2 if rgb_mode else 0) + (1 if use_alpha else 0),
|
||||
w & 0xFF, w >> 8, h & 0xFF, h >> 8]) + bytes(27)
|
||||
pixels = rgba.tobytes()
|
||||
if rgb_mode:
|
||||
for i in range(0, len(pixels), 4):
|
||||
r, g, b, a = pixels[i:i + 4]
|
||||
out += bytes((b, g, r, a)) if use_alpha else bytes((b, g, r))
|
||||
else:
|
||||
for i in range(0, len(pixels), 4):
|
||||
r, g, b, a = pixels[i:i + 4]
|
||||
rgb565 = (r >> 3) << 11 | (g >> 2) << 5 | (b >> 3)
|
||||
if use_alpha:
|
||||
out.append(a)
|
||||
out += bytes((rgb565 & 0xFF, rgb565 >> 8))
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def device_jpg(data, target):
|
||||
"""A jpg as the device takes it: turned a quarter anticlockwise for a portrait Orion
|
||||
(OntOxImageUtil.rotateJpg), otherwise as it is."""
|
||||
if not (target.platform is _ORION and target.portrait):
|
||||
return data
|
||||
import io
|
||||
from PIL import Image
|
||||
img = Image.open(io.BytesIO(data))
|
||||
buf = io.BytesIO()
|
||||
img.transpose(Image.Transpose.ROTATE_90).save(buf, "JPEG", quality=75)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# --- the plan ---------------------------------------------------------------------------
|
||||
|
||||
class PutFile:
|
||||
def __init__(self, device_uri, data, source):
|
||||
self.device_uri = device_uri
|
||||
self.data = data
|
||||
self.source = source # where the bytes come from, for the summary
|
||||
|
||||
|
||||
class PutPlan:
|
||||
"""What a put of the zip would do, and whether the zip fits the device."""
|
||||
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
self.problems = [] # any of these stops the put
|
||||
self.warnings = [] # worth confirming, but the put can go on
|
||||
self.app_source = "" # how app.sab was come by, for the summary
|
||||
self.kit_changes = [] # each chosen kit build that is not the device's
|
||||
self.scode_source = "" # where kits.scode comes from, when it goes
|
||||
self.app_files = [] # PutFile to '<name>.writing', renamed to '.stage'
|
||||
self.media = [] # PutFile: images, fonts, logo
|
||||
self.converted = 0 # images made from the backup's image bank
|
||||
self.images_missing = [] # images neither in device form nor as a source
|
||||
self.fonts_from_zip = [] # fonts the bank lacks; the backup's copy goes
|
||||
self.fonts_missing = [] # fonts the app names that neither has
|
||||
self.staged = False # set once the device holds app.sab.stage
|
||||
self.has_screen = True # False for a P-ION: the app is all that goes
|
||||
|
||||
@property
|
||||
def ok(self):
|
||||
return not self.problems
|
||||
|
||||
|
||||
def _app_portrait(app):
|
||||
comp = next((c for c in app.comps.values() if c["type"] == ORION_PLATFORM_TYPE), None)
|
||||
return bool(comp and app.prop(comp, "screenOrientation", False))
|
||||
|
||||
|
||||
class KitRow:
|
||||
"""One kit in the kits step of a put: the device's build, the backup's, and what to put.
|
||||
|
||||
`included` rows are what goes: into app.sab's schema and, when it is rebuilt, into
|
||||
kits.scode. A `required` row -- a kit the app's components name, or one an included
|
||||
kit depends on -- is always included.
|
||||
"""
|
||||
|
||||
def __init__(self, name, device, backup, builds, used=False, locked=False,
|
||||
unsupported=False):
|
||||
self.name = name
|
||||
self.device = device # checksum hex the device runs, or None
|
||||
self.backup = backup # checksum hex the backup's schema names, or None
|
||||
self.builds = builds # [(version, checksum hex)] Sedona home has, newest first
|
||||
self.used = used # the app's components name a type of it
|
||||
self.locked = locked # the backup holds only app.sab: nothing can change
|
||||
self.unsupported = unsupported # cannot run on the device's platform
|
||||
self.required = used
|
||||
self.included = used or (device is not None and not unsupported) or \
|
||||
(backup is not None and not unsupported)
|
||||
self.use = self._default() # the chosen checksum hex
|
||||
|
||||
def _default(self):
|
||||
"""The device's build, when Sedona home can describe it; then the backup's; then
|
||||
the newest."""
|
||||
known = {c for _, c in self.builds}
|
||||
if self.locked:
|
||||
return self.backup
|
||||
for checksum in (self.device, self.backup):
|
||||
if checksum in known:
|
||||
return checksum
|
||||
return self.builds[0][1] if self.builds else self.device or self.backup
|
||||
|
||||
@property
|
||||
def latest(self):
|
||||
return self.builds[0][1] if self.builds else None
|
||||
|
||||
|
||||
def _hex(checksum):
|
||||
return f"{_checksum(checksum):08x}"
|
||||
|
||||
|
||||
def _app_source(z, names):
|
||||
"""(sax entry or None, sab entry or None) of the backup's app."""
|
||||
sax = "app.sax" if "app.sax" in names else next(
|
||||
(n for n in sorted(names) if n.endswith(".sax") and "/" not in n), None)
|
||||
sab = "device/" + APP_SAB
|
||||
return sax, sab if sab in names else None
|
||||
|
||||
|
||||
def _backup_kits(path):
|
||||
"""([(kit, checksum or None)] the backup's schema names, {kits its components use},
|
||||
whether it holds only app.sab). Raises ValueError when the zip holds no app."""
|
||||
import xml.etree.ElementTree as ET
|
||||
with zipfile.ZipFile(path) as z:
|
||||
names = {n for n in z.namelist() if not n.endswith("/")}
|
||||
sax_name, sab_name = _app_source(z, names)
|
||||
if sax_name:
|
||||
root = ET.fromstring(z.read(sax_name))
|
||||
schema_elem = root.find("schema")
|
||||
kits = [(k.get("name"), k.get("checksum"))
|
||||
for k in (schema_elem.findall("kit") if schema_elem is not None else ())]
|
||||
used = {c.get("type", "").split("::")[0] for c in root.iter("comp")}
|
||||
return kits, used | {"sys"}, False
|
||||
if sab_name:
|
||||
app = sab_app.SabApp(z.read(sab_name))
|
||||
used = {c["type"].split("::")[0] for c in app.comps.values()}
|
||||
return app.kit_parts, used, True
|
||||
raise ValueError("The zip holds no app: neither app.sax nor device/app.sab.")
|
||||
|
||||
|
||||
def kit_rows(path, target):
|
||||
"""Every kit the kits step lists, in Manage Kits' groups (see KitRow.group).
|
||||
|
||||
The kits the backup's app needs, those its schema or the device holds without the
|
||||
app needing them, and every other kit Sedona home has. Each defaults to the device's
|
||||
build. Raises ValueError when the zip holds no readable app.
|
||||
"""
|
||||
kits, used, locked = _backup_kits(path)
|
||||
device = {n: _hex(c) for n, c in target.kits}
|
||||
backup = {n: _hex(c) if c else None for n, c in kits}
|
||||
names = list(dict.fromkeys([n for n, _ in kits] + list(device) + palette.list_kits()))
|
||||
unsupported = palette.unsupported_kits(set(backup) | set(device))
|
||||
rows = []
|
||||
for name in names:
|
||||
builds = [(v, _hex(c)) for v, c in palette.kit_builds(name)]
|
||||
if locked and name not in backup:
|
||||
continue # nothing can be added to a bare app.sab
|
||||
rows.append(KitRow(name, device.get(name), backup.get(name), builds,
|
||||
used=name in used, locked=locked,
|
||||
unsupported=name in unsupported and name not in used))
|
||||
settle_required(rows)
|
||||
return rows
|
||||
|
||||
|
||||
def settle_required(rows):
|
||||
"""Marks every kit an included kit depends on as required -- and so included, at its
|
||||
default build -- all the way down, as Manage Kits counts dependencies."""
|
||||
by_name = {r.name: r for r in rows}
|
||||
for row in rows:
|
||||
row.required = row.used
|
||||
pending = [r for r in rows if r.included or r.used]
|
||||
while pending:
|
||||
row = pending.pop()
|
||||
for dep in schema.read_kit_dependencies(row.name, row.use):
|
||||
dep_row = by_name.get(dep)
|
||||
if dep_row is not None and not dep_row.required:
|
||||
dep_row.required = dep_row.included = True
|
||||
pending.append(dep_row)
|
||||
for row in rows:
|
||||
if row.used:
|
||||
row.included = True
|
||||
|
||||
|
||||
KIT_GROUPS = ("Installed kits", "Installed but not used kits",
|
||||
"Kits available on the computer", "Not supported")
|
||||
|
||||
|
||||
def group(row):
|
||||
"""The Manage Kits section a row is listed under: the app needs it; the device or
|
||||
the backup holds it without the app needing it; Sedona home has it; or it cannot run
|
||||
on the device."""
|
||||
if row.unsupported:
|
||||
return KIT_GROUPS[3]
|
||||
if row.required:
|
||||
return KIT_GROUPS[0]
|
||||
if row.device or row.backup:
|
||||
return KIT_GROUPS[1]
|
||||
return KIT_GROUPS[2]
|
||||
|
||||
|
||||
def chosen_kits(rows):
|
||||
"""[(name, checksum hex)] of the included rows."""
|
||||
return [(r.name, r.use) for r in rows if r.included]
|
||||
|
||||
|
||||
def plan_put(path, target, rows=None, rebuild=False):
|
||||
"""Reads the zip at `path` and checks it against the device `target`.
|
||||
|
||||
rows are kit_rows() as the kits step left them; None takes the defaults. The
|
||||
included kits make app.sab's schema; kits.scode is built for them when they are not
|
||||
the device's kits, or when `rebuild` asks for it anyway.
|
||||
"""
|
||||
plan = PutPlan(path)
|
||||
try:
|
||||
z = zipfile.ZipFile(path)
|
||||
except (OSError, zipfile.BadZipFile) as e:
|
||||
plan.problems.append(f"Not a readable zip: {e}")
|
||||
return plan
|
||||
if rows is None:
|
||||
try:
|
||||
rows = kit_rows(path, target)
|
||||
except Exception as e:
|
||||
plan.problems.append(str(e))
|
||||
return plan
|
||||
with z:
|
||||
names = {n for n in z.namelist() if not n.endswith("/")}
|
||||
app, backup_portrait = _plan_app(plan, z, names, target, rows, rebuild)
|
||||
if app is None:
|
||||
return plan
|
||||
|
||||
platform = platform_of(app)
|
||||
if platform is None or platform is not target.platform:
|
||||
plan.problems.append(f"The backup is from {platform_label(platform)}; the device "
|
||||
f"is {platform_label(target.platform)}.")
|
||||
return plan
|
||||
|
||||
zip_name = app.prop(app.app, "deviceName", "") or ""
|
||||
if target.name and zip_name and zip_name != target.name:
|
||||
plan.warnings.append(f"The backup was taken from '{zip_name}'; this device is "
|
||||
f"'{target.name}'.")
|
||||
if platform.screen is None:
|
||||
plan.has_screen = False # no images or fonts to put
|
||||
return plan
|
||||
if target.delete_slot_id is None:
|
||||
plan.warnings.append("The device's platform service has no deleteFile action, "
|
||||
"so its old fonts and images stay.")
|
||||
|
||||
same_screen = platform is _RION or backup_portrait == target.portrait
|
||||
if not same_screen:
|
||||
plan.warnings.append(
|
||||
f"The backup is from a {'portrait' if backup_portrait else 'landscape'} "
|
||||
f"Orion; this one is {'portrait' if target.portrait else 'landscape'}. Its "
|
||||
"images are made again from the backup's image bank.")
|
||||
_plan_images(plan, z, names, platform, target, same_screen)
|
||||
_plan_fonts(plan, z, names, app, platform, target.font_extension)
|
||||
_plan_logo(plan, z, names, platform, target, same_screen)
|
||||
return plan
|
||||
|
||||
|
||||
def _plan_app(plan, z, names, target, rows, rebuild):
|
||||
"""Settles app.sab and kits.scode, as Ontrol's PutApp does.
|
||||
|
||||
The backup's app.sax is encoded for the chosen kit builds (a backup with only
|
||||
device/app.sab goes as it is). When the device's kits.scode cannot load that app, or
|
||||
a rebuild is asked for, kits.scode is built for the chosen builds with sedonac after
|
||||
a check of their natives against the device's VM; without sedonac, the backup's own
|
||||
kits.scode can stand in when the chosen builds are the backup's. Returns (decoded
|
||||
app, whether the backup came from a portrait screen).
|
||||
"""
|
||||
sax_name, sab_name = _app_source(z, names)
|
||||
chosen = chosen_kits(rows)
|
||||
original = None
|
||||
if sab_name:
|
||||
try:
|
||||
original = sab_app.SabApp(z.read(sab_name))
|
||||
except Exception as e:
|
||||
if not sax_name:
|
||||
plan.problems.append(f"{sab_name} cannot be read: {e}")
|
||||
return None, False
|
||||
|
||||
if sax_name:
|
||||
try:
|
||||
sab = sab_app.encode_sax(z.read(sax_name), kits=chosen)
|
||||
except Exception as e:
|
||||
plan.problems.append(f"{sax_name} cannot be encoded for the chosen kits: {e}")
|
||||
return None, False
|
||||
plan.app_source = f"{sax_name}, encoded for the chosen kits"
|
||||
source = sax_name
|
||||
else:
|
||||
sab = z.read(sab_name)
|
||||
plan.app_source = sab_name
|
||||
source = sab_name
|
||||
app = sab_app.SabApp(sab)
|
||||
app_kits = app.kit_parts
|
||||
|
||||
plan.kit_changes = [line for line in map(kit_change, rows) if line]
|
||||
device_kits = {(n, _checksum(c)) for n, c in target.kits}
|
||||
if sax_name:
|
||||
same = {(n, _checksum(c)) for n, c in app_kits} == device_kits
|
||||
else:
|
||||
same = kits_fit(app_kits, target.kits)
|
||||
if rebuild or not same:
|
||||
scode = _plan_scode(plan, z, names, target, app_kits)
|
||||
if scode is None:
|
||||
return None, False
|
||||
plan.app_files.append(scode)
|
||||
plan.app_files.append(PutFile(APP_SAB, sab, source))
|
||||
return app, _app_portrait(original or app)
|
||||
|
||||
|
||||
def kit_change(row):
|
||||
"""How a row changes the device's kits, or None."""
|
||||
def label(checksum):
|
||||
version = palette.version_for_checksum(row.name, checksum)
|
||||
return f"{version} ({checksum})" if version else checksum
|
||||
if row.device is None:
|
||||
return f"{row.name}: added at {label(row.use)}" if row.included else None
|
||||
if not row.included:
|
||||
return f"{row.name}: removed"
|
||||
if row.use != row.device:
|
||||
return f"{row.name}: {label(row.device)} -> {label(row.use)}"
|
||||
return None
|
||||
|
||||
|
||||
def _plan_scode(plan, z, names, target, app_kits):
|
||||
"""kits.scode for the app's kits: built by sedonac, or the backup's own."""
|
||||
manifest = kit_build.platform_manifest(target.platform_id)
|
||||
backup_scode = "device/" + KITS_SCODE
|
||||
backup_kits = None
|
||||
if "device/" + APP_SAB in names:
|
||||
try:
|
||||
backup_kits = sab_app.SabApp(z.read("device/" + APP_SAB)).kit_parts
|
||||
except Exception:
|
||||
pass
|
||||
why = None
|
||||
if manifest is None:
|
||||
why = (f"Sedona home has no platform manifest for {target.platform_id or 'the device'}"
|
||||
" (platforms/db), so kits.scode cannot be built or checked for it.")
|
||||
else:
|
||||
problems = kit_build.native_problems(app_kits, manifest)
|
||||
if problems:
|
||||
plan.problems.append("The chosen kits call native methods the device's VM "
|
||||
"does not have:")
|
||||
plan.problems += [" " + p for p in problems]
|
||||
return None
|
||||
try:
|
||||
data = kit_build.build_kits_scode(app_kits, manifest)
|
||||
except kit_build.BuildError as e:
|
||||
why = str(e)
|
||||
else:
|
||||
plan.scode_source = "built by sedonac for the chosen kits"
|
||||
return PutFile(KITS_SCODE, data, "sedonac")
|
||||
|
||||
if backup_kits is not None and backup_scode in names and \
|
||||
[(n, _checksum(c)) for n, c in backup_kits] == \
|
||||
[(n, _checksum(c)) for n, c in app_kits]:
|
||||
plan.warnings.append(why + " The backup's own kits.scode goes instead.")
|
||||
plan.scode_source = "the backup's, unchecked against the device's VM"
|
||||
return PutFile(KITS_SCODE, z.read(backup_scode), backup_scode)
|
||||
plan.problems.append(why)
|
||||
return None
|
||||
|
||||
|
||||
def _plan_images(plan, z, names, platform, target, same_screen):
|
||||
"""Every image the backup holds, in the form the device's screen takes.
|
||||
|
||||
The backup's device copy goes when it was taken off the same kind of screen;
|
||||
otherwise the image bank's source is made over, as Ontrol's tool always does.
|
||||
"""
|
||||
native, source = {}, {}
|
||||
for name in names:
|
||||
folder, _, file_name = name.rpartition("/")
|
||||
stem, ext = os.path.splitext(file_name)
|
||||
if folder == "device/images":
|
||||
native[stem] = name
|
||||
elif folder == platform.image_bank and ext.lower() in _SOURCE_IMAGE_EXTENSIONS:
|
||||
source[stem] = name
|
||||
for stem in sorted(set(native) | set(source), key=str.lower):
|
||||
if same_screen and stem in native:
|
||||
name = native[stem]
|
||||
plan.media.append(PutFile("images/" + name.rpartition("/")[2], z.read(name),
|
||||
"backup"))
|
||||
elif stem in source:
|
||||
name = source[stem]
|
||||
try:
|
||||
if name.lower().endswith(_JPG):
|
||||
plan.media.append(PutFile(f"images/{stem}.jpg",
|
||||
device_jpg(z.read(name), target), name))
|
||||
else:
|
||||
plan.media.append(PutFile(f"images/{stem}.bin",
|
||||
native_image(z.read(name), stem, target), name))
|
||||
plan.converted += 1
|
||||
except Exception as e:
|
||||
plan.images_missing.append(f"{stem} ({name}: {e})")
|
||||
else:
|
||||
plan.images_missing.append(stem)
|
||||
|
||||
|
||||
def _plan_logo(plan, z, names, platform, target, same_screen):
|
||||
"""The boot logo: the device's copy, or the backup's source made over (Ontrol's
|
||||
tool sends it into images/ -- system/ is where the device looks for it)."""
|
||||
copy = "device/" + platform.logo_uri
|
||||
if same_screen and copy in names:
|
||||
plan.media.append(PutFile(platform.logo_uri, z.read(copy), "backup"))
|
||||
return
|
||||
source = next((n for n in sorted(names) if n.rpartition("/")[0] == "system" and
|
||||
os.path.splitext(n)[0].endswith("logo")), None)
|
||||
if source is None:
|
||||
return
|
||||
try:
|
||||
data = z.read(source)
|
||||
if platform is _ORION:
|
||||
if not source.lower().endswith(_JPG):
|
||||
return
|
||||
plan.media.append(PutFile(platform.logo_uri, device_jpg(data, target), source))
|
||||
else:
|
||||
plan.media.append(PutFile(platform.logo_uri, native_image(data, "logo", target),
|
||||
source))
|
||||
except Exception as e:
|
||||
plan.warnings.append(f"The boot logo could not be made from {source}: {e}")
|
||||
|
||||
|
||||
def _plan_fonts(plan, z, names, app, platform, ext):
|
||||
"""Every font the backup holds or the app names, in the device's cut `ext`.
|
||||
|
||||
As Ontrol's tool does, the bank's file goes rather than the backup's; the backup's
|
||||
goes only for a font the bank lacks.
|
||||
"""
|
||||
in_zip = {} # stem -> {extension: entry}
|
||||
for name in names:
|
||||
folder = next((f for f in _FONT_FOLDERS if name.startswith(f)), None)
|
||||
if folder and "/" not in name[len(folder):]:
|
||||
stem, file_ext = os.path.splitext(name[len(folder):])
|
||||
in_zip.setdefault(stem, {}).setdefault(file_ext.lower(), name)
|
||||
required = {}
|
||||
for m in media_files(app, platform):
|
||||
if m.device_uri.startswith("fonts/"):
|
||||
stem = os.path.splitext(m.device_uri[len("fonts/"):])[0]
|
||||
required[stem] = required.get(stem, False) or m.required
|
||||
|
||||
for stem in sorted(set(in_zip) | set(required)):
|
||||
bank = _bank_source(platform.font_bank, stem + ext, False)
|
||||
if bank:
|
||||
with open(bank, "rb") as f:
|
||||
plan.media.append(PutFile("fonts/" + stem + ext, f.read(),
|
||||
f"{platform.font_bank}/{stem}{ext}"))
|
||||
elif ext in in_zip.get(stem, {}):
|
||||
plan.media.append(PutFile("fonts/" + stem + ext,
|
||||
z.read(in_zip[stem][ext]), "backup"))
|
||||
plan.fonts_from_zip.append(stem + ext)
|
||||
elif required.get(stem) or stem in in_zip:
|
||||
plan.fonts_missing.append(stem + ext)
|
||||
|
||||
|
||||
def put_with_files(client, plan, target, status=None):
|
||||
"""Puts the plan on the device; the device takes the app up when it restarts.
|
||||
|
||||
Runs off the main thread; status(text, fraction) reports progress. A failure
|
||||
raises, naming the step. Once app.sab.stage is in place the app is committed: a
|
||||
later failure leaves the new app staged with some of its media missing.
|
||||
"""
|
||||
def report(text, fraction=None):
|
||||
if status:
|
||||
status(text, fraction)
|
||||
|
||||
total = sum(len(f.data) for f in plan.app_files + plan.media) or 1
|
||||
done = 0
|
||||
|
||||
def put(uri, f):
|
||||
nonlocal done
|
||||
base = done
|
||||
|
||||
def progress(sent, size):
|
||||
report(f"Writing {uri} ...", (base + sent) / total)
|
||||
report(f"Writing {uri} ...", base / total)
|
||||
try:
|
||||
client.put_file(uri, f.data, progress)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Writing {uri}: {e}") from e
|
||||
done += len(f.data)
|
||||
|
||||
for f in plan.app_files:
|
||||
put(f.device_uri + ".writing", f)
|
||||
for f in plan.app_files:
|
||||
writing, stage = f.device_uri + ".writing", f.device_uri + ".stage"
|
||||
report(f"Renaming {writing} to {stage} ...", done / total)
|
||||
try:
|
||||
client.rename_file(writing, stage)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Renaming {writing} to {stage}: {e}") from e
|
||||
plan.staged = True
|
||||
|
||||
if target.delete_slot_id is not None:
|
||||
for pattern in ("fonts/*", "images/*"):
|
||||
report(f"Deleting {pattern} on the device ...", done / total)
|
||||
try:
|
||||
client.invoke(target.platform_comp_id, target.delete_slot_id,
|
||||
sox.STR_ID, pattern) # an asStr Buf goes as a Str
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Deleting {pattern}: {e}") from e
|
||||
|
||||
for f in plan.media:
|
||||
put(f.device_uri, f)
|
||||
report("Done", 1.0)
|
||||
+76
-8
@@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it.
|
||||
import configparser
|
||||
import os
|
||||
|
||||
VERSION = "0.0.0.135"
|
||||
VERSION = "0.0.0.182"
|
||||
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
|
||||
DEFAULT_GRID_SIZE = 10
|
||||
DEFAULT_ADD_TIMESTAMP = True
|
||||
@@ -41,6 +41,9 @@ status_label = None # the window's bottom status bar
|
||||
# colour wherever the change was made.
|
||||
DIRTY_TINT = "#ffe6e6"
|
||||
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,
|
||||
# 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_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 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
|
||||
@@ -79,9 +85,12 @@ is_dirty = False
|
||||
current_file_path = None
|
||||
active_components_list = [] # Tracks what components are actively painted
|
||||
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
|
||||
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_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
|
||||
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
|
||||
@@ -112,6 +121,8 @@ def load_preferences():
|
||||
left_pane_width = DEFAULT_LEFT_PANE_WIDTH
|
||||
sedona_home = ""
|
||||
trace_dasp = False
|
||||
lazy_loading = True
|
||||
show_slot_numbers = False
|
||||
|
||||
if os.path.exists(PROPERTIES_FILE):
|
||||
try:
|
||||
@@ -124,6 +135,8 @@ def load_preferences():
|
||||
)
|
||||
sedona_home = config.get("Editor", "sedona.home", fallback="").strip()
|
||||
trace_dasp = config.getboolean("Editor", "trace_dasp", fallback=False)
|
||||
lazy_loading = config.getboolean("Editor", "lazy_loading", fallback=True)
|
||||
show_slot_numbers = config.getboolean("Editor", "show_slot_numbers", fallback=False)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
@@ -144,12 +157,20 @@ def load_preferences():
|
||||
left_pane_width = max(
|
||||
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():
|
||||
"""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()
|
||||
try:
|
||||
config.read(PROPERTIES_FILE)
|
||||
except Exception:
|
||||
pass
|
||||
config["Editor"] = {
|
||||
"sedona.home": SEDONA_HOME,
|
||||
"grid_size": str(GRID_SIZE),
|
||||
@@ -157,11 +178,39 @@ def save_preferences():
|
||||
"box_width": str(BOX_WIDTH),
|
||||
"left_pane_width": str(LEFT_PANE_WIDTH),
|
||||
"trace_dasp": str(TRACE_DASP),
|
||||
"lazy_loading": str(LAZY_LOADING),
|
||||
"show_slot_numbers": str(SHOW_SLOT_NUMBERS),
|
||||
}
|
||||
with open(PROPERTIES_FILE, "w") as configfile:
|
||||
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():
|
||||
"""The window title with no file context, which handlers extend with their own suffix."""
|
||||
return f"Sedona SAX Offline Program Editor [v{VERSION}]"
|
||||
@@ -193,8 +242,7 @@ def mark_dirty():
|
||||
global is_dirty
|
||||
if not is_dirty:
|
||||
is_dirty = True
|
||||
if canvas is not None:
|
||||
canvas.config(bg=DIRTY_TINT)
|
||||
paint_sheet_tint()
|
||||
refresh_dirty_display()
|
||||
|
||||
|
||||
@@ -202,11 +250,26 @@ def mark_clean():
|
||||
"""Clears the unsaved-state marker after a successful save or a fresh load."""
|
||||
global is_dirty
|
||||
is_dirty = False
|
||||
if canvas is not None:
|
||||
canvas.config(bg=CLEAN_TINT)
|
||||
paint_sheet_tint()
|
||||
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_mode = False
|
||||
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}"
|
||||
|
||||
|
||||
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()
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 444 B |
@@ -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()
|
||||
+28
-6
@@ -21,8 +21,9 @@ import app_state
|
||||
class ConnectDialog:
|
||||
"""Modal dialog for entering device connection parameters."""
|
||||
|
||||
def __init__(self, parent, on_connect_callback):
|
||||
def __init__(self, parent, on_connect_callback, on_before_connect=None):
|
||||
self._callback = on_connect_callback
|
||||
self._before_connect = on_before_connect
|
||||
self._thread = None
|
||||
self._cancelled = False
|
||||
|
||||
@@ -103,6 +104,11 @@ class ConnectDialog:
|
||||
self._status_label.config(text="Invalid port number", foreground="#c00000")
|
||||
return
|
||||
|
||||
# Runs here, on the main thread, rather than in the worker: it is where a
|
||||
# device already connected is let go, and that takes the UI down with it.
|
||||
if self._before_connect is not None:
|
||||
self._before_connect()
|
||||
|
||||
self._btn_connect.config(state="disabled")
|
||||
self._status_label.config(text="Connecting...", foreground="#666666")
|
||||
|
||||
@@ -169,9 +175,10 @@ class DiscoverDialog:
|
||||
"""Modal dialog that broadcasts DASP DISCOVER, lists responding
|
||||
devices, and lets the user pick one to connect to."""
|
||||
|
||||
def __init__(self, parent, on_connect_callback):
|
||||
def __init__(self, parent, on_connect_callback, on_before_connect=None):
|
||||
self._parent = parent
|
||||
self._callback = on_connect_callback
|
||||
self._before_connect = on_before_connect
|
||||
self._discover_thread = None
|
||||
|
||||
self._dlg = tk.Toplevel(parent)
|
||||
@@ -194,7 +201,7 @@ class DiscoverDialog:
|
||||
hdr, text="Discover", command=self._do_discover, width=10)
|
||||
self._btn_discover.pack(side=tk.RIGHT)
|
||||
|
||||
self._wait_var = tk.StringVar(value="10")
|
||||
self._wait_var = tk.StringVar(value="2")
|
||||
ttk.Entry(hdr, textvariable=self._wait_var, width=4, justify="center").pack(
|
||||
side=tk.RIGHT, padx=(0, 4))
|
||||
ttk.Label(hdr, text="Wait (s):").pack(side=tk.RIGHT, padx=(8, 2))
|
||||
@@ -241,14 +248,23 @@ class DiscoverDialog:
|
||||
self._btn_connect.pack(side=tk.RIGHT, padx=(0, 8))
|
||||
|
||||
self._dlg.bind("<Escape>", lambda e: self._dlg.destroy())
|
||||
self._dlg.bind("<Return>", self._on_return)
|
||||
|
||||
self._dlg.update_idletasks()
|
||||
x = parent.winfo_x() + (parent.winfo_width() - self._dlg.winfo_width()) // 2
|
||||
y = parent.winfo_y() + (parent.winfo_height() - self._dlg.winfo_height()) // 2
|
||||
self._dlg.geometry(f"+{x}+{y}")
|
||||
self._dlg.lift()
|
||||
self._dlg.focus_force()
|
||||
self._btn_discover.focus_set()
|
||||
|
||||
# ── Discovery ──────────────────────────────────────────────────
|
||||
|
||||
def _on_return(self, event=None):
|
||||
if str(self._btn_discover.cget("state")) != "disabled":
|
||||
self._do_discover()
|
||||
return "break"
|
||||
|
||||
def _do_discover(self):
|
||||
try:
|
||||
port_int = int(self._port_var.get().strip())
|
||||
@@ -258,7 +274,7 @@ class DiscoverDialog:
|
||||
try:
|
||||
wait_time = max(1, min(60, int(self._wait_var.get().strip())))
|
||||
except ValueError:
|
||||
wait_time = 10
|
||||
wait_time = 2
|
||||
|
||||
self._btn_discover.config(state="disabled")
|
||||
self._status_label.config(text="Discovering devices...", foreground="#666666")
|
||||
@@ -318,7 +334,7 @@ class DiscoverDialog:
|
||||
host = values[0]
|
||||
port = self._port_var.get().strip()
|
||||
self._dlg.destroy()
|
||||
_CredentialsDialog(self._parent, self._callback, host, port)
|
||||
_CredentialsDialog(self._parent, self._callback, host, port, self._before_connect)
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
@@ -328,8 +344,9 @@ class DiscoverDialog:
|
||||
class _CredentialsDialog:
|
||||
"""Small modal that asks for username and password, then connects."""
|
||||
|
||||
def __init__(self, parent, on_connect_callback, host, port):
|
||||
def __init__(self, parent, on_connect_callback, host, port, on_before_connect=None):
|
||||
self._callback = on_connect_callback
|
||||
self._before_connect = on_before_connect
|
||||
self._host = host
|
||||
self._port = port
|
||||
self._thread = None
|
||||
@@ -397,6 +414,11 @@ class _CredentialsDialog:
|
||||
self._status_label.config(text="Invalid port", foreground="#c00000")
|
||||
return
|
||||
|
||||
# Runs here, on the main thread, rather than in the worker: it is where a
|
||||
# device already connected is let go, and that takes the UI down with it.
|
||||
if self._before_connect is not None:
|
||||
self._before_connect()
|
||||
|
||||
self._btn_connect.config(state="disabled")
|
||||
self._status_label.config(text="Connecting...", foreground="#666666")
|
||||
|
||||
|
||||
@@ -92,13 +92,136 @@ def _trace(direction, msg, datagram=None):
|
||||
import datetime
|
||||
ts = datetime.datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
||||
name = _MSG_TYPE_NAMES.get(msg.msg_type, f"?{msg.msg_type}")
|
||||
seq = f" seq={msg.seq_num}" if msg.seq_num >= 0 else ""
|
||||
ack = f" ack={msg.ack}" if msg.ack >= 0 else ""
|
||||
plen = len(msg.payload) if msg.payload else 0
|
||||
hexdump = ""
|
||||
arrow = {"S": ">> SEND", "R": "<< RECV"}.get(direction, direction)
|
||||
payload = msg.payload or b""
|
||||
size = f" ({len(datagram)} bytes)" if datagram else ""
|
||||
lines = [f"[DASP] {ts} {arrow} {name:<12} {_trace_fields(msg)}{size}"]
|
||||
if datagram:
|
||||
hexdump = " [" + datagram.hex(" ") + "]"
|
||||
print(f"[DASP] {ts} {direction} {name}{seq}{ack} payload={plen}{hexdump}")
|
||||
hdr_len = len(datagram) - len(payload)
|
||||
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
|
||||
# never wait on the console: when stdout is a pipe nobody reads, print blocks once the
|
||||
# pipe fills, and a file transfer's burst of chunks filled it in seconds -- the
|
||||
# receiver stopped and every request timed out. A full queue drops lines instead.
|
||||
_trace_queue = queue.Queue(maxsize=5000)
|
||||
_trace_thread = None
|
||||
_trace_dropped = 0
|
||||
|
||||
|
||||
def _trace_out(line):
|
||||
global _trace_thread, _trace_dropped
|
||||
if _trace_thread is None:
|
||||
_trace_thread = threading.Thread(target=_trace_writer, name="DaspTrace", daemon=True)
|
||||
_trace_thread.start()
|
||||
try:
|
||||
_trace_queue.put_nowait(line)
|
||||
except queue.Full:
|
||||
_trace_dropped += 1
|
||||
|
||||
|
||||
def _trace_writer():
|
||||
global _trace_dropped
|
||||
while True:
|
||||
line = _trace_queue.get()
|
||||
if _trace_dropped:
|
||||
dropped, _trace_dropped = _trace_dropped, 0
|
||||
line = f"[DASP] ... {dropped} trace lines dropped (console not keeping up)\n{line}"
|
||||
_write_stdout(line + "\n")
|
||||
|
||||
|
||||
def _write_stdout(text):
|
||||
"""Writes to the process's stdout handle directly, past Python's buffered writer.
|
||||
|
||||
The buffered writer has a lock, and a daemon thread holding it while the
|
||||
interpreter shuts down aborts the process ("could not acquire lock for stdout").
|
||||
"""
|
||||
import sys
|
||||
stream = sys.__stdout__
|
||||
if stream is None: # pythonw: no console to write to
|
||||
return
|
||||
try:
|
||||
fd = stream.fileno()
|
||||
data = text.encode(stream.encoding or "utf-8", errors="replace")
|
||||
while data:
|
||||
data = data[os.write(fd, data):]
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
_ERROR_HINTS = {
|
||||
@@ -460,13 +583,14 @@ class SendWindow:
|
||||
def send(self, payload):
|
||||
p = _Packet()
|
||||
p.enqueued_time = _ticks()
|
||||
p.payload = payload
|
||||
with self._cond:
|
||||
p.seq_num = self._seq_num & 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)
|
||||
p.payload = payload
|
||||
p.sent_time = _ticks()
|
||||
self._session._send_raw(self._to_msg(p))
|
||||
|
||||
def _enqueue(self, p):
|
||||
@@ -480,6 +604,7 @@ class SendWindow:
|
||||
else:
|
||||
self._tail.next = p
|
||||
self._tail = p
|
||||
p.sent_time = _ticks()
|
||||
self._size += 1
|
||||
self._cond.notify_all()
|
||||
|
||||
|
||||
+149
-9
@@ -31,6 +31,18 @@ def _slot_precision(type_name, slot_name):
|
||||
return 2
|
||||
|
||||
|
||||
def _as_sox_value(slot, val):
|
||||
"""An app.sab config value as a Sox config read leaves it in the cache."""
|
||||
if "s" in slot.flags:
|
||||
return val
|
||||
if slot.type in ("sys::Buf", "buf"):
|
||||
# Sox reads every Buf as a Str: its bytes but the last, as text.
|
||||
return val[:-1].decode("utf-8", errors="replace") if val else ""
|
||||
if slot.type == "bool":
|
||||
return val is not False # Sox reads a bool as u1 != 0, so null is true
|
||||
return val
|
||||
|
||||
|
||||
class DeviceSession:
|
||||
"""High-level session manager for a live Sedona device connection."""
|
||||
|
||||
@@ -38,6 +50,10 @@ class DeviceSession:
|
||||
self.sox_client = None
|
||||
self._event_queue = queue.Queue()
|
||||
self._current_subs = set() # comp_ids we subscribed for the open wiresheet
|
||||
# Components whose config and links came from app.sab and have not been read
|
||||
# since: their first sheet open skips reading those again.
|
||||
self._seeded = set()
|
||||
self.seed_info = "" # how the cache was filled, for the status bar
|
||||
|
||||
# ── Connect / Disconnect ────────────────────────────────────
|
||||
|
||||
@@ -53,18 +69,30 @@ class DeviceSession:
|
||||
|
||||
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:
|
||||
children = self.load_children(root_comp.id)
|
||||
if children:
|
||||
self.sox_client.update(children, CONFIG | RUNTIME)
|
||||
self.sox_client.update(children, self._values_to_read(children))
|
||||
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:
|
||||
return
|
||||
try:
|
||||
if self._current_subs:
|
||||
if unsubscribe and self._current_subs:
|
||||
comps = [self.sox_client.cache_get(cid)
|
||||
for cid in self._current_subs]
|
||||
comps = [c for c in comps if c is not None]
|
||||
@@ -159,6 +187,74 @@ class DeviceSession:
|
||||
if c is not None and c.kit_id == kit_id and c.type_id == type_id_val:
|
||||
c.type_name = full_name
|
||||
|
||||
# ── Seeding from app.sab ────────────────────────────────────
|
||||
|
||||
def seed_from_sab(self):
|
||||
"""Fills the cache with the whole app — tree, config and links — from app.sab.
|
||||
|
||||
Reading component by component costs a round trip each, and a device answers one
|
||||
request per pass of its loop: 230 components took 116 s on a P-ION, where saving
|
||||
the app and reading app.sab took 8 s. The app is saved first, so app.sab holds
|
||||
what is running. From here on Sox keeps the cache current, as it does for a
|
||||
lazily loaded one; runtime values are still read when a sheet opens.
|
||||
|
||||
Nothing goes into the cache unless the whole file decodes and its tree matches the
|
||||
device's, so on any failure the caller falls back to lazy loading. Returns the
|
||||
number of components.
|
||||
"""
|
||||
import app_backup
|
||||
import sab_app
|
||||
from sox import Link
|
||||
|
||||
client = self.sox_client
|
||||
root = client.cache_get(0) or client.load(0)
|
||||
app_backup.save_app(client)
|
||||
app = sab_app.SabApp(client.get_file(app_backup.APP_SAB))
|
||||
|
||||
# app.sab numbers kits by the app's schema and Sox by kits.scode's; the two can
|
||||
# differ, so types are matched by name, and a kit only when its checksum agrees.
|
||||
device_kits = {p.name: f"{p.checksum & 0xFFFFFFFF:08x}" for p in client.schema_parts}
|
||||
for name, checksum in app.kit_parts:
|
||||
if device_kits.get(name) != checksum:
|
||||
raise ValueError(f"kit {name} in app.sab is not the device's")
|
||||
type_key = {qname: key for key, qname in client.type_name_map.items()}
|
||||
|
||||
if [k["id"] for k in app.app["kids"]] != root.children:
|
||||
raise ValueError("app.sab does not match the running app")
|
||||
|
||||
comps = {}
|
||||
for c in app.comps.values():
|
||||
key = type_key.get(c["type"])
|
||||
if key is None:
|
||||
raise ValueError(f"no manifest for {c['type']}")
|
||||
comp = SoxComponent(c["id"], key[0], key[1], c["type"])
|
||||
comp.name = c["name"]
|
||||
comp.parent = c["parentId"]
|
||||
comp.children = [k["id"] for k in c["kids"]]
|
||||
comp.permissions = root.permissions
|
||||
for slot, val in c["props"]:
|
||||
comp.values[slot.name] = _as_sox_value(slot, val)
|
||||
comps[comp.id] = comp
|
||||
comps[0].subscription = root.subscription
|
||||
|
||||
# Sox reads a component's links both ways, into it and out of it.
|
||||
for from_c, from_slot, to_c, to_slot in app.links:
|
||||
link = Link(from_c["id"], from_slot, to_c["id"], to_slot)
|
||||
comps[from_c["id"]].links.append(link)
|
||||
if to_c["id"] != from_c["id"]:
|
||||
comps[to_c["id"]].links.append(link)
|
||||
|
||||
for comp in comps.values():
|
||||
client.cache_add(comp)
|
||||
self._seeded = set(comps)
|
||||
return len(comps)
|
||||
|
||||
def _values_to_read(self, comps):
|
||||
"""What to read for `comps` on a sheet open: config too, unless app.sab gave it."""
|
||||
if all(c.id in self._seeded for c in comps):
|
||||
return RUNTIME
|
||||
return CONFIG | RUNTIME
|
||||
|
||||
# ── Lazy Loading ────────────────────────────────────────────
|
||||
|
||||
def load_children(self, comp_id):
|
||||
@@ -176,6 +272,41 @@ class DeviceSession:
|
||||
for cid in parent.children
|
||||
if self.sox_client.cache_get(cid) is not None]
|
||||
|
||||
def used_kit_names(self):
|
||||
"""Every kit a component on the device belongs to, loading the whole tree to see.
|
||||
|
||||
The DOM only holds what has been opened, so it cannot answer this on its own. Only
|
||||
the tree is read — no config or runtime.
|
||||
|
||||
Requests go one at a time. A small controller handles a single folder's burst fine,
|
||||
but a back-to-back stream of them fills its receive buffer; it stops acking, and
|
||||
DASP closes the whole session ("unacked send timeout"). Waiting for each reply
|
||||
before sending the next keeps it at the pace the device sets. Anything already
|
||||
cached costs nothing, so only the first call is slow.
|
||||
"""
|
||||
client = self.sox_client
|
||||
if client is None:
|
||||
return set()
|
||||
|
||||
pending = [0]
|
||||
loaded = 0
|
||||
while pending:
|
||||
comp_id = pending.pop()
|
||||
comp = client.cache_get(comp_id)
|
||||
if comp is None:
|
||||
comp = client.load_ids([comp_id], checked=False)[0]
|
||||
loaded += 1
|
||||
if loaded % 20 == 0:
|
||||
app_state.set_status(f"Reading components from the device… {loaded}")
|
||||
if app_state.status_label is not None:
|
||||
app_state.status_label.update_idletasks()
|
||||
if comp is not None:
|
||||
pending.extend(comp.children)
|
||||
|
||||
parts = client.schema_parts or []
|
||||
return {parts[c.kit_id].name for c in client.cache
|
||||
if c is not None and 0 <= c.kit_id < len(parts)}
|
||||
|
||||
def find_component_of_type(self, type_name, slot_name):
|
||||
"""A component of `type_name` with `slot_name` read, loading where services live.
|
||||
|
||||
@@ -216,9 +347,9 @@ class DeviceSession:
|
||||
|
||||
all_link_remote_ids = set()
|
||||
for child in children:
|
||||
links = self.sox_client.read_links(child.id)
|
||||
child.links = links
|
||||
for lnk in links:
|
||||
if child.id not in self._seeded:
|
||||
child.links = self.sox_client.read_links(child.id)
|
||||
for lnk in child.links:
|
||||
all_link_remote_ids.add(lnk.from_comp_id)
|
||||
all_link_remote_ids.add(lnk.to_comp_id)
|
||||
|
||||
@@ -230,7 +361,14 @@ class DeviceSession:
|
||||
if remote_ids:
|
||||
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)
|
||||
to_subscribe = [folder_comp] + children if folder_comp else children
|
||||
@@ -294,8 +432,10 @@ class DeviceSession:
|
||||
schema_elem = ET.SubElement(sax_root, "schema")
|
||||
if self.sox_client and self.sox_client.schema_parts:
|
||||
for part in self.sox_client.schema_parts:
|
||||
# Sox sends the checksum as a signed int; .sax files and manifest names
|
||||
# spell it as 8 hex digits, and nothing matches unless this does too.
|
||||
ET.SubElement(schema_elem, "kit",
|
||||
name=part.name, checksum=str(part.checksum))
|
||||
name=part.name, checksum=f"{part.checksum & 0xFFFFFFFF:08x}")
|
||||
|
||||
app_elem = ET.SubElement(sax_root, "app")
|
||||
if root_comp is not None:
|
||||
|
||||
+6
-1
@@ -5,9 +5,14 @@ add_timestamp = True
|
||||
box_width = 250
|
||||
left_pane_width = 230
|
||||
trace_dasp = True
|
||||
lazy_loading = True
|
||||
show_slot_numbers = False
|
||||
|
||||
[Device]
|
||||
host = 192.168.1.29
|
||||
host = 192.168.0.26
|
||||
port = 1876
|
||||
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()
|
||||
+584
@@ -0,0 +1,584 @@
|
||||
"""Linking on the wiresheet with the mouse: slot hover, drag-out rubber band, drop.
|
||||
|
||||
Hovering a slot row highlights the half of it the pointer is in and swaps the cursor for
|
||||
a horizontal arrow: the right half of a row is where a link leaves a slot, so it points
|
||||
right, and the left half is where one arrives, so it points left. Pressing either
|
||||
button on a slot and dragging pulls a rubber-band line out of that side; letting go on
|
||||
another slot makes the link, and letting go on a component's title bar opens the Link
|
||||
dialog for the two components with the dragged slot already picked. Started on the right half, the slot under the pointer is
|
||||
the target; started on the left half, it is the source -- so a link can be drawn from
|
||||
either end. While dragging, the cursor points along the flow of the link over a slot that
|
||||
would take it, and turns into a no-entry sign over one that would not, so a bad drop is
|
||||
visible before the button is released.
|
||||
|
||||
A right press that never moves still opens the component's context menu. A left drag
|
||||
that starts on bare sheet draws a selection rectangle, and selects the boxes wholly
|
||||
inside it on release; with Ctrl held they join the current selection. A left press
|
||||
anywhere on a box selects it, but only a press on its title bar moves it; on a slot row
|
||||
it draws a link instead, as in Workbench.
|
||||
"""
|
||||
|
||||
import os
|
||||
import struct
|
||||
import tempfile
|
||||
import tkinter as tk
|
||||
|
||||
import app_state
|
||||
import sax_file
|
||||
import wiresheet
|
||||
from sax_dom import get_component_path
|
||||
|
||||
NO_ENTRY_PNG = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
"assets", "cursors", "no_entry.png")
|
||||
DRAG_THRESHOLD = 4 # pixels the pointer must travel before a press is a drag
|
||||
HOVER_COLOR = "#1f4e79"
|
||||
DROP_OK_COLOR = "#2e9d3a"
|
||||
DROP_BAD_COLOR = "#d62828"
|
||||
RUBBER_COLOR = "#2f6fba"
|
||||
MARQUEE_COLOR = "#2f6fba"
|
||||
CTRL_MASK = 0x0004
|
||||
|
||||
_cursors = {} # role -> Tk cursor spec, built on first use
|
||||
_press = None # pending right press: {"hit", "x", "y", "event"}
|
||||
_drag = None # live link drag: {"hit", "anchor"}
|
||||
_marquee = None # left press on bare sheet: {"x", "y", "cx", "cy", "state", "active"}
|
||||
_menu_callback = None # opens the ordinary right-click menu for an event
|
||||
_select_boxes = None # makes a list of comp tags the box selection
|
||||
_link_dialog = None # opens the Link dialog: (source, target, src_slot, tgt_slot)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- cursors
|
||||
|
||||
def _write_cur(path, pixels, hotspot):
|
||||
"""Writes a 32x32 32-bit Windows .cur from rows of (r, g, b, a) tuples."""
|
||||
size = 32
|
||||
xor = bytearray()
|
||||
for row in reversed(pixels): # DIBs are stored bottom-up
|
||||
for r, g, b, a in row:
|
||||
xor += bytes((b, g, r, a))
|
||||
mask = bytearray()
|
||||
for row in reversed(pixels):
|
||||
bits = 0
|
||||
for x, (_, _, _, a) in enumerate(row):
|
||||
if a == 0:
|
||||
bits |= 1 << (31 - x)
|
||||
mask += struct.pack(">I", bits)
|
||||
header = struct.pack("<IiiHHIIiiII", 40, size, size * 2, 1, 32, 0,
|
||||
len(xor) + len(mask), 0, 0, 0, 0)
|
||||
image = header + bytes(xor) + bytes(mask)
|
||||
icondir = struct.pack("<HHH", 0, 2, 1)
|
||||
entry = struct.pack("<BBBBHHII", size, size, 0, 0, hotspot[0], hotspot[1],
|
||||
len(image), 6 + 16)
|
||||
with open(path, "wb") as f:
|
||||
f.write(icondir + entry + image)
|
||||
|
||||
|
||||
def _inside_polygon(px, py, poly):
|
||||
inside = False
|
||||
j = len(poly) - 1
|
||||
for i in range(len(poly)):
|
||||
xi, yi = poly[i]
|
||||
xj, yj = poly[j]
|
||||
if (yi > py) != (yj > py) and px < (xj - xi) * (py - yi) / (yj - yi) + xi:
|
||||
inside = not inside
|
||||
j = i
|
||||
return inside
|
||||
|
||||
|
||||
def _arrow_pixels(pointing_right):
|
||||
"""A hollow block arrow: white body, black rim -- the Workbench link cursor."""
|
||||
poly = [(9, 14), (16, 14), (16, 10), (23, 16), (16, 22), (16, 18), (9, 18)]
|
||||
if not pointing_right:
|
||||
poly = [(32 - x, y) for x, y in poly]
|
||||
inside = [[_inside_polygon(x + 0.5, y + 0.5, poly) for x in range(32)]
|
||||
for y in range(32)]
|
||||
rows = []
|
||||
for y in range(32):
|
||||
row = []
|
||||
for x in range(32):
|
||||
if not inside[y][x]:
|
||||
row.append((0, 0, 0, 0))
|
||||
continue
|
||||
rim = any(not (0 <= x + dx < 32 and 0 <= y + dy < 32 and inside[y + dy][x + dx])
|
||||
for dx in (-1, 0, 1) for dy in (-1, 0, 1))
|
||||
row.append((0, 0, 0, 255) if rim else (255, 255, 255, 255))
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def _no_entry_pixels():
|
||||
"""assets/cursors/no_entry.png, centred on a 32x32 cursor; None if unreadable.
|
||||
|
||||
Read with PIL when it is installed, which keeps the image's soft edges; otherwise
|
||||
with Tk's own PNG reader, which only knows whether a pixel is transparent or not.
|
||||
"""
|
||||
if not os.path.exists(NO_ENTRY_PNG):
|
||||
return None
|
||||
try:
|
||||
from PIL import Image
|
||||
image = Image.open(NO_ENTRY_PNG).convert("RGBA")
|
||||
width, height = image.size
|
||||
|
||||
def pixel(x, y):
|
||||
return image.getpixel((x, y))
|
||||
except ImportError:
|
||||
image = tk.PhotoImage(file=NO_ENTRY_PNG)
|
||||
width, height = image.width(), image.height()
|
||||
|
||||
def pixel(x, y):
|
||||
if image.transparency_get(x, y):
|
||||
return (0, 0, 0, 0)
|
||||
return tuple(image.get(x, y)) + (255,)
|
||||
if width > 32 or height > 32:
|
||||
return None
|
||||
left, top = 16 - width // 2, 16 - height // 2
|
||||
rows = [[(0, 0, 0, 0)] * 32 for _ in range(32)]
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
rows[top + y][left + x] = pixel(x, y)
|
||||
return rows
|
||||
|
||||
|
||||
def _cursor(role):
|
||||
"""The Tk cursor for 'right', 'left' or 'no', drawn once and cached."""
|
||||
if role in _cursors:
|
||||
return _cursors[role]
|
||||
fallback = {"right": "sb_right_arrow", "left": "sb_left_arrow", "no": "X_cursor"}[role]
|
||||
spec = fallback
|
||||
if os.name == "nt":
|
||||
try:
|
||||
folder = os.path.join(tempfile.gettempdir(), "sedona_editor_cursors")
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
path = os.path.join(folder, f"link_{role}.cur")
|
||||
if role == "right":
|
||||
_write_cur(path, _arrow_pixels(True), (23, 16))
|
||||
elif role == "left":
|
||||
_write_cur(path, _arrow_pixels(False), (9, 16))
|
||||
else:
|
||||
pixels = _no_entry_pixels()
|
||||
if pixels is None:
|
||||
raise OSError("no_entry.png missing or unreadable")
|
||||
_write_cur(path, pixels, (16, 16))
|
||||
candidate = "{@" + path.replace("\\", "/") + "}"
|
||||
app_state.canvas.configure(cursor=candidate) # proves Tk can load it
|
||||
spec = candidate
|
||||
except (OSError, tk.TclError):
|
||||
spec = "no" if role == "no" else fallback
|
||||
_cursors[role] = spec
|
||||
return spec
|
||||
|
||||
|
||||
def _set_cursor(role):
|
||||
canvas = app_state.canvas
|
||||
try:
|
||||
canvas.configure(cursor=_cursor(role) if role else "")
|
||||
except tk.TclError:
|
||||
canvas.configure(cursor="")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- hit test
|
||||
|
||||
def slot_at(cx, cy):
|
||||
"""The slot row under a canvas point, as a dict, or None.
|
||||
|
||||
Picks the topmost box under the point, so a box lying over another wins, and says
|
||||
which half of the row the point is in.
|
||||
"""
|
||||
canvas = app_state.canvas
|
||||
for item in reversed(canvas.find_overlapping(cx, cy, cx, cy)):
|
||||
tags = canvas.gettags(item)
|
||||
if "component" not in tags:
|
||||
continue
|
||||
comp_tag = next((t for t in tags if t.startswith("comp_group_")), None)
|
||||
if comp_tag is None:
|
||||
continue
|
||||
box = wiresheet.get_box_bounds(comp_tag)
|
||||
if box is None:
|
||||
return None
|
||||
zoom = app_state.zoom
|
||||
half_row = wiresheet.SLOT_ROW_HEIGHT * zoom / 2
|
||||
for slot_name, offset in app_state.component_slot_offsets.get(comp_tag, {}).items():
|
||||
row_y = box[1] + offset * zoom
|
||||
if row_y - half_row <= cy < row_y + half_row:
|
||||
return {
|
||||
"comp_tag": comp_tag,
|
||||
"comp": app_state.canvas_comp_map.get(comp_tag),
|
||||
"slot": slot_name,
|
||||
"side": "right" if cx >= (box[0] + box[2]) / 2 else "left",
|
||||
"box": box,
|
||||
"row_y": row_y,
|
||||
"half_row": half_row,
|
||||
}
|
||||
return None # over a header or footer, not a slot
|
||||
return None
|
||||
|
||||
|
||||
def title_at(cx, cy):
|
||||
"""The comp tag whose title bar is topmost under a canvas point, or None."""
|
||||
canvas = app_state.canvas
|
||||
for item in reversed(canvas.find_overlapping(cx, cy, cx, cy)):
|
||||
tags = canvas.gettags(item)
|
||||
if "component" not in tags:
|
||||
continue
|
||||
comp_tag = next((t for t in tags if t.startswith("comp_group_")), None)
|
||||
if comp_tag is None:
|
||||
continue
|
||||
box = wiresheet.get_box_bounds(comp_tag)
|
||||
if box and box[1] <= cy <= box[1] + wiresheet.HEADER_HEIGHT * app_state.zoom:
|
||||
return comp_tag
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _slot_info(comp, slot_name):
|
||||
for slot in app_state.final_components_registry.get(comp.get("type", ""), []):
|
||||
if slot["name"] == slot_name:
|
||||
return slot
|
||||
return None
|
||||
|
||||
|
||||
def _check_link(source, target):
|
||||
"""Returns (ok, reason) for a link from one hit slot to another."""
|
||||
if source["comp"] is None or target["comp"] is None:
|
||||
return False, "No component there."
|
||||
if source["comp"] is target["comp"] and source["slot"] == target["slot"]:
|
||||
return False, "A slot cannot be linked to itself."
|
||||
src = _slot_info(source["comp"], source["slot"])
|
||||
tgt = _slot_info(target["comp"], target["slot"])
|
||||
if src is None or tgt is None:
|
||||
return False, "Slot not found in the kit manifest."
|
||||
for slot in (src, tgt):
|
||||
if "a" in slot["flags"] or slot["type"] == "void":
|
||||
return False, f"'{slot['name']}' is an action and cannot be linked."
|
||||
if tgt["readonly"]:
|
||||
return False, f"'{tgt['name']}' is read-only and cannot be a link target."
|
||||
if src["type"] != tgt["type"]:
|
||||
return False, (f"Type mismatch: {source['slot']} is {src['type']}, "
|
||||
f"{target['slot']} is {tgt['type']}.")
|
||||
from_ref, to_ref = _refs(source, target)
|
||||
links = app_state.xml_root_element.find("links")
|
||||
if links is not None:
|
||||
for link in links.findall("link"):
|
||||
if link.get("from") == from_ref and link.get("to") == to_ref:
|
||||
return False, "That link already exists."
|
||||
return True, f"Link {from_ref} → {to_ref}"
|
||||
|
||||
|
||||
def _refs(source, target):
|
||||
root = app_state.xml_root_element
|
||||
return (f"{get_component_path(source['comp'], root)}.{source['slot']}",
|
||||
f"{get_component_path(target['comp'], root)}.{target['slot']}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- drawing
|
||||
|
||||
def _highlight(hit, color):
|
||||
"""Rings the half of a slot row the pointer is over, with a bar on its outer edge."""
|
||||
canvas = app_state.canvas
|
||||
canvas.delete("slot_hover")
|
||||
if hit is None:
|
||||
return
|
||||
left, _, right, _ = hit["box"]
|
||||
mid = (left + right) / 2
|
||||
top, bottom = hit["row_y"] - hit["half_row"], hit["row_y"] + hit["half_row"]
|
||||
x1, x2 = (mid, right - 1) if hit["side"] == "right" else (left + 1, mid)
|
||||
canvas.create_rectangle(x1, top, x2, bottom, outline=color, width=2,
|
||||
tags=("slot_hover",))
|
||||
edge = right - 2 if hit["side"] == "right" else left + 2
|
||||
canvas.create_line(edge, top, edge, bottom, fill=color, width=4, tags=("slot_hover",))
|
||||
canvas.tag_raise("slot_hover")
|
||||
|
||||
|
||||
def _highlight_title(comp_tag, color):
|
||||
"""Rings a box's title bar: the drop that opens the Link dialog for that box."""
|
||||
canvas = app_state.canvas
|
||||
canvas.delete("slot_hover")
|
||||
left, top, right, _ = wiresheet.get_box_bounds(comp_tag)
|
||||
canvas.create_rectangle(left + 1, top + 1, right - 1,
|
||||
top + wiresheet.HEADER_HEIGHT * app_state.zoom,
|
||||
outline=color, width=2, tags=("slot_hover",))
|
||||
canvas.tag_raise("slot_hover")
|
||||
|
||||
|
||||
def _anchor(hit):
|
||||
left, _, right, _ = hit["box"]
|
||||
return (right if hit["side"] == "right" else left), hit["row_y"]
|
||||
|
||||
|
||||
def clear():
|
||||
"""Drops any hover ring, rubber band and drag state; called on every repaint."""
|
||||
global _press, _drag, _marquee
|
||||
canvas = app_state.canvas
|
||||
if canvas is None:
|
||||
return
|
||||
canvas.delete("slot_hover")
|
||||
canvas.delete("link_rubber")
|
||||
canvas.delete("marquee")
|
||||
_press = None
|
||||
_drag = None
|
||||
_marquee = None
|
||||
_set_cursor(None)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- events
|
||||
|
||||
def on_motion(event):
|
||||
"""Plain pointer movement: ring the slot half under the pointer."""
|
||||
if _drag is not None or app_state.drag_data.get("tag"):
|
||||
return
|
||||
canvas = app_state.canvas
|
||||
hit = slot_at(canvas.canvasx(event.x), canvas.canvasy(event.y))
|
||||
_highlight(hit, HOVER_COLOR)
|
||||
_set_cursor(hit["side"] if hit else None)
|
||||
|
||||
|
||||
def on_leave(_event):
|
||||
if _drag is None:
|
||||
app_state.canvas.delete("slot_hover")
|
||||
_set_cursor(None)
|
||||
|
||||
|
||||
def _start_press(event):
|
||||
"""Records a press on a slot row as a link drag in waiting; None if not on a slot."""
|
||||
global _press
|
||||
canvas = app_state.canvas
|
||||
hit = slot_at(canvas.canvasx(event.x), canvas.canvasy(event.y))
|
||||
if hit is None or hit["comp"] is None:
|
||||
_press = None
|
||||
return None
|
||||
_press = {"hit": hit, "x": event.x, "y": event.y}
|
||||
return _press
|
||||
|
||||
|
||||
def _drag_motion(event):
|
||||
"""Pulls the rubber band to the pointer and judges the slot under it, if any."""
|
||||
global _drag
|
||||
canvas = app_state.canvas
|
||||
if _drag is None:
|
||||
if _press is None:
|
||||
return
|
||||
if abs(event.x - _press["x"]) < DRAG_THRESHOLD and abs(event.y - _press["y"]) < DRAG_THRESHOLD:
|
||||
return
|
||||
_drag = {"hit": _press["hit"], "anchor": _anchor(_press["hit"])}
|
||||
app_state.set_status("Drop on a slot to link it; release elsewhere to cancel.")
|
||||
|
||||
cx, cy = canvas.canvasx(event.x), canvas.canvasy(event.y)
|
||||
start = _drag["hit"]
|
||||
ax, ay = _drag["anchor"]
|
||||
canvas.delete("link_rubber")
|
||||
canvas.create_line(ax, ay, cx, cy, fill=RUBBER_COLOR, width=2, dash=(4, 2),
|
||||
tags=("link_rubber",))
|
||||
canvas.tag_raise("link_rubber")
|
||||
|
||||
outgoing = start["side"] == "right"
|
||||
hit = slot_at(cx, cy)
|
||||
if hit is None:
|
||||
title_tag = title_at(cx, cy)
|
||||
if title_tag is not None:
|
||||
# Its own title too: a component may link one of its slots to another.
|
||||
_highlight_title(title_tag, DROP_OK_COLOR)
|
||||
_set_cursor("right" if outgoing else "left")
|
||||
app_state.set_status("Drop to pick the other slot in the Link dialog.")
|
||||
return
|
||||
if hit is None or (hit["comp_tag"] == start["comp_tag"] and hit["slot"] == start["slot"]):
|
||||
canvas.delete("slot_hover")
|
||||
_set_cursor(None)
|
||||
return
|
||||
ok, reason = _check_link(start, hit) if outgoing else _check_link(hit, start)
|
||||
# Ring the side of the drop slot the link would attach to, not the side under the
|
||||
# pointer: a link always arrives on the left of a row and leaves from its right.
|
||||
shown = dict(hit, side="left" if outgoing else "right")
|
||||
_highlight(shown, DROP_OK_COLOR if ok else DROP_BAD_COLOR)
|
||||
_set_cursor(("right" if outgoing else "left") if ok else "no")
|
||||
app_state.set_status(reason, warn=not ok)
|
||||
|
||||
|
||||
def _finish(event):
|
||||
"""Ends a press. Returns 'link' after a drag (made or refused), 'click' after a
|
||||
press that never moved, None if no press on a slot was pending."""
|
||||
global _press, _drag
|
||||
canvas = app_state.canvas
|
||||
press, drag = _press, _drag
|
||||
_press = _drag = None
|
||||
canvas.delete("link_rubber")
|
||||
canvas.delete("slot_hover")
|
||||
_set_cursor(None)
|
||||
|
||||
if drag is None:
|
||||
return "click" if press is not None else None
|
||||
|
||||
cx, cy = canvas.canvasx(event.x), canvas.canvasy(event.y)
|
||||
hit = slot_at(cx, cy)
|
||||
start = drag["hit"]
|
||||
title_tag = title_at(cx, cy) if hit is None else None
|
||||
if title_tag is not None:
|
||||
other = app_state.canvas_comp_map.get(title_tag)
|
||||
if other is None:
|
||||
app_state.set_status("Link cancelled.")
|
||||
elif start["side"] == "right":
|
||||
_link_dialog(start["comp"], other, start["slot"], None)
|
||||
else:
|
||||
_link_dialog(other, start["comp"], None, start["slot"])
|
||||
return "link"
|
||||
if hit is None or (hit["comp_tag"] == start["comp_tag"] and hit["slot"] == start["slot"]):
|
||||
app_state.set_status("Link cancelled.")
|
||||
return "link"
|
||||
source, target = (start, hit) if start["side"] == "right" else (hit, start)
|
||||
ok, reason = _check_link(source, target)
|
||||
if not ok:
|
||||
app_state.set_status(f"Cannot link: {reason}", warn=True)
|
||||
return "link"
|
||||
from_ref, to_ref = _refs(source, target)
|
||||
sax_file.add_link(from_ref, to_ref)
|
||||
app_state.set_status(f"Linked {from_ref} → {to_ref}")
|
||||
return "link"
|
||||
|
||||
|
||||
# Right button: on a slot it drags a link, and a press that never moves is the menu.
|
||||
|
||||
def on_right_press(event):
|
||||
if _start_press(event) is None:
|
||||
_menu_callback(event)
|
||||
|
||||
|
||||
def on_right_motion(event):
|
||||
_drag_motion(event)
|
||||
|
||||
|
||||
def on_right_release(event):
|
||||
if _finish(event) == "click":
|
||||
_menu_callback(event)
|
||||
|
||||
|
||||
# Left button on bare sheet: dragging pulls out a selection rectangle that selects every
|
||||
# box wholly inside it on release (Ctrl adds them to the current selection).
|
||||
|
||||
def _on_bare_sheet(cx, cy):
|
||||
"""True if no box or link lies under a canvas point."""
|
||||
canvas = app_state.canvas
|
||||
for item in canvas.find_overlapping(cx - 3, cy - 3, cx + 3, cy + 3):
|
||||
tags = canvas.gettags(item)
|
||||
if "component" in tags or "link" in tags:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _marquee_motion(event):
|
||||
"""Draws the selection rectangle once the pointer has moved far enough."""
|
||||
global _marquee
|
||||
if _marquee is None:
|
||||
return
|
||||
if not _marquee["active"]:
|
||||
if abs(event.x - _marquee["x"]) < DRAG_THRESHOLD and \
|
||||
abs(event.y - _marquee["y"]) < DRAG_THRESHOLD:
|
||||
return
|
||||
_marquee["active"] = True
|
||||
app_state.canvas.delete("slot_hover")
|
||||
canvas = app_state.canvas
|
||||
canvas.delete("marquee")
|
||||
canvas.create_rectangle(_marquee["cx"], _marquee["cy"],
|
||||
canvas.canvasx(event.x), canvas.canvasy(event.y),
|
||||
outline=MARQUEE_COLOR, width=1, dash=(4, 2),
|
||||
tags=("marquee",))
|
||||
canvas.tag_raise("marquee")
|
||||
|
||||
|
||||
def _marquee_finish(event):
|
||||
"""Ends a rectangle drag. Returns True if it selected, False if it was a click."""
|
||||
global _marquee
|
||||
marquee, _marquee = _marquee, None
|
||||
canvas = app_state.canvas
|
||||
canvas.delete("marquee")
|
||||
if marquee is None or not marquee["active"]:
|
||||
return False
|
||||
cx, cy = canvas.canvasx(event.x), canvas.canvasy(event.y)
|
||||
left, right = sorted((marquee["cx"], cx))
|
||||
top, bottom = sorted((marquee["cy"], cy))
|
||||
inside = []
|
||||
for comp_tag in app_state.canvas_comp_map:
|
||||
box = wiresheet.get_box_bounds(comp_tag)
|
||||
if box and left <= box[0] and top <= box[1] and box[2] <= right and box[3] <= bottom:
|
||||
inside.append(comp_tag)
|
||||
adding = (marquee["state"] | event.state) & CTRL_MASK
|
||||
wiresheet.deselect_link() # links and boxes are never selected together
|
||||
if adding:
|
||||
current = [t for t in app_state.selected_canvas_tags if t not in inside]
|
||||
_select_boxes(current + inside)
|
||||
else:
|
||||
_select_boxes(inside)
|
||||
count = len(app_state.selected_canvas_tags)
|
||||
app_state.set_status(f"{count} component{'s' if count != 1 else ''} selected.")
|
||||
return True
|
||||
|
||||
|
||||
# Left button: anywhere on a box selects it. Only the title bar moves it; a slot row
|
||||
# drags out a link, and the rest of the box does nothing more than select.
|
||||
|
||||
def _on_title_bar(comp_tag, cy):
|
||||
box = wiresheet.get_box_bounds(comp_tag)
|
||||
return box is not None and box[1] <= cy <= box[1] + wiresheet.HEADER_HEIGHT * app_state.zoom
|
||||
|
||||
|
||||
def on_left_press(event):
|
||||
global _marquee
|
||||
_marquee = None
|
||||
canvas = app_state.canvas
|
||||
cx, cy = canvas.canvasx(event.x), canvas.canvasy(event.y)
|
||||
if _on_bare_sheet(cx, cy):
|
||||
app_state.drag_data["tag"] = None
|
||||
if not event.state & CTRL_MASK:
|
||||
# A plain click on bare sheet still clears the selection, drag or not.
|
||||
wiresheet.deselect_link()
|
||||
_select_boxes([])
|
||||
_marquee = {"x": event.x, "y": event.y, "cx": cx, "cy": cy,
|
||||
"state": event.state, "active": False}
|
||||
return
|
||||
_box_handlers["press"](event) # selection, and arms a box move
|
||||
if _start_press(event) is not None:
|
||||
app_state.canvas.delete("slot_hover")
|
||||
comp_tag = app_state.drag_data.get("tag")
|
||||
if comp_tag and not _on_title_bar(comp_tag, app_state.canvas.canvasy(event.y)):
|
||||
app_state.drag_data["tag"] = None # selected, but not picked up
|
||||
|
||||
|
||||
def on_left_motion(event):
|
||||
if _marquee is not None:
|
||||
_marquee_motion(event)
|
||||
elif _press is not None or _drag is not None:
|
||||
_drag_motion(event)
|
||||
else:
|
||||
_box_handlers["motion"](event)
|
||||
|
||||
|
||||
def on_left_release(event):
|
||||
if _marquee is not None:
|
||||
_marquee_finish(event)
|
||||
elif _finish(event) in (None, "click"):
|
||||
_box_handlers["release"](event)
|
||||
|
||||
|
||||
_box_handlers = {}
|
||||
|
||||
|
||||
def install(canvas, menu_callback, box_press, box_motion, box_release, link_dialog,
|
||||
select_boxes):
|
||||
"""Binds hover, link dragging on both buttons, and the box handlers it wraps.
|
||||
|
||||
menu_callback opens the context menu; box_* are the sheet's own left-button
|
||||
select-and-move handlers, which still run whenever a press is not on a slot row;
|
||||
link_dialog opens the Link dialog when a link is dropped on a title bar;
|
||||
select_boxes makes a list of comp tags the selection after a rectangle drag.
|
||||
"""
|
||||
global _menu_callback, _link_dialog, _select_boxes
|
||||
_menu_callback = menu_callback
|
||||
_link_dialog = link_dialog
|
||||
_select_boxes = select_boxes
|
||||
_box_handlers.update(press=box_press, motion=box_motion, release=box_release)
|
||||
canvas.bind("<Motion>", on_motion)
|
||||
canvas.bind("<Leave>", on_leave, add="+")
|
||||
canvas.bind("<ButtonPress-1>", on_left_press)
|
||||
canvas.bind("<B1-Motion>", on_left_motion)
|
||||
canvas.bind("<ButtonRelease-1>", on_left_release)
|
||||
canvas.bind("<ButtonPress-3>", on_right_press)
|
||||
canvas.bind("<B3-Motion>", on_right_motion)
|
||||
canvas.bind("<ButtonRelease-3>", on_right_release)
|
||||
+424
-71
@@ -5,8 +5,17 @@ Checks app_state.live_mode and routes mutations through DeviceSession/SoxClient.
|
||||
In offline mode, falls through to the original DOM-only code paths.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
from tkinter import messagebox
|
||||
|
||||
import app_state
|
||||
from sox import 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
|
||||
# it, so a caller can put it in front of the user with warn_failure().
|
||||
last_error = ""
|
||||
|
||||
|
||||
def is_live():
|
||||
@@ -15,30 +24,59 @@ def is_live():
|
||||
and app_state.device_session.is_connected)
|
||||
|
||||
|
||||
def _fail(reason):
|
||||
global last_error
|
||||
last_error = reason
|
||||
return None
|
||||
|
||||
|
||||
def _session():
|
||||
"""The connected DeviceSession, or None with last_error saying why not."""
|
||||
if not is_live():
|
||||
_fail("The device is not connected.")
|
||||
return None
|
||||
return app_state.device_session
|
||||
|
||||
|
||||
def warn_failure(title, what, detail=None):
|
||||
"""Tells the user, in a dialog and the status bar, that `what` did not reach the device.
|
||||
|
||||
`detail` defaults to last_error. Nothing retries a write that failed, so the dialog
|
||||
is the only record the user gets that the editor and the device may now disagree.
|
||||
"""
|
||||
reason = detail if detail is not None else last_error
|
||||
app_state.set_status(f"{what}: {reason}", warn=True)
|
||||
messagebox.showwarning(title, f"{what}.\n\n{reason}", parent=app_state.window)
|
||||
|
||||
|
||||
# ── Property Write ──────────────────────────────────────────────
|
||||
|
||||
def live_set_prop_value(element, slot_name, value_str):
|
||||
"""In live mode, sends a Sox write to the device. Returns True on success."""
|
||||
if not is_live():
|
||||
ds = _session()
|
||||
if ds is None:
|
||||
return False
|
||||
|
||||
ds = app_state.device_session
|
||||
comp_id = _comp_id(element)
|
||||
if comp_id is None:
|
||||
_fail(f"'{element.get('name', '?')}' has no component id.")
|
||||
return False
|
||||
|
||||
slot_info = _find_slot(ds, comp_id, slot_name)
|
||||
if slot_info is None:
|
||||
return False
|
||||
|
||||
type_id = type_id_for_slot(slot_info["type_name"])
|
||||
coerced = _coerce_value(type_id, value_str)
|
||||
type_id = slot_info["type_id"]
|
||||
try:
|
||||
coerced = _coerce_value(type_id, value_str)
|
||||
except (TypeError, ValueError, binascii.Error) as e:
|
||||
_fail(f"'{value_str}' is not a valid value for {slot_name}: {e}")
|
||||
return False
|
||||
|
||||
try:
|
||||
ds.sox_client.write_prop(comp_id, slot_info["id"], type_id, coerced)
|
||||
return True
|
||||
except Exception as e:
|
||||
app_state.set_status(f"Write failed: {e}", warn=True)
|
||||
_fail(f"The device rejected the write to {slot_name}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@@ -46,43 +84,39 @@ def live_set_prop_value(element, slot_name, value_str):
|
||||
|
||||
def live_add_component(parent_element, kit_name, type_name, comp_name, config_values=None):
|
||||
"""In live mode, sends a Sox add to the device. Returns new comp_id or None."""
|
||||
if not is_live():
|
||||
ds = _session()
|
||||
if ds is None:
|
||||
return None
|
||||
|
||||
ds = app_state.device_session
|
||||
parent_id = _comp_id(parent_element)
|
||||
if parent_id is None:
|
||||
return None
|
||||
return _fail(f"The parent '{parent_element.get('name', '?')}' has no component id.")
|
||||
|
||||
kit_id = _kit_id(ds, kit_name)
|
||||
if kit_id is None:
|
||||
return _fail(f"Kit '{kit_name}' is not installed on the device.")
|
||||
type_id_val = _type_id(ds, kit_id, type_name)
|
||||
if kit_id is None or type_id_val is None:
|
||||
return None
|
||||
|
||||
sox_config = []
|
||||
if config_values:
|
||||
for tid, val in config_values:
|
||||
sox_config.append((tid, val))
|
||||
if type_id_val is None:
|
||||
return _fail(f"The device's '{kit_name}' kit has no type '{type_name}'.")
|
||||
|
||||
try:
|
||||
new_comp = ds.sox_client.add(parent_id, kit_id, type_id_val, comp_name, sox_config)
|
||||
new_comp = ds.sox_client.add(parent_id, kit_id, type_id_val, comp_name,
|
||||
list(config_values or []))
|
||||
ds.after_add(new_comp.id)
|
||||
return new_comp.id
|
||||
except Exception as e:
|
||||
app_state.set_status(f"Add failed: {e}", warn=True)
|
||||
return None
|
||||
return _fail(f"The device rejected the add: {e}")
|
||||
|
||||
|
||||
# ── Delete Component ────────────────────────────────────────────
|
||||
|
||||
def live_delete_component(element):
|
||||
"""In live mode, sends a Sox delete to the device. Returns True on success."""
|
||||
if not is_live():
|
||||
ds = _session()
|
||||
if ds is None:
|
||||
return False
|
||||
|
||||
ds = app_state.device_session
|
||||
comp_id = _comp_id(element)
|
||||
if comp_id is None:
|
||||
_fail(f"'{element.get('name', '?')}' has no component id.")
|
||||
return False
|
||||
|
||||
descendant_ids = []
|
||||
@@ -97,7 +131,7 @@ def live_delete_component(element):
|
||||
ds.after_delete(comp_id, descendant_ids)
|
||||
return True
|
||||
except Exception as e:
|
||||
app_state.set_status(f"Delete failed: {e}", warn=True)
|
||||
_fail(f"The device rejected the delete: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@@ -105,69 +139,107 @@ def live_delete_component(element):
|
||||
|
||||
def live_rename_component(element, new_name):
|
||||
"""In live mode, sends a Sox rename to the device. Returns True on success."""
|
||||
if not is_live():
|
||||
ds = _session()
|
||||
if ds is None:
|
||||
return False
|
||||
|
||||
ds = app_state.device_session
|
||||
comp_id = _comp_id(element)
|
||||
if comp_id is None:
|
||||
_fail(f"'{element.get('name', '?')}' has no component id.")
|
||||
return False
|
||||
|
||||
try:
|
||||
ds.sox_client.rename(comp_id, new_name)
|
||||
return True
|
||||
except Exception as e:
|
||||
app_state.set_status(f"Rename failed: {e}", warn=True)
|
||||
_fail(f"The device rejected the rename: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# ── Reorder Children ────────────────────────────────────────────
|
||||
|
||||
def live_reorder_children(parent_element, ordered_children):
|
||||
"""In live mode, sends a Sox reorder to the device. Returns True on success.
|
||||
|
||||
The device takes the order only when it names every child it has, no more and no
|
||||
fewer, so the list is checked against what the device last said before it is sent.
|
||||
"""
|
||||
ds = _session()
|
||||
if ds is None:
|
||||
return False
|
||||
parent_id = _comp_id(parent_element)
|
||||
if parent_id is None:
|
||||
_fail(f"'{parent_element.get('name', '?')}' has no component id.")
|
||||
return False
|
||||
child_ids = []
|
||||
for child in ordered_children:
|
||||
cid = _comp_id(child)
|
||||
if cid is None:
|
||||
_fail(f"'{child.get('name', '?')}' has no component id.")
|
||||
return False
|
||||
child_ids.append(cid)
|
||||
|
||||
client = ds.sox_client
|
||||
cached = client.cache_get(parent_id)
|
||||
if cached is not None and set(cached.children) != set(child_ids):
|
||||
_fail("The editor's list of children no longer matches the device's. "
|
||||
"Reconnect to read the device again.")
|
||||
return False
|
||||
try:
|
||||
client.reorder(parent_id, child_ids)
|
||||
return True
|
||||
except Exception as e:
|
||||
_fail(f"The device rejected the reorder: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# ── Link / Unlink ───────────────────────────────────────────────
|
||||
|
||||
def live_add_link(from_element, from_slot_name, to_element, to_slot_name):
|
||||
"""In live mode, sends a Sox link command. Returns True on success."""
|
||||
if not is_live():
|
||||
return False
|
||||
|
||||
ds = app_state.device_session
|
||||
def _link_ends(ds, from_element, from_slot_name, to_element, to_slot_name):
|
||||
"""(from_id, from_slot_id, to_id, to_slot_id), or None with last_error set."""
|
||||
if from_element is None or to_element is None:
|
||||
return _fail("One end of the link is not a component in the loaded tree.")
|
||||
from_id = _comp_id(from_element)
|
||||
to_id = _comp_id(to_element)
|
||||
if from_id is None or to_id is None:
|
||||
return False
|
||||
|
||||
return _fail("One end of the link has no component id.")
|
||||
from_slot = _find_slot(ds, from_id, from_slot_name)
|
||||
if from_slot is None:
|
||||
return None
|
||||
to_slot = _find_slot(ds, to_id, to_slot_name)
|
||||
if from_slot is None or to_slot is None:
|
||||
return False
|
||||
if to_slot is None:
|
||||
return None
|
||||
return from_id, from_slot["id"], to_id, to_slot["id"]
|
||||
|
||||
|
||||
def live_add_link(from_element, from_slot_name, to_element, to_slot_name):
|
||||
"""In live mode, sends a Sox link command. Returns True on success."""
|
||||
ds = _session()
|
||||
if ds is None:
|
||||
return False
|
||||
ends = _link_ends(ds, from_element, from_slot_name, to_element, to_slot_name)
|
||||
if ends is None:
|
||||
return False
|
||||
try:
|
||||
ds.sox_client.link(from_id, from_slot["id"], to_id, to_slot["id"])
|
||||
ds.sox_client.link(*ends)
|
||||
return True
|
||||
except Exception as e:
|
||||
app_state.set_status(f"Link failed: {e}", warn=True)
|
||||
_fail(f"The device rejected the link: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def live_delete_link(from_element, from_slot_name, to_element, to_slot_name):
|
||||
"""In live mode, sends a Sox unlink command. Returns True on success."""
|
||||
if not is_live():
|
||||
ds = _session()
|
||||
if ds is None:
|
||||
return False
|
||||
|
||||
ds = app_state.device_session
|
||||
from_id = _comp_id(from_element)
|
||||
to_id = _comp_id(to_element)
|
||||
if from_id is None or to_id is None:
|
||||
ends = _link_ends(ds, from_element, from_slot_name, to_element, to_slot_name)
|
||||
if ends is None:
|
||||
return False
|
||||
|
||||
from_slot = _find_slot(ds, from_id, from_slot_name)
|
||||
to_slot = _find_slot(ds, to_id, to_slot_name)
|
||||
if from_slot is None or to_slot is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
ds.sox_client.unlink(from_id, from_slot["id"], to_id, to_slot["id"])
|
||||
ds.sox_client.unlink(*ends)
|
||||
return True
|
||||
except Exception as e:
|
||||
app_state.set_status(f"Unlink failed: {e}", warn=True)
|
||||
_fail(f"The device rejected the unlink: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@@ -175,12 +247,12 @@ def live_delete_link(from_element, from_slot_name, to_element, to_slot_name):
|
||||
|
||||
def live_invoke(element, slot_name, arg=None):
|
||||
"""In live mode, sends a Sox invoke. Returns True on success."""
|
||||
if not is_live():
|
||||
ds = _session()
|
||||
if ds is None:
|
||||
return False
|
||||
|
||||
ds = app_state.device_session
|
||||
comp_id = _comp_id(element)
|
||||
if comp_id is None:
|
||||
_fail(f"'{element.get('name', '?')}' has no component id.")
|
||||
return False
|
||||
|
||||
slot_info = _find_slot(ds, comp_id, slot_name)
|
||||
@@ -191,10 +263,268 @@ def live_invoke(element, slot_name, arg=None):
|
||||
ds.sox_client.invoke(comp_id, slot_info["id"])
|
||||
return True
|
||||
except Exception as e:
|
||||
app_state.set_status(f"Invoke failed: {e}", warn=True)
|
||||
_fail(f"The device rejected the invoke of {slot_name}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# ── Undo: read a subtree back, put it back ──────────────────────
|
||||
|
||||
def read_subtree(element):
|
||||
"""Everything it takes to put `element` and all it holds back on the device.
|
||||
|
||||
Read from the device, not the DOM: the DOM only holds what has been opened, and a
|
||||
placeholder child carries neither config nor children of its own. Requests go one
|
||||
component at a time, at the pace the device sets (see used_kit_names).
|
||||
|
||||
Returns {"tree", "links", "siblings"}, or None with last_error set. A node is
|
||||
{"id", "kit_id", "type_id", "type_name", "name", "values", "element", "children"},
|
||||
where "element" is the DOM element showing it, if any, so a new id can reach it.
|
||||
A link end, and each of "siblings" (the parent's children, in order), is ("node",
|
||||
node), ("elem", element) or ("id", comp_id): a component outside the subtree is
|
||||
followed by its element when it has one, because its id changes if it is itself
|
||||
deleted and put back.
|
||||
"""
|
||||
ds = _session()
|
||||
if ds is None:
|
||||
return None
|
||||
comp_id = _comp_id(element)
|
||||
if comp_id is None:
|
||||
return _fail(f"'{element.get('name', '?')}' has no component id.")
|
||||
|
||||
client = ds.sox_client
|
||||
root = app_state.xml_root_element
|
||||
elements_by_id = {}
|
||||
if root is not None:
|
||||
for elem in root.iter("comp"):
|
||||
if elem.get("id") is not None:
|
||||
elements_by_id.setdefault(elem.get("id"), elem)
|
||||
|
||||
nodes_by_id = {}
|
||||
|
||||
def read(comp):
|
||||
if comp.type_name not in app_state.final_components_registry:
|
||||
raise _SnapshotError(f"No kit manifest describes {comp.type_name}, so "
|
||||
f"'{comp.name}' could not be put back as it was.")
|
||||
children = ds.load_children(comp.id)
|
||||
if len(children) != len(comp.children):
|
||||
raise _SnapshotError(f"Not every child of '{comp.name}' could be read.")
|
||||
client.update([comp], CONFIG)
|
||||
values = dict(comp.values)
|
||||
# The cache reads every Buf as a Str, which loses bytes of a binary one (a
|
||||
# user's cred); those are read again as what they are.
|
||||
for slot in app_state.final_components_registry[comp.type_name]:
|
||||
if ("c" in slot["flags"] and slot["type"] != "void"
|
||||
and _wire_type(slot["type"], slot["facets"].get("asStr") is True) == BUF_ID):
|
||||
values[slot["name"]] = client.read_prop(comp.id, slot["slot_id"], BUF_ID)
|
||||
node = {
|
||||
"id": comp.id,
|
||||
"kit_id": comp.kit_id,
|
||||
"type_id": comp.type_id,
|
||||
"type_name": comp.type_name,
|
||||
"name": comp.name,
|
||||
"values": values,
|
||||
"element": elements_by_id.get(str(comp.id)),
|
||||
"children": [],
|
||||
}
|
||||
nodes_by_id[comp.id] = node
|
||||
node["children"] = [read(child) for child in children]
|
||||
return node
|
||||
|
||||
try:
|
||||
top = client.load(comp_id)
|
||||
if top is None:
|
||||
return _fail(f"'{element.get('name', '?')}' could not be read from the device.")
|
||||
tree = read(top)
|
||||
|
||||
links = []
|
||||
seen = set()
|
||||
for cid in list(nodes_by_id):
|
||||
for lnk in client.read_links(cid):
|
||||
key = (lnk.from_comp_id, lnk.from_slot_id, lnk.to_comp_id, lnk.to_slot_id)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
links.append((_link_end(lnk.from_comp_id, nodes_by_id, elements_by_id),
|
||||
lnk.from_slot_id,
|
||||
_link_end(lnk.to_comp_id, nodes_by_id, elements_by_id),
|
||||
lnk.to_slot_id))
|
||||
except _SnapshotError as e:
|
||||
return _fail(str(e))
|
||||
except Exception as e:
|
||||
return _fail(f"'{element.get('name', '?')}' could not be read from the device: {e}")
|
||||
|
||||
parent = client.cache_get(top.parent)
|
||||
siblings = [_link_end(cid, nodes_by_id, elements_by_id)
|
||||
for cid in (parent.children if parent is not None else [])]
|
||||
return {"tree": tree, "links": links, "siblings": siblings}
|
||||
|
||||
|
||||
class _SnapshotError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _link_end(comp_id, nodes_by_id, elements_by_id):
|
||||
if comp_id in nodes_by_id:
|
||||
return ("node", nodes_by_id[comp_id])
|
||||
elem = elements_by_id.get(str(comp_id))
|
||||
if elem is not None:
|
||||
return ("elem", elem)
|
||||
return ("id", comp_id)
|
||||
|
||||
|
||||
def _link_end_id(end):
|
||||
kind, ref = end
|
||||
if kind == "node":
|
||||
return ref["id"]
|
||||
if kind == "elem":
|
||||
return _comp_id(ref)
|
||||
return ref
|
||||
|
||||
|
||||
def recreate_subtree(parent_element, snapshot, build_config):
|
||||
"""Adds a subtree from read_subtree back under `parent_element`, with its links.
|
||||
|
||||
The device hands out new ids. Each node, and the DOM element showing it, takes its
|
||||
new id, so whatever refers to them later still finds them. The component goes back
|
||||
to its old place among its siblings.
|
||||
|
||||
`build_config(type_name, device_values=...)` makes an add command's config values.
|
||||
Returns (top_created, complete); when not complete, last_error says what is missing.
|
||||
"""
|
||||
ds = _session()
|
||||
if ds is None:
|
||||
return False, False
|
||||
parent_id = _comp_id(parent_element)
|
||||
if parent_id is None:
|
||||
_fail(f"The parent '{parent_element.get('name', '?')}' has no component id.")
|
||||
return False, False
|
||||
|
||||
client = ds.sox_client
|
||||
tree = snapshot["tree"]
|
||||
|
||||
def create(node, under_id):
|
||||
config = build_config(node["type_name"], device_values=node["values"])
|
||||
comp = client.add(under_id, node["kit_id"], node["type_id"], node["name"], config)
|
||||
node["id"] = comp.id
|
||||
if node["element"] is not None:
|
||||
node["element"].set("id", str(comp.id))
|
||||
|
||||
def create_children(node):
|
||||
for child in node["children"]:
|
||||
create(child, node["id"])
|
||||
create_children(child)
|
||||
|
||||
try:
|
||||
create(tree, parent_id)
|
||||
except Exception as e:
|
||||
_fail(f"The device rejected the add of '{tree['name']}': {e}")
|
||||
return False, False
|
||||
|
||||
try:
|
||||
create_children(tree)
|
||||
for from_end, from_slot, to_end, to_slot in snapshot["links"]:
|
||||
client.link(_link_end_id(from_end), from_slot, _link_end_id(to_end), to_slot)
|
||||
|
||||
# The add put it last; the device's order is the order execution runs in.
|
||||
parent = client.cache_get(parent_id)
|
||||
wanted = [_link_end_id(end) for end in snapshot["siblings"]]
|
||||
if (parent is not None and set(wanted) == set(parent.children)
|
||||
and wanted != parent.children):
|
||||
client.reorder(parent_id, wanted)
|
||||
complete = True
|
||||
except Exception as e:
|
||||
_fail(f"'{tree['name']}' is back on the device, but not all of it: {e}")
|
||||
complete = False
|
||||
|
||||
ds.after_add(tree["id"])
|
||||
return True, complete
|
||||
|
||||
|
||||
def duplicate_subtrees(plans, links, build_config):
|
||||
"""Adds copies of read_subtree trees to the device, then the links among them.
|
||||
|
||||
`plans` is a list of (parent_element, node, new_name, new_meta): the node is a
|
||||
read_subtree tree, copied under `parent_element` with its top renamed and moved to
|
||||
`new_meta` (None keeps it where it was). Children keep their names, which stay
|
||||
unique because their parent is new. `links` holds (from_id, from_slot, to_id,
|
||||
to_slot) between components in the trees, by their original ids.
|
||||
|
||||
Nothing in a node is changed; the device hands out new ids for the copies.
|
||||
Returns (created, new_ids, complete): `created` lists (parent_element, new_top_id)
|
||||
for every top the device took, `new_ids` maps each original id to its copy's, and
|
||||
when not complete, last_error says what is missing.
|
||||
"""
|
||||
ds = _session()
|
||||
if ds is None:
|
||||
return [], {}, False
|
||||
client = ds.sox_client
|
||||
new_ids = {}
|
||||
created = []
|
||||
|
||||
def create(node, under_id, name, meta):
|
||||
values = dict(node["values"])
|
||||
if meta is not None:
|
||||
values["meta"] = meta
|
||||
config = build_config(node["type_name"], device_values=values)
|
||||
comp = client.add(under_id, node["kit_id"], node["type_id"], name, config)
|
||||
new_ids[node["id"]] = comp.id
|
||||
for child in node["children"]:
|
||||
create(child, comp.id, child["name"], None)
|
||||
|
||||
complete = True
|
||||
for parent_element, node, new_name, new_meta in plans:
|
||||
parent_id = _comp_id(parent_element)
|
||||
if parent_id is None:
|
||||
_fail(f"The parent '{parent_element.get('name', '?')}' has no component id.")
|
||||
complete = False
|
||||
break
|
||||
try:
|
||||
create(node, parent_id, new_name, new_meta)
|
||||
except Exception as e:
|
||||
if node["id"] in new_ids:
|
||||
created.append((parent_element, new_ids[node["id"]]))
|
||||
_fail(f"'{new_name}' was added to the device, but not all of it: {e}")
|
||||
else:
|
||||
_fail(f"The device rejected the add of '{new_name}': {e}")
|
||||
complete = False
|
||||
break
|
||||
created.append((parent_element, new_ids[node["id"]]))
|
||||
|
||||
if complete:
|
||||
for from_id, from_slot, to_id, to_slot in links:
|
||||
try:
|
||||
client.link(new_ids[from_id], from_slot, new_ids[to_id], to_slot)
|
||||
except Exception as e:
|
||||
_fail(f"The copies are on the device, but not every link between them: {e}")
|
||||
complete = False
|
||||
break
|
||||
|
||||
top_ids = {top_id for _, top_id in created}
|
||||
for top_id in top_ids:
|
||||
ds.after_add(top_id)
|
||||
# after_add reads only the top; the children's values are read too, so the DOM
|
||||
# built from the cache carries their slots.
|
||||
children = [client.cache_get(i) for i in new_ids.values() if i not in top_ids]
|
||||
children = [c for c in children if c is not None]
|
||||
if children:
|
||||
try:
|
||||
client.update(children, CONFIG)
|
||||
except Exception:
|
||||
pass
|
||||
return created, new_ids, complete
|
||||
|
||||
|
||||
def wire_value(type_id, raw):
|
||||
"""A value as the device sent it, in the form encode_value wants for `type_id`."""
|
||||
if type_id == BUF_ID:
|
||||
return bytes(raw) if isinstance(raw, (bytes, bytearray)) else str(raw).encode("utf-8")
|
||||
if type_id == STR_ID:
|
||||
if isinstance(raw, (bytes, bytearray)):
|
||||
return bytes(raw).decode("utf-8", errors="replace").rstrip("\0")
|
||||
return str(raw)
|
||||
return _coerce_value(type_id, str(raw))
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
def _comp_id(element):
|
||||
@@ -210,22 +540,38 @@ def _comp_id(element):
|
||||
|
||||
|
||||
def _find_slot(ds, comp_id, slot_name):
|
||||
"""{name, id, type_id} for a slot of a cached component, or None with last_error set.
|
||||
|
||||
A Buf goes over the wire as a Buf only when it is binary; an asStr Buf travels as a
|
||||
Str, which is how the rest of the editor already reads and writes it.
|
||||
"""
|
||||
comp = ds.sox_client.cache_get(comp_id)
|
||||
if comp is None:
|
||||
return None
|
||||
return _fail(f"Component {comp_id} is not loaded from the device.")
|
||||
type_name = ds.sox_client.type_name_map.get((comp.kit_id, comp.type_id))
|
||||
if type_name and type_name in app_state.final_components_registry:
|
||||
for s in app_state.final_components_registry[type_name]:
|
||||
if s["name"] == slot_name:
|
||||
slot_type = s.get("type") or "int"
|
||||
as_str = (s.get("facets") or {}).get("asStr") is True
|
||||
return {"name": s["name"], "id": s["slot_id"],
|
||||
"type_name": s.get("type", "int")}
|
||||
"type_id": _wire_type(slot_type, as_str)}
|
||||
return _fail(f"{type_name} has no slot '{slot_name}'.")
|
||||
slots = ds.sox_client.slot_registry.get((comp.kit_id, comp.type_id))
|
||||
if slots is None:
|
||||
return None
|
||||
return _fail(f"No kit manifest describes {type_name or 'this component'}'s slots, "
|
||||
"so the slot cannot be addressed on the device.")
|
||||
for s in slots:
|
||||
if s["name"] == slot_name:
|
||||
return s
|
||||
return None
|
||||
return {"name": s["name"], "id": s["id"],
|
||||
"type_id": _wire_type(s.get("type_name") or "int", s.get("is_as_str"))}
|
||||
return _fail(f"{type_name or 'This component'} has no slot '{slot_name}'.")
|
||||
|
||||
|
||||
def _wire_type(slot_type, as_str):
|
||||
if slot_type in ("sys::Buf", "Buf", "buf") and not as_str:
|
||||
return BUF_ID
|
||||
return type_id_for_slot(slot_type)
|
||||
|
||||
|
||||
def _kit_id(ds, kit_name):
|
||||
@@ -246,17 +592,24 @@ def _type_id(ds, kit_id, type_name):
|
||||
|
||||
|
||||
def _coerce_value(type_id, value_str):
|
||||
"""The Python value encode_value wants for `value_str` in document form.
|
||||
|
||||
Raises ValueError (or binascii.Error) when the text does not fit the type.
|
||||
"""
|
||||
from sox import BOOL_ID, BYTE_ID, SHORT_ID, INT_ID, LONG_ID, FLOAT_ID, DOUBLE_ID, STR_ID
|
||||
if type_id == BOOL_ID:
|
||||
return value_str.lower() in ("true", "1", "yes")
|
||||
elif type_id in (BYTE_ID, SHORT_ID, INT_ID):
|
||||
return int(value_str)
|
||||
elif type_id == LONG_ID:
|
||||
return int(value_str)
|
||||
elif type_id == FLOAT_ID:
|
||||
return float(value_str)
|
||||
elif type_id == DOUBLE_ID:
|
||||
elif type_id in (BYTE_ID, SHORT_ID, INT_ID, LONG_ID):
|
||||
try:
|
||||
return int(value_str)
|
||||
except ValueError:
|
||||
return int(value_str, 0) # meta and flags can be written as 0x...
|
||||
elif type_id in (FLOAT_ID, DOUBLE_ID):
|
||||
return float(value_str)
|
||||
elif type_id == STR_ID:
|
||||
return str(value_str)
|
||||
elif type_id == BUF_ID:
|
||||
if isinstance(value_str, (bytes, bytearray)):
|
||||
return bytes(value_str)
|
||||
return base64.b64decode(value_str or "", validate=True) # the .sax form
|
||||
return value_str
|
||||
|
||||
+9
-4
@@ -82,6 +82,11 @@ def column_label(version, checksum):
|
||||
return version if version else checksum
|
||||
|
||||
|
||||
def same_checksum(a, b):
|
||||
"""Whether two checksums name the same build, whatever their zero-padding."""
|
||||
return bool(a) and bool(b) and a.lower().lstrip("0") == b.lower().lstrip("0")
|
||||
|
||||
|
||||
def action_options(kit_name, current_checksum, is_declared, builds):
|
||||
"""The Action dropdown for one kit: (label, checksum to apply) pairs, no-op first.
|
||||
|
||||
@@ -91,7 +96,7 @@ def action_options(kit_name, current_checksum, is_declared, builds):
|
||||
"""
|
||||
installed_version = None
|
||||
for version, checksum in builds:
|
||||
if checksum == current_checksum:
|
||||
if same_checksum(checksum, current_checksum):
|
||||
installed_version = version
|
||||
break
|
||||
|
||||
@@ -109,7 +114,7 @@ def action_options(kit_name, current_checksum, is_declared, builds):
|
||||
installed_rank = palette.version_sort_key(installed_version) if installed_version else None
|
||||
|
||||
for version, checksum in builds:
|
||||
if is_declared and checksum == current_checksum:
|
||||
if is_declared and same_checksum(checksum, current_checksum):
|
||||
continue
|
||||
|
||||
label = build_label(version, checksum)
|
||||
@@ -176,7 +181,7 @@ def collect_rows():
|
||||
installed_text = NO_CHECKSUM
|
||||
else:
|
||||
installed_text = column_label(
|
||||
next((v for v, c in builds if c == checksum), ""), checksum
|
||||
next((v for v, c in builds if same_checksum(c, checksum)), ""), checksum
|
||||
)
|
||||
|
||||
rows.append({
|
||||
@@ -512,7 +517,7 @@ class KitsPanel:
|
||||
if "section" in row or not row["declared"] or not row["latest"]:
|
||||
continue
|
||||
latest_checksum = row["latest"][1]
|
||||
if latest_checksum != row["checksum"]:
|
||||
if not same_checksum(latest_checksum, row["checksum"]):
|
||||
selections.append((row["kit"], latest_checksum))
|
||||
|
||||
if selections:
|
||||
|
||||
+19
-6
@@ -552,13 +552,21 @@ class OxPageViewPanel:
|
||||
if not changes:
|
||||
return False
|
||||
|
||||
# Live: the device first, and the page only shows what the device accepted.
|
||||
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):
|
||||
live_failures.append(f"{name}: {live_hooks.last_error}")
|
||||
continue
|
||||
set_prop_value(element, name, after)
|
||||
if live_hooks.is_live() and not live_hooks.live_set_prop_value(element, name, after):
|
||||
live_failures.append(name)
|
||||
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))
|
||||
app_state.mark_dirty()
|
||||
|
||||
@@ -566,8 +574,13 @@ class OxPageViewPanel:
|
||||
|
||||
who = comp.get("name", "?")
|
||||
if live_failures:
|
||||
app_state.set_status(
|
||||
f"{who}: written locally but failed on device: {', '.join(live_failures)}")
|
||||
live_hooks.warn_failure(
|
||||
"Page Editor",
|
||||
f"{len(live_failures)} of {len(changes)} change(s) to {who} did not reach "
|
||||
"the device and were not applied",
|
||||
"\n".join(f" • {failure}" for failure in live_failures),
|
||||
)
|
||||
return len(live_failures) < len(changes)
|
||||
else:
|
||||
app_state.set_status(
|
||||
f"{who}: {', '.join(name for _, name, _, _ in changes)} changed.")
|
||||
|
||||
+34
-9
@@ -67,7 +67,12 @@ def kit_versions(kit_name):
|
||||
for entry in os.listdir(kit_dir):
|
||||
match = pattern.match(entry)
|
||||
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
|
||||
return versions
|
||||
@@ -227,11 +232,25 @@ def _is_component(qualified_name, seen=None):
|
||||
return result
|
||||
|
||||
|
||||
def _hidden_from_palette(type_element):
|
||||
"""True when the type's own facets carry @palette=false.
|
||||
|
||||
Only the type-level <facets> counts; slots have <facets> of their own nested deeper.
|
||||
"""
|
||||
facets = type_element.find("facets")
|
||||
if facets is None:
|
||||
return False
|
||||
for facet in facets.findall("bool"):
|
||||
if facet.get("name") == "palette" and facet.get("val") == "false":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def list_component_types(kit_name):
|
||||
"""Concrete component types in a kit, as (type name, base) pairs.
|
||||
|
||||
Primitives (no base) and abstract types (the 'a' flag) are left out — neither can be
|
||||
placed in an application.
|
||||
Primitives (no base), abstract types (the 'a' flag) and types marked @palette=false
|
||||
are left out — none of them belong in the palette.
|
||||
"""
|
||||
_, types = _load_kit(kit_name)
|
||||
|
||||
@@ -239,6 +258,8 @@ def list_component_types(kit_name):
|
||||
for type_name, type_element in types.items():
|
||||
if "a" in (type_element.get("flags") or ""):
|
||||
continue
|
||||
if _hidden_from_palette(type_element):
|
||||
continue
|
||||
if not _is_component(f"{kit_name}::{type_name}"):
|
||||
continue
|
||||
entries.append((type_name, type_element.get("base") or ""))
|
||||
@@ -253,7 +274,7 @@ def _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.
|
||||
|
||||
*matching* is the union of nativeKit names from platform builds that include
|
||||
@@ -262,8 +283,10 @@ def _platform_native_kits():
|
||||
|
||||
A kit that appears in *all_platforms* but not in *matching* has native code
|
||||
compiled for a different device and cannot work with this SAX.
|
||||
|
||||
sax_names stands in for the open file's kits when given.
|
||||
"""
|
||||
sax_names = set(app_state.active_schema_kits.keys())
|
||||
sax_names = set(app_state.active_schema_kits.keys() if sax_names is None else sax_names)
|
||||
db = _platforms_db()
|
||||
if not os.path.isdir(db):
|
||||
return None, None
|
||||
@@ -319,8 +342,9 @@ def _platform_native_kits():
|
||||
return (matching if found else None), all_platforms
|
||||
|
||||
|
||||
def unsupported_kits():
|
||||
"""Kit names that cannot work with the open file's platform.
|
||||
def unsupported_kits(sax_names=None):
|
||||
"""Kit names that cannot work with the open file's platform -- or with the platform
|
||||
of the kits in `sax_names`, when given.
|
||||
|
||||
A kit is unsupported when:
|
||||
- it has native code (hasNatives or listed as nativeKit on some platform)
|
||||
@@ -329,11 +353,12 @@ def unsupported_kits():
|
||||
|
||||
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:
|
||||
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:
|
||||
_unsupported_cache[sax_names] = set()
|
||||
return set()
|
||||
|
||||
+33
-8
@@ -35,7 +35,7 @@ import app_state
|
||||
import live_hooks
|
||||
import schema
|
||||
import undo
|
||||
from sax_dom import (get_component_path, read_meta_property, set_prop_value,
|
||||
from sax_dom import (get_component_path, read_meta_property, remove_prop, set_prop_value,
|
||||
update_meta_groups, update_meta_property)
|
||||
|
||||
# Sedona slot types, grouped by how a value of that type is parsed and bounded.
|
||||
@@ -1167,15 +1167,31 @@ class PropertiesPanel:
|
||||
self.status_var.set("No changes to save.")
|
||||
return
|
||||
|
||||
# In live mode the device is written first and a value only lands in the document
|
||||
# once the device took it, so the panel never shows what the device does not hold.
|
||||
live_failures = []
|
||||
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(
|
||||
element, prop_name, after):
|
||||
live_failures.append(f"{prop_name}: {live_hooks.last_error}")
|
||||
if prop_name == "meta": # applied above, ahead of the write
|
||||
if before is None:
|
||||
remove_prop(element, "meta")
|
||||
else:
|
||||
set_prop_value(element, "meta", before)
|
||||
continue
|
||||
if prop_name != "meta":
|
||||
set_prop_value(element, prop_name, after)
|
||||
if live_hooks.is_live():
|
||||
if not live_hooks.live_set_prop_value(element, prop_name, after):
|
||||
live_failures.append(prop_name)
|
||||
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)))
|
||||
app_state.mark_dirty()
|
||||
|
||||
@@ -1183,11 +1199,20 @@ class PropertiesPanel:
|
||||
if self.on_change is not None:
|
||||
self.on_change()
|
||||
|
||||
count = len(changes)
|
||||
count = written
|
||||
name = self.comp.get('name', '?')
|
||||
if live_failures:
|
||||
self.status_var.set(
|
||||
f"Written locally but failed on device: {', '.join(live_failures)}"
|
||||
f"{written} written, {len(live_failures)} rejected by the device — "
|
||||
"those still show the device's value."
|
||||
)
|
||||
live_hooks.warn_failure(
|
||||
"Properties",
|
||||
f"{len(live_failures)} of {len(changes)} change(s) to {name} did not reach "
|
||||
"the device and were not applied",
|
||||
"\n".join(f" • {failure}" for failure in live_failures),
|
||||
)
|
||||
return
|
||||
elif app_state.live_mode:
|
||||
self.status_var.set(
|
||||
f"{count} slot{'' if count == 1 else 's'} written to device."
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
"""The kits step of App > Put with Files, laid out as the Manage Kits panel is.
|
||||
|
||||
The same groups (kits the app needs, kits declared without being used, kits available,
|
||||
kits not supported), the same tick column and the same Action cell: "Keep at",
|
||||
"Upgrade to", "Downgrade to", "Add at", "REMOVE". Installed is what the device runs;
|
||||
Backup is what the backup's app was saved with.
|
||||
|
||||
What is ticked goes: into app.sab's schema and, when that is not the device's kits,
|
||||
into a kits.scode built for them (Ontrol's PutApp does the same). "Rebuild kits.scode"
|
||||
forces the build when nothing changed.
|
||||
"""
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox, ttk
|
||||
|
||||
import app_backup
|
||||
import manage_kits
|
||||
from manage_kits import NOT_DECLARED, SECTION_TAG, column_label, make_checkbox_image
|
||||
|
||||
REMOVE_ACTION = "REMOVE from device"
|
||||
|
||||
|
||||
def _options(row):
|
||||
"""The Action choices for a row, as (label, checksum) pairs."""
|
||||
if row.device:
|
||||
return [(label, checksum or row.device) for label, checksum in
|
||||
manage_kits.action_options(row.name, row.device, True, row.builds)]
|
||||
return [(f"Add at {manage_kits.build_label(v, c)}", c) for v, c in row.builds]
|
||||
|
||||
|
||||
def _build_text(row, checksum):
|
||||
if not checksum:
|
||||
return NOT_DECLARED
|
||||
version = next((v for v, c in row.builds if c == checksum), "")
|
||||
return column_label(version, checksum)
|
||||
|
||||
|
||||
class _Dialog:
|
||||
def __init__(self, parent, rows, title):
|
||||
self.rows = rows
|
||||
self.result = None
|
||||
self.original = [(r.included, r.use) for r in rows]
|
||||
|
||||
self.top = top = tk.Toplevel(parent)
|
||||
top.title(f"{title} - Kits")
|
||||
top.transient(parent)
|
||||
top.grab_set()
|
||||
top.geometry(f"+{parent.winfo_x() + 60}+{parent.winfo_y() + 40}")
|
||||
frame = ttk.Frame(top, padding=10)
|
||||
frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
columns = ("name", "installed", "backup", "latest", "action")
|
||||
table_frame = ttk.Frame(frame)
|
||||
table_frame.pack(fill=tk.BOTH, expand=True)
|
||||
self.table = ttk.Treeview(table_frame, columns=columns, show="tree headings",
|
||||
selectmode="browse", height=20)
|
||||
self.table.heading("#0", text="")
|
||||
self.table.column("#0", width=32, minwidth=32, stretch=False, anchor="center")
|
||||
for column, heading, width in [("name", "Kit name", 190),
|
||||
("installed", "Installed", 105),
|
||||
("backup", "Backup", 105),
|
||||
("latest", "Latest", 105),
|
||||
("action", "Action", 215)]:
|
||||
self.table.heading(column, text=heading, anchor="w")
|
||||
self.table.column(column, width=width, minwidth=width, anchor="w", stretch=False)
|
||||
vscroll = ttk.Scrollbar(table_frame, orient=tk.VERTICAL, command=self.table.yview)
|
||||
self.table.configure(yscrollcommand=vscroll.set)
|
||||
vscroll.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
self.table.pack(fill=tk.BOTH, expand=True)
|
||||
self.table.tag_configure(SECTION_TAG, background="#dcdcdc")
|
||||
|
||||
self.boxes = {(checked, enabled): make_checkbox_image(checked, enabled)
|
||||
for checked in (True, False) for enabled in (True, False)}
|
||||
self.cross_box = make_checkbox_image(False, False, cross=True)
|
||||
|
||||
locked = any(r.locked for r in rows)
|
||||
hint = ("The backup holds no app.sax, so its app goes with the kits it was saved "
|
||||
"with." if locked else
|
||||
"A tick means the kit goes to the device. Grey ticks are needed by the app "
|
||||
"and cannot be removed; untick another to remove it, tick one to add it. "
|
||||
"Click the Action cell to choose a build. Installed is the device's build. "
|
||||
"Kits other than the device's mean a new kits.scode.")
|
||||
ttk.Label(frame, text=hint, foreground="#666666", wraplength=780
|
||||
).pack(anchor="w", pady=(8, 0))
|
||||
|
||||
self.rebuild = tk.BooleanVar(value=False)
|
||||
self.rebuild_check = ttk.Checkbutton(frame, text="Rebuild kits.scode",
|
||||
variable=self.rebuild)
|
||||
self.rebuild_check.pack(anchor="w", pady=(6, 0))
|
||||
|
||||
button_row = ttk.Frame(frame)
|
||||
button_row.pack(fill=tk.X, pady=(10, 0))
|
||||
upgrade = ttk.Button(button_row, text="Upgrade all kits to latest version",
|
||||
command=self._upgrade_all)
|
||||
upgrade.pack(side=tk.LEFT)
|
||||
if locked:
|
||||
upgrade.state(["disabled"])
|
||||
ttk.Button(button_row, text="Cancel", command=lambda: self._close(False)
|
||||
).pack(side=tk.RIGHT)
|
||||
ttk.Button(button_row, text="Next", command=lambda: self._close(True)
|
||||
).pack(side=tk.RIGHT, padx=6)
|
||||
|
||||
self.row_by_item = {}
|
||||
self._editor = None
|
||||
self.table.bind("<Button-1>", self._on_click)
|
||||
self.table.bind("<MouseWheel>", lambda _e: self._close_editor(), add="+")
|
||||
top.protocol("WM_DELETE_WINDOW", lambda: self._close(False))
|
||||
self._rebuild_table()
|
||||
|
||||
# --- the table ---
|
||||
|
||||
def _action_text(self, row):
|
||||
if row.unsupported:
|
||||
return "Not supported"
|
||||
if not row.included:
|
||||
return REMOVE_ACTION if row.device else NOT_DECLARED
|
||||
return next((label for label, c in _options(row) if c == row.use),
|
||||
_build_text(row, row.use))
|
||||
|
||||
def _values(self, row):
|
||||
latest = row.builds[0] if row.builds else None
|
||||
return (row.name, _build_text(row, row.device), _build_text(row, row.backup),
|
||||
column_label(*latest) if latest else NOT_DECLARED, self._action_text(row))
|
||||
|
||||
def _image(self, row):
|
||||
if row.unsupported:
|
||||
return self.cross_box
|
||||
return self.boxes[(row.included, not (row.required or row.locked))]
|
||||
|
||||
def _rebuild_table(self):
|
||||
self._close_editor()
|
||||
top = self.table.yview()[0]
|
||||
self.table.delete(*self.table.get_children())
|
||||
self.row_by_item.clear()
|
||||
for title in app_backup.KIT_GROUPS:
|
||||
members = [r for r in self.rows if app_backup.group(r) == title]
|
||||
if not members:
|
||||
continue
|
||||
self.table.insert("", "end", values=(title, "", "", "", ""), tags=(SECTION_TAG,))
|
||||
for row in members:
|
||||
item = self.table.insert("", "end", image=self._image(row),
|
||||
values=self._values(row))
|
||||
self.row_by_item[item] = row
|
||||
self.table.yview_moveto(top)
|
||||
|
||||
# Kits other than the device's cannot run on its kits.scode.
|
||||
changed = any(app_backup.kit_change(r) for r in self.rows)
|
||||
if changed:
|
||||
self.rebuild.set(True)
|
||||
self.rebuild_check.state(["disabled"])
|
||||
else:
|
||||
self.rebuild_check.state(["!disabled"])
|
||||
|
||||
def _changed(self):
|
||||
app_backup.settle_required(self.rows)
|
||||
self._rebuild_table()
|
||||
|
||||
# --- editing ---
|
||||
|
||||
def _close_editor(self):
|
||||
if self._editor is not None:
|
||||
self._editor.destroy()
|
||||
self._editor = None
|
||||
|
||||
def _on_click(self, event):
|
||||
self._close_editor()
|
||||
if self.table.identify_region(event.x, event.y) not in ("tree", "cell"):
|
||||
return
|
||||
item = self.table.identify_row(event.y)
|
||||
row = self.row_by_item.get(item)
|
||||
if row is None or row.locked or row.unsupported:
|
||||
return
|
||||
column = self.table.identify_column(event.x)
|
||||
if column == "#0":
|
||||
self._toggle(row)
|
||||
elif column == "#5":
|
||||
self._edit_action(item, row)
|
||||
|
||||
def _toggle(self, row):
|
||||
if row.required:
|
||||
return
|
||||
if not row.included and not row.builds:
|
||||
messagebox.showwarning("Put with Files", f"Sedona home holds no build of "
|
||||
f"'{row.name}' to add it at.", parent=self.top)
|
||||
return
|
||||
row.included = not row.included
|
||||
if row.included and not row.use:
|
||||
row.use = row.latest
|
||||
self._changed()
|
||||
|
||||
def _edit_action(self, item, row):
|
||||
options = _options(row)
|
||||
if not options:
|
||||
return
|
||||
cell = self.table.bbox(item, "action")
|
||||
if not cell:
|
||||
return
|
||||
labels = [label for label, _ in options]
|
||||
variable = tk.StringVar(value=self._action_text(row))
|
||||
combo = ttk.Combobox(self.table, textvariable=variable, values=labels,
|
||||
state="readonly")
|
||||
combo.place(x=cell[0], y=cell[1], width=cell[2], height=cell[3])
|
||||
self._editor = combo
|
||||
|
||||
def commit(_event=None):
|
||||
if variable.get() in labels:
|
||||
row.use = options[labels.index(variable.get())][1]
|
||||
row.included = True
|
||||
self._close_editor()
|
||||
self._changed()
|
||||
|
||||
combo.bind("<<ComboboxSelected>>", commit)
|
||||
combo.bind("<Escape>", lambda _e: self._close_editor())
|
||||
combo.focus_set()
|
||||
combo.after_idle(lambda: combo.event_generate("<Down>"))
|
||||
|
||||
def _upgrade_all(self):
|
||||
for row in self.rows:
|
||||
if row.included and row.latest and not row.locked:
|
||||
row.use = row.latest
|
||||
self._changed()
|
||||
|
||||
def _close(self, ok):
|
||||
self._close_editor()
|
||||
if ok:
|
||||
self.result = bool(self.rebuild.get())
|
||||
else:
|
||||
for row, (included, use) in zip(self.rows, self.original):
|
||||
row.included, row.use = included, use
|
||||
app_backup.settle_required(self.rows)
|
||||
self.top.grab_release()
|
||||
self.top.destroy()
|
||||
|
||||
|
||||
def ask(parent, rows, title="Put with Files"):
|
||||
"""Shows the kits step; returns whether to rebuild kits.scode, or None when cancelled.
|
||||
|
||||
The rows are updated in place with what was chosen.
|
||||
"""
|
||||
dialog = _Dialog(parent, rows, title)
|
||||
dialog.top.wait_window()
|
||||
return dialog.result
|
||||
+629
@@ -0,0 +1,629 @@
|
||||
"""Reads a device's app.sab and writes it back out as app.sax, and encodes an app.sax
|
||||
into app.sab (encode_sax).
|
||||
|
||||
Ported from Py_sab2sax (sax.py/sab.py), itself a port of sedona.offline.OfflineApp
|
||||
(decodeAppBinary + encodeAppXml); its output matches `sedonac app.sab` byte for byte,
|
||||
down to the `<!-- /path -->` comments and Java's float formatting. Kit manifests come
|
||||
from Sedona home, found the way the editor finds them everywhere else.
|
||||
|
||||
A .sab holds config props only, so runtime values cannot come back: like sedonac, the
|
||||
manifest default stands in for them, and a value equal to its default is not written.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import math
|
||||
import struct
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import schema
|
||||
|
||||
|
||||
class SabError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class _Reader:
|
||||
def __init__(self, data):
|
||||
self.b, self.p = data, 0
|
||||
|
||||
def take(self, n):
|
||||
if self.p + n > len(self.b):
|
||||
raise SabError(f"truncated sab at offset {self.p}")
|
||||
v = self.b[self.p:self.p + n]
|
||||
self.p += n
|
||||
return v
|
||||
|
||||
def u1(self): return self.take(1)[0]
|
||||
def u2(self): return struct.unpack(">H", self.take(2))[0]
|
||||
def i4(self): return struct.unpack(">i", self.take(4))[0]
|
||||
def i8(self): return struct.unpack(">q", self.take(8))[0]
|
||||
def f4(self): return struct.unpack(">f", self.take(4))[0]
|
||||
def f8(self): return struct.unpack(">d", self.take(8))[0]
|
||||
|
||||
def str_(self):
|
||||
end = self.b.index(0, self.p)
|
||||
s = self.b[self.p:end].decode("ascii")
|
||||
self.p = end + 1
|
||||
return s
|
||||
|
||||
|
||||
# --- schema ---------------------------------------------------------------------------
|
||||
|
||||
class Slot:
|
||||
__slots__ = ("name", "type", "flags", "default", "facets")
|
||||
|
||||
def __init__(self, elem):
|
||||
self.name = elem.get("name")
|
||||
self.type = elem.get("type")
|
||||
self.flags = elem.get("flags", "")
|
||||
self.default = elem.get("default")
|
||||
self.facets = {f.get("name"): f.get("val") for f in elem.iter()
|
||||
if f is not elem and f.get("name") is not None}
|
||||
|
||||
|
||||
def _load_kits(parts):
|
||||
"""[(kitName, checksumHex)] in kit-id order -> (types by kit, types by qname)."""
|
||||
kits, types_by_q = [], {}
|
||||
for name, cks in parts:
|
||||
path = schema.find_manifest_path(name, cks)
|
||||
if path is None:
|
||||
raise SabError(f"no manifest for kit '{name}' in Sedona home")
|
||||
mroot = ET.parse(path).getroot()
|
||||
tlist = sorted(mroot.findall("type"), key=lambda t: int(t.get("id")))
|
||||
for t in tlist:
|
||||
types_by_q[f"{name}::{t.get('name')}"] = t
|
||||
kits.append(tlist)
|
||||
return kits, types_by_q
|
||||
|
||||
|
||||
def _resolve_slots(qname, types_by_q, cache):
|
||||
"""The type's slots, base chain first, indexed by slot id."""
|
||||
if qname in cache:
|
||||
return cache[qname]
|
||||
if qname not in types_by_q:
|
||||
raise SabError(f"type {qname} is not in the app's kits -- a kit build that "
|
||||
f"needs kit '{qname.split('::')[0]}', which the app does not list?")
|
||||
t = types_by_q[qname]
|
||||
base = t.get("base")
|
||||
out = list(_resolve_slots(base, types_by_q, cache)) if base else []
|
||||
by_name = {s.name: i for i, s in enumerate(out)}
|
||||
for elem in sorted(t.findall("slot"), key=lambda s: int(s.get("id"))):
|
||||
slot = Slot(elem)
|
||||
if slot.name in by_name: # an action override keeps the inherited id
|
||||
out[by_name[slot.name]] = slot
|
||||
else:
|
||||
by_name[slot.name] = len(out)
|
||||
out.append(slot)
|
||||
cache[qname] = out
|
||||
return out
|
||||
|
||||
|
||||
# --- values ---------------------------------------------------------------------------
|
||||
|
||||
def _int(raw): # Integer.decode: supports 0x
|
||||
if raw in (None, "", "null"):
|
||||
return 0
|
||||
return int(raw, 0) if raw.lower().startswith(("0x", "-0x")) else int(raw)
|
||||
|
||||
|
||||
def _flt(raw):
|
||||
if raw == "null":
|
||||
return float("nan") # explicit null only
|
||||
if raw in (None, ""):
|
||||
return 0.0 # Value.defaultForType -> zero
|
||||
return float(raw)
|
||||
|
||||
|
||||
def _decode_value(r, vtype, flags):
|
||||
if "s" in flags: # asStr Buf: u2 len (incl NUL)
|
||||
n = r.u2()
|
||||
return r.take(n).split(b"\0")[0].decode("ascii", errors="replace")
|
||||
if vtype == "bool":
|
||||
v = r.u1()
|
||||
return None if v == 2 else (v == 1)
|
||||
if vtype == "byte": return r.u1()
|
||||
if vtype == "short": return r.u2()
|
||||
if vtype == "int": return r.i4()
|
||||
if vtype == "long": return r.i8()
|
||||
if vtype == "float": return r.f4()
|
||||
if vtype == "double": return r.f8()
|
||||
if vtype in ("sys::Buf", "buf"):
|
||||
return bytes(r.take(r.u2()))
|
||||
raise SabError(f"unhandled slot type {vtype}")
|
||||
|
||||
|
||||
def _default_value(slot):
|
||||
"""The manifest default, or Value.defaultForType's zero."""
|
||||
vtype, flags, raw = slot.type, slot.flags, slot.default
|
||||
if "s" in flags:
|
||||
return raw or ""
|
||||
if vtype == "bool":
|
||||
return None if raw == "null" else raw in ("true", "1")
|
||||
if vtype in ("byte", "short", "int", "long"):
|
||||
return _int(raw)
|
||||
if vtype == "float":
|
||||
# the default is a 32-bit float too: 0.1 as a double is not the file's 0.1
|
||||
return _to_f32(_flt(raw))
|
||||
if vtype == "double":
|
||||
return _flt(raw)
|
||||
if vtype in ("sys::Buf", "buf"):
|
||||
return base64.b64decode(raw) if raw else b""
|
||||
raise SabError(f"unhandled slot type {vtype}")
|
||||
|
||||
|
||||
def _same(a, b):
|
||||
if isinstance(a, float) and isinstance(b, float):
|
||||
return (math.isnan(a) and math.isnan(b)) or a == b
|
||||
return type(a) is type(b) and a == b
|
||||
|
||||
|
||||
# --- Java number formatting -----------------------------------------------------------
|
||||
|
||||
_F32_MAX = 3.4028234663852886e38
|
||||
_F32_INF_EDGE = 3.4028235677973366e38 # midpoint to 2**128: below rounds to F32_MAX
|
||||
|
||||
|
||||
def _to_f32(v):
|
||||
try:
|
||||
return struct.unpack(">f", struct.pack(">f", v))[0]
|
||||
except OverflowError:
|
||||
if abs(v) < _F32_INF_EDGE:
|
||||
return math.copysign(_F32_MAX, v)
|
||||
return math.copysign(math.inf, v)
|
||||
|
||||
|
||||
def _shortest(a, single):
|
||||
"""Fewest digits that still round-trip -- what Java's toString picks."""
|
||||
for prec in range(0, 17):
|
||||
s = "%.*e" % (prec, a)
|
||||
v = float(s)
|
||||
if single:
|
||||
v = _to_f32(v)
|
||||
if v == a:
|
||||
break
|
||||
mant, exp = s.split("e")
|
||||
digits = mant.replace(".", "").rstrip("0") or "0"
|
||||
return digits, int(exp)
|
||||
|
||||
|
||||
def _java_float(v, single=True):
|
||||
"""Float/Double.toString: always a decimal point, E-form outside [1e-3, 1e7)."""
|
||||
if math.isnan(v):
|
||||
return "NaN"
|
||||
if math.isinf(v):
|
||||
return "-Infinity" if v < 0 else "Infinity"
|
||||
neg = math.copysign(1.0, v) < 0
|
||||
a = abs(v)
|
||||
if a == 0.0:
|
||||
return "-0.0" if neg else "0.0"
|
||||
digits, exp = _shortest(a, single)
|
||||
if -3 <= exp < 7:
|
||||
if exp >= 0:
|
||||
head = digits[:exp + 1].ljust(exp + 1, "0")
|
||||
tail = digits[exp + 1:] or "0"
|
||||
else:
|
||||
head, tail = "0", "0" * (-exp - 1) + digits
|
||||
out = head + "." + tail
|
||||
else:
|
||||
out = digits[0] + "." + (digits[1:] or "0") + "E" + str(exp)
|
||||
return "-" + out if neg else out
|
||||
|
||||
|
||||
def _encode_string(val, slot):
|
||||
"""Value.encodeString()."""
|
||||
vtype, flags = slot.type, slot.flags
|
||||
if "s" in flags:
|
||||
return _literal(val)
|
||||
if vtype == "bool":
|
||||
return "null" if val is None else ("true" if val else "false")
|
||||
if vtype == "float":
|
||||
return "null" if math.isnan(val) else _java_float(val, True)
|
||||
if vtype == "double":
|
||||
return "null" if math.isnan(val) else _java_float(val, False)
|
||||
if vtype in ("sys::Buf", "buf"):
|
||||
return base64.b64encode(val).decode("ascii")
|
||||
return str(val)
|
||||
|
||||
|
||||
def _literal(s):
|
||||
"""TextUtil.toLiteral."""
|
||||
return "".join({"\0": "\\0", "\n": "\\n", "\r": "\\r", "\t": "\\t",
|
||||
"\\": "\\\\", '"': '\\"', "$": "\\$"}.get(c, c) for c in s)
|
||||
|
||||
|
||||
def _safe(s):
|
||||
"""XWriter.safe: escape markup, quotes, and anything outside printable ASCII."""
|
||||
out = []
|
||||
for c in s:
|
||||
o = ord(c)
|
||||
if o < 0x20 or o > 0x7e or c in "'\"":
|
||||
out.append("&#x%x;" % o)
|
||||
elif c == "<":
|
||||
out.append("<")
|
||||
elif c == ">":
|
||||
out.append(">")
|
||||
elif c == "&":
|
||||
out.append("&")
|
||||
else:
|
||||
out.append(c)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
# --- the app --------------------------------------------------------------------------
|
||||
|
||||
class SabApp:
|
||||
"""A decoded app.sab: kits, components by id, and links.
|
||||
|
||||
Each component is a dict: id, type, name, parent, kids, and props -- a list of
|
||||
(Slot, value) for every config prop, defaults included.
|
||||
"""
|
||||
|
||||
def __init__(self, data):
|
||||
r = _Reader(data)
|
||||
if r.i4() != 0x73617070:
|
||||
raise SabError("not a .sab (bad magic)")
|
||||
ver = r.i4()
|
||||
if ver != 0x0003:
|
||||
raise SabError(f"unsupported sab version 0x{ver:x}")
|
||||
|
||||
self.kit_parts = []
|
||||
for _ in range(r.u1()):
|
||||
name = r.str_()
|
||||
self.kit_parts.append((name, f"{r.i4() & 0xFFFFFFFF:08x}"))
|
||||
kits, self._types_by_q = _load_kits(self.kit_parts)
|
||||
self._cache = {}
|
||||
r.u2() # maxId, recomputed on the way out
|
||||
|
||||
qname_of = {}
|
||||
for ki, ((kname, _), tlist) in enumerate(zip(self.kit_parts, kits)):
|
||||
for t in tlist:
|
||||
qname_of[(ki, int(t.get("id")))] = f"{kname}::{t.get('name')}"
|
||||
|
||||
by_id = {}
|
||||
while True:
|
||||
cid = r.u2()
|
||||
if cid == 0xFFFF:
|
||||
break
|
||||
ki, ti = r.u1(), r.u1()
|
||||
if (ki, ti) not in qname_of:
|
||||
raise SabError(f"comp {cid}: no type {ti} in kit {ki}")
|
||||
qname = qname_of[(ki, ti)]
|
||||
c = {"id": cid, "type": qname, "name": r.str_(), "kids": [], "props": []}
|
||||
c["parentId"], c["childId"], c["siblingId"] = r.u2(), r.u2(), r.u2()
|
||||
for slot in self.slots(qname):
|
||||
if "a" in slot.flags or "c" not in slot.flags:
|
||||
continue # config props only, in slot-id order
|
||||
c["props"].append((slot, _decode_value(r, slot.type, slot.flags)))
|
||||
if r.u1() != ord(";"):
|
||||
raise SabError(f"corrupted component {cid} {c['name']}")
|
||||
if cid in by_id:
|
||||
raise SabError(f"duplicate id: {cid}")
|
||||
by_id[cid] = c
|
||||
|
||||
def must(cid, what, owner):
|
||||
if cid not in by_id:
|
||||
raise SabError(f"comp {owner['id']} {owner['name']}: missing {what} {cid}")
|
||||
return by_id[cid]
|
||||
|
||||
for c in by_id.values():
|
||||
c["parent"] = must(c["parentId"], "parent", c) if c["parentId"] != 0xFFFF else None
|
||||
if c["childId"] != 0xFFFF:
|
||||
kid = must(c["childId"], "child", c)
|
||||
while True:
|
||||
c["kids"].append(kid)
|
||||
if kid["siblingId"] == 0xFFFF:
|
||||
break
|
||||
kid = must(kid["siblingId"], "nextSibling", kid)
|
||||
|
||||
self.links = []
|
||||
while True:
|
||||
fc = r.u2()
|
||||
if fc == 0xFFFF:
|
||||
break
|
||||
fs, tc, ts = r.u1(), r.u2(), r.u1()
|
||||
for cid in (fc, tc):
|
||||
if cid not in by_id:
|
||||
raise SabError(f"link references unknown comp id {cid}")
|
||||
self.links.append((by_id[fc], fs, by_id[tc], ts))
|
||||
if r.u1() != ord("."):
|
||||
raise SabError("invalid app end marker")
|
||||
|
||||
if 0 not in by_id:
|
||||
raise SabError("no app component (id 0)")
|
||||
self.comps = by_id
|
||||
self.app = by_id[0]
|
||||
|
||||
def slots(self, qname):
|
||||
return _resolve_slots(qname, self._types_by_q, self._cache)
|
||||
|
||||
def kit_names(self):
|
||||
return [name for name, _ in self.kit_parts]
|
||||
|
||||
def prop(self, comp, name, default=None):
|
||||
for slot, val in comp["props"]:
|
||||
if slot.name == name:
|
||||
return val
|
||||
return default
|
||||
|
||||
def slot(self, comp, name):
|
||||
return next((s for s in self.slots(comp["type"]) if s.name == name), None)
|
||||
|
||||
# --- writing app.sax ---
|
||||
|
||||
def to_sax(self):
|
||||
out = []
|
||||
w = out.append
|
||||
w("<?xml version='1.0'?>\n")
|
||||
w("<sedonaApp>\n")
|
||||
w("<schema>\n")
|
||||
for name, cks in self.kit_parts:
|
||||
w(f' <kit name="{_safe(name)}" checksum="{cks}" />\n')
|
||||
w("</schema>\n")
|
||||
w("<app>\n")
|
||||
self._write_props(w, self.app, 2)
|
||||
for kid in self.app["kids"]:
|
||||
self._write_comp(w, kid, 2)
|
||||
w("</app>\n")
|
||||
w("<links>\n")
|
||||
for fc, fs, tc, ts in self.links:
|
||||
w(' <link from="%s.%s" to="%s.%s"/>\n' % (
|
||||
_safe(_path_of(fc)), _safe(self._slot_name(fc, fs)),
|
||||
_safe(_path_of(tc)), _safe(self._slot_name(tc, ts))))
|
||||
w("</links>\n")
|
||||
w("</sedonaApp>\n")
|
||||
return "".join(out)
|
||||
|
||||
def _slot_name(self, comp, slot_id):
|
||||
slots = self.slots(comp["type"])
|
||||
if slot_id >= len(slots):
|
||||
raise SabError(f"{comp['type']} has no slot id {slot_id}")
|
||||
return slots[slot_id].name
|
||||
|
||||
def _write_props(self, w, c, indent):
|
||||
"""Only props whose value differs from the slot default get written."""
|
||||
n = 0
|
||||
for slot, val in c["props"]:
|
||||
if _same(val, _default_value(slot)):
|
||||
continue
|
||||
w('%s<prop name="%s" val="%s"/>\n' % (
|
||||
" " * indent, _safe(slot.name), _safe(_encode_string(val, slot))))
|
||||
n += 1
|
||||
return n
|
||||
|
||||
def _write_comp(self, w, c, indent):
|
||||
pad = " " * indent
|
||||
w(f"{pad}<!-- {_path_of(c)} -->\n")
|
||||
w(f'{pad}<comp name="{_safe(c["name"])}" id="{c["id"]}" type="{_safe(c["type"])}"')
|
||||
body = []
|
||||
n = self._write_props(body.append, c, indent + 2)
|
||||
for kid in c["kids"]:
|
||||
self._write_comp(body.append, kid, indent + 2)
|
||||
if not n and not c["kids"]:
|
||||
w("/>\n")
|
||||
else:
|
||||
w(">\n")
|
||||
w("".join(body))
|
||||
w(f"{pad}</comp>\n")
|
||||
|
||||
|
||||
def _path_of(c):
|
||||
parts = []
|
||||
while c["parent"] is not None:
|
||||
parts.append(c["name"])
|
||||
c = c["parent"]
|
||||
return "/" + "/".join(reversed(parts)) if parts else "/"
|
||||
|
||||
|
||||
# --- encoding app.sax to app.sab ------------------------------------------------------
|
||||
#
|
||||
# Ported from Py_sab2sax's sab.py (sedona.offline.OfflineApp decodeAppXml +
|
||||
# encodeAppBinary), with the value parsing brought in line with the Java Value classes:
|
||||
# asStr text is unescaped as TextUtil.fromLiteral does, "nullbool"/"nullfloat" are
|
||||
# read, and integers take Integer.decode's '#' hex and leading-zero octal.
|
||||
|
||||
def _decode_int(raw):
|
||||
"""Integer.decode."""
|
||||
if raw in (None, "", "null"):
|
||||
return 0
|
||||
s = raw.strip()
|
||||
neg = s.startswith("-")
|
||||
if s[:1] in "+-":
|
||||
s = s[1:]
|
||||
if s[:2].lower() == "0x":
|
||||
v = int(s[2:], 16)
|
||||
elif s.startswith("#"):
|
||||
v = int(s[1:], 16)
|
||||
elif len(s) > 1 and s.startswith("0"):
|
||||
v = int(s[1:], 8)
|
||||
else:
|
||||
v = int(s)
|
||||
return -v if neg else v
|
||||
|
||||
|
||||
def _from_literal(s):
|
||||
"""TextUtil.fromLiteral."""
|
||||
if "\\" not in s:
|
||||
return s
|
||||
escapes = {"0": "\0", "n": "\n", "r": "\r", "t": "\t", "\\": "\\", '"': '"', "$": "$"}
|
||||
out, i = [], 0
|
||||
while i < len(s):
|
||||
c = s[i]
|
||||
if c == "\\":
|
||||
i += 1
|
||||
if i >= len(s) or s[i] not in escapes:
|
||||
raise SabError(f"unknown escape sequence in {s!r}")
|
||||
c = escapes[s[i]]
|
||||
out.append(c)
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
class _Writer:
|
||||
def __init__(self):
|
||||
self.b = bytearray()
|
||||
|
||||
def u1(self, v): self.b.append(v & 0xFF)
|
||||
def u2(self, v): self.b += struct.pack(">H", v & 0xFFFF)
|
||||
def i4(self, v): self.b += struct.pack(">i", ((v + 2**31) % 2**32) - 2**31)
|
||||
def i8(self, v): self.b += struct.pack(">q", v)
|
||||
def f4(self, v): self.b += struct.pack(">f", _to_f32(v))
|
||||
def f8(self, v): self.b += struct.pack(">d", v)
|
||||
|
||||
def str_(self, s):
|
||||
self.b += s.encode("ascii")
|
||||
self.u1(0)
|
||||
|
||||
|
||||
def _encode_value(out, slot, raw):
|
||||
"""One config prop, from its sax text (or the manifest default when absent)."""
|
||||
vtype, flags = slot.type, slot.flags
|
||||
if "s" in flags: # asStr Buf: u2 len (incl NUL)
|
||||
data = _from_literal(raw or "").encode("ascii")
|
||||
out.u2(len(data) + 1)
|
||||
out.b += data
|
||||
out.u1(0)
|
||||
elif vtype == "bool":
|
||||
if raw in ("null", "nullbool"):
|
||||
out.u1(2)
|
||||
elif raw in (None, "", "false", "true"):
|
||||
out.u1(1 if raw == "true" else 0)
|
||||
else:
|
||||
raise SabError(f"invalid bool {raw!r}")
|
||||
elif vtype == "byte":
|
||||
out.u1(_decode_int(raw))
|
||||
elif vtype == "short":
|
||||
out.u2(_decode_int(raw))
|
||||
elif vtype == "int":
|
||||
out.i4(_decode_int(raw))
|
||||
elif vtype == "long":
|
||||
out.i8(_decode_int(raw))
|
||||
elif vtype in ("float", "double"):
|
||||
v = float("nan") if raw in ("null", "nullfloat", "nulldouble") else _flt(raw)
|
||||
(out.f4 if vtype == "float" else out.f8)(v)
|
||||
elif vtype in ("sys::Buf", "buf"):
|
||||
data = base64.b64decode(raw) if raw else b""
|
||||
out.u2(len(data))
|
||||
out.b += data
|
||||
else:
|
||||
raise SabError(f"unhandled slot type {vtype}")
|
||||
|
||||
|
||||
def encode_sax(sax, checksums=None, kits=None):
|
||||
"""app.sax (text or bytes) -> app.sab bytes, as `sedonac app.sax` writes it.
|
||||
|
||||
checksums, {kit name: checksum hex}, stands in for the sax's own -- how an app goes
|
||||
to a device whose kits are other builds than the ones it was saved with. kits,
|
||||
[(name, checksum hex)], replaces the schema altogether, as Ontrol's PutApp rewrites
|
||||
it for the kits chosen to put. Every kit's manifest must be in Sedona home under
|
||||
the checksum used.
|
||||
"""
|
||||
root = ET.fromstring(sax)
|
||||
schema_elem, app_elem = root.find("schema"), root.find("app")
|
||||
if schema_elem is None or app_elem is None:
|
||||
raise SabError("not a Sedona app: no <schema> or <app>")
|
||||
if kits is None:
|
||||
kits = [(k.get("name"), (checksums or {}).get(k.get("name")) or k.get("checksum"))
|
||||
for k in schema_elem.findall("kit")]
|
||||
parts = []
|
||||
for name, cks in kits:
|
||||
if not cks:
|
||||
raise SabError(f"no checksum for kit '{name}'")
|
||||
parts.append((name, f"{int(cks, 16):08x}"))
|
||||
parts.sort(key=lambda p: ("" if p[0] == "sys" else "\x01" + p[0])) # sortKits
|
||||
for name, cks in parts:
|
||||
path = schema.find_manifest_path(name, cks)
|
||||
if path is None or not path.endswith(f"-{cks}.xml"):
|
||||
raise SabError(f"no manifest for kit '{name}' {cks} in Sedona home")
|
||||
kits, types_by_q = _load_kits(parts)
|
||||
type_ids = {}
|
||||
for ki, ((kname, _), tlist) in enumerate(zip(parts, kits)):
|
||||
for t in tlist:
|
||||
type_ids[f"{kname}::{t.get('name')}"] = (ki, int(t.get("id")))
|
||||
cache = {}
|
||||
|
||||
comps = []
|
||||
|
||||
def walk(elem, qtype, parent):
|
||||
if qtype not in type_ids:
|
||||
raise SabError(f"unknown type '{qtype}' ({elem.get('name')})")
|
||||
c = {"elem": elem, "type": qtype, "parent": parent, "kids": [],
|
||||
"id": int(elem.get("id")) if elem.get("id") else None}
|
||||
comps.append(c)
|
||||
if parent is not None:
|
||||
parent["kids"].append(c)
|
||||
for kid in elem.findall("comp"):
|
||||
walk(kid, kid.get("type"), c)
|
||||
return c
|
||||
|
||||
app = walk(app_elem, "sys::App", None)
|
||||
app["id"] = 0
|
||||
used = {c["id"] for c in comps if c["id"] is not None}
|
||||
next_id = 1
|
||||
for c in comps: # ids for comps the sax gives none
|
||||
if c["id"] is None:
|
||||
while next_id in used:
|
||||
next_id += 1
|
||||
c["id"] = next_id
|
||||
used.add(next_id)
|
||||
by_id = {}
|
||||
for c in comps:
|
||||
if c["id"] in by_id:
|
||||
raise SabError(f"duplicate id: {c['id']}")
|
||||
by_id[c["id"]] = c
|
||||
|
||||
out = _Writer()
|
||||
out.i4(0x73617070) # "sapp"
|
||||
out.i4(0x0003) # version 0.3
|
||||
out.u1(len(parts))
|
||||
for name, cks in parts:
|
||||
out.str_(name)
|
||||
out.i4(int(cks, 16))
|
||||
out.u2(max(by_id))
|
||||
|
||||
for cid in sorted(by_id):
|
||||
c = by_id[cid]
|
||||
ki, ti = type_ids[c["type"]]
|
||||
out.u2(cid)
|
||||
out.u1(ki)
|
||||
out.u1(ti)
|
||||
out.str_(c["elem"].get("name", "app") if cid else "app")
|
||||
out.u2(c["parent"]["id"] if c["parent"] else 0xFFFF)
|
||||
out.u2(c["kids"][0]["id"] if c["kids"] else 0xFFFF)
|
||||
sibling = 0xFFFF
|
||||
if c["parent"]:
|
||||
kids = c["parent"]["kids"]
|
||||
i = kids.index(c)
|
||||
if i + 1 < len(kids):
|
||||
sibling = kids[i + 1]["id"]
|
||||
out.u2(sibling)
|
||||
props = {p.get("name"): p.get("val") for p in c["elem"].findall("prop")}
|
||||
for slot in _resolve_slots(c["type"], types_by_q, cache):
|
||||
if "a" in slot.flags or "c" not in slot.flags:
|
||||
continue # config props only
|
||||
_encode_value(out, slot, props.get(slot.name, slot.default))
|
||||
out.u1(ord(";"))
|
||||
out.u2(0xFFFF)
|
||||
|
||||
def ref(text):
|
||||
path, slot_name = text.rsplit(".", 1)
|
||||
cur = app
|
||||
for part in (p for p in path.split("/") if p):
|
||||
cur = next((k for k in cur["kids"] if k["elem"].get("name") == part), None)
|
||||
if cur is None:
|
||||
raise SabError(f"link to a missing component: {text}")
|
||||
names = [s.name for s in _resolve_slots(cur["type"], types_by_q, cache)]
|
||||
if slot_name not in names:
|
||||
raise SabError(f"link to a missing slot: {text}")
|
||||
return cur["id"], names.index(slot_name)
|
||||
|
||||
links = root.find("links")
|
||||
for link in (links.findall("link") if links is not None else ()):
|
||||
fc, fs = ref(link.get("from"))
|
||||
tc, ts = ref(link.get("to"))
|
||||
out.u2(fc)
|
||||
out.u1(fs)
|
||||
out.u2(tc)
|
||||
out.u1(ts)
|
||||
out.u2(0xFFFF)
|
||||
out.u1(ord("."))
|
||||
return bytes(out.b)
|
||||
+757
-84
File diff suppressed because it is too large
Load Diff
@@ -353,9 +353,11 @@ def get_slots_for_type(type_str, linked_slots=()):
|
||||
if "c" in slot_data["flags"]:
|
||||
continue
|
||||
|
||||
# Format to double digits (e.g., 00, 01, 02)
|
||||
padded_id = f"{slot_data['slot_id']:02d}"
|
||||
display_label = f"{padded_id}. {slot_name}"
|
||||
# Format to double digits (e.g., 00, 01, 02), unless turned off in Preferences
|
||||
if app_state.SHOW_SLOT_NUMBERS:
|
||||
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
|
||||
slots_list.append((slot_name, display_label))
|
||||
|
||||
@@ -134,7 +134,9 @@ class SoxMsg:
|
||||
|
||||
def write_i4(self, v):
|
||||
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
|
||||
|
||||
def write_i8(self, v):
|
||||
@@ -381,6 +383,11 @@ class SoxClient:
|
||||
# event callback: called on main thread via queue
|
||||
self.on_event = None # fn(comp_id, what_char)
|
||||
|
||||
# file transfer: one at a time; its chunks arrive unasked, so the receiver
|
||||
# hands them to whichever transfer is open
|
||||
self._xfer_lock = threading.Lock()
|
||||
self._xfer = None # _FileGet or _FilePut while a transfer is open
|
||||
|
||||
# ── Connect / Disconnect ────────────────────────────────────
|
||||
|
||||
def connect(self):
|
||||
@@ -459,6 +466,11 @@ class SoxClient:
|
||||
# Same 512-byte limit as read_schema: whatever is cut off is left
|
||||
# blank rather than failing the connect.
|
||||
platform_id = res.try_str() or ""
|
||||
# Ontrol's message-length header is for Orion only, as in the Niagara driver.
|
||||
# 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:
|
||||
self._session.include_message_len = platform_id.startswith("ontrol-orion")
|
||||
scode_flags = res.u1() if res.remaining > 0 else 0
|
||||
kit_versions = []
|
||||
for p in parts:
|
||||
@@ -657,13 +669,13 @@ class SoxClient:
|
||||
|
||||
# ── Invoke Action ───────────────────────────────────────────
|
||||
|
||||
def invoke(self, comp_id, slot_id, type_id=None, arg=None):
|
||||
def invoke(self, comp_id, slot_id, type_id=None, arg=None, timeout=30.0):
|
||||
req = _prepare_request(ord('i'))
|
||||
req.write_u2(comp_id)
|
||||
req.write_u1(slot_id)
|
||||
if arg is not None and type_id is not None:
|
||||
encode_value(req, type_id, arg)
|
||||
res = self.request(req)
|
||||
res = self.request(req, timeout)
|
||||
res.check_response('I')
|
||||
|
||||
# ── Add Component ───────────────────────────────────────────
|
||||
@@ -902,18 +914,167 @@ class SoxClient:
|
||||
def _get_slots(self, kit_id, type_id_val):
|
||||
return self.slot_registry.get((kit_id, type_id_val))
|
||||
|
||||
# ── File transfer ───────────────────────────────────────────
|
||||
|
||||
def get_file(self, uri, progress=None):
|
||||
"""Reads a file off the device ('app.sab', 'kits.scode', 'images/x.bin').
|
||||
|
||||
Ported from FileTransfer.getFile: an 'f' request opens the transfer, the
|
||||
device then pushes the file as unsolicited 'k' chunks, and a 'z' closes it.
|
||||
A file the device does not have comes back as a SoxException from the open.
|
||||
progress(done_bytes, total_bytes) is called from this thread as chunks land.
|
||||
"""
|
||||
if self.is_closed:
|
||||
raise SoxException("SoxClient closed")
|
||||
with self._xfer_lock:
|
||||
chunk_size = max(16, self._session.ideal_max - 18)
|
||||
req = _prepare_request(ord('f'))
|
||||
req.write_str("g")
|
||||
req.write_str(uri)
|
||||
req.write_i4(0) # fileSize: unknown on a get
|
||||
req.write_u2(chunk_size)
|
||||
req.write_u1(0) # end of headers
|
||||
|
||||
# Set before the open goes out: the first chunks can beat its reply.
|
||||
xfer = _FileGet()
|
||||
self._xfer = xfer
|
||||
opened = closed = False
|
||||
try:
|
||||
try:
|
||||
res = self.request(req)
|
||||
except SoxException as e:
|
||||
# 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
|
||||
# does not have.
|
||||
raise SoxException(
|
||||
f"The device did not answer the request for {uri} ({e}). Its "
|
||||
f"firmware may not support reading files over Sox; some devices "
|
||||
f"stop responding, or restart, when asked.") from e
|
||||
res.check_response('F')
|
||||
opened = True
|
||||
file_size = res.i4()
|
||||
chunk_size = res.u2()
|
||||
while res.remaining > 0: # response headers -- nothing we use
|
||||
if not res.str():
|
||||
break
|
||||
res.str()
|
||||
num_chunks = -(-file_size // chunk_size) if chunk_size else 0
|
||||
|
||||
timeout = getattr(self._session, "receive_timeout", 30.0) or 30.0
|
||||
while not xfer.done(num_chunks):
|
||||
if self.is_closed:
|
||||
raise SoxException(f"Session closed while reading {uri}")
|
||||
if time.monotonic() - xfer.last_receive > timeout:
|
||||
raise SoxException(f"Timed out reading {uri}")
|
||||
xfer.wait(0.5)
|
||||
if progress:
|
||||
progress(min(file_size, xfer.count() * chunk_size), file_size)
|
||||
|
||||
close = self.request(_prepare_request(ord('z')))
|
||||
closed = True
|
||||
close.check_response('Z')
|
||||
finally:
|
||||
self._xfer = None
|
||||
# Left open, the transfer keeps the device answering "busy" to the next.
|
||||
if opened and not closed and not self.is_closed:
|
||||
try:
|
||||
self.request(_prepare_request(ord('z')))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
data = bytearray(file_size)
|
||||
for num, chunk in xfer.chunks.items():
|
||||
if num < num_chunks:
|
||||
start = num * chunk_size
|
||||
data[start:start + len(chunk)] = chunk
|
||||
if progress:
|
||||
progress(file_size, file_size)
|
||||
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 ──────────────────────────────────────────────
|
||||
|
||||
def request(self, req):
|
||||
results = self.requests([req])
|
||||
def request(self, req, timeout=30.0):
|
||||
results = self.requests([req], timeout)
|
||||
return results[0]
|
||||
|
||||
def requests(self, reqs):
|
||||
def requests(self, reqs, timeout=30.0):
|
||||
"""Sends the batch and waits for every reply."""
|
||||
if self.is_closed:
|
||||
raise SoxException("SoxClient closed")
|
||||
|
||||
if len(reqs) > 255:
|
||||
return self._chunk_requests(reqs)
|
||||
return self._chunk_requests(reqs, timeout)
|
||||
|
||||
with self._lock:
|
||||
for i, req in enumerate(reqs):
|
||||
@@ -926,7 +1087,7 @@ class SoxClient:
|
||||
for req in reqs:
|
||||
self._session.send(req.to_bytes())
|
||||
|
||||
deadline = time.monotonic() + 30.0
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
@@ -948,11 +1109,11 @@ class SoxClient:
|
||||
self._responses = None
|
||||
raise SoxException("Request timeout")
|
||||
|
||||
def _chunk_requests(self, reqs):
|
||||
def _chunk_requests(self, reqs, timeout=30.0):
|
||||
results = []
|
||||
for i in range(0, len(reqs), 255):
|
||||
chunk = reqs[i:i + 255]
|
||||
results.extend(self.requests(chunk))
|
||||
results.extend(self.requests(chunk, timeout))
|
||||
return results
|
||||
|
||||
# called by receiver thread
|
||||
@@ -986,10 +1147,50 @@ class SoxClient:
|
||||
pass
|
||||
|
||||
|
||||
class _FileGet:
|
||||
"""The chunks of the get in progress, filled by the receiver thread."""
|
||||
|
||||
def __init__(self):
|
||||
self.chunks = {}
|
||||
self.last_receive = time.monotonic()
|
||||
self._event = threading.Event()
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def receive_chunk(self, msg):
|
||||
msg.u1() # 'k'
|
||||
msg.u1() # replyNum
|
||||
num = msg.u2()
|
||||
size = msg.u2()
|
||||
data = msg.read_bytes(size)
|
||||
with self._lock:
|
||||
self.chunks[num] = data
|
||||
self.last_receive = time.monotonic()
|
||||
self._event.set()
|
||||
|
||||
def count(self):
|
||||
with self._lock:
|
||||
return len(self.chunks)
|
||||
|
||||
def done(self, num_chunks):
|
||||
with self._lock:
|
||||
return all(n in self.chunks for n in range(num_chunks))
|
||||
|
||||
def wait(self, seconds):
|
||||
self._event.wait(seconds)
|
||||
self._event.clear()
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# 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):
|
||||
def __init__(self, client):
|
||||
super().__init__(name="SoxReceiver", daemon=True)
|
||||
@@ -1013,8 +1214,14 @@ class _SoxReceiver(threading.Thread):
|
||||
cmd = payload[0]
|
||||
if cmd == ord('e'):
|
||||
client._receive_event(msg)
|
||||
elif cmd == ord('k') or cmd == ord('z'):
|
||||
pass # file transfer — not needed for editor
|
||||
elif cmd == ord('k'):
|
||||
xfer = client._xfer
|
||||
if isinstance(xfer, _FileGet):
|
||||
xfer.receive_chunk(msg)
|
||||
elif cmd == ord('z'):
|
||||
xfer = client._xfer # the device closing a put
|
||||
if isinstance(xfer, _FilePut):
|
||||
xfer.closed.set()
|
||||
else:
|
||||
client._receive_response(msg)
|
||||
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
|
||||
import graph free of cycles (sax_file and wiresheet both push commands here).
|
||||
"""
|
||||
|
||||
import app_state
|
||||
import live_hooks
|
||||
import palette
|
||||
import schema
|
||||
from sax_dom import remove_prop, restore_children, set_prop_value, update_meta_property
|
||||
|
||||
_undo_stack = []
|
||||
_redo_stack = []
|
||||
_saved_depth = 0 # len(_undo_stack) as of the last save
|
||||
_refresh_view = None
|
||||
|
||||
|
||||
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
|
||||
_refresh_view = callback
|
||||
|
||||
@@ -34,39 +36,49 @@ def _refresh():
|
||||
|
||||
|
||||
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)
|
||||
_redo_stack.clear()
|
||||
|
||||
|
||||
def can_undo():
|
||||
return bool(_undo_stack)
|
||||
|
||||
|
||||
def can_redo():
|
||||
return bool(_redo_stack)
|
||||
class LiveUndoError(Exception):
|
||||
"""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():
|
||||
if not _undo_stack:
|
||||
return None
|
||||
command = _undo_stack.pop()
|
||||
command.undo()
|
||||
_redo_stack.append(command)
|
||||
_refresh()
|
||||
return command.label
|
||||
|
||||
|
||||
def redo():
|
||||
if not _redo_stack:
|
||||
try:
|
||||
command.undo()
|
||||
except LiveUndoError as failure:
|
||||
_abandon_history("Undo", failure)
|
||||
return None
|
||||
command = _redo_stack.pop()
|
||||
command.redo()
|
||||
_undo_stack.append(command)
|
||||
_refresh()
|
||||
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():
|
||||
"""Pins the current stack depth as the on-disk state."""
|
||||
global _saved_depth
|
||||
@@ -77,14 +89,13 @@ def reset():
|
||||
"""Drops all history, for when a different file is loaded."""
|
||||
global _saved_depth
|
||||
_undo_stack.clear()
|
||||
_redo_stack.clear()
|
||||
_saved_depth = 0
|
||||
|
||||
|
||||
def reload_schema_registry():
|
||||
"""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,
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
"""One Save from the Properties dialog: every slot it changed, as one undoable step.
|
||||
|
||||
@@ -167,6 +216,30 @@ class SlotValueCommand:
|
||||
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:
|
||||
"""Placing a component.
|
||||
|
||||
@@ -190,6 +263,25 @@ class AddComponentCommand:
|
||||
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:
|
||||
"""Adding a single link to the <links> section."""
|
||||
|
||||
@@ -248,3 +340,82 @@ class DeleteComponentCommand:
|
||||
links_parent.remove(link)
|
||||
if self.element in list(self.parent):
|
||||
self.parent.remove(self.element)
|
||||
|
||||
|
||||
class RenameComponentCommand:
|
||||
"""A rename, plus every link whose path ran through the component's old name."""
|
||||
|
||||
def __init__(self, element, old_name, new_name, link_changes, label="rename"):
|
||||
# link_changes: list of (link_element, attr, old_value, new_value)
|
||||
self.element = element
|
||||
self.old_name = old_name
|
||||
self.new_name = new_name
|
||||
self.link_changes = link_changes
|
||||
self.label = label
|
||||
|
||||
def undo(self):
|
||||
self.element.set("name", self.old_name)
|
||||
for link, attr, old_value, _ in self.link_changes:
|
||||
link.set(attr, old_value)
|
||||
|
||||
def redo(self):
|
||||
self.element.set("name", self.new_name)
|
||||
for link, attr, _, new_value in self.link_changes:
|
||||
link.set(attr, new_value)
|
||||
|
||||
|
||||
class LiveRenameCommand(RenameComponentCommand):
|
||||
"""A rename on the device: the old name goes back there before the DOM takes it."""
|
||||
|
||||
def undo(self):
|
||||
if not live_hooks.live_rename_component(self.element, self.old_name):
|
||||
raise LiveUndoError(f"{self.new_name} was not renamed back to {self.old_name} "
|
||||
"on the device")
|
||||
super().undo()
|
||||
|
||||
def redo(self):
|
||||
if not live_hooks.live_rename_component(self.element, self.new_name):
|
||||
raise LiveUndoError(f"{self.old_name} was not renamed to {self.new_name} "
|
||||
"on the device")
|
||||
super().redo()
|
||||
|
||||
|
||||
def apply_child_order(parent, ordered_children):
|
||||
"""Puts `parent`'s <comp> children in the given order; its other children stay put."""
|
||||
children = list(parent)
|
||||
comp_slots = [i for i, child in enumerate(children) if child.tag == "comp"]
|
||||
for slot, child in zip(comp_slots, ordered_children):
|
||||
children[slot] = child
|
||||
parent[:] = children
|
||||
|
||||
|
||||
class ReorderChildrenCommand:
|
||||
"""A new order for a component's children."""
|
||||
|
||||
def __init__(self, parent, old_order, new_order, label="reorder"):
|
||||
self.parent = parent
|
||||
self.old_order = list(old_order)
|
||||
self.new_order = list(new_order)
|
||||
self.label = label
|
||||
|
||||
def undo(self):
|
||||
apply_child_order(self.parent, self.old_order)
|
||||
|
||||
def redo(self):
|
||||
apply_child_order(self.parent, self.new_order)
|
||||
|
||||
|
||||
class LiveReorderChildrenCommand(ReorderChildrenCommand):
|
||||
"""A reorder on the device: the old order goes back there before the DOM takes it."""
|
||||
|
||||
def undo(self):
|
||||
if not live_hooks.live_reorder_children(self.parent, self.old_order):
|
||||
raise LiveUndoError(f"The old order of {self.parent.get('name', 'app')} "
|
||||
"could not be put back on the device")
|
||||
super().undo()
|
||||
|
||||
def redo(self):
|
||||
if not live_hooks.live_reorder_children(self.parent, self.new_order):
|
||||
raise LiveUndoError(f"{self.parent.get('name', 'app')} was not reordered "
|
||||
"on the device")
|
||||
super().redo()
|
||||
|
||||
+77
-3
@@ -17,6 +17,8 @@ from tkinter import font as tkfont, messagebox, ttk
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import app_state
|
||||
import live_hooks
|
||||
import sax_file
|
||||
import undo
|
||||
from sax_dom import set_prop_value
|
||||
|
||||
@@ -425,9 +427,35 @@ class _UserDialog:
|
||||
def _apply_add(self, username, password):
|
||||
elem = ET.Element("comp", name=username, type="sys::User")
|
||||
cred = _hash_credentials(username, password)
|
||||
ET.SubElement(elem, "prop", name="cred", val=cred)
|
||||
ET.SubElement(elem, "prop", name="perm", val=str(self._perm_val))
|
||||
ET.SubElement(elem, "prop", name="prov", val=str(self._prov_val))
|
||||
values = {"cred": cred, "perm": str(self._perm_val), "prov": str(self._prov_val)}
|
||||
|
||||
if app_state.live_mode:
|
||||
# One Sox add carrying every config value, so the user never exists on the
|
||||
# device without its password and rights.
|
||||
try:
|
||||
config = sax_file.build_config_values("sys::User", values)
|
||||
except (ValueError, TypeError) as e:
|
||||
live_hooks.warn_failure("Add User", f"User '{username}' was not added to the device",
|
||||
f"A value does not fit its slot: {e}")
|
||||
return
|
||||
new_id = live_hooks.live_add_component(
|
||||
self._service_comp, "sys", "User", username, config)
|
||||
if new_id is None:
|
||||
live_hooks.warn_failure("Add User", f"User '{username}' was not added to the device")
|
||||
return
|
||||
elem.set("id", str(new_id))
|
||||
|
||||
for name, val in values.items():
|
||||
ET.SubElement(elem, "prop", name=name, val=val)
|
||||
|
||||
if app_state.live_mode:
|
||||
index = len(list(self._service_comp))
|
||||
self._service_comp.append(elem)
|
||||
undo.push(sax_file.LiveComponentCommand(
|
||||
self._service_comp, index, elem, present=True, label="add user"))
|
||||
app_state.mark_dirty()
|
||||
app_state.set_status(f"User '{username}' added to device.")
|
||||
return
|
||||
|
||||
index = len(list(self._service_comp))
|
||||
cmd = undo.AddComponentCommand(self._service_comp, index, elem, label="add user")
|
||||
@@ -458,6 +486,30 @@ class _UserDialog:
|
||||
if not changes:
|
||||
return
|
||||
|
||||
if app_state.live_mode:
|
||||
failures = []
|
||||
applied = []
|
||||
for change in changes:
|
||||
elem, prop, _old, new = change
|
||||
if live_hooks.live_set_prop_value(elem, prop, new):
|
||||
set_prop_value(elem, prop, new)
|
||||
applied.append(change)
|
||||
app_state.mark_dirty()
|
||||
else:
|
||||
failures.append(f"{prop}: {live_hooks.last_error}")
|
||||
if applied:
|
||||
undo.push(undo.LiveSlotValueCommand(applied, label="edit user"))
|
||||
if failures:
|
||||
live_hooks.warn_failure(
|
||||
"Edit User",
|
||||
f"{len(failures)} of {len(changes)} change(s) to user '{username}' did "
|
||||
"not reach the device and were not applied",
|
||||
"\n".join(f" • {failure}" for failure in failures),
|
||||
)
|
||||
else:
|
||||
app_state.set_status(f"User '{username}' updated on device.")
|
||||
return
|
||||
|
||||
cmd = undo.SlotValueCommand(changes, label="edit user")
|
||||
cmd.redo()
|
||||
undo.push(cmd)
|
||||
@@ -593,6 +645,28 @@ class UserServicePanel:
|
||||
parent=self.frame.winfo_toplevel()):
|
||||
return
|
||||
|
||||
if app_state.live_mode:
|
||||
# Read before it goes: once deleted, the device has nothing left to read back.
|
||||
snapshot = live_hooks.read_subtree(user_elem)
|
||||
unreadable = live_hooks.last_error if snapshot is None else ""
|
||||
if not live_hooks.live_delete_component(user_elem):
|
||||
live_hooks.warn_failure("Delete User", f"User '{name}' was not deleted from the device")
|
||||
return
|
||||
index = list(self.comp).index(user_elem)
|
||||
self.comp.remove(user_elem)
|
||||
app_state.mark_dirty()
|
||||
if snapshot is not None:
|
||||
undo.push(sax_file.LiveComponentCommand(
|
||||
self.comp, index, user_elem, present=False, snapshot=snapshot,
|
||||
label="delete user"))
|
||||
app_state.set_status(f"User '{name}' deleted from device.")
|
||||
else:
|
||||
app_state.set_status(
|
||||
f"User '{name}' was deleted from the device, but this delete cannot be "
|
||||
f"undone: {unreadable}", warn=True)
|
||||
self._after_change()
|
||||
return
|
||||
|
||||
index = list(self.comp).index(user_elem)
|
||||
cmd = undo.AddComponentCommand(self.comp, index, user_elem, label="delete user")
|
||||
cmd.undo()
|
||||
|
||||
+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
|
||||
+1039
-301
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user