velbus-frontend 2026.6.99__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: velbus-frontend
3
+ Version: 2026.6.99
4
+ Summary: Home Assistant config panel frontend for Velbus.
5
+ Author-email: Maikel Punie <maikel.punie@gmail.com>
6
+ License: MIT
7
+ Project-URL: Source Code, https://github.com/Cereal2nd/velbus-frontend
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Home Automation
13
+ Requires-Python: >=3.13
14
+ Description-Content-Type: text/markdown
15
+
16
+ # Velbus config panel frontend
17
+
18
+ Static web component assets shipped with the `velbus-frontend` PyPI package for the Home Assistant Velbus integration config panel.
@@ -0,0 +1,3 @@
1
+ # Velbus config panel frontend
2
+
3
+ Static web component assets shipped with the `velbus-frontend` PyPI package for the Home Assistant Velbus integration config panel.
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["setuptools", "wheel"]
3
+
4
+ [project]
5
+ name = "velbus-frontend"
6
+ license = {text = "MIT"}
7
+ version = "2026.6.99"
8
+ description = "Home Assistant config panel frontend for Velbus."
9
+ readme = "README.md"
10
+ authors = [
11
+ {name = "Maikel Punie", email = "maikel.punie@gmail.com"}
12
+ ]
13
+ requires-python = ">=3.13"
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Home Automation",
20
+ ]
21
+
22
+ [project.urls]
23
+ "Source Code" = "https://github.com/Cereal2nd/velbus-frontend"
24
+
25
+ [tool.setuptools]
26
+ include-package-data = true
27
+
28
+ [tool.setuptools.packages.find]
29
+ include = ["velbus_frontend*"]
30
+
31
+ [tool.setuptools.package-data]
32
+ velbus_frontend = ["dist/**/*"]
33
+
34
+ [tool.bumpver]
35
+ current_version = "2026.7.1"
36
+ version_pattern = "YYYY.MM.INC0"
37
+ commit_message = "bump version {old_version} -> {new_version}"
38
+ commit = true
39
+ tag = true
40
+ push = true
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,20 @@
1
+ """Velbus config panel frontend package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.resources
6
+ from importlib.metadata import PackageNotFoundError, version
7
+
8
+ webcomponent_name = "velbus-panel"
9
+ entrypoint_js = "velbus-panel.js"
10
+ is_prod_build = False
11
+
12
+ try:
13
+ __version__ = version("velbus-frontend")
14
+ except PackageNotFoundError:
15
+ __version__ = "dev"
16
+
17
+
18
+ def locate_dir() -> str:
19
+ """Return the directory containing built frontend assets."""
20
+ return str(importlib.resources.files("velbus_frontend") / "dist")
@@ -0,0 +1,83 @@
1
+ export function createApi(hass, configEntryId) {
2
+ return async function callWs(type, extra = {}) {
3
+ return hass.callWS({
4
+ type,
5
+ config_entry: configEntryId,
6
+ ...extra,
7
+ });
8
+ };
9
+ }
10
+
11
+ export async function loadModules(callWs) {
12
+ const result = await callWs("velbus/config_panel/modules", {});
13
+ return result.modules || [];
14
+ }
15
+
16
+ export async function loadBaseData(callWs) {
17
+ return callWs("velbus/config_panel/get_base_data", {});
18
+ }
19
+
20
+ export async function loadModule(callWs, address) {
21
+ return callWs("velbus/config_panel/module/get", { address });
22
+ }
23
+
24
+ export async function loadActions(callWs, address, channel) {
25
+ const result = await callWs("velbus/config_panel/module/actions/get", {
26
+ address,
27
+ channel,
28
+ refresh: true,
29
+ });
30
+ return result.slots || [];
31
+ }
32
+
33
+ export async function saveChannelName(callWs, address, channel, value) {
34
+ return callWs("velbus/config_panel/module/config/set", {
35
+ address,
36
+ channel,
37
+ key: "name",
38
+ value,
39
+ });
40
+ }
41
+
42
+ export async function saveChannelEnabled(callWs, address, channel, enabled) {
43
+ return callWs("velbus/config_panel/module/config/set", {
44
+ address,
45
+ channel,
46
+ key: "enabled",
47
+ value: enabled,
48
+ });
49
+ }
50
+
51
+ export async function saveChannelContact(callWs, address, channel, value) {
52
+ return callWs("velbus/config_panel/module/config/set", {
53
+ address,
54
+ channel,
55
+ key: "contact",
56
+ value,
57
+ });
58
+ }
59
+
60
+ export async function programAction(
61
+ callWs,
62
+ address,
63
+ channel,
64
+ sourceAddress,
65
+ sourceChannel,
66
+ action
67
+ ) {
68
+ return callWs("velbus/config_panel/module/actions/set", {
69
+ address,
70
+ channel,
71
+ source_address: sourceAddress,
72
+ source_channel: sourceChannel,
73
+ action,
74
+ });
75
+ }
76
+
77
+ export async function clearActionSlot(callWs, address, channel, slot) {
78
+ return callWs("velbus/config_panel/module/actions/clear", {
79
+ address,
80
+ channel,
81
+ slot,
82
+ });
83
+ }
@@ -0,0 +1,59 @@
1
+ export function getModule(modules, address) {
2
+ return modules.find((module) => module.address === address);
3
+ }
4
+
5
+ export function formatSource(slot) {
6
+ if (slot.source_module_name) {
7
+ const channelLabel =
8
+ slot.source_channel_name ||
9
+ (slot.source_channel != null ? `Channel ${slot.source_channel}` : "?");
10
+ return `${slot.source_module_name} / ${channelLabel}`;
11
+ }
12
+ return `${slot.source_address}:${slot.source_channel ?? "?"}`;
13
+ }
14
+
15
+ export function sourceChannelOptions(modules, moduleAddress) {
16
+ const module = getModule(modules, moduleAddress);
17
+ if (!module?.channels) {
18
+ return "";
19
+ }
20
+ return Object.entries(module.channels)
21
+ .sort(([left], [right]) => Number(left) - Number(right))
22
+ .map(([channel, info]) => {
23
+ const label = info.name || `Channel ${channel}`;
24
+ return `<option value="${channel}">${label}</option>`;
25
+ })
26
+ .join("");
27
+ }
28
+
29
+ export function channelLabel(channel, sections, liveChannels) {
30
+ const channelMeta = (
31
+ sections.find((section) => section.type === "channels")?.channels || []
32
+ ).find((entry) => entry.channel === channel);
33
+ const live = liveChannels[String(channel)] || {};
34
+ return live.name || channelMeta?.name || `Channel ${channel}`;
35
+ }
36
+
37
+ export function findActionTable(sections) {
38
+ return sections.find((section) => section.type === "action_table");
39
+ }
40
+
41
+ export function findChannelNames(sections) {
42
+ return sections.find((section) => section.type === "channel_names");
43
+ }
44
+
45
+ export function findChannelEnable(sections) {
46
+ return sections.find((section) => section.type === "channel_enable");
47
+ }
48
+
49
+ export function findContact(sections) {
50
+ return sections.find((section) => section.type === "contact");
51
+ }
52
+
53
+ export function isProgrammedSlot(slot) {
54
+ if (slot.empty) {
55
+ return false;
56
+ }
57
+ const source = slot.source_address;
58
+ return source != null && source !== 0 && source !== 255;
59
+ }
@@ -0,0 +1,394 @@
1
+ import {
2
+ channelLabel,
3
+ findActionTable,
4
+ findChannelEnable,
5
+ findChannelNames,
6
+ findContact,
7
+ formatSource,
8
+ isProgrammedSlot,
9
+ sourceChannelOptions,
10
+ } from "./base.js";
11
+
12
+ function canEdit(advancedMode, interactionsDisabled) {
13
+ return advancedMode && !interactionsDisabled;
14
+ }
15
+
16
+ function renderAddActionDialog(ctx) {
17
+ const {
18
+ showAddActionDialog,
19
+ actionTable,
20
+ modules,
21
+ advancedMode,
22
+ interactionsDisabled,
23
+ sourceModuleAddress,
24
+ } = ctx;
25
+ if (!showAddActionDialog || !actionTable) {
26
+ return "";
27
+ }
28
+ const editable = canEdit(advancedMode, interactionsDisabled);
29
+ const sourceAddress =
30
+ sourceModuleAddress ?? (modules.length ? modules[0].address : null);
31
+ return `
32
+ <div class="dialog-backdrop" id="add-action-dialog">
33
+ <div class="dialog card">
34
+ <h3>Add ${
35
+ actionTable.kind === "input" ? "input action" : "action"
36
+ }</h3>
37
+ <p class="muted">Program a new action for the selected channel.</p>
38
+ <label><span>Source module</span>
39
+ <select id="source-module" ${editable ? "" : "disabled"}>
40
+ ${modules
41
+ .map(
42
+ (module) =>
43
+ `<option value="${module.address}" ${
44
+ module.address === sourceAddress ? "selected" : ""
45
+ }>${module.name} (${module.address})</option>`
46
+ )
47
+ .join("")}
48
+ </select>
49
+ </label>
50
+ <label><span>Source channel</span>
51
+ <select id="source-channel" ${editable ? "" : "disabled"}>
52
+ ${sourceChannelOptions(modules, sourceAddress)}
53
+ </select>
54
+ </label>
55
+ <label><span>Action</span>
56
+ <select id="action-key" ${editable ? "" : "disabled"}>
57
+ ${actionTable.actions
58
+ .map(
59
+ (action) =>
60
+ `<option value="${action.key}">${action.label}</option>`
61
+ )
62
+ .join("")}
63
+ </select>
64
+ </label>
65
+ <div class="dialog-actions">
66
+ <button class="secondary" id="cancel-add-action">Cancel</button>
67
+ <button id="confirm-add-action" ${
68
+ editable ? "" : "disabled"
69
+ }>Program action</button>
70
+ </div>
71
+ </div>
72
+ </div>`;
73
+ }
74
+
75
+ export function render(ctx) {
76
+ const {
77
+ moduleData,
78
+ modules,
79
+ actionChannel,
80
+ actionSlots,
81
+ loadingActions,
82
+ interactionsDisabled,
83
+ advancedMode,
84
+ showAddActionDialog,
85
+ sourceModuleAddress,
86
+ } = ctx;
87
+
88
+ if (!moduleData) {
89
+ return "";
90
+ }
91
+
92
+ const editable = canEdit(advancedMode, interactionsDisabled);
93
+
94
+ const schema = moduleData.schema || { sections: [] };
95
+ const sections = schema.sections || [];
96
+ const channelNames = findChannelNames(sections);
97
+ const actionTable = findActionTable(sections);
98
+ const channelEnable = findChannelEnable(sections);
99
+ const contact = findContact(sections);
100
+ const channels = moduleData.channels || {};
101
+ const actions = (actionSlots || []).filter(isProgrammedSlot);
102
+ const selectedChannelLabel = channelLabel(actionChannel, sections, channels);
103
+ const selectedSupportsEnable =
104
+ channelEnable?.channels?.includes(actionChannel) ||
105
+ (sections.find((section) => section.type === "channels")?.channels || []).some(
106
+ (entry) => entry.channel === actionChannel && entry.supports_enable
107
+ );
108
+ const selectedEnabled = channels[String(actionChannel)]?.enabled !== false;
109
+ const metaParts = [
110
+ `Address ${moduleData.address}`,
111
+ moduleData.type_name,
112
+ moduleData.sw_version ? `Firmware ${moduleData.sw_version}` : null,
113
+ moduleData.serial ? `Serial ${moduleData.serial}` : null,
114
+ ].filter(Boolean);
115
+
116
+ const dialogCtx = {
117
+ showAddActionDialog,
118
+ actionTable,
119
+ modules,
120
+ advancedMode,
121
+ interactionsDisabled,
122
+ sourceModuleAddress,
123
+ };
124
+
125
+ return `
126
+ <section class="card header">
127
+ <div class="header-row">
128
+ <button class="link back" id="back-button">← Modules</button>
129
+ ${
130
+ actionTable
131
+ ? `<button id="add-action" class="primary" ${
132
+ editable ? "" : "disabled"
133
+ }>Add action</button>`
134
+ : ""
135
+ }
136
+ </div>
137
+ <h2>${moduleData.name}</h2>
138
+ <p class="muted">${metaParts.join(" · ")}</p>
139
+ ${
140
+ !advancedMode
141
+ ? `<p class="warning">Advanced mode is disabled. Enable it in the Velbus integration configuration to program module memory.</p>`
142
+ : ""
143
+ }
144
+ </section>
145
+ ${
146
+ actionTable
147
+ ? `<div class="module-layout">
148
+ <section class="card channel-panel">
149
+ <h3>Channels</h3>
150
+ <ul class="channel-list">
151
+ ${actionTable.channels
152
+ .map((channel) => {
153
+ const label = channelLabel(channel, sections, channels);
154
+ const isActive = channel === actionChannel;
155
+ const live = channels[String(channel)] || {};
156
+ const disabled = live.enabled === false;
157
+ return `<li>
158
+ <button
159
+ type="button"
160
+ class="channel-item${isActive ? " active" : ""}${
161
+ disabled ? " disabled-channel" : ""
162
+ }"
163
+ data-action-channel="${channel}"
164
+ ${interactionsDisabled ? "disabled" : ""}
165
+ >
166
+ <span class="channel-name">${label}</span>
167
+ ${
168
+ disabled
169
+ ? `<span class="channel-badge">Disabled</span>`
170
+ : ""
171
+ }
172
+ </button>
173
+ </li>`;
174
+ })
175
+ .join("")}
176
+ </ul>
177
+ </section>
178
+ <section class="card actions-panel">
179
+ <div class="actions-header">
180
+ <h3>${
181
+ actionTable.kind === "input" ? "Input actions" : "Actions"
182
+ } — ${selectedChannelLabel}</h3>
183
+ ${
184
+ channelNames?.channels?.some(
185
+ (entry) => entry.channel === actionChannel
186
+ )
187
+ ? `<label class="channel-rename">
188
+ <span>Channel name</span>
189
+ <input
190
+ type="text"
191
+ maxlength="16"
192
+ data-channel-name="${actionChannel}"
193
+ value="${channels[String(actionChannel)]?.name || ""}"
194
+ ${editable ? "" : "disabled"}
195
+ />
196
+ </label>`
197
+ : ""
198
+ }
199
+ ${
200
+ contact?.channels?.includes(actionChannel)
201
+ ? `<label class="channel-contact">
202
+ <span>Contact</span>
203
+ <select
204
+ data-channel-contact="${actionChannel}"
205
+ ${editable ? "" : "disabled"}
206
+ >
207
+ ${(contact.options || ["NO", "NC"])
208
+ .map((option) => {
209
+ const current =
210
+ channels[String(actionChannel)]?.contact || "NO";
211
+ return `<option value="${option}" ${
212
+ option === current ? "selected" : ""
213
+ }>${option}</option>`;
214
+ })
215
+ .join("")}
216
+ </select>
217
+ </label>`
218
+ : ""
219
+ }
220
+ ${
221
+ selectedSupportsEnable
222
+ ? `<label class="channel-enable">
223
+ <input
224
+ type="checkbox"
225
+ data-channel-enable="${actionChannel}"
226
+ ${selectedEnabled ? "checked" : ""}
227
+ ${editable ? "" : "disabled"}
228
+ />
229
+ <span>Channel enabled</span>
230
+ </label>`
231
+ : ""
232
+ }
233
+ </div>
234
+ ${loadingActions ? "<p>Loading actions…</p>" : ""}
235
+ <table>
236
+ <thead>
237
+ <tr><th>Slot</th><th>Source</th><th>Action</th><th></th></tr>
238
+ </thead>
239
+ <tbody>
240
+ ${
241
+ actions.length
242
+ ? actions
243
+ .map(
244
+ (slot) => `<tr>
245
+ <td>${slot.slot}</td>
246
+ <td title="${slot.source_address}:${
247
+ slot.source_channel ?? "?"
248
+ }">${formatSource(slot)}</td>
249
+ <td>${slot.action_label || slot.action_key || ""}</td>
250
+ <td>${
251
+ editable
252
+ ? `<button class="link" data-clear-slot="${slot.slot}" ${
253
+ interactionsDisabled ? "disabled" : ""
254
+ }>Clear</button>`
255
+ : ""
256
+ }</td>
257
+ </tr>`
258
+ )
259
+ .join("")
260
+ : `<tr><td colspan="4">${
261
+ loadingActions
262
+ ? ""
263
+ : "No programmed actions for this channel."
264
+ }</td></tr>`
265
+ }
266
+ </tbody>
267
+ </table>
268
+ </section>
269
+ </div>
270
+ ${renderAddActionDialog(dialogCtx)}`
271
+ : `${
272
+ channelNames
273
+ ? `<section class="card">
274
+ <h3>Channel names</h3>
275
+ ${channelNames.channels
276
+ .map((channel) => {
277
+ const live = channels[String(channel.channel)] || {};
278
+ const value = live.name || "";
279
+ return `<label>
280
+ <span>${channel.name}</span>
281
+ <input
282
+ type="text"
283
+ maxlength="16"
284
+ data-channel-name="${channel.channel}"
285
+ value="${value}"
286
+ ${editable ? "" : "disabled"}
287
+ />
288
+ </label>`;
289
+ })
290
+ .join("")}
291
+ </section>`
292
+ : ""
293
+ }${
294
+ contact
295
+ ? `<section class="card">
296
+ <h3>Contact type</h3>
297
+ ${contact.channels
298
+ .map((channel) => {
299
+ const live = channels[String(channel)] || {};
300
+ const current = live.contact || "NO";
301
+ const label = channelLabel(channel, sections, channels);
302
+ return `<label>
303
+ <span>${label}</span>
304
+ <select
305
+ data-channel-contact="${channel}"
306
+ ${editable ? "" : "disabled"}
307
+ >
308
+ ${(contact.options || ["NO", "NC"])
309
+ .map(
310
+ (option) =>
311
+ `<option value="${option}" ${
312
+ option === current ? "selected" : ""
313
+ }>${option}</option>`
314
+ )
315
+ .join("")}
316
+ </select>
317
+ </label>`;
318
+ })
319
+ .join("")}
320
+ </section>`
321
+ : ""
322
+ }`
323
+ }`;
324
+ }
325
+
326
+ export function bind(root, handlers) {
327
+ root.querySelector("#back-button")?.addEventListener("click", () => {
328
+ handlers.onBack();
329
+ });
330
+
331
+ root.querySelectorAll("[data-action-channel]").forEach((element) => {
332
+ element.addEventListener("click", () => {
333
+ handlers.onSelectChannel(Number(element.dataset.actionChannel));
334
+ });
335
+ });
336
+
337
+ root.querySelector("#add-action")?.addEventListener("click", () => {
338
+ handlers.onShowAddAction();
339
+ });
340
+
341
+ root.querySelector("#cancel-add-action")?.addEventListener("click", () => {
342
+ handlers.onHideAddAction();
343
+ });
344
+
345
+ root.querySelector("#add-action-dialog")?.addEventListener("click", (event) => {
346
+ if (event.target.id === "add-action-dialog") {
347
+ handlers.onHideAddAction();
348
+ }
349
+ });
350
+
351
+ root.querySelector("#source-module")?.addEventListener("change", (event) => {
352
+ handlers.onSourceModuleChange(Number(event.target.value), root);
353
+ });
354
+
355
+ root.querySelector("#confirm-add-action")?.addEventListener("click", () => {
356
+ const sourceAddress = Number(root.querySelector("#source-module")?.value);
357
+ const sourceChannel = Number(root.querySelector("#source-channel")?.value);
358
+ const action = root.querySelector("#action-key")?.value;
359
+ handlers.onProgramAction(sourceAddress, sourceChannel, action);
360
+ });
361
+
362
+ root.querySelectorAll("[data-clear-slot]").forEach((element) => {
363
+ element.addEventListener("click", () => {
364
+ handlers.onClearSlot(Number(element.dataset.clearSlot));
365
+ });
366
+ });
367
+
368
+ root.querySelectorAll("[data-channel-name]").forEach((element) => {
369
+ element.addEventListener("change", (event) => {
370
+ handlers.onSaveChannelName(
371
+ Number(event.target.dataset.channelName),
372
+ event.target.value
373
+ );
374
+ });
375
+ });
376
+
377
+ root.querySelectorAll("[data-channel-enable]").forEach((element) => {
378
+ element.addEventListener("change", (event) => {
379
+ handlers.onSaveChannelEnabled(
380
+ Number(event.target.dataset.channelEnable),
381
+ event.target.checked
382
+ );
383
+ });
384
+ });
385
+
386
+ root.querySelectorAll("[data-channel-contact]").forEach((element) => {
387
+ element.addEventListener("change", (event) => {
388
+ handlers.onSaveChannelContact(
389
+ Number(event.target.dataset.channelContact),
390
+ event.target.value
391
+ );
392
+ });
393
+ });
394
+ }
@@ -0,0 +1,26 @@
1
+ /** @type {Record<number|string, string>} */
2
+ export const MODULE_TYPE_TO_PAGE = {
3
+ // Examples once specialized pages exist:
4
+ // 0x08: "relay",
5
+ // "VMB4RY": "relay",
6
+ };
7
+
8
+ export const MODULE_PAGES = {
9
+ generic: () => import("./generic.js"),
10
+ // relay: () => import("./relay.js"),
11
+ };
12
+
13
+ export function resolveModulePageType(moduleOrType) {
14
+ const typeId = moduleOrType?.type_id ?? moduleOrType;
15
+ const typeName = moduleOrType?.type_name;
16
+ return (
17
+ MODULE_TYPE_TO_PAGE[typeId] ??
18
+ MODULE_TYPE_TO_PAGE[typeName] ??
19
+ "generic"
20
+ );
21
+ }
22
+
23
+ export async function loadModulePage(pageType) {
24
+ const loader = MODULE_PAGES[pageType] ?? MODULE_PAGES.generic;
25
+ return loader();
26
+ }
@@ -0,0 +1,31 @@
1
+ export function renderModulesList(ctx) {
2
+ const { modules } = ctx;
3
+ return `
4
+ <section class="modules-section">
5
+ <h2>Modules</h2>
6
+ ${
7
+ modules.length
8
+ ? `<div class="module-grid">
9
+ ${modules
10
+ .map(
11
+ (module) => `
12
+ <button class="module-tile" type="button" data-address="${module.address}">
13
+ <span class="module-address">Address ${module.address} (0x${Number(module.address).toString(16).toUpperCase().padStart(2, "0")})</span>
14
+ <span class="module-name">${module.name}</span>
15
+ <span class="module-type muted">${module.type_name}</span>
16
+ </button>`
17
+ )
18
+ .join("")}
19
+ </div>`
20
+ : "<p>No modules found.</p>"
21
+ }
22
+ </section>`;
23
+ }
24
+
25
+ export function bindModulesList(root, handlers) {
26
+ root.querySelectorAll("[data-address]").forEach((element) => {
27
+ element.addEventListener("click", () => {
28
+ handlers.onSelect(Number(element.dataset.address));
29
+ });
30
+ });
31
+ }