OGP o   module.jsony   ż5  danger.json86  ¬  LICENSEä=  9  lights.jsonB  8  main.lua%z  T  	README.mdyŤ  –  {
    "kind": "module",
    "api": 1,
    "id": "shift-crown",
    "name": "Shift crown",
    "version": 4,
    "inputs": {
        "rpm": {
            "param": "engine speed",
            "unit": "RPM",
            "optional": true,
            "description": "Bind the real engine RPM source. A missing source remains inactive."
        }
    },
    "settings": {
        "startRpm": {
            "type": "integer",
            "default": 6000,
            "name": "First light",
            "description": "First segment turns on at this RPM.",
            "min": 0,
            "max": 20000,
            "unit": "RPM"
        },
        "clearRpm": {
            "type": "integer",
            "default": 6000,
            "name": "Clear below",
            "description": "All lights disappear below this RPM; must be <= First light.",
            "min": 0,
            "max": 20000,
            "unit": "RPM"
        },
        "fullRpm": {
            "type": "integer",
            "default": 7000,
            "name": "All lights",
            "description": "The final segment turns on here; all segment thresholds scale evenly from First light. Must exceed First light.",
            "min": 0,
            "max": 20000,
            "unit": "RPM"
        },
        "blinkRpm": {
            "type": "integer",
            "default": 7000,
            "name": "Blink from",
            "description": "Blink all lit segments at or above this RPM; must be >= All lights.",
            "min": 0,
            "max": 20000,
            "unit": "RPM"
        },
        "redlineRpm": {
            "type": "integer",
            "default": 7000,
            "name": "Redline",
            "description": "Warning dwell starts at Redline minus Margin, independently of the lights.",
            "min": 0,
            "max": 20000,
            "unit": "RPM"
        },
        "marginRpm": {
            "type": "integer",
            "default": 200,
            "name": "Warning margin",
            "description": "RPM below redline where continuous warning measurement begins.",
            "min": 0,
            "max": 20000,
            "unit": "RPM"
        },
        "blinkHz": {
            "type": "integer",
            "default": 4,
            "name": "Blink frequency",
            "description": "Complete on/off cycles each second, 50% duty.",
            "min": 1,
            "max": 10,
            "unit": "Hz"
        },
        "dwellMs": {
            "type": "integer",
            "default": 3000,
            "name": "Continuous dwell",
            "description": "Warning triggers strictly AFTER this continuous duration.",
            "min": 0,
            "max": 600000,
            "unit": "ms"
        },
        "minimumMs": {
            "type": "integer",
            "default": 2000,
            "name": "Minimum warning display",
            "description": "Starts at first appearance, even if RPM falls immediately.",
            "min": 0,
            "max": 600000,
            "unit": "ms"
        },
        "maxAgeMs": {
            "type": "integer",
            "default": 500,
            "name": "Maximum input age",
            "description": "Older successful publications hide lights and reset warning measurement. Not proof of ECU freshness.",
            "min": 20,
            "max": 10000,
            "unit": "ms"
        },
        "maxGapMs": {
            "type": "integer",
            "default": 250,
            "name": "Maximum observation gap",
            "description": "Longer gaps reset continuous warning measurement; no unseen time is counted.",
            "min": 20,
            "max": 10000,
            "unit": "ms"
        },
        "showLights": {
            "type": "bool",
            "default": true,
            "name": "Show shift lights",
            "description": "Visual switch only; measurement and published values keep running.",
            "group": "Appearance"
        },
        "showWarning": {
            "type": "bool",
            "default": true,
            "name": "Show danger window",
            "description": "Visual switch only; measurement and published values keep running.",
            "group": "Appearance"
        },
        "segmentCount": {
            "type": "integer",
            "default": 8,
            "name": "Number of segments",
            "description": "Whole segments, evenly spaced across the arc and the First light to All lights RPM range. The last two are red.",
            "min": 2,
            "max": 16,
            "group": "Appearance"
        },
        "smoothing": {
            "type": "number",
            "default": 5,
            "name": "Shift-light smoothing",
            "description": "Whole-segment progression only. 0 is instant; higher positive values respond faster, like gauge-editor smoothing. First light and clearing stay immediate; RPM, flash and warning timing are unchanged.",
            "min": 0,
            "max": 100,
            "step": 0.5,
            "group": "Appearance"
        },
        "flashMode": {
            "type": "choice",
            "default": "normal",
            "name": "Flashing",
            "description": "At Blink from RPM: keep the crown steady, blink its lit segments, or keep them lit while inverting the whole screen. Uses Blink frequency. Hidden lights and menu/interaction suppression also suppress inversion.",
            "choices": ["none", "normal", "invert"],
            "choiceLabels": {"none": "No flash", "normal": "Normal flash", "invert": "Whole-screen inversion"},
            "group": "Appearance"
        },
        "arcSpan": {
            "type": "integer",
            "default": 126,
            "name": "Arc span",
            "description": "Total arc span, including the gaps between segments; centred on the selected screen side. Small spans make narrow segments.",
            "min": 1,
            "max": 180,
            "unit": "degrees",
            "group": "Appearance"
        },
        "side": {
            "type": "integer",
            "default": 0,
            "name": "Screen side",
            "description": "Top, Right and Left fill clockwise. Bottom fills counterclockwise, from the lower left toward the lower right.",
            "min": 0,
            "max": 270,
            "step": 90,
            "choices": [270, 0, 90, 180],
            "choiceLabels": {"270": "Left", "0": "Top", "90": "Right", "180": "Bottom"},
            "group": "Appearance"
        },
        "line1": {
            "type": "string",
            "default": "DANGER TO",
            "name": "Warning line 1",
            "description": "Short upper line, up to 14 UTF-8 bytes. Preview the text at your chosen size and position.",
            "maxLength": 14,
            "group": "Appearance"
        },
        "line2": {
            "type": "string",
            "default": "MANIFOLD!",
            "name": "Warning line 2",
            "description": "Short lower line, up to 14 UTF-8 bytes. Leave a line empty if not needed.",
            "maxLength": 14,
            "group": "Appearance"
        },
        "amber": {
            "type": "string",
            "default": "#FFFFA400",
            "name": "Early segments",
            "description": "ARGB colour: alpha first; 00 transparent, FF opaque.",
            "control": "color",
            "maxLength": 9,
            "group": "Appearance"
        },
        "red": {
            "type": "string",
            "default": "#FFFF3333",
            "name": "Last two segments",
            "description": "ARGB colour: alpha first; 00 transparent, FF opaque.",
            "control": "color",
            "maxLength": 9,
            "group": "Appearance"
        },
        "panel": {
            "type": "string",
            "default": "#C4282A36",
            "name": "Translucent panel",
            "description": "ARGB colour: alpha first; 00 transparent, FF opaque.",
            "control": "color",
            "maxLength": 9,
            "group": "Appearance"
        },
        "ink": {
            "type": "string",
            "default": "#FFFF5555",
            "name": "Danger text",
            "description": "ARGB colour: alpha first; 00 transparent, FF opaque.",
            "control": "color",
            "maxLength": 9,
            "group": "Appearance"
        },
        "centreX": {
            "type": "integer",
            "default": 233,
            "name": "Crown centre X",
            "description": "Position/size in authored 466x466 coordinates; review within the round screen.",
            "min": 180,
            "max": 286,
            "group": "Appearance"
        },
        "centreY": {
            "type": "integer",
            "default": 233,
            "name": "Crown centre Y",
            "description": "Position/size in authored 466x466 coordinates; review within the round screen.",
            "min": 200,
            "max": 286,
            "group": "Appearance"
        },
        "innerRadius": {
            "type": "integer",
            "default": 200,
            "name": "Crown inner radius",
            "description": "Position/size in authored 466x466 coordinates; review within the round screen.",
            "min": 140,
            "max": 228,
            "group": "Appearance"
        },
        "outerRadius": {
            "type": "integer",
            "default": 229,
            "name": "Crown outer radius",
            "description": "Position/size in authored 466x466 coordinates; review within the round screen.",
            "min": 141,
            "max": 233,
            "group": "Appearance"
        },
        "panelY": {
            "type": "integer",
            "default": 233,
            "name": "Warning centre Y",
            "description": "Position/size in authored 466x466 coordinates; review within the round screen.",
            "min": 180,
            "max": 286,
            "group": "Appearance"
        },
        "textSize": {
            "type": "integer",
            "default": 4,
            "name": "Warning text size",
            "description": "Position/size in authored 466x466 coordinates; review within the round screen.",
            "min": 3,
            "max": 4,
            "group": "Appearance"
        }
    },
    "logic": {
        "file": "main.lua"
    },
    "views": {
        "lights": {
            "file": "lights.json",
            "transparent": true,
            "params": {
                "v0": "displaySegments",
                "v1": "flash"
            },
            "customs": {
                "showLights": "showLights",
                "showWarning": "showWarning",
                "segmentCount": "segmentCount",
                "arcSpan": "arcSpan",
                "side": "side",
                "line1": "line1",
                "line2": "line2",
                "amber": "amber",
                "red": "red",
                "panel": "panel",
                "ink": "ink",
                "centreX": "centreX",
                "centreY": "centreY",
                "innerRadius": "innerRadius",
                "outerRadius": "outerRadius",
                "panelY": "panelY",
                "textSize": "textSize"
            }
        },
        "danger": {
            "file": "danger.json",
            "transparent": true,
            "customs": {
                "showWarning": "showWarning",
                "line1": "line1",
                "line2": "line2",
                "panel": "panel",
                "ink": "ink",
                "panelY": "panelY",
                "textSize": "textSize"
            }
        }
    },
    "presentations": [
        {
            "event": "lights_on",
            "clearEvent": "lights_clear",
            "view": "lights",
            "mode": "indicator",
            "invertParam": "invert",
            "priority": 60,
            "snapshot": false
        },
        {
            "event": "danger_on",
            "clearEvent": "danger_clear",
            "view": "danger",
            "mode": "overlay",
            "priority": 80,
            "snapshot": false,
            "minVisibleMsSetting": "minimumMs"
        }
    ],
    "menu": [
        {
            "name": "Shift-light smoothing",
            "setting": "smoothing"
        },
        {
            "name": "Flashing",
            "setting": "flashMode"
        },
        {
            "name": "Show shift lights",
            "setting": "showLights"
        },
        {
            "name": "Show danger window",
            "setting": "showWarning"
        },
        {
            "name": "First light",
            "setting": "startRpm"
        },
        {
            "name": "Clear below",
            "setting": "clearRpm"
        },
        {
            "name": "All lights",
            "setting": "fullRpm"
        },
        {
            "name": "Blink from",
            "setting": "blinkRpm"
        },
        {
            "name": "Blink frequency",
            "setting": "blinkHz"
        },
        {
            "name": "Redline",
            "setting": "redlineRpm"
        },
        {
            "name": "Warning margin",
            "setting": "marginRpm"
        },
        {
            "name": "Continuous dwell",
            "setting": "dwellMs"
        },
        {
            "name": "Minimum warning display",
            "setting": "minimumMs"
        }
    ]
}
{
    "name": "Shift crown danger",
    "screenW": 466,
    "screenH": 466,
    "round": true,
    "customs": {
        "showWarning": {"type": "bool", "default": true},
        "line1": {"type": "string", "default": "DANGER TO"},
        "line2": {"type": "string", "default": "MANIFOLD!"},
        "panel": {"type": "color", "default": "#C4282A36"},
        "ink": {"type": "color", "default": "#FFFF5555"},
        "panelY": {"type": "number", "default": 233},
        "textSize": {"type": "number", "default": 4}
    },
    "vars": {
        "showWarning": "@showWarning",
        "line1": "@line1",
        "line2": "@line2",
        "panelY": "@panelY",
        "textSize": "@textSize"
    },
    "elements": [
        {
            "name": "danger_window", "type": "group", "visible": "$showWarning",
            "elements": [
                {
                    "name": "panel", "type": "rect", "layer": 1,
                    "x": 233, "y": "$panelY", "pixelCoords": true,
                    "w": 370, "h": 124, "radius": 22,
                    "color": {"a": "$panel_a", "r": "$panel_r", "g": "$panel_g", "b": "$panel_b"}
                },
                {
                    "name": "danger_line1", "type": "label", "layer": 2,
                    "x": 233, "y": "$panelY - 26", "pixelCoords": true,
                    "text": "$line1", "font": "visitor", "size": "$textSize", "align": "center", "valign": "center",
                    "color": {"a": "$ink_a", "r": "$ink_r", "g": "$ink_g", "b": "$ink_b"}
                },
                {
                    "name": "danger_line2", "type": "label", "layer": 2,
                    "x": 233, "y": "$panelY + 22", "pixelCoords": true,
                    "text": "$line2", "font": "visitor", "size": "$textSize", "align": "center", "valign": "center",
                    "color": {"a": "$ink_a", "r": "$ink_r", "g": "$ink_g", "b": "$ink_b"}
                }
            ]
        }
    ]
}
MIT License

