diff --git a/CLAUDE.md b/CLAUDE.md index 4dcb1bd..b368358 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,6 +44,11 @@ NAME=` the build leg, rendered into a temp folder (`RENDER=` to move it). Each step is a target of its own, which `make help` lists; the build leg's checks are `tests/build_checks.py`. +A render's `make test` runs the Light V2 fuzzer's first 300 seeded runs, as a +light repo's CI does. The render leg's `render-light-fuzz` runs it long, 20,000 +runs from seed 1. Before a library change, run other seeds too, one job each: +`make render-light-fuzz NAME=light LIGHT_FUZZ_SEED=`. + ## Rendering, and the default that lies ```bash @@ -98,7 +103,14 @@ opts into, like crypto, and gating it keeps a render from conflicting with Essentials' own `src/lib/expressions.lua` at its next update. `ui` gates `src/lib/ui/`, the kit for a custom Composer tab, and its three tests, so the repos without a tab carry neither its page files nor tests that prove nothing -there. A page splices `style.css` and `page.js` in with `#embed` at build time. +there. `light` gates the Light V2 library (`src/lib/light.lua`, `src/lib/light/`), +its test kit (`test/light_harness.lua`, `test/light_setup/`) and its suites +(`test/test_light_proxy.lua`, `test/test_light_fuzz.lua`), so a light repo's +`make test` runs them and the other repos carry none of it. Gating a module its +siblings call would leave them calling a module the render dropped; `lib.light` +is safe to gate because it is a leaf. It requires only modules that always +render, and the renders without the token prove nothing that always renders +requires it. A page splices `style.css` and `page.js` in with `#embed` at build time. There is no other module-to-module gating: every other `src/lib/*.lua` renders unconditionally, and `gen-squishy` bundles only what a driver actually requires, so a rendered-but-unused module is zero bytes in the `.c4z`. A new `src/lib` file needs no `_exclude` entry. diff --git a/Makefile b/Makefile index a4854c5..86d1c0c 100644 --- a/Makefile +++ b/Makefile @@ -19,6 +19,10 @@ override RENDER := $(abspath $(RENDER)) # A leg's steps run in order. .NOTPARALLEL: +# The long run of the Light V2 fuzzer, where a render's own make test runs its first 300 runs. +LIGHT_FUZZ_RUNS ?= 20000 +LIGHT_FUZZ_SEED ?= 1 + # ─── Help ───────────────────────────────────────────────────────────────────── .PHONY: help @@ -41,7 +45,7 @@ invalid-combinations: ## Check every combination in tests/template-renders/inval # ─── Render leg ─────────────────────────────────────────────────────────────── # CI's render leg is `make render-checks`; each of its steps is a target of its own. -.PHONY: render render-snapshot render-lib-loads render-crypto render-ui-js render-checks +.PHONY: render render-snapshot render-lib-loads render-crypto render-ui-js render-light-fuzz render-checks render: ## Render NAME into RENDER, replacing an earlier render there $(PYTHON) tests/template_renders.py render $(NAME) "$(RENDER)" @@ -66,7 +70,12 @@ render-ui-js: ## Check the render's lib/ui scripts under Node, each one the rend done; \ else echo "This render has no lib/ui."; fi -RENDER_LEG := render render-snapshot render-lib-loads render-crypto render-ui-js in-render-test +render-light-fuzz: ## Run the render's Light V2 fuzzer long: LIGHT_FUZZ_RUNS seeded runs from LIGHT_FUZZ_SEED + @if [ -f "$(RENDER)/test/test_light_fuzz.lua" ]; then \ + LIGHT_FUZZ_RUNS=$(LIGHT_FUZZ_RUNS) LIGHT_FUZZ_SEED=$(LIGHT_FUZZ_SEED) "$(RENDER)/test/run_test.sh" test_light_fuzz.lua; \ + else echo "This render has no lib.light."; fi + +RENDER_LEG := render render-snapshot render-lib-loads render-crypto render-ui-js in-render-test render-light-fuzz render-checks: $(RENDER_LEG) ## Run CI's render leg for NAME # ─── Build leg ──────────────────────────────────────────────────────────────── diff --git a/README.md b/README.md index e41897e..ea55fa3 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ packager's crypto/XML libs), and re-installs whenever that list changes. - **values.lua**: value parsing, coercion, and formatting utilities - **expressions.lua** / **expression-sandbox.lua**: expressions over device variables: `PARAM{device,variable}` tokens, evaluation within limits on time, memory and pattern searches, and debounced watches (only with `expressions` in `vendor_modules`) - **ui/**: the kit for a custom Composer tab: `tab-properties.lua` and `page-params.lua` for the driver, and `style.css` and `page.js`, which a page splices in with `#embed` (only with `ui` in `vendor_modules`) +- **light.lua** / **light/**: the Light V2 library a light driver is an adapter over, with its test kit and suites (only with `light` in `vendor_modules`) - **logging.lua** / **utils.lua**: structured logging and general utilities (always included) - **github-updater.lua**: GitHub Releases-based self-updater for non-DriverCentral (`oss`) builds - **diagnostics.lua**: Cloud Monitoring, a DriverCentral build's hourly memory and error reports to InfluxDB (only with `cloud_diagnostics`) @@ -179,7 +180,7 @@ Copier will show diffs for any files that changed in the template and let you re | `readme_build` | Build distribution for README | `oss` | | `primary_color` | Accent color for generated docs | `#109EFF` | | `icon_style` | Control4 icon artboard `make icons` exports: `shaped` (Shadow Shaped) or `square` (Shadow Square) | `shaped` | -| `vendor_modules` | Space-separated *optional* `vendor` modules (bitn crypto bthome noiseprotocol protobuf oauth), `expressions` for the `src/lib` expression engine, and `ui` for the custom Composer tab kit | (empty) | +| `vendor_modules` | Space-separated *optional* `vendor` modules (bitn crypto bthome noiseprotocol protobuf oauth), `expressions` for the `src/lib` expression engine, `ui` for the custom Composer tab kit, and `light` for the Light V2 library | (empty) | | `cloud_diagnostics` | Ship `lib.diagnostics` (Cloud Monitoring), which reports a DriverCentral build's memory use and errors to InfluxDB; needs four build secrets | `false` | | `extra_build_secrets` | Space-separated build secrets `.env.default` declares besides the template's own (`SUPPORT_CODE_SECRET`, and the four `INFLUXDB_*` with `cloud_diagnostics`); the Build workflow passes each by name | (empty) | | `extra_required_checks` | Space-separated status checks a PR must pass besides `build`, each a job name from the repo's own workflows; `tools/github-setup` puts them in the branch ruleset | (empty) | diff --git a/copier.yml b/copier.yml index 75a94bd..361bfa0 100644 --- a/copier.yml +++ b/copier.yml @@ -43,6 +43,12 @@ _exclude: - "{% if 'ui' not in vendor_modules.split() or 'expressions' not in vendor_modules.split() %}src/lib/ui/expressions.css{% endif %}" - "{% if 'ui' not in vendor_modules.split() or 'expressions' not in vendor_modules.split() %}src/lib/ui/expression-tab.lua{% endif %}" - "{% if 'ui' not in vendor_modules.split() or 'expressions' not in vendor_modules.split() %}test/test_ui_expression_tab.lua{% endif %}" + - "{% if 'light' not in vendor_modules.split() %}src/lib/light.lua{% endif %}" + - "{% if 'light' not in vendor_modules.split() %}src/lib/light{% endif %}" + - "{% if 'light' not in vendor_modules.split() %}test/light_harness.lua{% endif %}" + - "{% if 'light' not in vendor_modules.split() %}test/light_setup{% endif %}" + - "{% if 'light' not in vendor_modules.split() %}test/test_light_proxy.lua{% endif %}" + - "{% if 'light' not in vendor_modules.split() %}test/test_light_fuzz.lua{% endif %}" - "{% if not cloud_diagnostics %}src/lib/diagnostics.lua{% endif %}" - "{% if not cloud_diagnostics %}test/test_diagnostics_reporting.lua{% endif %}" - "{% if not cloud_diagnostics %}test/test_diagnostics_scrub.lua{% endif %}" @@ -90,7 +96,7 @@ icon_style: vendor_modules: type: str - help: "Space-separated optional vendor modules (available: bitn crypto bthome noiseprotocol protobuf oauth expressions ui). Note: the crypto/noiseprotocol/protobuf/bthome bundles are core builds that require bitn on the path; include bitn alongside them (crypto also for noiseprotocol). oauth ships Control4's OAuth authorization-code grant (drivers-common-public module/auth_code_grant.lua) and its short-link helper (global/make_short_link.lua). ui ships the kit for a custom Composer tab in src/lib/ui: the house style.css and page.js for a page to embed, the variable browser (variables.js and .css, lib.ui.devices), with expressions the expression editor (expressions.js and .css, lib.ui.expression-tab), and the driver side (lib.ui.page, lib.ui.page-params, lib.ui.tab-properties)." + help: "Space-separated optional vendor modules (available: bitn crypto bthome noiseprotocol protobuf oauth expressions ui light). Note: the crypto/noiseprotocol/protobuf/bthome bundles are core builds that require bitn on the path; include bitn alongside them (crypto also for noiseprotocol). oauth ships Control4's OAuth authorization-code grant (drivers-common-public module/auth_code_grant.lua) and its short-link helper (global/make_short_link.lua). ui ships the kit for a custom Composer tab in src/lib/ui: the house style.css and page.js for a page to embed, the variable browser (variables.js and .css, lib.ui.devices), with expressions the expression editor (expressions.js and .css, lib.ui.expression-tab), and the driver side (lib.ui.page, lib.ui.page-params, lib.ui.tab-properties). light ships the Light V2 library (src/lib/light.lua and src/lib/light/), its test kit (test/light_harness.lua, test/light_setup/) and its suites (test/test_light_proxy.lua, test/test_light_fuzz.lua); a Light V2 driver is then an adapter over it." default: "" cloud_diagnostics: diff --git a/example-driver/light/drivers/example_light/driver.c4zproj b/example-driver/light/drivers/example_light/driver.c4zproj new file mode 100644 index 0000000..f0b52a8 --- /dev/null +++ b/example-driver/light/drivers/example_light/driver.c4zproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/example-driver/light/drivers/example_light/driver.lua b/example-driver/light/drivers/example_light/driver.lua new file mode 100644 index 0000000..2b3ecb6 --- /dev/null +++ b/example-driver/light/drivers/example_light/driver.lua @@ -0,0 +1,149 @@ +require("lib.utils") +require("drivers-common-public.global.handlers") +require("drivers-common-public.global.lib") +require("drivers-common-public.global.timer") + +JSON = require("JSON") + +local log = require("lib.logging") +local Light = require("lib.light") +--#ifdef DRIVERCENTRAL +local drivercentral = require("lib.drivercentral") +local supportCode = require("lib.support-code") +local updateSettings = require("lib.update-settings") +--#endif + +-- The light this driver drives, in memory so the example needs no device: a color bulb that +-- fades itself over the time each command asks and reports its state when the fade has run. +local bulb = { on = false, level = 1, color = { x = 0.3127, y = 0.329, mode = Light.CCT } } + +-- What the bulb can do. It pushes its state when it likes, saying nothing of which command a push +-- follows, so its reports are taken as they come (trust). +local DECLARATION = { + dims = true, + color = true, + cct = { min = 2000, max = 6500 }, + fade = { by = "device" }, + reports = { policy = "trust" }, +} + +local light + +local function reportState() + light:report({ on = bulb.on, level = bulb.level, color = bulb.color }) +end + +local adapter = {} + +--- Put a command on the bulb. A field the command leaves out stays as it is. +--- @param cmd LightCommand +function adapter:send(cmd) + if cmd.on ~= nil then + bulb.on = cmd.on + end + if cmd.level then + bulb.level = cmd.level + end + if cmd.color then + bulb.color = { x = cmd.color.x, y = cmd.color.y, mode = cmd.color.mode } + end + SetTimer("ExampleLight.Report", math.max(1, cmd.ms or 0), reportState) +end + +--- Ask the bulb for its whole state. +function adapter:refresh() + SetTimer("ExampleLight.Report", 1, reportState) +end + +light = Light.new(adapter) + +function OnDriverInit() + --#ifdef DRIVERCENTRAL + drivercentral:startClient() + --#else + C4:AllowExecute(true) + --#endif + gInitialized = false + log:setLogName(C4:GetDeviceData(C4:GetDeviceID(), "name")) + log:setLogLevel(Properties["Log Level"]) + log:setLogMode(Properties["Log Mode"]) + log:trace("OnDriverInit()") +end + +function OnDriverLateInit() + log:trace("OnDriverLateInit()") + if not CheckMinimumVersion("Driver Status") then + return + end + + -- Fire OnPropertyChanged once per property to set initial globals. The router runs each + -- handler in its own xpcall and reports a failure itself, so one property's error doesn't + -- stop the rest. + for p, _ in pairs(Properties) do + OnPropertyChanged(p) + end + gInitialized = true + -- The bulb is always there: declared before the start, its first report brings the light online. + light:declare(DECLARATION) + light:start() + UpdateProperty("Driver Status", "Online") + --#ifdef DRIVERCENTRAL + drivercentral:startGate(function(licensed) + UpdateProperty("Driver Status", licensed and "Online" or drivercentral.UNLICENSED_STATUS) + end) + --#endif +end + +function OPC.Driver_Status(propertyValue) + log:trace("OPC.Driver_Status('%s')", propertyValue) + if not gInitialized then + UpdateProperty("Driver Status", "Initializing", false) + return + end +end + +function OPC.Driver_Version(propertyValue) + log:trace("OPC.Driver_Version('%s')", propertyValue) + C4:UpdateProperty("Driver Version", C4:GetDriverConfigInfo("version")) +end + +--#ifdef DRIVERCENTRAL +function OPC.Automatic_Updates(propertyValue) + log:trace("OPC.Automatic_Updates('%s')", propertyValue) + if gInitialized then + updateSettings:share("Automatic Updates", propertyValue) + end +end + +--- An update setting changed on another item. +function EC.SET_UPDATE_SETTING(params) + log:trace("EC.SET_UPDATE_SETTING(%s)", params) + updateSettings:take(params) +end + +--- Not logged: the value is the code. +function OPC.Support_Code(propertyValue) + supportCode:enter(propertyValue) +end +--#endif + +function OPC.Log_Mode(propertyValue) + log:trace("OPC.Log_Mode('%s')", propertyValue) + log:setLogMode(propertyValue) + CancelTimer("LogMode") + if not log:isEnabled() then + UpdateProperty("Log Level", "3 - Info", true) + return + end + log:warn("Log mode '%s' will expire in 3 hours", propertyValue) + SetTimer("LogMode", 3 * ONE_HOUR, function() + log:warn("Setting log mode to 'Off' (timer expired)") + UpdateProperty("Log Mode", "Off", true) + end) + OnPropertyChanged("Log Level") +end + +function OPC.Log_Level(propertyValue) + log:trace("OPC.Log_Level('%s')", propertyValue) + log:setLogLevel(propertyValue) +end diff --git a/example-driver/light/drivers/example_light/driver.xml b/example-driver/light/drivers/example_light/driver.xml new file mode 100644 index 0000000..6ade297 --- /dev/null +++ b/example-driver/light/drivers/example_light/driver.xml @@ -0,0 +1,204 @@ + + Example Light + + Finite Labs + Example Light + Finite Labs + lua_gen + DriverWorks + Copyright 2026 Finite Labs, LLC. All rights reserved. + 01/01/2026 12:00:00 PM + + 3.3.0 + + Lighting + + + + +