Initial push

This commit is contained in:
Ano-sys
2026-02-22 23:30:15 +01:00
commit 7a2d858dfb
58 changed files with 9896 additions and 0 deletions
+535
View File
@@ -0,0 +1,535 @@
# Handover Mobile Base Station
Dieses Repository ist eine vollständige Kopie eines fertigen Uni-Projekts zur Demonstration eines WLAN-Handover-Szenarios mit:
- einem mobilen Fahrzeug/Roboter (AlphaBot2 + Raspberry Pi + ESP32),
- mehreren ESP32-basierten Basisstationen,
- einem Webinterface zur Live-Visualisierung von Verbindungsdaten und Kamerabildern.
Das Projekt kombiniert Robotik, Embedded-Firmware, serielle Kommunikation, UDP-Weiterleitung und ein Web-Dashboard.
## Projektziel
Ziel ist es, ein mobiles System zu bauen, das sich zwischen zwei WLAN-Basisstationen bewegt und dabei:
- kontinuierlich Verbindungsdaten (RSSI, verbundene Basisstation, alternative Basisstation) überträgt,
- bei besserem Signal automatisch einen Handover ausführt,
- Kamerabilder vom Fahrzeug streamt,
- alle Daten in einem Webinterface visualisiert.
## Repository-Struktur
```text
HandoverMobileBaseStation/
├── doc_vault/ # Hardware-Doku, Datenblätter, Pinout-Grafiken
├── esp_firmware/ # Arduino-ESP32-Firmware (MBS1, MBS2, Fahrzeug)
│ ├── MBS1_stripped/
│ ├── MBS2_stripped/
│ └── car_stripped/
├── robot/ # Raspberry Pi / AlphaBot2 Python-Skripte
│ ├── AlphaBot2.py
│ ├── TRSensors.py
│ ├── line_follow.py
│ ├── pid_line_follow1.py
│ ├── IRremote.py
│ ├── cam_stream.py
│ └── ReadMe.md # Teil-Doku zum Robotik-Teil
└── webinterface/
└── cotmw/ # Vue 3 + Vite Frontend + Node Serial-Bridge
├── src/
└── serial/
```
## Gesamtarchitektur (vereinfacht)
```text
[Raspberry Pi am Roboter]
├─ Kamera -> cam_stream.py -> JSON (image) über USB-Serial (115200)
└─ AlphaBot2 / IR / Line-Follow (lokal auf Pi)
|
v
[ESP32 im Fahrzeug: car_stripped]
- sendet Telemetrie + Kamera-JSON per UDP (Port 6666)
- wechselt zwischen AP1/AP2 je nach RSSI
|
+---------+---------+
| |
v v
[MBS1 / AP1] [MBS2 / AP2]
| |
| (direkt) | (über MBS2_1 -> UART -> MBS2_2 -> AP1)
+---------+---------+
|
v
[ESP32 MBS1 per USB an PC]
|
v
[Node Serial Backend :6666]
|
v
[Vue Webinterface (Vite)]
```
Hinweis zu `MBS2`: Im Code ist die zweite Basisstation in zwei Rollen aufgeteilt (`MBS2_1` und `MBS2_2`). Dafür werden in der vollständigen Kette typischerweise zwei ESP32 mit derselben Sketch-Datei (aber unterschiedlichen `#define`-Modi) verwendet.
## Komponenten im Detail
### 1) `esp_firmware/` (ESP32 / Arduino)
### `MBS1_stripped` (AP1 + UDP -> USB-Serial Bridge)
Datei: `esp_firmware/MBS1_stripped/MBS1_stripped.ino`
Funktion:
- startet einen Access Point `ESP32-AP1`,
- hört auf UDP Port `6666`,
- schreibt empfangene UDP-Pakete auf `Serial` (USB, 115200 Baud),
- dient als zentrale Einspeisung ins Webinterface (über den PC-USB-Port).
Wichtige Parameter (im Code):
- SSID: `ESP32-AP1`
- Passwort: `PASS0000`
- AP-IP: `192.168.178.1`
- UDP-Port: `6666`
- Baudrate: `115200`
### `MBS2_stripped` (zweite Basisstation / Relay, zwei Modi)
Datei: `esp_firmware/MBS2_stripped/MBS2_stripped.ino`
Die Sketch enthält zwei Rollen, die über `#define` ausgewählt werden:
- `MBS2_1`: hostet `ESP32-AP2` (Access Point)
- `MBS2_2`: verbindet sich als Client mit `ESP32-AP1` und tunnelt Daten weiter
#### Modus `MBS2_1` (AP2)
- startet Access Point `ESP32-AP2`
- empfängt UDP-Pakete auf Port `6666`
- schreibt empfangene Daten auf `Serial` und `Serial2`
- kann damit Daten an eine zweite ESP32-Instanz weitergeben
#### Modus `MBS2_2` (Relay zu AP1)
- verbindet sich mit `ESP32-AP1`
- liest Daten von `Serial2`
- sendet diese Daten per UDP an AP1 (`192.168.178.1:6666`)
#### UART-Verbindung in `MBS2_stripped`
`Serial2` ist auf folgende Pins gelegt:
- RX: GPIO `18`
- TX: GPIO `17`
Siehe auch `doc_vault/esp32s3_pico_uart1_rx_tx.png`.
### `car_stripped` (Fahrzeug-ESP / Handover-Client)
Datei: `esp_firmware/car_stripped/car_stripped.ino`
Funktion:
- scannt verfügbare Netze (`ESP32-AP1`, `ESP32-AP2`)
- verbindet sich initial mit der stärkeren Basisstation
- sendet Statusdaten regelmäßig per UDP an das Gateway (`UDP_PORT 6666`)
- nimmt Kameradaten/JSON von `Serial` entgegen und leitet sie per UDP weiter
- führt automatischen Netzwechsel aus, wenn das andere Netz deutlich stärker ist
Handover-Logik (aus Code):
- Wechsel, wenn `otherRSSI` mindestens ca. `3 dBm` besser ist als aktuelles RSSI.
Gesendete Statusdaten (JSON, newline-terminiert):
```json
{"type":"text","IP":"192.168.178.x","Base Station":"ESP32-AP1","RSSI":"-56","Other Network":"ESP32-AP2","Other RSSI":"-68"}
```
Kameradaten (vom Raspberry Pi kommend) werden als JSON-Pakete weitergeleitet:
```json
{"type":"image","data":"<base64-jpeg>"}
```
### 2) `robot/` (Raspberry Pi + AlphaBot2)
Dieser Teil läuft auf dem Raspberry Pi auf dem Fahrzeug/Roboter.
### `cam_stream.py` (Kamera -> ESP über Serial)
Datei: `robot/cam_stream.py`
Funktion:
- liest Frames von einer Pi-Kamera (`Picamera2`),
- konvertiert zu Graustufen,
- komprimiert/verkürzt JPEGs so, dass Base64-Daten klein bleiben (im Code `MAX_B64 = 1200`),
- sendet die Daten als JSON (`type=image`) über Serial an den Fahrzeug-ESP,
- versucht bei Serial-Abbruch automatisch die Verbindung neu aufzubauen.
Wichtige Details:
- serielle Schnittstelle ist aktuell hartkodiert auf `/dev/ttyACM1`
- Baudrate: `115200`
- Ausgabeformat ist newline-terminiertes JSON (wichtig für den Parser im Backend)
### `line_follow.py` (regelbasierter Linienfolger)
Datei: `robot/line_follow.py`
Funktion:
- automatische Sensor-Kalibrierung (`TRSensor`)
- Erkennung schwarzer/weißer Linie
- einfache Korrekturlogik für Spurhaltung
- zusätzliche Erkennung stärkerer Kurven (90°-ähnliche Abbiegungen)
### `pid_line_follow1.py` (PID-Linienfolger)
Datei: `robot/pid_line_follow1.py`
Funktion:
- PID-Regelung für glatteres Folgen der Linie
- Suchroutine bei Linienverlust
- konfigurierbare Parameter (`Kp`, `Ki`, `Kd`, Geschwindigkeit, Timeouts)
### `IRremote.py` (Fernsteuerung + Start/Stopp von PID-Line-Follow)
Datei: `robot/IRremote.py`
Funktion:
- liest IR-Signale über GPIO
- steuert den AlphaBot2 manuell (vor/zurück/links/rechts/stop)
- passt PWM-Geschwindigkeit an
- startet `pid_line_follow1.py` als separaten Prozess per Tastendruck
- stoppt den PID-Line-Follow-Prozess erneut per Tastendruck
Hinweis:
- Die konkreten Tasten-Codes sind auf die verwendete Fernbedienung abgestimmt (NEC-artige IR-Codes im Skript).
### `AlphaBot2.py` und `TRSensors.py`
Dateien:
- `robot/AlphaBot2.py`
- `robot/TRSensors.py`
Funktion:
- Hardware-Abstraktion für Motoransteuerung (PWM, Fahrtrichtung)
- Ansteuerung und Kalibrierung des 5-Kanal-TR-Linienfolgesensors
### Vorhandene Teil-Doku
Datei: `robot/ReadMe.md`
Enthält zusätzliche Projektnotizen zu:
- Kamera-Streaming
- Line Follow (Entwicklungsideen)
- Fernbedienungssteuerung
- systemd/Daemon-Setup auf dem Raspberry Pi
### 3) `webinterface/cotmw/` (Vue 3 + Vite + Serial Backend)
Das Webinterface besteht aus zwei Teilen:
- einem Vue-Frontend (`webinterface/cotmw`)
- einem Node/Express-Backend für den Zugriff auf lokale Serial-Ports (`webinterface/cotmw/serial`)
### Frontend (Vue 3 + Vite)
Wichtige Dateien:
- `webinterface/cotmw/src/App.vue`
- `webinterface/cotmw/src/components/*`
- `webinterface/cotmw/vite.config.js`
Funktionen im UI:
- HUD mit Live-Werten:
- IP
- aktuelle Basisstation
- RSSI
- alternative Basisstation + RSSI
- Polling-Zähler
- Kamera-Widget (drag & drop)
- RSSI-Plot (aktuelle vs. alternative Basisstation)
- Topologie/Locations-Visualisierung (inkl. Handover-Animation)
- optionale Weiterleitung der Daten an externes System (`External.vue`)
#### Verstecktes Serial-Menü
Der Serial-Connector ist absichtlich versteckt und wird per Tastenkombination eingeblendet:
- `Ctrl + B` (Windows/Linux)
- `Cmd + B` (macOS)
Danach kann im UI:
- ein lokaler Serial-Port ausgewählt werden
- die Verbindung mit `115200` Baud aufgebaut werden
- der eingehende Datenstrom eingesehen werden
Das Frontend pollt anschließend standardmäßig alle `200 ms` den Backend-Endpunkt `/api/serial-utils/latest`.
### Backend (Node + Express + serialport)
Wichtige Dateien:
- `webinterface/cotmw/serial/server.js`
- `webinterface/cotmw/serial/serial.js`
- `webinterface/cotmw/serial/routes/serialUtils.js`
Funktion:
- listet lokale Serial-Ports
- verbindet sich mit ausgewähltem Port
- liest eingehende Zeilen (newline-basiert)
- speichert das zuletzt empfangene `text`-Paket und `image`-Paket
- stellt diese Daten per HTTP-API fürs Frontend bereit
API-Endpunkte (Port `6666`):
- `GET /api/serial-utils/serialports`
- `POST /api/serial-utils/connect` mit Body `{ "path": "/dev/tty..." }`
- `GET /api/serial-utils/latest`
#### Vite Proxy
In `webinterface/cotmw/vite.config.js` ist im Dev-Server ein Proxy konfiguriert:
- `/api` -> `http://localhost:6666`
Dadurch kann das Frontend lokal ohne CORS-Probleme auf das Serial-Backend zugreifen.
## Hardware-Übersicht (praktisch)
Je nach Aufbau werden typischerweise benötigt:
- 1x AlphaBot2 (oder kompatibles Chassis mit Motorsteuerung)
- 1x Raspberry Pi (z. B. Pi 4) auf dem Fahrzeug
- 1x Pi-Kamera (Picamera2-kompatibel)
- 1x TR-Linienfolgesensor (5-Kanal)
- 1x IR-Empfänger + passende Fernbedienung
- 1x ESP32 auf dem Fahrzeug (`car_stripped`)
- 1x ESP32 für `MBS1_stripped`
- 2x ESP32 für `MBS2_stripped` (einmal `MBS2_1`, einmal `MBS2_2`) bei vollem AP2-Relay-Aufbau
- 1x PC/Laptop für Webinterface + Node-Backend
## Software-Voraussetzungen
### PC / Laptop (Webinterface + Serial Backend)
- Node.js (empfohlen: aktuelle LTS-Version)
- npm
- Zugriff auf den USB-Serial-Port des MBS1-ESP32 (Linux: ggf. Gruppe `dialout`)
### Raspberry Pi (Robot / Kamera)
- Raspberry Pi OS
- Python 3
- `Picamera2` / `libcamera`
- `RPi.GPIO`
- `pyserial`
- `Pillow`
- optional/je nach Skript-Imports: `Flask`, `opencv-python`
In `robot/cam_stream.py` sind bereits Beispiel-Schritte für die Kamera-Umgebung notiert (APT + venv).
### ESP32-Entwicklung
- Arduino IDE mit ESP32-Boardpaket oder PlatformIO
- passende Board-Konfiguration für die verwendeten ESP32-Module
## Setup und Inbetriebnahme
### A) ESP32-Firmware flashen
1. `MBS1_stripped.ino` auf den ESP für AP1 flashen.
1. `MBS2_stripped.ino` einmal mit `#define MBS2_1` auf den AP2-ESP flashen.
1. `MBS2_stripped.ino` einmal mit `#define MBS2_2` auf den Relay-ESP flashen.
1. `car_stripped.ino` auf den Fahrzeug-ESP flashen.
Hinweise:
- SSIDs/Passwort sind im Code hartkodiert (`ESP32-AP1`, `ESP32-AP2`, `PASS0000`).
- UDP-Port ist durchgehend `6666`.
- Baudrate ist durchgehend `115200`.
### B) Verkabelung / Verbindungen herstellen
Mindestens notwendig:
- Fahrzeug-ESP per USB/Serial mit Raspberry Pi verbinden (für Kamera-/JSON-Übergabe)
- MBS1-ESP per USB/Serial mit PC/Laptop verbinden (für Webinterface-Datenquelle)
Für den vollen AP2-Relay-Aufbau zusätzlich:
- `MBS2_1` und `MBS2_2` per UART (`Serial2`) verbinden (Pins siehe Firmware / `doc_vault`)
### C) Serial-Backend starten (PC)
```bash
cd webinterface/cotmw/serial
npm install
node server.js
```
Das Backend läuft anschließend auf `http://localhost:6666`.
### D) Frontend starten (PC)
```bash
cd webinterface/cotmw
npm install
npm run dev
```
Danach im Browser die von Vite ausgegebene URL öffnen (typisch `http://localhost:5173`).
### E) Serial-Port im Webinterface verbinden
1. Webinterface öffnen.
1. `Ctrl + B` / `Cmd + B` drücken (Serial-Menü einblenden).
1. Den USB-Port des MBS1-ESP32 auswählen.
1. `Connect (Baud: 115200)` klicken.
Wenn Daten ankommen, sollten Telemetrie und Kamerabild im Dashboard erscheinen.
### F) Robotik-/Kamera-Skripte auf dem Raspberry Pi starten
Beispiele:
### Kamerastream zum Fahrzeug-ESP
```bash
cd robot
python3 cam_stream.py
```
### IR-Fernsteuerung + PID-Line-Follow-Start/Stopp
```bash
cd robot
python3 IRremote.py
```
### Direkter Start des PID-Line-Followers
```bash
cd robot
python3 pid_line_follow1.py
```
### Direkter Start des regelbasierten Line-Followers
```bash
cd robot
python3 line_follow.py
```
## Kommunikationsformate und Schnittstellen
### Serial (Pi -> Fahrzeug-ESP)
Format:
- newline-terminiertes JSON
- `115200` Baud
Beispiel:
```json
{"type":"image","data":"<base64-jpeg>"}\n
```
### UDP (Fahrzeug-ESP -> Basisstationen)
- Port: `6666`
- Payload: JSON-Strings (Text-Status und Kamera-Frames)
### HTTP (Web-Frontend -> Serial-Backend)
Basis-API (über Vite-Proxy als `/api/...`):
- `/api/serial-utils/serialports`
- `/api/serial-utils/connect`
- `/api/serial-utils/latest`
### Optional: Externe Datenweiterleitung (im Webinterface)
Komponente: `webinterface/cotmw/src/components/External.vue`
Funktion:
- sendet Änderungen von `rssi`, `other_rssi` und `image` an einen externen HTTP-Endpunkt
- Ziel-URL: `http://<ip>:<port>/api/send`
- Authentifizierung via Header `x-api-key`
Standardwerte im UI:
- IP: `138.68.114.176`
- Port: `3000`
## Bekannte Einschränkungen / technische Schulden
- Viele Parameter sind hartkodiert (SSID, Passwort, Ports, Serial-Pfade, IPs).
- `cam_stream.py` verwendet fest `/dev/ttyACM1`.
- Es gibt keine zentrale `.env`-Konfiguration.
- Das Serial-Backend hält nur das zuletzt empfangene `text`- und `image`-Paket im Speicher.
- Fehlerbehandlung und Logging sind für Demo/Prototyping ausgelegt, nicht für Produktion.
- Das Vue-Frontend enthält bewusst versteckte UI-Elemente (Serial-Menü per Shortcut).
- Die `webinterface/cotmw/package.json`-Scripts sind eher Frontend-orientiert; der Serial-Server wird separat gestartet.
## Troubleshooting
### Keine Daten im Webinterface
- Prüfen, ob `webinterface/cotmw/serial/server.js` läuft (Port `6666`).
- Prüfen, ob im Browser das Serial-Menü geöffnet und ein Port verbunden wurde (`Ctrl/Cmd + B`).
- Prüfen, ob der richtige USB-Port des MBS1-ESP32 gewählt wurde.
- Prüfen, ob Baudrate `115200` verwendet wird.
- Prüfen, ob tatsächlich newline-terminierte JSON-Pakete gesendet werden.
### Kamera erscheint nicht / instabil
- Kamerazugriff auf dem Raspberry Pi prüfen (`libcamera`, `Picamera2`).
- Serielle Verbindung zum Fahrzeug-ESP prüfen (`/dev/ttyACM1` in `robot/cam_stream.py` anpassen).
- Paketgröße kann kritisch sein: `MAX_B64` in `cam_stream.py` beeinflusst Bildqualität und Übertragbarkeit.
### ESP verbindet sich nicht mit Basisstationen
- SSID/Passwort im ESP-Code prüfen (`ESP32-AP1`, `ESP32-AP2`, `PASS0000`).
- Sicherstellen, dass beide Access Points gestartet sind.
- Versorgungsspannung und Antennen/Position prüfen (RSSI).
### Rechteprobleme bei Serial/GPIO (Linux)
- Benutzer in passende Gruppen aufnehmen (z. B. `dialout`, ggf. GPIO-spezifisch je Setup).
- Skripte testweise mit ausreichenden Rechten starten.
## Hinweise zur Weiterentwicklung
Sinnvolle nächste Schritte für eine robustere Version:
- zentrale Konfiguration (`.env` / Config-Dateien) für SSIDs, Ports, Serial-Geräte
- strukturiertere Paketprotokolle (Sequenznummern, Timestamps, Checks)
- besserer Image-Transport (Chunking statt einzelner kurzer Base64-Payloads)
- Telemetrie-Logging / Replay im Backend
- sauberer Start per `docker-compose` (Web + Serial-API, sofern Host-Serial durchgereicht)
- automatische Geräteerkennung und Health-Checks
## Zusatzmaterial im Repository
`doc_vault/` enthält u. a.:
- `esp32s3_pico_1_datasheet.pdf`
- `esp32s3_pico_uart1_rx_tx.png`
- weitere Projektdokumente (`doc_esp.odt`)
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