Copyright (c) 2026 Shift crown contributors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
{
    "name": "Shift crown lights",
    "screenW": 466,
    "screenH": 466,
    "round": true,
    "params": {"v0": "displaySegments", "v1": "flash"},
    "customs": {
        "showLights": {"type": "bool", "default": true},
        "segmentCount": {"type": "number", "default": 8},
        "arcSpan": {"type": "number", "default": 126},
        "side": {"type": "number", "default": 0},
        "amber": {"type": "color", "default": "#FFFFA400"},
        "red": {"type": "color", "default": "#FFFF3333"},
        "centreX": {"type": "number", "default": 233},
        "centreY": {"type": "number", "default": 233},
        "innerRadius": {"type": "number", "default": 200},
        "outerRadius": {"type": "number", "default": 229}
    },
    "vars": {
        "showLights": "@showLights",
        "segmentCount": "@segmentCount",
        "arcSpan": "@arcSpan",
        "side": "@side",
        "centreX": "@centreX",
        "centreY": "@centreY",
        "innerRadius": "@innerRadius",
        "outerRadius": "@outerRadius"
    },
    "elements": [
        {
            "name": "live_lights", "type": "group", "visible": "$showLights",
            "minValue": 0, "maxValue": 1,
            "elements": [
                {
                    "name": "segment_1", "type": "arc", "layer": 2, "mode": "vector",
                    "x": "$centreX", "y": "$centreY", "pixelCoords": true,
                    "startValue": 0, "endValue": 1,
                    "startAngle": "$side - $arcSpan/2 + ($side == 180 ? $segmentCount-1 : 0)*8*$arcSpan/(8*$segmentCount-1)",
                    "endAngle": "$side - $arcSpan/2 + (($side == 180 ? $segmentCount-1 : 0)*8+7)*$arcSpan/(8*$segmentCount-1)",
                    "innerRadius": "$innerRadius", "outerRadius": "$outerRadius",
                    "color": {"a": "$amber_a", "r": "$amber_r", "g": "$amber_g", "b": "$amber_b"},
                    "smoothing": 0, "antiAlias": true,
                    "visible": "1 <= $segmentCount-2 && [v0] >= 1 && [v1] > 0"
                },
                {
                    "name": "segment_2", "type": "arc", "layer": 2, "mode": "vector",
                    "x": "$centreX", "y": "$centreY", "pixelCoords": true,
                    "startValue": 0, "endValue": 1,
                    "startAngle": "$side - $arcSpan/2 + ($side == 180 ? $segmentCount-2 : 1)*8*$arcSpan/(8*$segmentCount-1)",
                    "endAngle": "$side - $arcSpan/2 + (($side == 180 ? $segmentCount-2 : 1)*8+7)*$arcSpan/(8*$segmentCount-1)",
                    "innerRadius": "$innerRadius", "outerRadius": "$outerRadius",
                    "color": {"a": "$amber_a", "r": "$amber_r", "g": "$amber_g", "b": "$amber_b"},
                    "smoothing": 0, "antiAlias": true,
                    "visible": "2 <= $segmentCount-2 && [v0] >= 2 && [v1] > 0"
                },
                {
                    "name": "segment_3", "type": "arc", "layer": 2, "mode": "vector",
                    "x": "$centreX", "y": "$centreY", "pixelCoords": true,
                    "startValue": 0, "endValue": 1,
                    "startAngle": "$side - $arcSpan/2 + ($side == 180 ? $segmentCount-3 : 2)*8*$arcSpan/(8*$segmentCount-1)",
                    "endAngle": "$side - $arcSpan/2 + (($side == 180 ? $segmentCount-3 : 2)*8+7)*$arcSpan/(8*$segmentCount-1)",
                    "innerRadius": "$innerRadius", "outerRadius": "$outerRadius",
                    "color": {"a": "$amber_a", "r": "$amber_r", "g": "$amber_g", "b": "$amber_b"},
                    "smoothing": 0, "antiAlias": true,
                    "visible": "3 <= $segmentCount-2 && [v0] >= 3 && [v1] > 0"
                },
                {
                    "name": "segment_4", "type": "arc", "layer": 2, "mode": "vector",
                    "x": "$centreX", "y": "$centreY", "pixelCoords": true,
                    "startValue": 0, "endValue": 1,
                    "startAngle": "$side - $arcSpan/2 + ($side == 180 ? $segmentCount-4 : 3)*8*$arcSpan/(8*$segmentCount-1)",
                    "endAngle": "$side - $arcSpan/2 + (($side == 180 ? $segmentCount-4 : 3)*8+7)*$arcSpan/(8*$segmentCount-1)",
                    "innerRadius": "$innerRadius", "outerRadius": "$outerRadius",
                    "color": {"a": "$amber_a", "r": "$amber_r", "g": "$amber_g", "b": "$amber_b"},
                    "smoothing": 0, "antiAlias": true,
                    "visible": "4 <= $segmentCount-2 && [v0] >= 4 && [v1] > 0"
                },
                {
                    "name": "segment_5", "type": "arc", "layer": 2, "mode": "vector",
                    "x": "$centreX", "y": "$centreY", "pixelCoords": true,
                    "startValue": 0, "endValue": 1,
                    "startAngle": "$side - $arcSpan/2 + ($side == 180 ? $segmentCount-5 : 4)*8*$arcSpan/(8*$segmentCount-1)",
                    "endAngle": "$side - $arcSpan/2 + (($side == 180 ? $segmentCount-5 : 4)*8+7)*$arcSpan/(8*$segmentCount-1)",
                    "innerRadius": "$innerRadius", "outerRadius": "$outerRadius",
                    "color": {"a": "$amber_a", "r": "$amber_r", "g": "$amber_g", "b": "$amber_b"},
                    "smoothing": 0, "antiAlias": true,
                    "visible": "5 <= $segmentCount-2 && [v0] >= 5 && [v1] > 0"
                },
                {
                    "name": "segment_6", "type": "arc", "layer": 2, "mode": "vector",
                    "x": "$centreX", "y": "$centreY", "pixelCoords": true,
                    "startValue": 0, "endValue": 1,
                    "startAngle": "$side - $arcSpan/2 + ($side == 180 ? $segmentCount-6 : 5)*8*$arcSpan/(8*$segmentCount-1)",
                    "endAngle": "$side - $arcSpan/2 + (($side == 180 ? $segmentCount-6 : 5)*8+7)*$arcSpan/(8*$segmentCount-1)",
                    "innerRadius": "$innerRadius", "outerRadius": "$outerRadius",
                    "color": {"a": "$amber_a", "r": "$amber_r", "g": "$amber_g", "b": "$amber_b"},
                    "smoothing": 0, "antiAlias": true,
                    "visible": "6 <= $segmentCount-2 && [v0] >= 6 && [v1] > 0"
                },
                {
                    "name": "segment_7", "type": "arc", "layer": 2, "mode": "vector",
                    "x": "$centreX", "y": "$centreY", "pixelCoords": true,
                    "startValue": 0, "endValue": 1,
                    "startAngle": "$side - $arcSpan/2 + ($side == 180 ? $segmentCount-7 : 6)*8*$arcSpan/(8*$segmentCount-1)",
                    "endAngle": "$side - $arcSpan/2 + (($side == 180 ? $segmentCount-7 : 6)*8+7)*$arcSpan/(8*$segmentCount-1)",
                    "innerRadius": "$innerRadius", "outerRadius": "$outerRadius",
                    "color": {"a": "$amber_a", "r": "$amber_r", "g": "$amber_g", "b": "$amber_b"},
                    "smoothing": 0, "antiAlias": true,
                    "visible": "7 <= $segmentCount-2 && [v0] >= 7 && [v1] > 0"
                },
                {
                    "name": "segment_8", "type": "arc", "layer": 2, "mode": "vector",
                    "x": "$centreX", "y": "$centreY", "pixelCoords": true,
                    "startValue": 0, "endValue": 1,
                    "startAngle": "$side - $arcSpan/2 + ($side == 180 ? $segmentCount-8 : 7)*8*$arcSpan/(8*$segmentCount-1)",
                    "endAngle": "$side - $arcSpan/2 + (($side == 180 ? $segmentCount-8 : 7)*8+7)*$arcSpan/(8*$segmentCount-1)",
                    "innerRadius": "$innerRadius", "outerRadius": "$outerRadius",
                    "color": {"a": "$amber_a", "r": "$amber_r", "g": "$amber_g", "b": "$amber_b"},
                    "smoothing": 0, "antiAlias": true,
                    "visible": "8 <= $segmentCount-2 && [v0] >= 8 && [v1] > 0"
                },
                {
                    "name": "segment_9", "type": "arc", "layer": 2, "mode": "vector",
                    "x": "$centreX", "y": "$centreY", "pixelCoords": true,
                    "startValue": 0, "endValue": 1,
                    "startAngle": "$side - $arcSpan/2 + ($side == 180 ? $segmentCount-9 : 8)*8*$arcSpan/(8*$segmentCount-1)",
                    "endAngle": "$side - $arcSpan/2 + (($side == 180 ? $segmentCount-9 : 8)*8+7)*$arcSpan/(8*$segmentCount-1)",
                    "innerRadius": "$innerRadius", "outerRadius": "$outerRadius",
                    "color": {"a": "$amber_a", "r": "$amber_r", "g": "$amber_g", "b": "$amber_b"},
                    "smoothing": 0, "antiAlias": true,
                    "visible": "9 <= $segmentCount-2 && [v0] >= 9 && [v1] > 0"
                },
                {
                    "name": "segment_10", "type": "arc", "layer": 2, "mode": "vector",
                    "x": "$centreX", "y": "$centreY", "pixelCoords": true,
                    "startValue": 0, "endValue": 1,
                    "startAngle": "$side - $arcSpan/2 + ($side == 180 ? $segmentCount-10 : 9)*8*$arcSpan/(8*$segmentCount-1)",
                    "endAngle": "$side - $arcSpan/2 + (($side == 180 ? $segmentCount-10 : 9)*8+7)*$arcSpan/(8*$segmentCount-1)",
                    "innerRadius": "$innerRadius", "outerRadius": "$outerRadius",
                    "color": {"a": "$amber_a", "r": "$amber_r", "g": "$amber_g", "b": "$amber_b"},
                    "smoothing": 0, "antiAlias": true,
                    "visible": "10 <= $segmentCount-2 && [v0] >= 10 && [v1] > 0"
                },
                {
                    "name": "segment_11", "type": "arc", "layer": 2, "mode": "vector",
                    "x": "$centreX", "y": "$centreY", "pixelCoords": true,
                    "startValue": 0, "endValue": 1,
                    "startAngle": "$side - $arcSpan/2 + ($side == 180 ? $segmentCount-11 : 10)*8*$arcSpan/(8*$segmentCount-1)",
                    "endAngle": "$side - $arcSpan/2 + (($side == 180 ? $segmentCount-11 : 10)*8+7)*$arcSpan/(8*$segmentCount-1)",
                    "innerRadius": "$innerRadius", "outerRadius": "$outerRadius",
                    "color": {"a": "$amber_a", "r": "$amber_r", "g": "$amber_g", "b": "$amber_b"},
                    "smoothing": 0, "antiAlias": true,
                    "visible": "11 <= $segmentCount-2 && [v0] >= 11 && [v1] > 0"
                },
                {
                    "name": "segment_12", "type": "arc", "layer": 2, "mode": "vector",
                    "x": "$centreX", "y": "$centreY", "pixelCoords": true,
                    "startValue": 0, "endValue": 1,
                    "startAngle": "$side - $arcSpan/2 + ($side == 180 ? $segmentCount-12 : 11)*8*$arcSpan/(8*$segmentCount-1)",
                    "endAngle": "$side - $arcSpan/2 + (($side == 180 ? $segmentCount-12 : 11)*8+7)*$arcSpan/(8*$segmentCount-1)",
                    "innerRadius": "$innerRadius", "outerRadius": "$outerRadius",
                    "color": {"a": "$amber_a", "r": "$amber_r", "g": "$amber_g", "b": "$amber_b"},
                    "smoothing": 0, "antiAlias": true,
                    "visible": "12 <= $segmentCount-2 && [v0] >= 12 && [v1] > 0"
                },
                {
                    "name": "segment_13", "type": "arc", "layer": 2, "mode": "vector",
                    "x": "$centreX", "y": "$centreY", "pixelCoords": true,
                    "startValue": 0, "endValue": 1,
                    "startAngle": "$side - $arcSpan/2 + ($side == 180 ? $segmentCount-13 : 12)*8*$arcSpan/(8*$segmentCount-1)",
                    "endAngle": "$side - $arcSpan/2 + (($side == 180 ? $segmentCount-13 : 12)*8+7)*$arcSpan/(8*$segmentCount-1)",
                    "innerRadius": "$innerRadius", "outerRadius": "$outerRadius",
                    "color": {"a": "$amber_a", "r": "$amber_r", "g": "$amber_g", "b": "$amber_b"},
                    "smoothing": 0, "antiAlias": true,
                    "visible": "13 <= $segmentCount-2 && [v0] >= 13 && [v1] > 0"
                },
                {
                    "name": "segment_14", "type": "arc", "layer": 2, "mode": "vector",
                    "x": "$centreX", "y": "$centreY", "pixelCoords": true,
                    "startValue": 0, "endValue": 1,
                    "startAngle": "$side - $arcSpan/2 + ($side == 180 ? $segmentCount-14 : 13)*8*$arcSpan/(8*$segmentCount-1)",
                    "endAngle": "$side - $arcSpan/2 + (($side == 180 ? $segmentCount-14 : 13)*8+7)*$arcSpan/(8*$segmentCount-1)",
                    "innerRadius": "$innerRadius", "outerRadius": "$outerRadius",
                    "color": {"a": "$amber_a", "r": "$amber_r", "g": "$amber_g", "b": "$amber_b"},
                    "smoothing": 0, "antiAlias": true,
                    "visible": "14 <= $segmentCount-2 && [v0] >= 14 && [v1] > 0"
                },
                {
                    "name": "red_penultimate", "type": "arc", "layer": 2, "mode": "vector",
                    "x": "$centreX", "y": "$centreY", "pixelCoords": true,
                    "startValue": 0, "endValue": 1,
                    "startAngle": "$side - $arcSpan/2 + ($side == 180 ? 1 : $segmentCount-2)*8*$arcSpan/(8*$segmentCount-1)",
                    "endAngle": "$side - $arcSpan/2 + (($side == 180 ? 1 : $segmentCount-2)*8+7)*$arcSpan/(8*$segmentCount-1)",
                    "innerRadius": "$innerRadius", "outerRadius": "$outerRadius",
                    "color": {"a": "$red_a", "r": "$red_r", "g": "$red_g", "b": "$red_b"},
                    "smoothing": 0, "antiAlias": true,
                    "visible": "[v0] >= $segmentCount-1 && [v1] > 0"
                },
                {
                    "name": "red_final", "type": "arc", "layer": 2, "mode": "vector",
                    "x": "$centreX", "y": "$centreY", "pixelCoords": true,
                    "startValue": 0, "endValue": 1,
                    "startAngle": "$side - $arcSpan/2 + ($side == 180 ? 0 : $segmentCount-1)*8*$arcSpan/(8*$segmentCount-1)",
                    "endAngle": "$side - $arcSpan/2 + (($side == 180 ? 0 : $segmentCount-1)*8+7)*$arcSpan/(8*$segmentCount-1)",
                    "innerRadius": "$innerRadius", "outerRadius": "$outerRadius",
                    "color": {"a": "$red_a", "r": "$red_r", "g": "$red_g", "b": "$red_b"},
                    "smoothing": 0, "antiAlias": true,
                    "visible": "[v0] >= $segmentCount && [v1] > 0"
                }
            ]
        }
    ]
}
-- Shift crown: transient measurement, separate live/held presentations.
local startS, startUs, lastS, lastUs, blinkS, blinkUs
local armed, fired, lightsEvent, dangerEvent = false, false, false, false
local progression = 0

