# yupptvws: YuppTV WebSocket Server (POC)

A test bed for the "agent client" that will live inside the YuppTV Scope and TG launcher apps. The app keeps one outbound WebSocket to this server; the server pushes signed commands (play, control, open_app) and the app acks them with the real result. A web console lets anyone type "play TV9" and watch it reach the app.

Everything is in memory. Restarting the server forgets devices; apps reconnect and re-register.

## URLs

| What | Where |
| --- | --- |
| Console (bot + devices + live log) | `/` |
| App simulator (a fake TV app in the browser) | `/simulator.html` |
| App socket | `wss://<host>/ws?device_id=<id>&token=<DEV_TOKEN>` (or header `Authorization: Bearer <DEV_TOKEN>`) |
| Health | `GET /health` |
| Devices | `GET /v1/devices` |
| Channel catalog used by the bot | `GET /v1/catalog` |
| Bot (natural text) | `POST /v1/bot {"text":"play TV9","device":"living room"}` |
| Voice-box style API | `POST /v1/voice/play {"query":"TV9"}`, `POST /v1/voice/control {"action":"pause"}`, `POST /v1/voice/open_app {"app":"netflix"}` |
| Raw command to one device | `POST /v1/command {"device_id":"tv-1","type":"play",...}` |
| Compute a signature (to verify your implementation) | `POST /v1/sign {"device_id":"tv-1","cmd_id":"...","type":"play",...}` |

## App protocol

### 1. Connect and register

Open the socket with the dev token, then send `register` once:

```json
{"type":"register","device_id":"tv-<android-id>","household_id":"h_demo","name":"Living room TV","room":"living room",
 "platform":"android-tv","app":{"package":"com.yupptv.scope.tvapp","version":"3.4.1"},
 "capabilities":["play_content_id","play_deeplink","control","open_app","now_playing","installed_apps"],
 "installed_apps":[{"package":"com.netflix.ninja","label":"Netflix"}]}
```

Reply: `{"type":"registered","device_id":"tv-…","server_time":1791700000000,"heartbeat_s":25,"ack_timeout_s":5}`

### 2. Heartbeat

Send `{"type":"ping"}` every 25 s; the server answers `{"type":"pong"}`. Reconnect with backoff (1 s → 60 s) on close, immediately on network change and app foreground.

### 3. Commands you receive

Every command carries `cmd_id`, `type`, `issued_at`, `expires_at` (unix seconds) and `sig`.

```json
{"cmd_id":"c_…","type":"play","content":{"content_id":"ch_tv9_telugu","kind":"live_channel","title":"TV9 Telugu","number":401},
 "fallback_deeplink":"yupptvscope://www.yupptvscope.com/channels/tv9-news/live?src=act","issued_at":1791700000,"expires_at":1791700030,"sig":"…"}
{"cmd_id":"c_…","type":"control","action":"pause","issued_at":…,"expires_at":…,"sig":"…"}
{"cmd_id":"c_…","type":"control","action":"set_volume","level":40,…}
{"cmd_id":"c_…","type":"open_app","package":"com.netflix.ninja","app":"netflix",…}
```

```json
{"cmd_id":"c_…","type":"message","text":"Dinner is ready","issued_at":…,"expires_at":…,"sig":"…"}
```

`message` is free text for the app to show (toast or banner) and ack. `action` is one of `pause, resume, stop, volume_up, volume_down, set_volume, mute, unmute, next_channel, previous_channel, home, back, tv_on, tv_off`.

Rules: ignore a command after `expires_at`; ack a `cmd_id` you already acked without executing it again (the server may resend after a reconnect).

### 4. Ack, always, within 1 second

```json
{"type":"ack","cmd_id":"c_…","ok":true,"state":"playing","foreground":"com.yupptv.scope.tvapp",
 "now_playing":{"content_id":"ch_tv9_telugu","title":"TV9 Telugu","kind":"live_channel"}}
{"type":"ack","cmd_id":"c_…","ok":false,"reason":"not_logged_in"}
```

`reason` is one of `not_installed, not_logged_in, content_unavailable, profile_required, expired, bad_signature, busy, unknown`.

### 5. State pushes (on change, plus every 5 minutes)

```json
{"type":"state","device_id":"tv-…","at":1791700042,"foreground":"com.yupptv.scope.tvapp",
 "now_playing":{"kind":"live_channel","content_id":"ch_tv9_telugu","title":"TV9 Telugu","state":"playing"},
 "volume":{"level":28,"muted":false},"screen":"player"}
```

`screen` is one of `home, details, player, profile_picker, login, error, other`.

Optional: `{"type":"catalog","version":"…","channels":[{"content_id","title","number","language","genre"}]}` after register, so the server resolves names against what this device can actually play.

### 6. Signature

`sig = hex(HMAC_SHA256(secret, cmd_id + "|" + device_id + "|" + type + "|" + issued_at + "|" + expires_at + "|" + payload_hash))`

where `payload_hash = hex(SHA256(canonical_json(payload)))`, `payload` = the command minus `cmd_id, type, issued_at, expires_at, sig`, and canonical JSON = keys sorted alphabetically (top level), no spaces. The POC secret is shared out of band (`HMAC_SECRET`). Verify it in the app and ack `bad_signature` on mismatch. Use `POST /v1/sign` to check your implementation against the server.

## Try it without an app

1. Open `/simulator.html` in one tab and press **Connect & register**.
2. Open `/` in another tab and type `play TV9`. The simulator shows the command, acks it, and the console shows the ack and the state push.
3. Change the simulator's behaviour to `not_logged_in` or `never ack` to see failure acks and the 5 s timeout.

## Android (Kotlin, OkHttp) sketch

```kotlin
val req = Request.Builder().url("$WS_URL/ws?device_id=$deviceId")
    .addHeader("Authorization", "Bearer $devToken").build()
val ws = client.newWebSocket(req, object : WebSocketListener() {
    override fun onOpen(ws: WebSocket, r: Response) {
        ws.send(register.toString())                 // the JSON from section 1
        heartbeat = scope.launch { while (true) { delay(25_000); ws.send("""{"type":"ping"}""") } }
    }
    override fun onMessage(ws: WebSocket, text: String) {
        val m = JSONObject(text)
        when (m.optString("type")) {
            "play" -> { val id = m.getString("cmd_id"); player.play(m.getJSONObject("content").getString("content_id"))
                        ws.send("""{"type":"ack","cmd_id":"$id","ok":true,"state":"playing","foreground":"$pkg"}""") }
            "control" -> { /* act on the player, then ack */ }
            "open_app" -> { /* launch leanback intent, ack with foreground package */ }
        }
    }
    override fun onClosed(ws: WebSocket, code: Int, reason: String) { reconnectWithBackoff() }
})
```

## Run locally

```bash
npm install
DEV_TOKEN=yupptv-dev HMAC_SECRET=dev-secret-change-me PORT=8080 npm start
# open http://localhost:8080/
```
