15 // Driver serializes one assembly's polling and commands. Run has one owner and
16 // closes the supplied Client on each attempt. Recoverable device failures
17 // publish offline and retry with backoff. Publication or cleanup failures
18 // return to the framework runner. The shared publisher must run alongside this
19 // driver. Refresh is safe during Run. Duplicate protection ends at ledger
20 // eviction or process restart. No operation starts a physical connection before
26 pollInterval time.Duration
28 publisher *driver.Publisher
30 queue <-chan []driver.Command
34 // New validates settings and requires the device's queue in router. The caller
35 // supplies valid publisher, router, and client dependencies and starts the
36 // router's intake separately. The driver owns the client until Run returns.
37 func New(settings Settings, publisher *driver.Publisher, router *driver.Router, client Client) (*Driver, error) {
38 if !protocol.ValidID(settings.DeviceID) {
39 return nil, fmt.Errorf("device ID must be a canonical ULID")
42 host, port, err := net.SplitHostPort(settings.Address)
43 if err != nil || host == "" {
44 return nil, fmt.Errorf("address must contain a host and TCP port")
47 number, err := strconv.Atoi(port)
48 if err != nil || number < 1 || number > 65535 {
49 return nil, fmt.Errorf("TCP port is outside its range")
52 if settings.Unit == 0 || settings.Unit > 247 {
53 return nil, fmt.Errorf("unit must be from one through 247")
56 if settings.PollInterval < 10*time.Millisecond || settings.PollInterval > time.Minute {
57 return nil, fmt.Errorf("poll interval must be from ten milliseconds through one minute")
60 points, err := newTopology(settings.Modules)
62 return nil, fmt.Errorf("validate topology: %w", err)
65 queue, exists := router.Queue(settings.DeviceID)
67 return nil, fmt.Errorf("device has no command queue")
70 ledger, err := driver.NewLedger(settings.DeviceID)
72 return nil, fmt.Errorf("create result ledger: %w", err)
76 deviceID: settings.DeviceID,
77 address: settings.Address,
79 pollInterval: settings.PollInterval,
88 // Run configures and verifies output modes before online or command work. A
89 // successful first read establishes a baseline without input events. Device
90 // failures retry after one second, doubling to thirty seconds until a complete
91 // baseline succeeds. Commands received during backoff fail without I/O.
92 func (d *Driver) Run(ctx context.Context) error {
93 err := d.publisher.Descriptor(ctx, d.deviceID, protocol.Metadata{}, d.topology.descriptor())
95 return fmt.Errorf("publish descriptor: %w", err)
101 healthy, retry, err := d.attempt(ctx)
102 if !retry || ctx.Err() != nil {
103 return fmt.Errorf("run assembly: %w", err)
106 err = d.publisher.Availability(ctx, d.deviceID, protocol.Metadata{}, protocol.AvailabilityPayload{Status: "offline"})
108 return fmt.Errorf("publish offline: %w", err)
115 err = d.wait(ctx, delay)
117 return fmt.Errorf("wait for reconnect: %w", err)
120 delay = min(delay*2, 30*time.Second)
124 // Refresh requests retained snapshots through the shared publisher. It does not
125 // repeat input events, physical commands, or pulse operations.
126 func (d *Driver) Refresh() {
127 d.publisher.Refresh()
130 func (d *Driver) attempt(ctx context.Context) (healthy bool, retry bool, err error) {
132 var failure *deviceError
134 retry = errors.As(err, &failure)
136 closeErr := d.client.Close()
139 err = fmt.Errorf("close assembly: %w", errors.Join(err, closeErr))
143 err = d.client.Connect(ctx, d.address, d.unit)
145 return false, false, &deviceError{err: fmt.Errorf("connect assembly: %w", err)}
148 connection := &connection{owner: d}
149 err = connection.start(ctx)
151 return false, false, fmt.Errorf("initialize assembly: %w", err)
155 handler := &commandHandler{connection: connection}
156 ticker := time.NewTicker(d.pollInterval)
162 return healthy, false, ctx.Err()
163 case commands := <-d.queue:
164 err = handler.handle(ctx, commands)
166 return healthy, false, fmt.Errorf("process commands: %w", err)
169 err = connection.poll(ctx)
171 return healthy, false, fmt.Errorf("poll assembly: %w", err)
177 func (d *Driver) wait(ctx context.Context, delay time.Duration) error {
178 timer := time.NewTimer(delay)
181 handler := &commandHandler{connection: &connection{owner: d}}
189 case commands := <-d.queue:
190 err := handler.unavailable(ctx, commands)
192 return fmt.Errorf("finish unavailable commands: %w", err)