Converts Crestron control-system project files into browsable HTML, working directly from the compiled files a processor and touch panel actually ship with. No SIMPL Windows or VTPro-e installation required.
Current state: working converter, SIMPL-to-C# generator, and HTML5 interface generator, verified against real project sets. Roadmap in NEXT.md.
py -3.12 run.py --session "Room 406" --csharp-only
Reads the .smw source program and emits a typed SIMPL# Pro project:
| File | Contents | Completeness |
|---|---|---|
Signals.cs |
Every named signal as a typed property (bool/ushort/string) |
Complete |
Devices.cs |
Every device with transport, RS232 framing, addressing | Complete |
Logic/*.cs |
One class per symbol, inputs and outputs in SIMPL order | See below |
ControlSystem.cs |
SIMPL# Pro entry point wiring it together | Complete |
CONVERSION.md |
Per-symbol account of what translated and what did not | — |
On a real production program:
| Count | |
|---|---|
| Signals, typed and event-driven | 2,036 |
| Devices with transports | 230 |
| Translated primitives | 1,323 (343 top-level + 980 inside macros) |
| User macros expanded inline | 18 (1,665 inner symbols) |
| SIMPL+ statements translated | 1,948 of 1,986 (98.1%) |
This matters more than any individual symbol. SIMPL does not run top to bottom. Signals propagate: changing a signal re-evaluates every symbol with that signal on an input, which may change its outputs, which propagates onward until the graph settles.
A generated program that ran each symbol once in declaration order would
compile, look plausible, and behave differently. So the emitted project ships
Runtime/Signal.cs with change-event signals, and every symbol subscribes to
its own inputs:
public NOT(Signals signals)
{
_signals = signals;
// SIMPL propagation: re-evaluate whenever an input changes.
_signals.ConfigDSPEnable2.Changed += _ => Execute();
}
public void Execute() { Output1 = !Input1; }Two fidelity details: a signal raises Changed only on an actual change,
matching SIMPL, where rewriting the same value does not re-trigger downstream
logic. And propagation depth is bounded, so a feedback loop in the source
program is reported through Propagation.Runaway instead of blowing the stack
on a live processor.
ControlSystem.Settle() evaluates every symbol once at startup to reach a
consistent initial state. After that, logic runs by propagation, not polling.
Symbols fall into three tiers and the generator never blurs them:
- Primitives translate to working C#.
Buffer,NOT,AND,OR,Stepper, the one shots, and the analog family have documented, unambiguous behaviour. ANOTsymbol becomesOutput1 = !Input1;bound to the real signal names. - Structural symbols carry no logic.
SUBSYSTEM,Logicfolders, and device slots become device registration, not code. - External modules become documented stubs.
.umcuser modules and.uspSIMPL+ modules keep their behaviour outside the.smw. Each becomes a stub with its exact wiring preserved, and every one is listed inCONVERSION.md.
Module bodies are translated to C#, not just described. Across all 32 recovered
modules that is 1,948 of 1,986 statements, 98.1%, with 14 modules at 100%.
Every generated class states its own coverage, and anything outside the
supported subset is emitted verbatim as // [untranslated] so nothing is
silently dropped or guessed at.
Four traps, each of which silently produces wrong C# if missed:
| SIMPL+ | Meaning | C# |
|---|---|---|
If (x = 0) |
Equality, not assignment | if (x == 0) |
x <> y |
Inequality | x != y |
wait (100) { } |
Scheduled block, not a sleep | scheduler callback, 1000 ms |
INTEGER |
Unsigned 16-bit | ushort |
wait is the dangerous one: the body runs later while execution continues, so
translating it as a blocking sleep would change behaviour on a live processor.
Module sources are not loose on disk, but SIMPL Windows bundles them into the
*_archive.zip it writes beside each program. A real archive holds 32 .usp,
32 .ush, and 18 .umc files, so most stubs are not opaque after all.
When an archive is present the generator reads it and emits the module's declared interface alongside the wired signals:
// SIMPL+ module interface, as declared in TCP_Client.usp:
// in 1 Digital Connect
// in 4 Analog Port_Number
// in 5 Serial IP_Address$ (array)
// out 1 Digital ConnectF
// Positions above are the module's own numbering and are
// NOT assumed to line up with the wired inputs below.plus the module's parameters, its SIMPL+ event handlers (PUSH Connect,
SOCKETCONNECT tcpClient), its functions, and its help text verbatim.
A deliberate limitation. Members stay positional (Input1, Output2)
rather than being renamed after SIMPL+ ports. The .smw wiring index and the
SIMPL+ port index do not correspond by any rule that could be verified against
these files, and a confidently wrong port name is worse than a positional one.
The interface block above gives a human everything needed to match them
deliberately.
Third-party compiled drivers such as Biamp Tesira and Vaddio RoboShot are the genuine exception: no source exists in any file here. For those the right move is calling the vendor's own SIMPL# library rather than porting anything.
This does not produce a drop-in replacement for a running program, and does not claim to. It produces a faithful typed skeleton with every signal, device, and connection intact and the mechanical logic already written.
The generated HTML5 interface in the semplice-blue theme, with every page
recovered from the panel file:
Clicking a control emits the real join it would send over CH5. Here Vol+
sends digital join 1, exactly as it was wired in the original panel, with the
mock bus echoing feedback:
The analysis report, generated from the same source files:
py -3.12 run.py --session "Hamilton 406" --only "Hamilton"
That single command creates a session folder on the Desktop and does all of this:
Hamilton 406/
original crestron files/ verbatim copies, sha256-verified, read-only
html5/<project>/
preview.html offline preview with a mock join bus
ch5/index.html deployable CH5 project
ch5/project.json the join contract, for diffing
CHECKLIST.md what needs a human before deployment
reports/ full analysis report
session.json provenance chain: source path, sha256, timestamps
Backups are written and verified before conversion starts. If the HTML5
interface misbehaves on hardware, the originals in original crestron files
are what gets reloaded onto the processor and panel.
Reads the compiled artifacts, cross-references them, and emits one self-contained HTML report per project set:
| Input | Format (verified by magic bytes) | What is extracted |
|---|---|---|
.smw |
ASCII INI-like records | SIMPL source program: devices, manufacturers, serial port settings, and the full logic graph |
.sig |
[RLSIG0001] or [LOGOSSIG001.000] |
Full signal table, named and symbol-scoped |
.spz |
ZIP | Fusion/RoomView join map from the embedded .rvi |
.vtz |
ZIP | Panel pages, controls, and every join from Environment.xml |
.vtp |
OLE2 compound file | Stream inventory (fallback when no .vtz) |
The report has six tabs: Overview, Join crosswalk, Panel objects, Program signals, Fusion map, and Orphans. Everything is inline, so a report is a single file you can email or attach to a ticket.
This is the point of the tool. The panel side and the program side each know join numbers, but no Crestron tool shows them side by side. Joining them exposes:
- panel-only joins: a control drives a join the program never declares
- program-only joins: logic with no user interface
- shared joins: one join driven by several objects, sometimes deliberate, sometimes a bug
py -3.12 run.py --list # show discovered project sets
py -3.12 run.py # convert everything under source_root
py -3.12 run.py --only "Hamilton" # convert one project set
py -3.12 run.py --session "Room 406" # session folder with verified backups
py -3.12 run.py --profile epson # manufacturer string variant
py -3.12 run.py --no-html5 # analysis report only
py -3.12 run.py --list-profiles # available string profiles
py -3.12 run.py --list-sessions # existing sessions
py -3.12 run.py --verify-session <path> # re-check a session's backups
py -3.12 run.py --source "D:\Some\Other\Folder"
Paths live in config.json, never in code.
A Crestron touch panel runs no logic. It exchanges joins with the processor: digital for buttons and feedback, analog for levels, serial for text. Every projector command, serial string, and IR burst lives in the processor program and keeps running untouched.
So converting a panel to HTML5 never reimplements device control. It
reproduces the join contract, and the processor keeps driving displays exactly
as before. That is why Vol+ in the screenshot above only needs to send
digital join 1: the program already knows what to do with it.
The deployable output targets CH5, Crestron's supported HTML5 mechanism,
and needs cr-com-lib.js from the CH5 toolchain alongside it. The
preview.html is self-contained and needs nothing, which is what makes the
conversion testable before any hardware is involved.
The original panel's appearance cannot be recovered by parsing. Its buttons
and backgrounds are bitmap assets compiled into the project's SWF files;
the theme XML carries only text colours. Verified on a real project set: all
eight panels share one CT_Semplice_AAVS theme with byte-identical previews,
and the XML holds just 472 colour entries, all of them text, against 1,495
ImageID references.
So the look of the generated interface is defined in html5/themes.py rather
than extracted:
py -3.12 run.py --list-themes
py -3.12 run.py --session "Room 406" --theme semplice-blue
| Theme | Look |
|---|---|
semplice-blue (default) |
Grey buttons on a deep blue background, the classroom house style |
dark |
Neutral dark interface |
light |
High-contrast light interface for bright rooms |
The active-state colour #0098ca is the one accent that genuinely does appear
in the original theme XML.
The same UI ships to rooms with different display brands. Profiles live in
strings.json as data, not code:
py -3.12 run.py --session "Lab" --profile epson # "Display" becomes "Projector"
- Dependency gate first.
deps.pyverifies Python 3.12+, installs missing packages, and aborts below 50 GB free disk. It never silently skips. - Streaming with checkpointing. Records go through the pipeline one at a time into SQLite and checkpoint on an interval. A crash at record 10,000 of 18,565 resumes at 10,001. Memory stays flat.
- Fail loud. If more than 1% of signal records fail the grammar, the run aborts rather than shipping a partial report. This caught a real bug during development: 16.41% of records were being misread, and the threshold forced a correct fix instead of a quiet fallback.
- Formats verified, never assumed. Every container is identified by magic bytes. Where a file states a value twice (the signal id appears as both trailing hex and a binary trailer), the parser cross-checks them and aborts on disagreement.
Documented from real files, since Crestron publishes none of this.
[RLSIG0001] record text is cp1252
[LOGOSSIG001.000] record text is UTF-16LE
per record:
uint16 LE total_len self-inclusive, counts these 2 bytes
payload (total_len - 2) encoded text + 6-byte trailer
trailer[0:4] uint32 LE signal id
trailer[4] flag_a
trailer[5] flag_b
Two record forms, both valid:
- named: the programmer's own globals, e.g.
Display-01_Power_On,TP_Room_Name$. A//prefix means declared but never driven. - symbol: scoped to a SIMPL symbol with a path and a repeated hex id,
e.g.
::Trig$:S-1.1:S-3:S-8.10.2.00004FD1
Plain ASCII, INI-like. Bracketed records of CRLF-separated Key=Value lines,
with ObjTp naming the record type. Vocabulary confirmed against 40 real
programs, all of which parse:
ObjTp |
Meaning | Key fields |
|---|---|---|
Sg |
Signal | H handle, Nm name, SgTp type |
Sm |
Symbol | SmC class, PrH parent, I1..In inputs, O1..On outputs |
Dv |
Device | Nm, Ad address, ProdLine |
Db |
Device database | Mnf manufacturer, Mdl model, Tpe type |
Cm |
Comm port | Ptl protocol, BRt baud, Pty/DBt/SBt framing |
Et / Cs |
Ethernet / Cresnet | IPA, IPM |
CED |
SmartGraphics control | SGControlType, SGControlName |
Signal typing was established from the data rather than guessed. The compiler
emits reserved placeholder signals that name their own type, which pins the
mapping exactly: SgTp absent is //__digital_reserved__, SgTp=2 is
//__analog_reserved__, and SgTp=4 is //__serial_reserved__.
This is what backs the claim that the conversion retains all coding and physical sends. On a current production program the parser recovers 8,662 logic connections, 230 devices, the RS232 framing for each serial device, and the manufacturers driven (Crestron, DirecTV, Lutron).
ZIP. swf/Environment.xml is UTF-16LE XML holding the whole project. An
object is any element with a ControlName child; joins are any descendant tag
matching *Join*, which covers the 31 distinct join tag names observed
without hardcoding control types.
Ten project sets, three consecutive clean runs, exit 0, 0.00% unrecognized records across 130,000+ signal records.


