added child devices

This commit is contained in:
Linrador 2026-05-29 16:18:28 +02:00
parent 47bf652b56
commit 5af9896514
12 changed files with 2139 additions and 279 deletions

View File

@ -154,6 +154,12 @@ type milestoneHardwareDevice struct {
SerialNumber string SerialNumber string
FirmwareVersion string FirmwareVersion string
Enabled bool Enabled bool
Cameras []milestoneNamedDevice
Microphones []milestoneNamedDevice
Inputs []milestoneNamedDevice
Outputs []milestoneNamedDevice
Speakers []milestoneNamedDevice
Metadata []milestoneNamedDevice
} }
func (s *Server) handleGetMilestoneSettings(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetMilestoneSettings(w http.ResponseWriter, r *http.Request) {
@ -790,6 +796,17 @@ func (s *Server) requestMilestoneHardwareDevices(
return nil, err return nil, err
} }
hardwareDetails, err := s.requestMilestoneHardwareDetails(
ctx,
host,
hardwareID,
accessToken,
skipTLSVerify,
)
if err != nil {
return nil, err
}
displayName := strings.TrimSpace(hardware.DisplayName) displayName := strings.TrimSpace(hardware.DisplayName)
if displayName == "" { if displayName == "" {
displayName = strings.TrimSpace(hardware.Name) displayName = strings.TrimSpace(hardware.Name)
@ -831,7 +848,144 @@ func (s *Server) requestMilestoneHardwareDevices(
enabled = !*hardware.Disabled enabled = !*hardware.Disabled
} }
ipAddress, port := extractMilestoneHardwareAddressParts(hardware.Address) hardwareAddress := strings.TrimSpace(milestoneStringValue(hardwareDetails["address"]))
if hardwareAddress == "" {
hardwareAddress = hardware.Address
}
ipAddress, port := extractMilestoneHardwareAddressParts(hardwareAddress)
embeddedCameras := milestoneNamedDevicesFromHardwareCollection(
hardwareDetails,
"cameras",
)
embeddedMicrophones := milestoneNamedDevicesFromHardwareCollection(
hardwareDetails,
"microphones",
)
embeddedInputs := milestoneNamedDevicesFromHardwareCollection(
hardwareDetails,
"inputs",
)
embeddedOutputs := milestoneNamedDevicesFromHardwareCollection(
hardwareDetails,
"outputs",
)
embeddedSpeakers := milestoneNamedDevicesFromHardwareCollection(
hardwareDetails,
"speakers",
)
embeddedMetadata := milestoneNamedDevicesFromHardwareCollection(
hardwareDetails,
"metadata",
)
camerasWithDisabled, err := s.requestMilestoneNamedDevicesByHardware(
ctx,
host,
"cameras",
hardwareID,
accessToken,
skipTLSVerify,
)
if err != nil {
return nil, err
}
microphonesWithDisabled, err := s.requestMilestoneNamedDevicesByHardware(
ctx,
host,
"microphones",
hardwareID,
accessToken,
skipTLSVerify,
)
if err != nil {
return nil, err
}
inputsWithDisabled, err := s.requestMilestoneNamedDevicesByHardware(
ctx,
host,
"inputs",
hardwareID,
accessToken,
skipTLSVerify,
)
if err != nil {
return nil, err
}
outputsWithDisabled, err := s.requestMilestoneNamedDevicesByHardware(
ctx,
host,
"outputs",
hardwareID,
accessToken,
skipTLSVerify,
)
if err != nil {
return nil, err
}
speakersWithDisabled, err := s.requestMilestoneNamedDevicesByHardware(
ctx,
host,
"speakers",
hardwareID,
accessToken,
skipTLSVerify,
)
if err != nil {
return nil, err
}
metadataWithDisabled, err := s.requestMilestoneNamedDevicesByHardware(
ctx,
host,
"metadata",
hardwareID,
accessToken,
skipTLSVerify,
)
if err != nil {
return nil, err
}
cameras := mergeMilestoneNamedDevices(
embeddedCameras,
camerasWithDisabled,
)
microphones := mergeMilestoneNamedDevices(
embeddedMicrophones,
microphonesWithDisabled,
)
inputs := mergeMilestoneNamedDevices(
embeddedInputs,
inputsWithDisabled,
)
outputs := mergeMilestoneNamedDevices(
embeddedOutputs,
outputsWithDisabled,
)
speakers := mergeMilestoneNamedDevices(
embeddedSpeakers,
speakersWithDisabled,
)
metadata := mergeMilestoneNamedDevices(
embeddedMetadata,
metadataWithDisabled,
)
result = append(result, milestoneHardwareDevice{ result = append(result, milestoneHardwareDevice{
HardwareID: hardwareID, HardwareID: hardwareID,
@ -846,26 +1000,100 @@ func (s *Server) requestMilestoneHardwareDevices(
SerialNumber: strings.TrimSpace(settings.SerialNumber), SerialNumber: strings.TrimSpace(settings.SerialNumber),
FirmwareVersion: strings.TrimSpace(settings.FirmwareVersion), FirmwareVersion: strings.TrimSpace(settings.FirmwareVersion),
Enabled: enabled, Enabled: enabled,
Cameras: cameras,
Microphones: microphones,
Inputs: inputs,
Outputs: outputs,
Speakers: speakers,
Metadata: metadata,
}) })
} }
cameraCount := 0
microphoneCount := 0
inputCount := 0
outputCount := 0
speakerCount := 0
metadataCount := 0
for _, hardwareDevice := range result {
cameraCount += len(hardwareDevice.Cameras)
microphoneCount += len(hardwareDevice.Microphones)
inputCount += len(hardwareDevice.Inputs)
outputCount += len(hardwareDevice.Outputs)
speakerCount += len(hardwareDevice.Speakers)
metadataCount += len(hardwareDevice.Metadata)
}
log.Printf( log.Printf(
"Milestone hardware loaded successfully: recordingServerId=%s count=%d", "Milestone hardware loaded successfully: recordingServerId=%s count=%d cameras=%d microphones=%d inputs=%d outputs=%d speakers=%d metadata=%d",
recordingServerID, recordingServerID,
len(result), len(result),
cameraCount,
microphoneCount,
inputCount,
outputCount,
speakerCount,
metadataCount,
) )
return result, nil return result, nil
} }
func (s *Server) requestMilestoneNamedDevicesByHardware(
ctx context.Context,
host string,
collection string,
hardwareID string,
accessToken string,
skipTLSVerify bool,
) ([]milestoneNamedDevice, error) {
switch collection {
case "cameras", "microphones":
default:
return nil, fmt.Errorf("unsupported milestone device collection: %s", collection)
}
requestURL := strings.TrimRight(host, "/") +
"/API/rest/v1/" +
collection +
"?disabled"
log.Printf(
"Requesting Milestone %s with disabled: url=%s hardwareId=%s skipTLSVerify=%t",
collection,
requestURL,
hardwareID,
skipTLSVerify,
)
body, err := requestMilestoneJSON(
ctx,
requestURL,
accessToken,
skipTLSVerify,
)
if err != nil {
return nil, err
}
return milestoneNamedDevicesFromListResponse(body, collection, hardwareID)
}
func extractMilestoneHardwareIPAddress(value string) string { func extractMilestoneHardwareIPAddress(value string) string {
host, _ := extractMilestoneHardwareAddressParts(value) host, _ := extractMilestoneHardwareAddressParts(value)
return host return host
} }
func extractMilestoneHardwarePort(value string) string { func defaultMilestonePortForScheme(scheme string) string {
_, port := extractMilestoneHardwareAddressParts(value) switch strings.ToLower(strings.TrimSpace(scheme)) {
return port case "https":
return "443"
case "http":
return "80"
default:
return "80"
}
} }
func extractMilestoneHardwareAddressParts(value string) (string, string) { func extractMilestoneHardwareAddressParts(value string) (string, string) {
@ -876,7 +1104,24 @@ func extractMilestoneHardwareAddressParts(value string) (string, string) {
parsedURL, err := url.Parse(value) parsedURL, err := url.Parse(value)
if err == nil && parsedURL.Hostname() != "" { if err == nil && parsedURL.Hostname() != "" {
return strings.TrimSpace(parsedURL.Hostname()), strings.TrimSpace(parsedURL.Port()) scheme := strings.ToLower(strings.TrimSpace(parsedURL.Scheme))
if scheme == "" {
scheme = "http"
}
port := strings.TrimSpace(parsedURL.Port())
if port == "" {
port = defaultMilestonePortForScheme(scheme)
}
return strings.TrimSpace(parsedURL.Hostname()), port
}
scheme := "http"
lowerValue := strings.ToLower(value)
if strings.HasPrefix(lowerValue, "https://") {
scheme = "https"
} }
value = strings.TrimPrefix(value, "http://") value = strings.TrimPrefix(value, "http://")
@ -884,10 +1129,19 @@ func extractMilestoneHardwareAddressParts(value string) (string, string) {
value = strings.TrimRight(value, "/") value = strings.TrimRight(value, "/")
if host, port, found := strings.Cut(value, ":"); found { if host, port, found := strings.Cut(value, ":"); found {
return strings.TrimSpace(host), strings.TrimSpace(port) port = strings.TrimSpace(port)
if isOnlyDigits(port) {
return strings.TrimSpace(host), port
}
} }
return strings.TrimSpace(value), "" return strings.TrimSpace(value), defaultMilestonePortForScheme(scheme)
}
func extractMilestoneHardwarePort(value string) string {
_, port := extractMilestoneHardwareAddressParts(value)
return port
} }
func (s *Server) requestMilestoneHardware( func (s *Server) requestMilestoneHardware(
@ -930,6 +1184,54 @@ func (s *Server) requestMilestoneHardware(
return hardwareResponse.Array, nil return hardwareResponse.Array, nil
} }
func (s *Server) requestMilestoneHardwareDetails(
ctx context.Context,
host string,
hardwareID string,
accessToken string,
skipTLSVerify bool,
) (map[string]any, error) {
requestURL := strings.TrimRight(host, "/") +
"/API/rest/v1/hardware/" +
url.PathEscape(hardwareID)
log.Printf(
"Requesting Milestone hardware details: url=%s skipTLSVerify=%t",
requestURL,
skipTLSVerify,
)
body, err := requestMilestoneJSON(
ctx,
requestURL,
accessToken,
skipTLSVerify,
)
if err != nil {
return nil, err
}
var envelope milestoneHardwareUpdateEnvelope
if err := json.Unmarshal(body, &envelope); err == nil && envelope.Data != nil {
return envelope.Data, nil
}
var direct map[string]any
if err := json.Unmarshal(body, &direct); err != nil {
return nil, fmt.Errorf(
"milestone hardware details response could not be parsed: body=%s error=%w",
truncateMilestoneLogBody(body),
err,
)
}
if data, ok := direct["data"].(map[string]any); ok {
return data, nil
}
return direct, nil
}
func (s *Server) requestMilestoneHardwareDriverSettings( func (s *Server) requestMilestoneHardwareDriverSettings(
ctx context.Context, ctx context.Context,
host string, host string,

View File

@ -14,29 +14,30 @@ import (
) )
type Device struct { type Device struct {
ID string `json:"id"` ID string `json:"id"`
InventoryNumber string `json:"inventoryNumber"` InventoryNumber string `json:"inventoryNumber"`
Manufacturer string `json:"manufacturer"` Manufacturer string `json:"manufacturer"`
Model string `json:"model"` Model string `json:"model"`
SerialNumber string `json:"serialNumber"` SerialNumber string `json:"serialNumber"`
MacAddress string `json:"macAddress"` MacAddress string `json:"macAddress"`
IPAddress string `json:"ipAddress"` IPAddress string `json:"ipAddress"`
MilestonePort string `json:"milestonePort"` MilestonePort string `json:"milestonePort"`
Location string `json:"location"` Location string `json:"location"`
LoanStatus string `json:"loanStatus"` LoanStatus string `json:"loanStatus"`
IsBaoDevice bool `json:"isBaoDevice"` IsBaoDevice bool `json:"isBaoDevice"`
Comment string `json:"comment"` Comment string `json:"comment"`
FirmwareVersion string `json:"firmwareVersion"` FirmwareVersion string `json:"firmwareVersion"`
FirmwareUpdate *DeviceFirmwareUpdate `json:"firmwareUpdate,omitempty"` FirmwareUpdate *DeviceFirmwareUpdate `json:"firmwareUpdate,omitempty"`
SupportBadges []DeviceSupportBadge `json:"supportBadges,omitempty"` SupportBadges []DeviceSupportBadge `json:"supportBadges,omitempty"`
MilestoneRecordingServerID string `json:"milestoneRecordingServerId"` MilestoneRecordingServerID string `json:"milestoneRecordingServerId"`
MilestoneHardwareID string `json:"milestoneHardwareId"` MilestoneHardwareID string `json:"milestoneHardwareId"`
MilestoneDisplayName string `json:"milestoneDisplayName"` MilestoneDisplayName string `json:"milestoneDisplayName"`
MilestoneEnabled bool `json:"milestoneEnabled"` MilestoneEnabled bool `json:"milestoneEnabled"`
MilestoneSyncedAt *time.Time `json:"milestoneSyncedAt,omitempty"` MilestoneSyncedAt *time.Time `json:"milestoneSyncedAt,omitempty"`
RelatedDevices []DeviceShort `json:"relatedDevices"` RelatedDevices []DeviceShort `json:"relatedDevices"`
CreatedAt time.Time `json:"createdAt"` MilestoneChildDevices []MilestoneHardwareChildDevice `json:"milestoneChildDevices"`
UpdatedAt time.Time `json:"updatedAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
} }
type DeviceFirmwareUpdate struct { type DeviceFirmwareUpdate struct {
@ -63,6 +64,19 @@ type DeviceShort struct {
Model string `json:"model"` Model string `json:"model"`
} }
type MilestoneHardwareChildDevice struct {
ID string `json:"id"`
MilestoneHardwareID string `json:"milestoneHardwareId"`
MilestoneDeviceID string `json:"milestoneDeviceId"`
DeviceType string `json:"deviceType"`
Name string `json:"name"`
DisplayName string `json:"displayName"`
ShortName string `json:"shortName"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type CreateDeviceRequest struct { type CreateDeviceRequest struct {
InventoryNumber string `json:"inventoryNumber"` InventoryNumber string `json:"inventoryNumber"`
Manufacturer string `json:"manufacturer"` Manufacturer string `json:"manufacturer"`
@ -137,7 +151,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
defer rows.Close() defer rows.Close()
devices := make([]Device, 0) devices := make([]Device, 0)
deviceMap := map[string]*Device{} deviceIndexMap := map[string]int{}
for rows.Next() { for rows.Next() {
var device Device var device Device
@ -169,9 +183,10 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
} }
device.RelatedDevices = []DeviceShort{} device.RelatedDevices = []DeviceShort{}
device.MilestoneChildDevices = []MilestoneHardwareChildDevice{}
devices = append(devices, device) devices = append(devices, device)
deviceMap[device.ID] = &devices[len(devices)-1] deviceIndexMap[device.ID] = len(devices) - 1
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
@ -216,9 +231,12 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
return return
} }
device, exists := deviceMap[deviceID] deviceIndex, exists := deviceIndexMap[deviceID]
if exists { if exists {
device.RelatedDevices = append(device.RelatedDevices, relatedDevice) devices[deviceIndex].RelatedDevices = append(
devices[deviceIndex].RelatedDevices,
relatedDevice,
)
} }
} }
@ -227,6 +245,72 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
return return
} }
childRows, err := s.db.Query(
r.Context(),
`
SELECT
devices.id::TEXT,
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.name,
milestone_hardware_child_devices.display_name,
milestone_hardware_child_devices.short_name,
milestone_hardware_child_devices.enabled,
milestone_hardware_child_devices.created_at,
milestone_hardware_child_devices.updated_at
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, '') <> ''
ORDER BY
milestone_hardware_child_devices.device_type ASC,
milestone_hardware_child_devices.name ASC,
milestone_hardware_child_devices.display_name ASC
`,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Could not load Milestone child devices")
return
}
defer childRows.Close()
for childRows.Next() {
var deviceID string
var childDevice MilestoneHardwareChildDevice
if err := childRows.Scan(
&deviceID,
&childDevice.ID,
&childDevice.MilestoneHardwareID,
&childDevice.MilestoneDeviceID,
&childDevice.DeviceType,
&childDevice.Name,
&childDevice.DisplayName,
&childDevice.ShortName,
&childDevice.Enabled,
&childDevice.CreatedAt,
&childDevice.UpdatedAt,
); err != nil {
writeError(w, http.StatusInternalServerError, "Could not read Milestone child devices")
return
}
deviceIndex, exists := deviceIndexMap[deviceID]
if exists {
devices[deviceIndex].MilestoneChildDevices = append(
devices[deviceIndex].MilestoneChildDevices,
childDevice,
)
}
}
if err := childRows.Err(); err != nil {
writeError(w, http.StatusInternalServerError, "Could not read Milestone child devices")
return
}
s.enrichDevicesWithFirmwareUpdates(r.Context(), devices) s.enrichDevicesWithFirmwareUpdates(r.Context(), devices)
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{

View File

@ -8,6 +8,7 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
@ -46,6 +47,151 @@ type updateMilestoneHardwareRequest struct {
Enabled *bool `json:"enabled"` Enabled *bool `json:"enabled"`
} }
type updateMilestoneChildDeviceRequest struct {
Enabled *bool `json:"enabled"`
}
type milestoneChildPatchDevice struct {
DeviceID string
ChildID string
MilestoneDeviceID string
DeviceType string
Enabled bool
}
func (s *Server) getMilestoneChildPatchDevice(
ctx context.Context,
deviceID string,
childID string,
) (milestoneChildPatchDevice, error) {
var child milestoneChildPatchDevice
err := s.db.QueryRow(
ctx,
`
SELECT
devices.id::TEXT,
milestone_hardware_child_devices.id::TEXT,
milestone_hardware_child_devices.milestone_device_id,
milestone_hardware_child_devices.device_type,
milestone_hardware_child_devices.enabled
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(
&child.DeviceID,
&child.ChildID,
&child.MilestoneDeviceID,
&child.DeviceType,
&child.Enabled,
)
return child, err
}
func (s *Server) handleUpdateMilestoneChildDevice(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
}
var input updateMilestoneChildDeviceRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if input.Enabled == nil {
writeError(w, http.StatusBadRequest, "enabled fehlt")
return
}
child, err := s.getMilestoneChildPatchDevice(r.Context(), deviceID, childID)
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "Milestone-Child-Device wurde nicht gefunden")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "Milestone-Child-Device 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
}
collection, err := milestoneCollectionForChildDeviceType(child.DeviceType)
if err != nil {
writeError(w, http.StatusBadRequest, "Unbekannter Milestone-Child-Device-Typ")
return
}
if err := patchMilestoneNamedDevice(
r.Context(),
config,
collection,
child.MilestoneDeviceID,
map[string]any{
"enabled": *input.Enabled,
},
); err != nil {
writeError(w, http.StatusBadGateway, "Milestone-Child-Device konnte nicht aktualisiert werden")
return
}
_, err = s.db.Exec(
r.Context(),
`
UPDATE milestone_hardware_child_devices
SET enabled = $2,
updated_at = now()
WHERE id = $1
`,
child.ChildID,
*input.Enabled,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Milestone-Child-Device konnte lokal nicht aktualisiert werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"id": child.ChildID,
"enabled": *input.Enabled,
})
}
func milestoneCollectionForChildDeviceType(deviceType string) (string, error) {
switch strings.TrimSpace(strings.ToLower(deviceType)) {
case "camera", "cameras":
return "cameras", nil
case "microphone", "microphones":
return "microphones", nil
case "input", "inputs":
return "inputs", nil
case "output", "outputs":
return "outputs", nil
case "speaker", "speakers":
return "speakers", nil
case "metadata":
return "metadata", nil
default:
return "", fmt.Errorf("unsupported milestone child device type: %s", deviceType)
}
}
func (s *Server) getMilestoneHardwarePatchDevice( func (s *Server) getMilestoneHardwarePatchDevice(
ctx context.Context, ctx context.Context,
deviceID string, deviceID string,
@ -282,11 +428,13 @@ func (s *Server) handleUpdateMilestoneHardware(w http.ResponseWriter, r *http.Re
return return
} }
if err := patchMilestoneHardware( if err := updateMilestoneHardware(
r.Context(), r.Context(),
config, config,
device.MilestoneHardwareID, device.MilestoneHardwareID,
payload, payload,
device.MilestoneDisplayName,
nextDisplayName,
); err != nil { ); err != nil {
s.logAndWriteUserError( s.logAndWriteUserError(
w, w,
@ -294,7 +442,7 @@ func (s *Server) handleUpdateMilestoneHardware(w http.ResponseWriter, r *http.Re
user, user,
http.StatusBadGateway, http.StatusBadGateway,
"milestone_hardware", "milestone_hardware",
"patch_hardware", "update_hardware",
"Milestone-Gerät konnte nicht aktualisiert werden", "Milestone-Gerät konnte nicht aktualisiert werden",
err, err,
LogFields{ LogFields{
@ -338,6 +486,41 @@ func (s *Server) handleUpdateMilestoneHardware(w http.ResponseWriter, r *http.Re
return return
} }
_, err = tx.Exec(
r.Context(),
`
UPDATE milestone_hardware_child_devices
SET
name = CASE
WHEN name = $2 THEN $3
WHEN name LIKE $2 || ' - %' THEN $3 || substring(name FROM char_length($2) + 1)
WHEN name LIKE $2 || ' %' THEN $3 || substring(name FROM char_length($2) + 1)
ELSE name
END,
display_name = CASE
WHEN display_name = $2 THEN $3
WHEN display_name LIKE $2 || ' - %' THEN $3 || substring(display_name FROM char_length($2) + 1)
WHEN display_name LIKE $2 || ' %' THEN $3 || substring(display_name FROM char_length($2) + 1)
ELSE display_name
END,
short_name = CASE
WHEN short_name = $2 THEN $3
WHEN short_name LIKE $2 || ' - %' THEN $3 || substring(short_name FROM char_length($2) + 1)
WHEN short_name LIKE $2 || ' %' THEN $3 || substring(short_name FROM char_length($2) + 1)
ELSE short_name
END,
updated_at = now()
WHERE milestone_hardware_id = $1
`,
device.MilestoneHardwareID,
device.MilestoneDisplayName,
nextDisplayName,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Milestone-Child-Devices konnten lokal nicht aktualisiert werden")
return
}
if len(changes) > 0 { if len(changes) > 0 {
if err := recordDeviceHistory(r.Context(), tx, device.ID, user, "updated", changes); err != nil { if err := recordDeviceHistory(r.Context(), tx, device.ID, user, "updated", changes); err != nil {
writeError(w, http.StatusInternalServerError, "Geräteverlauf konnte nicht gespeichert werden") writeError(w, http.StatusInternalServerError, "Geräteverlauf konnte nicht gespeichert werden")
@ -387,36 +570,59 @@ func normalizeMilestoneHardwareAddress(value string, port string) string {
return "" return ""
} }
if !strings.HasPrefix(value, "http://") && !strings.HasPrefix(value, "https://") { rawValue := value
if !strings.HasPrefix(strings.ToLower(value), "http://") &&
!strings.HasPrefix(strings.ToLower(value), "https://") {
value = "http://" + strings.TrimRight(value, "/") value = "http://" + strings.TrimRight(value, "/")
} }
parsedURL, err := url.Parse(value) parsedURL, err := url.Parse(value)
if err != nil || parsedURL.Hostname() == "" { if err != nil || parsedURL.Hostname() == "" {
value = strings.TrimRight(value, "/") host := strings.TrimRight(rawValue, "/")
if port != "" && !strings.Contains(value, ":") { host = strings.TrimPrefix(host, "http://")
value += ":" + port host = strings.TrimPrefix(host, "https://")
if existingHost, existingPort, found := strings.Cut(host, ":"); found {
host = strings.TrimSpace(existingHost)
if port == "" && isOnlyDigits(strings.TrimSpace(existingPort)) {
port = strings.TrimSpace(existingPort)
}
} }
return value + "/" if port == "" {
} port = defaultMilestonePortForScheme("http")
}
scheme := strings.TrimSpace(parsedURL.Scheme) scheme := milestoneSchemeForPort(port)
if scheme == "" {
scheme = "http" return scheme + "://" + host + ":" + port + "/"
} }
host := parsedURL.Hostname() host := parsedURL.Hostname()
if port == "" { if port == "" {
port = parsedURL.Port() port = normalizeMilestonePort(parsedURL.Port())
} }
hostPort := host if port == "" {
if port != "" { port = defaultMilestonePortForScheme(parsedURL.Scheme)
hostPort += ":" + port
} }
return scheme + "://" + hostPort + "/" scheme := milestoneSchemeForPort(port)
return scheme + "://" + host + ":" + port + "/"
}
func milestoneSchemeForPort(port string) string {
port = normalizeMilestonePort(port)
if port == "443" {
return "https"
}
return "http"
} }
func normalizeMilestonePort(value string) string { func normalizeMilestonePort(value string) string {
@ -430,6 +636,10 @@ func normalizeMilestonePort(value string) string {
return value return value
} }
type milestoneHardwareUpdateEnvelope struct {
Data map[string]any `json:"data"`
}
const milestoneTokenRefreshBeforeExpiry = 2 * time.Minute const milestoneTokenRefreshBeforeExpiry = 2 * time.Minute
func (s *Server) getMilestoneRuntimeConfig( func (s *Server) getMilestoneRuntimeConfig(
@ -554,11 +764,162 @@ func (s *Server) refreshMilestoneRuntimeToken(ctx context.Context) error {
return err return err
} }
func patchMilestoneHardware( func updateMilestoneHardware(
ctx context.Context, ctx context.Context,
config milestoneRuntimeConfig, config milestoneRuntimeConfig,
hardwareID string, hardwareID string,
payload any, updates map[string]any,
previousHardwareName string,
requestedHardwareName string,
) error {
currentHardware, err := getMilestoneHardware(ctx, config, hardwareID)
if err != nil {
return err
}
currentHardwareName := milestoneStringValue(currentHardware["name"])
if currentHardwareName == "" {
currentHardwareName = milestoneStringValue(currentHardware["displayName"])
}
oldHardwareName := strings.TrimSpace(previousHardwareName)
if oldHardwareName == "" {
oldHardwareName = currentHardwareName
}
newHardwareName := strings.TrimSpace(requestedHardwareName)
if value, exists := updates["name"]; exists {
if updateName := milestoneStringValue(value); updateName != "" {
newHardwareName = updateName
}
}
_, shouldUpdateDeviceNames := updates["name"]
hardwareDevices := []milestoneNamedDevice{}
if shouldUpdateDeviceNames {
embeddedDevices := milestoneNamedDevicesFromHardware(currentHardware)
camerasWithDisabled, err := requestMilestoneNamedDevicesByHardware(
ctx,
config,
"cameras",
hardwareID,
)
if err != nil {
return err
}
microphonesWithDisabled, err := requestMilestoneNamedDevicesByHardware(
ctx,
config,
"microphones",
hardwareID,
)
if err != nil {
return err
}
hardwareDevices = mergeMilestoneNamedDevices(
embeddedDevices,
camerasWithDisabled,
microphonesWithDisabled,
)
}
for key, value := range updates {
currentHardware[key] = value
}
if err := putMilestoneHardware(ctx, config, hardwareID, currentHardware); err != nil {
return err
}
if !shouldUpdateDeviceNames || newHardwareName == "" || len(hardwareDevices) == 0 {
return nil
}
return updateMilestoneChildDeviceBaseNames(
ctx,
config,
hardwareDevices,
oldHardwareName,
newHardwareName,
)
}
func getMilestoneHardware(
ctx context.Context,
config milestoneRuntimeConfig,
hardwareID string,
) (map[string]any, error) {
requestURL := strings.TrimRight(config.Host, "/") +
"/API/rest/v1/hardware/" +
url.PathEscape(hardwareID)
request, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
requestURL,
nil,
)
if err != nil {
return nil, err
}
tokenType := strings.TrimSpace(config.TokenType)
if tokenType == "" {
tokenType = "Bearer"
}
request.Header.Set("Authorization", tokenType+" "+config.AccessToken)
request.Header.Set("Accept", "application/json")
response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
responseBody, _ := io.ReadAll(response.Body)
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, newAppErrorf(
"milestone hardware get failed",
LogFields{
"statusCode": response.StatusCode,
"responseBody": string(responseBody),
"hardwareId": hardwareID,
},
"milestone hardware get failed: status=%d body=%s",
response.StatusCode,
string(responseBody),
)
}
var envelope milestoneHardwareUpdateEnvelope
if err := json.Unmarshal(responseBody, &envelope); err == nil && envelope.Data != nil {
return envelope.Data, nil
}
var direct map[string]any
if err := json.Unmarshal(responseBody, &direct); err != nil {
return nil, err
}
if data, ok := direct["data"].(map[string]any); ok {
return data, nil
}
return direct, nil
}
func putMilestoneHardware(
ctx context.Context,
config milestoneRuntimeConfig,
hardwareID string,
payload map[string]any,
) error { ) error {
body, err := json.Marshal(payload) body, err := json.Marshal(payload)
if err != nil { if err != nil {
@ -569,6 +930,482 @@ func patchMilestoneHardware(
"/API/rest/v1/hardware/" + "/API/rest/v1/hardware/" +
url.PathEscape(hardwareID) url.PathEscape(hardwareID)
request, err := http.NewRequestWithContext(
ctx,
http.MethodPut,
requestURL,
bytes.NewReader(body),
)
if err != nil {
return err
}
tokenType := strings.TrimSpace(config.TokenType)
if tokenType == "" {
tokenType = "Bearer"
}
request.Header.Set("Authorization", tokenType+" "+config.AccessToken)
request.Header.Set("Accept", "application/json")
request.Header.Set("Content-Type", "application/json")
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 newAppErrorf(
"milestone hardware put failed",
LogFields{
"statusCode": response.StatusCode,
"responseBody": string(responseBody),
"hardwareId": hardwareID,
"payload": payload,
},
"milestone hardware put failed: status=%d body=%s",
response.StatusCode,
string(responseBody),
)
}
return nil
}
func replaceMilestoneDeviceBaseName(
cameraName string,
oldHardwareName string,
newHardwareName string,
) (string, bool) {
cameraName = strings.TrimSpace(cameraName)
oldHardwareName = strings.TrimSpace(oldHardwareName)
newHardwareName = strings.TrimSpace(newHardwareName)
if cameraName == "" || newHardwareName == "" {
return cameraName, false
}
if oldHardwareName != "" {
if cameraName == oldHardwareName {
return newHardwareName, true
}
if strings.HasPrefix(cameraName, oldHardwareName) {
suffix := strings.TrimPrefix(cameraName, oldHardwareName)
if suffix == "" ||
strings.HasPrefix(suffix, " ") ||
strings.HasPrefix(suffix, "-") ||
strings.HasPrefix(suffix, "_") {
return newHardwareName + suffix, true
}
}
}
// Fallback für Fälle, in denen die Hardware bereits umbenannt wurde,
// die Kamera aber noch z. B. "Camera_102_Test - Kamera 1" heißt.
if baseName, suffix, found := strings.Cut(cameraName, " - "); found {
baseName = strings.TrimSpace(baseName)
suffix = strings.TrimSpace(suffix)
if baseName != "" && suffix != "" && baseName != newHardwareName {
return newHardwareName + " - " + suffix, true
}
}
return cameraName, false
}
func milestoneStringValue(value any) string {
text, ok := value.(string)
if !ok {
return ""
}
return strings.TrimSpace(text)
}
type milestoneNamedDevice struct {
Collection string
ID string
Name string
DisplayName string
ShortName string
Enabled bool
}
func milestoneItemEnabledValue(item map[string]any) bool {
enabled := true
if value, ok := item["enabled"].(bool); ok {
enabled = value
}
if value, ok := item["disabled"].(bool); ok {
enabled = !value
}
return enabled
}
func milestoneNamedDevicesFromHardware(hardware map[string]any) []milestoneNamedDevice {
devices := []milestoneNamedDevice{}
devices = append(
devices,
milestoneNamedDevicesFromHardwareCollection(hardware, "cameras")...,
)
devices = append(
devices,
milestoneNamedDevicesFromHardwareCollection(hardware, "microphones")...,
)
devices = append(
devices,
milestoneNamedDevicesFromHardwareCollection(hardware, "inputs")...,
)
devices = append(
devices,
milestoneNamedDevicesFromHardwareCollection(hardware, "outputs")...,
)
devices = append(
devices,
milestoneNamedDevicesFromHardwareCollection(hardware, "speakers")...,
)
devices = append(
devices,
milestoneNamedDevicesFromHardwareCollection(hardware, "metadata")...,
)
return devices
}
func milestoneNamedDevicesFromHardwareCollectionOnly(
hardware map[string]any,
collection string,
) []milestoneNamedDevice {
return milestoneNamedDevicesFromHardwareCollection(hardware, collection)
}
func milestoneNamedDevicesFromHardwareCollection(
hardware map[string]any,
collection string,
) []milestoneNamedDevice {
rawItems, ok := hardware[collection].([]any)
if !ok {
return nil
}
devices := make([]milestoneNamedDevice, 0, len(rawItems))
for _, rawItem := range rawItems {
item, ok := rawItem.(map[string]any)
if !ok {
continue
}
id := milestoneNamedDeviceID(item)
if id == "" {
continue
}
name := milestoneStringValue(item["name"])
displayName := milestoneStringValue(item["displayName"])
shortName := milestoneStringValue(item["shortName"])
if name == "" {
name = displayName
}
if name == "" {
name = shortName
}
if displayName == "" {
displayName = name
}
if shortName == "" {
shortName = name
}
if name == "" {
continue
}
enabled := milestoneItemEnabledValue(item)
devices = append(devices, milestoneNamedDevice{
Collection: collection,
ID: id,
Name: name,
DisplayName: displayName,
ShortName: shortName,
Enabled: enabled,
})
}
return devices
}
func mergeMilestoneNamedDevices(groups ...[]milestoneNamedDevice) []milestoneNamedDevice {
result := []milestoneNamedDevice{}
seen := map[string]bool{}
for _, group := range groups {
for _, device := range group {
key := device.Collection + ":" + device.ID
if device.ID == "" || seen[key] {
continue
}
seen[key] = true
result = append(result, device)
}
}
return result
}
func requestMilestoneNamedDevicesByHardware(
ctx context.Context,
config milestoneRuntimeConfig,
collection string,
hardwareID string,
) ([]milestoneNamedDevice, error) {
switch collection {
case "cameras", "microphones", "inputs", "outputs", "speakers", "metadata":
default:
return nil, fmt.Errorf("unsupported milestone device collection: %s", collection)
}
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
}
tokenType := strings.TrimSpace(config.TokenType)
if tokenType == "" {
tokenType = "Bearer"
}
request.Header.Set("Authorization", tokenType+" "+config.AccessToken)
request.Header.Set("Accept", "application/json")
response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
responseBody, _ := io.ReadAll(response.Body)
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, newAppErrorf(
"milestone child device list failed",
LogFields{
"statusCode": response.StatusCode,
"responseBody": string(responseBody),
"collection": collection,
"hardwareId": hardwareID,
},
"milestone child device list failed: collection=%s hardwareId=%s status=%d body=%s",
collection,
hardwareID,
response.StatusCode,
string(responseBody),
)
}
return milestoneNamedDevicesFromListResponse(responseBody, collection, hardwareID)
}
func milestoneNamedDevicesFromListResponse(
body []byte,
collection string,
hardwareID string,
) ([]milestoneNamedDevice, error) {
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 array, ok := decoded["data"].([]any); ok {
rawItems = array
} else if data, ok := decoded["data"].(map[string]any); ok {
if array, ok := data["array"].([]any); ok {
rawItems = array
}
}
if len(rawItems) == 0 {
return nil, nil
}
devices := make([]milestoneNamedDevice, 0, len(rawItems))
hardwareID = strings.TrimSpace(hardwareID)
for _, rawItem := range rawItems {
item, ok := rawItem.(map[string]any)
if !ok {
continue
}
parentID := milestoneNamedDeviceParentID(item)
if parentID != hardwareID {
continue
}
id := milestoneNamedDeviceID(item)
if id == "" {
continue
}
displayName := milestoneStringValue(item["displayName"])
shortName := milestoneStringValue(item["shortName"])
name := milestoneStringValue(item["name"])
if name == "" {
name = displayName
}
if name == "" {
name = shortName
}
if displayName == "" {
displayName = name
}
if shortName == "" {
shortName = name
}
if name == "" {
continue
}
enabled := milestoneItemEnabledValue(item)
devices = append(devices, milestoneNamedDevice{
Collection: collection,
ID: id,
Name: name,
DisplayName: displayName,
ShortName: shortName,
Enabled: enabled,
})
}
return devices, nil
}
func milestoneNamedDeviceParentID(item map[string]any) string {
relations, ok := item["relations"].(map[string]any)
if !ok {
return ""
}
parent, ok := relations["parent"].(map[string]any)
if !ok {
return ""
}
return milestoneStringValue(parent["id"])
}
func milestoneNamedDeviceID(item map[string]any) string {
if id := milestoneStringValue(item["id"]); id != "" {
return id
}
relations, ok := item["relations"].(map[string]any)
if !ok {
return ""
}
self, ok := relations["self"].(map[string]any)
if !ok {
return ""
}
return milestoneStringValue(self["id"])
}
func updateMilestoneChildDeviceBaseNames(
ctx context.Context,
config milestoneRuntimeConfig,
devices []milestoneNamedDevice,
oldHardwareName string,
newHardwareName string,
) error {
for _, device := range devices {
nextName, changed := replaceMilestoneDeviceBaseName(
device.Name,
oldHardwareName,
newHardwareName,
)
if !changed {
continue
}
if err := patchMilestoneNamedDevice(
ctx,
config,
device.Collection,
device.ID,
map[string]any{
"name": nextName,
"displayName": nextName,
"shortName": nextName,
},
); err != nil {
return err
}
}
return nil
}
func patchMilestoneNamedDevice(
ctx context.Context,
config milestoneRuntimeConfig,
collection string,
deviceID string,
payload map[string]any,
) error {
switch collection {
case "cameras", "microphones":
default:
return fmt.Errorf("unsupported milestone device collection: %s", collection)
}
body, err := json.Marshal(payload)
if err != nil {
return err
}
requestURL := strings.TrimRight(config.Host, "/") +
"/API/rest/v1/" +
collection +
"/" +
url.PathEscape(deviceID)
request, err := http.NewRequestWithContext( request, err := http.NewRequestWithContext(
ctx, ctx,
http.MethodPatch, http.MethodPatch,
@ -598,13 +1435,17 @@ func patchMilestoneHardware(
if response.StatusCode < 200 || response.StatusCode >= 300 { if response.StatusCode < 200 || response.StatusCode >= 300 {
return newAppErrorf( return newAppErrorf(
"milestone hardware patch failed", "milestone child device patch failed",
LogFields{ LogFields{
"statusCode": response.StatusCode, "statusCode": response.StatusCode,
"responseBody": string(responseBody), "responseBody": string(responseBody),
"hardwareId": hardwareID, "collection": collection,
"deviceId": deviceID,
"payload": payload,
}, },
"milestone hardware patch failed: status=%d body=%s", "milestone child device patch failed: collection=%s id=%s status=%d body=%s",
collection,
deviceID,
response.StatusCode, response.StatusCode,
string(responseBody), string(responseBody),
) )

View File

@ -50,6 +50,10 @@ func syncMilestoneHardwareDevices(
return syncedCount, err return syncedCount, err
} }
if err := syncMilestoneHardwareChildDevices(ctx, tx, hardwareDevice); err != nil {
return syncedCount, err
}
if synced { if synced {
syncedCount++ syncedCount++
} }
@ -58,6 +62,165 @@ func syncMilestoneHardwareDevices(
return syncedCount, nil return syncedCount, nil
} }
func syncMilestoneHardwareChildDevices(
ctx context.Context,
tx pgx.Tx,
hardwareDevice milestoneHardwareDevice,
) error {
hardwareID := strings.TrimSpace(hardwareDevice.HardwareID)
if hardwareID == "" {
return nil
}
_, err := tx.Exec(
ctx,
`
DELETE FROM milestone_hardware_child_devices
WHERE milestone_hardware_id = $1
`,
hardwareID,
)
if err != nil {
return err
}
for _, camera := range hardwareDevice.Cameras {
if err := upsertMilestoneHardwareChildDevice(
ctx,
tx,
hardwareID,
"camera",
camera,
); err != nil {
return err
}
}
for _, microphone := range hardwareDevice.Microphones {
if err := upsertMilestoneHardwareChildDevice(
ctx,
tx,
hardwareID,
"microphone",
microphone,
); err != nil {
return err
}
}
for _, input := range hardwareDevice.Inputs {
if err := upsertMilestoneHardwareChildDevice(
ctx,
tx,
hardwareID,
"input",
input,
); err != nil {
return err
}
}
for _, output := range hardwareDevice.Outputs {
if err := upsertMilestoneHardwareChildDevice(
ctx,
tx,
hardwareID,
"output",
output,
); err != nil {
return err
}
}
for _, speaker := range hardwareDevice.Speakers {
if err := upsertMilestoneHardwareChildDevice(
ctx,
tx,
hardwareID,
"speaker",
speaker,
); err != nil {
return err
}
}
for _, metadata := range hardwareDevice.Metadata {
if err := upsertMilestoneHardwareChildDevice(
ctx,
tx,
hardwareID,
"metadata",
metadata,
); err != nil {
return err
}
}
return nil
}
func upsertMilestoneHardwareChildDevice(
ctx context.Context,
tx pgx.Tx,
hardwareID string,
deviceType string,
childDevice milestoneNamedDevice,
) error {
milestoneDeviceID := strings.TrimSpace(childDevice.ID)
if milestoneDeviceID == "" {
return nil
}
name := strings.TrimSpace(childDevice.Name)
displayName := strings.TrimSpace(childDevice.DisplayName)
shortName := strings.TrimSpace(childDevice.ShortName)
if name == "" {
name = displayName
}
if name == "" {
name = shortName
}
if displayName == "" {
displayName = name
}
if shortName == "" {
shortName = name
}
_, err := tx.Exec(
ctx,
`
INSERT INTO milestone_hardware_child_devices (
milestone_hardware_id,
milestone_device_id,
device_type,
name,
display_name,
short_name,
enabled
)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (milestone_hardware_id, milestone_device_id, device_type)
DO UPDATE SET
name = EXCLUDED.name,
display_name = EXCLUDED.display_name,
short_name = EXCLUDED.short_name,
enabled = EXCLUDED.enabled,
updated_at = now()
`,
hardwareID,
milestoneDeviceID,
deviceType,
name,
displayName,
shortName,
childDevice.Enabled,
)
return err
}
func createOrUpdateMilestoneHardwareDevice( func createOrUpdateMilestoneHardwareDevice(
ctx context.Context, ctx context.Context,
tx pgx.Tx, tx pgx.Tx,

View File

@ -35,6 +35,10 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("PUT /devices/{id}/history/{historyId}", s.requireAuth(s.handleUpdateDeviceJournalEntry)) mux.HandleFunc("PUT /devices/{id}/history/{historyId}", s.requireAuth(s.handleUpdateDeviceJournalEntry))
mux.HandleFunc("PATCH /devices/{id}/milestone", s.requireAuth(s.handleUpdateMilestoneHardware)) mux.HandleFunc("PATCH /devices/{id}/milestone", s.requireAuth(s.handleUpdateMilestoneHardware))
mux.HandleFunc(
"PATCH /devices/{id}/milestone-child/{childId}",
s.requireAuth(s.handleUpdateMilestoneChildDevice),
)
mux.HandleFunc("GET /devices/firmware-check", s.requireAuth(s.handleGetCameraFirmwareCheckState)) mux.HandleFunc("GET /devices/firmware-check", s.requireAuth(s.handleGetCameraFirmwareCheckState))
mux.HandleFunc("POST /devices/firmware-check", s.requireAuth(s.handleCheckCameraFirmware)) mux.HandleFunc("POST /devices/firmware-check", s.requireAuth(s.handleCheckCameraFirmware))

View File

@ -347,6 +347,25 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
) )
`, `,
`
CREATE TABLE IF NOT EXISTS milestone_hardware_child_devices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
milestone_hardware_id TEXT NOT NULL,
milestone_device_id TEXT NOT NULL,
device_type TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
display_name TEXT NOT NULL DEFAULT '',
short_name TEXT NOT NULL DEFAULT '',
enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (milestone_hardware_id, milestone_device_id, device_type)
)
`,
` `
CREATE TABLE IF NOT EXISTS device_manufacturers ( CREATE TABLE IF NOT EXISTS device_manufacturers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@ -586,7 +605,8 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error {
`CREATE INDEX IF NOT EXISTS idx_dashboard_settings_user_id ON dashboard_settings(user_id)`, `CREATE INDEX IF NOT EXISTS idx_dashboard_settings_user_id ON dashboard_settings(user_id)`,
`ALTER TABLE devices ADD COLUMN IF NOT EXISTS milestone_port TEXT NOT NULL DEFAULT ''`, `CREATE INDEX IF NOT EXISTS idx_milestone_child_devices_hardware_id ON milestone_hardware_child_devices(milestone_hardware_id)`,
`CREATE INDEX IF NOT EXISTS idx_milestone_child_devices_device_id ON milestone_hardware_child_devices(milestone_device_id)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_inventory_number_unique ON devices(lower(inventory_number))`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_inventory_number_unique ON devices(lower(inventory_number))`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_serial_number_unique ON devices(lower(serial_number)) WHERE serial_number <> ''`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_serial_number_unique ON devices(lower(serial_number)) WHERE serial_number <> ''`,

View File

@ -65,8 +65,10 @@ export type Device = {
milestoneRecordingServerId?: string milestoneRecordingServerId?: string
milestoneHardwareId?: string milestoneHardwareId?: string
milestoneDisplayName: string milestoneDisplayName: string
milestonePort?: string
milestoneEnabled?: boolean milestoneEnabled?: boolean
milestoneSyncedAt?: string milestoneSyncedAt?: string
milestoneChildDevices?: MilestoneHardwareChildDevice[]
relatedDevices: RelatedDevice[] relatedDevices: RelatedDevice[]
isBaoDevice: boolean isBaoDevice: boolean
} }
@ -88,6 +90,17 @@ export type DeviceSupportBadge = {
supportUrl: string supportUrl: string
} }
export type MilestoneHardwareChildDevice = {
id: string
milestoneHardwareId: string
milestoneDeviceId: string
deviceType: 'camera' | 'microphone' | string
name: string
displayName: string
shortName: string
enabled: boolean
}
export type Operation = { export type Operation = {
id: string id: string
operationNumber: string operationNumber: string

View File

@ -0,0 +1,112 @@
// frontend\src\pages\devices\ChildDeviceCard.tsx
import Switch from '../../components/Switch'
import type { Device } from '../../components/types'
import {
ArrowDownTrayIcon,
ArrowUpTrayIcon,
CircleStackIcon,
MicrophoneIcon,
SpeakerWaveIcon,
VideoCameraIcon,
} from '@heroicons/react/20/solid'
type MilestoneChildDevice = NonNullable<Device['milestoneChildDevices']>[number]
type ChildDeviceCardProps = {
item: MilestoneChildDevice
isBusy?: boolean
onToggle?: (
childDevice: MilestoneChildDevice,
enabled: boolean,
) => void | Promise<void>
}
function getChildDevicePresentation(deviceType: string) {
const normalizedType = deviceType.toLowerCase()
if (normalizedType === 'microphone' || normalizedType === 'microphones') {
return {
Icon: MicrophoneIcon,
typeLabel: 'Mikrofon',
}
}
if (normalizedType === 'input' || normalizedType === 'inputs') {
return {
Icon: ArrowDownTrayIcon,
typeLabel: 'Eingang',
}
}
if (normalizedType === 'output' || normalizedType === 'outputs') {
return {
Icon: ArrowUpTrayIcon,
typeLabel: 'Ausgang',
}
}
if (normalizedType === 'speaker' || normalizedType === 'speakers') {
return {
Icon: SpeakerWaveIcon,
typeLabel: 'Lautsprecher',
}
}
if (normalizedType === 'metadata') {
return {
Icon: CircleStackIcon,
typeLabel: 'Metadaten',
}
}
return {
Icon: VideoCameraIcon,
typeLabel: 'Kamera',
}
}
export default function ChildDeviceCard({
item,
isBusy = false,
onToggle,
}: ChildDeviceCardProps) {
const itemTitle =
item.displayName || item.name || item.shortName || 'Unbenanntes Gerät'
const isEnabled = item.enabled ?? true
const { Icon, typeLabel } = getChildDevicePresentation(item.deviceType)
return (
<article className="w-full rounded-md border border-gray-200 bg-white px-3 py-2.5 shadow-xs dark:border-white/10 dark:bg-gray-900/50">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex items-center gap-2.5">
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-indigo-50 text-indigo-600 dark:bg-indigo-500/10 dark:text-indigo-400">
<Icon aria-hidden="true" className="size-4" />
</div>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-gray-900 dark:text-white">
{itemTitle}
</p>
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{typeLabel}
</p>
</div>
</div>
<div className="shrink-0 self-start">
<Switch
id={`milestone-child-${item.id}`}
checked={isEnabled}
disabled={isBusy || !onToggle}
onChange={(event) => void onToggle?.(item, event.target.checked)}
label=""
className="scale-90"
/>
</div>
</div>
</article>
)
}

View File

@ -6,8 +6,16 @@ import Checkbox from '../../components/Checkbox'
import Switch from '../../components/Switch' import Switch from '../../components/Switch'
import type { DeviceModalTabId } from './DeviceModalTabs' import type { DeviceModalTabId } from './DeviceModalTabs'
import Textarea from '../../components/Textarea' import Textarea from '../../components/Textarea'
import ChildDeviceCard from './ChildDeviceCard'
import type { Device } from '../../components/types' import type { Device } from '../../components/types'
import { VideoCameraIcon } from '@heroicons/react/20/solid' import {
ArrowDownTrayIcon,
ArrowUpTrayIcon,
CircleStackIcon,
MicrophoneIcon,
SpeakerWaveIcon,
VideoCameraIcon,
} from '@heroicons/react/20/solid'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080' const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
@ -18,6 +26,11 @@ type DeviceFormFieldsProps = {
devices: Device[] devices: Device[]
relatedDeviceIds: string[] relatedDeviceIds: string[]
onToggleRelatedDevice: (deviceId: string) => void onToggleRelatedDevice: (deviceId: string) => void
togglingMilestoneChildDeviceIds?: Set<string>
onToggleMilestoneChildDevice?: (
childDevice: NonNullable<Device['milestoneChildDevices']>[number],
enabled: boolean,
) => void | Promise<void>
} }
export const inputClassName = export const inputClassName =
@ -55,6 +68,62 @@ export function SectionHeader({
) )
} }
function MilestoneChildDeviceCards({
title,
emptyText,
items,
togglingIds,
onToggle,
icon: Icon,
}: {
title: string
emptyText: string
items: NonNullable<Device['milestoneChildDevices']>
togglingIds: Set<string>
onToggle?: (
childDevice: NonNullable<Device['milestoneChildDevices']>[number],
enabled: boolean,
) => void | Promise<void>
icon: typeof VideoCameraIcon
}) {
return (
<div className="rounded-lg border border-gray-200 bg-gray-50/60 p-4 dark:border-white/10 dark:bg-white/5">
<div className="flex items-center justify-between gap-3 border-b border-gray-200 pb-3 dark:border-white/10">
<div className="flex items-center gap-2">
<Icon
aria-hidden="true"
className="size-4 text-indigo-600 dark:text-indigo-400"
/>
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
{title}
</h4>
</div>
<span className="rounded-full bg-white px-2 py-0.5 text-xs font-medium text-gray-600 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10">
{items.length}
</span>
</div>
{items.length === 0 ? (
<p className="mt-4 text-sm text-gray-500 dark:text-gray-400">
{emptyText}
</p>
) : (
<div className="mt-4 flex flex-wrap gap-2">
{items.map((item) => (
<ChildDeviceCard
key={`${item.deviceType}-${item.milestoneDeviceId}`}
item={item}
isBusy={togglingIds.has(item.id)}
onToggle={onToggle}
/>
))}
</div>
)}
</div>
)
}
function formatMacAddress(value: string) { function formatMacAddress(value: string) {
const hexValue = value const hexValue = value
.replace(/[^0-9a-fA-F]/g, '') .replace(/[^0-9a-fA-F]/g, '')
@ -166,6 +235,8 @@ export default function DeviceFormFields({
devices, devices,
relatedDeviceIds, relatedDeviceIds,
onToggleRelatedDevice, onToggleRelatedDevice,
togglingMilestoneChildDeviceIds,
onToggleMilestoneChildDevice,
}: DeviceFormFieldsProps) { }: DeviceFormFieldsProps) {
const assignableDevices = useMemo( const assignableDevices = useMemo(
() => devices.filter((item) => item.id !== device?.id), () => devices.filter((item) => item.id !== device?.id),
@ -234,6 +305,32 @@ export default function DeviceFormFields({
device?.milestoneHardwareId || device?.milestoneRecordingServerId, device?.milestoneHardwareId || device?.milestoneRecordingServerId,
) )
const milestoneChildDevices = device?.milestoneChildDevices ?? []
const milestoneCameras = milestoneChildDevices.filter(
(item) => item.deviceType === 'camera' || item.deviceType === 'cameras',
)
const milestoneMicrophones = milestoneChildDevices.filter(
(item) => item.deviceType === 'microphone' || item.deviceType === 'microphones',
)
const milestoneInputs = milestoneChildDevices.filter(
(item) => item.deviceType === 'input' || item.deviceType === 'inputs',
)
const milestoneOutputs = milestoneChildDevices.filter(
(item) => item.deviceType === 'output' || item.deviceType === 'outputs',
)
const milestoneSpeakers = milestoneChildDevices.filter(
(item) => item.deviceType === 'speaker' || item.deviceType === 'speakers',
)
const milestoneMetadata = milestoneChildDevices.filter(
(item) => item.deviceType === 'metadata',
)
const milestoneReadonlyClassName = const milestoneReadonlyClassName =
'!cursor-not-allowed !bg-gray-50 !text-gray-500 !outline-gray-200 dark:!bg-white/10 dark:!text-gray-400 dark:!outline-white/10' '!cursor-not-allowed !bg-gray-50 !text-gray-500 !outline-gray-200 dark:!bg-white/10 dark:!text-gray-400 dark:!outline-white/10'
@ -384,92 +481,6 @@ export default function DeviceFormFields({
</div> </div>
</div> </div>
{isMilestoneDevice && (
<>
<input type="hidden" name="isMilestoneDevice" value="true" />
<div className="sm:col-span-6">
<label
htmlFor="milestoneDisplayName"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Anzeigename
</label>
<div className="mt-2">
<div className="relative mt-2">
<VideoCameraIcon
aria-hidden="true"
className={milestoneInputIconClassName}
/>
<input
id="milestoneDisplayName"
name="milestoneDisplayName"
type="text"
defaultValue={device?.milestoneDisplayName ?? ''}
className={classNames(inputClassName, milestoneInputWithIconClassName)}
/>
</div>
</div>
</div>
<div className="sm:col-span-3">
<label
htmlFor="milestoneHardwareId"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Milestone-Geräte-ID
</label>
<div className="relative mt-2">
<VideoCameraIcon
aria-hidden="true"
className={milestoneInputIconClassName}
/>
<input
id="milestoneHardwareId"
type="text"
value={device?.milestoneHardwareId ?? ''}
readOnly
aria-readonly="true"
title="Die Milestone-Geräte-ID wird aus Milestone synchronisiert."
className={classNames(
inputClassName,
milestoneInputWithIconClassName,
milestoneReadonlyClassName,
)}
/>
</div>
</div>
<div className="sm:col-span-3">
<label
htmlFor="milestoneEnabled"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Milestone-Status
</label>
<div className="mt-2 flex min-h-[38px] items-center gap-3 rounded-md border border-gray-200 bg-white px-3 py-1.5 dark:border-white/10 dark:bg-white/5">
<VideoCameraIcon
aria-hidden="true"
className="size-4 shrink-0 text-indigo-500 dark:text-indigo-400"
/>
<Switch
id="milestoneEnabled"
name="milestoneEnabled"
label="Aktiviert"
defaultChecked={device?.milestoneEnabled ?? true}
className="min-w-0 flex-1"
/>
</div>
</div>
</>
)}
<div className="sm:col-span-3"> <div className="sm:col-span-3">
<input <input
type="hidden" type="hidden"
@ -526,141 +537,292 @@ export default function DeviceFormFields({
</div> </div>
</div> </div>
<div className="sm:col-span-3"> {!isMilestoneDevice && (
<label <>
htmlFor="macAddress" <div className="sm:col-span-3">
className="block text-sm/6 font-medium text-gray-900 dark:text-white" <label
> htmlFor="macAddress"
MAC-Adresse className="block text-sm/6 font-medium text-gray-900 dark:text-white"
</label> >
MAC-Adresse
</label>
<div className="relative mt-2"> <div className="relative mt-2">
{isMilestoneDevice && ( <input
<VideoCameraIcon id="macAddress"
aria-hidden="true" name="macAddress"
className={milestoneInputIconClassName} type="text"
/> placeholder="00:11:22:33:44:55"
)} defaultValue={formatMacAddress(device?.macAddress ?? '')}
maxLength={17}
<input pattern="(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"
id="macAddress" title="Bitte gib eine gültige MAC-Adresse im Format 00:11:22:33:44:55 ein."
name="macAddress" onInput={handleMacAddressInput}
type="text" onInvalid={handleMacAddressInvalid}
placeholder="00:11:22:33:44:55" className={inputClassName}
defaultValue={formatMacAddress(device?.macAddress ?? '')} />
maxLength={17} </div>
pattern="(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"
title={
isMilestoneDevice
? 'MAC-Adresse wird aus Milestone synchronisiert.'
: 'Bitte gib eine gültige MAC-Adresse im Format 00:11:22:33:44:55 ein.'
}
readOnly={isMilestoneDevice}
aria-readonly={isMilestoneDevice}
onInput={isMilestoneDevice ? undefined : handleMacAddressInput}
onInvalid={isMilestoneDevice ? undefined : handleMacAddressInvalid}
className={classNames(
inputClassName,
isMilestoneDevice && milestoneInputWithIconClassName,
isMilestoneDevice && milestoneReadonlyClassName,
)}
/>
</div>
</div>
<div className="sm:col-span-3">
<label
htmlFor="ipAddress"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
IP-Adresse
</label>
<div className="relative mt-2">
{isMilestoneDevice && (
<VideoCameraIcon
aria-hidden="true"
className={milestoneInputIconClassName}
/>
)}
<input
id="ipAddress"
name="ipAddress"
type="text"
placeholder="192.168.1.100"
defaultValue={device?.ipAddress ?? ''}
className={classNames(
inputClassName,
isMilestoneDevice && milestoneInputWithIconClassName,
)}
/>
</div>
</div>
{isMilestoneDevice && (
<div className="sm:col-span-3">
<label
htmlFor="milestonePort"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Port
</label>
<div className="relative mt-2">
<VideoCameraIcon
aria-hidden="true"
className={milestoneInputIconClassName}
/>
<input
id="milestonePort"
name="milestonePort"
type="text"
inputMode="numeric"
placeholder="4107"
defaultValue={device?.milestonePort ?? ''}
className={classNames(
inputClassName,
milestoneInputWithIconClassName,
)}
/>
</div> </div>
</div>
<div className="sm:col-span-3">
<label
htmlFor="ipAddress"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
IP-Adresse
</label>
<div className="relative mt-2">
<input
id="ipAddress"
name="ipAddress"
type="text"
placeholder="192.168.1.100"
defaultValue={device?.ipAddress ?? ''}
className={inputClassName}
/>
</div>
</div>
<div className="sm:col-span-3">
<label
htmlFor="firmwareVersion"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Firmware
</label>
<div className="relative mt-2">
<input
id="firmwareVersion"
name="firmwareVersion"
type="text"
defaultValue={device?.firmwareVersion ?? ''}
className={inputClassName}
/>
</div>
</div>
</>
)} )}
<div className="sm:col-span-3"> {isMilestoneDevice && (
<label <section className="sm:col-span-6 rounded-lg border border-indigo-200 bg-indigo-50/50 p-4 dark:border-indigo-400/20 dark:bg-indigo-400/10">
htmlFor="firmwareVersion" <input type="hidden" name="isMilestoneDevice" value="true" />
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Firmware
</label>
<div className="relative mt-2"> <div className="border-b border-indigo-200 pb-3 dark:border-indigo-400/20">
{isMilestoneDevice && ( <div className="flex items-center gap-2">
<VideoCameraIcon <VideoCameraIcon
aria-hidden="true" aria-hidden="true"
className={milestoneInputIconClassName} className="size-4 text-indigo-600 dark:text-indigo-400"
/> />
)}
<input <h4 className="text-sm font-semibold text-gray-900 dark:text-white">
id="firmwareVersion" Milestone
name="firmwareVersion" </h4>
type="text" </div>
defaultValue={device?.firmwareVersion ?? ''}
readOnly={isMilestoneDevice} <p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
aria-readonly={isMilestoneDevice} Diese Felder werden mit Milestone synchronisiert.
title={isMilestoneDevice ? 'Firmware wird aus Milestone synchronisiert.' : undefined} </p>
className={classNames( </div>
inputClassName,
isMilestoneDevice && milestoneInputWithIconClassName, <div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-6">
isMilestoneDevice && milestoneReadonlyClassName, <div className="sm:col-span-6">
)} <label
/> htmlFor="milestoneDisplayName"
</div> className="block text-sm/6 font-medium text-gray-900 dark:text-white"
</div> >
Anzeigename
</label>
<div className="relative mt-2">
<VideoCameraIcon
aria-hidden="true"
className={milestoneInputIconClassName}
/>
<input
id="milestoneDisplayName"
name="milestoneDisplayName"
type="text"
defaultValue={device?.milestoneDisplayName ?? ''}
className={classNames(inputClassName, milestoneInputWithIconClassName)}
/>
</div>
</div>
<div className="sm:col-span-3">
<label
htmlFor="milestoneHardwareId"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Milestone-Geräte-ID
</label>
<div className="relative mt-2">
<VideoCameraIcon
aria-hidden="true"
className={milestoneInputIconClassName}
/>
<input
id="milestoneHardwareId"
type="text"
value={device?.milestoneHardwareId ?? ''}
readOnly
aria-readonly="true"
title="Die Milestone-Geräte-ID wird aus Milestone synchronisiert."
className={classNames(
inputClassName,
milestoneInputWithIconClassName,
milestoneReadonlyClassName,
)}
/>
</div>
</div>
<div className="sm:col-span-3">
<label
htmlFor="milestoneEnabled"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Milestone-Status
</label>
<div className="mt-2 flex min-h-[38px] items-center gap-3 rounded-md border border-gray-200 bg-white px-3 py-1.5 dark:border-white/10 dark:bg-white/5">
<VideoCameraIcon
aria-hidden="true"
className="size-4 shrink-0 text-indigo-500 dark:text-indigo-400"
/>
<Switch
id="milestoneEnabled"
name="milestoneEnabled"
label="Aktiviert"
defaultChecked={device?.milestoneEnabled ?? true}
className="min-w-0 flex-1"
/>
</div>
</div>
<div className="sm:col-span-3">
<label
htmlFor="ipAddress"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
IP-Adresse
</label>
<div className="relative mt-2">
<VideoCameraIcon
aria-hidden="true"
className={milestoneInputIconClassName}
/>
<input
id="ipAddress"
name="ipAddress"
type="text"
placeholder="192.168.1.100"
defaultValue={device?.ipAddress ?? ''}
className={classNames(inputClassName, milestoneInputWithIconClassName)}
/>
</div>
</div>
<div className="sm:col-span-3">
<label
htmlFor="milestonePort"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Port
</label>
<div className="relative mt-2">
<VideoCameraIcon
aria-hidden="true"
className={milestoneInputIconClassName}
/>
<input
id="milestonePort"
name="milestonePort"
type="text"
inputMode="numeric"
placeholder="4107"
defaultValue={device?.milestonePort ?? ''}
className={classNames(inputClassName, milestoneInputWithIconClassName)}
/>
</div>
</div>
<div className="sm:col-span-3">
<label
htmlFor="macAddress"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
MAC-Adresse
</label>
<div className="relative mt-2">
<VideoCameraIcon
aria-hidden="true"
className={milestoneInputIconClassName}
/>
<input
id="macAddress"
name="macAddress"
type="text"
placeholder="00:11:22:33:44:55"
defaultValue={formatMacAddress(device?.macAddress ?? '')}
maxLength={17}
pattern="(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"
title="MAC-Adresse wird aus Milestone synchronisiert."
readOnly
aria-readonly="true"
className={classNames(
inputClassName,
milestoneInputWithIconClassName,
milestoneReadonlyClassName,
)}
/>
</div>
</div>
<div className="sm:col-span-3">
<label
htmlFor="firmwareVersion"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Firmware
</label>
<div className="relative mt-2">
<VideoCameraIcon
aria-hidden="true"
className={milestoneInputIconClassName}
/>
<input
id="firmwareVersion"
name="firmwareVersion"
type="text"
defaultValue={device?.firmwareVersion ?? ''}
readOnly
aria-readonly="true"
title="Firmware wird aus Milestone synchronisiert."
className={classNames(
inputClassName,
milestoneInputWithIconClassName,
milestoneReadonlyClassName,
)}
/>
</div>
</div>
</div>
</section>
)}
</div> </div>
</section> </section>
@ -796,6 +958,78 @@ export default function DeviceFormFields({
)} )}
</div> </div>
</section> </section>
<section
hidden={activeTab !== 'hardware'}
className={classNames(sectionClassName, 'sm:col-span-6')}
>
<SectionHeader
title="Milestone-Hardware"
description="Zugeordnete Kameras und Mikrofone aus Milestone."
/>
{!isMilestoneDevice ? (
<div className="mt-4 rounded-md border border-dashed border-gray-200 p-4 text-sm text-gray-500 dark:border-white/10 dark:text-gray-400">
Dieses Gerät ist kein Milestone-Gerät.
</div>
) : (
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
<MilestoneChildDeviceCards
title="Kameras"
emptyText="Keine Kameras für diese Hardware gefunden."
items={milestoneCameras}
togglingIds={togglingMilestoneChildDeviceIds ?? new Set()}
onToggle={onToggleMilestoneChildDevice}
icon={VideoCameraIcon}
/>
<MilestoneChildDeviceCards
title="Mikrofone"
emptyText="Keine Mikrofone für diese Hardware gefunden."
items={milestoneMicrophones}
togglingIds={togglingMilestoneChildDeviceIds ?? new Set()}
onToggle={onToggleMilestoneChildDevice}
icon={MicrophoneIcon}
/>
<MilestoneChildDeviceCards
title="Eingänge"
emptyText="Keine Eingänge für diese Hardware gefunden."
items={milestoneInputs}
togglingIds={togglingMilestoneChildDeviceIds ?? new Set()}
onToggle={onToggleMilestoneChildDevice}
icon={ArrowDownTrayIcon}
/>
<MilestoneChildDeviceCards
title="Ausgänge"
emptyText="Keine Ausgänge für diese Hardware gefunden."
items={milestoneOutputs}
togglingIds={togglingMilestoneChildDeviceIds ?? new Set()}
onToggle={onToggleMilestoneChildDevice}
icon={ArrowUpTrayIcon}
/>
<MilestoneChildDeviceCards
title="Lautsprecher"
emptyText="Keine Lautsprecher für diese Hardware gefunden."
items={milestoneSpeakers}
togglingIds={togglingMilestoneChildDeviceIds ?? new Set()}
onToggle={onToggleMilestoneChildDevice}
icon={SpeakerWaveIcon}
/>
<MilestoneChildDeviceCards
title="Metadaten"
emptyText="Keine Metadaten für diese Hardware gefunden."
items={milestoneMetadata}
togglingIds={togglingMilestoneChildDeviceIds ?? new Set()}
onToggle={onToggleMilestoneChildDevice}
icon={CircleStackIcon}
/>
</div>
)}
</section>
</div> </div>
) )
} }

View File

@ -2,7 +2,12 @@
import { type ComponentType, type SVGProps } from 'react' import { type ComponentType, type SVGProps } from 'react'
export type DeviceModalTabId = 'masterData' | 'location' | 'accessories' | 'history' export type DeviceModalTabId =
| 'masterData'
| 'location'
| 'accessories'
| 'hardware'
| 'history'
type DeviceModalTabIcon = ComponentType<SVGProps<SVGSVGElement>> type DeviceModalTabIcon = ComponentType<SVGProps<SVGSVGElement>>

View File

@ -184,6 +184,8 @@ export default function DevicesPage() {
const [firmwareCheckNow, setFirmwareCheckNow] = useState(() => Date.now()) const [firmwareCheckNow, setFirmwareCheckNow] = useState(() => Date.now())
const closeCleanupTimeoutRef = useRef<number | null>(null) const closeCleanupTimeoutRef = useRef<number | null>(null)
const [togglingMilestoneChildDeviceIds, setTogglingMilestoneChildDeviceIds] = useState<Set<string>>(() => new Set())
useEffect(() => { useEffect(() => {
loadDevices() loadDevices()
void loadFirmwareCheckState() void loadFirmwareCheckState()
@ -205,6 +207,18 @@ export default function DevicesPage() {
} }
}, []) }, [])
useEffect(() => {
setEditingDevice((currentDevice) => {
if (!currentDevice) {
return currentDevice
}
const nextDevice = devices.find((device) => device.id === currentDevice.id)
return nextDevice ?? currentDevice
})
}, [devices])
useEffect(() => { useEffect(() => {
function handleDeviceNotification(event: Event) { function handleDeviceNotification(event: Event) {
void loadDevices({ silent: true }) void loadDevices({ silent: true })
@ -252,6 +266,56 @@ export default function DevicesPage() {
} }
} }
async function handleToggleMilestoneChildDevice(
device: Device,
childDevice: NonNullable<Device['milestoneChildDevices']>[number],
enabled: boolean,
) {
setTogglingMilestoneChildDeviceIds((currentIds) => {
const nextIds = new Set(currentIds)
nextIds.add(childDevice.id)
return nextIds
})
try {
const response = await fetch(
`${API_URL}/devices/${device.id}/milestone-child/${childDevice.id}`,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ enabled }),
},
)
if (!response.ok) {
throw new Error(await readApiError(response, 'Milestone-Child-Device konnte nicht aktualisiert werden'))
}
await loadDevices({ silent: true })
showToast({
variant: 'success',
title: enabled ? 'Gerät aktiviert' : 'Gerät deaktiviert',
message: `${childDevice.displayName || childDevice.name || childDevice.milestoneDeviceId} wurde aktualisiert.`,
})
} catch (error) {
showErrorToast({
title: 'Aktualisierung fehlgeschlagen',
error,
fallback: 'Milestone-Child-Device konnte nicht aktualisiert werden',
})
} finally {
setTogglingMilestoneChildDeviceIds((currentIds) => {
const nextIds = new Set(currentIds)
nextIds.delete(childDevice.id)
return nextIds
})
}
}
async function handleCheckFirmware() { async function handleCheckFirmware() {
setIsCheckingFirmware(true) setIsCheckingFirmware(true)
@ -885,7 +949,7 @@ export default function DevicesPage() {
type="button" type="button"
disabled={isToggling} disabled={isToggling}
onClick={(event) => handleToggleMilestoneDeviceStatus(device, event)} onClick={(event) => handleToggleMilestoneDeviceStatus(device, event)}
className="inline-flex min-w-28 cursor-pointer justify-center rounded-md transition disabled:cursor-wait disabled:opacity-60" className="inline-flex min-w-24 cursor-pointer justify-center rounded-md transition disabled:cursor-wait disabled:opacity-60"
title={ title={
isToggling isToggling
? 'Milestone-Status wird geändert...' ? 'Milestone-Status wird geändert...'
@ -1029,6 +1093,14 @@ export default function DevicesPage() {
isJournalSubmitting={isJournalSubmitting} isJournalSubmitting={isJournalSubmitting}
onJournalEntrySubmit={handleCreateDeviceJournalEntry} onJournalEntrySubmit={handleCreateDeviceJournalEntry}
onJournalEntryUpdate={handleUpdateDeviceJournalEntry} onJournalEntryUpdate={handleUpdateDeviceJournalEntry}
togglingMilestoneChildDeviceIds={togglingMilestoneChildDeviceIds}
onToggleMilestoneChildDevice={(childDevice, enabled) => {
if (!editingDevice) {
return
}
return handleToggleMilestoneChildDevice(editingDevice, childDevice, enabled)
}}
/> />
</div> </div>

View File

@ -44,6 +44,11 @@ type EditDeviceModalProps = {
isJournalSubmitting?: boolean isJournalSubmitting?: boolean
onJournalEntrySubmit?: (content: string) => void | Promise<void> onJournalEntrySubmit?: (content: string) => void | Promise<void>
onJournalEntryUpdate?: (itemId: string | number, content: string) => void | Promise<void> onJournalEntryUpdate?: (itemId: string | number, content: string) => void | Promise<void>
togglingMilestoneChildDeviceIds?: Set<string>
onToggleMilestoneChildDevice?: (
childDevice: NonNullable<Device['milestoneChildDevices']>[number],
enabled: boolean,
) => void | Promise<void>
} }
type JournalItems = ComponentProps<typeof Journal>['items'] type JournalItems = ComponentProps<typeof Journal>['items']
@ -64,6 +69,11 @@ const editDeviceTabs = [
name: 'Zubehör', name: 'Zubehör',
icon: WrenchScrewdriverIcon, icon: WrenchScrewdriverIcon,
}, },
{
id: 'hardware' as const,
name: 'Hardware',
icon: VideoCameraIcon,
},
{ {
id: 'history' as const, id: 'history' as const,
name: 'Journal', name: 'Journal',
@ -132,6 +142,8 @@ export default function EditDeviceModal({
isJournalSubmitting = false, isJournalSubmitting = false,
onJournalEntrySubmit, onJournalEntrySubmit,
onJournalEntryUpdate, onJournalEntryUpdate,
togglingMilestoneChildDeviceIds,
onToggleMilestoneChildDevice,
}: EditDeviceModalProps) { }: EditDeviceModalProps) {
const [activeTab, setActiveTab] = useState<DeviceModalTabId>('masterData') const [activeTab, setActiveTab] = useState<DeviceModalTabId>('masterData')
@ -239,10 +251,6 @@ export default function EditDeviceModal({
</DialogTitle> </DialogTitle>
<div className="mt-1 space-y-1"> <div className="mt-1 space-y-1">
<p className="text-sm text-gray-500 dark:text-gray-400">
Bearbeite Gerätedaten, Standort, Verleih-Status und Zubehör.
</p>
{device?.milestoneHardwareId && ( {device?.milestoneHardwareId && (
<p className="flex items-center gap-1.5 text-xs text-indigo-600 dark:text-indigo-400"> <p className="flex items-center gap-1.5 text-xs text-indigo-600 dark:text-indigo-400">
<VideoCameraIcon aria-hidden="true" className="size-4 shrink-0" /> <VideoCameraIcon aria-hidden="true" className="size-4 shrink-0" />
@ -328,6 +336,8 @@ export default function EditDeviceModal({
devices={devices} devices={devices}
relatedDeviceIds={relatedDeviceIds} relatedDeviceIds={relatedDeviceIds}
onToggleRelatedDevice={onToggleRelatedDevice} onToggleRelatedDevice={onToggleRelatedDevice}
togglingMilestoneChildDeviceIds={togglingMilestoneChildDeviceIds}
onToggleMilestoneChildDevice={onToggleMilestoneChildDevice}
/> />
)} )}
</div> </div>