package store import ( "errors" "sync" "time" ) // ErrOutOfSequence is returned when a heartbeat timestamp is earlier than // the latest accepted heartbeat for that device. var ErrOutOfSequence = errors.New("heartbeat out of sequence") // heartbeatHistory tracks one device's heartbeat timeline. // Unexported: only Store methods expose behavior. type heartbeatHistory struct { mu sync.Mutex count int64 first time.Time last time.Time } func (h *heartbeatHistory) add(sentAt time.Time) error { h.mu.Lock() defer h.mu.Unlock() if h.first.IsZero() { h.first = sentAt h.last = sentAt h.count = 1 return nil } // Equal timestamps are allowed (matches C#: sentAt < LastHeartBeat only). if sentAt.Before(h.last) { return ErrOutOfSequence } h.last = sentAt h.count++ return nil } func (h *heartbeatHistory) uptime() float64 { h.mu.Lock() defer h.mu.Unlock() if h.first.IsZero() { return 0 } minutes := h.last.Sub(h.first).Minutes() // Single heartbeat (or identical timestamps): treat span as one minute. if minutes == 0 { minutes = 1 } return float64(h.count) / minutes * 100 } // historyFor returns the per-device history, creating it if needed. // The store-level lock only covers map insert/lookup; callers then use // the history's own lock for mutations/reads. func (s *Store) historyFor(id DeviceID) *heartbeatHistory { s.mu.Lock() defer s.mu.Unlock() h, ok := s.heartbeats[id] if !ok { h = &heartbeatHistory{} s.heartbeats[id] = h } return h } // AddHeartbeat records a heartbeat for deviceID. // Returns ErrOutOfSequence if sentAt is earlier than the last accepted beat. func (s *Store) AddHeartbeat(deviceID DeviceID, sentAt time.Time) error { return s.historyFor(deviceID).add(sentAt) } // Uptime returns uptime percent for deviceID: // // (heartbeatCount / minutesBetweenFirstAndLast) * 100 // // Unknown / never-seen devices return 0. A zero-length span is treated as 1 minute. func (s *Store) Uptime(deviceID DeviceID) float64 { s.mu.Lock() h, ok := s.heartbeats[deviceID] s.mu.Unlock() if !ok { return 0 } return h.uptime() }