Выбор пина — выпадающий список, провод рисуется от реальной позиции

PIN_CATALOG: 16 доп. GPIO с возможностями (аналоговый вход/цифровой
вход/цифровой выход) и настоящими координатами на плате — те же,
что заняты sensor-1/fan-1/light-1/pump-1, исключены. Список в форме
фильтруется под тип добавляемого устройства (вход/выход, аналоговый/
дискретный) и не предлагает уже занятые пины.

Устройство с выбранным пином рисуется проводом прямо от этого пина
на схеме (левый/правый край платы, высота = позиция пина), а не от
общей пунктирной шины EXT — та осталась только как запасной вариант
для устройств без указанного пина.
This commit is contained in:
2026-08-18 03:06:41 +05:00
parent 5674917f95
commit 0b1c894190
+125 -27
View File
@@ -152,7 +152,7 @@
<div id="conn" class="conn offline"><span class="dot"></span><span id="conn-label">подключение…</span></div>
<div class="board-wrap">
<svg class="board" viewBox="0 0 700 460" xmlns="http://www.w3.org/2000/svg">
<svg class="board" viewBox="-120 0 940 480" xmlns="http://www.w3.org/2000/svg">
<!-- ESP32 devkit board -->
<g transform="translate(255,20)">
<rect x="0" y="0" width="190" height="300" rx="8" fill="#1a4d2e" stroke="#0d2e1b" stroke-width="2"/>
@@ -175,14 +175,18 @@
<!-- expansion header (I2C/GPIO for extra devices, not one fixed pin) -->
<g id="pin-ext"><circle class="pin" cx="95" cy="300" r="3.5"/></g>
<!-- decorative header pins -->
<g fill="#8a8a8a">
<!-- assignable extra pins (see PIN_CATALOG in <script>) -->
<g class="pin" fill="#8a8a8a" id="assignable-pins">
<circle cx="0" cy="20" r="2.5"/><circle cx="0" cy="45" r="2.5"/><circle cx="0" cy="70" r="2.5"/>
<circle cx="0" cy="95" r="2.5"/><circle cx="0" cy="120" r="2.5"/><circle cx="0" cy="175" r="2.5"/>
<circle cx="0" cy="200" r="2.5"/><circle cx="0" cy="225" r="2.5"/><circle cx="0" cy="250" r="2.5"/>
<circle cx="190" cy="20" r="2.5"/><circle cx="190" cy="45" r="2.5"/><circle cx="190" cy="70" r="2.5"/>
<circle cx="190" cy="95" r="2.5"/><circle cx="190" cy="150" r="2.5"/><circle cx="190" cy="190" r="2.5"/>
<circle cx="190" cy="230" r="2.5"/><circle cx="190" cy="250" r="2.5"/><circle cx="190" cy="270" r="2.5"/>
<circle cx="190" cy="230" r="2.5"/>
</g>
<!-- purely decorative, not in PIN_CATALOG -->
<g fill="#8a8a8a">
<circle cx="190" cy="250" r="2.5"/><circle cx="190" cy="270" r="2.5"/>
</g>
<text class="pin-label" x="8" y="153">GPIO4</text>
<text class="pin-label" x="150" y="127">GPIO16</text>
@@ -190,6 +194,8 @@
<text class="pin-label" x="150" y="207">GPIO18</text>
<text class="pin-label" x="60" y="298" text-anchor="end">EXT</text>
</g>
<!-- labels + wires for extra devices pinned to a real GPIO — filled in by renderPinnedDevices() -->
<g id="pinned-devices"></g>
<!-- sensor (left) -->
<path class="wire" d="M 255,170 C 180,170 140,60 90,60" stroke="var(--wire-sensor)"/>
@@ -253,6 +259,34 @@ const SIGNAL_PRESETS = {
const CORE_LABELS = { sensor: 'Датчик', fan: 'Вентилятор', light: 'Свет', pump: 'Помпа' };
// Assignable GPIO pins for extra devices — deliberately not the full ESP32
// pinout, just enough to be realistic: which pins can only be read
// (34/35/36/39 are input-only on a real ESP32, no output driver), which
// support analog (ADC) input, and which are plain digital GPIO. Absolute
// board coordinates = the ESP32 rect's own translate(255,20) + local x/y,
// matching the four core pins' wire-start points (255 for the left edge,
// 445 for the right edge).
const PIN_CATALOG = [
{ pin: 'GPIO32', x: 255, y: 40, analogIn: true, digitalIn: true, digitalOut: true },
{ pin: 'GPIO33', x: 255, y: 65, analogIn: true, digitalIn: true, digitalOut: true },
{ pin: 'GPIO25', x: 255, y: 90, analogIn: true, digitalIn: true, digitalOut: true },
{ pin: 'GPIO26', x: 255, y: 115, analogIn: true, digitalIn: true, digitalOut: true },
{ pin: 'GPIO27', x: 255, y: 140, analogIn: true, digitalIn: true, digitalOut: true },
{ pin: 'GPIO13', x: 255, y: 195, analogIn: true, digitalIn: true, digitalOut: true },
{ pin: 'GPIO14', x: 255, y: 220, analogIn: true, digitalIn: true, digitalOut: true },
{ pin: 'GPIO5', x: 255, y: 245, analogIn: false, digitalIn: true, digitalOut: true },
{ pin: 'GPIO19', x: 255, y: 270, analogIn: false, digitalIn: true, digitalOut: true },
{ pin: 'GPIO21', x: 445, y: 40, analogIn: false, digitalIn: true, digitalOut: true },
{ pin: 'GPIO22', x: 445, y: 65, analogIn: false, digitalIn: true, digitalOut: true },
{ pin: 'GPIO23', x: 445, y: 90, analogIn: false, digitalIn: true, digitalOut: true },
{ pin: 'GPIO34', x: 445, y: 115, analogIn: true, digitalIn: true, digitalOut: false },
{ pin: 'GPIO35', x: 445, y: 170, analogIn: true, digitalIn: true, digitalOut: false },
{ pin: 'GPIO36', x: 445, y: 210, analogIn: true, digitalIn: true, digitalOut: false },
{ pin: 'GPIO39', x: 445, y: 250, analogIn: true, digitalIn: true, digitalOut: false },
];
function pinEntry(name) { return PIN_CATALOG.find((p) => p.pin === name); }
let latest = {}; // slot -> last known Device
let cardSlots = new Set(); // slots that currently have a rendered card
@@ -347,7 +381,7 @@ function addDeviceCardHTML() {
<label>Название сигнала</label>
<input type="text" id="new-device-signal-name" placeholder="напр. door_open">
<div class="checkbox-row">
<input type="checkbox" id="new-device-discrete">
<input type="checkbox" id="new-device-discrete" onchange="refreshPinOptions()">
<label style="margin:0" for="new-device-discrete">Дискретный (вкл/выкл)</label>
</div>
</div>
@@ -368,10 +402,10 @@ function addDeviceCardHTML() {
<input type="text" id="new-device-zone-id" value="1">
</div>
</div>
<label>Пин (необязательно)</label>
<input type="text" id="new-device-pin" placeholder="напр. GPIO25">
<label>Пин</label>
<select id="new-device-pin"></select>
<button onclick="addDevice()">Создать устройство</button>
<p class="hint">Создаёт отдельный MQTT-девайс (свой external_id) — так же, как завели бы новое устройство на платформе, а не довесок к существующему.</p>
<p class="hint">Создаёт отдельный MQTT-девайс (свой external_id) — так же, как завели бы новое устройство на платформе, а не довесок к существующему. Список пинов сужается под выбранный тип сигнала и не предлагает уже занятые.</p>
</div>`;
}
@@ -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 = '<option value="">— без пина (EXT) —</option>' +
compatible.map((p) => `<option value="${p.pin}">${p.pin}</option>`).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 += `<circle cx="${x}" cy="${y}" r="24" fill="#233348" stroke="var(--wire-sensor)" stroke-width="2"/>`;
svg += `<text x="${x}" y="${y + 5}" text-anchor="middle" font-size="13" fill="#e6edf5" font-weight="700">${escapeHTML(value)}</text>`;
} else {
const on = !!d.power;
svg += `<circle cx="${x}" cy="${y}" r="24" fill="${on ? 'var(--accent-dim)' : '#233348'}" stroke="var(--wire-fan)" stroke-width="2"/>`;
svg += `<text x="${x}" y="${y + 4}" text-anchor="middle" font-size="10" fill="${on ? 'var(--accent)' : '#8ea0b8'}" font-weight="700">${on ? 'ON' : 'OFF'}</text>`;
}
svg += `<text class="peripheral-label" x="${x}" y="${y + 42}">${title}</text>`;
svg += `<text class="peripheral-sub" x="${x}" y="${y + 53}">${escapeHTML(sub)}</text>`;
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 += `<line x1="${x}" y1="${busY}" x2="${x}" y2="${y - 26}" stroke="var(--wire-ext)" stroke-width="2" stroke-dasharray="4 3"/>`;
if (d.pin) {
svg += `<text x="${x + 6}" y="${busY + 14}" font-size="9" fill="var(--wire-ext)">${escapeHTML(d.pin)}</text>`;
}
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 += `<circle cx="${x}" cy="${y}" r="24" fill="#233348" stroke="var(--wire-sensor)" stroke-width="2"/>`;
svg += `<text x="${x}" y="${y + 5}" text-anchor="middle" font-size="13" fill="#e6edf5" font-weight="700">${escapeHTML(value)}</text>`;
} else {
const on = !!d.power;
svg += `<circle cx="${x}" cy="${y}" r="24" fill="${on ? 'var(--accent-dim)' : '#233348'}" stroke="var(--wire-fan)" stroke-width="2"/>`;
svg += `<text x="${x}" y="${y + 4}" text-anchor="middle" font-size="10" fill="${on ? 'var(--accent)' : '#8ea0b8'}" font-weight="700">${on ? 'ON' : 'OFF'}</text>`;
}
svg += `<text class="peripheral-label" x="${x}" y="${y + 42}">${title}</text>`;
svg += `<text class="peripheral-sub" x="${x}" y="${y + 53}">${sub}</text>`;
// 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 += `<path class="wire" d="M ${entry.x},${y} L ${iconX + (onLeft ? 24 : -24)},${y}" stroke="${wireColor}"/>`;
svg += `<text x="${entry.x + (onLeft ? 6 : -6)}" y="${y - 6}" font-size="9" fill="${wireColor}" text-anchor="${onLeft ? 'start' : 'end'}">${escapeHTML(entry.pin)}</text>`;
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();
</script>
</body>