Skip to content

New Feature: Litter Hopper #247

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
malodie opened this issue Aug 6, 2024 · 18 comments
Open

New Feature: Litter Hopper #247

malodie opened this issue Aug 6, 2024 · 18 comments
Labels
enhancement New feature or request help wanted Extra attention is needed

Comments

@malodie
Copy link

malodie commented Aug 6, 2024

LitterRobot has added a new feature to the LR4, the litter hopper. It would be excellent to add this as a feature in pylitterbot to then extend that to Home Assistant. The app does not display the litter hopper information until you physically click into the Litter Robot section and the hopper itself.

I'm hoping to contribute and add this feature, but I am unsure of how to use the non-public API to see if this information is available. Due to its availability on the app, I am assuming it is available in the API. I will need to spend some time discovering how this works, or if you have any shortcuts available (e.g. a way to list endpoints) or some useful documentation around accessing and reading non-public graphql that would be really helpful.

@natekspencer natekspencer added enhancement New feature or request help wanted Extra attention is needed labels Aug 6, 2024
@natekspencer
Copy link
Owner

On my iPad, I've had success with Proxyman. On Android I've had to use a mitm proxy to find the endpoints the app is hitting. I see a couple of fields in the existing LR4 endpoint for hopperStatus with values like enabled, disabled, empty, but guessing when you click in there is more information than that.

@malodie
Copy link
Author

malodie commented Aug 6, 2024

Thanks for the response. I primarily work on windows at home (sad) but there is a proxyman windows app. If that drives me nuts I'll switch to ubuntu. I will play with it and see what I can find!

@malodie
Copy link
Author

malodie commented Aug 6, 2024

I tried to set up the proxy with my android phone (via my PC). It grabs the traffic but I can't actually see any of the requests fully, and it just seems to have a single endpoint that loads all of the data. I'm not seeing any of the direct API requests. I think it's having issues with the cert, which I set up following the instructions. I'll have to try either emulating it on my Windows PC, or come up with another idea, or at worst use some sort of proxy intercept on my phone.

Tell me this is going to be the one time I really wish I was in the mac ecosystem...

They explicitly state that this is harder with android.

@natekspencer
Copy link
Owner

I tried to set up the proxy with my android phone (via my PC). It grabs the traffic but I can't actually see any of the requests fully, and it just seems to have a single endpoint that loads all of the data. I'm not seeing any of the direct API requests. I think it's having issues with the cert, which I set up following the instructions. I'll have to try either emulating it on my Windows PC, or come up with another idea, or at worst use some sort of proxy intercept on my phone.

Tell me this is going to be the one time I really wish I was in the mac ecosystem...

They explicitly state that this is harder with android.

If you can share the full endpoint, I can check if I'm able to see a few things.

@malodie
Copy link
Author

malodie commented Aug 6, 2024

I only get so far as to see the api.onesignal.com and api.exponea.com in the list. But, it gives me an internal error. According to the troubleshooting, I can't actually get that information on Android because I do not own the application.

image

That might mean this is a nonstarter unless I can get my hands on an apple device.

From the docs:
If you're trying to intercept Android apps that you're not an owner -> It isn't possible to intercept -> ❌
https://docs.proxyman.io/troubleshooting/get-ssl-error-from-https-request-and-response#android-device

I'll check out Http Toolkit and see if I have more success.

@natekspencer
Copy link
Owner

You'll probably be looking for an iothings.site url as that is what most of the other endpoints use.

@malodie
Copy link
Author

malodie commented Aug 7, 2024

Yeah, you're right. I was able to at least see that URL with HTTP Toolkit, but there's still the android device issue.
image
image

I don't think there's a way for me to emulate this app.

On the bright side, my best friend has an iPad she's not using and has agreed to lend it to me, so I can hopefully move forward next week.

Thanks for your help on this :)

@jrhe
Copy link
Contributor

jrhe commented Sep 12, 2024

