Выбор пина — выпадающий список, провод рисуется от реальной позиции
PIN_CATALOG: 16 доп. GPIO с возможностями (аналоговый вход/цифровой вход/цифровой выход) и настоящими координатами на плате — те же, что заняты sensor-1/fan-1/light-1/pump-1, исключены. Список в форме фильтруется под тип добавляемого устройства (вход/выход, аналоговый/ дискретный) и не предлагает уже занятые пины. Устройство с выбранным пином рисуется проводом прямо от этого пина на схеме (левый/правый край платы, высота = позиция пина), а не от общей пунктирной шины EXT — та осталась только как запасной вариант для устройств без указанного пина.
This commit is contained in:
@@ -152,7 +152,7 @@
|
|||||||
<div id="conn" class="conn offline"><span class="dot"></span><span id="conn-label">подключение…</span></div>
|
<div id="conn" class="conn offline"><span class="dot"></span><span id="conn-label">подключение…</span></div>
|
||||||
|
|
||||||
<div class="board-wrap">
|
<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 -->
|
<!-- ESP32 devkit board -->
|
||||||
<g transform="translate(255,20)">
|
<g transform="translate(255,20)">
|
||||||
<rect x="0" y="0" width="190" height="300" rx="8" fill="#1a4d2e" stroke="#0d2e1b" stroke-width="2"/>
|
<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) -->
|
<!-- 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>
|
<g id="pin-ext"><circle class="pin" cx="95" cy="300" r="3.5"/></g>
|
||||||
|
|
||||||
<!-- decorative header pins -->
|
<!-- assignable extra pins (see PIN_CATALOG in <script>) -->
|
||||||
<g fill="#8a8a8a">
|
<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="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="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="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="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="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>
|
</g>
|
||||||
<text class="pin-label" x="8" y="153">GPIO4</text>
|
<text class="pin-label" x="8" y="153">GPIO4</text>
|
||||||
<text class="pin-label" x="150" y="127">GPIO16</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="150" y="207">GPIO18</text>
|
||||||
<text class="pin-label" x="60" y="298" text-anchor="end">EXT</text>
|
<text class="pin-label" x="60" y="298" text-anchor="end">EXT</text>
|
||||||
</g>
|
</g>
|
||||||
|
<!-- labels + wires for extra devices pinned to a real GPIO — filled in by renderPinnedDevices() -->
|
||||||
|
<g id="pinned-devices"></g>
|
||||||
|
|
||||||
<!-- sensor (left) -->
|
<!-- sensor (left) -->
|
||||||
<path class="wire" d="M 255,170 C 180,170 140,60 90,60" stroke="var(--wire-sensor)"/>
|
<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: 'Помпа' };
|
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 latest = {}; // slot -> last known Device
|
||||||
let cardSlots = new Set(); // slots that currently have a rendered card
|
let cardSlots = new Set(); // slots that currently have a rendered card
|
||||||
|
|
||||||
@@ -347,7 +381,7 @@ function addDeviceCardHTML() {
|
|||||||
<label>Название сигнала</label>
|
<label>Название сигнала</label>
|
||||||
<input type="text" id="new-device-signal-name" placeholder="напр. door_open">
|
<input type="text" id="new-device-signal-name" placeholder="напр. door_open">
|
||||||
<div class="checkbox-row">
|
<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>
|
<label style="margin:0" for="new-device-discrete">Дискретный (вкл/выкл)</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -368,10 +402,10 @@ function addDeviceCardHTML() {
|
|||||||
<input type="text" id="new-device-zone-id" value="1">
|
<input type="text" id="new-device-zone-id" value="1">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label>Пин (необязательно)</label>
|
<label>Пин</label>
|
||||||
<input type="text" id="new-device-pin" placeholder="напр. GPIO25">
|
<select id="new-device-pin"></select>
|
||||||
<button onclick="addDevice()">Создать устройство</button>
|
<button onclick="addDevice()">Создать устройство</button>
|
||||||
<p class="hint">Создаёт отдельный MQTT-девайс (свой external_id) — так же, как завели бы новое устройство на платформе, а не довесок к существующему.</p>
|
<p class="hint">Создаёт отдельный MQTT-девайс (свой external_id) — так же, как завели бы новое устройство на платформе, а не довесок к существующему. Список пинов сужается под выбранный тип сигнала и не предлагает уже занятые.</p>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,6 +413,36 @@ function onNewDeviceKindChange() {
|
|||||||
const kind = document.getElementById('new-device-kind').value;
|
const kind = document.getElementById('new-device-kind').value;
|
||||||
document.getElementById('new-device-sensor-fields').style.display = kind === 'sensor' ? 'block' : 'none';
|
document.getElementById('new-device-sensor-fields').style.display = kind === 'sensor' ? 'block' : 'none';
|
||||||
document.getElementById('new-device-actuator-fields').style.display = kind === 'actuator' ? '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() {
|
async function addDevice() {
|
||||||
@@ -519,7 +583,10 @@ function render(snapshot) {
|
|||||||
document.getElementById('bulb-body').setAttribute('fill', latest.light?.power ? '#e8b339' : '#233348');
|
document.getElementById('bulb-body').setAttribute('fill', latest.light?.power ? '#e8b339' : '#233348');
|
||||||
document.getElementById('pump-ring').classList.toggle('on', !!latest.pump?.power);
|
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) {
|
function insertCard(d) {
|
||||||
@@ -539,6 +606,29 @@ function flashCard(slot) {
|
|||||||
// Draws every non-core device as its own icon hanging off the "EXT" bus
|
// 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
|
// below the fixed board — visually distinct (dashed wire) from the four
|
||||||
// GPIO-wired peripherals, since these aren't on a real fixed pin.
|
// 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) {
|
function renderExtensionDevices(extras) {
|
||||||
const g = document.getElementById('extension-devices');
|
const g = document.getElementById('extension-devices');
|
||||||
const trunk = document.getElementById('ext-trunk');
|
const trunk = document.getElementById('ext-trunk');
|
||||||
@@ -561,26 +651,33 @@ function renderExtensionDevices(extras) {
|
|||||||
const x = startX + spacing * i;
|
const x = startX + spacing * i;
|
||||||
const y = busY + 55;
|
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"/>`;
|
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) {
|
const sub = `${escapeHTML(d.external_id)} · ${d.kind === 'sensor' ? 'input' : 'output'}`;
|
||||||
svg += `<text x="${x + 6}" y="${busY + 14}" font-size="9" fill="var(--wire-ext)">${escapeHTML(d.pin)}</text>`;
|
svg += deviceIconSVG(d, x, y, sub);
|
||||||
}
|
});
|
||||||
|
|
||||||
const title = escapeHTML(deviceTitle(d));
|
g.innerHTML = svg;
|
||||||
const pinPrefix = d.pin ? `${escapeHTML(d.pin)} · ` : '';
|
}
|
||||||
const sub = `${pinPrefix}${escapeHTML(d.external_id)} · ${d.kind === 'sensor' ? 'input' : 'output'}`;
|
|
||||||
|
|
||||||
if (d.kind === 'sensor') {
|
// Devices with a real assigned pin get a solid wire straight out from that
|
||||||
const name = Object.keys(d.readings || {})[0];
|
// pin's actual position on the board — left-side pins grow an icon to the
|
||||||
const value = name !== undefined ? d.readings[name] : 0;
|
// left of the board, right-side pins to the right, both at the pin's own
|
||||||
svg += `<circle cx="${x}" cy="${y}" r="24" fill="#233348" stroke="var(--wire-sensor)" stroke-width="2"/>`;
|
// height, so the wire reads as "this exact pin goes to this device".
|
||||||
svg += `<text x="${x}" y="${y + 5}" text-anchor="middle" font-size="13" fill="#e6edf5" font-weight="700">${escapeHTML(value)}</text>`;
|
function renderPinnedDevices(pinned) {
|
||||||
} else {
|
const g = document.getElementById('pinned-devices');
|
||||||
const on = !!d.power;
|
let svg = '';
|
||||||
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>`;
|
pinned.forEach((d) => {
|
||||||
}
|
const entry = pinEntry(d.pin);
|
||||||
svg += `<text class="peripheral-label" x="${x}" y="${y + 42}">${title}</text>`;
|
if (!entry) return; // pin no longer in the catalog (shouldn't happen) — skip rather than crash
|
||||||
svg += `<text class="peripheral-sub" x="${x}" y="${y + 53}">${sub}</text>`;
|
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;
|
g.innerHTML = svg;
|
||||||
@@ -615,6 +712,7 @@ function connectEvents() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('cards').innerHTML = addDeviceCardHTML();
|
document.getElementById('cards').innerHTML = addDeviceCardHTML();
|
||||||
|
refreshPinOptions();
|
||||||
connectEvents();
|
connectEvents();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user