Skip to content
Merged
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
8 changes: 6 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,13 @@ render-lib-loads: ## Check every src/lib module in the render loads
render-crypto: ## Check the render's c4_shim resolves its FFI crypto backend
"$(RENDER)/test/run_test.sh" "$(CURDIR)/tests/ffi_crypto_backend.lua"

render-ui-js: ## Check the render's lib/ui/page.js under Node, where the render has one
render-ui-js: ## Check the render's lib/ui scripts under Node, each one the render ships
@if [ -f "$(RENDER)/src/lib/ui/page.js" ]; then \
UI_PAGE_JS="$(RENDER)/src/lib/ui/page.js" node --test tests/ui_page_js.test.mjs; \
for kit in page variables expressions; do \
if [ -f "$(RENDER)/src/lib/ui/$$kit.js" ]; then \
UI_DIR="$(RENDER)/src/lib/ui" node --test "tests/ui_$${kit}_js.test.mjs" || exit 1; \
fi; \
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
Expand Down
7 changes: 6 additions & 1 deletion copier.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ _exclude:
- "{% if 'ui' not in vendor_modules.split() %}test/test_ui_tab_properties.lua{% endif %}"
- "{% if 'ui' not in vendor_modules.split() %}test/test_ui_page_params.lua{% endif %}"
- "{% if 'ui' not in vendor_modules.split() %}test/test_ui_pages.lua{% endif %}"
- "{% if 'ui' not in vendor_modules.split() %}test/test_ui_devices.lua{% endif %}"
- "{% if 'ui' not in vendor_modules.split() or 'expressions' not in vendor_modules.split() %}src/lib/ui/expressions.js{% endif %}"
- "{% 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 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 @@ -85,7 +90,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: lib.ui.tab-properties, lib.ui.page-params, and style.css and page.js for a page to embed."
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)."
default: ""

cloud_diagnostics:
Expand Down
82 changes: 48 additions & 34 deletions template/CONTRIBUTING.md.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -832,13 +832,26 @@ such as `drivers/tplink_light/www/documentation/index.md` in

### Custom Composer Tabs

`src/lib/ui/` is the kit for a driver's custom Composer tab: the shared look
(`style.css`), the page's link to its driver, the tab bar, demo mode and a card
of the driver's properties (`page.js`), and the driver's side of that card
(`lib.ui.tab-properties` and `lib.ui.page-params`).
`src/lib/ui/` holds everything a driver's custom Composer tab shares with the
others, so a page keeps only its own views and its driver only its own commands:

- `style.css`: the house look, from the tab bar and cards to form fields,
tables, chips, the segmented switch and a list beside its editor
- `page.js`: the page's link to its driver, the live-link and demo banners, the
tabs, the properties card and demo mode
- `variables.js` and `variables.css`: the variable browser, with
`lib.ui.devices` on the driver's side
{%- if 'expressions' in vendor_modules.split() %}
- `expressions.js` and `expressions.css`: the expression editor, with its
highlighting, help and live preview, and `lib.ui.expression-tab` on the
driver's side
{%- endif %}
- `lib.ui.page`, `lib.ui.page-params` and `lib.ui.tab-properties`: the driver's
side of the link and the properties card

A page embeds the stylesheet first in its `<style>` and the script first in its
`<script>`, then adds its own:
Each file's header comment lists what it offers. A page embeds the stylesheets
in its `<style>` and the scripts in its `<script>`, the kit's first, then adds
its own:

```html
<style>
Expand All @@ -847,53 +860,54 @@ A page embeds the stylesheet first in its `<style>` and the script first in its
<div class="nav">
<div class="nav-tab active" data-panel="status">Status</div>
</div>
<div class="banner warn" id="conn-warn" hidden>Not connected to the controller. Open this tab from Composer. The values shown are examples.</div>
<div class="panel active" id="panel-status">
<div class="card"><h3>Status</h3><div id="status"></div></div>
<div class="card" hidden><h3>Properties</h3><div id="props"></div></div>
</div>
<script>
<!-- #embed ../../../../src/lib/ui/page.js -->
Kit.initTabs();
function showStatus(status) {
document.getElementById("status").textContent = status.text;
}
Kit.initTabs(null, { hash: true });
Kit.initPropertiesCard(document.getElementById("props"));
// Outside Composer, this stands in for the driver.
Kit.demoMode(function (command) {
if (command === "_GET_CONFIG") Kit.deliver("CONFIG_DATA", { properties: "[]" });
if (command === "_GET_STATUS") Kit.deliver("STATUS", { text: "Connected" });
});
Kit.connectDriver({
handlers: { STATUS: showStatus },
onConnect: function () { Kit.sendCommand("_GET_STATUS"); },
});
Kit.connectDriver({ onConnect: function () { Kit.sendCommand("_GET_CONFIG"); } });
</script>
```

Everything `page.js` offers is on one object, `Kit`, so a page's own function of
the same name never replaces the kit's; alias the names you use often
(`var sendCommand = Kit.sendCommand;`). `style.css` styles the shared base, tab
bar, cards, buttons and banner, and otherwise only its own `.kit-*` markup, so a
page keeps its own styles for labels, inputs, tables and the like.

The build splices both files in, so each `.c4z` ships one self-contained page;
open the built page in `build/<distribution>/drivers/<name>/www/html/` to see
it. The tab bar shows from two tabs. Opened outside Composer, the page shows the
`#conn-warn` banner and the demo data its `Kit.demoMode` answer gives, which is
what the documentation's screenshots are taken from.

The driver answers the card's two commands:
and its driver answers:

```lua
local page = require("lib.ui.page")
local pageParams = require("lib.ui.page-params")
local tabProperties = require("lib.ui.tab-properties")
require("lib.ui.tab-properties"):serve()

function UIR._GET_CONFIG()
C4:SendDataToUI("CONFIG_DATA", { properties = JSON:encode(tabProperties:list()) })
function UIR._GET_STATUS()
page.send("STATUS", { text = Properties["Driver Status"] })
end

function UIR._SET_PROPERTY(tParams)
function UIR._SET_MODE(tParams)
local params = pageParams(tParams)
local err = tabProperties:set(params.property, params.value)
if err then
C4:SendDataToUI("PROPERTY_ERROR", { payload = JSON:encode({ property = params.property, error = err }) })
end
-- params.mode is what the page sent
end
```

and, at the end of `OnDriverLateInit`, `tabProperties:follow(UIR._GET_CONFIG)`,
so a change made in Composer reaches the open tab. Decode every other command's
parameters with `pageParams` too: the page breaks them into lines.
A page restyles nothing the kit styles: `test/test_ui_pages.lua` fails one that
does, so a better look goes into the kit for every tab.

The build splices the files in, so each `.c4z` ships one self-contained page;
open the built page in `build/<distribution>/drivers/<name>/www/html/` to see
it. Opened outside Composer, the page shows the demo banner and the data its
`Kit.demoMode` answer gives, which is what the documentation's screenshots are
taken from. Opened with a controller address but no token, an OSS build logs in
through the dev proxy's saved login (`tools/dev/c4proxy.py` where a repo has
one), so a live check needs nothing typed; the DriverCentral build has no such
login.
{%- endif %}
165 changes: 9 additions & 156 deletions template/src/lib/expressions.lua
Original file line number Diff line number Diff line change
Expand Up @@ -400,162 +400,15 @@ function Expressions.evaluate(mode, template)
return evaluation
end

---------------------------------------------------------------------------
-- Device Browser
---------------------------------------------------------------------------

--- Whether a proxy merely restates the device that declares it. A proxy named for
--- itself, such as a security panel's areas, stays its own entry.
--- @param proxy table|nil
--- @param owner table
--- @return boolean
local function isRestatedProxy(proxy, owner)
return proxy ~= nil and tostring(proxy.deviceName) == tostring(owner.deviceName)
end

--- Section heading for a member of a merged entry, taken from the proxy's .c4i.
--- @param dev table
--- @param isOwner boolean
--- @return string
local function proxySectionLabel(dev, isOwner)
if isOwner then
return "Device"
end
local file = tostring((dev or {}).driverFileName or "")
local base = file:match("^(.*)%.c4i$") or file:match("^(.*)%.c4z$")
return IsEmpty(base) and "Proxy" or base
end

--- The devices with variables, for a page to pick from, by room and name. A driver
--- and the proxy it declares are separate project items sharing a room and name, so
--- they are one entry. A project can hold many items of a driver, so a name that is
--- still not unique in its room gets the device id appended.
--- @return { id: integer, name: string, roomName: string }[] devices
function Expressions.devices()
log:trace("Expressions.devices()")
local devices = {}
local allDevices = C4:GetDevices() or {}

-- Proxy id -> owning driver id, for proxies that only restate their owner.
local ownerOf = {}
for id, dev in pairs(allDevices) do
for proxyId in pairs(dev.proxies or {}) do
local pid = tonumber(proxyId) or proxyId
if isRestatedProxy(allDevices[pid], dev) then
ownerOf[pid] = id
end
end
end

