Replace unnecessary fmt.Sprintf calls with string literals, use slices.Contains instead of manual loop, and use compound assignment operator. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
464 lines
14 KiB
Go
464 lines
14 KiB
Go
package roomba
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"slices"
|
|
"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("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("Side brush: OK (142 hrs)\n")
|
|
b.WriteString("Main brush: OK (98 hrs)\n")
|
|
b.WriteString("\n")
|
|
b.WriteString("WiFi: Connected (SmartHome-5G)\n")
|
|
b.WriteString("Signal: -38 dBm\n")
|
|
b.WriteString("Alexa: Linked\n")
|
|
b.WriteString("Google Home: Linked\n")
|
|
b.WriteString("iRobot Home App: Connected\n")
|
|
b.WriteString("\n")
|
|
b.WriteString("Firmware: v4.3.7-stable\n")
|
|
b.WriteString("LIDAR: Operational\n")
|
|
b.WriteString("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"}
|
|
if !slices.Contains(validDays, day) {
|
|
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 %= 24
|
|
return fmt.Sprintf("%dd %dh", days, hours)
|
|
}
|
|
if hours > 0 {
|
|
return fmt.Sprintf("%dh %dm", hours, minutes)
|
|
}
|
|
return fmt.Sprintf("%dm", minutes)
|
|
}
|