# Create a reusable Gauge.S module

Start with a **manifest + Lua + OGS view**, packaged as one `.ogp`. A module runs
globally across gauge changes; a view only draws its outputs. This guide targets
Module API 1 on V6/V6.1 (466×466 round display), using the production Lua32/WASM
runtime. It works offline. It does not require firmware changes or Portal keys.

Read [llms.txt](llms.txt) for the compact API and a complete three-file warning
example. [guide.html](guide.html) describes the customer's controls. In the source
repository, these files live in `deploy/wifi/modules/`; the detailed implementation
contract is `docs/designs/modules/CONTRACT.md`, packaging is
`docs/designs/modules/PACKAGING.md`, and general gauge JSON documentation is
`prep/gauges/README.md`. Use the installed/local editor for this API version;
the public hosted editor can be on an earlier release.

## 1. Specify behaviour before drawing

Write a small state table: inputs/units, valid/missing data, start and clear
boundaries, timer reset rules, events, outputs and what each state should draw.
Define `>=` versus `>`, hysteresis, continuous versus accumulated duration, and
whether a warning clears or latches. Test exact boundaries, not only middle values.

Separate three kinds of controls:

* **Measurement settings**: thresholds, duration, source age and gap limits.
* **Appearance settings**: colours, position, font, independent visual switches.
  Switching off an indicator must not silently disable a measurement or its timer.
* **Host choices**: session Start/Stop, Run on boot, screen navigation and popups.
  Saving choices, applying setup and starting are separate operations.

Use existing named input roles; never hardcode an installation's instance name in
Lua or its view. Outputs such as `my_instance.active` become ordinary bindable and
loggable parameters. Use a numeric `valid`/state flag when zero would be ambiguous.
Do not add history/storage to a transient indicator unless the feature needs it.

## 2. Build a complete package

Keep a clean source folder containing only distribution files:

```text
my-module/
    module.json       # entry manifest, FIRST file in the OGP
    main.lua
    view.json
    README.md         # settings, states, install/use, limits
    LICENSE
```

Tests, screenshots and generated `.ogp` exports belong outside this folder. Include
all required assets/fonts/presets and licence notices. Paths are relative, use `/`,
and may not contain traversal, absolute roots or case collisions. The full pack is
content-addressed; auxiliary files also change its hash.

Use `kind:"module"`, `api:1`, a stable `id`, readable `name`, integer `version`,
`logic:{file:"main.lua"}`, `inputs`, `settings`, `views` and `presentations` as
needed. Outputs are **declared by Lua setup**, not by a manifest outputs array.
An input example is `"temperature":{"param":"Oil Temp","unit":"C"}`.
`optional:true` allows a missing binding and returns nil; it does not fabricate a
source. Required missing bindings fail setup. Discover real device names through
`GET /parameters?json`; case-insensitive resolution does not correct wrong units.

Setting definitions use `type`, `default`, `name`, `description`, optional `unit`,
`min`, `max`, and `group`. Types: number/integer/bool/string/choice/param/track.
Choices use a string `choices` array. Optional `choiceLabels` maps those stored
strings to friendly web-form labels (for example `{"0":"Top","180":"Bottom"}`);
Lua, saved configuration and the device menu still use the original values.
Numeric settings can also supply a numeric `choices` array for a web dropdown,
retaining numeric saved/Lua/view-custom values. Use `min`/`max`/`step` for device
controls and assert any discrete-value constraint during setup. A string choice
cannot be used as a numeric OGS variable in geometry expressions.
Use `group:"Appearance"` for visual options.
A colour is a string with `control:"color"` and `maxLength:9`. Defaults must pass
their own constraints even if an instance will override them. Validate relationships
(e.g. clear <= trigger) with descriptive assertions in `on_setup`. Do not silently
clamp contradictory saved settings. Expose useful numeric/bool/choice settings in
`menu:[{"name":"Limit","setting":"limit"}]`; the generated web form exposes all.
List cross-setting constraints in the README too: this API version can report a
generic `Lua setup/parameter registration failed` instead of the assertion text.

## 3. Write for the actual Lua sandbox

