13 const publishTimeout = 5 * time.Second
15 // Publisher sends typed device messages and owns retained-value suppression.
16 // Its methods are safe for concurrent use. Run owns repeats and Refresh
17 // requests reconnect snapshots. The caller supplies a valid transport and runs
18 // this publisher once alongside the integration drivers.
19 type Publisher struct {
22 retained map[protocol.Address]*retainedValue
26 // NewPublisher creates a publisher with no cached state. The caller retains
27 // ownership of transport and must keep it available until Run stops.
28 func NewPublisher(transport Transport) *Publisher {
31 retained: make(map[protocol.Address]*retainedValue),
32 refresh: make(chan struct{}, 1),
36 // Run repeats measurements and online evidence at the contract interval. A send
37 // failure returns to the runner. Observation times stay unchanged and repeated
38 // messages have fresh IDs without command correlation or causation.
39 func (p *Publisher) Run(ctx context.Context) error {
40 ticker := time.NewTicker(250 * time.Millisecond)
54 err := p.repeat(ctx, force)
56 return fmt.Errorf("repeat snapshots: %w", err)
61 // Refresh coalesces a nonblocking request to resend all cached snapshots.
62 func (p *Publisher) Refresh() {
64 case p.refresh <- struct{}{}:
69 // Descriptor sends retained metadata unless its value equals the last success.
70 // Missing message ID and observation time are filled by the publisher. A
71 // supplied correlation or causation always bypasses value suppression.
72 func (p *Publisher) Descriptor(ctx context.Context, deviceID string, metadata protocol.Metadata, payload protocol.DescriptorPayload) error {
73 return publishTyped(ctx, p, protocol.Address{
74 Kind: protocol.Descriptor,
79 // Availability sends reachability and repeats online evidence during Run.
80 // Metadata defaults and command correlation follow Descriptor.
81 func (p *Publisher) Availability(ctx context.Context, deviceID string, metadata protocol.Metadata, payload protocol.AvailabilityPayload) error {
82 return publishTyped(ctx, p, protocol.Address{
83 Kind: protocol.Availability,
88 // Reported sends a complete retained value. Non-Boolean values repeat during
89 // Run. A Boolean point also repeats after Telemetry marks it as a measurement.
90 // Metadata defaults and command correlation follow Descriptor.
91 func (p *Publisher) Reported(ctx context.Context, deviceID string, capability string, metadata protocol.Metadata, payload protocol.ValuePayload) error {
92 return publishTyped(ctx, p, protocol.Address{
93 Kind: protocol.Reported,
99 // Telemetry sends changed measurements at QoS 0 without retention. The latest
100 // observation supplies the retained reported snapshot on repeat or reconnect,
101 // not on every telemetry change. Equal samples are suppressed until repeat.
102 // Metadata defaults and command correlation follow Descriptor.
103 func (p *Publisher) Telemetry(ctx context.Context, deviceID string, capability string, metadata protocol.Metadata, payload protocol.ValuePayload) error {
104 return publishTyped(ctx, p, protocol.Address{
105 Kind: protocol.Telemetry,
108 }, metadata, payload)
111 // Event sends each occurrence without suppression. Metadata defaults follow
112 // Descriptor. The caller owns event-specific observation and safety rules.
113 func (p *Publisher) Event(ctx context.Context, deviceID string, event string, metadata protocol.Metadata, payload protocol.EventPayload) error {
114 return publishTyped(ctx, p, protocol.Address{
115 Kind: protocol.Event,
118 }, metadata, payload)
121 // Result sends an outcome without suppression. Correlation must equal the
122 // command ID. Reuse ledger metadata to replay the same result ID and time.
123 // Missing message ID and observation time follow Descriptor defaults.
124 func (p *Publisher) Result(ctx context.Context, deviceID string, commandID string, metadata protocol.Metadata, payload protocol.ResultPayload) error {
125 return publishTyped(ctx, p, protocol.Address{
126 Kind: protocol.Result,
129 }, metadata, payload)
132 func (p *Publisher) publish(ctx context.Context, value publicationValue) error {
138 if value.metadata.ExpiresAt != nil && !time.Now().Before(*value.metadata.ExpiresAt) {
139 return fmt.Errorf("publication expired")
142 publication := value.publication
144 if !publication.Retain && value.address.Kind != protocol.Telemetry || value.metadata.ExpiresAt != nil {
145 err = p.send(ctx, publication)
147 return fmt.Errorf("send direct publication: %w", err)
154 if key.Kind == protocol.Telemetry {
155 key.Kind = protocol.Reported
160 entry := p.retained[key]
162 entry = &retainedValue{retainedAt: time.Now()}
163 p.retained[key] = entry
168 defer entry.mu.Unlock()
171 entry.measurement = entry.measurement || value.measurement
173 equal := bytes.Equal(entry.successful, value.comparison)
174 correlated := value.metadata.CorrelationID != "" || value.metadata.CausationID != ""
175 due := entry.measurement && !now.Before(entry.retainedAt.Add(protocol.MeasurementRepeatInterval)) || value.online && !now.Before(entry.publishedAt.Add(protocol.OnlineRepeatInterval))
176 if equal && !correlated && !due {
180 if entry.measurement && equal && due && !correlated {
181 value, err = value.repeated()
183 return fmt.Errorf("prepare reported repeat: %w", err)
186 publication = value.publication
189 err = p.send(ctx, publication)
191 return fmt.Errorf("send publication: %w", err)
194 entry.successful = value.comparison
195 entry.publishedAt = now
196 if publication.Retain {
197 entry.retainedAt = now
203 func (p *Publisher) send(ctx context.Context, publication protocol.Publication) error {
209 if publication.ExpiresAt != nil && !time.Now().Before(*publication.ExpiresAt) {
210 return fmt.Errorf("publication expired")
213 ctx, cancel := context.WithTimeout(ctx, publishTimeout)
216 err = p.transport.Publish(ctx, publication)
218 return fmt.Errorf("send transport publication: %w", err)
224 func (p *Publisher) repeat(ctx context.Context, force bool) error {
227 entries := make([]*retainedValue, 0, len(p.retained))
229 for _, entry := range p.retained {
230 entries = append(entries, entry)
235 for _, entry := range entries {
236 err := p.repeatEntry(ctx, entry, force)
238 return fmt.Errorf("repeat retained value: %w", err)
245 func (p *Publisher) repeatEntry(ctx context.Context, entry *retainedValue, force bool) error {
247 defer entry.mu.Unlock()
250 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))
255 value, err := entry.latest.repeated()
257 return fmt.Errorf("prepare snapshot: %w", err)
260 err = p.send(ctx, value.publication)
262 return fmt.Errorf("send snapshot: %w", err)
265 entry.successful = value.comparison
266 entry.publishedAt = now
267 entry.retainedAt = now
272 func publishTyped[T any](ctx context.Context, publisher *Publisher, address protocol.Address, metadata protocol.Metadata, payload T) error {
273 value, err := newPublicationValue(address, metadata, payload)
275 return fmt.Errorf("prepare typed publication: %w", err)
278 err = publisher.publish(ctx, value)
280 return fmt.Errorf("publish %s: %w", address.Kind, err)