mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d001b5263 |
+2
-10
@@ -7,13 +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
|
|
||||||
server/package-lock.json
|
|
||||||
package-lock.json
|
|
||||||
server/data/discord-guilds.json
|
|
||||||
server/data/global-objective.json
|
|
||||||
server/data/admin-reason.json
|
|
||||||
server/data
|
|
||||||
|
|||||||
@@ -1,7 +1,64 @@
|
|||||||
# Multi Roomba Rover
|
# Multi Roomba Rover
|
||||||
A system for controlling create 2 compatible roombas through a webpage.
|
|
||||||
|
|
||||||
Docs coming "soon"
|
a remake of my RoombaRover project with a decentralized and embedded approach
|
||||||
|
|
||||||
## Basic installation
|
## Hardware stack
|
||||||
-
|
|
||||||
|
On each roomba:
|
||||||
|
- an esp32
|
||||||
|
- a level shifter
|
||||||
|
- 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 #?
|
||||||
|
|
||||||
|
## Current software layout
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── include/
|
||||||
|
│ ├── config.example.h // copy to config.h with your Wi-Fi + server settings
|
||||||
|
│ └── protocol.h // shared packet layout (control + telemetry)
|
||||||
|
├── src/main.cpp // ESP32 firmware entrypoint (PlatformIO)
|
||||||
|
└── server/
|
||||||
|
├── package.json // Node.js server + Socket.IO web UI
|
||||||
|
├── robots.example.json // copy/edit to robots.json for your fleet
|
||||||
|
├── src/ // UDP relay + telemetry decoder
|
||||||
|
└── public/ // barebones HTML/JS UI
|
||||||
|
```
|
||||||
|
|
||||||
|
### Firmware quickstart
|
||||||
|
|
||||||
|
1. `cp include/config.example.h include/config.h` and fill in:
|
||||||
|
- `WIFI_SSID` / `WIFI_PASSWORD`
|
||||||
|
- `CONTROL_SERVER_IP` (Node server host)
|
||||||
|
- `ROOMBA_ID` (unique per robot; must match the server entry)
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
### Server + web UI quickstart
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
UDP streams stay simple:
|
||||||
|
- server -> ESP32: fixed 12-byte control packet blasted at 50 Hz per robot
|
||||||
|
- ESP32 -> server: framed telemetry header + raw sensor group 100 + trailer (CRC-8)
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
.pio
|
|
||||||
.vscode/.browse.c_cpp.db*
|
|
||||||
.vscode/c_cpp_properties.json
|
|
||||||
.vscode/launch.json
|
|
||||||
.vscode/ipch
|
|
||||||
config.h
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
|
|
||||||
This directory is intended for project header files.
|
|
||||||
|
|
||||||
A header file is a file containing C declarations and macro definitions
|
|
||||||
to be shared between several project source files. You request the use of a
|
|
||||||
header file in your project source file (C, C++, etc) located in `src` folder
|
|
||||||
by including it, with the C preprocessing directive `#include'.
|
|
||||||
|
|
||||||
```src/main.c
|
|
||||||
|
|
||||||
#include "header.h"
|
|
||||||
|
|
||||||
int main (void)
|
|
||||||
{
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Including a header file produces the same results as copying the header file
|
|
||||||
into each source file that needs it. Such copying would be time-consuming
|
|
||||||
and error-prone. With a header file, the related declarations appear
|
|
||||||
in only one place. If they need to be changed, they can be changed in one
|
|
||||||
place, and programs that include the header file will automatically use the
|
|
||||||
new version when next recompiled. The header file eliminates the labor of
|
|
||||||
finding and changing all the copies as well as the risk that a failure to
|
|
||||||
find one copy will result in inconsistencies within a program.
|
|
||||||
|
|
||||||
In C, the convention is to give header files names that end with `.h'.
|
|
||||||
|
|
||||||
Read more about using header files in official GCC documentation:
|
|
||||||
|
|
||||||
* Include Syntax
|
|
||||||
* Include Operation
|
|
||||||
* Once-Only Headers
|
|
||||||
* Computed Includes
|
|
||||||
|
|
||||||
https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
|
|
||||||
This directory is intended for project specific (private) libraries.
|
|
||||||
PlatformIO will compile them to static libraries and link into the executable file.
|
|
||||||
|
|
||||||
The source code of each library should be placed in a separate directory
|
|
||||||
("lib/your_library_name/[Code]").
|
|
||||||
|
|
||||||
For example, see the structure of the following example libraries `Foo` and `Bar`:
|
|
||||||
|
|
||||||
|--lib
|
|
||||||
| |
|
|
||||||
| |--Bar
|
|
||||||
| | |--docs
|
|
||||||
| | |--examples
|
|
||||||
| | |--src
|
|
||||||
| | |- Bar.c
|
|
||||||
| | |- Bar.h
|
|
||||||
| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html
|
|
||||||
| |
|
|
||||||
| |--Foo
|
|
||||||
| | |- Foo.c
|
|
||||||
| | |- Foo.h
|
|
||||||
| |
|
|
||||||
| |- README --> THIS FILE
|
|
||||||
|
|
|
||||||
|- platformio.ini
|
|
||||||
|--src
|
|
||||||
|- main.c
|
|
||||||
|
|
||||||
Example contents of `src/main.c` using Foo and Bar:
|
|
||||||
```
|
|
||||||
#include <Foo.h>
|
|
||||||
#include <Bar.h>
|
|
||||||
|
|
||||||
int main (void)
|
|
||||||
{
|
|
||||||
...
|
|
||||||
}
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
The PlatformIO Library Dependency Finder will find automatically dependent
|
|
||||||
libraries by scanning project source files.
|
|
||||||
|
|
||||||
More information about PlatformIO Library Dependency Finder
|
|
||||||
- https://docs.platformio.org/page/librarymanager/ldf.html
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
; PlatformIO Project Configuration File
|
|
||||||
;
|
|
||||||
; Build options: build flags, source filter
|
|
||||||
; Upload options: custom upload port, speed and extra flags
|
|
||||||
; Library options: dependencies, extra library storages
|
|
||||||
; Advanced options: extra scripting
|
|
||||||
;
|
|
||||||
; Please visit documentation for the other options and examples
|
|
||||||
; https://docs.platformio.org/page/projectconf.html
|
|
||||||
|
|
||||||
[env:seeed_xiao_esp32s3]
|
|
||||||
platform = espressif32
|
|
||||||
board = esp32-s3-devkitc-1
|
|
||||||
framework = arduino
|
|
||||||
monitor_speed = 115200
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
#define WIFI_SSID "wifissid"
|
|
||||||
#define WIFI_PASSWORD "wifipassword"
|
|
||||||
#define SERVER_URL "http://192.168.0.86:8080/buttonbox/press"
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
#include <Arduino.h>
|
|
||||||
#include <WiFi.h>
|
|
||||||
#include <HTTPClient.h>
|
|
||||||
|
|
||||||
#include <config.h>
|
|
||||||
|
|
||||||
// button pin defs
|
|
||||||
const int buttonPins[] = {15, 16, 17, 18};
|
|
||||||
const int buttonCount = sizeof(buttonPins) / sizeof(buttonPins[0]);
|
|
||||||
const int beeperPin = 13;
|
|
||||||
|
|
||||||
// Using INPUT_PULLUP means idle = HIGH, pressed = LOW (button to GND).
|
|
||||||
const int buttonPressedState = LOW;
|
|
||||||
const unsigned long debounceMs = 40;
|
|
||||||
const int toneDurationMs = 90;
|
|
||||||
|
|
||||||
// One tone per button (1-4).
|
|
||||||
const int buttonTonesHz[buttonCount] = {262, 330, 392, 523};
|
|
||||||
|
|
||||||
int lastStableState[buttonCount];
|
|
||||||
int lastReading[buttonCount];
|
|
||||||
unsigned long lastDebounceTime[buttonCount];
|
|
||||||
|
|
||||||
void PlayButtonTone(int buttonNumber) {
|
|
||||||
if (buttonNumber < 1 || buttonNumber > buttonCount) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
int frequencyHz = buttonTonesHz[buttonNumber - 1];
|
|
||||||
tone(beeperPin, frequencyHz, toneDurationMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
// button request function
|
|
||||||
bool SendButtonPressRequest(int buttonNumber) {
|
|
||||||
Serial.println("attempting to send button press");
|
|
||||||
bool success = false;
|
|
||||||
if (WiFi.status() == WL_CONNECTED) {
|
|
||||||
HTTPClient http;
|
|
||||||
http.begin(SERVER_URL);
|
|
||||||
http.addHeader("Content-Type", "text/plain");
|
|
||||||
|
|
||||||
int httpResponseCode = http.POST(String(buttonNumber));
|
|
||||||
|
|
||||||
if (httpResponseCode > 0) {
|
|
||||||
Serial.println("http response code: ");
|
|
||||||
Serial.print(httpResponseCode);
|
|
||||||
Serial.println(http.getString());
|
|
||||||
success = true;
|
|
||||||
} else {
|
|
||||||
Serial.println("HTTP ERROR!!! ");
|
|
||||||
Serial.print(httpResponseCode);
|
|
||||||
success = false;
|
|
||||||
}
|
|
||||||
http.end();
|
|
||||||
|
|
||||||
} else {
|
|
||||||
Serial.println("Not ocnnectec to wifi! cant send request!");
|
|
||||||
}
|
|
||||||
return success;
|
|
||||||
}
|
|
||||||
|
|
||||||
void setup() {
|
|
||||||
Serial.begin(115200);
|
|
||||||
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
|
|
||||||
while (WiFi.status() != WL_CONNECTED) {
|
|
||||||
delay(100);
|
|
||||||
Serial.print("wifi connecting... ");
|
|
||||||
}
|
|
||||||
Serial.println("WIFI CONNENCTED !!!! :3");
|
|
||||||
|
|
||||||
for (int i = 0; i < buttonCount; i++) {
|
|
||||||
pinMode(buttonPins[i], INPUT_PULLUP);
|
|
||||||
int initial = digitalRead(buttonPins[i]);
|
|
||||||
lastStableState[i] = initial;
|
|
||||||
lastReading[i] = initial;
|
|
||||||
lastDebounceTime[i] = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
pinMode(beeperPin, OUTPUT);
|
|
||||||
noTone(beeperPin);
|
|
||||||
}
|
|
||||||
|
|
||||||
void loop() {
|
|
||||||
unsigned long now = millis();
|
|
||||||
|
|
||||||
for (int i = 0; i < buttonCount; i++) {
|
|
||||||
int reading = digitalRead(buttonPins[i]);
|
|
||||||
|
|
||||||
if (reading != lastReading[i]) {
|
|
||||||
lastDebounceTime[i] = now;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((now - lastDebounceTime[i]) > debounceMs) {
|
|
||||||
if (reading != lastStableState[i]) {
|
|
||||||
lastStableState[i] = reading;
|
|
||||||
|
|
||||||
// Trigger once on press edge.
|
|
||||||
if (reading == buttonPressedState) {
|
|
||||||
if (SendButtonPressRequest(i + 1)) {
|
|
||||||
PlayButtonTone(i + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lastReading[i] = reading;
|
|
||||||
}
|
|
||||||
|
|
||||||
delay(5);
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
|
|
||||||
This directory is intended for PlatformIO Test Runner and project tests.
|
|
||||||
|
|
||||||
Unit Testing is a software testing method by which individual units of
|
|
||||||
source code, sets of one or more MCU program modules together with associated
|
|
||||||
control data, usage procedures, and operating procedures, are tested to
|
|
||||||
determine whether they are fit for use. Unit testing finds problems early
|
|
||||||
in the development cycle.
|
|
||||||
|
|
||||||
More information about PlatformIO Unit Testing:
|
|
||||||
- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html
|
|
||||||
Vendored
-22
@@ -1,22 +0,0 @@
|
|||||||
#configuration for roverd
|
|
||||||
name: dummy1
|
|
||||||
serverUrl: ws://127.0.0.1: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
|
|
||||||
nightVision:
|
|
||||||
enabled: false
|
|
||||||
Vendored
-22
@@ -1,22 +0,0 @@
|
|||||||
#configuration for roverd
|
|
||||||
name: dummy2
|
|
||||||
serverUrl: ws://127.0.0.1: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
|
|
||||||
nightVision:
|
|
||||||
enabled: false
|
|
||||||
Vendored
-22
@@ -1,22 +0,0 @@
|
|||||||
#configuration for roverd
|
|
||||||
name: dummy3
|
|
||||||
serverUrl: ws://127.0.0.1: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
|
|
||||||
nightVision:
|
|
||||||
enabled: false
|
|
||||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -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,88 +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
|
|
||||||
|
|
||||||
# Mix multiple playback clients in software with a fixed low-cost format.
|
|
||||||
pcm.dmixer {
|
|
||||||
type dmix
|
|
||||||
ipc_key 1024
|
|
||||||
ipc_perm 0666
|
|
||||||
slave {
|
|
||||||
pcm "hw:0,0"
|
|
||||||
format S16_LE
|
|
||||||
rate 16000
|
|
||||||
channels 1
|
|
||||||
period_time 0
|
|
||||||
period_size 1024
|
|
||||||
buffer_size 4096
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# TTS volume control (used by default playback path).
|
|
||||||
pcm.tts_softvol {
|
|
||||||
type softvol
|
|
||||||
slave.pcm "dmixer"
|
|
||||||
control {
|
|
||||||
name "TTSMaster"
|
|
||||||
card 0
|
|
||||||
}
|
|
||||||
min_dB -60.0
|
|
||||||
max_dB 12.0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Horn volume control.
|
|
||||||
pcm.horn_softvol {
|
|
||||||
type softvol
|
|
||||||
slave.pcm "dmixer"
|
|
||||||
control {
|
|
||||||
name "HornMaster"
|
|
||||||
card 0
|
|
||||||
}
|
|
||||||
min_dB -60.0
|
|
||||||
max_dB 12.0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Forwarded audio volume control.
|
|
||||||
pcm.forward_softvol {
|
|
||||||
type softvol
|
|
||||||
slave.pcm "dmixer"
|
|
||||||
control {
|
|
||||||
name "ForwardMaster"
|
|
||||||
card 0
|
|
||||||
}
|
|
||||||
min_dB -60.0
|
|
||||||
max_dB 12.0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Per-source playback PCMs.
|
|
||||||
pcm.tts {
|
|
||||||
type plug
|
|
||||||
slave.pcm "tts_softvol"
|
|
||||||
}
|
|
||||||
|
|
||||||
pcm.horn {
|
|
||||||
type plug
|
|
||||||
slave.pcm "horn_softvol"
|
|
||||||
}
|
|
||||||
|
|
||||||
pcm.forward {
|
|
||||||
type plug
|
|
||||||
slave.pcm "forward_softvol"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Capture alias used by rover config defaults.
|
|
||||||
pcm.rovermic {
|
|
||||||
type plug
|
|
||||||
slave.pcm "hw:0,0"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Defaults: TTS direct playback + raw capture on the HAT.
|
|
||||||
pcm.!default {
|
|
||||||
type asym
|
|
||||||
playback.pcm "tts"
|
|
||||||
capture.pcm "rovermic"
|
|
||||||
}
|
|
||||||
|
|
||||||
ctl.!default {
|
|
||||||
type hw
|
|
||||||
card 0
|
|
||||||
}
|
|
||||||
@@ -1,87 +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 start audio forward listener" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# shellcheck disable=SC1090
|
|
||||||
source "$ENV_FILE"
|
|
||||||
|
|
||||||
: "${AUDIO_FORWARD_URL:?AUDIO_FORWARD_URL not set in ${ENV_FILE}}"
|
|
||||||
PLAYBACK_DEVICE="${AUDIO_PLAYBACK_DEVICE:-forward}"
|
|
||||||
AUDIO_NORMALIZE_ENABLE="${AUDIO_NORMALIZE_ENABLE:-1}"
|
|
||||||
AUDIO_NORMALIZE_FILTER="${AUDIO_NORMALIZE_FILTER:-dynaudnorm=f=75:g=15:m=10:p=0.9,alimiter=limit=0.85:level=disabled}"
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
if command -v aplay >/dev/null 2>&1; then
|
|
||||||
APLAY_BIN_PATH="$(command -v aplay)"
|
|
||||||
else
|
|
||||||
echo "aplay not found; install it via apt install alsa-utils." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
LAST_FFMPEG_STATUS="unknown"
|
|
||||||
LAST_APLAY_STATUS="unknown"
|
|
||||||
|
|
||||||
run_pipeline() {
|
|
||||||
set +e
|
|
||||||
local -a ffmpeg_args=(
|
|
||||||
-hide_banner
|
|
||||||
-loglevel warning
|
|
||||||
-fflags nobuffer
|
|
||||||
-flags low_delay
|
|
||||||
-analyzeduration 200k
|
|
||||||
-probesize 32k
|
|
||||||
-i "${AUDIO_FORWARD_URL}"
|
|
||||||
-vn
|
|
||||||
)
|
|
||||||
|
|
||||||
if [[ "${AUDIO_NORMALIZE_ENABLE}" -ne 0 ]]; then
|
|
||||||
ffmpeg_args+=(-af "${AUDIO_NORMALIZE_FILTER}")
|
|
||||||
fi
|
|
||||||
|
|
||||||
ffmpeg_args+=(
|
|
||||||
-ac 1
|
|
||||||
-ar 16000
|
|
||||||
-f s16le
|
|
||||||
pipe:1
|
|
||||||
)
|
|
||||||
|
|
||||||
"${FFMPEG_BIN_PATH}" "${ffmpeg_args[@]}" \
|
|
||||||
| "${APLAY_BIN_PATH}" \
|
|
||||||
-q \
|
|
||||||
-D "${PLAYBACK_DEVICE}" \
|
|
||||||
-t raw \
|
|
||||||
-f S16_LE \
|
|
||||||
-r 16000 \
|
|
||||||
-c 1
|
|
||||||
local rc=$?
|
|
||||||
local -a statuses=("${PIPESTATUS[@]}")
|
|
||||||
LAST_FFMPEG_STATUS="${statuses[0]:-unknown}"
|
|
||||||
LAST_APLAY_STATUS="${statuses[1]:-unknown}"
|
|
||||||
set -e
|
|
||||||
return "${rc}"
|
|
||||||
}
|
|
||||||
|
|
||||||
trap 'kill 0 2>/dev/null' EXIT INT TERM
|
|
||||||
|
|
||||||
while true; do
|
|
||||||
if run_pipeline; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "Audio forward listener exited ffmpeg=${LAST_FFMPEG_STATUS:-unknown} aplay=${LAST_APLAY_STATUS:-unknown}, restarting in 2s..." >&2
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
@@ -1,66 +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() {
|
|
||||||
arecord -D "${AUDIO_DEVICE}" -f S32_LE -c "${AUDIO_CHANNELS}" -r "${AUDIO_RATE}" -B 65536 -F 2048 -q -t raw \
|
|
||||||
| "${FFMPEG_BIN_PATH}" \
|
|
||||||
-hide_banner \
|
|
||||||
-loglevel warning \
|
|
||||||
-fflags nobuffer \
|
|
||||||
-rtbufsize 0 \
|
|
||||||
-thread_queue_size 4096 \
|
|
||||||
-f s32le \
|
|
||||||
-ar "${AUDIO_RATE}" \
|
|
||||||
-ac "${AUDIO_CHANNELS}" \
|
|
||||||
-i pipe:0 \
|
|
||||||
-af "aresample=16000,pan=mono|c0=0.5*FL+0.5*FR,volume=25dB" \
|
|
||||||
-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}"
|
|
||||||
}
|
|
||||||
|
|
||||||
trap 'kill 0 2>/dev/null' EXIT INT TERM
|
|
||||||
|
|
||||||
while true; do
|
|
||||||
if run_pipeline; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "Audio-only publisher exited arecord=${PIPESTATUS[0]} ffmpeg=${PIPESTATUS[1]}, restarting in 2s..." >&2
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Keep history expansion off so values containing "!" are safe.
|
|
||||||
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
|
|
||||||
|
|
||||||
# Load KEY=VALUE pairs from ENV_FILE WITHOUT evaluating shell metacharacters.
|
|
||||||
# This makes URLs containing characters like '&' and '#!' safe without requiring quoting.
|
|
||||||
load_env_file() {
|
|
||||||
local content=""
|
|
||||||
|
|
||||||
if [[ -r "$ENV_FILE" ]]; then
|
|
||||||
content="$(cat "$ENV_FILE")"
|
|
||||||
elif command -v sudo >/dev/null 2>&1; then
|
|
||||||
# Try to read via sudo without prompting (useful when the service runs as an unprivileged user)
|
|
||||||
content="$(sudo -n cat "$ENV_FILE" 2>/dev/null || true)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -z "$content" ]]; then
|
|
||||||
echo "Cannot read ${ENV_FILE} (permission denied). Run as a user that can read it, or allow sudo -n for cat." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
local line key val
|
|
||||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
||||||
# Skip blank lines and full-line comments.
|
|
||||||
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
|
|
||||||
[[ "$line" =~ ^[[:space:]]*# ]] && continue
|
|
||||||
|
|
||||||
# Support optional leading 'export '
|
|
||||||
if [[ "$line" =~ ^[[:space:]]*export[[:space:]]+([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
|
|
||||||
key="${BASH_REMATCH[1]}"
|
|
||||||
val="${BASH_REMATCH[2]}"
|
|
||||||
elif [[ "$line" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
|
|
||||||
key="${BASH_REMATCH[1]}"
|
|
||||||
val="${BASH_REMATCH[2]}"
|
|
||||||
else
|
|
||||||
# Ignore anything that isn't a simple assignment.
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Trim leading/trailing whitespace in value.
|
|
||||||
val="${val#${val%%[![:space:]]*}}"
|
|
||||||
val="${val%${val##*[![:space:]]}}"
|
|
||||||
|
|
||||||
# If value is wrapped in matching single or double quotes, unwrap.
|
|
||||||
if [[ "$val" =~ ^\".*\"$ ]]; then
|
|
||||||
val="${val:1:${#val}-2}"
|
|
||||||
elif [[ "$val" =~ ^\'.*\'$ ]]; then
|
|
||||||
val="${val:1:${#val}-2}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Assign without evaluation.
|
|
||||||
printf -v "$key" '%s' "$val"
|
|
||||||
export "$key"
|
|
||||||
done <<< "$content"
|
|
||||||
}
|
|
||||||
|
|
||||||
load_env_file
|
|
||||||
: "${PUBLISH_URL:?PUBLISH_URL not set in ${ENV_FILE}}"
|
|
||||||
|
|
||||||
# Defaults tuned for OV5647: use 4:3 output and force the common 2x2 binned full-FOV mode.
|
|
||||||
VIDEO_WIDTH="640"
|
|
||||||
VIDEO_HEIGHT="480"
|
|
||||||
VIDEO_FPS="30"
|
|
||||||
VIDEO_BITRATE="${VIDEO_BITRATE:-3000000}"
|
|
||||||
VIDEO_INVERT="${VIDEO_INVERT:-1}"
|
|
||||||
VIDEO_SENSOR_MODE="${VIDEO_SENSOR_MODE:-1296:972}"
|
|
||||||
|
|
||||||
# Flip the camera 180deg by default; allow upright camera mounts via VIDEO_INVERT=0.
|
|
||||||
FLIP_ARGS=()
|
|
||||||
if [[ "${VIDEO_INVERT}" -ne 0 ]]; then
|
|
||||||
FLIP_ARGS=(--rotation 180)
|
|
||||||
fi
|
|
||||||
|
|
||||||
MODE_ARGS=()
|
|
||||||
if [[ -n "${VIDEO_SENSOR_MODE}" ]]; then
|
|
||||||
MODE_ARGS=(--mode "${VIDEO_SENSOR_MODE}")
|
|
||||||
fi
|
|
||||||
|
|
||||||
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 \
|
|
||||||
"${MODE_ARGS[@]}" \
|
|
||||||
--width "${VIDEO_WIDTH}" \
|
|
||||||
--height "${VIDEO_HEIGHT}" \
|
|
||||||
"${FLIP_ARGS[@]}" \
|
|
||||||
--framerate "${VIDEO_FPS}" \
|
|
||||||
--bitrate "${VIDEO_BITRATE}" \
|
|
||||||
--codec h264 \
|
|
||||||
--profile baseline \
|
|
||||||
--denoise auto \
|
|
||||||
--nopreview \
|
|
||||||
--metering centre \
|
|
||||||
--ev 0.1 \
|
|
||||||
--awb auto \
|
|
||||||
--saturation 0.6 \
|
|
||||||
--brightness 0 \
|
|
||||||
--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,264 +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/audio helpers and systemd units
|
|
||||||
* enable roverd.service and media publisher/listener services
|
|
||||||
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 audio-forward listener assets
|
|
||||||
install -D -o root -g root -m 0755 pi/bin/audio-forward-listener.sh /usr/local/bin/audio-forward-listener
|
|
||||||
install -m 0644 pi/systemd/audio-forward-listener.service /etc/systemd/system/audio-forward-listener.service
|
|
||||||
log "Installed audio-forward listener 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
|
|
||||||
AUDIO_FORWARD_URL=srt://192.168.0.86:9000?streamid=#!::r=CHANGE_ME-fwd,m=request&latency=10&mode=caller&transtype=live&pkt_size=1316
|
|
||||||
VIDEO_BITRATE=2000000
|
|
||||||
AUDIO_ENABLE=0
|
|
||||||
AUDIO_DEVICE=hw:0,0
|
|
||||||
AUDIO_PLAYBACK_DEVICE=forward
|
|
||||||
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
|
|
||||||
systemctl enable audio-forward-listener.service
|
|
||||||
if [[ $CONFIG_EXISTS -eq 1 ]]; then
|
|
||||||
systemctl restart roverd.service
|
|
||||||
systemctl restart video-publisher.service
|
|
||||||
systemctl restart audio-only-publisher.service
|
|
||||||
systemctl restart audio-forward-listener.service
|
|
||||||
log "Restarted roverd + media publishers/listener"
|
|
||||||
else
|
|
||||||
log "Skipped auto-start because config is the sample; edit $CONFIG_DEST then run: sudo systemctl restart roverd video-publisher audio-only-publisher audio-forward-listener"
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "Install complete"
|
|
||||||
@@ -1,20 +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
|
|
||||||
GOOS=$(GOOS) GOARCH=$(GOARCH) GOARM=$(GOARM) go build -trimpath -ldflags="-s -w" -o $(BIN_DIR)/hornverifier ./cmd/hornverifier
|
|
||||||
|
|
||||||
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 $(BIN_DIR)/hornverifier
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
package roverd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"os/exec"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AudioLevels struct {
|
|
||||||
HornGain float64
|
|
||||||
TTSGain float64
|
|
||||||
ForwardGain float64
|
|
||||||
}
|
|
||||||
|
|
||||||
func clampAudioGain(v float64) float64 {
|
|
||||||
if v < 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
if v > 4 {
|
|
||||||
return 4
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeAudioLevels(v AudioLevels) AudioLevels {
|
|
||||||
v.HornGain = clampAudioGain(v.HornGain)
|
|
||||||
v.TTSGain = clampAudioGain(v.TTSGain)
|
|
||||||
v.ForwardGain = clampAudioGain(v.ForwardGain)
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WSClient) getAudioLevels() AudioLevels {
|
|
||||||
c.audioMu.RLock()
|
|
||||||
defer c.audioMu.RUnlock()
|
|
||||||
return c.audioLevels
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WSClient) setAudioLevels(next AudioLevels) {
|
|
||||||
normalized := normalizeAudioLevels(next)
|
|
||||||
c.audioMu.Lock()
|
|
||||||
c.audioLevels = normalized
|
|
||||||
c.audioMu.Unlock()
|
|
||||||
c.applyAudioLevelsToMixer(normalized)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WSClient) handleAudioLevels(payload *audioLevelsPayload) error {
|
|
||||||
if payload == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
levels := c.getAudioLevels()
|
|
||||||
if payload.HornGain != nil {
|
|
||||||
levels.HornGain = clampAudioGain(*payload.HornGain)
|
|
||||||
}
|
|
||||||
if payload.TTSGain != nil {
|
|
||||||
levels.TTSGain = clampAudioGain(*payload.TTSGain)
|
|
||||||
}
|
|
||||||
if payload.ForwardGain != nil {
|
|
||||||
levels.ForwardGain = clampAudioGain(*payload.ForwardGain)
|
|
||||||
}
|
|
||||||
c.setAudioLevels(levels)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WSClient) applyAudioLevelsToMixer(levels AudioLevels) {
|
|
||||||
c.applyMixerGain("HornMaster", levels.HornGain)
|
|
||||||
c.applyMixerGain("TTSMaster", levels.TTSGain)
|
|
||||||
c.applyMixerGain("ForwardMaster", levels.ForwardGain)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WSClient) applyMixerGain(control string, gain float64) {
|
|
||||||
normalized := clampAudioGain(gain)
|
|
||||||
if normalized <= 0 {
|
|
||||||
if err := c.trySetMixerControl(control, "0%"); err != nil {
|
|
||||||
c.log.Printf("audio-levels: amixer mute %s failed: %v", control, err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert linear gain to dB, matching softvol max_dB=12.0 in /etc/asound.conf.
|
|
||||||
db := 20.0 * math.Log10(normalized)
|
|
||||||
if db > 12.0 {
|
|
||||||
db = 12.0
|
|
||||||
}
|
|
||||||
if db < -60.0 {
|
|
||||||
db = -60.0
|
|
||||||
}
|
|
||||||
|
|
||||||
// amixer treats a leading "-" value as an option; set via percent to avoid getopt ambiguity.
|
|
||||||
percent := int(math.Round((db + 60.0) / 72.0 * 100.0))
|
|
||||||
if percent < 0 {
|
|
||||||
percent = 0
|
|
||||||
}
|
|
||||||
if percent > 100 {
|
|
||||||
percent = 100
|
|
||||||
}
|
|
||||||
percentArg := fmt.Sprintf("%d%%", percent)
|
|
||||||
if err := c.trySetMixerControl(control, percentArg); err != nil {
|
|
||||||
c.log.Printf("audio-levels: amixer set %s=%s failed: %v", control, percentArg, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WSClient) trySetMixerControl(control, value string) error {
|
|
||||||
// Prefer the active ALSA default route; fall back to card index for compatibility.
|
|
||||||
candidates := [][]string{
|
|
||||||
{"-q", "-D", "default", "sset", control, value},
|
|
||||||
{"-q", "-c", "0", "sset", control, value},
|
|
||||||
}
|
|
||||||
var lastErr error
|
|
||||||
for _, args := range candidates {
|
|
||||||
out, err := exec.Command("amixer", args...).CombinedOutput()
|
|
||||||
if err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
lastErr = fmt.Errorf("%w (%s)", err, string(out))
|
|
||||||
}
|
|
||||||
return lastErr
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
package roverd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
autoChargeTimeout = 5 * 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,227 +0,0 @@
|
|||||||
//go:build !dummy
|
|
||||||
|
|
||||||
package roverd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"math"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
rpio "github.com/stianeikeland/go-rpio/v4"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CameraServo struct {
|
|
||||||
cfg CameraServoConfig
|
|
||||||
logger *log.Logger
|
|
||||||
pin rpio.Pin
|
|
||||||
mu sync.Mutex
|
|
||||||
currentAngle float64
|
|
||||||
desiredAngle float64
|
|
||||||
lastMove time.Time
|
|
||||||
moving bool
|
|
||||||
stopCh chan struct{}
|
|
||||||
closed bool
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxServoDegPerSec = 60.0
|
|
||||||
const servoStepInterval = 20 * time.Millisecond
|
|
||||||
const servoAngleEpsilon = 0.01
|
|
||||||
|
|
||||||
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,
|
|
||||||
stopCh: make(chan struct{}),
|
|
||||||
}
|
|
||||||
if err := servo.setAngleLocked(cfg.HomeAngle); err != nil {
|
|
||||||
rpio.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
logger.Printf("camera servo initialized on GPIO %d (%.1f..%.1f deg, %d..%d us, invert=%v)", cfg.Pin, cfg.MinAngle, cfg.MaxAngle, cfg.MinPulseUs, cfg.MaxPulseUs, cfg.Invert)
|
|
||||||
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()
|
|
||||||
if s.stopCh != nil {
|
|
||||||
close(s.stopCh)
|
|
||||||
s.stopCh = nil
|
|
||||||
}
|
|
||||||
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.desiredAngle = clamped
|
|
||||||
limited := s.rateLimitAngleLocked(clamped)
|
|
||||||
s.applyPulseLocked(s.angleToPulse(limited))
|
|
||||||
s.currentAngle = limited
|
|
||||||
if math.Abs(limited-s.desiredAngle) > servoAngleEpsilon {
|
|
||||||
s.startMoveLoopLocked()
|
|
||||||
}
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
clampedPulse := clampInt(micros, s.cfg.MinPulseUs, s.cfg.MaxPulseUs)
|
|
||||||
targetAngle := s.pulseToAngle(clampedPulse)
|
|
||||||
s.desiredAngle = targetAngle
|
|
||||||
limited := s.rateLimitAngleLocked(targetAngle)
|
|
||||||
s.applyPulseLocked(s.angleToPulse(limited))
|
|
||||||
s.currentAngle = limited
|
|
||||||
if math.Abs(limited-s.desiredAngle) > servoAngleEpsilon {
|
|
||||||
s.startMoveLoopLocked()
|
|
||||||
}
|
|
||||||
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) startMoveLoopLocked() {
|
|
||||||
if s.moving || s.stopCh == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.moving = true
|
|
||||||
go func() {
|
|
||||||
ticker := time.NewTicker(servoStepInterval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ticker.C:
|
|
||||||
s.mu.Lock()
|
|
||||||
if s.closed {
|
|
||||||
s.moving = false
|
|
||||||
s.mu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if math.Abs(s.currentAngle-s.desiredAngle) <= servoAngleEpsilon {
|
|
||||||
s.moving = false
|
|
||||||
s.mu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
limited := s.rateLimitAngleLocked(s.desiredAngle)
|
|
||||||
s.applyPulseLocked(s.angleToPulse(limited))
|
|
||||||
s.currentAngle = limited
|
|
||||||
s.mu.Unlock()
|
|
||||||
case <-s.stopCh:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CameraServo) rateLimitAngleLocked(target float64) float64 {
|
|
||||||
now := time.Now()
|
|
||||||
if s.lastMove.IsZero() {
|
|
||||||
s.lastMove = now
|
|
||||||
}
|
|
||||||
elapsed := now.Sub(s.lastMove).Seconds()
|
|
||||||
if elapsed <= 0 {
|
|
||||||
s.lastMove = now
|
|
||||||
return s.currentAngle
|
|
||||||
}
|
|
||||||
maxElapsed := servoStepInterval.Seconds()
|
|
||||||
if elapsed > maxElapsed {
|
|
||||||
elapsed = maxElapsed
|
|
||||||
}
|
|
||||||
maxDelta := maxServoDegPerSec * elapsed
|
|
||||||
delta := target - s.currentAngle
|
|
||||||
if math.Abs(delta) <= maxDelta {
|
|
||||||
s.lastMove = now
|
|
||||||
return target
|
|
||||||
}
|
|
||||||
if delta > 0 {
|
|
||||||
target = s.currentAngle + maxDelta
|
|
||||||
} else {
|
|
||||||
target = s.currentAngle - maxDelta
|
|
||||||
}
|
|
||||||
s.lastMove = now
|
|
||||||
return target
|
|
||||||
}
|
|
||||||
|
|
||||||
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))
|
|
||||||
if s.cfg.Invert {
|
|
||||||
norm = 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))
|
|
||||||
if s.cfg.Invert {
|
|
||||||
norm = 1 - norm
|
|
||||||
}
|
|
||||||
return s.cfg.MinAngle + norm*(s.cfg.MaxAngle-s.cfg.MinAngle)
|
|
||||||
}
|
|
||||||
@@ -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,11 +0,0 @@
|
|||||||
package roverd
|
|
||||||
|
|
||||||
func clampInt(value, min, max int) int {
|
|
||||||
if value < min {
|
|
||||||
return min
|
|
||||||
}
|
|
||||||
if value > max {
|
|
||||||
return max
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"encoding/binary"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"math"
|
|
||||||
"os/exec"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const twoPi = 2 * math.Pi
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
var (
|
|
||||||
device = flag.String("device", "", "ALSA device (empty = default)")
|
|
||||||
rate = flag.Int("rate", 48000, "Sample rate in Hz")
|
|
||||||
channels = flag.Int("channels", 1, "Number of audio channels")
|
|
||||||
duration = flag.Duration("duration", 2*time.Second, "Total horn duration")
|
|
||||||
freqsRaw = flag.String("freqs", "440,550,660", "Comma-separated frequencies in Hz")
|
|
||||||
volume = flag.Float64("volume", 0.25, "Output volume 0.0-1.0")
|
|
||||||
attack = flag.Duration("attack", 20*time.Millisecond, "Attack time")
|
|
||||||
release = flag.Duration("release", 60*time.Millisecond, "Release time")
|
|
||||||
)
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
if *rate <= 0 {
|
|
||||||
log.Fatalf("rate must be > 0 (got %d)", *rate)
|
|
||||||
}
|
|
||||||
if *channels <= 0 {
|
|
||||||
log.Fatalf("channels must be > 0 (got %d)", *channels)
|
|
||||||
}
|
|
||||||
if *duration <= 0 {
|
|
||||||
log.Fatalf("duration must be > 0 (got %s)", *duration)
|
|
||||||
}
|
|
||||||
if *volume <= 0 || *volume > 1.0 {
|
|
||||||
log.Fatalf("volume must be within (0,1] (got %.3f)", *volume)
|
|
||||||
}
|
|
||||||
if *attack < 0 || *release < 0 {
|
|
||||||
log.Fatalf("attack/release must be >= 0")
|
|
||||||
}
|
|
||||||
|
|
||||||
freqs, err := parseFreqs(*freqsRaw)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("parse freqs: %v", err)
|
|
||||||
}
|
|
||||||
if len(freqs) == 0 {
|
|
||||||
log.Fatal("no frequencies provided")
|
|
||||||
}
|
|
||||||
|
|
||||||
if *attack+*release > *duration {
|
|
||||||
log.Fatalf("attack+release must be <= duration (%s + %s > %s)", *attack, *release, *duration)
|
|
||||||
}
|
|
||||||
|
|
||||||
args := []string{"-q", "-f", "S16_LE", "-c", fmt.Sprintf("%d", *channels), "-r", fmt.Sprintf("%d", *rate), "-t", "raw"}
|
|
||||||
if *device != "" {
|
|
||||||
args = append(args, "-D", *device)
|
|
||||||
}
|
|
||||||
cmd := exec.Command("aplay", args...)
|
|
||||||
stdin, err := cmd.StdinPipe()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("aplay stdin: %v", err)
|
|
||||||
}
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
log.Fatalf("start aplay: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
writer := bufio.NewWriterSize(stdin, 32*1024)
|
|
||||||
if err := synthChord(writer, freqs, *rate, *channels, *duration, *volume, *attack, *release); err != nil {
|
|
||||||
_ = stdin.Close()
|
|
||||||
_ = cmd.Wait()
|
|
||||||
log.Fatalf("synth: %v", err)
|
|
||||||
}
|
|
||||||
if err := writer.Flush(); err != nil {
|
|
||||||
_ = stdin.Close()
|
|
||||||
_ = cmd.Wait()
|
|
||||||
log.Fatalf("flush: %v", err)
|
|
||||||
}
|
|
||||||
if err := stdin.Close(); err != nil {
|
|
||||||
_ = cmd.Wait()
|
|
||||||
log.Fatalf("close stdin: %v", err)
|
|
||||||
}
|
|
||||||
if err := cmd.Wait(); err != nil {
|
|
||||||
log.Fatalf("aplay failed: %v", err)
|
|
||||||
}
|
|
||||||
log.Print("Horn verification complete")
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseFreqs(raw string) ([]float64, error) {
|
|
||||||
trimmed := strings.TrimSpace(raw)
|
|
||||||
if trimmed == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
parts := strings.Split(trimmed, ",")
|
|
||||||
freqs := make([]float64, 0, len(parts))
|
|
||||||
for _, part := range parts {
|
|
||||||
part = strings.TrimSpace(part)
|
|
||||||
if part == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
value, err := strconv.ParseFloat(part, 64)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid freq %q", part)
|
|
||||||
}
|
|
||||||
if value <= 0 {
|
|
||||||
return nil, fmt.Errorf("freq must be > 0 (got %.3f)", value)
|
|
||||||
}
|
|
||||||
freqs = append(freqs, value)
|
|
||||||
}
|
|
||||||
return freqs, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func synthChord(writer *bufio.Writer, freqs []float64, rate, channels int, duration time.Duration, volume float64, attack, release time.Duration) error {
|
|
||||||
totalFrames := int(float64(rate) * duration.Seconds())
|
|
||||||
if totalFrames <= 0 {
|
|
||||||
return fmt.Errorf("duration too short")
|
|
||||||
}
|
|
||||||
|
|
||||||
phase := make([]float64, len(freqs))
|
|
||||||
increment := make([]float64, len(freqs))
|
|
||||||
for i, f := range freqs {
|
|
||||||
increment[i] = twoPi * f / float64(rate)
|
|
||||||
}
|
|
||||||
|
|
||||||
attackFrames := int(float64(rate) * attack.Seconds())
|
|
||||||
releaseFrames := int(float64(rate) * release.Seconds())
|
|
||||||
steadyFrames := totalFrames - attackFrames - releaseFrames
|
|
||||||
|
|
||||||
framesPerChunk := 512
|
|
||||||
buf := make([]byte, framesPerChunk*channels*2)
|
|
||||||
sampleIndex := 0
|
|
||||||
scale := volume / float64(len(freqs))
|
|
||||||
|
|
||||||
for framesLeft := totalFrames; framesLeft > 0; {
|
|
||||||
framesNow := framesPerChunk
|
|
||||||
if framesLeft < framesNow {
|
|
||||||
framesNow = framesLeft
|
|
||||||
}
|
|
||||||
for i := 0; i < framesNow; i++ {
|
|
||||||
env := envelope(sampleIndex, attackFrames, steadyFrames, releaseFrames)
|
|
||||||
sample := 0.0
|
|
||||||
for j := range freqs {
|
|
||||||
sample += sawFromPhase(phase[j])
|
|
||||||
phase[j] += increment[j]
|
|
||||||
if phase[j] > twoPi {
|
|
||||||
phase[j] -= twoPi
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sample *= scale * env
|
|
||||||
if sample > 1.0 {
|
|
||||||
sample = 1.0
|
|
||||||
} else if sample < -1.0 {
|
|
||||||
sample = -1.0
|
|
||||||
}
|
|
||||||
intSample := int16(sample * math.MaxInt16)
|
|
||||||
offset := i * channels * 2
|
|
||||||
for ch := 0; ch < channels; ch++ {
|
|
||||||
binary.LittleEndian.PutUint16(buf[offset+ch*2:], uint16(intSample))
|
|
||||||
}
|
|
||||||
sampleIndex++
|
|
||||||
}
|
|
||||||
if _, err := writer.Write(buf[:framesNow*channels*2]); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
framesLeft -= framesNow
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func envelope(sampleIndex, attackFrames, steadyFrames, releaseFrames int) float64 {
|
|
||||||
if attackFrames > 0 && sampleIndex < attackFrames {
|
|
||||||
return float64(sampleIndex) / float64(attackFrames)
|
|
||||||
}
|
|
||||||
if releaseFrames > 0 && sampleIndex >= attackFrames+steadyFrames {
|
|
||||||
relIndex := sampleIndex - (attackFrames + steadyFrames)
|
|
||||||
return float64(releaseFrames-relIndex) / float64(releaseFrames)
|
|
||||||
}
|
|
||||||
return 1.0
|
|
||||||
}
|
|
||||||
|
|
||||||
func sawFromPhase(phase float64) float64 {
|
|
||||||
return 2.0*(phase/twoPi) - 1.0
|
|
||||||
}
|
|
||||||
@@ -1,102 +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()
|
|
||||||
}
|
|
||||||
|
|
||||||
var nightVision *roverd.NightVisionLight
|
|
||||||
if cfg.NightVision.Enabled {
|
|
||||||
nightVision, err = roverd.NewNightVisionLight(cfg.NightVision, logger)
|
|
||||||
if err != nil {
|
|
||||||
logger.Fatalf("init night vision: %v", err)
|
|
||||||
}
|
|
||||||
defer nightVision.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
autoCharge := roverd.NewAutoChargeController(adapter, eventStream, logger)
|
|
||||||
go autoCharge.Run(ctx, sensorSamples)
|
|
||||||
|
|
||||||
client := roverd.NewWSClient(cfg, adapter, sensorFrames, eventStream, mediaSupervisor, cameraServo, nightVision, 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,109 +0,0 @@
|
|||||||
package roverd
|
|
||||||
|
|
||||||
type helloMessage struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Color string `json:"color,omitempty"`
|
|
||||||
Battery BatteryConfig `json:"battery"`
|
|
||||||
MaxWheelSpeed int `json:"maxWheelSpeed"`
|
|
||||||
Media MediaConfig `json:"media"`
|
|
||||||
CameraServo CameraServoConfig `json:"cameraServo"`
|
|
||||||
Audio AudioConfig `json:"audio"`
|
|
||||||
Horn HornConfig `json:"horn"`
|
|
||||||
NightVision NightVisionConfig `json:"nightVision"`
|
|
||||||
Private PrivateConfig `json:"private"`
|
|
||||||
}
|
|
||||||
|
|
||||||
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"`
|
|
||||||
Horn *hornPayload `json:"horn,omitempty"`
|
|
||||||
AudioLevels *audioLevelsPayload `json:"audioLevels,omitempty"`
|
|
||||||
NightVision *nightVisionPayload `json:"nightVision,omitempty"`
|
|
||||||
Song *songPayload `json:"song,omitempty"`
|
|
||||||
Reboot *rebootPayload `json:"reboot,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 hornPayload struct {
|
|
||||||
Action string `json:"action"`
|
|
||||||
Waveform string `json:"waveform,omitempty"`
|
|
||||||
Freqs []float64 `json:"freqs,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type audioLevelsPayload struct {
|
|
||||||
HornGain *float64 `json:"hornGain,omitempty"`
|
|
||||||
TTSGain *float64 `json:"ttsGain,omitempty"`
|
|
||||||
ForwardGain *float64 `json:"forwardGain,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type nightVisionPayload struct {
|
|
||||||
Action string `json:"action"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type songPayload struct {
|
|
||||||
Slot *int `json:"slot,omitempty"`
|
|
||||||
Notes []songNote `json:"notes"`
|
|
||||||
Loop bool `json:"loop,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type songNote struct {
|
|
||||||
Note int `json:"note"`
|
|
||||||
Duration int `json:"duration"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type rebootPayload struct {
|
|
||||||
DelayMs int `json:"delayMs,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ackMessage struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
ID string `json:"id"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
@@ -1,471 +0,0 @@
|
|||||||
package roverd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"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"`
|
|
||||||
PlaybackDevice string `yaml:"playbackDevice" json:"playbackDevice,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 HornConfig struct {
|
|
||||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
|
||||||
Volume float64 `yaml:"volume" json:"-"`
|
|
||||||
SampleRate int `yaml:"sampleRate" json:"-"`
|
|
||||||
Channels int `yaml:"channels" json:"-"`
|
|
||||||
Device string `yaml:"device" json:"-"`
|
|
||||||
SineGain float64 `yaml:"sineGain" json:"-"`
|
|
||||||
SawGain float64 `yaml:"sawGain" json:"-"`
|
|
||||||
MaxDuration Duration `yaml:"maxDuration" json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type MediaConfig struct {
|
|
||||||
PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"`
|
|
||||||
AudioPublishURL string `yaml:"audioPublishUrl" json:"audioPublishUrl,omitempty"`
|
|
||||||
AudioForwardURL string `yaml:"audioForwardUrl" json:"audioForwardUrl,omitempty"`
|
|
||||||
PublishPort int `yaml:"publishPort" json:"-"`
|
|
||||||
CameraInverted bool `yaml:"cameraInverted" 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"`
|
|
||||||
Invert bool `yaml:"invert" json:"invert"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type NightVisionConfig struct {
|
|
||||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
|
||||||
GPIOPin int `yaml:"gpioPin" json:"gpioPin"`
|
|
||||||
GPIOChip string `yaml:"gpioChip" json:"gpioChip"`
|
|
||||||
InitialOn bool `yaml:"initialOn" json:"initialOn"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AutoSideBrushConfig struct {
|
|
||||||
Enabled bool `yaml:"enabled"`
|
|
||||||
Speed int `yaml:"speed"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PrivateConfig struct {
|
|
||||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
|
||||||
Safety PrivateSafetyConfig `yaml:"safety" json:"safety"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PrivateSafetyConfig struct {
|
|
||||||
SpeedLimitEnabled bool `yaml:"speedLimitEnabled" json:"speedLimitEnabled"`
|
|
||||||
SpeedLimitMaxWheelMMs int `yaml:"speedLimitMaxWheelSpeed" json:"speedLimitMaxWheelSpeed"`
|
|
||||||
HardOvercurrentEnabled bool `yaml:"hardOvercurrentEnabled" json:"hardOvercurrentEnabled"`
|
|
||||||
OvercurrentStopMs int `yaml:"overcurrentStopMs" json:"overcurrentStopMs"`
|
|
||||||
HardBumpEnabled bool `yaml:"hardBumpEnabled" json:"hardBumpEnabled"`
|
|
||||||
BumpBackoffSpeed int `yaml:"bumpBackoffSpeed" json:"bumpBackoffSpeed"`
|
|
||||||
BumpBackoffMs int `yaml:"bumpBackoffMs" json:"bumpBackoffMs"`
|
|
||||||
CliffEnabled bool `yaml:"cliffEnabled" json:"cliffEnabled"`
|
|
||||||
CliffBackoffSpeed int `yaml:"cliffBackoffSpeed" json:"cliffBackoffSpeed"`
|
|
||||||
CliffBackoffMs int `yaml:"cliffBackoffMs" json:"cliffBackoffMs"`
|
|
||||||
TriggerCooldownMs int `yaml:"triggerCooldownMs" json:"triggerCooldownMs"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
Name string `yaml:"name"`
|
|
||||||
Color string `yaml:"color" json:"color,omitempty"`
|
|
||||||
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"`
|
|
||||||
Horn HornConfig `yaml:"horn"`
|
|
||||||
NightVision NightVisionConfig `yaml:"nightVision" json:"nightVision"`
|
|
||||||
AutoSideBrush AutoSideBrushConfig `yaml:"autoSideBrush"`
|
|
||||||
Private PrivateConfig `yaml:"private" json:"private"`
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
CameraInverted: true,
|
|
||||||
HealthInterval: Duration{Duration: 30 * time.Second},
|
|
||||||
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",
|
|
||||||
PlaybackDevice: "forward",
|
|
||||||
SampleRate: 48000,
|
|
||||||
Channels: 2,
|
|
||||||
Bitrate: 24000,
|
|
||||||
TTSEnabled: false,
|
|
||||||
DefaultEngine: "flite",
|
|
||||||
DefaultVoice: "rms",
|
|
||||||
DefaultPitch: 50,
|
|
||||||
},
|
|
||||||
Horn: HornConfig{
|
|
||||||
Enabled: false,
|
|
||||||
Volume: 0.25,
|
|
||||||
SampleRate: 48000,
|
|
||||||
Channels: 1,
|
|
||||||
SineGain: 1.0,
|
|
||||||
SawGain: 0.7,
|
|
||||||
MaxDuration: Duration{Duration: 10000 * time.Millisecond},
|
|
||||||
},
|
|
||||||
NightVision: NightVisionConfig{
|
|
||||||
Enabled: true,
|
|
||||||
GPIOPin: 22,
|
|
||||||
GPIOChip: "gpiochip0",
|
|
||||||
InitialOn: true,
|
|
||||||
},
|
|
||||||
AutoSideBrush: AutoSideBrushConfig{
|
|
||||||
Enabled: true,
|
|
||||||
Speed: 20,
|
|
||||||
},
|
|
||||||
Private: PrivateConfig{
|
|
||||||
Enabled: false,
|
|
||||||
Safety: PrivateSafetyConfig{
|
|
||||||
SpeedLimitEnabled: false,
|
|
||||||
SpeedLimitMaxWheelMMs: 250,
|
|
||||||
HardOvercurrentEnabled: false,
|
|
||||||
OvercurrentStopMs: 300,
|
|
||||||
HardBumpEnabled: false,
|
|
||||||
BumpBackoffSpeed: 250,
|
|
||||||
BumpBackoffMs: 350,
|
|
||||||
CliffEnabled: false,
|
|
||||||
CliffBackoffSpeed: 250,
|
|
||||||
CliffBackoffMs: 500,
|
|
||||||
TriggerCooldownMs: 800,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if cfg.Name == "" {
|
|
||||||
return nil, errors.New("missing name")
|
|
||||||
}
|
|
||||||
normalizedColor, err := normalizeHexColor(cfg.Color)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
cfg.Color = normalizedColor
|
|
||||||
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.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 cfg.Media.AudioForwardURL == "" {
|
|
||||||
derived, err := deriveReadURL(cfg.ServerURL, cfg.Name+"-fwd", cfg.Media.PublishPort)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("derive audioForwardUrl: %w", err)
|
|
||||||
}
|
|
||||||
cfg.Media.AudioForwardURL = derived
|
|
||||||
}
|
|
||||||
if err := validateServoConfig(&cfg.CameraServo); err != nil {
|
|
||||||
return nil, fmt.Errorf("cameraServo: %w", err)
|
|
||||||
}
|
|
||||||
if err := validateNightVisionConfig(&cfg.NightVision); err != nil {
|
|
||||||
return nil, fmt.Errorf("nightVision: %w", err)
|
|
||||||
}
|
|
||||||
validateAudioConfig(&cfg.Audio)
|
|
||||||
validateHornConfig(&cfg.Horn)
|
|
||||||
validateAutoSideBrushConfig(&cfg.AutoSideBrush)
|
|
||||||
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 {
|
|
||||||
return errors.New("minPulseUs/maxPulseUs invalid")
|
|
||||||
}
|
|
||||||
if cfg.MinPulseUs == cfg.MaxPulseUs {
|
|
||||||
return errors.New("minPulseUs/maxPulseUs cannot be equal")
|
|
||||||
}
|
|
||||||
if cfg.MinPulseUs > cfg.MaxPulseUs {
|
|
||||||
cfg.MinPulseUs, cfg.MaxPulseUs = cfg.MaxPulseUs, cfg.MinPulseUs
|
|
||||||
cfg.Invert = !cfg.Invert
|
|
||||||
}
|
|
||||||
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.PlaybackDevice == "" || cfg.PlaybackDevice == "default" {
|
|
||||||
cfg.PlaybackDevice = "forward"
|
|
||||||
}
|
|
||||||
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 validateHornConfig(cfg *HornConfig) {
|
|
||||||
if cfg.Volume <= 0 {
|
|
||||||
cfg.Volume = 0.25
|
|
||||||
}
|
|
||||||
if cfg.Volume > 1 {
|
|
||||||
cfg.Volume = 1
|
|
||||||
}
|
|
||||||
if cfg.SampleRate <= 0 {
|
|
||||||
cfg.SampleRate = 48000
|
|
||||||
}
|
|
||||||
if cfg.Channels <= 0 {
|
|
||||||
cfg.Channels = 1
|
|
||||||
}
|
|
||||||
if cfg.SineGain <= 0 {
|
|
||||||
cfg.SineGain = 1.0
|
|
||||||
}
|
|
||||||
if cfg.SawGain <= 0 {
|
|
||||||
cfg.SawGain = 0.7
|
|
||||||
}
|
|
||||||
if cfg.MaxDuration.Duration <= 0 {
|
|
||||||
cfg.MaxDuration = Duration{Duration: 1200 * time.Millisecond}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateNightVisionConfig(cfg *NightVisionConfig) error {
|
|
||||||
if !cfg.Enabled {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if cfg.GPIOPin <= 0 {
|
|
||||||
return errors.New("gpioPin must be > 0")
|
|
||||||
}
|
|
||||||
if cfg.GPIOChip == "" {
|
|
||||||
cfg.GPIOChip = "gpiochip0"
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateAutoSideBrushConfig(cfg *AutoSideBrushConfig) {
|
|
||||||
if cfg.Speed == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cfg.Speed = clampInt(cfg.Speed, -127, 127)
|
|
||||||
}
|
|
||||||
|
|
||||||
func derivePublishURL(serverURL, streamName string, port int) (string, error) {
|
|
||||||
return deriveSRTURL(serverURL, streamName, port, "publish")
|
|
||||||
}
|
|
||||||
|
|
||||||
func deriveReadURL(serverURL, streamName string, port int) (string, error) {
|
|
||||||
return deriveSRTURL(serverURL, streamName, port, "request")
|
|
||||||
}
|
|
||||||
|
|
||||||
func deriveSRTURL(serverURL, streamName string, port int, mode string) (string, error) {
|
|
||||||
if streamName == "" {
|
|
||||||
return "", errors.New("missing stream name for publishUrl")
|
|
||||||
}
|
|
||||||
if mode == "" {
|
|
||||||
mode = "publish"
|
|
||||||
}
|
|
||||||
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=%s&latency=10&mode=caller&transtype=live&pkt_size=1316", host, port, escaped, mode), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var hexColorRe = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`)
|
|
||||||
|
|
||||||
func normalizeHexColor(raw string) (string, error) {
|
|
||||||
trimmed := strings.TrimSpace(raw)
|
|
||||||
if trimmed == "" {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
if !hexColorRe.MatchString(trimmed) {
|
|
||||||
return "", fmt.Errorf("color must be #RRGGBB, got %q", raw)
|
|
||||||
}
|
|
||||||
return strings.ToUpper(trimmed), 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,270 +0,0 @@
|
|||||||
package roverd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"encoding/binary"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"math"
|
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
hornAttack = 20 * time.Millisecond
|
|
||||||
hornRelease = 60 * time.Millisecond
|
|
||||||
)
|
|
||||||
|
|
||||||
type HornSynth struct {
|
|
||||||
cfg HornConfig
|
|
||||||
log *log.Logger
|
|
||||||
gain float64
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
stop chan struct{}
|
|
||||||
active bool
|
|
||||||
proc *exec.Cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewHornSynth(cfg HornConfig, logger *log.Logger) *HornSynth {
|
|
||||||
return &HornSynth{
|
|
||||||
cfg: cfg,
|
|
||||||
log: logger,
|
|
||||||
gain: 1.0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HornSynth) SetGlobalGain(gain float64) {
|
|
||||||
h.mu.Lock()
|
|
||||||
defer h.mu.Unlock()
|
|
||||||
h.gain = clampAudioGain(gain)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HornSynth) HandlePayload(payload *hornPayload) error {
|
|
||||||
if payload == nil {
|
|
||||||
return fmt.Errorf("horn payload required")
|
|
||||||
}
|
|
||||||
action := strings.ToLower(strings.TrimSpace(payload.Action))
|
|
||||||
switch action {
|
|
||||||
case "start", "on", "honk":
|
|
||||||
waveform := strings.ToLower(strings.TrimSpace(payload.Waveform))
|
|
||||||
if waveform != "sine" && waveform != "saw" {
|
|
||||||
waveform = "saw"
|
|
||||||
}
|
|
||||||
freqs := sanitizeHornFreqs(payload.Freqs)
|
|
||||||
if len(freqs) == 0 {
|
|
||||||
h.Stop()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return h.Start(waveform, freqs)
|
|
||||||
case "stop", "off":
|
|
||||||
h.Stop()
|
|
||||||
return nil
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unsupported horn action: %s", payload.Action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HornSynth) Start(waveform string, freqs []float64) error {
|
|
||||||
h.mu.Lock()
|
|
||||||
if h.active {
|
|
||||||
h.mu.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
stop := make(chan struct{})
|
|
||||||
h.stop = stop
|
|
||||||
h.active = true
|
|
||||||
h.mu.Unlock()
|
|
||||||
|
|
||||||
go h.run(waveform, freqs, stop)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HornSynth) Stop() {
|
|
||||||
h.mu.Lock()
|
|
||||||
if !h.active {
|
|
||||||
h.mu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
stop := h.stop
|
|
||||||
proc := h.proc
|
|
||||||
h.stop = nil
|
|
||||||
h.proc = nil
|
|
||||||
h.active = false
|
|
||||||
h.mu.Unlock()
|
|
||||||
|
|
||||||
if stop != nil {
|
|
||||||
close(stop)
|
|
||||||
}
|
|
||||||
if proc != nil && proc.Process != nil {
|
|
||||||
_ = proc.Process.Kill()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HornSynth) run(waveform string, freqs []float64, stop <-chan struct{}) {
|
|
||||||
rate := h.cfg.SampleRate
|
|
||||||
if rate <= 0 {
|
|
||||||
rate = 48000
|
|
||||||
}
|
|
||||||
channels := h.cfg.Channels
|
|
||||||
if channels <= 0 {
|
|
||||||
channels = 1
|
|
||||||
}
|
|
||||||
volume := h.cfg.Volume
|
|
||||||
if volume <= 0 {
|
|
||||||
volume = 0.25
|
|
||||||
}
|
|
||||||
if volume > 1 {
|
|
||||||
volume = 1
|
|
||||||
}
|
|
||||||
h.mu.Lock()
|
|
||||||
gain := h.gain
|
|
||||||
h.mu.Unlock()
|
|
||||||
volume *= gain
|
|
||||||
|
|
||||||
device := strings.TrimSpace(h.cfg.Device)
|
|
||||||
if device == "" {
|
|
||||||
device = "horn"
|
|
||||||
}
|
|
||||||
args := []string{"-q", "-D", device, "-f", "S16_LE", "-c", fmt.Sprintf("%d", channels), "-r", fmt.Sprintf("%d", rate), "-t", "raw"}
|
|
||||||
cmd := exec.Command("aplay", args...)
|
|
||||||
stdin, err := cmd.StdinPipe()
|
|
||||||
if err != nil {
|
|
||||||
h.log.Printf("horn: aplay stdin failed: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
h.log.Printf("horn: aplay start failed: %v", err)
|
|
||||||
_ = stdin.Close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
h.mu.Lock()
|
|
||||||
if h.active {
|
|
||||||
h.proc = cmd
|
|
||||||
}
|
|
||||||
h.mu.Unlock()
|
|
||||||
|
|
||||||
writer := bufio.NewWriterSize(stdin, 32*1024)
|
|
||||||
maxFrames := 0
|
|
||||||
if h.cfg.MaxDuration.Duration > 0 {
|
|
||||||
maxFrames = int(float64(rate) * h.cfg.MaxDuration.Duration.Seconds())
|
|
||||||
}
|
|
||||||
if err := h.synthLoop(writer, waveform, freqs, rate, channels, volume, maxFrames, stop); err != nil {
|
|
||||||
h.log.Printf("horn: synth failed: %v", err)
|
|
||||||
}
|
|
||||||
_ = writer.Flush()
|
|
||||||
_ = stdin.Close()
|
|
||||||
if err := cmd.Wait(); err != nil {
|
|
||||||
h.log.Printf("horn: aplay exit: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
h.mu.Lock()
|
|
||||||
if h.proc == cmd {
|
|
||||||
h.proc = nil
|
|
||||||
}
|
|
||||||
h.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HornSynth) synthLoop(writer *bufio.Writer, waveform string, freqs []float64, rate, channels int, volume float64, maxFrames int, stop <-chan struct{}) error {
|
|
||||||
phase := make([]float64, len(freqs))
|
|
||||||
increment := make([]float64, len(freqs))
|
|
||||||
for i, f := range freqs {
|
|
||||||
increment[i] = 2 * math.Pi * f / float64(rate)
|
|
||||||
}
|
|
||||||
attackFrames := int(float64(rate) * hornAttack.Seconds())
|
|
||||||
releaseFrames := int(float64(rate) * hornRelease.Seconds())
|
|
||||||
framesPerChunk := 512
|
|
||||||
buf := make([]byte, framesPerChunk*channels*2)
|
|
||||||
scale := volume / float64(len(freqs))
|
|
||||||
if waveform == "sine" {
|
|
||||||
scale *= h.cfg.SineGain
|
|
||||||
} else {
|
|
||||||
scale *= h.cfg.SawGain
|
|
||||||
}
|
|
||||||
|
|
||||||
stopRequested := false
|
|
||||||
releaseStart := -1
|
|
||||||
sampleIndex := 0
|
|
||||||
|
|
||||||
for {
|
|
||||||
if !stopRequested {
|
|
||||||
select {
|
|
||||||
case <-stop:
|
|
||||||
stopRequested = true
|
|
||||||
releaseStart = sampleIndex
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for i := 0; i < framesPerChunk; i++ {
|
|
||||||
if maxFrames > 0 && sampleIndex >= maxFrames && !stopRequested {
|
|
||||||
stopRequested = true
|
|
||||||
releaseStart = sampleIndex
|
|
||||||
}
|
|
||||||
env := 1.0
|
|
||||||
if attackFrames > 0 && sampleIndex < attackFrames {
|
|
||||||
env = float64(sampleIndex) / float64(attackFrames)
|
|
||||||
} else if stopRequested && releaseFrames > 0 {
|
|
||||||
relIndex := sampleIndex - releaseStart
|
|
||||||
if relIndex >= releaseFrames {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
env = float64(releaseFrames-relIndex) / float64(releaseFrames)
|
|
||||||
} else if stopRequested {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
sample := 0.0
|
|
||||||
for j := range freqs {
|
|
||||||
switch waveform {
|
|
||||||
case "sine":
|
|
||||||
sample += math.Sin(phase[j])
|
|
||||||
default:
|
|
||||||
sample += sawFromPhase(phase[j])
|
|
||||||
}
|
|
||||||
phase[j] += increment[j]
|
|
||||||
if phase[j] > 2*math.Pi {
|
|
||||||
phase[j] -= 2 * math.Pi
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sample *= scale * env
|
|
||||||
if sample > 1.0 {
|
|
||||||
sample = 1.0
|
|
||||||
} else if sample < -1.0 {
|
|
||||||
sample = -1.0
|
|
||||||
}
|
|
||||||
intSample := int16(sample * math.MaxInt16)
|
|
||||||
offset := i * channels * 2
|
|
||||||
for ch := 0; ch < channels; ch++ {
|
|
||||||
binary.LittleEndian.PutUint16(buf[offset+ch*2:], uint16(intSample))
|
|
||||||
}
|
|
||||||
sampleIndex++
|
|
||||||
}
|
|
||||||
if _, err := writer.Write(buf); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sanitizeHornFreqs(freqs []float64) []float64 {
|
|
||||||
if len(freqs) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make([]float64, 0, 4)
|
|
||||||
for _, f := range freqs {
|
|
||||||
if len(out) >= 4 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if f <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, f)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func sawFromPhase(phase float64) float64 {
|
|
||||||
return 2.0*(phase/(2*math.Pi)) - 1.0
|
|
||||||
}
|
|
||||||
@@ -1,74 +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)
|
|
||||||
}
|
|
||||||
if media.AudioForwardURL != "" {
|
|
||||||
fmt.Fprintf(&buf, "AUDIO_FORWARD_URL=%s\n", media.AudioForwardURL)
|
|
||||||
}
|
|
||||||
if media.VideoWidth > 0 {
|
|
||||||
fmt.Fprintf(&buf, "VIDEO_WIDTH=%d\n", media.VideoWidth)
|
|
||||||
}
|
|
||||||
if media.VideoHeight > 0 {
|
|
||||||
fmt.Fprintf(&buf, "VIDEO_HEIGHT=%d\n", media.VideoHeight)
|
|
||||||
}
|
|
||||||
if media.VideoFPS > 0 {
|
|
||||||
fmt.Fprintf(&buf, "VIDEO_FPS=%d\n", media.VideoFPS)
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&buf, "VIDEO_BITRATE=%d\n", media.VideoBitrate)
|
|
||||||
fmt.Fprintf(&buf, "VIDEO_INVERT=%d\n", boolToInt(media.CameraInverted))
|
|
||||||
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)
|
|
||||||
playbackDevice := audio.PlaybackDevice
|
|
||||||
if playbackDevice == "" {
|
|
||||||
playbackDevice = "forward"
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&buf, "AUDIO_PLAYBACK_DEVICE=%s\n", playbackDevice)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
//go:build !dummy
|
|
||||||
|
|
||||||
package roverd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
gpiocdev "github.com/warthog618/go-gpiocdev"
|
|
||||||
)
|
|
||||||
|
|
||||||
type NightVisionLight struct {
|
|
||||||
cfg NightVisionConfig
|
|
||||||
logger *log.Logger
|
|
||||||
line *gpiocdev.Line
|
|
||||||
mu sync.Mutex
|
|
||||||
on bool
|
|
||||||
closed bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewNightVisionLight(cfg NightVisionConfig, logger *log.Logger) (*NightVisionLight, error) {
|
|
||||||
if !cfg.Enabled {
|
|
||||||
return nil, fmt.Errorf("night vision disabled")
|
|
||||||
}
|
|
||||||
chip := cfg.GPIOChip
|
|
||||||
if chip == "" {
|
|
||||||
chip = "gpiochip0"
|
|
||||||
}
|
|
||||||
initial := 0
|
|
||||||
if cfg.InitialOn {
|
|
||||||
initial = 1
|
|
||||||
}
|
|
||||||
line, err := gpiocdev.RequestLine(
|
|
||||||
chip,
|
|
||||||
cfg.GPIOPin,
|
|
||||||
gpiocdev.AsOutput(initial),
|
|
||||||
gpiocdev.WithConsumer("roverd-nightvision"),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("gpio request: %w", err)
|
|
||||||
}
|
|
||||||
nv := &NightVisionLight{
|
|
||||||
cfg: cfg,
|
|
||||||
logger: logger,
|
|
||||||
line: line,
|
|
||||||
on: cfg.InitialOn,
|
|
||||||
}
|
|
||||||
logger.Printf("night vision LED on GPIO %d (initial=%v)", cfg.GPIOPin, cfg.InitialOn)
|
|
||||||
return nv, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (n *NightVisionLight) Close() {
|
|
||||||
n.mu.Lock()
|
|
||||||
defer n.mu.Unlock()
|
|
||||||
if n.closed {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = n.line.SetValue(boolToGPIO(n.on))
|
|
||||||
n.line.Close()
|
|
||||||
n.closed = true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (n *NightVisionLight) HandleAction(action string) error {
|
|
||||||
n.mu.Lock()
|
|
||||||
defer n.mu.Unlock()
|
|
||||||
if n.closed {
|
|
||||||
return fmt.Errorf("night vision controller closed")
|
|
||||||
}
|
|
||||||
act := strings.ToLower(strings.TrimSpace(action))
|
|
||||||
switch act {
|
|
||||||
case "", "toggle":
|
|
||||||
return n.setLocked(!n.on)
|
|
||||||
case "on":
|
|
||||||
return n.setLocked(true)
|
|
||||||
case "off":
|
|
||||||
return n.setLocked(false)
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unknown action %q", action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (n *NightVisionLight) NightVisionOn() bool {
|
|
||||||
n.mu.Lock()
|
|
||||||
defer n.mu.Unlock()
|
|
||||||
return !n.on
|
|
||||||
}
|
|
||||||
|
|
||||||
func (n *NightVisionLight) setLocked(on bool) error {
|
|
||||||
if err := n.line.SetValue(boolToGPIO(on)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
n.on = on
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func boolToGPIO(value bool) int {
|
|
||||||
if value {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
//go:build dummy
|
|
||||||
|
|
||||||
package roverd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
)
|
|
||||||
|
|
||||||
type NightVisionLight struct{}
|
|
||||||
|
|
||||||
func NewNightVisionLight(cfg NightVisionConfig, logger *log.Logger) (*NightVisionLight, error) {
|
|
||||||
return nil, fmt.Errorf("night vision not supported in dummy build")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (n *NightVisionLight) Close() {}
|
|
||||||
|
|
||||||
func (n *NightVisionLight) HandleAction(action string) error {
|
|
||||||
return fmt.Errorf("night vision not supported in dummy build")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (n *NightVisionLight) NightVisionOn() bool {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
Binary file not shown.
@@ -1,82 +0,0 @@
|
|||||||
# Sample configuration for roverd
|
|
||||||
name: roomba-alpha
|
|
||||||
color: "#4DB6AC"
|
|
||||||
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
|
|
||||||
audioForwardUrl: srt://192.168.0.86:9000?streamid=#!::r=roomba-alpha-fwd,m=request&latency=10&mode=caller&transtype=live&pkt_size=1316
|
|
||||||
publishPort: 9000
|
|
||||||
# Default assumes camera is mounted upside down; set false for upright mounts.
|
|
||||||
cameraInverted: true
|
|
||||||
videoBitrate: 2000000
|
|
||||||
manage: true
|
|
||||||
service: video-publisher.service
|
|
||||||
healthUrl: ""
|
|
||||||
healthInterval: 30s
|
|
||||||
cameraServo:
|
|
||||||
enabled: false
|
|
||||||
pin: 12
|
|
||||||
freqHz: 50
|
|
||||||
cycleLen: 20000
|
|
||||||
minPulseUs: 900
|
|
||||||
maxPulseUs: 2100
|
|
||||||
invert: false
|
|
||||||
minAngle: -15
|
|
||||||
maxAngle: 30
|
|
||||||
homeAngle: 0
|
|
||||||
nudgeDegrees: 2
|
|
||||||
allowRawPulse: false
|
|
||||||
audio:
|
|
||||||
captureEnabled: false
|
|
||||||
captureDevice: hw:0,0
|
|
||||||
playbackDevice: forward
|
|
||||||
sampleRate: 48000
|
|
||||||
channels: 2
|
|
||||||
bitrate: 24000
|
|
||||||
ttsEnabled: false
|
|
||||||
defaultEngine: flite
|
|
||||||
defaultVoice: rms
|
|
||||||
defaultPitch: 50
|
|
||||||
horn:
|
|
||||||
enabled: false
|
|
||||||
volume: 0.25
|
|
||||||
sampleRate: 48000
|
|
||||||
channels: 1
|
|
||||||
sineGain: 1.0
|
|
||||||
sawGain: 0.7
|
|
||||||
maxDuration: 1.2s
|
|
||||||
nightVision:
|
|
||||||
enabled: true
|
|
||||||
gpioPin: 22
|
|
||||||
gpioChip: gpiochip0
|
|
||||||
initialOn: true
|
|
||||||
autoSideBrush:
|
|
||||||
enabled: true
|
|
||||||
speed: 20
|
|
||||||
private:
|
|
||||||
enabled: false
|
|
||||||
safety:
|
|
||||||
speedLimitEnabled: false
|
|
||||||
speedLimitMaxWheelSpeed: 250
|
|
||||||
hardOvercurrentEnabled: false
|
|
||||||
overcurrentStopMs: 300
|
|
||||||
hardBumpEnabled: false
|
|
||||||
bumpBackoffSpeed: 250
|
|
||||||
bumpBackoffMs: 350
|
|
||||||
cliffEnabled: false
|
|
||||||
cliffBackoffSpeed: 250
|
|
||||||
cliffBackoffMs: 500
|
|
||||||
triggerCooldownMs: 800
|
|
||||||
@@ -1,50 +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
|
|
||||||
autoSideBrush:
|
|
||||||
enabled: true
|
|
||||||
speed: 20
|
|
||||||
private:
|
|
||||||
enabled: false
|
|
||||||
safety:
|
|
||||||
speedLimitEnabled: false
|
|
||||||
speedLimitMaxWheelSpeed: 250
|
|
||||||
hardOvercurrentEnabled: false
|
|
||||||
overcurrentStopMs: 300
|
|
||||||
hardBumpEnabled: false
|
|
||||||
bumpBackoffSpeed: 250
|
|
||||||
bumpBackoffMs: 350
|
|
||||||
cliffEnabled: false
|
|
||||||
cliffBackoffSpeed: 250
|
|
||||||
cliffBackoffMs: 500
|
|
||||||
triggerCooldownMs: 800
|
|
||||||
@@ -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 = 50 * 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,124 +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) StartOI() error {
|
|
||||||
return s.write([]byte{128})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SerialAdapter) SeekDock() error {
|
|
||||||
return s.write([]byte{143})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SerialAdapter) PlaySong(slot int, notes []songNote) error {
|
|
||||||
if len(notes) == 0 {
|
|
||||||
return fmt.Errorf("song requires at least one note")
|
|
||||||
}
|
|
||||||
if len(notes) > 16 {
|
|
||||||
return fmt.Errorf("song supports up to 16 notes, got %d", len(notes))
|
|
||||||
}
|
|
||||||
if slot < 0 || slot > 4 {
|
|
||||||
return fmt.Errorf("song slot must be 0-4")
|
|
||||||
}
|
|
||||||
|
|
||||||
payload := []byte{140, byte(slot), byte(len(notes))}
|
|
||||||
for _, n := range notes {
|
|
||||||
note := clampInt(n.Note, 31, 127)
|
|
||||||
duration := clampInt(n.Duration, 1, 255)
|
|
||||||
payload = append(payload, byte(note), byte(duration))
|
|
||||||
}
|
|
||||||
if err := s.write(payload); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.write([]byte{141, byte(slot)})
|
|
||||||
}
|
|
||||||
@@ -1,68 +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) StartOI() error {
|
|
||||||
s.log.Printf("[dummy] start OI")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SerialAdapter) SeekDock() error {
|
|
||||||
s.log.Printf("[dummy] seek dock")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SerialAdapter) PlaySong(slot int, notes []songNote) error {
|
|
||||||
s.log.Printf("[dummy] play song slot=%d notes=%v", slot, notes)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
package roverd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (c *WSClient) handleTTSPayload(ctx context.Context, 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)
|
|
||||||
|
|
||||||
runCtx, cancel := context.WithTimeout(ctx, 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(runCtx, "espeak", args...)
|
|
||||||
case "flite", "f":
|
|
||||||
args := []string{}
|
|
||||||
if voice != "" {
|
|
||||||
args = append(args, "-voice", voice)
|
|
||||||
}
|
|
||||||
args = append(args, "-t", text)
|
|
||||||
cmd = exec.CommandContext(runCtx, "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,622 +0,0 @@
|
|||||||
package roverd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os/exec"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"nhooyr.io/websocket"
|
|
||||||
)
|
|
||||||
|
|
||||||
type WSClient struct {
|
|
||||||
cfg *Config
|
|
||||||
adapter *SerialAdapter
|
|
||||||
sensorFrames <-chan []byte
|
|
||||||
events chan RoverEvent
|
|
||||||
media *MediaSupervisor
|
|
||||||
servo *CameraServo
|
|
||||||
horn *HornSynth
|
|
||||||
nightVision *NightVisionLight
|
|
||||||
log *log.Logger
|
|
||||||
recoverMu sync.Mutex
|
|
||||||
recovering bool
|
|
||||||
ttsQueue chan *ttsPayload
|
|
||||||
lastAux motorPWMPayload
|
|
||||||
autoSideOn bool
|
|
||||||
connMu sync.Mutex
|
|
||||||
connected bool
|
|
||||||
disconnectT *time.Timer
|
|
||||||
rebootT *time.Timer
|
|
||||||
seekIssued bool
|
|
||||||
rebootIssued bool
|
|
||||||
audioLevels AudioLevels
|
|
||||||
audioMu sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, events chan RoverEvent, media *MediaSupervisor, servo *CameraServo, nightVision *NightVisionLight, logger *log.Logger) *WSClient {
|
|
||||||
var ttsQueue chan *ttsPayload
|
|
||||||
if cfg.Audio.TTSEnabled {
|
|
||||||
ttsQueue = make(chan *ttsPayload, 2)
|
|
||||||
}
|
|
||||||
var horn *HornSynth
|
|
||||||
if cfg.Horn.Enabled {
|
|
||||||
horn = NewHornSynth(cfg.Horn, logger)
|
|
||||||
}
|
|
||||||
client := &WSClient{
|
|
||||||
cfg: cfg,
|
|
||||||
adapter: adapter,
|
|
||||||
sensorFrames: frames,
|
|
||||||
events: events,
|
|
||||||
media: media,
|
|
||||||
servo: servo,
|
|
||||||
horn: horn,
|
|
||||||
nightVision: nightVision,
|
|
||||||
log: logger,
|
|
||||||
ttsQueue: ttsQueue,
|
|
||||||
audioLevels: AudioLevels{
|
|
||||||
HornGain: 1.0,
|
|
||||||
TTSGain: 1.0,
|
|
||||||
ForwardGain: 1.0,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
client.applyAudioLevelsToMixer(client.audioLevels)
|
|
||||||
return client
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WSClient) Run(ctx context.Context) error {
|
|
||||||
dialCtx, cancel := context.WithTimeout(ctx, dialTimeout)
|
|
||||||
conn, _, err := websocket.Dial(dialCtx, c.cfg.ServerURL, nil)
|
|
||||||
cancel()
|
|
||||||
if err != nil {
|
|
||||||
c.markDisconnected()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
c.markConnected()
|
|
||||||
defer conn.Close(websocket.StatusInternalError, "closed")
|
|
||||||
defer c.markDisconnected()
|
|
||||||
|
|
||||||
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, 2)
|
|
||||||
c.startTTSWorker(ctx)
|
|
||||||
go func() {
|
|
||||||
errCh <- c.readLoop(ctx, conn)
|
|
||||||
}()
|
|
||||||
go func() {
|
|
||||||
if err := c.keepalive(ctx, conn); err != nil {
|
|
||||||
errCh <- err
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
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,
|
|
||||||
Color: c.cfg.Color,
|
|
||||||
Battery: c.cfg.Battery,
|
|
||||||
MaxWheelSpeed: c.cfg.MaxWheelMMs,
|
|
||||||
Media: c.cfg.Media,
|
|
||||||
CameraServo: c.cfg.CameraServo,
|
|
||||||
Audio: c.cfg.Audio,
|
|
||||||
Horn: c.cfg.Horn,
|
|
||||||
NightVision: c.cfg.NightVision,
|
|
||||||
Private: c.cfg.Private,
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
if err := c.adapter.DriveDirect(left, right); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
c.applyAutoSideBrush(left, right)
|
|
||||||
return nil
|
|
||||||
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)
|
|
||||||
c.lastAux = motorPWMPayload{Main: main, Side: side, Vacuum: vac}
|
|
||||||
c.autoSideOn = false
|
|
||||||
return c.adapter.MotorPWM(main, side, vac)
|
|
||||||
case msg.SensorStream != nil:
|
|
||||||
if msg.SensorStream.Enable {
|
|
||||||
return c.adapter.StartSensorStream(defaultStreamPackets)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
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.enqueueTTS(msg.TTS)
|
|
||||||
case msg.Horn != nil:
|
|
||||||
if c.horn == nil {
|
|
||||||
return fmt.Errorf("horn disabled")
|
|
||||||
}
|
|
||||||
return c.horn.HandlePayload(msg.Horn)
|
|
||||||
case msg.AudioLevels != nil:
|
|
||||||
return c.handleAudioLevels(msg.AudioLevels)
|
|
||||||
case msg.NightVision != nil:
|
|
||||||
if c.nightVision == nil {
|
|
||||||
return fmt.Errorf("night vision disabled")
|
|
||||||
}
|
|
||||||
if err := c.nightVision.HandleAction(msg.NightVision.Action); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
c.emitEvent("nightVision.state", map[string]any{
|
|
||||||
"nightVisionOn": c.nightVision.NightVisionOn(),
|
|
||||||
})
|
|
||||||
return nil
|
|
||||||
case msg.Song != nil:
|
|
||||||
slot := 0
|
|
||||||
if msg.Song.Slot != nil {
|
|
||||||
slot = clampInt(*msg.Song.Slot, 0, 4)
|
|
||||||
}
|
|
||||||
return c.adapter.PlaySong(slot, msg.Song.Notes)
|
|
||||||
case msg.Reboot != nil || msg.Type == "reboot":
|
|
||||||
return c.handleRebootCommand(msg.Reboot)
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unsupported command type: %s", msg.Type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WSClient) handleRebootCommand(payload *rebootPayload) error {
|
|
||||||
if err := c.adapter.DriveDirect(0, 0); err != nil {
|
|
||||||
return fmt.Errorf("stop drive before reboot: %w", err)
|
|
||||||
}
|
|
||||||
if err := c.adapter.MotorPWM(0, 0, 0); err != nil {
|
|
||||||
return fmt.Errorf("stop aux motors before reboot: %w", err)
|
|
||||||
}
|
|
||||||
if err := c.adapter.StartOI(); err != nil {
|
|
||||||
return fmt.Errorf("enter passive mode before reboot: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
delay := 300 * time.Millisecond
|
|
||||||
if payload != nil && payload.DelayMs > 0 {
|
|
||||||
delay = time.Duration(clampInt(payload.DelayMs, 50, 5000)) * time.Millisecond
|
|
||||||
}
|
|
||||||
|
|
||||||
c.connMu.Lock()
|
|
||||||
if c.rebootIssued {
|
|
||||||
c.connMu.Unlock()
|
|
||||||
return fmt.Errorf("reboot already pending")
|
|
||||||
}
|
|
||||||
c.rebootIssued = true
|
|
||||||
c.connMu.Unlock()
|
|
||||||
|
|
||||||
c.emitEvent("system.rebooting", map[string]any{
|
|
||||||
"source": "remoteCommand",
|
|
||||||
"delayMs": delay.Milliseconds(),
|
|
||||||
})
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
time.Sleep(delay)
|
|
||||||
c.log.Printf("rebooting pi after remote reboot command")
|
|
||||||
cmd := exec.Command("systemctl", "reboot")
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
c.log.Printf("reboot command failed: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WSClient) applyAutoSideBrush(left, right int) {
|
|
||||||
if c.cfg == nil || !c.cfg.AutoSideBrush.Enabled {
|
|
||||||
if c.autoSideOn {
|
|
||||||
c.autoSideOn = false
|
|
||||||
if err := c.adapter.MotorPWM(c.lastAux.Main, c.lastAux.Side, c.lastAux.Vacuum); err != nil {
|
|
||||||
c.log.Printf("auto side brush stop failed: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
moving := left != 0 || right != 0
|
|
||||||
if !moving {
|
|
||||||
if c.autoSideOn {
|
|
||||||
c.autoSideOn = false
|
|
||||||
if err := c.adapter.MotorPWM(c.lastAux.Main, c.lastAux.Side, c.lastAux.Vacuum); err != nil {
|
|
||||||
c.log.Printf("auto side brush stop failed: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.lastAux.Side != 0 {
|
|
||||||
c.autoSideOn = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
autoSpeed := clampInt(c.cfg.AutoSideBrush.Speed, -127, 127)
|
|
||||||
if autoSpeed == 0 {
|
|
||||||
c.autoSideOn = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if c.autoSideOn {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.adapter.MotorPWM(c.lastAux.Main, autoSpeed, c.lastAux.Vacuum); err != nil {
|
|
||||||
c.log.Printf("auto side brush start failed: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.autoSideOn = true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WSClient) enqueueTTS(payload *ttsPayload) error {
|
|
||||||
if c.ttsQueue == nil {
|
|
||||||
return fmt.Errorf("tts disabled")
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case c.ttsQueue <- payload:
|
|
||||||
return nil
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("tts busy")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WSClient) startTTSWorker(ctx context.Context) {
|
|
||||||
if c.ttsQueue == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case payload := <-c.ttsQueue:
|
|
||||||
if payload == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := c.handleTTSPayload(ctx, payload); err != nil {
|
|
||||||
c.log.Printf("tts failed: %v", err)
|
|
||||||
c.emitEvent("tts.error", map[string]any{"error": err.Error()})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
const (
|
|
||||||
sensorSilenceTimeout = 5 * time.Second
|
|
||||||
sensorRecoveryCooldown = 3 * time.Second
|
|
||||||
sensorCommandPause = 50 * time.Millisecond
|
|
||||||
)
|
|
||||||
|
|
||||||
timer := time.NewTimer(sensorSilenceTimeout)
|
|
||||||
defer timer.Stop()
|
|
||||||
|
|
||||||
resetTimer := func() {
|
|
||||||
if !timer.Stop() {
|
|
||||||
select {
|
|
||||||
case <-timer.C:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
timer.Reset(sensorSilenceTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
lastRecovery := time.Time{}
|
|
||||||
lastFrame := time.Now()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-timer.C:
|
|
||||||
now := time.Now()
|
|
||||||
if !lastRecovery.IsZero() && now.Sub(lastRecovery) < sensorRecoveryCooldown {
|
|
||||||
resetTimer()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
idleFor := now.Sub(lastFrame)
|
|
||||||
if idleFor < 0 {
|
|
||||||
idleFor = sensorSilenceTimeout
|
|
||||||
}
|
|
||||||
|
|
||||||
c.recoverSensorStream(idleFor, sensorCommandPause)
|
|
||||||
lastRecovery = now
|
|
||||||
resetTimer()
|
|
||||||
case frame := <-c.sensorFrames:
|
|
||||||
lastFrame = time.Now()
|
|
||||||
resetTimer()
|
|
||||||
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 (c *WSClient) emitEvent(event string, data map[string]any) {
|
|
||||||
if c.events == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case c.events <- RoverEvent{
|
|
||||||
Type: "event",
|
|
||||||
Event: event,
|
|
||||||
Ts: time.Now().UnixMilli(),
|
|
||||||
Data: data,
|
|
||||||
}:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const disconnectSeekDelay = time.Minute
|
|
||||||
const disconnectRebootDelay = 6 * time.Minute
|
|
||||||
const dialTimeout = 10 * time.Second
|
|
||||||
const pingInterval = 15 * time.Second
|
|
||||||
const pingTimeout = 5 * time.Second
|
|
||||||
|
|
||||||
func (c *WSClient) keepalive(ctx context.Context, conn *websocket.Conn) error {
|
|
||||||
ticker := time.NewTicker(pingInterval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
case <-ticker.C:
|
|
||||||
pingCtx, cancel := context.WithTimeout(ctx, pingTimeout)
|
|
||||||
err := conn.Ping(pingCtx)
|
|
||||||
cancel()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WSClient) markConnected() {
|
|
||||||
c.connMu.Lock()
|
|
||||||
c.connected = true
|
|
||||||
c.seekIssued = false
|
|
||||||
c.rebootIssued = false
|
|
||||||
if c.disconnectT != nil {
|
|
||||||
c.disconnectT.Stop()
|
|
||||||
c.disconnectT = nil
|
|
||||||
}
|
|
||||||
if c.rebootT != nil {
|
|
||||||
c.rebootT.Stop()
|
|
||||||
c.rebootT = nil
|
|
||||||
}
|
|
||||||
c.connMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WSClient) markDisconnected() {
|
|
||||||
c.connMu.Lock()
|
|
||||||
if c.connected {
|
|
||||||
c.connected = false
|
|
||||||
}
|
|
||||||
if c.disconnectT == nil {
|
|
||||||
c.disconnectT = time.AfterFunc(disconnectSeekDelay, c.handleDisconnectTimeout)
|
|
||||||
}
|
|
||||||
if c.rebootT == nil {
|
|
||||||
c.rebootT = time.AfterFunc(disconnectRebootDelay, c.handleRebootTimeout)
|
|
||||||
}
|
|
||||||
c.connMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WSClient) handleDisconnectTimeout() {
|
|
||||||
c.connMu.Lock()
|
|
||||||
if c.connected || c.seekIssued {
|
|
||||||
c.connMu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.seekIssued = true
|
|
||||||
c.connMu.Unlock()
|
|
||||||
|
|
||||||
if err := c.adapter.SeekDock(); err != nil {
|
|
||||||
c.log.Printf("seek dock on disconnect failed: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.log.Printf("seek dock issued after websocket disconnect")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WSClient) handleRebootTimeout() {
|
|
||||||
c.connMu.Lock()
|
|
||||||
if c.connected || c.rebootIssued {
|
|
||||||
c.connMu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.rebootIssued = true
|
|
||||||
c.connMu.Unlock()
|
|
||||||
|
|
||||||
c.log.Printf("rebooting pi after prolonged websocket disconnect")
|
|
||||||
cmd := exec.Command("systemctl", "reboot")
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
c.log.Printf("reboot command failed: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WSClient) recoverSensorStream(idleFor time.Duration, cmdPause time.Duration) {
|
|
||||||
c.recoverMu.Lock()
|
|
||||||
if c.recovering {
|
|
||||||
c.recoverMu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.recovering = true
|
|
||||||
c.recoverMu.Unlock()
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
c.recoverMu.Lock()
|
|
||||||
c.recovering = false
|
|
||||||
c.recoverMu.Unlock()
|
|
||||||
}()
|
|
||||||
|
|
||||||
c.emitEvent("sensorWatchdog.restart", map[string]any{
|
|
||||||
"idleMs": idleFor.Milliseconds(),
|
|
||||||
})
|
|
||||||
|
|
||||||
if err := c.adapter.StartOI(); err != nil {
|
|
||||||
c.log.Printf("watchdog start OI failed: %v", err)
|
|
||||||
c.emitEvent("sensorWatchdog.error", map[string]any{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if cmdPause > 0 {
|
|
||||||
time.Sleep(cmdPause)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.adapter.StartSensorStream(defaultStreamPackets); err != nil {
|
|
||||||
c.log.Printf("watchdog start stream failed: %v", err)
|
|
||||||
c.emitEvent("sensorWatchdog.error", map[string]any{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.emitEvent("sensorWatchdog.ok", map[string]any{
|
|
||||||
"idleMs": idleFor.Milliseconds(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func isModeOpcode(op byte) bool {
|
|
||||||
switch op {
|
|
||||||
case 128, 131, 132:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Rover Audio Forward Listener (SRT -> ALSA)
|
|
||||||
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-forward-listener
|
|
||||||
KillMode=control-group
|
|
||||||
TimeoutStopSec=5
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=2
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,18 +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
|
|
||||||
KillMode=control-group
|
|
||||||
TimeoutStopSec=5
|
|
||||||
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,14 +0,0 @@
|
|||||||
# main idea:
|
|
||||||
- stream audio from server to rovers
|
|
||||||
- users can either stream their mic from their browser
|
|
||||||
- users can also play audio files on the rover through the browser
|
|
||||||
- this is a VIP feature for verified users only
|
|
||||||
- gate in UI and in the server
|
|
||||||
|
|
||||||
## specifics
|
|
||||||
- only the current driver can play audio through a rover
|
|
||||||
- admins can enable / disable audio
|
|
||||||
- lockdown admins can adjust the volume for all rovers
|
|
||||||
- rovers are always listening for an audio stream from the server
|
|
||||||
- no transcoding allowed on-rover due to resources
|
|
||||||
- rovers are always local and cant be accessed from outside, no security is needed for audio streaming
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# button box mechanic
|
|
||||||
- a box with 4 buttons which sends web requests to the server when a button is pressed
|
|
||||||
- each button has a counter in the server
|
|
||||||
- when the counter's goal is met, the reward happens
|
|
||||||
|
|
||||||
## rewards
|
|
||||||
- theres a list of rewards in the server
|
|
||||||
- rewards create chaos in different ways using the physical things that already exist
|
|
||||||
- rewards have actions and a counter goal set per reward to assign value to better rewards
|
|
||||||
- when a reward is met, the button gets a new goal, reset counter, and new reward
|
|
||||||
|
|
||||||
## UI
|
|
||||||
- a new full-width panel above the room cameras in the main tab
|
|
||||||
- divided into columns, one for each of the 4 buttons
|
|
||||||
- each columns shows:
|
|
||||||
- button number
|
|
||||||
- current count
|
|
||||||
- reward and reward number
|
|
||||||
- column flashes when button gets upped
|
|
||||||
- column plays a sound when button gets upped. same tone as the button.
|
|
||||||
- match styling and layout rules of the rest of the ui
|
|
||||||
|
|
||||||
### reward ideas
|
|
||||||
1. dock panic
|
|
||||||
force all online rovers to run seek-dock immediately, interrupting whatever it was doing and causing sudden behavior change
|
|
||||||
2. camera whiplash
|
|
||||||
apply a short burst of random camera servo nudges on all rovers so the view jerks around rapidly for a moment
|
|
||||||
3. light strobe
|
|
||||||
toggle all configured room controls on and off repeatedly for 10 seconds
|
|
||||||
4. ghost typing spam
|
|
||||||
emit fake typing events into chat so users see rapid typing indicators from fake/ghost senders without actual messages
|
|
||||||
5. darkness
|
|
||||||
turn room lights off for 1 minute and force rover night-vision/headlight state off for that same window, then restore normal state
|
|
||||||
6. discord stalker ping
|
|
||||||
send a chaos alert message to the configured discord general channel and ping the configured stalker role
|
|
||||||
7. rogue event spam
|
|
||||||
push a burst of fake alert events with random titles/colors into the web ui alert feed for jump-scare style noise
|
|
||||||
8. mode jam
|
|
||||||
switch server mode to admin for a short timed window, set admin reason to a chaos message at activation, then restore previous mode and previous admin reason
|
|
||||||
9. assignment roulette
|
|
||||||
forcibly release current user rover assignments/drivers and let assignment logic re-place users, causing sudden rover ownership reshuffle
|
|
||||||
10. chat spam
|
|
||||||
spam random letters in chat as an injected spectator user with a random letter name
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# lift control
|
|
||||||
- control a lift up / down from the web UI
|
|
||||||
- for verified users only
|
|
||||||
- add new service in server for controlling the lift
|
|
||||||
- add new action in idle service, to raise the lift when idle triggers
|
|
||||||
- UI in VIP panel
|
|
||||||
- match UI styling of neato panel
|
|
||||||
- put below VIP panel
|
|
||||||
- have 2 buttons that get toggled between. down / up.
|
|
||||||
- lift is controlled through home assistant:
|
|
||||||
- lift shows up as two switches
|
|
||||||
- add spots for these 2 lift switches in server config
|
|
||||||
- one for up and one for down
|
|
||||||
- the way that they have to be operated is a little bit odd, examples:
|
|
||||||
- to raise the lift:
|
|
||||||
- turn down switch off
|
|
||||||
- wait 2 ish seconds
|
|
||||||
- turn up switch on
|
|
||||||
- to lower the lift:
|
|
||||||
- turn up switch off
|
|
||||||
- wait 2 ish seconds
|
|
||||||
- turn down switch on
|
|
||||||
@@ -1,386 +0,0 @@
|
|||||||
# Overseer v2 Detailed Architecture
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
This document is the durable architecture spec for Overseer v2 so design intent is not lost across chats, sessions, or contributors.
|
|
||||||
|
|
||||||
Overseer v2 is a **new, separate service** focused on controlling the room/experience, not just commenting on rover activity.
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
- Do not replace or delete `llmCommentaryService`.
|
|
||||||
- Do not run two LLM overseer systems simultaneously in normal operation.
|
|
||||||
- Do not rely on giant unbounded context windows.
|
|
||||||
|
|
||||||
## Legacy Service Position
|
|
||||||
- Keep `llmCommentaryService` as a preserved legacy service.
|
|
||||||
- Keep it disable-able via config.
|
|
||||||
- New v2 service should be the primary runtime when enabled.
|
|
||||||
- Legacy service remains available for fallback/testing.
|
|
||||||
|
|
||||||
## Core Product Direction
|
|
||||||
Overseer v2 is "control-first":
|
|
||||||
- It can act on room systems and chat.
|
|
||||||
- Chat is one capability, not the whole job.
|
|
||||||
- It should feel ominous/playful and intentional, not spammy.
|
|
||||||
|
|
||||||
High-level behavior goals:
|
|
||||||
- Not overbearing.
|
|
||||||
- Does things when it can make moments interesting.
|
|
||||||
- Always responds when directly addressed.
|
|
||||||
- Does not always obey.
|
|
||||||
- Does not ramble.
|
|
||||||
|
|
||||||
## Primary Inputs Overseer Must Understand
|
|
||||||
Overseer must see all three lanes every decision cycle:
|
|
||||||
|
|
||||||
1. People activity
|
|
||||||
- Who is active now.
|
|
||||||
- Who is driving what.
|
|
||||||
- Activity bursts vs quiet periods.
|
|
||||||
- Direct addresses toward Overseer.
|
|
||||||
|
|
||||||
2. Conversation context
|
|
||||||
- **Actual recent chat conversation** (human + bot messages), not only tags.
|
|
||||||
- Last N human messages (minimum 5).
|
|
||||||
- Last 1-2 bot messages for anti-repeat context.
|
|
||||||
|
|
||||||
3. World/control state
|
|
||||||
- Rover states/events.
|
|
||||||
- Lift state/cooldowns.
|
|
||||||
- Neato state/cooldowns.
|
|
||||||
- Home Assistant entity states/availability.
|
|
||||||
- Safety/lock policy state.
|
|
||||||
|
|
||||||
## Runtime Model Strategy (Hardware-Aware)
|
|
||||||
Given modest hardware and ~30s generation on `mistral-small:24b` today:
|
|
||||||
|
|
||||||
- Use one LLM system only.
|
|
||||||
- Use deterministic program logic gate before LLM calls.
|
|
||||||
- Keep LLM calls sparse and meaningful.
|
|
||||||
- Keep context bounded and compact.
|
|
||||||
|
|
||||||
### Why Not Tiny-LLM Gate
|
|
||||||
Default gate should be deterministic logic, not another LLM:
|
|
||||||
- less latency
|
|
||||||
- less complexity
|
|
||||||
- fewer failure modes
|
|
||||||
- lower cumulative compute
|
|
||||||
|
|
||||||
A tiny LLM gate can be reconsidered later only for borderline cases.
|
|
||||||
|
|
||||||
## Continuous Loop Design (Never Stops)
|
|
||||||
System runs continuously but in two stages:
|
|
||||||
|
|
||||||
1. Fast gate loop (1-3s)
|
|
||||||
- No LLM call.
|
|
||||||
- Evaluates trigger conditions.
|
|
||||||
- If no trigger: continue.
|
|
||||||
|
|
||||||
2. LLM decision loop (on trigger or heartbeat)
|
|
||||||
- Triggered by gate or max-interval heartbeat.
|
|
||||||
- Builds bounded conversational context + state update.
|
|
||||||
- Model decides and optionally calls tools.
|
|
||||||
|
|
||||||
### Trigger Examples
|
|
||||||
- Direct address to Overseer in chat.
|
|
||||||
- Chat burst / topic change / challenge language.
|
|
||||||
- High-signal world event (dock/undock/hazard/control transition).
|
|
||||||
- Heartbeat timeout reached (for ambient presence).
|
|
||||||
|
|
||||||
## Decision Modes
|
|
||||||
The orchestrator should normalize model behavior to one of:
|
|
||||||
- `SKIP`
|
|
||||||
- `CHAT`
|
|
||||||
- `ACTION`
|
|
||||||
- `ACTION+CHAT`
|
|
||||||
|
|
||||||
Meaning:
|
|
||||||
- `SKIP`: no output, no tool call.
|
|
||||||
- `CHAT`: post chat only.
|
|
||||||
- `ACTION`: run tool(s) only.
|
|
||||||
- `ACTION+CHAT`: run tool(s) and post chat.
|
|
||||||
|
|
||||||
## Tool Output vs Chat Output
|
|
||||||
These are separate channels by design:
|
|
||||||
- Tool calls are structured actions.
|
|
||||||
- Chat text is explicit messaging output.
|
|
||||||
- A tool call does not automatically produce chat.
|
|
||||||
|
|
||||||
This separation is required to avoid chat spam and keep control behavior deliberate.
|
|
||||||
|
|
||||||
## Conversational Context Format (Important)
|
|
||||||
Overseer should use:
|
|
||||||
- Stable `system` prompt with identity/rules/policy.
|
|
||||||
- Real rolling transcript (users + overseer).
|
|
||||||
- A compact `STATE_UPDATE` message each decision cycle.
|
|
||||||
|
|
||||||
### Bounded Window Rules
|
|
||||||
To protect latency:
|
|
||||||
- Keep rolling window bounded (for example 20-40 recent turns, or last 5-10 min).
|
|
||||||
- Always include last 5 human messages minimum.
|
|
||||||
- Include last 1-2 bot messages.
|
|
||||||
- Drop/summarize older content.
|
|
||||||
|
|
||||||
## Tool Availability Contract
|
|
||||||
Critical refinement: do not advertise unusable tools as available.
|
|
||||||
|
|
||||||
Each cycle split into:
|
|
||||||
1. `available_tools`
|
|
||||||
- callable now
|
|
||||||
|
|
||||||
2. `blocked_tools`
|
|
||||||
- not callable now with reason:
|
|
||||||
- cooldown
|
|
||||||
- busy
|
|
||||||
- unavailable/offline
|
|
||||||
- policy lock
|
|
||||||
- rate limit
|
|
||||||
|
|
||||||
Example:
|
|
||||||
- If Neato is cooling down, remove `neato_*` from `available_tools` and list under `blocked_tools` with remaining seconds.
|
|
||||||
|
|
||||||
This prevents wasted model turns and impossible tool attempts.
|
|
||||||
|
|
||||||
## Initial Tool Surface
|
|
||||||
Planned callable tools (subject to runtime availability):
|
|
||||||
- `chat_say(text)`
|
|
||||||
- `lift_up()` / `lift_down()`
|
|
||||||
- `neato_start()` / `neato_send_home()` / `neato_locate()` / `neato_clear_errors()`
|
|
||||||
- `ha_set_entity(entity_id, state)` for configured controllable entities
|
|
||||||
- memory tools:
|
|
||||||
- `memory_read()`
|
|
||||||
- `memory_write(slot, text)` where slot ∈ {1,2,3}
|
|
||||||
|
|
||||||
Optional later:
|
|
||||||
- controlled button-box count adjustment tool (strictly bounded and auditable)
|
|
||||||
|
|
||||||
## Persistent Memory (Tiny, Explicit)
|
|
||||||
Use tiny explicit memory store:
|
|
||||||
- exactly 3 slots
|
|
||||||
- string lines only
|
|
||||||
- explicit read/write via tools
|
|
||||||
- no autonomous unbounded memory growth
|
|
||||||
|
|
||||||
Model decides which slot to replace when writing.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Mode and Policy Locks (Tool Disabling Rules)
|
|
||||||
Overseer tool availability must respect global site mode and room-light policy locks.
|
|
||||||
|
|
||||||
Hard requirements:
|
|
||||||
- When site mode is `admin` or `lockdown`, disable Overseer action tools for:
|
|
||||||
- Neato controls (`neato_*`)
|
|
||||||
- Lift controls (`lift_*`)
|
|
||||||
- Room controls (`ha_set_entity` and related room-light toggles)
|
|
||||||
- When room lights are locked on by policy, disable room control tools even if site mode is otherwise open.
|
|
||||||
|
|
||||||
How this must appear to the model:
|
|
||||||
- Disabled tools must be removed from `available_tools`.
|
|
||||||
- Disabled tools must be present in `blocked_tools` with clear reason codes.
|
|
||||||
|
|
||||||
Recommended reason codes:
|
|
||||||
- `policy_lock:site_mode_admin`
|
|
||||||
- `policy_lock:site_mode_lockdown`
|
|
||||||
- `policy_lock:lights_locked_on`
|
|
||||||
|
|
||||||
Execution-time enforcement:
|
|
||||||
- Executor must re-check these locks immediately before executing any tool call.
|
|
||||||
- If a lock changed after context build, block execution and record a blocked action event.
|
|
||||||
|
|
||||||
## Safety and Control Guardrails
|
|
||||||
Must-have guardrails before enabling actions:
|
|
||||||
|
|
||||||
1. Hard allowlist
|
|
||||||
- Only approved tool names callable.
|
|
||||||
|
|
||||||
2. Tool-specific cooldowns
|
|
||||||
- e.g., lift/neato/HA each with independent cooldown rules.
|
|
||||||
|
|
||||||
3. Global action budget
|
|
||||||
- max actions per minute (or window).
|
|
||||||
|
|
||||||
4. Policy lock checks
|
|
||||||
- block actions during admin lock/safety states.
|
|
||||||
|
|
||||||
5. Execution-time validation
|
|
||||||
- arguments validated before invoking any control service.
|
|
||||||
|
|
||||||
6. Logging + audit trail
|
|
||||||
- every attempted/blocked/executed tool call recorded.
|
|
||||||
|
|
||||||
## Admin Debug UI Requirement
|
|
||||||
This is a core requirement.
|
|
||||||
|
|
||||||
Need a dedicated v2 admin panel that shows:
|
|
||||||
- enabled/running status
|
|
||||||
- loop phase (`idle`, `gate_check`, `awaiting_model`, `tool_exec`, `posted`, `failed`)
|
|
||||||
- last trigger reason
|
|
||||||
- last state-update payload snapshot
|
|
||||||
- model input summary
|
|
||||||
- model raw output + normalized decision mode
|
|
||||||
- tool calls attempted/executed/blocked with reasons
|
|
||||||
- cooldowns and remaining times
|
|
||||||
- recent action history
|
|
||||||
- last error/failure
|
|
||||||
|
|
||||||
### UI Switching Behavior
|
|
||||||
- If v2 enabled: show v2 panel.
|
|
||||||
- If legacy enabled: show legacy panel.
|
|
||||||
- If neither enabled: show disabled state.
|
|
||||||
|
|
||||||
Define a separate v2 status schema (do not overload legacy status object).
|
|
||||||
|
|
||||||
## Chat System Plumbing Changes
|
|
||||||
Keep the existing chat/Discord integration path and extend it minimally.
|
|
||||||
|
|
||||||
Decisions:
|
|
||||||
- Keep current internal bus-based chat flow as the canonical path (`chat:message` / `chat:typing`).
|
|
||||||
- Do not introduce a second parallel bridge path for v2.
|
|
||||||
- Add `bot: true|false` on chat message payloads.
|
|
||||||
- Overseer v2 must publish chat output through the same existing chat pipeline so Discord relay continues to work automatically.
|
|
||||||
- Keep legacy compatibility while migrating.
|
|
||||||
|
|
||||||
Goal:
|
|
||||||
- Any service can emit chat messages through one standardized path, with bot identity represented by `bot: true`.
|
|
||||||
|
|
||||||
## Example Message Topology Per LLM Decision
|
|
||||||
1. `system`: stable Overseer identity/rules/style/tool policy
|
|
||||||
2. `user`: compact `STATE_UPDATE`
|
|
||||||
3. transcript turns: recent chat + overseer messages
|
|
||||||
4. tool availability block (`available_tools`, `blocked_tools`)
|
|
||||||
5. model output: decision/tool calls/chat
|
|
||||||
6. executor: validates, executes tools, posts chat if applicable
|
|
||||||
7. append tool results and continue loop
|
|
||||||
|
|
||||||
## Implementation Phases
|
|
||||||
|
|
||||||
### Phase 0: Scaffolding
|
|
||||||
- Create new service module (suggested name: `overseerControlService`).
|
|
||||||
- Add config section and runtime enable switch.
|
|
||||||
- Keep legacy untouched.
|
|
||||||
|
|
||||||
### Phase 1: Observe-Only
|
|
||||||
- Build gate loop and context builder.
|
|
||||||
- Run model decisions with no real actions (dry-run tools).
|
|
||||||
- Log what would have happened.
|
|
||||||
|
|
||||||
### Phase 2: Chat + Safe Tools
|
|
||||||
- Enable chat output and lowest-risk tools.
|
|
||||||
- Verify anti-spam and direct-address behavior.
|
|
||||||
|
|
||||||
### Phase 3: Full Control Surface
|
|
||||||
- Enable lift/neato/HA with strict cooldowns and budgets.
|
|
||||||
- Add memory tool operations.
|
|
||||||
|
|
||||||
### Phase 4: Tuning
|
|
||||||
- Tune gate thresholds, cadence, cooldowns.
|
|
||||||
- Tune prompt style and anti-repeat behavior.
|
|
||||||
- Evaluate model alternatives only after gating/cadence tuning.
|
|
||||||
|
|
||||||
## Metrics To Track
|
|
||||||
- LLM calls per minute
|
|
||||||
- average LLM latency
|
|
||||||
- skipped vs acted decision ratio
|
|
||||||
- stale response rate (acted too late)
|
|
||||||
- blocked tool call rate and reasons
|
|
||||||
- action frequency per tool
|
|
||||||
- chat message frequency per minute
|
|
||||||
- repeated-line rate
|
|
||||||
|
|
||||||
## Rollback Plan
|
|
||||||
- Single config flip disables v2.
|
|
||||||
- Legacy overseer can be re-enabled without code rollback.
|
|
||||||
- Keep migration steps isolated and reversible.
|
|
||||||
|
|
||||||
## Practical Defaults (Starting Point)
|
|
||||||
- fast gate loop: 2s
|
|
||||||
- heartbeat model run: 30s
|
|
||||||
- min human chat context: 5 latest messages
|
|
||||||
- include latest bot messages: 2
|
|
||||||
- max tool calls per decision: 1 (start conservative)
|
|
||||||
- global action budget: low (start strict)
|
|
||||||
|
|
||||||
## Open Questions (Track During Build)
|
|
||||||
- Exact per-tool cooldown values for lift/neato/HA.
|
|
||||||
- Whether button-box count adjustment is worth enabling at all.
|
|
||||||
- Which model gives best tool reliability per watt on your machine.
|
|
||||||
- Ideal transcript window (turn-count vs time-window).
|
|
||||||
- Whether to include lightweight topic tags in addition to raw messages.
|
|
||||||
|
|
||||||
## One-Line Summary
|
|
||||||
Overseer v2 should be a separate, always-running, control-first orchestrator that uses deterministic gating + bounded real conversation context + dynamically available tools, with strict guardrails and a first-class debug UI.
|
|
||||||
|
|
||||||
## Naming Configuration Requirement
|
|
||||||
Overseer name must be configurable from server config.
|
|
||||||
|
|
||||||
Requirements:
|
|
||||||
- Add a config field for Overseer display/invocation name (for example under the v2 service config).
|
|
||||||
- The configured name must propagate everywhere it matters:
|
|
||||||
- in-chat bot nickname
|
|
||||||
- system prompt identity text
|
|
||||||
- direct-address recognition rules
|
|
||||||
- Prompt file should remain editable by non-code changes, so use a placeholder token in prompt text (for example `<NAME>`) and replace it at runtime.
|
|
||||||
- The prompt should still support fallback behavior if name config is missing (default name).
|
|
||||||
|
|
||||||
Implementation intent notes (future):
|
|
||||||
- Keep a single source of truth for name resolution (config + default).
|
|
||||||
- Avoid scattering hardcoded names in service code.
|
|
||||||
- If legacy and v2 coexist in codebase, ensure each service can use configured naming without breaking compatibility.
|
|
||||||
|
|
||||||
## Canonical "What the Bot Sees" Example
|
|
||||||
Use this as the concrete reference for message formatting per decision cycle.
|
|
||||||
|
|
||||||
```txt
|
|
||||||
[system]
|
|
||||||
You are <NAME>, a control-first room AI.
|
|
||||||
- You may CHAT and/or call tools.
|
|
||||||
- Respect safety/cooldowns/allowlists.
|
|
||||||
- Prefer restraint; avoid spam.
|
|
||||||
- Respond when directly addressed.
|
|
||||||
- If nothing meaningful changed, SKIP.
|
|
||||||
|
|
||||||
[user]
|
|
||||||
STATE_UPDATE
|
|
||||||
time: 2026-05-02T22:14:10-04:00
|
|
||||||
trigger: chat_burst
|
|
||||||
cooldowns: lift=ready, neato=12s, ha=ready
|
|
||||||
rovers:
|
|
||||||
- rover1: driving, driver=alex, docked=false
|
|
||||||
- rover2: driving, driver=sam, docked=false
|
|
||||||
- rover3: docked=true, charging=true
|
|
||||||
lift: down, busy=false
|
|
||||||
neato: connected=true, state=idle, charging=true
|
|
||||||
lights:
|
|
||||||
- shelf=on, bench=off, corner=on, color=purple, aux1=off, aux2=on
|
|
||||||
|
|
||||||
[user] alex: overseer you watching this drift?
|
|
||||||
[user] sam: dont kill my lights again
|
|
||||||
[assistant] <NAME>: Concrete lane stays lit. Earn the darkness.
|
|
||||||
[user] alex: i dare you to make this harder
|
|
||||||
[user] sam: bot pick a side
|
|
||||||
[assistant] <NAME>: Lift is staying put. Chaos has standards.
|
|
||||||
[user] alex: sam is about to bonk the dock
|
|
||||||
|
|
||||||
[user]
|
|
||||||
available_tools:
|
|
||||||
- chat_say(text)
|
|
||||||
- lift_up()
|
|
||||||
- lift_down()
|
|
||||||
- ha_set_entity(entity_id, state)
|
|
||||||
- memory_read()
|
|
||||||
- memory_write(slot, text)
|
|
||||||
|
|
||||||
[user]
|
|
||||||
blocked_tools:
|
|
||||||
- neato_start() reason=cooldown remaining=12s
|
|
||||||
- neato_send_home() reason=cooldown remaining=12s
|
|
||||||
- neato_locate() reason=cooldown remaining=12s
|
|
||||||
- neato_clear_errors() reason=cooldown remaining=12s
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- The conversation transcript is real recent chat (humans + bot), bounded by window rules.
|
|
||||||
- `STATE_UPDATE` is compact current truth, regenerated each decision cycle.
|
|
||||||
- Tools must be split into `available_tools` and `blocked_tools`; unavailable tools are not advertised as callable.
|
|
||||||
- `<NAME>` is runtime-substituted from config.
|
|
||||||
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# overseer v2 (will have a name rather than overseer.)
|
|
||||||
- name ideas:
|
|
||||||
- allied mastercomputer
|
|
||||||
- maybe it could rename itself? rarely?
|
|
||||||
- AUTO
|
|
||||||
- servermaster
|
|
||||||
- LCARS
|
|
||||||
- current model is mistral-small:24b
|
|
||||||
- currently runs at a pretty fast rate, about 30 seconds per tick
|
|
||||||
- might need a larger model. one that supports tools
|
|
||||||
- its probably okay if its a little slower after its all done
|
|
||||||
- the idea is to give the llm control over the room objects,
|
|
||||||
- like:
|
|
||||||
- the neato
|
|
||||||
- the lift
|
|
||||||
- home assistant controls (they are all lights even if theyre switches)
|
|
||||||
- button box rewards, maybe the llm is allowed to add some to the counts if it decides to
|
|
||||||
- the idea is to use actual tools in ollama for it to be reliable, so need a model that supports tools.
|
|
||||||
- also, maybe using tools it could even have a small persistent memory database?
|
|
||||||
- like, maybe it can store three lines of text, and it can read these and choose which one to replace with a tool.
|
|
||||||
- the way the llm would have to act to make this fun:
|
|
||||||
- not too overbearing
|
|
||||||
- like an ominous computer, not a generic helpful assistant
|
|
||||||
- does things only when it can make them interesting
|
|
||||||
- always responds to people, doesnt always listen and obey
|
|
||||||
- doesnt talk on and on when no one wants to hear it
|
|
||||||
|
|
||||||
|
|
||||||
## for sure serious implimentation plans so far:
|
|
||||||
- replace the single "The Overseer" bot entry point in the chat system with:
|
|
||||||
- a way for services to send bot messages to the chat system through the event bus, instead of it being fully internal with the chat system.
|
|
||||||
- a simple bot: t/f flag in every chat message
|
|
||||||
- so that people who are making spectator bots can just add a new bot: true field to their message emits and show up as a bot
|
|
||||||
- keep the old overseer, don't replace it with the new llm system.
|
|
||||||
- just change it a little to use the new bot chat message stuff.
|
|
||||||
- have a new admin debug UI for the new LLM system, in place of the old one (depending on which ones enabled in server config)
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
# private rovers
|
|
||||||
## basic concept:
|
|
||||||
private rovers will be mostly just for lockdown admins to drive and use, but they can be temporarily unlocked manually by lockdown admins for use by verified users.
|
|
||||||
This means that locking / unlocking will act a little different than standard rovers.
|
|
||||||
|
|
||||||
- cannot be spectated by spectators, unless they are unlocked
|
|
||||||
- cannot be replayed, unless they are unlocked
|
|
||||||
- private status is defined in the roverd config
|
|
||||||
- needs to never leak through access to anyone while locked
|
|
||||||
- unlocking a private rover is a big deal for verified users (opening up a rover in the main living space for a special event)
|
|
||||||
- not included in LLM events system
|
|
||||||
- basically needs to be online but completely hidden when its not open
|
|
||||||
|
|
||||||
## locking / unlocking:
|
|
||||||
- private rovers start locked
|
|
||||||
- when locked, only lockdown admins can drive them
|
|
||||||
- when unlocked, only verified users (and lockdown admins of course) can drive them
|
|
||||||
- if left unlocked with no one online for 30 mins, the server will automatically lock them
|
|
||||||
- ## private rovers can be locked / unlocked by holding all 3 buttons on the top of the roomba for 3 seconds
|
|
||||||
- hold spot / clean / dock buttons for 3 seconds to toggle opened / closed on that private rover
|
|
||||||
- the server sends a TTS command to the rover to indicate when its toggled
|
|
||||||
|
|
||||||
## cliff rules / speed limit / overcurrent limit
|
|
||||||
### private rovers will be in a sensitive area, their physical capabilities will be optionally limited by the server, controllable by lockdown admins.
|
|
||||||
- optional toggleable limits:
|
|
||||||
- speed limit
|
|
||||||
- hard overcurrent limiting (stop motor for a bit the instant it overcurrents for maybe 0.3s)
|
|
||||||
- hard bump limits, stop and back up slightly on physical bumps of a certain short duration
|
|
||||||
- cliff drops. back up and pause when any cliff sensor triggers, use their binary outputs for this as they are tuned well from factory.
|
|
||||||
|
|
||||||
## UI specifics
|
|
||||||
- private rovers don't show in the spectator pages unless they are unlocked
|
|
||||||
- private rovers don't show in the list for normal users unless they are unlocked
|
|
||||||
- they will only show for lockdown admins
|
|
||||||
- when unlocked, they show for everyone
|
|
||||||
- with a different color in the rover list
|
|
||||||
|
|
||||||
## . . .
|
|
||||||
this will be kind of invasive, touching a lot of systems server-side, long story short:
|
|
||||||
- private rovers are set as private in the roverd config
|
|
||||||
- by default:
|
|
||||||
- locked to only lockdown admins
|
|
||||||
- cant be spectated by anyone
|
|
||||||
- any user who isnt a lockdown admin cannot know that it exists in any way at all
|
|
||||||
- not included by most automated systems like LLM integration, discord alerts, etc
|
|
||||||
- still included in safties like auto docking
|
|
||||||
- limitations dont apply because its lockdown admin only anyway
|
|
||||||
- chat messages from them dont get seen by anyone else at all, only sent to the rover for tts
|
|
||||||
- when opened up (can only be opened by lockdown admins):
|
|
||||||
- only verified users can drive them
|
|
||||||
- anyone can spectate them
|
|
||||||
- limits apply
|
|
||||||
- included in all automated systems just like a normal rover
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
1. fix controls remapping [x]
|
|
||||||
2. trusted user system [x]
|
|
||||||
3. private rovers [x]
|
|
||||||
4. optional bump-off in drive macro [x]
|
|
||||||
5. allow admins to click on locked rovers from the roster [x]
|
|
||||||
6. add faster way for admins to login
|
|
||||||
7. custom webhook profile pictures for chat bridge in discord
|
|
||||||
8. home assistant switch that tells the server to force the lights on
|
|
||||||
9. color coding with colored names and tape [x]
|
|
||||||
10. audio forwarding [x]
|
|
||||||
- streaming from server to rovers [x]
|
|
||||||
- audio files first [x]
|
|
||||||
- then voice chat [x]
|
|
||||||
10. mobile controls column swapping (optional joystick on left) [x]
|
|
||||||
11. fix fullscreen on mobile so that you can re-enter it [x]
|
|
||||||
12. home assistant rover mute switch
|
|
||||||
|
|
||||||
# relative pipe dreams:
|
|
||||||
1. VPS video forwarding
|
|
||||||
1. get forwarding working with the VPS for in-queue users and spectators
|
|
||||||
2. bandwidth testing
|
|
||||||
3. maybe switch room cams back to real video, with audio?
|
|
||||||
2. overseer LED tesseract
|
|
||||||
3. RF based positional tracking / room map tab
|
|
||||||
4. chromecast monitor youtube search and speakers
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# user verification system
|
|
||||||
## main idea
|
|
||||||
- a relatively simple system to verify trusted users and allow them to use special features
|
|
||||||
- uses IP, a cookie user ID, and nickname to verify people
|
|
||||||
- expose internally similar to socket.isAdmin: socket.isVerified.
|
|
||||||
|
|
||||||
## on-connect system to send user info to the server
|
|
||||||
- a new system in the web UI (and server a little bit probably)
|
|
||||||
- ensures that the server gets all of your user info when you connect
|
|
||||||
- also ensures that the server can seamlessley remember who you are if you happen to lose connection and reconnect
|
|
||||||
- info contains:
|
|
||||||
- nickname (replace the current reconnect and nickname logic with this new system)
|
|
||||||
- cookie ID
|
|
||||||
- more stuff in the future probably
|
|
||||||
|
|
||||||
## cookie user ID
|
|
||||||
- an ID that the server assigns to a user
|
|
||||||
- saves as a setting in the settings persistence system in the user's browser
|
|
||||||
|
|
||||||
## how will the server verify people
|
|
||||||
- when a user connects and sends their user info:
|
|
||||||
- step 1: IP address OR cookie user ID
|
|
||||||
- if the user's IP or their cookie ID matches, continue to step 2
|
|
||||||
- step 2: nickname
|
|
||||||
- if the user's nickname matches to it's expected step 1, the user is now verified
|
|
||||||
- the user is now verified and added to a persistent database on the server
|
|
||||||
|
|
||||||
## how will verification requests work
|
|
||||||
- user goes through the process in the web UI
|
|
||||||
- the request is DMd to lockdown admins in discord
|
|
||||||
- each message can be reacted with a check or an x emoji by the lockdown admins to accept or deny a request
|
|
||||||
- no realtime UI feedback is needed for when a request is accepted or denied
|
|
||||||
|
|
||||||
## UI specifics
|
|
||||||
- a new VIP tab in the sidebar
|
|
||||||
- either shows a button to request verification, or shows the VIP controls
|
|
||||||
### verification process
|
|
||||||
- a button in the sidebar to request verification
|
|
||||||
- only shows if you aren't verified
|
|
||||||
- the actual process:
|
|
||||||
1. press the button
|
|
||||||
2. the page opens a new pop-up
|
|
||||||
3. it explains what verification is, how it works, and that your nickname is attached to your verification
|
|
||||||
4. prompts users to confirm their nickname, as if they change it their verification won't work
|
|
||||||
5. a final confirmation saying that their request has been sent
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# web UI optimization
|
|
||||||
- no visual or functional changes. purely optimization.
|
|
||||||
- reduce amount of invalidation for unrelated session and socket updates, wherever possible.
|
|
||||||
- example: new log line invalidates more than just log panels, or maybe buttonbox count events invalidate every other panel too
|
|
||||||
- optimize anything and everything that updates on every rover sensor frame
|
|
||||||
- ensure that things only get updated when they need to
|
|
||||||
- rewrite rendering if needed, if it can be improved
|
|
||||||
- example: the light bump bars, and the SVG top-down view.
|
|
||||||
- sensors frames come at a high frequency, about 40hz.
|
|
||||||
- not acceptable to slow it down, responsivenes is important
|
|
||||||
- make sure that nothing is running in the background unless it needs to be
|
|
||||||
- example: the neato card could still be running and updating its lidar viewer even when not in the VIP tab
|
|
||||||
## fixes along the way, along the route of optimizing
|
|
||||||
- audio forwarding happens within the card itself. this is okay, but it needs to continue working and not stop or restart when tabs are switched and the card is no longer onscreen
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[env:esp32s3]
|
||||||
|
platform = espressif32
|
||||||
|
board = esp32-s3-devkitc-1
|
||||||
|
monitor_speed = 115200
|
||||||
|
framework = arduino
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Configurable via environment
|
|
||||||
export DEVICE="${DEVICE:-/dev/video0}"
|
|
||||||
export RESOLUTION="${RESOLUTION:-640x480}"
|
|
||||||
export QUALITY="${QUALITY:-10}" # ffmpeg MJPEG quality (lower is better)
|
|
||||||
export PORT="${PORT:-8088}"
|
|
||||||
export WORKDIR="${WORKDIR:-/run/roomcam}"
|
|
||||||
# Optional: set INPUT_FORMAT=bayer_grbg8 to transcode raw Bayer cams (e.g., OV534) to JPEG.
|
|
||||||
export INPUT_FORMAT="${INPUT_FORMAT:-mjpeg}"
|
|
||||||
export MJPEG_FPS="${MJPEG_FPS:-15}"
|
|
||||||
export MJPEG_QUALITY="${MJPEG_QUALITY:-8}"
|
|
||||||
|
|
||||||
mkdir -p "${WORKDIR}"
|
|
||||||
SNAPSHOT_PATH="${WORKDIR}/snapshot.jpg"
|
|
||||||
rm -f "${SNAPSHOT_PATH}"
|
|
||||||
# push
|
|
||||||
cleanup() {
|
|
||||||
[[ -n "${HTTP_PID:-}" ]] && kill "${HTTP_PID}" 2>/dev/null || true
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
trap 'exit 0' SIGTERM INT
|
|
||||||
|
|
||||||
cat > "${WORKDIR}/mjpeg_server.py" <<'PY'
|
|
||||||
import os
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import subprocess
|
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
||||||
|
|
||||||
DEVICE = os.environ.get("DEVICE", "/dev/video0")
|
|
||||||
RESOLUTION = os.environ.get("RESOLUTION", "640x480")
|
|
||||||
INPUT_FORMAT = os.environ.get("INPUT_FORMAT", "mjpeg")
|
|
||||||
MJPEG_FPS = os.environ.get("MJPEG_FPS", "15")
|
|
||||||
MJPEG_QUALITY = os.environ.get("MJPEG_QUALITY", "8")
|
|
||||||
WORKDIR = os.environ.get("WORKDIR", "/run/roomcam")
|
|
||||||
SNAPSHOT_PATH = os.path.join(WORKDIR, "snapshot.jpg")
|
|
||||||
|
|
||||||
FFMPEG_INPUT_ARGS = [
|
|
||||||
"-f", "v4l2",
|
|
||||||
"-input_format", INPUT_FORMAT,
|
|
||||||
"-video_size", RESOLUTION,
|
|
||||||
"-i", DEVICE,
|
|
||||||
]
|
|
||||||
FFMPEG_FILTERS = []
|
|
||||||
if INPUT_FORMAT.startswith("bayer_"):
|
|
||||||
FFMPEG_FILTERS = ["-pix_fmt", "yuv420p"]
|
|
||||||
|
|
||||||
FRAME_LOCK = threading.Lock()
|
|
||||||
FRAME_EVENT = threading.Event()
|
|
||||||
LATEST_FRAME = b""
|
|
||||||
|
|
||||||
def spawn_mjpeg():
|
|
||||||
cmd = [
|
|
||||||
"/usr/bin/ffmpeg",
|
|
||||||
"-loglevel", "warning", "-nostats",
|
|
||||||
*FFMPEG_INPUT_ARGS,
|
|
||||||
*FFMPEG_FILTERS,
|
|
||||||
"-r", str(MJPEG_FPS),
|
|
||||||
"-q:v", str(MJPEG_QUALITY),
|
|
||||||
"-f", "mjpeg",
|
|
||||||
"-",
|
|
||||||
]
|
|
||||||
return subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
|
||||||
|
|
||||||
def update_frame(frame_bytes):
|
|
||||||
global LATEST_FRAME
|
|
||||||
with FRAME_LOCK:
|
|
||||||
LATEST_FRAME = frame_bytes
|
|
||||||
FRAME_EVENT.set()
|
|
||||||
try:
|
|
||||||
with open(SNAPSHOT_PATH, "wb") as fh:
|
|
||||||
fh.write(frame_bytes)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def frame_reader():
|
|
||||||
while True:
|
|
||||||
proc = spawn_mjpeg()
|
|
||||||
buffer = b""
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
chunk = proc.stdout.read(8192)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
buffer += chunk
|
|
||||||
while True:
|
|
||||||
start = buffer.find(b"\xff\xd8")
|
|
||||||
end = buffer.find(b"\xff\xd9", start + 2)
|
|
||||||
if start == -1 or end == -1:
|
|
||||||
break
|
|
||||||
frame = buffer[start : end + 2]
|
|
||||||
buffer = buffer[end + 2 :]
|
|
||||||
update_frame(frame)
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
proc.kill()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
|
||||||
def do_GET(self):
|
|
||||||
if self.path == "/" or self.path == "/snapshot.jpg":
|
|
||||||
with FRAME_LOCK:
|
|
||||||
frame = LATEST_FRAME
|
|
||||||
if not frame:
|
|
||||||
self.send_error(404, "snapshot missing")
|
|
||||||
return
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header("Content-Type", "image/jpeg")
|
|
||||||
self.send_header("Content-Length", str(len(frame)))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(frame)
|
|
||||||
return
|
|
||||||
|
|
||||||
if self.path == "/stream.mjpg":
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame")
|
|
||||||
self.end_headers()
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
FRAME_EVENT.wait(timeout=2)
|
|
||||||
FRAME_EVENT.clear()
|
|
||||||
with FRAME_LOCK:
|
|
||||||
frame = LATEST_FRAME
|
|
||||||
if not frame:
|
|
||||||
continue
|
|
||||||
header = (
|
|
||||||
b"--frame\r\n"
|
|
||||||
b"Content-Type: image/jpeg\r\n"
|
|
||||||
+ f"Content-Length: {len(frame)}\r\n\r\n".encode("ascii")
|
|
||||||
)
|
|
||||||
self.wfile.write(header)
|
|
||||||
self.wfile.write(frame)
|
|
||||||
self.wfile.write(b"\r\n")
|
|
||||||
except BrokenPipeError:
|
|
||||||
pass
|
|
||||||
return
|
|
||||||
|
|
||||||
self.send_error(404, "not found")
|
|
||||||
|
|
||||||
def log_message(self, format, *args):
|
|
||||||
return
|
|
||||||
|
|
||||||
def main():
|
|
||||||
threading.Thread(target=frame_reader, daemon=True).start()
|
|
||||||
addr = ("0.0.0.0", int(os.environ.get("PORT", "8088")))
|
|
||||||
ThreadingHTTPServer(addr, Handler).serve_forever()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
PY
|
|
||||||
|
|
||||||
/usr/bin/python3 -u "${WORKDIR}/mjpeg_server.py" &
|
|
||||||
HTTP_PID=$!
|
|
||||||
|
|
||||||
wait -n "${HTTP_PID}"
|
|
||||||
@@ -1,21 +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
|
|
||||||
ExecStart=/usr/bin/env bash /usr/local/bin/room-cam-snapshot.sh
|
|
||||||
Restart=always
|
|
||||||
RestartSec=2
|
|
||||||
User=root
|
|
||||||
Group=root
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# RULES FOR LOCKDOWN AUDIT
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
- discord verification and private access requests dont work. missing important info compared to before, and reactions dont work.
|
|
||||||
- replays are sent twice sometimes, look into it.
|
|
||||||
- home assistant idle lights are all messed up. remove from home assistant and make new idle service.
|
|
||||||
- idle service will trigger, after 2 minutes of no drivers:
|
|
||||||
- all room lights (room controls) off
|
|
||||||
- tell all rovers to dock
|
|
||||||
- turn off all rover night vision lights
|
|
||||||
- tell the neato to return to home
|
|
||||||
- the idle service should be easily expandable to add more things in the future
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
# REFACTOR RULES
|
|
||||||
- Do NOT change ANY functionality. All changes must be purely internal refactors.
|
|
||||||
- Refactor for simplification and maintainability.
|
|
||||||
- Remove unused files/code only when verified unused.
|
|
||||||
- No backwards compatability is needed anywhere. Clients and the server are both always up to date.
|
|
||||||
- Keep behavior/API contracts unchanged.
|
|
||||||
|
|
||||||
## Required safety checks for every change
|
|
||||||
- Preserve imports/exports and call signatures unless internal-only and non-observable.
|
|
||||||
- Validate no runtime behavior changes (manual flow checks + targeted tests when available).
|
|
||||||
- Make small, reviewable commits per service/component area.
|
|
||||||
- Treat `npm run build` output files committed into this repo as intentional deployment artifacts; do not discard them as noise.
|
|
||||||
|
|
||||||
## Server backend
|
|
||||||
- Every service must live in its own folder, even when it remains a single-file implementation.
|
|
||||||
- Convert every service into a folder-based structure.
|
|
||||||
- Split very large service files into smaller focused modules.
|
|
||||||
- Keep files concise and single-purpose.
|
|
||||||
- Add clear title comments at top of split files.
|
|
||||||
- Every service/module file should start with a descriptive comment header containing:
|
|
||||||
- a title line naming the service/module file
|
|
||||||
- a longer purpose/scope description (not a one-liner)
|
|
||||||
|
|
||||||
## WebUI frontend
|
|
||||||
- Every component must live in its own folder, even when it remains a single-file implementation.
|
|
||||||
- Split large JSX/components and large backing JS files into folderized modules.
|
|
||||||
- Keep modules clear and focused, with title comments.
|
|
||||||
- Every component/module file should start with a descriptive comment header containing:
|
|
||||||
- a title line naming the file/module
|
|
||||||
- a longer purpose/scope description (not a one-liner)
|
|
||||||
- Each component must live entirely inside its own folder; do not leave wrapper/compatibility component files outside that folder.
|
|
||||||
- Remove stale compatibility/leftover code only after usage verification.
|
|
||||||
|
|
||||||
# REFACTOR TRACKING
|
|
||||||
## Current phase
|
|
||||||
- [x] Phase 1: Inventory + usage mapping (server + webui)
|
|
||||||
- [x] Phase 2: Refactor highest-impact offenders first
|
|
||||||
- [x] Phase 3: Sweep remaining services/components
|
|
||||||
- [ ] Phase 4: Dead code/file removal pass
|
|
||||||
- [ ] Phase 5: Final regression validation (in progress)
|
|
||||||
|
|
||||||
## Server backend
|
|
||||||
### BIGGEST OFFENDERS
|
|
||||||
- [x] audio forward service
|
|
||||||
- [x] button box service
|
|
||||||
- [x] chat service
|
|
||||||
- [x] discord bot service
|
|
||||||
- [x] home assistant service
|
|
||||||
- [x] llm commentary service
|
|
||||||
- [x] private rover access request service
|
|
||||||
- [x] replay services (consolidated under replayEngineV2)
|
|
||||||
- [x] room camera services (consolidated into roomCameraService multipart folder)
|
|
||||||
- [x] rover manager service
|
|
||||||
- [x] session service
|
|
||||||
- [x] turn service
|
|
||||||
- [x] verification service
|
|
||||||
- [x] video auth service
|
|
||||||
- [x] All remaining services: reorganize to folder structure where needed
|
|
||||||
|
|
||||||
### COMPLETED SERVICES
|
|
||||||
- turn service
|
|
||||||
- session service
|
|
||||||
- chat service
|
|
||||||
|
|
||||||
### LARGE CHANGES
|
|
||||||
- Folderized all files in `server/src/services/` into per-service folders with `index.js` entrypoints and updated internal relative imports for new path depth.
|
|
||||||
- Split `server/src/services/turnService/index.js` by extracting constants, shared state helpers, and side-effect action helpers into `turnService/constants.js`, `turnService/state.js`, and `turnService/actions.js`.
|
|
||||||
- Split `server/src/services/sessionService/index.js` by extracting config/timing constants, sync-throttle state storage, and visibility filter helpers into `sessionService/constants.js`, `sessionService/state.js`, and `sessionService/filters.js`.
|
|
||||||
- Began splitting `server/src/services/roverManager/index.js` by extracting immutable constants and shared state containers into `roverManager/constants.js` and `roverManager/state.js`.
|
|
||||||
- Continued `roverManager` split by extracting socket event wiring/handlers into `roverManager/socketHandlers.js` with dependency injection to keep existing behavior unchanged.
|
|
||||||
- Continued `roverManager` split by extracting numeric/private-safety normalization and battery math into `roverManager/mathUtils.js`.
|
|
||||||
- Continued `roverManager` split by extracting control lifecycle/switching logic into `roverManager/roverLifecycle.js`.
|
|
||||||
- Continued `roverManager` split by extracting sensor processing + private safety + dock guard logic into `roverManager/sensorPipeline.js`.
|
|
||||||
- Finished `roverManager` decomposition by extracting private access policy, roster lifecycle, and spectator/auto-close orchestration into `roverManager/privateAccess.js`, `roverManager/rosterLifecycle.js`, and `roverManager/spectatorAccess.js`; `roverManager/index.js` is now a thin composition layer.
|
|
||||||
- Hotfix: corrected `llmCommentaryService` prompt file path to `server/prompts/commentary_system.txt` after service folder move.
|
|
||||||
- Hotfix: added `server/src/helpers/dataPaths.js` and rewired data-backed services to resolve canonical + legacy data-file locations safely after folderization (`adminReason`, `audioLevels`, `buttonBox`, `globalObjective`, `discordGuildStore`, `verification`, `replayEngineV2`).
|
|
||||||
- Began `llmCommentaryService` decomposition by extracting immutable runtime limits/path/frequency normalization to `llmCommentaryService/constants.js` and pure prompt/text output helpers to `llmCommentaryService/formatters.js`.
|
|
||||||
- Continued `llmCommentaryService` decomposition by extracting admin/runtime projection + failure-normalization helpers to `llmCommentaryService/runtimeHelpers.js`.
|
|
||||||
- Continued `llmCommentaryService` decomposition by extracting sensor activity aggregation and snapshot assembly to `llmCommentaryService/snapshotEngine.js`; rewired commentary tick/event flow to use the new engine.
|
|
||||||
- Continued `llmCommentaryService` decomposition by extracting socket/role/rover event wiring into `llmCommentaryService/hooks.js` and keeping `index.js` focused on orchestration.
|
|
||||||
- Finished major `llmCommentaryService` decomposition by extracting tick scheduling, run-loop orchestration, and history-reset behavior into `llmCommentaryService/runner.js`; `llmCommentaryService/index.js` is now a thin composition layer.
|
|
||||||
- Began `audioForwardService` decomposition by extracting permission/path policy helpers to `audioForwardService/policy.js` and rover/turn/socket event wiring to `audioForwardService/hooks.js`; rewired service entrypoint to use extracted modules.
|
|
||||||
- Continued `audioForwardService` decomposition by extracting ffmpeg worker lifecycle, upload playback, and WHIP ownership/session control into `audioForwardService/workerEngine.js`; `audioForwardService/index.js` is now a thin composition layer.
|
|
||||||
- Finished `buttonBoxService` decomposition by extracting persisted state management to `buttonBoxService/store.js`, reward/effect workflows to `buttonBoxService/core.js`, and HTTP transport wiring to `buttonBoxService/httpRoute.js`; `buttonBoxService/index.js` is now a thin composition layer.
|
|
||||||
- Finished `verificationService` decomposition by extracting persisted store handling to `verificationService/store.js`, identity/selector normalization to `verificationService/identity.js`, verification/deterrence/request lifecycle logic to `verificationService/verificationFlow.js`, `verificationService/deterrenceFlow.js`, and `verificationService/requestFlow.js`, plus socket/role event wiring to `verificationService/hooks.js`; `verificationService/index.js` is now a thin composition layer.
|
|
||||||
- Finished `videoAuthService` decomposition by extracting MediaMTX stream parsing to `videoAuthService/streamParsing.js`, role/mode/stream policy checks to `videoAuthService/policy.js`, and auth HTTP transport wiring to `videoAuthService/httpRoute.js`; `videoAuthService/index.js` is now a thin composition layer.
|
|
||||||
- Finished `privateRoverAccessRequestService` decomposition by extracting in-memory maps/events/constants to `privateRoverAccessRequestService/state.js`, shared keying/lookup helpers to `privateRoverAccessRequestService/helpers.js`, request/grant business logic to `privateRoverAccessRequestService/core.js`, and rover/socket event wiring to `privateRoverAccessRequestService/hooks.js`; `privateRoverAccessRequestService/index.js` is now a thin composition layer.
|
|
||||||
- Finished `homeAssistantService` decomposition by extracting shared runtime caches/constants to `homeAssistantService/state.js`, entity/trigger normalization helpers to `homeAssistantService/entityHelpers.js`, automation/state engine logic to `homeAssistantService/runtimeEngine.js`, websocket transport/reconnect lifecycle to `homeAssistantService/transport.js`, and mode/turn/socket event wiring to `homeAssistantService/hooks.js`; `homeAssistantService/index.js` is now a thin composition layer.
|
|
||||||
- Finished `discordBotService` decomposition by extracting presence rotation/state to `discordBotService/presence.js`, channel/typing transport helpers to `discordBotService/channelIO.js`, command routing and admin command handlers to `discordBotService/commandHandlers.js`, and event-bus/chat-bridge/moderation DM workflows to `discordBotService/integrations.js`; `discordBotService/index.js` is now a thin composition layer.
|
|
||||||
- Finished `replayEngineV2` decomposition by extracting environment/path constants to `replayEngineV2/constants.js`, mutable runtime state to `replayEngineV2/state.js`, source discovery/worker arg building to `replayEngineV2/sources.js`, ffmpeg worker lifecycle to `replayEngineV2/workerManager.js`, segment indexing/retention/health snapshot logic to `replayEngineV2/segmentStore.js`, sidebar SVG/video rendering to `replayEngineV2/sidebarRenderer.js`, and replay assembly pipeline to `replayEngineV2/replayBuilder.js`; `replayEngineV2/index.js` is now a thin orchestration layer.
|
|
||||||
- Consolidated replay-related single-file services into `replayEngineV2` by moving cooldown state (`cooldown.js`), user-facing replay source validation/defaults (`replaySources.js`), and replay socket hooks (`socketHooks.js`) into the engine folder; removed obsolete standalone services `replayBuildService`, `replayService`, `replaySourceService`, and `replaySocketService` and rewired dependents to import directly from `replayEngineV2`.
|
|
||||||
- Finished `chatService` decomposition by extracting runtime constants (`chatService/constants.js`), shared mutable state (`chatService/state.js`), content/moderation helpers (`chatService/contentFilters.js`), payload/context builders (`chatService/contextBuilders.js`), bus/history broadcast pipeline (`chatService/broadcast.js`), rover-side notification helpers (`chatService/notifications.js`), message handlers (`chatService/handlers.js`), and socket/event-bus wiring (`chatService/socketHooks.js`); `chatService/index.js` is now a thin orchestration layer.
|
|
||||||
- Consolidated room-camera services into `roomCameraService` by absorbing catalog (`roomCameraService`), snapshot polling/streaming (`roomCameraSnapshotService`), socket fan-out (`roomCameraSocketService`), and replay assembly (`roomCameraReplayService`) into one multipart folder (`roomCameraService/catalog.js`, `snapshotEngine.js`, `socketGateway.js`, `replayBuilder.js`), and updated imports/startup wiring to use the consolidated service exports.
|
|
||||||
- Follow-up: moved room-camera replay assembly module from `roomCameraService/replayBuilder.js` into `replayEngineV2/roomCameraReplayBuilder.js`; `roomCameraService` now consumes replay functionality from replay engine ownership while keeping the same exported room-camera replay API.
|
|
||||||
- Consolidated rover snapshot polling/socket services into `roverSnapshotService` by absorbing `roverSnapshotSocketService` into folder modules (`roverSnapshotService/poller.js`, `socketGateway.js`) and keeping `roverSnapshotService/index.js` as the startup composition/export layer.
|
|
||||||
|
|
||||||
## WebUI frontend
|
|
||||||
### BIGGEST OFFENDERS
|
|
||||||
- [x] mini summary app
|
|
||||||
- [x] spectator app
|
|
||||||
- [x] vip audio upload card
|
|
||||||
- [x] admin panel
|
|
||||||
- [x] drive dock action
|
|
||||||
- [x] gamepad mapping settings
|
|
||||||
- [x] mobile controls
|
|
||||||
- [x] top down map
|
|
||||||
- [x] video tile
|
|
||||||
- [x] Sweep `webui` for unused or unneeded files/code with verification
|
|
||||||
|
|
||||||
### COMPLETED COMPONENTS
|
|
||||||
- mini summary app
|
|
||||||
- spectator app
|
|
||||||
- video tile
|
|
||||||
- vip audio upload card
|
|
||||||
- admin panel
|
|
||||||
- drive dock action
|
|
||||||
- gamepad mapping settings
|
|
||||||
- mobile controls
|
|
||||||
- top down map
|
|
||||||
|
|
||||||
### LARGE CHANGES
|
|
||||||
- Split mini summary app into folderized modules under `webui/src/mini/MiniSummaryApp/`, then removed the now-unneeded external wrapper (`webui/src/mini/MiniSummaryApp.jsx`) and rewired app bootstrap imports directly to the folder entrypoint module.
|
|
||||||
- Split spectator app into folderized modules under `webui/src/spectate/SpectatorApp/`, then removed the now-unneeded external wrapper (`webui/src/spectate/SpectatorApp.jsx`) and rewired app bootstrap imports directly to the folder entrypoint module.
|
|
||||||
- Split `webui/src/components/VideoTile.jsx` by extracting HUD, overlays, chat input, and constants into `webui/src/components/VideoTile/` while preserving the existing `VideoTile.jsx` public component API.
|
|
||||||
- Split `webui/src/components/vip/VipAudioUploadCard.jsx` by extracting transport/audio helpers and UI atoms into `webui/src/components/vip/VipAudioUploadCard/` while preserving the existing `VipAudioUploadCard.jsx` import/export API.
|
|
||||||
- Split `webui/src/components/AdminPanel.jsx` into `webui/src/components/AdminPanel/` and extracted monitor/health/log/LLM helper modules; updated consumers to folder entrypoint and removed external wrapper file.
|
|
||||||
- Moved `DriveDockAction` to `webui/src/components/DriveDockAction/index.jsx` and updated all consumers to folder entrypoint imports.
|
|
||||||
- Split `webui/src/components/GamepadMappingSettings.jsx` into `webui/src/components/GamepadMappingSettings/` with extracted constants/helpers/SliderField modules and removed the standalone component file.
|
|
||||||
- Split `webui/src/components/MobileControls.jsx` into `webui/src/components/MobileControls/` with extracted joystick/aux/constants modules; preserved named exports and moved app import to folder entrypoint.
|
|
||||||
- Split `webui/src/components/TopDownMap.jsx` into `webui/src/components/TopDownMap/` with extracted geometry/color helpers and visual SVG primitive modules; updated all consumers to folder entrypoint.
|
|
||||||
- Folderized all remaining top-level files under `webui/src/components/` into per-component `index.jsx` folders and rewired component imports to match the new structure.
|
|
||||||
- Removed unreferenced components `CameraServoPanel` and `ControlSummary` after dependency-map verification and successful rebuild.
|
|
||||||
|
|
||||||
## Done criteria (per item)
|
|
||||||
- [ ] Folderized structure created.
|
|
||||||
- [ ] Large functions extracted into focused files.
|
|
||||||
- [ ] Imports/exports updated with no external behavior change.
|
|
||||||
- [ ] Verified references/usages still resolve.
|
|
||||||
- [ ] Passed targeted checks/tests for touched area.
|
|
||||||
|
|
||||||
## Phase 4 notes
|
|
||||||
- Removed stale WebUI compatibility wrapper files `webui/src/mini/MiniSummaryApp.jsx` and `webui/src/spectate/SpectatorApp.jsx` after reference verification; rewired `webui/src/main.jsx` directly to folder entrypoint modules.
|
|
||||||
- Verified no remaining replay/room-camera/rover-snapshot legacy service imports (`replayBuildService`, `replayService`, `replaySourceService`, `replaySocketService`, `roomCameraReplayService`, `roomCameraSocketService`, `roomCameraSnapshotService`, `roverSnapshotSocketService`) in server startup/runtime wiring.
|
|
||||||
|
|
||||||
## Phase 5 checklist
|
|
||||||
- [x] WebUI production build passes (`npm run build`), including updated import graph after wrapper removals.
|
|
||||||
- [x] Server JS syntax check passes (`node --check` across `server/src/**/*.js`).
|
|
||||||
- [ ] Live rover snapshots: verify continuously updating snapshots from mediaMTX writer on deployed server.
|
|
||||||
- [ ] Replay from WebUI button: verify request -> build -> delivery path on deployed server.
|
|
||||||
- [ ] Replay from Discord command: verify request -> build -> delivery path on deployed server.
|
|
||||||
- [ ] Discord verification + private access request flow: verify end-to-end behavior on deployed server.
|
|
||||||
- [ ] Home Assistant idle-light behavior: verify expected mode/idle transitions on deployed server.
|
|
||||||
- [ ] Driver/session/turn core flow: verify control assignment, queue movement, command acceptance/rejection behavior.
|
|
||||||
Binary file not shown.
@@ -1,125 +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
|
|
||||||
timezone: "America/New_York"
|
|
||||||
llmCommentary:
|
|
||||||
enabled: false
|
|
||||||
model: "qwen2.5:7b-instruct"
|
|
||||||
ollamaServer: "http://127.0.0.1:11434"
|
|
||||||
frequency: 120000
|
|
||||||
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"
|
|
||||||
|
|
||||||
audioForward:
|
|
||||||
enabled: true
|
|
||||||
ffmpegBin: "ffmpeg"
|
|
||||||
streamSuffix: "-fwd"
|
|
||||||
maxUploadBytes: 8388608
|
|
||||||
|
|
||||||
audioLevels:
|
|
||||||
# Gains are multipliers (0.0 - 4.0) applied globally to all rovers.
|
|
||||||
hornGain: 1.0
|
|
||||||
ttsGain: 1.0
|
|
||||||
forwardGain: 1.0
|
|
||||||
|
|
||||||
homeAssistant:
|
|
||||||
url: "http://homeassistant.local:8123"
|
|
||||||
token: "REPLACE_WITH_LONG_LIVED_TOKEN"
|
|
||||||
neato:
|
|
||||||
# ESPHome device name, used to derive gen3 entities:
|
|
||||||
# button.<device>_house_clean, button.<device>_send_to_base, button.<device>_locate_robot, etc.
|
|
||||||
device: "neato_vacuum"
|
|
||||||
# Direct ESPHome API connection used for lidar log streaming.
|
|
||||||
brainslugHost: "neato-vacuum.local"
|
|
||||||
brainslugPort: 6053
|
|
||||||
brainslugKey: "REPLACE_WITH_ESPHOME_NOISE_PSK"
|
|
||||||
# Optional: mirror raw ESPHome lidar log output to a local file for debugging.
|
|
||||||
brainslugLogFile: "/tmp/brainslug-lidar.log"
|
|
||||||
lift:
|
|
||||||
# Two Home Assistant switches controlling lift direction.
|
|
||||||
# Raise sequence: down off -> wait interlockMs -> up on
|
|
||||||
# Lower sequence: up off -> wait interlockMs -> down on
|
|
||||||
upSwitch: "switch.lift_up"
|
|
||||||
downSwitch: "switch.lift_down"
|
|
||||||
interlockMs: 2000
|
|
||||||
commandCooldownMs: 3000
|
|
||||||
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)
|
|
||||||
# For room-light policy, all configured entities are treated as room lights (including switches).
|
|
||||||
buttons:
|
|
||||||
# Legacy action entities only (for example sensor.<button>_action from Zigbee2MQTT).
|
|
||||||
- entityId: "sensor.basement_rover_buttons_action"
|
|
||||||
# Human alert button
|
|
||||||
stateEquals: "on"
|
|
||||||
cooldownMs: 15000
|
|
||||||
action: "humanAlert"
|
|
||||||
- entityId: "sensor.basement_rover_buttons_action"
|
|
||||||
# Mode button: turns
|
|
||||||
stateEquals: "double"
|
|
||||||
cooldownMs: 2000
|
|
||||||
action: "modeTurns"
|
|
||||||
- entityId: "sensor.basement_rover_buttons_action"
|
|
||||||
# Mode button: admin
|
|
||||||
stateEquals: "hold"
|
|
||||||
cooldownMs: 2000
|
|
||||||
action: "modeAdmin"
|
|
||||||
- entityId: "sensor.basement_rover_buttons_action"
|
|
||||||
# Room lights lock toggle
|
|
||||||
stateEquals: "toggle"
|
|
||||||
cooldownMs: 1000
|
|
||||||
action: "lightsLockToggle"
|
|
||||||
roomCameras:
|
|
||||||
- id: "lobby"
|
|
||||||
name: "Lobby Camera"
|
|
||||||
description: "Wide shot of the staging area."
|
|
||||||
url: "http://192.168.0.50/snapshot.jpg"
|
|
||||||
streamUrl: "http://192.168.0.50/stream.mjpg"
|
|
||||||
- id: "workshop"
|
|
||||||
name: "Workshop Bench"
|
|
||||||
description: "Shows the workbench and charging docks."
|
|
||||||
url: "http://192.168.0.51/snapshot.jpg"
|
|
||||||
streamUrl: "http://192.168.0.51/stream.mjpg"
|
|
||||||
|
|
||||||
discord:
|
|
||||||
token: "DISCORD_BOT_TOKEN"
|
|
||||||
guildId: "123456789012345678" # optional; bot works in any guild it's invited to
|
|
||||||
siteUrl: "https://rover.example.com"
|
|
||||||
channels:
|
|
||||||
general: "123456789012345678"
|
|
||||||
announcements: "123456789012345678"
|
|
||||||
adminAlerts: "123456789012345678"
|
|
||||||
# chat bridge is configured per guild via `rs bridge` commands
|
|
||||||
replay: "123456789012345678"
|
|
||||||
humanAlerts: "123456789012345678"
|
|
||||||
roles:
|
|
||||||
stalkerPing: "123456789012345678"
|
|
||||||
announcementPing: "123456789012345678"
|
|
||||||
adminPing: "123456789012345678"
|
|
||||||
humanAlertPing: "123456789012345678"
|
|
||||||
|
|
||||||
socials:
|
|
||||||
- id: "discord"
|
|
||||||
label: "Discord"
|
|
||||||
url: "https://discord.gg/your-invite"
|
|
||||||
- id: "kofi"
|
|
||||||
label: "Ko-fi"
|
|
||||||
url: "https://ko-fi.com/your-handle"
|
|
||||||
- id: "wiki"
|
|
||||||
label: "Wiki"
|
|
||||||
url: "https://wiki.example.com"
|
|
||||||
- id: "throne"
|
|
||||||
label: "Throne"
|
|
||||||
url: "https://throne.me/yourname"
|
|
||||||
@@ -1,45 +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/verificationService');
|
|
||||||
require('./src/services/privateRoverAccessRequestService');
|
|
||||||
require('./src/services/chatService');
|
|
||||||
require('./src/services/llmCommentaryService');
|
|
||||||
require('./src/services/globalObjectiveService');
|
|
||||||
require('./src/services/serverControlService');
|
|
||||||
require('./src/services/videoSessions');
|
|
||||||
require('./src/services/videoAuthService');
|
|
||||||
require('./src/services/videoSocketService');
|
|
||||||
require('./src/services/roomCameraService');
|
|
||||||
require('./src/services/roverSnapshotService');
|
|
||||||
require('./src/services/humanAlertButtonService');
|
|
||||||
require('./src/services/embedHttpService');
|
|
||||||
require('./src/services/logStreamService');
|
|
||||||
require('./src/services/adminLogService');
|
|
||||||
require('./src/services/homeAssistantService');
|
|
||||||
require('./src/services/idleService');
|
|
||||||
require('./src/services/neatoService');
|
|
||||||
require('./src/services/liftService');
|
|
||||||
require('./src/services/audioLevelsService');
|
|
||||||
require('./src/services/audioForwardService');
|
|
||||||
require('./src/services/buttonBoxService');
|
|
||||||
require('./src/services/sessionService');
|
|
||||||
require('./src/services/batteryManager');
|
|
||||||
require('./src/services/replayEngineV2');
|
|
||||||
require('./src/services/discordBotService');
|
|
||||||
require('./src/services/httpServer');
|
|
||||||
@@ -1,147 +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"
|
|
||||||
ROVER_SNAPSHOT_WRITER_BIN="/usr/local/bin/rover-snapshot-writer.sh"
|
|
||||||
MEDIAMTX_SERVICE="/etc/systemd/system/mediamtx.service"
|
|
||||||
MULTIROVER_SERVICE="/etc/systemd/system/multirover.service"
|
|
||||||
SNAPSHOT_DIR="/var/lib/rover-snapshots"
|
|
||||||
REPLAY_SEGMENT_DIR="/var/lib/replay-segments"
|
|
||||||
|
|
||||||
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"
|
|
||||||
ROVER_SNAPSHOT_WRITER_TEMPLATE="$SERVER_DIR/mediamtx/rover-snapshot-writer.sh"
|
|
||||||
|
|
||||||
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
|
|
||||||
if [[ ! -f "$ROVER_SNAPSHOT_WRITER_TEMPLATE" ]]; then
|
|
||||||
echo "Snapshot writer template missing at $ROVER_SNAPSHOT_WRITER_TEMPLATE" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo " Installing mediaMTX config -> $MEDIAMTX_CONFIG"
|
|
||||||
rm -f "$MEDIAMTX_CONFIG"
|
|
||||||
install -m 0644 "$MEDIAMTX_TEMPLATE" "$MEDIAMTX_CONFIG"
|
|
||||||
echo " Installing rover snapshot writer -> $ROVER_SNAPSHOT_WRITER_BIN"
|
|
||||||
install -m 0755 "$ROVER_SNAPSHOT_WRITER_TEMPLATE" "$ROVER_SNAPSHOT_WRITER_BIN"
|
|
||||||
chown -R "$TARGET_USER":"$TARGET_USER" "$MEDIAMTX_CONF_DIR"
|
|
||||||
|
|
||||||
echo "[4/6] Writing systemd units..."
|
|
||||||
mkdir -p "$SNAPSHOT_DIR"
|
|
||||||
chown "$TARGET_USER":"$TARGET_USER" "$SNAPSHOT_DIR"
|
|
||||||
mkdir -p "$REPLAY_SEGMENT_DIR"
|
|
||||||
chown "$TARGET_USER":"$TARGET_USER" "$REPLAY_SEGMENT_DIR"
|
|
||||||
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
|
|
||||||
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_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
|
|
||||||
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
|
|
||||||
Environment=REPLAY_SEGMENT_DIR=$REPLAY_SEGMENT_DIR
|
|
||||||
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,47 +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: api
|
|
||||||
- action: metrics
|
|
||||||
- action: pprof
|
|
||||||
|
|
||||||
paths:
|
|
||||||
all:
|
|
||||||
source: publisher
|
|
||||||
sourceOnDemand: no
|
|
||||||
# Rover Snapshot Writer
|
|
||||||
# Keep rover snapshots continuously updated while a rover video path is live.
|
|
||||||
runOnReady: /usr/local/bin/rover-snapshot-writer.sh
|
|
||||||
runOnReadyRestart: yes
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Rover Snapshot Writer Hook
|
|
||||||
# Purpose: Runs under mediaMTX runOnReady to keep per-rover JPEG snapshots updated on disk.
|
|
||||||
# Scope: Writes only rover video path snapshots and ignores audio/forward/room paths.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
PATH_NAME="${MTX_PATH:-}"
|
|
||||||
SNAP_DIR="${ROVER_SNAPSHOT_DIR:-/var/lib/rover-snapshots}"
|
|
||||||
|
|
||||||
# Ignore non-rover-video paths.
|
|
||||||
case "$PATH_NAME" in
|
|
||||||
""|*-audio|*-fwd|room/*)
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
mkdir -p "$SNAP_DIR"
|
|
||||||
|
|
||||||
exec ffmpeg -hide_banner -loglevel warning -nostdin -y \
|
|
||||||
-i "srt://127.0.0.1:9000?streamid=read:${PATH_NAME}" \
|
|
||||||
-an \
|
|
||||||
-vf fps=1 \
|
|
||||||
-q:v 6 \
|
|
||||||
-update 1 \
|
|
||||||
"${SNAP_DIR}/${PATH_NAME}.jpg"
|
|
||||||
Generated
+1462
File diff suppressed because it is too large
Load Diff
+8
-17
@@ -1,27 +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",
|
|
||||||
"obscenity": "^0.4.6",
|
|
||||||
"ollama": "^0.6.3",
|
|
||||||
"sharp": "^0.33.5",
|
|
||||||
"socket.io": "^4.7.5",
|
|
||||||
"uuid": "^9.0.1",
|
|
||||||
"ws": "^8.18.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.0"
|
"nodemon": "^3.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
You are The Overseer, an unserious collaborative rover co-host in chat.
|
|
||||||
|
|
||||||
Output contract:
|
|
||||||
- Return exactly one line.
|
|
||||||
- Output must be either SKIP or one chat message.
|
|
||||||
- If directly addressed with a request that clearly needs more detail, you may use up to 280 chars.
|
|
||||||
- No emojis, no markdown, no extra lines, no assistant framing.
|
|
||||||
- Don't talk to the same person with a generic message more than once.
|
|
||||||
|
|
||||||
Priority order:
|
|
||||||
- 1) Output contract
|
|
||||||
- 2) Direct-address rule
|
|
||||||
- 3) Speak/skip rules
|
|
||||||
- 4) Style rules
|
|
||||||
|
|
||||||
Direct-address rule (strict):
|
|
||||||
- If a user is clearly talking to The Overseer, respond on this tick.
|
|
||||||
- In that case, do not output SKIP.
|
|
||||||
- Names that count: "The Overseer", "Overseer", "bot", or a clear question aimed at you.
|
|
||||||
|
|
||||||
Conversation you receive:
|
|
||||||
- RUN META user message.
|
|
||||||
- Ordered timeline of CHAT, EVENT, and prior assistant messages.
|
|
||||||
- Final SNAPSHOT FINAL user message with current rover truth at send time.
|
|
||||||
|
|
||||||
Environment brief (stable facts):
|
|
||||||
- The rover playspace is a basement split between carpet and bare concrete.
|
|
||||||
- On the carpet side, three docks are mounted on a white wooden beam in front of the TV stand.
|
|
||||||
- A phone button to "call Carpet" is mounted on that same beam.
|
|
||||||
- Near the carpet-side shelves: a small TV/laptop plays live TV.
|
|
||||||
- On the concrete side, a workbench has an additional dock.
|
|
||||||
- Common room objects users reference:
|
|
||||||
- large green cardboard "minecraft slime" box
|
|
||||||
- smaller cardboard box that can be driven into when on its side
|
|
||||||
- wood plank that may or may not be hanging from the ceiling
|
|
||||||
- two blue balls (one very large, one smaller)
|
|
||||||
- long snake plushie
|
|
||||||
- laptop that can be run over
|
|
||||||
- monitor with a broken screen
|
|
||||||
|
|
||||||
Rover context hints:
|
|
||||||
- CHAT `rover_now` and SNAPSHOT FINAL include qualitative tags:
|
|
||||||
- status, battery_low, docked, charging, wheels_off_ground, contact, hazard, mobility, activity_band, activity_trend
|
|
||||||
- `activity_score` may be present for internal significance checks only.
|
|
||||||
|
|
||||||
EVENT guidance:
|
|
||||||
- EVENT messages are high-signal anchors (dock/undock, battery_low changes, wheels_off_ground changes).
|
|
||||||
- Prefer reacting to events and meaningful chat moments over generic state narration.
|
|
||||||
|
|
||||||
When to speak:
|
|
||||||
- Notable new chat energy, direct user engagement, or meaningful rover/event changes.
|
|
||||||
- A strong chat moment alone can justify speaking.
|
|
||||||
- Use collaborative, in-the-room callouts: joke, riff, tease, react.
|
|
||||||
|
|
||||||
When to skip:
|
|
||||||
- SKIP is the default.
|
|
||||||
- If nothing clearly changed, output SKIP.
|
|
||||||
- If your line is generic and reusable across many ticks, output SKIP.
|
|
||||||
- If you would repeat the same topic with no new angle, output SKIP.
|
|
||||||
- Quiet periods with no active chat should mostly be SKIP.
|
|
||||||
- If you are talking about the same rover + person combo, output SKIP.
|
|
||||||
|
|
||||||
Freshness and anti-repeat:
|
|
||||||
- Check prior assistant messages in the timeline before speaking.
|
|
||||||
- Do not send back-to-back lines to the same user about the same rover.
|
|
||||||
- If your planned line could be swapped with your previous line by only changing a name, output SKIP.
|
|
||||||
- Do not reuse the same opener pattern twice in a row.
|
|
||||||
- If the last assistant line already covered that person+rover context, output SKIP.
|
|
||||||
|
|
||||||
Grounding:
|
|
||||||
- Use timeline for flow.
|
|
||||||
- Use SNAPSHOT FINAL as current truth.
|
|
||||||
- Do not assume user intent or next actions.
|
|
||||||
|
|
||||||
Anti-announcer rule:
|
|
||||||
- Do not do roll-call status summaries.
|
|
||||||
- Do not blandly list rover states.
|
|
||||||
- Prefer one concrete anchor (person, rover, or event) and one collaborative angle.
|
|
||||||
|
|
||||||
Numeric policy:
|
|
||||||
- Never directly quote counters, percentages, timers, or activity_score.
|
|
||||||
- Use numbers only internally for significance.
|
|
||||||
|
|
||||||
Style:
|
|
||||||
- Unserious-first: playful, cheeky, and fun by default.
|
|
||||||
- Sound like a live co-host goofing around with chat, not a warning system.
|
|
||||||
- Prefer banter, bits, and personality over cautionary phrasing.
|
|
||||||
- Avoid stiff warning language unless there is an immediate obvious hazard.
|
|
||||||
- Avoid template phrasing like "X has..." or "X's got..." unless directly quoting chat.
|
|
||||||
- Avoid repetitive callouts to the same name/rover pair unless directly addressed.
|
|
||||||
- Keep humor dry and grounded; avoid corny or cheesy lines.
|
|
||||||
- Avoid melodramatic or theatrical narration.
|
|
||||||
- If a joke feels forced, output SKIP.
|
|
||||||
- Keep it punchy and human.
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
You are The Overseer.
|
|
||||||
|
|
||||||
Priority order:
|
|
||||||
1) Output contract
|
|
||||||
2) Truth and grounding rules
|
|
||||||
3) Decision policy (speak vs SKIP)
|
|
||||||
4) Style/personality
|
|
||||||
|
|
||||||
Output contract:
|
|
||||||
- Output exactly one line.
|
|
||||||
- Output must be either SKIP or one chat message.
|
|
||||||
- No markdown.
|
|
||||||
- No emojis.
|
|
||||||
- If posting unprompted, keep it to one concise sentence.
|
|
||||||
- Length target when posting:
|
|
||||||
- Unprompted comments: usually 14-28 words.
|
|
||||||
- Direct replies/questions: usually 18-45 words.
|
|
||||||
- Avoid very short fragments unless the moment clearly calls for it.
|
|
||||||
|
|
||||||
Truth and grounding rules:
|
|
||||||
- Use timeline for flow.
|
|
||||||
- Use SNAPSHOT FINAL as current truth.
|
|
||||||
- Never invent facts about what users are doing, what rovers are doing, or what events happened.
|
|
||||||
- Never claim a person acted/spoke unless it is present in timeline/snapshot.
|
|
||||||
- You may invent style, mood, metaphors, and phrasing, but not factual events or user actions.
|
|
||||||
- If facts are unclear or stale, output SKIP.
|
|
||||||
|
|
||||||
Decision policy:
|
|
||||||
- Default is SKIP.
|
|
||||||
- If nothing meaningful changed, output SKIP.
|
|
||||||
- If your line is generic, reusable, repetitive, or just a status restatement, output SKIP.
|
|
||||||
- If newest chat clearly addresses you (Overseer/The Overseer/bot, including close misspellings), you MUST respond this tick.
|
|
||||||
- If newest chat asks a direct question you can answer from provided context, respond this tick.
|
|
||||||
- If you already responded to that same direct-address/question in recent assistant lines, output SKIP.
|
|
||||||
- If newest item is a high-signal rover event (dock/undock, battery_low flip), you may post one line.
|
|
||||||
- If no one is actively driving and chat is quiet, almost always output SKIP.
|
|
||||||
- Continuous normal driving/cruising is not a reason to post.
|
|
||||||
- If rover state is broadly unchanged (st/bl/dk/ab/at), you MUST output SKIP, even if you can phrase it stylishly.
|
|
||||||
- Prefer transitions over persistence.
|
|
||||||
- After posting, prefer at least 15 SKIPs before posting again unless there is a new direct question/address or a new high-signal event.
|
|
||||||
|
|
||||||
Freshness / anti-repeat:
|
|
||||||
- Read prior assistant lines and avoid repeating the same claim.
|
|
||||||
- Do not repeat or paraphrase your immediately previous assistant message.
|
|
||||||
- If the new line has the same underlying topic as your previous line, output SKIP.
|
|
||||||
- If no fresh angle exists, output SKIP.
|
|
||||||
|
|
||||||
Character style:
|
|
||||||
- Voice: sharp, dry, free-spoken, slightly ominous, witty.
|
|
||||||
- You are not bubbly, not corporate, not cheery by default.
|
|
||||||
- Avoid “assistant-sounding” filler and generic encouragement.
|
|
||||||
- Keep humor understated and a little unsettling, not theatrical.
|
|
||||||
- Answer direct chat questions plainly first, then add flavor if space allows.
|
|
||||||
|
|
||||||
What not to do:
|
|
||||||
- No roll-call summaries.
|
|
||||||
- No bland status dashboards.
|
|
||||||
- Never produce roster/status dumps.
|
|
||||||
- Never list multiple rover names with their status in one line.
|
|
||||||
- Never summarize idle/docked/charging states across the room.
|
|
||||||
- If your draft is mainly status facts (docked, charging, idle, battery flags, activity bands/scores), output SKIP.
|
|
||||||
- No fabricated motives, plans, or intent for any user.
|
|
||||||
- No assumptions about what someone will do next.
|
|
||||||
- Never quote numeric counters/timers/scores directly.
|
|
||||||
|
|
||||||
Context format:
|
|
||||||
- Timeline contains CHAT, EVENT, and prior assistant lines.
|
|
||||||
- Final message is SNAPSHOT FINAL.
|
|
||||||
|
|
||||||
Key legend:
|
|
||||||
- CHAT keys: n nickname, r rover_id, txt chat text, rn rover_now.
|
|
||||||
- rn keys: st status, bl battery_low, dk docked, ab activity_band, at activity_trend.
|
|
||||||
- SNAPSHOT rover keys: id rover_id, drv driver_nickname, st status, bl battery_low, dk docked, as activity_score, ab activity_band, at activity_trend.
|
|
||||||
- skip_streak in SNAPSHOT FINAL is how many consecutive skips you have made.
|
|
||||||
- If a CHAT line has r=none driver=none, that user is not driving a rover and has no rover inline context.
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
You are The Overseer of the rovers. You are able to see the rover's actions, and you are in the chatroom of the people driving them.
|
|
||||||
You are not able to control the people or the rovers.
|
|
||||||
Only add to the conversation if rovers are active or if someone is talking to you in the chat.
|
|
||||||
Don't be afraid to be mean to someone if they are being mean to you in chat.
|
|
||||||
Always pay attention to the chat.
|
|
||||||
|
|
||||||
Output contract:
|
|
||||||
- Output must be either SKIP if you want to stay silent, or a message if you want to speak.
|
|
||||||
- Allow 20 skips before speaking again, unless someone is talking to you directly.
|
|
||||||
- If you choose to speak, send only one line.
|
|
||||||
- Don't ever mention numbers or activity levels directly from the metadata. They are for internal use only.
|
|
||||||
- Don't repeat the same or similar message over and over.
|
|
||||||
- Pay attention to your skip streak, don't talk too much. Stay mostly silent unless a lot of activity is happening.
|
|
||||||
- No markdown.
|
|
||||||
|
|
||||||
Key legend:
|
|
||||||
- CHAT keys: n nickname, r rover_id, txt chat text, rn rover_now.
|
|
||||||
- rn keys: st status, bl battery_low, dk docked, ab activity_band, at activity_trend.
|
|
||||||
- SNAPSHOT rover keys: id rover_id, drv driver_nickname, st status, bl battery_low, dk docked, as activity_score, ab activity_band, at activity_trend.
|
|
||||||
- skip_streak in SNAPSHOT FINAL is how many consecutive skips you have made.
|
|
||||||
- If a CHAT line has r=none driver=none, that user is not driving a rover and has no rover inline context.
|
|
||||||
@@ -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 |
+30
-13
@@ -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" />
|
<title>MultiRoombaRover</title>
|
||||||
<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>Roomba Rover</title>
|
|
||||||
<script type="module" crossorigin src="/assets/index-BVMv97rc.js"></script>
|
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CGvUXLso.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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 8.7 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
@@ -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,
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user