adding to git.i0t.app

This commit is contained in:
Levi Woodard
2026-07-01 18:21:14 -05:00
commit 55a8ea4bee
26 changed files with 3534 additions and 0 deletions

358
internal/protect/protect.go Normal file
View File

@@ -0,0 +1,358 @@
// 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/<id>"), 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
}