136 lines
5.0 KiB
Go
136 lines
5.0 KiB
Go
package brain_control
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var stableID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$`)
|
|
|
|
func MapSourceConfig(f SourceFacts) (SourceConfig, error) {
|
|
if !stableID.MatchString(f.ConfigID) || !stableID.MatchString(f.SiteID) || !stableID.MatchString(f.LogicalDeviceID) || !stableID.MatchString(f.Profile.ID) || f.Revision < 1 || f.Profile.Width < 1 || f.Profile.Height < 1 || f.Profile.FrameRate <= 0 {
|
|
return SourceConfig{}, errors.New("invalid source configuration facts")
|
|
}
|
|
encoding := strings.ToUpper(f.Profile.Encoding)
|
|
if encoding != "H264" && encoding != "H265" && encoding != "MJPEG" {
|
|
return SourceConfig{}, errors.New("unsupported profile encoding")
|
|
}
|
|
if f.PublishedAt.IsZero() || f.EffectiveAt.Before(f.PublishedAt) {
|
|
return SourceConfig{}, errors.New("invalid source configuration time")
|
|
}
|
|
if !stableID.MatchString(f.RuleSetVersion) {
|
|
return SourceConfig{}, errors.New("invalid rule set version")
|
|
}
|
|
if strings.ContainsAny(f.MediaPath, "?#@\\") || strings.Contains(f.MediaPath, "://") || f.MediaPath == "" {
|
|
return SourceConfig{}, errors.New("media path must be opaque and credential-free")
|
|
}
|
|
if len(f.Areas) > 1024 || len(f.DirectionalLines) > 1024 {
|
|
return SourceConfig{}, errors.New("too many rules")
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, a := range f.Areas {
|
|
if !stableID.MatchString(a.ID) || seen[a.ID] || a.Version < 1 || a.Kind != "danger_area" || len(a.Points) < 3 || len(a.Points) > 256 || !validPoints(a.Points) || polygonArea(a.Points) == 0 {
|
|
return SourceConfig{}, errors.New("invalid area rule")
|
|
}
|
|
seen[a.ID] = true
|
|
}
|
|
for _, l := range f.DirectionalLines {
|
|
if !stableID.MatchString(l.ID) || seen[l.ID] || l.Version < 1 || l.Kind != "directional_line" || (l.TriggerDirection != "left_to_right" && l.TriggerDirection != "right_to_left") || !validPoints([]Point{l.Start, l.End}) || l.Start == l.End {
|
|
return SourceConfig{}, errors.New("invalid directional line rule")
|
|
}
|
|
seen[l.ID] = true
|
|
}
|
|
var out SourceConfig
|
|
out.SchemaVersion, out.ConfigID, out.Revision = SourceConfigVersion, f.ConfigID, f.Revision
|
|
out.PublishedAt, out.EffectiveAt = f.PublishedAt.UTC(), f.EffectiveAt.UTC()
|
|
out.Site.ID, out.LogicalDevice.ID = f.SiteID, f.LogicalDeviceID
|
|
out.Profile = f.Profile
|
|
out.Profile.Encoding = encoding
|
|
out.Media.Ref, out.Media.Transport = "media:"+strings.TrimPrefix(f.MediaPath, "/"), "rtsp"
|
|
out.RuleSet.Version = f.RuleSetVersion
|
|
out.RuleSet.State = "active"
|
|
if f.Disabled {
|
|
out.RuleSet.State = "disabled"
|
|
}
|
|
if f.NeedsRecalibration {
|
|
out.RuleSet.State = "recalibration_required"
|
|
}
|
|
out.RuleSet.ProfileBinding.ProfileID, out.RuleSet.ProfileBinding.Width, out.RuleSet.ProfileBinding.Height = f.Profile.ID, f.Profile.Width, f.Profile.Height
|
|
out.RuleSet.Areas = append([]AreaRule(nil), f.Areas...)
|
|
out.RuleSet.DirectionalLines = append([]DirectionalLineRule(nil), f.DirectionalLines...)
|
|
if out.RuleSet.State != "active" {
|
|
for i := range out.RuleSet.Areas {
|
|
out.RuleSet.Areas[i].Enabled = false
|
|
}
|
|
for i := range out.RuleSet.DirectionalLines {
|
|
out.RuleSet.DirectionalLines[i].Enabled = false
|
|
}
|
|
}
|
|
digest, err := sourceDigest(out)
|
|
if err != nil {
|
|
return SourceConfig{}, err
|
|
}
|
|
out.Integrity.Algorithm, out.Integrity.Value = "sha256", digest
|
|
return out, nil
|
|
}
|
|
|
|
func MarshalSourceConfig(config SourceConfig) ([]byte, error) { return json.Marshal(config) }
|
|
|
|
func sourceDigest(config SourceConfig) (string, error) {
|
|
raw, err := json.Marshal(config)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var value map[string]any
|
|
if err = json.Unmarshal(raw, &value); err != nil {
|
|
return "", err
|
|
}
|
|
delete(value, "integrity")
|
|
canonical, err := json.Marshal(value)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
sum := sha256.Sum256(canonical)
|
|
return hex.EncodeToString(sum[:]), nil
|
|
}
|
|
|
|
func validPoints(points []Point) bool {
|
|
for _, p := range points {
|
|
if p.X < 0 || p.X > 1 || p.Y < 0 || p.Y > 1 {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
func polygonArea(p []Point) float64 {
|
|
var a float64
|
|
for i := range p {
|
|
n := p[(i+1)%len(p)]
|
|
a += p[i].X*n.Y - n.X*p[i].Y
|
|
}
|
|
if a < 0 {
|
|
a = -a
|
|
}
|
|
return a / 2
|
|
}
|
|
|
|
func ValidateSourceConfig(config SourceConfig) error {
|
|
if config.SchemaVersion != SourceConfigVersion {
|
|
return fmt.Errorf("unsupported source config version")
|
|
}
|
|
digest, err := sourceDigest(config)
|
|
if err != nil || config.Integrity.Algorithm != "sha256" || digest != config.Integrity.Value {
|
|
return errors.New("source config integrity mismatch")
|
|
}
|
|
_, err = MapSourceConfig(SourceFacts{ConfigID: config.ConfigID, SiteID: config.Site.ID, LogicalDeviceID: config.LogicalDevice.ID, MediaPath: strings.TrimPrefix(config.Media.Ref, "media:"), Revision: config.Revision, PublishedAt: config.PublishedAt, EffectiveAt: config.EffectiveAt, Profile: config.Profile, RuleSetVersion: config.RuleSet.Version, Disabled: config.RuleSet.State == "disabled", NeedsRecalibration: config.RuleSet.State == "recalibration_required", Areas: config.RuleSet.Areas, DirectionalLines: config.RuleSet.DirectionalLines})
|
|
return err
|
|
}
|
|
|
|
func UTCNow() time.Time { return time.Now().UTC() }
|