teg/backend/milestone_cameras.go
2026-06-22 13:57:42 +02:00

1278 lines
35 KiB
Go

// backend\milestone_cameras.go
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"regexp"
"strings"
"github.com/jackc/pgx/v5"
)
type milestoneCameraPatchDevice struct {
DeviceID string
InventoryNumber string
ChildID string
MilestoneHardwareID string
MilestoneDeviceID string
DeviceType string
IPAddress string
MilestonePort 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"`
AvailableResolutions []string `json:"availableResolutions,omitempty"`
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"`
}
var milestoneResolutionPattern = regexp.MustCompile(`(?i)(\d{2,5})\s*(?:x|\x{00D7})\s*(\d{2,5})`)
var milestoneResolutionCandidateKeys = []string{
"availableResolutions",
"availableResolution",
"supportedResolutions",
"supportedResolution",
"resolutionOptions",
"resolutionValues",
"resolutionChoices",
"allowedResolutions",
"allowedResolution",
"resolutions",
"videoResolutions",
}
func normalizeMilestoneResolution(value any) string {
text := strings.TrimSpace(milestoneStringValue(value))
if text == "" {
return ""
}
match := milestoneResolutionPattern.FindStringSubmatch(text)
if len(match) >= 3 {
return match[1] + "x" + match[2]
}
return ""
}
func extractMilestoneResolutionValues(value any, depth int) []string {
if depth > 4 || value == nil {
return nil
}
switch typedValue := value.(type) {
case string:
values := []string{}
for _, part := range strings.FieldsFunc(typedValue, func(r rune) bool {
return r == ',' || r == ';' || r == '|'
}) {
if resolution := normalizeMilestoneResolution(part); resolution != "" {
values = append(values, resolution)
}
}
return values
case float64, int, int64:
if resolution := normalizeMilestoneResolution(typedValue); resolution != "" {
return []string{resolution}
}
return nil
case []any:
values := []string{}
for _, item := range typedValue {
values = append(values, extractMilestoneResolutionValues(item, depth+1)...)
}
return values
case map[string]any:
values := []string{}
for _, key := range []string{"value", "name", "displayName", "label", "text", "id", "resolution"} {
values = append(values, extractMilestoneResolutionValues(typedValue[key], depth+1)...)
}
for _, key := range []string{"values", "options", "items", "choices", "allowedValues", "allowed", "array"} {
values = append(values, extractMilestoneResolutionValues(typedValue[key], depth+1)...)
}
for key := range typedValue {
if resolution := normalizeMilestoneResolution(key); resolution != "" {
values = append(values, resolution)
}
}
return values
default:
if resolution := normalizeMilestoneResolution(typedValue); resolution != "" {
return []string{resolution}
}
return nil
}
}
func milestoneAvailableResolutions(streamMap map[string]any, currentResolution string) []string {
seen := map[string]struct{}{}
values := []string{}
add := func(candidates ...string) {
for _, candidate := range candidates {
resolution := normalizeMilestoneResolution(candidate)
if resolution == "" {
continue
}
if _, exists := seen[resolution]; exists {
continue
}
seen[resolution] = struct{}{}
values = append(values, resolution)
}
}
for _, key := range milestoneResolutionCandidateKeys {
for _, resolution := range extractMilestoneResolutionValues(streamMap[key], 0) {
add(resolution)
}
}
if _, ok := streamMap["resolution"].(map[string]any); ok {
for _, resolution := range extractMilestoneResolutionValues(streamMap["resolution"], 0) {
add(resolution)
}
}
add(currentResolution)
return values
}
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,
COALESCE(devices.ip_address, ''),
COALESCE(devices.milestone_port, ''),
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.IPAddress,
&camera.MilestonePort,
&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"]),
}
stream.AvailableResolutions = milestoneAvailableResolutions(
streamMap,
stream.Resolution,
)
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"`
LocalDeviceID string `json:"localDeviceId,omitempty"`
LocalChildID string `json:"localChildId,omitempty"`
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"`
}
type milestoneLocalDeviceRef struct {
DeviceID string
ChildID string
}
func milestoneLocalDeviceRefKey(hardwareID string, deviceType string, milestoneDeviceID string) string {
return strings.TrimSpace(hardwareID) + "|" +
strings.TrimSpace(strings.ToLower(deviceType)) + "|" +
strings.TrimSpace(milestoneDeviceID)
}
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 (s *Server) getMilestoneLocalDeviceRefMap(
ctx context.Context,
) (map[string]milestoneLocalDeviceRef, error) {
rows, err := s.db.Query(
ctx,
`
SELECT
devices.id::TEXT,
milestone_hardware_child_devices.id::TEXT,
milestone_hardware_child_devices.milestone_hardware_id,
milestone_hardware_child_devices.device_type,
milestone_hardware_child_devices.milestone_device_id
FROM milestone_hardware_child_devices
INNER JOIN devices
ON devices.milestone_hardware_id = milestone_hardware_child_devices.milestone_hardware_id
WHERE COALESCE(devices.milestone_hardware_id, '') <> ''
`,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]milestoneLocalDeviceRef{}
for rows.Next() {
var deviceID string
var childID string
var hardwareID string
var deviceType string
var milestoneDeviceID string
if err := rows.Scan(
&deviceID,
&childID,
&hardwareID,
&deviceType,
&milestoneDeviceID,
); err != nil {
return nil, err
}
deviceID = strings.TrimSpace(deviceID)
childID = strings.TrimSpace(childID)
hardwareID = strings.TrimSpace(hardwareID)
deviceType = strings.TrimSpace(strings.ToLower(deviceType))
milestoneDeviceID = strings.TrimSpace(milestoneDeviceID)
if deviceID == "" || childID == "" || hardwareID == "" || milestoneDeviceID == "" {
continue
}
out[milestoneLocalDeviceRefKey(hardwareID, deviceType, milestoneDeviceID)] = milestoneLocalDeviceRef{
DeviceID: deviceID,
ChildID: childID,
}
}
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,
localDeviceRefs map[string]milestoneLocalDeviceRef,
) ([]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])
localDeviceRef := localDeviceRefs[milestoneLocalDeviceRefKey(hardwareID, deviceType, id)]
items = append(items, milestoneCameraListItem{
ID: id,
LocalDeviceID: localDeviceRef.DeviceID,
LocalChildID: localDeviceRef.ChildID,
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
}
localDeviceRefs, err := s.getMilestoneLocalDeviceRefMap(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "Lokale Milestone-Gerätezuordnung konnte nicht geladen werden")
return
}
cameras, err := getMilestoneDeviceList(r.Context(), config, "cameras", hardwareDisplayNames, localDeviceRefs)
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, localDeviceRefs)
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
}
}
}
}
func getMilestoneApiCameraStreams(
ctx context.Context,
config milestoneRuntimeConfig,
milestoneDeviceID string,
) ([]any, error) {
requestURL := strings.TrimRight(config.Host, "/") +
"/API/rest/v1/cameras/" + url.PathEscape(milestoneDeviceID) + "/streams"
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 camera streams get failed: status=%d", response.StatusCode)
}
var decoded map[string]any
if err := json.Unmarshal(body, &decoded); err != nil {
return nil, err
}
if array, ok := decoded["array"].([]any); ok {
return array, nil
}
return []any{}, nil
}
func putMilestoneApiStream(
ctx context.Context,
config milestoneRuntimeConfig,
streamID string,
payload map[string]any,
) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
requestURL := strings.TrimRight(config.Host, "/") +
"/API/rest/v1/streams/" + url.PathEscape(streamID)
request, err := http.NewRequestWithContext(ctx, http.MethodPut, requestURL, bytes.NewReader(body))
if err != nil {
return err
}
setMilestoneRequestHeaders(request, config, true)
response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
responseBody, _ := io.ReadAll(response.Body)
if response.StatusCode < 200 || response.StatusCode >= 300 {
return fmt.Errorf("milestone stream put failed: status=%d body=%s", response.StatusCode, string(responseBody))
}
return nil
}
func (s *Server) handleGetMilestoneCameraApiStreams(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
}
config, err := s.getMilestoneRuntimeConfig(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden")
return
}
streams, err := getMilestoneApiCameraStreams(r.Context(), config, cameraDevice.MilestoneDeviceID)
if err != nil {
log.Printf("Milestone camera API streams failed: %v", err)
writeError(w, http.StatusBadGateway, "Milestone-Streams konnten nicht geladen werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"streams": streams,
})
}
func (s *Server) handleUpdateMilestoneApiStream(w http.ResponseWriter, r *http.Request) {
deviceID := strings.TrimSpace(r.PathValue("id"))
childID := strings.TrimSpace(r.PathValue("childId"))
streamID := strings.TrimSpace(r.PathValue("streamId"))
if deviceID == "" || childID == "" || streamID == "" {
writeError(w, http.StatusBadRequest, "Geräte-ID, Child-ID oder Stream-ID fehlt")
return
}
_, 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
}
var payload map[string]any
if err := readJSON(r, &payload); 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
}
if err := putMilestoneApiStream(r.Context(), config, streamID, payload); err != nil {
log.Printf("Milestone stream put failed: %v", err)
writeError(w, http.StatusBadGateway, "Milestone-Stream konnte nicht aktualisiert werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
})
}
func getMilestoneDeviceSettings(
ctx context.Context,
config milestoneRuntimeConfig,
milestoneDeviceID string,
) (map[string]any, error) {
requestURL := strings.TrimRight(config.Host, "/") +
"/API/rest/v1/settings/" + url.PathEscape(milestoneDeviceID)
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 settings get failed: status=%d", response.StatusCode)
}
var envelope struct {
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
return nil, err
}
if envelope.Data == nil {
return map[string]any{}, nil
}
return envelope.Data, nil
}
func patchMilestoneDeviceSettings(
ctx context.Context,
config milestoneRuntimeConfig,
settingsID string,
payload map[string]any,
) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
requestURL := strings.TrimRight(config.Host, "/") +
"/API/rest/v1/settings/" + url.PathEscape(settingsID)
request, err := http.NewRequestWithContext(ctx, http.MethodPatch, requestURL, bytes.NewReader(body))
if err != nil {
return err
}
setMilestoneRequestHeaders(request, config, true)
response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
responseBody, _ := io.ReadAll(response.Body)
if response.StatusCode < 200 || response.StatusCode >= 300 {
return fmt.Errorf("milestone settings patch failed: status=%d body=%s", response.StatusCode, string(responseBody))
}
return nil
}
func (s *Server) handleGetMilestoneCameraSettingsDetail(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
}
config, err := s.getMilestoneRuntimeConfig(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden")
return
}
settings, err := getMilestoneDeviceSettings(r.Context(), config, cameraDevice.MilestoneDeviceID)
if err != nil {
log.Printf("Milestone camera settings get failed: %v", err)
writeError(w, http.StatusBadGateway, "Milestone-Einstellungen konnten nicht geladen werden")
return
}
activeStreams, err := getMilestoneApiCameraStreams(r.Context(), config, cameraDevice.MilestoneDeviceID)
if err != nil {
log.Printf("Milestone camera active streams get failed: %v", err)
activeStreams = []any{}
}
onvifResolutions, err := s.getCachedOnvifCameraResolutions(
r.Context(),
cameraDevice,
config.SkipTLSVerify,
)
if err != nil {
log.Printf(
"ONVIF camera resolutions could not be loaded: hardwareId=%s deviceId=%s error=%v",
cameraDevice.MilestoneHardwareID,
cameraDevice.MilestoneDeviceID,
err,
)
}
if len(onvifResolutions) > 0 {
applyOnvifAvailableResolutionsToMilestoneSettings(settings, onvifResolutions)
applyOnvifAvailableResolutionsToMilestoneActiveStreams(activeStreams, onvifResolutions)
}
writeJSON(w, http.StatusOK, map[string]any{
"settings": settings,
"activeStreams": activeStreams,
})
}
func (s *Server) handleUpdateMilestoneCameraSettingsDetail(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
}
var payload map[string]any
if err := readJSON(r, &payload); 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
}
if err := patchMilestoneDeviceSettings(r.Context(), config, cameraDevice.MilestoneDeviceID, payload); err != nil {
log.Printf("Milestone camera settings patch failed: %v", err)
writeError(w, http.StatusBadGateway, "Milestone-Einstellungen konnten nicht aktualisiert werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
})
}