This is Lua **5.5.1 with int32 integers and float32 numbers**, not desktop Lua.
Available globals: `assert`, `error`, `ipairs`, `next`, `pairs`, `select`, `tonumber`,
`tostring`, `type`, `math` (except random/randomseed), `cfg`, `ctx`, `out`.
There is **no `string`, `table`, `os`, `io`, `print`, `require`, `pcall`, coroutine,
loader, metatable or GC-control library**. Table literals/indexing, `#`, loops,
concatenation `..` and normal operators work. Do not use `string.format`,
`table.insert`, `os.clock` or a stock-Lua run as your only verification.

Return a lifecycle table. At `on_setup` only:

* `ctx.parameter("value", {unit="C", dec=1, initial=0})` declares a scalar.
* `ctx.expression("derived", {expr="x*2", x=ctx.source("input"), hz=20})`
  declares an independently rated ordinary CDS expression when needed.
* `ctx.on_periodic(50, update)` or `ctx.on_update("input", 50, update)` registers
  callbacks. Declare in deterministic order: explicit calls or a dense `ipairs`
  array, never `pairs` for parameter declarations.

`update(seconds,micros)` reads `ctx.read("input")` -> value-or-nil, observation
seconds/micros, publication seconds/micros-or-nil. Write finite numbers only to
declared `out.value`. A Lua bool must become `condition and 1 or 0` for an output.
An initial source can lack publication metadata; choose/document a freshness
policy. Recent calculated publications do not prove fresh physical CAN data.

Periodic callbacks are useful for elapsed timers, blinking and stale-input expiry:
an update-only subscription cannot expire itself when publications stop. Rates are
upper bounds (0.001–100 Hz), not deadlines; shared adaptive scheduling can delay a
call. Track elapsed time, **never count calls as time**. Keep only bounded private
state and reuse tables; avoid per-tick strings or growing arrays. `on_enable` resets
transient state explicitly. A fresh VM is created after Stop/Start, while committed
public values remain. Gauge/view changes do not restart the VM.

**Output writes are forbidden during source load, `on_setup` and `on_disable`.**
Use `initial` in declarations for setup defaults; publish resets in `on_enable`.
Stop hides presentations through the host and retains the last published values.
An `on_disable` that calls your output-reset function will fault. Omit that callback
when there is no private cleanup/storage work. Expression outputs are always
read-only to Lua.

### Time, continuity and blinking

Use timestamp pairs: `ctx.elapsed(s,us,startS,startUs)` returns exact whole seconds
and remaining micros; `ctx.elapsed_ms(...)` returns floor integer milliseconds.
Never flatten boot time into float32 or an int32 microsecond count. Zero is a valid
timestamp: use nil to mean "not started". Only convert short **durations** to float.

For a continuous dwell, start on the first qualifying observation; reset when the
condition clears, data becomes unacceptable, or an observation gap breaks
continuity. A gap must not count as proven time above threshold. If the requirement
says "longer than T", compare strictly greater; reset/rearm must work repeatedly.
An already-fired warning need not keep an unbounded duration counter. Never restore
an in-progress monotonic timer across reboot. `elapsed_ms` has a signed-int32 range;
for indefinitely latched states use pairs or stop accumulating after the decision.

Hz means **complete on+off cycles per second**. A 4-Hz, 50%-duty indicator has
125-ms on and off halves. Derive phase from elapsed time so a delayed callback skips
missed edges rather than replaying them or drifting. Keep the phase origin bounded
(or use exact split-duration modular arithmetic), avoiding long-uptime float loss.
Choose a callback rate comfortably above twice the maximum supported blink rate.

Emit events on state transitions with `ctx.emit("active")` / `ctx.emit("clear")`.
Do not emit once per frame/flash: that changes arbitration recency, replaces frozen
snapshots and restarts visible-time tracking. Successful callbacks commit outputs/events together;
faults roll them back and close the VM. `ctx.emit` snapshots values at that line.

## 4. Draw a transparent, readable view