local function beyond(s, us, a, b, ms)
    local ds, du = ctx.elapsed(s, us, a, b)
    local whole = ms // 1000
    return ds > whole or (ds == whole and du > (ms % 1000) * 1000)
end

local function update(s, us)
    local rpm, _, _, pubS, pubUs = ctx.read("rpm")
    local valid = rpm ~= nil and pubS ~= nil
        and not beyond(s, us, pubS, pubUs, cfg.maxAgeMs)
    local gap = lastS ~= nil and beyond(s, us, lastS, lastUs, cfg.maxGapMs)
    local dt = 0
    if lastS ~= nil then
        local ds, du = ctx.elapsed(s, us, lastS, lastUs)
        dt = ds + du / 1000000.0
    end
    lastS, lastUs = s, us
    out.valid = valid and 1 or 0
    if valid then out.rpm = rpm end

    if not valid or rpm < cfg.clearRpm then
        armed = false
    elseif rpm >= cfg.startRpm then
        armed = true
    end
    out.segments = 0
    out.flash = 1
    out.invert = 0
    if armed and valid then
        out.segments = math.min(cfg.segmentCount, 1 + math.floor(
            math.max(0, rpm - cfg.startRpm) * (cfg.segmentCount - 1) / (cfg.fullRpm - cfg.startRpm)))
        if rpm >= cfg.blinkRpm then
            if blinkS == nil then blinkS, blinkUs = s, us end
            local _, du = ctx.elapsed(s, us, blinkS, blinkUs)
            -- Integer Hz: whole seconds contain a whole number of cycles.
            -- The remaining microseconds keep all arithmetic in int32.
            local on = (du * cfg.blinkHz) % 1000000 < 500000
            if cfg.flashMode == "normal" then out.flash = on and 1 or 0 end
            if cfg.flashMode == "invert" and cfg.showLights then out.invert = on and 1 or 0 end
        else
            blinkS, blinkUs = nil, nil
        end
    else
        blinkS, blinkUs = nil, nil
    end

    -- Smooth only the displayed count, never RPM, thresholds or clock state.
    -- Quantize to whole segments; first light/clear cannot acquire easing delay.
    if out.segments == 0 or cfg.smoothing == 0 then
        progression = out.segments
    elseif progression == 0 then
        progression = 1
    else
        local diff = out.segments - progression
        if math.abs(diff) < 0.001 then progression = out.segments
        else progression = progression + diff * math.min(1, dt * cfg.smoothing) end
    end
    out.displaySegments = math.floor(progression + 0.5)

    if gap or not valid or rpm < cfg.redlineRpm - cfg.marginRpm then
        startS, startUs = nil, nil
        fired = false
    end
    out.dwellMs = 0
    if valid and rpm >= cfg.redlineRpm - cfg.marginRpm then
        if startS == nil then startS, startUs = s, us end
        if not fired then
            fired = beyond(s, us, startS, startUs, cfg.dwellMs)
            out.dwellMs = math.min(cfg.dwellMs + 1,
                ctx.elapsed_ms(s, us, startS, startUs))
        else
            out.dwellMs = cfg.dwellMs + 1
        end
    end
    out.warning = fired and 1 or 0

    local lights = armed and valid and cfg.showLights
    local danger = fired and cfg.showWarning
    if lights ~= lightsEvent then
        lightsEvent = lights
        ctx.emit(lights and "lights_on" or "lights_clear")
    end
    if danger ~= dangerEvent then
        dangerEvent = danger
        ctx.emit(danger and "danger_on" or "danger_clear")
    end
