R-ION meeting room panel: Sedona app, page images and simulator package

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 08:15:47 +03:00
co-authored by Claude Opus 5.5
commit 41995634c4
23 changed files with 1171 additions and 0 deletions
+427
View File
@@ -0,0 +1,427 @@
#!/usr/bin/env python3
"""R-ION meeting room panel: builds the page images and the Sedona app (.sax).
python build_meeting_room.py <assets dir> <fontBank dir> [out dir]
<assets dir> the unzipped meeting_panel_assets.zip (uses bin/*_a8.bin icon masks)
<fontBank dir> <sedona home>/ontrol/fontBank - static captions are drawn with the
device's own bitmap fonts so they match the runtime labels exactly
out dir default ./out -> out/imageBank/MR_*.png + out/app-Rion-MeetingRoom.sax
Static captions are baked into the images; only values that change at runtime
(room temperature, remaining time, setpoint, humidity) are Ox labels.
"""
import base64, os, sys
from xml.sax.saxutils import quoteattr
from PIL import Image, ImageDraw
ASSETS, FONTS = sys.argv[1], sys.argv[2]
OUT = sys.argv[3] if len(sys.argv) > 3 else os.path.join(os.path.dirname(os.path.abspath(__file__)), "out")
IMG_OUT = os.path.join(OUT, "imageBank")
os.makedirs(IMG_OUT, exist_ok=True)
# ---- palette (ui_assets.h) -----------------------------------------------------------
HEX = {"BG": "#101418", "SURFACE": "#202830", "RAISED": "#303C48", "DISABLED": "#586470",
"MUTED": "#A0ACB8", "TEXT": "#FFFFFF", "INUSE": "#C83C10", "INUSE_EDGE": "#E85020",
"AVAILABLE": "#30C060", "PRIMARY": "#F8B830", "COOLING": "#3890F0"}
RGB = {k: tuple(int(v[i:i + 2], 16) for i in (1, 3, 5)) for k, v in HEX.items()}
C565 = {k: ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3) for k, (r, g, b) in RGB.items()}
# ---- device bitmap fonts (same layout the Sedona editor reads) -----------------------
class BitmapFont:
def __init__(self, path):
d = self.data = open(path, "rb").read()
self.height = d[34]
self.glyphs = {}
for i in range(d[32] | d[33] << 8):
e = d[64 + i * 10:74 + i * 10]
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 layout(self, text):
out, x = [], 0
for ch in text:
g = self.glyphs.get(ord(ch))
if not g:
continue
_, w, _, xo, _, adv = g
xoff = 1 if xo < 0 else xo + 1
out.append((ord(ch), x + xoff))
x += max(adv, w, xoff + w)
return out, x
def width(self, text):
return self.layout(text)[1]
def mask(self, code):
off, w, h, _, yo, _ = self.glyphs[code]
m = Image.new("L", (max(1, w), max(1, h)))
px = bytearray(w * h)
for i in range(w * h):
b = self.data[off + (i >> 1)]
px[i] = ((b >> 4) if i % 2 == 0 else (b & 0x0F)) * 17
if w and h:
m.putdata(px)
return m, yo
_fonts = {}
def font(size, bold=False):
key = (size, bold)
if key not in _fonts:
_fonts[key] = BitmapFont(os.path.join(FONTS, f"{'bold ' if bold else ''}{size}pt Tahoma.bin"))
return _fonts[key]
def text(im, s, x, y, h, f, colour, align="left", w=0):
"""Draw s in a box (x, y, w, h), vertically centred like Draw_Text_Box."""
glyphs, tw = f.layout(s)
if align == "center":
x += (w - tw) // 2
elif align == "right":
x += w - tw
top = y + (h - f.height) // 2
solid = Image.new("RGB", (1, 1), RGB[colour])
for code, gx in glyphs:
m, yo = f.mask(code)
im.paste(solid.resize(m.size), (x + gx, top + yo), m)
return tw
def icon(im, name, x, y, colour):
size = {"play": 20, "minus": 32, "plus": 32}.get(name, 18 if name.startswith("tab_") else 26)
raw = open(os.path.join(ASSETS, "bin", f"ico_{name}_a8.bin"), "rb").read()
m = Image.frombytes("L", (size, size), raw)
im.paste(Image.new("RGB", (size, size), RGB[colour]), (x, y), m)
return size
def rect(d, x0, y0, x1, y1, fill, border=None, bw=0):
if border:
d.rectangle([x0, y0, x1, y1], fill=RGB[border])
d.rectangle([x0 + bw, y0 + bw, x1 - bw, y1 - bw], fill=RGB[fill])
else:
d.rectangle([x0, y0, x1, y1], fill=RGB[fill])
def save(im, name):
assert len(name) <= 12, name
im.save(os.path.join(IMG_OUT, name + ".png"))
# ---- layout ---------------------------------------------------------------------------
ROOM_NAME = "Meeting Room"
TABS = [(0, 106, "tab_calendar", "Meeting"), (107, 212, "tab_bulb", "Lights"), (213, 319, "tab_thermo", "Climate")]
EXT_X = [8, 112, 216]
TILES = [(8, 32), (164, 32), (8, 116), (164, 116)] # 148 x 76
SCENES = [("presentation", "Presentation"), ("meeting", "Meeting"), ("video", "Video call"), ("power", "All off")]
def page(active_tab):
im = Image.new("RGB", (320, 240), RGB["BG"])
d = ImageDraw.Draw(im)
rect(d, 0, 23, 319, 23, "RAISED")
rect(d, 0, 192, 319, 192, "RAISED")
text(im, ROOM_NAME, 8, 0, 23, font(16, True), "TEXT")
for i, (x0, x1, ico, label) in enumerate(TABS):
on = i == active_tab
if on:
rect(d, x0, 193, x1, 239, "SURFACE")
rect(d, x0, 193, x1, 195, "PRIMARY")
col = "TEXT" if on else "MUTED"
f = font(16, on)
gw = 18 + 6 + f.width(label)
gx = x0 + (x1 - x0 + 1 - gw) // 2
icon(im, ico, gx, 196 + (44 - 18) // 2, col)
text(im, label, gx + 24, 196, 44, f, col)
return im, d
# Meeting - room free
im, d = page(0)
rect(d, 8, 32, 311, 127, "AVAILABLE")
text(im, "Available", 20, 38, 44, font(32, True), "BG")
text(im, "Tap below to start a meeting", 20, 84, 24, font(16), "BG")
rect(d, 8, 136, 311, 191, "PRIMARY")
f = font(18, True)
gw = 20 + 8 + f.width("Start meeting")
gx = 8 + (304 - gw) // 2
icon(im, "play", gx, 136 + 18, "BG")
text(im, "Start meeting", gx + 28, 136, 56, f, "BG")
save(im, "MR_free")
# Meeting - in progress (extend buttons are BooleanImages drawn on top)
im, d = page(0)
rect(d, 8, 32, 311, 127, "INUSE")
text(im, "Meeting in progress", 20, 36, 32, font(24, True), "TEXT")
x = 216
rect(d, x, 136, x + 95, 191, "SURFACE", "INUSE_EDGE", 2)
f = font(18, True)
gw = 26 + 4 + f.width("End")
gx = x + (96 - gw) // 2
icon(im, "power", gx, 136 + 15, "INUSE_EDGE")
text(im, "End", gx + 30, 136, 56, f, "TEXT")
save(im, "MR_inuse")
for mins in (15, 30):
for on in (True, False):
b = Image.new("RGB", (96, 56)); bd = ImageDraw.Draw(b)
rect(bd, 0, 0, 95, 55, "RAISED" if on else "SURFACE")
text(b, f"+{mins} min", 0, 0, 56, font(18, True), "TEXT" if on else "DISABLED", "center", 96)
save(b, f"MR_e{mins}{'on' if on else 'off'}")
# Lights
im, d = page(1)
save(im, "MR_light")
for n, (ico, label) in enumerate(SCENES, 1):
for on in (True, False):
t = Image.new("RGB", (148, 76)); td = ImageDraw.Draw(t)
if on:
rect(td, 0, 0, 147, 75, "PRIMARY")
else:
rect(td, 0, 0, 147, 75, "SURFACE", "RAISED", 2)
col = "BG" if on else "TEXT"
icon(t, ico, 12, 25, col)
f = font(16, True)
if f.width(label) > 148 - 46 - 6:
f = font(16)
text(t, label, 46, 0, 76, f, col)
save(t, f"MR_s{n}{'on' if on else 'off'}")
# Climate
im, d = page(2)
rect(d, 8, 32, 71, 135, "RAISED")
rect(d, 80, 32, 231, 135, "SURFACE")
rect(d, 240, 32, 311, 135, "RAISED")
rect(d, 8, 144, 311, 191, "SURFACE")
icon(im, "minus", 24, 68, "TEXT")
icon(im, "plus", 260, 68, "TEXT")
text(im, "Setpoint", 80, 36, 18, font(12), "MUTED", "center", 152)
text(im, "Room", 20, 146, 18, font(12), "MUTED")
text(im, "Humidity", 168, 146, 18, font(12), "MUTED")
save(im, "MR_clim")
# ---- Sedona app ---------------------------------------------------------------------
def buf(s):
"""Non-@asStr Buf props are stored base64 with the terminating NUL."""
return base64.b64encode(s.encode("utf-8") + b"\0").decode()
class Comp:
_next = 1
def __init__(self, name, type_, props=None, ws=None, parent=None):
assert len(name) <= 7, name
self.name, self.type, self.children = name, type_, []
self.props = dict(props or {})
self.id = Comp._next
Comp._next += 1
if ws: # wiresheet position: meta = y<<24 | x<<16 | 1
self.props = {"meta": (ws[1] << 24) | (ws[0] << 16) | 1, **self.props}
self.parent = parent
if parent:
parent.children.append(self)
@property
def path(self):
return (self.parent.path if self.parent else "") + "/" + self.name
links = []
def link(a, slot_a, b, slot_b):
links.append((f"{a.path}.{slot_a}", f"{b.path}.{slot_b}"))
root = []
def top(name, type_="sys::Folder", props=None, ws=None):
c = Comp(name, type_, props, ws)
root.append(c)
return c
# services
service = top("service", ws=(2, 2))
plat = Comp("RionPlt", "ontrolRion::RionPlatformService", {"register01": "22.0"}, (2, 2), service)
oxbase = Comp("OxBase", "ontrolOx::OxBase", {"lcdSleepBrightness": 10, "lcdSleepTimeout": 60}, (2, 2), plat)
users = Comp("users", "sys::UserService", {}, (2, 8), service)
Comp("admin", "sys::User", {"cred": "wHy/iCHUdXWkccFgala3nl9uamg=", "perm": 2147483647, "prov": 255}, (2, 2), users)
Comp("guest", "sys::User", {"cred": "jVShQjDWIkrr+o3lYLcAPPvNVio=", "perm": 63}, (2, 8), users)
Comp("sox", "sox::SoxService", {}, (2, 14), service)
# pages
ui = top("ui", ws=(16, 2))
W = lambda x, y, w, h: {"x": x, "y": y, "width": w, "height": h}
TOUCH = {"backgroundColor": C565["BG"], "transparent": "true"}
def header_temp(p):
return Comp("RoomT", "ontrolOx::NumericLabel", {
**W(232, 1, 80, 21), "fontSize": 16, "attributes": 0x28, # right aligned, background
"text": buf("%.1f°C"), "textColor": C565["MUTED"], "backgroundColor": C565["BG"]}, parent=p)
def tab_touch(name, p, tab):
x0, x1 = TABS[tab][0], TABS[tab][1]
return Comp(name, "ontrolOx::Geometry", {**W(x0, 193, x1 - x0 + 1, 47), **TOUCH}, parent=p)
def page_comp(name, image, timeout, ws):
return Comp(name, "ontrolOx::OxPage", {"backgroundColor": C565["BG"], "imageFileName": image,
"touchTimeout": timeout}, ws, ui)
pFree = page_comp("Free", "MR_free", 0, (2, 2))
tFreeT = header_temp(pFree)
start = Comp("Start", "ontrolOx::Geometry", {**W(8, 136, 304, 56), **TOUCH}, parent=pFree)
fTabL, fTabC = tab_touch("TabLt", pFree, 1), tab_touch("TabCl", pFree, 2)
pUse = page_comp("InUse", "MR_inuse", 0, (2, 10))
tUseT = header_temp(pUse)
left = Comp("Left", "ontrolOx::NumericLabel", {
**W(20, 72, 280, 48), "fontSize": 32, "fontAttributes": 1, "attributes": 0x08,
"text": buf("%.0f min left"), "textColor": C565["TEXT"], "backgroundColor": C565["INUSE"]}, parent=pUse)
ext = {}
for n, mins in enumerate((15, 30)):
ext[mins] = Comp(f"Ext{mins}", "ontrolOx::BooleanImage", {
**W(EXT_X[n], 136, 96, 56), "falseImageFileName": f"MR_e{mins}off",
"trueImageFileName": f"MR_e{mins}on"}, parent=pUse)
end = Comp("End", "ontrolOx::Geometry", {**W(216, 136, 96, 56), **TOUCH}, parent=pUse)
uTabL, uTabC = tab_touch("TabLt", pUse, 1), tab_touch("TabCl", pUse, 2)
pLight = page_comp("Lights", "MR_light", 60, (2, 18))
tLightT = header_temp(pLight)
tiles = [Comp(f"Scene{n}", "ontrolOx::BooleanImage", {
**W(x, y, 148, 76), "falseImageFileName": f"MR_s{n}off", "trueImageFileName": f"MR_s{n}on"},
parent=pLight) for n, (x, y) in enumerate(TILES, 1)]
lTabM, lTabC = tab_touch("TabMt", pLight, 0), tab_touch("TabCl", pLight, 2)
pClim = page_comp("Climate", "MR_clim", 60, (2, 26))
tClimT = header_temp(pClim)
setpt = Comp("SetPt", "ontrolOx::NumericLabel", {
**W(80, 56, 152, 64), "fontSize": 48, "fontAttributes": 1, "attributes": 0x18,
"text": buf("%.1f°"), "textColor": C565["TEXT"], "backgroundColor": C565["SURFACE"]}, parent=pClim)
roomV = Comp("RoomV", "ontrolOx::NumericLabel", {
**W(20, 164, 136, 24), "fontSize": 18, "fontAttributes": 1, "attributes": 0x08,
"text": buf("%.1f°C"), "textColor": C565["TEXT"], "backgroundColor": C565["SURFACE"]}, parent=pClim)
humV = Comp("HumV", "ontrolOx::NumericLabel", {
**W(168, 164, 136, 24), "fontSize": 18, "fontAttributes": 1, "attributes": 0x08,
"text": buf("%.0f %%"), "textColor": C565["TEXT"], "backgroundColor": C565["SURFACE"]}, parent=pClim)
down = Comp("Down", "ontrolOx::Geometry", {**W(8, 32, 64, 104), **TOUCH}, parent=pClim)
up = Comp("Up", "ontrolOx::Geometry", {**W(240, 32, 72, 104), **TOUCH}, parent=pClim)
cTabM, cTabL = tab_touch("TabMt", pClim, 0), tab_touch("TabLt", pClim, 1)
# meeting logic
mtg = top("meeting", ws=(30, 2))
timer = Comp("Timer", "ontrolControl::CountDown", {}, (2, 2), mtg) # seconds left; out = meeting on (BootA clears it)
defDur = Comp("DefDur", "control::ConstInt", {"out": 3600}, (2, 10), mtg) # Start = 60 min
maxDur = Comp("MaxDur", "control::ConstFloat", {"out": "14400.0"}, (2, 16), mtg) # max 4 h
k = {v: Comp(f"K{v}", "control::ConstFloat", {"out": f"{v}.0"}, (2, 22 + 6 * i), mtg)
for i, v in enumerate((59, 60, 900, 1800))}
bootOn = Comp("BootOn", "control::ConstBool", {"out": "true"}, (2, 46), mtg)
TRIG = {"componentId": timer.id, "slotId": 1, "trigger": "true"} # CountDown.count
startA = Comp("StartA", "ontrolTrigger::TrigActionInt", TRIG, (14, 2), mtg)
endA = Comp("EndA", "ontrolTrigger::TrigActionInt", TRIG, (14, 10), mtg)
bootA = Comp("BootA", "ontrolTrigger::TrigActionInt", TRIG, (14, 18), mtg)
link(start, "touch", startA, "in"); link(defDur, "out", startA, "value")
link(end, "touch", endA, "in")
link(bootOn, "out", bootA, "in")
cntF = Comp("CntF", "control::I2F", {}, (14, 26), mtg)
link(timer, "count", cntF, "in")
# minutes left, rounded up: (count + 59) / 60
add59 = Comp("Add59", "control::Add2", {}, (26, 26), mtg)
div60 = Comp("Div60", "control::Div2", {}, (38, 26), mtg)
minI = Comp("MinI", "control::F2I", {}, (50, 26), mtg)
minF = Comp("MinF", "control::I2F", {}, (62, 26), mtg)
link(cntF, "out", add59, "in1"); link(k[59], "out", add59, "in2")
link(add59, "out", div60, "in1"); link(k[60], "out", div60, "in2")
link(div60, "out", minI, "in"); link(minI, "out", minF, "in")
link(minF, "out", left, "in")
# extend: count += 900 / 1800, only while the result stays under MaxDur
for row, mins in enumerate((15, 30)):
y = 34 + row * 16
sec = mins * 60
add = Comp(f"Add{mins}", "control::Add2", {}, (26, y), mtg)
toI = Comp(f"Ext{mins}I", "control::F2I", {}, (38, y), mtg)
act = Comp(f"Ext{mins}A", "ontrolTrigger::TrigActionInt", TRIG, (62, y), mtg)
lim = Comp(f"Lim{mins}", "control::Sub2", {}, (26, y + 8), mtg)
cmp_ = Comp(f"Cmp{mins}", "control::Cmpr", {}, (38, y + 8), mtg)
gate = Comp(f"And{mins}", "control::And2", {}, (50, y + 8), mtg)
link(cntF, "out", add, "in1"); link(k[sec], "out", add, "in2")
link(add, "out", toI, "in"); link(toI, "out", act, "value")
link(maxDur, "out", lim, "in1"); link(k[sec], "out", lim, "in2")
link(cntF, "out", cmp_, "x"); link(lim, "out", cmp_, "y")
link(cmp_, "xly", ext[mins], "in")
link(ext[mins], "touch", gate, "in1"); link(cmp_, "xly", gate, "in2")
link(gate, "out", act, "in")
# home page follows the meeting state; idle pages fall back to it
pageSw = Comp("PageSw", "control::ISW", {}, (26, 2), mtg)
link(pFree, "pageId", pageSw, "in1"); link(pUse, "pageId", pageSw, "in2")
link(timer, "out", pageSw, "s1"); link(pageSw, "out", oxbase, "homePageId")
# lighting scenes: Scene.out = 1 Presentation, 2 Meeting, 3 Video call, 4 All off
lights = top("lights", ws=(30, 10))
scene = Comp("Scene", "ontrolTrigger::TrigNumericSelect12",
{"value1": "1.0", "value2": "2.0", "value3": "3.0", "value4": "4.0"}, (14, 2), lights)
startSc = Comp("StartSc", "control::Or2", {}, (2, 2), lights) # Start meeting -> Meeting scene
link(tiles[1], "touch", startSc, "in1"); link(start, "touch", startSc, "in2")
for n, tile in enumerate(tiles, 1):
kc = Comp(f"S{n}", "control::ConstFloat", {"out": f"{n}.0"}, (2, 6 + 6 * n), lights)
cm = Comp(f"Is{n}", "control::Cmpr", {}, (26, 6 + 6 * n), lights)
link(scene, "out", cm, "x"); link(kc, "out", cm, "y"); link(cm, "xey", tile, "in")
if n == 2:
link(startSc, "out", scene, "trigIn2")
else:
link(tile, "touch", scene, f"trigIn{n}")
# climate: setpoint kept in platform register01 across restarts
clim = top("climate", ws=(30, 18))
spReg = Comp("SpReg", "ontrolControl::RegFloat", {"address": 1}, (2, 2), clim)
spUd = Comp("SpUD", "ontrolTrigger::TrigNumericUpDown",
{"delta": "0.5", "min": "16.0", "max": "30.0"}, (14, 2), clim)
link(spReg, "value", spUd, "value"); link(spUd, "out", spReg, "in")
link(up, "touch", spUd, "trigUp"); link(down, "touch", spUd, "trigDown")
link(spUd, "out", setpt, "in")
for lab in (tFreeT, tUseT, tLightT, tClimT, roomV):
link(plat, "roomTemperature", lab, "in")
link(plat, "roomRh", humV, "in")
# tab navigation
link(fTabL, "touch", pLight, "trigThisPage"); link(fTabC, "touch", pClim, "trigThisPage")
link(uTabL, "touch", pLight, "trigThisPage"); link(uTabC, "touch", pClim, "trigThisPage")
link(lTabM, "touch", pLight, "trigHome"); link(lTabC, "touch", pClim, "trigThisPage")
link(cTabM, "touch", pClim, "trigHome"); link(cTabL, "touch", pLight, "trigThisPage")
# ---- write .sax ----------------------------------------------------------------------
KITS = ["sys", "control", "inet", "sox", "ontrolPlatform", "ontrolRion", "ontrolOx",
"ontrolControl", "ontrolTrigger"]
lines = ["<?xml version='1.0'?>", "<sedonaApp>", "<schema>"]
lines += [f' <kit name="{k}" />' for k in KITS]
lines += ["</schema>", "<app>", ' <prop name="deviceName" val="Rion"/>',
' <prop name="appName" val="MeetRoom"/>', ' <prop name="scanPeriod" val="100"/>']
def emit(c, depth):
ind = " " * depth
lines.append(f"{ind}<!-- {c.path} -->")
lines.append(f'{ind}<comp name="{c.name}" id="{c.id}" type="{c.type}">')
for k_, v in c.props.items():
lines.append(f'{ind} <prop name="{k_}" val={quoteattr(str(v))}/>')
for ch in c.children:
emit(ch, depth + 1)
lines.append(f"{ind}</comp>")
for c in root:
emit(c, 1)
lines += ["</app>", "<links>"]
lines += [f' <link from="{a}" to="{b}"/>' for a, b in links]
lines += ["</links>", "</sedonaApp>", ""]
with open(os.path.join(OUT, "app-Rion-MeetingRoom.sax"), "w", encoding="utf-8", newline="\n") as fp:
fp.write("\n".join(lines))
print(f"{len(os.listdir(IMG_OUT))} images, {Comp._next - 1} components, {len(links)} links -> {OUT}")