The physical screen is **1.7 inches**, even when your browser enlarges it. Prefer
large short lines, a clear hierarchy and generous spacing. Fit inside the circle,
not merely its square bounding box. Two large centred lines are often clearer than
one clipped sentence. Inspect at native pixels and small physical-like size over a
real underlying gauge. Built-in `visitor` is a small pixel font (integer `size`
3–5 is common); `microgramma20/32/48`, `silverline26/32/52` and `bmw16/20/24/28/32`
are already larger fonts, normally size 1. Measure with production screenshots.
Labels have at most 31 UTF-8 bytes; split long text across labels. `size`, not
`fontSize`, is the font multiplier. Value formats hold 15 bytes; durations support
`m:ss.mmm` and `+m:ss.mmm` for a value stored in seconds.

Each manifest view maps up to eight local roles to **local declared output keys**:

```json
"views": {
    "status": {
        "file": "view.json", "transparent": true,
        "params": {"v0": "active", "v1": "value"},
        "customs": {"showValue": "showValue", "ink": "ink"}
    }
}
```

The view declares matching `params` and `customs`, then `vars` references custom
values (`"ink":"@ink"`). Elements use `"color":"$ink"`,
`"defaultParam":"[v1]"` and numeric runtime conditions such as `"visible":"[v0] > 0"`.
`[v0]` is a view role; `{Holder Name}` is a global binding; `$name` is a load-time
variable. Prefer roles for reusable outputs. Expression `v` means the raw bound
value, `x` usually mapped percent, **not engineering units**. Runtime expressions
use OGS/muParser syntax (`&&`, `||`, `!`, `?:`), not Lua `and`/`or`/`not`.

For a **bool custom**, use a direct `"visible":"$showValue"` on a parent group
and numeric runtime conditions on its children. On the current production renderer,
putting a bool custom inside a compound expression (e.g. `[v0] > 0 && $showValue`)
can silently hide the element even when both terms are true. Do not use that form.

```json
{"type":"group", "visible":"$showValue", "elements":[
    {"type":"valueText", "layer":2, "visible":"[v0] > 0",
     "defaultParam":"[v1]", "font":"visitor", "size":4,
     "x":50, "y":50, "align":"center", "format":"%.0f"}
]}
```

Default x/y coordinates are percentages of the authored view; `pixelCoords:true`
on an element selects pixels. Rectangles use centre x/y and pixel w/h; optional
`radius` rounds corners. Groups organize bindings/visibility, not local positions;
their children still use view coordinates. Views are centred on their parent.
A full 466×466 transparent view is suitable for separate top and centre content.

Angles are 0° at twelve o'clock, positive clockwise. An asset-free fixed ring
segment can use `type:"arc", mode:"vector", minValue:0, maxValue:1,
startValue:0, endValue:1, startAngle:-20, endAngle:-8, innerRadius:205,
outerRadius:225`. Use `layer:1` or `2`, `smoothing:0` and an explicit runtime
`visible` condition for independently switched content. Do not rely on baked
layer-0 decoration for animation.

**Unlit means absent:** give each indicator a visibility condition, with no dark
off-colour, background, outline or full-screen mask. At blink-off and when cleared,
the ordinary gauge's original pixels must be visible. A translucent warning panel
can be drawn only while its message is visible. `#AARRGGBB` has alpha first.

There are **two bounded automatic slots**: one central popup (`overlay` or
`screen`) and one independent `indicator` above it. Priority, then newest, picks
one winner inside each slot. Use indicator mode for compact perimeter lights that
must coexist with other modules' central warnings/results; place them clear of
central content. Menus and interaction grace suppress both. Older hosts without
indicator support reject that mode at setup. Use `snapshot:false` for live
changing/blinking values; `snapshot:true` is for frozen results.

Automatic views prepare when the instance starts, then stay hot in PSRAM through
clear, blinking, preemption and menu suppression. Threshold appearances do not
read files or parse JSON. Keep views compact: the renderer shares a 1-MiB quota
for automatic view metadata/arenas, with separate fixed Canvas object/tile limits.
Check the `visuals` object in `/modules` or `sim_modulesStatus()`: `ready` should
equal `wanted`, with zero `failures`. Rejected preparation retries on Stop/Start
or explicit gauge reload, not on every event. Preparation can take time; warning
minimum visibility starts only after the first successful appearance. Example:

```json
"presentations": [{
    "event": "active", "clearEvent": "clear", "view": "status",
    "mode": "overlay", "snapshot": false, "priority": 70
}]
```