end

return {
    on_setup = function()
        assert(math.maxinteger == 2147483647, "Module API 1 requires int32 Lua")
        assert(16777216.0 + 1.0 == 16777216.0, "Module API 1 requires float32 Lua")
        assert(cfg.clearRpm <= cfg.startRpm, "Clear must not exceed Start")
        assert(cfg.startRpm < cfg.fullRpm, "Full must exceed Start")
        assert(cfg.fullRpm <= cfg.blinkRpm, "Blink must not precede Full")
        assert(cfg.marginRpm <= cfg.redlineRpm, "Margin must not exceed Redline")
        assert(cfg.innerRadius < cfg.outerRadius, "Outer radius must exceed Inner")
        assert(cfg.side % 90 == 0, "Side must be Top, Right, Bottom or Left")
        ctx.parameter("rpm", {unit="RPM", dec=0})
        ctx.parameter("valid", {noLog=true})
        ctx.parameter("segments", {noLog=true})
        ctx.parameter("flash", {initial=1, noLog=true})
        ctx.parameter("warning", {noLog=true})
        ctx.parameter("dwellMs", {unit="ms", noLog=true})
        ctx.parameter("displaySegments", {noLog=true})
        ctx.parameter("invert", {noLog=true})
        ctx.on_periodic(100, update)
    end,
    on_enable = function()
        startS, startUs, lastS, lastUs, blinkS, blinkUs = nil, nil, nil, nil, nil, nil
        armed, fired, lightsEvent, dangerEvent = false, false, false, false
        progression = 0
        out.rpm, out.valid, out.segments, out.flash, out.warning, out.dwellMs = 0, 0, 0, 1, 0, 0
        out.displaySegments, out.invert = 0, 0
    end
}
# Shift crown â€” Module API 1 / V6 + V6.1