@@ -0,0 +1,51 @@
#include <Arduino.h>
#include <WiFi.h>
#include <lwip/sockets.h>
#define _SSID "ESP32-AP1"
#define _PASSWD "PASS0000"
#define UDP_PORT 6666
#define MTU 100000
IPAddress apIP(192,168,178,1);
IPAddress subnetMask(255,255,255,0);
static int udp_sock;
void udp_task(void* /*args*/) {
struct sockaddr_in sourceAddr;
socklen_t addrLen = sizeof(sourceAddr);
static uint8_t udp_buf[MTU];
while (true) {
int len = recvfrom(udp_sock, udp_buf, MTU - 1, 0, (struct sockaddr*)&sourceAddr, &addrLen);
if (len > 0) {
udp_buf[len] = '\n';
Serial.write(udp_buf, len + 1);
}
}
}
void setup() {
Serial.begin(115200);
while (!Serial) { delay(10); }
WiFi.mode(WIFI_AP);
WiFi.softAPConfig(apIP, apIP, subnetMask);
WiFi.softAP(_SSID, _PASSWD, /*channel*/1, /*hidden*/false, /*maxConn*/4);
udp_sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
struct sockaddr_in localAddr;
memset(&localAddr, 0, sizeof(localAddr));
localAddr.sin_family = AF_INET;
localAddr.sin_port = htons(UDP_PORT);
localAddr.sin_addr.s_addr = htonl(INADDR_ANY);
bind(udp_sock, (struct sockaddr*)&localAddr, sizeof(localAddr));
xTaskCreatePinnedToCore(udp_task, "UDPReceiver", 4096, nullptr, 5, nullptr, 1);
}
void loop() {
vTaskDelay(pdMS_TO_TICKS(1000));
}
@@ -0,0 +1,220 @@
#include <WiFi.h>
#include <WiFiUdp.h>
#define DEBUG
// #define MBS2_1 // AP hosting ESP32-AP2
#define MBS2_2 // Client connected to ESP32-AP1
#ifdef MBS2_1
#ifdef MBS2_2
#undef MBS2_2
#endif
#endif
#ifdef MBS2_2
#ifdef MBS2_1
#undef MBS2_1
#endif
#endif
#ifdef MBS2_1
#define _NAME "MBS2_1"
#define ap_ssid "ESP32-AP2"
#define ap_passwd "PASS0000"
IPAddress apIP(192, 168, 178, 1);
IPAddress subnetMask(255, 255, 255, 0);
#endif
#ifdef MBS2_2
#define _NAME "MBS2_2"
#define base_ssid "ESP32-AP1"
#define base_passwd "PASS0000"
#endif
#define MAX_PACKET_SIZE 1500
#define UDP_PORT 6666
WiFiUDP udp;
IPAddress localIP = IPAddress();
void (*routeMBS)(void);
#pragma region Access Point Functions
#ifdef MBS2_1
void init_wifi_access_point(){
WiFi.mode(WIFI_AP);
WiFi.softAPConfig(apIP, apIP, subnetMask);
WiFi.softAP(ap_ssid, ap_passwd, /* channel */ 1, /* hidden */ false, /* maxConn */ 4);
}
char *receive(){
int packetSize = udp.parsePacket();
if(packetSize <= 0) return NULL;
packetSize = packetSize > MAX_PACKET_SIZE ? MAX_PACKET_SIZE: packetSize;
char *buffer = (char*)malloc(packetSize + 1);
udp.read(buffer, packetSize);
buffer[packetSize] = '\0';
return buffer;
}
void send(char *buf){
Serial.write(buf);
Serial2.write(buf);
}
String readSerialUntil(const String& delimiter) {
String res = "";
unsigned long start = millis();
while (!res.endsWith(delimiter) && (millis() - start < 1000)) {
if (Serial.available()) {
char c = Serial.read();
res += c;
start = millis();
} else {
// yield the CPU so the watchdog wont fire
taskYIELD();
}
}
return res;
}
void doSerialReading(){
if(Serial.available()){
Serial2.print(readSerialUntil("\n"));
}
}
void MBS2_1_loop(){
#ifdef DEBUG
/*
To debug loop through instances
MBS2_1 Serial2 out -> MBS2_1 Serial2 in UDP out
*/
doSerialReading();
#endif
char *recv = receive();
if(recv != NULL){
log(String("Received: "));
log(recv);
send(recv);
free(recv);
recv = NULL;
}
free(recv); // NULL free is safe
vTaskDelay(pdMS_TO_TICKS(1));
}
#endif // #ifdef MBS2_1
#pragma endregion
#pragma region Client (Base Station) Functions
#ifdef MBS2_2
IPAddress apIP(192,168,178,1);
void init_wifi_base_station_connection(){
WiFi.mode(WIFI_STA);
WiFi.begin(base_ssid, base_passwd);
#ifdef DEBUG
size_t start = millis();
#endif
while(WiFi.status() != WL_CONNECTED){ // while esps aren't connected nothing can run either
#ifdef DEBUG
log(String("Trying to connect to: ") + String(base_ssid) + String("\nTime: ") + String(millis() - start));
Serial.flush();
#endif
// Serial.println("Trying to connect to: " + this->base_ssid + "\nTime: " + String(millis() - start) + "/" + String(connection_timeout));
delay(50);
}
if(WiFi.status() == WL_CONNECTED){
localIP = WiFi.localIP();
}
}
void send(const String& msg){
udp.beginPacket(apIP, UDP_PORT);
udp.write((uint8_t*)msg.c_str(), msg.length());
udp.endPacket();
}
String readSerialUntil(const String& delimiter) {
String res = "";
unsigned long start = millis();
while (!res.endsWith(delimiter) && (millis() - start < 1000)) {
if (Serial2.available()) {
char c = Serial2.read();
res += c;
start = millis();
} else {
// yield the CPU so the watchdog wont fire
taskYIELD();
}
}
return res;
}
void MBS2_2_loop(){
if(Serial2.available()){
String recv = readSerialUntil("\n");
log("Received: " + recv);
send(recv);
}
// always give os a tick
vTaskDelay(pdMS_TO_TICKS(1));
}
#endif // #ifdef MBS2_2
#pragma endregion
void log(String buf){
#ifdef DEBUG
Serial.println(buf);
#endif
}
void log(char *buf){
#ifdef DEBUG
Serial.write(buf);
#endif
}
void setup() {
#ifdef DEBUG
Serial.begin(115200);
#endif
Serial2.begin(115200, SERIAL_8N1, 18, 17);
#ifdef MBS2_1
init_wifi_access_point();
routeMBS = MBS2_1_loop;
#endif
#ifdef MBS2_2
init_wifi_base_station_connection();
routeMBS = MBS2_2_loop;
#endif
udp.begin(UDP_PORT);
}
void loop() {
#ifdef DEBUG
static size_t start = millis();
if(millis() - start >= 1000){
log(String(_NAME));
start = millis();
}
#endif
routeMBS();
taskYIELD();
}
+191
View File
@@ -0,0 +1,191 @@
#include <Arduino.h>
#include <WiFi.h>
#include <lwip/sockets.h>
#define _PASSWD "PASS0000"
#define UDP_PORT 6666
#define MTU 100000
WiFiUDP udp;
SemaphoreHandle_t netw_mtx;
String SSIDs[] = { "ESP32-AP1", "ESP32-AP2" };
int scanStatus = WIFI_SCAN_FAILED;
unsigned long lastScanRequest = 0;
int otherRSSI = -9999;
String otherSSID = "";
String readSerialUntil(const String& delimiter) {
String res = "";
unsigned long start = millis();
while (!res.endsWith(delimiter) && (millis() - start < 1000)) {
if (Serial.available()) {
char c = Serial.read();
res += c;
start = millis();
} else {
// yield the CPU so the watchdog wont fire
taskYIELD();
}
}
return res;
}
int findSSIDIndex(const String &ssid, size_t network_count) {
for(int i = 0; i < network_count; i++) {
if(WiFi.SSID(i) == ssid) return i;
}
return -1;
}
int getSignalStrengthFromStation(const String& ssid) {
if(ssid == "") {
if(WiFi.isConnected()) {
return WiFi.RSSI();
} else {
return -9999;
}
}
size_t found_network_count = WiFi.scanNetworks(false);
int index = -1;
if((index = findSSIDIndex(ssid, found_network_count)) == -1) return -9999;
return WiFi.RSSI(index);
}
String getAvailableInfo() {
String ret = "{\"type\":\"text\",\"IP\":\"";
ret += WiFi.localIP().toString();
ret += "\",\"Base Station\":\"";
ret += WiFi.SSID();
ret += "\",\"RSSI\":\"";
ret += WiFi.RSSI();
ret += "\",\"Other Network\":\"";
ret += otherSSID;
ret += "\",\"Other RSSI\":\"";
ret += otherRSSI;
ret += "\"}\n";
return ret;
}
void send_udp(void *args){
String* msg = static_cast<String*>(args);
udp.beginPacket(WiFi.gatewayIP(), UDP_PORT);
udp.write((uint8_t*)msg->c_str(), msg->length());
udp.endPacket();
}
void invoke_fn_mtx(SemaphoreHandle_t mtx, void (*fn)(void*), void* args){
xSemaphoreTake(mtx, portMAX_DELAY);
fn(args);
xSemaphoreGive(mtx);
}
void changeNetwork(){
if (otherRSSI < -1000) return;
if(WiFi.RSSI() < otherRSSI - 3){
String networkSwitchJson = "{\"type\": \"info\", \"data\": \"netsw\"}";
invoke_fn_mtx(netw_mtx, send_udp, (void*)&networkSwitchJson);
String prevSSID = WiFi.SSID();
otherRSSI = WiFi.RSSI();
udp.stop();
WiFi.disconnect();
WiFi.begin(otherSSID, _PASSWD);
if (WiFi.waitForConnectResult() == WL_CONNECTED) {
udp.begin(UDP_PORT);
otherSSID = prevSSID;
}
}
}
void udp_task(void* /*args*/) {
int start = millis();
while(true){
if(Serial.available()){
String recv = readSerialUntil("\n");
invoke_fn_mtx(netw_mtx, send_udp, (void*)&recv);
}
if(millis() - start >= 1000){
if(scanStatus == WIFI_SCAN_FAILED && millis() - lastScanRequest > 10000){
scanStatus = WiFi.scanNetworks(true);
lastScanRequest = millis();
}
int n = WiFi.scanComplete();
if(n >= 0){
for(int i = 0; i < n; i++){
if(WiFi.SSID(i) == otherSSID){
otherRSSI = WiFi.RSSI(i);
break;
}
}
WiFi.scanDelete();
scanStatus = WIFI_SCAN_FAILED;
}
String recv = getAvailableInfo();
invoke_fn_mtx(netw_mtx, send_udp, (void*)&recv);
start = millis();
}
vTaskDelay(pdMS_TO_TICKS(1));
}
}
void setup() {
Serial.begin(115200);
while (!Serial) { delay(10); }
netw_mtx = xSemaphoreCreateMutex();
WiFi.mode(WIFI_STA);
int ap1_rssi = -9999;
int ap2_rssi = -9999;
int n = WiFi.scanNetworks();
for(int i = 0; i < n; i++){
String ssid = WiFi.SSID(i);
int rssi = WiFi.RSSI(i);
if(ssid == SSIDs[0]) ap1_rssi = rssi;
if(ssid == SSIDs[1]) ap2_rssi = rssi;
}
WiFi.scanDelete();
if(ap1_rssi >= ap2_rssi){
WiFi.begin(SSIDs[0], _PASSWD);
otherSSID = SSIDs[1];
otherRSSI = ap2_rssi;
}
else{
WiFi.begin(SSIDs[1], _PASSWD);
otherSSID = SSIDs[0];
otherRSSI = ap1_rssi;
}
if(WiFi.waitForConnectResult() != WL_CONNECTED)
Serial.println("WiFi-Verbindung fehlgeschlagen!");
else{
Serial.print("Verbunden mit ");
Serial.print(WiFi.SSID());
Serial.print(" (RSSI ");
Serial.print(WiFi.RSSI());
Serial.println(" dBm)");
}
udp.begin(UDP_PORT);
xTaskCreatePinnedToCore( udp_task, "UDPReceiver", 4096, nullptr, 5, nullptr, 1);
}
void loop() {
changeNetwork();
vTaskDelay(pdMS_TO_TICKS(1000));
}
View File
+107
View File
@@ -0,0 +1,107 @@
import RPi.GPIO as GPIO
import time
class AlphaBot2(object):
def __init__(self,ain1=12,ain2=13,ena=6,bin1=20,bin2=21,enb=26):
self.AIN1 = ain1
self.AIN2 = ain2
self.BIN1 = bin1
self.BIN2 = bin2
self.ENA = ena
self.ENB = enb
self.PA = 50
self.PB = 50
GPIO.setmode(GPIO.BCM)
GPIO.setwarnings(False)
GPIO.setup(self.AIN1,GPIO.OUT)
GPIO.setup(self.AIN2,GPIO.OUT)
GPIO.setup(self.BIN1,GPIO.OUT)
GPIO.setup(self.BIN2,GPIO.OUT)
GPIO.setup(self.ENA,GPIO.OUT)
GPIO.setup(self.ENB,GPIO.OUT)
self.PWMA = GPIO.PWM(self.ENA,500)
self.PWMB = GPIO.PWM(self.ENB,500)
self.PWMA.start(self.PA)
self.PWMB.start(self.PB)
self.stop()
def forward(self):
self.PWMA.ChangeDutyCycle(self.PA)
self.PWMB.ChangeDutyCycle(self.PB)
GPIO.output(self.AIN1,GPIO.LOW)
GPIO.output(self.AIN2,GPIO.HIGH)
GPIO.output(self.BIN1,GPIO.LOW)
GPIO.output(self.BIN2,GPIO.HIGH)
def stop(self):
self.PWMA.ChangeDutyCycle(0)
self.PWMB.ChangeDutyCycle(0)
GPIO.output(self.AIN1,GPIO.LOW)
GPIO.output(self.AIN2,GPIO.LOW)
GPIO.output(self.BIN1,GPIO.LOW)
GPIO.output(self.BIN2,GPIO.LOW)
def backward(self):
self.PWMA.ChangeDutyCycle(self.PA)
self.PWMB.ChangeDutyCycle(self.PB)
GPIO.output(self.AIN1,GPIO.HIGH)
GPIO.output(self.AIN2,GPIO.LOW)
GPIO.output(self.BIN1,GPIO.HIGH)
GPIO.output(self.BIN2,GPIO.LOW)
def left(self):
self.PWMA.ChangeDutyCycle(30)
self.PWMB.ChangeDutyCycle(30)
GPIO.output(self.AIN1,GPIO.HIGH)
GPIO.output(self.AIN2,GPIO.LOW)
GPIO.output(self.BIN1,GPIO.LOW)
GPIO.output(self.BIN2,GPIO.HIGH)
def right(self):
self.PWMA.ChangeDutyCycle(30)
self.PWMB.ChangeDutyCycle(30)
GPIO.output(self.AIN1,GPIO.LOW)
GPIO.output(self.AIN2,GPIO.HIGH)
GPIO.output(self.BIN1,GPIO.HIGH)
GPIO.output(self.BIN2,GPIO.LOW)
def setPWMA(self,value):
self.PA = value
self.PWMA.ChangeDutyCycle(self.PA)
def setPWMB(self,value):
self.PB = value
self.PWMB.ChangeDutyCycle(self.PB)
def setMotor(self, left, right):
if((right >= 0) and (right <= 100)):
GPIO.output(self.AIN1,GPIO.HIGH)
GPIO.output(self.AIN2,GPIO.LOW)
self.PWMA.ChangeDutyCycle(right)
elif((right < 0) and (right >= -100)):
GPIO.output(self.AIN1,GPIO.LOW)
GPIO.output(self.AIN2,GPIO.HIGH)
self.PWMA.ChangeDutyCycle(0 - right)
if((left >= 0) and (left <= 100)):
GPIO.output(self.BIN1,GPIO.HIGH)
GPIO.output(self.BIN2,GPIO.LOW)
self.PWMB.ChangeDutyCycle(left)
elif((left < 0) and (left >= -100)):
GPIO.output(self.BIN1,GPIO.LOW)
GPIO.output(self.BIN2,GPIO.HIGH)
self.PWMB.ChangeDutyCycle(0 - left)
if __name__=='__main__':
Ab = AlphaBot2()
Ab.forward()
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
GPIO.cleanup()
+166
View File
@@ -0,0 +1,166 @@
import RPi.GPIO as GPIO
import threading
import time
import subprocess
import os, signal, sys
from pid_line_follow1 import *
from AlphaBot2 import AlphaBot2
from TRSensors import TRSensor
Ab = AlphaBot2()
IR = 17
PWM = 50
'''def move_and_stop(func, duration=0.5):
func()
time.sleep(duration)
Ab.stop()'''
def setup_gpio():
"""Initialisiert die GPIO-Pins."""
GPIO.setmode(GPIO.BCM)
GPIO.setwarnings(False)
GPIO.setup(IR, GPIO.IN)
def getkey():
if GPIO.input(IR) == 0:
count = 0
while GPIO.input(IR) == 0 and count < 200: #9ms
count += 1
time.sleep(0.00006)
if(count < 10):
#print("None returned")
return None
count = 0
while GPIO.input(IR) == 1 and count < 80: #4.5ms
count += 1
time.sleep(0.00006)
#print("High-Burst-Length:", count)
if (count < 20):
#print("repeat returned")
return "repeat"
idx = 0
cnt = 0
data = [0,0,0,0]
for i in range(0,32):
count = 0
while GPIO.input(IR) == 0 and count < 15: #0.56ms
count += 1
time.sleep(0.00006)
count = 0
while GPIO.input(IR) == 1 and count < 40: #0: 0.56mx
count += 1 #1: 1.69ms
time.sleep(0.00006)
if count > 7:
data[idx] |= 1<<cnt
if cnt == 7:
cnt = 0
idx += 1
else:
cnt += 1
# print data
if data[0]+data[1] == 0xFF and data[2]+data[3] == 0xFF: #check
print("OK")
return data[2]
return None
def stop_listener():
"""
Deine RemoteAPI muss hier so lange blocken, bis
ein KeyEvent eintrifft und dir den Code 0x43 liefert.
"""
while True:
code = getkey()
if code == 0x43:
os.kill(os.getpid(), signal.SIGINT)
return
setup_gpio()
print('IRremote Test Start ...')
Ab.stop()
last_key = 0
last_key_press_time = 0
current_action = None
try:
while True:
key = getkey()
# 0x43 == line_follow
if key == "repeat":
key = last_key
last_key_press_time = time.time()
elif key is not None:
print("getkey:")
print(hex(key))
last_key = key
last_key_press_time = time.time()
if key is not None:
last_key_press_time = time.time()
if key == 0x18 and current_action != "forward":
Ab.forward()
print("forward")
current_action = "forward"
elif key == 0x08 and current_action != "left":
Ab.left()
print("left")
current_action = "left"
elif key == 0x1c and current_action != "stop":
Ab.stop()
print("stop")
current_action = "stop"
elif key == 0x5a and current_action != "right":
Ab.right()
print("right")
current_action = "right"
elif key == 0x52 and current_action != "backward":
Ab.backward()
print("backward")
current_action = "backward"
elif key == 0x15:
if(PWM + 10 < 101):
PWM += 10
Ab.setPWMA(PWM)
Ab.setPWMB(PWM)
print("PWM:", PWM)
elif key == 0x07:
if(PWM - 10 > -1):
PWM -= 10
Ab.setPWMA(PWM)
Ab.setPWMB(PWM)
print("PWM:", PWM)
elif key == 0x43:
tr = TRSensor()
running = threading.Event()
process = subprocess.Popen(
["python", "pid_line_follow1.py"],
)
# follow_thread = threading.Thread(target=follow, args=(tr, Ab, running), daemon=True)
# follow_thread.start()
key = getkey()
while(key != 0x43):
key = getkey()
# running.set()
process.terminate()
Ab.setPWMA(PWM)
Ab.setPWMB(PWM)
time.sleep(0.05)
else:
if time.time() - last_key_press_time > 0.1 and current_action != "stop":
Ab.stop()
print("stopping bc no key is being pressed")
current_action = "stop"
except KeyboardInterrupt:
GPIO.cleanup()
+153
View File
@@ -0,0 +1,153 @@
# Robot
## Cam-stream
Files:
- cam_stream.py
### Ziel
Kamera läuft und schickt Bilder via seriell an den ESP.
### Voraussetzung
- Die Kamera 🤡
- ESP am Serial-Port `/dev/ttyACM1` (115200 Baud)
### Umsetzung
In Endlosschleife wird ein 640x480-Graustufen-Frame von der Kamera gelesen und als JPEG komprimiert bis die Größe unter 1200 Bytes liegt.\
Danach wird der Frame in JSON-Format über seriell an den ESP gesendet.\
Sollte es zu einem Verbindungsabbruch zwischen Pi und ESP kommen wird automatisch so lange eine Neuverbindung versucht, bis diese Verbindung wieder steht.
### Probleme während der Bearbeitung und ihre Lösungen
Die Kamera wurde nicht konstant erkannt (beim Suchen der Kamera wurde sie manchmal gefunden und manchmal nicht, meistens nicht).\
Eine Neuinstallation von Raspbian hat das Problem gelöst, allerdings wurde diesmal die full-version installiert.
-----------------------
## Line follow
Files:
- AlphaBot2.py
- TRSensors.py
- line_follow.py
### Ziel
Roboter soll in der Lage sein einer Linie auf dem Boden zu folgen,
ohne diesen zu verlieren
### Voraussetzung
Linienbreite deckt 3 der 5 Sensoren ab (die mittleren), äußeren Sensoren müssen den boden sehen.
### Umsetzung
#### erste Idee: (Verworfen)
Von 5 Sensoren müssen die mittleren 3 immer die Linie sehen.\
Mithilfe der äußeren Sensoren werden Korrekturen durchgeführt, damit der Roboter auf der Linie bleibt.
#### zweite Idee:
Es müssen wieder die mittleren 3 Sensoren auf der Linie sein.\
Diesmal wird die Korrektur aber von Sensor 1 und Sensor 3 durchgeführt (Index beginnt bei 0).\
Mithilfe der äußeren Sensoren, soll entschieden werden, ob eine Abzweigung existiert und falls sie existiert wird eine stärkere Korrektur durchgeführt damit der Roboter sich richtig dreht.
### Probleme während der Bearbeitung und ihre Lösungen
#### erste Idee:
Der Roboter hat zu sehr gezappelt und es war schwer zu unterscheiden ob nur leicht korrigiert werden musste oder ob die Linie die Richtung gewechselt hat.
Das Problem sollte durch die zweite Idee gelöst werden.
#### zweite Idee: (Idee wurde vereinfacht auf nur starke Kurven = Roboter muss eine 90° Drehung machen um zu folgen)
Der Roboter interpretiert, je nach Untergrund, Kurven falsch.\
Obwohl keine Kurve vorhanden ist biegt er doch ab.\
Wenn eine Kurve vorhanden ist nimmt er diese nicht korrekt war und bleibt am Ende der Linie stehen.
-------------------
## remote steering
Files:
- AlphaBot2.py
- IRremote.py
### Ziel
Steuerung des kompletten Roboters über Fernbedienung.
Möglich machen:
- Roboter bewegen
- Linienfolgen starten und stoppen + Kalibrierung manuell starten
- (Kameraausrichtung ermöglichen)
### Voraussetzung
Die Fernbedienung 🤡
### Umsetzung Buttons
- play/pause = starten bzw. stoppen des line follows
- 2 = geradeaus fahren
- 4 = nach links drehen um die eigene Achse
- 6 = nach rechts drehen um die eigene Achse
- 8 = rückwärts fahren
### Probleme während der Bearbeitung und ihre Lösungen
Keine Probleme
-------------------
## Daemon
Files: Kein
### Ziel
Automatisches starten des Kamerastreams,
sowie die Steuerung über eine Fernbedienung.
### Umsetzung
1. **Service-Datei anlegen**
Erstelle unter `/etc/systemd/system/` eine Datei namens `<service_name>.service` mit folgendem Inhalt:
```ini
[Unit]
Description=<Kurzbeschreibung des Dienstes>
After=network.target
[Service]
Type=simple
User=<system_user>
WorkingDirectory=<Arbeitsverzeichnis>
ExecStart=<Pfad zu Interpreter> <Pfad zum Skript>
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
2. **Systemd neu einlesen**
``sudo systemctl daemon-reload``
3. **Dienst aktivierten (Boot-Start)**
``sudo systemctl enable <service_name>.service``
4. **Dienst sofort starten**
``sudo systemctl start <service_name>.service``
5. **Status und Logs prüfen**
- Status anzeigen:
``sudo systemctl status <service_name>.service``
- Live-Logs:
``sudo journalctl -u <service_name>.service -f``
### Probleme während der Bearbeitung und ihre Lösungen
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/python
# -*- coding:utf-8 -*-
import RPi.GPIO as GPIO
import time
CS = 5
Clock = 25
Address = 24
DataOut = 23
Button = 7
class TRSensor(object):
def __init__(self,numSensors = 5):
self.numSensors = numSensors
self.calibratedMin = [0] * self.numSensors
self.calibratedMax = [1023] * self.numSensors
self.last_value = 0
GPIO.setmode(GPIO.BCM)
GPIO.setwarnings(False)
GPIO.setup(Clock,GPIO.OUT)
GPIO.setup(Address,GPIO.OUT)
GPIO.setup(CS,GPIO.OUT)
GPIO.setup(DataOut,GPIO.IN,GPIO.PUD_UP)
GPIO.setup(Button,GPIO.IN,GPIO.PUD_UP)
"""
Reads the sensor values into an array. There *MUST* be space
for as many values as there were sensors specified in the constructor.
Example usage:
unsigned int sensor_values[8];
sensors.read(sensor_values);
The values returned are a measure of the reflectance in abstract units,
with higher values corresponding to lower reflectance (e.g. a black
surface or a void).
"""
def AnalogRead(self):
value = [0]*(self.numSensors+1)
#Read Channel0~channel6 AD value
for j in range(0,self.numSensors+1):
GPIO.output(CS, GPIO.LOW)
for i in range(0,8):
#sent 8-bit Address
if i<4:
if(((j) >> (3 - i)) & 0x01):
GPIO.output(Address,GPIO.HIGH)
else:
GPIO.output(Address,GPIO.LOW)
else:
GPIO.output(Address,GPIO.LOW)
#read MSB 4-bit data
value[j] <<= 1
if(GPIO.input(DataOut)):
value[j] |= 0x01
GPIO.output(Clock,GPIO.HIGH)
GPIO.output(Clock,GPIO.LOW)
for i in range(0,4):
#read LSB 8-bit data
value[j] <<= 1
if(GPIO.input(DataOut)):
value[j] |= 0x01
GPIO.output(Clock,GPIO.HIGH)
GPIO.output(Clock,GPIO.LOW)
#no mean ,just delay
# for i in range(0,6):
# GPIO.output(Clock,GPIO.HIGH)
# GPIO.output(Clock,GPIO.LOW)
time.sleep(0.0001)
GPIO.output(CS,GPIO.HIGH)
for i in range(0,6):
value[i] >>= 2
# print (value[1:])
return value[1:]
"""
Reads the sensors 10 times and uses the results for
calibration. The sensor values are not returned; instead, the
maximum and minimum values found over time are stored internally
and used for the readCalibrated() method.
"""
def calibrate(self):
max_sensor_values = [0]*self.numSensors
min_sensor_values = [0]*self.numSensors
for j in range(0,10):
sensor_values = self.AnalogRead()
for i in range(0,self.numSensors):
# set the max we found THIS time
if((j == 0) or max_sensor_values[i] < sensor_values[i]):
max_sensor_values[i] = sensor_values[i]
# set the min we found THIS time
if((j == 0) or min_sensor_values[i] > sensor_values[i]):
min_sensor_values[i] = sensor_values[i]
# record the min and max calibration values
for i in range(0,self.numSensors):
if(min_sensor_values[i] > self.calibratedMin[i]):
self.calibratedMin[i] = min_sensor_values[i]
if(max_sensor_values[i] < self.calibratedMax[i]):
self.calibratedMax[i] = max_sensor_values[i]
"""
Returns values calibrated to a value between 0 and 1000, where
0 corresponds to the minimum value read by calibrate() and 1000
corresponds to the maximum value. Calibration values are
stored separately for each sensor, so that differences in the
sensors are accounted for automatically.
"""
def readCalibrated(self):
value = 0
#read the needed values
sensor_values = self.AnalogRead()
for i in range (0,self.numSensors):
denominator = self.calibratedMax[i] - self.calibratedMin[i]
if(denominator != 0):
value = (sensor_values[i] - self.calibratedMin[i])* 1000 / denominator
if(value < 0):
value = 0
elif(value > 1000):
value = 1000
sensor_values[i] = value
#print("readCalibrated",sensor_values)
return sensor_values
"""
Operates the same as read calibrated, but also returns an
estimated position of the robot with respect to a line. The
estimate is made using a weighted average of the sensor indices
multiplied by 1000, so that a return value of 0 indicates that
the line is directly below sensor 0, a return value of 1000
indicates that the line is directly below sensor 1, 2000
indicates that it's below sensor 2000, etc. Intermediate
values indicate that the line is between two sensors. The
formula is:
0*value0 + 1000*value1 + 2000*value2 + ...
--------------------------------------------
value0 + value1 + value2 + ...
By default, this function assumes a dark line (high values)
surrounded by white (low values). If your line is light on
black, set the optional second argument white_line to true. In
this case, each sensor value will be replaced by (1000-value)
before the averaging.
"""
def readLine(self, white_line = 0):
sensor_values = self.readCalibrated()
avg = 0
sum = 0
on_line = 0
for i in range(0,self.numSensors):
value = sensor_values[i]
if(white_line):
value = 1000-value
# keep track of whether we see the line at all
if(value > 200):
on_line = 1
# only average in values that are above a noise threshold
if(value > 50):
avg += value * (i * 1000); # this is for the weighted total,
sum += value; #this is for the denominator
if(on_line != 1):
# If it last read to the left of center, return 0.
if(self.last_value < (self.numSensors - 1)*1000/2):
#print("left")
self.last_value = 0
# If it last read to the right of center, return the max.
else:
#print("right")
self.last_value = (self.numSensors - 1)*1000
else:
self.last_value = avg/sum
return self.last_value,sensor_values
# Simple example prints accel/mag data once per second:
if __name__ == '__main__':
TR = TRSensor()
print("TRSensor Example")
while True:
try:
print(TR.AnalogRead())
time.sleep(0.2)
except KeyboardInterrupt:
break
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+98
View File
@@ -0,0 +1,98 @@
"""
Requirements Step by Step:
sudo apt update
sudo apt install libjpeg62-turbo-dev python3-prctl python3-picamera2 python3-libcamera libcamera-apps libcamera-dev
python3 -m venv --system-site-packages env
source env/bin/activate
pip install flask pillow
"""
from flask import Flask, Response
from picamera2 import Picamera2
from PIL import Image
import io
import base64
import serial
import time
import cv2
s = serial.Serial('/dev/ttyACM1', 115200, timeout=1) # serielle verbindung zum ESP aufbauen
time.sleep(2)
'''
Kamera Konfigurieren mit einem Format von 640x480
'''
picam2 = Picamera2()
config = picam2.create_preview_configuration(main={"size": (640, 480)})
picam2.configure(config)
picam2.start()
MAX_RAW = 1200
MAX_B64 = 1200
# TODO: map image to MAX_B64
def encode_image_to_b64_with_limit(img: Image.Image, max_b64: int = 1024, scale_step: float = 0.9, initial_quality: int = 30, min_quality: int = 5) -> str:
quality = initial_quality
working_img = img.copy()
while True:
buf = io.BytesIO()
working_img.save(buf, format="JPEG", quality=quality, optimize=True)
jpeg_bytes = buf.getvalue()
b64 = base64.b64encode(jpeg_bytes).decode("utf-8")
if len(b64) <= max_b64 or (working_img.width < 2 or working_img.height < 2):
return b64
# if len(jpeg_bytes) <= MAX_RAW:
# return jpeg_bytes
new_w = max(1, int(working_img.width * scale_step))
new_h = max(1, int(working_img.height * scale_step))
working_img = working_img.resize((new_w, new_h), Image.LANCZOS)
if quality > min_quality:
quality = max(min_quality, int(quality * scale_step))
def init_serial():
global s
s = None
while s is None:
try:
s = serial.Serial('/dev/ttyACM1', 115200, timeout=1)
except Exception:
time.sleep(1)
continue
def gen():
global s
while True:
arr = picam2.capture_array("main") # Einen frame holen
img = Image.fromarray(arr).convert("L") # Bild in Graustufen umwandeln
# TODO: skip config with 640x480
img = img.resize((640, 480), Image.LANCZOS)
b64 = encode_image_to_b64_with_limit(img, MAX_B64) # Bildqualität reduzieren bis es eine Größe von maximal MAX_B64 (1200) hat
print("Image_b64 size: " + str(len(b64)))
try:
s.write(("{\"type\":\"image\",\"data\":\"" + b64 + "\"}\n").encode("utf-8")) # Frame als JSON-String an den ESP verschicken
# s.write((b64 + "\n").encode("utf-8"))
except Exception:
'''
War das schreiben über seriell nicht möglich wird ein erneuter Verbindungsaufbau versucht bis die Verbindung wieder steht.
Es wird davon ausgegangen das wir die Verbindung verloren haben und deswegen das schreiben nicht möglich war.
'''
init_serial()
time.sleep(0.1)
if __name__ == "__main__":
# app.run(host="0.0.0.0", port=1234, threaded=True)
try:
gen()
except KeyboardInterrupt:
serial.close()
print("Uebertragung beenden")
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""
Line following robot for Raspberry Pi 4 using AlphaBot2 and TRSensor
Ohne LED-Visualisierung
"""
import time
import RPi.GPIO as GPIO
from AlphaBot2 import AlphaBot2
from TRSensors import TRSensor
MAX_SPEED = 25
CORRECTION_STRENGTH = 8
EXTRA_CURVE_CORRECTION = 2
LINE_DETECTED_THRESHOLD = 650
# Liste für gleitenden Durchschnitt
last_outer = []
def setup_gpio():
GPIO.setmode(GPIO.BCM)
GPIO.setwarnings(False)
def calibrate_sensors(tr, robot):
print("Auto-calibrating sensors...")
for i in range(5):
tr.calibratedMin[i] = 1023
tr.calibratedMax[i] = 0
for _ in range(3):
robot.setPWMA(20)
robot.setPWMB(15)
robot.forward()
for _ in range(30):
raw = tr.AnalogRead()
for i in range(5):
tr.calibratedMin[i] = min(tr.calibratedMin[i], raw[i])
tr.calibratedMax[i] = max(tr.calibratedMax[i], raw[i])
time.sleep(0.02)
robot.backward()
for _ in range(30):
raw = tr.AnalogRead()
for i in range(5):
tr.calibratedMin[i] = min(tr.calibratedMin[i], raw[i])
tr.calibratedMax[i] = max(tr.calibratedMax[i], raw[i])
time.sleep(0.02)
robot.stop()
print("Calibration complete.")
print(" Min:", tr.calibratedMin)
print(" Max:", tr.calibratedMax)
def detect_line_type(tr):
sensor_values = tr.readCalibrated()
center_avg = sum(sensor_values[1:4]) / 3
outer_avg = (sensor_values[0] + sensor_values[4]) / 2
white_line = outer_avg < center_avg
print("Detected line type:", "WHITE on BLACK" if white_line else "BLACK on WHITE")
return white_line
def compute_correction(sensors):
left = sensors[1]
right = sensors[3]
correction = 0
if abs(left - right) < 50:
correction = 0
elif left < LINE_DETECTED_THRESHOLD and right > LINE_DETECTED_THRESHOLD:
correction = -CORRECTION_STRENGTH
elif right < LINE_DETECTED_THRESHOLD and left > LINE_DETECTED_THRESHOLD:
correction = CORRECTION_STRENGTH
if sensors[0] < LINE_DETECTED_THRESHOLD:
correction -= EXTRA_CURVE_CORRECTION
elif sensors[4] < LINE_DETECTED_THRESHOLD:
correction += EXTRA_CURVE_CORRECTION
return correction
def perform_turn_if_needed(sensors, robot, position):
global last_outer
l = sensors[0]
r = sensors[4]
last_outer.append((l, r))
if len(last_outer) > 5:
last_outer.pop(0)
avg_l = sum(x for x, _ in last_outer) / len(last_outer)
avg_r = sum(y for _, y in last_outer) / len(last_outer)
delta = avg_r - avg_l
print(f"Turn-Check: AvgL={avg_l:.0f}, AvgR={avg_r:.0f}, Δ={delta:.0f}, pos={position:.1f}")
if not (1500 <= position <= 2500):
return False
if avg_l > 800 and avg_r > 800:
return False
if avg_r > 800 and avg_l < 500 and delta > 400:
print("→ 90° right-curve detected")
robot.stop()
time.sleep(0.1)
robot.right()
time.sleep(0.3)
robot.forward()
time.sleep(0.35)
return True
elif avg_l > 800 and avg_r < 500 and delta < -600 and sensors[2] < LINE_DETECTED_THRESHOLD:
print("← 90° left-curve detected")
robot.stop()
time.sleep(0.1)
robot.left()
time.sleep(0.3)
robot.forward()
time.sleep(0.35)
return True
return False
def line_follow_loop(tr, robot, white_line):
robot.forward()
while True:
position, sensors = tr.readLine(white_line=white_line)
print(f"Sensors: {sensors}, pos={position:.1f}")
if perform_turn_if_needed(sensors, robot, position):
robot.forward()
time.sleep(0.35)
robot.stop()
break
if all(s > LINE_DETECTED_THRESHOLD for s in sensors):
time.sleep(0.05)
robot.backward()
time.sleep(0.35)
robot.stop()
continue
correction = compute_correction(sensors)
left_speed = min(MAX_SPEED, max(0, MAX_SPEED + correction))
right_speed = min(MAX_SPEED, max(0, MAX_SPEED - correction))
robot.setPWMA(int(left_speed))
robot.setPWMB(int(right_speed))
time.sleep(0.02)
def line_follow(tr=None, robot=None):
if tr is None or robot is None:
setup_gpio()
robot = AlphaBot2()
tr = TRSensor()
robot.stop()
calibrate_sensors(tr, robot)
white_line = detect_line_type(tr)
print("Starting line follow...")
try:
line_follow_loop(tr, robot, white_line)
except KeyboardInterrupt:
print("Stopping and cleaning up...")
robot.stop()
GPIO.cleanup()
if __name__ == "__main__":
setup_gpio()
robot = AlphaBot2()
tr = TRSensor()
robot.stop()
calibrate_sensors(tr, robot)
input("Press Enter to continue...")
line_follow(tr, robot)
+119
View File
@@ -0,0 +1,119 @@
from enum import Enum
import enum
import time
import RPi.GPIO as GPIO
from AlphaBot2 import AlphaBot2
from TRSensors import TRSensor
import datetime
class LINE_MODE(Enum):
UNSET = -1
WHITE_LINE_MODE = 0
BLACK_LINE_MODE = 1
class STATE(Enum):
INIT_SUCCESS = 0
INIT_FAIL = -1
BTN_PIN = 7
GPIO.setmode(GPIO.BCM)
GPIO.setwarnings(False)
GPIO.setup(BTN_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP)
MAX_SPEED = 40
OVERDRIVE_SPEED = 50
MAX_REVERSE_SPEED = 20
MAX_CORRECTION_STRENGTH = 5
LineColorDescriptor: LINE_MODE = LINE_MODE.UNSET
CurrentCorrectionStrength: int = 1
logging: bool = True
LineSearchingDirection: int = 0 # -1 -> left, 1 -> right
TR = TRSensor()
Ab = AlphaBot2()
Ab.stop()
def log(msg: str) -> None:
if not logging: return;
print("-> Logger")
print(f"Timestamp: {datetime.datetime.now()}")
print(f"Message: {msg}")
print("<- Logger")
def LineModeBoolConverter(mode: LINE_MODE) -> bool:
return True if mode == LINE_MODE.WHITE_LINE_MODE else False
def setup() -> int:
log("Initializing TR!")
for i in range(1, 4):
TR.calibratedMin[i] = 1023
TR.calibratedMax[i] = 0
log("Calibration sensors!")
for _ in range(100):
values = TR.AnalogRead()
for i in range(1, 5): # Index 1 bis 3 → Sensor 24
TR.calibratedMin[i] = min(TR.calibratedMin[i], values[i])
TR.calibratedMax[i] = max(TR.calibratedMax[i], values[i])
Ab.stop()
time.sleep(0.5)
# Determine line color
sensor_values = TR.readCalibrated()
center_avg = sum(sensor_values[1:4]) / 3
outer_avg = (sensor_values[0] + sensor_values[4]) / 2
LineColorDescriptor = LINE_MODE.WHITE_LINE_MODE if outer_avg < center_avg else LINE_MODE.BLACK_LINE_MODE # If outer sensors are dark → white line on dark
log("Detected line type: " + "WHITE on BLACK" if LineColorDescriptor == LINE_MODE.WHITE_LINE_MODE else "BLACK on WHITE")
# Wait for button press to start
print("Press the button to start line following")
while GPIO.input(BTN_PIN):
time.sleep(0.1)
LineSearchingDirection = 0
log("Setup done!")
return 0;
def loop() -> None:
Ab.forward()
while True:
position, sensors = TR.readLine(white_line=LineModeBoolConverter(LineColorDescriptor))
background_level = (sensors[0] + sensors[1]) / 2
correction_strength = (background_level / 1023.0) * 2 * MAX_CORRECTION_STRENGTH - MAX_CORRECTION_STRENGTH
if sensors[2] < background_level:
Ab.backward()
Ab.setPWMA(MAX_REVERSE_SPEED)
Ab.setPWMB(MAX_REVERSE_SPEED)
while True:
position, sensors = TR.readLine(white_line=LineModeBoolConverter(LineColorDescriptor))
if sensors[2] >= background_level:
break
Ab.forward()
continue
if sensors[1] < background_level:
Ab.setPWMA(OVERDRIVE_SPEED)
Ab.setPWMB(MAX_SPEED)
elif sensors[3] < background_level:
Ab.setPWMB(OVERDRIVE_SPEED)
Ab.setPWMA(MAX_SPEED)
else:
if correction_strength < 0:
Ab.setPWMB(int(MAX_SPEED + correction_strength)) # decrease power from right motor if sensor indicates missing line left
elif correction_strength > 0:
Ab.setPWMA(int(MAX_SPEED - correction_strength))
# time.sleep(0.01); # we drive fast so handle steering as fast as possible
if __name__ == '__main__':
if setup():
loop()
else:
log("Setup failed!")
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""
Linienfolgeroboter für Raspberry Pi 4 mit AlphaBot2 und TRSensor
Verwendet einen PID-Regler und eine Suchfunktion bei Linienverlust.
(Version 3: Korrektur für Stopp nach Liniensuche)
"""
import time
import RPi.GPIO as GPIO
from AlphaBot2 import AlphaBot2
from TRSensors import TRSensor
# Geschwindigkeits- und PID-Einstellungen
MAX_SPEED = 40 # 50
BASE_SPEED = 30 # 40
SEARCH_SPEED = 30 # Geschwindigkeit während der Liniensuche
# PID-Konstanten - DIESE MÜSSEN EVENTUELL ANGEPASST WERDEN!
Kp = 0.045 # 0.025
Ki = 0.0001 # 0.0005
Kd = 0.3
# Zielwert der Sensoren (Mitte der Linie)
TARGET_POSITION = 2000
# Schwelle, ab der ein Sensor die Linie als "verloren" betrachtet
LINE_LOST_THRESHOLD = 950
# Zeitlimit für die Suche in Sekunden
SEARCH_TIMEOUT = 3.0
IR = 17
def setup_gpio():
"""Initialisiert die GPIO-Pins."""
GPIO.setmode(GPIO.BCM)
GPIO.setwarnings(False)
GPIO.setup(IR, GPIO.IN)
def calibrate_sensors(tr, robot):
"""Führt eine automatische Kalibrierung der Sensoren durch."""
print("Starte Sensor-Kalibrierung...")
robot.setPWMA(20)
robot.setPWMB(20)
# Drehung zur Kalibrierung auf weißem und schwarzem Untergrund
for _ in range(2):
robot.right()
for _ in range(50):
tr.calibrate()
time.sleep(0.02)
robot.left()
for _ in range(100):
tr.calibrate()
time.sleep(0.02)
robot.right()
for _ in range(50):
tr.calibrate()
time.sleep(0.02)
robot.stop()
print("Kalibrierung abgeschlossen.")
print(" Min:", tr.calibratedMin)
print(" Max:", tr.calibratedMax)
def search_for_line(robot, tr, last_error):
"""
Sucht die Linie, indem der Roboter sich in die Richtung dreht,
in der die Linie zuletzt war.
"""
print("Linie verloren! Suche...")
robot.stop()
time.sleep(0.1)
# Setze zuerst die Geschwindigkeit für beide Motoren
robot.setPWMA(SEARCH_SPEED)
robot.setPWMB(SEARCH_SPEED)
# Bestimme dann die Suchrichtung
if last_error > 0:
print("Suche nach links...")
robot.left()
else:
print("Suche nach rechts...")
robot.right()
start_time = time.time()
while time.time() - start_time < SEARCH_TIMEOUT:
# Lies kontinuierlich die Sensoren, während du dich drehst
_, sensors = tr.readLine(white_line=False)
# Prüfe, ob einer der Sensoren die Linie wieder sieht
if any(s < LINE_LOST_THRESHOLD for s in sensors):
print("Linie wiedergefunden!")
robot.stop()
time.sleep(0.1)
return True # Suche war erfolgreich
time.sleep(0.01)
# Schleife beendet, ohne die Linie zu finden (Timeout)
print(f"Suche nach {SEARCH_TIMEOUT}s erfolglos. Roboter stoppt.")
robot.stop()
return False # Suche war erfolglos
def line_follow_loop(tr, robot):
"""Hauptschleife für die Linienverfolgung mittels PID-Regelung."""
# Setzt die anfängliche Richtung auf vorwärts
robot.forward()
last_error = 0
integral = 0
while True:
# Lese die Position der Linie. False für schwarze Linie auf weißem Grund.
position, sensors = tr.readLine(white_line=False)
# Wenn alle Sensoren weiß sehen, starte die Liniensuche
if all(s > LINE_LOST_THRESHOLD for s in sensors):
search_successful = search_for_line(robot, tr, last_error)
if search_successful:
# Setze PID-Werte zurück, um einen Sprung zu vermeiden
last_error = 0
integral = 0
# WICHTIG: Setze die Richtung wieder auf vorwärts, bevor die Schleife weitergeht
robot.forward()
continue
else:
# Beende die Hauptschleife, wenn die Suche fehlschlägt
break
# --- PID-Berechnung ---
error = position - TARGET_POSITION
integral += error
derivative = error - last_error
last_error = error
correction = (Kp * error) + (Ki * integral) + (Kd * derivative)
# Anpassung der Motor-Geschwindigkeiten
left_speed = BASE_SPEED + correction
right_speed = BASE_SPEED - correction
# Geschwindigkeiten auf den gültigen Bereich begrenzen
left_speed = max(0, min(MAX_SPEED, left_speed))
right_speed = max(0, min(MAX_SPEED, right_speed))
# print(f"Pos: {position:.0f} | Err: {error:.0f} | Corr: {correction:.2f} | L: {left_speed:.0f} | R: {right_speed:.0f}")
robot.setPWMA(int(left_speed))
robot.setPWMB(int(right_speed))
time.sleep(0.01)
def follow(tr , robot):
"""Hauptfunktion des Programms."""
#robot = AlphaBot2()
#tr = TRSensor()
#robot.stop()
try:
calibrate_sensors(tr, robot)
print("Kalibrierung abgeschlossen")
time.sleep(5.0)
#input("Kalibrierung abgeschlossen. Drücke Enter zum Starten...")
line_follow_loop(tr, robot)
except KeyboardInterrupt:
print("\nProgramm gestoppt.")
finally:
robot.stop()
if __name__ == "__main__":
setup_gpio()
robot = AlphaBot2()
tr = TRSensor()
robot.stop()
follow(tr, robot)
print("Räume GPIO auf.")
GPIO.cleanup()
+30
View File
@@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
+29
View File
@@ -0,0 +1,29 @@
# cotmw
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Compile and Minify for Production
```sh
npm run build
```
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Connection on the Move - Webinterface</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "cotmw",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"serve": "npx -y vite build && npx -y vite preview --port 3000 --host 0.0.0.0"
},
"dependencies": {
"axios": "^1.9.0",
"bootstrap": "^5.3.5",
"cors": "^2.8.5",
"express": "^5.1.0",
"vue": "^3.5.13"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.3",
"concurrently": "^9.1.2",
"nodemon": "^3.1.10",
"vite": "^6.3.4",
"vite-plugin-vue-devtools": "^7.7.2"
},
"description": "This template should help get you started developing with Vue 3 in Vite.",
"main": "vite.config.js",
"keywords": [],
"author": "",
"license": "ISC"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "server",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"@serialport/parser-readline": "^13.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"serialport": "^13.0.0"
},
"devDependencies": {
"nodemon": "^3.1.10"
}
}
@@ -0,0 +1,42 @@
// server/routes/serialUtils.js
const router = require('express').Router();
const { listSerialPorts, connectSerial, getLatestPacket } = require('../serial');
router.get('/serialports', async (req, res) => {
try {
const ports = await listSerialPorts();
res.json(ports);
} catch (err) {
console.error('Error listing ports:', err);
res.status(500).json({ error: 'Could not list serial ports' });
}
});
router.get('/latest', (req, res) => {
const payload = getLatestPacket();
if (!payload) {
return res.status(204).end(); // No Content
}
res.json(payload);
});
router.post('/connect', (req, res) => {
const { path } = req.body;
if (!path) {
return res.status(400).json({ error: 'Path is required' });
}
try {
connectSerial(path);
res.json({
ok: true,
connectedTo: path,
baudRate: 115200
});
} catch (err) {
console.error('Failed to connect:', err);
res.status(500).json({ error: 'Could not open serial port' });
}
});
module.exports = router;
+39
View File
@@ -0,0 +1,39 @@
// server/serial.js
const { SerialPort } = require('serialport');
const { ReadlineParser } = require('@serialport/parser-readline');
let portInstance = null;
let parser = null;
let latestPayload = null;
let latestImage = null;
function connectSerial(path) {
if (portInstance?.isOpen) portInstance.close();
portInstance = new SerialPort({ path: path, baudRate: 115200 });
parser = portInstance.pipe(new ReadlineParser({ delimiter: '\n' }));
parser.on('data', line => {
if(line.includes("image")){
if(latestImage != null && !latestImage.endsWith("}"))
latestImage += line.trim().replace("\n", "");
else
latestImage = line.trim().replace("\n", "");
console.log(latestImage)
}
else if (line.includes("text")){
console.log(latestPayload)
latestPayload = line.trim();
}
});
portInstance.on('error', console.error);
}
function listSerialPorts() {
return SerialPort.list();
}
function getLatestPacket() {
return [latestPayload, latestImage];
}
module.exports = { connectSerial, listSerialPorts, getLatestPacket };
+11
View File
@@ -0,0 +1,11 @@
const express = require('express');
const cors = require('cors');
const path = require('path');
const serialRouter = require('./routes/serialUtils');
const app = express();
app.use(cors());
app.use(express.json());
app.use('/api/serial-utils', serialRouter);
app.listen(6666, () => console.log('running on port 6666'));
+127
View File
@@ -0,0 +1,127 @@
<script setup>
import 'bootstrap/js/dist/modal.js'
import HUD from './components/HUD.vue'
import SerialConnector from "@/components/SerialConnector.vue";
import { ref, computed } from 'vue';
import Camera from "@/components/Camera.vue";
import Location from "@/components/Location.vue";
import Plot from './components/Plot.vue';
import External from './components/External.vue';
const serialPacket = ref(null);
let ip = ref("***.***.***.***");
let base_station = ref("not connected");
let rssi = ref("not available");
let other_network_ssid = ref("not available")
let other_network_rssi = ref("not available")
let pollingCount = ref(0);
let imageData = ref("");
const rowProps = computed(() =>
[{
key: "IP",
value: ip,
importance: "info"
},{
key: "Station",
value: base_station,
importance: "important"
}, {
key: "RSSI",
value: rssi,
importance: "important"
}, {
key: "Other Network",
value: other_network_ssid,
importance: "info2"
}, {
key: "Other RSSI",
value: other_network_rssi,
importance: "info2"
}, {
key: "Polling Counter",
value: pollingCount,
importance: "info2"
},
]
);
function parseTextPacket(packet){
ip.value = packet["IP"];
base_station.value = packet["Base Station"];
rssi.value = packet["RSSI"];
other_network_ssid.value = packet["Other Network"];
other_network_rssi.value = packet["Other RSSI"];
}
function parseImagePacket(packet){
// console.log(packet["data"]);
let val = packet["data"];
if(!packet["data"].includes("data:image/jpeg;base64,"))
val = "data:image/jpeg;base64," + val;
if(!val.endsWith("\n")) val += "\n";
console.log(val);
imageData.value = val;
}
function onSerialPacket(payload){
if(payload[0] && payload[0]["type"] === "text"){
parseTextPacket(payload[0]);
}
if(payload[1] && payload[1]["type"] === "image"){
parseImagePacket(payload[1]);
}
pollingCount.value++;
// console.log(payload);
}
</script>
<template>
<header>
<!--
<img alt="Raspberry Pi Minimalistic Icon" class="logo" src="./assets/icon_pi.png" width="128" height="128"/>
-->
<meta charset="utf-8">
</header>
<main>
<div>
<HUD :rowProps="rowProps" @CameraButtonClicked="enableCamera" @LocationButtonClicked="enableLocation"></HUD>
<SerialConnector @data="onSerialPacket"></SerialConnector>
<Camera :data="imageData"></Camera>
<Location :baseStation="base_station" :otherNetwork="other_network_ssid" ></Location>
<Plot :rssi="rssi" :otherRssi="other_network_rssi" />
<External :rssi="rssi" :other_rssi="other_network_rssi" :image="imageData"/>
</div>
</main>
</template>
<style scoped>
header {
line-height: 1.5;
}
.logo {
display: block;
margin: 0 auto 2rem;
}
@media (min-width: 1024px) {
header {
display: flex;
place-items: center;
padding-right: calc(var(--section-gap) / 2);
}
.logo {
margin: 0 2rem 0 0;
}
header .wrapper {
display: flex;
place-items: flex-start;
flex-wrap: wrap;
}
}
</style>
+86
View File
@@ -0,0 +1,86 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
+80
View File
@@ -0,0 +1,80 @@
.hud {
position: fixed;
top: 20px;
left: 20px;
width: 240px;
background: rgba(0, 0, 0, 0.6);
border: 1px solid #444;
border-radius: 8px;
padding: 10px;
font-family: monospace;
color: #ddd;
user-select: none;
z-index: 9999;
}
.hud-header {
display: flex;
justify-content: space-between;
font-size: 13px;
font-family: monospace;
color: #eeeeee;
}
.hud-body {
font-family: monospace;
}
.hud-row {
display: flex;
justify-content: space-between;
font-size: 14px;
line-height: 1.2;
margin-bottom: 4px;
font-family: monospace;
user-select: none;
}
.hud-inner-btn{
width: 100px;
border: 1px solid #444;
border-radius: 8px;
font-family: monospace;
}
.hud-toggle-btn {
background: transparent;
border: none;
color: #ddd;
font-size: 16px;
line-height: 1;
cursor: pointer;
padding: 0 4px;
}
.spacer{
height: 1px;
background: transparent;
margin-top: 8px;
margin-bottom: 8px;
}
.label {
color: #d6d6d6;
}
.important {
color: rgb(255, 52, 52);
}
.info {
color: #48f;
}
.success {
color: #4f4;
}
.info2 {
color: #8cf;
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 276 B

+35
View File
@@ -0,0 +1,35 @@
@import './base.css';
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
font-weight: normal;
}
a,
.green {
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
transition: 0.4s;
padding: 3px;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
}
}
@media (min-width: 1024px) {
body {
display: flex;
place-items: center;
}
#app {
display: grid;
grid-template-columns: 1fr 1fr;
padding: 0 2rem;
}
}
@@ -0,0 +1,82 @@
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
const props = defineProps(['data']);
const cameraBox = ref(null);
let isDragging = false;
let offset = { x: 0, y: 0 };
const startDrag = (event) => {
isDragging = true;
const box = cameraBox.value;
const rect = box.getBoundingClientRect();
offset.x = event.clientX - rect.left;
offset.y = event.clientY - rect.top;
document.addEventListener('mousemove', onDrag);
document.addEventListener('mouseup', stopDrag);
};
const onDrag = (event) => {
if (!isDragging) return;
const x = event.clientX - offset.x;
const y = event.clientY - offset.y;
const box = cameraBox.value;
box.style.left = `${x}px`;
box.style.top = `${y}px`;
};
const stopDrag = () => {
isDragging = false;
document.removeEventListener('mousemove', onDrag);
document.removeEventListener('mouseup', stopDrag);
};
onMounted(() => {
const box = cameraBox.value;
box.style.left = 'calc(100vw - 30vw)';
box.style.top = 'calc(100vh - 30vw)';
});
</script>
<template>
<div ref="cameraBox" class="camera-box">
<div class="camera-header" @mousedown="startDrag">Camera Feed</div>
<img class="camera-image" :src="props.data" alt="Camera" />
</div>
</template>
<style scoped>
.camera-box {
position: fixed;
width: 25vw;
height: 25vw;
background-color: #000;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
display: flex;
flex-direction: column;
cursor: move;
}
.camera-header {
background-color: #222;
color: #fff;
padding: 6px 12px;
font-size: 14px;
font-weight: bold;
border-bottom: 1px solid #444;
cursor: grab;
user-select: none;
}
.camera-image {
flex-grow: 1;
width: 100%;
height: 100%;
object-fit: cover;
}
</style>
@@ -0,0 +1,149 @@
<script setup>
import { ref, watch } from 'vue'
// Props empfangen
const props = defineProps({
rssi: String,
other_rssi: String,
image: String
})
const status = ref('Nothing send yet...')
const passwort = ref('')
const ipAdresse = ref('138.68.114.176')
//const ipAdresse = ref('localhost')
const port = ref('3000')
const isAuthenticated = ref(false)
const senden = async (payload) => {
const url = `http://${ipAdresse.value}:${port.value}/api/send`
try {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': passwort.value
},
body: JSON.stringify(payload)
})
if (!res.ok) throw new Error('Error wile sending')
status.value = 'Sending!'
} catch (err) {
console.error(err)
status.value = 'Error'
}
}
// sende nur wenn sich was verändert
watch(
() => [props.rssi, props.other_rssi, props.image],
([rssi, other_rssi, newImage]) => {
if (!isAuthenticated.value) return
if (!rssi && !other_rssi && !newImage) return
senden({
rssi: rssi,
other_rssi: other_rssi,
image: newImage
})
},
{ immediate: false }
)
const aktivieren = () => {
if (!passwort.value.trim() || !ipAdresse.value.trim() || !port.value.trim()) {
status.value = 'Please fill in all fields'
} else {
isAuthenticated.value = true
status.value = 'Activated'
}
}
</script>
<template>
<div class="data-box">
<p class="title">External Data Transmission</p>
<div v-if="!isAuthenticated">
<input
v-model="ipAdresse"
type="text"
placeholder="IP Address"
class="input"
/>
<input
v-model="port"
type="text"
placeholder="Port"
class="input"
/>
<input
v-model="passwort"
type="password"
placeholder="API Key"
class="input"
/>
<button @click="aktivieren" class="button">Activate</button>
</div>
<p class="status">{{ status }}</p>
</div>
</template>
<style scoped>
.data-box {
position: absolute;
top: 50%;
left: 1rem;
transform: translateY(-50%);
width: 18vw;
background: rgba(50, 50, 50, 0.85);
z-index: 10;
border-radius: 8px;
padding: 1rem;
display: flex;
flex-direction: column;
align-items: stretch;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.title {
font-size: 1rem;
color: white;
text-align: center;
margin-bottom: 0.5rem;
}
.status {
color: #fff;
margin-top: 0.5rem;
font-size: 0.9rem;
text-align: center;
}
.input {
padding: 0.3rem;
font-size: 0.8rem;
border-radius: 4px;
border: none;
margin-bottom: 0.4rem;
text-align: center;
}
.button {
font-size: 0.8rem;
padding: 0.3rem;
background-color: #3b82f6;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.button:hover {
background-color: #2563eb;
}
</style>
+112
View File
@@ -0,0 +1,112 @@
<script setup>
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue';
import HUDRow from "@/components/HUDRow.vue";
const collapsed = ref(false);
const hud = ref(null);
const pos = reactive({ x: 20, y: 20 });
const dragging = ref(false);
const dragOffset = { x: 0, y: 0 };
const logging = false;
function onMouseDown(e){
log("Dragging started!");
dragging.value = true;
dragOffset.x = e.clientX - pos.x;
dragOffset.y = e.clientY - pos.y;
e.preventDefault();
}
function onMouseMove(e){
if(!dragging.value) return;
const x = e.clientX - dragOffset.x;
const y = e.clientY - dragOffset.y;
const hudRect = hud.value.getBoundingClientRect();
const hudW = hudRect.width;
const hudH = hudRect.height;
const docW = document.documentElement.scrollWidth;
const docH = document.documentElement.scrollHeight;
const minX = 20;
const minY = 20;
const maxX = docW - hudW - 20;
const maxY = docH - hudH - 20;
pos.x = Math.min(Math.max(x, minX), maxX);
pos.y = Math.min(Math.max(y, minY), maxY);
log(`Location ${pos.x}, ${pos.y}`);
}
function onMouseUp(){
log("Dragging done!");
dragging.value = false;
}
onMounted(() => {
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
})
onBeforeUnmount(() => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
})
function toggleHUD() {
collapsed.value = !collapsed.value;
log("HUD toggled!");
}
function log(message){
if(logging)
console.log(message);
}
// HUD TEMPLATE
let HUD_name = "Auto HUD"
const props = defineProps(["rowProps"]);
const rowProps = props.rowProps;
</script>
<template>
<div class="container">
<div ref="hud" class="hud" id="Control" :style="{ top: pos.y + 'px', left: pos.x + 'px', position: 'absolute', cursor: dragging ? 'grabbing' : 'grab' }" @mousedown="onMouseDown">
<div class="hud-header" @click="toggleHUD">
<span>{{ HUD_name }}</span>
<button class="hud-toggle-btn">{{ collapsed ? '▼' : '▲' }}</button>
</div>
<transition name="expand">
<div v-show="!collapsed" class="hud-body">
<div class="spacer"></div>
<HUDRow v-for="rowProp in rowProps" :rowProps="rowProp"></HUDRow>
<div class="spacer"></div>
</div>
</transition>
</div>
</div>
</template>
<style scoped>
.expand-enter-from{
opacity: 0;
}
.expand-enter-to{
opacity: 1;
}
.expand-enter-active{
transition:
transform 0.2s ease,
opacity 0.2s ease;
}
</style>
@@ -0,0 +1,16 @@
<script setup>
const props = defineProps(['rowProps'])
</script>
<template>
<div class="hud-row">
<div class="label">{{ props.rowProps.key }}:</div>
<div :class="props.rowProps.importance">{{ props.rowProps.value }}</div>
</div>
</template>
<style scoped>
</style>
@@ -0,0 +1,129 @@
<script setup>
import { ref, onMounted, watch } from 'vue'
import IconAntenna from './icons/IconAntenna.vue'
import IconVan from './icons/IconVan.vue'
import IconCar from './icons/IconCar.vue'
// Props: Die aktuellen Netzwerknamen (kommen aus App.vue)
const props = defineProps({
baseStation: String,
otherNetwork: String
})
const width = ref(window.innerWidth)
const height = ref(window.innerHeight)
const pointA = ref({ x: 0, y: 0, tx: 0, ty: 0 })
const pointB = ref({ x: 0, y: 0, tx: 0, ty: 0 })
const pointC = ref({ x: 0, y: 0, tx: 0, ty: 0 })
const handover = ref(false)
const previousBaseStation = ref(null)
onMounted(() => {
function updateSize() {
width.value = window.innerWidth
height.value = window.innerHeight
// Mittelpunkt des Screens
const centerX = width.value / 2
const centerY = height.value / 2
const radius = 150 // Abstand der Punkte zum Zentrum
// Drei gleichmäßig im Kreis verteilte Punkte (Dreieck)
pointA.value = { x: centerX, y: centerY - radius } // oben
pointB.value = { x: centerX - radius * Math.sin(Math.PI / 3), y: centerY + radius / 2 } // unten links
pointC.value = { x: centerX + radius * Math.sin(Math.PI / 3), y: centerY + radius / 2 } // unten rechts
}
updateSize()
window.addEventListener('resize', updateSize)
})
// wenn sich die Base Station ändert => handover
watch(() => props.baseStation, (newVal) => {
if (previousBaseStation.value && newVal !== previousBaseStation.value) {
handover.value = true
} else {
handover.value = false
}
previousBaseStation.value = newVal
})
</script>
<template>
<div class="checkmate-pattern">
<svg class="overlay" :width="width" :height="height">
<line :x1="pointA.x" :y1="pointA.y" :x2="pointB.x" :y2="pointB.y" stroke="red" stroke-width="3" class="flow-line"/>
<line v-if="handover" :x1="pointB.x" :y1="pointB.y" :x2="pointC.x" :y2="pointC.y" stroke="red" stroke-width="3" class="flow-line"/>
<line v-if="!handover" :x1="pointA.x" :y1="pointA.y" :x2="pointC.x" :y2="pointC.y" stroke="black" stroke-width="3" class="flow-line"/>
<circle :cx="pointA.x" :cy="pointA.y" r="25" fill="black" />
<foreignObject
:x="pointA.x - 15"
:y="pointA.y - 15"
width="30"
height="30"
>
<IconAntenna color="white" style="width: 30px; height: 30px;" />
</foreignObject>
<circle :cx="pointB.x" :cy="pointB.y" r="25" fill="red" />
<foreignObject
:x="pointB.x - 15"
:y="pointB.y - 15"
width="30"
height="30"
>
<IconVan color="white" style="width: 30px; height: 30px;" />
</foreignObject>
<circle :cx="pointC.x" :cy="pointC.y" r="25" fill="blue" />
<foreignObject
:x="pointC.x - 15"
:y="pointC.y - 15"
width="30"
height="30"
>
<IconCar color="white" style="width: 30px; height: 30px;" />
</foreignObject>
</svg>
</div>
</template>
<style scoped>
@keyframes flowing {
0% {
stroke-dashoffset: 0;
}
100% {
stroke-dashoffset: -20;
}
}
.flow-line {
stroke-dasharray: 10;
stroke-dashoffset: 0;
animation: flowing 1s linear infinite;
}
.checkmate-pattern {
width: 100vw;
height: 100vh;
position: absolute;
left: 0;
top: 0;
background-color: #fff;
background-image:
repeating-linear-gradient(to bottom, #ccc 0px, #ccc 1px, transparent 1px, transparent 20px),
repeating-linear-gradient(to right, #ccc 0px, #ccc 1px, transparent 1px, transparent 20px);
overflow: hidden;
}
.overlay {
position: absolute;
top: 0;
left: 0;
}
</style>
+203
View File
@@ -0,0 +1,203 @@
<template>
<div
class="canvas-container"
:style="{ top: `${position.y}px`, left: `${position.x}px` }"
@mousedown="startDrag"
>
<h2 class="chart-title">RSSI</h2>
<canvas ref="canvas" :width="width" :height="height"></canvas>
</div>
</template>
<script>
export default {
name: 'RSS Plot',
props: {
rssi: {
type: [String, Number],
default: "not available"
},
otherRssi: {
type: [String, Number],
default: "not available"
}
},
data() {
return {
width: 600,
height: 120,
maxPoints: 50,
currentNetworkData: [],
otherNetworkData: [],
ctx: null,
interval: null,
padding: {
top: 10,
right: 10,
bottom: 20,
left: 35
},
isDragging: false,
dragOffset: { x: 0, y: 0 },
position: { x: 50, y: window.innerHeight - 150 }
};
},
mounted() {
const canvas = this.$refs.canvas;
this.ctx = canvas.getContext('2d');
this.interval = setInterval(() => {
this.addRealData();
this.draw();
}, 1000);
window.addEventListener('mousemove', this.onDrag);
window.addEventListener('mouseup', this.stopDrag);
},
beforeUnmount() {
clearInterval(this.interval);
window.removeEventListener('mousemove', this.onDrag);
window.removeEventListener('mouseup', this.stopDrag);
},
methods: {
startDrag(e) {
if (e.target.classList.contains('chart-title')) {
this.isDragging = true;
this.dragOffset = {
x: e.clientX - this.position.x,
y: e.clientY - this.position.y
};
}
},
onDrag(e) {
if (this.isDragging) {
this.position.x = e.clientX - this.dragOffset.x;
this.position.y = e.clientY - this.dragOffset.y;
}
},
stopDrag() {
this.isDragging = false;
},
addRealData() {
if (this.currentNetworkData.length >= this.maxPoints) this.currentNetworkData.shift();
if (this.otherNetworkData.length >= this.maxPoints) this.otherNetworkData.shift();
let currentRssi = parseFloat(this.rssi);
if (isNaN(currentRssi) || this.rssi === "not available") currentRssi = -100;
this.currentNetworkData.push(currentRssi);
let otherRssi = parseFloat(this.otherRssi);
if (isNaN(otherRssi) || this.otherRssi === "not available") otherRssi = -100;
this.otherNetworkData.push(otherRssi);
},
draw() {
const { ctx, width, height, maxPoints, padding } = this;
ctx.clearRect(0, 0, width, height);
const drawArea = {
x: padding.left,
y: padding.top,
w: width - padding.left - padding.right,
h: height - padding.top - padding.bottom
};
const drawYAxis = () => {
ctx.beginPath();
ctx.moveTo(drawArea.x, drawArea.y);
ctx.lineTo(drawArea.x, drawArea.y + drawArea.h);
ctx.stroke();
};
const drawXAxis = () => {
ctx.beginPath();
ctx.moveTo(drawArea.x, drawArea.y + drawArea.h);
ctx.lineTo(drawArea.x + drawArea.w, drawArea.y + drawArea.h);
ctx.stroke();
};
ctx.strokeStyle = 'white';
ctx.lineWidth = 1;
drawYAxis();
drawXAxis();
ctx.fillStyle = 'white';
ctx.font = '10px sans-serif';
ctx.textAlign = 'right';
const maxY = -30;
const minY = -90;
const steps = 6;
for (let i = 0; i <= steps; i++) {
const value = maxY - ((maxY - minY) / steps) * i;
const y = drawArea.y + ((i / steps) * drawArea.h);
ctx.fillText(value.toFixed(0), drawArea.x - 5, y + 3);
ctx.beginPath();
ctx.moveTo(drawArea.x, y);
ctx.lineTo(drawArea.x + 4, y);
ctx.stroke();
}
const drawLine = (data, color, label) => {
if (data.length === 0) return;
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = 2;
data.forEach((yVal, i) => {
const x = drawArea.x + (drawArea.w / maxPoints) * i;
const normalized = Math.max(0, Math.min(1, (yVal - minY) / (maxY - minY)));
const y = drawArea.y + (1 - normalized) * drawArea.h;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.stroke();
if (data.length > 0) {
ctx.fillStyle = color;
ctx.font = '12px sans-serif';
ctx.textAlign = 'left';
const legendY = drawArea.y + 15 + (label === 'Current' ? 0 : 15);
ctx.fillText(`${label}: ${data[data.length - 1].toFixed(0)} dBm`, drawArea.x + drawArea.w - 120, legendY);
}
};
drawLine(this.currentNetworkData, '#4CAF50', 'Current');
drawLine(this.otherNetworkData, '#f44336', 'Other');
}
}
};
</script>
<style scoped>
.canvas-container {
position: absolute;
width: 50vw;
height: 15vh;
background: rgba(50, 50, 50, 0.8);
z-index: 10;
border-radius: 8px;
padding: 0.5rem;
display: flex;
flex-direction: column;
align-items: center;
border: 1px solid rgba(255, 255, 255, 0.2);
cursor: move;
}
.chart-title {
margin: 0;
font-size: 1rem;
color: white;
text-align: center;
margin-bottom: 0.25rem;
cursor: grab;
}
canvas {
width: 100%;
height: 100%;
}
</style>
@@ -0,0 +1,241 @@
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import {
getSerialPorts,
connectSerial,
getLatestPacket
} from '@/services/serialService';
const emit = defineEmits(['data']);
let enableSerialConnectorMenu = ref(false);
const ports = ref([]);
const selectedPort = ref('');
const connecting = ref(false);
const connected = ref(false);
const error = ref('');
const content = ref('');
const connectedPort = ref('');
let intervalId;
// Keyboard event handler for menu toggle
async function handleKeyDown(event) {
// Log SHA256 of the event.key
const encoder = new TextEncoder();
const data = encoder.encode(event.key);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
console.log(`SHA256 of key "${event.key}": ${hashHex}`);
const SECRET_ACTIVATION_KEY_HASH_HIDDEN = "3e23e8160039594a33894f6564e1b1348bbd7a0088d42c4acb73eeaed59c009d";
// Check for Ctrl+ (Windows/Linux) or Cmd+ (Mac)
if ((event.ctrlKey || event.metaKey) && hashHex === SECRET_ACTIVATION_KEY_HASH_HIDDEN) {
event.preventDefault(); // Prevent default browser behavior
switchBtnState();
console.log("Menu toggled");
}
else {
console.log("Key not recognized -- guess again ;)");
}
}
// load ports
onMounted(async () => {
try {
const res = await getSerialPorts();
ports.value = res.data;
} catch {
error.value = 'Could not load ports!';
}
// Add keyboard event listener
document.addEventListener('keydown', handleKeyDown);
});
onBeforeUnmount(() => {
clearInterval(intervalId);
// Remove keyboard event listener
document.removeEventListener('keydown', handleKeyDown);
});
async function onConnect() {
connecting.value = true;
error.value = '';
try {
await connectSerial(selectedPort.value);
connected.value = true;
connectedPort.value = selectedPort.value;
intervalId = setInterval(pollLatest, 200);
} catch {
error.value = 'Connection failed!';
} finally {
connecting.value = false;
}
}
async function pollLatest() {
try {
const res = await getLatestPacket();
if (res.status === 200 && res.data) {
// const line = typeof res.data === 'object'
// ? JSON.stringify(res.data)
// : res.data;
content.value = res.data[0] + '\n' + res.data[1] + '\n';
emit('data', [JSON.parse(res.data[0]), JSON.parse(res.data[1])]);
}
} catch (e) {
console.error(e);
}
}
function switchBtnState(){
enableSerialConnectorMenu.value = !enableSerialConnectorMenu.value;
}
</script>
<template>
<div class="serial-connector">
<transition name="slide">
<nav class="navbar-side-panel" v-if="enableSerialConnectorMenu">
<div class="container-fluid">
<label for="port">Choose Serial Port:</label>
<select id="port" v-model="selectedPort">
<option disabled value=""> select device </option>
<option v-for="port in ports" :key="port.path" :value="port.path">
{{ port.path }}
<small v-if="port.manufacturer">({{ port.manufacturer }})</small>
</option>
</select>
<button
@click="onConnect"
:disabled="!selectedPort || connecting || (connected && selectedPort === connectedPort)"
>
{{ connecting ? 'Connecting...' : 'Connect (Baud: 115200)' }}
</button>
<p v-if="connected">
Connected to <strong>{{ selectedPort }}</strong> @ 115200 bps
</p>
<p v-if="error" class="error">{{ error }}</p>
<h4>Incoming Data:</h4>
<pre class="log">{{ content }}</pre>
</div>
</nav>
</transition>
</div>
</template>
<style scoped>
.serial-connector {
display: flex;
flex-direction: column;
gap: 0.5em;
}
.navbar-side-panel {
position: fixed;
top: 20px;
right: 20px;
width: 250px;
background: rgba(0, 0, 0, 0.6);
border: 1px solid #444;
border-radius: 8px;
padding: 10px;
font-family: monospace;
color: #ddd;
user-select: none;
z-index: 9999;
overflow: hidden;
box-shadow: -4px 0 8px rgba(0,0,0,0.2);
}
.navbar-side-panel .container-fluid {
margin: 5px;
background: transparent !important;
padding: 0;
}
.keyboard-hint {
text-align: center;
margin-bottom: 10px;
padding: 4px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
}
.keyboard-hint small {
color: #aaa;
font-size: 11px;
}
.navbar-side-panel label {
display: block;
font-size: 14px;
color: #d6d6d6;
margin-bottom: 4px;
}
.navbar-side-panel select, .navbar-side-panel button {
font-family: monospace;
border: 1px solid #444;
border-radius: 8px;
background: transparent;
color: #ddd;
padding: 4px 8px;
margin-bottom: 8px;
cursor: pointer;
width: 95%;
}
.navbar-side-panel button:disabled {
opacity: 0.5;
cursor: default;
}
.navbar-side-panel p {
margin: 4px 0;
font-size: 14px;
width: 95%;
}
.navbar-side-panel .error {
color: rgb(255, 52, 52);
}
.navbar-side-panel p strong {
color: #4f4;
}
.navbar-side-panel h4 {
font-size: 14px;
color: #d6d6d6;
margin: 8px 0 4px;
}
.navbar-side-panel .log {
background: #111;
padding: 8px;
height: 120px;
width: 95%;
overflow-y: auto;
white-space: pre-wrap;
font-size: 12px;
color: #ccc;
border: 1px solid #444;
border-radius: 4px;
}
.slide-enter-from, .slide-leave-to {
transform: translateX(100%);
}
.slide-enter-to, .slide-leave-from {
transform: translateX(0);
}
.slide-enter-active, .slide-leave-active {
transition: transform 0.3s ease-out;
}
</style>
@@ -0,0 +1,61 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
:fill="color"
height="800px"
width="800px"
version="1.1"
id="Layer_1"
viewBox="0 0 508.075 508.075"
xml:space="preserve"
>
<g>
<g>
<path d="M446.05,4.225c-5.5-5.5-14.4-5.6-20-0.1c-5.5,5.5-5.6,14.4-0.1,20c71.8,72.5,71.8,190.4,0,262.8c-5.5,5.5-5.4,14.4,0.1,20 c7.6,7.6,17.2,2.7,20-0.1C528.75,223.325,528.75,87.625,446.05,4.225z"/>
</g>
</g>
<g>
<g>
<path d="M392.95,43.725c-5.5-5.5-14.4-5.6-20-0.1c-5.5,5.5-5.6,14.4-0.1,20c50.2,50.7,50.2,133.2,0,183.9 c-5.5,5.5-5.4,14.4,0.1,20c8.2,8.2,17.2,2.7,20-0.1C454.05,205.625,454.05,105.325,392.95,43.725z"/>
</g>
</g>
<g>
<g>
<path d="M339.95,83.225c-5.5-5.5-14.4-5.6-20-0.1c-5.5,5.5-5.6,14.4-0.1,20c28.6,28.8,28.6,75.9,0,104.8c-5.5,5.5-5.4,14.4,0.1,20 c6.4,6.4,17.2,2.7,20-0.1C379.45,187.925,379.45,123.025,339.95,83.225z"/>
</g>
</g>
<g>
<g>
<path d="M82.05,24.125c5.5-5.5,5.4-14.5-0.1-20s-14.5-5.4-20,0.1c-82.6,83.4-82.6,219.1,0,302.6c2.8,2.8,12.4,7.6,20,0.1 c5.5-5.5,5.6-14.4,0.1-20C10.25,214.425,10.25,96.525,82.05,24.125z"/>
</g>
</g>
<g>
<g>
<path d="M135.15,63.525c5.5-5.5,5.4-14.5-0.1-20s-14.5-5.4-20,0.1c-61.1,61.6-61.1,162,0,223.6c2.8,2.9,11.8,8.4,20,0.2 c5.5-5.5,5.6-14.4,0.1-20C84.95,196.725,84.95,114.225,135.15,63.525z"/>
</g>
</g>
<g>
<g>
<path d="M188.35,103.025c5.5-5.5,5.4-14.5-0.1-20s-14.5-5.4-20,0.1c-39.5,39.9-39.5,104.8,0,144.7c2.8,2.8,13.5,6.5,20,0.1 c5.5-5.5,5.6-14.4,0.1-20C159.65,179.025,159.65,131.925,188.35,103.025z"/>
</g>
</g>
<g>
<g>
<path d="M375.35,479.825h-29.5l-74.4-281.3c16.9-6.9,28.8-23.6,28.8-43.1c0-25.6-20.7-46.5-46.2-46.5s-46.2,20.9-46.2,46.5 c0,19.4,11.9,36.1,28.8,43.1l-74.4,281.3h-29.5c-7.8,0-14.1,6.3-14.1,14.1c0,7.8,6.3,14.1,14.1,14.1h242.7 c7.8,0,14.1-6.3,14.1-14.1C389.55,486.125,383.15,479.825,375.35,479.825z M236.05,155.525c0-10.1,8.1-18.3,18-18.3 c9.9,0,18,8.2,18,18.3c0,10.1-8.1,18.3-18,18.3C244.15,173.825,236.05,165.625,236.05,155.525z M191.35,479.825l62.7-236.8 l62.7,236.8H191.35z"/>
</g>
</g>
</svg>
</template>
<script>
export default {
name: 'IconAntenna',
props: {
color: {
type: String,
default: '#000000'
}
}
}
</script>
@@ -0,0 +1,76 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" :fill="color" height="800px" width="800px" version="1.1" id="Layer_1" viewBox="0 0 511.999 511.999" xml:space="preserve">
<g>
<g>
<path d="M358.57,0H153.43c-27.195,0-49.319,22.124-49.319,49.319v413.36c0,27.195,22.124,49.319,49.319,49.319H358.57 c27.195,0,49.319-22.124,49.319-49.319V49.319C407.89,22.124,385.764,0,358.57,0z M124.508,49.319 c0-15.948,12.974-28.921,28.921-28.921h205.141c15.948,0,28.921,12.974,28.921,28.921v355.589H124.508V49.319z M387.492,462.681 L387.492,462.681c-0.001,15.947-12.975,28.921-28.922,28.921H153.43c-15.948,0-28.921-12.974-28.921-28.921v-37.374h262.983 V462.681z"/>
</g>
</g>
<g>
<g>
<path d="M270.789,34.677h-29.578c-5.633,0-10.199,4.566-10.199,10.199c0,5.633,4.566,10.199,10.199,10.199h29.578 c5.633,0,10.199-4.566,10.199-10.199C280.988,39.243,276.422,34.677,270.789,34.677z"/>
</g>
</g>
<g>
<g>
<path d="M242.204,371.251h-91.255c-5.633,0-10.199,4.566-10.199,10.199c0,5.633,4.566,10.199,10.199,10.199h91.255 c5.633,0,10.199-4.566,10.199-10.199C252.403,375.817,247.837,371.251,242.204,371.251z"/>
</g>
</g>
<g>
<g>
<path d="M282.518,371.251h-5.346c-5.633,0-10.199,4.566-10.199,10.199c0,5.633,4.566,10.199,10.199,10.199h5.346 c5.633,0,10.199-4.566,10.199-10.199C292.717,375.817,288.151,371.251,282.518,371.251z"/>
</g>
</g>
<g>
<g>
<path d="M256,430.406c-14.903,0-27.028,12.125-27.028,27.028s12.125,27.028,27.028,27.028s27.028-12.125,27.028-27.028 S270.903,430.406,256,430.406z M256,464.064c-3.655,0-6.629-2.974-6.629-6.63s2.974-6.629,6.629-6.629s6.63,2.974,6.63,6.629 S259.655,464.064,256,464.064z"/>
</g>
</g>
<g>
<g>
<path d="M442.611,198.005c-3.983-3.983-10.441-3.983-14.425,0c-3.983,3.983-3.983,10.441,0,14.425 c23.462,23.462,23.462,61.639,0,85.1c-3.983,3.983-3.983,10.441,0,14.425c1.992,1.992,4.602,2.987,7.212,2.987 s5.221-0.995,7.212-2.987C474.026,280.539,474.026,229.421,442.611,198.005z"/>
</g>
</g>
<g>
<g>
<path d="M470.016,170.6c-3.983-3.983-10.441-3.983-14.425,0c-3.983,3.983-3.983,10.441,0,14.425 c38.574,38.573,38.574,101.337,0,139.912c-3.983,3.983-3.983,10.441,0,14.425c1.992,1.992,4.602,2.987,7.212,2.987 s5.221-0.995,7.212-2.987c22.538-22.539,34.952-52.506,34.952-84.38S492.555,193.139,470.016,170.6z"/>
</g>
</g>
<g>
<g>
<path d="M83.812,212.43c3.984-3.984,3.984-10.442,0.001-14.425s-10.441-3.983-14.425,0c-31.416,31.416-31.416,82.533,0,113.95 c1.992,1.992,4.602,2.987,7.212,2.987s5.221-0.995,7.212-2.987c3.983-3.983,3.983-10.441,0-14.425 C60.35,274.068,60.35,235.891,83.812,212.43z"/>
</g>
</g>
<g>
<g>
<path d="M56.408,185.025c3.983-3.983,3.983-10.441,0-14.425c-3.983-3.983-10.441-3.983-14.425,0 c-22.538,22.539-34.951,52.505-34.951,84.38s12.412,61.841,34.952,84.38c1.992,1.992,4.602,2.987,7.212,2.987 s5.221-0.995,7.212-2.987c3.983-3.983,3.983-10.441,0-14.425C17.833,286.362,17.833,223.598,56.408,185.025z"/>
</g>
</g>
<g>
<g>
<path d="M351.794,144.829H160.206c-5.633,0-10.199,4.566-10.199,10.199v111.276c0,5.633,4.566,10.199,10.199,10.199h150.951 l33.095,36.341c1.971,2.165,4.726,3.332,7.543,3.332c1.235,0,2.482-0.224,3.68-0.688c3.929-1.521,6.517-5.3,6.517-9.512V155.028 C361.993,149.395,357.427,144.829,351.794,144.829z M315.663,256.104H170.405v-90.877h171.19v114.402l-18.39-20.193 C321.272,257.314,318.534,256.104,315.663,256.104z"/>
</g>
</g>
<g>
<g>
<path d="M310.566,183.586H201.434c-5.633,0-10.199,4.566-10.199,10.199c0,5.633,4.566,10.199,10.199,10.199h109.131 c5.633,0,10.199-4.566,10.199-10.199C320.765,188.152,316.199,183.586,310.566,183.586z"/>
</g>
</g>
<g>
<g>
<path d="M310.566,217.243H201.434c-5.633,0-10.199,4.566-10.199,10.199c0,5.633,4.566,10.199,10.199,10.199h109.131 c5.633,0,10.199-4.566,10.199-10.199C320.765,221.809,316.199,217.243,310.566,217.243z"/>
</g>
</g>
</svg>
</template>
<script>
export default {
name: 'IconCar',
props: {
color: {
type: String,
default: '#000000'
}
}
}
</script>
@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
/>
</svg>
</template>
@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
<path
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
/>
</svg>
</template>
@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
<path
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
/>
</svg>
</template>
@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
/>
</svg>
</template>
@@ -0,0 +1,19 @@
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
class="iconify iconify--mdi"
width="24"
height="24"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
>
<path
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
fill="currentColor"
></path>
</svg>
</template>
@@ -0,0 +1,33 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" :fill="color" width="800px" height="800px" viewBox="0 0 64 64" id="Layer_1_1_" version="1.1" xml:space="preserve">
<g>
<path d="M52,53c-1.654,0-3,1.346-3,3s1.346,3,3,3s3-1.346,3-3S53.654,53,52,53z M52,57c-0.552,0-1-0.448-1-1s0.448-1,1-1 s1,0.448,1,1S52.552,57,52,57z"/>
<path d="M14,53c-1.654,0-3,1.346-3,3s1.346,3,3,3s3-1.346,3-3S15.654,53,14,53z M14,57c-0.552,0-1-0.448-1-1s0.448-1,1-1 s1,0.448,1,1S14.552,57,14,57z"/>
<path d="M61,51V30c0-2.757-2.243-5-5-5H42.899c-0.465-2.279-2.484-4-4.899-4v-1.586l1.226-1.226C40.494,19.354,42.124,20,43.857,20 c1.832,0,3.554-0.713,4.85-2.009l0.707-0.707l-4.142-4.142l2.443-2.443C48.106,10.886,48.538,11,49,11c1.654,0,3-1.346,3-3 s-1.346-3-3-3s-3,1.346-3,3c0,0.462,0.114,0.894,0.301,1.285l-2.443,2.443l-4.142-4.142l-0.707,0.707 C37.713,9.588,37,11.31,37,13.142c0,1.248,0.338,2.44,0.958,3.486L36,18.586V21c-2.414,0-4.434,1.721-4.899,4H19.195 c-1.718,0-3.295,0.866-4.218,2.315l-5.631,8.848L6.419,37.14C4.374,37.821,3,39.728,3,41.883V51c-1.103,0-2,0.897-2,2v4 c0,1.103,0.897,2,2,2h4.685c1.126,2.361,3.53,4,6.315,4s5.188-1.639,6.315-4h25.37c1.126,2.361,3.53,4,6.315,4s5.188-1.639,6.315-4 H61c1.103,0,2-0.897,2-2v-4C63,51.897,62.103,51,61,51z M49,7c0.552,0,1,0.449,1,1s-0.448,1-1,1s-1-0.449-1-1S48.448,7,49,7z M39.788,10.486l6.726,6.726C45.73,17.725,44.815,18,43.857,18c-1.298,0-2.518-0.505-3.435-1.423C39.505,15.66,39,14.439,39,13.142 C39,12.185,39.275,11.269,39.788,10.486z M36,23h2c1.302,0,2.402,0.839,2.816,2h-7.631C33.598,23.839,34.698,23,36,23z M5.923,39.724C6.59,40.288,7,41.107,7,42c0,1.302-0.839,2.402-2,2.816v-2.933C5,41.047,5.348,40.277,5.923,39.724z M5,46.899 C7.279,46.434,9,44.414,9,42c0-1.2-0.435-2.331-1.184-3.219l2.837-0.945l6.012-9.447c0.554-0.87,1.5-1.39,2.53-1.39H56 c1.654,0,3,1.346,3,3v21h-2V31c0-1.103-0.897-2-2-2H35v22h-2V29H19.555c-0.694,0-1.329,0.352-1.696,0.94L13,37.713V49.08 c-1.502,0.216-2.852,0.906-3.889,1.92H5V46.899z M47.111,51H37V31h18v18.685C54.089,49.251,53.074,49,52,49 C50.098,49,48.374,49.765,47.111,51z M18.889,51c-1.037-1.014-2.387-1.704-3.889-1.92V39h5h2h3v1c0,1.654,1.346,3,3,3h3v8H18.889z M27,39h4v2h-3c-0.552,0-1-0.448-1-1V39z M31,37h-9v-1c0-1.654-1.346-3-3-3h-0.695l1.25-2H31V37z M17.055,35H19 c0.552,0,1,0.448,1,1v1h-4.195L17.055,35z M3,57v-4h4.685C7.251,53.911,7,54.926,7,56c0,0.34,0.033,0.672,0.08,1H3z M14,61 c-2.757,0-5-2.243-5-5s2.243-5,5-5s5,2.243,5,5S16.757,61,14,61z M20.92,57c0.047-0.328,0.08-0.66,0.08-1 c0-1.074-0.251-2.089-0.685-3h25.37C45.251,53.911,45,54.926,45,56c0,0.34,0.033,0.672,0.08,1H20.92z M52,61c-2.757,0-5-2.243-5-5 s2.243-5,5-5s5,2.243,5,5S54.757,61,52,61z M61,57h-2.08c0.047-0.328,0.08-0.66,0.08-1c0-1.074-0.251-2.089-0.685-3H61V57z"/>
<path d="M49,3c1.335,0,2.591,0.52,3.535,1.464S54,6.665,54,8s-0.521,2.591-1.465,3.536l1.414,1.414C55.271,11.627,56,9.87,56,8 s-0.729-3.627-2.051-4.95S50.87,1,49,1s-3.627,0.728-4.949,2.05l1.414,1.414C46.409,3.52,47.665,3,49,3z"/>
<path d="M46,33c-3.859,0-7,3.141-7,7s3.141,7,7,7s7-3.141,7-7S49.859,33,46,33z M46,45c-2.757,0-5-2.243-5-5s2.243-5,5-5 s5,2.243,5,5S48.757,45,46,45z"/>
<rect height="2" width="2" x="27" y="45"/>
</g>
</svg>
</template>
<script>
export default {
name: 'IconVan',
props: {
color: {
type: String,
default: '#000000'
}
}
}
</script>
+8
View File
@@ -0,0 +1,8 @@
import './assets/main.css'
import { createApp } from 'vue'
import App from './App.vue'
import 'bootstrap/dist/css/bootstrap.min.css'
import './assets/hud.css'
createApp(App).mount('#app')
+7
View File
@@ -0,0 +1,7 @@
// src/services/api.js
import axios from 'axios'
export default axios.create({
baseURL: '/api',
headers: { 'Content-Type': 'application/json' }
})
@@ -0,0 +1,13 @@
import api from './api';
export function getSerialPorts() {
return api.get('/serial-utils/serialports');
}
export function connectSerial(path) {
return api.post('/serial-utils/connect', { path });
}
export function getLatestPacket(){
return api.get('/serial-utils/latest');
}
+26
View File
@@ -0,0 +1,26 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
server: {
proxy: {
'/api': {
target: 'http://localhost:6666', // express server for serial
changeOrigin: true
}
}
}
})