added child devices
This commit is contained in:
parent
47bf652b56
commit
5af9896514
@ -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,
|
||||||
|
|||||||
@ -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{
|
||||||
|
|||||||
@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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 <> ''`,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -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>>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user