# homebridge-oto

A [Homebridge](https://github.com/homebridge/homebridge) plugin for [OTO smart sprinklers](https://otolawn.com/). 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](#how-the-plugin-works)
- [Discovered REST APIs](#discovered-rest-apis)
- [Water Now — how it works](#water-now--how-it-works)
- [Installation](#installation)
- [Configuration](#configuration)
- [Local development](#local-development)
- [Troubleshooting](#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 service
```

`OtoPlatform` 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](https://github.com/rob-farrellrobotics/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": "user@example.com",
  "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:** `GET` requests to EMS must **not** include a `Content-Type` header. The service returns `400 Bad Request` if `Content-Type: application/json` is present on a GET. Only set `Content-Type` on 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 zoneId
```

#### Update a zone

```
PUT /account/{uid}/device/{deviceId}/zone/{zoneId}
Content-Type: application/json

Body: zone fields to update
```

#### Get 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](#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](#start-immediate-watering--water-now-)) 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 by `zoneId`

```json
{
  "platforms": [
    {
      "platform": "Oto",
      "transport": "cloud",
      "email": "your@email.com",
      "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.

### The switch stays on until watering completes

`/manual-start` returns the backend's computed runtime (`schedule_item.runtime_min`).
The plugin uses it to **hold the HomeKit valve on for the full run** and then
turn it off automatically when watering is complete — so the switch reflects the
real state instead of flipping back immediately after the tap. While a zone runs,
HomeKit shows a live **Remaining Duration** countdown for it.

If you turn the valve off early, the pending auto-off is cancelled and a
best-effort stop is sent (see the stop note below). If the backend doesn't report
a runtime (e.g. the local `irrigoto` transport, which manages its own duration),
the plugin falls back to the HomeKit valve duration.

### 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
- Keeps the valve switch on for the server-computed runtime, with a live
  countdown, and auto-offs when watering finishes
- 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](https://github.com/homebridge/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

```bash
npm install -g homebridge-oto
```

Homebridge 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

```json
{
  "platforms": [
    {
      "platform": "Oto",
      "name": "OTO Sprinklers",
      "transport": "cloud",
      "email": "you@example.com",
      "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

```json
{
  "platforms": [
    {
      "platform": "Oto",
      "name": "OTO Sprinklers",
      "transport": "cloud",
      "email": "you@example.com",
      "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](#water-now--how-it-works).

### Local config — irrigoto ESPHome firmware

```json
{
  "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

```bash
git clone https://github.com/jonnadul/homebridge-oto.git
cd homebridge-oto
npm install
```

**Build once:**
```bash
npm run build
```

**Watch mode** (rebuilds on save):
```bash
npm run watch
```

**Run tests:**
```bash
npm test
```

**Lint:**
```bash
npm run lint
```

**Link into a local Homebridge for live testing:**
```bash
# In the plugin repo
npm link

# In your Homebridge directory (or globally)
npm link homebridge-oto

# Restart Homebridge
homebridge -D
```

The `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

1. Check the Homebridge log for errors from `OtoPlatform`
2. Confirm your OTO account has at least one device configured and online in the OTO app
3. 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](#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](LICENSE).
