1278 lines
35 KiB
Go
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,
|
|
})
|
|
}
|