mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d001b5263 |
+2
-5
@@ -7,8 +7,5 @@ logs
|
|||||||
node_modules/
|
node_modules/
|
||||||
.pio
|
.pio
|
||||||
.vscode/
|
.vscode/
|
||||||
config.h
|
include/config.h
|
||||||
robots.json
|
server/robots.json
|
||||||
roverd-dummy
|
|
||||||
server/config.yaml
|
|
||||||
package-lock.json
|
|
||||||
|
|||||||
@@ -1,89 +1,64 @@
|
|||||||
# Multi Roomba Rover
|
# Multi Roomba Rover
|
||||||
|
|
||||||
a remake of my RoombaRover project with a decentralized and embedded approach
|
a remake of my RoombaRover project with a decentralized and embedded approach
|
||||||
supports multiple roombas
|
|
||||||
|
|
||||||
on each roomba:
|
## Hardware stack
|
||||||
- a raspberry pi zero 2 w
|
|
||||||
- a raspberry pi camera
|
|
||||||
- roomba's serial port hooked up to the built in serial port on the raspberry pi
|
|
||||||
|
|
||||||
pi provisioning:
|
On each roomba:
|
||||||
- enable serial port
|
- an esp32
|
||||||
- disable wifi powersave
|
- a level shifter
|
||||||
- disable bluetooth
|
- DONT FORGET THE BRC PIN PULSE
|
||||||
|
- a power supply
|
||||||
|
- an openIPC camera
|
||||||
|
- USB wifi card
|
||||||
|
- microphone
|
||||||
|
- speaker
|
||||||
|
- MAYBE a master relay which can be turned off programatically to save the roomba from discharging. based on battery voltage plus urgent battery #?
|
||||||
|
|
||||||
## Repo layout
|
## Current software layout
|
||||||
|
|
||||||
- `pi/roverd`: tiny Go daemon that bridges the Create 2 serial port, BRC pin, and the control server via WebSockets.
|
```
|
||||||
- `server`: Node.js process that terminates rover sockets, relays commands to/from the Socket.IO UI, and serves `public/`.
|
.
|
||||||
- `pi/bin` / `pi/systemd`: helper scripts + systemd units for the rover (roverd itself plus the SRT video publisher that streams camera feeds to the server).
|
├── include/
|
||||||
- `docs/pi-deployment.md`: per-rover build + install instructions (cross-compiling on Fedora 43, deploying roverd + mediaMTX).
|
│ ├── config.example.h // copy to config.h with your Wi-Fi + server settings
|
||||||
|
│ └── protocol.h // shared packet layout (control + telemetry)
|
||||||
## Quick start
|
├── src/main.cpp // ESP32 firmware entrypoint (PlatformIO)
|
||||||
|
└── server/
|
||||||
```bash
|
├── package.json // Node.js server + Socket.IO web UI
|
||||||
# build the Pi agent (armv7)
|
├── robots.example.json // copy/edit to robots.json for your fleet
|
||||||
cd pi/roverd
|
├── src/ // UDP relay + telemetry decoder
|
||||||
mkdir -p ../../dist
|
└── public/ // barebones HTML/JS UI
|
||||||
make pi-build
|
|
||||||
|
|
||||||
# start the server + UI
|
|
||||||
cd ../../server
|
|
||||||
npm install
|
|
||||||
npm run start
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Need a fake rover or multi-rover testing without hardware? Use the dummy build:
|
### Firmware quickstart
|
||||||
|
|
||||||
```bash
|
1. `cp include/config.example.h include/config.h` and fill in:
|
||||||
cd pi/roverd
|
- `WIFI_SSID` / `WIFI_PASSWORD`
|
||||||
mkdir -p ../../dist
|
- `CONTROL_SERVER_IP` (Node server host)
|
||||||
make dummy
|
- `ROOMBA_ID` (unique per robot; must match the server entry)
|
||||||
./../../dist/roverd-dummy -config ./roverd.sample.yaml
|
- tweak ports only if you have a reason.
|
||||||
```
|
2. Flash with PlatformIO: `pio run -t upload` (env `esp32s3`).
|
||||||
|
3. The firmware spawns three FreeRTOS tasks:
|
||||||
|
- control loop (5 ms cadence) – consumes UDP control packets and drives the Create 2 via UART pins 16/17. Wheel commands decay to zero if no packets arrive for 250 ms.
|
||||||
|
- telemetry loop (500 ms cadence) – polls sensor group 100, appends Wi-Fi/LRU stats, and streams UDP telemetry to the server.
|
||||||
|
- BRC maintenance – pulses GPIO5 low for 1 s every minute to keep the robot awake.
|
||||||
|
|
||||||
The dummy binary connects to the Node server, emits simulated sensor frames, and logs every command it receives, so you can spin up as many virtual rovers as you’d like on your dev machine.
|
### Server + web UI quickstart
|
||||||
|
|
||||||
## Admin config & authentication
|
1. `cd server`
|
||||||
|
2. `cp robots.example.json robots.json` and add one entry per robot. Only the `id` is required (must match `ROOMBA_ID` in the firmware); override `controlPort`/`maxWheelSpeed` if you deviate from defaults.
|
||||||
|
3. Install deps: `npm install`
|
||||||
|
4. Run in dev mode: `npm run dev`
|
||||||
|
- HTTP + Socket.IO on `http://localhost:8080`
|
||||||
|
- UDP control bind port `62000`, telemetry bind port `62001` (override with env vars).
|
||||||
|
5. Open the web UI:
|
||||||
|
- select a robot
|
||||||
|
- drive with WASD (left/right wheel mm/s shown in telemetry summary)
|
||||||
|
- buttons issue Safe/Full/Enable-OI/Dock commands
|
||||||
|
- sensor list renders the decoded Create 2 group-100 payload plus ESP stats
|
||||||
|
|
||||||
Before running the Node server, copy `server/config.example.yaml` to `server/config.yaml` and customize the admin records (password hashes, Discord IDs, lockdown permission). Those credentials are used by the driver UI’s login panel—only admins can toggle locks/modes, and lockdown admins retain access when the system enters lockdown mode. The spectator page (future) can set `role:set` to `spectator`, and the server enforces all permissions server-side so client tweaks can’t grant extra control.
|
Each ESP32 announces itself as soon as it streams telemetry, so the server automatically learns the robot’s current IP address (no static DHCP entries required). If you do know a static IP, you can still set `deviceHost` in `robots.json` and the server will use it immediately.
|
||||||
|
|
||||||
Deploy a rover by copying the repo + `dist/roverd` to the Pi and running the helper (it installs roverd plus the SRT video publisher service):
|
UDP streams stay simple:
|
||||||
|
- server -> ESP32: fixed 12-byte control packet blasted at 50 Hz per robot
|
||||||
```bash
|
- ESP32 -> server: framed telemetry header + raw sensor group 100 + trailer (CRC-8)
|
||||||
cd ~/MultiRoombaRover
|
|
||||||
sudo ./pi/install_roverd.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Then point each rover's `/etc/roverd.yaml` at `ws://<server>:8080/rover`, set `name` to the rover’s ID, and (optionally) override `media.publishUrl` if your control server isn’t `192.168.0.86`. The video publisher service (`video-publisher.service`) captures the Pi camera with `rpicam-vid`/`libcamera-vid`, pipes the raw H264 into the stock FFmpeg binary, and publishes via SRT to the server’s mediaMTX instance at `srt://<server>:9000?streamid=#!::r=<name>,m=publish`. The installer now just pulls `libcamera-apps` + `ffmpeg` from apt (no custom build). Use the “Restart Camera” button if you enable media management so roverd can bounce the publisher service remotely.
|
|
||||||
Heads-up: the BRC pulser now uses libgpiod; make sure the `roverd` service account is in the `gpio` group (or otherwise allowed to access `/dev/gpiochip*`) and set `brc.gpioChip` if your hardware exposes a different chip name.
|
|
||||||
|
|
||||||
## Fedora server deployment
|
|
||||||
|
|
||||||
Run the installer from inside the `server/` directory after cloning the repo onto your Fedora 43 Server box:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/MultiRoombaRover/server
|
|
||||||
sudo ./install_server.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
The script must be executed via `sudo` from the user that owns the repo. It will:
|
|
||||||
|
|
||||||
- install Node.js/npm plus curl/tar
|
|
||||||
- run `npm install --production`
|
|
||||||
- copy `config.example.yaml` to `config.yaml` if needed (edit the file afterwards for admins + `media.whepBaseUrl`)
|
|
||||||
- download mediaMTX v1.15.3 and drop it into `/usr/local/bin`
|
|
||||||
- write `/etc/mediamtx/mediamtx.yml` from `server/mediamtx/mediamtx.yml` (SRT ingest on :9000, open ingest, viewer auth webhook at `/mediamtx/auth`)
|
|
||||||
- create + enable `mediamtx.service` and `multirover.service`, both running as your repo user and pointing at the clone directly
|
|
||||||
|
|
||||||
Publishing rovers lives on a trusted network, so the shipped config (tracked at `server/mediamtx/mediamtx.yml`) skips HTTP auth for SRT ingest and whitelists any path that matches `rover-*`. The installer overwrites `/etc/mediamtx/mediamtx.yml` every time you run it—if you need to tweak ports or add TURN servers, edit the template in the repo and rerun `install_server.sh` so every box stays in sync automatically.
|
|
||||||
|
|
||||||
Once finished, update `server/config.yaml` with your admin passwords and `media.whepBaseUrl` (set it to the URL you expose publicly, e.g. `https://rover.otter.land/video`). If your proxy can’t rewrite paths, create the `/video` location there and add a custom nginx snippet to rewrite `/video/<rover>/whep` to `/<>/whep` before forwarding to mediaMTX. Restart `multirover.service` whenever you edit the config. To pull updates later, just `git pull`, re-run `npm install --production` inside `server/`, and restart the service—no need to rerun the installer.
|
|
||||||
|
|
||||||
Room cameras now use JPEG snapshots (4 fps) instead of WHEP. Each entry in `roomCameras` must include a `url` pointing at the snapshot endpoint; the server polls and relays frames over socket.io with the same access rules as before.
|
|
||||||
|
|
||||||
### Video handshake + diagnostics
|
|
||||||
|
|
||||||
- Every `video:request` returns `{ url, token }`. The browser posts the SDP offer to `url` and includes `Authorization: Basic base64(token:token)`. mediaMTX forwards the username (`token`) to `/mediamtx/auth`, which checks the socket’s permissions (driver assignment, admin/spectator role, lockdown state) and either returns 200 or 401—no query parameters are involved anymore.
|
|
||||||
- To see what mediaMTX is ingesting from the Pis, run `npm run check:media` (or `node scripts/checkMedia.js`). It hits `/v3/paths/list` and prints each rover’s `ready` state and byte counters so you can instantly spot publish issues.
|
|
||||||
|
|||||||
Vendored
-20
@@ -1,20 +0,0 @@
|
|||||||
#configuration for roverd
|
|
||||||
name: dummy1
|
|
||||||
serverUrl: ws://192.168.0.84:8080/rover
|
|
||||||
serial:
|
|
||||||
device: /dev/ttyS0
|
|
||||||
baud: 115200
|
|
||||||
brc:
|
|
||||||
gpioPin: 25
|
|
||||||
pulseEvery: 1m
|
|
||||||
pulseWidth: 1s
|
|
||||||
battery:
|
|
||||||
full: 2068
|
|
||||||
warn: 1700
|
|
||||||
urgent: 1650
|
|
||||||
maxWheelSpeed: 350
|
|
||||||
media:
|
|
||||||
manage: false
|
|
||||||
service: mediamtx.service
|
|
||||||
healthUrl: http://127.0.0.1:9997/v3/paths/list
|
|
||||||
healthInterval: 30s
|
|
||||||
Vendored
-20
@@ -1,20 +0,0 @@
|
|||||||
#configuration for roverd
|
|
||||||
name: dummy2
|
|
||||||
serverUrl: ws://192.168.0.84:8080/rover
|
|
||||||
serial:
|
|
||||||
device: /dev/ttyS0
|
|
||||||
baud: 115200
|
|
||||||
brc:
|
|
||||||
gpioPin: 25
|
|
||||||
pulseEvery: 1m
|
|
||||||
pulseWidth: 1s
|
|
||||||
battery:
|
|
||||||
full: 2068
|
|
||||||
warn: 1700
|
|
||||||
urgent: 1650
|
|
||||||
maxWheelSpeed: 350
|
|
||||||
media:
|
|
||||||
manage: false
|
|
||||||
service: mediamtx.service
|
|
||||||
healthUrl: http://127.0.0.1:9997/v3/paths/list
|
|
||||||
healthInterval: 30s
|
|
||||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -1,112 +0,0 @@
|
|||||||
# Pi Deployment Guide
|
|
||||||
|
|
||||||
## Tooling
|
|
||||||
|
|
||||||
Fedora 43:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo dnf install golang libgpiod
|
|
||||||
```
|
|
||||||
|
|
||||||
Cross-compiling roverd for Pi Zero 2 W (ARMv7):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd pi/roverd
|
|
||||||
mkdir -p ../../dist
|
|
||||||
make pi-build
|
|
||||||
```
|
|
||||||
|
|
||||||
The binary is placed in `dist/roverd` (relative to the repo root).
|
|
||||||
|
|
||||||
### Dummy rover build (for local testing)
|
|
||||||
|
|
||||||
If you need extra “virtual” rovers on your laptop/CI box, build the dummy binary:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd pi/roverd
|
|
||||||
mkdir -p ../../dist
|
|
||||||
make dummy
|
|
||||||
```
|
|
||||||
|
|
||||||
Run the resulting `dist/roverd-dummy` on any machine; it will connect to the server, stream fake Group 100 sensor data, and log drive commands so you can test multi-rover features without additional hardware.
|
|
||||||
|
|
||||||
## Automated installation (recommended)
|
|
||||||
|
|
||||||
Install the Raspberry Pi camera helpers (Bookworm ships them):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt update
|
|
||||||
sudo apt install libcamera-apps
|
|
||||||
```
|
|
||||||
|
|
||||||
Once the binary (and repo) are on the Pi, run the helper script from the repo root:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/MultiRoombaRover
|
|
||||||
sudo ./pi/install_roverd.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
What the script does:
|
|
||||||
|
|
||||||
- creates the `roverd` service account (dialout/gpio/video groups) if missing and installs `/usr/local/bin/roverd`
|
|
||||||
- copies `pi/roverd/roverd.sample.yaml` to `/etc/roverd.yaml` if the file is absent (existing configs are left untouched)
|
|
||||||
- installs/enables the `roverd.service` systemd unit, restarting it automatically when a config already exists
|
|
||||||
- installs `/usr/local/bin/video-publisher`, drops `video-publisher.service`, and enables it so video is published automatically on boot
|
|
||||||
|
|
||||||
Flags:
|
|
||||||
|
|
||||||
| Flag | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `-b PATH` | use a different roverd binary (defaults to `dist/roverd`) |
|
|
||||||
| `-c PATH` | seed `/etc/roverd.yaml` from another template |
|
|
||||||
|
|
||||||
If the script installs the sample config, it will remind you to edit `/etc/roverd.yaml` before manually restarting the service: set `name`, `serverUrl`, serial device, BRC pin, battery thresholds, and optionally override `media.publishUrl`. When left blank, roverd automatically publishes to `srt://<server-host>:9000?streamid=#!::r=<name>,m=publish…` (the host comes from `serverUrl`). If your reverse proxy adds prefixes (like `/video/<name>`), use its rewrite options so the rover keeps publishing to plain `<name>`.
|
|
||||||
|
|
||||||
Re-run `pi/install_roverd.sh` any time you pull updates—the script overwrites the roverd + video-publisher binaries and drops the latest systemd units so the only configuration you ever touch manually is `/etc/roverd.yaml`. `roverd` rewrites `/var/lib/roverd/video.env` on startup, so no other files need editing.
|
|
||||||
## Manual installation
|
|
||||||
|
|
||||||
1. Copy the binary and config:
|
|
||||||
```bash
|
|
||||||
sudo useradd -r -s /usr/sbin/nologin roverd || true
|
|
||||||
sudo install -o roverd -g roverd -m 0755 dist/roverd /usr/local/bin/roverd
|
|
||||||
sudo install -o roverd -g roverd -m 0640 pi/roverd/roverd.sample.yaml /etc/roverd.yaml
|
|
||||||
```
|
|
||||||
Adjust `/etc/roverd.yaml` for each rover: `name`, `serverUrl` (e.g. `ws://control-server:8080/rover`), serial port path, battery thresholds, GPIO pin for BRC, and (if needed) the media `publishUrl` override. Otherwise, roverd derives `srt://<server-host>:9000?streamid=#!::r=<name>,m=publish&latency=20&mode=caller&transtype=live&pkt_size=1316` based on the `serverUrl`; keep proxy-only path prefixes out of this URL so every rover keeps the same simple stream name.
|
|
||||||
|
|
||||||
2. Install the systemd unit:
|
|
||||||
```bash
|
|
||||||
sudo install -m 0644 pi/systemd/roverd.service /etc/systemd/system/roverd.service
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
sudo systemctl enable --now roverd.service
|
|
||||||
```
|
|
||||||
|
|
||||||
`roverd` requires access to `/dev/ttyAMA0` and `/dev/gpiochip*`; keeping it under its own user ensures the rest of the system stays isolated—just make sure the account belongs to the `dialout` and `gpio` groups so it can reach the UART and libgpiod.
|
|
||||||
The video publisher needs read access to the camera devices (`/dev/media*`, `/dev/video*`), so the install script adds the `roverd` service account to the `video` group; if you created the user manually, make sure it belongs to `video`.
|
|
||||||
**BRC note:** configure `brc.gpioPin` (and `brc.gpioChip` if you’re not using `gpiochip0`) and ensure the `roverd` user has permission to toggle that line—no root privileges are required anymore.
|
|
||||||
If you set `media.manage: true` in `/etc/roverd.yaml`, make sure the `roverd` service account can invoke `systemctl <action> <media.service>` (the installer wires `video-publisher.service` to run as `roverd`, so no sudo tweaks are required unless you rename it).
|
|
||||||
|
|
||||||
## Video publisher details
|
|
||||||
|
|
||||||
The `video-publisher.service` unit runs `/usr/local/bin/video-publisher`, piping `rpicam-vid`/`libcamera-vid` straight into the stock FFmpeg package:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rpicam-vid (or libcamera-vid) --inline --timeout 0 --width=WIDTH --height=HEIGHT \
|
|
||||||
--framerate=FPS --bitrate=BITRATE --codec h264 --profile baseline --output - \
|
|
||||||
| ffmpeg -hide_banner -loglevel warning -fflags nobuffer \
|
|
||||||
-f h264 -i pipe:0 -c:v copy -an -flush_packets 1 -f mpegts $PUBLISH_URL
|
|
||||||
```
|
|
||||||
|
|
||||||
`/var/lib/roverd/video.env` carries all tunables (`PUBLISH_URL`, resolution, FPS, bitrate). `roverd` rewrites the file whenever you restart it or hit the “Restart Camera” button so the publisher always inherits the correct rover ID + bitrate knobs. Because we now use the distro FFmpeg build with SRT enabled, the installer is much faster—no custom compile steps.
|
|
||||||
Use `sudo systemctl status video-publisher` to watch logs; the unit auto-restarts whenever the connection drops or FFmpeg exits with an error.
|
|
||||||
|
|
||||||
## Server + UI
|
|
||||||
|
|
||||||
From the repo root:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd server
|
|
||||||
npm install
|
|
||||||
npm run start
|
|
||||||
```
|
|
||||||
|
|
||||||
This launches the HTTP server (serving the barebones UI) and the rover WebSocket endpoint at `ws://<server>:8080/rover`. The UI expects `roverd` instances to send `hello` frames so it can populate the rover list. Use the mode buttons to emit Start/Safe/Full/Passive/Dock commands (they send the raw OI opcode bytes), tap the sensor toggle to request Group 100 streaming, and use WASD for drive testing; the UI emits Drive Direct commands ~8 times per second, so the rover sees them immediately.
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// Copy this file to include/config.h and fill in your network + server settings.
|
||||||
|
|
||||||
|
#define WIFI_SSID "YourNetworkName"
|
||||||
|
#define WIFI_PASSWORD "YourNetworkPassword"
|
||||||
|
|
||||||
|
// UDP server that issues control packets and receives telemetry.
|
||||||
|
#define CONTROL_SERVER_IP "192.168.1.50"
|
||||||
|
#define CONTROL_SERVER_PORT 62000
|
||||||
|
#define TELEMETRY_SERVER_PORT 62001
|
||||||
|
|
||||||
|
// Local ports on the ESP32. Keeping them distinct simplifies sniffing.
|
||||||
|
#define ESP32_CONTROL_PORT 50010
|
||||||
|
#define ESP32_TELEMETRY_PORT 50011
|
||||||
|
|
||||||
|
// Friendly name to embed in telemetry.
|
||||||
|
#define ROOMBA_ID "roomba-alpha"
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <type_traits>
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
|
||||||
|
namespace mrr {
|
||||||
|
|
||||||
|
constexpr uint8_t kControlMagic = 0xAA;
|
||||||
|
constexpr uint8_t kTelemetryMagic = 0x55;
|
||||||
|
constexpr uint8_t kProtocolVersion = 1;
|
||||||
|
constexpr size_t kSensorGroup100Length = 80;
|
||||||
|
constexpr size_t kMaxRobotIdLength = 16;
|
||||||
|
|
||||||
|
enum class OiModeRequest : uint8_t {
|
||||||
|
kNoChange = 0,
|
||||||
|
kPassive = 1,
|
||||||
|
kSafe = 2,
|
||||||
|
kFull = 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
enum ActionBits : uint8_t {
|
||||||
|
kActionSeekDock = 0x01,
|
||||||
|
kActionPlaySong = 0x02,
|
||||||
|
kActionLoadSong = 0x04,
|
||||||
|
kActionEnableOi = 0x08,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct __attribute__((packed)) ControlPacket {
|
||||||
|
uint8_t magic{kControlMagic};
|
||||||
|
uint8_t version{kProtocolVersion};
|
||||||
|
uint16_t seq{};
|
||||||
|
int16_t left_mmps{};
|
||||||
|
int16_t right_mmps{};
|
||||||
|
uint8_t oi_mode{};
|
||||||
|
uint8_t actions{};
|
||||||
|
uint8_t song_slot{};
|
||||||
|
uint8_t checksum{};
|
||||||
|
};
|
||||||
|
|
||||||
|
static_assert(sizeof(ControlPacket) == 12, "ControlPacket must remain packed");
|
||||||
|
|
||||||
|
struct __attribute__((packed)) TelemetryPacketHeader {
|
||||||
|
uint8_t magic{kTelemetryMagic};
|
||||||
|
uint8_t version{kProtocolVersion};
|
||||||
|
uint16_t seq{};
|
||||||
|
uint32_t uptime_ms{};
|
||||||
|
uint32_t last_control_age_ms{};
|
||||||
|
int8_t wifi_rssi_dbm{};
|
||||||
|
uint8_t status_bits{};
|
||||||
|
uint8_t sensor_bytes{};
|
||||||
|
uint8_t robot_id_length{};
|
||||||
|
char robot_id[kMaxRobotIdLength]{};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct __attribute__((packed)) TelemetryPacketTrailer {
|
||||||
|
int16_t applied_left_mmps{};
|
||||||
|
int16_t applied_right_mmps{};
|
||||||
|
uint16_t last_control_seq{};
|
||||||
|
uint16_t dropped_control_packets{};
|
||||||
|
uint8_t checksum{};
|
||||||
|
};
|
||||||
|
|
||||||
|
inline uint8_t checksum8(const uint8_t* data, size_t len) {
|
||||||
|
uint32_t sum = 0;
|
||||||
|
for (size_t i = 0; i < len; ++i) {
|
||||||
|
sum += data[i];
|
||||||
|
}
|
||||||
|
return static_cast<uint8_t>(sum & 0xFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
template <typename T>
|
||||||
|
inline uint8_t checksumPayload(const T& pod) {
|
||||||
|
static_assert(std::is_trivially_copyable<T>::value, "checksum payload must be POD");
|
||||||
|
return checksum8(reinterpret_cast<const uint8_t*>(&pod), sizeof(T));
|
||||||
|
}
|
||||||
|
|
||||||
|
template <typename T>
|
||||||
|
inline uint8_t checksumExcludingLastByte(const T& pod) {
|
||||||
|
static_assert(std::is_trivially_copyable<T>::value, "checksum payload must be POD");
|
||||||
|
return checksum8(reinterpret_cast<const uint8_t*>(&pod), sizeof(T) - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace mrr
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
## esp32 firmware
|
||||||
|
- hooked up to the roomba's UART on pins 16 and 17
|
||||||
|
- pin 5 is connected to the roomba's BRC pin
|
||||||
|
- pulse the BRC pin low for 1 second every minute to keep the roomba awake
|
||||||
|
- connect to wifi
|
||||||
|
- connect to the server
|
||||||
|
- get a full frame of sensor group 100 from the roomba every 500ms
|
||||||
|
- send it to the server over the sensor UDP stream
|
||||||
|
- listen to the server's control UDP stream (per roomba) and do the following accordingly:
|
||||||
|
- set wheel speeds
|
||||||
|
- seek dock
|
||||||
|
- enable OI
|
||||||
|
- safe mode
|
||||||
|
- full mode
|
||||||
|
- play song
|
||||||
|
- load song
|
||||||
|
|
||||||
|
## esp32 -> server communication
|
||||||
|
- one UDP stream to the esp32 for controlling the roomba
|
||||||
|
- might look like this:
|
||||||
|
- left wheel speed
|
||||||
|
- right wheel speed
|
||||||
|
- OI mode
|
||||||
|
- seek dock?
|
||||||
|
- blasts out at a constant rate from the server for each roomba
|
||||||
|
- the esp32 will listen, and follow the latest command that it sees
|
||||||
|
- one UDP stream from the esp32 to the server for sending sensor data frames and other telemetry
|
||||||
|
- one full frame of sensor data per datagram
|
||||||
|
- send raw sensor data, the server will decode it
|
||||||
|
- add other telemetry from the esp32, like signal strength, etc.
|
||||||
|
- maybe use this stream as a sign that the esp32 is still running healthily?
|
||||||
|
|
||||||
|
## nodejs server
|
||||||
|
- KISS
|
||||||
|
- decode the sensor data from each roomba
|
||||||
|
- can support multiple roombas connected from the ground up
|
||||||
|
- keep it simple, worry about getting the esp32 firmware right.
|
||||||
|
- but the server DOES have to exist for testing
|
||||||
|
- IS the web server, hosts an entire static folder for the web UI
|
||||||
|
|
||||||
|
## server -> web UI communication
|
||||||
|
- socket.io
|
||||||
|
- don't do anything fancy with the socket.io setup
|
||||||
|
- it works fine out of the box, we will optimize it later
|
||||||
|
|
||||||
|
## the web UI
|
||||||
|
- KISS
|
||||||
|
- plain old html. no styling even. just bare minimum for testing
|
||||||
|
- what it needs to do:
|
||||||
|
- allow user to select the roomba from a list
|
||||||
|
- make the selected roomba drive with WASD
|
||||||
|
- have buttons to set the OI mode, and tell the roomba to dock
|
||||||
|
- show a plain list of the sensor data from the selected roomba
|
||||||
|
|
||||||
|
### general javascript programming guidelines (applies to the web UI too)
|
||||||
|
- everything ES6
|
||||||
|
- one entrypoint file in the web UI
|
||||||
|
- everything modular
|
||||||
|
- everything easy to read, understand, and work on
|
||||||
|
- comment where you think is best to describe whats going on
|
||||||
|
|
||||||
|
## closing notes
|
||||||
|
- keep the user input path (web UI -> server -> roomba) as light and responsive as possible. responsiveness is key for this.
|
||||||
|
- responsiveness is the name of the game. The future of this program is teleoperation over the internet, with a camera on each roomba. keyboard inputs from the user must be near instant.
|
||||||
|
- on the esp32 firmware side of things, sensor data is second priority to having a responsive control system
|
||||||
|
- but sensor data DOES have to exist.
|
||||||
|
- the future of this project will involve assigning one roomba to a user, make the server able to do that from the ground up.
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# Use the Google Voice HAT soundcard as the primary device by name (card id is "sndrpigooglevoi")
|
|
||||||
options snd_rpi_googlevoicehat_soundcard index=0
|
|
||||||
|
|
||||||
# Software playback volume (adjust with: amixer -c0 sset 'SoftMaster' 70%)
|
|
||||||
pcm.softvol {
|
|
||||||
type softvol
|
|
||||||
slave.pcm "plughw:0,0"
|
|
||||||
control {
|
|
||||||
name "SoftMaster"
|
|
||||||
card 0
|
|
||||||
}
|
|
||||||
min_dB -51.0
|
|
||||||
max_dB 0.0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Defaults: playback through softvol, capture raw on the HAT
|
|
||||||
pcm.!default {
|
|
||||||
type asym
|
|
||||||
playback.pcm "softvol"
|
|
||||||
capture.pcm "hw:0,0"
|
|
||||||
}
|
|
||||||
|
|
||||||
ctl.!default {
|
|
||||||
type hw
|
|
||||||
card 0
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
set +H
|
|
||||||
|
|
||||||
ENV_FILE="${VIDEO_ENV_FILE:-/var/lib/roverd/video.env}"
|
|
||||||
|
|
||||||
if [[ ! -f "$ENV_FILE" ]]; then
|
|
||||||
echo "Environment file ${ENV_FILE} missing; cannot publish audio" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# shellcheck disable=SC1090
|
|
||||||
source "$ENV_FILE"
|
|
||||||
AUDIO_ENABLE="${AUDIO_ENABLE:-0}"
|
|
||||||
if [[ "${AUDIO_ENABLE}" -ne 1 ]]; then
|
|
||||||
echo "Audio capture disabled; skipping audio-only publisher" >&2
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
: "${AUDIO_PUBLISH_URL:?AUDIO_PUBLISH_URL not set in ${ENV_FILE}}"
|
|
||||||
|
|
||||||
AUDIO_DEVICE="${AUDIO_DEVICE:-hw:0,0}"
|
|
||||||
AUDIO_RATE="${AUDIO_RATE:-48000}"
|
|
||||||
AUDIO_CHANNELS="${AUDIO_CHANNELS:-2}"
|
|
||||||
|
|
||||||
if [[ -n "${FFMPEG_BIN:-}" ]]; then
|
|
||||||
FFMPEG_BIN_PATH="$FFMPEG_BIN"
|
|
||||||
elif command -v ffmpeg >/dev/null 2>&1; then
|
|
||||||
FFMPEG_BIN_PATH="$(command -v ffmpeg)"
|
|
||||||
else
|
|
||||||
echo "ffmpeg not found; install it via apt install ffmpeg." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
run_pipeline() {
|
|
||||||
"${FFMPEG_BIN_PATH}" \
|
|
||||||
-hide_banner \
|
|
||||||
-loglevel warning \
|
|
||||||
-fflags nobuffer \
|
|
||||||
-rtbufsize 0 \
|
|
||||||
-thread_queue_size 4096 \
|
|
||||||
-f s32le \
|
|
||||||
-ar "${AUDIO_RATE}" \
|
|
||||||
-ac "${AUDIO_CHANNELS}" \
|
|
||||||
-i <(arecord -D "${AUDIO_DEVICE}" -f S32_LE -c "${AUDIO_CHANNELS}" -r "${AUDIO_RATE}" -B 65536 -F 2048 -q -t raw) \
|
|
||||||
-af "aresample=16000,pan=mono|c0=0.5*FL+0.5*FR,volume=30dB" \
|
|
||||||
-c:a libopus \
|
|
||||||
-b:a 24000 \
|
|
||||||
-ar:a 16000 \
|
|
||||||
-ac:a 1 \
|
|
||||||
-application lowdelay \
|
|
||||||
-frame_duration 20 \
|
|
||||||
-compression_level 0 \
|
|
||||||
-f mpegts \
|
|
||||||
"${AUDIO_PUBLISH_URL}"
|
|
||||||
}
|
|
||||||
|
|
||||||
while true; do
|
|
||||||
if run_pipeline; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "Audio-only publisher exited, restarting in 2s..." >&2
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
# Disable history expansion so PUBLISH_URL values with "!" are safe when sourcing env files.
|
|
||||||
set +H
|
|
||||||
|
|
||||||
ENV_FILE="${VIDEO_ENV_FILE:-/var/lib/roverd/video.env}"
|
|
||||||
|
|
||||||
if [[ ! -f "$ENV_FILE" ]]; then
|
|
||||||
echo "Environment file ${ENV_FILE} missing; cannot publish" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# shellcheck disable=SC1090
|
|
||||||
source "$ENV_FILE"
|
|
||||||
: "${PUBLISH_URL:?PUBLISH_URL not set in ${ENV_FILE}}"
|
|
||||||
|
|
||||||
VIDEO_WIDTH="${VIDEO_WIDTH:-1920}"
|
|
||||||
VIDEO_HEIGHT="${VIDEO_HEIGHT:-1080}"
|
|
||||||
VIDEO_FPS="${VIDEO_FPS:-30}"
|
|
||||||
VIDEO_BITRATE="${VIDEO_BITRATE:-3000000}"
|
|
||||||
# Flip the camera 180deg (supported by rpicam-vid/libcamera-vid)
|
|
||||||
FLIP_ARGS=(--rotation 180)
|
|
||||||
|
|
||||||
if [[ -n "${LIBCAMERA_BIN:-}" ]]; then
|
|
||||||
LIBCAMERA_BIN_PATH="$LIBCAMERA_BIN"
|
|
||||||
elif command -v rpicam-vid >/dev/null 2>&1; then
|
|
||||||
LIBCAMERA_BIN_PATH="$(command -v rpicam-vid)"
|
|
||||||
elif command -v libcamera-vid >/dev/null 2>&1; then
|
|
||||||
LIBCAMERA_BIN_PATH="$(command -v libcamera-vid)"
|
|
||||||
else
|
|
||||||
echo "Neither rpicam-vid nor libcamera-vid found; install libcamera-apps." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "${FFMPEG_BIN:-}" ]]; then
|
|
||||||
FFMPEG_BIN_PATH="$FFMPEG_BIN"
|
|
||||||
elif command -v ffmpeg >/dev/null 2>&1; then
|
|
||||||
FFMPEG_BIN_PATH="$(command -v ffmpeg)"
|
|
||||||
else
|
|
||||||
echo "ffmpeg not found; install it via apt install ffmpeg." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
run_pipeline() {
|
|
||||||
"${LIBCAMERA_BIN_PATH}" \
|
|
||||||
--inline \
|
|
||||||
--timeout 0 \
|
|
||||||
--width "${VIDEO_WIDTH}" \
|
|
||||||
--height "${VIDEO_HEIGHT}" \
|
|
||||||
"${FLIP_ARGS[@]}" \
|
|
||||||
--framerate "${VIDEO_FPS}" \
|
|
||||||
--bitrate "${VIDEO_BITRATE}" \
|
|
||||||
--codec h264 \
|
|
||||||
--profile baseline \
|
|
||||||
--denoise cdn_off \
|
|
||||||
--nopreview \
|
|
||||||
--awb custom \
|
|
||||||
--awbgains 1.5,1.8 \
|
|
||||||
--output - \
|
|
||||||
| "${FFMPEG_BIN_PATH}" \
|
|
||||||
-hide_banner \
|
|
||||||
-loglevel warning \
|
|
||||||
-fflags nobuffer \
|
|
||||||
-use_wallclock_as_timestamps 1 \
|
|
||||||
-f h264 \
|
|
||||||
-i pipe:0 \
|
|
||||||
-c:v copy \
|
|
||||||
-an \
|
|
||||||
-flush_packets 1 \
|
|
||||||
-f mpegts \
|
|
||||||
"${PUBLISH_URL}"
|
|
||||||
}
|
|
||||||
|
|
||||||
while true; do
|
|
||||||
if run_pipeline; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "Video publisher exited, restarting in 2s..." >&2
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# Installer for the roverd agent on Raspberry Pi
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
BINARY_SRC="dist/roverd"
|
|
||||||
CONFIG_SRC="pi/roverd/roverd.sample.yaml"
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat <<'USAGE'
|
|
||||||
Usage: sudo ./pi/install_roverd.sh [options]
|
|
||||||
|
|
||||||
Options:
|
|
||||||
-b, --binary <path> Path to the roverd binary (default: dist/roverd)
|
|
||||||
-c, --config <path> Source config to install if /etc/roverd.yaml is missing
|
|
||||||
(default: pi/roverd/roverd.sample.yaml)
|
|
||||||
-h, --help Show this help text
|
|
||||||
|
|
||||||
The script must run from the repository root and as root (sudo). It will:
|
|
||||||
* create system users/groups if needed
|
|
||||||
* install /usr/local/bin/roverd and /etc/roverd.yaml
|
|
||||||
* install /usr/local/bin/video-publisher and its systemd unit
|
|
||||||
* enable roverd.service and video-publisher.service
|
|
||||||
USAGE
|
|
||||||
}
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
-b|--binary)
|
|
||||||
BINARY_SRC="${2:-}"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
-c|--config)
|
|
||||||
CONFIG_SRC="${2:-}"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
-h|--help)
|
|
||||||
usage
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Unknown option: $1" >&2
|
|
||||||
usage
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
if [[ "${EUID}" -ne 0 ]]; then
|
|
||||||
echo "Please run as root (sudo)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ! -f "$BINARY_SRC" ]]; then
|
|
||||||
echo "Binary not found at $BINARY_SRC" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ! -f "$CONFIG_SRC" ]]; then
|
|
||||||
echo "Config source not found at $CONFIG_SRC" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
ensure_user() {
|
|
||||||
local user="$1"
|
|
||||||
local groups="${2:-}"
|
|
||||||
if ! id -u "$user" >/dev/null 2>&1; then
|
|
||||||
if [[ -n "$groups" ]]; then
|
|
||||||
useradd -r -s /usr/sbin/nologin -G "$groups" "$user"
|
|
||||||
else
|
|
||||||
useradd -r -s /usr/sbin/nologin "$user"
|
|
||||||
fi
|
|
||||||
elif [[ -n "$groups" ]]; then
|
|
||||||
usermod -a -G "$groups" "$user"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
log() {
|
|
||||||
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*"
|
|
||||||
}
|
|
||||||
|
|
||||||
if ! command -v rpicam-vid >/dev/null 2>&1 && ! command -v libcamera-vid >/dev/null 2>&1; then
|
|
||||||
log "WARNING: neither rpicam-vid nor libcamera-vid found in PATH; install libcamera-apps."
|
|
||||||
fi
|
|
||||||
|
|
||||||
install_video_deps() {
|
|
||||||
if command -v ffmpeg >/dev/null 2>&1 && (command -v rpicam-vid >/dev/null 2>&1 || command -v libcamera-vid >/dev/null 2>&1); then
|
|
||||||
log "Video dependencies already installed; skipping apt install"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
log "Installing video dependencies (libcamera-apps, ffmpeg)..."
|
|
||||||
apt-get update
|
|
||||||
apt-get install -y --no-install-recommends libcamera-apps ffmpeg
|
|
||||||
}
|
|
||||||
|
|
||||||
find_boot_config() {
|
|
||||||
if [[ -f /boot/firmware/config.txt ]]; then
|
|
||||||
printf "/boot/firmware/config.txt"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
if [[ -f /boot/config.txt ]]; then
|
|
||||||
printf "/boot/config.txt"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_pwm_overlay() {
|
|
||||||
local boot_config
|
|
||||||
if ! boot_config="$(find_boot_config)"; then
|
|
||||||
log "WARNING: unable to locate /boot config.txt; please ensure dtoverlay=pwm-2chan is added manually for servo support"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
if grep -Eq '^\s*dtoverlay=pwm(-2chan)?' "$boot_config"; then
|
|
||||||
log "PWM overlay already present in $boot_config"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
local backup="${boot_config}.roverd.$(date +%Y%m%d%H%M%S).bak"
|
|
||||||
cp "$boot_config" "$backup"
|
|
||||||
{
|
|
||||||
echo ""
|
|
||||||
echo "# Added by roverd installer to expose PWM hardware for camera servo control on GPIO12/13 (leaves GPIO18/19 free for I2S)"
|
|
||||||
echo "dtoverlay=pwm-2chan,pin=12,func=4,pin2=13,func2=4"
|
|
||||||
} >> "$boot_config"
|
|
||||||
log "Enabled dtoverlay=pwm-2chan on GPIO12/13 in $boot_config (backup at $backup). Reboot required for changes to apply."
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_user roverd "dialout,gpio,video,render,audio"
|
|
||||||
install -o roverd -g roverd -m 0755 "$BINARY_SRC" /usr/local/bin/roverd
|
|
||||||
log "Installed roverd binary"
|
|
||||||
|
|
||||||
CONFIG_DEST="/etc/roverd.yaml"
|
|
||||||
CONFIG_EXISTS=0
|
|
||||||
if [[ -f "$CONFIG_DEST" ]]; then
|
|
||||||
CONFIG_EXISTS=1
|
|
||||||
log "Existing $CONFIG_DEST found; leaving it in place"
|
|
||||||
else
|
|
||||||
install -D -o roverd -g roverd -m 0640 "$CONFIG_SRC" "$CONFIG_DEST"
|
|
||||||
log "Installed sample config to $CONFIG_DEST (edit before starting service)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
install -m 0644 pi/systemd/roverd.service /etc/systemd/system/roverd.service
|
|
||||||
log "Installed roverd systemd unit"
|
|
||||||
|
|
||||||
install_video_deps
|
|
||||||
ensure_pwm_overlay
|
|
||||||
|
|
||||||
# Enable Google AIY v1 sound card, ALSA defaults, and TTS engines
|
|
||||||
install_audio_support() {
|
|
||||||
local boot_config
|
|
||||||
if ! boot_config="$(find_boot_config)"; then
|
|
||||||
log "WARNING: unable to locate /boot config.txt; please enable googlevoicehat-soundcard overlay manually"
|
|
||||||
else
|
|
||||||
# Ensure onboard audio is disabled (prevents card index flapping)
|
|
||||||
if grep -Eq '^\s*dtparam=audio=on\b' "$boot_config"; then
|
|
||||||
log "Disabling onboard audio (dtparam=audio=on -> off) in $boot_config"
|
|
||||||
sed -i 's/^\s*dtparam=audio=on\b/# roverd disabled onboard audio\ndtparam=audio=off/' "$boot_config"
|
|
||||||
fi
|
|
||||||
if ! grep -Eq '^\s*dtparam=audio=off\b' "$boot_config"; then
|
|
||||||
log "Adding dtparam=audio=off to $boot_config"
|
|
||||||
echo "dtparam=audio=off" >> "$boot_config"
|
|
||||||
fi
|
|
||||||
if ! grep -Eq '^\s*dtparam=i2s=on\b' "$boot_config"; then
|
|
||||||
log "Adding dtparam=i2s=on to $boot_config"
|
|
||||||
echo "dtparam=i2s=on" >> "$boot_config"
|
|
||||||
fi
|
|
||||||
if ! grep -Eq '^\s*dtoverlay=googlevoicehat-soundcard\b' "$boot_config"; then
|
|
||||||
local backup="${boot_config}.roverd.$(date +%Y%m%d%H%M%S).bak"
|
|
||||||
cp "$boot_config" "$backup"
|
|
||||||
{
|
|
||||||
echo ""
|
|
||||||
echo "# Added by roverd installer to enable Google AIY v1 sound card"
|
|
||||||
echo "dtoverlay=googlevoicehat-soundcard"
|
|
||||||
} >> "$boot_config"
|
|
||||||
log "Enabled googlevoicehat-soundcard overlay in $boot_config (backup at $backup). Reboot required."
|
|
||||||
else
|
|
||||||
log "googlevoicehat-soundcard overlay already present in $boot_config"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
if [[ -f pi/asound.conf ]]; then
|
|
||||||
install -m 0644 pi/asound.conf /etc/asound.conf
|
|
||||||
log "Installed ALSA config to /etc/asound.conf"
|
|
||||||
alsa_reload_notice=1
|
|
||||||
else
|
|
||||||
log "WARNING: pi/asound.conf missing; skipping ALSA config install"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${alsa_reload_notice:-0}" -eq 1 ]]; then
|
|
||||||
log "ALSA config updated; reboot recommended for overlay + audio changes"
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "Installing TTS/audio packages (flite, espeak)..."
|
|
||||||
# check for flite and espeak before installing, and then install them if either is missing
|
|
||||||
if command -v flite >/dev/null 2>&1 && command -v espeak >/dev/null 2>&1; then
|
|
||||||
log "TTS packages flite and espeak already installed; skipping apt install"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
apt-get update
|
|
||||||
apt-get install -y --no-install-recommends flite espeak
|
|
||||||
}
|
|
||||||
|
|
||||||
# Install video publisher assets
|
|
||||||
install -D -o root -g root -m 0755 pi/bin/video-publisher.sh /usr/local/bin/video-publisher
|
|
||||||
log "Installed video-publisher helper"
|
|
||||||
install -m 0644 pi/systemd/video-publisher.service /etc/systemd/system/video-publisher.service
|
|
||||||
log "Installed video-publisher systemd unit"
|
|
||||||
# Install audio-only publisher assets
|
|
||||||
install -D -o root -g root -m 0755 pi/bin/audio-only-publisher.sh /usr/local/bin/audio-only-publisher
|
|
||||||
install -m 0644 pi/systemd/audio-only-publisher.service /etc/systemd/system/audio-only-publisher.service
|
|
||||||
log "Installed audio-only publisher helper + systemd unit"
|
|
||||||
install -d -o roverd -g roverd /var/lib/roverd
|
|
||||||
cat > /var/lib/roverd/video.env <<'ENV'
|
|
||||||
# Managed by roverd; placeholder values will be overwritten at runtime.
|
|
||||||
PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
|
||||||
AUDIO_PUBLISH_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-audio,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
|
||||||
VIDEO_WIDTH=1280
|
|
||||||
VIDEO_HEIGHT=720
|
|
||||||
VIDEO_FPS=30
|
|
||||||
VIDEO_BITRATE=2000000
|
|
||||||
AUDIO_ENABLE=0
|
|
||||||
AUDIO_DEVICE=hw:0,0
|
|
||||||
AUDIO_RATE=48000
|
|
||||||
AUDIO_CHANNELS=2
|
|
||||||
ENV
|
|
||||||
chown roverd:roverd /var/lib/roverd/video.env
|
|
||||||
chmod 0640 /var/lib/roverd/video.env
|
|
||||||
# Create persistent audio FIFO for capture -> publisher
|
|
||||||
FIFO_PATH="/var/lib/roverd/audio.pcm"
|
|
||||||
if [[ -p "$FIFO_PATH" ]]; then
|
|
||||||
chown roverd:audio "$FIFO_PATH"
|
|
||||||
chmod 0660 "$FIFO_PATH"
|
|
||||||
else
|
|
||||||
rm -f "$FIFO_PATH"
|
|
||||||
mkfifo "$FIFO_PATH"
|
|
||||||
chown roverd:audio "$FIFO_PATH"
|
|
||||||
chmod 0660 "$FIFO_PATH"
|
|
||||||
fi
|
|
||||||
# Ensure ALSA config is in place for rovermic device
|
|
||||||
install -m 0644 pi/asound.conf /etc/asound.conf
|
|
||||||
log "Installed ALSA config (/etc/asound.conf)"
|
|
||||||
|
|
||||||
install_audio_support
|
|
||||||
|
|
||||||
systemctl daemon-reload
|
|
||||||
systemctl enable roverd.service
|
|
||||||
systemctl enable video-publisher.service
|
|
||||||
systemctl enable audio-only-publisher.service
|
|
||||||
if [[ $CONFIG_EXISTS -eq 1 ]]; then
|
|
||||||
systemctl restart roverd.service
|
|
||||||
systemctl restart video-publisher.service
|
|
||||||
systemctl restart audio-only-publisher.service
|
|
||||||
log "Restarted roverd + video/audio publishers"
|
|
||||||
else
|
|
||||||
log "Skipped auto-start because config is the sample; edit $CONFIG_DEST then run: sudo systemctl restart roverd video-publisher audio-only-publisher"
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "Install complete"
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
BIN_DIR ?= ../../dist
|
|
||||||
GOOS ?= linux
|
|
||||||
GOARCH ?= arm
|
|
||||||
GOARM ?= 6
|
|
||||||
|
|
||||||
.PHONY: build pi-build dummy clean
|
|
||||||
|
|
||||||
build:
|
|
||||||
go build -o $(BIN_DIR)/roverd ./cmd/roverd
|
|
||||||
|
|
||||||
pi-build:
|
|
||||||
GOOS=$(GOOS) GOARCH=$(GOARCH) GOARM=$(GOARM) go build -trimpath -ldflags="-s -w" -o $(BIN_DIR)/roverd ./cmd/roverd
|
|
||||||
GOOS=$(GOOS) GOARCH=$(GOARCH) GOARM=$(GOARM) go build -trimpath -ldflags="-s -w" -o $(BIN_DIR)/servoverifier ./cmd/servoverifier
|
|
||||||
|
|
||||||
dummy:
|
|
||||||
GOOS=linux GOARCH=amd64 go build -tags dummy -o $(BIN_DIR)/roverd-dummy ./cmd/roverd
|
|
||||||
|
|
||||||
clean:
|
|
||||||
rm -f $(BIN_DIR)/roverd $(BIN_DIR)/servoverifier
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
package roverd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
autoChargeTimeout = 10 * time.Second
|
|
||||||
autoChargeCooldown = 0 * time.Minute
|
|
||||||
sourceHomeBase = 1 << 1
|
|
||||||
)
|
|
||||||
|
|
||||||
type AutoChargeController struct {
|
|
||||||
adapter *SerialAdapter
|
|
||||||
events chan<- RoverEvent
|
|
||||||
logger *log.Logger
|
|
||||||
timerStart time.Time
|
|
||||||
cooldownUntil time.Time
|
|
||||||
lastState byte
|
|
||||||
lastSources byte
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAutoChargeController(adapter *SerialAdapter, events chan<- RoverEvent, logger *log.Logger) *AutoChargeController {
|
|
||||||
return &AutoChargeController{
|
|
||||||
adapter: adapter,
|
|
||||||
events: events,
|
|
||||||
logger: logger,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *AutoChargeController) Run(ctx context.Context, samples <-chan SensorSample) {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case sample := <-samples:
|
|
||||||
a.processSample(sample)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *AutoChargeController) processSample(sample SensorSample) {
|
|
||||||
now := time.Now()
|
|
||||||
docked := sample.ChargeSources&sourceHomeBase != 0
|
|
||||||
charging := isCharging(sample.ChargingState)
|
|
||||||
|
|
||||||
if !docked || charging {
|
|
||||||
if !a.timerStart.IsZero() {
|
|
||||||
a.emitEvent("autoCharge.timerCleared", map[string]any{
|
|
||||||
"durationMs": time.Since(a.timerStart).Milliseconds(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
a.timerStart = time.Time{}
|
|
||||||
a.lastState = sample.ChargingState
|
|
||||||
a.lastSources = sample.ChargeSources
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// docked but not charging
|
|
||||||
if a.cooldownUntil.After(now) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if a.timerStart.IsZero() {
|
|
||||||
a.timerStart = now
|
|
||||||
a.emitEvent("autoCharge.timerStarted", map[string]any{
|
|
||||||
"chargingState": sample.ChargingState,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if now.Sub(a.timerStart) >= autoChargeTimeout {
|
|
||||||
if err := a.adapter.SeekDock(); err != nil {
|
|
||||||
a.emitEvent("autoCharge.seekDockError", map[string]any{"error": err.Error()})
|
|
||||||
} else {
|
|
||||||
a.emitEvent("autoCharge.seekDockIssued", map[string]any{
|
|
||||||
"waitingMs": autoChargeTimeout.Milliseconds(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
a.timerStart = time.Time{}
|
|
||||||
a.cooldownUntil = now.Add(autoChargeCooldown)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func isCharging(state byte) bool {
|
|
||||||
switch state {
|
|
||||||
case 1, 2, 3, 4:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *AutoChargeController) emitEvent(event string, data map[string]any) {
|
|
||||||
if a.events == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case a.events <- RoverEvent{
|
|
||||||
Type: "event",
|
|
||||||
Event: event,
|
|
||||||
Ts: time.Now().UnixMilli(),
|
|
||||||
Data: data,
|
|
||||||
}:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
//go:build !dummy
|
|
||||||
|
|
||||||
package roverd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
gpiocdev "github.com/warthog618/go-gpiocdev"
|
|
||||||
)
|
|
||||||
|
|
||||||
type BRCPulser struct {
|
|
||||||
cfg BRCConfig
|
|
||||||
logger *log.Logger
|
|
||||||
line *gpiocdev.Line
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewBRCPulser(cfg BRCConfig, logger *log.Logger) (*BRCPulser, error) {
|
|
||||||
chip := cfg.GPIOChip
|
|
||||||
if chip == "" {
|
|
||||||
chip = "gpiochip0"
|
|
||||||
}
|
|
||||||
|
|
||||||
line, err := gpiocdev.RequestLine(
|
|
||||||
chip,
|
|
||||||
cfg.GPIOPin,
|
|
||||||
gpiocdev.AsOutput(1),
|
|
||||||
gpiocdev.WithConsumer("roverd-brc"),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &BRCPulser{cfg: cfg, logger: logger, line: line}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *BRCPulser) Close() {
|
|
||||||
if b.line != nil {
|
|
||||||
_ = b.line.SetValue(1)
|
|
||||||
b.line.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *BRCPulser) Start(ctx context.Context) {
|
|
||||||
go func() {
|
|
||||||
ticker := time.NewTicker(b.cfg.PulseEvery.Duration)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
b.pulseOnce()
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *BRCPulser) pulseOnce() {
|
|
||||||
if b.line == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := b.line.SetValue(0); err != nil {
|
|
||||||
b.logger.Printf("brc pulse low: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
time.Sleep(b.cfg.PulseWidth.Duration)
|
|
||||||
if err := b.line.SetValue(1); err != nil {
|
|
||||||
b.logger.Printf("brc pulse high: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
//go:build dummy
|
|
||||||
|
|
||||||
package roverd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log"
|
|
||||||
)
|
|
||||||
|
|
||||||
type BRCPulser struct{}
|
|
||||||
|
|
||||||
func NewBRCPulser(cfg BRCConfig, logger *log.Logger) (*BRCPulser, error) {
|
|
||||||
logger.Printf("[dummy] BRC configured on pin %d", cfg.GPIOPin)
|
|
||||||
return &BRCPulser{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *BRCPulser) Close() {}
|
|
||||||
|
|
||||||
func (b *BRCPulser) Start(ctx context.Context) {}
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
//go:build !dummy
|
|
||||||
|
|
||||||
package roverd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"math"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
rpio "github.com/stianeikeland/go-rpio/v4"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CameraServo struct {
|
|
||||||
cfg CameraServoConfig
|
|
||||||
logger *log.Logger
|
|
||||||
pin rpio.Pin
|
|
||||||
mu sync.Mutex
|
|
||||||
currentAngle float64
|
|
||||||
closed bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewCameraServo(cfg CameraServoConfig, logger *log.Logger) (*CameraServo, error) {
|
|
||||||
if !cfg.Enabled {
|
|
||||||
return nil, fmt.Errorf("camera servo disabled")
|
|
||||||
}
|
|
||||||
if err := rpio.Open(); err != nil {
|
|
||||||
return nil, fmt.Errorf("open gpio: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
pin := rpio.Pin(cfg.Pin)
|
|
||||||
pin.Mode(rpio.Pwm)
|
|
||||||
targetClock := cfg.FreqHz * cfg.CycleLen
|
|
||||||
pin.Freq(targetClock)
|
|
||||||
|
|
||||||
servo := &CameraServo{
|
|
||||||
cfg: cfg,
|
|
||||||
logger: logger,
|
|
||||||
pin: pin,
|
|
||||||
}
|
|
||||||
if err := servo.setAngleLocked(cfg.HomeAngle); err != nil {
|
|
||||||
rpio.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
logger.Printf("camera servo initialized on GPIO %d (%.1f..%.1f deg)", cfg.Pin, cfg.MinAngle, cfg.MaxAngle)
|
|
||||||
return servo, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CameraServo) Close() {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
if s.closed {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.applyPulseLocked(s.angleToPulse(s.cfg.HomeAngle))
|
|
||||||
rpio.Close()
|
|
||||||
s.closed = true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CameraServo) SetAngle(angle float64) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
return s.setAngleLocked(angle)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CameraServo) setAngleLocked(angle float64) error {
|
|
||||||
if s.closed {
|
|
||||||
return fmt.Errorf("servo closed")
|
|
||||||
}
|
|
||||||
clamped := clampFloat(angle, s.cfg.MinAngle, s.cfg.MaxAngle)
|
|
||||||
s.applyPulseLocked(s.angleToPulse(clamped))
|
|
||||||
s.currentAngle = clamped
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CameraServo) Nudge(delta float64) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
if delta == 0 {
|
|
||||||
delta = s.cfg.NudgeDegrees
|
|
||||||
}
|
|
||||||
target := s.currentAngle + delta
|
|
||||||
return s.setAngleLocked(target)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CameraServo) SetPulseWidth(micros int) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
if s.closed {
|
|
||||||
return fmt.Errorf("servo closed")
|
|
||||||
}
|
|
||||||
if !s.cfg.AllowRawPulse {
|
|
||||||
return fmt.Errorf("raw pulse commands disabled")
|
|
||||||
}
|
|
||||||
if micros <= 0 {
|
|
||||||
return fmt.Errorf("pulse width must be > 0")
|
|
||||||
}
|
|
||||||
s.applyPulseLocked(micros)
|
|
||||||
s.currentAngle = s.pulseToAngle(micros)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CameraServo) CurrentAngle() float64 {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
return s.currentAngle
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CameraServo) applyPulseLocked(micros int) {
|
|
||||||
micros = clampInt(micros, s.cfg.MinPulseUs, s.cfg.MaxPulseUs)
|
|
||||||
s.pin.DutyCycle(uint32(micros), uint32(s.cfg.CycleLen))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CameraServo) angleToPulse(angle float64) int {
|
|
||||||
totalRange := s.cfg.MaxAngle - s.cfg.MinAngle
|
|
||||||
if totalRange == 0 {
|
|
||||||
return s.cfg.MinPulseUs
|
|
||||||
}
|
|
||||||
norm := (angle - s.cfg.MinAngle) / totalRange
|
|
||||||
norm = math.Max(0, math.Min(1, norm))
|
|
||||||
pulseRange := s.cfg.MaxPulseUs - s.cfg.MinPulseUs
|
|
||||||
return s.cfg.MinPulseUs + int(math.Round(norm*float64(pulseRange)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CameraServo) pulseToAngle(pulse int) float64 {
|
|
||||||
pulseRange := s.cfg.MaxPulseUs - s.cfg.MinPulseUs
|
|
||||||
if pulseRange == 0 {
|
|
||||||
return s.cfg.MinAngle
|
|
||||||
}
|
|
||||||
norm := float64(pulse-s.cfg.MinPulseUs) / float64(pulseRange)
|
|
||||||
norm = math.Max(0, math.Min(1, norm))
|
|
||||||
return s.cfg.MinAngle + norm*(s.cfg.MaxAngle-s.cfg.MinAngle)
|
|
||||||
}
|
|
||||||
|
|
||||||
func clampInt(value, min, max int) int {
|
|
||||||
if value < min {
|
|
||||||
return min
|
|
||||||
}
|
|
||||||
if value > max {
|
|
||||||
return max
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
//go:build dummy
|
|
||||||
|
|
||||||
package roverd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CameraServo struct{}
|
|
||||||
|
|
||||||
func NewCameraServo(cfg CameraServoConfig, logger *log.Logger) (*CameraServo, error) {
|
|
||||||
return nil, fmt.Errorf("camera servo not supported in dummy build")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CameraServo) Close() {}
|
|
||||||
|
|
||||||
func (c *CameraServo) SetAngle(angle float64) error {
|
|
||||||
return fmt.Errorf("camera servo disabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CameraServo) Nudge(delta float64) error {
|
|
||||||
return fmt.Errorf("camera servo disabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CameraServo) SetPulseWidth(micros int) error {
|
|
||||||
return fmt.Errorf("camera servo disabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CameraServo) CurrentAngle() float64 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"flag"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
roverd "multiroombarover/pi/roverd"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
var cfgPath string
|
|
||||||
flag.StringVar(&cfgPath, "config", "/etc/roverd.yaml", "path to roverd configuration file")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
cfg, err := roverd.LoadConfig(cfgPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("load config: %v", err)
|
|
||||||
}
|
|
||||||
if err := roverd.UpdatePublisherEnv(cfg.Media, cfg.Audio); err != nil {
|
|
||||||
log.Fatalf("prepare media env: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
logger := log.New(os.Stdout, "roverd: ", log.LstdFlags|log.Lmicroseconds|log.LUTC)
|
|
||||||
|
|
||||||
serialPort, err := roverd.OpenSerial(cfg.Serial)
|
|
||||||
if err != nil {
|
|
||||||
logger.Fatalf("open serial: %v", err)
|
|
||||||
}
|
|
||||||
defer serialPort.Close()
|
|
||||||
|
|
||||||
var pulser *roverd.BRCPulser
|
|
||||||
if cfg.BRC.Enabled() {
|
|
||||||
pulser, err = roverd.NewBRCPulser(cfg.BRC, logger)
|
|
||||||
if err != nil {
|
|
||||||
logger.Fatalf("init BRC pulser: %v", err)
|
|
||||||
}
|
|
||||||
defer pulser.Close()
|
|
||||||
pulser.Start(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
sensorFrames := make(chan []byte, 8)
|
|
||||||
sensorSamples := make(chan roverd.SensorSample, 8)
|
|
||||||
eventStream := make(chan roverd.RoverEvent, 16)
|
|
||||||
|
|
||||||
streamer := roverd.NewSensorStreamer(serialPort, sensorFrames, sensorSamples, logger)
|
|
||||||
go streamer.Run(ctx)
|
|
||||||
|
|
||||||
adapter := roverd.NewSerialAdapter(serialPort, logger)
|
|
||||||
|
|
||||||
mediaSupervisor := roverd.NewMediaSupervisor(cfg.Media, cfg.Audio, logger)
|
|
||||||
if mediaSupervisor != nil {
|
|
||||||
mediaSupervisor.Start(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
var cameraServo *roverd.CameraServo
|
|
||||||
if cfg.CameraServo.Enabled {
|
|
||||||
cameraServo, err = roverd.NewCameraServo(cfg.CameraServo, logger)
|
|
||||||
if err != nil {
|
|
||||||
logger.Fatalf("init camera servo: %v", err)
|
|
||||||
}
|
|
||||||
defer cameraServo.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
autoCharge := roverd.NewAutoChargeController(adapter, eventStream, logger)
|
|
||||||
go autoCharge.Run(ctx, sensorSamples)
|
|
||||||
|
|
||||||
client := roverd.NewWSClient(cfg, adapter, sensorFrames, eventStream, mediaSupervisor, cameraServo, logger)
|
|
||||||
|
|
||||||
retryDelay := time.Second
|
|
||||||
for ctx.Err() == nil {
|
|
||||||
if err := client.Run(ctx); err != nil {
|
|
||||||
logger.Printf("websocket loop ended: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-time.After(retryDelay):
|
|
||||||
}
|
|
||||||
|
|
||||||
if retryDelay < 30*time.Second {
|
|
||||||
retryDelay *= 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"flag"
|
|
||||||
"log"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
rpio "github.com/stianeikeland/go-rpio/v4"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
var (
|
|
||||||
pinNum = flag.Int("pin", 19, "BCM pin connected to the servo signal line")
|
|
||||||
freqHz = flag.Int("freq", 50, "Servo PWM frequency in Hz")
|
|
||||||
cycleLen = flag.Int("cycle", 20000, "PWM cycle length (counts per period)")
|
|
||||||
minPulse = flag.Int("min", 900, "Minimum pulse width in microseconds")
|
|
||||||
maxPulse = flag.Int("max", 2100, "Maximum pulse width in microseconds")
|
|
||||||
stepPulse = flag.Int("step", 100, "Pulse width increment in microseconds when sweeping")
|
|
||||||
sweeps = flag.Int("sweeps", 2, "How many full min→max→min sweeps to perform")
|
|
||||||
pause = flag.Duration("pause", 150*time.Millisecond, "Delay between pulse adjustments")
|
|
||||||
holdPulse = flag.Int("hold", 0, "Pulse width to hold before exiting (0 = midpoint of min/max)")
|
|
||||||
)
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
if *freqHz <= 0 || *cycleLen <= 0 {
|
|
||||||
log.Fatalf("invalid freq (%d) or cycle (%d)", *freqHz, *cycleLen)
|
|
||||||
}
|
|
||||||
if *minPulse <= 0 || *maxPulse <= 0 || *minPulse >= *maxPulse {
|
|
||||||
log.Fatalf("invalid min/max pulses (%d/%d)", *minPulse, *maxPulse)
|
|
||||||
}
|
|
||||||
if *stepPulse <= 0 {
|
|
||||||
log.Fatalf("step must be > 0 (got %d)", *stepPulse)
|
|
||||||
}
|
|
||||||
if *pause <= 0 {
|
|
||||||
log.Fatalf("pause must be > 0 (got %s)", pause)
|
|
||||||
}
|
|
||||||
if *sweeps < 0 {
|
|
||||||
log.Fatalf("sweeps must be >= 0 (got %d)", *sweeps)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := rpio.Open(); err != nil {
|
|
||||||
log.Fatalf("open gpio: %v", err)
|
|
||||||
}
|
|
||||||
defer rpio.Close()
|
|
||||||
|
|
||||||
pin := rpio.Pin(*pinNum)
|
|
||||||
pin.Mode(rpio.Pwm)
|
|
||||||
|
|
||||||
targetClock := *freqHz * *cycleLen
|
|
||||||
pin.Freq(targetClock)
|
|
||||||
log.Printf("Configured PWM pin %d at %d Hz (clock=%d Hz, cycle=%d)", *pinNum, *freqHz, targetClock, *cycleLen)
|
|
||||||
|
|
||||||
setPulse := func(us int) {
|
|
||||||
clamped := clamp(us, *minPulse, *maxPulse)
|
|
||||||
pin.DutyCycle(uint32(clamped), uint32(*cycleLen))
|
|
||||||
log.Printf("pulse -> %dµs", clamped)
|
|
||||||
}
|
|
||||||
|
|
||||||
mid := (*minPulse + *maxPulse) / 2
|
|
||||||
setPulse(mid)
|
|
||||||
|
|
||||||
runSweep := func() {
|
|
||||||
for pulse := *minPulse; pulse <= *maxPulse; pulse += *stepPulse {
|
|
||||||
setPulse(pulse)
|
|
||||||
time.Sleep(*pause)
|
|
||||||
}
|
|
||||||
for pulse := *maxPulse - *stepPulse; pulse >= *minPulse; pulse -= *stepPulse {
|
|
||||||
setPulse(pulse)
|
|
||||||
time.Sleep(*pause)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 0; i < *sweeps; i++ {
|
|
||||||
log.Printf("Sweep %d/%d", i+1, *sweeps)
|
|
||||||
runSweep()
|
|
||||||
}
|
|
||||||
|
|
||||||
finalPulse := *holdPulse
|
|
||||||
if finalPulse <= 0 {
|
|
||||||
finalPulse = mid
|
|
||||||
}
|
|
||||||
setPulse(finalPulse)
|
|
||||||
log.Printf("Holding at %dµs", clamp(finalPulse, *minPulse, *maxPulse))
|
|
||||||
log.Print("Servo verification complete")
|
|
||||||
}
|
|
||||||
|
|
||||||
func clamp(value, min, max int) int {
|
|
||||||
if value < min {
|
|
||||||
return min
|
|
||||||
}
|
|
||||||
if value > max {
|
|
||||||
return max
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
package roverd
|
|
||||||
|
|
||||||
type helloMessage struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Battery BatteryConfig `json:"battery"`
|
|
||||||
MaxWheelSpeed int `json:"maxWheelSpeed"`
|
|
||||||
Media MediaConfig `json:"media"`
|
|
||||||
CameraServo CameraServoConfig `json:"cameraServo"`
|
|
||||||
Audio AudioConfig `json:"audio"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type sensorMessage struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Timestamp int64 `json:"ts"`
|
|
||||||
Data string `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type inboundMessage struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
ID string `json:"id"`
|
|
||||||
DriveDirect *driveDirectPayload `json:"driveDirect,omitempty"`
|
|
||||||
MotorPWM *motorPWMPayload `json:"motorPwm,omitempty"`
|
|
||||||
Raw string `json:"raw,omitempty"`
|
|
||||||
SensorStream *sensorStreamPayload `json:"sensorStream,omitempty"`
|
|
||||||
Media *mediaCommand `json:"media,omitempty"`
|
|
||||||
Servo *servoPayload `json:"servo,omitempty"`
|
|
||||||
TTS *ttsPayload `json:"tts,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type driveDirectPayload struct {
|
|
||||||
Left int `json:"left"`
|
|
||||||
Right int `json:"right"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type motorPWMPayload struct {
|
|
||||||
Main int `json:"main"`
|
|
||||||
Side int `json:"side"`
|
|
||||||
Vacuum int `json:"vacuum"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type sensorStreamPayload struct {
|
|
||||||
Enable bool `json:"enable"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type mediaCommand struct {
|
|
||||||
Action string `json:"action"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type servoPayload struct {
|
|
||||||
Angle *float64 `json:"angle,omitempty"`
|
|
||||||
Nudge *float64 `json:"nudge,omitempty"`
|
|
||||||
PulseUs *int `json:"pulseUs,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ttsPayload struct {
|
|
||||||
Text string `json:"text"`
|
|
||||||
Engine string `json:"engine,omitempty"`
|
|
||||||
Voice string `json:"voice,omitempty"`
|
|
||||||
Pitch int `json:"pitch,omitempty"`
|
|
||||||
Speak bool `json:"speak,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ackMessage struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
ID string `json:"id"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
package roverd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
type SerialConfig struct {
|
|
||||||
Device string `yaml:"device"`
|
|
||||||
Baud int `yaml:"baud"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Duration struct {
|
|
||||||
time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Duration) UnmarshalYAML(value *yaml.Node) error {
|
|
||||||
var raw string
|
|
||||||
if err := value.Decode(&raw); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
parsed, err := time.ParseDuration(raw)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
d.Duration = parsed
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d Duration) MarshalYAML() (interface{}, error) {
|
|
||||||
return d.Duration.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type BRCConfig struct {
|
|
||||||
GPIOPin int `yaml:"gpioPin"`
|
|
||||||
GPIOChip string `yaml:"gpioChip"`
|
|
||||||
PulseEvery Duration `yaml:"pulseEvery"`
|
|
||||||
PulseWidth Duration `yaml:"pulseWidth"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b BRCConfig) Enabled() bool {
|
|
||||||
return b.GPIOPin >= 0
|
|
||||||
}
|
|
||||||
|
|
||||||
type BatteryConfig struct {
|
|
||||||
Full int `yaml:"full"`
|
|
||||||
Warn int `yaml:"warn"`
|
|
||||||
Urgent int `yaml:"urgent"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AudioConfig struct {
|
|
||||||
CaptureEnabled bool `yaml:"captureEnabled" json:"captureEnabled"`
|
|
||||||
CaptureDevice string `yaml:"captureDevice" json:"captureDevice,omitempty"`
|
|
||||||
SampleRate int `yaml:"sampleRate" json:"sampleRate,omitempty"`
|
|
||||||
Channels int `yaml:"channels" json:"channels,omitempty"`
|
|
||||||
Bitrate int `yaml:"bitrate" json:"bitrate,omitempty"`
|
|
||||||
TTSEnabled bool `yaml:"ttsEnabled" json:"ttsEnabled"`
|
|
||||||
DefaultEngine string `yaml:"defaultEngine" json:"defaultEngine,omitempty"`
|
|
||||||
DefaultVoice string `yaml:"defaultVoice" json:"defaultVoice,omitempty"`
|
|
||||||
DefaultPitch int `yaml:"defaultPitch" json:"defaultPitch,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type MediaConfig struct {
|
|
||||||
PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"`
|
|
||||||
AudioPublishURL string `yaml:"audioPublishUrl" json:"audioPublishUrl,omitempty"`
|
|
||||||
PublishPort int `yaml:"publishPort" json:"-"`
|
|
||||||
Manage bool `yaml:"manage"`
|
|
||||||
ManageAudio bool `yaml:"manageAudio"`
|
|
||||||
Service string `yaml:"service"`
|
|
||||||
AudioService string `yaml:"audioService"`
|
|
||||||
HealthURL string `yaml:"healthUrl"`
|
|
||||||
HealthInterval Duration `yaml:"healthInterval"`
|
|
||||||
VideoWidth int `yaml:"videoWidth" json:"-"`
|
|
||||||
VideoHeight int `yaml:"videoHeight" json:"-"`
|
|
||||||
VideoFPS int `yaml:"videoFps" json:"-"`
|
|
||||||
VideoBitrate int `yaml:"videoBitrate" json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CameraServoConfig struct {
|
|
||||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
|
||||||
Pin int `yaml:"pin" json:"pin"`
|
|
||||||
FreqHz int `yaml:"freqHz" json:"freqHz"`
|
|
||||||
CycleLen int `yaml:"cycleLen" json:"cycleLen"`
|
|
||||||
MinPulseUs int `yaml:"minPulseUs" json:"minPulseUs"`
|
|
||||||
MaxPulseUs int `yaml:"maxPulseUs" json:"maxPulseUs"`
|
|
||||||
MinAngle float64 `yaml:"minAngle" json:"minAngle"`
|
|
||||||
MaxAngle float64 `yaml:"maxAngle" json:"maxAngle"`
|
|
||||||
HomeAngle float64 `yaml:"homeAngle" json:"homeAngle"`
|
|
||||||
NudgeDegrees float64 `yaml:"nudgeDegrees" json:"nudgeDegrees"`
|
|
||||||
AllowRawPulse bool `yaml:"allowRawPulse" json:"allowRawPulse"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
Name string `yaml:"name"`
|
|
||||||
ServerURL string `yaml:"serverUrl"`
|
|
||||||
Serial SerialConfig `yaml:"serial"`
|
|
||||||
BRC BRCConfig `yaml:"brc"`
|
|
||||||
Battery BatteryConfig `yaml:"battery"`
|
|
||||||
MaxWheelMMs int `yaml:"maxWheelSpeed"`
|
|
||||||
Media MediaConfig `yaml:"media"`
|
|
||||||
CameraServo CameraServoConfig `yaml:"cameraServo"`
|
|
||||||
Audio AudioConfig `yaml:"audio"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func LoadConfig(path string) (*Config, error) {
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
cfg := Config{
|
|
||||||
MaxWheelMMs: 500,
|
|
||||||
BRC: BRCConfig{
|
|
||||||
GPIOPin: 4,
|
|
||||||
GPIOChip: "gpiochip0",
|
|
||||||
PulseEvery: Duration{
|
|
||||||
Duration: time.Minute,
|
|
||||||
},
|
|
||||||
PulseWidth: Duration{
|
|
||||||
Duration: time.Second,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Media: MediaConfig{
|
|
||||||
PublishPort: 9000,
|
|
||||||
HealthInterval: Duration{Duration: 30 * time.Second},
|
|
||||||
VideoWidth: 1280,
|
|
||||||
VideoHeight: 720,
|
|
||||||
VideoFPS: 30,
|
|
||||||
VideoBitrate: 2000000,
|
|
||||||
},
|
|
||||||
CameraServo: CameraServoConfig{
|
|
||||||
Pin: 12,
|
|
||||||
FreqHz: 50,
|
|
||||||
CycleLen: 20000,
|
|
||||||
MinPulseUs: 900,
|
|
||||||
MaxPulseUs: 2100,
|
|
||||||
MinAngle: -15,
|
|
||||||
MaxAngle: 30,
|
|
||||||
HomeAngle: 0,
|
|
||||||
NudgeDegrees: 2,
|
|
||||||
},
|
|
||||||
Audio: AudioConfig{
|
|
||||||
CaptureEnabled: false,
|
|
||||||
CaptureDevice: "rovermic",
|
|
||||||
SampleRate: 48000,
|
|
||||||
Channels: 2,
|
|
||||||
Bitrate: 24000,
|
|
||||||
TTSEnabled: false,
|
|
||||||
DefaultEngine: "flite",
|
|
||||||
DefaultVoice: "rms",
|
|
||||||
DefaultPitch: 50,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if cfg.Name == "" {
|
|
||||||
return nil, errors.New("missing name")
|
|
||||||
}
|
|
||||||
if cfg.ServerURL == "" {
|
|
||||||
return nil, errors.New("missing serverUrl")
|
|
||||||
}
|
|
||||||
if cfg.Serial.Device == "" || cfg.Serial.Baud == 0 {
|
|
||||||
return nil, errors.New("serial device/baud required")
|
|
||||||
}
|
|
||||||
if cfg.Battery.Full == 0 {
|
|
||||||
return nil, errors.New("battery thresholds required")
|
|
||||||
}
|
|
||||||
if cfg.MaxWheelMMs <= 0 || cfg.MaxWheelMMs > 500 {
|
|
||||||
return nil, fmt.Errorf("maxWheelSpeed must be 1-500, got %d", cfg.MaxWheelMMs)
|
|
||||||
}
|
|
||||||
if cfg.BRC.GPIOChip == "" {
|
|
||||||
cfg.BRC.GPIOChip = "gpiochip0"
|
|
||||||
}
|
|
||||||
if cfg.Media.Manage && cfg.Media.Service == "" {
|
|
||||||
return nil, errors.New("media.manage requires media.service")
|
|
||||||
}
|
|
||||||
if cfg.Media.Manage && cfg.Media.HealthInterval.Duration <= 0 {
|
|
||||||
cfg.Media.HealthInterval = Duration{Duration: 30 * time.Second}
|
|
||||||
}
|
|
||||||
if cfg.Media.VideoWidth <= 0 {
|
|
||||||
cfg.Media.VideoWidth = 1280
|
|
||||||
}
|
|
||||||
if cfg.Media.VideoHeight <= 0 {
|
|
||||||
cfg.Media.VideoHeight = 720
|
|
||||||
}
|
|
||||||
if cfg.Media.VideoFPS <= 0 {
|
|
||||||
cfg.Media.VideoFPS = 30
|
|
||||||
}
|
|
||||||
if cfg.Media.VideoBitrate <= 0 {
|
|
||||||
cfg.Media.VideoBitrate = 3000000
|
|
||||||
}
|
|
||||||
if cfg.Media.PublishPort <= 0 {
|
|
||||||
cfg.Media.PublishPort = 9000
|
|
||||||
}
|
|
||||||
if cfg.Media.PublishURL == "" {
|
|
||||||
derived, err := derivePublishURL(cfg.ServerURL, cfg.Name, cfg.Media.PublishPort)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("derive publishUrl: %w", err)
|
|
||||||
}
|
|
||||||
cfg.Media.PublishURL = derived
|
|
||||||
}
|
|
||||||
if cfg.Media.AudioPublishURL == "" {
|
|
||||||
derived, err := derivePublishURL(cfg.ServerURL, cfg.Name+"-audio", cfg.Media.PublishPort)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("derive audioPublishUrl: %w", err)
|
|
||||||
}
|
|
||||||
cfg.Media.AudioPublishURL = derived
|
|
||||||
}
|
|
||||||
if err := validateServoConfig(&cfg.CameraServo); err != nil {
|
|
||||||
return nil, fmt.Errorf("cameraServo: %w", err)
|
|
||||||
}
|
|
||||||
validateAudioConfig(&cfg.Audio)
|
|
||||||
return &cfg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateServoConfig(cfg *CameraServoConfig) error {
|
|
||||||
if !cfg.Enabled {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if cfg.Pin <= 0 {
|
|
||||||
return errors.New("pin must be > 0")
|
|
||||||
}
|
|
||||||
if cfg.FreqHz <= 0 {
|
|
||||||
return errors.New("freqHz must be > 0")
|
|
||||||
}
|
|
||||||
if cfg.CycleLen <= 0 {
|
|
||||||
return errors.New("cycleLen must be > 0")
|
|
||||||
}
|
|
||||||
if cfg.MinPulseUs <= 0 || cfg.MaxPulseUs <= 0 || cfg.MinPulseUs >= cfg.MaxPulseUs {
|
|
||||||
return errors.New("minPulseUs/maxPulseUs invalid")
|
|
||||||
}
|
|
||||||
if cfg.MinAngle >= cfg.MaxAngle {
|
|
||||||
return errors.New("minAngle must be less than maxAngle")
|
|
||||||
}
|
|
||||||
cfg.HomeAngle = clampFloat(cfg.HomeAngle, cfg.MinAngle, cfg.MaxAngle)
|
|
||||||
if cfg.NudgeDegrees <= 0 {
|
|
||||||
cfg.NudgeDegrees = 2
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func clampFloat(value, min, max float64) float64 {
|
|
||||||
if value < min {
|
|
||||||
return min
|
|
||||||
}
|
|
||||||
if value > max {
|
|
||||||
return max
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateAudioConfig(cfg *AudioConfig) {
|
|
||||||
if cfg.CaptureEnabled && cfg.CaptureDevice == "" {
|
|
||||||
cfg.CaptureDevice = "hw:0,0"
|
|
||||||
}
|
|
||||||
if cfg.SampleRate <= 0 {
|
|
||||||
cfg.SampleRate = 48000
|
|
||||||
}
|
|
||||||
if cfg.Channels <= 0 {
|
|
||||||
cfg.Channels = 2
|
|
||||||
}
|
|
||||||
if cfg.Bitrate <= 0 {
|
|
||||||
cfg.Bitrate = 64000
|
|
||||||
}
|
|
||||||
if cfg.DefaultEngine == "" {
|
|
||||||
cfg.DefaultEngine = "flite"
|
|
||||||
}
|
|
||||||
if cfg.DefaultVoice == "" {
|
|
||||||
cfg.DefaultVoice = "rms"
|
|
||||||
}
|
|
||||||
if cfg.DefaultPitch <= 0 {
|
|
||||||
cfg.DefaultPitch = 50
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func derivePublishURL(serverURL, streamName string, port int) (string, error) {
|
|
||||||
if streamName == "" {
|
|
||||||
return "", errors.New("missing stream name for publishUrl")
|
|
||||||
}
|
|
||||||
parsed, err := url.Parse(serverURL)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
host := parsed.Hostname()
|
|
||||||
if host == "" {
|
|
||||||
return "", errors.New("serverUrl missing host")
|
|
||||||
}
|
|
||||||
if port <= 0 {
|
|
||||||
port = 9000
|
|
||||||
}
|
|
||||||
escaped := url.PathEscape(streamName)
|
|
||||||
return fmt.Sprintf("srt://%s:%d?streamid=#!::r=%s,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316", host, port, escaped), nil
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package roverd
|
|
||||||
|
|
||||||
type RoverEvent struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Event string `json:"event"`
|
|
||||||
Ts int64 `json:"ts"`
|
|
||||||
Data map[string]any `json:"data,omitempty"`
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
module multiroombarover/pi/roverd
|
|
||||||
|
|
||||||
go 1.25.4
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/stianeikeland/go-rpio/v4 v4.6.0
|
|
||||||
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07
|
|
||||||
github.com/warthog618/go-gpiocdev v0.9.1
|
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
|
||||||
nhooyr.io/websocket v1.8.17
|
|
||||||
)
|
|
||||||
|
|
||||||
require golang.org/x/sys v0.38.0 // indirect
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/stianeikeland/go-rpio/v4 v4.6.0 h1:eAJgtw3jTtvn/CqwbC82ntcS+dtzUTgo5qlZKe677EY=
|
|
||||||
github.com/stianeikeland/go-rpio/v4 v4.6.0/go.mod h1:A3GvHxC1Om5zaId+HqB3HKqx4K/AqeckxB7qRjxMK7o=
|
|
||||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
|
||||||
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 h1:UyzmZLoiDWMRywV4DUYb9Fbt8uiOSooupjTq10vpvnU=
|
|
||||||
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA=
|
|
||||||
github.com/warthog618/go-gpiocdev v0.9.1 h1:pwHPaqjJfhCipIQl78V+O3l9OKHivdRDdmgXYbmhuCI=
|
|
||||||
github.com/warthog618/go-gpiocdev v0.9.1/go.mod h1:dN3e3t/S2aSNC+hgigGE/dBW8jE1ONk9bDSEYfoPyl8=
|
|
||||||
github.com/warthog618/go-gpiosim v0.1.1 h1:MRAEv+T+itmw+3GeIGpQJBfanUVyg0l3JCTwHtwdre4=
|
|
||||||
github.com/warthog618/go-gpiosim v0.1.1/go.mod h1:YXsnB+I9jdCMY4YAlMSRrlts25ltjmuIsrnoUrBLdqU=
|
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=
|
|
||||||
nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
package roverd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
)
|
|
||||||
|
|
||||||
const publisherEnvPath = "/var/lib/roverd/video.env"
|
|
||||||
|
|
||||||
func UpdatePublisherEnv(media MediaConfig, audio AudioConfig) error {
|
|
||||||
if media.PublishURL == "" {
|
|
||||||
return fmt.Errorf("media publishUrl missing")
|
|
||||||
}
|
|
||||||
if media.AudioPublishURL == "" && audio.CaptureEnabled {
|
|
||||||
return fmt.Errorf("audio publishUrl missing")
|
|
||||||
}
|
|
||||||
if media.VideoWidth <= 0 || media.VideoHeight <= 0 || media.VideoFPS <= 0 || media.VideoBitrate <= 0 {
|
|
||||||
return fmt.Errorf("invalid media dimensions/bitrate")
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(filepath.Dir(publisherEnvPath), 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var buf bytes.Buffer
|
|
||||||
fmt.Fprintf(&buf, "PUBLISH_URL=%s\n", media.PublishURL)
|
|
||||||
if audio.CaptureEnabled && media.AudioPublishURL != "" {
|
|
||||||
fmt.Fprintf(&buf, "AUDIO_PUBLISH_URL=%s\n", media.AudioPublishURL)
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&buf, "VIDEO_WIDTH=%d\n", media.VideoWidth)
|
|
||||||
fmt.Fprintf(&buf, "VIDEO_HEIGHT=%d\n", media.VideoHeight)
|
|
||||||
fmt.Fprintf(&buf, "VIDEO_FPS=%d\n", media.VideoFPS)
|
|
||||||
fmt.Fprintf(&buf, "VIDEO_BITRATE=%d\n", media.VideoBitrate)
|
|
||||||
audioDevice := audio.CaptureDevice
|
|
||||||
if audioDevice == "" || audioDevice == "rovermic" {
|
|
||||||
audioDevice = "hw:0,0"
|
|
||||||
}
|
|
||||||
if audio.SampleRate <= 0 {
|
|
||||||
audio.SampleRate = 48000
|
|
||||||
}
|
|
||||||
if audio.Channels <= 0 {
|
|
||||||
audio.Channels = 2
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&buf, "AUDIO_ENABLE=%d\n", boolToInt(audio.CaptureEnabled))
|
|
||||||
fmt.Fprintf(&buf, "AUDIO_DEVICE=%s\n", audioDevice)
|
|
||||||
fmt.Fprintf(&buf, "AUDIO_RATE=%d\n", audio.SampleRate)
|
|
||||||
fmt.Fprintf(&buf, "AUDIO_CHANNELS=%d\n", audio.Channels)
|
|
||||||
if err := os.WriteFile(publisherEnvPath, buf.Bytes(), 0o640); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func boolToInt(v bool) int {
|
|
||||||
if v {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
package roverd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os/exec"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type MediaSupervisor struct {
|
|
||||||
cfg MediaConfig
|
|
||||||
audio AudioConfig
|
|
||||||
logger *log.Logger
|
|
||||||
client *http.Client
|
|
||||||
checkInterval time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewMediaSupervisor(cfg MediaConfig, audio AudioConfig, logger *log.Logger) *MediaSupervisor {
|
|
||||||
if err := UpdatePublisherEnv(cfg, audio); err != nil {
|
|
||||||
logger.Printf("media supervisor: update env failed: %v", err)
|
|
||||||
}
|
|
||||||
if !cfg.Manage || cfg.Service == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
interval := cfg.HealthInterval.Duration
|
|
||||||
if interval <= 0 {
|
|
||||||
interval = 30 * time.Second
|
|
||||||
}
|
|
||||||
var client *http.Client
|
|
||||||
if cfg.HealthURL != "" {
|
|
||||||
client = &http.Client{Timeout: 5 * time.Second}
|
|
||||||
}
|
|
||||||
return &MediaSupervisor{
|
|
||||||
cfg: cfg,
|
|
||||||
audio: audio,
|
|
||||||
logger: logger,
|
|
||||||
client: client,
|
|
||||||
checkInterval: interval,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MediaSupervisor) Start(ctx context.Context) {
|
|
||||||
if m == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := UpdatePublisherEnv(m.cfg, m.audio); err != nil {
|
|
||||||
m.logger.Printf("media supervisor: update env failed: %v", err)
|
|
||||||
}
|
|
||||||
if m.cfg.HealthURL == "" || m.client == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
go func() {
|
|
||||||
ticker := time.NewTicker(m.checkInterval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
if err := m.checkAndRepair(); err != nil {
|
|
||||||
m.logger.Printf("media supervisor: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
if err := m.checkAndRepair(); err != nil {
|
|
||||||
m.logger.Printf("media supervisor: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MediaSupervisor) HandleAction(ctx context.Context, action string) error {
|
|
||||||
if m == nil {
|
|
||||||
return errors.New("media supervisor disabled")
|
|
||||||
}
|
|
||||||
if err := UpdatePublisherEnv(m.cfg, m.audio); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
switch action {
|
|
||||||
case "start", "stop", "restart", "reload", "status":
|
|
||||||
return m.runSystemctl(ctx, action)
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unknown media action: %s", action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MediaSupervisor) checkAndRepair() error {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 7*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
if m.checkHealth(ctx) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
m.logger.Printf("media supervisor: health check failed, restarting %s", m.cfg.Service)
|
|
||||||
if err := m.runSystemctl(ctx, "restart"); err != nil {
|
|
||||||
return fmt.Errorf("restart mediamtx: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MediaSupervisor) checkHealth(ctx context.Context) bool {
|
|
||||||
if m.client == nil || m.cfg.HealthURL == "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, m.cfg.HealthURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
m.logger.Printf("media supervisor: health request: %v", err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
resp, err := m.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
m.logger.Printf("media supervisor: health request failed: %v", err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
_, _ = io.Copy(io.Discard, resp.Body)
|
|
||||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
m.logger.Printf("media supervisor: unexpected health status %d", resp.StatusCode)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MediaSupervisor) runSystemctl(ctx context.Context, action string) error {
|
|
||||||
if m.cfg.Service == "" {
|
|
||||||
return errors.New("no media service configured")
|
|
||||||
}
|
|
||||||
runCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
cmd := exec.CommandContext(runCtx, "systemctl", action, m.cfg.Service)
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("systemctl %s %s: %w (%s)", action, m.cfg.Service, err, string(output))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
Binary file not shown.
@@ -1,49 +0,0 @@
|
|||||||
# Sample configuration for roverd
|
|
||||||
name: roomba-alpha
|
|
||||||
serverUrl: ws://control-server.local:8080/rover
|
|
||||||
serial:
|
|
||||||
device: /dev/ttyAMA0
|
|
||||||
baud: 115200
|
|
||||||
brc:
|
|
||||||
gpioPin: 4
|
|
||||||
gpioChip: gpiochip0
|
|
||||||
pulseEvery: 1m
|
|
||||||
pulseWidth: 1s
|
|
||||||
battery:
|
|
||||||
full: 2068
|
|
||||||
warn: 1700
|
|
||||||
urgent: 1650
|
|
||||||
maxWheelSpeed: 350
|
|
||||||
media:
|
|
||||||
publishUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha,m=publish&latency=10&mode=caller&transtype=live&pkt_size=1316
|
|
||||||
publishPort: 9000
|
|
||||||
videoWidth: 1280
|
|
||||||
videoHeight: 720
|
|
||||||
videoFps: 30
|
|
||||||
videoBitrate: 2000000
|
|
||||||
manage: true
|
|
||||||
service: video-publisher.service
|
|
||||||
healthUrl: ""
|
|
||||||
healthInterval: 30s
|
|
||||||
cameraServo:
|
|
||||||
enabled: false
|
|
||||||
pin: 12
|
|
||||||
freqHz: 50
|
|
||||||
cycleLen: 20000
|
|
||||||
minPulseUs: 900
|
|
||||||
maxPulseUs: 2100
|
|
||||||
minAngle: -15
|
|
||||||
maxAngle: 30
|
|
||||||
homeAngle: 0
|
|
||||||
nudgeDegrees: 2
|
|
||||||
allowRawPulse: false
|
|
||||||
audio:
|
|
||||||
captureEnabled: false
|
|
||||||
captureDevice: hw:0,0
|
|
||||||
sampleRate: 48000
|
|
||||||
channels: 2
|
|
||||||
bitrate: 24000
|
|
||||||
ttsEnabled: false
|
|
||||||
defaultEngine: flite
|
|
||||||
defaultVoice: rms
|
|
||||||
defaultPitch: 50
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# Sample configuration for roverd
|
|
||||||
name: roomba-alpha
|
|
||||||
serverUrl: ws://control-server.local:8080/rover
|
|
||||||
serial:
|
|
||||||
device: /dev/ttyAMA0
|
|
||||||
baud: 115200
|
|
||||||
brc:
|
|
||||||
gpioPin: 4
|
|
||||||
gpioChip: gpiochip0
|
|
||||||
pulseEvery: 1m
|
|
||||||
pulseWidth: 1s
|
|
||||||
battery:
|
|
||||||
full: 2068
|
|
||||||
warn: 1700
|
|
||||||
urgent: 1650
|
|
||||||
maxWheelSpeed: 350
|
|
||||||
media:
|
|
||||||
manage: false
|
|
||||||
service: mediamtx.service
|
|
||||||
healthUrl: http://127.0.0.1:9997/v3/paths/list
|
|
||||||
healthInterval: 30s
|
|
||||||
cameraServo:
|
|
||||||
enabled: false
|
|
||||||
pin: 19
|
|
||||||
freqHz: 50
|
|
||||||
cycleLen: 20000
|
|
||||||
minPulseUs: 900
|
|
||||||
maxPulseUs: 2100
|
|
||||||
minAngle: -15
|
|
||||||
maxAngle: 30
|
|
||||||
homeAngle: 0
|
|
||||||
nudgeDegrees: 2
|
|
||||||
allowRawPulse: false
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package roverd
|
|
||||||
|
|
||||||
var (
|
|
||||||
defaultStreamPackets = []byte{100, 21, 34}
|
|
||||||
packetSizes = map[byte]int{
|
|
||||||
100: 80,
|
|
||||||
21: 1,
|
|
||||||
34: 1,
|
|
||||||
}
|
|
||||||
expectedPayloadLength = func() int {
|
|
||||||
sum := 0
|
|
||||||
for _, id := range defaultStreamPackets {
|
|
||||||
sum += 1 + packetSizes[id]
|
|
||||||
}
|
|
||||||
return sum
|
|
||||||
}()
|
|
||||||
)
|
|
||||||
|
|
||||||
type SensorSample struct {
|
|
||||||
Timestamp int64
|
|
||||||
ChargingState byte
|
|
||||||
ChargeSources byte
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
//go:build !dummy
|
|
||||||
|
|
||||||
package roverd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"context"
|
|
||||||
"encoding/hex"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
sensorHeader = 19
|
|
||||||
sensorReadTimeout = 150 * time.Millisecond
|
|
||||||
sensorThrottleMinimum = 250 * time.Millisecond
|
|
||||||
)
|
|
||||||
|
|
||||||
type SensorStreamer struct {
|
|
||||||
r io.Reader
|
|
||||||
rawOut chan<- []byte
|
|
||||||
parsed chan<- SensorSample
|
|
||||||
logger *log.Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewSensorStreamer(r io.Reader, rawOut chan<- []byte, parsed chan<- SensorSample, logger *log.Logger) *SensorStreamer {
|
|
||||||
return &SensorStreamer{r: r, rawOut: rawOut, parsed: parsed, logger: logger}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SensorStreamer) Run(ctx context.Context) {
|
|
||||||
reader := bufio.NewReader(s.r)
|
|
||||||
var nextSend time.Time
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
header, err := reader.ReadByte()
|
|
||||||
if err != nil {
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if header != sensorHeader {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
nBytes, err := reader.ReadByte()
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
frame := make([]byte, int(nBytes)+3)
|
|
||||||
frame[0] = sensorHeader
|
|
||||||
frame[1] = nBytes
|
|
||||||
if _, err := io.ReadFull(reader, frame[2:]); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if !validateChecksum(frame) {
|
|
||||||
s.logger.Printf("sensor checksum failed: %s", hex.EncodeToString(frame))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
if !nextSend.IsZero() && now.Before(nextSend) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
nextSend = now.Add(sensorThrottleMinimum)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case s.rawOut <- frame:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.parsed != nil {
|
|
||||||
if sample, ok := decodeSensorSample(frame); ok {
|
|
||||||
select {
|
|
||||||
case s.parsed <- sample:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateChecksum(buf []byte) bool {
|
|
||||||
var sum int
|
|
||||||
for _, b := range buf {
|
|
||||||
sum += int(b)
|
|
||||||
}
|
|
||||||
return byte(sum&0xFF) == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeSensorSample(frame []byte) (SensorSample, bool) {
|
|
||||||
if len(frame) < 3 {
|
|
||||||
return SensorSample{}, false
|
|
||||||
}
|
|
||||||
nBytes := int(frame[1])
|
|
||||||
if nBytes+3 != len(frame) {
|
|
||||||
return SensorSample{}, false
|
|
||||||
}
|
|
||||||
payload := frame[2 : 2+nBytes]
|
|
||||||
if len(payload) != expectedPayloadLength {
|
|
||||||
return SensorSample{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
idx := 0
|
|
||||||
var sample SensorSample
|
|
||||||
var seen byte
|
|
||||||
for idx < len(payload) {
|
|
||||||
id := payload[idx]
|
|
||||||
idx++
|
|
||||||
size, ok := packetSizes[id]
|
|
||||||
if !ok {
|
|
||||||
return SensorSample{}, false
|
|
||||||
}
|
|
||||||
if idx+size > len(payload) {
|
|
||||||
return SensorSample{}, false
|
|
||||||
}
|
|
||||||
segment := payload[idx : idx+size]
|
|
||||||
switch id {
|
|
||||||
case 21:
|
|
||||||
sample.ChargingState = segment[0]
|
|
||||||
seen |= 1
|
|
||||||
case 34:
|
|
||||||
sample.ChargeSources = segment[0]
|
|
||||||
seen |= 2
|
|
||||||
}
|
|
||||||
idx += size
|
|
||||||
}
|
|
||||||
sample.Timestamp = time.Now().UnixMilli()
|
|
||||||
return sample, seen&3 == 3
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
//go:build dummy
|
|
||||||
|
|
||||||
package roverd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log"
|
|
||||||
"math/rand"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const sensorHeader = 19
|
|
||||||
|
|
||||||
type SensorStreamer struct {
|
|
||||||
rawOut chan<- []byte
|
|
||||||
parsed chan<- SensorSample
|
|
||||||
logger *log.Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewSensorStreamer(_ interface{}, rawOut chan<- []byte, parsed chan<- SensorSample, logger *log.Logger) *SensorStreamer {
|
|
||||||
return &SensorStreamer{rawOut: rawOut, parsed: parsed, logger: logger}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SensorStreamer) Run(ctx context.Context) {
|
|
||||||
ticker := time.NewTicker(200 * time.Millisecond)
|
|
||||||
defer ticker.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
frame := buildDummyFrame()
|
|
||||||
select {
|
|
||||||
case s.rawOut <- frame:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
sample := SensorSample{
|
|
||||||
Timestamp: time.Now().UnixMilli(),
|
|
||||||
ChargingState: 3, // trickle charging
|
|
||||||
ChargeSources: 0b10, // home base present
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case s.parsed <- sample:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildDummyFrame() []byte {
|
|
||||||
payload := make([]byte, 0, expectedPayloadLength)
|
|
||||||
payload = append(payload, 100)
|
|
||||||
group := make([]byte, packetSizes[100])
|
|
||||||
group[0] = byte(rand.Intn(16)) // bumps
|
|
||||||
payload = append(payload, group...)
|
|
||||||
payload = append(payload, 21, 3)
|
|
||||||
payload = append(payload, 34, 0b10)
|
|
||||||
|
|
||||||
buf := make([]byte, 0, len(payload)+3)
|
|
||||||
buf = append(buf, sensorHeader, byte(len(payload)))
|
|
||||||
buf = append(buf, payload...)
|
|
||||||
checksum := calcChecksum(buf)
|
|
||||||
buf = append(buf, checksum)
|
|
||||||
return buf
|
|
||||||
}
|
|
||||||
|
|
||||||
func calcChecksum(buf []byte) byte {
|
|
||||||
sum := 0
|
|
||||||
for _, b := range buf {
|
|
||||||
sum += int(b)
|
|
||||||
}
|
|
||||||
return byte((-sum) & 0xFF)
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
//go:build !dummy
|
|
||||||
|
|
||||||
package roverd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/tarm/serial"
|
|
||||||
)
|
|
||||||
|
|
||||||
type SerialAdapter struct {
|
|
||||||
port io.ReadWriteCloser
|
|
||||||
encoder *base64.Encoding
|
|
||||||
mu sync.Mutex
|
|
||||||
log *log.Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
func OpenSerial(cfg SerialConfig) (*serial.Port, error) {
|
|
||||||
return serial.OpenPort(&serial.Config{
|
|
||||||
Name: cfg.Device,
|
|
||||||
Baud: cfg.Baud,
|
|
||||||
ReadTimeout: sensorReadTimeout,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewSerialAdapter(port io.ReadWriteCloser, logger *log.Logger) *SerialAdapter {
|
|
||||||
return &SerialAdapter{
|
|
||||||
port: port,
|
|
||||||
encoder: base64.StdEncoding,
|
|
||||||
log: logger,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SerialAdapter) write(buf []byte) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
n, err := s.port.Write(buf)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if n != len(buf) {
|
|
||||||
return fmt.Errorf("short write %d/%d", n, len(buf))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SerialAdapter) DriveDirect(left, right int) error {
|
|
||||||
payload := []byte{
|
|
||||||
145,
|
|
||||||
byte((right >> 8) & 0xFF),
|
|
||||||
byte(right & 0xFF),
|
|
||||||
byte((left >> 8) & 0xFF),
|
|
||||||
byte(left & 0xFF),
|
|
||||||
}
|
|
||||||
return s.write(payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SerialAdapter) MotorPWM(main, side, vacuum int) error {
|
|
||||||
payload := []byte{
|
|
||||||
144,
|
|
||||||
byte(main & 0xFF),
|
|
||||||
byte(side & 0xFF),
|
|
||||||
byte(vacuum & 0xFF),
|
|
||||||
}
|
|
||||||
return s.write(payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SerialAdapter) StartSensorStream(packets []byte) error {
|
|
||||||
if len(packets) == 0 {
|
|
||||||
return errors.New("sensor stream requires packets")
|
|
||||||
}
|
|
||||||
payload := []byte{148, byte(len(packets))}
|
|
||||||
payload = append(payload, packets...)
|
|
||||||
return s.write(payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SerialAdapter) PauseSensorStream(pause bool) error {
|
|
||||||
state := byte(1)
|
|
||||||
if pause {
|
|
||||||
state = 0
|
|
||||||
}
|
|
||||||
return s.write([]byte{150, state})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SerialAdapter) SendRaw(raw []byte) error {
|
|
||||||
return s.write(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SerialAdapter) SeekDock() error {
|
|
||||||
return s.write([]byte{143})
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
//go:build dummy
|
|
||||||
|
|
||||||
package roverd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
)
|
|
||||||
|
|
||||||
type dummyPort struct{}
|
|
||||||
|
|
||||||
func (dummyPort) Read(p []byte) (int, error) { return 0, io.EOF }
|
|
||||||
func (dummyPort) Write(p []byte) (int, error) { return len(p), nil }
|
|
||||||
func (dummyPort) Close() error { return nil }
|
|
||||||
|
|
||||||
func OpenSerial(cfg SerialConfig) (io.ReadWriteCloser, error) {
|
|
||||||
return dummyPort{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type SerialAdapter struct {
|
|
||||||
log *log.Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewSerialAdapter(_ io.ReadWriteCloser, logger *log.Logger) *SerialAdapter {
|
|
||||||
return &SerialAdapter{log: logger}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SerialAdapter) DriveDirect(left, right int) error {
|
|
||||||
s.log.Printf("[dummy] drive L=%d R=%d", left, right)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SerialAdapter) MotorPWM(main, side, vacuum int) error {
|
|
||||||
s.log.Printf("[dummy] motor main=%d side=%d vacuum=%d", main, side, vacuum)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SerialAdapter) StartSensorStream(packets []byte) error {
|
|
||||||
if len(packets) == 0 {
|
|
||||||
return errors.New("sensor stream requires packets")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SerialAdapter) PauseSensorStream(pause bool) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SerialAdapter) SendRaw(raw []byte) error {
|
|
||||||
s.log.Printf("[dummy] raw %v", raw)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SerialAdapter) SeekDock() error {
|
|
||||||
s.log.Printf("[dummy] seek dock")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
package roverd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (c *WSClient) handleTTSPayload(payload *ttsPayload) error {
|
|
||||||
if payload == nil {
|
|
||||||
return fmt.Errorf("tts payload required")
|
|
||||||
}
|
|
||||||
if !c.cfg.Audio.TTSEnabled {
|
|
||||||
return fmt.Errorf("tts disabled on rover")
|
|
||||||
}
|
|
||||||
if payload.Speak == false {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
text := strings.TrimSpace(payload.Text)
|
|
||||||
if text == "" {
|
|
||||||
return fmt.Errorf("tts text required")
|
|
||||||
}
|
|
||||||
if len([]rune(text)) > 512 {
|
|
||||||
text = string([]rune(text)[:512])
|
|
||||||
}
|
|
||||||
|
|
||||||
engine := strings.ToLower(strings.TrimSpace(payload.Engine))
|
|
||||||
if engine == "" {
|
|
||||||
engine = strings.ToLower(strings.TrimSpace(c.cfg.Audio.DefaultEngine))
|
|
||||||
}
|
|
||||||
if engine == "" {
|
|
||||||
engine = "flite"
|
|
||||||
}
|
|
||||||
|
|
||||||
voice := strings.TrimSpace(payload.Voice)
|
|
||||||
if voice == "" {
|
|
||||||
voice = strings.TrimSpace(c.cfg.Audio.DefaultVoice)
|
|
||||||
}
|
|
||||||
pitch := payload.Pitch
|
|
||||||
if pitch <= 0 {
|
|
||||||
pitch = c.cfg.Audio.DefaultPitch
|
|
||||||
}
|
|
||||||
pitch = clampInt(pitch, 0, 99)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
var cmd *exec.Cmd
|
|
||||||
switch engine {
|
|
||||||
case "espeak", "e":
|
|
||||||
args := []string{}
|
|
||||||
if pitch > 0 {
|
|
||||||
args = append(args, "-p", fmt.Sprintf("%d", pitch))
|
|
||||||
}
|
|
||||||
args = append(args, text)
|
|
||||||
cmd = exec.CommandContext(ctx, "espeak", args...)
|
|
||||||
case "flite", "f":
|
|
||||||
args := []string{}
|
|
||||||
if voice != "" {
|
|
||||||
args = append(args, "-voice", voice)
|
|
||||||
}
|
|
||||||
args = append(args, "-t", text)
|
|
||||||
cmd = exec.CommandContext(ctx, "flite", args...)
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unsupported tts engine: %s", engine)
|
|
||||||
}
|
|
||||||
|
|
||||||
out, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("tts exec failed: %w (%s)", err, string(out))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
package roverd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"nhooyr.io/websocket"
|
|
||||||
)
|
|
||||||
|
|
||||||
type WSClient struct {
|
|
||||||
cfg *Config
|
|
||||||
adapter *SerialAdapter
|
|
||||||
sensorFrames <-chan []byte
|
|
||||||
events <-chan RoverEvent
|
|
||||||
media *MediaSupervisor
|
|
||||||
servo *CameraServo
|
|
||||||
log *log.Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events <-chan RoverEvent, media *MediaSupervisor, servo *CameraServo, logger *log.Logger) *WSClient {
|
|
||||||
return &WSClient{
|
|
||||||
cfg: cfg,
|
|
||||||
adapter: adapter,
|
|
||||||
sensorFrames: frames,
|
|
||||||
events: events,
|
|
||||||
media: media,
|
|
||||||
servo: servo,
|
|
||||||
log: logger,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WSClient) Run(ctx context.Context) error {
|
|
||||||
conn, _, err := websocket.Dial(ctx, c.cfg.ServerURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer conn.Close(websocket.StatusInternalError, "closed")
|
|
||||||
|
|
||||||
if err := c.sendHello(ctx, conn); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := c.ensureSensorStream(); err != nil {
|
|
||||||
c.log.Printf("sensor stream init failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
errCh := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
errCh <- c.readLoop(ctx, conn)
|
|
||||||
}()
|
|
||||||
go c.forwardSensors(ctx, conn)
|
|
||||||
go c.forwardEvents(ctx, conn)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
conn.Close(websocket.StatusNormalClosure, "context done")
|
|
||||||
return ctx.Err()
|
|
||||||
case err := <-errCh:
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error {
|
|
||||||
msg := helloMessage{
|
|
||||||
Type: "hello",
|
|
||||||
Name: c.cfg.Name,
|
|
||||||
Battery: c.cfg.Battery,
|
|
||||||
MaxWheelSpeed: c.cfg.MaxWheelMMs,
|
|
||||||
Media: c.cfg.Media,
|
|
||||||
CameraServo: c.cfg.CameraServo,
|
|
||||||
Audio: c.cfg.Audio,
|
|
||||||
}
|
|
||||||
c.log.Printf("sending hello (camera servo enabled=%v pin=%d)", msg.CameraServo.Enabled, msg.CameraServo.Pin)
|
|
||||||
return writeJSON(ctx, conn, msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WSClient) readLoop(ctx context.Context, conn *websocket.Conn) error {
|
|
||||||
for {
|
|
||||||
_, data, err := conn.Read(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var msg inboundMessage
|
|
||||||
if err := json.Unmarshal(data, &msg); err != nil {
|
|
||||||
c.log.Printf("invalid command: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if msg.ID == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
status := "ok"
|
|
||||||
cmdErr := c.dispatch(ctx, &msg)
|
|
||||||
if cmdErr != nil {
|
|
||||||
status = "error"
|
|
||||||
}
|
|
||||||
ack := ackMessage{
|
|
||||||
Type: "ack",
|
|
||||||
ID: msg.ID,
|
|
||||||
Status: status,
|
|
||||||
}
|
|
||||||
if cmdErr != nil {
|
|
||||||
ack.Error = cmdErr.Error()
|
|
||||||
}
|
|
||||||
if err := writeJSON(ctx, conn, ack); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
|
|
||||||
switch {
|
|
||||||
case msg.DriveDirect != nil:
|
|
||||||
left := clamp(msg.DriveDirect.Left, -c.cfg.MaxWheelMMs, c.cfg.MaxWheelMMs)
|
|
||||||
right := clamp(msg.DriveDirect.Right, -c.cfg.MaxWheelMMs, c.cfg.MaxWheelMMs)
|
|
||||||
return c.adapter.DriveDirect(left, right)
|
|
||||||
case msg.MotorPWM != nil:
|
|
||||||
main := clamp(msg.MotorPWM.Main, -127, 127)
|
|
||||||
side := clamp(msg.MotorPWM.Side, -127, 127)
|
|
||||||
vac := clamp(msg.MotorPWM.Vacuum, 0, 127)
|
|
||||||
return c.adapter.MotorPWM(main, side, vac)
|
|
||||||
case msg.SensorStream != nil:
|
|
||||||
if msg.SensorStream.Enable {
|
|
||||||
if err := c.adapter.StartSensorStream(defaultStreamPackets); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return c.adapter.PauseSensorStream(false)
|
|
||||||
}
|
|
||||||
return c.adapter.PauseSensorStream(true)
|
|
||||||
case msg.Raw != "" && len(msg.Raw) > 0:
|
|
||||||
buf, err := base64.StdEncoding.DecodeString(msg.Raw)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("raw decode: %w", err)
|
|
||||||
}
|
|
||||||
if err := c.adapter.SendRaw(buf); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(buf) > 0 && isModeOpcode(buf[0]) {
|
|
||||||
return c.ensureSensorStream()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
case msg.Media != nil:
|
|
||||||
if c.media == nil {
|
|
||||||
return fmt.Errorf("media supervisor disabled")
|
|
||||||
}
|
|
||||||
return c.media.HandleAction(ctx, msg.Media.Action)
|
|
||||||
case msg.Servo != nil:
|
|
||||||
if c.servo == nil {
|
|
||||||
return fmt.Errorf("camera servo disabled")
|
|
||||||
}
|
|
||||||
return c.handleServoCommand(msg.Servo)
|
|
||||||
case msg.TTS != nil:
|
|
||||||
return c.handleTTSPayload(msg.TTS)
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unsupported command type: %s", msg.Type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WSClient) handleServoCommand(payload *servoPayload) error {
|
|
||||||
switch {
|
|
||||||
case payload.Angle != nil:
|
|
||||||
return c.servo.SetAngle(*payload.Angle)
|
|
||||||
case payload.Nudge != nil:
|
|
||||||
return c.servo.Nudge(*payload.Nudge)
|
|
||||||
case payload.PulseUs != nil:
|
|
||||||
return c.servo.SetPulseWidth(*payload.PulseUs)
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("servo command requires angle, nudge, or pulseUs")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WSClient) forwardSensors(ctx context.Context, conn *websocket.Conn) {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case frame := <-c.sensorFrames:
|
|
||||||
msg := sensorMessage{
|
|
||||||
Type: "sensor",
|
|
||||||
Timestamp: time.Now().UnixMilli(),
|
|
||||||
Data: base64.StdEncoding.EncodeToString(frame),
|
|
||||||
}
|
|
||||||
if err := writeJSON(ctx, conn, msg); err != nil {
|
|
||||||
c.log.Printf("sensor send failed: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WSClient) forwardEvents(ctx context.Context, conn *websocket.Conn) {
|
|
||||||
if c.events == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case evt := <-c.events:
|
|
||||||
if evt.Type == "" {
|
|
||||||
evt.Type = "event"
|
|
||||||
}
|
|
||||||
if err := writeJSON(ctx, conn, evt); err != nil {
|
|
||||||
c.log.Printf("event send failed: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeJSON(ctx context.Context, conn *websocket.Conn, v any) error {
|
|
||||||
data, err := json.Marshal(v)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return conn.Write(ctx, websocket.MessageText, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func clamp(value, min, max int) int {
|
|
||||||
if value < min {
|
|
||||||
return min
|
|
||||||
}
|
|
||||||
if value > max {
|
|
||||||
return max
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WSClient) ensureSensorStream() error {
|
|
||||||
if err := c.adapter.StartSensorStream(defaultStreamPackets); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return c.adapter.PauseSensorStream(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isModeOpcode(op byte) bool {
|
|
||||||
switch op {
|
|
||||||
case 128, 131, 132:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Rover Audio Publisher (ALSA -> SRT)
|
|
||||||
After=network-online.target roverd.service
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=roverd
|
|
||||||
Group=roverd
|
|
||||||
EnvironmentFile=/var/lib/roverd/video.env
|
|
||||||
ExecStart=/usr/local/bin/audio-only-publisher
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=2
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Multi-Roomba rover control agent
|
|
||||||
After=network-online.target mediamtx.service
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
ExecStart=/usr/local/bin/roverd -config /etc/roverd.yaml
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=5
|
|
||||||
AmbientCapabilities=CAP_SYS_TTY_CONFIG CAP_SYS_RAWIO
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Rover Video Publisher (libcamera -> SRT)
|
|
||||||
After=network-online.target roverd.service
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=roverd
|
|
||||||
Group=roverd
|
|
||||||
WorkingDirectory=/var/lib/roverd
|
|
||||||
EnvironmentFile=/var/lib/roverd/video.env
|
|
||||||
ExecStart=/usr/local/bin/video-publisher
|
|
||||||
Restart=always
|
|
||||||
RestartSec=2
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# general idea
|
|
||||||
- the pi on each roomba will have a speaker and microphone
|
|
||||||
- alsa devices
|
|
||||||
- they are both the default audio device
|
|
||||||
- you will have to modify roverd, the pi install script, and the server to get this all to work
|
|
||||||
|
|
||||||
# adding stuff to installation script
|
|
||||||
- I am using the google voice AIY v1 kits for audio
|
|
||||||
- boot config stuff
|
|
||||||
- enable `dtoverlay=googlevoicehat-soundcard`
|
|
||||||
- disable `dtparam=audio=on`
|
|
||||||
- copy asound.conf in pi folder to /etc/asound.conf
|
|
||||||
|
|
||||||
# microphone
|
|
||||||
- add microphone to the SRT publish stream
|
|
||||||
|
|
||||||
# speaker
|
|
||||||
- user's chat messages will be TTS'ed through the speaker on the pi
|
|
||||||
- either flite with a way to choose the voice
|
|
||||||
- or espeak where you can choose the pitch
|
|
||||||
- on the web UI and in the chat API
|
|
||||||
- add new stuff to chat
|
|
||||||
- only shows up if you are on a rover
|
|
||||||
- only shows up if TTS is enabled on that rover
|
|
||||||
- people can choose between flite or espeak
|
|
||||||
- if they choose flite, they can choose the voice from the default flite voices (exclude awb and awb_time)
|
|
||||||
- if they choose espeak, they can choose the pitch. Have a dropdown with increments of 10 from 0 to 99
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# access control modes:
|
|
||||||
- open
|
|
||||||
- open to the public
|
|
||||||
- users are randomly assigned a rover to drive, with priority on the rover with the least amount of drivers
|
|
||||||
- if there are multiple people on one rover, those people will all be controlling that rover at the same time
|
|
||||||
- turns
|
|
||||||
- open to the public
|
|
||||||
- users are randomly assigned to a rover same as open mode
|
|
||||||
- if there are multiple people on one rover, they will each have one minute at a time to drive.
|
|
||||||
- the turn queue loops
|
|
||||||
- the rover will stop moving and stop all aux. motors if the turn switches to a different person
|
|
||||||
- admin
|
|
||||||
- admin authentication is required to access the driver page at all
|
|
||||||
- lockdown
|
|
||||||
- ONLY lockdown admins can access the driver page
|
|
||||||
- the future spectator page is DISABLED (not even a way to log into it)
|
|
||||||
|
|
||||||
# roles in the access control system:
|
|
||||||
- user
|
|
||||||
- default, for normal people who visit the site
|
|
||||||
- admin
|
|
||||||
- authentication needed
|
|
||||||
- can drive and view any rover when not on lockdown, no matter who is controlling it
|
|
||||||
- can switch modes (including switching to lockdown mode)
|
|
||||||
- lockdown admin
|
|
||||||
- authentication needed
|
|
||||||
- only works if said admin has lockdown: enabled in config
|
|
||||||
- can ALWAYS access and control EVERYTHING that there is to do on the site
|
|
||||||
|
|
||||||
# other things to keep in mind:
|
|
||||||
- in the future, there will be a Discord bot for community alerts and a few admin controls
|
|
||||||
- wherever the admin list is configured, there has to be a spot for their discord ID
|
|
||||||
- admin's discord IDs are linked to their admin name server-side
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# general idea
|
|
||||||
- uses the rover's reported battery full, warn, and urgent values
|
|
||||||
- will apply to all rovers individually
|
|
||||||
- uses the rover locking system
|
|
||||||
- completely server side
|
|
||||||
- always use the battery warn value as 0% battery and the full value as 100%
|
|
||||||
|
|
||||||
## first: the server-side server-wide event bus
|
|
||||||
- global server event bus
|
|
||||||
- used for realtime alerts between modules
|
|
||||||
- includes a way to tell where the event is coming from
|
|
||||||
|
|
||||||
## what will the battery manager do?
|
|
||||||
- watch each rover's battery charge number (reported in sensors)
|
|
||||||
- if the number reaches warn, fire an event on the event bus
|
|
||||||
- the UI will show a warning to users, independently based on sensor data
|
|
||||||
- once the rover is docked and charging, lock it.
|
|
||||||
- when the battery is fully charged, unlock it.
|
|
||||||
|
|
||||||
## while we're at it...
|
|
||||||
- add a lock reason to the rover locking system
|
|
||||||
- add the following to the rover roster in the UI session state:
|
|
||||||
- battery full #
|
|
||||||
- battery warn #
|
|
||||||
- battery urgent #
|
|
||||||
- in the rover roster UI component, add a display for battery percentage and lock reason.
|
|
||||||
- make the rover's background red when locked
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# general idea
|
|
||||||
- IR led on the front of each roomba
|
|
||||||
- connected to a pin on the pi
|
|
||||||
- made to be very directional
|
|
||||||
- each roomba has a different IR code
|
|
||||||
- use the omni reciever to get a shot
|
|
||||||
- keep score per user
|
|
||||||
- use the go alert system
|
|
||||||
- alert discord with a live score count
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
# general idea
|
|
||||||
- discord bot run by the server
|
|
||||||
- use discord.js
|
|
||||||
- don't use discord's slash commands
|
|
||||||
- listen for commands the old fashioned way
|
|
||||||
- only admins with IDs set in the server config can use commands
|
|
||||||
- use nice looking embeds with colors for everything but messages
|
|
||||||
|
|
||||||
# features
|
|
||||||
|
|
||||||
## admin server management commands
|
|
||||||
- admins can lock and unlock rovers from discord
|
|
||||||
- admins can change the server access control mode
|
|
||||||
|
|
||||||
## announcements
|
|
||||||
- the bot has an announcements channel assigned in the server config. It will also have an announcement role ID to optionally ping. it will announce:
|
|
||||||
- when a rover is locked / unlocked (no ping)
|
|
||||||
- when the server access mode is changed (ping)
|
|
||||||
- the bot will have an admin ping role and alert channel. in here will be:
|
|
||||||
- rover activity (no pings)
|
|
||||||
- docking
|
|
||||||
- charging
|
|
||||||
- stopping charging
|
|
||||||
- undocking
|
|
||||||
- rover alerts (ping)
|
|
||||||
- rover comes online
|
|
||||||
- rover goes offline
|
|
||||||
- rover is at warn battery
|
|
||||||
- rover is at urgent battery
|
|
||||||
|
|
||||||
## chat bridge
|
|
||||||
- bridge between the server chat and discord chat
|
|
||||||
- use chat events that are on the bus already
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# general idea:
|
|
||||||
- control on / off switches and lights in home assistant from the web UI
|
|
||||||
- use this:
|
|
||||||
- https://www.npmjs.com/package/home-assistant-js-websocket/v/3.1.2
|
|
||||||
- remember to ignore updates that are of the same states, this library will give you a lot of those
|
|
||||||
- switches and lights are configured in the server's config file
|
|
||||||
- give each one a name (no description)
|
|
||||||
- auto detect a switch type or light type
|
|
||||||
- deliver list of lights to the UI through the session service
|
|
||||||
- show realtime on/off status of the switches / lights in the UI
|
|
||||||
- create a react component for the controls
|
|
||||||
- it will automatically create a control for each switch / light
|
|
||||||
|
|
||||||
## permissions:
|
|
||||||
- even if someone isnt assigned to a rover, they should be able to control the switches / lights
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# Video flow (SRT ingest, WHEP playback)
|
|
||||||
|
|
||||||
1. **Raspberry Pi Zero 2 W**
|
|
||||||
- `pi/bin/video-publisher.sh` captures the CSI camera with `rpicam-vid`/`libcamera-vid` using the onboard H.264 encoder (`--inline --profile baseline --bitrate …`).
|
|
||||||
- The Annex-B stream is piped into the stock FFmpeg package and pushed to the control server over SRT as MPEG‑TS: `ffmpeg -f h264 -i - -c copy -f mpegts "srt://<server>:9000?streamid=#!::r=rover-alpha,m=publish&latency=20&mode=caller&transtype=live&pkt_size=1316"`.
|
|
||||||
- Configuration lives in `/var/lib/roverd/video.env` (`PUBLISH_URL`, resolution, FPS, bitrate). `roverd` rewrites this file whenever the rover config changes, so onboarding a new rover is just flashing the SD card, setting its `name`, and plugging it into the trusted LAN—**no auth or per-rover server config is required on the Pi ⇄ server hop.**
|
|
||||||
|
|
||||||
2. **mediaMTX on the server**
|
|
||||||
- Single wildcard path handles every rover:
|
|
||||||
```yaml
|
|
||||||
paths:
|
|
||||||
"~^rover-(?P<id>[a-z0-9_-]+)$":
|
|
||||||
source: publisher
|
|
||||||
sourceOnDemand: no
|
|
||||||
sourceProtocol: srt
|
|
||||||
readBufferCount: 512
|
|
||||||
webrtcEnable: yes
|
|
||||||
webrtcMaxPlayoutDelay: 0
|
|
||||||
alwaysRemuxWhep: no
|
|
||||||
```
|
|
||||||
- Pis publish to `srt://<server>:9000` with the streamid above; mediaMTX auto-creates the path and fans it out over WHEP/WebRTC. If you expose playback under `/video/<id>` externally, let the reverse proxy rewrite that prefix back to `<id>` before forwarding to mediaMTX so the wildcard continues to match every rover.
|
|
||||||
- Only playback is gated: mediaMTX calls the Node server to validate JWTs on `/whep/rover-<id>`, so “locking” a stream is as simple as refusing to mint viewer tokens for that rover. Ingest stays unauthenticated because it lives on a secure LAN.
|
|
||||||
|
|
||||||
3. **Web clients**
|
|
||||||
- Tiny helper (React hook or vanilla class) that:
|
|
||||||
1. Requests a viewer token for rover `<id>`.
|
|
||||||
2. Issues a `POST` to `<mediamtx-host>/<rover-id>/whep` with `Authorization: Basic base64(token:token)` (token issued by the server when the client calls `video:request`).
|
|
||||||
3. Maintains auto-reconnect timers on ICE failure so dashboard widgets can come/go without reloading the page.
|
|
||||||
- Operator dashboard mounts one player tied to the assigned rover. The spectator view instantiates one player per tile, muting + pausing hidden elements to keep CPU usage sane even when every rover is shown simultaneously.
|
|
||||||
|
|
||||||
# Deployment checklist
|
|
||||||
|
|
||||||
- `pi/install_roverd.sh` installs the `video-publisher` helper, drops `/var/lib/roverd/video.env`, and pulls in `libcamera-apps` + `ffmpeg` from apt. No custom FFmpeg, no WHIP builds, no extra config—Pis become plug-and-play.
|
|
||||||
- `roverd` derives `media.publishUrl` automatically from `serverUrl`: `srt://<server>:9000?streamid=#!::r=<name>,m=publish&latency=20&mode=caller&transtype=live&pkt_size=1316`. The media supervisor rewrites `video.env` and manages `video-publisher.service` whenever you hit “Restart Camera” or change `/etc/roverd.yaml`.
|
|
||||||
- The server’s mediaMTX config switches to the wildcard block above, leaves SRT ingest open, and enforces JWTs only on WHEP viewers. Fan-out stays inside mediaMTX so every browser sees the same low-latency stream (≈250–350 ms glass-to-glass).
|
|
||||||
- Adding a rover = flash SD → set `/etc/roverd.yaml` (`name`, `serverUrl`, camera knobs if needed) → boot it. The server auto-discovers the new `rover-<id>` stream with zero manual edits.
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
# nicknames:
|
|
||||||
- users will be able to have and set nicknames
|
|
||||||
- a user's nickname will store in the browser using persistence.js
|
|
||||||
- the user's nickname should probably just be in socket.data.nickname
|
|
||||||
- on connection, the web UI will tell the server "this is my nickname"
|
|
||||||
- completely enforced by the web UI
|
|
||||||
- replace any place in the web UI that shows a socket ID with the nickname
|
|
||||||
- create a small react component which can set your nickname and save it
|
|
||||||
|
|
||||||
# user list in session data:
|
|
||||||
- add a list of users to the session data for the web UI
|
|
||||||
- contains for each user:
|
|
||||||
- socket ID
|
|
||||||
- nickname
|
|
||||||
- the rover that they are driving
|
|
||||||
- their role (user, admin, lockdown, spectator)
|
|
||||||
|
|
||||||
# user list:
|
|
||||||
- a list of users
|
|
||||||
- new react component
|
|
||||||
- uses user list with nicknames from session data
|
|
||||||
- show their nickname and whether or not they are an admin
|
|
||||||
|
|
||||||
# chat:
|
|
||||||
- server side chat system:
|
|
||||||
- don't store message history
|
|
||||||
- emit an event to the event bus for each message
|
|
||||||
- eventually will be forwarded to discord through a bot
|
|
||||||
- listen to chat message events on the bus
|
|
||||||
- eventually the discord bot will also send messages to the server chat
|
|
||||||
- profanity filter
|
|
||||||
- spam filter
|
|
||||||
- repeated words
|
|
||||||
- keymashing
|
|
||||||
- etc
|
|
||||||
- somehow link chat messages to rovers
|
|
||||||
- in the future rovers will have TTS onboard, and will speak chat messages only from the person driving the rover
|
|
||||||
- chat on the UI
|
|
||||||
- new react component
|
|
||||||
- show messages as they come in:
|
|
||||||
- time (just like 19:23), sender nickname, rover they are driving, message
|
|
||||||
- pressing enter on the keyboard will pause rover control, and focus the chat box.
|
|
||||||
- pressing enter again after typing will send the message
|
|
||||||
|
|
||||||
|
|
||||||
# layout and styling for new UI elements:
|
|
||||||
- use index.css styles to match the new stuff to the current UI
|
|
||||||
- just edit the desktop page for now
|
|
||||||
- layout:
|
|
||||||
- in the left column, a new row in between video and logs
|
|
||||||
- 50/50 split between:
|
|
||||||
- user list and nickname entry
|
|
||||||
- chat history and chat box
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
## multi roomba rover
|
|
||||||
|
|
||||||
a website where people can control multiple irobot create 2 robots in real time 100% responsively
|
|
||||||
with a raspberry pi zero 2 W on each roomba, along with a raspberry pi camera
|
|
||||||
a central nodejs control server will tell the raspberry pis what to do with the roomba
|
|
||||||
|
|
||||||
## the pi side (roomba side)
|
|
||||||
- pi's onboard UART is hooked up to the roomba's serial port
|
|
||||||
- another GPIO pin connected to the roomba's BRC pin
|
|
||||||
- pull it low for one second every minute to keep the roomba awake
|
|
||||||
- streams the rpi camera over webRTC with mediamtx
|
|
||||||
- streams roomba sensor group 100 to the server
|
|
||||||
- sensor streaming is required and important, but is allowed to falter sometimes
|
|
||||||
- listens for roomba commands from the server
|
|
||||||
- commands NEED to happen
|
|
||||||
- roomba control program needs to be simple, lightweight, and 100% responsive
|
|
||||||
|
|
||||||
## pi -> server communication
|
|
||||||
- stateless
|
|
||||||
- streaming based
|
|
||||||
- on connection, the pi will send the following info:
|
|
||||||
- rover's name
|
|
||||||
- motor enable / disable
|
|
||||||
- vacuum
|
|
||||||
- main brush
|
|
||||||
- side brush
|
|
||||||
- battery full number
|
|
||||||
- battery warning number
|
|
||||||
- battery urgent number
|
|
||||||
|
|
||||||
## nodejs server
|
|
||||||
- KISS
|
|
||||||
- decode the sensor data from each roomba
|
|
||||||
- can support multiple roombas connected from the ground up
|
|
||||||
- keep it simple, worry about getting the pi comms right.
|
|
||||||
- but the server DOES have to exist for testing
|
|
||||||
- IS the web server, hosts an entire static folder for the web UI
|
|
||||||
|
|
||||||
## server -> web UI communication
|
|
||||||
- socket.io
|
|
||||||
- don't do anything fancy with the socket.io setup
|
|
||||||
- it works fine out of the box, we will optimize it later
|
|
||||||
|
|
||||||
## the web UI
|
|
||||||
- KISS
|
|
||||||
- plain old html. no styling even. just bare minimum for testing
|
|
||||||
- what it needs to do:
|
|
||||||
- allow user to select the roomba from a list
|
|
||||||
- make the selected roomba drive with WASD
|
|
||||||
- have buttons to set the OI mode, and tell the roomba to dock
|
|
||||||
- show a plain list of the sensor data from the selected roomba
|
|
||||||
|
|
||||||
### general javascript programming guidelines (applies to the web UI too)
|
|
||||||
- everything ES6
|
|
||||||
- one entrypoint file in the web UI
|
|
||||||
- everything modular
|
|
||||||
- everything easy to read, understand, and work on
|
|
||||||
- comment where you think is best to describe whats going on
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# general idea
|
|
||||||
- servo connected to pin 19 on the pi
|
|
||||||
- it will allow the camera to look up and down
|
|
||||||
- use go-rpio
|
|
||||||
- it has a hardware PWM implementation
|
|
||||||
- mechanical design will have limits
|
|
||||||
- set the limits in roverd config
|
|
||||||
- control the servo from the web browser
|
|
||||||
- expose both a slider and buttons for "nudging" it up and down
|
|
||||||
|
|
||||||
## verification of functionality before full implementation
|
|
||||||
- make sure that the go program can properly use the GPIO correctly before full implementation
|
|
||||||
- modify the roverd installer to add the needed stuff to enable PWM access
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# general idea
|
|
||||||
- the pi's microphone needs to be streamed to users
|
|
||||||
- as part of the rover's video feed
|
|
||||||
- the pi model is a zero 2 W
|
|
||||||
- it CANNOT handle transcoding the audio onboard, I have tried.
|
|
||||||
- the server is super powerful and can handle transcoding
|
|
||||||
- it is NOT acceptable to add more than 100ms of video latency from glass to glass
|
|
||||||
- it is NOT acceptable to break video streaming for pis that don't have microphones
|
|
||||||
|
|
||||||
### OLD, BAD, but working audio configuration that worked for the microphone
|
|
||||||
```
|
|
||||||
-f alsa -guess_layout_max 0 -thread_queue_size 2048 -ac 1 -ar 48000 -i plughw:0,0 \
|
|
||||||
-map 1:a:0 \
|
|
||||||
-c:a libopus -b:a 24000 -compression_level 0 -application voip -frame_duration 60 \
|
|
||||||
-ac:a 1 -ar:a 48000 -af "pan=1c|c0=c0,volume=20dB"
|
|
||||||
```
|
|
||||||
- the raspberry pi CANNOT HANDLE AUDIO ENCODING onboard. this is just to show you how to use the mic.
|
|
||||||
|
|
||||||
|
|
||||||
sudo systemctl restart roverd
|
|
||||||
#sudo systemctl restart audio-capture
|
|
||||||
sudo systemctl restart video-publisher
|
|
||||||
sudo cat /var/lib/roverd/video.env
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
## raspberry pi
|
|
||||||
- need to throttle sensor sending, maybe only send one in every 5 packets.
|
|
||||||
|
|
||||||
## 3d models for camera:
|
|
||||||
- https://www.thingiverse.com/thing:2873677
|
|
||||||
- https://www.printables.com/model/356894-raspberry-camera-module-with-automatic-ir-cut-swit
|
|
||||||
- https://www.thingiverse.com/thing:4514531
|
|
||||||
|
|
||||||
## spectator page
|
|
||||||
- completely remake it from scratch
|
|
||||||
|
|
||||||
## todo
|
|
||||||
<!-- 1. battery manager -->
|
|
||||||
<!-- 2. pi-side sensor throttle (1/5th) -->
|
|
||||||
<!-- 3. convert rover roster to a reusable component, unbake it from telemetry and admin panels -->
|
|
||||||
<!-- 4. room cameras -->
|
|
||||||
4. fix gamepad input - more complex axis assignment? division, combined axes?
|
|
||||||
<!-- 5. home assistant controls -->
|
|
||||||
<!-- 6. nicknames -->
|
|
||||||
<!-- 7. discord bot -->
|
|
||||||
<!-- 8. turns mode display -->
|
|
||||||
<!-- 9. online user list -->
|
|
||||||
<!-- 10. chat -->
|
|
||||||
<!-- 9. discord invite button -->
|
|
||||||
10. redo both mobile layouts
|
|
||||||
11. redo spectator view (last)
|
|
||||||
21. finally.. set the favicon and title
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# general idea
|
|
||||||
- a modular control system
|
|
||||||
- every piece of the UI that uses rover controls will go through this, including onscreen click buttons
|
|
||||||
- allows for dynamic control labels based on saved settings
|
|
||||||
- allow a place to assign OI command macros
|
|
||||||
- like the one that the drive button uses now
|
|
||||||
- allows realtime responsive control of the rover that you are driving
|
|
||||||
|
|
||||||
## some sort of system for the site to save settings per browser
|
|
||||||
- needs to be extensible
|
|
||||||
- future things will use it
|
|
||||||
- expose functions like saveSettings and loadSettings
|
|
||||||
- other parts of the UI will need to save and load settings using this function
|
|
||||||
- use cookies
|
|
||||||
|
|
||||||
## keyboard controls:
|
|
||||||
- remappable by new component in settings tab
|
|
||||||
- key mappings will save
|
|
||||||
- controls for the keyboard
|
|
||||||
- driving
|
|
||||||
- WASD blended for tank steering
|
|
||||||
- hold backslash to move faster
|
|
||||||
- hold right shift to move slower
|
|
||||||
- aux motors
|
|
||||||
- main brush
|
|
||||||
- hold O to move it forward at speed 127
|
|
||||||
- hold L to move it backward at speed -127
|
|
||||||
- side brush
|
|
||||||
- hold P to move it forward at speed 127
|
|
||||||
- hold ; to move it backward at speed -70
|
|
||||||
- vacuum motor
|
|
||||||
- hold [ to move it at speed 127
|
|
||||||
- hold ' to move it at speed 50
|
|
||||||
- ALL AUX MOTORS
|
|
||||||
- hold . to move at full speed forward (127)
|
|
||||||
- camera movement
|
|
||||||
- hold I to look up
|
|
||||||
- hold K to look down
|
|
||||||
- dock and drive hotkeys
|
|
||||||
- G for drive (use same 3 part macro as the drive button)
|
|
||||||
- H for dock (seek dock command)
|
|
||||||
|
|
||||||
## Mobile controls
|
|
||||||
- 2 different layouts, already implemented just needs improved.
|
|
||||||
### mobile landscape
|
|
||||||
- a video game style layout
|
|
||||||
- on the left, buttons that you hold to operate the aux motors
|
|
||||||
- in the middle, is the rover video component.
|
|
||||||
- on the right, an area for a floating joystick and above is a unified control to see and change the rover's mode (drive or dock)
|
|
||||||
### mobile portrait
|
|
||||||
- designed to be driven vertically with 2 hands
|
|
||||||
- mostly fine already
|
|
||||||
- rover video at the top
|
|
||||||
- then below it, is a section with the aux motor buttons on the left, and the floating joystick are on the right.
|
|
||||||
|
|
||||||
|
|
||||||
## Gamepad controls:
|
|
||||||
- use some sort of react thing that makes it easy to use the web gamepad stuff
|
|
||||||
- left joystick for movement, right joystick for moving camera up / down
|
|
||||||
- both fully analog
|
|
||||||
- right trigger for the main brush motor
|
|
||||||
- fully analog
|
|
||||||
- press right bumper to switch it to reverse
|
|
||||||
- left trigger for the side brush motor
|
|
||||||
- fully analog
|
|
||||||
- press left bumper to switch it to reverse
|
|
||||||
- hold the right face button to run the vacuum motor
|
|
||||||
- hold the lower face button to run all aux motors forward
|
|
||||||
- left and right on the Dpad switch between drive and dock modes
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
# general idea
|
|
||||||
- convert room cameras from webrtc h264 streams to 4fps jpegs sent over socket.io
|
|
||||||
|
|
||||||
## requirements
|
|
||||||
- make updated services for server and pi room cams
|
|
||||||
- no backwards compatability is needed
|
|
||||||
- room cameras are both 4:3, make sure they show as such in the UI
|
|
||||||
- remove anything related to the webRTC room cams
|
|
||||||
- send the jpegs efficiently (as binary)
|
|
||||||
- make sure the room cams are still authed as they are now
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# general idea
|
|
||||||
- add support for multiple room cameras through the server to the UI
|
|
||||||
|
|
||||||
## what needs to happen on the server:
|
|
||||||
- add support for a path of room cameras on mediamtx
|
|
||||||
- /room/<camera_name>
|
|
||||||
- CANNOT interfere with rover cameras (/<rover_name>)
|
|
||||||
- needs to use the same auth system as the rover camera
|
|
||||||
- room cameras will be streamed to the server over SRT
|
|
||||||
|
|
||||||
## what needs to happen in the web UI
|
|
||||||
- users should be able to see all room cameras, even if not assigned to a rover
|
|
||||||
- automatically add a room camera player for each room camera
|
|
||||||
- make a component that shows all room cameras
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# server code structure:
|
|
||||||
The server's internal structure will be modular, and by modular i mean completely modular,
|
|
||||||
the modules will import what they need from other modules, and export what other modules will need from them.
|
|
||||||
the entrypoint file will contain nothing but a long list of `require('')`s for all of the modules in the proper order
|
|
||||||
|
|
||||||
for example, A service which automatically assigns a newly connected user to a roomba that isn't in use.
|
|
||||||
this service would import the roomba list from whatever other service contains it, and import the global socket.io server instance. It will add its own io.on('connection') to the socket.io instance, which contains the logic for assigning users to roombas.
|
|
||||||
|
|
||||||
- modular code structure
|
|
||||||
- one folder for each of these categories
|
|
||||||
- globals
|
|
||||||
- GLOBALS ARE: "static" parts of the server that don't contain any interactive logic.
|
|
||||||
- where the express, websocket, and socket.io instances will be
|
|
||||||
- other global things
|
|
||||||
- services
|
|
||||||
- SERVICES ARE: parts of the program that are part of the interaction pipeline.
|
|
||||||
- contains things like the roomba manager
|
|
||||||
- will also in the future contain other things like a discord bot, home assistant integration, etc
|
|
||||||
- anything with a large amount of controlling logic should be in here
|
|
||||||
- helpers
|
|
||||||
- HELPERS ARE: parts of the program that other modules only pull helper funcions or classes from.
|
|
||||||
- if a function or class is dedicated to a service, it should NOT be in a helper.
|
|
||||||
- contains passive helpers
|
|
||||||
- things like the logger system
|
|
||||||
- no "service" logic in here, only things that are passively pulled out and used inside other modules
|
|
||||||
- one entrypoint file that contains NOTHING but `require('')`s.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# server <-> web client logistics
|
|
||||||
|
|
||||||
I want each connected roomba to have a list of drivers. if a socket is not in this list, they are not allowed to drive the roomba. I will be adding admins, authentication, a turns system, and automatic roomba assignment later so it is important that we start with this system in place.
|
|
||||||
a roomba's list of drivers will be completely managed by the server, users should not be able to change driver lists, even in a hacky way.
|
|
||||||
|
|
||||||
Each user will also have to see sensor data from the roomba that they are controlling. I want it to be done in this way:
|
|
||||||
- for each connected roomba, there is a socket.io room where all of the sensor data is streamed out to clients
|
|
||||||
- the client will be added to the room, where they can see all of the active roomba's sensor data
|
|
||||||
- ALSO for future use, it needs to work properly if a socket is subscribed to all rooms, in the future there will be a spectator page which can view all of the roombas at once.
|
|
||||||
|
|
||||||
I want there to be a ground up system where I can set the entire service to four different modes:
|
|
||||||
- open (anyone can drive, roombas are assigned randomly. if they are all full, two people will be controlling the same roomba)
|
|
||||||
- turns (anyone can drive, roombas are assigned randomly. if they are all full, each person gets one minute on their selected roomba)
|
|
||||||
- admin (only authenticated admins can log in, anyone can still view but no one is allowed to drive but admins)
|
|
||||||
- lockdown (only "lockdown" admins can view or drive. no one else can view, not even spectators).
|
|
||||||
only admins can change modes.
|
|
||||||
|
|
||||||
do NOT implement any authentication stuff yet, just add a stub service with places set up to put the auth. logic
|
|
||||||
|
|
||||||
# web client code structure:
|
|
||||||
- the same as the server's code structure
|
|
||||||
- all ES6
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
# spectate system overhaul
|
|
||||||
- rip out and remake the entire page
|
|
||||||
- basically keep nothing from it
|
|
||||||
- right now the spectator page is a mess, and hasn't been updated in a while
|
|
||||||
- does not work properly, aside from the page being out of date
|
|
||||||
- the spectate page should use modules from the driver page
|
|
||||||
- there should be no such thing as a "spectate mode" on the driver page. If someone wants to spectate, they must go to the spectator page.
|
|
||||||
|
|
||||||
## rules of the spectator page
|
|
||||||
- spectators are not allowed to drive
|
|
||||||
- spectators are not allowed to chat
|
|
||||||
- spectators are not allowed to have nicknames
|
|
||||||
- spectators are not allowed to login as admin
|
|
||||||
- these are the permissions for spectators based on the sever's access control mode
|
|
||||||
- open
|
|
||||||
- spectators can spectate
|
|
||||||
- turns
|
|
||||||
- spectators can spectate
|
|
||||||
- admin
|
|
||||||
- spectators can spectate
|
|
||||||
- lockdown
|
|
||||||
- spectators can NOT spectate
|
|
||||||
|
|
||||||
|
|
||||||
## what should be on the spectator page?
|
|
||||||
- for each rover
|
|
||||||
- video component
|
|
||||||
- current driver
|
|
||||||
- sensor telemetry component
|
|
||||||
- logs
|
|
||||||
- room cameras
|
|
||||||
- online user list
|
|
||||||
|
|
||||||
## layout and styling of the spectator page
|
|
||||||
- use same styling style as the driver page
|
|
||||||
- feel free to add spectator role checks to hide the features that spectators can't use in certain components.
|
|
||||||
- mainly designed for a 4:3 monitor
|
|
||||||
- make use of the vertical space
|
|
||||||
- a row of columns, one for each rover at the top
|
|
||||||
- below the row of columns:
|
|
||||||
- 50/50 split
|
|
||||||
- user list
|
|
||||||
- chat
|
|
||||||
- room cameras
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
# general idea:
|
|
||||||
- very compact UI
|
|
||||||
- utilitarian
|
|
||||||
- function before pretty
|
|
||||||
- tiny text, too.
|
|
||||||
- no large margins or padding. very little wasted space
|
|
||||||
- up to 1 tailwind unit unless otherwise needed
|
|
||||||
- black background, gray cards, white or gray text
|
|
||||||
- tailwind
|
|
||||||
- no title or bar at the top, vertical screen space is precious on all layouts
|
|
||||||
- alerts should be moved to a tiny toast popup that will show at the top center of the screen
|
|
||||||
|
|
||||||
# three different layouts for the driver page
|
|
||||||
- desktop
|
|
||||||
- rover video central, no title or anything above it
|
|
||||||
- overlayed on rover video (HUD like in a video game)
|
|
||||||
- build the HUD into the video tile
|
|
||||||
- because everywhere the video is, the HUD should be too
|
|
||||||
- shows a dot that blinks on each sensor frame
|
|
||||||
- shows wheel drops and bumpers
|
|
||||||
- shows a big "loud" warning overlay during an overcurrent
|
|
||||||
- simple, easy to understand battery bar UNDERNEATH the video:
|
|
||||||
- takes in battery full, warn, and urgent values
|
|
||||||
- treat full as 100% and warn as 0%
|
|
||||||
- make the bar flash red when its 0% or lower
|
|
||||||
- on left side of rover video:
|
|
||||||
- sensor data, visualized.
|
|
||||||
- battery charge out of capacity (1763/2068)
|
|
||||||
- charging status
|
|
||||||
- OI mode
|
|
||||||
- battery voltage (in volts not mv)
|
|
||||||
- battery current
|
|
||||||
- a teeny show of the raw data (because we get it on the client and it looks cool)
|
|
||||||
- on right side of rover video
|
|
||||||
- a simple, easy control panel with the following actions:
|
|
||||||
- a button that runs the start OI, dock, then full commands (in that order)
|
|
||||||
- also contains a little status of whether or not the rover is in "driving mode" (OI in full mode)
|
|
||||||
- a button that tells the rover to seek dock
|
|
||||||
- explains that you should be straight in front of, and about a foot from the dock for a successful attempt
|
|
||||||
- contains two status indicators:
|
|
||||||
- docked / not docked (homebase true)
|
|
||||||
- charging / not charging (when chargingstate isn't "not charging" the rover is charging)
|
|
||||||
- below the upper control and video row
|
|
||||||
- center:
|
|
||||||
- a place for the room camera (not implemented yet)
|
|
||||||
- left:
|
|
||||||
- admin login and controls
|
|
||||||
- right:
|
|
||||||
- logs
|
|
||||||
- mobile in portrait mode
|
|
||||||
- rover video at the top, with HUD
|
|
||||||
- just below:
|
|
||||||
- joystick with buttons for aux. motor controls
|
|
||||||
- have a button to run all aux motors forward at max speed
|
|
||||||
- also have buttons to run motors forward / backward individually
|
|
||||||
- probably use some premade react joystick
|
|
||||||
- scroll down more to see:
|
|
||||||
- the same simple mode control buttons from the desktop layout
|
|
||||||
- also needs to have the admin login and controls
|
|
||||||
- needs to have all the functionality as the desktop page
|
|
||||||
- mobile in landscape mode
|
|
||||||
- made for videogame-like control of the rover
|
|
||||||
- control buttons and aux motors on the left
|
|
||||||
- rover video in the middle
|
|
||||||
- joystick on the right
|
|
||||||
- again, scroll down to see more
|
|
||||||
- also has to do everything
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# general idea
|
|
||||||
- a bunch of small UI adjustments
|
|
||||||
- always keep styling consistent to the rest of the page
|
|
||||||
|
|
||||||
## everywhere
|
|
||||||
- add an audio stream status to the rover video panel, only show it if the rover has the separate audio stream
|
|
||||||
- make the room camera panels save their stack setting per instance of the panel in the web UI's settings storage system
|
|
||||||
- remove the status bar below room cameras
|
|
||||||
- put it in the corner of the feed, like the other video panels
|
|
||||||
- make the styling of the room camera panels match everything else
|
|
||||||
- when the user list switches to turns, always show the plain user list, but below the turns info
|
|
||||||
|
|
||||||
## on the spectator page
|
|
||||||
- rover video panels
|
|
||||||
- no more telemetry bar below each rover video
|
|
||||||
- ovelay the tiny telemetry summary on the left side of the video, with the rest of the HUD elements
|
|
||||||
- including the battery bar, make it vertical on the right side of the video
|
|
||||||
- no more title bar above each rover video
|
|
||||||
- the rover's name is already on the HUD
|
|
||||||
- add current driver to the HUD
|
|
||||||
- sidebar
|
|
||||||
- shrink the logs panel
|
|
||||||
- add an actual rover list to the top of the sidebar
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[env:esp32s3]
|
||||||
|
platform = espressif32
|
||||||
|
board = esp32-s3-devkitc-1
|
||||||
|
monitor_speed = 115200
|
||||||
|
framework = arduino
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# Room camera snapshot service
|
|
||||||
|
|
||||||
Lightweight systemd service that serves a JPEG snapshot from any MJPEG-capable webcam (most USB webcams) at 4 fps. The server pulls `http://<host>:8080/snapshot.jpg` for room cams.
|
|
||||||
|
|
||||||
## Files
|
|
||||||
- `room-cam-snapshot.sh` – ffmpeg + simple HTTP server wrapper
|
|
||||||
- `room-cam.service` – systemd unit template
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
1) Copy the service into place (adjust path/env as needed):
|
|
||||||
```bash
|
|
||||||
sudo cp roomcam-service/room-cam.service /etc/systemd/system/room-cam.service
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
sudo systemctl enable --now room-cam.service
|
|
||||||
```
|
|
||||||
2) Override defaults via `Environment=` in the unit or drop-ins:
|
|
||||||
- `DEVICE=/dev/video0`
|
|
||||||
- `RESOLUTION=640x480`
|
|
||||||
- `QUALITY=5` (ffmpeg MJPEG quality; lower is higher quality)
|
|
||||||
- `PORT=8080`
|
|
||||||
- `WORKDIR=/run/roomcam`
|
|
||||||
- `INPUT_FORMAT=mjpeg` (use `bayer_grbg8` for OV534/raw Bayer cams; aliases `GRBG`/`grbg` are accepted)
|
|
||||||
3) Point the server `roomCameras[].url` to `http://<host>:8080/snapshot.jpg`.
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- The camera runs at its native MJPEG frame rate; the server polls snapshots at ~4 fps, so no extra filtering is applied here.
|
|
||||||
|
|
||||||
To run outside systemd, just execute `./room-cam-snapshot.sh` with any overrides.
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Configurable via environment
|
|
||||||
DEVICE="${DEVICE:-/dev/video0}"
|
|
||||||
RESOLUTION="${RESOLUTION:-640x480}"
|
|
||||||
QUALITY="${QUALITY:-5}" # ffmpeg MJPEG quality (lower is better)
|
|
||||||
PORT="${PORT:-8088}"
|
|
||||||
WORKDIR="${WORKDIR:-/run/roomcam}"
|
|
||||||
# Optional: set INPUT_FORMAT=bayer_grbg8 to transcode raw Bayer cams (e.g., OV534) to JPEG.
|
|
||||||
INPUT_FORMAT="${INPUT_FORMAT:-mjpeg}"
|
|
||||||
|
|
||||||
mkdir -p "${WORKDIR}"
|
|
||||||
SNAPSHOT_PATH="${WORKDIR}/snapshot.jpg"
|
|
||||||
rm -f "${SNAPSHOT_PATH}"
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
[[ -n "${FFMPEG_PID:-}" ]] && kill "${FFMPEG_PID}" 2>/dev/null || true
|
|
||||||
[[ -n "${HTTP_PID:-}" ]] && kill "${HTTP_PID}" 2>/dev/null || true
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
trap 'exit 0' SIGTERM INT
|
|
||||||
|
|
||||||
FFMPEG_INPUT_ARGS=(-f v4l2 -input_format "${INPUT_FORMAT}" -video_size "${RESOLUTION}" -i "${DEVICE}")
|
|
||||||
FFMPEG_FILTERS=()
|
|
||||||
if [[ "${INPUT_FORMAT}" == bayer_* ]]; then
|
|
||||||
# Convert raw Bayer to a JPEG-friendly pixel format.
|
|
||||||
FFMPEG_FILTERS=(-pix_fmt yuv420p)
|
|
||||||
fi
|
|
||||||
|
|
||||||
/usr/bin/ffmpeg -y \
|
|
||||||
-loglevel warning -nostats \
|
|
||||||
"${FFMPEG_INPUT_ARGS[@]}" \
|
|
||||||
"${FFMPEG_FILTERS[@]}" \
|
|
||||||
-q:v "${QUALITY}" \
|
|
||||||
-f image2 -update 1 "${SNAPSHOT_PATH}" &
|
|
||||||
FFMPEG_PID=$!
|
|
||||||
|
|
||||||
/usr/bin/python3 -u -m http.server "${PORT}" --directory "${WORKDIR}" --bind 0.0.0.0 &
|
|
||||||
HTTP_PID=$!
|
|
||||||
|
|
||||||
wait -n "${FFMPEG_PID}" "${HTTP_PID}"
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Room camera snapshot server (MJPEG webcam)
|
|
||||||
After=network-online.target
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
Environment=DEVICE=/dev/video0
|
|
||||||
Environment=RESOLUTION=640x480
|
|
||||||
Environment=QUALITY=5
|
|
||||||
Environment=PORT=8088
|
|
||||||
Environment=WORKDIR=/run/roomcam
|
|
||||||
Environment=INPUT_FORMAT=mjpeg
|
|
||||||
WorkingDirectory=/home/daniel/gits/MultiRoombaRover/roomcam-service
|
|
||||||
ExecStart=/usr/bin/env bash /home/daniel/gits/MultiRoombaRover/roomcam-service/room-cam-snapshot.sh
|
|
||||||
Restart=always
|
|
||||||
RestartSec=2
|
|
||||||
User=root
|
|
||||||
Group=root
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
admins:
|
|
||||||
- username: admin
|
|
||||||
password_hash: "$2b$10$ZW4Jy7ctIt7k9V1AogFky.v4wedLF92t4/ZlT9kWPlIiCmdQNzJ.C" # password: adminpass
|
|
||||||
discord_id: "1234567890"
|
|
||||||
lockdown: false
|
|
||||||
- username: lockdown
|
|
||||||
password_hash: "$2b$10$n0L0oe1ZQy7IgM.FvVAzb.aXz43uaZWFiT0wr.05uNoVIDLawmrCG" # password: lockdownpass
|
|
||||||
discord_id: "0987654321"
|
|
||||||
lockdown: true
|
|
||||||
media:
|
|
||||||
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
|
|
||||||
# http://<base>/<roverId>/whep
|
|
||||||
# Example: http://192.168.0.86:8889/video
|
|
||||||
whepBaseUrl: "http://192.168.0.86:8889/video"
|
|
||||||
|
|
||||||
homeAssistant:
|
|
||||||
url: "http://homeassistant.local:8123"
|
|
||||||
token: "REPLACE_WITH_LONG_LIVED_TOKEN"
|
|
||||||
entities:
|
|
||||||
- id: "light.lab_main"
|
|
||||||
name: "Lab Lights"
|
|
||||||
- id: "switch.dock_power"
|
|
||||||
name: "Dock Power"
|
|
||||||
# type is optional; if omitted it is inferred from the entity id (light/switch)
|
|
||||||
roomCameras:
|
|
||||||
- id: "lobby"
|
|
||||||
name: "Lobby Camera"
|
|
||||||
description: "Wide shot of the staging area."
|
|
||||||
url: "http://192.168.0.50/snapshot.jpg"
|
|
||||||
- id: "workshop"
|
|
||||||
name: "Workshop Bench"
|
|
||||||
description: "Shows the workbench and charging docks."
|
|
||||||
url: "http://192.168.0.51/snapshot.jpg"
|
|
||||||
|
|
||||||
discord:
|
|
||||||
token: "DISCORD_BOT_TOKEN"
|
|
||||||
guildId: "123456789012345678" # optional; bot works in any guild it's invited to
|
|
||||||
channels:
|
|
||||||
announcements: "123456789012345678"
|
|
||||||
adminAlerts: "123456789012345678"
|
|
||||||
chatBridge: "123456789012345678"
|
|
||||||
roles:
|
|
||||||
announcementPing: "123456789012345678"
|
|
||||||
adminPing: "123456789012345678"
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
require('./src/globals/logger');
|
|
||||||
require('./src/globals/config');
|
|
||||||
require('./src/globals/http');
|
|
||||||
require('./src/globals/io');
|
|
||||||
require('./src/globals/ws');
|
|
||||||
|
|
||||||
require('./src/helpers/sensorDecoder');
|
|
||||||
|
|
||||||
require('./src/services/alertService');
|
|
||||||
require('./src/services/authService');
|
|
||||||
require('./src/services/eventBus');
|
|
||||||
require('./src/services/modeManager');
|
|
||||||
require('./src/services/lockdownGuard');
|
|
||||||
require('./src/services/roverManager');
|
|
||||||
require('./src/services/commandService');
|
|
||||||
require('./src/services/roverConnectionService');
|
|
||||||
require('./src/services/assignmentService');
|
|
||||||
require('./src/services/nicknameService');
|
|
||||||
require('./src/services/chatService');
|
|
||||||
require('./src/services/videoSessions');
|
|
||||||
require('./src/services/videoAuthService');
|
|
||||||
require('./src/services/videoSocketService');
|
|
||||||
require('./src/services/roomCameraSocketService');
|
|
||||||
require('./src/services/logStreamService');
|
|
||||||
require('./src/services/homeAssistantService');
|
|
||||||
require('./src/services/sessionService');
|
|
||||||
require('./src/services/batteryManager');
|
|
||||||
require('./src/services/discordBotService');
|
|
||||||
require('./src/services/httpServer');
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
MEDIAMTX_VERSION="1.15.3"
|
|
||||||
MEDIAMTX_BASE_URL="https://github.com/bluenviron/mediamtx/releases/download/v${MEDIAMTX_VERSION}"
|
|
||||||
MEDIAMTX_BIN="/usr/local/bin/mediamtx"
|
|
||||||
MEDIAMTX_CONF_DIR="/etc/mediamtx"
|
|
||||||
MEDIAMTX_CONFIG="$MEDIAMTX_CONF_DIR/mediamtx.yml"
|
|
||||||
MEDIAMTX_SERVICE="/etc/systemd/system/mediamtx.service"
|
|
||||||
MULTIROVER_SERVICE="/etc/systemd/system/multirover.service"
|
|
||||||
|
|
||||||
if [[ $EUID -ne 0 ]]; then
|
|
||||||
echo "This installer must be run with sudo/root." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -z "${SUDO_USER:-}" || "${SUDO_USER}" == "root" ]]; then
|
|
||||||
echo "Run this script via 'sudo' from the normal user that owns the repo." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
TARGET_USER="$SUDO_USER"
|
|
||||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
|
||||||
SERVER_DIR="$SCRIPT_DIR"
|
|
||||||
CONFIG_PATH="$SERVER_DIR/config.yaml"
|
|
||||||
MEDIAMTX_TEMPLATE="$SERVER_DIR/mediamtx/mediamtx.yml"
|
|
||||||
|
|
||||||
echo "[1/6] Installing dependencies..."
|
|
||||||
dnf install -y nodejs npm curl tar >/dev/null
|
|
||||||
NODE_BIN="$(command -v node)"
|
|
||||||
|
|
||||||
echo "[2/6] Installing Node production deps..."
|
|
||||||
runuser -u "$TARGET_USER" -- bash -c "cd '$SERVER_DIR' && npm install --production"
|
|
||||||
|
|
||||||
if [[ ! -f "$CONFIG_PATH" ]]; then
|
|
||||||
cp "$SERVER_DIR/config.example.yaml" "$CONFIG_PATH"
|
|
||||||
chown "$TARGET_USER":"$TARGET_USER" "$CONFIG_PATH"
|
|
||||||
echo "Copied config.example.yaml to config.yaml; edit it before exposing the service."
|
|
||||||
fi
|
|
||||||
|
|
||||||
tmpdir=$(mktemp -d)
|
|
||||||
trap 'rm -rf "$tmpdir"' EXIT
|
|
||||||
|
|
||||||
arch=$(uname -m)
|
|
||||||
case "$arch" in
|
|
||||||
x86_64|amd64)
|
|
||||||
mediamtx_pkg="mediamtx_v${MEDIAMTX_VERSION}_linux_amd64.tar.gz"
|
|
||||||
;;
|
|
||||||
aarch64)
|
|
||||||
mediamtx_pkg="mediamtx_v${MEDIAMTX_VERSION}_linux_arm64.tar.gz"
|
|
||||||
;;
|
|
||||||
armv7l)
|
|
||||||
mediamtx_pkg="mediamtx_v${MEDIAMTX_VERSION}_linux_armv7.tar.gz"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Unsupported architecture: $arch" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
echo "[3/6] Installing mediaMTX ${MEDIAMTX_VERSION}..."
|
|
||||||
curl -L "$MEDIAMTX_BASE_URL/$mediamtx_pkg" -o "$tmpdir/mediamtx.tgz"
|
|
||||||
tar -xzf "$tmpdir/mediamtx.tgz" -C "$tmpdir" mediamtx
|
|
||||||
install -m 0755 "$tmpdir/mediamtx" "$MEDIAMTX_BIN"
|
|
||||||
|
|
||||||
mkdir -p "$MEDIAMTX_CONF_DIR"
|
|
||||||
if [[ ! -f "$MEDIAMTX_TEMPLATE" ]]; then
|
|
||||||
echo "mediaMTX template missing at $MEDIAMTX_TEMPLATE" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo " Installing mediaMTX config -> $MEDIAMTX_CONFIG"
|
|
||||||
rm -f "$MEDIAMTX_CONFIG"
|
|
||||||
install -m 0644 "$MEDIAMTX_TEMPLATE" "$MEDIAMTX_CONFIG"
|
|
||||||
chown -R "$TARGET_USER":"$TARGET_USER" "$MEDIAMTX_CONF_DIR"
|
|
||||||
|
|
||||||
echo "[4/6] Writing systemd units..."
|
|
||||||
cat > "$MEDIAMTX_SERVICE" <<EOF
|
|
||||||
[Unit]
|
|
||||||
Description=mediaMTX WebRTC Server
|
|
||||||
After=network-online.target
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
User=$TARGET_USER
|
|
||||||
Group=$TARGET_USER
|
|
||||||
WorkingDirectory=$MEDIAMTX_CONF_DIR
|
|
||||||
ExecStart=$MEDIAMTX_BIN $MEDIAMTX_CONFIG
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=2
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
EOF
|
|
||||||
|
|
||||||
cat > "$MULTIROVER_SERVICE" <<EOF
|
|
||||||
[Unit]
|
|
||||||
Description=Multi-Roomba Rover control server
|
|
||||||
After=network-online.target mediamtx.service
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
User=$TARGET_USER
|
|
||||||
Group=$TARGET_USER
|
|
||||||
WorkingDirectory=$SERVER_DIR
|
|
||||||
Environment=NODE_ENV=production
|
|
||||||
Environment=SERVER_CONFIG=$CONFIG_PATH
|
|
||||||
ExecStart=$NODE_BIN $SERVER_DIR/index.js
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=2
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
EOF
|
|
||||||
|
|
||||||
chmod 644 "$MEDIAMTX_SERVICE" "$MULTIROVER_SERVICE"
|
|
||||||
|
|
||||||
echo "[5/6] Enabling services..."
|
|
||||||
systemctl daemon-reload
|
|
||||||
systemctl enable --now mediamtx.service
|
|
||||||
systemctl enable --now multirover.service
|
|
||||||
systemctl restart mediamtx.service
|
|
||||||
systemctl restart multirover.service
|
|
||||||
|
|
||||||
echo "[6/6] Done."
|
|
||||||
echo
|
|
||||||
echo "Services installed:"
|
|
||||||
echo " mediamtx.service (WebRTC fan-out)"
|
|
||||||
echo " multirover.service (Node.js control server)"
|
|
||||||
echo
|
|
||||||
echo "Update $CONFIG_PATH to set admins, lockdown settings, and media parameters."
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
# Managed by install_server.sh; edit server/mediamtx/mediamtx.yml and rerun the installer.
|
|
||||||
logLevel: info
|
|
||||||
|
|
||||||
api: yes
|
|
||||||
apiAddress: 0.0.0.0:9997
|
|
||||||
metrics: yes
|
|
||||||
metricsAddress: 0.0.0.0:9998
|
|
||||||
pprof: no
|
|
||||||
pprofAddress: 127.0.0.1:9999
|
|
||||||
|
|
||||||
rtsp: no
|
|
||||||
rtmp: no
|
|
||||||
hls: no
|
|
||||||
|
|
||||||
webrtc: yes
|
|
||||||
webrtcLocalUDPAddress: :8189
|
|
||||||
webrtcLocalTCPAddress: :8189
|
|
||||||
webrtcAdditionalHosts: ['rover.otter.land', '192.168.0.100']
|
|
||||||
webrtcICEServers2:
|
|
||||||
# Google public STUN (world-wide, very commonly used)
|
|
||||||
- url: stun:stun.l.google.com:19302
|
|
||||||
- url: stun:stun1.l.google.com:19302
|
|
||||||
- url: stun:stun2.l.google.com:19302
|
|
||||||
- url: stun:stun3.l.google.com:19302
|
|
||||||
- url: stun:stun4.l.google.com:19302
|
|
||||||
|
|
||||||
# Cloudflare STUN (anycast, global PoPs)
|
|
||||||
- url: stun:stun.cloudflare.com:3478
|
|
||||||
|
|
||||||
srt: yes
|
|
||||||
srtAddress: :9000
|
|
||||||
|
|
||||||
authMethod: http
|
|
||||||
authHTTPAddress: http://127.0.0.1:8080/mediamtx/auth
|
|
||||||
authHTTPExclude:
|
|
||||||
- action: publish
|
|
||||||
- action: api
|
|
||||||
- action: metrics
|
|
||||||
- action: pprof
|
|
||||||
|
|
||||||
paths:
|
|
||||||
all:
|
|
||||||
source: publisher
|
|
||||||
sourceOnDemand: no
|
|
||||||
Generated
+22
-445
@@ -1,188 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "multiroombarover-server",
|
"name": "multi-roomba-rover-server",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "multiroombarover-server",
|
"name": "multi-roomba-rover-server",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcrypt": "^6.0.0",
|
|
||||||
"discord.js": "^14.25.1",
|
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"home-assistant-js-websocket": "^3.1.2",
|
"socket.io": "^4.7.5"
|
||||||
"js-yaml": "^4.1.1",
|
|
||||||
"morgan": "^1.10.0",
|
|
||||||
"socket.io": "^4.7.5",
|
|
||||||
"uuid": "^9.0.1",
|
|
||||||
"ws": "^8.18.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.0"
|
"nodemon": "^3.0.3"
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@discordjs/builders": {
|
|
||||||
"version": "1.13.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.0.tgz",
|
|
||||||
"integrity": "sha512-COK0uU6ZaJI+LA67H/rp8IbEkYwlZf3mAoBI5wtPh5G5cbEQGNhVpzINg2f/6+q/YipnNIKy6fJDg6kMUKUw4Q==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@discordjs/formatters": "^0.6.1",
|
|
||||||
"@discordjs/util": "^1.1.1",
|
|
||||||
"@sapphire/shapeshift": "^4.0.0",
|
|
||||||
"discord-api-types": "^0.38.31",
|
|
||||||
"fast-deep-equal": "^3.1.3",
|
|
||||||
"ts-mixer": "^6.0.4",
|
|
||||||
"tslib": "^2.6.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16.11.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@discordjs/collection": {
|
|
||||||
"version": "1.5.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz",
|
|
||||||
"integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16.11.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@discordjs/formatters": {
|
|
||||||
"version": "0.6.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz",
|
|
||||||
"integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"discord-api-types": "^0.38.33"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16.11.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@discordjs/rest": {
|
|
||||||
"version": "2.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz",
|
|
||||||
"integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@discordjs/collection": "^2.1.1",
|
|
||||||
"@discordjs/util": "^1.1.1",
|
|
||||||
"@sapphire/async-queue": "^1.5.3",
|
|
||||||
"@sapphire/snowflake": "^3.5.3",
|
|
||||||
"@vladfrangu/async_event_emitter": "^2.4.6",
|
|
||||||
"discord-api-types": "^0.38.16",
|
|
||||||
"magic-bytes.js": "^1.10.0",
|
|
||||||
"tslib": "^2.6.3",
|
|
||||||
"undici": "6.21.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@discordjs/rest/node_modules/@discordjs/collection": {
|
|
||||||
"version": "2.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
|
|
||||||
"integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@discordjs/util": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"discord-api-types": "^0.38.33"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@discordjs/ws": {
|
|
||||||
"version": "1.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz",
|
|
||||||
"integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@discordjs/collection": "^2.1.0",
|
|
||||||
"@discordjs/rest": "^2.5.1",
|
|
||||||
"@discordjs/util": "^1.1.0",
|
|
||||||
"@sapphire/async-queue": "^1.5.2",
|
|
||||||
"@types/ws": "^8.5.10",
|
|
||||||
"@vladfrangu/async_event_emitter": "^2.2.4",
|
|
||||||
"discord-api-types": "^0.38.1",
|
|
||||||
"tslib": "^2.6.2",
|
|
||||||
"ws": "^8.17.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16.11.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@discordjs/ws/node_modules/@discordjs/collection": {
|
|
||||||
"version": "2.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
|
|
||||||
"integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sapphire/async-queue": {
|
|
||||||
"version": "1.5.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
|
|
||||||
"integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=v14.0.0",
|
|
||||||
"npm": ">=7.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sapphire/shapeshift": {
|
|
||||||
"version": "4.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz",
|
|
||||||
"integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"fast-deep-equal": "^3.1.3",
|
|
||||||
"lodash": "^4.17.21"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=v16"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sapphire/snowflake": {
|
|
||||||
"version": "3.5.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz",
|
|
||||||
"integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=v14.0.0",
|
|
||||||
"npm": ">=7.0.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@socket.io/component-emitter": {
|
"node_modules/@socket.io/component-emitter": {
|
||||||
@@ -209,25 +39,6 @@
|
|||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/ws": {
|
|
||||||
"version": "8.18.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
|
||||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@vladfrangu/async_event_emitter": {
|
|
||||||
"version": "2.4.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz",
|
|
||||||
"integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=v14.0.0",
|
|
||||||
"npm": ">=7.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
"version": "1.3.8",
|
"version": "1.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
@@ -255,12 +66,6 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/argparse": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
|
||||||
"license": "Python-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/array-flatten": {
|
"node_modules/array-flatten": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||||
@@ -283,38 +88,6 @@
|
|||||||
"node": "^4.5.0 || >= 5.9"
|
"node": "^4.5.0 || >= 5.9"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/basic-auth": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"safe-buffer": "5.1.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/basic-auth/node_modules/safe-buffer": {
|
|
||||||
"version": "5.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
|
||||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/bcrypt": {
|
|
||||||
"version": "6.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
|
|
||||||
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"node-addon-api": "^8.3.0",
|
|
||||||
"node-gyp-build": "^4.8.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
@@ -523,42 +296,6 @@
|
|||||||
"npm": "1.2.8000 || >= 1.4.16"
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/discord-api-types": {
|
|
||||||
"version": "0.38.34",
|
|
||||||
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.34.tgz",
|
|
||||||
"integrity": "sha512-muq7xKGznj5MSFCzuIm/2TO7DpttuomUTemVM82fRqgnMl70YRzEyY24jlbiV6R9tzOTq6A6UnZ0bsfZeKD38Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"workspaces": [
|
|
||||||
"scripts/actions/documentation"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/discord.js": {
|
|
||||||
"version": "14.25.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz",
|
|
||||||
"integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@discordjs/builders": "^1.13.0",
|
|
||||||
"@discordjs/collection": "1.5.3",
|
|
||||||
"@discordjs/formatters": "^0.6.2",
|
|
||||||
"@discordjs/rest": "^2.6.0",
|
|
||||||
"@discordjs/util": "^1.2.0",
|
|
||||||
"@discordjs/ws": "^1.2.3",
|
|
||||||
"@sapphire/snowflake": "3.5.3",
|
|
||||||
"discord-api-types": "^0.38.33",
|
|
||||||
"fast-deep-equal": "3.1.3",
|
|
||||||
"lodash.snakecase": "4.1.1",
|
|
||||||
"magic-bytes.js": "^1.10.0",
|
|
||||||
"tslib": "^2.6.3",
|
|
||||||
"undici": "6.21.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -649,27 +386,6 @@
|
|||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/engine.io/node_modules/ws": {
|
|
||||||
"version": "8.17.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
|
|
||||||
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"bufferutil": "^4.0.1",
|
|
||||||
"utf-8-validate": ">=5.0.2"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"bufferutil": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"utf-8-validate": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/es-define-property": {
|
"node_modules/es-define-property": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
@@ -761,12 +477,6 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
|
||||||
"version": "3.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/fill-range": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
@@ -816,6 +526,21 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@@ -921,12 +646,6 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/home-assistant-js-websocket": {
|
|
||||||
"version": "3.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/home-assistant-js-websocket/-/home-assistant-js-websocket-3.1.2.tgz",
|
|
||||||
"integrity": "sha512-HzaJtuhufBkppfXerla5cJHSP8uXzLoSRV+XPCcYlOttiKzqLQHD3sPm2T9UHTHPPppy3jRZtaA86ISfH3GK/g==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/http-errors": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||||
@@ -1023,36 +742,6 @@
|
|||||||
"node": ">=0.12.0"
|
"node": ">=0.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
|
||||||
"version": "4.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
|
||||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"argparse": "^2.0.1"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"js-yaml": "bin/js-yaml.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lodash": {
|
|
||||||
"version": "4.17.21",
|
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
|
||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/lodash.snakecase": {
|
|
||||||
"version": "4.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",
|
|
||||||
"integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/magic-bytes.js": {
|
|
||||||
"version": "1.12.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.12.1.tgz",
|
|
||||||
"integrity": "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -1135,34 +824,6 @@
|
|||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/morgan": {
|
|
||||||
"version": "1.10.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
|
|
||||||
"integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"basic-auth": "~2.0.1",
|
|
||||||
"debug": "2.6.9",
|
|
||||||
"depd": "~2.0.0",
|
|
||||||
"on-finished": "~2.3.0",
|
|
||||||
"on-headers": "~1.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/morgan/node_modules/on-finished": {
|
|
||||||
"version": "2.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
|
|
||||||
"integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ee-first": "1.1.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
@@ -1178,26 +839,6 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/node-addon-api": {
|
|
||||||
"version": "8.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
|
|
||||||
"integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "^18 || ^20 || >= 21"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/node-gyp-build": {
|
|
||||||
"version": "4.8.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
|
||||||
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"node-gyp-build": "bin.js",
|
|
||||||
"node-gyp-build-optional": "optional.js",
|
|
||||||
"node-gyp-build-test": "build-test.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nodemon": {
|
"node_modules/nodemon": {
|
||||||
"version": "3.1.10",
|
"version": "3.1.10",
|
||||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
|
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
|
||||||
@@ -1295,15 +936,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/on-headers": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/parseurl": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
@@ -1639,27 +1271,6 @@
|
|||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/socket.io-adapter/node_modules/ws": {
|
|
||||||
"version": "8.17.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
|
|
||||||
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"bufferutil": "^4.0.1",
|
|
||||||
"utf-8-validate": ">=5.0.2"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"bufferutil": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"utf-8-validate": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/socket.io-parser": {
|
"node_modules/socket.io-parser": {
|
||||||
"version": "4.2.4",
|
"version": "4.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
|
||||||
@@ -1773,18 +1384,6 @@
|
|||||||
"nodetouch": "bin/nodetouch.js"
|
"nodetouch": "bin/nodetouch.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ts-mixer": {
|
|
||||||
"version": "6.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz",
|
|
||||||
"integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/tslib": {
|
|
||||||
"version": "2.8.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
|
||||||
"license": "0BSD"
|
|
||||||
},
|
|
||||||
"node_modules/type-is": {
|
"node_modules/type-is": {
|
||||||
"version": "1.6.18",
|
"version": "1.6.18",
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||||
@@ -1805,15 +1404,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/undici": {
|
|
||||||
"version": "6.21.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz",
|
|
||||||
"integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.17"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.16.0",
|
"version": "7.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||||
@@ -1838,19 +1428,6 @@
|
|||||||
"node": ">= 0.4.0"
|
"node": ">= 0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/uuid": {
|
|
||||||
"version": "9.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
|
||||||
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
|
|
||||||
"funding": [
|
|
||||||
"https://github.com/sponsors/broofa",
|
|
||||||
"https://github.com/sponsors/ctavan"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"uuid": "dist/bin/uuid"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vary": {
|
"node_modules/vary": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
@@ -1861,9 +1438,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.18.3",
|
"version": "8.17.1",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
|
||||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
|
|||||||
+8
-14
@@ -1,24 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "multiroombarover-server",
|
"name": "multi-roomba-rover-server",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"type": "module",
|
||||||
|
"description": "UDP relay and web UI for MultiRoombaRover",
|
||||||
|
"main": "src/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node index.js",
|
"start": "node src/server.js",
|
||||||
"dev": "nodemon index.js",
|
"dev": "nodemon src/server.js"
|
||||||
"check:media": "node scripts/checkMedia.js"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcrypt": "^6.0.0",
|
|
||||||
"discord.js": "^14.25.1",
|
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"home-assistant-js-websocket": "^3.1.2",
|
"socket.io": "^4.7.5"
|
||||||
"js-yaml": "^4.1.1",
|
|
||||||
"morgan": "^1.10.0",
|
|
||||||
"socket.io": "^4.7.5",
|
|
||||||
"uuid": "^9.0.1",
|
|
||||||
"ws": "^8.18.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.0"
|
"nodemon": "^3.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
const socket = io();
|
||||||
|
|
||||||
|
const DRIVE_SPEED = 250;
|
||||||
|
const TURN_SPEED = 200;
|
||||||
|
const STATUS_FLAGS = [
|
||||||
|
{ bit: 0x01, label: 'wifi' },
|
||||||
|
{ bit: 0x02, label: 'oi-ready' },
|
||||||
|
{ bit: 0x04, label: 'sensors' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
robots: [],
|
||||||
|
selectedRobotId: null,
|
||||||
|
telemetry: {},
|
||||||
|
activeKeys: new Set(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const robotSelect = document.getElementById('robotSelect');
|
||||||
|
const telemetrySummary = document.getElementById('telemetrySummary');
|
||||||
|
const sensorList = document.getElementById('sensorList');
|
||||||
|
const safeModeBtn = document.getElementById('safeModeBtn');
|
||||||
|
const fullModeBtn = document.getElementById('fullModeBtn');
|
||||||
|
const enableOiBtn = document.getElementById('enableOiBtn');
|
||||||
|
const seekDockBtn = document.getElementById('seekDockBtn');
|
||||||
|
const playSongBtn = document.getElementById('playSongBtn');
|
||||||
|
const songSlotInput = document.getElementById('songSlot');
|
||||||
|
|
||||||
|
function flattenSensors(obj, prefix = '') {
|
||||||
|
const result = {};
|
||||||
|
Object.entries(obj || {}).forEach(([key, value]) => {
|
||||||
|
const path = prefix ? `${prefix}.${key}` : key;
|
||||||
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
Object.assign(result, flattenSensors(value, path));
|
||||||
|
} else {
|
||||||
|
result[path] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRobots() {
|
||||||
|
robotSelect.innerHTML = '';
|
||||||
|
state.robots.forEach((robot) => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = robot.id;
|
||||||
|
option.textContent = robot.id;
|
||||||
|
if (robot.id === state.selectedRobotId) {
|
||||||
|
option.selected = true;
|
||||||
|
}
|
||||||
|
robotSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTelemetry() {
|
||||||
|
const telemetry = state.telemetry[state.selectedRobotId];
|
||||||
|
if (!telemetry) {
|
||||||
|
telemetrySummary.textContent = 'No telemetry';
|
||||||
|
sensorList.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { header, trailer, sensors } = telemetry;
|
||||||
|
const flags = STATUS_FLAGS
|
||||||
|
.filter((flag) => header.statusBits & flag.bit)
|
||||||
|
.map((flag) => flag.label)
|
||||||
|
.join(', ');
|
||||||
|
const summaryLines = [
|
||||||
|
`Seq: ${header.seq}`,
|
||||||
|
`Uptime: ${header.uptimeMs} ms`,
|
||||||
|
`Last Control Age: ${header.lastControlAgeMs} ms`,
|
||||||
|
`WiFi RSSI: ${header.wifiRssiDbm} dBm`,
|
||||||
|
`Status: ${flags || 'none'}`,
|
||||||
|
`Applied mm/s: L ${trailer.appliedLeftMmps} | R ${trailer.appliedRightMmps}`,
|
||||||
|
`Dropped control packets: ${trailer.droppedControlPackets}`,
|
||||||
|
];
|
||||||
|
telemetrySummary.textContent = summaryLines.join('\n');
|
||||||
|
|
||||||
|
if (sensors) {
|
||||||
|
const flat = flattenSensors(sensors);
|
||||||
|
sensorList.textContent = Object.entries(flat)
|
||||||
|
.map(([key, value]) => `${key}: ${value}`)
|
||||||
|
.join('\n');
|
||||||
|
} else {
|
||||||
|
sensorList.textContent = 'Sensor block missing';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastDrive() {
|
||||||
|
if (!state.selectedRobotId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const vectors = { w: 0, a: 0, s: 0, d: 0 };
|
||||||
|
state.activeKeys.forEach((key) => {
|
||||||
|
if (vectors[key] !== undefined) {
|
||||||
|
vectors[key] = 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let left = 0;
|
||||||
|
let right = 0;
|
||||||
|
if (vectors.w) {
|
||||||
|
left += DRIVE_SPEED;
|
||||||
|
right += DRIVE_SPEED;
|
||||||
|
}
|
||||||
|
if (vectors.s) {
|
||||||
|
left -= DRIVE_SPEED;
|
||||||
|
right -= DRIVE_SPEED;
|
||||||
|
}
|
||||||
|
if (vectors.a) {
|
||||||
|
left -= TURN_SPEED;
|
||||||
|
right += TURN_SPEED;
|
||||||
|
}
|
||||||
|
if (vectors.d) {
|
||||||
|
left += TURN_SPEED;
|
||||||
|
right -= TURN_SPEED;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.emit('drive', {
|
||||||
|
robotId: state.selectedRobotId,
|
||||||
|
left,
|
||||||
|
right,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKey(event, isDown) {
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
if (!['w', 'a', 's', 'd'].includes(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
if (isDown) {
|
||||||
|
state.activeKeys.add(key);
|
||||||
|
} else {
|
||||||
|
state.activeKeys.delete(key);
|
||||||
|
}
|
||||||
|
broadcastDrive();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (event) => handleKey(event, true));
|
||||||
|
document.addEventListener('keyup', (event) => handleKey(event, false));
|
||||||
|
|
||||||
|
robotSelect.addEventListener('change', (event) => {
|
||||||
|
state.selectedRobotId = event.target.value;
|
||||||
|
renderTelemetry();
|
||||||
|
});
|
||||||
|
|
||||||
|
safeModeBtn.addEventListener('click', () => {
|
||||||
|
if (!state.selectedRobotId) return;
|
||||||
|
socket.emit('mode', { robotId: state.selectedRobotId, mode: 'SAFE' });
|
||||||
|
});
|
||||||
|
|
||||||
|
fullModeBtn.addEventListener('click', () => {
|
||||||
|
if (!state.selectedRobotId) return;
|
||||||
|
socket.emit('mode', { robotId: state.selectedRobotId, mode: 'FULL' });
|
||||||
|
});
|
||||||
|
|
||||||
|
enableOiBtn.addEventListener('click', () => {
|
||||||
|
if (!state.selectedRobotId) return;
|
||||||
|
socket.emit('enableOi', { robotId: state.selectedRobotId });
|
||||||
|
});
|
||||||
|
|
||||||
|
seekDockBtn.addEventListener('click', () => {
|
||||||
|
if (!state.selectedRobotId) return;
|
||||||
|
socket.emit('seekDock', { robotId: state.selectedRobotId });
|
||||||
|
});
|
||||||
|
|
||||||
|
playSongBtn.addEventListener('click', () => {
|
||||||
|
if (!state.selectedRobotId) return;
|
||||||
|
const slot = Number(songSlotInput.value) || 0;
|
||||||
|
socket.emit('playSong', { robotId: state.selectedRobotId, slot });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('robots', (robots) => {
|
||||||
|
state.robots = robots;
|
||||||
|
if (!state.selectedRobotId && robots.length > 0) {
|
||||||
|
state.selectedRobotId = robots[0].id;
|
||||||
|
}
|
||||||
|
renderRobots();
|
||||||
|
renderTelemetry();
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('telemetrySnapshot', (entries) => {
|
||||||
|
entries.forEach(({ robotId, telemetry }) => {
|
||||||
|
state.telemetry[robotId] = telemetry;
|
||||||
|
});
|
||||||
|
renderTelemetry();
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('telemetry', ({ robotId, telemetry }) => {
|
||||||
|
state.telemetry[robotId] = telemetry;
|
||||||
|
if (robotId === state.selectedRobotId) {
|
||||||
|
renderTelemetry();
|
||||||
|
}
|
||||||
|
});
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
+29
-12
@@ -1,20 +1,37 @@
|
|||||||
<!doctype html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/png" href="/bitmap.png" />
|
|
||||||
<link rel="apple-touch-icon" href="/bitmap.png" />
|
|
||||||
<link rel="manifest" href="/manifest.json" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<meta name="theme-color" content="#020617" />
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
||||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
|
||||||
<title>MultiRoombaRover</title>
|
<title>MultiRoombaRover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-CnvHZcbU.js"></script>
|
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-B1UrCI1T.css">
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<main>
|
||||||
|
<h1>MultiRoombaRover</h1>
|
||||||
|
<section>
|
||||||
|
<label for="robotSelect">Select Roomba:</label>
|
||||||
|
<select id="robotSelect"></select>
|
||||||
|
</section>
|
||||||
|
<section id="driveHints">
|
||||||
|
<p>Use WASD for drive control. Release keys to stop.</p>
|
||||||
|
<div>
|
||||||
|
<button id="safeModeBtn">Safe Mode</button>
|
||||||
|
<button id="fullModeBtn">Full Mode</button>
|
||||||
|
<button id="enableOiBtn">Enable OI</button>
|
||||||
|
<button id="seekDockBtn">Seek Dock</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<label for="songSlot">Song Slot:</label>
|
||||||
|
<input type="number" id="songSlot" value="0" min="0" max="15" />
|
||||||
|
<button id="playSongBtn">Play Song</button>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Telemetry</h2>
|
||||||
|
<pre id="telemetrySummary"></pre>
|
||||||
|
<pre id="sensorList"></pre>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script src="/socket.io/socket.io.js"></script>
|
||||||
|
<script type="module" src="./app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Multi Roomba Rover",
|
|
||||||
"short_name": "MRR",
|
|
||||||
"description": "Remote driving interface for the MultiRoomba Rover fleet.",
|
|
||||||
"start_url": "/",
|
|
||||||
"scope": "/",
|
|
||||||
"display": "standalone",
|
|
||||||
"background_color": "#000000",
|
|
||||||
"theme_color": "#020617",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "/bitmap.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "any"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "roomba-alpha",
|
||||||
|
"controlPort": 50010,
|
||||||
|
"maxWheelSpeed": 350
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
const http = require('http');
|
|
||||||
|
|
||||||
const api = process.env.MEDIAMTX_API || 'http://127.0.0.1:9997';
|
|
||||||
|
|
||||||
function fetchJSON(path) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const req = http.request(api + path, (res) => {
|
|
||||||
let data = '';
|
|
||||||
res.on('data', (chunk) => (data += chunk));
|
|
||||||
res.on('end', () => {
|
|
||||||
try {
|
|
||||||
resolve(JSON.parse(data));
|
|
||||||
} catch (err) {
|
|
||||||
reject(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on('error', reject);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const list = await fetchJSON('/v3/paths/list');
|
|
||||||
if (!list.items || !list.items.length) {
|
|
||||||
console.log('No active paths');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
list.items.forEach((item) => {
|
|
||||||
console.log(
|
|
||||||
`${item.name.padEnd(12)} ready=${item.ready} tracks=${item.tracks.join(',') || 'none'} bytes=${item.bytesReceived}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err) => {
|
|
||||||
console.error('check-media failed:', err.message);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
const bcrypt = require('bcrypt');
|
|
||||||
const readline = require('readline');
|
|
||||||
|
|
||||||
const passwordFromArg = process.argv[2];
|
|
||||||
|
|
||||||
async function hashPassword(password) {
|
|
||||||
try {
|
|
||||||
const hash = await bcrypt.hash(password, 10);
|
|
||||||
console.log(`Password: ${password}`);
|
|
||||||
console.log(`Hash: ${hash}`);
|
|
||||||
process.exit(0);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error hashing password:', err.message);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (passwordFromArg) {
|
|
||||||
hashPassword(passwordFromArg);
|
|
||||||
} else {
|
|
||||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
||||||
rl.question('Password to hash: ', (answer) => {
|
|
||||||
rl.close();
|
|
||||||
hashPassword(answer);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function checksum8(buffer, length = buffer.length) {
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < length; i += 1) {
|
||||||
|
sum = (sum + buffer[i]) & 0xff;
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export const CONTROL_STREAM_HZ = 50;
|
||||||
|
export const CONTROL_BIND_PORT = parseInt(process.env.CONTROL_BIND_PORT || '62000', 10);
|
||||||
|
export const TELEMETRY_BIND_PORT = parseInt(process.env.TELEMETRY_BIND_PORT || '62001', 10);
|
||||||
|
export const DEFAULT_DEVICE_CONTROL_PORT = parseInt(
|
||||||
|
process.env.DEVICE_CONTROL_PORT || '50010',
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CONTROL_CONSTANTS = {
|
||||||
|
MAGIC: 0xAA,
|
||||||
|
VERSION: 1,
|
||||||
|
ACTIONS: {
|
||||||
|
SEEK_DOCK: 0x01,
|
||||||
|
PLAY_SONG: 0x02,
|
||||||
|
LOAD_SONG: 0x04,
|
||||||
|
ENABLE_OI: 0x08,
|
||||||
|
},
|
||||||
|
MODES: {
|
||||||
|
NO_CHANGE: 0,
|
||||||
|
PASSIVE: 1,
|
||||||
|
SAFE: 2,
|
||||||
|
FULL: 3,
|
||||||
|
},
|
||||||
|
MAX_SPEED_MMPS: 500,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TELEMETRY_CONSTANTS = {
|
||||||
|
MAGIC: 0x55,
|
||||||
|
VERSION: 1,
|
||||||
|
HEADER_SIZE: 32,
|
||||||
|
TRAILER_SIZE: 9,
|
||||||
|
SENSOR_BLOB_BYTES: 80,
|
||||||
|
MAX_ROBOT_ID_LEN: 16,
|
||||||
|
};
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
const path = require('path');
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
port: process.env.PORT || 8080,
|
|
||||||
staticDir: path.join(__dirname, '..', '..', 'public'),
|
|
||||||
};
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
const path = require('path');
|
|
||||||
const http = require('http');
|
|
||||||
const express = require('express');
|
|
||||||
const morgan = require('morgan');
|
|
||||||
const config = require('./config');
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
app.use(morgan('dev'));
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(express.static(config.staticDir));
|
|
||||||
app.get('/spectate', (req, res) => {
|
|
||||||
res.sendFile(path.join(config.staticDir, 'index.html'));
|
|
||||||
});
|
|
||||||
|
|
||||||
const httpServer = http.createServer(app);
|
|
||||||
|
|
||||||
module.exports = { app, httpServer };
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
const { Server: SocketIOServer } = require('socket.io');
|
|
||||||
const { httpServer } = require('./http');
|
|
||||||
|
|
||||||
const io = new SocketIOServer(httpServer, {
|
|
||||||
cors: { origin: '*' },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Allow more service listeners without warnings.
|
|
||||||
io.sockets.setMaxListeners(30);
|
|
||||||
io.of('/').setMaxListeners(30);
|
|
||||||
|
|
||||||
module.exports = io;
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
const sinks = new Set();
|
|
||||||
|
|
||||||
function notifySinks(level, label, args) {
|
|
||||||
if (!sinks.size) return;
|
|
||||||
const entry = {
|
|
||||||
level,
|
|
||||||
label: label || null,
|
|
||||||
args,
|
|
||||||
message: args.map((value) => {
|
|
||||||
if (typeof value === 'string') return value;
|
|
||||||
try {
|
|
||||||
return JSON.stringify(value);
|
|
||||||
} catch (err) {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
}).join(' '),
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
sinks.forEach((sink) => {
|
|
||||||
try {
|
|
||||||
sink(entry);
|
|
||||||
} catch (err) {
|
|
||||||
// avoid recursive logging
|
|
||||||
console.error(entry.timestamp, '[ERROR]', '[logger]', 'Log sink failed', err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function stamp(level, label, args) {
|
|
||||||
const fields = [new Date().toISOString(), `[${level}]`];
|
|
||||||
if (label) {
|
|
||||||
fields.push(`[${label}]`);
|
|
||||||
}
|
|
||||||
notifySinks(level, label, args);
|
|
||||||
return [...fields, ...args];
|
|
||||||
}
|
|
||||||
|
|
||||||
function baseLogger(label) {
|
|
||||||
return {
|
|
||||||
info: (...args) => console.log(...stamp('INFO', label, args)),
|
|
||||||
warn: (...args) => console.warn(...stamp('WARN', label, args)),
|
|
||||||
error: (...args) => console.error(...stamp('ERROR', label, args)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function registerSink(fn) {
|
|
||||||
if (typeof fn !== 'function') return () => {};
|
|
||||||
sinks.add(fn);
|
|
||||||
return () => sinks.delete(fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
...baseLogger(),
|
|
||||||
child: (label) => baseLogger(label),
|
|
||||||
registerSink,
|
|
||||||
};
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
const { WebSocketServer } = require('ws');
|
|
||||||
const { httpServer } = require('./http');
|
|
||||||
const logger = require('./logger');
|
|
||||||
|
|
||||||
const roverWSS = new WebSocketServer({ noServer: true });
|
|
||||||
|
|
||||||
httpServer.on('upgrade', (req, socket, head) => {
|
|
||||||
if (req.url.startsWith('/rover')) {
|
|
||||||
roverWSS.handleUpgrade(req, socket, head, (ws) => {
|
|
||||||
roverWSS.emit('connection', ws, req);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
socket.destroy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
roverWSS.on('connection', () => {
|
|
||||||
logger.info('Rover websocket connected');
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = roverWSS;
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const yaml = require('js-yaml');
|
|
||||||
|
|
||||||
const CONFIG_PATH = process.env.SERVER_CONFIG || path.join(__dirname, '..', '..', 'config.yaml');
|
|
||||||
|
|
||||||
let cachedConfig;
|
|
||||||
|
|
||||||
function loadConfig() {
|
|
||||||
if (cachedConfig) {
|
|
||||||
return cachedConfig;
|
|
||||||
}
|
|
||||||
const file = fs.readFileSync(CONFIG_PATH, 'utf8');
|
|
||||||
cachedConfig = yaml.load(file);
|
|
||||||
return cachedConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
loadConfig,
|
|
||||||
};
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
const HEADER = 0x13;
|
|
||||||
const CHARGING_STATE = {
|
|
||||||
0: 'not charging',
|
|
||||||
1: 'reconditioning charging',
|
|
||||||
2: 'full charging',
|
|
||||||
3: 'trickle charging',
|
|
||||||
4: 'waiting',
|
|
||||||
5: 'charging fault',
|
|
||||||
};
|
|
||||||
|
|
||||||
const OI_MODES = {
|
|
||||||
1: 'off',
|
|
||||||
2: 'passive',
|
|
||||||
3: 'safe',
|
|
||||||
4: 'full',
|
|
||||||
};
|
|
||||||
|
|
||||||
const GROUP100_LAYOUT = [
|
|
||||||
{ id: 7, key: 'bumpsAndWheelDrops', bytes: 1, parser: parseBumps },
|
|
||||||
{ id: 8, key: 'wall', bytes: 1, parser: parseBool },
|
|
||||||
{ id: 9, key: 'cliffLeft', bytes: 1, parser: parseBool },
|
|
||||||
{ id: 10, key: 'cliffFrontLeft', bytes: 1, parser: parseBool },
|
|
||||||
{ id: 11, key: 'cliffFrontRight', bytes: 1, parser: parseBool },
|
|
||||||
{ id: 12, key: 'cliffRight', bytes: 1, parser: parseBool },
|
|
||||||
{ id: 13, key: 'virtualWall', bytes: 1, parser: parseBool },
|
|
||||||
{ id: 14, key: 'wheelOvercurrents', bytes: 1, parser: parseWheelCurrents },
|
|
||||||
{ id: 15, key: 'dirtDetect', bytes: 1, parser: parseUInt },
|
|
||||||
{ id: 16, key: 'dirtDetectLeft', bytes: 1, parser: parseUInt },
|
|
||||||
{ id: 17, key: 'infraredCharacterOmni', bytes: 1, parser: parseUInt },
|
|
||||||
{ id: 18, key: 'buttons', bytes: 1, parser: parseButtons },
|
|
||||||
{ id: 19, key: 'distanceMm', bytes: 2, parser: parseInt },
|
|
||||||
{ id: 20, key: 'angleDeg', bytes: 2, parser: parseInt },
|
|
||||||
{ id: 21, key: 'chargingState', bytes: 1, parser: parseChargingState },
|
|
||||||
{ id: 22, key: 'voltageMv', bytes: 2, parser: parseUInt },
|
|
||||||
{ id: 23, key: 'currentMa', bytes: 2, parser: parseInt },
|
|
||||||
{ id: 24, key: 'batteryTemperatureC', bytes: 1, parser: parseInt },
|
|
||||||
{ id: 25, key: 'batteryChargeMah', bytes: 2, parser: parseUInt },
|
|
||||||
{ id: 26, key: 'batteryCapacityMah', bytes: 2, parser: parseUInt },
|
|
||||||
{ id: 27, key: 'wallSignal', bytes: 2, parser: parseUInt },
|
|
||||||
{ id: 28, key: 'cliffLeftSignal', bytes: 2, parser: parseUInt },
|
|
||||||
{ id: 29, key: 'cliffFrontLeftSignal', bytes: 2, parser: parseUInt },
|
|
||||||
{ id: 30, key: 'cliffFrontRightSignal', bytes: 2, parser: parseUInt },
|
|
||||||
{ id: 31, key: 'cliffRightSignal', bytes: 2, parser: parseUInt },
|
|
||||||
{ id: 32, key: 'chargingSourcesAvailable', bytes: 1, parser: parseChargeSources },
|
|
||||||
{ id: 33, key: 'chargingSourcesReserved', bytes: 2, parser: parseUInt },
|
|
||||||
{ id: 34, key: 'chargingSources', bytes: 1, parser: parseChargeSources },
|
|
||||||
{ id: 35, key: 'oiMode', bytes: 1, parser: parseOiMode },
|
|
||||||
{ id: 36, key: 'songNumber', bytes: 1, parser: parseUInt },
|
|
||||||
{ id: 37, key: 'songPlaying', bytes: 1, parser: parseBool },
|
|
||||||
{ id: 38, key: 'streamPacketCount', bytes: 1, parser: parseUInt },
|
|
||||||
{ id: 39, key: 'requestedVelocity', bytes: 2, parser: parseInt },
|
|
||||||
{ id: 40, key: 'requestedRadius', bytes: 2, parser: parseInt },
|
|
||||||
{ id: 41, key: 'requestedRightVelocity', bytes: 2, parser: parseInt },
|
|
||||||
{ id: 42, key: 'requestedLeftVelocity', bytes: 2, parser: parseInt },
|
|
||||||
{ id: 43, key: 'encoderCountsLeft', bytes: 2, parser: parseUInt },
|
|
||||||
{ id: 44, key: 'encoderCountsRight', bytes: 2, parser: parseUInt },
|
|
||||||
{ id: 45, key: 'lightBumper', bytes: 1, parser: parseLightBumper },
|
|
||||||
{ id: 46, key: 'lightBumpLeftSignal', bytes: 2, parser: parseUInt },
|
|
||||||
{ id: 47, key: 'lightBumpFrontLeftSignal', bytes: 2, parser: parseUInt },
|
|
||||||
{ id: 48, key: 'lightBumpCenterLeftSignal', bytes: 2, parser: parseUInt },
|
|
||||||
{ id: 49, key: 'lightBumpCenterRightSignal', bytes: 2, parser: parseUInt },
|
|
||||||
{ id: 50, key: 'lightBumpFrontRightSignal', bytes: 2, parser: parseUInt },
|
|
||||||
{ id: 51, key: 'lightBumpRightSignal', bytes: 2, parser: parseUInt },
|
|
||||||
{ id: 52, key: 'infraredCharacterLeft', bytes: 1, parser: parseUInt },
|
|
||||||
{ id: 53, key: 'infraredCharacterRight', bytes: 1, parser: parseUInt },
|
|
||||||
{ id: 54, key: 'wheelLeftCurrentMa', bytes: 2, parser: parseInt },
|
|
||||||
{ id: 55, key: 'wheelRightCurrentMa', bytes: 2, parser: parseInt },
|
|
||||||
{ id: 56, key: 'mainBrushCurrentMa', bytes: 2, parser: parseInt },
|
|
||||||
{ id: 57, key: 'sideBrushCurrentMa', bytes: 2, parser: parseInt },
|
|
||||||
{ id: 58, key: 'stasis', bytes: 1, parser: parseBool },
|
|
||||||
];
|
|
||||||
|
|
||||||
const GROUP100_TOTAL = GROUP100_LAYOUT.reduce((sum, spec) => sum + spec.bytes, 0);
|
|
||||||
|
|
||||||
const TOP_LEVEL_PACKETS = {
|
|
||||||
100: GROUP100_TOTAL,
|
|
||||||
21: 1,
|
|
||||||
34: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
function parseSensorFrame(base64Data) {
|
|
||||||
if (!base64Data) return null;
|
|
||||||
const buf = Buffer.from(base64Data, 'base64');
|
|
||||||
if (buf.length < 4 || buf[0] !== HEADER) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const nBytes = buf[1];
|
|
||||||
if (buf.length < nBytes + 3) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const payload = buf.slice(2, 2 + nBytes);
|
|
||||||
const checksum = buf[2 + nBytes];
|
|
||||||
if (!validateChecksum(buf.slice(0, 2 + nBytes + 1), checksum)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const decoded = {};
|
|
||||||
let offset = 0;
|
|
||||||
while (offset < payload.length) {
|
|
||||||
const packetId = payload[offset++];
|
|
||||||
const size = TOP_LEVEL_PACKETS[packetId];
|
|
||||||
if (!size || offset + size > payload.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const segment = payload.slice(offset, offset + size);
|
|
||||||
offset += size;
|
|
||||||
if (packetId === 100) {
|
|
||||||
Object.assign(decoded, decodeGroup100(segment));
|
|
||||||
} else if (packetId === 21 && decoded.chargingState == null) {
|
|
||||||
decoded.chargingState = parseChargingState(segment);
|
|
||||||
} else if (packetId === 34 && decoded.chargingSources == null) {
|
|
||||||
decoded.chargingSources = parseChargeSources(segment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return decoded;
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeGroup100(buf) {
|
|
||||||
if (buf.length !== GROUP100_TOTAL) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
const values = {};
|
|
||||||
let offset = 0;
|
|
||||||
for (const spec of GROUP100_LAYOUT) {
|
|
||||||
const slice = buf.slice(offset, offset + spec.bytes);
|
|
||||||
offset += spec.bytes;
|
|
||||||
try {
|
|
||||||
values[spec.key] = spec.parser ? spec.parser(slice) : parseUInt(slice);
|
|
||||||
} catch (err) {
|
|
||||||
values[spec.key] = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseBool(buf) {
|
|
||||||
return Boolean(buf[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseUInt(buf) {
|
|
||||||
return buf.readUIntBE(0, buf.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseInt(buf) {
|
|
||||||
return buf.readIntBE(0, buf.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseBumps(buf) {
|
|
||||||
const value = buf[0];
|
|
||||||
return {
|
|
||||||
bumpRight: Boolean(value & 0x01),
|
|
||||||
bumpLeft: Boolean(value & 0x02),
|
|
||||||
wheelDropRight: Boolean(value & 0x04),
|
|
||||||
wheelDropLeft: Boolean(value & 0x08),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseWheelCurrents(buf) {
|
|
||||||
const value = buf[0];
|
|
||||||
return {
|
|
||||||
sideBrush: Boolean(value & 0x01),
|
|
||||||
mainBrush: Boolean(value & 0x04),
|
|
||||||
rightWheel: Boolean(value & 0x08),
|
|
||||||
leftWheel: Boolean(value & 0x10),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const BUTTON_LABELS = ['clean', 'spot', 'dock', 'minute', 'hour', 'day', 'schedule', 'clock'];
|
|
||||||
function parseButtons(buf) {
|
|
||||||
const v = buf[0];
|
|
||||||
const result = {};
|
|
||||||
BUTTON_LABELS.forEach((label, idx) => {
|
|
||||||
result[label] = Boolean(v & (1 << idx));
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseChargingState(buf) {
|
|
||||||
const code = buf[0];
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
label: CHARGING_STATE[code] || 'unknown',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseChargeSources(buf) {
|
|
||||||
const value = buf[0];
|
|
||||||
return {
|
|
||||||
internalCharger: Boolean(value & 0x01),
|
|
||||||
homeBase: Boolean(value & 0x02),
|
|
||||||
raw: value,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseOiMode(buf) {
|
|
||||||
const code = buf[0];
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
label: OI_MODES[code] || 'unknown',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const LIGHT_BUMPER_LABELS = ['left', 'frontLeft', 'centerLeft', 'centerRight', 'frontRight', 'right'];
|
|
||||||
function parseLightBumper(buf) {
|
|
||||||
const value = buf[0];
|
|
||||||
const obj = {};
|
|
||||||
LIGHT_BUMPER_LABELS.forEach((label, idx) => {
|
|
||||||
obj[label] = Boolean(value & (1 << idx));
|
|
||||||
});
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateChecksum(frame, checksum) {
|
|
||||||
let sum = 0;
|
|
||||||
for (const byte of frame) {
|
|
||||||
sum = (sum + byte) & 0xff;
|
|
||||||
}
|
|
||||||
return (sum & 0xff) === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
parseSensorFrame,
|
|
||||||
CHARGING_STATE,
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
import { DEFAULT_DEVICE_CONTROL_PORT } from './constants.js';
|
||||||
|
|
||||||
|
const REQUIRED_FIELDS = ['id'];
|
||||||
|
|
||||||
|
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const serverRoot = path.resolve(moduleDir, '..');
|
||||||
|
|
||||||
|
function readJson(filePath) {
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const content = fs.readFileSync(filePath, 'utf8');
|
||||||
|
return JSON.parse(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConfig() {
|
||||||
|
const candidates = [
|
||||||
|
path.join(serverRoot, 'robots.json'),
|
||||||
|
path.join(serverRoot, 'robots.example.json'),
|
||||||
|
path.join(process.cwd(), 'server', 'robots.json'),
|
||||||
|
path.join(process.cwd(), 'server', 'robots.example.json'),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const data = readJson(candidate);
|
||||||
|
if (data) {
|
||||||
|
if (candidate.endsWith('robots.example.json')) {
|
||||||
|
console.warn('[robots] robots.json missing, using example configuration');
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadRobots() {
|
||||||
|
const payload = resolveConfig();
|
||||||
|
if (!payload) {
|
||||||
|
throw new Error('robots configuration file not found');
|
||||||
|
}
|
||||||
|
if (!Array.isArray(payload)) {
|
||||||
|
throw new Error('robots configuration must be an array');
|
||||||
|
}
|
||||||
|
return payload.map((entry) => {
|
||||||
|
for (const field of REQUIRED_FIELDS) {
|
||||||
|
if (!entry[field]) {
|
||||||
|
throw new Error(`robot entry missing field ${field}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: entry.id,
|
||||||
|
host: entry.deviceHost || entry.host || null,
|
||||||
|
controlPort: Number(entry.deviceControlPort || entry.controlPort || DEFAULT_DEVICE_CONTROL_PORT),
|
||||||
|
maxWheelSpeed: Number(entry.maxWheelSpeed || 500),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import path from 'path';
|
||||||
|
import http from 'http';
|
||||||
|
import dgram from 'dgram';
|
||||||
|
import express from 'express';
|
||||||
|
import { Server as SocketIo } from 'socket.io';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CONTROL_BIND_PORT,
|
||||||
|
CONTROL_CONSTANTS,
|
||||||
|
CONTROL_STREAM_HZ,
|
||||||
|
TELEMETRY_BIND_PORT,
|
||||||
|
} from './constants.js';
|
||||||
|
import { loadRobots } from './robotRegistry.js';
|
||||||
|
import { buildControlPacket } from './udpPackets.js';
|
||||||
|
import { decodeTelemetry } from './telemetryDecoder.js';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const server = http.createServer(app);
|
||||||
|
const io = new SocketIo(server, {
|
||||||
|
cors: {
|
||||||
|
origin: '*',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const robots = loadRobots();
|
||||||
|
if (robots.length === 0) {
|
||||||
|
throw new Error('No robots configured. Add at least one entry to server/robots.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
const robotState = new Map();
|
||||||
|
const telemetryState = new Map();
|
||||||
|
|
||||||
|
robots.forEach((robot) => {
|
||||||
|
robotState.set(robot.id, {
|
||||||
|
config: robot,
|
||||||
|
seq: 0,
|
||||||
|
leftMmps: 0,
|
||||||
|
rightMmps: 0,
|
||||||
|
pendingMode: CONTROL_CONSTANTS.MODES.NO_CHANGE,
|
||||||
|
pendingActions: 0,
|
||||||
|
songSlot: 0,
|
||||||
|
lastKnownHost: robot.host || null,
|
||||||
|
lastKnownPort: robot.controlPort,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const controlSocket = dgram.createSocket('udp4');
|
||||||
|
controlSocket.on('error', (err) => {
|
||||||
|
console.error('[control] socket error', err);
|
||||||
|
});
|
||||||
|
controlSocket.bind(CONTROL_BIND_PORT, () => {
|
||||||
|
console.log(`[control] bound on port ${CONTROL_BIND_PORT}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const telemetrySocket = dgram.createSocket('udp4');
|
||||||
|
telemetrySocket.on('message', (msg, rinfo) => {
|
||||||
|
try {
|
||||||
|
const telemetry = decodeTelemetry(msg);
|
||||||
|
const robotId = telemetry.header.robotId || rinfo.address;
|
||||||
|
telemetryState.set(robotId, telemetry);
|
||||||
|
const state = robotState.get(robotId);
|
||||||
|
if (state) {
|
||||||
|
state.lastKnownHost = rinfo.address;
|
||||||
|
state.lastKnownPort = state.config.controlPort;
|
||||||
|
} else {
|
||||||
|
console.warn(`[telemetry] received frame from unknown robot ${robotId} (${rinfo.address})`);
|
||||||
|
}
|
||||||
|
io.emit('telemetry', { robotId, telemetry });
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[telemetry] failed to decode packet', err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
telemetrySocket.bind(TELEMETRY_BIND_PORT, () => {
|
||||||
|
console.log(`[telemetry] listening on port ${TELEMETRY_BIND_PORT}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||||
|
|
||||||
|
function clamp(value, min, max) {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDrive(robotId, left, right) {
|
||||||
|
const state = robotState.get(robotId);
|
||||||
|
if (!state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const limit = state.config.maxWheelSpeed || CONTROL_CONSTANTS.MAX_SPEED_MMPS;
|
||||||
|
const parsedLeft = Number(left) || 0;
|
||||||
|
const parsedRight = Number(right) || 0;
|
||||||
|
state.leftMmps = clamp(parsedLeft, -limit, limit);
|
||||||
|
state.rightMmps = clamp(parsedRight, -limit, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestMode(robotId, mode) {
|
||||||
|
const state = robotState.get(robotId);
|
||||||
|
if (!state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.pendingMode = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerAction(robotId, actionBit, songSlot = 0) {
|
||||||
|
const state = robotState.get(robotId);
|
||||||
|
if (!state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.pendingActions |= actionBit;
|
||||||
|
state.songSlot = songSlot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendControlFrame(robotId) {
|
||||||
|
const state = robotState.get(robotId);
|
||||||
|
if (!state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!state.lastKnownHost) {
|
||||||
|
return; // have not yet received telemetry -> cannot address robot
|
||||||
|
}
|
||||||
|
const packet = buildControlPacket({
|
||||||
|
seq: state.seq++,
|
||||||
|
leftMmps: state.leftMmps,
|
||||||
|
rightMmps: state.rightMmps,
|
||||||
|
mode: state.pendingMode,
|
||||||
|
actions: state.pendingActions,
|
||||||
|
songSlot: state.songSlot,
|
||||||
|
});
|
||||||
|
controlSocket.send(
|
||||||
|
packet,
|
||||||
|
0,
|
||||||
|
packet.length,
|
||||||
|
state.lastKnownPort,
|
||||||
|
state.lastKnownHost,
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
console.warn(`[control] failed to send to ${state.lastKnownHost}`, err.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
state.pendingMode = CONTROL_CONSTANTS.MODES.NO_CHANGE;
|
||||||
|
state.pendingActions = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
for (const robot of robots) {
|
||||||
|
sendControlFrame(robot.id);
|
||||||
|
}
|
||||||
|
}, Math.round(1000 / CONTROL_STREAM_HZ));
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
console.log('[socket] client connected');
|
||||||
|
socket.emit('robots', robots);
|
||||||
|
socket.emit(
|
||||||
|
'telemetrySnapshot',
|
||||||
|
Array.from(telemetryState.entries()).map(([robotId, telemetry]) => ({
|
||||||
|
robotId,
|
||||||
|
telemetry,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
socket.on('drive', ({ robotId, left = 0, right = 0 } = {}) => {
|
||||||
|
updateDrive(robotId, left, right);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('mode', ({ robotId, mode }) => {
|
||||||
|
const modes = CONTROL_CONSTANTS.MODES;
|
||||||
|
const requested = mode
|
||||||
|
? modes[mode.toUpperCase()] ?? modes.NO_CHANGE
|
||||||
|
: modes.NO_CHANGE;
|
||||||
|
requestMode(robotId, requested);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('seekDock', ({ robotId }) => {
|
||||||
|
triggerAction(robotId, CONTROL_CONSTANTS.ACTIONS.SEEK_DOCK);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('enableOi', ({ robotId }) => {
|
||||||
|
triggerAction(robotId, CONTROL_CONSTANTS.ACTIONS.ENABLE_OI);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('playSong', ({ robotId, slot = 0 }) => {
|
||||||
|
triggerAction(robotId, CONTROL_CONSTANTS.ACTIONS.PLAY_SONG, slot);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
console.log('[socket] client disconnected');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 8080;
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`[server] listening on http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
const io = require('../globals/io');
|
|
||||||
|
|
||||||
const COLORS = {
|
|
||||||
info: '#2196f3',
|
|
||||||
success: '#4caf50',
|
|
||||||
warn: '#f0b651',
|
|
||||||
error: '#e53935',
|
|
||||||
};
|
|
||||||
|
|
||||||
function sendAlert({ color, title, message, ts = Date.now() }) {
|
|
||||||
const payload = {
|
|
||||||
color: color || COLORS.info,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
ts,
|
|
||||||
};
|
|
||||||
io.emit('alert', payload);
|
|
||||||
io.emit('alert:new', { id: `${ts}-${Math.random().toString(36).slice(2)}`, ...payload });
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
COLORS,
|
|
||||||
sendAlert,
|
|
||||||
};
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
const EventEmitter = require('events');
|
|
||||||
const io = require('../globals/io');
|
|
||||||
const logger = require('../globals/logger').child('assignment');
|
|
||||||
const { MODES, getMode, modeEvents } = require('./modeManager');
|
|
||||||
const { roleEvents, getRole, isAdmin } = require('./roleService');
|
|
||||||
const roverManager = require('./roverManager');
|
|
||||||
|
|
||||||
const socketRefs = new Map(); // socketId -> socket
|
|
||||||
const assignments = new Map(); // socketId -> roverId
|
|
||||||
const waiting = new Set(); // socketIds waiting for placement
|
|
||||||
const assignmentEvents = new EventEmitter();
|
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
|
||||||
socketRefs.set(socket.id, socket);
|
|
||||||
socket.on('disconnect', () => {
|
|
||||||
socketRefs.delete(socket.id);
|
|
||||||
unassignSocket(socket);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
roleEvents.on('change', ({ socket, role }) => {
|
|
||||||
if (!socket || !socket.id) return;
|
|
||||||
if (role === 'user') {
|
|
||||||
assignSocket(socket);
|
|
||||||
} else {
|
|
||||||
unassignSocket(socket);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
modeEvents.on('change', (mode) => {
|
|
||||||
if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) {
|
|
||||||
// release non-admin drivers
|
|
||||||
for (const [socketId, roverId] of assignments.entries()) {
|
|
||||||
const socket = socketRefs.get(socketId);
|
|
||||||
if (socket && !isAdmin(socket)) {
|
|
||||||
releaseAssignment(socket, roverId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reassignWaiting();
|
|
||||||
});
|
|
||||||
|
|
||||||
roverManager.managerEvents.on('lock', ({ roverId, locked }) => {
|
|
||||||
if (locked) {
|
|
||||||
reassignFromRover(roverId);
|
|
||||||
} else {
|
|
||||||
reassignWaiting();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
roverManager.managerEvents.on('rover', ({ action }) => {
|
|
||||||
if (action === 'removed' || action === 'upsert') {
|
|
||||||
reassignWaiting();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function assignSocket(socket) {
|
|
||||||
if (!socket || isAdmin(socket) || getRole(socket) !== 'user') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// avoid double assignment
|
|
||||||
if (assignments.has(socket.id)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const target = pickRover();
|
|
||||||
if (!target) {
|
|
||||||
waiting.add(socket.id);
|
|
||||||
logger.info('No rover available, user waiting', socket.id);
|
|
||||||
assignmentEvents.emit('update', socket.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
roverManager.requestControl(target.id, socket, { allowUser: true });
|
|
||||||
assignments.set(socket.id, target.id);
|
|
||||||
waiting.delete(socket.id);
|
|
||||||
logger.info('Assigned user to rover', socket.id, target.id);
|
|
||||||
assignmentEvents.emit('update', socket.id);
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('Failed to assign user', err.message);
|
|
||||||
waiting.add(socket.id);
|
|
||||||
assignmentEvents.emit('update', socket.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function unassignSocket(socket) {
|
|
||||||
if (!socket) return;
|
|
||||||
waiting.delete(socket.id);
|
|
||||||
const roverId = assignments.get(socket.id);
|
|
||||||
if (roverId) {
|
|
||||||
roverManager.releaseControl(roverId, socket);
|
|
||||||
assignments.delete(socket.id);
|
|
||||||
logger.info('Unassigned socket from rover', socket.id, roverId);
|
|
||||||
assignmentEvents.emit('update', socket.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function reassignFromRover(roverId) {
|
|
||||||
for (const [socketId, rid] of assignments.entries()) {
|
|
||||||
if (rid !== roverId) continue;
|
|
||||||
const socket = socketRefs.get(socketId);
|
|
||||||
if (!socket) {
|
|
||||||
assignments.delete(socketId);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
roverManager.releaseControl(rid, socket);
|
|
||||||
assignments.delete(socketId);
|
|
||||||
assignSocket(socket);
|
|
||||||
assignmentEvents.emit('update', socketId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function reassignWaiting() {
|
|
||||||
for (const socketId of Array.from(waiting)) {
|
|
||||||
const socket = socketRefs.get(socketId);
|
|
||||||
if (socket) {
|
|
||||||
assignSocket(socket);
|
|
||||||
} else {
|
|
||||||
waiting.delete(socketId);
|
|
||||||
}
|
|
||||||
assignmentEvents.emit('update', socketId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function releaseAssignment(socket, roverId) {
|
|
||||||
roverManager.releaseControl(roverId, socket);
|
|
||||||
assignments.delete(socket.id);
|
|
||||||
waiting.add(socket.id);
|
|
||||||
logger.info('Released assignment back to queue', socket.id, roverId);
|
|
||||||
assignmentEvents.emit('update', socket.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function forceRelease(roverId, socketId) {
|
|
||||||
const socket = socketRefs.get(socketId) || io.sockets.sockets.get(socketId);
|
|
||||||
if (assignments.get(socketId) === roverId) {
|
|
||||||
assignments.delete(socketId);
|
|
||||||
}
|
|
||||||
waiting.delete(socketId);
|
|
||||||
if (socket) {
|
|
||||||
roverManager.releaseControl(roverId, socket);
|
|
||||||
logger.info('Force released socket from rover', socketId, roverId);
|
|
||||||
} else {
|
|
||||||
logger.warn('Force release: socket not found', socketId, roverId);
|
|
||||||
}
|
|
||||||
assignmentEvents.emit('update', socketId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickRover() {
|
|
||||||
const mode = getMode();
|
|
||||||
if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const candidates = Array.from(roverManager.rovers.values()).filter((rover) => {
|
|
||||||
if (!rover || rover.locked) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
if (candidates.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
candidates.sort((a, b) => a.drivers.size - b.drivers.size);
|
|
||||||
return candidates[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
function describeAssignment(socketId) {
|
|
||||||
const assignedRoverId = assignments.get(socketId) || null;
|
|
||||||
const adminRoverId = assignedRoverId ? null : roverManager.getPrimaryRoverForSocket(socketId);
|
|
||||||
const waitingIndex = waiting.has(socketId) ? Array.from(waiting).indexOf(socketId) : -1;
|
|
||||||
const roverId = assignedRoverId || adminRoverId || null;
|
|
||||||
const waitingStatus = waiting.has(socketId);
|
|
||||||
return {
|
|
||||||
roverId,
|
|
||||||
status: assignedRoverId ? 'assigned' : adminRoverId ? 'admin' : waitingStatus ? 'waiting' : null,
|
|
||||||
queuePosition: waitingIndex >= 0 ? waitingIndex + 1 : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
assignmentEvents,
|
|
||||||
describeAssignment,
|
|
||||||
forceRelease,
|
|
||||||
};
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
const bcrypt = require('bcrypt');
|
|
||||||
const io = require('../globals/io');
|
|
||||||
const logger = require('../globals/logger').child('authService');
|
|
||||||
const { loadConfig } = require('../helpers/configLoader');
|
|
||||||
const { clearLockdownTimer } = require('./lockdownGuard');
|
|
||||||
const { setRole } = require('./roleService');
|
|
||||||
|
|
||||||
const config = loadConfig();
|
|
||||||
const admins = config.admins || [];
|
|
||||||
|
|
||||||
function findAdmin(username) {
|
|
||||||
return admins.find((admin) => admin.username === username);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function authenticate(username, password) {
|
|
||||||
const admin = findAdmin(username);
|
|
||||||
if (!admin) {
|
|
||||||
throw new Error('Invalid credentials');
|
|
||||||
}
|
|
||||||
const ok = await bcrypt.compare(password, admin.password_hash);
|
|
||||||
if (!ok) {
|
|
||||||
throw new Error('Invalid credentials');
|
|
||||||
}
|
|
||||||
return admin;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAdmin(socket) {
|
|
||||||
return socket?.data?.role === 'admin' || socket?.data?.role === 'lockdown';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLockdownAdmin(socket) {
|
|
||||||
return socket?.data?.role === 'lockdown';
|
|
||||||
}
|
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
|
||||||
const requestedRole = socket.handshake?.query?.role;
|
|
||||||
const initialRole = requestedRole === 'spectator' ? 'spectator' : 'user';
|
|
||||||
setRole(socket, initialRole);
|
|
||||||
logger.info('Socket connected with role', socket.id, initialRole);
|
|
||||||
socket.emit('auth:role', { role: initialRole });
|
|
||||||
socket.on('auth:login', async ({ username, password }, cb = () => {}) => {
|
|
||||||
try {
|
|
||||||
const admin = await authenticate(username, password);
|
|
||||||
const role = admin.lockdown ? 'lockdown' : 'admin';
|
|
||||||
socket.data.user = { username: admin.username, discordId: admin.discord_id };
|
|
||||||
setRole(socket, role);
|
|
||||||
socket.emit('auth:role', { role });
|
|
||||||
clearLockdownTimer(socket);
|
|
||||||
logger.info('Login success', socket.id, role);
|
|
||||||
cb({ success: true, role: socket.data.role });
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('Login failed', socket.id, err.message);
|
|
||||||
cb({ success: false, error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleRoleChange({ role } = {}, cb = () => {}) {
|
|
||||||
if (role === 'spectator' || role === 'user') {
|
|
||||||
setRole(socket, role);
|
|
||||||
socket.emit('auth:role', { role });
|
|
||||||
logger.info('Role changed via client request', socket.id, role);
|
|
||||||
cb({ success: true, role });
|
|
||||||
} else {
|
|
||||||
cb({ error: 'Invalid role' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.on('role:set', handleRoleChange);
|
|
||||||
socket.on('session:setRole', handleRoleChange);
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
isAdmin,
|
|
||||||
isLockdownAdmin,
|
|
||||||
authenticate,
|
|
||||||
};
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
const logger = require('../globals/logger').child('batteryManager');
|
|
||||||
const { publishEvent } = require('./eventBus');
|
|
||||||
const { managerEvents, lockRover, rovers } = require('./roverManager');
|
|
||||||
|
|
||||||
const STATES = {
|
|
||||||
NORMAL: 'normal',
|
|
||||||
WARN: 'warn',
|
|
||||||
URGENT: 'urgent',
|
|
||||||
DOCKED: 'docked',
|
|
||||||
CHARGING: 'charging',
|
|
||||||
FULL: 'full',
|
|
||||||
LOCKED: 'locked',
|
|
||||||
};
|
|
||||||
|
|
||||||
const WAITING_UNLOCK_MS = 5 * 60 * 1000;
|
|
||||||
|
|
||||||
const roverState = new Map(); // roverId -> state snapshot
|
|
||||||
|
|
||||||
function getState(roverId) {
|
|
||||||
if (!roverState.has(roverId)) {
|
|
||||||
roverState.set(roverId, {
|
|
||||||
lastPercent: null,
|
|
||||||
warned: false,
|
|
||||||
urgent: false,
|
|
||||||
onDock: false,
|
|
||||||
dockedCharging: false,
|
|
||||||
charging: false,
|
|
||||||
batteryLocked: false,
|
|
||||||
waitingSince: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return roverState.get(roverId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldLock(record) {
|
|
||||||
return record.lockReason == null || record.lockReason === 'battery';
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSensorEvent({ roverId, sensors, batteryState }) {
|
|
||||||
if (!roverId) return;
|
|
||||||
const record = rovers.get(roverId);
|
|
||||||
if (!record) return;
|
|
||||||
const state = getState(roverId);
|
|
||||||
const config = record.meta?.battery || {};
|
|
||||||
const lockable = shouldLock(record);
|
|
||||||
|
|
||||||
if (batteryState?.percent != null) {
|
|
||||||
state.lastPercent = batteryState.percent;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.warned && batteryState?.warnActive) {
|
|
||||||
state.warned = true;
|
|
||||||
publishEvent({
|
|
||||||
source: 'batteryManager',
|
|
||||||
type: 'battery.warn',
|
|
||||||
payload: { roverId, batteryState },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!state.urgent && batteryState?.urgentActive) {
|
|
||||||
state.urgent = true;
|
|
||||||
publishEvent({
|
|
||||||
source: 'batteryManager',
|
|
||||||
type: 'battery.urgent',
|
|
||||||
payload: { roverId, batteryState },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const chargingState = sensors?.chargingState?.code;
|
|
||||||
const chargingSources = sensors?.chargingSources;
|
|
||||||
const onDock = Boolean(chargingSources?.homeBase);
|
|
||||||
if (onDock && !state.onDock) {
|
|
||||||
state.onDock = true;
|
|
||||||
publishEvent({
|
|
||||||
source: 'batteryManager',
|
|
||||||
type: 'battery.docked',
|
|
||||||
payload: { roverId, batteryState },
|
|
||||||
});
|
|
||||||
} else if (!onDock && state.onDock) {
|
|
||||||
state.onDock = false;
|
|
||||||
publishEvent({
|
|
||||||
source: 'batteryManager',
|
|
||||||
type: 'battery.undocked',
|
|
||||||
payload: { roverId, batteryState },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const chargingLabel = sensors?.chargingState?.label?.toLowerCase();
|
|
||||||
const chargingByLabel =
|
|
||||||
chargingLabel === 'waiting' || chargingLabel === 'full charging' || chargingLabel === 'trickle charging';
|
|
||||||
const chargingByCode = chargingState === 2 || chargingState === 3 || chargingState === 4;
|
|
||||||
const isCharging = chargingByLabel || chargingByCode;
|
|
||||||
if (isCharging && !state.charging) {
|
|
||||||
state.charging = true;
|
|
||||||
publishEvent({
|
|
||||||
source: 'batteryManager',
|
|
||||||
type: 'battery.charging.start',
|
|
||||||
payload: { roverId, batteryState },
|
|
||||||
});
|
|
||||||
} else if (!isCharging && state.charging) {
|
|
||||||
state.charging = false;
|
|
||||||
publishEvent({
|
|
||||||
source: 'batteryManager',
|
|
||||||
type: 'battery.charging.stop',
|
|
||||||
payload: { roverId, batteryState },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const dockedCharging = onDock && isCharging;
|
|
||||||
if (dockedCharging && !state.dockedCharging) {
|
|
||||||
state.dockedCharging = true;
|
|
||||||
} else if (!dockedCharging && state.dockedCharging) {
|
|
||||||
state.dockedCharging = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const waitingState = chargingState === 4;
|
|
||||||
if (waitingState && state.waitingSince == null) {
|
|
||||||
state.waitingSince = Date.now();
|
|
||||||
} else if (!waitingState && state.waitingSince != null) {
|
|
||||||
state.waitingSince = null;
|
|
||||||
}
|
|
||||||
const waitingLongEnough =
|
|
||||||
waitingState && state.waitingSince != null && Date.now() - state.waitingSince >= WAITING_UNLOCK_MS;
|
|
||||||
|
|
||||||
const warnThreshold =
|
|
||||||
typeof config?.Warn === 'number' ? config.Warn : batteryState?.warn ?? null;
|
|
||||||
const fullThreshold =
|
|
||||||
typeof config?.Full === 'number' ? config.Full : batteryState?.full ?? null;
|
|
||||||
const chargeMah = batteryState?.charge ?? null;
|
|
||||||
let waitingPercent = batteryState?.percent ?? null;
|
|
||||||
if (chargeMah != null && warnThreshold != null && fullThreshold != null && fullThreshold > warnThreshold) {
|
|
||||||
waitingPercent = (chargeMah - warnThreshold) / (fullThreshold - warnThreshold);
|
|
||||||
waitingPercent = Math.max(0, Math.min(1, waitingPercent));
|
|
||||||
}
|
|
||||||
|
|
||||||
const isFull =
|
|
||||||
(batteryState?.charge != null && fullThreshold != null && batteryState.charge >= fullThreshold) ||
|
|
||||||
(batteryState?.percent != null && batteryState.percent >= 0.99);
|
|
||||||
|
|
||||||
const needsCharge = state.warned;
|
|
||||||
|
|
||||||
if (dockedCharging && lockable && needsCharge && !state.batteryLocked && !isFull && !waitingLongEnough) {
|
|
||||||
logger.info('Auto-locking rover for charging', { roverId });
|
|
||||||
lockRover(roverId, true, { reason: 'battery' });
|
|
||||||
state.batteryLocked = true;
|
|
||||||
publishEvent({
|
|
||||||
source: 'batteryManager',
|
|
||||||
type: 'battery.locked',
|
|
||||||
payload: { roverId, batteryState },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const waitingHalfOrMore = waitingPercent != null && waitingPercent >= 0.5;
|
|
||||||
const shouldUnlock = state.batteryLocked && waitingLongEnough && waitingHalfOrMore;
|
|
||||||
|
|
||||||
if (shouldUnlock) {
|
|
||||||
logger.info('Unlocking rover after charge', {
|
|
||||||
roverId,
|
|
||||||
isFull,
|
|
||||||
docked: dockedCharging,
|
|
||||||
waitingState,
|
|
||||||
waitingPercent,
|
|
||||||
waitedMs: state.waitingSince ? Date.now() - state.waitingSince : null,
|
|
||||||
});
|
|
||||||
lockRover(roverId, false, { reason: 'battery' });
|
|
||||||
state.batteryLocked = false;
|
|
||||||
state.warned = false;
|
|
||||||
state.urgent = false;
|
|
||||||
state.waitingSince = null;
|
|
||||||
publishEvent({
|
|
||||||
source: 'batteryManager',
|
|
||||||
type: 'battery.unlocked',
|
|
||||||
payload: { roverId, batteryState },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
managerEvents.on('sensor', handleSensorEvent);
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
const { v4: uuidv4 } = require('uuid');
|
|
||||||
const io = require('../globals/io');
|
|
||||||
const logger = require('../globals/logger').child('chatService');
|
|
||||||
const { publishEvent, subscribe } = require('./eventBus');
|
|
||||||
const { getRole } = require('./roleService');
|
|
||||||
const { describeAssignment } = require('./assignmentService');
|
|
||||||
const roverManager = require('./roverManager');
|
|
||||||
const { getNickname } = require('./nicknameService');
|
|
||||||
const { issueCommand } = require('./commandService');
|
|
||||||
|
|
||||||
const RATE_LIMIT_WINDOW_MS = 8000;
|
|
||||||
const RATE_LIMIT_MAX = 5;
|
|
||||||
const rateBuckets = new Map(); // socketId -> [timestamps]
|
|
||||||
|
|
||||||
const PROFANITY_LIST = ['bitch', 'cunt', 'nigger', 'nigga', 'asshole', 'dick'];
|
|
||||||
const DUPLICATE_WINDOW_MS = 15000;
|
|
||||||
const lastMessageBySocket = new Map(); // socketId -> { text, ts }
|
|
||||||
|
|
||||||
function withinRateLimit(socketId) {
|
|
||||||
const now = Date.now();
|
|
||||||
const entries = rateBuckets.get(socketId) || [];
|
|
||||||
const next = entries.filter((ts) => now - ts <= RATE_LIMIT_WINDOW_MS);
|
|
||||||
next.push(now);
|
|
||||||
rateBuckets.set(socketId, next);
|
|
||||||
return next.length <= RATE_LIMIT_MAX;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasProfanity(text) {
|
|
||||||
const lower = text.toLowerCase();
|
|
||||||
return PROFANITY_LIST.some((word) => lower.includes(word));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDuplicate(socketId, text) {
|
|
||||||
const prev = lastMessageBySocket.get(socketId);
|
|
||||||
const now = Date.now();
|
|
||||||
if (!prev) {
|
|
||||||
lastMessageBySocket.set(socketId, { text, ts: now });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
lastMessageBySocket.set(socketId, { text, ts: now });
|
|
||||||
return prev.text === text && now - prev.ts <= DUPLICATE_WINDOW_MS;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isKeymash(text) {
|
|
||||||
if (!text) return false;
|
|
||||||
if (/(.)\1{6,}/.test(text)) return true; // same char 7+
|
|
||||||
if (/^[asdfghjkl;'\-=\[\]\\]{6,}$/i.test(text)) return true;
|
|
||||||
if (/^[qwertyuiop]{6,}$/i.test(text)) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveRoverId(socketId) {
|
|
||||||
const primary = roverManager.getPrimaryRoverForSocket(socketId);
|
|
||||||
if (primary) return primary;
|
|
||||||
const assignment = describeAssignment(socketId);
|
|
||||||
return assignment?.roverId || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeUserText(raw) {
|
|
||||||
if (typeof raw !== 'string') return '';
|
|
||||||
return raw.replace(/\\n/g, '\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildMessage(socket, text, meta = {}) {
|
|
||||||
const roverId = meta.roverId || resolveRoverId(socket?.id);
|
|
||||||
return {
|
|
||||||
id: uuidv4(),
|
|
||||||
ts: Date.now(),
|
|
||||||
socketId: socket?.id || null,
|
|
||||||
nickname: meta.nickname || getNickname(socket) || null,
|
|
||||||
role: meta.role || getRole(socket),
|
|
||||||
roverId,
|
|
||||||
fromDiscord: Boolean(meta.fromDiscord),
|
|
||||||
text,
|
|
||||||
tts: meta.tts || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function broadcastMessage(message) {
|
|
||||||
publishEvent({ source: 'chat', type: 'chat:message', payload: message });
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeTtsOptions(raw = {}) {
|
|
||||||
if (!raw || typeof raw !== 'object') return null;
|
|
||||||
const speak = raw.speak !== false;
|
|
||||||
if (!speak) return null;
|
|
||||||
const engine = typeof raw.engine === 'string' && raw.engine.toLowerCase() === 'espeak' ? 'espeak' : 'flite';
|
|
||||||
const voice = typeof raw.voice === 'string' ? raw.voice.trim() : undefined;
|
|
||||||
let pitch = Number.isFinite(raw.pitch) ? Math.round(raw.pitch) : undefined;
|
|
||||||
if (typeof pitch === 'number') {
|
|
||||||
pitch = Math.max(0, Math.min(99, pitch));
|
|
||||||
}
|
|
||||||
return { speak, engine, voice, pitch };
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleIncoming({ text, tts } = {}, socket, cb = () => {}) {
|
|
||||||
const role = getRole(socket);
|
|
||||||
// if (role === 'spectator') {
|
|
||||||
// cb({ error: 'Spectators cannot chat' });
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
const normalized = normalizeUserText(text);
|
|
||||||
const clean = normalized.trim();
|
|
||||||
if (!clean) {
|
|
||||||
cb({ error: 'Message required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!withinRateLimit(socket.id)) {
|
|
||||||
cb({ error: 'Slow down' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (clean.length > 256) {
|
|
||||||
cb({ error: 'Message too long' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (hasProfanity(clean)) {
|
|
||||||
cb({ error: 'Message blocked' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// if (isDuplicate(socket.id, clean)) {
|
|
||||||
// cb({ error: 'Duplicate message' });
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
if (isKeymash(clean)) {
|
|
||||||
cb({ error: 'Message looks like spam' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const roverId = resolveRoverId(socket?.id);
|
|
||||||
const ttsOptions = normalizeTtsOptions(tts);
|
|
||||||
const message = buildMessage(socket, clean, { fromDiscord: false, roverId, tts: ttsOptions });
|
|
||||||
logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
|
|
||||||
broadcastMessage(message);
|
|
||||||
maybeSpeak(socket, message, ttsOptions);
|
|
||||||
cb({ success: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
function maybeSpeak(socket, message, ttsOptions) {
|
|
||||||
if (!ttsOptions || !message?.roverId) return;
|
|
||||||
const record = roverManager.rovers.get(message.roverId);
|
|
||||||
const audio = record?.meta?.audio || {};
|
|
||||||
const ttsEnabled = Boolean(audio.ttsEnabled);
|
|
||||||
if (!ttsEnabled) return;
|
|
||||||
if (!roverManager.canDrive(message.roverId, socket)) return;
|
|
||||||
try {
|
|
||||||
issueCommand(message.roverId, {
|
|
||||||
type: 'tts',
|
|
||||||
tts: {
|
|
||||||
text: message.text,
|
|
||||||
engine: ttsOptions.engine,
|
|
||||||
voice: ttsOptions.voice,
|
|
||||||
pitch: ttsOptions.pitch,
|
|
||||||
speak: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
logger.info('TTS sent', { roverId: message.roverId, engine: ttsOptions.engine, socket: socket.id });
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('TTS send failed', { roverId: message.roverId, error: err.message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendExternalMessage({ text, nickname = 'Discord', role = 'admin', roverId = null }) {
|
|
||||||
const normalized = normalizeUserText(text);
|
|
||||||
const clean = normalized.trim();
|
|
||||||
if (!clean || clean.length > 256) {
|
|
||||||
throw new Error('Message invalid');
|
|
||||||
}
|
|
||||||
if (hasProfanity(clean)) {
|
|
||||||
throw new Error('Message blocked');
|
|
||||||
}
|
|
||||||
if (isKeymash(clean)) {
|
|
||||||
throw new Error('Message looks like spam');
|
|
||||||
}
|
|
||||||
const message = buildMessage(null, clean, {
|
|
||||||
nickname,
|
|
||||||
role,
|
|
||||||
roverId,
|
|
||||||
fromDiscord: true,
|
|
||||||
});
|
|
||||||
logger.info('External chat message', { roverId, nickname });
|
|
||||||
broadcastMessage(message);
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
|
||||||
socket.on('chat:send', (payload = {}, cb = () => {}) => handleIncoming(payload, socket, cb));
|
|
||||||
});
|
|
||||||
|
|
||||||
subscribe('chat:message', ({ payload }) => {
|
|
||||||
if (!payload) return;
|
|
||||||
io.emit('chat:message', payload);
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
handleIncoming,
|
|
||||||
sendExternalMessage,
|
|
||||||
};
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
const { v4: uuidv4 } = require('uuid');
|
|
||||||
const io = require('../globals/io');
|
|
||||||
const roverManager = require('./roverManager');
|
|
||||||
const logger = require('../globals/logger').child('commandService');
|
|
||||||
|
|
||||||
const pendingCommands = new Map(); // id -> { roverId }
|
|
||||||
|
|
||||||
function issueCommand(roverId, payload) {
|
|
||||||
const record = roverManager.rovers.get(roverId);
|
|
||||||
if (!record || !record.ws) {
|
|
||||||
throw new Error('Rover offline');
|
|
||||||
}
|
|
||||||
const id = uuidv4();
|
|
||||||
const message = { ...payload, id };
|
|
||||||
record.ws.send(JSON.stringify(message));
|
|
||||||
pendingCommands.set(id, { roverId, ts: Date.now(), type: payload.type });
|
|
||||||
logger.info('Issued command', roverId, payload.type, id);
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAck(msg) {
|
|
||||||
const pending = pendingCommands.get(msg.id);
|
|
||||||
if (!pending) return;
|
|
||||||
pendingCommands.delete(msg.id);
|
|
||||||
logger.info('Command acknowledged', pending.roverId, pending.type, msg.status);
|
|
||||||
io.emit('commandAck', {
|
|
||||||
roverId: pending.roverId,
|
|
||||||
id: msg.id,
|
|
||||||
status: msg.status || 'ok',
|
|
||||||
error: msg.error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
issueCommand,
|
|
||||||
handleAck,
|
|
||||||
};
|
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
|
||||||
function handleCommand({ roverId, type, data } = {}, cb) {
|
|
||||||
const reply = typeof cb === 'function' ? cb : () => {};
|
|
||||||
try {
|
|
||||||
if (!roverId) {
|
|
||||||
throw new Error('roverId required');
|
|
||||||
}
|
|
||||||
if (!roverManager.canDrive(roverId, socket)) {
|
|
||||||
throw new Error('Not your turn or no control');
|
|
||||||
}
|
|
||||||
const payload = data ? { ...data } : {};
|
|
||||||
const id = issueCommand(roverId, { type, ...payload });
|
|
||||||
logger.info('Queued command', socket.id, roverId, type);
|
|
||||||
try {
|
|
||||||
const { recordActivity } = require('./turnService');
|
|
||||||
recordActivity(roverId, socket.id);
|
|
||||||
} catch (err) {
|
|
||||||
// best effort; ignore activity update errors
|
|
||||||
}
|
|
||||||
reply({ id });
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('Command rejected', socket.id, err.message);
|
|
||||||
reply({ error: err.message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.on('command', handleCommand);
|
|
||||||
socket.on('command:issue', handleCommand);
|
|
||||||
});
|
|
||||||
@@ -1,436 +0,0 @@
|
|||||||
const {
|
|
||||||
Client,
|
|
||||||
GatewayIntentBits,
|
|
||||||
Partials,
|
|
||||||
ActivityType,
|
|
||||||
EmbedBuilder,
|
|
||||||
} = require('discord.js');
|
|
||||||
const logger = require('../globals/logger').child('discordBot');
|
|
||||||
const { loadConfig } = require('../helpers/configLoader');
|
|
||||||
const { subscribe } = require('./eventBus');
|
|
||||||
const { getRoster, lockRover } = require('./roverManager');
|
|
||||||
const { MODES, getMode, setMode } = require('./modeManager');
|
|
||||||
const { sendExternalMessage } = require('./chatService');
|
|
||||||
|
|
||||||
const config = loadConfig();
|
|
||||||
const discordConfig = config.discord || {};
|
|
||||||
const enabled = Boolean(discordConfig.token);
|
|
||||||
const adminIds = new Set(
|
|
||||||
(config.admins || []).map((a) => String(a.discord_id || '').trim()).filter(Boolean),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!enabled) {
|
|
||||||
logger.info('Discord bot disabled; missing token in config.discord.token');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const intents = [
|
|
||||||
GatewayIntentBits.Guilds,
|
|
||||||
GatewayIntentBits.GuildMessages,
|
|
||||||
GatewayIntentBits.MessageContent,
|
|
||||||
];
|
|
||||||
|
|
||||||
const client = new Client({
|
|
||||||
intents,
|
|
||||||
partials: [Partials.Channel],
|
|
||||||
});
|
|
||||||
|
|
||||||
const channelCache = new Map();
|
|
||||||
let skippedFirstModeAnnouncement = false;
|
|
||||||
|
|
||||||
function sanitizeMentions(text) {
|
|
||||||
if (!text) return '';
|
|
||||||
return String(text)
|
|
||||||
// strip mention tokens
|
|
||||||
.replace(/<(@[!&]?\d+|#\d+)>/g, '[ping removed]')
|
|
||||||
// neutralize everyone/here
|
|
||||||
.replace(/@everyone/gi, '[everyone]')
|
|
||||||
.replace(/@here/gi, '[here]');
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatRoverStatus(rover) {
|
|
||||||
if (!rover) return 'Unknown rover';
|
|
||||||
const percent = rover.batteryState?.percentDisplay;
|
|
||||||
const battery = percent != null ? `${percent}%` : 'n/a';
|
|
||||||
const lockLabel = rover.locked ? '🔒 locked' : '🔓 unlocked';
|
|
||||||
return `**${rover.name || rover.id}** — ${lockLabel} — battery ${battery}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function countReady() {
|
|
||||||
const roster = getRoster();
|
|
||||||
const total = roster.length;
|
|
||||||
const ready = roster.filter((r) => !r.locked).length;
|
|
||||||
return { ready, total };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updatePresence() {
|
|
||||||
if (!client?.user) return;
|
|
||||||
const { ready, total } = countReady();
|
|
||||||
try {
|
|
||||||
await client.user.setPresence({
|
|
||||||
activities: [{ name: `${ready}/${total} Rovers Ready`, type: ActivityType.Watching }],
|
|
||||||
status: 'online',
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('Failed to update Discord presence', err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchChannel(id) {
|
|
||||||
if (!id) return null;
|
|
||||||
if (channelCache.has(id)) return channelCache.get(id);
|
|
||||||
try {
|
|
||||||
const channel = await client.channels.fetch(id);
|
|
||||||
if (channel) {
|
|
||||||
channelCache.set(id, channel);
|
|
||||||
return channel;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('Failed to fetch Discord channel', { id, error: err.message });
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendToChannel(id, content, options = {}, allowedMentions = { parse: [] }, sanitizeContent = true) {
|
|
||||||
const channel = await fetchChannel(id);
|
|
||||||
if (!channel) return;
|
|
||||||
try {
|
|
||||||
const messageContent = sanitizeContent ? sanitizeMentions(content) : content;
|
|
||||||
await channel.send({ content: messageContent, allowedMentions, ...options });
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('Failed to send Discord message', { id, error: err.message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAdminUser(discordId) {
|
|
||||||
return adminIds.has(String(discordId || '').trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatHelp() {
|
|
||||||
return [
|
|
||||||
'**Rover Bot Commands**',
|
|
||||||
'`rs help` — show this help',
|
|
||||||
'`rs status [id]` — show rover status (all or one)',
|
|
||||||
'`rs lock <id>` — lock a rover',
|
|
||||||
'`rs unlock <id>` — unlock a rover',
|
|
||||||
'`rs mode <open|turns|admin|lockdown>` — change server mode',
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function findRover(id) {
|
|
||||||
const roster = getRoster();
|
|
||||||
if (!id) return null;
|
|
||||||
return roster.find((r) => String(r.id) === String(id) || String(r.name) === String(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleStatusCommand(message, roverId) {
|
|
||||||
const roster = getRoster();
|
|
||||||
if (!roverId) {
|
|
||||||
const summary = roster.map((r) => formatRoverStatus(r)).join('\n') || 'No rovers online.';
|
|
||||||
await message.reply({
|
|
||||||
content: sanitizeMentions(summary.slice(0, 1900)),
|
|
||||||
allowedMentions: { parse: [], repliedUser: false },
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const rover = findRover(roverId);
|
|
||||||
await message.reply({
|
|
||||||
content: sanitizeMentions(formatRoverStatus(rover).slice(0, 1900)),
|
|
||||||
allowedMentions: { parse: [], repliedUser: false },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleLockCommand(message, roverId, locked) {
|
|
||||||
if (!roverId) {
|
|
||||||
await message.reply({
|
|
||||||
content: 'Specify a rover ID. Example: `rs lock alpha`',
|
|
||||||
allowedMentions: { parse: [], repliedUser: false },
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
lockRover(roverId, locked, { reason: 'discord' });
|
|
||||||
await message.reply({
|
|
||||||
content: sanitizeMentions(`${locked ? 'Locked' : 'Unlocked'} ${roverId}.`),
|
|
||||||
allowedMentions: { parse: [], repliedUser: false },
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
await message.reply({
|
|
||||||
content: sanitizeMentions(`Failed: ${err.message}`),
|
|
||||||
allowedMentions: { parse: [], repliedUser: false },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleModeCommand(message, mode) {
|
|
||||||
const next = String(mode || '').toLowerCase();
|
|
||||||
if (!Object.values(MODES).includes(next)) {
|
|
||||||
await message.reply({
|
|
||||||
content: 'Invalid mode. Use one of: open, turns, admin, lockdown.',
|
|
||||||
allowedMentions: { parse: [], repliedUser: false },
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
setMode(next, null, { force: true });
|
|
||||||
await message.reply({
|
|
||||||
content: sanitizeMentions(`Mode set to ${next}.`),
|
|
||||||
allowedMentions: { parse: [], repliedUser: false },
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
await message.reply({
|
|
||||||
content: sanitizeMentions(`Failed to set mode: ${err.message}`),
|
|
||||||
allowedMentions: { parse: [], repliedUser: false },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCommand(message) {
|
|
||||||
if (message.author.bot) return;
|
|
||||||
const content = (message.content || '').trim();
|
|
||||||
if (!content.toLowerCase().startsWith('rs')) return;
|
|
||||||
if (!isAdminUser(message.author.id)) {
|
|
||||||
return; // ignore non-admins
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokens = content.split(/\s+/);
|
|
||||||
tokens.shift(); // remove prefix
|
|
||||||
const action = (tokens.shift() || '').toLowerCase();
|
|
||||||
|
|
||||||
switch (action) {
|
|
||||||
case 'help':
|
|
||||||
await message.reply(formatHelp());
|
|
||||||
break;
|
|
||||||
case 'status':
|
|
||||||
await handleStatusCommand(message, tokens[0]);
|
|
||||||
break;
|
|
||||||
case 'lock':
|
|
||||||
await handleLockCommand(message, tokens[0], true);
|
|
||||||
break;
|
|
||||||
case 'unlock':
|
|
||||||
await handleLockCommand(message, tokens[0], false);
|
|
||||||
break;
|
|
||||||
case 'mode':
|
|
||||||
await handleModeCommand(message, tokens[0]);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
await message.reply(formatHelp());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatChatLine(payload) {
|
|
||||||
const name = payload.nickname || payload.socketId?.slice(0, 6) || 'unknown';
|
|
||||||
const rover = payload.roverId ? `**[${payload.roverId}]** ` : '';
|
|
||||||
const role =
|
|
||||||
payload.role === 'admin' || payload.role === 'lockdown' ? '**(admin)** ' : '';
|
|
||||||
return `${rover}${role}${name}: ${payload.text}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleBridgeInbound(message) {
|
|
||||||
const bridgeChannelId = discordConfig?.channels?.chatBridge;
|
|
||||||
if (!bridgeChannelId) return;
|
|
||||||
if (String(message.channelId) !== String(bridgeChannelId)) return;
|
|
||||||
if (message.author.bot) return;
|
|
||||||
const content = (message.content || '').trim();
|
|
||||||
if (content.toLowerCase().startsWith('rs')) return; // don't echo commands
|
|
||||||
const nickname =
|
|
||||||
message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord';
|
|
||||||
const role = isAdminUser(message.author.id) ? 'admin' : 'user';
|
|
||||||
try {
|
|
||||||
sendExternalMessage({
|
|
||||||
text: content,
|
|
||||||
nickname,
|
|
||||||
role,
|
|
||||||
roverId: null,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('Failed to bridge inbound Discord chat', err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildEmbed({ title, description, color }) {
|
|
||||||
const embed = new EmbedBuilder().setTitle(title || 'Update').setColor(color || 0x2196f3);
|
|
||||||
if (description) embed.setDescription(description);
|
|
||||||
embed.setTimestamp(new Date());
|
|
||||||
return embed;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function announce({ channelId, content, pingRoleId, color, title, description }) {
|
|
||||||
if (!channelId) return;
|
|
||||||
const prefix = pingRoleId ? `<@&${pingRoleId}> ` : '';
|
|
||||||
const embed = buildEmbed({ title, description, color });
|
|
||||||
const allowedMentions = pingRoleId ? { roles: [pingRoleId], parse: [] } : { parse: [] };
|
|
||||||
await sendToChannel(
|
|
||||||
channelId,
|
|
||||||
`${prefix}${content || ''}`.trim(),
|
|
||||||
{ embeds: [embed] },
|
|
||||||
allowedMentions,
|
|
||||||
!pingRoleId, // keep role mention intact when pinging
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBusEvent(event) {
|
|
||||||
const { type, payload } = event || {};
|
|
||||||
const channels = discordConfig.channels || {};
|
|
||||||
const roles = discordConfig.roles || {};
|
|
||||||
switch (type) {
|
|
||||||
case 'mode.changed':
|
|
||||||
if (!skippedFirstModeAnnouncement) {
|
|
||||||
skippedFirstModeAnnouncement = true;
|
|
||||||
updatePresence();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
announce({
|
|
||||||
channelId: channels.announcements,
|
|
||||||
pingRoleId: roles.announcementPing || null,
|
|
||||||
color: 0x2196f3,
|
|
||||||
title: 'Mode Changed',
|
|
||||||
description: `Server mode set to **${payload?.mode}**`,
|
|
||||||
});
|
|
||||||
updatePresence();
|
|
||||||
break;
|
|
||||||
case 'rover.locked':
|
|
||||||
announce({
|
|
||||||
channelId: channels.announcements,
|
|
||||||
color: 0xf0b651,
|
|
||||||
title: 'Rover Locked',
|
|
||||||
description: `${payload?.roverId} locked${payload?.reason ? ` (${payload.reason})` : ''}.`,
|
|
||||||
});
|
|
||||||
updatePresence();
|
|
||||||
break;
|
|
||||||
case 'rover.unlocked':
|
|
||||||
announce({
|
|
||||||
channelId: channels.announcements,
|
|
||||||
pingRoleId: roles.announcementPing || null,
|
|
||||||
color: 0x4caf50,
|
|
||||||
title: 'Rover Unlocked',
|
|
||||||
description: `${payload?.roverId} unlocked.`,
|
|
||||||
});
|
|
||||||
updatePresence();
|
|
||||||
break;
|
|
||||||
case 'rover.online':
|
|
||||||
announce({
|
|
||||||
channelId: channels.adminAlerts,
|
|
||||||
pingRoleId: roles.adminPing || null,
|
|
||||||
color: 0x4caf50,
|
|
||||||
title: 'Rover Online',
|
|
||||||
description: `${payload?.roverId} is online.`,
|
|
||||||
});
|
|
||||||
updatePresence();
|
|
||||||
break;
|
|
||||||
case 'rover.offline':
|
|
||||||
announce({
|
|
||||||
channelId: channels.adminAlerts,
|
|
||||||
pingRoleId: roles.adminPing || null,
|
|
||||||
color: 0xe53935,
|
|
||||||
title: 'Rover Offline',
|
|
||||||
description: `${payload?.roverId} went offline.`,
|
|
||||||
});
|
|
||||||
updatePresence();
|
|
||||||
break;
|
|
||||||
case 'battery.warn':
|
|
||||||
announce({
|
|
||||||
channelId: channels.adminAlerts,
|
|
||||||
pingRoleId: roles.adminPing || null,
|
|
||||||
color: 0xf0b651,
|
|
||||||
title: 'Battery Warn',
|
|
||||||
description: `${payload?.roverId} at ${payload?.batteryState?.percentDisplay ?? 'low'}%.`,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'battery.urgent':
|
|
||||||
announce({
|
|
||||||
channelId: channels.adminAlerts,
|
|
||||||
pingRoleId: roles.adminPing || null,
|
|
||||||
color: 0xe53935,
|
|
||||||
title: 'Battery Urgent',
|
|
||||||
description: `${payload?.roverId} at ${payload?.batteryState?.percentDisplay ?? 'urgent'}%.`,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'battery.docked':
|
|
||||||
announce({
|
|
||||||
channelId: channels.adminAlerts,
|
|
||||||
color: 0x2196f3,
|
|
||||||
title: 'Docked',
|
|
||||||
description: `${payload?.roverId} docked.`,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'battery.undocked':
|
|
||||||
announce({
|
|
||||||
channelId: channels.adminAlerts,
|
|
||||||
color: 0x2196f3,
|
|
||||||
title: 'Undocked',
|
|
||||||
description: `${payload?.roverId} undocked.`,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'battery.charging.start':
|
|
||||||
announce({
|
|
||||||
channelId: channels.adminAlerts,
|
|
||||||
color: 0x2196f3,
|
|
||||||
title: 'Charging Started',
|
|
||||||
description: `${payload?.roverId} started charging.`,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'battery.charging.stop':
|
|
||||||
announce({
|
|
||||||
channelId: channels.adminAlerts,
|
|
||||||
color: 0xf0b651,
|
|
||||||
title: 'Charging Stopped',
|
|
||||||
description: `${payload?.roverId} stopped charging.`,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'battery.locked':
|
|
||||||
announce({
|
|
||||||
channelId: channels.adminAlerts,
|
|
||||||
color: 0xf0b651,
|
|
||||||
title: 'Locked for Charging',
|
|
||||||
description: `${payload?.roverId} locked for charging.`,
|
|
||||||
});
|
|
||||||
updatePresence();
|
|
||||||
break;
|
|
||||||
case 'battery.unlocked':
|
|
||||||
announce({
|
|
||||||
channelId: channels.adminAlerts,
|
|
||||||
color: 0x4caf50,
|
|
||||||
title: 'Unlocked after Charging',
|
|
||||||
description: `${payload?.roverId} unlocked after charging.`,
|
|
||||||
});
|
|
||||||
updatePresence();
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleChatBridgeOutbound(event) {
|
|
||||||
const payload = event?.payload;
|
|
||||||
if (!payload || payload.fromDiscord) return;
|
|
||||||
const bridgeChannelId = discordConfig?.channels?.chatBridge;
|
|
||||||
if (!bridgeChannelId) return;
|
|
||||||
const line = formatChatLine(payload);
|
|
||||||
const text = line.length > 1900 ? `${line.slice(0, 1897)}...` : line;
|
|
||||||
sendToChannel(bridgeChannelId, text, {}, { parse: [] });
|
|
||||||
}
|
|
||||||
|
|
||||||
client.on('messageCreate', async (message) => {
|
|
||||||
try {
|
|
||||||
await handleCommand(message);
|
|
||||||
await handleBridgeInbound(message);
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('Error handling Discord message', err.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
client.once('ready', () => {
|
|
||||||
logger.info('Discord bot logged in', { tag: client.user?.tag });
|
|
||||||
updatePresence();
|
|
||||||
});
|
|
||||||
|
|
||||||
subscribe('*', handleBusEvent);
|
|
||||||
subscribe('chat:message', handleChatBridgeOutbound);
|
|
||||||
|
|
||||||
client.login(discordConfig.token).catch((err) => {
|
|
||||||
logger.error('Discord login failed', err.message);
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = {};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user