#!/usr/bin/env python """ A python library for controlling an iRobot cleaning robot Only the Roomba 980 is tested; the Roomba 960 should work and possibly the Braava Jet """ from __future__ import print_function import calendar import collections import datetime from enum import Enum import json import requests import socket import struct # Disable SSL warning from requests - the Roomba's SSL certificate is self signed from requests.packages.urllib3.exceptions import InsecureRequestWarning requests.packages.urllib3.disable_warnings(InsecureRequestWarning) # Monkey patch the json module to be able to encode Enums and datetime.time _json_default = json.JSONEncoder().default def _encode_enum(self, obj): if isinstance(obj, Enum): return obj.name if isinstance(obj, datetime.time): return str(obj) return _json_default(self, obj) json.JSONEncoder.default = _encode_enum class CarpetBoost(Enum): Unknown = -1 Auto = 0 Eco = 16 Perf = 80 @classmethod def PrefName(cls): return "carpetBoost" class CleaningPasses(Enum): Unknown = -2 Auto = 0 One = 1024 Two = 1025 @classmethod def PrefName(cls): return "cleaningPasses" class FinishWhenBinFull(Enum): Unknown = -4 On = 0 Off = 32 @classmethod def PrefName(cls): return "finishWhenBinFull" class EdgeClean(Enum): Unknown = -8 On = 0 Off = 2 @classmethod def PrefName(cls): return "edgeClean" class MissionState(Enum): Unknown = -1 BinFull = 1 BinMissing = 2 Normal = 4 Resuming = 8 class RobotStatus(Enum): Unknown = "unknown" Idle = "none" Cleaning = "run" Stopped = "stop" Charging = "charge" Resuming = "resume" ReturningHome = "hmPostMsn" Cancelling = "hmUsrDock" Stuck = "stuck" class BinStatus(Enum): Unknown = -1, Normal = 0, Full = 1, Missing = 2 class ReadyStatus(Enum): Unknown = -1 Ready = 0 Cliff = 1 BothWheelsDropped = 2 LeftWheelDropped = 3 RightWheelDropped = 4 BinMissing = 7 Charging = 15 BinFull = 16 # From http://homesupport.irobot.com/app/answers/detail/a_id/9024/~/roomba-900-error-messages _ErrorMessages = { 1 : "Roomba is stuck with its left or right wheel hanging down.", 2 : "The debris extractors can't turn.", 5 : "The left or right wheel is stuck.", 6 : "The cliff sensors are dirty, it is hanging over a drop, or it is stuck on a dark surface.", 8 : "The fan is stuck or its filter is clogged.", 9 : "The bumper is stuck, or the bumper sensor is dirty.", 10: "The left or right wheel is not moving.", 11: "Roomba has an internal error.", 14: "The bin has a bad connection to the robot.", 15: "Roomba has an internal error.", 16: "Roomba has started while moving or at an angle, or was bumped while running.", 17: "The cleaning job is incomplete.", 18: "Roomba cannot return to the Home Base or starting position." } _MissionCycleToCleaningPasses = { "quick" : CleaningPasses.One, "clean" : CleaningPasses.Two } class RobotError(Exception): """ Exception thrown when there is an error """ def __init__(self, errorCode): super(RobotError, self).__init__() self.errorCode = errorCode def __str__(self): return "Error code {}".format(self.errorCode) class Robot(object): """ This object represents an iRobot cleaning robot """ @staticmethod def GetPassword(robotIP): """ Get the password for this robot Before calling this method, place the robot on its dock and then hold down the home button for 3-4 seconds, until the LEDs illuminate and the robot emits a series of tones. Then quickly call this method Returns: The robot password (str) """ result = requests.post("https://{}/umi".format(robotIP), data=json.dumps({"do" : "get", "args" : ["passwd"], "id" : 0}), headers={"Content-Type" : "application/json"}, verify=False) res = result.json() if "err" in res: raise RobotError(res["err"]) return res["ok"]["passwd"] @staticmethod def GetBLID(robotIP, password): """ Get this robot's BLID, which you need for making cloud-based calls to the robot Returns: The robot BLID (str) """ result = requests.post("https://{}/umi".format(robotIP), data=json.dumps({"do" : "get", "args" : ["sys"], "id" : 0}), auth=("user", password), headers={"Content-Type" : "application/json"}, verify=False) res = result.json() if "err" in res: raise RobotError(res["err"]) return "".join([i[2:] for i in map(hex, res["ok"]["blid"])]) def __init__(self, robotIP, robotPassword): self.ip = robotIP self.password = robotPassword self.nextID = 1 def _GetRequestID(self): """ Get a unique request ID Returns: A request ID (int) """ rid = self.nextID self.nextID += 1 return rid def _PostToRobot(self, cmd, args): """ Send a command to the robot and get the response Args: cmd: the "do" argument for the request (str) args: the "args" argument for the request (str or list) Returns: The JSON response parsed into a dictionary (dict) """ if isinstance(args, str) or not isinstance(args, collections.Iterable): args = [args] post_data = json.dumps({"do" : cmd, "args" : args, "id" : self._GetRequestID()}) # print(post_data) result = requests.post("https://{}/umi".format(self.ip), data=post_data, auth=("user", self.password), headers={"Content-Type" : "application/json"}, verify=False) # print(result.request.body) # print(result.text) res = result.json() if "err" in res: raise RobotError(res["err"]) return res["ok"] def _DecodePreferencesFlags(self, flags): """ Decode the 'flags' field from a preferences call into individual preferences enums. Args: flags: the integer flags value (int) Returns: A dictionary of preferences (dict) """ prefs = {} for conf in (CarpetBoost, CleaningPasses, FinishWhenBinFull, EdgeClean): pref_name = conf.PrefName() test = flags & max(conf, key=lambda x: x.value).value try: prefs[pref_name] = conf(test) except ValueError: prefs[pref_name] = conf["Unknown"] return prefs def _EncodePreferencesFlags(self, prefs): """ Encode a dictionary of preferences into a single 'flags' integer Args: prefs: a dictionary of preferences (dict) Returns: An integer representing the value of the preferences (int) """ flags = 0 for conf in (CarpetBoost, CleaningPasses, FinishWhenBinFull, EdgeClean): pref_name = conf.PrefName() assert pref_name in prefs, "{} must be in prefs".format(pref_name) assert isinstance(prefs[pref_name], conf), "{} must be a {} enum".format(pref_name, conf.__name__) flags += prefs[pref_name].value return flags def StartCleaning(self): """ Start a cleaning cycle """ self._PostToRobot("set", ["cmd", {"op" : "start"}]) def PauseCleaning(self): """ Pause the current cleaning cycle This command has no effect if the robot is not currently cleaning """ self._PostToRobot("set", ["cmd", {"op" : "pause"}]) def ResumeCleaning(self): """ Resume a paused cleaning cycle This command has no effect if the robot is not currently paused """ self._PostToRobot("set", ["cmd", {"op" : "resume"}]) def EndCleaning(self): """ End the current cleaning cycle This command has no effect if the robot is not currently cleaning or paused """ self._PostToRobot("set", ["cmd", {"op" : "stop"}]) def ReturnHome(self): """ Send the robot back to the home dock The robot must be stopped or paused first """ self._PostToRobot("set", ["cmd", {"op" : "dock"}]) def GetCleaningPreferences(self): """ Get this robot's cleaning preferences Returns: A dictionary of preferences (dict) """ result = self._PostToRobot("get", "prefs") prefs = {} for key, value in list(result.items()): if key == "flags": continue prefs[key] = value # Decode the flags prefs.update(self._DecodePreferencesFlags(result["flags"])) return prefs def GetTime(self): """ Get the time this robot is set to Returns: A dictionary with the time of day and day of week (dict) """ result = self._PostToRobot("get", "time") day_idx = [idx for idx, day in enumerate(calendar.day_abbr) if day.lower() == result["d"]][0] return { "time" : datetime.time(result["h"], result["m"]), "weekday" : calendar.day_name[day_idx] } def GetSchedule(self): """ Get the cleaning schedule for this robot Returns: A dictionary representing the schedule per day (dict) """ res = self._PostToRobot("get", "week") schedule = {} for idx in range(7): cal_day_idx = idx - 1 if cal_day_idx < 0: cal_day_idx = 6 schedule[calendar.day_name[cal_day_idx]] = { "clean" : True if res["cycle"][idx] == "start" else False, "startTime" : datetime.time(res["h"][idx], res["m"][idx]) } return schedule def GetMission(self): """ Get the real-time status and position of the robot Returns: A dictionary with the current robot status (dict) """ res = self._PostToRobot("get", "mssn") # Transform the data to be more user friendly and closer to how the app presents it res["batteryPercentage"] = res.pop("batPct") if res["expireM"] <= 0: res.pop("expireM") else: res["minutesUntilMissionCancelled"] = res.pop("expireM") res["missionElapsedMinutes"] = res.pop("mssnM") try: res["readyStatus"] = ReadyStatus(res["notReady"]) except ValueError: res["readyStatus"] = ReadyStatus.Unknown if res["readyStatus"] != ReadyStatus.Unknown: res.pop("notReady") res["robotPosition"] = res.pop("pos") if res["rechrgM"] <= 0: res.pop("rechrgM") else: res["rechargeMinutesRemaining"] = res.pop("rechrgM") res["missionCoveredSquareFootage"] = res.pop("sqft") res["binStatus"] = BinStatus.Normal if res["flags"] & MissionState.BinMissing.value == MissionState.BinMissing.value: res["binStatus"] = BinStatus.Missing elif res["flags"] & MissionState.BinFull.value == MissionState.BinFull.value: res["binStatus"] = BinStatus.Full try: res["robotStatus"] = RobotStatus(res["phase"]) except ValueError: res["robotStatus"] = RobotStatus.Unknown if res["robotStatus"] == RobotStatus.Cleaning and res["flags"] & MissionState.Resuming.value == MissionState.Resuming.value: res["robotStatus"] = RobotStatus.Resuming if res["robotStatus"] != RobotStatus.Unknown: res.pop("flags") res.pop("phase") if res["error"] == 0: res.pop("error") elif res["error"] in _ErrorMessages: res["errorMessage"] = _ErrorMessages[res["error"]] if res["cycle"] == "none": res.pop("cycle") elif res["cycle"] in _MissionCycleToCleaningPasses: res["cycle"] = _MissionCycleToCleaningPasses[res["cycle"]] return res def GetWiFiDetails(self): """ Get detailed information about the robot's WiFi connection Returns: A dictionary of wifi information (dict) """ res = self._PostToRobot("get", "wlstat") # Transform the data to be more user friendly and closer to how the app presents it res["bssid"] = ":".join([i[2:] for i in map(hex, res["bssid"])]) res["dhcp"] = True if res["dhcp"] == 1 else False res["ipAddress"] = socket.inet_ntoa(struct.pack("I", res.pop("addr"))) res["subnetMask"] = socket.inet_ntoa(struct.pack("I", res.pop("mask"))) res["router"] = socket.inet_ntoa(struct.pack("I", res.pop("gtwy"))) res["dns1"] = socket.inet_ntoa(struct.pack("I", res.pop("dns1"))) res["dns2"] = socket.inet_ntoa(struct.pack("I", res.pop("dns2"))) res["signalStrength"] = res.pop("strssi") res["securityType"] = "WPA2" if res["sec"] == 4 else str(res["sec"]) res.pop("sec") return res def GetWiFiStatus(self): """ Get a simple check of the robot's WiFi status Returns: A dictionary of status (dict) """ res = self._PostToRobot("get", "wllaststat") # Transform data to better match GetWiFiDetails res["signalStrength"] = res.pop("strssi") return res def GetCloudConfig(self): return self._PostToRobot("get", "cloudcfg") def GetSKU(self): return self._PostToRobot("get", "sku") def GetSys(self): return self._PostToRobot("get", "sys") def GetBBRun(self): return self._PostToRobot("get", "bbrun") def GetWiFiSettings(self): return self._PostToRobot("get", "wlconfig") def GetStatus(self): """ Get a combined view of preferences and mission in a single call """ return { "cleaningPreferences" : self.GetCleaningPreferences(), "mission" : self.GetMission() } def SetCleaningPreferences(self, prefs): """ Set the cleaning preferences for this robot. All of the fields are required in the preferences dictionary, even if you are not changing them. The easiest way to use this function is to call GetCleaningPreferences, modify the result, and use that as the input to this function. Args: prefs: a dictionary of preferences """ newprefs = collections.OrderedDict([ ("flags", self._EncodePreferencesFlags(prefs)), ("lang", prefs["lang"]), ("timezone", prefs["timezone"]), ("name", prefs["name"]) ]) self._PostToRobot("set", ["prefs", newprefs]) def SetCarpetBoost(self, newValue): """ Set the Carpet Boost cleaning preference Args: newValue: the value to set (CarpetBoost) """ assert isinstance(newValue, CarpetBoost), "newValue must be a CarpetBoost enum value" prefs = self.GetCleaningPreferences() prefs[CarpetBoost.PrefName()] = newValue self.SetCleaningPreferences(prefs) def SetCleaningPasses(self, newValue): """ Set the Cleaning Passes cleaning preference Args: newValue: the value to set (CleaningPasses) """ assert isinstance(newValue, CleaningPasses), "newValue must be a CleaningPasses enum value" prefs = self.GetCleaningPreferences() prefs[CleaningPasses.PrefName()] = newValue self.SetCleaningPreferences(prefs) def SetFinishWhenBinFull(self, newValue): """ Set the Finish When Bin Full cleaning preference Args: newValue: the value to set (FinishWhenBinFull) """ assert isinstance(newValue, FinishWhenBinFull), "newValue must be a FinishWhenBinFull enum value" prefs = self.GetCleaningPreferences() prefs[FinishWhenBinFull.PrefName()] = newValue self.SetCleaningPreferences(prefs) def SetEdgeClean(self, newValue): """ Set the Edge Clean cleaning preference Args: newValue: the value to set (EdgeClean) """ assert isinstance(newValue, EdgeClean), "newValue must be an EdgeClean enum value" prefs = self.GetCleaningPreferences() prefs[EdgeClean.PrefName()] = newValue self.SetCleaningPreferences(prefs) def SetTimezone(self, newValue): """ Set the robot's timezone. The time zone must be a tz database name. https://en.wikipedia.org/wiki/List_of_tz_database_time_zones For instance, common US time zones: Pacific time: 'America/Los_Angeles' or 'US/Pacific' Mountain time: 'America/Denver' or 'US/Mountain' Central time: 'America/Chicago' or 'US/Central' Eastern time: 'America/New_York' or 'US/Eastern' Args: newValue: the time zone name (str) """ prefs = self.GetCleaningPreferences() prefs["timezone"] = newValue self.SetCleaningPreferences(prefs) def SetTime(self, newTime): """ Set the robot's time. The robot only cares about weekday, hour and minute, so those are the only fields that need to be accurate in the datetime object. Args: newTime: the time to set the robot to (datetime) """ weekday = newTime.isoweekday() if weekday > 6: weekday = 0 self._PostToRobot("set", ["time", collections.OrderedDict([ ("d", weekday), ("h", newTime.hour), ("m", newTime.minute)]) ]) def SetTimeNow(self): """ Set the robot's time to the current time """ self.SetTime(datetime.datetime.now()) def SetSchedule(self, newSchedule): """ Set the cleaning schedule for this robot. The easiest way to use this function is to call GetSchedule, modify the result, and use that as the input to this function Args: schedule: the schedule to set (dict) """ # Sort calendar day names into the order the robot expects days = {} for cal_idx, dayname in enumerate(calendar.day_name): idx = cal_idx + 1 if cal_idx < 6 else 0 days[idx] = dayname sched = collections.OrderedDict([ ("cycle", []), ("h", []), ("m", []) ]) for idx in sorted(days): dayname = days[idx] if newSchedule[dayname]["clean"]: sched["cycle"].append("start") else: sched["cycle"].append("none") sched["h"].append(newSchedule[dayname]["startTime"].hour) sched["m"].append(newSchedule[dayname]["startTime"].minute) self._PostToRobot("set", ["week", sched])