Roomba 980 Wifi Connectivity Reverse engineering

Hi all, I’ve improved upon Elfege’s device type to make a larger state machine out of the Roomba virtual switch so that it more closely follows the iRobot App experience.

Here is my unpublished github repo and here is the code block for copy/paste consumption:
Update 1: Included a ‘bin full’ status
Update 2: More error handling, included some additional valueTile, made pollingInterval a setting (default - 4min)
Update 3: Added the Consumable capability in order to allow CORE SmartApps to be used for notifications!

/**
*  Roomba 9xx - Virtual Switch
*
*  Copyright 2016 Steve-Gregory
*
*  Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
*  in compliance with the License. You may obtain a copy of the License at:
*
*      http://www.apache.org/licenses/LICENSE-2.0
*
*  Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
*  on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
*  for the specific language governing permissions and limitations under the License.
*
*/
metadata {
    definition (name: "Roomba 9xx - Virtual Switch", namespace: "Steve-Gregory", author: "Steve-Gregory") {
        capability "Switch"
        capability "Refresh"
        capability "Polling"
        capability "Consumable"
        capability "Configuration"

        command "dock"
        command "refresh"
        command "resume"
        command "pause"

        attribute "batteryLevel", "number"
        attribute "totalJobs", "number"
        attribute "totalJobHrs", "number"
        attribute "headline", "string"
        attribute "robotName", "string"
        attribute "preferences_set", "string"
        attribute "status", "string"
    }
}
// simulator metadata
simulator {
}
//Preferences
preferences {
        section("Roomba Credentials") {
            input title: "Roomba Credentials", description: "The username/password can be retrieved via node.js & dorita980", displayDuringSetup: true, type: "paragraph", element: "paragraph"
            input "roomba_username", "text", title: "Roomba username/blid", required: true, displayDuringSetup: true
            input "roomba_password", "password", title: "Roomba password", required: true, displayDuringSetup: true
            input "roomba_host", "string", title:"Roomba Host (Default: Use the Cloud)", defaultValue:""
        }
        section("Misc.") {
            input title: "Polling Interval", description: "This feature allows you to change the frequency of polling for the robot in minutes (1-59)", displayDuringSetup: true, type: "paragraph", element: "paragraph"
            input "pollInterval", "number", title: "Polling Interval", description: "Change polling frequency (in minutes)", defaultValue:4, range: "1..59", required: true, displayDuringSetup: true
        }
}
// Settings updated
def updated() {
    //log.debug "Updated settings ${settings}..
    schedule("0 0/${settings.pollInterval} * * * ?", poll)  // 4min polling is normal for irobots
    poll()
}
// Configuration
def configure() {
    log.debug "Configuring.."
    poll()
}
//Consumable
def setConsumableStatus(statusString) {
    log.debug "User requested setting the Consumable Status - ${statusString}"
    def status = device.latestValue("status")
    log.debug "Setting value based on last roomba state - ${status}"
    if(status == "bin-full") {
        return "maintenance_required"
    } else {
        return "good"
    }
}
//Refresh
def refresh() {
    log.debug "Executing 'refresh'"
    poll()
}
//Polling
def poll() {
    log.debug "Polling for status ----"
    sendEvent(name: "headline", value: "Polling the API", displayed: false)
    state.RoombaCmd = "getStatus"
    apiGet()
}
// UI tile definitions
tiles {

    multiAttributeTile(name:"CLEAN", type:"lighting", width: 6, height: 4, canChangeIcon: true) {
        tileAttribute("device.status", key: "PRIMARY_CONTROL") {
            attributeState "error", label: 'Error', icon: "st.switches.switch.off", backgroundColor: "#bc2323" // No action allowed here
            attributeState "bin-full", label: 'Bin Full', icon: "st.switches.switch.off", backgroundColor: "#bc2323" // No action allowed here
            attributeState "docked", label: 'Start Clean', action: "switch.on", icon: "st.switches.switch.off", backgroundColor: "#ffffff", nextState: "starting"
            attributeState "docking", label: 'Docking', icon: "st.switches.switch.off", backgroundColor: "#ffa81e" // No action allowed here
            attributeState "starting", label: 'Starting Clean', icon: "st.switches.switch.off", backgroundColor: "#ffffff"
            attributeState "cleaning", label: 'Stop Clean', action: "stop", icon: "st.switches.switch.on", backgroundColor: "#79b821"
            attributeState "pausing", label: 'Stop Clean', icon: "st.switches.switch.on", backgroundColor: "#79b821" // No action allowed here
            attributeState "paused", label: 'Send Home', action: "switch.off", icon: "st.switches.switch.on", backgroundColor: "#79b821", nextState: "docking"
            attributeState "resuming", label: 'Stop Clean', icon: "st.switches.switch.on", backgroundColor: "#79b821" // No action allowed here
        }
        tileAttribute("device.headline", key: "SECONDARY_CONTROL") {
           attributeState "default", label:'${currentValue}'
        }
        tileAttribute("device.batteryLevel", key: "SLIDER_CONTROL") {
        }
    }
    standardTile("DOCK", "device.status", width: 2, height: 2) {
        state "docked", label: 'Docked', backgroundColor: "#79b821" // No action allowed here
        state "docking", label: 'Docking', backgroundColor: "#ffa81e" // No action allowed here
        state "starting", label: 'UnDocking', backgroundColor: "#ffa81e" // No action allowed here
        state "cleaning", label: 'Not on Dock', backgroundColor: "#ffffff", nextState: "docking"
        state "pausing", label: 'Not on Dock', backgroundColor: "#ffffff", nextState: "docking" // No action allowed here
        state "paused", label: 'Dock', action: "dock", backgroundColor: "#ffffff", nextState: "docking"
        state "resuming", label: 'Not on Dock', backgroundColor: "#ffffff", defaultState: true // No action allowed here
    }
    standardTile("PAUSE", "device.status", width: 2, height: 2) {
        state "docked", label: 'Pause', backgroundColor: "#ffffff", defaultState: true // No action allowed here
        state "docking", label: 'Pause', backgroundColor: "#ffffff" // No action allowed here
        state "starting", label: 'Pause', backgroundColor: "#ffffff" // No action allowed here
        state "cleaning", label: 'Pause', action: "pause", backgroundColor: "#ffffff"
        state "pausing", label: 'Pausing..', backgroundColor: "#79b821" // No action allowed here
        state "paused", label: 'Paused', backgroundColor: "#79b821" // No action allowed here
        state "resuming", label: 'Pause', backgroundColor: "#ffffff" // No action allowed here
    }
    standardTile("RESUME", "device.status", width: 2, height: 2) {
        state "docked", label: 'Resume', backgroundColor: "#ffffff", defaultState: true // No action allowed here
        state "docking", label: 'Resume', backgroundColor: "#ffffff" // No action allowed here
        state "starting", label: 'Resume', backgroundColor: "#ffffff" // No action allowed here
        state "cleaning", label: 'Resume', backgroundColor: "#ffffff" // No action allowed here
        state "pausing", label: 'Resume', backgroundColor: "#79b821" // No action allowed here
        state "paused", label: 'Resume', action: "resume", backgroundColor: "#ffffff"
        state "resuming", label: 'Resuming..', backgroundColor: "#79b821" // No action allowed here
    }
    standardTile("refresh", "device.status", width: 6, height: 2, decoration: "flat") {
        state "default", label:'Refresh', action:"refresh.refresh", icon:"st.secondary.refresh"
    }
    valueTile("job_count", "device.totalJobs", width: 3, height: 1, decoration: "flat") {
        state "default", label:'Number of Cleaning Jobs:\n${currentValue} jobs'
    }
    valueTile("job_hr_count", "device.totalJobHrs", width: 3, height: 1, decoration: "flat") {
        state "default", label:'Total Job Time:\n${currentValue} hours'
    }
    valueTile("current_job_time", "device.runtimeMins", width: 3, height: 1, decoration: "flat") {
        state "default", label:'Current Job Runtime:\n${currentValue} minutes'
    }
    main "CLEAN"
    details(["STATUS",
             "CLEAN", "DOCK", "PAUSE", "RESUME",
             "refresh", "job_count", "job_hr_count", "current_job_time"])
}
// Switch methods
def on() {
    def status = device.latestValue("status")
    log.debug "On based on state - ${status}"
    if(status == "paused") {
        resume()
    } else {
        start()
    }
}
def off() {
    def status = device.latestValue("status")
    log.debug "Off based on state - ${status}"
    if(status == "paused") {
        dock()
    } else {
        stop()
    }
}
// Actions
def start() {
    sendEvent(name: "status", value: "starting")
    state.RoombaCmd = "start"
    apiGet()
    runIn(30, poll)
}
def stop() {
    sendEvent(name: "status", value: "stopping")
    state.RoombaCmd = "stop"
    apiGet()
    runIn(30, poll)
}
def dock() {
    sendEvent(name: "status", value: "docking")
    state.RoombaCmd = "dock"
    apiGet()
    runIn(30, poll)
}
def pause() {
    sendEvent(name: "status", value: "pausing")
    state.RoombaCmd = "pause"
    apiGet()
    runIn(30, poll)
}
def resume() {
    sendEvent(name: "status", value: "resuming")
    state.RoombaCmd = "resume"
    apiGet()
    runIn(30, poll)
}
// API methods
def parse(description) {
    def msg = parseLanMessage(description)
    def headersAsString = msg.header // => headers as a string
    def headerMap = msg.headers      // => headers as a Map
    def body = msg.body              // => request body as a string
    def status = msg.status          // => http status code of the response
    def json = msg.json              // => any JSON included in response body, as a data structure of lists and maps
    def xml = msg.xml                // => any XML included in response body, as a document tree structure
    def data = msg.data              // => either JSON or XML in response body (whichever is specified by content-type header in response)
}
def apiGet() {
    def request_query = ""
    def request_host = ""
    def encoded_str = "${roomba_username}:${roomba_password}".bytes.encodeBase64()

    //Handle prefrences
    if("${roomba_host}" == "" || "${roomba_host}" == "null") {
        request_host = "https://irobot.axeda.com"
    } else {
        log.debug "Using Roomba Host: ${roomba_host}"
        request_host = "${roomba_host}"
    }

    //Validation before calling the API
    if(!roomba_username || !roomba_password) {
        def new_status = "Username/Password not set. Configure required before using device."
        sendEvent(name: "headline", value: new_status, displayed: false)
        sendEvent(name: "preferences_set", value: "missing", displayed: false)
        return
    } else if(state.preferences_set != "missing") {
        sendEvent(name: "preferences_set", value: "ready", displayed: false)
    }

    state.AssetID = "ElPaso@irobot!${roomba_username}"
    state.Authorization = "${encoded_str}"

    // Path (No changes required)
    def request_path = "/services/v1/rest/Scripto/execute/AspenApiRequest"
    // Query manipulation
    if( state.RoombaCmd == "getStatus" || state.RoombaCmd == "accumulatedHistorical" || state.RoombaCmd == "missionHistory") {
        request_query = "?blid=${roomba_username}&robotpwd=${roomba_password}&method=${state.RoombaCmd}"
    } else {
        request_query = "?blid=${roomba_username}&robotpwd=${roomba_password}&method=multipleFieldSet&value=%7B%0A%20%20%22remoteCommand%22%20:%20%22${state.RoombaCmd}%22%0A%7D"
    }

    def requestURI = "${request_host}${request_path}${request_query}"
    def httpRequest = [
        method:"GET",
        uri: "${requestURI}",
        headers: [
            'User-Agent': 'aspen%20production/2618 CFNetwork/758.3.15 Darwin/15.4.0',
            Accept: '*/*',
            'Accept-Language': 'en-us',
            'ASSET-ID': state.AssetID,
        ]
    ]
    try {
        httpGet(httpRequest) { resp ->
            resp.headers.each {
                log.debug "${it.name} : ${it.value}"
            }
            log.debug "response contentType: ${resp.contentType}"
            log.debug "response data: ${resp.data}"
            parseResponseByCmd(resp, state.RoombaCmd)
        }
    } catch (e) {
        log.error "something went wrong: $e"
    }
}
def parseResponseByCmd(resp, command) {
    //Parsing
    def data = resp.data
    if(command == "getStatus") {
        setStatus(data)
    } else if(command == "accumulatedHistorical" ) {
        //readSummaryInfo -- same as getStatus but easier to parse
    } else if(command == "missionHistory") {
        //readMissionHistory -- get results about last 30 jobs -- Out of scope for device-type?
    }
}
def setStatus(data) {
    //TODO: Mine other data here later? add support for "percent completion"?
    def rstatus = data.robot_status
    def robotName = data.robotName
    def mission = data.mission
    def runstats = data.bbrun
    def cschedule = data.cleanSchedule
    def pmaint = data.preventativeMaintenance
    def robot_status = new groovy.json.JsonSlurper().parseText(rstatus)
    def robot_history = new groovy.json.JsonSlurper().parseText(mission)
    def runtime_stats = new groovy.json.JsonSlurper().parseText(runstats)
    def schedule = new groovy.json.JsonSlurper().parseText(cschedule)
    def maintenance = new groovy.json.JsonSlurper().parseText(pmaint)
    log.debug "Robot Status = ${robot_status}"
    log.debug "Robot History = ${robot_history}"
    log.debug "Runtime stats= ${runtime_stats}"
    log.debug "Robot schedule= ${schedule}"
    log.debug "Robot Maintenance= ${maintenance}"
    def current_cycle = robot_status['cycle']
    def current_charge = robot_status['batPct']
    def current_phase = robot_status['phase']
    def num_mins_running = robot_status['mssnM']
    def flags = robot_status['flags']  // Unknown what 'Flags' 0/1/2/5 mean?
    def readyCode = robot_status['notReady']
    def num_cleaning_jobs = robot_history['nMssn']
    def num_dirt_detected = runtime_stats['nScrubs']
    def total_job_time = runtime_stats['hr']
    

    def new_status = get_robot_status(current_phase, current_cycle, current_charge, readyCode)
    def roomba_value = get_robot_enum(current_phase, readyCode)

    log.debug("Robot updates -- ${roomba_value} + ${new_status}")
    //Set the state object
    if(roomba_value == "cleaning") {
        state.switch = "on"
    } else {
        state.switch = "off"
    }

    if(status == "bin-full") {
        state.consumableStatus = "maintenance_required"
    } else {
        state.consumableStatus = "good"
    }

    //send events, display final event
    sendEvent(name: "robotName", value: robotName, displayed: false)
    sendEvent(name: "totalJobHrs", value: total_job_time, displayed: false)
    sendEvent(name: "totalJobs", value: num_cleaning_jobs, displayed: false)
    sendEvent(name: "runtimeMins", value: num_mins_running, displayed: false)
    sendEvent(name: "batteryLevel", value: current_charge, displayed: false)
    sendEvent(name: "headline", value: new_status, displayed: false)
    sendEvent(name: "status", value: roomba_value)
    sendEvent(name: "switch", value: state.switch)
    sendEvent(name: "consumableStatus", value: state.consumableStatus)
}
def get_robot_enum(current_phase, readyCode) {
    if(readyCode != 0) {
        if(readyCode == 16) {
            return "bin-full"
        } else {
            return "error"
        }
    } else if(current_phase == "charge") {
        return "docked"
    } else if(current_phase == "hmUsrDock") {
        return "docking"
    } else if(current_phase == "pause" || current_phase == "stop") {
        return "paused"
    } else if(current_phase == "run") {
        return "cleaning"
    } else {
        //"Stuck" phase falls into this category.
        log.error "Unknown phase - Raw 'robot_status': ${status}. Add to 'get_robot_enum'"
        return "error"
    }
}
def parse_not_ready_status(readyCode) {
    def robotName = device.latestValue("robotName")

    if(readyCode == 16) {
      return "${robotName} bin is full. Empty bin to continue."
    } else if(readyCode == 7) {
      return "${robotName} is not upright. Place robot on flat surface to continue."
    } else if (readyCode == 1) {
      return "${robotName} is stuck. Move robot to continue."
    } else {
      return "${robotName} returned notReady=${readyCode}. See iRobot app for details."
    }
}
def get_robot_status(current_phase, current_cycle, current_charge, readyCode) {
    log.debug "Enter get_robot_status"

    def robotName = device.latestValue("robotName")

    if(readyCode != 0) {
      return parse_not_ready_status(readyCode)
    } else if(current_phase == "charge") {
        if (current_charge == 100) {
            return "${robotName} is Docked/Fully Charged"
        } else {
            return "${robotName} is Docked/Charging"
        }
    } else if(current_phase == "hmUsrDock") {
        return "${robotName} is returning home"
    } else if(current_phase == "run") {
        return "${robotName} is cleaning (${current_cycle} cycle)"
    } else if(current_phase == "pause" || current_phase == "stop") {
        return "Paused - 'Dock' or 'Resume'?"
    }

    log.error "Unknown phase - ${current_phase}."
    return "Error - refresh to continue. Code changes required if problem persists."
}

