Compare commits
2
Commits
da0f941d79
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b1c894190 | ||
|
|
5674917f95 |
@@ -43,10 +43,60 @@ func (s *Server) routes() {
|
|||||||
|
|
||||||
s.mux.HandleFunc("GET /api/snapshot", s.handleSnapshot)
|
s.mux.HandleFunc("GET /api/snapshot", s.handleSnapshot)
|
||||||
s.mux.HandleFunc("GET /api/events", s.handleEvents)
|
s.mux.HandleFunc("GET /api/events", s.handleEvents)
|
||||||
|
s.mux.HandleFunc("POST /api/devices", s.handleAddDevice)
|
||||||
|
s.mux.HandleFunc("DELETE /api/devices/{slot}", s.handleRemoveDevice)
|
||||||
s.mux.HandleFunc("POST /api/devices/{slot}/identity", s.handleSetIdentity)
|
s.mux.HandleFunc("POST /api/devices/{slot}/identity", s.handleSetIdentity)
|
||||||
s.mux.HandleFunc("POST /api/devices/{slot}/telemetry", s.handlePublishTelemetry)
|
s.mux.HandleFunc("POST /api/devices/{slot}/telemetry", s.handlePublishTelemetry)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleAddDevice creates a new standalone sensor (input) or actuator
|
||||||
|
// (output) device — a separate MQTT device_id, not another sensor_type or
|
||||||
|
// a manual override tacked onto an existing device. Mirrors how you'd
|
||||||
|
// actually add a second physical device to the platform (a new Device row
|
||||||
|
// with its own external_id).
|
||||||
|
func (s *Server) handleAddDevice(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Kind string `json:"kind"` // "sensor" | "actuator"
|
||||||
|
SignalName string `json:"signal_name"`
|
||||||
|
ExternalID string `json:"external_id"`
|
||||||
|
ZoneID string `json:"zone_id"`
|
||||||
|
Pin string `json:"pin"`
|
||||||
|
Discrete bool `json:"discrete"`
|
||||||
|
SupportsLevel bool `json:"supports_level"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ExternalID == "" || req.ZoneID == "" {
|
||||||
|
http.Error(w, "external_id and zone_id are required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var slot string
|
||||||
|
switch req.Kind {
|
||||||
|
case "sensor":
|
||||||
|
if req.SignalName == "" {
|
||||||
|
http.Error(w, "signal_name is required for a sensor device", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slot = s.store.AddSensorDevice(req.SignalName, req.ExternalID, req.ZoneID, req.Pin, req.Discrete)
|
||||||
|
case "actuator":
|
||||||
|
slot = s.store.AddActuatorDevice(req.ExternalID, req.ZoneID, req.Pin, req.SupportsLevel)
|
||||||
|
default:
|
||||||
|
http.Error(w, `kind must be "sensor" or "actuator"`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, s.store.Snapshot())
|
||||||
|
s.logger.Info("extra device added", "slot", slot, "kind", req.Kind, "device_id", req.ExternalID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRemoveDevice(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slot := r.PathValue("slot")
|
||||||
|
if !s.store.RemoveDevice(slot) {
|
||||||
|
http.Error(w, "unknown or non-removable slot", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, s.store.Snapshot())
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleSnapshot(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSnapshot(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, http.StatusOK, s.store.Snapshot())
|
writeJSON(w, http.StatusOK, s.store.Snapshot())
|
||||||
}
|
}
|
||||||
|
|||||||
+364
-139
@@ -20,6 +20,7 @@
|
|||||||
--wire-fan: #5b7ba8;
|
--wire-fan: #5b7ba8;
|
||||||
--wire-light: #e8b339;
|
--wire-light: #e8b339;
|
||||||
--wire-pump: #3aa0e8;
|
--wire-pump: #3aa0e8;
|
||||||
|
--wire-ext: #7a8ba8;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
@@ -30,7 +31,7 @@
|
|||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
h1 { font-size: 1.3rem; font-weight: 600; margin: 0 0 4px; }
|
h1 { font-size: 1.3rem; font-weight: 600; margin: 0 0 4px; }
|
||||||
.subtitle { color: var(--muted); margin: 0 0 24px; font-size: 0.9rem; max-width: 640px; }
|
.subtitle { color: var(--muted); margin: 0 0 24px; font-size: 0.9rem; max-width: 680px; }
|
||||||
.conn {
|
.conn {
|
||||||
display: inline-flex; align-items: center; gap: 6px;
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
font-size: 0.8rem; color: var(--muted); margin-bottom: 20px;
|
font-size: 0.8rem; color: var(--muted); margin-bottom: 20px;
|
||||||
@@ -79,15 +80,18 @@
|
|||||||
transition: box-shadow .3s, border-color .3s;
|
transition: box-shadow .3s, border-color .3s;
|
||||||
}
|
}
|
||||||
.card.flash { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(51,209,122,0.25); }
|
.card.flash { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(51,209,122,0.25); }
|
||||||
|
.card.add-device-card { border-style: dashed; }
|
||||||
.card h2 {
|
.card h2 {
|
||||||
font-size: 0.95rem; margin: 0 0 12px;
|
font-size: 0.95rem; margin: 0 0 12px;
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||||
}
|
}
|
||||||
|
.card h2 .title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.badge {
|
.badge {
|
||||||
font-size: 0.72rem; padding: 2px 8px; border-radius: 999px;
|
font-size: 0.72rem; padding: 2px 8px; border-radius: 999px;
|
||||||
background: var(--panel-2); color: var(--muted); font-weight: 500;
|
background: var(--panel-2); color: var(--muted); font-weight: 500; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.badge.on { background: var(--accent-dim); color: var(--accent); }
|
.badge.on { background: var(--accent-dim); color: var(--accent); }
|
||||||
|
.badge.extra { background: #2a2440; color: #b39ddb; }
|
||||||
|
|
||||||
label { display: block; font-size: 0.78rem; color: var(--muted); margin: 10px 0 4px; }
|
label { display: block; font-size: 0.78rem; color: var(--muted); margin: 10px 0 4px; }
|
||||||
input[type=text], input[type=number] {
|
input[type=text], input[type=number] {
|
||||||
@@ -105,6 +109,7 @@
|
|||||||
font-weight: 600; font-size: 0.85rem; cursor: pointer;
|
font-weight: 600; font-size: 0.85rem; cursor: pointer;
|
||||||
}
|
}
|
||||||
button.secondary { background: var(--panel-2); color: var(--text); border: 1px solid var(--border); }
|
button.secondary { background: var(--panel-2); color: var(--text); border: 1px solid var(--border); }
|
||||||
|
button.danger { background: transparent; color: #e08a8a; border: 1px solid #4a2c2c; }
|
||||||
button:active { transform: translateY(1px); }
|
button:active { transform: translateY(1px); }
|
||||||
|
|
||||||
.status-line { display: flex; justify-content: space-between; font-size: 0.85rem; margin-top: 8px; }
|
.status-line { display: flex; justify-content: space-between; font-size: 0.85rem; margin-top: 8px; }
|
||||||
@@ -112,17 +117,10 @@
|
|||||||
.value { color: var(--text); font-weight: 600; }
|
.value { color: var(--text); font-weight: 600; }
|
||||||
.hint { color: var(--muted); font-size: 0.78rem; margin-top: 10px; }
|
.hint { color: var(--muted); font-size: 0.78rem; margin-top: 10px; }
|
||||||
|
|
||||||
.signal-group-label {
|
|
||||||
font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em;
|
|
||||||
color: var(--muted); margin-top: 12px; margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
.signal-group-label:first-child { margin-top: 0; }
|
|
||||||
|
|
||||||
.signal-row {
|
.signal-row {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
gap: 8px; padding: 6px 0; border-top: 1px solid var(--border); font-size: 0.85rem;
|
gap: 8px; padding: 6px 0; font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
.signal-row:first-of-type { border-top: none; }
|
|
||||||
.signal-row .name { color: var(--muted); }
|
.signal-row .name { color: var(--muted); }
|
||||||
.signal-row input[type=range] { flex: 1; }
|
.signal-row input[type=range] { flex: 1; }
|
||||||
.signal-row .val { width: 44px; text-align: right; font-weight: 600; }
|
.signal-row .val { width: 44px; text-align: right; font-weight: 600; }
|
||||||
@@ -137,22 +135,24 @@
|
|||||||
}
|
}
|
||||||
.toggle.on .knob { left: 20px; background: var(--accent); }
|
.toggle.on .knob { left: 20px; background: var(--accent); }
|
||||||
|
|
||||||
.add-signal { border-top: 1px dashed var(--border); margin-top: 12px; padding-top: 12px; }
|
|
||||||
.add-signal .row2 { display: flex; gap: 8px; margin-top: 6px; }
|
|
||||||
select {
|
select {
|
||||||
padding: 7px 9px; border-radius: 7px; border: 1px solid var(--border);
|
width: 100%; padding: 7px 9px; border-radius: 7px; border: 1px solid var(--border);
|
||||||
background: var(--panel-2); color: var(--text); font-size: 0.85rem;
|
background: var(--panel-2); color: var(--text); font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
.field-row { display: flex; gap: 8px; }
|
||||||
|
.field-row > div { flex: 1; }
|
||||||
|
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; font-size: 0.85rem; }
|
||||||
|
.checkbox-row input { width: auto; margin: 0; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<h1>ESP32 Emulator</h1>
|
<h1>ESP32 Emulator</h1>
|
||||||
<p class="subtitle">Виртуальный ESP32-контроллер гроубокса. Слева — какой пин к чему подключён. Показания датчика вы задаёте и публикуете сами (входные сигналы → в автоматику); питание/уровень актуаторов меняются САМИ, когда платформа реально присылает команду (выход из автоматики → сюда).</p>
|
<p class="subtitle">Виртуальный ESP32-контроллер гроубокса. Слева — какой пин к чему подключён. Показания задаёте и публикуете сами (входные сигналы → в автоматику); питание/уровень актуаторов меняются САМИ, когда платформа реально присылает команду (выход из автоматики → сюда). Дополнительные сигналы — это отдельные устройства (свой external_id), а не довесок к существующим.</p>
|
||||||
<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 340" 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"/>
|
||||||
@@ -172,21 +172,30 @@
|
|||||||
<g id="pin-fan"><circle class="pin" cx="190" cy="130" r="3.5"/></g>
|
<g id="pin-fan"><circle class="pin" cx="190" cy="130" r="3.5"/></g>
|
||||||
<g id="pin-light"><circle class="pin" cx="190" cy="170" r="3.5"/></g>
|
<g id="pin-light"><circle class="pin" cx="190" cy="170" r="3.5"/></g>
|
||||||
<g id="pin-pump"><circle class="pin" cx="190" cy="210" r="3.5"/></g>
|
<g id="pin-pump"><circle class="pin" cx="190" cy="210" r="3.5"/></g>
|
||||||
|
<!-- 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 -->
|
<!-- 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>
|
||||||
<text class="pin-label" x="150" y="167">GPIO17</text>
|
<text class="pin-label" x="150" y="167">GPIO17</text>
|
||||||
<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>
|
||||||
</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)"/>
|
||||||
@@ -230,125 +239,81 @@
|
|||||||
</g>
|
</g>
|
||||||
<text class="peripheral-label" x="615" y="332">Помпа</text>
|
<text class="peripheral-label" x="615" y="332">Помпа</text>
|
||||||
<text class="peripheral-sub" x="500" y="332">GPIO18 · relay</text>
|
<text class="peripheral-sub" x="500" y="332">GPIO18 · relay</text>
|
||||||
|
|
||||||
|
<!-- extra devices attach here, below the fixed board — see renderExtensionDevices() -->
|
||||||
|
<path class="wire" id="ext-trunk" d="M 350,320 L 350,360" stroke="var(--wire-ext)" stroke-dasharray="4 3"/>
|
||||||
|
<text x="350" y="378" text-anchor="middle" font-size="9" fill="var(--wire-ext)">доп. устройства (EXT)</text>
|
||||||
|
<g id="extension-devices"></g>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid" id="cards"></div>
|
<div class="grid" id="cards"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const SLOTS = {
|
// Presets give known signal names a friendly slider range/unit; anything
|
||||||
sensor: { label: 'Датчик (входные сигналы)', kind: 'sensor' },
|
// else is just min 0 / max 100 unless the user picked "discrete".
|
||||||
fan: { label: 'Вентилятор', kind: 'actuator' },
|
|
||||||
light: { label: 'Свет', kind: 'actuator' },
|
|
||||||
pump: { label: 'Помпа', kind: 'actuator' },
|
|
||||||
};
|
|
||||||
|
|
||||||
// Signals with a slider preset (min/max/step/unit); anything else added via
|
|
||||||
// "add signal" is either a plain number or a discrete on/off toggle.
|
|
||||||
const SIGNAL_PRESETS = {
|
const SIGNAL_PRESETS = {
|
||||||
temperature: { min: -10, max: 50, step: 0.5, unit: '°C' },
|
temperature: { min: -10, max: 50, step: 0.5, unit: '°C' },
|
||||||
humidity: { min: 0, max: 100, step: 1, unit: '%' },
|
humidity: { min: 0, max: 100, step: 1, unit: '%' },
|
||||||
};
|
};
|
||||||
|
|
||||||
let latest = {};
|
const CORE_LABELS = { sensor: 'Датчик', fan: 'Вентилятор', light: 'Свет', pump: 'Помпа' };
|
||||||
let knownSignals = {}; // slot -> Set of sensor_type names currently rendered
|
|
||||||
|
|
||||||
function cardHTML(slot) {
|
// Assignable GPIO pins for extra devices — deliberately not the full ESP32
|
||||||
const meta = SLOTS[slot];
|
// pinout, just enough to be realistic: which pins can only be read
|
||||||
const idPrefix = slot;
|
// (34/35/36/39 are input-only on a real ESP32, no output driver), which
|
||||||
let body = `
|
// support analog (ADC) input, and which are plain digital GPIO. Absolute
|
||||||
<label>external_id</label>
|
// board coordinates = the ESP32 rect's own translate(255,20) + local x/y,
|
||||||
<input type="text" id="${idPrefix}-external-id">
|
// matching the four core pins' wire-start points (255 for the left edge,
|
||||||
<label>zone_id</label>
|
// 445 for the right edge).
|
||||||
<input type="text" id="${idPrefix}-zone-id">
|
const PIN_CATALOG = [
|
||||||
<button class="secondary" onclick="saveIdentity('${slot}')">Сохранить идентификатор</button>
|
{ 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 },
|
||||||
|
];
|
||||||
|
|
||||||
if (meta.kind === 'sensor') {
|
function pinEntry(name) { return PIN_CATALOG.find((p) => p.pin === name); }
|
||||||
body += `
|
|
||||||
<div id="${idPrefix}-signals"></div>
|
|
||||||
<div class="add-signal">
|
|
||||||
<label>Добавить сигнал</label>
|
|
||||||
<div class="row2">
|
|
||||||
<input type="text" id="${idPrefix}-new-name" placeholder="напр. door_open">
|
|
||||||
<select id="${idPrefix}-new-kind">
|
|
||||||
<option value="analog">аналоговый</option>
|
|
||||||
<option value="discrete">дискретный (вкл/выкл)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button class="secondary" onclick="addSignal('${slot}')">Добавить</button>
|
|
||||||
</div>
|
|
||||||
<p class="hint">Входные сигналы публикуются в MQTT автоматически (слайдер — с небольшой задержкой после того, как отпустили; переключатель — сразу).</p>
|
|
||||||
<button class="secondary" onclick="publishAll('${slot}')">Переопубликовать всё сейчас</button>
|
|
||||||
`;
|
|
||||||
} else {
|
|
||||||
body += `
|
|
||||||
<div class="status-line"><span>Питание</span><span id="${idPrefix}-power">—</span></div>
|
|
||||||
<div class="status-line" id="${idPrefix}-level-row" style="display:none">
|
|
||||||
<span>Уровень</span><span id="${idPrefix}-level">—</span>
|
|
||||||
</div>
|
|
||||||
<p class="hint">Выходной сигнал — меняется, когда платформа присылает команду по MQTT. Ручного переключателя здесь нет специально.</p>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const badge = meta.kind === 'actuator' ? `<span class="badge" id="${idPrefix}-badge">выключено</span>` : '';
|
let latest = {}; // slot -> last known Device
|
||||||
return `<div class="card" id="${idPrefix}-card">
|
let cardSlots = new Set(); // slots that currently have a rendered card
|
||||||
<h2>${meta.label} ${badge}</h2>
|
|
||||||
${body}
|
function escapeHTML(str) {
|
||||||
</div>`;
|
return String(str).replace(/[&<>"']/g, (c) => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('cards').innerHTML = Object.keys(SLOTS).map(cardHTML).join('');
|
function cssId(name) { return name.replace(/[^a-zA-Z0-9_-]/g, '_'); }
|
||||||
|
|
||||||
function isDiscreteSignal(slot, name) {
|
|
||||||
return knownSignals[slot]?.discrete?.has(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
function analogRowHTML(slot, name, value) {
|
function analogRowHTML(slot, name, value) {
|
||||||
const preset = SIGNAL_PRESETS[name];
|
const preset = SIGNAL_PRESETS[name];
|
||||||
const min = preset?.min ?? 0, max = preset?.max ?? 100, step = preset?.step ?? 1, unit = preset?.unit ?? '';
|
const min = preset?.min ?? 0, max = preset?.max ?? 100, step = preset?.step ?? 1, unit = preset?.unit ?? '';
|
||||||
return `<div class="signal-row" data-signal="${name}">
|
return `<div class="signal-row" data-signal="${escapeHTML(name)}">
|
||||||
<span class="name">${name}${unit ? ' ('+unit+')' : ''}</span>
|
<span class="name">${escapeHTML(name)}${unit ? ' ('+unit+')' : ''}</span>
|
||||||
<input type="range" min="${min}" max="${max}" step="${step}" value="${value}"
|
<input type="range" min="${min}" max="${max}" step="${step}" value="${value}"
|
||||||
oninput="onSliderInput('${slot}','${name}', this.value)">
|
oninput="onSliderInput('${slot}','${escapeHTML(name)}', this.value)">
|
||||||
<span class="val" id="${slot}-${cssId(name)}-val">${value}</span>
|
<span class="val" id="${slot}-${cssId(name)}-val">${value}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function discreteRowHTML(slot, name, value) {
|
function discreteRowHTML(slot, name, value) {
|
||||||
return `<div class="signal-row" data-signal="${name}">
|
return `<div class="signal-row" data-signal="${escapeHTML(name)}">
|
||||||
<span class="name">${name}</span>
|
<span class="name">${escapeHTML(name)}</span>
|
||||||
<div class="toggle ${value ? 'on' : ''}" onclick="toggleDiscrete('${slot}','${name}')"><div class="knob"></div></div>
|
<div class="toggle ${value ? 'on' : ''}" onclick="toggleDiscrete('${slot}','${escapeHTML(name)}')"><div class="knob"></div></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cssId(name) { return name.replace(/[^a-zA-Z0-9_-]/g, '_'); }
|
|
||||||
|
|
||||||
// Discrete vs analog are shown as two visually separate groups — a
|
|
||||||
// discrete "door_open" signal is still just another sensor_type on the
|
|
||||||
// same physical device underneath (one MQTT device can report several
|
|
||||||
// sensor_types, same as real telemetry), but grouping keeps it from
|
|
||||||
// reading as "attached to the temperature sensor".
|
|
||||||
function renderSignals(slot, readings) {
|
|
||||||
const container = document.getElementById(`${slot}-signals`);
|
|
||||||
knownSignals[slot] = knownSignals[slot] || { discrete: new Set() };
|
|
||||||
|
|
||||||
const analogNames = orderedNames(readings).filter((n) => !isDiscreteSignal(slot, n));
|
|
||||||
const discreteNames = Object.keys(readings).filter((n) => isDiscreteSignal(slot, n)).sort();
|
|
||||||
|
|
||||||
let html = '';
|
|
||||||
if (analogNames.length) {
|
|
||||||
html += `<div class="signal-group-label">Аналоговые</div>`;
|
|
||||||
html += analogNames.map((name) => analogRowHTML(slot, name, readings[name])).join('');
|
|
||||||
}
|
|
||||||
if (discreteNames.length) {
|
|
||||||
html += `<div class="signal-group-label">Дискретные</div>`;
|
|
||||||
html += discreteNames.map((name) => discreteRowHTML(slot, name, readings[name])).join('');
|
|
||||||
}
|
|
||||||
container.innerHTML = html;
|
|
||||||
updateBoardText(slot, readings);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Known presets sort first (temperature before humidity), custom signals
|
// Known presets sort first (temperature before humidity), custom signals
|
||||||
// after, alphabetically — keeps the board's two-line readout predictable.
|
// after, alphabetically — keeps the board's two-line readout predictable.
|
||||||
function orderedNames(readings) {
|
function orderedNames(readings) {
|
||||||
@@ -357,6 +322,179 @@ function orderedNames(readings) {
|
|||||||
return names.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
|
return names.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deviceTitle(d) {
|
||||||
|
if (d.core) return CORE_LABELS[d.slot];
|
||||||
|
if (d.kind === 'sensor') return Object.keys(d.readings || {})[0] || d.external_id;
|
||||||
|
return d.external_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardHTML(d) {
|
||||||
|
const badge = d.kind === 'actuator'
|
||||||
|
? `<span class="badge" id="${d.slot}-badge">выключено</span>`
|
||||||
|
: (!d.core ? `<span class="badge extra">доп.</span>` : '');
|
||||||
|
|
||||||
|
let body = '';
|
||||||
|
if (d.pin) {
|
||||||
|
body += `<p class="hint" style="margin-top:0">Пин: <strong style="color:var(--text)">${escapeHTML(d.pin)}</strong></p>`;
|
||||||
|
}
|
||||||
|
body += `
|
||||||
|
<label>external_id</label>
|
||||||
|
<input type="text" id="${d.slot}-external-id">
|
||||||
|
<label>zone_id</label>
|
||||||
|
<input type="text" id="${d.slot}-zone-id">
|
||||||
|
<button class="secondary" onclick="saveIdentity('${d.slot}')">Сохранить идентификатор</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (d.kind === 'sensor') {
|
||||||
|
body += `<div id="${d.slot}-signals"></div>`;
|
||||||
|
body += `<p class="hint">Публикуется в MQTT автоматически (слайдер — с небольшой задержкой после того, как отпустили; переключатель — сразу).</p>`;
|
||||||
|
} else {
|
||||||
|
body += `
|
||||||
|
<div class="status-line"><span>Питание</span><span id="${d.slot}-power">—</span></div>
|
||||||
|
<div class="status-line" id="${d.slot}-level-row" style="display:none">
|
||||||
|
<span>Уровень</span><span id="${d.slot}-level">—</span>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Выходной сигнал — меняется, когда платформа присылает команду по MQTT. Ручного переключателя здесь нет специально.</p>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!d.core) {
|
||||||
|
body += `<button class="danger" onclick="removeDevice('${d.slot}')">Удалить устройство</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<div class="card" id="${d.slot}-card">
|
||||||
|
<h2><span class="title">${escapeHTML(deviceTitle(d))}</span> ${badge}</h2>
|
||||||
|
${body}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDeviceCardHTML() {
|
||||||
|
return `<div class="card add-device-card" id="add-device-card">
|
||||||
|
<h2><span class="title">Добавить устройство</span></h2>
|
||||||
|
<label>Тип</label>
|
||||||
|
<select id="new-device-kind" onchange="onNewDeviceKindChange()">
|
||||||
|
<option value="sensor">Вход (датчик)</option>
|
||||||
|
<option value="actuator">Выход (актуатор)</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div id="new-device-sensor-fields">
|
||||||
|
<label>Название сигнала</label>
|
||||||
|
<input type="text" id="new-device-signal-name" placeholder="напр. door_open">
|
||||||
|
<div class="checkbox-row">
|
||||||
|
<input type="checkbox" id="new-device-discrete" onchange="refreshPinOptions()">
|
||||||
|
<label style="margin:0" for="new-device-discrete">Дискретный (вкл/выкл)</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="new-device-actuator-fields" style="display:none">
|
||||||
|
<div class="checkbox-row">
|
||||||
|
<input type="checkbox" id="new-device-supports-level">
|
||||||
|
<label style="margin:0" for="new-device-supports-level">Поддерживает уровень (set_level)</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<div>
|
||||||
|
<label>external_id</label>
|
||||||
|
<input type="text" id="new-device-external-id" placeholder="напр. door-1">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>zone_id</label>
|
||||||
|
<input type="text" id="new-device-zone-id" value="1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label>Пин</label>
|
||||||
|
<select id="new-device-pin"></select>
|
||||||
|
<button onclick="addDevice()">Создать устройство</button>
|
||||||
|
<p class="hint">Создаёт отдельный MQTT-девайс (свой external_id) — так же, как завели бы новое устройство на платформе, а не довесок к существующему. Список пинов сужается под выбранный тип сигнала и не предлагает уже занятые.</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
const kind = document.getElementById('new-device-kind').value;
|
||||||
|
const externalId = document.getElementById('new-device-external-id').value.trim();
|
||||||
|
const zoneId = document.getElementById('new-device-zone-id').value.trim();
|
||||||
|
if (!externalId || !zoneId) return;
|
||||||
|
|
||||||
|
const payload = { kind, external_id: externalId, zone_id: zoneId,
|
||||||
|
pin: document.getElementById('new-device-pin').value.trim() };
|
||||||
|
if (kind === 'sensor') {
|
||||||
|
const name = document.getElementById('new-device-signal-name').value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
payload.signal_name = name;
|
||||||
|
payload.discrete = document.getElementById('new-device-discrete').checked;
|
||||||
|
} else {
|
||||||
|
payload.supports_level = document.getElementById('new-device-supports-level').checked;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch('/api/devices', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
alert('Не удалось создать устройство: ' + (await res.text()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('new-device-external-id').value = '';
|
||||||
|
document.getElementById('new-device-signal-name').value = '';
|
||||||
|
document.getElementById('new-device-pin').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeDevice(slot) {
|
||||||
|
if (!confirm('Удалить это устройство?')) return;
|
||||||
|
await fetch(`/api/devices/${slot}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSignals(d) {
|
||||||
|
const container = document.getElementById(`${d.slot}-signals`);
|
||||||
|
const readings = d.readings || {};
|
||||||
|
const names = orderedNames(readings);
|
||||||
|
container.innerHTML = names.map((name) => {
|
||||||
|
// Core sensor's own readings (temperature/humidity) are always analog;
|
||||||
|
// an extra sensor device has exactly one reading, flagged by the
|
||||||
|
// server via d.discrete.
|
||||||
|
const discrete = !d.core && d.discrete;
|
||||||
|
return discrete ? discreteRowHTML(d.slot, name, readings[name]) : analogRowHTML(d.slot, name, readings[name]);
|
||||||
|
}).join('');
|
||||||
|
updateBoardText(d.slot, readings);
|
||||||
|
}
|
||||||
|
|
||||||
function updateBoardText(slot, readings) {
|
function updateBoardText(slot, readings) {
|
||||||
if (slot !== 'sensor') return;
|
if (slot !== 'sensor') return;
|
||||||
const [primary, secondary] = orderedNames(readings);
|
const [primary, secondary] = orderedNames(readings);
|
||||||
@@ -367,10 +505,10 @@ function updateBoardText(slot, readings) {
|
|||||||
|
|
||||||
// Debounced per-signal auto-publish: a value only "counts" as sent to the
|
// Debounced per-signal auto-publish: a value only "counts" as sent to the
|
||||||
// platform ~350ms after the user stops moving the slider, so a full drag
|
// platform ~350ms after the user stops moving the slider, so a full drag
|
||||||
// doesn't flood MQTT with one message per pixel — but it still leaves the
|
// doesn't flood MQTT with one message per pixel — but the server (and thus
|
||||||
// server (and thus the SSE truth every client, including reconnects,
|
// the SSE truth every client, including reconnects, converges to) is back
|
||||||
// converges to) in sync within a fraction of a second, not only when a
|
// in sync within a fraction of a second either way, not only when a
|
||||||
// separate "publish" button is remembered/clicked.
|
// separate "publish" button is remembered and clicked.
|
||||||
const publishTimers = {};
|
const publishTimers = {};
|
||||||
|
|
||||||
function schedulePublish(slot, name, value) {
|
function schedulePublish(slot, name, value) {
|
||||||
@@ -392,38 +530,28 @@ function toggleDiscrete(slot, name) {
|
|||||||
const cur = latest[slot]?.readings?.[name] ?? 0;
|
const cur = latest[slot]?.readings?.[name] ?? 0;
|
||||||
const next = cur ? 0 : 1;
|
const next = cur ? 0 : 1;
|
||||||
latest[slot].readings[name] = next;
|
latest[slot].readings[name] = next;
|
||||||
renderSignals(slot, latest[slot].readings);
|
renderSignals(latest[slot]);
|
||||||
publishOne(slot, name, next); // discrete flips are instant, no debounce needed
|
publishOne(slot, name, next); // discrete flips are instant, no debounce needed
|
||||||
}
|
}
|
||||||
|
|
||||||
function addSignal(slot) {
|
|
||||||
const nameInput = document.getElementById(`${slot}-new-name`);
|
|
||||||
const kindSelect = document.getElementById(`${slot}-new-kind`);
|
|
||||||
const name = nameInput.value.trim();
|
|
||||||
if (!name) return;
|
|
||||||
const isDiscrete = kindSelect.value === 'discrete';
|
|
||||||
|
|
||||||
knownSignals[slot] = knownSignals[slot] || { discrete: new Set() };
|
|
||||||
if (isDiscrete) knownSignals[slot].discrete.add(name);
|
|
||||||
|
|
||||||
if (!latest[slot]) latest[slot] = { readings: {} };
|
|
||||||
latest[slot].readings[name] = 0;
|
|
||||||
renderSignals(slot, latest[slot].readings);
|
|
||||||
nameInput.value = '';
|
|
||||||
publishOne(slot, name, 0); // publish immediately so it survives an SSE reconnect right away
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(snapshot) {
|
function render(snapshot) {
|
||||||
|
const seenSlots = new Set();
|
||||||
|
|
||||||
for (const d of snapshot.devices) {
|
for (const d of snapshot.devices) {
|
||||||
|
seenSlots.add(d.slot);
|
||||||
const prevPower = latest[d.slot]?.power;
|
const prevPower = latest[d.slot]?.power;
|
||||||
const prevLevel = latest[d.slot]?.level;
|
const prevLevel = latest[d.slot]?.level;
|
||||||
latest[d.slot] = d;
|
latest[d.slot] = d;
|
||||||
|
|
||||||
|
if (!cardSlots.has(d.slot)) {
|
||||||
|
insertCard(d);
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById(`${d.slot}-external-id`).value = d.external_id;
|
document.getElementById(`${d.slot}-external-id`).value = d.external_id;
|
||||||
document.getElementById(`${d.slot}-zone-id`).value = d.zone_id;
|
document.getElementById(`${d.slot}-zone-id`).value = d.zone_id;
|
||||||
|
|
||||||
if (d.kind === 'sensor') {
|
if (d.kind === 'sensor') {
|
||||||
renderSignals(d.slot, d.readings || {});
|
renderSignals(d);
|
||||||
} else {
|
} else {
|
||||||
const badge = document.getElementById(`${d.slot}-badge`);
|
const badge = document.getElementById(`${d.slot}-badge`);
|
||||||
badge.textContent = d.power ? 'включено' : 'выключено';
|
badge.textContent = d.power ? 'включено' : 'выключено';
|
||||||
@@ -441,10 +569,32 @@ function render(snapshot) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drop cards for devices that no longer exist (removed extras).
|
||||||
|
for (const slot of [...cardSlots]) {
|
||||||
|
if (!seenSlots.has(slot)) {
|
||||||
|
document.getElementById(`${slot}-card`)?.remove();
|
||||||
|
cardSlots.delete(slot);
|
||||||
|
delete latest[slot];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('fan-blades').classList.toggle('on', !!latest.fan?.power);
|
document.getElementById('fan-blades').classList.toggle('on', !!latest.fan?.power);
|
||||||
document.getElementById('bulb-glow').classList.toggle('on', !!latest.light?.power);
|
document.getElementById('bulb-glow').classList.toggle('on', !!latest.light?.power);
|
||||||
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);
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const cardsEl = document.getElementById('cards');
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.innerHTML = cardHTML(d);
|
||||||
|
cardsEl.appendChild(wrapper.firstElementChild);
|
||||||
|
cardSlots.add(d.slot);
|
||||||
}
|
}
|
||||||
|
|
||||||
function flashCard(slot) {
|
function flashCard(slot) {
|
||||||
@@ -453,6 +603,86 @@ function flashCard(slot) {
|
|||||||
setTimeout(() => card.classList.remove('flash'), 900);
|
setTimeout(() => card.classList.remove('flash'), 900);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
|
||||||
|
if (!extras.length) {
|
||||||
|
g.innerHTML = '';
|
||||||
|
trunk.setAttribute('d', 'M 350,320 L 350,360');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const busY = 375;
|
||||||
|
const spacing = 110;
|
||||||
|
const totalWidth = spacing * (extras.length - 1);
|
||||||
|
const startX = 350 - totalWidth / 2;
|
||||||
|
trunk.setAttribute('d', `M 350,320 L 350,${busY}`);
|
||||||
|
|
||||||
|
let svg = `<line x1="${startX}" y1="${busY}" x2="${startX + totalWidth}" y2="${busY}" stroke="var(--wire-ext)" stroke-width="2" stroke-dasharray="4 3"/>`;
|
||||||
|
|
||||||
|
extras.forEach((d, i) => {
|
||||||
|
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"/>`;
|
||||||
|
const sub = `${escapeHTML(d.external_id)} · ${d.kind === 'sensor' ? 'input' : 'output'}`;
|
||||||
|
svg += deviceIconSVG(d, x, y, sub);
|
||||||
|
});
|
||||||
|
|
||||||
|
g.innerHTML = svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
async function saveIdentity(slot) {
|
async function saveIdentity(slot) {
|
||||||
const external_id = document.getElementById(`${slot}-external-id`).value.trim();
|
const external_id = document.getElementById(`${slot}-external-id`).value.trim();
|
||||||
const zone_id = document.getElementById(`${slot}-zone-id`).value.trim();
|
const zone_id = document.getElementById(`${slot}-zone-id`).value.trim();
|
||||||
@@ -471,13 +701,6 @@ async function publishOne(slot, sensorType, value) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function publishAll(slot) {
|
|
||||||
const readings = latest[slot]?.readings || {};
|
|
||||||
for (const [name, value] of Object.entries(readings)) {
|
|
||||||
await publishOne(slot, name, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function connectEvents() {
|
function connectEvents() {
|
||||||
const es = new EventSource('/api/events');
|
const es = new EventSource('/api/events');
|
||||||
const conn = document.getElementById('conn');
|
const conn = document.getElementById('conn');
|
||||||
@@ -488,6 +711,8 @@ function connectEvents() {
|
|||||||
es.onmessage = (e) => render(JSON.parse(e.data));
|
es.onmessage = (e) => render(JSON.parse(e.data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.getElementById('cards').innerHTML = addDeviceCardHTML();
|
||||||
|
refreshPinOptions();
|
||||||
connectEvents();
|
connectEvents();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+118
-33
@@ -1,11 +1,16 @@
|
|||||||
// Package state holds the emulator's in-memory model of a growbox
|
// Package state holds the emulator's in-memory model of the controller:
|
||||||
// controller board: one sensor (reports readings) and up to three
|
// four fixed board slots wired to real GPIO pins (one sensor, three
|
||||||
// actuators (respond to power/level commands). Nothing here persists
|
// actuators) plus any number of extra standalone sensor devices the user
|
||||||
// across restarts — the emulator is a throwaway dev/demo tool, not a
|
// adds at runtime — each its own MQTT device_id, the way a second physical
|
||||||
// real device.
|
// sensor would show up in the real platform, not just another sensor_type
|
||||||
|
// bolted onto the first one. Nothing here persists across restarts — the
|
||||||
|
// emulator is a throwaway dev/demo tool, not a real device.
|
||||||
package state
|
package state
|
||||||
|
|
||||||
import "sync"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
type Kind string
|
type Kind string
|
||||||
|
|
||||||
@@ -14,30 +19,45 @@ const (
|
|||||||
KindActuator Kind = "actuator"
|
KindActuator Kind = "actuator"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Device is one slot on the controller board. Sensor slots use Readings;
|
// coreSlots are wired to a fixed GPIO pin on the board illustration and
|
||||||
// actuator slots use Power/Level. A slot is inert (not published/commanded)
|
// can't be removed — only their identity (external_id/zone_id) is editable.
|
||||||
// when ExternalID is empty, so the board can represent fewer than four
|
var coreSlots = []string{"sensor", "fan", "light", "pump"}
|
||||||
// devices without special-casing "missing" ones.
|
|
||||||
|
// Device is one board slot or standalone extra sensor. Sensor devices use
|
||||||
|
// Readings; actuator devices use Power/Level. Core is true for the four
|
||||||
|
// fixed board slots (rendered wired to a GPIO pin, can't be deleted) and
|
||||||
|
// false for user-added extra sensors (rendered as their own card, can be
|
||||||
|
// removed). Discrete only matters for single-reading extra sensors, telling
|
||||||
|
// the frontend to render a toggle instead of a slider.
|
||||||
type Device struct {
|
type Device struct {
|
||||||
Slot string `json:"slot"` // "sensor" | "fan" | "light" | "pump" — fixed board position
|
Slot string `json:"slot"`
|
||||||
|
Core bool `json:"core"`
|
||||||
ExternalID string `json:"external_id"`
|
ExternalID string `json:"external_id"`
|
||||||
ZoneID string `json:"zone_id"`
|
ZoneID string `json:"zone_id"`
|
||||||
Kind Kind `json:"kind"`
|
Kind Kind `json:"kind"`
|
||||||
Readings map[string]float64 `json:"readings,omitempty"`
|
Readings map[string]float64 `json:"readings,omitempty"`
|
||||||
|
Discrete bool `json:"discrete,omitempty"`
|
||||||
Power bool `json:"power,omitempty"`
|
Power bool `json:"power,omitempty"`
|
||||||
SupportsLevel bool `json:"supports_level,omitempty"`
|
SupportsLevel bool `json:"supports_level,omitempty"`
|
||||||
Level float64 `json:"level,omitempty"`
|
Level float64 `json:"level,omitempty"`
|
||||||
|
// Pin is a free-text label the user assigns (e.g. "GPIO25") for extra
|
||||||
|
// devices — purely cosmetic (shown on the board/card), not validated
|
||||||
|
// against real ESP32 pin capabilities or checked for collisions with
|
||||||
|
// the four core pins.
|
||||||
|
Pin string `json:"pin,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot is the full board state pushed to the browser (initial load and
|
// Snapshot is the full state pushed to the browser (initial load and every
|
||||||
// every SSE update) — simpler than diffing for a board this small.
|
// SSE update) — simpler than diffing for a board this small.
|
||||||
type Snapshot struct {
|
type Snapshot struct {
|
||||||
Devices []Device `json:"devices"`
|
Devices []Device `json:"devices"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Store struct {
|
type Store struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
devices map[string]*Device // keyed by Slot
|
devices map[string]*Device
|
||||||
|
order []string // rendering order: core slots first, then extras oldest-first
|
||||||
|
nextExtra int
|
||||||
|
|
||||||
subMu sync.Mutex
|
subMu sync.Mutex
|
||||||
subs map[chan Snapshot]struct{}
|
subs map[chan Snapshot]struct{}
|
||||||
@@ -46,24 +66,26 @@ type Store struct {
|
|||||||
func NewStore() *Store {
|
func NewStore() *Store {
|
||||||
return &Store{
|
return &Store{
|
||||||
devices: map[string]*Device{
|
devices: map[string]*Device{
|
||||||
"sensor": {Slot: "sensor", ExternalID: "sensor-1", ZoneID: "1", Kind: KindSensor,
|
"sensor": {Slot: "sensor", Core: true, ExternalID: "sensor-1", ZoneID: "1", Kind: KindSensor,
|
||||||
Readings: map[string]float64{"temperature": 24, "humidity": 55}},
|
Readings: map[string]float64{"temperature": 24, "humidity": 55}},
|
||||||
"fan": {Slot: "fan", ExternalID: "fan-1", ZoneID: "1", Kind: KindActuator, SupportsLevel: true},
|
"fan": {Slot: "fan", Core: true, ExternalID: "fan-1", ZoneID: "1", Kind: KindActuator, SupportsLevel: true},
|
||||||
"light": {Slot: "light", ExternalID: "light-1", ZoneID: "1", Kind: KindActuator, SupportsLevel: true},
|
"light": {Slot: "light", Core: true, ExternalID: "light-1", ZoneID: "1", Kind: KindActuator, SupportsLevel: true},
|
||||||
"pump": {Slot: "pump", ExternalID: "pump-1", ZoneID: "1", Kind: KindActuator},
|
"pump": {Slot: "pump", Core: true, ExternalID: "pump-1", ZoneID: "1", Kind: KindActuator},
|
||||||
},
|
},
|
||||||
|
order: append([]string(nil), coreSlots...),
|
||||||
|
nextExtra: 1,
|
||||||
subs: make(map[chan Snapshot]struct{}),
|
subs: make(map[chan Snapshot]struct{}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot returns a deep-enough copy of the current board state (readings
|
// Snapshot returns a deep-enough copy of the current state (readings maps
|
||||||
// map is copied so callers can't mutate internal state through it).
|
// are copied so callers can't mutate internal state through them).
|
||||||
func (s *Store) Snapshot() Snapshot {
|
func (s *Store) Snapshot() Snapshot {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
devices := make([]Device, 0, len(s.devices))
|
devices := make([]Device, 0, len(s.order))
|
||||||
for _, slot := range []string{"sensor", "fan", "light", "pump"} {
|
for _, slot := range s.order {
|
||||||
d := *s.devices[slot]
|
d := *s.devices[slot]
|
||||||
if d.Readings != nil {
|
if d.Readings != nil {
|
||||||
readings := make(map[string]float64, len(d.Readings))
|
readings := make(map[string]float64, len(d.Readings))
|
||||||
@@ -89,9 +111,72 @@ func (s *Store) Device(slot string) (Device, bool) {
|
|||||||
return *d, true
|
return *d, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeviceBySlotExternalID finds the slot name for a given external_id, used
|
// AddSensorDevice creates a new standalone sensor device — its own MQTT
|
||||||
// to route an incoming MQTT command (which only carries the external_id in
|
// device_id, one named reading (analog or discrete, both just a float64
|
||||||
// its topic) back to a board slot.
|
// underneath — the wire format doesn't distinguish them). Returns the new
|
||||||
|
// slot name. Mirrors adding a second physical sensor to the platform (a
|
||||||
|
// new Device row with its own external_id), not another sensor_type
|
||||||
|
// tacked onto an existing device.
|
||||||
|
func (s *Store) AddSensorDevice(signalName, externalID, zoneID, pin string, discrete bool) string {
|
||||||
|
s.mu.Lock()
|
||||||
|
slot := s.newExtraSlot()
|
||||||
|
s.devices[slot] = &Device{
|
||||||
|
Slot: slot, Core: false, ExternalID: externalID, ZoneID: zoneID, Kind: KindSensor,
|
||||||
|
Readings: map[string]float64{signalName: 0}, Discrete: discrete, Pin: pin,
|
||||||
|
}
|
||||||
|
s.order = append(s.order, slot)
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.broadcast()
|
||||||
|
return slot
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddActuatorDevice creates a new standalone output device — same idea as
|
||||||
|
// AddSensorDevice but for the output side: it only reacts to real
|
||||||
|
// devices/{id}/commands from the platform, same as the four board actuators.
|
||||||
|
func (s *Store) AddActuatorDevice(externalID, zoneID, pin string, supportsLevel bool) string {
|
||||||
|
s.mu.Lock()
|
||||||
|
slot := s.newExtraSlot()
|
||||||
|
s.devices[slot] = &Device{
|
||||||
|
Slot: slot, Core: false, ExternalID: externalID, ZoneID: zoneID, Kind: KindActuator,
|
||||||
|
SupportsLevel: supportsLevel, Pin: pin,
|
||||||
|
}
|
||||||
|
s.order = append(s.order, slot)
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.broadcast()
|
||||||
|
return slot
|
||||||
|
}
|
||||||
|
|
||||||
|
// newExtraSlot must be called with s.mu held.
|
||||||
|
func (s *Store) newExtraSlot() string {
|
||||||
|
slot := fmt.Sprintf("extra-%d", s.nextExtra)
|
||||||
|
s.nextExtra++
|
||||||
|
return slot
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveDevice deletes an extra sensor or actuator device. Core board
|
||||||
|
// slots can't be removed (they're wired to a physical pin, not optional).
|
||||||
|
func (s *Store) RemoveDevice(slot string) bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
d, ok := s.devices[slot]
|
||||||
|
if !ok || d.Core {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
delete(s.devices, slot)
|
||||||
|
for i, sl := range s.order {
|
||||||
|
if sl == slot {
|
||||||
|
s.order = append(s.order[:i], s.order[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.broadcast()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// SlotForExternalID finds the slot name for a given external_id, used to
|
||||||
|
// route an incoming MQTT command (which only carries the external_id in
|
||||||
|
// its topic) back to a device.
|
||||||
func (s *Store) SlotForExternalID(externalID string) (string, bool) {
|
func (s *Store) SlotForExternalID(externalID string) (string, bool) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
@@ -117,7 +202,7 @@ func (s *Store) SetIdentity(slot, externalID, zoneID string) {
|
|||||||
s.broadcast()
|
s.broadcast()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetReading updates one sensor_type's value for the sensor slot.
|
// SetReading updates one sensor_type's value on a sensor device.
|
||||||
func (s *Store) SetReading(slot, sensorType string, value float64) {
|
func (s *Store) SetReading(slot, sensorType string, value float64) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if d, ok := s.devices[slot]; ok && d.Kind == KindSensor {
|
if d, ok := s.devices[slot]; ok && d.Kind == KindSensor {
|
||||||
@@ -130,17 +215,17 @@ func (s *Store) SetReading(slot, sensorType string, value float64) {
|
|||||||
// ApplyCommand applies an incoming {"action": ..., "level": ...} command
|
// ApplyCommand applies an incoming {"action": ..., "level": ...} command
|
||||||
// (as published by device-control-service to devices/{id}/commands) to the
|
// (as published by device-control-service to devices/{id}/commands) to the
|
||||||
// actuator identified by externalID. Returns the resulting reported state
|
// actuator identified by externalID. Returns the resulting reported state
|
||||||
// (for the ack payload) and whether externalID matched a known slot.
|
// (for the ack payload) and whether externalID matched a known device.
|
||||||
func (s *Store) ApplyCommand(externalID, action string, level float64, hasLevel bool) (map[string]any, bool) {
|
func (s *Store) ApplyCommand(externalID, action string, level float64, hasLevel bool) (map[string]any, bool) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
var slot *Device
|
var target *Device
|
||||||
for _, d := range s.devices {
|
for _, d := range s.devices {
|
||||||
if d.ExternalID == externalID {
|
if d.ExternalID == externalID {
|
||||||
slot = d
|
target = d
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if slot == nil {
|
if target == nil {
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
@@ -148,14 +233,14 @@ func (s *Store) ApplyCommand(externalID, action string, level float64, hasLevel
|
|||||||
reported := map[string]any{}
|
reported := map[string]any{}
|
||||||
switch action {
|
switch action {
|
||||||
case "turn_on":
|
case "turn_on":
|
||||||
slot.Power = true
|
target.Power = true
|
||||||
reported["power"] = "on"
|
reported["power"] = "on"
|
||||||
case "turn_off":
|
case "turn_off":
|
||||||
slot.Power = false
|
target.Power = false
|
||||||
reported["power"] = "off"
|
reported["power"] = "off"
|
||||||
case "set_level":
|
case "set_level":
|
||||||
if hasLevel {
|
if hasLevel {
|
||||||
slot.Level = level
|
target.Level = level
|
||||||
reported["level"] = level
|
reported["level"] = level
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user