added child devices
This commit is contained in:
parent
47bf652b56
commit
5af9896514
@ -154,6 +154,12 @@ type milestoneHardwareDevice struct {
|
||||
SerialNumber string
|
||||
FirmwareVersion string
|
||||
Enabled bool
|
||||
Cameras []milestoneNamedDevice
|
||||
Microphones []milestoneNamedDevice
|
||||
Inputs []milestoneNamedDevice
|
||||
Outputs []milestoneNamedDevice
|
||||
Speakers []milestoneNamedDevice
|
||||
Metadata []milestoneNamedDevice
|
||||
}
|
||||
|
||||
func (s *Server) handleGetMilestoneSettings(w http.ResponseWriter, r *http.Request) {
|
||||
@ -790,6 +796,17 @@ func (s *Server) requestMilestoneHardwareDevices(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hardwareDetails, err := s.requestMilestoneHardwareDetails(
|
||||
ctx,
|
||||
host,
|
||||
hardwareID,
|
||||
accessToken,
|
||||
skipTLSVerify,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
displayName := strings.TrimSpace(hardware.DisplayName)
|
||||
if displayName == "" {
|
||||
displayName = strings.TrimSpace(hardware.Name)
|
||||
@ -831,7 +848,144 @@ func (s *Server) requestMilestoneHardwareDevices(
|
||||
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{
|
||||
HardwareID: hardwareID,
|
||||
@ -846,26 +1000,100 @@ func (s *Server) requestMilestoneHardwareDevices(
|
||||
SerialNumber: strings.TrimSpace(settings.SerialNumber),
|
||||
FirmwareVersion: strings.TrimSpace(settings.FirmwareVersion),
|
||||
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(
|
||||
"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,
|
||||
len(result),
|
||||
cameraCount,
|
||||
microphoneCount,
|
||||
inputCount,
|
||||
outputCount,
|
||||
speakerCount,
|
||||
metadataCount,
|
||||
)
|
||||
|
||||
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 {
|
||||
host, _ := extractMilestoneHardwareAddressParts(value)
|
||||
return host
|
||||
}
|
||||
|
||||
func extractMilestoneHardwarePort(value string) string {
|
||||
_, port := extractMilestoneHardwareAddressParts(value)
|
||||
return port
|
||||
func defaultMilestonePortForScheme(scheme string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(scheme)) {
|
||||
case "https":
|
||||
return "443"
|
||||
case "http":
|
||||
return "80"
|
||||
default:
|
||||
return "80"
|
||||
}
|
||||
}
|
||||
|
||||
func extractMilestoneHardwareAddressParts(value string) (string, string) {
|
||||
@ -876,7 +1104,24 @@ func extractMilestoneHardwareAddressParts(value string) (string, string) {
|
||||
|
||||
parsedURL, err := url.Parse(value)
|
||||
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://")
|
||||
@ -884,10 +1129,19 @@ func extractMilestoneHardwareAddressParts(value string) (string, string) {
|
||||
value = strings.TrimRight(value, "/")
|
||||
|
||||
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(
|
||||
@ -930,6 +1184,54 @@ func (s *Server) requestMilestoneHardware(
|
||||
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(
|
||||
ctx context.Context,
|
||||
host string,
|
||||
|
||||
@ -14,29 +14,30 @@ import (
|
||||
)
|
||||
|
||||
type Device struct {
|
||||
ID string `json:"id"`
|
||||
InventoryNumber string `json:"inventoryNumber"`
|
||||
Manufacturer string `json:"manufacturer"`
|
||||
Model string `json:"model"`
|
||||
SerialNumber string `json:"serialNumber"`
|
||||
MacAddress string `json:"macAddress"`
|
||||
IPAddress string `json:"ipAddress"`
|
||||
MilestonePort string `json:"milestonePort"`
|
||||
Location string `json:"location"`
|
||||
LoanStatus string `json:"loanStatus"`
|
||||
IsBaoDevice bool `json:"isBaoDevice"`
|
||||
Comment string `json:"comment"`
|
||||
FirmwareVersion string `json:"firmwareVersion"`
|
||||
FirmwareUpdate *DeviceFirmwareUpdate `json:"firmwareUpdate,omitempty"`
|
||||
SupportBadges []DeviceSupportBadge `json:"supportBadges,omitempty"`
|
||||
MilestoneRecordingServerID string `json:"milestoneRecordingServerId"`
|
||||
MilestoneHardwareID string `json:"milestoneHardwareId"`
|
||||
MilestoneDisplayName string `json:"milestoneDisplayName"`
|
||||
MilestoneEnabled bool `json:"milestoneEnabled"`
|
||||
MilestoneSyncedAt *time.Time `json:"milestoneSyncedAt,omitempty"`
|
||||
RelatedDevices []DeviceShort `json:"relatedDevices"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID string `json:"id"`
|
||||
InventoryNumber string `json:"inventoryNumber"`
|
||||
Manufacturer string `json:"manufacturer"`
|
||||
Model string `json:"model"`
|
||||
SerialNumber string `json:"serialNumber"`
|
||||
MacAddress string `json:"macAddress"`
|
||||
IPAddress string `json:"ipAddress"`
|
||||
MilestonePort string `json:"milestonePort"`
|
||||
Location string `json:"location"`
|
||||
LoanStatus string `json:"loanStatus"`
|
||||
IsBaoDevice bool `json:"isBaoDevice"`
|
||||
Comment string `json:"comment"`
|
||||
FirmwareVersion string `json:"firmwareVersion"`
|
||||
FirmwareUpdate *DeviceFirmwareUpdate `json:"firmwareUpdate,omitempty"`
|
||||
SupportBadges []DeviceSupportBadge `json:"supportBadges,omitempty"`
|
||||
MilestoneRecordingServerID string `json:"milestoneRecordingServerId"`
|
||||
MilestoneHardwareID string `json:"milestoneHardwareId"`
|
||||
MilestoneDisplayName string `json:"milestoneDisplayName"`
|
||||
MilestoneEnabled bool `json:"milestoneEnabled"`
|
||||
MilestoneSyncedAt *time.Time `json:"milestoneSyncedAt,omitempty"`
|
||||
RelatedDevices []DeviceShort `json:"relatedDevices"`
|
||||
MilestoneChildDevices []MilestoneHardwareChildDevice `json:"milestoneChildDevices"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type DeviceFirmwareUpdate struct {
|
||||
@ -63,6 +64,19 @@ type DeviceShort struct {
|
||||
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 {
|
||||
InventoryNumber string `json:"inventoryNumber"`
|
||||
Manufacturer string `json:"manufacturer"`
|
||||
@ -137,7 +151,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
|
||||
defer rows.Close()
|
||||
|
||||
devices := make([]Device, 0)
|
||||
deviceMap := map[string]*Device{}
|
||||
deviceIndexMap := map[string]int{}
|
||||
|
||||
for rows.Next() {
|
||||
var device Device
|
||||
@ -169,9 +183,10 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
device.RelatedDevices = []DeviceShort{}
|
||||
device.MilestoneChildDevices = []MilestoneHardwareChildDevice{}
|
||||
|
||||
devices = append(devices, device)
|
||||
deviceMap[device.ID] = &devices[len(devices)-1]
|
||||
deviceIndexMap[device.ID] = len(devices) - 1
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
@ -216,9 +231,12 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := deviceMap[deviceID]
|
||||
deviceIndex, exists := deviceIndexMap[deviceID]
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
|
||||
@ -8,6 +8,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@ -46,6 +47,151 @@ type updateMilestoneHardwareRequest struct {
|
||||
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(
|
||||
ctx context.Context,
|
||||
deviceID string,
|
||||
@ -282,11 +428,13 @@ func (s *Server) handleUpdateMilestoneHardware(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
|
||||
if err := patchMilestoneHardware(
|
||||
if err := updateMilestoneHardware(
|
||||
r.Context(),
|
||||
config,
|
||||
device.MilestoneHardwareID,
|
||||
payload,
|
||||
device.MilestoneDisplayName,
|
||||
nextDisplayName,
|
||||
); err != nil {
|
||||
s.logAndWriteUserError(
|
||||
w,
|
||||
@ -294,7 +442,7 @@ func (s *Server) handleUpdateMilestoneHardware(w http.ResponseWriter, r *http.Re
|
||||
user,
|
||||
http.StatusBadGateway,
|
||||
"milestone_hardware",
|
||||
"patch_hardware",
|
||||
"update_hardware",
|
||||
"Milestone-Gerät konnte nicht aktualisiert werden",
|
||||
err,
|
||||
LogFields{
|
||||
@ -338,6 +486,41 @@ func (s *Server) handleUpdateMilestoneHardware(w http.ResponseWriter, r *http.Re
|
||||
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 err := recordDeviceHistory(r.Context(), tx, device.ID, user, "updated", changes); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Geräteverlauf konnte nicht gespeichert werden")
|
||||
@ -387,36 +570,59 @@ func normalizeMilestoneHardwareAddress(value string, port string) string {
|
||||
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, "/")
|
||||
}
|
||||
|
||||
parsedURL, err := url.Parse(value)
|
||||
if err != nil || parsedURL.Hostname() == "" {
|
||||
value = strings.TrimRight(value, "/")
|
||||
if port != "" && !strings.Contains(value, ":") {
|
||||
value += ":" + port
|
||||
host := strings.TrimRight(rawValue, "/")
|
||||
host = strings.TrimPrefix(host, "http://")
|
||||
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)
|
||||
if scheme == "" {
|
||||
scheme = "http"
|
||||
scheme := milestoneSchemeForPort(port)
|
||||
|
||||
return scheme + "://" + host + ":" + port + "/"
|
||||
}
|
||||
|
||||
host := parsedURL.Hostname()
|
||||
|
||||
if port == "" {
|
||||
port = parsedURL.Port()
|
||||
port = normalizeMilestonePort(parsedURL.Port())
|
||||
}
|
||||
|
||||
hostPort := host
|
||||
if port != "" {
|
||||
hostPort += ":" + port
|
||||
if port == "" {
|
||||
port = defaultMilestonePortForScheme(parsedURL.Scheme)
|
||||
}
|
||||
|
||||
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 {
|
||||
@ -430,6 +636,10 @@ func normalizeMilestonePort(value string) string {
|
||||
return value
|
||||
}
|
||||
|
||||
type milestoneHardwareUpdateEnvelope struct {
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
|
||||
const milestoneTokenRefreshBeforeExpiry = 2 * time.Minute
|
||||
|
||||
func (s *Server) getMilestoneRuntimeConfig(
|
||||
@ -554,11 +764,162 @@ func (s *Server) refreshMilestoneRuntimeToken(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func patchMilestoneHardware(
|
||||
func updateMilestoneHardware(
|
||||
ctx context.Context,
|
||||
config milestoneRuntimeConfig,
|
||||
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 {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
@ -569,6 +930,482 @@ func patchMilestoneHardware(
|
||||
"/API/rest/v1/hardware/" +
|
||||
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(
|
||||
ctx,
|
||||
http.MethodPatch,
|
||||
@ -598,13 +1435,17 @@ func patchMilestoneHardware(
|
||||
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return newAppErrorf(
|
||||
"milestone hardware patch failed",
|
||||
"milestone child device patch failed",
|
||||
LogFields{
|
||||
"statusCode": response.StatusCode,
|
||||
"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,
|
||||
string(responseBody),
|
||||
)
|
||||
|
||||
@ -50,6 +50,10 @@ func syncMilestoneHardwareDevices(
|
||||
return syncedCount, err
|
||||
}
|
||||
|
||||
if err := syncMilestoneHardwareChildDevices(ctx, tx, hardwareDevice); err != nil {
|
||||
return syncedCount, err
|
||||
}
|
||||
|
||||
if synced {
|
||||
syncedCount++
|
||||
}
|
||||
@ -58,6 +62,165 @@ func syncMilestoneHardwareDevices(
|
||||
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(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
|
||||
@ -35,6 +35,10 @@ func (s *Server) Routes() http.Handler {
|
||||
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-child/{childId}",
|
||||
s.requireAuth(s.handleUpdateMilestoneChildDevice),
|
||||
)
|
||||
|
||||
mux.HandleFunc("GET /devices/firmware-check", s.requireAuth(s.handleGetCameraFirmwareCheckState))
|
||||
mux.HandleFunc("POST /devices/firmware-check", s.requireAuth(s.handleCheckCameraFirmware))
|
||||
|
||||
@ -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 (
|
||||
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)`,
|
||||
|
||||
`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_serial_number_unique ON devices(lower(serial_number)) WHERE serial_number <> ''`,
|
||||
|
||||
@ -65,8 +65,10 @@ export type Device = {
|
||||
milestoneRecordingServerId?: string
|
||||
milestoneHardwareId?: string
|
||||
milestoneDisplayName: string
|
||||
milestonePort?: string
|
||||
milestoneEnabled?: boolean
|
||||
milestoneSyncedAt?: string
|
||||
milestoneChildDevices?: MilestoneHardwareChildDevice[]
|
||||
relatedDevices: RelatedDevice[]
|
||||
isBaoDevice: boolean
|
||||
}
|
||||
@ -88,6 +90,17 @@ export type DeviceSupportBadge = {
|
||||
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 = {
|
||||
id: string
|
||||
operationNumber: string
|
||||
|
||||
112
frontend/src/pages/devices/ChildDeviceCard.tsx
Normal file
112
frontend/src/pages/devices/ChildDeviceCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -6,8 +6,16 @@ import Checkbox from '../../components/Checkbox'
|
||||
import Switch from '../../components/Switch'
|
||||
import type { DeviceModalTabId } from './DeviceModalTabs'
|
||||
import Textarea from '../../components/Textarea'
|
||||
import ChildDeviceCard from './ChildDeviceCard'
|
||||
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'
|
||||
|
||||
@ -18,6 +26,11 @@ type DeviceFormFieldsProps = {
|
||||
devices: Device[]
|
||||
relatedDeviceIds: string[]
|
||||
onToggleRelatedDevice: (deviceId: string) => void
|
||||
togglingMilestoneChildDeviceIds?: Set<string>
|
||||
onToggleMilestoneChildDevice?: (
|
||||
childDevice: NonNullable<Device['milestoneChildDevices']>[number],
|
||||
enabled: boolean,
|
||||
) => void | Promise<void>
|
||||
}
|
||||
|
||||
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) {
|
||||
const hexValue = value
|
||||
.replace(/[^0-9a-fA-F]/g, '')
|
||||
@ -166,6 +235,8 @@ export default function DeviceFormFields({
|
||||
devices,
|
||||
relatedDeviceIds,
|
||||
onToggleRelatedDevice,
|
||||
togglingMilestoneChildDeviceIds,
|
||||
onToggleMilestoneChildDevice,
|
||||
}: DeviceFormFieldsProps) {
|
||||
const assignableDevices = useMemo(
|
||||
() => devices.filter((item) => item.id !== device?.id),
|
||||
@ -234,6 +305,32 @@ export default function DeviceFormFields({
|
||||
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 =
|
||||
'!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>
|
||||
|
||||
{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">
|
||||
<input
|
||||
type="hidden"
|
||||
@ -526,141 +537,292 @@ export default function DeviceFormFields({
|
||||
</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>
|
||||
{!isMilestoneDevice && (
|
||||
<>
|
||||
<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">
|
||||
{isMilestoneDevice && (
|
||||
<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={
|
||||
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 className="relative mt-2">
|
||||
<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="Bitte gib eine gültige MAC-Adresse im Format 00:11:22:33:44:55 ein."
|
||||
onInput={handleMacAddressInput}
|
||||
onInvalid={handleMacAddressInvalid}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</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">
|
||||
<label
|
||||
htmlFor="firmwareVersion"
|
||||
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Firmware
|
||||
</label>
|
||||
{isMilestoneDevice && (
|
||||
<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">
|
||||
<input type="hidden" name="isMilestoneDevice" value="true" />
|
||||
|
||||
<div className="relative mt-2">
|
||||
{isMilestoneDevice && (
|
||||
<VideoCameraIcon
|
||||
aria-hidden="true"
|
||||
className={milestoneInputIconClassName}
|
||||
/>
|
||||
)}
|
||||
<div className="border-b border-indigo-200 pb-3 dark:border-indigo-400/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<VideoCameraIcon
|
||||
aria-hidden="true"
|
||||
className="size-4 text-indigo-600 dark:text-indigo-400"
|
||||
/>
|
||||
|
||||
<input
|
||||
id="firmwareVersion"
|
||||
name="firmwareVersion"
|
||||
type="text"
|
||||
defaultValue={device?.firmwareVersion ?? ''}
|
||||
readOnly={isMilestoneDevice}
|
||||
aria-readonly={isMilestoneDevice}
|
||||
title={isMilestoneDevice ? 'Firmware wird aus Milestone synchronisiert.' : undefined}
|
||||
className={classNames(
|
||||
inputClassName,
|
||||
isMilestoneDevice && milestoneInputWithIconClassName,
|
||||
isMilestoneDevice && milestoneReadonlyClassName,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Milestone
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Diese Felder werden mit Milestone synchronisiert.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-6">
|
||||
<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="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>
|
||||
</section>
|
||||
|
||||
@ -796,6 +958,78 @@ export default function DeviceFormFields({
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@ -2,7 +2,12 @@
|
||||
|
||||
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>>
|
||||
|
||||
|
||||
@ -184,6 +184,8 @@ export default function DevicesPage() {
|
||||
const [firmwareCheckNow, setFirmwareCheckNow] = useState(() => Date.now())
|
||||
const closeCleanupTimeoutRef = useRef<number | null>(null)
|
||||
|
||||
const [togglingMilestoneChildDeviceIds, setTogglingMilestoneChildDeviceIds] = useState<Set<string>>(() => new Set())
|
||||
|
||||
useEffect(() => {
|
||||
loadDevices()
|
||||
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(() => {
|
||||
function handleDeviceNotification(event: Event) {
|
||||
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() {
|
||||
setIsCheckingFirmware(true)
|
||||
|
||||
@ -885,7 +949,7 @@ export default function DevicesPage() {
|
||||
type="button"
|
||||
disabled={isToggling}
|
||||
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={
|
||||
isToggling
|
||||
? 'Milestone-Status wird geändert...'
|
||||
@ -1029,6 +1093,14 @@ export default function DevicesPage() {
|
||||
isJournalSubmitting={isJournalSubmitting}
|
||||
onJournalEntrySubmit={handleCreateDeviceJournalEntry}
|
||||
onJournalEntryUpdate={handleUpdateDeviceJournalEntry}
|
||||
togglingMilestoneChildDeviceIds={togglingMilestoneChildDeviceIds}
|
||||
onToggleMilestoneChildDevice={(childDevice, enabled) => {
|
||||
if (!editingDevice) {
|
||||
return
|
||||
}
|
||||
|
||||
return handleToggleMilestoneChildDevice(editingDevice, childDevice, enabled)
|
||||
}}
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
@ -44,6 +44,11 @@ type EditDeviceModalProps = {
|
||||
isJournalSubmitting?: boolean
|
||||
onJournalEntrySubmit?: (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']
|
||||
@ -64,6 +69,11 @@ const editDeviceTabs = [
|
||||
name: 'Zubehör',
|
||||
icon: WrenchScrewdriverIcon,
|
||||
},
|
||||
{
|
||||
id: 'hardware' as const,
|
||||
name: 'Hardware',
|
||||
icon: VideoCameraIcon,
|
||||
},
|
||||
{
|
||||
id: 'history' as const,
|
||||
name: 'Journal',
|
||||
@ -132,6 +142,8 @@ export default function EditDeviceModal({
|
||||
isJournalSubmitting = false,
|
||||
onJournalEntrySubmit,
|
||||
onJournalEntryUpdate,
|
||||
togglingMilestoneChildDeviceIds,
|
||||
onToggleMilestoneChildDevice,
|
||||
}: EditDeviceModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<DeviceModalTabId>('masterData')
|
||||
|
||||
@ -239,10 +251,6 @@ export default function EditDeviceModal({
|
||||
</DialogTitle>
|
||||
|
||||
<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 && (
|
||||
<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" />
|
||||
@ -328,6 +336,8 @@ export default function EditDeviceModal({
|
||||
devices={devices}
|
||||
relatedDeviceIds={relatedDeviceIds}
|
||||
onToggleRelatedDevice={onToggleRelatedDevice}
|
||||
togglingMilestoneChildDeviceIds={togglingMilestoneChildDeviceIds}
|
||||
onToggleMilestoneChildDevice={onToggleMilestoneChildDevice}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user