An eligible event can stay active while some/all children blink off. Overlay mode
preserves the gauge; `transparent:true` also preserves it when opened explicitly
as a dedicated view. Menus/console and the ten-second interaction grace suppress
popups regardless of priority. This is expected behaviour, not a missing draw.

### Progression smoothing and panel inversion

Visibility expressions use raw effective values, not element easing. For whole
segments, publish a separate eased/quantized display count and bind visibility to
it. Keep raw measurement values and all threshold/timer decisions independent.
Match editor semantics: `current += (target-current)*min(1, dt*smoothing)`, with
0 instant, positive values faster as they increase, and a small settling snap.
First-light/clear bypasses belong to package policy; test both directions and loss
of input rather than only a settled image.

A presentation may declare `"invertParam":"invert"` naming one declared module
output. Positive selected values invert the entire panel after a successful draw.
Use `snapshot:false` and publish the phase yourself for live blinking. Hidden,
preempted, cleared, disabled/faulted or menu-suppressed presentations cannot invert.
The display owner reuses the panel command and combines it with the ordinary
flashPoint alarm; simulator/capture output emulates the same RGB inversion. This
does not add an OGS element or affect standalone components/manual style previews.
Older hosts may ignore this new field; whole-screen mode needs updated firmware.

### Minimum-visible warnings beside live indicators

`minVisibleMsSetting` names an integer setting (0–600000 ms); the presentation must
also have `clearEvent` and no timed `holdMs`. The host starts this minimum at the
**first successful draw**, not at the trigger or clear event. If cleared before
ever drawing, the unseen warning is cancelled. After it has drawn, an early clear
retains it until the minimum; a still-active warning remains longer. Menu ownership,
Stop and dismissal still win; hidden time after first draw counts toward the minimum.

The minimum retains the **view**, not children that your JSON hides. Do not gate
the warning text on a live `active` output which becomes zero on clear: that would
leave a retained but empty view. Keep the warning text present for the lifetime of
its warning presentation. Its live companions can still use changing output roles
and `snapshot:false`.

When an indicator and a minimum-visible warning need different lifetimes, use
`mode:"indicator", snapshot:false` for the lights and `mode:"overlay"` for the
message. Emit/clear each eligibility independently. Do not duplicate the lights in
the message view: they would be drawn twice, changing translucent colours. Never
freeze the live indicators to hold a warning.
The warning view's text does not need a live-active visibility gate: the host owns
its display lifetime. Keep presentation eligibility separate from measurement:
turning off an appearance switch must not stop the timer, but an entirely hidden
view should not unnecessarily claim popup priority. Explicitly opening a warning
view shows its authored warning content; automatic event lifetime rules apply to
popups, not to manually selected views.

## 5. Verify with production Lua32 and OGS

JSON validation only checks structure; it does not execute Lua or prove pixels.
Use the local/device **OGS editor**, File → Modules. Choose **page-local memory**
before first preview setup (especially on plain HTTP/AP). Create ordinary preview
inputs first, import the complete folder/OGP, stage the package, Register, then
Start session. Close the dialog and allow the interaction grace to expire to see
automatic popups. Stop/Start, gauge reload and explicit view opening are separate
tests. Preview replay is debugging only; it never writes the device's sensors.

For an agent with a Gauge.S3 checkout, run Python Playwright (Chrome or Chromium).
Serve `deploy/` with `python -m http.server 8080 --directory deploy`, then open
`http://127.0.0.1:8080/wifi/sim/editor.html`. Downloading the installed editor from
`http://gauge.local/wifi/sim/editor.html` also works; use only preview APIs below.
No Node.js toolchain is needed. Keep test runners and PNGs under `debug/`.

Use a Python environment that actually has Playwright/Pillow; the bare Windows
`python` can be an unrelated application interpreter. Create one with
`python -m venv debug/module-author-venv`, install `playwright` and `Pillow` using
its Python, then run its `python -m playwright install chromium`. The executable is
`debug/module-author-venv/Scripts/python.exe` on Windows, or
`debug/module-author-venv/bin/python` on POSIX. With only the Gauge.Modules source
checkout, use the installed device editor above; this repository does not bundle
an engine build. Use a fresh browser context. For a
loopback static server without `service-worker.js`, block service workers during
ordinary preview tests; sandboxed custom-panel tests instead need the offline
AP-origin route pattern described in `testing/modules/README.md`.
For example: `browser.new_context(service_workers="block")`. Do not count a known
static-fixture 404 as a module failure, or conceal unrelated console errors.

