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:
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)
|
||||
}
|
||||
Reference in New Issue
Block a user