package driver import ( "bytes" "context" "fmt" "sync" "time" "onehouse/protocol" ) const publishTimeout = 5 * time.Second // Publisher sends typed device messages and owns retained-value suppression. // Its methods are safe for concurrent use. Run owns repeats and Refresh // requests reconnect snapshots. The caller supplies a valid transport and runs // this publisher once alongside the integration drivers. type Publisher struct { transport Transport mu sync.Mutex retained map[protocol.Address]*retainedValue refresh chan struct{} } // NewPublisher creates a publisher with no cached state. The caller retains // ownership of transport and must keep it available until Run stops. func NewPublisher(transport Transport) *Publisher { return &Publisher{ transport: transport, retained: make(map[protocol.Address]*retainedValue), refresh: make(chan struct{}, 1), } } // Run repeats measurements and online evidence at the contract interval. A send // failure returns to the runner. Observation times stay unchanged and repeated // messages have fresh IDs without command correlation or causation. func (p *Publisher) Run(ctx context.Context) error { ticker := time.NewTicker(250 * time.Millisecond) defer ticker.Stop() for { force := false select { case <-ctx.Done(): return ctx.Err() case <-p.refresh: force = true case <-ticker.C: } err := p.repeat(ctx, force) if err != nil { return fmt.Errorf("repeat snapshots: %w", err) } } } // Refresh coalesces a nonblocking request to resend all cached snapshots. func (p *Publisher) Refresh() { select { case p.refresh <- struct{}{}: default: } } // Descriptor sends retained metadata unless its value equals the last success. // Missing message ID and observation time are filled by the publisher. A // supplied correlation or causation always bypasses value suppression. func (p *Publisher) Descriptor(ctx context.Context, deviceID string, metadata protocol.Metadata, payload protocol.DescriptorPayload) error { return publishTyped(ctx, p, protocol.Address{ Kind: protocol.Descriptor, ID: deviceID, }, metadata, payload) } // Availability sends reachability and repeats online evidence during Run. // Metadata defaults and command correlation follow Descriptor. func (p *Publisher) Availability(ctx context.Context, deviceID string, metadata protocol.Metadata, payload protocol.AvailabilityPayload) error { return publishTyped(ctx, p, protocol.Address{ Kind: protocol.Availability, ID: deviceID, }, metadata, payload) } // Reported sends a complete retained value. Non-Boolean values repeat during // Run. A Boolean point also repeats after Telemetry marks it as a measurement. // Metadata defaults and command correlation follow Descriptor. func (p *Publisher) Reported(ctx context.Context, deviceID string, capability string, metadata protocol.Metadata, payload protocol.ValuePayload) error { return publishTyped(ctx, p, protocol.Address{ Kind: protocol.Reported, ID: deviceID, Target: capability, }, metadata, payload) } // Telemetry sends changed measurements at QoS 0 without retention. The latest // observation supplies the retained reported snapshot on repeat or reconnect, // not on every telemetry change. Equal samples are suppressed until repeat. // Metadata defaults and command correlation follow Descriptor. func (p *Publisher) Telemetry(ctx context.Context, deviceID string, capability string, metadata protocol.Metadata, payload protocol.ValuePayload) error { return publishTyped(ctx, p, protocol.Address{ Kind: protocol.Telemetry, ID: deviceID, Target: capability, }, metadata, payload) } // Event sends each occurrence without suppression. Metadata defaults follow // Descriptor. The caller owns event-specific observation and safety rules. func (p *Publisher) Event(ctx context.Context, deviceID string, event string, metadata protocol.Metadata, payload protocol.EventPayload) error { return publishTyped(ctx, p, protocol.Address{ Kind: protocol.Event, ID: deviceID, Target: event, }, metadata, payload) } // Result sends an outcome without suppression. Correlation must equal the // command ID. Reuse ledger metadata to replay the same result ID and time. // Missing message ID and observation time follow Descriptor defaults. func (p *Publisher) Result(ctx context.Context, deviceID string, commandID string, metadata protocol.Metadata, payload protocol.ResultPayload) error { return publishTyped(ctx, p, protocol.Address{ Kind: protocol.Result, ID: deviceID, Target: commandID, }, metadata, payload) } func (p *Publisher) publish(ctx context.Context, value publicationValue) error { err := ctx.Err() if err != nil { return err } if value.metadata.ExpiresAt != nil && !time.Now().Before(*value.metadata.ExpiresAt) { return fmt.Errorf("publication expired") } publication := value.publication if !publication.Retain && value.address.Kind != protocol.Telemetry || value.metadata.ExpiresAt != nil { err = p.send(ctx, publication) if err != nil { return fmt.Errorf("send direct publication: %w", err) } return nil } key := value.address if key.Kind == protocol.Telemetry { key.Kind = protocol.Reported } p.mu.Lock() entry := p.retained[key] if entry == nil { entry = &retainedValue{retainedAt: time.Now()} p.retained[key] = entry } p.mu.Unlock() entry.mu.Lock() defer entry.mu.Unlock() entry.latest = value entry.measurement = entry.measurement || value.measurement now := time.Now() equal := bytes.Equal(entry.successful, value.comparison) correlated := value.metadata.CorrelationID != "" || value.metadata.CausationID != "" due := entry.measurement && !now.Before(entry.retainedAt.Add(protocol.MeasurementRepeatInterval)) || value.online && !now.Before(entry.publishedAt.Add(protocol.OnlineRepeatInterval)) if equal && !correlated && !due { return nil } if entry.measurement && equal && due && !correlated { value, err = value.repeated() if err != nil { return fmt.Errorf("prepare reported repeat: %w", err) } publication = value.publication } err = p.send(ctx, publication) if err != nil { return fmt.Errorf("send publication: %w", err) } entry.successful = value.comparison entry.publishedAt = now if publication.Retain { entry.retainedAt = now } return nil } func (p *Publisher) send(ctx context.Context, publication protocol.Publication) error { err := ctx.Err() if err != nil { return err } if publication.ExpiresAt != nil && !time.Now().Before(*publication.ExpiresAt) { return fmt.Errorf("publication expired") } ctx, cancel := context.WithTimeout(ctx, publishTimeout) defer cancel() err = p.transport.Publish(ctx, publication) if err != nil { return fmt.Errorf("send transport publication: %w", err) } return nil } func (p *Publisher) repeat(ctx context.Context, force bool) error { p.mu.Lock() entries := make([]*retainedValue, 0, len(p.retained)) for _, entry := range p.retained { entries = append(entries, entry) } p.mu.Unlock() for _, entry := range entries { err := p.repeatEntry(ctx, entry, force) if err != nil { return fmt.Errorf("repeat retained value: %w", err) } } return nil } func (p *Publisher) repeatEntry(ctx context.Context, entry *retainedValue, force bool) error { entry.mu.Lock() defer entry.mu.Unlock() now := time.Now() due := entry.measurement && (!now.Before(entry.retainedAt.Add(protocol.MeasurementRepeatInterval)) || !bytes.Equal(entry.successful, entry.latest.comparison)) || entry.latest.online && !now.Before(entry.publishedAt.Add(protocol.OnlineRepeatInterval)) if !force && !due { return nil } value, err := entry.latest.repeated() if err != nil { return fmt.Errorf("prepare snapshot: %w", err) } err = p.send(ctx, value.publication) if err != nil { return fmt.Errorf("send snapshot: %w", err) } entry.successful = value.comparison entry.publishedAt = now entry.retainedAt = now return nil } func publishTyped[T any](ctx context.Context, publisher *Publisher, address protocol.Address, metadata protocol.Metadata, payload T) error { value, err := newPublicationValue(address, metadata, payload) if err != nil { return fmt.Errorf("prepare typed publication: %w", err) } err = publisher.publish(ctx, value) if err != nil { return fmt.Errorf("publish %s: %w", address.Kind, err) } return nil }