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.
- velbus_frontend-2026.6.99/PKG-INFO +18 -0
- velbus_frontend-2026.6.99/README.md +3 -0
- velbus_frontend-2026.6.99/pyproject.toml +40 -0
- velbus_frontend-2026.6.99/setup.cfg +4 -0
- velbus_frontend-2026.6.99/velbus_frontend/__init__.py +20 -0
- velbus_frontend-2026.6.99/velbus_frontend/dist/api.js +83 -0
- velbus_frontend-2026.6.99/velbus_frontend/dist/module-pages/base.js +59 -0
- velbus_frontend-2026.6.99/velbus_frontend/dist/module-pages/generic.js +394 -0
- velbus_frontend-2026.6.99/velbus_frontend/dist/module-pages/registry.js +26 -0
- velbus_frontend-2026.6.99/velbus_frontend/dist/pages/modules-list.js +31 -0
- velbus_frontend-2026.6.99/velbus_frontend/dist/styles.js +195 -0
- velbus_frontend-2026.6.99/velbus_frontend/dist/velbus-panel.js +532 -0
- velbus_frontend-2026.6.99/velbus_frontend.egg-info/PKG-INFO +18 -0
- velbus_frontend-2026.6.99/velbus_frontend.egg-info/SOURCES.txt +14 -0
- velbus_frontend-2026.6.99/velbus_frontend.egg-info/dependency_links.txt +1 -0
- velbus_frontend-2026.6.99/velbus_frontend.egg-info/top_level.txt +1 -0
|
@@ -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,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,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
|
+
}
|