// Package protect is a minimal client for the UniFi Protect local API. // // UniFi Protect has no officially documented public API, but the local // endpoints used here (/api/auth/login and /proxy/protect/api/bootstrap) are // stable and are the same ones the Home Assistant integration and the // uiprotect/pyunifiprotect libraries rely on. We authenticate as a local // Protect user, read the bootstrap document, and construct RTSPS URLs from // each camera's enabled channel alias. package protect import ( "bytes" "context" "crypto/tls" "encoding/json" "fmt" "io" "net/http" "net/http/cookiejar" "strings" "time" ) // Client talks to a single UniFi Protect controller. type Client struct { host string rtspPort int http *http.Client csrf string } // Camera is a discovered Protect camera with its resolved stream URLs. type Camera struct { ID string Name string State string // "CONNECTED", "DISCONNECTED", ... Channels []Channel } // Channel is one encoding profile (high/medium/low) on a camera. type Channel struct { ID int Name string // "High", "Medium", "Low" Width int Height int RTSPEnabled bool RTSPAlias string } // New builds a client. verifyTLS=false accepts the console's self-signed cert. func New(host string, rtspPort int, verifyTLS bool) (*Client, error) { jar, err := cookiejar.New(nil) if err != nil { return nil, err } if rtspPort == 0 { rtspPort = 7441 } return &Client{ host: host, rtspPort: rtspPort, http: &http.Client{ Timeout: 20 * time.Second, Jar: jar, Transport: &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: !verifyTLS}, //nolint:gosec // self-signed console cert }, }, }, nil } // Login authenticates and captures the session cookie + CSRF token. UniFi OS // returns the CSRF token in a response header on successful login. func (c *Client) Login(ctx context.Context, username, password string) error { body, _ := json.Marshal(map[string]any{ "username": username, "password": password, "rememberMe": true, }) url := fmt.Sprintf("https://%s/api/auth/login", c.host) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") resp, err := c.http.Do(req) if err != nil { return fmt.Errorf("connecting to controller %s: %w", c.host, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) return fmt.Errorf("login failed (%s): %s", resp.Status, strings.TrimSpace(string(snippet))) } // UniFi OS exposes the CSRF token via header; capture whichever casing. if tok := resp.Header.Get("X-CSRF-Token"); tok != "" { c.csrf = tok } else if tok := resp.Header.Get("X-Updated-CSRF-Token"); tok != "" { c.csrf = tok } return nil } // bootstrap is the subset of the Protect bootstrap document we care about. type bootstrap struct { Cameras []struct { ID string `json:"id"` Name string `json:"name"` State string `json:"state"` IsRTSPEnabled bool `json:"isRtspEnabled"` ChannelsWrapper []struct { ID int `json:"id"` Name string `json:"name"` Width int `json:"width"` Height int `json:"height"` IsRTSPEnabled bool `json:"isRtspEnabled"` RTSPAlias string `json:"rtspAlias"` } `json:"channels"` } `json:"cameras"` } // Cameras fetches the bootstrap document and returns the camera list. func (c *Client) Cameras(ctx context.Context) ([]Camera, error) { url := fmt.Sprintf("https://%s/proxy/protect/api/bootstrap", c.host) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } if c.csrf != "" { req.Header.Set("X-CSRF-Token", c.csrf) } resp, err := c.http.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) return nil, fmt.Errorf("bootstrap failed (%s): %s", resp.Status, strings.TrimSpace(string(snippet))) } var b bootstrap if err := json.NewDecoder(resp.Body).Decode(&b); err != nil { return nil, fmt.Errorf("decoding bootstrap: %w", err) } out := make([]Camera, 0, len(b.Cameras)) for _, bc := range b.Cameras { cam := Camera{ID: bc.ID, Name: bc.Name, State: bc.State} for _, ch := range bc.ChannelsWrapper { cam.Channels = append(cam.Channels, Channel{ ID: ch.ID, Name: ch.Name, Width: ch.Width, Height: ch.Height, RTSPEnabled: ch.IsRTSPEnabled, RTSPAlias: ch.RTSPAlias, }) } out = append(out, cam) } return out, nil } // StreamURL builds the RTSPS URL for a channel alias on this controller. // enableSrtp is required by Protect's RTSPS endpoint. func (c *Client) StreamURL(alias string) string { return fmt.Sprintf("rtsps://%s:%d/%s?enableSrtp", c.host, c.rtspPort, alias) } // BestEnabledChannel returns the highest-resolution channel that has RTSP // enabled, or nil if none are enabled. Preferring the top channel gives the // sharpest wall tile; callers can pick a lower one for dense grids. func (cam Camera) BestEnabledChannel() *Channel { var best *Channel for i := range cam.Channels { ch := &cam.Channels[i] if !ch.RTSPEnabled || ch.RTSPAlias == "" { continue } if best == nil || ch.Width*ch.Height > best.Width*best.Height { best = ch } } return best } // LowestEnabledChannel returns the lowest-resolution enabled channel, useful // for dense grids where a substream is plenty. func (cam Camera) LowestEnabledChannel() *Channel { var low *Channel for i := range cam.Channels { ch := &cam.Channels[i] if !ch.RTSPEnabled || ch.RTSPAlias == "" { continue } if low == nil || ch.Width*ch.Height < low.Width*low.Height { low = ch } } return low } // ChannelByPreference picks a channel to enable RTSP on, by preference // "high" | "medium" | "low". It matches on the channel name first (Protect // labels them "High"/"Medium"/"Low") and falls back to resolution ranking so // it still works on cameras with unusual channel names. func (cam Camera) ChannelByPreference(pref string) *Channel { pref = strings.ToLower(strings.TrimSpace(pref)) for i := range cam.Channels { if strings.ToLower(cam.Channels[i].Name) == pref { return &cam.Channels[i] } } if len(cam.Channels) == 0 { return nil } var pick *Channel for i := range cam.Channels { ch := &cam.Channels[i] if pick == nil { pick = ch continue } switch pref { case "low": if ch.Width*ch.Height < pick.Width*pick.Height { pick = ch } default: // treat anything else as "highest resolution" if ch.Width*ch.Height > pick.Width*pick.Height { pick = ch } } } return pick } // do issues an authenticated request to a Protect API path (e.g. // "/proxy/protect/api/cameras/"), attaching the session cookie (via the // client's jar) and the CSRF token. The caller closes the response body. func (c *Client) do(ctx context.Context, method, path string, body any) (*http.Response, error) { var rdr io.Reader if body != nil { raw, err := json.Marshal(body) if err != nil { return nil, err } rdr = bytes.NewReader(raw) } url := fmt.Sprintf("https://%s%s", c.host, path) req, err := http.NewRequestWithContext(ctx, method, url, rdr) if err != nil { return nil, err } if body != nil { req.Header.Set("Content-Type", "application/json") } if c.csrf != "" { req.Header.Set("X-CSRF-Token", c.csrf) } return c.http.Do(req) } // EnableRTSP turns on RTSP for one channel of a camera and returns the newly // assigned rtspAlias. It reads the camera's current channels as raw JSON and // flips only isRtspEnabled on the target channel before PATCHing them back, so // no other encoder settings (bitrate, fps, ...) are disturbed. func (c *Client) EnableRTSP(ctx context.Context, cameraID string, channelID int) (string, error) { getResp, err := c.do(ctx, http.MethodGet, "/proxy/protect/api/cameras/"+cameraID, nil) if err != nil { return "", err } defer getResp.Body.Close() if getResp.StatusCode != http.StatusOK { snippet, _ := io.ReadAll(io.LimitReader(getResp.Body, 512)) return "", fmt.Errorf("fetching camera %s (%s): %s", cameraID, getResp.Status, strings.TrimSpace(string(snippet))) } // Keep channels as raw maps to preserve every field we don't touch. var cam struct { Channels []map[string]any `json:"channels"` } if err := json.NewDecoder(getResp.Body).Decode(&cam); err != nil { return "", fmt.Errorf("decoding camera %s: %w", cameraID, err) } found := false for _, ch := range cam.Channels { id, ok := ch["id"].(float64) if ok && int(id) == channelID { ch["isRtspEnabled"] = true found = true } } if !found { return "", fmt.Errorf("camera %s has no channel %d", cameraID, channelID) } patchResp, err := c.do(ctx, http.MethodPatch, "/proxy/protect/api/cameras/"+cameraID, map[string]any{"channels": cam.Channels}) if err != nil { return "", err } defer patchResp.Body.Close() if patchResp.StatusCode != http.StatusOK { snippet, _ := io.ReadAll(io.LimitReader(patchResp.Body, 512)) return "", fmt.Errorf("enabling RTSP on camera %s (%s): %s", cameraID, patchResp.Status, strings.TrimSpace(string(snippet))) } var updated struct { Channels []struct { ID int `json:"id"` RTSPAlias string `json:"rtspAlias"` } `json:"channels"` } if err := json.NewDecoder(patchResp.Body).Decode(&updated); err != nil { return "", fmt.Errorf("decoding RTSP-enable response: %w", err) } for _, ch := range updated.Channels { if ch.ID == channelID { if ch.RTSPAlias == "" { return "", fmt.Errorf("camera %s channel %d still has no rtspAlias after enabling", cameraID, channelID) } return ch.RTSPAlias, nil } } return "", fmt.Errorf("channel %d missing from RTSP-enable response", channelID) } // EnableMissing enables RTSP on the preferred channel for every camera that // currently has no RTSP-enabled channel, mutating cams in place so their // BestEnabledChannel/LowestEnabledChannel become usable. It returns the names // it enabled and, per camera name, any error encountered. func (c *Client) EnableMissing(ctx context.Context, cams []Camera, pref string) (enabled []string, failed map[string]error) { failed = map[string]error{} for i := range cams { cam := &cams[i] if cam.BestEnabledChannel() != nil { continue } target := cam.ChannelByPreference(pref) if target == nil { failed[cam.Name] = fmt.Errorf("no channels to enable") continue } alias, err := c.EnableRTSP(ctx, cam.ID, target.ID) if err != nil { failed[cam.Name] = err continue } // Reflect the change in the in-memory model. for j := range cam.Channels { if cam.Channels[j].ID == target.ID { cam.Channels[j].RTSPEnabled = true cam.Channels[j].RTSPAlias = alias } } enabled = append(enabled, cam.Name) } return enabled, failed }