Here are some screenshots of the app in action:
Switch (Text alternates between status’ and 'battery level’


Running roomba

Pausing Roomba

Docking Roomba

Docking Roomba via master-button (doesn’t always work)

Recent Activity

Settings

CoRE SmartApp integration via Consumable Capability:

ToDo before release:

- Handle common errors (Bin full, stuck, “not upright”)
- CoRE SmartApp example for ‘bin full’ notifications
- Work on “refresh after actions” Update:30sec after running command, API will poll, in addition to the schedule() poll configurable in settings, this should work for most people
- Work on the main button being able to start/pause/dock Update: Fixed
- Use ‘password’ input for the password in preferences
- Include historical information as valueTile
- Including settings manipulation from the device

#Notes for future contributions/contributors:
Including Settings manipulation: (API scraped from iRobot using Fiddler+proxy)

#Edge cleaning OFF
blid=...&robotpwd=...&method=multipleFieldSet&value={"twoPass":0,"openOnly":1,"schedHold":0,"manualUpdate":0,"carpetBoost":0,"binPause":0,"vacHigh":0,"noPP":0,"ecoCharge":0,"noAutoPasses":0}
#Edge Cleaning ON
blid=...&robotpwd=...&method=multipleFieldSet&value={"twoPass":0,"openOnly":0,"schedHold":0,"manualUpdate":0,"carpetBoost":0,"binPause":0,"vacHigh":0,"noPP":0,"ecoCharge":0,"noAutoPasses":0}


#Finish cleaning when bin is full
blid=...&robotpwd=...&method=multipleFieldSet&value={"twoPass":0,"openOnly":0,"schedHold":0,"manualUpdate":0,"carpetBoost":0,"binPause":1,"vacHigh":0,"noPP":0,"ecoCharge":0,"noAutoPasses":0}

#Don't finish job, wait for bin to be emptied, then press clean to resume

blid=...&robotpwd=...&method=multipleFieldSet&value={"twoPass":0,"openOnly":0,"schedHold":0,"manualUpdate":0,"carpetBoost":0,"binPause":0,"vacHigh":0,"noPP":0,"ecoCharge":0,"noAutoPasses":0}

#Cleaning passes
##One pass
blid=...&robotpwd=...&method=multipleFieldSet&value={"twoPass":0,"openOnly":0,"schedHold":0,"manualUpdate":0,"carpetBoost":0,"binPause":0,"vacHigh":0,"noPP":0,"ecoCharge":0,"noAutoPasses":1}
##Two pass
blid=...&robotpwd=...&method=multipleFieldSet&value={"twoPass":1,"openOnly":0,"schedHold":0,"manualUpdate":0,"carpetBoost":0,"binPause":0,"vacHigh":0,"noPP":0,"ecoCharge":0,"noAutoPasses":1}
##Automatic
blid=...&robotpwd=...&method=multipleFieldSet&value={"twoPass":0,"openOnly":0,"schedHold":0,"manualUpdate":0,"carpetBoost":0,"binPause":0,"vacHigh":0,"noPP":0,"ecoCharge":0,"noAutoPasses":0}

#Carpet Boost
##Automatic
blid=...&robotpwd=...&method=multipleFieldSet&value={"twoPass":0,"openOnly":0,"schedHold":0,"manualUpdate":0,"carpetBoost":0,"binPause":0,"vacHigh":0,"noPP":0,"ecoCharge":0,"noAutoPasses":0}##Performance
blid=...&robotpwd=...&method=multipleFieldSet&value={"twoPass":0,"openOnly":0,"schedHold":0,"manualUpdate":0,"carpetBoost":1,"binPause":0,"vacHigh":1,"noPP":0,"ecoCharge":0,"noAutoPasses":0}
##ECO mode
blid=...&robotpwd=...&method=multipleFieldSet&value={"twoPass":0,"openOnly":0,"schedHold":0,"manualUpdate":0,"carpetBoost":1,"binPause":0,"vacHigh":0,"noPP":0,"ecoCharge":0,"noAutoPasses":0}

# Update cleaning schedule (All days set to "No Cleaning" at 9am)
blid=...&robotpwd=...&method=multipleFieldSet&value={ "cleanSchedule" : { "cycle" : ["none", "none", "none", "none", "none", "none", "none"], "h" : [9, 9, 9, 9, 9, 9, 9], "m" : [0, 0, 0, 0, 0, 0, 0] } }
5 Likes

Looks good.

Any way to get a full bin push notification or event?

Thanks!

Support for “bin full” as a state have been added above.

Edit: Also, I’ve got the schedule() call to work via update() so after a user sets (or re-sets) preferences you should continue to get updated every 4min – rather than refresh every time…

1 Like

Awesome!!! I got too lazy to do this myself but you did it! :slight_smile: great job @steve

Elfège Leylavergne

1 Like

If you use the latest revision of code above and refresh your device, you can write a CoRE SmartApp that will send a Push notification when roomba changes from "good" to "maintenance_required" – I’ve included an example screenshot as well!

2 Likes

Thanks!

I’m working on the shoulders of giants, I got on this band-wagon late (first time with smartthings, device-type writing, groovy!) and was super impressed with the level of detail found in this thread.

Big THANK YOU to everyone in this thread for keeping the effort going :grinning:

Push notification of maintenance

You have been able to address an obvious over site that iRobot has failed to resolve for over a year now.

Thanks for the great work! It works even better when I enter the BLID as the username instead of the password and vice versa…!

Installation instructions:
Add new repo steve-gregory/irobot-manager/master to settings,
Select update from repo, and check the irobot-roomba.groovy file, publish and update.

Adding your Roomba:
Create a new device from the Graph website and select iRobot Roomba.
Once device is created, edit the preferences to include the required settings (blid and password collected from dorita980 api).

Open smartthings app and hit the refresh button. You should be fully online at this point.

3 Likes

It seems I cannot control Roomba via Smartthings after the Robot firmware is updated to v2.0.0-34. I also cannot get the username and password using dorita980 code. It seems Roomba no longer listening on port 443.

I can only control Roomba from the iRobot app. From the app, it is sending requests to https://disc-prod.iot.irobotapi.com

Is there anyone experience the same issue? Thanks for the help and time.

Interesting, no new firmware listed in the release notes yet.

http://homesupport.irobot.com/app/answers/detail/a_id/529/p/4116

I just double checked and am on 1.6.6 still :confused:

Try pushing and holding the clean button to reset.

Hi again

would you happen to know how to control the roomba’s movement? I think I remember that it’s about sending serial packets and therefore it must be a tcp to serial protocol, right? I think I remember from the old versions something like Straight = [a numerica value]. Any idea if there is a way to imp^lement this in the device handlers we created? Let me know.

Out of curiosity, what is it you want to try and do by having this ability?

I’m out of town and for the first time in history my two web controled robots lost their connections. For one of those robots it seems I won’t be able to do anything until I get back home, but for the other it suffices that I push it a little bit backward so it connects to its docking station, which will restart it. So remotely maneuvering the roomba would allow me to do just that in a matter of seconds.

it would also be a great way to, at some point, make it possible to just say “Alexa, just go clean the bedroom” and that would trigger a series of command leading the roomba to the proper place… especially that it seems we can now access roomba’s floor map, isn’t it?

Seems like this might only be available on the local APIs? I read about manual control of Roomba via IR signals (and also read the 9xx series does respond to 700/800 series IR controller) but I have yet to see those calls posted at the API level?

If you know the calls or have link to docs I can try my luck and see what happens :slight_smile:

I am currently looking into this : https://hackingroomba.com/about/sample/

1 Like

I already tried resetting all the setting, restart roomba, reconnect to wifi. Nothing works as Roomba no longer listens to port 443.

I found the new firmware improve the mapping a lot. The irobot app seems loading a lot faster and responsive.

However, it seems the new firmware causes the smartthings integration not working anymore.

There are others reporting the 980 firmware upgrade to 2.0 here and here

It looks like you may have been selected for a slow-rollout of the update… Additionally, you can find release notes for the app here and release notes for the 9xx roombas here