diff --git a/bus.go b/bus.go index afc05d5..0392ec9 100644 --- a/bus.go +++ b/bus.go @@ -2,11 +2,21 @@ package lucifer3 import ( "fmt" + "log" + "os" + "strconv" + "strings" "sync" ) type ServiceKey struct{} +var showVerboseEvents bool + +type verboseEventOrCommand interface { + VerboseKey() string +} + type Event interface { EventDescription() string } @@ -29,6 +39,8 @@ type EventBus struct { privilegedList []ActiveService signal chan struct{} setStates int32 + verboseCount int + verboseCounts map[string]int } // JoinCallback joins the event bus for a moment. @@ -63,12 +75,27 @@ func (b *EventBus) JoinPrivileged(service ActiveService) { } func (b *EventBus) RunCommand(command Command) { - fmt.Println("[COMMAND]", command.CommandDescription()) + if v, ok := command.(verboseEventOrCommand); ok && !showVerboseEvents { + b.mu.Lock() + b.countVerbose(v.VerboseKey()) + b.mu.Unlock() + + } else { + fmt.Println("[COMMAND]", command.CommandDescription()) + } + b.send(serviceMessage{command: command}) } func (b *EventBus) RunEvent(event Event) { - fmt.Println("[EVENT]", event.EventDescription()) + if v, ok := event.(verboseEventOrCommand); ok && !showVerboseEvents { + b.mu.Lock() + b.countVerbose(v.VerboseKey()) + b.mu.Unlock() + } else { + fmt.Println("[EVENT]", event.EventDescription()) + } + b.send(serviceMessage{event: event}) } @@ -179,7 +206,41 @@ func (l *serviceListener) run(signal <-chan struct{}) { } } +func (b *EventBus) countVerbose(key string) { + if b.verboseCounts == nil { + b.verboseCounts = make(map[string]int, 8) + } + b.verboseCounts[key] = b.verboseCounts[key] + 1 + b.verboseCount += 1 + + if b.verboseCount >= 1000 { + first := true + + s := strings.Builder{} + s.WriteString("[EVENT] 1000 verbose events hidden (") + for key, value := range b.verboseCounts { + if !first { + s.WriteString(", ") + } + first = false + + s.WriteString(key) + s.WriteRune(':') + s.WriteString(strconv.Itoa(value)) + } + s.WriteRune(')') + log.Println(s.String()) + + b.verboseCounts = make(map[string]int, 8) + b.verboseCount = 0 + } +} + type serviceMessage struct { event Event command Command } + +func init() { + showVerboseEvents = os.Getenv("LUCIFER4_VERBOSE") == "true" +} diff --git a/cmd/lucifer4-server/main.go b/cmd/lucifer4-server/main.go index 1d63665..31a682d 100644 --- a/cmd/lucifer4-server/main.go +++ b/cmd/lucifer4-server/main.go @@ -11,8 +11,9 @@ import ( "git.aiterp.net/lucifer3/server/services/uistate" "log" "os" + "os/signal" "strconv" - "time" + "syscall" ) func main() { @@ -48,7 +49,10 @@ func main() { bus.RunEvent(events.Started{}) - time.Sleep(time.Hour * 16) + exitSignal := make(chan os.Signal) + signal.Notify(exitSignal, os.Interrupt, os.Kill, syscall.SIGTERM) + sig := <-exitSignal + log.Println("Received signal", sig) } func env(key string) string { diff --git a/commands/state.go b/commands/state.go index bc5e0f8..b508800 100644 --- a/commands/state.go +++ b/commands/state.go @@ -23,8 +23,16 @@ func (c SetState) CommandDescription() string { return fmt.Sprintf("SetState(%s, %s)", c.ID, c.State) } +func (c SetState) VerboseKey() string { + return "SetState" +} + type SetStateBatch map[string]device.State func (c SetStateBatch) CommandDescription() string { return fmt.Sprintf("SetStateBatch(%d devices)", len(c)) } + +func (c SetStateBatch) VerboseKey() string { + return "SetStateBatch" +} diff --git a/events/button.go b/events/button.go deleted file mode 100644 index 7543edb..0000000 --- a/events/button.go +++ /dev/null @@ -1,38 +0,0 @@ -package events - -import "fmt" - -type ButtonPressed struct { - ID string `json:"id"` - SwipedID string `json:"swipedId,omitempty"` - Name string `json:"name"` -} - -func (e ButtonPressed) EventDescription() string { - if e.SwipedID != "" { - return fmt.Sprintf("ButtonPressed(name:%s, swipe:%s->%s)", e.Name, e.SwipedID, e.ID) - } else { - return fmt.Sprintf("ButtonPressed(name:%s, id:%s)", e.Name, e.ID) - } -} - -func (e ButtonPressed) TriggerKind() string { - return "ButtonPressed:" + e.Name -} - -func (e ButtonPressed) TriggerValue(key string) (string, bool) { - switch key { - case "name": - return e.Name, true - case "id": - return e.ID, true - case "swipedFromId", "swipedId": - if e.SwipedID != "" { - return e.SwipedID, true - } else { - return "", false - } - default: - return "", false - } -} diff --git a/events/device.go b/events/device.go index 620caf4..14378b9 100644 --- a/events/device.go +++ b/events/device.go @@ -45,6 +45,10 @@ func (e HardwareState) EventDescription() string { ) } +func (e HardwareState) VerboseKey() string { + return "HardwareState" +} + // HardwareMetadata contains things that has no bearing on the functionality of // lucifer, but may be interesting to have in the GUI. type HardwareMetadata struct { @@ -63,6 +67,10 @@ func (e HardwareMetadata) EventDescription() string { return fmt.Sprintf("HardwareMetadata(id:%s, icon:%s, ...)", e.ID, e.Icon) } +func (e HardwareMetadata) VerboseKey() string { + return "HardwareMetadata" +} + // DeviceReady is triggered to indicate that set-state commands will be heeded for the device // by this ID. It may be unavailable, however. type DeviceReady struct { diff --git a/events/sensors.go b/events/sensors.go new file mode 100644 index 0000000..e9655ab --- /dev/null +++ b/events/sensors.go @@ -0,0 +1,86 @@ +package events + +import "fmt" + +type TemperatureChanged struct { + ID string + Temperature float64 +} + +func (e TemperatureChanged) TriggerKind() string { + return "TemperatureChanged" +} + +func (e TemperatureChanged) TriggerValue(key string) (string, bool) { + switch key { + case "temperature": + return fmt.Sprintf("%.2f", e.Temperature), true + case "id": + return e.ID, true + default: + return "", false + } +} + +func (e TemperatureChanged) EventDescription() string { + return fmt.Sprintf("TemperatureChanged(id=%s, temperature=%.2f)", e.ID, e.Temperature) +} + +type MotionSensed struct { + ID string + SecondsSince float64 +} + +func (e MotionSensed) TriggerKind() string { + return "MotionSensed" +} + +func (e MotionSensed) TriggerValue(key string) (string, bool) { + switch key { + case "secondsSince": + return fmt.Sprintf("%.1f", e.SecondsSince), true + case "id": + return e.ID, true + default: + return "", false + } +} + +func (e MotionSensed) EventDescription() string { + return fmt.Sprintf("MotionSensed(id=%s, secondsSince=%.2f)", e.ID, e.SecondsSince) +} + +type ButtonPressed struct { + ID string `json:"id"` + SwipedID string `json:"swipedId,omitempty"` + Name string `json:"name"` +} + +func (e ButtonPressed) EventDescription() string { + if e.SwipedID != "" { + return fmt.Sprintf("ButtonPressed(name=%s, swipe=%s->%s)", e.Name, e.SwipedID, e.ID) + } else { + return fmt.Sprintf("ButtonPressed(name=%s, id=%s)", e.Name, e.ID) + } +} + +func (e ButtonPressed) TriggerKind() string { + return "ButtonPressed:" + e.Name +} + +func (e ButtonPressed) TriggerValue(key string) (string, bool) { + switch key { + case "name": + return e.Name, true + case "id": + return e.ID, true + case "swipedFromId", "swipedId": + if e.SwipedID != "" { + return e.SwipedID, true + } else { + return "", false + } + default: + return "", false + } +} diff --git a/internal/color/color.go b/internal/color/color.go index ad9360a..52443d8 100644 --- a/internal/color/color.go +++ b/internal/color/color.go @@ -280,9 +280,9 @@ func (col *Color) String() string { case col.XY != nil: return fmt.Sprintf("xy:%.4f,%.4f", col.XY.X, col.XY.Y) case col.HS != nil && col.K != nil: - return fmt.Sprintf("hsk:%.4f,%.3f,%d", col.HS.Hue, col.HS.Sat, *col.K) + return fmt.Sprintf("hsk:%.2f,%.3f,%d", col.HS.Hue, col.HS.Sat, *col.K) case col.HS != nil: - return fmt.Sprintf("hs:%.4f,%.3f", col.HS.Hue, col.HS.Sat) + return fmt.Sprintf("hs:%.2f,%.3f", col.HS.Hue, col.HS.Sat) case col.K != nil: return fmt.Sprintf("k:%d", *col.K) default: diff --git a/services/httpapiv1/service.go b/services/httpapiv1/service.go index b864b49..9f6913e 100644 --- a/services/httpapiv1/service.go +++ b/services/httpapiv1/service.go @@ -16,6 +16,9 @@ func New(addr string) (lucifer3.Service, error) { svc := &service{} e := echo.New() + e.HideBanner = true + e.HidePort = true + e.GET("/state", func(c echo.Context) error { svc.mu.Lock() data := svc.data diff --git a/services/hue/bridge.go b/services/hue/bridge.go index 0820444..5f7ecb9 100644 --- a/services/hue/bridge.go +++ b/services/hue/bridge.go @@ -3,6 +3,7 @@ package hue import ( "context" "errors" + "fmt" lucifer3 "git.aiterp.net/lucifer3/server" "git.aiterp.net/lucifer3/server/device" "git.aiterp.net/lucifer3/server/events" @@ -45,6 +46,8 @@ type Bridge struct { colorFlags map[string]device.ColorFlags reachable map[string]bool hasSeen map[string]bool + lastMotion map[string]time.Time + lastButton map[string]time.Time triggerCongruenceCheckCh chan struct{} @@ -112,22 +115,57 @@ func (b *Bridge) RefreshAll() ([]lucifer3.Event, error) { b.mu.Lock() hasSeen := b.hasSeen reachable := b.reachable + lastMotion := b.lastMotion b.mu.Unlock() + + oldHasSeen := hasSeen hasSeen = gentools.CopyMap(hasSeen) reachable = gentools.CopyMap(reachable) + lastMotion = gentools.CopyMap(lastMotion) colorFlags := make(map[string]device.ColorFlags) activeStates := make(map[string]device.State) newEvents := make([]lucifer3.Event, 0, 0) + extraEvents := make([]lucifer3.Event, 0, 0) for id, res := range resources { - if res.Type == "device" && res.Metadata.Archetype != "bridge_v2" { + if res.Owner != nil && !oldHasSeen[res.Owner.ID] { + if res.Temperature != nil { + extraEvents = append(extraEvents, events.TemperatureChanged{ + ID: b.fullId(*res), + Temperature: res.Temperature.Temperature, + }) + } + + if res.Motion != nil { + if res.Motion.Motion { + extraEvents = append(extraEvents, events.MotionSensed{ + ID: b.fullId(*res), + SecondsSince: 0, + }) + + lastMotion[b.fullId(*res)] = time.Now() + } else { + extraEvents = append(extraEvents, events.MotionSensed{ + ID: b.fullId(*res), + SecondsSince: 301, + }) + + lastMotion[b.fullId(*res)] = time.Now().Add(-time.Millisecond * 301) + } + } + } + + if res.Type == "device" { hwState, hwEvent := res.GenerateEvent(b.host, resources) + if hwState.SupportFlags == 0 { + continue + } + + newEvents = append(newEvents, hwState) if !hasSeen[id] { - newEvents = append(newEvents, hwState, hwEvent, events.DeviceReady{ID: hwState.ID}) + newEvents = append(newEvents, hwEvent, events.DeviceReady{ID: hwState.ID}) hasSeen[id] = true - } else { - newEvents = append(newEvents, hwState) } activeStates[id] = hwState.State @@ -142,29 +180,40 @@ func (b *Bridge) RefreshAll() ([]lucifer3.Event, error) { b.colorFlags = colorFlags b.activeStates = activeStates b.reachable = reachable + b.lastMotion = lastMotion b.mu.Unlock() - return newEvents, nil + return append(newEvents, extraEvents...), nil } -func (b *Bridge) ApplyPatches(resources []ResourceData) (events []lucifer3.Event, shouldRefresh bool) { +func (b *Bridge) ApplyPatches(date time.Time, resources []ResourceData) (eventList []lucifer3.Event, shouldRefresh bool) { b.mu.Lock() resourceMap := b.resources activeStates := b.activeStates reachable := b.reachable colorFlags := b.colorFlags + lastMotion := b.lastMotion + lastButton := b.lastButton b.mu.Unlock() mapCopy := gentools.CopyMap(resourceMap) activeStatesCopy := gentools.CopyMap(activeStates) reachableCopy := gentools.CopyMap(reachable) colorFlagsCopy := gentools.CopyMap(colorFlags) + lastMotionCopy := gentools.CopyMap(lastMotion) + lastButtonCopy := gentools.CopyMap(lastButton) for _, resource := range resources { if mapCopy[resource.ID] != nil { mapCopy[resource.ID] = mapCopy[resource.ID].WithPatch(resource) } else { - log.Println(resource.ID, resource.Type, "not seen!") + eventList = append(eventList, events.Log{ + ID: b.fullId(resource), + Level: "info", + Code: "hue_patch_found_unknown_device", + Message: "Refresh triggered, because of unknown device", + }) + shouldRefresh = true } } @@ -173,13 +222,59 @@ func (b *Bridge) ApplyPatches(resources []ResourceData) (events []lucifer3.Event if resource.Owner != nil && resource.Owner.Kind == "device" { if parent, ok := mapCopy[resource.Owner.ID]; ok { hwState, _ := parent.GenerateEvent(b.host, mapCopy) - events = append(events, hwState) + eventList = append(eventList, hwState) activeStatesCopy[resource.Owner.ID] = hwState.State reachableCopy[resource.Owner.ID] = !hwState.Unreachable if hwState.ColorFlags != 0 { colorFlagsCopy[resource.Owner.ID] = hwState.ColorFlags } + + if resource.Temperature != nil { + eventList = append(eventList, events.TemperatureChanged{ + ID: b.fullId(resource), + Temperature: resource.Temperature.Temperature, + }) + } + if resource.Motion != nil { + if resource.Motion.Motion { + eventList = append(eventList, events.MotionSensed{ + ID: b.fullId(resource), + SecondsSince: 0, + }) + + lastMotionCopy[resource.ID] = time.Now() + } + } + if resource.Button != nil { + valid := false + if resource.Button.LastEvent == "initial_press" { + valid = true + } else if resource.Button.LastEvent == "long_release" { + valid = false + } else if resource.Button.LastEvent == "repeat" { + valid = date.Sub(lastButtonCopy[resource.ID]) >= time.Millisecond*500 + } else { + valid = date.Sub(lastButtonCopy[resource.ID]) >= time.Millisecond*990 + } + + if valid { + lastButtonCopy[resource.ID] = date + + owner := resourceMap[resource.Owner.ID] + if owner != nil { + index := owner.ServiceIndex("button", resource.ID) + if index != -1 { + eventList = append(eventList, events.ButtonPressed{ + ID: b.fullId(*owner), + Name: []string{"On", "DimUp", "DimDown", "Off"}[index], + }) + } + } + } + } + } else { + shouldRefresh = true } } } @@ -189,6 +284,8 @@ func (b *Bridge) ApplyPatches(resources []ResourceData) (events []lucifer3.Event b.activeStates = activeStatesCopy b.reachable = reachableCopy b.colorFlags = colorFlagsCopy + b.lastMotion = lastMotionCopy + b.lastButton = lastButtonCopy b.mu.Unlock() return @@ -242,6 +339,9 @@ func (b *Bridge) Run(ctx context.Context, bus *lucifer3.EventBus) interface{} { step := time.NewTicker(time.Second * 30) defer step.Stop() + quickStep := time.NewTicker(time.Second * 5) + defer quickStep.Stop() + for { select { case updates, ok := <-sse: @@ -249,8 +349,12 @@ func (b *Bridge) Run(ctx context.Context, bus *lucifer3.EventBus) interface{} { if !ok { return errors.New("SSE lost connection") } + if len(updates) == 0 { + continue + } newEvents, shouldUpdate := b.ApplyPatches( + updates[0].CreationTime, gentools.Flatten(gentools.Map(updates, func(update SSEUpdate) []ResourceData { return update.Data })), @@ -269,6 +373,20 @@ func (b *Bridge) Run(ctx context.Context, bus *lucifer3.EventBus) interface{} { b.triggerCongruenceCheck() } + case <-quickStep.C: + b.mu.Lock() + lastMotion := b.lastMotion + b.mu.Unlock() + + for id, value := range lastMotion { + since := time.Since(value) + sinceMod := since % time.Minute + if since > time.Second*30 && (sinceMod <= time.Second*5) { + bus.RunEvent(events.MotionSensed{ID: id, SecondsSince: since.Seconds()}) + } + } + + b.triggerCongruenceCheck() case <-step.C: hwEvents, err := b.RefreshAll() if err != nil { @@ -307,17 +425,14 @@ func (b *Bridge) makeCongruentLoop(ctx context.Context) { updates := make(map[string]ResourceUpdate) for id, desired := range desiredStates { - active, activeOK := activeStates[id] lightID := resources[id].ServiceID("light") if !reachable[id] || !activeOK || lightID == nil { - log.Println("No light", !reachable[id], !activeOK, lightID == nil) continue } light := resources[*lightID] if light == nil { - log.Println("No light", *lightID) continue } @@ -433,6 +548,15 @@ func (b *Bridge) makeCongruentLoop(ctx context.Context) { } } +func (b *Bridge) fullId(res ResourceData) string { + id := res.ID + if res.Owner != nil && res.Owner.Kind == "device" { + id = res.Owner.ID + } + + return fmt.Sprintf("hue:%s:%s", b.host, id) +} + func (b *Bridge) triggerCongruenceCheck() { select { case b.triggerCongruenceCheckCh <- struct{}{}: diff --git a/services/uistate/data.go b/services/uistate/data.go index 3604aec..af920c1 100644 --- a/services/uistate/data.go +++ b/services/uistate/data.go @@ -47,6 +47,14 @@ func (d *Data) WithPatch(patches ...Patch) Data { if patch.Device.ClearActiveColorRGB { pd.ActiveColorRGB = nil } + if patch.Device.Sensors != nil { + if patch.Device.Sensors.LastMotion != nil { + pd.Sensors.LastMotion = gentools.ShallowCopy(patch.Device.Sensors.LastMotion) + } + if patch.Device.Sensors.Temperature != nil { + pd.Sensors.Temperature = gentools.ShallowCopy(patch.Device.Sensors.Temperature) + } + } if patch.Device.Delete { delete(newData.Devices, pd.ID) @@ -115,6 +123,12 @@ type Device struct { DesiredState *device.State `json:"desiredState"` Aliases []string `json:"aliases"` Assignment *uuid.UUID `json:"assignment"` + Sensors DeviceSensors `json:"sensors"` +} + +type DeviceSensors struct { + LastMotion *float64 `json:"lastMotion,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` } type Assignment struct { diff --git a/services/uistate/patch.go b/services/uistate/patch.go index 33724b6..3260c8e 100644 --- a/services/uistate/patch.go +++ b/services/uistate/patch.go @@ -14,13 +14,30 @@ type Patch struct { Device *DevicePatch `json:"device,omitempty"` } +func (e Patch) VerboseKey() string { + return "uistate.Patch" +} + func (e Patch) EventDescription() string { if e.Device != nil { switch { + case e.Device.Name != nil: + return fmt.Sprintf("uistate.Patch(device=%s, name=%s)", e.Device.ID, e.Device.Name) case e.Device.DesiredState != nil: - return fmt.Sprintf("uistate.Patch(device=%s, desired state)", e.Device.ID) + return fmt.Sprintf("uistate.Patch(device=%s, desiredState=%s)", e.Device.ID, e.Device.DesiredState.String()) + case e.Device.HWState != nil: + return fmt.Sprintf("uistate.Patch(device=%s, hwState)", e.Device.ID) + case e.Device.HWMetadata != nil: + return fmt.Sprintf("uistate.Patch(device=%s, hwMetadata)", e.Device.ID) + case e.Device.AddAlias != nil: + return fmt.Sprintf("uistate.Patch(device=%s, addAlias=%s)", e.Device.ID, *e.Device.AddAlias) + case e.Device.RemoveAlias != nil: + return fmt.Sprintf("uistate.Patch(device=%s, removeAlias=%s)", e.Device.ID, *e.Device.RemoveAlias) + case e.Device.ActiveColorRGB != nil: + col := color.Color{RGB: e.Device.ActiveColorRGB} + return fmt.Sprintf("uistate.Patch(device=%s, activeColorRgb=%s)", e.Device.ID, col.String()) default: - return fmt.Sprintf("uistate.Patch(device=%s)", e.Device.ID) + return fmt.Sprintf("uistate.Patch(device=%s, ...other)", e.Device.ID) } } else if e.Assignment != nil { return fmt.Sprintf("uistate.Patch(assignment=%s)", e.Assignment.ID) @@ -40,8 +57,9 @@ type DevicePatch struct { RemoveAlias *string `json:"removeAlias,omitempty"` Assignment *uuid.UUID `json:"assignment,omitempty"` ClearAssignment bool `json:"clearAssignment,omitempty"` - ActiveColorRGB *color.RGB `json:"activeColorRgb"` - ClearActiveColorRGB bool `json:"clearActiveColorRGB"` + ActiveColorRGB *color.RGB `json:"activeColorRgb,omitempty"` + ClearActiveColorRGB bool `json:"clearActiveColorRGB,omitempty"` + Sensors *DeviceSensors `json:"sensors,omitempty"` Delete bool `json:"delete,omitempty"` } diff --git a/services/uistate/service.go b/services/uistate/service.go index 2fb75ac..ca5ec89 100644 --- a/services/uistate/service.go +++ b/services/uistate/service.go @@ -80,6 +80,16 @@ func (s *service) HandleEvent(bus *lucifer3.EventBus, event lucifer3.Event) { ID: event.ID, Delete: true, }}} + case events.MotionSensed: + patches = []Patch{{Device: &DevicePatch{ + ID: event.ID, + Sensors: &DeviceSensors{LastMotion: gentools.Ptr(event.SecondsSince)}, + }}} + case events.TemperatureChanged: + patches = []Patch{{Device: &DevicePatch{ + ID: event.ID, + Sensors: &DeviceSensors{Temperature: gentools.Ptr(event.Temperature)}, + }}} case events.DeviceAssigned: // Un-assign from current assignment (if any) if d, ok := s.data.Devices[event.DeviceID]; ok && d.Assignment != nil {