Configurable amber/red arc segments around a 466x466 round display. No assets,
external scripts or runtime fonts are required. All unlit pixels are absent.

## Behaviour and defaults

* First segment at **6000 RPM**, then equal increments to all segments at
  **7000 RPM**. Clear below **6000 RPM**. Default eight; choose **2â€“16**.
  For N segments, segment i starts at `startRpm + (i-1)*(fullRpm-startRpm)/(N-1)`.
  Segments are whole on/off shapes. The last two are red, including both when N=2.
* **Shift-light smoothing** defaults to **5**; **0** is instant. Higher positive
  values respond faster, like gauge-editor exponential easing. The raw segment
  target is eased and rounded to the nearest whole segment. First light starts
  immediately at one; clearing bypasses easing. RPM and all timers stay raw.
* Choose **Left, Top, Right or Bottom** and an arc span of **1â€“180 degrees**.
  Default Top / 126 degrees reproduces the original eight-segment crown. Bottom
  fills **counterclockwise**, lower left to lower right; other sides fill clockwise.
  Gaps scale with span/count, with a 7:1 segment-to-gap angular ratio.
* At/above **7000 RPM**, blink at **4 complete cycles/second** (125 ms on/off).
  Every off segment exposes the underlying gauge without an outline/background.
  **Flashing** selects **No flash**, **Normal flash** (default) or **Whole-screen
  inversion**. Inversion keeps lit segments present and alternates the entire
  display's colours using the existing panel inversion command, at the same rate.
