// Package shadow gives health-check-service read/offline-detection access // to the Device Shadow keyspace in Redis. device-control-service owns the // full Shadow API (desired/reported state patches, online-touch on // telemetry/ack) — this is deliberately the narrow subset a periodic // offline-scanner needs, kept as its own small copy rather than a shared // module: the two services only overlap on reading/writing `status` and // `last_seen`, and duplicating ~2 key-naming functions is cheaper than a // fourth shared Go module for that. package shadow import ( "context" "fmt" "strconv" "time" "github.com/redis/go-redis/v9" ) const knownDevicesKey = "devices:known" func statusKey(deviceID string) string { return "device:" + deviceID + ":status" } func lastSeenKey(deviceID string) string { return "device:" + deviceID + ":last_seen" } const ( StatusOnline = "online" StatusOffline = "offline" ) type Store struct { rdb *redis.Client } func New(rdb *redis.Client) *Store { return &Store{rdb: rdb} } func Connect(ctx context.Context, addr, password string, db int) (*Store, error) { rdb := redis.NewClient(&redis.Options{Addr: addr, Password: password, DB: db}) if err := rdb.Ping(ctx).Err(); err != nil { return nil, fmt.Errorf("ping redis: %w", err) } return New(rdb), nil } func (s *Store) Close() error { return s.rdb.Close() } // KnownDevices lists every device ID device-control-service has heard from. func (s *Store) KnownDevices(ctx context.Context) ([]string, error) { ids, err := s.rdb.SMembers(ctx, knownDevicesKey).Result() if err != nil { return nil, fmt.Errorf("list known devices: %w", err) } return ids, nil } // MarkOfflineIfStale flips deviceID to offline if it is currently online and // its last_seen is older than timeout. It reports whether a change was made. func (s *Store) MarkOfflineIfStale(ctx context.Context, deviceID string, timeout time.Duration) (changed bool, err error) { status, err := s.rdb.Get(ctx, statusKey(deviceID)).Result() if err == redis.Nil || status != StatusOnline { return false, nil } if err != nil { return false, fmt.Errorf("get status for %s: %w", deviceID, err) } lastSeenRaw, err := s.rdb.Get(ctx, lastSeenKey(deviceID)).Result() if err != nil && err != redis.Nil { return false, fmt.Errorf("get last_seen for %s: %w", deviceID, err) } var lastSeen time.Time if lastSeenRaw != "" { sec, err := strconv.ParseInt(lastSeenRaw, 10, 64) if err != nil { return false, fmt.Errorf("parse last_seen for %s: %w", deviceID, err) } lastSeen = time.Unix(sec, 0) } if time.Since(lastSeen) <= timeout { return false, nil } if err := s.rdb.Set(ctx, statusKey(deviceID), StatusOffline, 0).Err(); err != nil { return false, fmt.Errorf("set status offline for %s: %w", deviceID, err) } return true, nil }