feat: add roomba shell (iRobot Roomba j7+ vacuum emulator)
New novelty shell emulating RoombaOS with cleaning, scheduling, diagnostics, floor map, and humorous history entries. Bump version to 0.16.0. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7
PLAN.md
7
PLAN.md
@@ -179,7 +179,12 @@ Goal: Add the entertaining shell implementations.
|
|||||||
- DDL/DML acknowledgments (CREATE TABLE, INSERT, UPDATE, DELETE, etc.)
|
- DDL/DML acknowledgments (CREATE TABLE, INSERT, UPDATE, DELETE, etc.)
|
||||||
- Username-to-shell routing: configurable `[shell.username_routes]` maps usernames to shells
|
- Username-to-shell routing: configurable `[shell.username_routes]` maps usernames to shells
|
||||||
|
|
||||||
### 3.7 Other Shell Ideas (Future)
|
### 3.7 Roomba Shell ✅
|
||||||
|
- iRobot Roomba j7+ vacuum robot interface
|
||||||
|
- Status, cleaning, scheduling, diagnostics, floor map
|
||||||
|
- Humorous history entries (cat encounters, sock tangles, sticky substances)
|
||||||
|
|
||||||
|
### 3.8 Other Shell Ideas (Future)
|
||||||
- **Nuclear launch terminal:** "ENTER LAUNCH AUTHORIZATION CODE"
|
- **Nuclear launch terminal:** "ENTER LAUNCH AUTHORIZATION CODE"
|
||||||
- **ELIZA therapist:** every response is a therapy question
|
- **ELIZA therapist:** every response is a therapy question
|
||||||
- **Pizza ordering terminal:** "Welcome to PizzaNet v2.3"
|
- **Pizza ordering terminal:** "Welcome to PizzaNet v2.3"
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ Key settings:
|
|||||||
- `auth.accept_after` — accept login after N failures per IP (default `10`)
|
- `auth.accept_after` — accept login after N failures per IP (default `10`)
|
||||||
- `auth.credential_ttl` — how long to remember accepted credentials (default `24h`)
|
- `auth.credential_ttl` — how long to remember accepted credentials (default `24h`)
|
||||||
- `auth.static_credentials` — always-accepted username/password pairs (optional `shell` field routes to a specific shell)
|
- `auth.static_credentials` — always-accepted username/password pairs (optional `shell` field routes to a specific shell)
|
||||||
- Available shells: `bash` (fake Linux shell), `fridge` (Samsung Smart Fridge OS), `banking` (80s-style bank terminal TUI), `adventure` (Zork-style text adventure dungeon), `cisco` (Cisco IOS CLI with mode state machine and command abbreviation), `psql` (PostgreSQL psql interactive terminal)
|
- Available shells: `bash` (fake Linux shell), `fridge` (Samsung Smart Fridge OS), `banking` (80s-style bank terminal TUI), `adventure` (Zork-style text adventure dungeon), `cisco` (Cisco IOS CLI with mode state machine and command abbreviation), `psql` (PostgreSQL psql interactive terminal), `roomba` (iRobot Roomba vacuum robot)
|
||||||
- `shell.username_routes` — map usernames to specific shells (e.g. `postgres = "psql"`); credential-specific shell overrides take priority
|
- `shell.username_routes` — map usernames to specific shells (e.g. `postgres = "psql"`); credential-specific shell overrides take priority
|
||||||
- `storage.db_path` — SQLite database path (default `oubliette.db`)
|
- `storage.db_path` — SQLite database path (default `oubliette.db`)
|
||||||
- `storage.retention_days` — auto-prune records older than N days (default `90`)
|
- `storage.retention_days` — auto-prune records older than N days (default `90`)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
"git.t-juice.club/torjus/oubliette/internal/web"
|
"git.t-juice.club/torjus/oubliette/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
const Version = "0.15.0"
|
const Version = "0.16.0"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if err := run(); err != nil {
|
if err := run(); err != nil {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
"git.t-juice.club/torjus/oubliette/internal/shell/cisco"
|
"git.t-juice.club/torjus/oubliette/internal/shell/cisco"
|
||||||
"git.t-juice.club/torjus/oubliette/internal/shell/fridge"
|
"git.t-juice.club/torjus/oubliette/internal/shell/fridge"
|
||||||
psqlshell "git.t-juice.club/torjus/oubliette/internal/shell/psql"
|
psqlshell "git.t-juice.club/torjus/oubliette/internal/shell/psql"
|
||||||
|
"git.t-juice.club/torjus/oubliette/internal/shell/roomba"
|
||||||
"git.t-juice.club/torjus/oubliette/internal/storage"
|
"git.t-juice.club/torjus/oubliette/internal/storage"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
@@ -62,6 +63,9 @@ func New(cfg config.Config, store storage.Store, logger *slog.Logger, m *metrics
|
|||||||
if err := registry.Register(psqlshell.NewPsqlShell(), 1); err != nil {
|
if err := registry.Register(psqlshell.NewPsqlShell(), 1); err != nil {
|
||||||
return nil, fmt.Errorf("registering psql shell: %w", err)
|
return nil, fmt.Errorf("registering psql shell: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := registry.Register(roomba.NewRoombaShell(), 1); err != nil {
|
||||||
|
return nil, fmt.Errorf("registering roomba shell: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
geo, err := geoip.New()
|
geo, err := geoip.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
469
internal/shell/roomba/roomba.go
Normal file
469
internal/shell/roomba/roomba.go
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
package roomba
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.t-juice.club/torjus/oubliette/internal/shell"
|
||||||
|
)
|
||||||
|
|
||||||
|
const sessionTimeout = 5 * time.Minute
|
||||||
|
|
||||||
|
// RoombaShell emulates an iRobot Roomba vacuum robot interface.
|
||||||
|
type RoombaShell struct{}
|
||||||
|
|
||||||
|
// NewRoombaShell returns a new RoombaShell instance.
|
||||||
|
func NewRoombaShell() *RoombaShell {
|
||||||
|
return &RoombaShell{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RoombaShell) Name() string { return "roomba" }
|
||||||
|
func (r *RoombaShell) Description() string { return "iRobot Roomba shell emulator" }
|
||||||
|
|
||||||
|
func (r *RoombaShell) Handle(ctx context.Context, sess *shell.SessionContext, rw io.ReadWriteCloser) error {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, sessionTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
state := newRoombaState()
|
||||||
|
|
||||||
|
banner := strings.ReplaceAll(bootBanner(), "\n", "\r\n")
|
||||||
|
fmt.Fprint(rw, banner)
|
||||||
|
|
||||||
|
for {
|
||||||
|
if _, err := fmt.Fprint(rw, "RoombaOS> "); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
line, err := shell.ReadLine(ctx, rw)
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
fmt.Fprint(rw, "logout\r\n")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result := state.dispatch(trimmed)
|
||||||
|
|
||||||
|
var output string
|
||||||
|
if result.output != "" {
|
||||||
|
output = result.output
|
||||||
|
output = strings.ReplaceAll(output, "\r\n", "\n")
|
||||||
|
output = strings.ReplaceAll(output, "\n", "\r\n")
|
||||||
|
fmt.Fprintf(rw, "%s\r\n", output)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sess.Store != nil {
|
||||||
|
if err := sess.Store.AppendSessionLog(ctx, sess.SessionID, trimmed, output); err != nil {
|
||||||
|
return fmt.Errorf("append session log: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sess.OnCommand != nil {
|
||||||
|
sess.OnCommand("roomba")
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.exit {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func bootBanner() string {
|
||||||
|
return `
|
||||||
|
____ _ ___ ____
|
||||||
|
| _ \ ___ ___ _ __ ___ | |__ __ _ / _ \/ ___|
|
||||||
|
| |_) / _ \ / _ \| '_ ` + "`" + ` _ \| '_ \ / _` + "`" + ` | | | \___ \
|
||||||
|
| _ < (_) | (_) | | | | | | |_) | (_| | |_| |___) |
|
||||||
|
|_| \_\___/ \___/|_| |_| |_|_.__/ \__,_|\___/|____/
|
||||||
|
|
||||||
|
iRobot Roomba j7+ | RoombaOS v4.3.7
|
||||||
|
Serial: RMB-7291-J7P-0482 | Firmware: 4.3.7-stable
|
||||||
|
Battery: 73% | WiFi: Connected (SmartHome-5G)
|
||||||
|
|
||||||
|
Type 'help' for available commands.
|
||||||
|
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
type room struct {
|
||||||
|
name string
|
||||||
|
areaSqFt int
|
||||||
|
lastCleaned time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type scheduleEntry struct {
|
||||||
|
day string
|
||||||
|
time string
|
||||||
|
}
|
||||||
|
|
||||||
|
type historyEntry struct {
|
||||||
|
timestamp time.Time
|
||||||
|
room string
|
||||||
|
duration string
|
||||||
|
note string
|
||||||
|
}
|
||||||
|
|
||||||
|
type roombaState struct {
|
||||||
|
battery int
|
||||||
|
dustbin int
|
||||||
|
status string
|
||||||
|
rooms []room
|
||||||
|
schedule []scheduleEntry
|
||||||
|
cleanHistory []historyEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
type commandResult struct {
|
||||||
|
output string
|
||||||
|
exit bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRoombaState() *roombaState {
|
||||||
|
now := time.Now()
|
||||||
|
return &roombaState{
|
||||||
|
battery: 73,
|
||||||
|
dustbin: 61,
|
||||||
|
status: "Docked",
|
||||||
|
rooms: []room{
|
||||||
|
{"Kitchen", 180, now.Add(-2 * time.Hour)},
|
||||||
|
{"Living Room", 320, now.Add(-5 * time.Hour)},
|
||||||
|
{"Bedroom", 200, now.Add(-26 * time.Hour)},
|
||||||
|
{"Hallway", 60, now.Add(-5 * time.Hour)},
|
||||||
|
{"Bathroom", 75, now.Add(-50 * time.Hour)},
|
||||||
|
{"Cat's Room", 110, now.Add(-3 * time.Hour)},
|
||||||
|
},
|
||||||
|
schedule: []scheduleEntry{
|
||||||
|
{"Monday", "09:00"},
|
||||||
|
{"Wednesday", "09:00"},
|
||||||
|
{"Friday", "09:00"},
|
||||||
|
{"Saturday", "14:00"},
|
||||||
|
},
|
||||||
|
cleanHistory: []historyEntry{
|
||||||
|
{now.Add(-2 * time.Hour), "Kitchen", "23 min", "Completed normally"},
|
||||||
|
{now.Add(-3 * time.Hour), "Cat's Room", "18 min", "Cat detected - rerouting"},
|
||||||
|
{now.Add(-5 * time.Hour), "Living Room", "34 min", "Encountered sock near couch"},
|
||||||
|
{now.Add(-5*time.Hour - 40*time.Minute), "Hallway", "8 min", "Completed normally"},
|
||||||
|
{now.Add(-26 * time.Hour), "Bedroom", "27 min", "Tangled in phone charger"},
|
||||||
|
{now.Add(-50 * time.Hour), "Bathroom", "14 min", "Unidentified sticky substance detected"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *roombaState) dispatch(input string) commandResult {
|
||||||
|
parts := strings.Fields(input)
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return commandResult{}
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := strings.ToLower(parts[0])
|
||||||
|
args := parts[1:]
|
||||||
|
|
||||||
|
switch cmd {
|
||||||
|
case "help":
|
||||||
|
return s.cmdHelp()
|
||||||
|
case "status":
|
||||||
|
return s.cmdStatus()
|
||||||
|
case "clean":
|
||||||
|
return s.cmdClean(args)
|
||||||
|
case "dock":
|
||||||
|
return s.cmdDock()
|
||||||
|
case "map":
|
||||||
|
return s.cmdMap()
|
||||||
|
case "schedule":
|
||||||
|
return s.cmdSchedule(args)
|
||||||
|
case "history":
|
||||||
|
return s.cmdHistory()
|
||||||
|
case "diagnostics":
|
||||||
|
return s.cmdDiagnostics()
|
||||||
|
case "alerts":
|
||||||
|
return s.cmdAlerts()
|
||||||
|
case "reboot":
|
||||||
|
return s.cmdReboot()
|
||||||
|
case "exit", "logout":
|
||||||
|
return commandResult{output: "Disconnecting from RoombaOS. Happy cleaning!", exit: true}
|
||||||
|
default:
|
||||||
|
return commandResult{output: fmt.Sprintf("RoombaOS: unknown command '%s'. Type 'help' for available commands.", cmd)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *roombaState) cmdHelp() commandResult {
|
||||||
|
help := `Available commands:
|
||||||
|
help - Show this help message
|
||||||
|
status - Show robot status
|
||||||
|
clean - Start full cleaning job
|
||||||
|
clean room <name> - Clean a specific room
|
||||||
|
dock - Return to dock
|
||||||
|
map - Show floor plan and room list
|
||||||
|
schedule - List cleaning schedule
|
||||||
|
schedule add <day> <time> - Add scheduled cleaning
|
||||||
|
schedule remove <day> - Remove scheduled cleaning
|
||||||
|
history - Show recent cleaning history
|
||||||
|
diagnostics - Run system diagnostics
|
||||||
|
alerts - Show active alerts
|
||||||
|
reboot - Reboot RoombaOS
|
||||||
|
exit / logout - Disconnect`
|
||||||
|
return commandResult{output: help}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *roombaState) cmdStatus() commandResult {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("=== RoombaOS System Status ===\n")
|
||||||
|
b.WriteString(fmt.Sprintf("Model: iRobot Roomba j7+\n"))
|
||||||
|
b.WriteString(fmt.Sprintf("Status: %s\n", s.status))
|
||||||
|
b.WriteString(fmt.Sprintf("Battery: %d%%\n", s.battery))
|
||||||
|
b.WriteString(fmt.Sprintf("Dustbin: %d%% full\n", s.dustbin))
|
||||||
|
b.WriteString(fmt.Sprintf("Side brush: OK (142 hrs)\n"))
|
||||||
|
b.WriteString(fmt.Sprintf("Main brush: OK (98 hrs)\n"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(fmt.Sprintf("WiFi: Connected (SmartHome-5G)\n"))
|
||||||
|
b.WriteString(fmt.Sprintf("Signal: -38 dBm\n"))
|
||||||
|
b.WriteString(fmt.Sprintf("Alexa: Linked\n"))
|
||||||
|
b.WriteString(fmt.Sprintf("Google Home: Linked\n"))
|
||||||
|
b.WriteString(fmt.Sprintf("iRobot Home App: Connected\n"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(fmt.Sprintf("Firmware: v4.3.7-stable\n"))
|
||||||
|
b.WriteString(fmt.Sprintf("LIDAR: Operational\n"))
|
||||||
|
b.WriteString(fmt.Sprintf("Clean Area Total: 12,847 sq ft (lifetime)"))
|
||||||
|
return commandResult{output: b.String()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *roombaState) cmdClean(args []string) commandResult {
|
||||||
|
if s.status == "Cleaning" {
|
||||||
|
return commandResult{output: "Already cleaning. Use 'dock' to cancel and return to dock."}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(args) >= 2 && strings.ToLower(args[0]) == "room" {
|
||||||
|
roomName := strings.Join(args[1:], " ")
|
||||||
|
for _, r := range s.rooms {
|
||||||
|
if strings.EqualFold(r.name, roomName) {
|
||||||
|
s.status = "Cleaning"
|
||||||
|
return commandResult{output: fmt.Sprintf(
|
||||||
|
"Starting targeted clean: %s (%d sq ft)\nEstimated time: %d minutes\nUndocking... navigating to %s...",
|
||||||
|
r.name, r.areaSqFt, r.areaSqFt/8, r.name,
|
||||||
|
)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return commandResult{output: fmt.Sprintf("Room '%s' not found. Use 'map' to see available rooms.", roomName)}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(args) > 0 {
|
||||||
|
return commandResult{output: "Usage: clean [room <name>]"}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.status = "Cleaning"
|
||||||
|
var totalArea int
|
||||||
|
for _, r := range s.rooms {
|
||||||
|
totalArea += r.areaSqFt
|
||||||
|
}
|
||||||
|
return commandResult{output: fmt.Sprintf(
|
||||||
|
"Starting full house clean\nTotal area: %d sq ft across %d rooms\nEstimated time: %d minutes\nUndocking... beginning clean cycle...",
|
||||||
|
totalArea, len(s.rooms), totalArea/8,
|
||||||
|
)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *roombaState) cmdDock() commandResult {
|
||||||
|
if s.status == "Docked" {
|
||||||
|
return commandResult{output: "Already docked."}
|
||||||
|
}
|
||||||
|
if s.status == "Returning to dock" {
|
||||||
|
return commandResult{output: "Already returning to dock."}
|
||||||
|
}
|
||||||
|
s.status = "Returning to dock"
|
||||||
|
return commandResult{output: "Cancelling current job. Returning to dock...\nEstimated arrival: 2 minutes"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *roombaState) cmdMap() commandResult {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("=== Floor Plan ===\n\n")
|
||||||
|
b.WriteString(" +------------+----------+\n")
|
||||||
|
b.WriteString(" | | |\n")
|
||||||
|
b.WriteString(" | Kitchen | Bathroom |\n")
|
||||||
|
b.WriteString(" | 180sqft | 75sqft |\n")
|
||||||
|
b.WriteString(" | | |\n")
|
||||||
|
b.WriteString(" +------+-----+----+-----+\n")
|
||||||
|
b.WriteString(" | | | |\n")
|
||||||
|
b.WriteString(" | Hall | Living | Cat |\n")
|
||||||
|
b.WriteString(" | 60sf | Room | Rm |\n")
|
||||||
|
b.WriteString(" | | 320sqft |110sf|\n")
|
||||||
|
b.WriteString(" +------+ +-----+\n")
|
||||||
|
b.WriteString(" | | |\n")
|
||||||
|
b.WriteString(" | Bed +----------+\n")
|
||||||
|
b.WriteString(" | room | [DOCK]\n")
|
||||||
|
b.WriteString(" |200sf |\n")
|
||||||
|
b.WriteString(" +------+\n")
|
||||||
|
b.WriteString("\nRoom Details:\n")
|
||||||
|
b.WriteString(fmt.Sprintf(" %-15s %-10s %s\n", "ROOM", "AREA", "LAST CLEANED"))
|
||||||
|
b.WriteString(fmt.Sprintf(" %-15s %-10s %s\n", "----", "----", "------------"))
|
||||||
|
for _, r := range s.rooms {
|
||||||
|
ago := time.Since(r.lastCleaned).Truncate(time.Minute)
|
||||||
|
b.WriteString(fmt.Sprintf(" %-15s %-10s %s ago\n", r.name, fmt.Sprintf("%d sqft", r.areaSqFt), formatDuration(ago)))
|
||||||
|
}
|
||||||
|
return commandResult{output: b.String()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *roombaState) cmdSchedule(args []string) commandResult {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return s.scheduleList()
|
||||||
|
}
|
||||||
|
|
||||||
|
sub := strings.ToLower(args[0])
|
||||||
|
switch sub {
|
||||||
|
case "add":
|
||||||
|
if len(args) < 3 {
|
||||||
|
return commandResult{output: "Usage: schedule add <day> <time>\nExample: schedule add Tuesday 10:00"}
|
||||||
|
}
|
||||||
|
return s.scheduleAdd(args[1], args[2])
|
||||||
|
case "remove":
|
||||||
|
if len(args) < 2 {
|
||||||
|
return commandResult{output: "Usage: schedule remove <day>"}
|
||||||
|
}
|
||||||
|
return s.scheduleRemove(args[1])
|
||||||
|
default:
|
||||||
|
return commandResult{output: fmt.Sprintf("Unknown schedule subcommand '%s'. Try: add, remove", sub)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *roombaState) scheduleList() commandResult {
|
||||||
|
if len(s.schedule) == 0 {
|
||||||
|
return commandResult{output: "No cleaning schedule configured."}
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("=== Cleaning Schedule ===\n")
|
||||||
|
b.WriteString(fmt.Sprintf(" %-12s %s\n", "DAY", "TIME"))
|
||||||
|
b.WriteString(fmt.Sprintf(" %-12s %s\n", "---", "----"))
|
||||||
|
for _, e := range s.schedule {
|
||||||
|
b.WriteString(fmt.Sprintf(" %-12s %s\n", e.day, e.time))
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf("\n%d scheduled cleaning(s)", len(s.schedule)))
|
||||||
|
return commandResult{output: b.String()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *roombaState) scheduleAdd(day, t string) commandResult {
|
||||||
|
day = capitalizeFirst(strings.ToLower(day))
|
||||||
|
validDays := []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"}
|
||||||
|
valid := false
|
||||||
|
for _, d := range validDays {
|
||||||
|
if d == day {
|
||||||
|
valid = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
return commandResult{output: fmt.Sprintf("Invalid day '%s'. Use a day of the week (e.g. Monday, Tuesday).", day)}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, e := range s.schedule {
|
||||||
|
if strings.EqualFold(e.day, day) {
|
||||||
|
return commandResult{output: fmt.Sprintf("Schedule for %s already exists. Remove it first.", day)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.schedule = append(s.schedule, scheduleEntry{day: day, time: t})
|
||||||
|
return commandResult{output: fmt.Sprintf("Scheduled cleaning added: %s at %s", day, t)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *roombaState) scheduleRemove(day string) commandResult {
|
||||||
|
day = capitalizeFirst(strings.ToLower(day))
|
||||||
|
for i, e := range s.schedule {
|
||||||
|
if strings.EqualFold(e.day, day) {
|
||||||
|
s.schedule = append(s.schedule[:i], s.schedule[i+1:]...)
|
||||||
|
return commandResult{output: fmt.Sprintf("Removed schedule for %s.", day)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return commandResult{output: fmt.Sprintf("No schedule found for '%s'.", day)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *roombaState) cmdHistory() commandResult {
|
||||||
|
if len(s.cleanHistory) == 0 {
|
||||||
|
return commandResult{output: "No cleaning history."}
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("=== Cleaning History ===\n")
|
||||||
|
b.WriteString(fmt.Sprintf(" %-20s %-15s %-10s %s\n", "TIME", "ROOM", "DURATION", "NOTE"))
|
||||||
|
b.WriteString(fmt.Sprintf(" %-20s %-15s %-10s %s\n", "----", "----", "--------", "----"))
|
||||||
|
for _, h := range s.cleanHistory {
|
||||||
|
ts := h.timestamp.Format("2006-01-02 15:04")
|
||||||
|
b.WriteString(fmt.Sprintf(" %-20s %-15s %-10s %s\n", ts, h.room, h.duration, h.note))
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf("\n%d session(s) recorded", len(s.cleanHistory)))
|
||||||
|
return commandResult{output: b.String()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *roombaState) cmdDiagnostics() commandResult {
|
||||||
|
diag := `Running RoombaOS diagnostics...
|
||||||
|
|
||||||
|
[1/8] Cliff sensors........... OK
|
||||||
|
[2/8] Bumper sensor........... OK
|
||||||
|
[3/8] Side brush motor........ OK (142 hrs until replacement)
|
||||||
|
[4/8] Main brush motor........ OK (98 hrs until replacement)
|
||||||
|
[5/8] Wheel motors............ OK (L: 1204 hrs, R: 1204 hrs)
|
||||||
|
[6/8] LIDAR module............ OK (last calibrated 3 days ago)
|
||||||
|
[7/8] Dustbin sensor.......... OK
|
||||||
|
[8/8] WiFi module............. OK (signal: -38 dBm)
|
||||||
|
|
||||||
|
ALL SYSTEMS NOMINAL`
|
||||||
|
return commandResult{output: diag}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *roombaState) cmdAlerts() commandResult {
|
||||||
|
var alerts []string
|
||||||
|
if s.dustbin >= 60 {
|
||||||
|
alerts = append(alerts, fmt.Sprintf("WARNING: Dustbin %d%% full - consider emptying", s.dustbin))
|
||||||
|
}
|
||||||
|
alerts = append(alerts,
|
||||||
|
"WARNING: Side brush replacement due in 12 hours",
|
||||||
|
"INFO: Unidentified sticky substance detected in Kitchen",
|
||||||
|
"INFO: Cat frequently blocking cleaning path in Cat's Room",
|
||||||
|
"INFO: Firmware update available: v4.4.0-beta",
|
||||||
|
"INFO: Filter replacement recommended in 14 days",
|
||||||
|
)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("=== Active Alerts ===\n")
|
||||||
|
for _, a := range alerts {
|
||||||
|
b.WriteString(a + "\n")
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf("\n%d alert(s) active", len(alerts)))
|
||||||
|
return commandResult{output: b.String()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *roombaState) cmdReboot() commandResult {
|
||||||
|
reboot := `RoombaOS is rebooting...
|
||||||
|
|
||||||
|
Stopping navigation engine..... done
|
||||||
|
Saving room map data........... done
|
||||||
|
Flushing cleaning logs......... done
|
||||||
|
Disconnecting from WiFi........ done
|
||||||
|
|
||||||
|
Rebooting now. Goodbye!`
|
||||||
|
return commandResult{output: reboot, exit: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func capitalizeFirst(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return strings.ToUpper(s[:1]) + s[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDuration(d time.Duration) string {
|
||||||
|
hours := int(d.Hours())
|
||||||
|
minutes := int(d.Minutes()) % 60
|
||||||
|
if hours >= 24 {
|
||||||
|
days := hours / 24
|
||||||
|
hours = hours % 24
|
||||||
|
return fmt.Sprintf("%dd %dh", days, hours)
|
||||||
|
}
|
||||||
|
if hours > 0 {
|
||||||
|
return fmt.Sprintf("%dh %dm", hours, minutes)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dm", minutes)
|
||||||
|
}
|
||||||
@@ -34,6 +34,11 @@ password = "admin"
|
|||||||
# password = "cisco"
|
# password = "cisco"
|
||||||
# shell = "cisco"
|
# shell = "cisco"
|
||||||
|
|
||||||
|
# [[auth.static_credentials]]
|
||||||
|
# username = "irobot"
|
||||||
|
# password = "roomba"
|
||||||
|
# shell = "roomba"
|
||||||
|
|
||||||
[storage]
|
[storage]
|
||||||
db_path = "oubliette.db"
|
db_path = "oubliette.db"
|
||||||
retention_days = 90
|
retention_days = 90
|
||||||
@@ -75,6 +80,9 @@ hostname = "ubuntu-server"
|
|||||||
# db_name = "postgres"
|
# db_name = "postgres"
|
||||||
# pg_version = "15.4"
|
# pg_version = "15.4"
|
||||||
|
|
||||||
|
# [shell.roomba]
|
||||||
|
# No configuration options currently.
|
||||||
|
|
||||||
# [detection]
|
# [detection]
|
||||||
# enabled = true
|
# enabled = true
|
||||||
# threshold = 0.6 # 0.0–1.0, sessions above this trigger notifications
|
# threshold = 0.6 # 0.0–1.0, sessions above this trigger notifications
|
||||||
|
|||||||
Reference in New Issue
Block a user