185 lines
5.3 KiB
Go
185 lines
5.3 KiB
Go
// Package device contains the Sense device-ledger domain model.
|
|
package device
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
DefaultVideoChannels = 16
|
|
MaximumVideoChannels = 128
|
|
)
|
|
|
|
type Modality string
|
|
|
|
const (
|
|
ModalityVideo Modality = "video"
|
|
ModalityRadar Modality = "radar"
|
|
ModalityContact Modality = "contact"
|
|
ModalityButton Modality = "button"
|
|
ModalityWearable Modality = "wearable"
|
|
ModalityOther Modality = "other"
|
|
)
|
|
|
|
type Capability string
|
|
|
|
const (
|
|
CapabilityVideoCapture Capability = "video_capture"
|
|
CapabilityAudioCapture Capability = "audio_capture"
|
|
CapabilitySpatialRule Capability = "spatial_rule"
|
|
CapabilityTelemetry Capability = "telemetry"
|
|
)
|
|
|
|
type DesiredState string
|
|
|
|
const (
|
|
DesiredDisabled DesiredState = "disabled"
|
|
DesiredEnabled DesiredState = "enabled"
|
|
)
|
|
|
|
type ActualState string
|
|
|
|
const (
|
|
ActualPending ActualState = "pending"
|
|
ActualOnline ActualState = "online"
|
|
ActualOffline ActualState = "offline"
|
|
ActualFailed ActualState = "failed"
|
|
)
|
|
|
|
type Site struct {
|
|
TenantID string
|
|
ID string
|
|
Name string
|
|
MaxVideoChannels int
|
|
}
|
|
|
|
func (s *Site) ApplyDefaults() {
|
|
if s.MaxVideoChannels == 0 {
|
|
s.MaxVideoChannels = DefaultVideoChannels
|
|
}
|
|
}
|
|
|
|
func (s Site) Validate() error {
|
|
if strings.TrimSpace(s.TenantID) == "" || strings.TrimSpace(s.ID) == "" {
|
|
return errors.New("tenant ID and site ID are required")
|
|
}
|
|
if s.MaxVideoChannels < 1 || s.MaxVideoChannels > MaximumVideoChannels {
|
|
return fmt.Errorf("max video channels must be between 1 and %d", MaximumVideoChannels)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type Device struct {
|
|
ID string
|
|
TenantID string
|
|
SiteID string
|
|
AreaID string
|
|
SerialNumber string
|
|
Name string
|
|
Modality Modality
|
|
Capabilities []Capability
|
|
DesiredState DesiredState
|
|
ActualState ActualState
|
|
EndpointRef string
|
|
CredentialRef string
|
|
ProfileToken string
|
|
PathName string
|
|
Generation int64
|
|
ResourceVersion int64
|
|
QuotaSourceVersion int64
|
|
AreaPolicySourceVersion int64
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
func (d Device) Validate() error {
|
|
if strings.TrimSpace(d.ID) == "" || strings.TrimSpace(d.TenantID) == "" || strings.TrimSpace(d.SiteID) == "" {
|
|
return errors.New("device ID, tenant ID and site ID are required")
|
|
}
|
|
if strings.TrimSpace(d.SerialNumber) == "" || strings.TrimSpace(d.Name) == "" {
|
|
return errors.New("serial number and device name are required")
|
|
}
|
|
if !validModality(d.Modality) {
|
|
return fmt.Errorf("unsupported modality %q", d.Modality)
|
|
}
|
|
if d.DesiredState != DesiredEnabled && d.DesiredState != DesiredDisabled {
|
|
return fmt.Errorf("unsupported desired state %q", d.DesiredState)
|
|
}
|
|
if d.ActualState != ActualPending && d.ActualState != ActualOnline && d.ActualState != ActualOffline && d.ActualState != ActualFailed {
|
|
return fmt.Errorf("unsupported actual state %q", d.ActualState)
|
|
}
|
|
if d.DesiredState == DesiredEnabled && d.HasCapability(CapabilityVideoCapture) {
|
|
if strings.TrimSpace(d.EndpointRef) == "" || strings.TrimSpace(d.PathName) == "" {
|
|
return errors.New("enabled video devices require endpoint ref and path name")
|
|
}
|
|
}
|
|
if d.EndpointRef != "" {
|
|
endpoint, err := url.Parse(d.EndpointRef)
|
|
if err != nil || endpoint.Scheme == "" {
|
|
return errors.New("endpoint ref must be an absolute URI")
|
|
}
|
|
if endpoint.User != nil {
|
|
return errors.New("endpoint ref must not contain credentials")
|
|
}
|
|
}
|
|
if d.PathName != "" && !validPathName(d.PathName) {
|
|
return errors.New("path name must contain safe ASCII segments")
|
|
}
|
|
seen := make(map[Capability]struct{}, len(d.Capabilities))
|
|
for _, capability := range d.Capabilities {
|
|
if !validCapability(capability) {
|
|
return fmt.Errorf("unsupported capability %q", capability)
|
|
}
|
|
if _, ok := seen[capability]; ok {
|
|
return fmt.Errorf("duplicate capability %q", capability)
|
|
}
|
|
seen[capability] = struct{}{}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (d Device) HasCapability(capability Capability) bool {
|
|
return slices.Contains(d.Capabilities, capability)
|
|
}
|
|
|
|
func (d Device) ConsumesVideoChannel() bool {
|
|
return d.DesiredState == DesiredEnabled && d.HasCapability(CapabilityVideoCapture)
|
|
}
|
|
|
|
func validModality(value Modality) bool {
|
|
return slices.Contains([]Modality{ModalityVideo, ModalityRadar, ModalityContact, ModalityButton, ModalityWearable, ModalityOther}, value)
|
|
}
|
|
|
|
func validCapability(value Capability) bool {
|
|
return slices.Contains([]Capability{CapabilityVideoCapture, CapabilityAudioCapture, CapabilitySpatialRule, CapabilityTelemetry}, value)
|
|
}
|
|
|
|
func validPathName(value string) bool {
|
|
if strings.HasPrefix(value, "/") || strings.HasSuffix(value, "/") || strings.Contains(value, "//") || strings.Contains(value, "..") {
|
|
return false
|
|
}
|
|
for _, character := range value {
|
|
if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') ||
|
|
(character >= '0' && character <= '9') || strings.ContainsRune("-_/.", character) {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
return value != ""
|
|
}
|
|
|
|
type QuotaExceededError struct {
|
|
TenantID string
|
|
SiteID string
|
|
Limit int
|
|
}
|
|
|
|
func (e *QuotaExceededError) Error() string {
|
|
return fmt.Sprintf("video channel quota exceeded for site %s/%s (limit %d)", e.TenantID, e.SiteID, e.Limit)
|
|
}
|