After `GaugeAgent.ready()` is true, this browser-side sequence uses the actual
production package loader, Lua runtime and renderer. `files` is a manifest-first
array of `{name, data:Uint8Array}`. Call these from `page.evaluate`:

```javascript
Module.moduleStorageMode = 'memory';
const bytes = new Uint8Array(OGP.pack(files));
let binary = ''; for (const b of bytes) binary += String.fromCharCode(b);
const imported = await GaugeAgent.loadOgpB64(btoa(binary), 'module.ogp');
if (!imported.ok) throw new Error(imported.error);
// Import lazily loads ModulePackages; no runtime is started.
Module.ccall('sim_setParam', null, ['string','number'], ['author input', 0]);
const plan = await ModulePackages.stageMemory(Module, files, 'author_probe', null,
    {inputs:{temperature:'author input'}, settings:{}}); // use YOUR role names
const request = Module.ccall('sim_modulesSetup', 'number',
    ['string','string','number'], ['/modules/modules.json','author_probe',0]);
if (!request) throw new Error('Setup was not queued');
```

Await `JSON.parse(Module.ccall('sim_modulesStatus','string',[],[])).setup.lastRequest`
matching `request`, then check `setup.ok/error`. Do not wait only for `.ok` (a failure
would time out without telling you why). Fetch this status snapshot as `state`:

```javascript
const item = state.instances.find(i => i.id === 'author_probe');
const request = Module.ccall('sim_modulesControl','number',
    ['string','number','string','string'], [item.id,item.generation,'enable','']);
```

Await that instance's matching `lastRequest`, check `requestOk`, `enabled`,
`faulted`, `error`, `values` and `peakHeapBytes`. Input changes use `sim_setParam`
as above; module outputs cannot be injected. Refresh an input's publication at a
representative rate while testing a dwell. A single set followed by a long wait
tests **staleness**, not a continuously acquired sensor.

Capture the actual `#canvas` with Playwright; WebGL `toDataURL` can return black.
Loading a simple contrasting base gauge via `GaugeAgent.loadDesign(jsonText,[])`
is useful for proving transparent pixels. Let the ordinary load fade settle before
taking a baseline; compare against the known settled colour, then require cleared
regions to match exactly. **Setup first**, then load gauges that
reference module outputs: the simulator otherwise auto-creates conflicting holders.
Preserve the exported complete module via `GaugeAgent.exportModuleOgpB64()`.
For native 466×466 screenshots, temporarily place the existing canvas at integral
CSS coordinates and size (then restore its style); fractional layout can otherwise
produce a 466×467 Playwright crop. Do not replace the bound canvas.

Exact duration boundaries can be checked with `page.clock.install()` **before**
opening/initializing the production WASM editor. Pause using a Python timezone-aware
`datetime` (numeric Python clock dates are seconds, not JS epoch milliseconds),
then `page.clock.run_for(milliseconds)` in small steps. Keep the ordinary input
publication timer running under that clock and inspect actual module outputs.
Do not replace the module callback, native clock helper or scheduler. Test real-time
execution too; virtual time checks equality, not device performance.
While time is paused, asynchronous package staging still needs its receipt timers:
start the Promise without awaiting it inside a blocking `page.evaluate`, advance
small clock steps, then inspect its completion/error result. A native HTML dialog
also sits above a fixed canvas regardless of z-index. During popup-suppression tests,
move the still-open dialog outside the screenshot crop and make its backdrop
transparent; preserve the actual open state and production suppression policy.
The blocked-worker static fixture can log `Service Worker registration blocked by
Playwright` and an undefined-`scope` registration warning; retain these separately
from unexpected errors.

On-device `/bitmap` freezes pixels before transferring the BMP. Download completion
is not the draw time; a transfer can outlast a short popup. Use separate activations
or capture/header bounds for short-window checks. Network polling can also alias
blinking; qualify exact phase in production WASM and use device-local observation
timestamps for hardware frequency measurements.

