From c9d143d84bb0a785318e8edc2aa4c4430bafb9c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torjus=20H=C3=A5kestad?= Date: Wed, 18 Feb 2026 14:06:59 +0100 Subject: [PATCH] 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 --- PLAN.md | 7 +- README.md | 2 +- cmd/oubliette/main.go | 2 +- internal/server/server.go | 4 + internal/shell/roomba/roomba.go | 469 ++++++++++++++++++++++++++++++++ oubliette.toml.example | 8 + 6 files changed, 489 insertions(+), 3 deletions(-) create mode 100644 internal/shell/roomba/roomba.go diff --git a/PLAN.md b/PLAN.md index 8901da1..c77df99 100644 --- a/PLAN.md +++ b/PLAN.md @@ -179,7 +179,12 @@ Goal: Add the entertaining shell implementations. - DDL/DML acknowledgments (CREATE TABLE, INSERT, UPDATE, DELETE, etc.) - 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" - **ELIZA therapist:** every response is a therapy question - **Pizza ordering terminal:** "Welcome to PizzaNet v2.3" diff --git a/README.md b/README.md index 0a54271..5794b5b 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Key settings: - `auth.accept_after` — accept login after N failures per IP (default `10`) - `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) -- 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 - `storage.db_path` — SQLite database path (default `oubliette.db`) - `storage.retention_days` — auto-prune records older than N days (default `90`) diff --git a/cmd/oubliette/main.go b/cmd/oubliette/main.go index a83c5db..74f8a65 100644 --- a/cmd/oubliette/main.go +++ b/cmd/oubliette/main.go @@ -20,7 +20,7 @@ import ( "git.t-juice.club/torjus/oubliette/internal/web" ) -const Version = "0.15.0" +const Version = "0.16.0" func main() { if err := run(); err != nil { diff --git a/internal/server/server.go b/internal/server/server.go index e4229c3..bf8ff5f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -25,6 +25,7 @@ import ( "git.t-juice.club/torjus/oubliette/internal/shell/cisco" "git.t-juice.club/torjus/oubliette/internal/shell/fridge" 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" "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 { 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() if err != nil { diff --git a/internal/shell/roomba/roomba.go b/internal/shell/roomba/roomba.go new file mode 100644 index 0000000..eb1650d --- /dev/null +++ b/internal/shell/roomba/roomba.go @@ -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 - Clean a specific room + dock - Return to dock + map - Show floor plan and room list + schedule - List cleaning schedule + schedule add