teg/backend/milestone_cameras.go
2026-06-12 16:51:33 +02:00

721 lines
20 KiB
Go

// backend\milestone_cameras.go
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"strings"
"github.com/jackc/pgx/v5"
)
type milestoneCameraPatchDevice struct {
DeviceID string
InventoryNumber string
ChildID string
MilestoneHardwareID string
MilestoneDeviceID string
DeviceType string
Enabled bool
RecordingEnabled bool
}
type milestoneRecordingStorageRef struct {
ID string `json:"id"`
DisplayName string `json:"displayName,omitempty"`
Name string `json:"name,omitempty"`
DiskPath string `json:"diskPath,omitempty"`
Mounted bool `json:"mounted,omitempty"`
}
type milestoneCameraStream struct {
Index int `json:"index"`
Codec string `json:"codec"`
FPS *int `json:"fps"`
JPEGQuality *int `json:"jPEGQuality"`
Quality *int `json:"quality"`
Resolution string `json:"resolution"`
EdgeStorageSupported bool `json:"edgeStorageSupported"`
}
type milestoneCameraDetail struct {
ID string `json:"id"`
MilestoneDeviceID string `json:"milestoneDeviceId"`
MilestoneHardwareID string `json:"milestoneHardwareId"`
Enabled bool `json:"enabled"`
RecordingEnabled bool `json:"recordingEnabled"`
RecordingFramerate *int `json:"recordingFramerate"`
RecordKeyframesOnly bool `json:"recordKeyframesOnly"`
RecordingStorage *milestoneRecordingStorageRef `json:"recordingStorage,omitempty"`
ArchiveStorages []milestoneArchiveStorage `json:"archiveStorages"`
Streams []milestoneCameraStream `json:"streams"`
}
type updateMilestoneCameraStreamRequest struct {
Index int `json:"index"`
Codec string `json:"codec"`
FPS *int `json:"fps"`
JPEGQuality *int `json:"jPEGQuality"`
Quality *int `json:"quality"`
Resolution string `json:"resolution"`
}
type updateMilestoneCameraDetailsRequest struct {
Enabled *bool `json:"enabled"`
RecordingEnabled *bool `json:"recordingEnabled"`
RecordingFramerate *int `json:"recordingFramerate"`
RecordKeyframesOnly *bool `json:"recordKeyframesOnly"`
RecordingStorageID *string `json:"recordingStorageId"`
Streams []updateMilestoneCameraStreamRequest `json:"streams"`
}
func (s *Server) getMilestoneCameraPatchDevice(
ctx context.Context,
deviceID string,
childID string,
) (milestoneCameraPatchDevice, error) {
var camera milestoneCameraPatchDevice
err := s.db.QueryRow(
ctx,
`
SELECT
devices.id::TEXT,
devices.inventory_number,
milestone_hardware_child_devices.id::TEXT,
milestone_hardware_child_devices.milestone_hardware_id,
milestone_hardware_child_devices.milestone_device_id,
milestone_hardware_child_devices.device_type,
milestone_hardware_child_devices.enabled,
COALESCE(milestone_hardware_child_devices.recording_enabled, false)
FROM milestone_hardware_child_devices
INNER JOIN devices
ON devices.milestone_hardware_id = milestone_hardware_child_devices.milestone_hardware_id
WHERE devices.id = $1
AND milestone_hardware_child_devices.id = $2
LIMIT 1
`,
deviceID,
childID,
).Scan(
&camera.DeviceID,
&camera.InventoryNumber,
&camera.ChildID,
&camera.MilestoneHardwareID,
&camera.MilestoneDeviceID,
&camera.DeviceType,
&camera.Enabled,
&camera.RecordingEnabled,
)
return camera, err
}
func (s *Server) handleGetMilestoneCameraDetails(w http.ResponseWriter, r *http.Request) {
deviceID := strings.TrimSpace(r.PathValue("id"))
childID := strings.TrimSpace(r.PathValue("childId"))
if deviceID == "" || childID == "" {
writeError(w, http.StatusBadRequest, "Geräte-ID oder Child-ID fehlt")
return
}
cameraDevice, err := s.getMilestoneCameraPatchDevice(r.Context(), deviceID, childID)
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "Milestone-Kamera wurde nicht gefunden")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "Milestone-Kamera konnte nicht geladen werden")
return
}
if strings.TrimSpace(strings.ToLower(cameraDevice.DeviceType)) != "camera" &&
strings.TrimSpace(strings.ToLower(cameraDevice.DeviceType)) != "cameras" {
writeError(w, http.StatusBadRequest, "Das Child-Device ist keine Kamera")
return
}
config, err := s.getMilestoneRuntimeConfig(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden")
return
}
cameraData, err := getMilestoneNamedDevice(
r.Context(),
config,
"cameras",
cameraDevice.MilestoneDeviceID,
)
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Kamera konnte nicht geladen werden")
return
}
archiveStorages := []milestoneArchiveStorage{}
var currentStorage *milestoneStorage
if recordingStorage, ok := cameraData["recordingStorage"].(map[string]any); ok {
recordingStorageID := strings.TrimSpace(milestoneStringValue(recordingStorage["id"]))
if recordingStorageID != "" {
currentStorage, err = getMilestoneStorage(
r.Context(),
config,
recordingStorageID,
)
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Storage konnte nicht geladen werden")
return
}
archiveStorages, err = getMilestoneArchiveStorages(
r.Context(),
config,
recordingStorageID,
)
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Archive-Storage konnte nicht geladen werden")
return
}
}
}
camera := mapMilestoneCameraDetail(cameraData, cameraDevice, currentStorage, archiveStorages)
writeJSON(w, http.StatusOK, map[string]any{
"camera": camera,
})
}
func (s *Server) handleUpdateMilestoneCameraDetails(w http.ResponseWriter, r *http.Request) {
deviceID := strings.TrimSpace(r.PathValue("id"))
childID := strings.TrimSpace(r.PathValue("childId"))
if deviceID == "" || childID == "" {
writeError(w, http.StatusBadRequest, "Geräte-ID oder Child-ID fehlt")
return
}
cameraDevice, err := s.getMilestoneCameraPatchDevice(r.Context(), deviceID, childID)
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "Milestone-Kamera wurde nicht gefunden")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "Milestone-Kamera konnte nicht geladen werden")
return
}
if strings.TrimSpace(strings.ToLower(cameraDevice.DeviceType)) != "camera" &&
strings.TrimSpace(strings.ToLower(cameraDevice.DeviceType)) != "cameras" {
writeError(w, http.StatusBadRequest, "Das Child-Device ist keine Kamera")
return
}
var input updateMilestoneCameraDetailsRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
config, err := s.getMilestoneRuntimeConfig(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden")
return
}
currentCamera, err := getMilestoneNamedDevice(
r.Context(),
config,
"cameras",
cameraDevice.MilestoneDeviceID,
)
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Kamera konnte nicht geladen werden")
return
}
if input.Enabled != nil {
currentCamera["enabled"] = *input.Enabled
}
if input.RecordingEnabled != nil {
currentCamera["recordingEnabled"] = *input.RecordingEnabled
}
if input.RecordingFramerate != nil {
currentCamera["recordingFramerate"] = *input.RecordingFramerate
}
if input.RecordKeyframesOnly != nil {
currentCamera["recordKeyframesOnly"] = *input.RecordKeyframesOnly
}
if input.RecordingStorageID != nil {
recordingStorageID := strings.TrimSpace(*input.RecordingStorageID)
if recordingStorageID == "" {
currentCamera["recordingStorage"] = nil
} else {
currentCamera["recordingStorage"] = map[string]any{
"type": "storages",
"id": recordingStorageID,
}
}
}
applyMilestoneCameraStreamUpdates(currentCamera, input.Streams)
if err := patchMilestoneNamedDevice(
r.Context(),
config,
"cameras",
cameraDevice.MilestoneDeviceID,
currentCamera,
); err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Kamera konnte nicht aktualisiert werden")
return
}
_, err = s.db.Exec(
r.Context(),
`
UPDATE milestone_hardware_child_devices
SET
enabled = COALESCE($2, enabled),
recording_enabled = COALESCE($3, recording_enabled),
recording_storage_id = COALESCE($4, recording_storage_id),
updated_at = now()
WHERE id = $1
`,
cameraDevice.ChildID,
input.Enabled,
input.RecordingEnabled,
input.RecordingStorageID,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Milestone-Kamera konnte lokal nicht aktualisiert werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
})
}
func mapMilestoneCameraDetail(
cameraData map[string]any,
cameraDevice milestoneCameraPatchDevice,
currentStorage *milestoneStorage,
archiveStorages []milestoneArchiveStorage,
) milestoneCameraDetail {
detail := milestoneCameraDetail{
ID: cameraDevice.ChildID,
MilestoneDeviceID: cameraDevice.MilestoneDeviceID,
MilestoneHardwareID: cameraDevice.MilestoneHardwareID,
Enabled: milestoneBoolValue(cameraData["enabled"]),
RecordingEnabled: milestoneBoolValue(cameraData["recordingEnabled"]),
RecordKeyframesOnly: milestoneBoolValue(cameraData["recordKeyframesOnly"]),
Streams: extractMilestoneCameraStreams(cameraData),
ArchiveStorages: archiveStorages,
}
if value, ok := milestoneIntPointerValue(cameraData["recordingFramerate"]); ok {
detail.RecordingFramerate = value
}
if currentStorage != nil && strings.TrimSpace(currentStorage.ID) != "" {
detail.RecordingStorage = &milestoneRecordingStorageRef{
ID: currentStorage.ID,
DisplayName: currentStorage.DisplayName,
Name: currentStorage.Name,
DiskPath: currentStorage.DiskPath,
Mounted: currentStorage.Mounted,
}
} else if recordingStorage, ok := cameraData["recordingStorage"].(map[string]any); ok {
recordingStorageID := strings.TrimSpace(milestoneStringValue(recordingStorage["id"]))
if recordingStorageID != "" {
detail.RecordingStorage = &milestoneRecordingStorageRef{
ID: recordingStorageID,
}
}
}
return detail
}
func extractMilestoneCameraStreams(cameraData map[string]any) []milestoneCameraStream {
settings, ok := cameraData["settings"].([]any)
if !ok {
return []milestoneCameraStream{}
}
streams := []milestoneCameraStream{}
for _, rawSetting := range settings {
setting, ok := rawSetting.(map[string]any)
if !ok {
continue
}
rawStreams, ok := setting["stream"].([]any)
if !ok {
continue
}
for index, rawStream := range rawStreams {
streamMap, ok := rawStream.(map[string]any)
if !ok {
continue
}
stream := milestoneCameraStream{
Index: index,
Codec: milestoneStringValue(streamMap["codec"]),
Resolution: milestoneStringValue(streamMap["resolution"]),
EdgeStorageSupported: milestoneBoolValue(streamMap["edgeStorageSupported"]),
}
if value, ok := milestoneIntPointerValue(streamMap["FPS"]); ok {
stream.FPS = value
}
if value, ok := milestoneIntPointerValue(streamMap["jPEGQuality"]); ok {
stream.JPEGQuality = value
}
if value, ok := milestoneIntPointerValue(streamMap["quality"]); ok {
stream.Quality = value
}
streams = append(streams, stream)
}
}
return streams
}
type milestoneCameraListItem struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"displayName"`
Type string `json:"type"`
Enabled bool `json:"enabled"`
RecordingEnabled bool `json:"recordingEnabled"`
RecordingStorageID string `json:"recordingStorageId"`
HardwareID string `json:"hardwareId"`
HardwareDisplayName string `json:"hardwareDisplayName"`
}
func (s *Server) getMilestoneHardwareDisplayNameMap(
ctx context.Context,
) (map[string]string, error) {
rows, err := s.db.Query(
ctx,
`
SELECT
COALESCE(milestone_hardware_id, ''),
COALESCE(milestone_display_name, inventory_number, '')
FROM devices
WHERE COALESCE(milestone_hardware_id, '') <> ''
`,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]string{}
for rows.Next() {
var hardwareID string
var displayName string
if err := rows.Scan(&hardwareID, &displayName); err != nil {
return nil, err
}
hardwareID = strings.TrimSpace(hardwareID)
displayName = strings.TrimSpace(displayName)
if hardwareID == "" || displayName == "" {
continue
}
if _, exists := out[hardwareID]; !exists {
out[hardwareID] = displayName
}
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func getMilestoneDeviceList(
ctx context.Context,
config milestoneRuntimeConfig,
collection string,
hardwareDisplayNames map[string]string,
) ([]milestoneCameraListItem, error) {
requestURL := strings.TrimRight(config.Host, "/") +
"/API/rest/v1/" + collection + "?disabled"
request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
if err != nil {
return nil, err
}
setMilestoneRequestHeaders(request, config, false)
response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
return nil, err
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, fmt.Errorf("milestone %s list failed: status=%d", collection, response.StatusCode)
}
var decoded map[string]any
if err := json.Unmarshal(body, &decoded); err != nil {
return nil, err
}
var rawItems []any
if array, ok := decoded["array"].([]any); ok {
rawItems = array
} else if data, ok := decoded["data"].(map[string]any); ok {
if array, ok := data["array"].([]any); ok {
rawItems = array
}
}
deviceType := "camera"
if collection == "microphones" {
deviceType = "microphone"
}
items := make([]milestoneCameraListItem, 0, len(rawItems))
for _, raw := range rawItems {
item, ok := raw.(map[string]any)
if !ok {
continue
}
id := milestoneNamedDeviceID(item)
if id == "" {
continue
}
name := milestoneStringValue(item["name"])
displayName := milestoneStringValue(item["displayName"])
if name == "" {
name = displayName
}
if displayName == "" {
displayName = name
}
if name == "" {
continue
}
enabled := milestoneItemEnabledValue(item)
recordingEnabled := false
if v, ok := item["recordingEnabled"].(bool); ok {
recordingEnabled = v
}
recordingStorageID := ""
if rs, ok := item["recordingStorage"].(map[string]any); ok {
recordingStorageID = strings.TrimSpace(milestoneStringValue(rs["id"]))
}
if recordingStorageID == "" {
if relations, ok := item["relations"].(map[string]any); ok {
if rs, ok := relations["recordingStorage"].(map[string]any); ok {
recordingStorageID = strings.TrimSpace(milestoneStringValue(rs["id"]))
}
}
}
hardwareID := milestoneNamedDeviceParentID(item)
hardwareDisplayName := strings.TrimSpace(hardwareDisplayNames[hardwareID])
items = append(items, milestoneCameraListItem{
ID: id,
Name: name,
DisplayName: displayName,
Type: deviceType,
Enabled: enabled,
RecordingEnabled: recordingEnabled,
RecordingStorageID: recordingStorageID,
HardwareID: hardwareID,
HardwareDisplayName: hardwareDisplayName,
})
}
return items, nil
}
func (s *Server) handleListMilestoneCameras(w http.ResponseWriter, r *http.Request) {
config, err := s.getMilestoneRuntimeConfig(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden")
return
}
hardwareDisplayNames, err := s.getMilestoneHardwareDisplayNameMap(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "Milestone-Hardwarenamen konnten nicht geladen werden")
return
}
cameras, err := getMilestoneDeviceList(r.Context(), config, "cameras", hardwareDisplayNames)
if err != nil {
log.Printf("Milestone cameras list failed: %v", err)
writeError(w, http.StatusBadGateway, "Milestone-Kameras konnten nicht geladen werden")
return
}
microphones, err := getMilestoneDeviceList(r.Context(), config, "microphones", hardwareDisplayNames)
if err != nil {
log.Printf("Milestone microphones list failed: %v", err)
writeError(w, http.StatusBadGateway, "Milestone-Mikrofone konnten nicht geladen werden")
return
}
all := make([]milestoneCameraListItem, 0, len(cameras)+len(microphones))
all = append(all, cameras...)
all = append(all, microphones...)
writeJSON(w, http.StatusOK, map[string]any{"devices": all})
}
type patchMilestoneDeviceRequest struct {
Enabled *bool `json:"enabled"`
RecordingEnabled *bool `json:"recordingEnabled"`
}
func (s *Server) handlePatchMilestoneDevice(w http.ResponseWriter, r *http.Request) {
collection := strings.TrimSpace(r.PathValue("collection"))
deviceID := strings.TrimSpace(r.PathValue("id"))
if collection != "cameras" && collection != "microphones" {
writeError(w, http.StatusBadRequest, "Ungültige Gerätekategorie")
return
}
if deviceID == "" {
writeError(w, http.StatusBadRequest, "Geräte-ID fehlt")
return
}
var input patchMilestoneDeviceRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
payload := map[string]any{}
if input.Enabled != nil {
payload["enabled"] = *input.Enabled
}
if input.RecordingEnabled != nil {
payload["recordingEnabled"] = *input.RecordingEnabled
}
if len(payload) == 0 {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
return
}
config, err := s.getMilestoneRuntimeConfig(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden")
return
}
if err := patchMilestoneNamedDevice(r.Context(), config, collection, deviceID, payload); err != nil {
log.Printf("Milestone device patch failed: collection=%s id=%s error=%v", collection, deviceID, err)
writeError(w, http.StatusBadGateway, "Gerät konnte nicht aktualisiert werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func applyMilestoneCameraStreamUpdates(
cameraData map[string]any,
streamUpdates []updateMilestoneCameraStreamRequest,
) {
if len(streamUpdates) == 0 {
return
}
settings, ok := cameraData["settings"].([]any)
if !ok {
return
}
updatesByIndex := map[int]updateMilestoneCameraStreamRequest{}
for _, streamUpdate := range streamUpdates {
updatesByIndex[streamUpdate.Index] = streamUpdate
}
for _, rawSetting := range settings {
setting, ok := rawSetting.(map[string]any)
if !ok {
continue
}
rawStreams, ok := setting["stream"].([]any)
if !ok {
continue
}
for index, rawStream := range rawStreams {
streamMap, ok := rawStream.(map[string]any)
if !ok {
continue
}
update, exists := updatesByIndex[index]
if !exists {
continue
}
streamMap["codec"] = update.Codec
streamMap["resolution"] = update.Resolution
if update.FPS != nil {
streamMap["FPS"] = *update.FPS
}
if update.JPEGQuality != nil {
streamMap["jPEGQuality"] = *update.JPEGQuality
}
if update.Quality != nil {
streamMap["quality"] = *update.Quality
}
}
}
}