homebridge-oto
v1.0.0
Published
Homebridge plugin for Oto smart sprinklers
Maintainers
Readme
homebridge-oto
A Homebridge plugin for OTO smart sprinklers. It exposes every OTO zone as a HomeKit Irrigation Valve accessory so you can check watering status, control zones, and build automations — using nothing but your existing OTO app credentials.
OTO has no official API and no HomeKit support. This plugin works by reverse-engineering the same Google Cloud backend the OTO mobile app uses. All endpoints were confirmed against a live OTO account.
Contents
- How the plugin works
- Discovered REST APIs
- Water Now — how it works
- Installation
- Configuration
- Local development
- Troubleshooting
How the plugin works
Architecture
Homebridge
└── OtoPlatform (DynamicPlatformPlugin)
├── OtoApiClient (transport facade)
│ ├── CloudTransport ← Firebase Auth + OTO Cloud Run APIs
│ └── LocalTransport ← irrigoto ESPHome firmware (local LAN, no cloud)
└── SprinklerAccessory × N (one per zone)
├── Valve service (Active, InUse, ValveType=IRRIGATION, SetDuration)
└── AccessoryInformation serviceOtoPlatform calls getControllers() at startup to discover devices, then getZones() on each device to build the zone list. One SprinklerAccessory is registered in Homebridge per zone.
Authentication and token refresh
CloudTransport authenticates lazily — the first API call triggers a Firebase email/password sign-in. The resulting idToken (JWT, valid 1 hour) is attached as Authorization: Bearer <token> on every subsequent request. Before each call the transport checks if the token expires within the next 60 seconds; if so it uses the refreshToken to silently obtain a fresh idToken without re-entering credentials. If the refresh token is itself stale the transport falls back to a full re-login.
Status polling and caching
Each SprinklerAccessory runs an independent 30-second timer that calls getZoneStatus(). The underlying status call is GET /device/{deviceId}/status on the EMS service, which returns a pathIndex field: null means the device is idle; any integer means it is actively running a zone.
Because a device with 8 zones would otherwise fire 8 concurrent status requests on every tick, CloudTransport keeps a 6-second in-memory cache of each device's last status response. All zones on the same device share the cached response within that window, collapsing N requests into one.
One important nuance: pathIndex is a device-level indicator, not a zone-level one. OTO's API does not tell you which specific zone is running — only that the device is running something. As a result, all zones on the same device report watering: true simultaneously while the device is active. This is the best accuracy the available API allows.
HomeKit valve mapping
Each zone becomes a HomeKit Valve service with ValveType set to IRRIGATION. HomeKit requires both Active (user intent) and InUse (physical state) to be set; the plugin keeps them in sync on every status update and on every start/stop command. SetDuration (1–60 minutes) is exposed and stored locally; it is passed to startZone() when the valve is turned on.
Transport switching
Set "transport": "local" to use the LocalTransport instead of the cloud. It talks to a device running the community irrigoto ESPHome firmware over your LAN via ESPHome's built-in web-server REST API (GET /switch/zone_N, POST /switch/zone_N/turn_on, etc.). No OTO credentials required, but it requires opening the device and reflashing the ESP32.
Discovered REST APIs
OTO's backend consists of three Google Cloud Run services in us-central1 (GCP project oto-test-3254b, project number 716180884817) and standard Firebase Auth endpoints.
Firebase Authentication
Sign in
POST https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword
?key=AIzaSyBlkDTR_GZSnGJuXdWAtH65ceeXyHtSrIs
Content-Type: application/json
Body:
{
"email": "[email protected]",
"password": "...",
"returnSecureToken": true
}
Response 200:
{
"idToken": "<jwt>", // bearer token for all Cloud Run calls
"refreshToken": "<token>", // long-lived; exchange for new idToken
"expiresIn": "3600", // seconds until idToken expires
"localId": "<uid>" // Firebase UID; used in all /account/{uid}/... paths
}Refresh token
POST https://securetoken.googleapis.com/v1/token
?key=AIzaSyBlkDTR_GZSnGJuXdWAtH65ceeXyHtSrIs
Content-Type: application/x-www-form-urlencoded
Body: grant_type=refresh_token&refresh_token=<refreshToken>
Response 200:
{
"id_token": "<new jwt>",
"refresh_token": "<new refresh token>",
"expires_in": "3600",
"user_id": "<uid>"
}EMS — primary data service
Base URL: https://oto-cloud-service-ems-prod-716180884817.us-central1.run.app
All requests require: Authorization: Bearer <idToken>
Critical:
GETrequests to EMS must not include aContent-Typeheader. The service returns400 Bad RequestifContent-Type: application/jsonis present on a GET. Only setContent-Typeon requests that send a body (POST, PUT, PATCH).
List controllers (devices)
GET /account/{uid}/devices
Response 200: array of device objects
[
{
"unitName": "oto5736825", // stable device ID used in all other paths
"userName": "Front Yard", // user-assigned name from OTO app
...
}
]List zones
GET /account/{uid}/device/{deviceId}/zones
Response 200: array of zone objects
[
{
"zoneId": "FhFU1lfRN3IhyxPX",
"zoneName": "Left Front Yard",
"wateringTime": 4.5, // minutes
"nozzleAnglePath_cdeg": [12900], // nozzle sweep angle(s) in centi-degrees
"throwDistanceCoords_cm": [105],
"wateringArray": [1,1,1,1,1,1,1], // which days of the week to water (Sun–Sat)
"pathType": 2
},
...
]Create a zone
POST /account/{uid}/device/{deviceId}/zone
Content-Type: application/json
Body: same fields as zone object above (omit zoneId; server assigns it)
Response 200: the created zone object including its new zoneIdUpdate a zone
PUT /account/{uid}/device/{deviceId}/zone/{zoneId}
Content-Type: application/json
Body: zone fields to updateGet device status
GET /device/{deviceId}/status
Response 200:
{
"pathIndex": null, // null = device idle; integer = device actively running a zone
"scheduleId": "FwO1l4Y0", // ID of the schedule currently executing, if any
"version": "...", // firmware version
...
}pathIndex is the only real-time activity indicator. It is device-level — the API does not reveal which specific zone is running, only that the device is busy.
Trigger immediate watering
Immediate watering is not an EMS endpoint. "Water Now" is handled by the
Scheduler service's POST /manual-start — see Scheduler service
below. (An earlier POST /account/{uid}/run + zone_group_id theory turned out
to be a dead end; live app capture proved /manual-start is what the app uses.)
Scheduler service
Base URL: https://oto-cloud-service-scheduler-prod-716180884817.us-central1.run.app
Start immediate watering — "Water Now" ✅
Exactly what the OTO app sends when you tap Water Now on a zone. Confirmed by live capture of the iOS app.
POST /manual-start
Content-Type: application/json
(no Authorization header — the endpoint is open and returns 200 without a token)
Body:
{
"uid": "<uid>",
"deviceId": "oto5736825",
"zoneId": "FhFU1lfRN3IhyxPX",
"wateringQuantity": 12.7
}
Response 200:
{
"message": "Manual event successfully scheduled",
"scheduleId": "f2MMHk05",
"schedule_item": { "runtime_min": 22.95, "scheduleItemType": "MANUAL", "zoneGroupId": null, ... }
}wateringQuantity is a watering depth in millimetres (not a duration); the
backend converts it to a runtime using the zone's precipitation rate. The app
uses 31.75 mm (≈ 1.25") for a deep soak. Since the call only needs values the
plugin already discovers, every zone works with no extra setup.
Push schedule to device
Regenerates the device's schedule from the server-side record and transmits it to the device over-the-air. The device re-syncs its schedule; in practice this can interrupt an active watering run.
POST /schedule
Authorization: Bearer <idToken>
Content-Type: application/json
Body:
{
"uid": "<uid>",
"deviceId": "oto5736825",
"shouldRegenerate": true
}
Response 200: "true"The plugin uses this as a best-effort stop command since no dedicated stop endpoint was found.
Unitcall service
https://oto-cloud-service-unitcall-prod-716180884817.us-central1.run.app
Device-to-cloud telemetry only. No useful control endpoints were found.
Firestore (blocked)
OTO stores zone groups, schedules, and other config in Cloud Firestore under project oto-test-3254b. All direct Firestore REST API calls return 403 PERMISSION_DENIED — the security rules deny client access entirely. The Cloud Run services access Firestore via the Firebase Admin SDK server-side.
Water Now — how it works
Water Now works out of the box — no proxy capture, no zone_group_id, no
per-zone setup. Tapping a valve on in HomeKit calls the Scheduler's
POST /manual-start (see above) with
values the plugin already discovers.
Watering amount (depth, not duration)
OTO is depth-based: /manual-start takes a wateringQuantity in
millimetres and the backend computes the runtime from each zone's precipitation
rate. HomeKit's valve duration slider does not map cleanly to this, so the
plugin ignores it and sends a configurable depth instead:
wateringQuantity— global default depth in mm (plugin default:12.7, ≈ 0.5")wateringQuantities— optional per-zone overrides, keyed byzoneId
{
"platforms": [
{
"platform": "Oto",
"transport": "cloud",
"email": "[email protected]",
"password": "your-password",
"wateringQuantity": 12.7,
"wateringQuantities": {
"FhFU1lfRN3IhyxPX": 25.4,
"cCVzq2E6dBwMLUrP": 19.05
}
}
]
}For reference, the OTO app sends 31.75 mm (≈ 1.25") for a deep soak, which on
one test zone produced a ~23-minute run. Start conservative and tune to taste.
What the plugin does
- Discovers all your OTO devices and zones automatically
- Polls and reports real-time watering status
- Exposes every zone as a HomeKit Irrigation Valve you can turn on/off
- Supports HomeKit automations on zone state
Note on stop: OTO exposes no dedicated stop endpoint. Turning a valve off pushes a schedule regeneration as a best-effort interrupt; the device also stops on its own at the end of the computed run.
Installation
Prerequisites
- Node.js 18 or later
- A running Homebridge instance
- An OTO account with at least one device set up in the OTO app
Install via Homebridge UI
Search for homebridge-oto in the Homebridge plugin search and install from there.
Install via npm
npm install -g homebridge-otoHomebridge will pick it up automatically on the next restart.
Configuration
Add a platform block to your Homebridge config.json (typically at ~/.homebridge/config.json):
Minimal — status + Water Now
{
"platforms": [
{
"platform": "Oto",
"name": "OTO Sprinklers",
"transport": "cloud",
"email": "[email protected]",
"password": "your-oto-password"
}
]
}This discovers all devices and zones, shows live watering status, and lets you
turn any zone on/off in HomeKit — Water Now works with no extra setup. The
default watering depth is 12.7 mm (≈ 0.5").
Full cloud config — custom watering amounts
{
"platforms": [
{
"platform": "Oto",
"name": "OTO Sprinklers",
"transport": "cloud",
"email": "[email protected]",
"password": "your-oto-password",
"wateringQuantity": 12.7,
"wateringQuantities": {
"FhFU1lfRN3IhyxPX": 25.4,
"cCVzq2E6dBwMLUrP": 19.05
},
"updateInterval": 30
}
]
}wateringQuantity is the default watering depth in mm; wateringQuantities
overrides it per zone (keyed by zoneId). See
Water Now — how it works.
Local config — irrigoto ESPHome firmware
{
"platforms": [
{
"platform": "Oto",
"name": "OTO Sprinklers",
"transport": "local",
"localDevices": ["irrigoto-ab12cd.local", "irrigoto-ef34gh.local"],
"updateInterval": 30
}
]
}All options
| Option | Type | When required | Description |
|---|---|---|---|
| platform | string | always | Must be "Oto" |
| name | string | | Label shown in Homebridge logs (default: "Oto") |
| transport | string | | "cloud" (default) or "local" |
| email | string | cloud | Your OTO account email |
| password | string | cloud | Your OTO account password |
| wateringQuantity | number | | Default Water Now depth in mm (default: 12.7) |
| wateringQuantities | object | | Per-zone depth overrides, keyed by zoneId |
| localDevices | string[] | local | irrigoto device hostnames or IPs |
| updateInterval | integer | | Status poll interval in seconds, 10–600 (default: 30) |
Local development
git clone https://github.com/jonnadul/homebridge-oto.git
cd homebridge-oto
npm installBuild once:
npm run buildWatch mode (rebuilds on save):
npm run watchRun tests:
npm testLint:
npm run lintLink into a local Homebridge for live testing:
# In the plugin repo
npm link
# In your Homebridge directory (or globally)
npm link homebridge-oto
# Restart Homebridge
homebridge -DThe dist/ directory is the compiled output. Homebridge loads dist/index.js as the plugin entry point (set in package.json "main").
Source layout
| File | Purpose |
|---|---|
| src/index.ts | Plugin entry point — registers OtoPlatform with Homebridge |
| src/platform.ts | OtoPlatform — discovers devices/zones, manages accessory lifecycle |
| src/transport.ts | Shared types (OtoController, OtoZone, OtoTransport interface, OtoClientConfig) |
| src/cloudTransport.ts | CloudTransport — Firebase Auth + OTO Cloud Run API |
| src/localTransport.ts | LocalTransport — irrigoto ESPHome web-server REST |
| src/otoApi.ts | OtoApiClient facade — selects transport from config |
| src/sprinklerAccessory.ts | SprinklerAccessory — HomeKit Valve per zone |
| config.schema.json | Homebridge UI config schema |
| docs/OTO_API.md | Extended API reference and proxy capture walkthrough |
Troubleshooting
"OTO sign-in failed"
Your email or password is wrong, or the OTO backend is unreachable. Verify you can log into the OTO mobile app with the same credentials.
Zones not appearing
- Check the Homebridge log for errors from
OtoPlatform - Confirm your OTO account has at least one device configured and online in the OTO app
- Try restarting Homebridge — the platform retries discovery every 60 seconds on failure
A zone waters too long or too little
The plugin sends a watering depth (wateringQuantity, in mm), and OTO
converts it to a runtime from the zone's precipitation rate. Lower the depth for
a shorter run, or set a per-zone value in wateringQuantities. See
Water Now — how it works.
"no direct stop endpoint found"
When you turn a valve off, the plugin pushes your OTO schedule to the device (the closest available stop command). The device will stop watering when the current schedule segment ends. This is a backend API limitation.
Status is wrong — all zones show watering at once
OTO's /device/{deviceId}/status endpoint reports only that the device is running, not which specific zone. While any zone on a device is active, all valves for that device will show InUse in HomeKit. This is the best accuracy the API provides.
Token errors after a long gap
Firebase id tokens expire after 1 hour. The plugin refreshes them automatically using the long-lived refresh token. If Homebridge was suspended for an extended period (days), the refresh token itself may have expired — restart Homebridge to force a full re-login.
Disclaimer
This plugin is not affiliated with or endorsed by OTO Lawn Care. It works by reverse-engineering OTO's backend from traffic observed on a personal account. OTO may change their backend at any time, which could break this plugin. Use it at your own risk, and always verify your irrigation schedule is running correctly in the OTO app.
License
Apache 2.0 — see LICENSE.