* At/above **redline 7000 minus margin 200 = 6800 RPM**, start continuous dwell.
  Trigger only after **MORE than 3000 ms**; equality never triggers.
* The centred, editable two-line **DANGER TO / MANIFOLD!** window has a rounded translucent
  panel. Its **2000-ms minimum starts on first successful display**, independently
  of the measurement timer. Falling below 6800 resets measurement immediately but
  cannot shorten an already-shown minimum. Continued high RPM holds it indefinitely.
* Live shift segments keep changing/blinking beside oil warnings, results and the
  retained danger window, using the independent indicator slot.
  Lower RPM rearms a fresh full dwell. Stop/Start resets all transient state; gauge
  switches do not. No timer or history is persisted.

## Settings

Generated offline web controls expose every setting. The device menu exposes both
visual switches and the principal RPM/frequency/time values.

| Setting | Default | Meaning |
|---|---:|---|
| startRpm / clearRpm | 6000 / 6000 | First light; clear strictly below. A lower clear provides hysteresis (one segment remains below start). |
| fullRpm / blinkRpm | 7000 / 7000 | All selected segments; blink starts inclusively. |
| blinkHz | 4 | Integer 1â€“10, complete 50%-duty cycles/second. |
| redlineRpm / marginRpm | 7000 / 200 | Independent warning threshold = redline minus margin. |
| dwellMs / minimumMs | 3000 / 2000 | Strict continuous dwell; host-owned minimum from first display. |
| maxAgeMs / maxGapMs | 500 / 250 | Publication age and callback-gap policy. |
| showLights / showWarning | true / true | Separate appearance switches; neither disables measurement. |
| segmentCount / arcSpan | 8 / 126 | 2â€“16 whole segments; total span including gaps, max 180 degrees. |
| smoothing | 5 | 0â€“100; 0 instant, higher positive values faster. Display progression only. |
| flashMode | normal | `none`, `normal`, `invert`; friendly labels in Appearance. Raw blink threshold/frequency are unchanged. |
| side | Top | Web choices Left/Top/Right/Bottom; stored numeric angles 270/0/90/180. Bottom reverses progression. |
| line1 / line2 | DANGER TO / MANIFOLD! | Editable warning lines, up to 14 UTF-8 bytes each; empty is allowed. |
| amber / red | #FFFFA400 / #FFFF3333 | Early / last two segment ARGB. |
| panel / ink | #C4282A36 / #FFFF5555 | Translucent panel / warning text ARGB. |
| centreX / centreY | 233 / 233 | Crown centre in pixels. |
| innerRadius / outerRadius | 200 / 229 | Segment thickness/radius in pixels. |
| panelY / textSize | 233 / 4 | Warning vertical position / Visitor multiplier (3â€“4). |

