Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ NAME=<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=<n>`.

## Rendering, and the default that lies

```bash
Expand Down Expand Up @@ -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.
Expand Down
13 changes: 11 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)"

Expand All @@ -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 ────────────────────────────────────────────────────────────────
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down Expand Up @@ -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) |
Expand Down
8 changes: 7 additions & 1 deletion copier.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}"
Expand Down Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions example-driver/light/drivers/example_light/driver.c4zproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<Driver type="c4z" name="example_light" squishLua="true">
<Items>
<Item type="dir" c4zDir="www" name="www" recurse="true" exclude="false"/>
<Item type="file" name="driver.lua"/>
<Item type="file" name="driver.xml"/>
</Items>
</Driver>
149 changes: 149 additions & 0 deletions example-driver/light/drivers/example_light/driver.lua
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading