Files
genieacs/ui/components/all-parameters.ts
T
Zaid Humaimidi 6f3ce6f7c8 refactor: replace array-based expressions with typed class hierarchy
Replace the old Lisp-like array expression representation
(["AND", ["PARAM", ...], ...]) with a class hierarchy rooted in
an abstract Expression class (Literal, Parameter, Binary, Unary,
FunctionCall, Conditional). This gives compile-time type safety
to expression construction, evaluation, and pattern matching.

Key changes:
- New lib/common/expression.ts defines the class hierarchy with
  map/evaluate/toString methods
- Rewrite parser as hand-rolled recursive descent, removing the
  parsimmon dependency
- Extend Path with a colon separator to unify parameter identity
  and attributes into a single address space (e.g.
  "Device.WiFi.SSID.1.Name:writable"). The colon field tracks
  how many trailing segments are attribute qualifiers. PathSet
  rewritten with separate param/attr segment indexes
- Flatten device attribute representation in the UI to
  colon-delimited keys mirroring the new path syntax
2026-03-12 14:58:20 -07:00

244 lines
6.9 KiB
TypeScript

import { ClosureComponent } from "mithril";
import { m } from "../components.ts";
import * as taskQueue from "../task-queue.ts";
import memoize from "../../lib/common/memoize.ts";
import { icon } from "../tailwind-utility-components.ts";
import { QueryResponse, evaluateExpression } from "../store.ts";
import debounce from "../../lib/common/debounce.ts";
import Expression, { Value } from "../../lib/common/expression.ts";
import { FlatDevice } from "../../lib/ui/db.ts";
import Path from "../../lib/common/path.ts";
function escapeRegExp(str): string {
return str.replace(/[-[\]/{}()*+?.\\^$|]/g, "\\$&");
}
interface Parameter {
path: Expression.Parameter;
value?: Value;
writable?: boolean;
object?: boolean;
}
const prepareParams = memoize((device: FlatDevice): Parameter[][] => {
const map = new Map<string, Parameter>();
for (const [k, v] of Object.entries(device)) {
const [param, attr] = k.split(":");
let attrs = map.get(param);
if (!attrs)
map.set(
param,
(attrs = { path: new Expression.Parameter(Path.parse(param)) }),
);
attrs[attr || "value"] = v;
}
const res: Parameter[][] = [];
for (const [key, attrs] of map) {
let count = 0;
for (
let i = key.lastIndexOf(".", key.length - 2);
i >= 0;
i = key.lastIndexOf(".", i - 1)
)
++count;
while (res.length <= count) res.push([]);
res[count].push(attrs);
}
return res;
});
interface Attrs {
device: FlatDevice;
limit: Expression;
deviceQuery: QueryResponse;
}
const component: ClosureComponent<Attrs> = () => {
let queryString: string;
const formQueryString = debounce((args: string[]) => {
queryString = args[args.length - 1];
m.redraw();
}, 500);
return {
view: (vnode) => {
const device = vnode.attrs.device;
const allParams = prepareParams(device);
let limit = 100;
if (vnode.attrs.limit) {
const l = evaluateExpression(vnode.attrs.limit, device);
if (typeof l.value === "number") limit = l.value;
}
const search = m(
"input.appearance-none border-0 block w-full px-4 py-3 border-stone-300 placeholder-stone-500 text-stone-900 focus:ring-cyan-500 text-sm rounded-t-lg font-mono focus:ring-2",
{
type: "text",
placeholder: "Search parameters",
oninput: (e) => {
formQueryString(e.target.value);
e.redraw = false;
},
},
);
const instanceRegex = /\.[0-9]+$/;
let re;
if (queryString) {
const keywords = queryString.split(" ").filter((s) => s);
if (keywords.length)
re = new RegExp(keywords.map((s) => escapeRegExp(s)).join(".*"), "i");
}
const filteredParams: Parameter[] = [];
let count = 0;
for (const keys of allParams) {
let c = 0;
for (const k of keys) {
const str = k.value ? `${k.path.toString()} ${k.value}` : k;
if (re && !re.test(str)) continue;
++c;
if (count < limit) filteredParams.push(k);
}
count += c;
}
filteredParams.sort((a, b) => {
if (a.path < b.path) return -1;
if (a.path > b.path) return 1;
return 0;
});
const rows = filteredParams.map((p) => {
const val = [];
if (p.value) {
val.push(
m(
"parameter",
Object.assign({ device: device, parameter: p.path }),
),
);
} else if (p.object && p.writable) {
if (instanceRegex.test(p.path.toString())) {
val.push(
m(
"button",
{
title: "Delete this instance",
onclick: () => {
taskQueue.queueTask({
name: "deleteObject",
device: device["DeviceID.ID"] as string,
objectName: p.path.toString(),
});
},
},
m(icon, {
name: "delete-instance",
class:
"inline h-4 w-4 ml-1 text-cyan-700 hover:text-cyan-900",
}),
),
);
} else {
val.push(
m(
"button",
{
title: "Create a new instance",
onclick: () => {
taskQueue.queueTask({
name: "addObject",
device: device["DeviceID.ID"] as string,
objectName: p.path.toString(),
});
},
},
m(icon, {
name: "add-instance",
class:
"inline h-4 w-4 ml-1 text-cyan-700 hover:text-cyan-900",
}),
),
);
}
}
val.push(
m(
"button",
{
title: "Refresh tree",
onclick: () => {
taskQueue.queueTask({
name: "getParameterValues",
device: device["DeviceID.ID"] as string,
parameterNames: [p.path.toString()],
});
},
},
m(icon, {
name: "refresh",
class: "inline h-4 w-4 ml-1 text-cyan-700 hover:text-cyan-900",
}),
),
);
return m(
"tr",
m(
"td.pl-4 pr-2 py-2 truncate",
m("long-text", { text: p.path.toString() }),
),
m("td.pr-4 py-2 text-right flex justify-end", val),
);
});
return m(
"loading",
{ queries: [vnode.attrs.deviceQuery] },
m(
".bg-white shadow-sm rounded-lg",
search,
m(
".overflow-hidden",
m(
".overflow-y-scroll h-96 shadow-inner",
m(
"table.w-full table-fixed font-mono text-xs text-stone-900",
m("tbody.divide-y divide-stone-200", rows),
),
),
m(
"div.text-stone-700 px-4 py-3 flex justify-between items-end",
m(
"span.text-xs",
"Displaying ",
m("span.font-medium", "" + filteredParams.length),
" out of ",
m("span.font-medium", "" + count),
" parameters",
),
m(
"a.text-cyan-700 hover:text-cyan-900 text-sm font-medium",
{
href: `api/devices/${encodeURIComponent(
device["DeviceID.ID"],
)}.csv`,
download: "",
},
"Download",
),
),
),
),
);
},
};
};
export default component;