package effectenforcer import ( lucifer3 "git.aiterp.net/lucifer3/server" "git.aiterp.net/lucifer3/server/commands" "git.aiterp.net/lucifer3/server/device" "git.aiterp.net/lucifer3/server/events" "git.aiterp.net/lucifer3/server/internal/color" "git.aiterp.net/lucifer3/server/internal/gentools" "github.com/google/uuid" "sync" "sync/atomic" "time" ) func NewService(resolver device.Resolver, sceneMap device.SceneMap) lucifer3.ActiveService { s := &effectEnforcer{ resolver: resolver, sceneMap: sceneMap, list: make([]*effectEnforcerRun, 0, 16), index: make(map[string]*effectEnforcerRun, 8), supportFlags: make(map[string]device.SupportFlags), colorFlags: make(map[string]device.ColorFlags), temperatures: make(map[string]float64), motions: make(map[string]float64), } return s } type effectEnforcer struct { mu sync.Mutex resolver device.Resolver sceneMap device.SceneMap supportFlags map[string]device.SupportFlags colorFlags map[string]device.ColorFlags temperatures map[string]float64 motions map[string]float64 started uint32 list []*effectEnforcerRun index map[string]*effectEnforcerRun } func (s *effectEnforcer) Active() bool { return true } func (s *effectEnforcer) HandleEvent(bus *lucifer3.EventBus, event lucifer3.Event) { switch event := event.(type) { case events.HardwareState: s.mu.Lock() colorFlags := s.colorFlags supportFlags := s.supportFlags s.mu.Unlock() colorFlags = gentools.CopyMap(colorFlags) supportFlags = gentools.CopyMap(supportFlags) colorFlags[event.ID] = event.ColorFlags supportFlags[event.ID] = event.SupportFlags s.mu.Lock() s.colorFlags = colorFlags s.supportFlags = supportFlags s.mu.Unlock() case events.DeviceReady: // If the device is managed by the effect enforcer, cause the effect to be // re-ran. s.mu.Lock() if run, ok := s.index[event.ID]; ok { run.due = time.Now() } s.mu.Unlock() case events.DeviceAssigned: s.triggerVariableEffects(bus, event.DeviceID) case events.MotionSensed: s.mu.Lock() s.motions[event.ID] = event.SecondsSince s.mu.Unlock() s.triggerVariableEffects(bus, event.ID) case events.TemperatureChanged: s.mu.Lock() s.temperatures[event.ID] = event.Temperature s.mu.Unlock() s.triggerVariableEffects(bus, event.ID) } } func (s *effectEnforcer) HandleCommand(bus *lucifer3.EventBus, command lucifer3.Command) { switch command := command.(type) { case commands.Assign: pointers := s.resolver.Resolve(command.Match) allowedIDs := make([]string, 0, len(pointers)) for _, ptr := range pointers { if s.sceneMap.SceneID(ptr.ID) == nil { allowedIDs = append(allowedIDs, ptr.ID) } } if len(pointers) == 0 { if command.ID != nil { bus.RunEvent(events.AssignmentRemoved{ID: *command.ID}) } else { bus.RunEvent(events.Log{ Level: "info", Code: "assignment_matched_none", Message: "Assignment to \"" + command.Match + "\" matched no devices.", }) } return } id := uuid.New() if command.ID != nil { id = *command.ID } bus.RunEvent(events.AssignmentCreated{ ID: id, Match: command.Match, Effect: command.Effect, }) s.mu.Lock() // Create a new run newRun := &effectEnforcerRun{ match: command.Match, id: id, due: time.Now(), ids: allowedIDs, effect: command.Effect, } s.list = append(s.list, newRun) // Switch over the indices. for _, id := range allowedIDs { if oldRun := s.index[id]; oldRun != nil { oldRun.remove(id) } s.index[id] = newRun } s.mu.Unlock() if atomic.CompareAndSwapUint32(&s.started, 0, 1) { go s.runLoop(bus) } for _, deviceID := range allowedIDs { bus.RunEvent(events.DeviceAssigned{ DeviceID: deviceID, AssignmentID: gentools.Ptr(id), }) } } } func (s *effectEnforcer) triggerVariableEffects(bus *lucifer3.EventBus, id string) { now := time.Now() s.mu.Lock() for _, run := range s.list { found := false for _, id2 := range run.ids { if id2 == id { found = true break } } if !found { continue } run.variables = map[string]float64{} totalMotion := 0.0 motionSamples := 0 minMotion := 0.0 maxMotion := 0.0 totalTemperature := 0.0 temperatureSamples := 0 minTemperature := 0.0 maxTemperature := 0.0 for _, id := range run.ids { if motion, ok := s.motions[id]; ok { totalMotion += motion motionSamples += 1 if motion < minMotion || motionSamples == 1 { minMotion = motion } if motion > maxMotion { maxMotion = motion } } if temperature, ok := s.temperatures[id]; ok { totalTemperature += temperature temperatureSamples += 1 if temperature < minTemperature || temperatureSamples == 1 { minTemperature = temperature } if temperature > maxTemperature { maxTemperature = temperature } } } if temperatureSamples > 0 { run.variables["temperature.avg"] = totalTemperature / float64(temperatureSamples) run.variables["temperature.min"] = minTemperature run.variables["temperature.max"] = maxTemperature } if motionSamples > 0 { run.variables["motion.avg"] = totalMotion / float64(motionSamples) run.variables["motion.min"] = minMotion run.variables["motion.max"] = maxMotion } bus.RunEvent(events.AssignmentVariables{ID: run.id, Map: gentools.CopyMap(run.variables)}) if _, ok := run.effect.(lucifer3.VariableEffect); ok { run.due = now } } s.mu.Unlock() } func (s *effectEnforcer) runLoop(bus *lucifer3.EventBus) { deleteList := make([]int, 0, 8) batch := make(commands.SetStateBatch, 64) deleteIDs := make([]uuid.UUID, 0) for now := range time.NewTicker(time.Millisecond * 50).C { deleteIDs = deleteIDs[:0] s.mu.Lock() colorFlags := s.colorFlags supportFlags := s.supportFlags for i, run := range s.list { if run.dead { deleteIDs = append(deleteIDs, run.id) deleteList = append(deleteList, i-len(deleteList)) continue } if run.due.IsZero() || now.Before(run.due) { continue } for j, id := range run.ids { if id == "" { continue } state := run.effect.State(j, len(run.ids), run.round) if vEff, ok := run.effect.(lucifer3.VariableEffect); ok { variableName := vEff.VariableName() if variable, ok := run.variables[variableName]; ok { state = vEff.VariableState(j, len(run.ids), variable) } } batch[id] = state } if freq := run.effect.Frequency(); freq > 0 { if freq < 100*time.Millisecond { run.due = now } else { run.due = run.due.Add(freq) } run.round += 1 } else { run.due = time.Time{} } } if len(deleteList) > 0 { for _, i := range deleteList { s.list = append(s.list[:i], s.list[i+1:]...) } deleteList = deleteList[:0] } s.mu.Unlock() for _, id := range deleteIDs { bus.RunEvent(events.AssignmentRemoved{ID: id}) } if len(batch) > 0 { for id, state := range batch { sf := supportFlags[id] if state.Power != nil && !sf.HasAny(device.SFlagPower) { state.Power = nil } if state.Temperature != nil && !sf.HasAny(device.SFlagTemperature) { state.Temperature = nil } if state.Intensity != nil && !sf.HasAny(device.SFlagIntensity) { state.Intensity = nil } if state.Color != nil && !sf.HasAny(device.SFlagColor) { state.Color = nil } else if state.Color != nil { cf := colorFlags[id] invalid := (state.Color.K != nil && !cf.HasAll(device.CFlagKelvin)) || (state.Color.XY != nil && !cf.HasAll(device.CFlagXY)) || (state.Color.RGB != nil && !cf.HasAll(device.CFlagRGB)) || (state.Color.HS != nil && !cf.HasAll(device.CFlagHS)) if invalid { var converted color.Color var ok bool switch { case cf.HasAny(device.CFlagXY): converted, ok = state.Color.ToXY() case cf.HasAny(device.CFlagRGB): converted, ok = state.Color.ToRGB() case cf.HasAny(device.CFlagHS): converted, ok = state.Color.ToHS() } if !ok { state.Color = nil } else { state.Color = &converted } } } batch[id] = state } bus.RunCommand(batch) batch = make(commands.SetStateBatch, 64) } } } type effectEnforcerRun struct { id uuid.UUID match string due time.Time ids []string effect lucifer3.Effect dead bool round int variables map[string]float64 } // remove takes out an id from the effect, and returns whether the effect is empty. func (r *effectEnforcerRun) remove(id string) { anyLeft := false for i, id2 := range r.ids { if id2 == id { r.ids[i] = "" } else if id2 != "" { anyLeft = true } } if !anyLeft { r.dead = true } return }