Создаёт отдельный MQTT-девайс (свой external_id) — так же, как завели бы новое устройство на платформе, а не довесок к существующему.
+
Создаёт отдельный MQTT-девайс (свой external_id) — так же, как завели бы новое устройство на платформе, а не довесок к существующему. Список пинов сужается под выбранный тип сигнала и не предлагает уже занятые.
`;
}
@@ -379,6 +413,36 @@ function onNewDeviceKindChange() {
const kind = document.getElementById('new-device-kind').value;
document.getElementById('new-device-sensor-fields').style.display = kind === 'sensor' ? 'block' : 'none';
document.getElementById('new-device-actuator-fields').style.display = kind === 'actuator' ? 'block' : 'none';
+ refreshPinOptions();
+}
+
+// Pins already claimed by any current device — the four core devices don't
+// use PIN_CATALOG entries at all (their GPIO4/16/17/18 are fixed and never
+// offered here), so only extras' .pin values need excluding.
+function usedPins() {
+ return new Set(Object.values(latest).map((d) => d.pin).filter(Boolean));
+}
+
+function compatiblePins(kind, discrete) {
+ const used = usedPins();
+ return PIN_CATALOG.filter((p) => {
+ if (used.has(p.pin)) return false;
+ if (kind === 'actuator') return p.digitalOut;
+ return discrete ? p.digitalIn : p.analogIn;
+ });
+}
+
+function refreshPinOptions() {
+ const select = document.getElementById('new-device-pin');
+ if (!select) return; // add-device card not mounted yet
+ const kind = document.getElementById('new-device-kind').value;
+ const discrete = document.getElementById('new-device-discrete').checked;
+ const compatible = compatiblePins(kind, discrete);
+
+ const prev = select.value;
+ select.innerHTML = '' +
+ compatible.map((p) => ``).join('');
+ if (compatible.some((p) => p.pin === prev)) select.value = prev;
}
async function addDevice() {
@@ -519,7 +583,10 @@ function render(snapshot) {
document.getElementById('bulb-body').setAttribute('fill', latest.light?.power ? '#e8b339' : '#233348');
document.getElementById('pump-ring').classList.toggle('on', !!latest.pump?.power);
- renderExtensionDevices(snapshot.devices.filter((d) => !d.core));
+ const extras = snapshot.devices.filter((d) => !d.core);
+ renderPinnedDevices(extras.filter((d) => d.pin && pinEntry(d.pin)));
+ renderExtensionDevices(extras.filter((d) => !d.pin || !pinEntry(d.pin)));
+ refreshPinOptions();
}
function insertCard(d) {
@@ -539,6 +606,29 @@ function flashCard(slot) {
// Draws every non-core device as its own icon hanging off the "EXT" bus
// below the fixed board — visually distinct (dashed wire) from the four
// GPIO-wired peripherals, since these aren't on a real fixed pin.
+// Shared icon+label markup for one extra device at a given (x,y) — used by
+// both the pinned layout (real GPIO position) and the unpinned fallback
+// (generic EXT bus).
+function deviceIconSVG(d, x, y, sub) {
+ const title = escapeHTML(deviceTitle(d));
+ let svg = '';
+ if (d.kind === 'sensor') {
+ const name = Object.keys(d.readings || {})[0];
+ const value = name !== undefined ? d.readings[name] : 0;
+ svg += ``;
+ svg += `${escapeHTML(value)}`;
+ } else {
+ const on = !!d.power;
+ svg += ``;
+ svg += `${on ? 'ON' : 'OFF'}`;
+ }
+ svg += `${title}`;
+ svg += `${escapeHTML(sub)}`;
+ return svg;
+}
+
+// Devices with no pin assigned hang off a generic dashed "EXT" bus below
+// the board — there's no real position to draw them at.
function renderExtensionDevices(extras) {
const g = document.getElementById('extension-devices');
const trunk = document.getElementById('ext-trunk');
@@ -561,26 +651,33 @@ function renderExtensionDevices(extras) {
const x = startX + spacing * i;
const y = busY + 55;
svg += ``;
- if (d.pin) {
- svg += `${escapeHTML(d.pin)}`;
- }
+ const sub = `${escapeHTML(d.external_id)} · ${d.kind === 'sensor' ? 'input' : 'output'}`;
+ svg += deviceIconSVG(d, x, y, sub);
+ });
- const title = escapeHTML(deviceTitle(d));
- const pinPrefix = d.pin ? `${escapeHTML(d.pin)} · ` : '';
- const sub = `${pinPrefix}${escapeHTML(d.external_id)} · ${d.kind === 'sensor' ? 'input' : 'output'}`;
+ g.innerHTML = svg;
+}
- if (d.kind === 'sensor') {
- const name = Object.keys(d.readings || {})[0];
- const value = name !== undefined ? d.readings[name] : 0;
- svg += ``;
- svg += `${escapeHTML(value)}`;
- } else {
- const on = !!d.power;
- svg += ``;
- svg += `${on ? 'ON' : 'OFF'}`;
- }
- svg += `${title}`;
- svg += `${sub}`;
+// Devices with a real assigned pin get a solid wire straight out from that
+// pin's actual position on the board — left-side pins grow an icon to the
+// left of the board, right-side pins to the right, both at the pin's own
+// height, so the wire reads as "this exact pin goes to this device".
+function renderPinnedDevices(pinned) {
+ const g = document.getElementById('pinned-devices');
+ let svg = '';
+
+ pinned.forEach((d) => {
+ const entry = pinEntry(d.pin);
+ if (!entry) return; // pin no longer in the catalog (shouldn't happen) — skip rather than crash
+ const onLeft = entry.x === 255;
+ const iconX = onLeft ? -40 : 730;
+ const y = entry.y;
+ const wireColor = d.kind === 'sensor' ? 'var(--wire-sensor)' : 'var(--wire-fan)';
+
+ svg += ``;
+ svg += `${escapeHTML(entry.pin)}`;
+ const sub = `${escapeHTML(entry.pin)} · ${d.kind === 'sensor' ? 'input' : 'output'}`;
+ svg += deviceIconSVG(d, iconX, y, sub);
});
g.innerHTML = svg;
@@ -615,6 +712,7 @@ function connectEvents() {
}
document.getElementById('cards').innerHTML = addDeviceCardHTML();
+refreshPinOptions();
connectEvents();