Required relations: `clearRpm <= startRpm < fullRpm <= blinkRpm`,
`marginRpm <= redlineRpm`, `innerRadius < outerRadius`. Invalid setup is rejected;
this host version may return a generic Lua-setup error instead of assertion text.
Position combinations near the circle edge require a visual review.

## Input, outputs and time

Bind `rpm` explicitly to the real engine-RPM parameter (default `engine speed`).
Missing/unpublished input is inactive, not fabricated zero. Publications older
than maxAgeMs clear lights and reset dwell; gaps exceeding maxGapMs reset dwell,
even if a fresh high reading arrives afterward. A gap restarts at that new reading.
Equality at either age/gap limit remains acceptable. Negative RPM is simply below
threshold. A retained warning minimum survives source loss like an ordinary clear.
Recent expression publications do not establish freshness of the underlying ECU.

Outputs: `<instance>.rpm` (last valid RPM, CSV-loggable), `.valid`, `.segments`
(raw 0â€“segmentCount target), `.displaySegments` (eased whole count, before masking),
`.flash` (0/1 segment visibility; always 1 outside Normal flash), `.invert` (0/1
panel request; 0 when Show shift lights is off), `.warning` (measurement active,
not the retained window), `.dwellMs` (caps at configured dwell+1 after triggering).
Status values use `noLog` except RPM. Time uses native split-second helpers;
100-Hz periodic callbacks are an upper bound, not a real-time deadline.

