#!/usr/bin/env python3 """R-ION meeting room panel: builds the page images and the Sedona app (.sax). python build_meeting_room.py [out dir] the unzipped meeting_panel_assets.zip (uses bin/*_a8.bin icon masks) /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 = ["", "", ""] lines += [f' ' for k in KITS] lines += ["", "", ' ', ' ', ' '] def emit(c, depth): ind = " " * depth lines.append(f"{ind}") lines.append(f'{ind}') for k_, v in c.props.items(): lines.append(f'{ind} ') for ch in c.children: emit(ch, depth + 1) lines.append(f"{ind}") for c in root: emit(c, 1) lines += ["", ""] lines += [f' ' for a, b in links] lines += ["", "", ""] 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}")