-- Collapse chains so a proxy of a proxy still lands in an entry.
local rootOf, membersOf = {}, {}
for id in pairs(allDevices) do
local root, seen = id, { [id] = true }
while ownerOf[root] ~= nil and not seen[ownerOf[root]] do
root = ownerOf[root]
seen[root] = true
end
rootOf[id] = root
membersOf[root] = membersOf[root] or {}
table.insert(membersOf[root], id)
end

for id, dev in pairs(allDevices) do
if rootOf[id] == id then
local hasVariables = false
for _, mid in ipairs(membersOf[id]) do
local ok, memberVars = pcall(C4.GetDeviceVariables, C4, mid)
if ok and memberVars ~= nil and next(memberVars) ~= nil then
hasVariables = true
break
end
end
if hasVariables then
devices[#devices + 1] = {
id = id,
name = dev.deviceName or ("Device " .. id),
roomName = dev.roomName or "",
}
end
end
end
local labelCounts = {}
for _, d in ipairs(devices) do
local label = d.roomName .. " > " .. d.name
labelCounts[label] = (labelCounts[label] or 0) + 1
end
for _, d in ipairs(devices) do
if labelCounts[d.roomName .. " > " .. d.name] > 1 then
d.name = string.format("%s (%s)", d.name, d.id)
end
end
table.sort(devices, function(a, b)
if a.roomName ~= b.roomName then
return a.roomName < b.roomName
end
return a.name < b.name
end)
return devices
end

--- A device's variables, for a page to pick from: its own first, then those of the
--- proxies that restate it.
--- @param deviceId integer|string
--- @return { id: integer, deviceId: integer, section: string, order: integer, name: string, type: string, value: any }[]|nil variables Nil when the id isn't a number.
function Expressions.deviceVariables(deviceId)
log:trace("Expressions.deviceVariables(%s)", deviceId)
local devId = tonumber(deviceId)
if not devId then
return nil
end

local entry = C4:GetDevices({ DeviceIds = tostring(devId) })[devId] or {}
local members = { { id = devId, section = proxySectionLabel(entry, true), order = 1 } }
local queue, seen = { { id = devId, dev = entry } }, { [devId] = true }
while #queue > 0 do
local cur = table.remove(queue, 1)
-- pairs() over proxies has no defined order, and sections shouldn't change between calls.
local childIds = {}
for proxyId in pairs((cur.dev or {}).proxies or {}) do
childIds[#childIds + 1] = tonumber(proxyId) or proxyId
end
table.sort(childIds)
for _, pid in ipairs(childIds) do
local proxy = C4:GetDevices({ DeviceIds = tostring(pid) })[pid]
if not seen[pid] and isRestatedProxy(proxy, cur.dev) then
seen[pid] = true
members[#members + 1] = { id = pid, section = proxySectionLabel(proxy, false), order = #members + 1 }
queue[#queue + 1] = { id = pid, dev = proxy }
end
end
end

local vars = {}
for _, member in ipairs(members) do
local ok, deviceVars = pcall(C4.GetDeviceVariables, C4, member.id)
if ok and deviceVars then
for varId, varInfo in pairs(deviceVars) do
vars[#vars + 1] = {
id = tonumber(varId),
deviceId = member.id,
section = member.section,
order = member.order,
name = varInfo.name or ("var" .. varId),
type = varInfo.type or "STRING",
value = varInfo.value,
}
end
end
end
-- Order on the member's position, not its label: a proxy's .c4i can be named Device,
-- and LuaJIT's table.sort doesn't reject the cycle that tie makes.
table.sort(vars, function(a, b)
if a.order ~= b.order then
return a.order < b.order
end
return a.name < b.name
end)
return vars
--- Why a template doesn't compile. Each token stands in as a name, so its value, which changes, plays no part: a
--- template that fails here fails whatever its variables hold. It is compiled, never run.
--- @param template string
--- @return string|nil err The compiler's message, worded as `evaluate` words it, or nil when it compiles.
function Expressions.compileError(template)
-- Each token as evaluate puts it, so a text evaluate can't compile can't compile here either.
local source = tostring(template or ""):gsub(PARAM_PATTERN, "__p[1]")
local _, err = loadstring("return " .. source)
return err and ("compile error: " .. err) or nil
end

---------------------------------------------------------------------------
Expand Down
Loading
Loading