## Install/use

Import `shift-crown.ogp` in Modules. Select an instance id and a real RPM binding.
**Stage package and configuration â†’ Register/Apply saved setup â†’ Start session**.
Enable Popups for automatic presentation. Run on boot is a separate opt-in.
For changes: Stop â†’ Save choices â†’ Apply saved setup â†’ Start. Appearance switches
do not change thresholds or policy, though applying any setup restarts the VM.

The crown uses `mode:"indicator"`; central messages use the ordinary popup slot.
Each slot selects one winner by priority, then newest. The danger view contains
only its panel/text, so translucent crown segments are never drawn twice.
Its warning text is deliberately not gated by `.warning`: the host holds it until
the minimum expires. Explicitly opening that view shows its authored warning text;
automatic event lifetimes apply only to popups. The normal `lights` view can also
be opened transparently; whole-screen inversion belongs to the automatic presentation,
not a manually opened view or a component/style preview. Menus, interaction grace,
a higher-priority presentation
in the same slot and Stop may suppress it; hidden time after first appearance counts toward
the minimum. Clearing before its first display cancels the unseen warning.

## Packaging/licence

Manifest-first complete OGP: module.json, main.lua, lights.json, danger.json,
README.md and LICENSE. Source is MIT. The referenced test-blink gauge informed
the visual arc-band treatment only; its engine/gauge implementation is not bundled.
Browser verification/evidence is supplied beside the package, not inside it.

Version 4 adds smoothing and flash modes and needs a host supporting presentation
`invertParam` for whole-screen inversion. Version 3 requires `mode:"indicator"`.
It separates the crown from central messages. Version 2 added design controls to
the independently authored v1 package. The lights view contains a bounded 16-slot
crown: 14 optional amber slots plus two red slots at the selected count's end. There is no
per-tick geometry generation. Layout/settings apply at setup;
use the unsaved production-renderer style preview to check placement and text.
