This commit is contained in:
Mobkom21 2026-05-12 21:23:54 +02:00
commit e8cedc7de1
7 changed files with 5792 additions and 1833 deletions

File diff suppressed because it is too large Load Diff

View File

@ -9,13 +9,11 @@
#show: metropolis-theme.with(
aspect-ratio: "16-9",
config-info(
title: [GPS-Tracking und Impact-Detection mit Raspberry Pi],
title: [Getränkeautomat mit dem Raspberry Pi],
// title: [Daten mit 4G],
subtitle: [Mobile Kommunikationsnetze],
author: [NOAH BALSIGER, THOMAS ZWICKER],
date: datetime.today(),
// institution: [FHGR],
// logo: image("img/logo.png", width: 2em),
),
)
@ -37,9 +35,9 @@
line((10, -3), (4, -3), mark: (end: ">"), fill: blue, stroke: orange + 10pt)
})
== 4G / 5G
// == 4G / 5G
== 4G vs. 5G: technische Unterschiede
#slide[
= 4G vs. 5G: technische Unterschiede
#table(
columns: 3,
@ -55,8 +53,8 @@
5G ist nicht nur „schnelleres 4G“, sondern eine flexiblere Netzarchitektur
für hohe Datenraten, tiefe Latenz und viele gleichzeitige Geräte.
]
== 5G NSA vs. SA
#slide[
= 5G NSA vs. SA
*Non-Standalone (NSA):*
- 5G NR wird zusätzlich zu LTE verwendet
@ -77,8 +75,8 @@
`UE -> 5G gNodeB -> 5GC`
]
]
== Mesh, Sidelink und Device-to-Device
#slide[
= Mesh, Sidelink und Device-to-Device
Normales 5G ist *kein öffentliches Mesh-Netz*.
@ -105,41 +103,105 @@
Smartphones zur Antenne weiter.
]
== öffentliche IP
== Carrier-Grade NAT (CGNAT)
=======
== Öffentliche IP & Carrier-Grade NAT (CGNAT)
>>>>>>> Stashed changes
#grid(columns: (1fr,)*1, gutter: 10pt,
[#align(center)[#image("img/cgnat.png", width: 80%)]],
)
== VPN Wireguard / Tailscale
#grid(columns: (1fr,)*1, gutter: 10pt,
[#align(center)[#image("img/vpn.png", width: 60%)]],
#grid(columns: (1fr, 1fr), gutter: 10pt,
[
*Herausforderung:*
- Pi hat keine öffentliche IPv4-Adresse (CGNAT)
// - Eingehende Verbindungen werden vom Provider blockiert
*Lösung: Wireguard*
- *Peer-to-Peer:* Pi baut Verbindung aktiv nach aussen auf
- *UDP-basiert:* Effizient und schnell auf mobilen Verbindungen
- *Roaming:* Verbindung bleibt stabil, auch wenn die Funkzelle wechselt
],
[#align(center)[#image("img/vpn.png", width: 100%)]],
)
== Dynamic DNS
#grid(columns: (1fr,)*1, gutter: 10pt,
[#align(center)[#image("img/dns.png", width: 60%)]],
#grid(columns: (1.5fr, 1fr), gutter: 10pt,
[
- Mobilfunk-IPs sind meist dynamisch und wechseln oft
- *Dynamic DNS (DDNS):* Verknüpft wechselnde IP mit festem Hostnamen
- *Vorteil:* Einfacher Zugriff über `mobkom.xxx.de` statt Kryptischer IP-Adressen
],
[#align(center)[#image("img/dns.png", width: 100%)]],
)
== Externer Broker
== Security mqtt
== MQTT Sicherheit & Externer Broker
#slide[
// = Mehrstufiges Sicherheitskonzept
#grid(columns: (1fr, 1fr), gutter: 15pt,
[
*Transport-Ebene (TLS/SSL)*
- Verschlüsselung des gesamten Kanals
- Schützt auch Metadaten (Topics)
- Zertifikatsmanagement erforderlich
- Sicher gegen Man-in-the-Middle
],
[
*Daten-Ebene (Payload)*
- Verschlüsselung direkt in Python (AES)
- Schutz vor kompromittierten Brokern
- "Zero Trust": Broker sieht nur Chiffre
- Sehr geringer Overhead
]
)
#v(1em)
*Sicherung des externen Brokers:*
- *Authentifizierung:* Kein Zugriff ohne gültige Credentials
- *ACLs (Access Control Lists):* Pi darf nur auf `/getraenke/status` schreiben
- *Vorteil Extern:* Erreichbarkeit ohne Port-Forwarding/VPN-Zwang für Clients
]
== GPS
#slide[
#grid(columns: (1fr, 1fr), gutter: 15pt,
[
*Ortung via LTE-Modul*
== Aufbau
- Integriertes GNSS-Modul auf dem Waveshare HAT
- *Datenformat:* NMEA-0183 (Standard-Textprotokoll)
- *Anwendungsfall:* Tracking des Automaten oder Diebstahlschutz
- *Herausforderung:* GPS-Antenne benötigt meist Sichtkontakt zum Himmel
],
[#align(center)[#image("img/csm_1_Laufzeitmessung_4b22a687fd.png", width: 80%)]],
)
]
// == Aufbau
// 3. Wichtiger technischer Unterschied: USB vs. UART
// Hier kommt ein entscheidender Punkt für deine Performance:
// - Über GPIO (UART): Wenn du den HAT nur oben aufsteckst, ist die Geschwindigkeit auf die serielle Rate begrenzt (meistens max. 115.200 bis 4.000.000 bps). Das ist für Sensordaten okay, aber für "echtes" Surfen sehr langsam.
// - Über USB-Kabel: Wenn du das mitgelieferte USB-Kabel zusätzlich zwischen HAT und Pi einsteckst, wird das Modul als echtes Netzwerkgerät erkannt (WWAN). Damit erreichst du die volle 4G-Geschwindigkeit (bis zu 150 Mbit/s).
== Hardware & Aufbau
#slide[
*Die technischen Tücken*
#table(
columns: (1fr, 1fr),
[*Anbindung via GPIO (UART)*], [*Anbindung via USB*],
[Einfaches Aufstecken], [Zusätzliches Kabel nötig],
[Max. 115'200 Baud (0.1 Mbit/s)], [WWAN-Interface (bis 150 Mbit/s)],
[GPS/LTE teilen sich serielle Ressource], [Parallele Datenströme möglich],
[Nur für kleine Sensordaten (MQTT)], [Massentauglich (Streaming/Updates)],
)
*Wichtig:* Ohne USB-Kabel können GPS und LTE nicht simultan mit voller Performance genutzt werden.
]
== Demo
#grid(columns: (1fr,)*2, gutter: 10pt,
// [#align(center)[#image("img/lts.png", width: 80%)]],
#grid(columns: (1fr,)*1, gutter: 10pt,
[#align(center)[#image("img/demo_aufbau.svg", width: 100%)]],
// [#align(center)[#image("img/seitenansicht.png", width: 100%)]],
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

120
img/demo_aufbau.drawio Normal file
View File

@ -0,0 +1,120 @@
<mxfile host="Electron" agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/29.6.1 Chrome/142.0.7444.265 Electron/39.8.10 Safari/537.36" version="29.6.1">
<diagram name="Seite-1" id="gKl-JJAu4m5QpsmeQUmM">
<mxGraphModel dx="1425" dy="988" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="1654" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="fu6FnLP_hD63TvWQ5mC9-20" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=none;dashed=1;strokeWidth=2;" value="" vertex="1">
<mxGeometry height="480" width="400" x="1000" y="280" as="geometry" />
</mxCell>
<mxCell id="fu6FnLP_hD63TvWQ5mC9-1" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=none;dashed=1;strokeWidth=2;" value="" vertex="1">
<mxGeometry height="480" width="400" x="120" y="280" as="geometry" />
</mxCell>
<mxCell id="fu6FnLP_hD63TvWQ5mC9-2" parent="1" style="text;html=1;whiteSpace=wrap;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;rounded=0;" value="&lt;font style=&quot;font-size: 24px;&quot;&gt;Raspberry PI&lt;/font&gt;" vertex="1">
<mxGeometry height="100" width="170" x="130" y="260" as="geometry" />
</mxCell>
<mxCell id="fu6FnLP_hD63TvWQ5mC9-5" edge="1" parent="1" source="fu6FnLP_hD63TvWQ5mC9-3" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" target="fu6FnLP_hD63TvWQ5mC9-4" value="">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="fu6FnLP_hD63TvWQ5mC9-7" edge="1" parent="1" source="fu6FnLP_hD63TvWQ5mC9-3" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;dashed=1;strokeWidth=1;" target="fu6FnLP_hD63TvWQ5mC9-6" value="">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="220" y="600" />
<mxPoint x="320" y="600" />
<mxPoint x="320" y="670" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="fu6FnLP_hD63TvWQ5mC9-9" edge="1" parent="1" source="fu6FnLP_hD63TvWQ5mC9-3" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" target="fu6FnLP_hD63TvWQ5mC9-8" value="">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="fu6FnLP_hD63TvWQ5mC9-3" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="Python" vertex="1">
<mxGeometry height="60" width="120" x="160" y="500" as="geometry" />
</mxCell>
<mxCell id="fu6FnLP_hD63TvWQ5mC9-4" parent="1" style="whiteSpace=wrap;html=1;rounded=0;" value="Sens Hat" vertex="1">
<mxGeometry height="60" width="120" x="160" y="640" as="geometry" />
</mxCell>
<mxCell id="fu6FnLP_hD63TvWQ5mC9-6" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="LTE Hat" vertex="1">
<mxGeometry height="60" width="120" x="360" y="640" as="geometry" />
</mxCell>
<mxCell id="fu6FnLP_hD63TvWQ5mC9-11" edge="1" parent="1" source="fu6FnLP_hD63TvWQ5mC9-8" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" target="fu6FnLP_hD63TvWQ5mC9-10" value="">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="fu6FnLP_hD63TvWQ5mC9-8" parent="1" style="whiteSpace=wrap;html=1;rounded=0;" value="Mqtt Broker" vertex="1">
<mxGeometry height="60" width="120" x="160" y="360" as="geometry" />
</mxCell>
<mxCell id="fu6FnLP_hD63TvWQ5mC9-13" edge="1" parent="1" source="fu6FnLP_hD63TvWQ5mC9-10" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" target="fu6FnLP_hD63TvWQ5mC9-6" value="">
<mxGeometry relative="1" as="geometry">
<mxPoint x="420" y="500" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="fu6FnLP_hD63TvWQ5mC9-10" parent="1" style="whiteSpace=wrap;html=1;rounded=0;" value="VPN&lt;br&gt;Wireguard" vertex="1">
<mxGeometry height="60" width="120" x="360" y="360" as="geometry" />
</mxCell>
<mxCell id="fu6FnLP_hD63TvWQ5mC9-15" parent="1" style="rounded=1;whiteSpace=wrap;html=1;fillColor=none;dashed=1;strokeWidth=2;" value="" vertex="1">
<mxGeometry height="480" width="400" x="560" y="280" as="geometry" />
</mxCell>
<mxCell id="fu6FnLP_hD63TvWQ5mC9-16" parent="1" style="text;html=1;whiteSpace=wrap;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;rounded=0;" value="&lt;font style=&quot;font-size: 24px;&quot;&gt;WG&lt;/font&gt;" vertex="1">
<mxGeometry height="100" width="170" x="530" y="260" as="geometry" />
</mxCell>
<mxCell id="fu6FnLP_hD63TvWQ5mC9-19" edge="1" parent="1" source="fu6FnLP_hD63TvWQ5mC9-17" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" target="fu6FnLP_hD63TvWQ5mC9-18" value="">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="fu6FnLP_hD63TvWQ5mC9-17" parent="1" style="rounded=1;whiteSpace=wrap;html=1;" value="Raspberry PI&lt;br&gt;VPN Server" vertex="1">
<mxGeometry height="60" width="120" x="700" y="480" as="geometry" />
</mxCell>
<mxCell id="fu6FnLP_hD63TvWQ5mC9-18" parent="1" style="rounded=1;whiteSpace=wrap;html=1;" value="Modem" vertex="1">
<mxGeometry height="60" width="120" x="700" y="620" as="geometry" />
</mxCell>
<mxCell id="fu6FnLP_hD63TvWQ5mC9-21" parent="1" style="text;html=1;whiteSpace=wrap;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;rounded=0;" value="&lt;span style=&quot;font-size: 24px;&quot;&gt;FHGR&lt;/span&gt;" vertex="1">
<mxGeometry height="100" width="170" x="980" y="260" as="geometry" />
</mxCell>
<mxCell id="fu6FnLP_hD63TvWQ5mC9-24" edge="1" parent="1" source="fu6FnLP_hD63TvWQ5mC9-22" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" target="fu6FnLP_hD63TvWQ5mC9-23" value="">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="fu6FnLP_hD63TvWQ5mC9-22" parent="1" style="rounded=1;whiteSpace=wrap;html=1;" value="Laptop" vertex="1">
<mxGeometry height="60" width="120" x="1140" y="360" as="geometry" />
</mxCell>
<mxCell id="fu6FnLP_hD63TvWQ5mC9-29" edge="1" parent="1" source="fu6FnLP_hD63TvWQ5mC9-23" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" target="fu6FnLP_hD63TvWQ5mC9-28" value="">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="fu6FnLP_hD63TvWQ5mC9-23" parent="1" style="rounded=1;whiteSpace=wrap;html=1;" value="VPN&lt;br&gt;Wireguard" vertex="1">
<mxGeometry height="60" width="120" x="1140" y="500" as="geometry" />
</mxCell>
<mxCell id="fu6FnLP_hD63TvWQ5mC9-25" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="Funkmast&lt;br&gt;Swisscom" vertex="1">
<mxGeometry height="60" width="120" x="360" y="797" as="geometry" />
</mxCell>
<mxCell id="fu6FnLP_hD63TvWQ5mC9-26" edge="1" parent="1" source="fu6FnLP_hD63TvWQ5mC9-25" style="shape=flexArrow;endArrow=classic;startArrow=classic;html=1;rounded=0;exitX=0.5;exitY=0;exitDx=0;exitDy=0;" target="fu6FnLP_hD63TvWQ5mC9-6" value="">
<mxGeometry height="100" relative="1" width="100" as="geometry">
<mxPoint x="700" y="700" as="sourcePoint" />
<mxPoint x="800" y="600" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="fu6FnLP_hD63TvWQ5mC9-27" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="Internet" vertex="1">
<mxGeometry height="60" width="120" x="700" y="797" as="geometry" />
</mxCell>
<mxCell id="fu6FnLP_hD63TvWQ5mC9-28" parent="1" style="rounded=1;whiteSpace=wrap;html=1;" value="Eduroam" vertex="1">
<mxGeometry height="60" width="120" x="1140" y="640" as="geometry" />
</mxCell>
<mxCell id="fu6FnLP_hD63TvWQ5mC9-30" edge="1" parent="1" source="fu6FnLP_hD63TvWQ5mC9-25" style="shape=flexArrow;endArrow=classic;startArrow=classic;html=1;rounded=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" target="fu6FnLP_hD63TvWQ5mC9-27" value="">
<mxGeometry height="100" relative="1" width="100" as="geometry">
<mxPoint x="700" y="790" as="sourcePoint" />
<mxPoint x="800" y="690" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="fu6FnLP_hD63TvWQ5mC9-31" edge="1" parent="1" source="fu6FnLP_hD63TvWQ5mC9-27" style="shape=flexArrow;endArrow=classic;startArrow=classic;html=1;rounded=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;exitX=0.5;exitY=0;exitDx=0;exitDy=0;" target="fu6FnLP_hD63TvWQ5mC9-18" value="">
<mxGeometry height="100" relative="1" width="100" as="geometry">
<mxPoint x="700" y="790" as="sourcePoint" />
<mxPoint x="800" y="690" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="fu6FnLP_hD63TvWQ5mC9-32" edge="1" parent="1" source="fu6FnLP_hD63TvWQ5mC9-27" style="shape=flexArrow;endArrow=classic;startArrow=classic;html=1;rounded=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" target="fu6FnLP_hD63TvWQ5mC9-28" value="">
<mxGeometry height="100" relative="1" width="100" as="geometry">
<mxPoint x="700" y="790" as="sourcePoint" />
<mxPoint x="800" y="690" as="targetPoint" />
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

4
img/demo_aufbau.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 159 KiB

View File

@ -280,6 +280,138 @@
white-space: pre-wrap;
}
.selecta-warning {
padding: 10px;
margin-bottom: 12px;
border-radius: 12px;
background: rgba(255,176,32,.12);
border: 1px solid rgba(255,176,32,.28);
color: #ffd28a;
font-size: 13px;
min-height: 40px;
}
.selecta-warning.inactive {
background: rgba(47,209,124,.10);
border-color: rgba(47,209,124,.25);
color: #b6f7d1;
}
.warning-stack {
display: grid;
gap: 8px;
}
.warning-item {
padding: 9px 10px;
border-radius: 10px;
background: rgba(255,176,32,.13);
border: 1px solid rgba(255,176,32,.32);
color: #ffd28a;
}
.warning-item.empty {
background: rgba(255,92,92,.16);
border-color: rgba(255,92,92,.45);
color: #ffb7b7;
font-weight: 700;
}
.warning-title {
display: flex;
justify-content: space-between;
gap: 8px;
align-items: center;
}
.warning-message {
margin-top: 2px;
font-size: 12px;
opacity: .9;
}
.drink-list {
display: grid;
gap: 9px;
}
.drink-row {
display: grid;
grid-template-columns: 18px 1fr auto;
gap: 10px;
align-items: center;
padding: 10px;
border-radius: 12px;
background: rgba(0,0,0,.16);
border: 1px solid rgba(255,255,255,.05);
}
.drink-row.low {
border-color: rgba(255,176,32,.32);
background: rgba(255,176,32,.08);
}
.drink-row.empty {
border-color: rgba(255,92,92,.45);
background: rgba(255,92,92,.10);
}
.drink-color {
width: 16px;
height: 16px;
border-radius: 5px;
border: 1px solid rgba(255,255,255,.2);
}
.drink-info {
display: grid;
gap: 5px;
}
.drink-title {
display: flex;
justify-content: space-between;
gap: 8px;
color: var(--text);
font-size: 13px;
}
.drink-title .selected {
color: var(--cyan);
font-weight: 700;
}
.stockbar {
height: 8px;
border-radius: 999px;
background: rgba(255,255,255,.08);
overflow: hidden;
}
.stockbar-fill {
height: 100%;
background: var(--cyan);
border-radius: 999px;
width: 0%;
}
.stockbar-fill.low {
background: var(--orange) !important;
}
.stockbar-fill.empty {
background: var(--red) !important;
}
.selecta-tools {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 12px;
}
@media (max-width: 1200px) {
.dashboard { grid-template-columns: repeat(2, minmax(260px, 1fr)); }
}
@ -363,6 +495,29 @@
<div id="matrix" class="matrix"></div>
<div id="ledStatus" class="led-status">Status: -</div>
</section>
<section class="card">
<div class="card-head">
<div class="card-title"><h2>Selecta Automat</h2><span>MOBKOM/SELECTA/#</span></div>
<button class="sub-btn primary" data-sub="selecta">Abonnieren</button>
</div>
<div id="selectaWarning" class="selecta-warning inactive">Keine Warnung.</div>
<div class="selecta-tools">
<button id="selectaGetBtn">Status laden</button>
<button id="selectaResetBtn" class="primary">Alle auffüllen</button>
</div>
<div class="selecta-tools">
<button id="selectaOnBtn">Automat aktivieren</button>
<button id="selectaOffBtn" class="danger">Automat deaktivieren</button>
</div>
<div id="drinkList" class="drink-list"></div>
</section>
</main>
<div id="log" class="log">Bereit.</div>
@ -375,6 +530,12 @@
const TOPIC_LED_MATRIX = "MOBKOM/LED/set_matrix";
const TOPIC_LED_STATE = "MOBKOM/LED/state";
const TOPIC_LED_STATE_MATRIX = "MOBKOM/LED/state_matrix";
const TOPIC_SELECTA_STATE = "MOBKOM/SELECTA/state";
const TOPIC_SELECTA_WARNING = "MOBKOM/SELECTA/warning";
const TOPIC_SELECTA_REFILL = "MOBKOM/SELECTA/refill";
const TOPIC_SELECTA_RESET = "MOBKOM/SELECTA/reset";
const TOPIC_SELECTA_GET_STATE = "MOBKOM/SELECTA/get_state";
const TOPIC_SELECTA_ACTIVE = "MOBKOM/SELECTA/active";
const groups = {
gps: {
@ -410,6 +571,9 @@
},
led: {
filter: "MOBKOM/LED/state_matrix"
},
selecta: {
filter: "MOBKOM/SELECTA/#"
}
};
@ -424,13 +588,19 @@
const readAllBtn = document.getElementById("readAllBtn");
const clearBtn = document.getElementById("clearBtn");
const logBox = document.getElementById("log");
const selectaWarning = document.getElementById("selectaWarning");
const drinkList = document.getElementById("drinkList");
const selectaGetBtn = document.getElementById("selectaGetBtn");
const selectaResetBtn = document.getElementById("selectaResetBtn");
const selectaOnBtn = document.getElementById("selectaOnBtn");
const selectaOffBtn = document.getElementById("selectaOffBtn");
let client = null;
let isPainting = false;
let paintMode = "draw";
let patternBusy = false;
const subscribed = { gps: false, sens: false, imu: false, led: false };
const subscribed = { gps: false, sens: false, imu: false, led: false, selecta: false };
const valueEls = {};
const ledEls = [];
@ -683,6 +853,140 @@
}
}
function rgbToCssSelecta(rgb) {
return `rgb(${Number(rgb[0]) || 0}, ${Number(rgb[1]) || 0}, ${Number(rgb[2]) || 0})`;
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function renderSelectaWarnings(warnings) {
if (!warnings || warnings.length === 0) {
selectaWarning.classList.add("inactive");
selectaWarning.innerHTML = "Keine Warnung.";
return;
}
selectaWarning.classList.remove("inactive");
selectaWarning.innerHTML = `
<div class="warning-stack">
${warnings.map(w => {
const stock = Number(w.stock);
const isEmpty = stock <= 0 || w.severity === "empty";
const title = isEmpty ? "LEER" : "Tiefer Bestand";
const name = escapeHtml(w.name || w.id || "Getränk");
const message = escapeHtml(w.message || (isEmpty ? `${name} ist leer.` : `Nur noch ${stock}x ${name} übrig.`));
return `
<div class="warning-item ${isEmpty ? "empty" : ""}">
<div class="warning-title">
<span>${title}: ${name}</span>
<span>${Number.isFinite(stock) ? stock : "?"}</span>
</div>
<div class="warning-message">${message}</div>
</div>
`;
}).join("")}
</div>
`;
}
function handleSelectaWarning(payload) {
try {
const data = JSON.parse(payload);
if (Array.isArray(data.warnings)) {
renderSelectaWarnings(data.active ? data.warnings : []);
return;
}
if (data.active) {
renderSelectaWarnings([data]);
} else {
renderSelectaWarnings([]);
}
} catch (_) {
renderSelectaWarnings([{ name: "Warnung", stock: NaN, message: payload, severity: "low" }]);
}
}
function refillSelecta(id) {
publish(TOPIC_SELECTA_REFILL, JSON.stringify({ id: id, stock: 8 }));
}
function handleSelectaState(payload) {
try {
const data = JSON.parse(payload);
drinkList.innerHTML = "";
for (const d of data.drinks || []) {
const row = document.createElement("div");
row.className = "drink-row";
const stock = Number(d.stock);
const max = Number(d.max) || 8;
const warningLimit = Number(data.warning_limit) || 3;
if (stock <= 0) row.classList.add("empty");
else if (stock <= warningLimit) row.classList.add("low");
const color = document.createElement("div");
color.className = "drink-color";
color.style.background = rgbToCssSelecta(d.color || [0,0,0]);
const info = document.createElement("div");
info.className = "drink-info";
const title = document.createElement("div");
title.className = "drink-title";
title.innerHTML = `<span class="${d.selected ? "selected" : ""}">${d.selected ? "▶ " : ""}${escapeHtml(d.name)}</span><span>${stock}/${max}</span>`;
const bar = document.createElement("div");
bar.className = "stockbar";
const fill = document.createElement("div");
fill.className = "stockbar-fill";
fill.style.width = `${Math.max(0, Math.min(100, (stock / max) * 100))}%`;
fill.style.background = rgbToCssSelecta(d.color || [0,0,0]);
if (stock <= 0) fill.classList.add("empty");
else if (stock <= warningLimit) fill.classList.add("low");
bar.appendChild(fill);
info.appendChild(title);
info.appendChild(bar);
const btn = document.createElement("button");
btn.textContent = "Auffüllen";
btn.onclick = () => refillSelecta(d.id);
row.appendChild(color);
row.appendChild(info);
row.appendChild(btn);
drinkList.appendChild(row);
}
const stateWarnings = (data.drinks || [])
.filter(d => Number(d.stock) <= (Number(data.warning_limit) || 3))
.map(d => ({
id: d.id,
name: d.name,
stock: Number(d.stock),
limit: Number(data.warning_limit) || 3,
severity: Number(d.stock) <= 0 ? "empty" : "low",
message: Number(d.stock) <= 0
? `${d.name} ist leer.`
: `Nur noch ${d.stock}x ${d.name} übrig.`
}));
renderSelectaWarnings(stateWarnings);
} catch (e) {
log("Selecta state konnte nicht gelesen werden: " + payload);
}
}
function updateSubButton(key) {
const btn = document.querySelector(`[data-sub="${key}"]`);
if (!btn) return;
@ -707,6 +1011,9 @@
client.subscribe(topic, { qos: 0 });
subscribed[key] = true;
log("Subscribed: " + topic);
if (key === "selecta") {
setTimeout(() => publish(TOPIC_SELECTA_GET_STATE, "ok"), 200);
}
} else {
client.unsubscribe(topic);
subscribed[key] = false;
@ -743,6 +1050,10 @@
const payload = message.toString();
if (topic === TOPIC_LED_STATE || topic === TOPIC_LED_STATE_MATRIX) {
handleLedState(payload);
} else if (topic === TOPIC_SELECTA_STATE) {
handleSelectaState(payload);
} else if (topic === TOPIC_SELECTA_WARNING) {
handleSelectaWarning(payload);
} else if (valueEls[topic]) {
valueEls[topic].textContent = formatValue(payload);
}
@ -775,6 +1086,10 @@
disconnectBtn.addEventListener("click", disconnectMqtt);
clearBtn.addEventListener("click", clearMatrix);
readAllBtn.addEventListener("click", readAllPixels);
selectaGetBtn.addEventListener("click", () => publish(TOPIC_SELECTA_GET_STATE, "ok"));
selectaResetBtn.addEventListener("click", () => publish(TOPIC_SELECTA_RESET, "ok"));
selectaOnBtn.addEventListener("click", () => publish(TOPIC_SELECTA_ACTIVE, "on"));
selectaOffBtn.addEventListener("click", () => publish(TOPIC_SELECTA_ACTIVE, "off"));
for (const btn of document.querySelectorAll("[data-sub]")) {
btn.addEventListener("click", () => toggleSubscribe(btn.dataset.sub));