Minimum evidence for a timer/indicator:

1. Exact thresholds, hysteresis, progressive states and every blink on/off state.
2. Just-before/after dwell, an interrupted dwell and a second successful rearm.
3. Missing/stale input and an excessive callback gap, with no fabricated continuity.
4. A different frequency/threshold setup, each independent appearance switch off,
   and invalid-setting rejection. Changing style must not reset measurement policy.
5. Stop/Start reset; underlying gauge switch/reload leaves a running timer intact.
6. Screenshots with empty, partial, full, warning, blink-off and clear states;
   compare inactive regions to the base gauge. Read every line for clipping.
7. Clean browser console, no module fault, heap comfortably below 65,536 bytes,
    complete OGP round trip retaining source/assets and licences.
8. Minimum-visible warning: early clear after first draw, clear before any draw,
   continued active warning past the minimum, and actual live companion pixels
   throughout the retained interval. Readouts must keep updating after clear.

Record expected and observed results, not just screenshots or "looks good".
State any physical sensor/load checks that cannot run. The browser shares production
logic but does not prove ECU freshness, hardware timing or peak device load.

## 6. Install and hand over

Export the complete `.ogp`, import on the device Modules tab, choose a stable
instance id and **Stage package and configuration → Register/Apply saved setup →
Start session**. Select real bindings explicitly; do not ship a simulated sensor
as the default. Run on boot is a separate saved opt-in. For automated installation,
use `ModulePackages.stageDevice(API, files, id, null, null, choices)` in the device
page; it verifies all received bytes and waits for the saved-source receipt.
Omit `choices.inputs/settings` to preserve existing maps; supplied maps replace
them completely. Stop replacements first. Details and raw HTTP receipts: [llms.txt](llms.txt).

Verify the physical `/bitmap` BMP, convert to PNG and inspect multiple active/off
states. Fetch status before/after so the image has an honest source/state label.
Device `testp`/`testt` are live expressions: a poke can be overwritten immediately.
Release firmware has no `/test/*` injection API. Use an explicitly labelled,
isolated temporary stimulus for bench tests, restore the real binding afterward,
and never change customer thresholds to disguise missing real sensor input.

Deliver source + OGP + README settings/defaults + verification report and PNGs.
Keep unrelated module history, bindings, saved choices and underlying gauges intact.
No rebuild is needed for a package-only Lua/JSON change. Engine changes do require
firmware and both simulator builds and the repository's shared-code checks.

The ordinary configuration endpoint merges individual `settings`/`inputs` keys:
an empty map does not reset earlier overrides. Send explicit declared defaults when
resetting through that endpoint. Source staging has a different contract: supplied
maps replace the complete saved maps; omitted maps preserve choices.

## Troubleshooting

| Symptom | Check |
|---|---|
| `attempt to index a nil value` | A forbidden desktop Lua library? Read the sandbox list. |
| Missing binding / duplicate parameter | Create preview inputs before setup; set up modules before loading output-consuming views. |
| Correct outputs, no popup | Popup choice, closed menu/grace, winning higher-priority module, event emission and view role map. |
| Flash appears once then freezes | `snapshot:false`, periodic callback and live visibility binding. |
| Bool display switch hides an active value | Use direct bool visibility on a group, numeric runtime conditions on children. |
| Stop faults while trying to reset | `out.*` is read-only in `on_disable`; reset in `on_enable`. |
| Minimum-visible popup becomes empty | The host retains the view, not live-gated children; leave warning content present. |
| One overlay hides the other | Overlay/screen share one winner. Put compact independent lights in `mode:"indicator"`; priority still selects one winner per slot. |
| Dimming/black area when off | Remove off shapes/backgrounds; use runtime `visible`; explicit view needs transparency. |
| Wrong scale/position or truncated text | Percent versus pixelCoords; group children are view-relative; font size and round-screen edges. |
| Setup succeeds but new choices do nothing | Save does not apply setup; stop, apply, start. |
| Source replacement collision | One manifest path per package id; stage every sibling together before reboot. |
| An HTTP 202 seemed successful | Wait for the matching request/session/generation receipt and read its error. |
