Skip to content

Commit ca775d3

Browse files
Brandon Salzbergclaude
andcommitted
Add alert commands for viewing and downloading policy alerts
- `rhombus alert recent` — list alerts from the last hour - `rhombus alert thumb <uuid>` — download alert thumbnail - `rhombus alert download <uuid>` — download alert clip manifest - `rhombus alert play <uuid>` — play alert clip in browser - Filter by camera, time range, max results - Relative time parsing (5m ago, 1h ago, 2d ago) - Federated token auth for browser playback Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e5a04e3 commit ca775d3

1 file changed

Lines changed: 370 additions & 0 deletions

File tree

cmd/alert.go

Lines changed: 370 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"os"
9+
"path/filepath"
10+
"strconv"
11+
"strings"
12+
"time"
13+
14+
"github.com/RhombusSystems/rhombus-cli/internal/client"
15+
"github.com/RhombusSystems/rhombus-cli/internal/config"
16+
"github.com/spf13/cobra"
17+
)
18+
19+
const mediaBaseURL = "https://media.rhombussystems.com"
20+
21+
func init() {
22+
alertCmd := &cobra.Command{
23+
Use: "alert",
24+
Short: "View, download, and play policy alerts",
25+
Long: "List policy alerts, download their thumbnails/clips, or play them in the browser.",
26+
}
27+
28+
recentCmd := &cobra.Command{
29+
Use: "recent",
30+
Short: "Show alerts from the last hour",
31+
RunE: runAlertList,
32+
}
33+
recentCmd.Flags().String("camera", "", "Filter by camera name or UUID")
34+
recentCmd.Flags().String("after", "1h ago", "Show alerts after this time")
35+
recentCmd.Flags().Int("max", 20, "Maximum number of alerts to show")
36+
37+
thumbCmd := &cobra.Command{
38+
Use: "thumb [alert-uuid]",
39+
Short: "Download an alert's thumbnail",
40+
Args: cobra.ExactArgs(1),
41+
RunE: runAlertThumbnail,
42+
}
43+
thumbCmd.Flags().String("output", "", "Output file path (default: auto-generated)")
44+
45+
downloadCmd := &cobra.Command{
46+
Use: "download [alert-uuid]",
47+
Short: "Download an alert's video clip as MP4",
48+
Args: cobra.ExactArgs(1),
49+
RunE: runAlertDownload,
50+
}
51+
downloadCmd.Flags().String("output", "", "Output file path (default: auto-generated)")
52+
53+
playCmd := &cobra.Command{
54+
Use: "play [alert-uuid]",
55+
Short: "Play an alert's clip in the browser",
56+
Args: cobra.ExactArgs(1),
57+
RunE: runAlertPlay,
58+
}
59+
60+
alertCmd.AddCommand(recentCmd)
61+
alertCmd.AddCommand(thumbCmd)
62+
alertCmd.AddCommand(downloadCmd)
63+
alertCmd.AddCommand(playCmd)
64+
rootCmd.AddCommand(alertCmd)
65+
}
66+
67+
func runAlertList(cmd *cobra.Command, args []string) error {
68+
cfg := config.LoadFromCmd(cmd)
69+
cameraFilter, _ := cmd.Flags().GetString("camera")
70+
afterStr, _ := cmd.Flags().GetString("after")
71+
maxResults, _ := cmd.Flags().GetInt("max")
72+
73+
afterMs, err := parseTimestamp(afterStr)
74+
if err != nil {
75+
return fmt.Errorf("invalid 'after' time: %w", err)
76+
}
77+
78+
cameraNames := getCameraNameMap(cfg)
79+
80+
var deviceFilter []string
81+
if cameraFilter != "" {
82+
uuid, _, err := resolveCamera(cfg, cameraFilter)
83+
if err != nil {
84+
return err
85+
}
86+
deviceFilter = []string{uuid}
87+
}
88+
89+
body := map[string]any{
90+
"afterTimestampMs": afterMs,
91+
"maxResults": maxResults,
92+
}
93+
if len(deviceFilter) > 0 {
94+
body["deviceFilter"] = deviceFilter
95+
}
96+
97+
resp, err := client.APICall(cfg, "/api/event/getPolicyAlertsV2", body)
98+
if err != nil {
99+
return err
100+
}
101+
102+
alerts, _ := resp["policyAlerts"].([]any)
103+
if len(alerts) == 0 {
104+
fmt.Println("No alerts found.")
105+
return nil
106+
}
107+
108+
fmt.Printf("%-24s %-15s %-6s %-20s %s\n", "UUID", "Camera", "Dur", "Time", "Triggers")
109+
fmt.Println(strings.Repeat("-", 95))
110+
111+
for _, a := range alerts {
112+
alert, ok := a.(map[string]any)
113+
if !ok {
114+
continue
115+
}
116+
117+
uuid, _ := alert["uuid"].(string)
118+
deviceUuid, _ := alert["deviceUuid"].(string)
119+
tsMs, _ := alert["timestampMs"].(float64)
120+
durSec, _ := alert["durationSec"].(float64)
121+
triggers, _ := alert["policyAlertTriggers"].([]any)
122+
123+
camName := cameraNames[deviceUuid]
124+
if camName == "" {
125+
camName = deviceUuid[:12] + "..."
126+
}
127+
if len(camName) > 15 {
128+
camName = camName[:15]
129+
}
130+
131+
triggerStrs := make([]string, 0)
132+
for _, t := range triggers {
133+
if s, ok := t.(string); ok {
134+
triggerStrs = append(triggerStrs, s)
135+
}
136+
}
137+
138+
ts := time.UnixMilli(int64(tsMs))
139+
fmt.Printf("%-24s %-15s %4.0fs %-20s %s\n",
140+
uuid, camName, durSec, ts.Format("Jan 2 3:04:05 PM"), strings.Join(triggerStrs, ", "))
141+
}
142+
143+
return nil
144+
}
145+
146+
func runAlertThumbnail(cmd *cobra.Command, args []string) error {
147+
cfg := config.LoadFromCmd(cmd)
148+
alertUuid := args[0]
149+
outputPath, _ := cmd.Flags().GetString("output")
150+
151+
alert, err := getAlertDetails(cfg, alertUuid)
152+
if err != nil {
153+
return err
154+
}
155+
156+
region := getAlertRegion(alert, "thumbnailLocation")
157+
thumbnailURL := fmt.Sprintf("%s/media/metadata/%s/%s.jpeg", mediaBaseURL, region, alertUuid)
158+
159+
if outputPath == "" {
160+
outputPath = fmt.Sprintf("alert_%s.jpeg", alertUuid)
161+
}
162+
163+
fmt.Printf("Downloading alert thumbnail...\n")
164+
if err := downloadWithAuth(cfg, thumbnailURL, outputPath); err != nil {
165+
return fmt.Errorf("download failed: %w", err)
166+
}
167+
168+
fmt.Printf("Thumbnail saved: %s\n", outputPath)
169+
return nil
170+
}
171+
172+
func runAlertDownload(cmd *cobra.Command, args []string) error {
173+
cfg := config.LoadFromCmd(cmd)
174+
alertUuid := args[0]
175+
outputPath, _ := cmd.Flags().GetString("output")
176+
177+
alert, err := getAlertDetails(cfg, alertUuid)
178+
if err != nil {
179+
return err
180+
}
181+
182+
deviceUuid, _ := alert["deviceUuid"].(string)
183+
region := getAlertRegion(alert, "clipLocation")
184+
185+
clipMpdURL := fmt.Sprintf("%s/media/metadata/%s/%s/%s/clip.mpd",
186+
mediaBaseURL, deviceUuid, region, alertUuid)
187+
188+
if outputPath == "" {
189+
outputPath = fmt.Sprintf("alert_%s.mpd", alertUuid)
190+
}
191+
192+
fmt.Printf("Downloading alert clip...\n")
193+
if err := downloadWithAuth(cfg, clipMpdURL, outputPath); err != nil {
194+
return fmt.Errorf("download failed: %w", err)
195+
}
196+
197+
fmt.Printf("Clip manifest saved: %s\n", outputPath)
198+
fmt.Println("Note: This is a DASH manifest. Use 'rhombus alert play' to view in browser.")
199+
return nil
200+
}
201+
202+
func runAlertPlay(cmd *cobra.Command, args []string) error {
203+
cfg := config.LoadFromCmd(cmd)
204+
alertUuid := args[0]
205+
206+
alert, err := getAlertDetails(cfg, alertUuid)
207+
if err != nil {
208+
return err
209+
}
210+
211+
deviceUuid, _ := alert["deviceUuid"].(string)
212+
region := getAlertRegion(alert, "clipLocation")
213+
tsMs, _ := alert["timestampMs"].(float64)
214+
durSec, _ := alert["durationSec"].(float64)
215+
216+
cameraNames := getCameraNameMap(cfg)
217+
camName := cameraNames[deviceUuid]
218+
if camName == "" {
219+
camName = deviceUuid
220+
}
221+
222+
fmt.Printf("Playing alert: %s at %s (%.0fs)\n", camName,
223+
time.UnixMilli(int64(tsMs)).Format("Jan 2 3:04:05 PM"), durSec)
224+
225+
fedResp, err := client.APICall(cfg, "/api/org/generateFederatedSessionToken", map[string]any{
226+
"durationSec": 3600,
227+
})
228+
if err != nil {
229+
return fmt.Errorf("generating federated token: %w", err)
230+
}
231+
federatedToken, _ := fedResp["federatedSessionToken"].(string)
232+
233+
clipMpdURL := fmt.Sprintf("%s/media/metadata/%s/%s/%s/clip.mpd",
234+
mediaBaseURL, deviceUuid, region, alertUuid)
235+
streamURL := clipMpdURL + "?x-auth-scheme=federated-token&x-auth-ft=" + federatedToken
236+
237+
htmlPath, err := generatePlayerHTML(fmt.Sprintf("%s — Alert", camName), streamURL)
238+
if err != nil {
239+
return fmt.Errorf("generating player: %w", err)
240+
}
241+
242+
openInBrowser("file://" + htmlPath)
243+
fmt.Println("Alert clip opened in browser.")
244+
return nil
245+
}
246+
247+
func getAlertDetails(cfg config.Config, alertUuid string) (map[string]any, error) {
248+
resp, err := client.APICall(cfg, "/api/event/getPolicyAlertDetails", map[string]any{
249+
"policyAlertUuid": alertUuid,
250+
})
251+
if err != nil {
252+
return nil, fmt.Errorf("fetching alert details: %w", err)
253+
}
254+
255+
alert, ok := resp["policyAlert"].(map[string]any)
256+
if !ok || alert == nil {
257+
return nil, fmt.Errorf("alert not found: %s", alertUuid)
258+
}
259+
260+
return alert, nil
261+
}
262+
263+
func getAlertRegion(alert map[string]any, locationField string) string {
264+
loc, _ := alert[locationField].(map[string]any)
265+
if r, ok := loc["region"].(string); ok {
266+
return r
267+
}
268+
return "us-west-2"
269+
}
270+
271+
func getCameraNameMap(cfg config.Config) map[string]string {
272+
names := make(map[string]string)
273+
resp, err := client.APICall(cfg, "/api/camera/getMinimalCameraStateList", map[string]any{})
274+
if err != nil {
275+
return names
276+
}
277+
cameras, _ := resp["cameraStates"].([]any)
278+
for _, c := range cameras {
279+
cam, ok := c.(map[string]any)
280+
if !ok {
281+
continue
282+
}
283+
uuid, _ := cam["uuid"].(string)
284+
name, _ := cam["name"].(string)
285+
if uuid != "" && name != "" {
286+
names[uuid] = name
287+
}
288+
}
289+
return names
290+
}
291+
292+
func parseTimestamp(s string) (int64, error) {
293+
s = strings.TrimSpace(s)
294+
if s == "now" {
295+
return time.Now().UnixMilli(), nil
296+
}
297+
if ms, err := strconv.ParseInt(s, 10, 64); err == nil {
298+
return ms, nil
299+
}
300+
s = strings.TrimSuffix(s, " ago")
301+
s = strings.TrimSpace(s)
302+
for _, suffix := range []struct{ s string; d time.Duration }{
303+
{"s", time.Second}, {"m", time.Minute}, {"h", time.Hour},
304+
} {
305+
if strings.HasSuffix(s, suffix.s) {
306+
if n, err := strconv.Atoi(strings.TrimSuffix(s, suffix.s)); err == nil {
307+
return time.Now().Add(-time.Duration(n) * suffix.d).UnixMilli(), nil
308+
}
309+
}
310+
}
311+
if strings.HasSuffix(s, "d") {
312+
if n, err := strconv.Atoi(strings.TrimSuffix(s, "d")); err == nil {
313+
return time.Now().Add(-time.Duration(n) * 24 * time.Hour).UnixMilli(), nil
314+
}
315+
}
316+
return 0, fmt.Errorf("cannot parse: %s (use epoch ms, 'now', or relative like '5m ago')", s)
317+
}
318+
319+
func downloadWithAuth(cfg config.Config, url, outputPath string) error {
320+
req, err := http.NewRequest("GET", url, nil)
321+
if err != nil {
322+
return err
323+
}
324+
req.Header.Set("x-auth-apikey", cfg.ApiKey)
325+
if cfg.IsPartner {
326+
req.Header.Set("x-auth-scheme", "partner-api-token")
327+
} else {
328+
req.Header.Set("x-auth-scheme", "api-token")
329+
}
330+
331+
httpClient, err := client.GetHTTPClient(cfg)
332+
if err != nil {
333+
return err
334+
}
335+
336+
resp, err := httpClient.Do(req)
337+
if err != nil {
338+
return err
339+
}
340+
defer resp.Body.Close()
341+
342+
if resp.StatusCode != 200 {
343+
body, _ := io.ReadAll(resp.Body)
344+
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
345+
}
346+
347+
dir := filepath.Dir(outputPath)
348+
if dir != "." && dir != "" {
349+
os.MkdirAll(dir, 0755)
350+
}
351+
352+
f, err := os.Create(outputPath)
353+
if err != nil {
354+
return err
355+
}
356+
defer f.Close()
357+
358+
written, err := io.Copy(f, resp.Body)
359+
if err != nil {
360+
return err
361+
}
362+
363+
fmt.Printf(" Downloaded %.1f KB\n", float64(written)/1024)
364+
return nil
365+
}
366+
367+
func marshalJSON(v any) string {
368+
b, _ := json.MarshalIndent(v, "", " ")
369+
return string(b)
370+
}

0 commit comments

Comments
 (0)