Compare commits
78
Commits
190ab79bdc
..
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 | ||
|
|
f15fc42363 | ||
|
|
35ab61cefa | ||
|
|
73d168fd87 | ||
|
|
a9593de065 | ||
|
|
fd35e5c9bb | ||
|
|
6a52603951 | ||
|
|
c01a192365 | ||
|
|
138d9927be | ||
|
|
691bedd542 | ||
|
|
7b8c48f7c2 | ||
|
|
e5cb04b8cf | ||
|
|
43d4849533 | ||
|
|
f241448a5b | ||
|
|
f3446f32ad | ||
|
|
7ca029e3ea | ||
|
|
26f9abb0dd | ||
|
|
b0e769075f | ||
|
|
fd951f5b82 | ||
|
|
d60b349cac | ||
|
|
b0eb859f32 | ||
|
|
74c169a401 | ||
|
|
cbf1010c16 | ||
|
|
9e2c063880 | ||
|
|
16edd144e9 | ||
|
|
d5ef8eea6f | ||
|
|
43549046e5 |
@@ -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.116"
|
||||
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 |
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 461 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -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()
|
||||
|
||||
|
||||
+231
-69
@@ -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 ────────────────────────────────────
|
||||
|
||||
@@ -45,6 +61,7 @@ class DeviceSession:
|
||||
"""DASP connect → read schema → load root. Called from background thread."""
|
||||
self.sox_client = SoxClient(host, int(port), user, password)
|
||||
self.sox_client.on_event = self._on_sox_event
|
||||
self.sox_client.checksum_resolver = schema.resolve_truncated_checksum
|
||||
self.sox_client.connect()
|
||||
|
||||
parts = self.sox_client.read_schema()
|
||||
@@ -52,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]
|
||||
@@ -87,54 +116,39 @@ class DeviceSession:
|
||||
def _load_slot_registry(self, parts):
|
||||
"""Builds slot_registry from kit manifests for the device's schema."""
|
||||
registry = {}
|
||||
base_slots = []
|
||||
own_slots = {} # "kit::Type" -> declared slots
|
||||
bases = {} # "kit::Type" -> "kit::Base" or None
|
||||
for kit_id, part in enumerate(parts):
|
||||
kit_name = part.name
|
||||
checksum = part.checksum
|
||||
manifest = schema.find_manifest(kit_name, checksum)
|
||||
if manifest is None:
|
||||
continue
|
||||
if kit_name == "sys":
|
||||
base_slots = self._extract_component_base_slots(manifest)
|
||||
self._register_kit_types(registry, kit_id, kit_name, manifest)
|
||||
# Prepend inherited Component base slots (meta, etc.) to every type,
|
||||
# then renumber IDs to match the wire slot index (positional in the
|
||||
self._register_kit_types(registry, kit_id, kit_name, manifest,
|
||||
own_slots, bases)
|
||||
# Flatten each type's slots through its whole base chain (e.g.
|
||||
# Component -> Service -> ontrolOx::Widget -> PushButtonImage), then
|
||||
# renumber IDs to match the wire slot index (positional in the
|
||||
# flattened inherited + own list — see sedona Type.java resolveSlots).
|
||||
if base_slots:
|
||||
for key in registry:
|
||||
existing_names = {s["name"] for s in registry[key]}
|
||||
for bs in base_slots:
|
||||
if bs["name"] not in existing_names:
|
||||
registry[key].insert(0, dict(bs))
|
||||
for i, slot in enumerate(registry[key]):
|
||||
slot["id"] = i
|
||||
for key in registry:
|
||||
qname = self.sox_client.type_name_map[key]
|
||||
chain = []
|
||||
seen = set()
|
||||
t = qname
|
||||
while t and t not in seen and t in own_slots:
|
||||
seen.add(t)
|
||||
chain.append(t)
|
||||
t = bases.get(t)
|
||||
flat = []
|
||||
for t in reversed(chain):
|
||||
flat.extend(dict(s) for s in own_slots[t])
|
||||
for i, slot in enumerate(flat):
|
||||
slot["id"] = i
|
||||
registry[key] = flat
|
||||
self.sox_client.slot_registry = registry
|
||||
|
||||
def _extract_component_base_slots(self, manifest):
|
||||
"""Extracts base slots from sys::Component that all types inherit."""
|
||||
for type_elem in manifest.iter("type"):
|
||||
if type_elem.get("name") == "Component":
|
||||
slots = []
|
||||
for slot_elem in type_elem.findall("slot"):
|
||||
slot_name = slot_elem.get("name", "")
|
||||
slot_id = int(slot_elem.get("id", "-1"))
|
||||
slot_type = slot_elem.get("type", "int")
|
||||
flags_str = slot_elem.get("flags", "")
|
||||
slots.append({
|
||||
"name": slot_name,
|
||||
"id": slot_id,
|
||||
"type_name": slot_type,
|
||||
"is_config": "c" in flags_str or "p" in flags_str,
|
||||
"is_runtime": "r" in flags_str,
|
||||
"is_action": "a" in flags_str,
|
||||
"is_operator": "o" in flags_str,
|
||||
"is_as_str": slot_type == "Str",
|
||||
"is_prop": "a" not in flags_str,
|
||||
})
|
||||
return slots
|
||||
return []
|
||||
|
||||
def _register_kit_types(self, registry, kit_id, kit_name, manifest):
|
||||
def _register_kit_types(self, registry, kit_id, kit_name, manifest,
|
||||
own_slots, bases):
|
||||
root = manifest
|
||||
for type_elem in root.iter("type"):
|
||||
type_name = type_elem.get("name", "")
|
||||
@@ -165,12 +179,82 @@ class DeviceSession:
|
||||
})
|
||||
registry[(kit_id, type_id_val)] = slots
|
||||
full_name = f"{kit_name}::{type_name}"
|
||||
own_slots[full_name] = slots
|
||||
bases[full_name] = type_elem.get("base") or None
|
||||
self.sox_client.type_name_map[(kit_id, type_id_val)] = full_name
|
||||
# update type_name on any cached component of this type
|
||||
for c in self.sox_client.cache:
|
||||
if c is not None and c.kit_id == kit_id and c.type_id == type_id_val:
|
||||
c.type_name = full_name
|
||||
|
||||
# ── Seeding from app.sab ────────────────────────────────────
|
||||
|
||||
def seed_from_sab(self):
|
||||
"""Fills the cache with the whole app — tree, config and links — from app.sab.
|
||||
|
||||
Reading component by component costs a round trip each, and a device answers one
|
||||
request per pass of its loop: 230 components took 116 s on a P-ION, where saving
|
||||
the app and reading app.sab took 8 s. The app is saved first, so app.sab holds
|
||||
what is running. From here on Sox keeps the cache current, as it does for a
|
||||
lazily loaded one; runtime values are still read when a sheet opens.
|
||||
|
||||
Nothing goes into the cache unless the whole file decodes and its tree matches the
|
||||
device's, so on any failure the caller falls back to lazy loading. Returns the
|
||||
number of components.
|
||||
"""
|
||||
import app_backup
|
||||
import sab_app
|
||||
from sox import Link
|
||||
|
||||
client = self.sox_client
|
||||
root = client.cache_get(0) or client.load(0)
|
||||
app_backup.save_app(client)
|
||||
app = sab_app.SabApp(client.get_file(app_backup.APP_SAB))
|
||||
|
||||
# app.sab numbers kits by the app's schema and Sox by kits.scode's; the two can
|
||||
# differ, so types are matched by name, and a kit only when its checksum agrees.
|
||||
device_kits = {p.name: f"{p.checksum & 0xFFFFFFFF:08x}" for p in client.schema_parts}
|
||||
for name, checksum in app.kit_parts:
|
||||
if device_kits.get(name) != checksum:
|
||||
raise ValueError(f"kit {name} in app.sab is not the device's")
|
||||
type_key = {qname: key for key, qname in client.type_name_map.items()}
|
||||
|
||||
if [k["id"] for k in app.app["kids"]] != root.children:
|
||||
raise ValueError("app.sab does not match the running app")
|
||||
|
||||
comps = {}
|
||||
for c in app.comps.values():
|
||||
key = type_key.get(c["type"])
|
||||
if key is None:
|
||||
raise ValueError(f"no manifest for {c['type']}")
|
||||
comp = SoxComponent(c["id"], key[0], key[1], c["type"])
|
||||
comp.name = c["name"]
|
||||
comp.parent = c["parentId"]
|
||||
comp.children = [k["id"] for k in c["kids"]]
|
||||
comp.permissions = root.permissions
|
||||
for slot, val in c["props"]:
|
||||
comp.values[slot.name] = _as_sox_value(slot, val)
|
||||
comps[comp.id] = comp
|
||||
comps[0].subscription = root.subscription
|
||||
|
||||
# Sox reads a component's links both ways, into it and out of it.
|
||||
for from_c, from_slot, to_c, to_slot in app.links:
|
||||
link = Link(from_c["id"], from_slot, to_c["id"], to_slot)
|
||||
comps[from_c["id"]].links.append(link)
|
||||
if to_c["id"] != from_c["id"]:
|
||||
comps[to_c["id"]].links.append(link)
|
||||
|
||||
for comp in comps.values():
|
||||
client.cache_add(comp)
|
||||
self._seeded = set(comps)
|
||||
return len(comps)
|
||||
|
||||
def _values_to_read(self, comps):
|
||||
"""What to read for `comps` on a sheet open: config too, unless app.sab gave it."""
|
||||
if all(c.id in self._seeded for c in comps):
|
||||
return RUNTIME
|
||||
return CONFIG | RUNTIME
|
||||
|
||||
# ── Lazy Loading ────────────────────────────────────────────
|
||||
|
||||
def load_children(self, comp_id):
|
||||
@@ -188,6 +272,68 @@ 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.
|
||||
|
||||
Nothing is loaded until a folder is opened, so a platform service the user never
|
||||
expanded to is not cached yet. It sits one or two levels down (/service/plat), and
|
||||
those are loaded to find it rather than the whole app.
|
||||
"""
|
||||
client = self.sox_client
|
||||
if client is None:
|
||||
return None
|
||||
|
||||
def cached():
|
||||
return next((c for c in client.cache
|
||||
if c is not None and c.type_name == type_name), None)
|
||||
|
||||
comp = cached()
|
||||
if comp is None:
|
||||
for top in self.load_children(0):
|
||||
if top.name == "service" or top.type_name == type_name:
|
||||
for child in self.load_children(top.id):
|
||||
if child.children and child.type_name != type_name:
|
||||
self.load_children(child.id)
|
||||
comp = cached()
|
||||
if comp is not None and slot_name not in comp.values:
|
||||
client.update([comp], CONFIG)
|
||||
return comp
|
||||
|
||||
# ── Wiresheet Open / Close ──────────────────────────────────
|
||||
|
||||
def open_wiresheet(self, folder_comp_id):
|
||||
@@ -201,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)
|
||||
|
||||
@@ -215,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
|
||||
@@ -279,12 +432,18 @@ 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:
|
||||
app_elem.set("id", str(root_comp.id))
|
||||
# The app is a component like any other, so the Properties view needs its
|
||||
# type and slot values too. A .sax file leaves both implied; here they are not.
|
||||
app_elem.set("type", root_comp.type_name)
|
||||
self._add_prop_elements(app_elem, root_comp)
|
||||
for child_id in root_comp.children:
|
||||
child_comp = self.sox_client.cache_get(child_id)
|
||||
if child_comp is not None:
|
||||
@@ -303,22 +462,7 @@ class DeviceSession:
|
||||
"type": type_qname,
|
||||
}
|
||||
comp_elem = ET.SubElement(parent_elem, "comp", **attribs)
|
||||
|
||||
slots = self.sox_client.slot_registry.get(
|
||||
(sox_comp.kit_id, sox_comp.type_id))
|
||||
if slots:
|
||||
for slot in slots:
|
||||
if slot.get("is_action"):
|
||||
continue
|
||||
name = slot["name"]
|
||||
if name in sox_comp.values:
|
||||
val = sox_comp.values[name]
|
||||
if isinstance(val, float):
|
||||
val_str = _fmt_float(val, _slot_precision(type_qname, name))
|
||||
else:
|
||||
val_str = str(val)
|
||||
ET.SubElement(comp_elem, "prop",
|
||||
name=name, val=val_str)
|
||||
self._add_prop_elements(comp_elem, sox_comp)
|
||||
|
||||
for child_id in sox_comp.children:
|
||||
child_comp = self.sox_client.cache_get(child_id)
|
||||
@@ -331,6 +475,24 @@ class DeviceSession:
|
||||
|
||||
return comp_elem
|
||||
|
||||
def _add_prop_elements(self, comp_elem, sox_comp):
|
||||
"""Writes a <prop> for every cached non-action slot value of `sox_comp`."""
|
||||
slots = self.sox_client.slot_registry.get(
|
||||
(sox_comp.kit_id, sox_comp.type_id))
|
||||
if not slots:
|
||||
return
|
||||
for slot in slots:
|
||||
if slot.get("is_action"):
|
||||
continue
|
||||
name = slot["name"]
|
||||
if name in sox_comp.values:
|
||||
val = sox_comp.values[name]
|
||||
if isinstance(val, float):
|
||||
val_str = _fmt_float(val, _slot_precision(sox_comp.type_name, name))
|
||||
else:
|
||||
val_str = str(val)
|
||||
ET.SubElement(comp_elem, "prop", name=name, val=val_str)
|
||||
|
||||
def build_links_elements(self, sax_root):
|
||||
"""Adds <link> elements under a <links> container, matching .sax structure."""
|
||||
links_elem = sax_root.find("links")
|
||||
@@ -368,16 +530,13 @@ class DeviceSession:
|
||||
|
||||
def _slot_name_by_id(self, kit_id, type_id_val, slot_id):
|
||||
# SOX links use absolute slot IDs across the full inheritance chain.
|
||||
# final_components_registry has correctly resolved absolute IDs;
|
||||
# the per-type slot_registry only stores local (per-type) IDs
|
||||
# which are wrong for types with intermediate parents.
|
||||
type_name = self.sox_client.type_name_map.get((kit_id, type_id_val))
|
||||
if type_name and type_name in app_state.final_components_registry:
|
||||
for s in app_state.final_components_registry[type_name]:
|
||||
if s["slot_id"] == slot_id:
|
||||
return s["name"]
|
||||
# Fallback to slot_registry (local IDs — wrong for inherited types,
|
||||
# but keeps things working when the schema registry is not yet loaded)
|
||||
# Fallback to slot_registry (flattened through the base chain, so
|
||||
# its IDs are absolute too)
|
||||
slots = self.sox_client.slot_registry.get((kit_id, type_id_val))
|
||||
if slots:
|
||||
for s in slots:
|
||||
@@ -437,6 +596,9 @@ class DeviceSession:
|
||||
@staticmethod
|
||||
def _find_element_by_id(root, comp_id):
|
||||
target = str(comp_id)
|
||||
app_elem = root.find("app")
|
||||
if app_elem is not None and app_elem.get("id") == target:
|
||||
return app_elem
|
||||
for elem in root.iter("comp"):
|
||||
if elem.get("id") == target:
|
||||
return elem
|
||||
|
||||
@@ -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.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:
|
||||
|
||||
+786
@@ -0,0 +1,786 @@
|
||||
"""WYSIWYG HMI page editor for ontrolOx::OxPage components.
|
||||
|
||||
Renders the device screen canvas at its native resolution and places the child
|
||||
widgets (ontrolOx::Widget subclasses) by their x, y, width, height slots.
|
||||
Supports selection, drag-move, and a synced widget tree.
|
||||
|
||||
Device resolution is detected from the schema kits:
|
||||
ontrolOrion → 800×480, or 480×800 when its platform service's screenOrientation
|
||||
is portrait (drawn in frame_orion_portrait.png)
|
||||
ontrolRion → 320×240
|
||||
|
||||
A device frame image (the physical bezel) sits behind the screen area when the
|
||||
corresponding PNG is found in the assets/ directory next to this module.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tkinter as tk
|
||||
from tkinter import font as tkfont, ttk
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageTk
|
||||
_HAS_PIL = True
|
||||
except ImportError:
|
||||
_HAS_PIL = False
|
||||
|
||||
import app_state
|
||||
import live_hooks
|
||||
import undo
|
||||
from ox_widget_properties import ORION_KIT, OxWidgetPropertiesPanel, orion_portrait
|
||||
from ox_widget_render import WidgetPainter
|
||||
from sax_dom import set_prop_value
|
||||
|
||||
HEADER_BG = "#f2f2f2"
|
||||
|
||||
_ASSETS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets")
|
||||
|
||||
DEVICE_PROFILES = {
|
||||
"ontrolOrion": {
|
||||
"screen": (800, 480),
|
||||
"frame_file": "frame_orion.png",
|
||||
"screen_offset": (191, 169),
|
||||
"default_zoom": 0.5,
|
||||
},
|
||||
"ontrolRion": {
|
||||
"screen": (320, 240),
|
||||
"frame_file": "frame_rion.png",
|
||||
"screen_offset": (76, 62),
|
||||
"default_zoom": 1.0,
|
||||
},
|
||||
}
|
||||
ORION_PORTRAIT_PROFILE = {
|
||||
"screen": (480, 800),
|
||||
"frame_file": "frame_orion_portrait.png",
|
||||
"screen_offset": (190, 170),
|
||||
"default_zoom": 0.5,
|
||||
}
|
||||
DEFAULT_SCREEN = (800, 480)
|
||||
DEFAULT_ZOOM = 0.5
|
||||
|
||||
ZOOM_LEVELS = (0.25, 0.33, 0.5, 0.67, 0.8, 1.0, 1.25, 1.5, 2.0, 3.0)
|
||||
|
||||
CANVAS_PADDING = 16
|
||||
# About what the widget tree / properties pane asks for (300px sheet + scrollbar).
|
||||
RIGHT_PANE_WIDTH = 320
|
||||
|
||||
WIDGET_OUTLINE_WIDTH = 1
|
||||
SELECTED_OUTLINE = "#1f4e79"
|
||||
SELECTED_OUTLINE_WIDTH = 2
|
||||
HANDLE_SIZE = 6
|
||||
HANDLE_COLOUR = "#00b050"
|
||||
|
||||
_SHORT_TYPE_NAMES = {
|
||||
"NumericLabel": "NL",
|
||||
"NumericLabelSet": "NLS",
|
||||
"PushButtonLabel": "PBL",
|
||||
"PushButtonImage": "PBI",
|
||||
"BooleanLabel": "BL",
|
||||
"BooleanImage": "BI",
|
||||
"Geometry": "Geo",
|
||||
"CalibrationButton": "Cal",
|
||||
"NumericSelectImage": "NSI",
|
||||
"NumericSelectLabel": "NSL",
|
||||
"DateLabelSet": "DLS",
|
||||
"TimeLabelSet": "TLS",
|
||||
"ScheduleLabelSet": "ScL",
|
||||
}
|
||||
|
||||
|
||||
def _rgb565_to_hex(val):
|
||||
try:
|
||||
v = int(val)
|
||||
except (TypeError, ValueError):
|
||||
return "#ffffff"
|
||||
if v < 0:
|
||||
v &= 0xFFFF
|
||||
r = ((v >> 11) & 0x1F) * 255 // 31
|
||||
g = ((v >> 5) & 0x3F) * 255 // 63
|
||||
b = (v & 0x1F) * 255 // 31
|
||||
return f"#{r:02x}{g:02x}{b:02x}"
|
||||
|
||||
|
||||
def _get_prop(comp, name, default=None):
|
||||
prop = comp.find(f"prop[@name='{name}']")
|
||||
return prop.get("val", default) if prop is not None else default
|
||||
|
||||
|
||||
def _short_type(full_type):
|
||||
short = full_type.rsplit("::", 1)[-1] if "::" in full_type else full_type
|
||||
return _SHORT_TYPE_NAMES.get(short, short)
|
||||
|
||||
|
||||
def _detect_device_profile():
|
||||
for kit_name, profile in DEVICE_PROFILES.items():
|
||||
if kit_name in app_state.active_schema_kits:
|
||||
if kit_name == ORION_KIT and orion_portrait():
|
||||
return ORION_PORTRAIT_PROFILE
|
||||
return profile
|
||||
return None
|
||||
|
||||
|
||||
def _detect_screen_size():
|
||||
profile = _detect_device_profile()
|
||||
return profile["screen"] if profile else DEFAULT_SCREEN
|
||||
|
||||
|
||||
def _load_frame_image(profile):
|
||||
if profile is None:
|
||||
return None, None
|
||||
path = os.path.join(_ASSETS_DIR, profile["frame_file"])
|
||||
if not os.path.isfile(path):
|
||||
return None, None
|
||||
if _HAS_PIL:
|
||||
try:
|
||||
pil_img = Image.open(path).convert("RGBA")
|
||||
tk_img = ImageTk.PhotoImage(pil_img)
|
||||
return pil_img, tk_img
|
||||
except Exception:
|
||||
return None, None
|
||||
try:
|
||||
return None, tk.PhotoImage(file=path)
|
||||
except tk.TclError:
|
||||
return None, None
|
||||
|
||||
|
||||
def _is_ox_widget(comp):
|
||||
comp_type = comp.get("type", "")
|
||||
return comp_type.startswith("ontrolOx::") and comp_type != "ontrolOx::OxPage"
|
||||
|
||||
|
||||
def _get_widget_children(page_comp):
|
||||
return [c for c in page_comp if c.tag == "comp" and _is_ox_widget(c)]
|
||||
|
||||
|
||||
CANVAS_BG = "#e0e0e0"
|
||||
|
||||
GRID_STEP = 10 # device pixels
|
||||
GRID_MIN_SPACING = 4 # canvas pixels; a denser grid is left out rather than drawn solid
|
||||
GRID_SHADE = 0.82 # grid line = page colour scaled by this (lifted on dark pages)
|
||||
|
||||
# Page Editor toggles, held for the session like the Sedona sheet's: whether the page
|
||||
# can be changed (edit mode, which is also when the 10x10 grid is drawn), whether a
|
||||
# drag lands on the grid, and whether the tree/properties pane shows.
|
||||
edit_mode = False
|
||||
snap_to_grid = True
|
||||
show_right_pane = True
|
||||
|
||||
|
||||
def _grid_colour(page_hex):
|
||||
r, g, b = (int(page_hex[i:i + 2], 16) for i in (1, 3, 5))
|
||||
if (r * 299 + g * 587 + b * 114) / 1000 < 60:
|
||||
r, g, b = (min(255, v + 45) for v in (r, g, b))
|
||||
else:
|
||||
r, g, b = (int(v * GRID_SHADE) for v in (r, g, b))
|
||||
return f"#{r:02x}{g:02x}{b:02x}"
|
||||
|
||||
|
||||
class OxPageViewPanel:
|
||||
|
||||
def __init__(self, parent, comp, on_change=None, on_close=None):
|
||||
self.comp = comp
|
||||
self.on_change = on_change
|
||||
self.on_close = on_close
|
||||
self.dirty = False
|
||||
self.frame = tk.Frame(parent)
|
||||
|
||||
profile = _detect_device_profile()
|
||||
self._screen_w, self._screen_h = _detect_screen_size()
|
||||
self._frame_pil, self._frame_image = _load_frame_image(profile)
|
||||
self._scaled_frame = None
|
||||
|
||||
if self._frame_image and profile:
|
||||
self._screen_ox = profile["screen_offset"][0]
|
||||
self._screen_oy = profile["screen_offset"][1]
|
||||
frame_w = self._frame_image.width()
|
||||
frame_h = self._frame_image.height()
|
||||
self._frame_ox = CANVAS_PADDING
|
||||
self._frame_oy = CANVAS_PADDING
|
||||
self._canvas_w = frame_w + CANVAS_PADDING * 2
|
||||
self._canvas_h = frame_h + CANVAS_PADDING * 2
|
||||
else:
|
||||
self._screen_ox = 0
|
||||
self._screen_oy = 0
|
||||
self._frame_ox = 0
|
||||
self._frame_oy = 0
|
||||
self._canvas_w = self._screen_w + CANVAS_PADDING * 2
|
||||
self._canvas_h = self._screen_h + CANVAS_PADDING * 2
|
||||
|
||||
self._zoom = profile.get("default_zoom", DEFAULT_ZOOM) if profile else DEFAULT_ZOOM
|
||||
self._selected_widget = None
|
||||
self._widget_items = {}
|
||||
self._tag_to_comp = {}
|
||||
self._widget_boxes = {}
|
||||
self._drag = {"tag": None, "x": 0, "y": 0}
|
||||
|
||||
self._build_header()
|
||||
self._build_body()
|
||||
self._build_footer()
|
||||
self._apply_right_pane()
|
||||
self.refresh()
|
||||
|
||||
@property
|
||||
def _screen_canvas_x(self):
|
||||
if self._frame_image:
|
||||
return self._frame_ox + self._screen_ox
|
||||
return CANVAS_PADDING
|
||||
|
||||
@property
|
||||
def _screen_canvas_y(self):
|
||||
if self._frame_image:
|
||||
return self._frame_oy + self._screen_oy
|
||||
return CANVAS_PADDING
|
||||
|
||||
def _build_header(self):
|
||||
header = tk.Frame(self.frame, padx=8, pady=6, bg=HEADER_BG)
|
||||
header.pack(fill=tk.X)
|
||||
|
||||
name_font = tkfont.Font(family="Segoe UI", size=9, weight="bold")
|
||||
plain_font = tkfont.Font(family="Segoe UI", size=9)
|
||||
|
||||
tk.Label(header, text="Page Editor", font=name_font,
|
||||
bg=HEADER_BG).pack(side=tk.LEFT)
|
||||
|
||||
page_name = self.comp.get("name", "?")
|
||||
tk.Label(header, text=f"— {page_name} ({self._screen_w}×{self._screen_h})",
|
||||
font=plain_font, fg="#555555", bg=HEADER_BG).pack(side=tk.LEFT,
|
||||
padx=(8, 0))
|
||||
|
||||
self._widget_count_var = tk.StringVar(value="")
|
||||
tk.Label(header, textvariable=self._widget_count_var, font=plain_font,
|
||||
fg="#555555", bg=HEADER_BG).pack(side=tk.RIGHT)
|
||||
|
||||
self._edit_var = tk.BooleanVar(value=edit_mode)
|
||||
self._snap_var = tk.BooleanVar(value=snap_to_grid)
|
||||
self._right_pane_var = tk.BooleanVar(value=show_right_pane)
|
||||
|
||||
def _build_body(self):
|
||||
body = ttk.PanedWindow(self.frame, orient=tk.HORIZONTAL)
|
||||
body.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
canvas_frame = ttk.Frame(body)
|
||||
self._build_canvas(canvas_frame)
|
||||
body.add(canvas_frame, weight=1)
|
||||
|
||||
# Workbench's layout: the widget tree above, the selected widget's sheet below.
|
||||
side = ttk.PanedWindow(body, orient=tk.VERTICAL)
|
||||
body.add(side, weight=0)
|
||||
self._body = body
|
||||
self._side = side
|
||||
|
||||
tree_frame = ttk.Frame(side)
|
||||
self._build_widget_tree(tree_frame)
|
||||
side.add(tree_frame, weight=1)
|
||||
|
||||
self._props = OxWidgetPropertiesPanel(side, self._commit_props, self._repaint_all)
|
||||
self._props.set_editable(self._edit_var.get())
|
||||
side.add(self._props.frame, weight=3)
|
||||
|
||||
def _build_widget_tree(self, parent):
|
||||
ttk.Label(parent, text="Widgets", font=("TkDefaultFont", 9, "bold")).pack(
|
||||
anchor="w", padx=4, pady=(4, 2))
|
||||
|
||||
tree_container = ttk.Frame(parent)
|
||||
tree_container.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)
|
||||
|
||||
self._tree = ttk.Treeview(tree_container, show="tree", selectmode="browse",
|
||||
height=8)
|
||||
vscroll = ttk.Scrollbar(tree_container, orient=tk.VERTICAL,
|
||||
command=self._tree.yview)
|
||||
self._tree.configure(yscrollcommand=vscroll.set)
|
||||
|
||||
tree_container.columnconfigure(0, weight=1)
|
||||
tree_container.rowconfigure(0, weight=1)
|
||||
self._tree.grid(row=0, column=0, sticky="nsew")
|
||||
vscroll.grid(row=0, column=1, sticky="ns")
|
||||
|
||||
self._tree.bind("<<TreeviewSelect>>", self._on_tree_select)
|
||||
self._tree_items = {}
|
||||
|
||||
def _build_canvas(self, parent):
|
||||
vscroll = ttk.Scrollbar(parent, orient=tk.VERTICAL)
|
||||
vscroll.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
hscroll = ttk.Scrollbar(parent, orient=tk.HORIZONTAL)
|
||||
hscroll.pack(side=tk.BOTTOM, fill=tk.X)
|
||||
|
||||
self._canvas = tk.Canvas(
|
||||
parent, bg=self._background(), highlightthickness=0,
|
||||
xscrollcommand=hscroll.set, yscrollcommand=vscroll.set,
|
||||
scrollregion=(0, 0, int(self._canvas_w * self._zoom), int(self._canvas_h * self._zoom)),
|
||||
)
|
||||
self._canvas.pack(fill=tk.BOTH, expand=True)
|
||||
vscroll.config(command=self._canvas.yview)
|
||||
hscroll.config(command=self._canvas.xview)
|
||||
|
||||
self._canvas.bind("<ButtonPress-1>", self._on_canvas_click)
|
||||
self._canvas.bind("<B1-Motion>", self._on_canvas_drag)
|
||||
self._canvas.bind("<ButtonRelease-1>", self._on_canvas_release)
|
||||
self._canvas.bind("<ButtonPress-3>", self._on_canvas_right_click)
|
||||
self._canvas.bind("<Control-MouseWheel>", self._on_wheel_zoom)
|
||||
|
||||
def _render_page(self):
|
||||
c = self._canvas
|
||||
c.delete("all")
|
||||
self._widget_items.clear()
|
||||
self._tag_to_comp.clear()
|
||||
self._widget_boxes.clear()
|
||||
z = self._zoom
|
||||
|
||||
sx = self._screen_canvas_x * z
|
||||
sy = self._screen_canvas_y * z
|
||||
|
||||
bg_val = _get_prop(self.comp, "backgroundColor", "65535")
|
||||
bg_colour = _rgb565_to_hex(bg_val)
|
||||
c.create_rectangle(sx, sy, sx + self._screen_w * z, sy + self._screen_h * z,
|
||||
fill=bg_colour, outline="",
|
||||
tags=("screen_bg",))
|
||||
|
||||
self._painter = WidgetPainter(c, sx, sy, z, bg_colour)
|
||||
self._painter.page_image(_get_prop(self.comp, "imageFileName", ""),
|
||||
self._screen_w, self._screen_h)
|
||||
if self._edit_var.get():
|
||||
self._draw_grid(sx, sy, _grid_colour(bg_colour))
|
||||
|
||||
if self._frame_image:
|
||||
if z != 1.0 and self._frame_pil is not None:
|
||||
# Rescaled once per zoom level; every edit redraws the page.
|
||||
if getattr(self, "_scaled_frame_zoom", None) != z:
|
||||
orig_w, orig_h = self._frame_pil.size
|
||||
zw = max(1, int(orig_w * z))
|
||||
zh = max(1, int(orig_h * z))
|
||||
resized = self._frame_pil.resize((zw, zh), Image.LANCZOS)
|
||||
self._scaled_frame = ImageTk.PhotoImage(resized)
|
||||
self._scaled_frame_zoom = z
|
||||
c.create_image(self._frame_ox * z, self._frame_oy * z, anchor="nw",
|
||||
image=self._scaled_frame, tags=("device_frame",))
|
||||
else:
|
||||
c.create_image(self._frame_ox, self._frame_oy, anchor="nw",
|
||||
image=self._frame_image, tags=("device_frame",))
|
||||
else:
|
||||
c.create_rectangle(sx, sy, sx + self._screen_w * z, sy + self._screen_h * z,
|
||||
fill="", outline="#333333", width=2,
|
||||
tags=("screen_border",))
|
||||
|
||||
widgets = _get_widget_children(self.comp)
|
||||
for widget in widgets:
|
||||
self._draw_widget(widget)
|
||||
|
||||
self._widget_count_var.set(
|
||||
f"{len(widgets)} widget{'' if len(widgets) == 1 else 's'}")
|
||||
|
||||
self._populate_tree()
|
||||
|
||||
def _draw_grid(self, sx, sy, colour):
|
||||
"""The 10x10 device-pixel grid over the page background, under the widgets."""
|
||||
c = self._canvas
|
||||
z = self._zoom
|
||||
if GRID_STEP * z < GRID_MIN_SPACING:
|
||||
return
|
||||
right = sx + self._screen_w * z
|
||||
bottom = sy + self._screen_h * z
|
||||
for gx in range(GRID_STEP, self._screen_w, GRID_STEP):
|
||||
c.create_line(sx + gx * z, sy, sx + gx * z, bottom, fill=colour, tags=("grid",))
|
||||
for gy in range(GRID_STEP, self._screen_h, GRID_STEP):
|
||||
c.create_line(sx, sy + gy * z, right, sy + gy * z, fill=colour, tags=("grid",))
|
||||
|
||||
def _on_edit_toggled(self):
|
||||
global edit_mode
|
||||
edit_mode = self._edit_var.get()
|
||||
if not edit_mode:
|
||||
self._props.flush_edits()
|
||||
self._props.set_editable(edit_mode)
|
||||
self.refresh()
|
||||
app_state.set_status("Page Editor: edit mode — the page can be changed."
|
||||
if edit_mode else "Page Editor: view mode — nothing can be changed.")
|
||||
|
||||
def _on_snap_toggled(self):
|
||||
global snap_to_grid
|
||||
snap_to_grid = self._snap_var.get()
|
||||
|
||||
def _on_right_pane_toggled(self):
|
||||
global show_right_pane
|
||||
show_right_pane = self._right_pane_var.get()
|
||||
self._apply_right_pane()
|
||||
|
||||
def _apply_right_pane(self):
|
||||
"""Takes the widget tree and properties sheet out of the split, or puts them back."""
|
||||
showing = str(self._side) in self._body.panes()
|
||||
if self._right_pane_var.get() and not showing:
|
||||
self._body.add(self._side, weight=0)
|
||||
elif not self._right_pane_var.get() and showing:
|
||||
self._props.flush_edits()
|
||||
self._body.forget(self._side)
|
||||
|
||||
# ── main toolbar ──────────────────────────────────────────────
|
||||
|
||||
def toolbar_tools(self):
|
||||
"""The Page Editor's own toolbar buttons: (icon, tooltip, variable, command).
|
||||
|
||||
`main` lays them out on its toolbar while this editor is open and takes them
|
||||
down with it. A button with a variable is a toggle; add new tools here.
|
||||
"""
|
||||
return [
|
||||
("✏", "Edit Mode", self._edit_var, self._on_edit_toggled),
|
||||
("⌗", "Snap to Grid", self._snap_var, self._on_snap_toggled),
|
||||
("◨", "Widgets / Properties Pane", self._right_pane_var,
|
||||
self._on_right_pane_toggled),
|
||||
]
|
||||
|
||||
def _draw_widget(self, widget):
|
||||
"""Paints the widget as the device would, then lays an empty box over it.
|
||||
|
||||
The box is what selection outlines and what a drag's bbox is read from, so a
|
||||
transparent label with no text can still be found and moved.
|
||||
"""
|
||||
tag = f"ox_widget_{id(widget)}"
|
||||
tags = (tag, "ox_widget")
|
||||
x, y, w, h = self._painter.draw(widget, tags, _short_type(widget.get("type", "")))
|
||||
|
||||
z = self._zoom
|
||||
cx = (self._screen_canvas_x + x) * z
|
||||
cy = (self._screen_canvas_y + y) * z
|
||||
self._canvas.create_rectangle(cx, cy, cx + w * z, cy + h * z, fill="", outline="",
|
||||
width=WIDGET_OUTLINE_WIDTH,
|
||||
tags=(tag, "ox_widget", "ox_widget_rect"))
|
||||
|
||||
self._widget_items[widget] = tag
|
||||
self._tag_to_comp[tag] = widget
|
||||
self._widget_boxes[widget] = (x, y, w, h)
|
||||
|
||||
def _widget_at(self, cx, cy):
|
||||
"""The topmost widget whose box holds canvas point (cx, cy): the last one drawn."""
|
||||
z = self._zoom
|
||||
px = cx / z - self._screen_canvas_x
|
||||
py = cy / z - self._screen_canvas_y
|
||||
for widget in reversed(_get_widget_children(self.comp)):
|
||||
box = self._widget_boxes.get(widget)
|
||||
if box and box[0] <= px <= box[0] + box[2] and box[1] <= py <= box[1] + box[3]:
|
||||
return widget
|
||||
return None
|
||||
|
||||
def _populate_tree(self):
|
||||
for item in self._tree.get_children():
|
||||
self._tree.delete(item)
|
||||
self._tree_items.clear()
|
||||
|
||||
page_name = self.comp.get("name", "?")
|
||||
page_iid = self._tree.insert("", tk.END, text=f"\U0001f4c4 {page_name}",
|
||||
open=True)
|
||||
|
||||
widgets = _get_widget_children(self.comp)
|
||||
for widget in widgets:
|
||||
name = widget.get("name", "?")
|
||||
short = _short_type(widget.get("type", ""))
|
||||
iid = self._tree.insert(page_iid, tk.END, text=f"{name} [{short}]")
|
||||
self._tree_items[iid] = widget
|
||||
|
||||
def _select_widget(self, widget):
|
||||
c = self._canvas
|
||||
|
||||
c.itemconfig("ox_widget_rect", outline="", width=WIDGET_OUTLINE_WIDTH)
|
||||
c.delete("selection_handle")
|
||||
|
||||
self._selected_widget = widget
|
||||
# With nothing selected the sheet shows the page itself, as Workbench does.
|
||||
self._props.show(widget if widget is not None else self.comp)
|
||||
if widget is None:
|
||||
return
|
||||
|
||||
tag = self._widget_items.get(widget)
|
||||
if tag is None:
|
||||
return
|
||||
|
||||
# Only the outline comes forward: raising the widget itself would paint it over
|
||||
# the ones the device draws later.
|
||||
c.itemconfig(f"{tag} && ox_widget_rect", outline=SELECTED_OUTLINE,
|
||||
width=SELECTED_OUTLINE_WIDTH)
|
||||
c.tag_raise(f"{tag} && ox_widget_rect")
|
||||
|
||||
bbox = c.bbox(f"{tag} && ox_widget_rect")
|
||||
if bbox:
|
||||
self._draw_handles(bbox)
|
||||
|
||||
for iid, comp in self._tree_items.items():
|
||||
if comp is widget:
|
||||
# selection_set always fires <<TreeviewSelect>>, which calls
|
||||
# back into _select_widget — only set it when it changes.
|
||||
if self._tree.selection() != (iid,):
|
||||
self._tree.selection_set(iid)
|
||||
self._tree.see(iid)
|
||||
break
|
||||
|
||||
name = widget.get("name", "?")
|
||||
comp_type = widget.get("type", "")
|
||||
x = _get_prop(widget, "x", "0")
|
||||
y = _get_prop(widget, "y", "0")
|
||||
w = _get_prop(widget, "width", "0")
|
||||
h = _get_prop(widget, "height", "0")
|
||||
app_state.set_status(
|
||||
f"{name} ({comp_type}) x={x} y={y} w={w} h={h}")
|
||||
|
||||
def _draw_handles(self, bbox):
|
||||
c = self._canvas
|
||||
x1, y1, x2, y2 = bbox
|
||||
hs = HANDLE_SIZE // 2
|
||||
points = [
|
||||
(x1, y1), ((x1 + x2) / 2, y1), (x2, y1),
|
||||
(x1, (y1 + y2) / 2), (x2, (y1 + y2) / 2),
|
||||
(x1, y2), ((x1 + x2) / 2, y2), (x2, y2),
|
||||
]
|
||||
for px, py in points:
|
||||
c.create_rectangle(px - hs, py - hs, px + hs, py + hs,
|
||||
fill=HANDLE_COLOUR, outline=HANDLE_COLOUR,
|
||||
tags=("selection_handle",))
|
||||
|
||||
def _repaint_all(self):
|
||||
"""Every view redrawn from the document, this one included."""
|
||||
if self.on_change is not None:
|
||||
self.on_change()
|
||||
else:
|
||||
self.refresh()
|
||||
|
||||
def _commit_props(self, comp, values):
|
||||
"""Writes {prop: raw} to `comp` as one undoable step, and to the device when live.
|
||||
|
||||
Returns whether anything changed; a change repaints every view, this one included.
|
||||
"""
|
||||
changes = []
|
||||
for name, after in values.items():
|
||||
prop = comp.find(f"prop[@name='{name}']")
|
||||
before = prop.get("val") if prop is not None else None
|
||||
if before != after:
|
||||
changes.append((comp, name, before, after))
|
||||
if not changes:
|
||||
return False
|
||||
|
||||
# Live: the device first, and the page only shows what the device accepted.
|
||||
live_failures = []
|
||||
applied = []
|
||||
for change in changes:
|
||||
element, name, _before, after = change
|
||||
if app_state.live_mode and not live_hooks.live_set_prop_value(element, name, after):
|
||||
live_failures.append(f"{name}: {live_hooks.last_error}")
|
||||
continue
|
||||
set_prop_value(element, name, after)
|
||||
applied.append(change)
|
||||
|
||||
if app_state.live_mode:
|
||||
if applied:
|
||||
undo.push(undo.LiveSlotValueCommand(applied))
|
||||
else:
|
||||
undo.push(undo.SlotValueCommand(changes))
|
||||
app_state.mark_dirty()
|
||||
|
||||
self._repaint_all()
|
||||
|
||||
who = comp.get("name", "?")
|
||||
if live_failures:
|
||||
live_hooks.warn_failure(
|
||||
"Page Editor",
|
||||
f"{len(live_failures)} of {len(changes)} change(s) to {who} did not reach "
|
||||
"the device and were not applied",
|
||||
"\n".join(f" • {failure}" for failure in live_failures),
|
||||
)
|
||||
return len(live_failures) < len(changes)
|
||||
else:
|
||||
app_state.set_status(
|
||||
f"{who}: {', '.join(name for _, name, _, _ in changes)} changed.")
|
||||
return True
|
||||
|
||||
def _on_canvas_click(self, event):
|
||||
c = self._canvas
|
||||
cx = c.canvasx(event.x)
|
||||
cy = c.canvasy(event.y)
|
||||
|
||||
# Hit-tested by box, not by canvas item: a transparent label is mostly grid.
|
||||
widget = self._widget_at(cx, cy)
|
||||
if widget is not None:
|
||||
self._select_widget(widget)
|
||||
if not self._edit_var.get():
|
||||
self._drag["tag"] = None
|
||||
return
|
||||
box = self._widget_boxes[widget]
|
||||
self._drag.update(tag=self._widget_items[widget], x=cx, y=cy,
|
||||
origin=(box[0], box[1]), pos=(box[0], box[1]))
|
||||
else:
|
||||
self._select_widget(None)
|
||||
self._drag["tag"] = None
|
||||
|
||||
def _on_canvas_drag(self, event):
|
||||
if not self._drag["tag"]:
|
||||
return
|
||||
|
||||
c = self._canvas
|
||||
z = self._zoom
|
||||
cx = c.canvasx(event.x)
|
||||
cy = c.canvasy(event.y)
|
||||
|
||||
# Worked out from where the drag began rather than step by step, so snapping
|
||||
# never accumulates rounding and the widget lands exactly where it is drawn.
|
||||
ox, oy = self._drag["origin"]
|
||||
new_x = ox + (cx - self._drag["x"]) / z
|
||||
new_y = oy + (cy - self._drag["y"]) / z
|
||||
if self._snap_var.get():
|
||||
new_x = round(new_x / GRID_STEP) * GRID_STEP
|
||||
new_y = round(new_y / GRID_STEP) * GRID_STEP
|
||||
new_x = int(round(max(0, min(new_x, self._screen_w))))
|
||||
new_y = int(round(max(0, min(new_y, self._screen_h))))
|
||||
|
||||
px, py = self._drag["pos"]
|
||||
if (new_x, new_y) != (px, py):
|
||||
c.move(self._drag["tag"], (new_x - px) * z, (new_y - py) * z)
|
||||
self._drag["pos"] = (new_x, new_y)
|
||||
c.delete("selection_handle")
|
||||
|
||||
def _on_canvas_release(self, event):
|
||||
tag = self._drag["tag"]
|
||||
if not tag:
|
||||
return
|
||||
|
||||
widget = self._tag_to_comp.get(tag)
|
||||
if widget is None:
|
||||
self._drag["tag"] = None
|
||||
return
|
||||
|
||||
self._drag["tag"] = None
|
||||
moved = False
|
||||
new_x, new_y = self._drag["pos"]
|
||||
if (new_x, new_y) != self._drag["origin"]:
|
||||
moved = self._commit_props(widget, {"x": str(new_x), "y": str(new_y)})
|
||||
|
||||
# A commit re-renders through refresh(); a drag that went nowhere still has to
|
||||
# put back the handles the motion took down.
|
||||
if not moved:
|
||||
self.refresh()
|
||||
|
||||
def _on_canvas_right_click(self, event):
|
||||
c = self._canvas
|
||||
cx = c.canvasx(event.x)
|
||||
cy = c.canvasy(event.y)
|
||||
|
||||
widget = self._widget_at(cx, cy)
|
||||
if widget is None:
|
||||
return
|
||||
self._select_widget(widget)
|
||||
|
||||
menu = tk.Menu(self._canvas, tearoff=0)
|
||||
menu.add_command(label="Properties",
|
||||
command=lambda: self._open_properties(widget))
|
||||
menu.post(event.x_root, event.y_root)
|
||||
|
||||
def _on_tree_select(self, event):
|
||||
sel = self._tree.selection()
|
||||
if not sel:
|
||||
return
|
||||
widget = self._tree_items.get(sel[0])
|
||||
if widget is not None:
|
||||
self._select_widget(widget)
|
||||
|
||||
def _open_properties(self, widget):
|
||||
pass
|
||||
|
||||
def _on_wheel_zoom(self, event):
|
||||
if not event.delta:
|
||||
return "break"
|
||||
step = 1 if event.delta > 0 else -1
|
||||
level = self.zoom_step(step)
|
||||
anchor = (event.x, event.y)
|
||||
self._zoom_with_anchor(level, anchor)
|
||||
from main import update_zoom_label
|
||||
update_zoom_label()
|
||||
return "break"
|
||||
|
||||
def _zoom_with_anchor(self, level, anchor):
|
||||
c = self._canvas
|
||||
before = self._zoom
|
||||
sheet_x = c.canvasx(anchor[0]) / before
|
||||
sheet_y = c.canvasy(anchor[1]) / before
|
||||
|
||||
after = self.set_zoom(level)
|
||||
if after == before:
|
||||
return
|
||||
|
||||
region = str(c.cget("scrollregion")).split()
|
||||
if len(region) == 4:
|
||||
span_x = float(region[2]) or 1.0
|
||||
span_y = float(region[3]) or 1.0
|
||||
c.xview_moveto(max(0.0, (sheet_x * after - anchor[0]) / span_x))
|
||||
c.yview_moveto(max(0.0, (sheet_y * after - anchor[1]) / span_y))
|
||||
|
||||
# ── Zoom ──────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def zoom(self):
|
||||
return self._zoom
|
||||
|
||||
def zoom_step(self, direction):
|
||||
idx = 0
|
||||
for i, lvl in enumerate(ZOOM_LEVELS):
|
||||
if lvl <= self._zoom:
|
||||
idx = i
|
||||
target = idx + direction
|
||||
target = max(0, min(len(ZOOM_LEVELS) - 1, target))
|
||||
return ZOOM_LEVELS[target]
|
||||
|
||||
def set_zoom(self, level):
|
||||
level = max(ZOOM_LEVELS[0], min(ZOOM_LEVELS[-1], level))
|
||||
self._zoom = level
|
||||
self._apply_zoom()
|
||||
return level
|
||||
|
||||
def fit_zoom_level(self):
|
||||
c = self._canvas
|
||||
view_w = c.winfo_width() - 4
|
||||
view_h = c.winfo_height() - 4
|
||||
if view_w <= 0 or view_h <= 0:
|
||||
return None
|
||||
fit_x = view_w / self._canvas_w
|
||||
fit_y = view_h / self._canvas_h
|
||||
return max(ZOOM_LEVELS[0], min(ZOOM_LEVELS[-1], min(fit_x, fit_y)))
|
||||
|
||||
def _apply_zoom(self):
|
||||
z = self._zoom
|
||||
zw = int(self._canvas_w * z)
|
||||
zh = int(self._canvas_h * z)
|
||||
self._canvas.config(scrollregion=(0, 0, zw, zh))
|
||||
self.refresh()
|
||||
|
||||
def refresh(self):
|
||||
"""Redraws from the document, keeping the selection when the widget survives."""
|
||||
selected = self._selected_widget
|
||||
self._render_page()
|
||||
if selected is not None and selected not in _get_widget_children(self.comp):
|
||||
selected = None
|
||||
self._select_widget(selected)
|
||||
|
||||
def _background(self):
|
||||
return app_state.DIRTY_TINT if app_state.is_dirty else CANVAS_BG
|
||||
|
||||
def refresh_dirty_tint(self):
|
||||
"""Wears the wiresheet's unsaved-state tint while the document has unwritten changes."""
|
||||
self._canvas.config(bg=self._background())
|
||||
|
||||
def close(self):
|
||||
if self.on_close is not None:
|
||||
self.on_close()
|
||||
return True
|
||||
|
||||
def destroy(self):
|
||||
if self.frame.winfo_exists():
|
||||
self.frame.destroy()
|
||||
|
||||
def _build_footer(self):
|
||||
footer = tk.Frame(self.frame, padx=8, pady=6)
|
||||
footer.pack(side=tk.BOTTOM, fill=tk.X)
|
||||
|
||||
ttk.Button(footer, text="Close", width=9,
|
||||
command=self.close).pack(side=tk.RIGHT)
|
||||
|
||||
|
||||
def is_ox_page(comp):
|
||||
return comp is not None and comp.get("type", "") == "ontrolOx::OxPage"
|
||||
|
||||
|
||||
def build_ox_page_view_panel(parent, comp, on_change=None, on_close=None):
|
||||
if comp is None or parent is None:
|
||||
return None
|
||||
return OxPageViewPanel(parent, comp, on_change, on_close)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,532 @@
|
||||
"""Draws Ox widgets on the Page Editor canvas the way the device's firmware draws them.
|
||||
|
||||
The rules are the R-ION firmware's (graphics.c, ontrolXx.c) and each widget's draw():
|
||||
|
||||
text boxes drawn with the device's own bitmap fonts from Sedona home's fontBank
|
||||
(a Windows font of the same size when the file is not there). The
|
||||
line is centred vertically, aligned by attributes bits 4-5 when the
|
||||
box is wider than the text, and cut at the character that would cross
|
||||
the right edge. A box whose draw-background bit (3) is clear is
|
||||
transparent. The last `subscriptDigits` characters are drawn half size.
|
||||
images centred in a box larger than the image, cropped to a smaller one; a
|
||||
file the editor cannot find is drawn as the device draws a missing
|
||||
one, a red and white checker.
|
||||
values each widget shows the text its `in` currently selects, so a live
|
||||
page reads as the device does and an offline one shows the defaults.
|
||||
|
||||
Everything is drawn in canvas units: device pixels times the zoom.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import os
|
||||
import tkinter as tk
|
||||
from tkinter import font as tkfont
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageTk
|
||||
_HAS_PIL = True
|
||||
except ImportError:
|
||||
_HAS_PIL = False
|
||||
|
||||
import app_state
|
||||
import properties
|
||||
from ox_widget_properties import (_Model, BACKGROUND_BIT, ALIGN_MASK, SUBSCRIPT_MASK,
|
||||
DATE_HIDE_YEAR, DATE_TWO_DIGIT_YEAR, DATE_LEADING_ZERO,
|
||||
DATE_MONTH_FIRST, IMAGE_EXTENSIONS, PORTRAIT_FONT_EXTENSION,
|
||||
bank_folders, font_extensions, font_names,
|
||||
parse_lookup, parse_numeric_format, rgb565_to_hex)
|
||||
|
||||
PLACEHOLDER_FILL = "#dce6f2"
|
||||
PLACEHOLDER_OUTLINE = "#7f9db9"
|
||||
HIDDEN_OUTLINE = "#9a9a9a"
|
||||
CHECKER_COLOURS = ((232, 60, 60), (255, 255, 255)) # the firmware's RED, WHITE
|
||||
CHECKER_CELL = 4
|
||||
SEPARATORS = {0: "/", 1: ".", 2: "-", 3: " "}
|
||||
FALLBACK_FAMILY = "Segoe UI"
|
||||
|
||||
_font_cache = {}
|
||||
_families = None
|
||||
_text_cache = {} # rendered lines, so a redraw does not re-decode every glyph
|
||||
TEXT_CACHE_SIZE = 400
|
||||
|
||||
|
||||
def _font(family, pixels, style):
|
||||
"""A Tk font `pixels` tall; style is fontAttributes' 0 normal, 1 bold, 2 italic."""
|
||||
global _families
|
||||
if _families is None:
|
||||
_families = {f.lower() for f in tkfont.families()}
|
||||
if family.lower() not in _families:
|
||||
family = FALLBACK_FAMILY
|
||||
key = (family, pixels, style)
|
||||
if key not in _font_cache:
|
||||
_font_cache[key] = tkfont.Font(
|
||||
family=family, size=-max(1, pixels),
|
||||
weight="bold" if style == 1 else "normal",
|
||||
slant="italic" if style == 2 else "roman")
|
||||
return _font_cache[key]
|
||||
|
||||
|
||||
# --- the device's own bitmap fonts ----------------------------------------------------
|
||||
|
||||
class BitmapFont:
|
||||
"""A device font file: fontBank/'32pt Tahoma.bin', or fontBankXL/'.oxfl' (same layout).
|
||||
|
||||
Orion's portrait cut, fontBankXL/'.oxfp', has the same header and table but stores
|
||||
each glyph turned a quarter turn anticlockwise: `height` pixels to a row, `width`
|
||||
rows. glyph_image turns it back, so every caller sees the upright glyph.
|
||||
|
||||
0..63 header; u16 character count at 32, line height at 34
|
||||
64.. 10 bytes per character: code (u8, or u16 above 191), data offset (u24
|
||||
at 2), width, height, x offset (signed), y offset, x advance
|
||||
data 4-bit grey, two pixels a byte, high nibble first, row by row
|
||||
"""
|
||||
|
||||
def __init__(self, path):
|
||||
data = open(path, "rb").read()
|
||||
self.data = data
|
||||
self.rotated = path.lower().endswith(PORTRAIT_FONT_EXTENSION)
|
||||
self.height = data[34]
|
||||
count = data[32] | data[33] << 8
|
||||
self.glyphs = {}
|
||||
for index in range(count):
|
||||
e = data[64 + index * 10:74 + index * 10]
|
||||
if len(e) < 10:
|
||||
break
|
||||
code = e[0] if e[0] < 192 and e[1] == 0 else e[0] | e[1] << 8
|
||||
self.glyphs[code] = (e[2] | e[3] << 8 | e[4] << 16, e[5], e[6],
|
||||
e[7] - 256 if e[7] > 127 else e[7], e[8], e[9])
|
||||
|
||||
def metrics(self, code):
|
||||
"""(printWidth, xOffset) the way Calculate_Text_Width works them out."""
|
||||
_, width, _, x_off, _, advance = self.glyphs[code]
|
||||
x_offset = 1 if x_off < 0 else x_off + 1
|
||||
print_width = max(advance, width, x_offset + width)
|
||||
return print_width, x_offset
|
||||
|
||||
def glyph_image(self, code):
|
||||
offset, width, height, _, _, _ = self.glyphs[code]
|
||||
stored = (height, width) if self.rotated else (width, height)
|
||||
img = Image.new("L", (max(1, stored[0]), max(1, stored[1])))
|
||||
if width and height:
|
||||
raw = self.data[offset:offset + (width * height + 1) // 2]
|
||||
pixels = bytearray(width * height)
|
||||
for i in range(width * height):
|
||||
byte = raw[i >> 1] if (i >> 1) < len(raw) else 0
|
||||
pixels[i] = ((byte >> 4) if i % 2 == 0 else (byte & 0x0F)) * 17
|
||||
img.putdata(pixels)
|
||||
return img.transpose(Image.ROTATE_270) if self.rotated else img
|
||||
|
||||
|
||||
_bitmap_fonts = {}
|
||||
|
||||
|
||||
def find_bitmap_font(family, size, style):
|
||||
"""The device font for fontName/fontSize/fontAttributes, or None when none is local.
|
||||
|
||||
get_Font_File_Name's naming: '<size>pt <Name>', prefixed 'bold ' for bold. There is
|
||||
no italic cut, so italic falls back to the regular one as the device's lookup would
|
||||
find nothing better. The file comes from the platform's own bank, and on Orion in
|
||||
the cut its screen orientation draws with.
|
||||
"""
|
||||
if not _HAS_PIL:
|
||||
return None
|
||||
stems = [f"{'bold ' if style == 1 else ''}{size}pt {family}"]
|
||||
if style == 1:
|
||||
stems.append(f"{size}pt {family}")
|
||||
candidates = [os.path.join(folder, stem + ext) for stem in stems
|
||||
for folder in bank_folders("font") for ext in font_extensions()]
|
||||
for path in candidates:
|
||||
if path in _bitmap_fonts:
|
||||
return _bitmap_fonts[path]
|
||||
if os.path.isfile(path):
|
||||
try:
|
||||
_bitmap_fonts[path] = BitmapFont(path)
|
||||
except (OSError, IndexError):
|
||||
_bitmap_fonts[path] = None
|
||||
return _bitmap_fonts[path]
|
||||
return None
|
||||
|
||||
|
||||
def render_bitmap_text(font, text, box_width, subscript):
|
||||
"""(mask, text width): the line as Draw_Text_Box lays it out, in device pixels.
|
||||
|
||||
Characters the font lacks are skipped, the last `subscript` drawn ones are halved
|
||||
in both directions and sit on the lower half of the line (Stretch_Char), and the
|
||||
line stops at the first character that would cross the box's right edge.
|
||||
"""
|
||||
codes = [ord(ch) for ch in text if ord(ch) in font.glyphs]
|
||||
stretch_from = len(codes) - subscript if 0 < subscript <= len(codes) else len(codes)
|
||||
layout, width = [], 0
|
||||
for n, code in enumerate(codes):
|
||||
print_width, x_offset = font.metrics(code)
|
||||
stretched = n >= stretch_from
|
||||
if stretched:
|
||||
print_width, x_offset = print_width >> 1, x_offset >> 1
|
||||
if width + print_width > box_width:
|
||||
break
|
||||
layout.append((code, width + x_offset, stretched))
|
||||
width += print_width
|
||||
|
||||
mask = Image.new("L", (max(1, width), max(1, font.height)))
|
||||
for code, x, stretched in layout:
|
||||
glyph = font.glyph_image(code)
|
||||
_, _, height, _, y_offset, _ = font.glyphs[code]
|
||||
if stretched:
|
||||
glyph = glyph.resize((max(1, glyph.width >> 1), max(1, glyph.height >> 1)),
|
||||
Image.BOX)
|
||||
y_offset += height >> 1
|
||||
mask.paste(glyph, (x, y_offset), glyph)
|
||||
return mask, width
|
||||
|
||||
|
||||
def find_image_file(name):
|
||||
"""The local file for a device image name, looked for in Sedona home's image banks."""
|
||||
if not name:
|
||||
return None
|
||||
for folder in bank_folders("image"):
|
||||
for ext in IMAGE_EXTENSIONS:
|
||||
path = os.path.join(folder, name + ext)
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def _format_value(fmt, value):
|
||||
"""sprintf(fmt, value) for the %f formats NumericLabel carries."""
|
||||
parts = parse_numeric_format(fmt)
|
||||
if parts is not None:
|
||||
prefix, show, precision, suffix = parts
|
||||
return f"{prefix}{value:.{precision}f}{suffix}" if show else prefix
|
||||
try:
|
||||
return fmt % value
|
||||
except (TypeError, ValueError):
|
||||
return fmt
|
||||
|
||||
|
||||
def _lookup_text(text, index):
|
||||
"""Process_Text: prefix + the index'th item + suffix, or '<n>:Error'."""
|
||||
prefix, items, suffix = parse_lookup(text)
|
||||
if 0 <= index < len(items):
|
||||
return f"{prefix}{items[index]}{suffix}"
|
||||
return f"{index}:Error"
|
||||
|
||||
|
||||
def _date_text(options, separator):
|
||||
"""Today's date as DateLabelSet.prepareDateString lays it out."""
|
||||
today = datetime.date.today()
|
||||
no_zero = bool(options & DATE_LEADING_ZERO) # the firmware drops the zero when set
|
||||
day = str(today.day) if no_zero else f"{today.day:02d}"
|
||||
month = str(today.month) if no_zero else f"{today.month:02d}"
|
||||
sep = SEPARATORS.get(separator, "/")
|
||||
first, second = (month, day) if options & DATE_MONTH_FIRST else (day, month)
|
||||
text = f"{first}{sep}{second}"
|
||||
if not options & DATE_HIDE_YEAR:
|
||||
year = today.year % 100 if options & DATE_TWO_DIGIT_YEAR else today.year
|
||||
text += f"{sep}{year:02d}"
|
||||
return text
|
||||
|
||||
|
||||
def _time_text(prefix, minutes, am_pm):
|
||||
"""drawTimeTextBox with digits 0: prefix + HH:MM, optionally 12-hour."""
|
||||
minutes = int(minutes) % 1440
|
||||
hour, minute = divmod(minutes, 60)
|
||||
if not am_pm:
|
||||
return f"{prefix}{hour:02d}:{minute:02d}"
|
||||
suffix = " AM" if hour < 12 else " PM"
|
||||
hour = 12 if hour == 0 else hour - 12 if hour > 12 else hour
|
||||
return f"{prefix}{hour:02d}:{minute:02d}{suffix}"
|
||||
|
||||
|
||||
def _as_float(raw, fallback=0.0):
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
|
||||
|
||||
def _bool_state(raw):
|
||||
return properties.bool_state(raw)
|
||||
|
||||
|
||||
class WidgetPainter:
|
||||
"""Paints one page's widgets; holds the PhotoImages the canvas needs kept alive."""
|
||||
|
||||
def __init__(self, canvas, origin_x, origin_y, zoom, page_colour):
|
||||
self.c = canvas
|
||||
self.ox = origin_x
|
||||
self.oy = origin_y
|
||||
self.z = zoom
|
||||
self.page_colour = page_colour
|
||||
self.images = []
|
||||
|
||||
# ── primitives ────────────────────────────────────────────────
|
||||
|
||||
def _xy(self, x, y):
|
||||
return self.ox + x * self.z, self.oy + y * self.z
|
||||
|
||||
def _shape(self, x, y, w, h, fill, shape, tags):
|
||||
x1, y1 = self._xy(x, y)
|
||||
x2, y2 = self._xy(x + w, y + h)
|
||||
maker = self.c.create_oval if shape else self.c.create_rectangle
|
||||
maker(x1, y1, x2, y2, fill=fill, outline="", tags=tags)
|
||||
|
||||
def text_box(self, m, text, x, y, w, h, text_colour, box_colour, tags,
|
||||
attributes=None, shape=None):
|
||||
"""A label's box and text.
|
||||
|
||||
The line box (the font's full height) is centred on the widget box even when
|
||||
it is the taller of the two, so the glyphs sit in the box as Workbench shows
|
||||
them rather than hanging below a short one.
|
||||
"""
|
||||
attributes = m.int("attributes", 0x08) if attributes is None else attributes
|
||||
shape = m.int("shape") if shape is None else shape
|
||||
names = font_names(m) if "fontName" in m.slots else ("Tahoma",)
|
||||
family_index = m.int("fontName")
|
||||
family = names[family_index] if 0 <= family_index < len(names) else names[0]
|
||||
size = m.int("fontSize", 16)
|
||||
style = m.int("fontAttributes")
|
||||
|
||||
if attributes & BACKGROUND_BIT:
|
||||
self._shape(x, y, w, h, rgb565_to_hex(box_colour), shape, tags)
|
||||
if not text:
|
||||
return
|
||||
|
||||
digits = attributes & SUBSCRIPT_MASK
|
||||
bitmap = find_bitmap_font(family, size, style)
|
||||
if bitmap is not None:
|
||||
self._bitmap_text(bitmap, text, x, y, w, h, attributes, digits, text_colour, tags)
|
||||
else:
|
||||
self._tk_text(family, size, style, text, x, y, w, h, attributes, digits,
|
||||
text_colour, tags)
|
||||
|
||||
@staticmethod
|
||||
def _left_space(attributes, box_width, text_width):
|
||||
if box_width <= text_width:
|
||||
return 0
|
||||
alignment = attributes & ALIGN_MASK
|
||||
if alignment == 0x20:
|
||||
return box_width - text_width
|
||||
if alignment == 0x10:
|
||||
return (box_width - text_width) / 2
|
||||
return 0
|
||||
|
||||
def _bitmap_text(self, font, text, x, y, w, h, attributes, digits, text_colour, tags):
|
||||
key = (id(font), text, w, digits, text_colour, self.z)
|
||||
cached = _text_cache.get(key)
|
||||
if cached is None:
|
||||
mask, width = render_bitmap_text(font, text, w, digits)
|
||||
if width == 0:
|
||||
return
|
||||
size = (max(1, round(mask.width * self.z)), max(1, round(mask.height * self.z)))
|
||||
if size != mask.size:
|
||||
# Up close the device's pixels are shown as pixels; below 1:1 they blend.
|
||||
mask = mask.resize(size, Image.NEAREST if self.z >= 2 else Image.LANCZOS)
|
||||
r, g, b = (int(rgb565_to_hex(text_colour)[i:i + 2], 16) for i in (1, 3, 5))
|
||||
img = Image.new("RGBA", mask.size, (r, g, b, 0))
|
||||
img.putalpha(mask)
|
||||
if len(_text_cache) > TEXT_CACHE_SIZE:
|
||||
_text_cache.clear()
|
||||
cached = _text_cache[key] = (ImageTk.PhotoImage(img), width)
|
||||
photo, width = cached
|
||||
self.images.append(photo)
|
||||
left = self._left_space(attributes, w, width)
|
||||
top = (h - font.height) / 2
|
||||
self.c.create_image(*self._xy(x + left, y + top), anchor="nw", image=photo, tags=tags)
|
||||
|
||||
def _tk_text(self, family, size, style, text, x, y, w, h, attributes, digits,
|
||||
text_colour, tags):
|
||||
"""The fallback when no device font file is found: a Windows font of the size."""
|
||||
z = self.z
|
||||
main_font = _font(family, round(size * z), style)
|
||||
half_font = _font(family, round(size * z / 2), style)
|
||||
split = len(text) - digits if 0 < digits < len(text) else len(text)
|
||||
chars = [(ch, main_font if i < split else half_font) for i, ch in enumerate(text)]
|
||||
|
||||
limit = w * z
|
||||
drawn, width = [], 0.0
|
||||
for ch, fnt in chars:
|
||||
advance = fnt.measure(ch)
|
||||
if width + advance > limit + 0.5:
|
||||
break
|
||||
drawn.append((ch, fnt))
|
||||
width += advance
|
||||
|
||||
bx, by = self._xy(x, y)
|
||||
cursor = bx + self._left_space(attributes, limit, width)
|
||||
top = by + (h * z - main_font.metrics("linespace")) / 2
|
||||
colour = rgb565_to_hex(text_colour)
|
||||
main_text = "".join(ch for ch, fnt in drawn if fnt is main_font)
|
||||
tail_text = "".join(ch for ch, fnt in drawn if fnt is half_font)
|
||||
if main_text:
|
||||
self.c.create_text(cursor, top, text=main_text, anchor="nw", fill=colour,
|
||||
font=main_font, tags=tags)
|
||||
cursor += main_font.measure(main_text)
|
||||
if tail_text:
|
||||
baseline = top + main_font.metrics("ascent") - half_font.metrics("ascent")
|
||||
self.c.create_text(cursor, baseline, text=tail_text, anchor="nw", fill=colour,
|
||||
font=half_font, tags=tags)
|
||||
|
||||
def image_box(self, name, x, y, w, h, tags):
|
||||
path = find_image_file(name)
|
||||
if not _HAS_PIL:
|
||||
self._placeholder(x, y, w, h, name or "image", tags)
|
||||
return
|
||||
if path is None:
|
||||
self._checker(x, y, w, h, tags)
|
||||
return
|
||||
try:
|
||||
img = Image.open(path).convert("RGBA")
|
||||
except Exception:
|
||||
self._checker(x, y, w, h, tags)
|
||||
return
|
||||
iw, ih = img.size
|
||||
crop_w, crop_h = min(iw, w), min(ih, h)
|
||||
img = img.crop((0, 0, crop_w, crop_h))
|
||||
left = (w - iw) // 2 if w > iw else 0
|
||||
top = (h - ih) // 2 if h > ih else 0
|
||||
size = (max(1, round(crop_w * self.z)), max(1, round(crop_h * self.z)))
|
||||
if size != img.size:
|
||||
img = img.resize(size, Image.LANCZOS)
|
||||
photo = ImageTk.PhotoImage(img)
|
||||
self.images.append(photo)
|
||||
self.c.create_image(*self._xy(x + left, y + top), anchor="nw", image=photo, tags=tags)
|
||||
|
||||
def _checker(self, x, y, w, h, tags):
|
||||
cw, ch = max(1, round(w * self.z)), max(1, round(h * self.z))
|
||||
cell = max(1, round(CHECKER_CELL * self.z))
|
||||
img = Image.new("RGB", (cw, ch))
|
||||
pixels = img.load()
|
||||
for py in range(ch):
|
||||
row = (py // cell) & 1
|
||||
for px in range(cw):
|
||||
pixels[px, py] = CHECKER_COLOURS[((px // cell) & 1) ^ row]
|
||||
photo = ImageTk.PhotoImage(img)
|
||||
self.images.append(photo)
|
||||
self.c.create_image(*self._xy(x, y), anchor="nw", image=photo, tags=tags)
|
||||
|
||||
def _placeholder(self, x, y, w, h, label, tags):
|
||||
x1, y1 = self._xy(x, y)
|
||||
x2, y2 = self._xy(x + w, y + h)
|
||||
self.c.create_rectangle(x1, y1, x2, y2, fill=PLACEHOLDER_FILL,
|
||||
outline=PLACEHOLDER_OUTLINE, tags=tags)
|
||||
self.c.create_text((x1 + x2) / 2, (y1 + y2) / 2, text=label, fill="#000000",
|
||||
font=(FALLBACK_FAMILY, max(7, min(10, int(min(x2 - x1, y2 - y1) // 8)))),
|
||||
tags=tags)
|
||||
|
||||
def page_image(self, name, width, height):
|
||||
"""OxPage.imageFileName, drawn over the page colour from the top-left corner."""
|
||||
if not name or find_image_file(name) is None:
|
||||
return
|
||||
self.image_box(name, 0, 0, width, height, ("page_image",))
|
||||
|
||||
# ── widgets ───────────────────────────────────────────────────
|
||||
|
||||
def draw(self, widget, tags, short_label):
|
||||
"""Paints `widget`; returns its box (x, y, w, h) in device pixels."""
|
||||
m = _Model(widget)
|
||||
x, y = m.int("x"), m.int("y")
|
||||
w, h = max(1, m.int("width", 80)), max(1, m.int("height", 80))
|
||||
kind = widget.get("type", "").rsplit("::", 1)[-1]
|
||||
|
||||
if _bool_state(m.raw("visibility")) == "false":
|
||||
x1, y1 = self._xy(x, y)
|
||||
x2, y2 = self._xy(x + w, y + h)
|
||||
self.c.create_rectangle(x1, y1, x2, y2, outline=HIDDEN_OUTLINE, dash=(3, 3),
|
||||
tags=tags)
|
||||
return x, y, w, h
|
||||
|
||||
painter = getattr(self, f"_draw_{kind}", None)
|
||||
if painter is None:
|
||||
self._placeholder(x, y, w, h, f"{widget.get('name', '?')}\n({short_label})", tags)
|
||||
else:
|
||||
painter(m, x, y, w, h, tags)
|
||||
return x, y, w, h
|
||||
|
||||
def _draw_PushButtonLabel(self, m, x, y, w, h, tags):
|
||||
self.text_box(m, m.text("text"), x, y, w, h, m.int("textColor"),
|
||||
m.int("backgroundColor", 0xFF00), tags)
|
||||
|
||||
def _draw_NumericLabel(self, m, x, y, w, h, tags):
|
||||
fmt = m.text("text")
|
||||
value = _as_float(m.raw("in"))
|
||||
tab = m.int("valueTabPosition")
|
||||
split = fmt.find("%")
|
||||
if tab == 0 or split <= 0:
|
||||
self.text_box(m, _format_value(fmt, value), x, y, w, h, m.int("textColor"),
|
||||
m.int("backgroundColor", 0xFF00), tags)
|
||||
return
|
||||
attributes = m.int("attributes", 0x08)
|
||||
self.text_box(m, fmt[:split].replace("%%", "%"), x, y, tab, h, m.int("textColor"),
|
||||
m.int("backgroundColor", 0xFF00), tags, shape=0)
|
||||
self.text_box(m, _format_value(": " + fmt[split:], value), x + tab, y, w - tab, h,
|
||||
m.int("textColor"), m.int("backgroundColor", 0xFF00), tags,
|
||||
attributes=attributes & 0xCF, shape=0)
|
||||
|
||||
_draw_NumericLabelSet = _draw_NumericLabel
|
||||
|
||||
def _draw_NumericSelectLabel(self, m, x, y, w, h, tags):
|
||||
text = m.text("text")
|
||||
if not text:
|
||||
return
|
||||
self.text_box(m, _lookup_text(text, m.int("in")), x, y, w, h, m.int("textColor"),
|
||||
m.int("backgroundColor", 0xFF00), tags)
|
||||
|
||||
def _draw_BooleanLabel(self, m, x, y, w, h, tags):
|
||||
state = _bool_state(m.raw("in"))
|
||||
if state == "true":
|
||||
text, fg, bg = m.text("trueText"), m.int("trueTextColor"), m.int("trueBackgroundColor")
|
||||
else:
|
||||
text = m.text("falseText") if state == "false" else "Null"
|
||||
fg, bg = m.int("falseTextColor"), m.int("falseBackgroundColor")
|
||||
attributes = m.int("attributes", 0x08)
|
||||
if not text:
|
||||
attributes &= 0xF7
|
||||
self.text_box(m, text, x, y, w, h, fg, bg, tags, attributes=attributes)
|
||||
|
||||
def _draw_ScheduleLabelSet(self, m, x, y, w, h, tags):
|
||||
state = _bool_state(m.raw("in"))
|
||||
if state == "true":
|
||||
img, fg, bg, index = (m.text("trueImageFileName"), m.int("trueTextColor"),
|
||||
m.int("trueBackgroundColor"), 1)
|
||||
else:
|
||||
img = m.text("falseImageFileName") if state == "false" else ""
|
||||
fg, bg = m.int("falseTextColor"), m.int("falseBackgroundColor")
|
||||
index = 0 if state == "false" else 2
|
||||
if img:
|
||||
self.image_box(img, x, y, w, h, tags)
|
||||
elif m.text("text"):
|
||||
self.text_box(m, _lookup_text(m.text("text"), index), x, y, w, h, fg, bg, tags)
|
||||
|
||||
def _draw_DateLabelSet(self, m, x, y, w, h, tags):
|
||||
text = _date_text(m.int("dateFormatOptions"), m.int("dateSeparatorCharacter"))
|
||||
self.text_box(m, text, x, y, w, h, m.int("textColor"),
|
||||
m.int("backgroundColor", 0xFF00), tags)
|
||||
|
||||
def _draw_TimeLabelSet(self, m, x, y, w, h, tags):
|
||||
am_pm = _bool_state(m.raw("amPmFormat")) == "true"
|
||||
text = _time_text(m.text("text"), m.int("in"), am_pm)
|
||||
self.text_box(m, text, x, y, w, h, m.int("textColor"),
|
||||
m.int("backgroundColor", 0xFF00), tags,
|
||||
attributes=m.int("attributes", 0x08) & 0xF8)
|
||||
|
||||
def _draw_Geometry(self, m, x, y, w, h, tags):
|
||||
if _bool_state(m.raw("transparent")) != "true":
|
||||
self._shape(x, y, w, h, rgb565_to_hex(m.int("backgroundColor", 0xFF00)),
|
||||
m.int("shape"), tags)
|
||||
|
||||
def _draw_CalibrationButton(self, m, x, y, w, h, tags):
|
||||
self._shape(x, y, w, h, rgb565_to_hex(m.int("backgroundColor", 0xFF00)),
|
||||
m.int("shape"), tags)
|
||||
|
||||
def _draw_PushButtonImage(self, m, x, y, w, h, tags):
|
||||
self.image_box(m.text("imageFileName"), x, y, w, h, tags)
|
||||
|
||||
def _draw_BooleanImage(self, m, x, y, w, h, tags):
|
||||
state = _bool_state(m.raw("in"))
|
||||
if state == "null":
|
||||
return
|
||||
name = m.text("trueImageFileName" if state == "true" else "falseImageFileName")
|
||||
self.image_box(name, x, y, w, h, tags)
|
||||
|
||||
def _draw_NumericSelectImage(self, m, x, y, w, h, tags):
|
||||
self.image_box(f"{m.text('imageFileNamePrefix')}{m.int('in')}", x, y, w, h, tags)
|
||||
+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()
|
||||
|
||||
+38
-10
@@ -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.
|
||||
@@ -508,7 +508,9 @@ class PropertiesPanel:
|
||||
# the view would open already claiming unsaved edits.
|
||||
self._loading = False
|
||||
|
||||
comp_type = comp.get("type", "sys::Component")
|
||||
# A .sax file's <app> carries no type attribute: it is always a sys::App.
|
||||
default_type = "sys::App" if comp.tag == "app" else "sys::Component"
|
||||
comp_type = comp.get("type") or default_type
|
||||
self.slots = schema.get_full_slots_for_type(comp_type)
|
||||
|
||||
self.frame = tk.Frame(parent)
|
||||
@@ -624,7 +626,8 @@ class PropertiesPanel:
|
||||
name_font = tkfont.Font(family="Segoe UI", size=9, weight="bold")
|
||||
type_font = tkfont.Font(family="Segoe UI", size=9)
|
||||
|
||||
self._tinted(tk.Label(header, text=self.comp.get("name", "?"), font=name_font,
|
||||
default_name = "App" if self.comp.tag == "app" else "?"
|
||||
self._tinted(tk.Label(header, text=self.comp.get("name", default_name), font=name_font,
|
||||
bg=HEADER_BG), HEADER_BG).pack(side=tk.LEFT)
|
||||
self._tinted(tk.Label(header, text=f"({comp_type})", font=type_font,
|
||||
fg="#555555", bg=HEADER_BG),
|
||||
@@ -1164,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()
|
||||
|
||||
@@ -1180,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)
|
||||
+763
-86
File diff suppressed because it is too large
Load Diff
@@ -224,9 +224,32 @@ def find_manifest_path(kit_name, checksum):
|
||||
return None
|
||||
|
||||
|
||||
def resolve_truncated_checksum(kit_name, prefix):
|
||||
"""The checksum of the one local manifest whose leading bytes match ``prefix``.
|
||||
|
||||
Used when a device's schema reply cut a kit's checksum short. Returns the
|
||||
checksum as a signed 32-bit int (as read off the wire), or None when no
|
||||
manifest, or more than one, matches.
|
||||
"""
|
||||
manifest_dir = os.path.join(app_state.SEDONA_HOME, "manifests", kit_name)
|
||||
if not os.path.isdir(manifest_dir):
|
||||
return None
|
||||
want = prefix.hex()
|
||||
matches = set()
|
||||
for f in os.listdir(manifest_dir):
|
||||
if f.startswith(f"{kit_name}-") and f.endswith(".xml"):
|
||||
hex_part = f[len(kit_name) + 1:-4].zfill(8).lower()
|
||||
if hex_part.startswith(want):
|
||||
matches.add(hex_part)
|
||||
if len(matches) != 1:
|
||||
return None
|
||||
value = int(matches.pop(), 16)
|
||||
return value - (1 << 32) if value & 0x80000000 else value
|
||||
|
||||
|
||||
def find_manifest(kit_name, checksum):
|
||||
"""Returns the parsed ET root of a kit manifest, or None."""
|
||||
checksum_hex = f"{checksum:08x}" if isinstance(checksum, int) else checksum
|
||||
checksum_hex = f"{checksum & 0xFFFFFFFF:08x}" if isinstance(checksum, int) else checksum
|
||||
path = find_manifest_path(kit_name, checksum_hex)
|
||||
if path is None:
|
||||
return None
|
||||
@@ -330,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))
|
||||
|
||||
@@ -95,6 +95,13 @@ class SoxMsg:
|
||||
self._pos += 1 # null terminator
|
||||
return s
|
||||
|
||||
def try_str(self):
|
||||
"""Like str(), but returns None instead of overrunning a truncated buffer."""
|
||||
end = self._buf.find(0, self._pos, self._size)
|
||||
if end < 0:
|
||||
return None
|
||||
return self.str()
|
||||
|
||||
def read_bytes(self, n):
|
||||
data = bytes(self._buf[self._pos:self._pos + n])
|
||||
self._pos += n
|
||||
@@ -127,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):
|
||||
@@ -363,6 +372,10 @@ class SoxClient:
|
||||
# each slot dict: {name, id, type_name, is_config, is_runtime, is_action, is_operator, is_as_str}
|
||||
self.slot_registry = {}
|
||||
|
||||
# fn(kit_name, prefix_bytes) -> signed i4 checksum or None; recovers a
|
||||
# checksum the device's reply truncated. Set externally by device_session.
|
||||
self.checksum_resolver = None
|
||||
|
||||
# type name map — set externally by device_session
|
||||
# maps (kit_id, type_id) -> "kit::TypeName" for proper type resolution
|
||||
self.type_name_map = {}
|
||||
@@ -370,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):
|
||||
@@ -413,10 +431,28 @@ class SoxClient:
|
||||
res.check_response('V')
|
||||
num = res.u1()
|
||||
parts = []
|
||||
# The device builds this reply in a fixed 512-byte packet and silently
|
||||
# drops whatever does not fit, so a device with many kits can cut the
|
||||
# list short — sometimes mid-checksum of the last kit.
|
||||
for _ in range(num):
|
||||
name = res.str()
|
||||
checksum = res.i4()
|
||||
name = res.try_str()
|
||||
if name is None:
|
||||
break
|
||||
if res.remaining >= 4:
|
||||
checksum = res.i4()
|
||||
else:
|
||||
prefix = res.read_bytes(res.remaining)
|
||||
checksum = (self.checksum_resolver(name, prefix)
|
||||
if self.checksum_resolver else None)
|
||||
if checksum is None:
|
||||
break
|
||||
parts.append(KitPart(name, checksum))
|
||||
if len(parts) < num:
|
||||
raise SoxException(
|
||||
f"The device lists {num} kits but its reply was cut off after "
|
||||
f"{len(parts)} — the list does not fit in the device's 512-byte "
|
||||
f"Sox packet. Remove unused kits from the device's scode, or "
|
||||
f"shorten kit names.")
|
||||
self.schema_parts = parts
|
||||
return parts
|
||||
|
||||
@@ -427,17 +463,26 @@ class SoxClient:
|
||||
req = _prepare_request(ord('y'))
|
||||
res = self.request(req)
|
||||
res.check_response('Y')
|
||||
platform_id = res.str()
|
||||
scode_flags = res.u1()
|
||||
# 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:
|
||||
ver = res.str()
|
||||
kit_versions.append((p.name, p.checksum, ver))
|
||||
ver = res.try_str()
|
||||
kit_versions.append((p.name, p.checksum, ver or ""))
|
||||
vi = VersionInfo(platform_id, scode_flags, kit_versions)
|
||||
prop_num = res.u1()
|
||||
prop_num = res.u1() if res.remaining > 0 else 0
|
||||
for _ in range(prop_num):
|
||||
k = res.str()
|
||||
v = res.str()
|
||||
k = res.try_str()
|
||||
v = res.try_str()
|
||||
if k is None or v is None:
|
||||
break
|
||||
vi.props[k] = v
|
||||
self.version_info = vi
|
||||
return vi
|
||||
@@ -624,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 ───────────────────────────────────────────
|
||||
@@ -869,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):
|
||||
@@ -893,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:
|
||||
@@ -915,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
|
||||
@@ -953,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)
|
||||
@@ -980,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()
|
||||
|
||||
@@ -0,0 +1,690 @@
|
||||
"""The User Manager: a table of sys::User components under a sys::UserService.
|
||||
|
||||
Like the other panels it takes the editor pane over from the canvas rather than
|
||||
floating above it. `main` is what hides the canvas and puts this in its place;
|
||||
this module knows only the frame it was handed.
|
||||
|
||||
Each sys::User component carries three properties:
|
||||
cred — SHA-1 of "username:password" (20 bytes, base64 in the SAX)
|
||||
perm — 32-bit int: four groups × 7 permission flags per group
|
||||
prov — 8-bit byte: app/kits/svm provisioning flags
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import tkinter as tk
|
||||
from tkinter import font as tkfont, messagebox, ttk
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import app_state
|
||||
import live_hooks
|
||||
import sax_file
|
||||
import undo
|
||||
from sax_dom import set_prop_value
|
||||
|
||||
HEADER_BG = "#f2f2f2"
|
||||
|
||||
PERM_FLAGS = ["or", "ow", "oi", "ar", "aw", "ai", "ua"]
|
||||
PERM_LABELS = "rwIRWIu"
|
||||
|
||||
PROV_FLAGS = [("app", 0x01), ("kits", 0x02), ("svm", 0x04)]
|
||||
|
||||
|
||||
def _hash_credentials(username, password):
|
||||
"""SHA-1 of 'username:password', the Sedona credential algorithm."""
|
||||
return base64.b64encode(
|
||||
hashlib.sha1(f"{username}:{password}".encode("utf-8")).digest()
|
||||
).decode("ascii")
|
||||
|
||||
|
||||
def _decode_perm(perm_val):
|
||||
"""32-bit int -> list of four 7-element bool lists."""
|
||||
try:
|
||||
val = int(perm_val)
|
||||
except (TypeError, ValueError):
|
||||
val = 0
|
||||
groups = []
|
||||
for g in range(4):
|
||||
byte = (val >> (g * 8)) & 0xFF
|
||||
groups.append([(byte >> bit) & 1 == 1 for bit in range(7)])
|
||||
return groups
|
||||
|
||||
|
||||
def _encode_perm(groups):
|
||||
"""List of four 7-element bool lists -> 32-bit int."""
|
||||
val = 0
|
||||
for g, flags in enumerate(groups):
|
||||
byte = 0
|
||||
for bit, on in enumerate(flags):
|
||||
if on:
|
||||
byte |= (1 << bit)
|
||||
val |= (byte << (g * 8))
|
||||
return val
|
||||
|
||||
|
||||
def _format_perm(perm_val):
|
||||
"""Human-readable: '1=rwIRWIu, 2=rwIRWIu, ...'"""
|
||||
groups = _decode_perm(perm_val)
|
||||
parts = []
|
||||
for g, flags in enumerate(groups):
|
||||
chars = "".join(PERM_LABELS[i] if on else "-" for i, on in enumerate(flags))
|
||||
parts.append(f"{g + 1}={chars}")
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
def _decode_prov(prov_val):
|
||||
"""8-bit byte -> dict of flag name -> bool."""
|
||||
try:
|
||||
val = int(prov_val)
|
||||
except (TypeError, ValueError):
|
||||
val = 0
|
||||
return {name: bool(val & mask) for name, mask in PROV_FLAGS}
|
||||
|
||||
|
||||
def _encode_prov(flags_dict):
|
||||
val = 0
|
||||
for name, mask in PROV_FLAGS:
|
||||
if flags_dict.get(name):
|
||||
val |= mask
|
||||
return val
|
||||
|
||||
|
||||
def _format_prov(prov_val):
|
||||
"""Human-readable: 'app,kits,svm' for set flags."""
|
||||
flags = _decode_prov(prov_val)
|
||||
active = [name for name, on in flags.items() if on]
|
||||
return ",".join(active) if active else "(none)"
|
||||
|
||||
|
||||
def _get_prop(comp, name):
|
||||
prop = comp.find(f"prop[@name='{name}']")
|
||||
return prop.get("val") if prop is not None else None
|
||||
|
||||
|
||||
def _safe_int(val):
|
||||
if val is None:
|
||||
return 0
|
||||
try:
|
||||
return int(val)
|
||||
except (TypeError, ValueError):
|
||||
try:
|
||||
return int(float(val))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _get_users(service_comp):
|
||||
"""Returns child comp elements, excluding uncached placeholders."""
|
||||
return [c for c in service_comp
|
||||
if c.tag == "comp" and c.get("name", "") != "..."]
|
||||
|
||||
|
||||
def _resolve_comp(comp):
|
||||
"""Re-locate a comp element in the current DOM by id (live mode rebuilds the tree)."""
|
||||
root = app_state.xml_root_element
|
||||
if root is None:
|
||||
return comp
|
||||
comp_id = comp.get("id")
|
||||
if comp_id is None:
|
||||
return comp
|
||||
for elem in root.iter("comp"):
|
||||
if elem.get("id") == comp_id:
|
||||
return elem
|
||||
return comp
|
||||
|
||||
|
||||
def is_user_service(comp):
|
||||
"""Whether a component is a sys::UserService (or looks like one in live mode)."""
|
||||
if comp is None:
|
||||
return False
|
||||
type_name = comp.get("type", "")
|
||||
if "UserService" in type_name:
|
||||
return True
|
||||
for child in comp:
|
||||
if child.tag == "comp" and "User" in child.get("type", ""):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class _PermissionsDialog:
|
||||
"""Workbench-style permissions grid: Groups x Operator/Admin columns."""
|
||||
|
||||
def __init__(self, parent, perm_val):
|
||||
self.result = None
|
||||
groups = _decode_perm(perm_val)
|
||||
|
||||
self._dlg = tk.Toplevel(parent)
|
||||
self._dlg.title("Permissions")
|
||||
self._dlg.transient(parent)
|
||||
self._dlg.grab_set()
|
||||
self._dlg.resizable(False, False)
|
||||
|
||||
grid = tk.Frame(self._dlg, padx=8, pady=8)
|
||||
grid.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
COL_HEADERS = [
|
||||
("Operator", ["Read", "Write", "Invoke"]),
|
||||
("Admin", ["Read", "Write", "Invoke", "User"]),
|
||||
]
|
||||
|
||||
# header row 0: "Groups" + category spans
|
||||
tk.Label(grid, text="Groups", relief=tk.GROOVE, bg="#e8e8e8",
|
||||
padx=4, pady=2).grid(row=0, column=0, rowspan=2, sticky=tk.NSEW)
|
||||
col = 1
|
||||
for cat_name, sub_cols in COL_HEADERS:
|
||||
span = len(sub_cols)
|
||||
tk.Label(grid, text=cat_name, relief=tk.GROOVE, bg="#d8e8f0",
|
||||
font=("TkDefaultFont", 8, "bold"),
|
||||
padx=4, pady=2).grid(row=0, column=col, columnspan=span,
|
||||
sticky=tk.NSEW)
|
||||
col += span
|
||||
|
||||
# header row 1: sub-column names
|
||||
col = 1
|
||||
for _, sub_cols in COL_HEADERS:
|
||||
for sub in sub_cols:
|
||||
tk.Label(grid, text=sub, relief=tk.GROOVE, bg="#e8e8e8",
|
||||
font=("TkDefaultFont", 7), padx=4, pady=1).grid(
|
||||
row=1, column=col, sticky=tk.NSEW)
|
||||
col += 1
|
||||
|
||||
# data rows
|
||||
self._perm_vars = []
|
||||
for g in range(4):
|
||||
r = g + 2
|
||||
tk.Label(grid, text=f"Group {g + 1}", relief=tk.GROOVE,
|
||||
bg="#f4f4f4", padx=4, pady=1).grid(row=r, column=0,
|
||||
sticky=tk.NSEW)
|
||||
gvars = []
|
||||
for f_idx in range(7):
|
||||
var = tk.BooleanVar(value=groups[g][f_idx])
|
||||
var.trace_add("write", lambda *_a, g=g, f=f_idx: self._on_perm_toggle(g, f))
|
||||
ttk.Checkbutton(grid, variable=var).grid(
|
||||
row=r, column=f_idx + 1, padx=6, pady=1)
|
||||
gvars.append(var)
|
||||
self._perm_vars.append(gvars)
|
||||
|
||||
# Clear All / Set All
|
||||
action_frame = tk.Frame(grid)
|
||||
action_frame.grid(row=6, column=0, columnspan=8, sticky=tk.E, pady=(6, 0))
|
||||
ttk.Button(action_frame, text="Clear All", width=8,
|
||||
command=self._clear_all).pack(side=tk.LEFT, padx=2)
|
||||
ttk.Button(action_frame, text="Set All", width=8,
|
||||
command=self._set_all).pack(side=tk.LEFT, padx=2)
|
||||
|
||||
# OK / Cancel
|
||||
btn_frame = tk.Frame(self._dlg, pady=6)
|
||||
btn_frame.pack()
|
||||
ttk.Button(btn_frame, text="OK", width=10, command=self._on_ok).pack(
|
||||
side=tk.LEFT, padx=4)
|
||||
ttk.Button(btn_frame, text="Cancel", width=10, command=self._on_cancel).pack(
|
||||
side=tk.LEFT, padx=4)
|
||||
|
||||
self._dlg.protocol("WM_DELETE_WINDOW", self._on_cancel)
|
||||
self._dlg.wait_window()
|
||||
|
||||
def _on_perm_toggle(self, group, flag_idx):
|
||||
"""Auto-cascade: write implies read, admin implies operator."""
|
||||
gvars = self._perm_vars[group]
|
||||
if not gvars[flag_idx].get():
|
||||
return
|
||||
# Flags: 0=or, 1=ow, 2=oi, 3=ar, 4=aw, 5=ai, 6=ua
|
||||
# Write (1) → Read (0); Admin Write (4) → Admin Read (3)
|
||||
if flag_idx in (1, 4):
|
||||
gvars[flag_idx - 1].set(True)
|
||||
# Admin flag (3,4,5) → same Operator flag (0,1,2)
|
||||
if 3 <= flag_idx <= 5:
|
||||
gvars[flag_idx - 3].set(True)
|
||||
|
||||
def _clear_all(self):
|
||||
for gvars in self._perm_vars:
|
||||
for v in gvars:
|
||||
v.set(False)
|
||||
|
||||
def _set_all(self):
|
||||
for gvars in self._perm_vars:
|
||||
for v in gvars:
|
||||
v.set(True)
|
||||
|
||||
def _on_ok(self):
|
||||
groups = [[v.get() for v in gvars] for gvars in self._perm_vars]
|
||||
self.result = _encode_perm(groups)
|
||||
self._dlg.destroy()
|
||||
|
||||
def _on_cancel(self):
|
||||
self._dlg.destroy()
|
||||
|
||||
|
||||
class _ProvisioningDialog:
|
||||
"""Provisioning flags popup: app, kits, svm checkboxes."""
|
||||
|
||||
def __init__(self, parent, prov_val):
|
||||
self.result = None
|
||||
flags = _decode_prov(prov_val)
|
||||
|
||||
self._dlg = tk.Toplevel(parent)
|
||||
self._dlg.title("Provisioning Permissions")
|
||||
self._dlg.transient(parent)
|
||||
self._dlg.grab_set()
|
||||
self._dlg.resizable(False, False)
|
||||
|
||||
main = ttk.Frame(self._dlg, padding=10)
|
||||
main.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
self._prov_vars = {}
|
||||
for name, _ in PROV_FLAGS:
|
||||
var = tk.BooleanVar(value=flags.get(name, False))
|
||||
ttk.Checkbutton(main, text=name, variable=var).pack(
|
||||
anchor=tk.W, pady=2)
|
||||
self._prov_vars[name] = var
|
||||
|
||||
btn_frame = ttk.Frame(main)
|
||||
btn_frame.pack(pady=(10, 0))
|
||||
ttk.Button(btn_frame, text="OK", width=10, command=self._on_ok).pack(
|
||||
side=tk.LEFT, padx=4)
|
||||
ttk.Button(btn_frame, text="Cancel", width=10, command=self._on_cancel).pack(
|
||||
side=tk.LEFT, padx=4)
|
||||
|
||||
self._dlg.protocol("WM_DELETE_WINDOW", self._on_cancel)
|
||||
self._dlg.wait_window()
|
||||
|
||||
def _on_ok(self):
|
||||
self.result = _encode_prov(
|
||||
{name: var.get() for name, var in self._prov_vars.items()})
|
||||
self._dlg.destroy()
|
||||
|
||||
def _on_cancel(self):
|
||||
self._dlg.destroy()
|
||||
|
||||
|
||||
class _UserDialog:
|
||||
"""Compact Edit/Add User dialog with >> buttons opening Permissions/Provisioning popups."""
|
||||
|
||||
def __init__(self, parent, service_comp, user_elem, on_done):
|
||||
self._service_comp = service_comp
|
||||
self._user_elem = user_elem
|
||||
self._on_done = on_done
|
||||
self._is_edit = user_elem is not None
|
||||
|
||||
self._perm_val = _safe_int(_get_prop(user_elem, "perm")) if self._is_edit else 0
|
||||
self._prov_val = _safe_int(_get_prop(user_elem, "prov")) if self._is_edit else 0
|
||||
|
||||
self._dlg = tk.Toplevel(parent)
|
||||
self._dlg.title("Edit User" if self._is_edit else "Add User")
|
||||
self._dlg.transient(parent)
|
||||
self._dlg.grab_set()
|
||||
self._dlg.resizable(False, False)
|
||||
|
||||
main = ttk.Frame(self._dlg, padding=10)
|
||||
main.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
row = 0
|
||||
ttk.Label(main, text="Username").grid(row=row, column=0, sticky=tk.W, pady=2)
|
||||
self._name_var = tk.StringVar(
|
||||
value=user_elem.get("name", "") if self._is_edit else "")
|
||||
name_entry = ttk.Entry(main, textvariable=self._name_var, width=28)
|
||||
name_entry.grid(row=row, column=1, columnspan=2, sticky=tk.EW, pady=2)
|
||||
if self._is_edit:
|
||||
name_entry.config(state="disabled")
|
||||
|
||||
row += 1
|
||||
ttk.Label(main, text="Password").grid(row=row, column=0, sticky=tk.W, pady=2)
|
||||
self._pass_var = tk.StringVar()
|
||||
ttk.Entry(main, textvariable=self._pass_var, show="*", width=28).grid(
|
||||
row=row, column=1, columnspan=2, sticky=tk.EW, pady=2)
|
||||
|
||||
row += 1
|
||||
ttk.Label(main, text="Confirm").grid(row=row, column=0, sticky=tk.W, pady=2)
|
||||
self._confirm_var = tk.StringVar()
|
||||
ttk.Entry(main, textvariable=self._confirm_var, show="*", width=28).grid(
|
||||
row=row, column=1, columnspan=2, sticky=tk.EW, pady=2)
|
||||
|
||||
# permissions summary + >> button
|
||||
row += 1
|
||||
ttk.Separator(main, orient=tk.HORIZONTAL).grid(
|
||||
row=row, column=0, columnspan=3, sticky=tk.EW, pady=6)
|
||||
|
||||
row += 1
|
||||
ttk.Label(main, text="Permissions").grid(row=row, column=0, sticky=tk.W, pady=2)
|
||||
self._perm_label = tk.StringVar(value=_format_perm(self._perm_val))
|
||||
ttk.Label(main, textvariable=self._perm_label,
|
||||
font=("TkDefaultFont", 8)).grid(
|
||||
row=row, column=1, sticky=tk.W, pady=2)
|
||||
ttk.Button(main, text="»", width=3,
|
||||
command=self._open_permissions).grid(
|
||||
row=row, column=2, sticky=tk.E, pady=2)
|
||||
|
||||
# provisioning summary + >> button
|
||||
row += 1
|
||||
ttk.Label(main, text="Provisioning Permissions").grid(
|
||||
row=row, column=0, sticky=tk.W, pady=2)
|
||||
self._prov_label = tk.StringVar(value=_format_prov(self._prov_val))
|
||||
ttk.Label(main, textvariable=self._prov_label,
|
||||
font=("TkDefaultFont", 8)).grid(
|
||||
row=row, column=1, sticky=tk.W, pady=2)
|
||||
ttk.Button(main, text="»", width=3,
|
||||
command=self._open_provisioning).grid(
|
||||
row=row, column=2, sticky=tk.E, pady=2)
|
||||
|
||||
# OK / Cancel
|
||||
row += 1
|
||||
btn_frame = ttk.Frame(main)
|
||||
btn_frame.grid(row=row, column=0, columnspan=3, pady=(10, 0))
|
||||
ttk.Button(btn_frame, text="OK", width=10, command=self._on_ok).pack(
|
||||
side=tk.LEFT, padx=4)
|
||||
ttk.Button(btn_frame, text="Cancel", width=10, command=self._on_cancel).pack(
|
||||
side=tk.LEFT, padx=4)
|
||||
|
||||
self._dlg.protocol("WM_DELETE_WINDOW", self._on_cancel)
|
||||
name_entry.focus_set()
|
||||
self._dlg.wait_window()
|
||||
|
||||
def _open_permissions(self):
|
||||
dlg = _PermissionsDialog(self._dlg, self._perm_val)
|
||||
if dlg.result is not None:
|
||||
self._perm_val = dlg.result
|
||||
self._perm_label.set(_format_perm(self._perm_val))
|
||||
|
||||
def _open_provisioning(self):
|
||||
dlg = _ProvisioningDialog(self._dlg, self._prov_val)
|
||||
if dlg.result is not None:
|
||||
self._prov_val = dlg.result
|
||||
self._prov_label.set(_format_prov(self._prov_val))
|
||||
|
||||
def _on_ok(self):
|
||||
username = self._name_var.get().strip()
|
||||
password = self._pass_var.get()
|
||||
confirm = self._confirm_var.get()
|
||||
|
||||
if not username:
|
||||
messagebox.showwarning("Validation", "Username is required.", parent=self._dlg)
|
||||
return
|
||||
if not self._is_edit and not password:
|
||||
messagebox.showwarning("Validation", "Password is required for new users.",
|
||||
parent=self._dlg)
|
||||
return
|
||||
if password and password != confirm:
|
||||
messagebox.showwarning("Validation", "Passwords do not match.", parent=self._dlg)
|
||||
return
|
||||
|
||||
if not self._is_edit:
|
||||
for c in _get_users(self._service_comp):
|
||||
if c.get("name", "") == username:
|
||||
messagebox.showwarning("Validation",
|
||||
f"User '{username}' already exists.",
|
||||
parent=self._dlg)
|
||||
return
|
||||
|
||||
if self._is_edit:
|
||||
self._apply_edit(username, password)
|
||||
else:
|
||||
self._apply_add(username, password)
|
||||
|
||||
self._dlg.destroy()
|
||||
if self._on_done:
|
||||
self._on_done()
|
||||
|
||||
def _apply_add(self, username, password):
|
||||
elem = ET.Element("comp", name=username, type="sys::User")
|
||||
cred = _hash_credentials(username, password)
|
||||
values = {"cred": cred, "perm": str(self._perm_val), "prov": str(self._prov_val)}
|
||||
|
||||
if app_state.live_mode:
|
||||
# One Sox add carrying every config value, so the user never exists on the
|
||||
# device without its password and rights.
|
||||
try:
|
||||
config = sax_file.build_config_values("sys::User", values)
|
||||
except (ValueError, TypeError) as e:
|
||||
live_hooks.warn_failure("Add User", f"User '{username}' was not added to the device",
|
||||
f"A value does not fit its slot: {e}")
|
||||
return
|
||||
new_id = live_hooks.live_add_component(
|
||||
self._service_comp, "sys", "User", username, config)
|
||||
if new_id is None:
|
||||
live_hooks.warn_failure("Add User", f"User '{username}' was not added to the device")
|
||||
return
|
||||
elem.set("id", str(new_id))
|
||||
|
||||
for name, val in values.items():
|
||||
ET.SubElement(elem, "prop", name=name, val=val)
|
||||
|
||||
if app_state.live_mode:
|
||||
index = len(list(self._service_comp))
|
||||
self._service_comp.append(elem)
|
||||
undo.push(sax_file.LiveComponentCommand(
|
||||
self._service_comp, index, elem, present=True, label="add user"))
|
||||
app_state.mark_dirty()
|
||||
app_state.set_status(f"User '{username}' added to device.")
|
||||
return
|
||||
|
||||
index = len(list(self._service_comp))
|
||||
cmd = undo.AddComponentCommand(self._service_comp, index, elem, label="add user")
|
||||
cmd.redo()
|
||||
undo.push(cmd)
|
||||
app_state.mark_dirty()
|
||||
|
||||
def _apply_edit(self, username, password):
|
||||
user = self._user_elem
|
||||
changes = []
|
||||
|
||||
new_perm = str(self._perm_val)
|
||||
old_perm = _get_prop(user, "perm")
|
||||
if new_perm != old_perm:
|
||||
changes.append((user, "perm", old_perm, new_perm))
|
||||
|
||||
new_prov = str(self._prov_val)
|
||||
old_prov = _get_prop(user, "prov")
|
||||
if new_prov != old_prov:
|
||||
changes.append((user, "prov", old_prov, new_prov))
|
||||
|
||||
if password:
|
||||
new_cred = _hash_credentials(username, password)
|
||||
old_cred = _get_prop(user, "cred")
|
||||
if new_cred != old_cred:
|
||||
changes.append((user, "cred", old_cred, new_cred))
|
||||
|
||||
if not changes:
|
||||
return
|
||||
|
||||
if app_state.live_mode:
|
||||
failures = []
|
||||
applied = []
|
||||
for change in changes:
|
||||
elem, prop, _old, new = change
|
||||
if live_hooks.live_set_prop_value(elem, prop, new):
|
||||
set_prop_value(elem, prop, new)
|
||||
applied.append(change)
|
||||
app_state.mark_dirty()
|
||||
else:
|
||||
failures.append(f"{prop}: {live_hooks.last_error}")
|
||||
if applied:
|
||||
undo.push(undo.LiveSlotValueCommand(applied, label="edit user"))
|
||||
if failures:
|
||||
live_hooks.warn_failure(
|
||||
"Edit User",
|
||||
f"{len(failures)} of {len(changes)} change(s) to user '{username}' did "
|
||||
"not reach the device and were not applied",
|
||||
"\n".join(f" • {failure}" for failure in failures),
|
||||
)
|
||||
else:
|
||||
app_state.set_status(f"User '{username}' updated on device.")
|
||||
return
|
||||
|
||||
cmd = undo.SlotValueCommand(changes, label="edit user")
|
||||
cmd.redo()
|
||||
undo.push(cmd)
|
||||
app_state.mark_dirty()
|
||||
|
||||
def _on_cancel(self):
|
||||
self._dlg.destroy()
|
||||
|
||||
|
||||
class UserServicePanel:
|
||||
"""Table of users under a sys::UserService, with Add/Edit/Delete."""
|
||||
|
||||
def __init__(self, parent, comp, on_change=None, on_close=None):
|
||||
self.comp = comp
|
||||
self.on_change = on_change
|
||||
self.on_close = on_close
|
||||
self.dirty = False
|
||||
self.frame = tk.Frame(parent)
|
||||
|
||||
self._build_header()
|
||||
self._build_table()
|
||||
self._build_footer()
|
||||
self._refresh_table()
|
||||
|
||||
def _build_header(self):
|
||||
header = tk.Frame(self.frame, padx=8, pady=6, bg=HEADER_BG)
|
||||
header.pack(fill=tk.X)
|
||||
|
||||
name_font = tkfont.Font(family="Segoe UI", size=9, weight="bold")
|
||||
plain_font = tkfont.Font(family="Segoe UI", size=9)
|
||||
|
||||
tk.Label(header, text="User Manager", font=name_font,
|
||||
bg=HEADER_BG).pack(side=tk.LEFT)
|
||||
tk.Label(header, text=f"— {self.comp.get('name', '?')}",
|
||||
font=plain_font, fg="#555555", bg=HEADER_BG).pack(side=tk.LEFT,
|
||||
padx=(8, 0))
|
||||
self.count_var = tk.StringVar(value="")
|
||||
tk.Label(header, textvariable=self.count_var, font=plain_font, fg="#555555",
|
||||
bg=HEADER_BG).pack(side=tk.RIGHT)
|
||||
|
||||
def _build_table(self):
|
||||
area = tk.Frame(self.frame)
|
||||
area.pack(fill=tk.BOTH, expand=True, padx=8, pady=(4, 0))
|
||||
|
||||
columns = ("username", "permissions", "provisioning")
|
||||
self.table = ttk.Treeview(area, columns=columns, show="headings",
|
||||
selectmode="browse")
|
||||
self.table.heading("username", text="Username")
|
||||
self.table.heading("permissions", text="Group Permissions")
|
||||
self.table.heading("provisioning", text="Provisioning")
|
||||
|
||||
self.table.column("username", width=120, minwidth=80)
|
||||
self.table.column("permissions", width=320, minwidth=200)
|
||||
self.table.column("provisioning", width=120, minwidth=80)
|
||||
|
||||
vscroll = ttk.Scrollbar(area, orient=tk.VERTICAL, command=self.table.yview)
|
||||
self.table.configure(yscrollcommand=vscroll.set)
|
||||
|
||||
self.table.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
vscroll.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
|
||||
self.table.bind("<Double-1>", lambda _e: self._on_edit())
|
||||
self._user_elements = {}
|
||||
|
||||
def _build_footer(self):
|
||||
footer = tk.Frame(self.frame, padx=8, pady=6)
|
||||
footer.pack(side=tk.BOTTOM, fill=tk.X)
|
||||
|
||||
ttk.Button(footer, text="Close", width=9,
|
||||
command=self.close).pack(side=tk.RIGHT)
|
||||
ttk.Button(footer, text="Delete User", width=12,
|
||||
command=self._on_delete).pack(side=tk.RIGHT, padx=(0, 6))
|
||||
ttk.Button(footer, text="Edit User", width=12,
|
||||
command=self._on_edit).pack(side=tk.RIGHT, padx=(0, 6))
|
||||
ttk.Button(footer, text="Add User", width=12,
|
||||
command=self._on_add).pack(side=tk.RIGHT, padx=(0, 6))
|
||||
|
||||
def _refresh_table(self):
|
||||
for item in self.table.get_children():
|
||||
self.table.delete(item)
|
||||
self._user_elements.clear()
|
||||
|
||||
self.comp = _resolve_comp(self.comp)
|
||||
users = _get_users(self.comp)
|
||||
for user_elem in users:
|
||||
name = user_elem.get("name", "?")
|
||||
perm = _get_prop(user_elem, "perm")
|
||||
prov = _get_prop(user_elem, "prov")
|
||||
|
||||
iid = self.table.insert("", tk.END, values=(
|
||||
name,
|
||||
_format_perm(perm),
|
||||
_format_prov(prov),
|
||||
))
|
||||
self._user_elements[iid] = user_elem
|
||||
|
||||
self.count_var.set(f"{len(users)} user{'' if len(users) == 1 else 's'}")
|
||||
|
||||
def refresh(self):
|
||||
self._refresh_table()
|
||||
|
||||
def _selected_user(self):
|
||||
sel = self.table.selection()
|
||||
if not sel:
|
||||
return None, None
|
||||
return sel[0], self._user_elements.get(sel[0])
|
||||
|
||||
def _after_change(self):
|
||||
self._refresh_table()
|
||||
if self.on_change:
|
||||
self.on_change()
|
||||
|
||||
def _on_add(self):
|
||||
_UserDialog(self.frame.winfo_toplevel(), self.comp, None, self._after_change)
|
||||
|
||||
def _on_edit(self):
|
||||
iid, user_elem = self._selected_user()
|
||||
if user_elem is None:
|
||||
messagebox.showinfo("Edit User", "Select a user first.",
|
||||
parent=self.frame.winfo_toplevel())
|
||||
return
|
||||
_UserDialog(self.frame.winfo_toplevel(), self.comp, user_elem, self._after_change)
|
||||
|
||||
def _on_delete(self):
|
||||
iid, user_elem = self._selected_user()
|
||||
if user_elem is None:
|
||||
messagebox.showinfo("Delete User", "Select a user first.",
|
||||
parent=self.frame.winfo_toplevel())
|
||||
return
|
||||
|
||||
name = user_elem.get("name", "?")
|
||||
if not messagebox.askyesno("Delete User", f"Delete user '{name}'?",
|
||||
parent=self.frame.winfo_toplevel()):
|
||||
return
|
||||
|
||||
if app_state.live_mode:
|
||||
# Read before it goes: once deleted, the device has nothing left to read back.
|
||||
snapshot = live_hooks.read_subtree(user_elem)
|
||||
unreadable = live_hooks.last_error if snapshot is None else ""
|
||||
if not live_hooks.live_delete_component(user_elem):
|
||||
live_hooks.warn_failure("Delete User", f"User '{name}' was not deleted from the device")
|
||||
return
|
||||
index = list(self.comp).index(user_elem)
|
||||
self.comp.remove(user_elem)
|
||||
app_state.mark_dirty()
|
||||
if snapshot is not None:
|
||||
undo.push(sax_file.LiveComponentCommand(
|
||||
self.comp, index, user_elem, present=False, snapshot=snapshot,
|
||||
label="delete user"))
|
||||
app_state.set_status(f"User '{name}' deleted from device.")
|
||||
else:
|
||||
app_state.set_status(
|
||||
f"User '{name}' was deleted from the device, but this delete cannot be "
|
||||
f"undone: {unreadable}", warn=True)
|
||||
self._after_change()
|
||||
return
|
||||
|
||||
index = list(self.comp).index(user_elem)
|
||||
cmd = undo.AddComponentCommand(self.comp, index, user_elem, label="delete user")
|
||||
cmd.undo()
|
||||
undo.push(cmd)
|
||||
app_state.mark_dirty()
|
||||
self._after_change()
|
||||
|
||||
def close(self):
|
||||
if self.on_close is not None:
|
||||
self.on_close()
|
||||
return True
|
||||
|
||||
def destroy(self):
|
||||
if self.frame.winfo_exists():
|
||||
self.frame.destroy()
|
||||
|
||||
|
||||
def build_user_service_panel(parent, comp, on_change=None, on_close=None):
|
||||
if comp is None or parent is None:
|
||||
return None
|
||||
return UserServicePanel(parent, comp, on_change=on_change, on_close=on_close)
|
||||
+199
@@ -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