@malodie When I was doing some work on this to add pet profiles/weights I futzed around with a few options but ultimately settled on the following as it was easiest:

  1. Install Android Studio
  2. Create an android emulator with an Android Open Source Project (AOSP) version of android on the emulated device. This is important as if you chose a non-AOSP version you will not have root access to the device, which means you won't be able to install root certificates to intercept SSL.
  3. Install the root certificates of your chose proxy software. I used MITMProxy before with wireshark, but HTTP Toolkit is much more user friendly. Use the android with ADB connection method of connection, as scanning a QR code with an emulated device is a headache. AOSP android builds do not have the play store, so you will have to download the HTTP Toolkit APK from elsewhere. I used APK Pure (https://apkpure.com/http-toolkit/tech.httptoolkit.android.v1). You should see in the app both "User Trust Enabled" and "System Trust Enabled"
  4. Install the whisker app. (https://apkpure.com/whisker/com.whisker.android)

@jrhe
Copy link
Contributor

jrhe commented Sep 12, 2024

Had a quick poke and can confirm the method above works for the whisker app.
Below are endpoints, requests and responses for:

  • enabling the hopper
  • disabling the hopper
  • getting the litter dispense activities
  • general status across the websocket - seems to have added 'hopperStatus' and 'isHopperRemoved'. Appears to be sent by the client app but I didn't see a response.

Hope this helps. I don't actually have a hopper so can't really do much more unfortunately.

Endpoint: POST https://lr4.iothings.site/graphql
Request body:

{
  "operationName": null,
  "variables": {
    "serial": "<REDACTED>",
    "command": "disableHopper",
    "commandSource": "app"
  },
  "query": "mutation sendLitterRobot4Command($serial: String!, $command: String!, $value: String, $commandSource: String) {\n  __typename\n  sendLitterRobot4Command(input: {serial: $serial, command: $command, value: $value, commandSource: $commandSource})\n}"
}

Response body:

{
  "data": {
    "__typename": "Mutation",
    "sendLitterRobot4Command": "command \"disableHopper (0x020C0000)\" sent"
  }
}

Endpoint: POST https://lr4.iothings.site/graphql
Request body:

{
  "operationName": null,
  "variables": {
    "serial": "<REDACTED>",
    "command": "enableHopper",
    "commandSource": "app"
  },
  "query": "mutation sendLitterRobot4Command($serial: String!, $command: String!, $value: String, $commandSource: String) {\n  __typename\n  sendLitterRobot4Command(input: {serial: $serial, command: $command, value: $value, commandSource: $commandSource})\n}"
}

Response body:

{
  "data": {
    "__typename": "Mutation",
    "sendLitterRobot4Command": "command \"enableHopper (0x020C0001)\" sent"
  }
}

Endpoint: POST https://lr4.iothings.site/graphql
Request body:

{
  "variables": {
    "serial": "<REDACTED>",
    "consumer": "app",
    "limit": 10,
    "activityTypes": [
      "litterHopperDispensed"
    ]
  },
  "query": "    query GetLR4($serial: String!, $consumer: String, $startTimestamp: String, $endTimestamp: String, $limit: Int, $activityTypes: [String]) {\n      getLitterRobot4Activity(serial: $serial, consumer: $consumer, startTimestamp: $startTimestamp, endTimestamp: $endTimestamp, limit: $limit, activityTypes: $activityTypes) {\n        value\n        timestamp\n        measure\n        actionValue\n      }\n    }\n  "
}

Response body:

{
  "data": {
    "getLitterRobot4Activity": []
  }
}

Also got this over the websocket wss://lr4.iothings.site/graphql/realtime?header=<REDACTED - JWT Token>&payload=e30%3D

{
  "id": "<REDACTED - uuid v4 id string>",
  "type": "start",
  "payload": {
    "data": "{\"variables\":{\"userId\":\"<REDACTED - integer>\"},\"query\":\"    subscription GetLR4($userId: String!) {\\n      litterRobot4StateSubscriptionByUser(userId: $userId) {\\n      robots {\\n        name\\n        serial\\n        unitId\\n        unitPowerType\\n        unitPowerStatus\\n        robotStatus\\n        unitTimezone\\n        unitPowerStatus\\n        isOnboarded\\n        setupDateTime\\n        cleanCycleWaitTime\\n        isKeypadLockout\\n        nightLightBrightness\\n        nightLightMode\\n        litterLevel\\n        DFILevelPercent\\n        globeMotorFaultStatus\\n        catWeight\\n        isBonnetRemoved\\n        isDFIFull\\n        wifiRssi\\n        isOnline\\n        espFirmware\\n        picFirmwareVersion\\n        laserBoardFirmwareVersion\\n        isFirmwareUpdateTriggered\\n        sleepStatus\\n        catDetect\\n        robotCycleState\\n        robotCycleStatus\\n        isLaserDirty\\n        panelBrightnessHigh\\n        smartWeightEnabled\\n        surfaceType\\n        hopperStatus\\n        scoopsSavedCount\\n        isHopperRemoved\\n        litterLevelPercentage\\n        litterLevelState\\n        weekdaySleepModeEnabled {\\n            Sunday {\\n                sleepTime\\n                wakeTime\\n                isEnabled\\n            }\\n            Monday {\\n                sleepTime\\n                wakeTime\\n                isEnabled\\n            }\\n            Tuesday {\\n                sleepTime\\n                wakeTime\\n                isEnabled\\n            }\\n            Wednesday {\\n                sleepTime\\n                wakeTime\\n                isEnabled\\n            }\\n            Thursday {\\n                sleepTime\\n                wakeTime\\n                isEnabled\\n            }\\n            Friday {\\n                sleepTime\\n                wakeTime\\n                isEnabled\\n            }\\n            Saturday {\\n                sleepTime\\n                wakeTime\\n                isEnabled\\n            }\\n        }\\n        }\\n      }\\n    }\\n  \"}",
    "extensions": {
      "authorization": {
        "Accept": "application/json, text/javascript",
        "Content-Encoding": "amz-1.0",
        "Content-Type": "application/json; charset=utf-8",
        "Authorization": "Bearer <REDACTED - JWT token>",
        "Host": "lr4.iothings.site"
      }
    }
  }
} 

@natekspencer
Copy link
Owner

Thanks for those @jrhe. I saw the hopperStatus and isHopperRemoved fields before, but wasn't sure what would come back in hopperStatus. Initially I thought there might be a different endpoint to get more details about the hopper accessory, but looking at the information here and on Whisker's website, I don't think there is much more info about the hopper than these couple of fields.

@jrhe
Copy link
Contributor

jrhe commented Sep 12, 2024

I think the API just provides those two vars and then allows you to query dispensing history (litterHopperDispensed activity type).

hopperStatus should be something like full/running low/empty based on the screenshots I have seen.

I think there is a distinction between the hopper being enabled, and hopper being removed. When I tried to enable the hopper without actually having one installed I got a "LitterHopper Error: Motor fault di... - There's an issue with the LitterHopper's motor connection. [...]".

The hopper itself seems to just have a single barrel jack, that presumably provides power, with no additional sensors. I think any data provided is calculated based on the resistance of the motor (missing, spinning, spinning freely/empty), the LR's litter level sensors, and how many times the hopper has run since being refilled.

Hopefully @malodie can check out the exact values!

@malodie
Copy link
Author

malodie commented Sep 12, 2024

Unfortunately I still don't have an iPad to check this :(

@jrhe
Copy link
Contributor

jrhe commented Sep 12, 2024

You should be able to install the android emulator as above if you want to do it without an ipad

@elmigbot
Copy link

elmigbot commented Oct 4, 2024

@natekspencer @malodie Just checking to see if this additional functionality is coming. For me, I'd love to turn off the hopper at night because its a bit noisier than the LR4 itself.

Let me know if I can help with testing, etc.

@malodie
Copy link
Author

malodie commented Oct 14, 2024

I tried to do it with android and I can't capture the traffic. Sorry. If I get an ipad I will but I have no idea when that will be as I own no apple products.

@f3ndot
Copy link

f3ndot commented Apr 23, 2025

Hey gang, I have a LR4 and LitterHopper as well as Proxyman Pro and a physical iPhone/iOS device. Happy to contribute but sadly am unable to see any iothings.site host. Just the api.onesignal.com, api.exponea.com, api3.branch.io and perhaps a few other telemetry hosts. SSL/TLS root certificates installed correctly as I'm able to decrypt these hosts' requests/responses. Am I missing something glaringly obvious here to see the GraphQL or WS endpoints?

EDIT: I suspect that perhaps they're using certificate pinning? Or something else that prevents traditional MITM tools from sniffing the requests. I'll try to see if I can use Frida to bypass with the iOS Simulator later.

@f3ndot
Copy link

f3ndot commented Apr 25, 2025

I was able to dump the GraphQL schema via their introspection endpoint still being enabled in production.

Full Schema: lr4.graphql.txt

UnitDiagnostics looks particularly useful in general FYI.

Here is hopper-pertinent schema:

enum HopperStatusEnum {
  ENABLED
  DISABLED
  MOTOR_FAULT_SHORT
  MOTOR_OT_AMPS
  MOTOR_DISCONNECTED
  EMPTY
}

type ToggleHopperOutput {
  success: Boolean
}

type UnitDiagnostics {
  # ...
  hopperMotorAmperes: Int
  # ...
}

type LitterRobot4 {
  # ...
  hopperStatus: HopperStatusEnum
  isHopperRemoved: Boolean
  # ...
}

type Mutation {
  # ...
  toggleHopper(serial: String!, isRemoved: Boolean!): ToggleHopperOutput
  # ...
}

type Query {
  # ...
  getUnitDiagnosticsBySerial(serial: String!): UnitDiagnostics
  getUnitDiagnosticsByUser(userId: String!): [UnitDiagnostics]
  getLitterRobot4BySerial(serial: String!): LitterRobot4
  getLitterRobot4ByUnitId(unitId: ID!): LitterRobot4
  getLitterRobot4ByUser(userId: String!): [LitterRobot4]
  # ...
}

Obviously I'll be able to shed more light on any sort of LR4 hopper-related commands once I get a MITM setup going

@f3ndot
Copy link

f3ndot commented Apr 25, 2025

and thanks to @jrhe here are some real world examples for litterHopperDispensed activity:

GraphQL query:

query GetLitterRobot4Activity {
    getLitterRobot4Activity(
        serial: "REDACTED"
        limit: 100
        consumer: "app"
        activityTypes: ["litterHopperDispensed"]
    ) {
        serial
        commandSource
        consumer
        stateString
        valueString
        originalHex
        actionValue
        value
        timestamp
        measure
    }
}

Response:

{
    "data": {
        "getLitterRobot4Activity": [
            {
                "serial": "REDACTED",
                "commandSource": null,
                "consumer": "app",
                "stateString": null,
                "valueString": null,
                "originalHex": null,
                "actionValue": "85",
                "value": "litterHopperDispensed",
                "timestamp": "2025-04-21 00:23:26.000",
                "measure": "action"
            },
            {
                "serial": "REDACTED",
                "commandSource": null,
                "consumer": "app",
                "stateString": null,
                "valueString": null,
                "originalHex": null,
                "actionValue": "83",
                "value": "litterHopperDispensed",
                "timestamp": "2025-04-21 07:14:15.000",
                "measure": "action"
            },
            {
                "serial": "REDACTED",
                "commandSource": null,
                "consumer": "app",
                "stateString": null,
                "valueString": null,
                "originalHex": null,
                "actionValue": "84",
                "value": "litterHopperDispensed",
                "timestamp": "2025-04-22 10:11:14.000",
                "measure": "action"
            },
            {
                "serial": "REDACTED",
                "commandSource": null,
                "consumer": "app",
                "stateString": null,
                "valueString": null,
                "originalHex": null,
                "actionValue": "82",
                "value": "litterHopperDispensed",
                "timestamp": "2025-04-23 02:43:52.000",
                "measure": "action"
            },
            {
                "serial": "REDACTED",
                "commandSource": null,
                "consumer": "app",
                "stateString": null,
                "valueString": null,
                "originalHex": null,
                "actionValue": "87",
                "value": "litterHopperDispensed",
                "timestamp": "2025-04-24 04:20:58.000",
                "measure": "action"
            }
        ]
    }
}

Corresponding Whisker iOS App UI that appears to be powered by this:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request help wanted Extra attention is needed
Projects
None yet
Development

No branches or pull requests

5 participants