dredger-iot 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
 - data/CHANGELOG.md +33 -0
 - data/LICENSE.txt +21 -0
 - data/README.md +521 -0
 - data/lib/dredger/iot/bus/auto.rb +87 -0
 - data/lib/dredger/iot/bus/gpio.rb +59 -0
 - data/lib/dredger/iot/bus/gpio_label_adapter.rb +41 -0
 - data/lib/dredger/iot/bus/gpio_libgpiod.rb +63 -0
 - data/lib/dredger/iot/bus/i2c.rb +55 -0
 - data/lib/dredger/iot/bus/i2c_linux.rb +52 -0
 - data/lib/dredger/iot/bus.rb +5 -0
 - data/lib/dredger/iot/pins/beaglebone.rb +56 -0
 - data/lib/dredger/iot/pins.rb +3 -0
 - data/lib/dredger/iot/reading.rb +28 -0
 - data/lib/dredger/iot/scheduler.rb +43 -0
 - data/lib/dredger/iot/sensors/base_sensor.rb +31 -0
 - data/lib/dredger/iot/sensors/bme280.rb +26 -0
 - data/lib/dredger/iot/sensors/bme280_provider.rb +157 -0
 - data/lib/dredger/iot/sensors/bmp180.rb +26 -0
 - data/lib/dredger/iot/sensors/dht22.rb +26 -0
 - data/lib/dredger/iot/sensors/dht22_provider.rb +79 -0
 - data/lib/dredger/iot/sensors/ds18b20.rb +24 -0
 - data/lib/dredger/iot/sensors/ds18b20_provider.rb +55 -0
 - data/lib/dredger/iot/sensors/mcp9808.rb +23 -0
 - data/lib/dredger/iot/sensors.rb +11 -0
 - data/lib/dredger/iot/version.rb +7 -0
 - data/lib/dredger/iot.rb +14 -0
 - metadata +112 -0
 
| 
         @@ -0,0 +1,59 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # frozen_string_literal: true
         
     | 
| 
      
 2 
     | 
    
         
            +
             
     | 
| 
      
 3 
     | 
    
         
            +
            module Dredger
         
     | 
| 
      
 4 
     | 
    
         
            +
              module IoT
         
     | 
| 
      
 5 
     | 
    
         
            +
                module Bus
         
     | 
| 
      
 6 
     | 
    
         
            +
                  # Simple GPIO bus with pluggable backend. Defaults to Simulation.
         
     | 
| 
      
 7 
     | 
    
         
            +
                  class GPIO
         
     | 
| 
      
 8 
     | 
    
         
            +
                    def initialize(backend: nil)
         
     | 
| 
      
 9 
     | 
    
         
            +
                      @backend = backend || Simulation.new
         
     | 
| 
      
 10 
     | 
    
         
            +
                    end
         
     | 
| 
      
 11 
     | 
    
         
            +
             
     | 
| 
      
 12 
     | 
    
         
            +
                    def set_direction(pin_label, direction)
         
     | 
| 
      
 13 
     | 
    
         
            +
                      @backend.set_direction(pin_label, direction)
         
     | 
| 
      
 14 
     | 
    
         
            +
                    end
         
     | 
| 
      
 15 
     | 
    
         
            +
             
     | 
| 
      
 16 
     | 
    
         
            +
                    def write(pin_label, value)
         
     | 
| 
      
 17 
     | 
    
         
            +
                      @backend.write(pin_label, value)
         
     | 
| 
      
 18 
     | 
    
         
            +
                    end
         
     | 
| 
      
 19 
     | 
    
         
            +
             
     | 
| 
      
 20 
     | 
    
         
            +
                    def read(pin_label)
         
     | 
| 
      
 21 
     | 
    
         
            +
                      @backend.read(pin_label)
         
     | 
| 
      
 22 
     | 
    
         
            +
                    end
         
     | 
| 
      
 23 
     | 
    
         
            +
             
     | 
| 
      
 24 
     | 
    
         
            +
                    # Simulation backend for tests/development
         
     | 
| 
      
 25 
     | 
    
         
            +
                    class Simulation
         
     | 
| 
      
 26 
     | 
    
         
            +
                      def initialize
         
     | 
| 
      
 27 
     | 
    
         
            +
                        @values = Hash.new(0)
         
     | 
| 
      
 28 
     | 
    
         
            +
                        @directions = {}
         
     | 
| 
      
 29 
     | 
    
         
            +
                      end
         
     | 
| 
      
 30 
     | 
    
         
            +
             
     | 
| 
      
 31 
     | 
    
         
            +
                      def set_direction(pin_label, direction)
         
     | 
| 
      
 32 
     | 
    
         
            +
                        raise ArgumentError, 'direction must be :in or :out' unless %i[in out].include?(direction)
         
     | 
| 
      
 33 
     | 
    
         
            +
             
     | 
| 
      
 34 
     | 
    
         
            +
                        @directions[pin_label] = direction
         
     | 
| 
      
 35 
     | 
    
         
            +
                      end
         
     | 
| 
      
 36 
     | 
    
         
            +
             
     | 
| 
      
 37 
     | 
    
         
            +
                      def write(pin_label, value)
         
     | 
| 
      
 38 
     | 
    
         
            +
                        raise ArgumentError, 'value must be 0 or 1' unless [0, 1, true, false].include?(value)
         
     | 
| 
      
 39 
     | 
    
         
            +
                        raise 'pin not configured for :out' unless @directions[pin_label] == :out
         
     | 
| 
      
 40 
     | 
    
         
            +
             
     | 
| 
      
 41 
     | 
    
         
            +
                        @values[pin_label] = [1, true].include?(value) ? 1 : 0
         
     | 
| 
      
 42 
     | 
    
         
            +
                      end
         
     | 
| 
      
 43 
     | 
    
         
            +
             
     | 
| 
      
 44 
     | 
    
         
            +
                      def read(pin_label)
         
     | 
| 
      
 45 
     | 
    
         
            +
                        # If configured as input, just return last seen value (or default 0)
         
     | 
| 
      
 46 
     | 
    
         
            +
                        @values[pin_label]
         
     | 
| 
      
 47 
     | 
    
         
            +
                      end
         
     | 
| 
      
 48 
     | 
    
         
            +
             
     | 
| 
      
 49 
     | 
    
         
            +
                      # Helpers for test injection
         
     | 
| 
      
 50 
     | 
    
         
            +
                      def inject_input(pin_label, value)
         
     | 
| 
      
 51 
     | 
    
         
            +
                        raise ArgumentError, 'value must be 0 or 1' unless [0, 1, true, false].include?(value)
         
     | 
| 
      
 52 
     | 
    
         
            +
             
     | 
| 
      
 53 
     | 
    
         
            +
                        @values[pin_label] = [1, true].include?(value) ? 1 : 0
         
     | 
| 
      
 54 
     | 
    
         
            +
                      end
         
     | 
| 
      
 55 
     | 
    
         
            +
                    end
         
     | 
| 
      
 56 
     | 
    
         
            +
                  end
         
     | 
| 
      
 57 
     | 
    
         
            +
                end
         
     | 
| 
      
 58 
     | 
    
         
            +
              end
         
     | 
| 
      
 59 
     | 
    
         
            +
            end
         
     | 
| 
         @@ -0,0 +1,41 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # frozen_string_literal: true
         
     | 
| 
      
 2 
     | 
    
         
            +
             
     | 
| 
      
 3 
     | 
    
         
            +
            module Dredger
         
     | 
| 
      
 4 
     | 
    
         
            +
              module IoT
         
     | 
| 
      
 5 
     | 
    
         
            +
                module Bus
         
     | 
| 
      
 6 
     | 
    
         
            +
                  # Adapts label strings (e.g. 'P9_12') to PinRef with chip:line for libgpiod backends
         
     | 
| 
      
 7 
     | 
    
         
            +
                  class GPIOLabelAdapter
         
     | 
| 
      
 8 
     | 
    
         
            +
                    def initialize(backend:, mapper: Dredger::IoT::Pins::Beaglebone)
         
     | 
| 
      
 9 
     | 
    
         
            +
                      @backend = backend
         
     | 
| 
      
 10 
     | 
    
         
            +
                      @mapper = mapper
         
     | 
| 
      
 11 
     | 
    
         
            +
                    end
         
     | 
| 
      
 12 
     | 
    
         
            +
             
     | 
| 
      
 13 
     | 
    
         
            +
                    def set_direction(pin, direction)
         
     | 
| 
      
 14 
     | 
    
         
            +
                      @backend.set_direction(resolve(pin), direction)
         
     | 
| 
      
 15 
     | 
    
         
            +
                    end
         
     | 
| 
      
 16 
     | 
    
         
            +
             
     | 
| 
      
 17 
     | 
    
         
            +
                    def write(pin, value)
         
     | 
| 
      
 18 
     | 
    
         
            +
                      @backend.write(resolve(pin), value)
         
     | 
| 
      
 19 
     | 
    
         
            +
                    end
         
     | 
| 
      
 20 
     | 
    
         
            +
             
     | 
| 
      
 21 
     | 
    
         
            +
                    def read(pin)
         
     | 
| 
      
 22 
     | 
    
         
            +
                      @backend.read(resolve(pin))
         
     | 
| 
      
 23 
     | 
    
         
            +
                    end
         
     | 
| 
      
 24 
     | 
    
         
            +
             
     | 
| 
      
 25 
     | 
    
         
            +
                    private
         
     | 
| 
      
 26 
     | 
    
         
            +
             
     | 
| 
      
 27 
     | 
    
         
            +
                    def resolve(pin)
         
     | 
| 
      
 28 
     | 
    
         
            +
                      # Already a PinRef with line
         
     | 
| 
      
 29 
     | 
    
         
            +
                      return pin if pin.respond_to?(:line) && !pin.line.nil?
         
     | 
| 
      
 30 
     | 
    
         
            +
                      # Numeric line
         
     | 
| 
      
 31 
     | 
    
         
            +
                      return Integer(pin) if pin.is_a?(Integer) || pin.to_s =~ /^\d+$/
         
     | 
| 
      
 32 
     | 
    
         
            +
                      # Beaglebone-style label
         
     | 
| 
      
 33 
     | 
    
         
            +
                      return @mapper.resolve_label_to_pinref(pin) if @mapper.respond_to?(:resolve_label_to_pinref)
         
     | 
| 
      
 34 
     | 
    
         
            +
             
     | 
| 
      
 35 
     | 
    
         
            +
                      raise ArgumentError, 'Unsupported pin format'
         
     | 
| 
      
 36 
     | 
    
         
            +
                    end
         
     | 
| 
      
 37 
     | 
    
         
            +
                  end
         
     | 
| 
      
 38 
     | 
    
         
            +
                end
         
     | 
| 
      
 39 
     | 
    
         
            +
              end
         
     | 
| 
      
 40 
     | 
    
         
            +
            end
         
     | 
| 
      
 41 
     | 
    
         
            +
            # EOF
         
     | 
| 
         @@ -0,0 +1,63 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # frozen_string_literal: true
         
     | 
| 
      
 2 
     | 
    
         
            +
             
     | 
| 
      
 3 
     | 
    
         
            +
            require 'ffi'
         
     | 
| 
      
 4 
     | 
    
         
            +
             
     | 
| 
      
 5 
     | 
    
         
            +
            module Dredger
         
     | 
| 
      
 6 
     | 
    
         
            +
              module IoT
         
     | 
| 
      
 7 
     | 
    
         
            +
                module Bus
         
     | 
| 
      
 8 
     | 
    
         
            +
                  # GPIO backend using libgpiod ctxless helpers. Requires chip name and line offset.
         
     | 
| 
      
 9 
     | 
    
         
            +
                  class GpioLibgpiod
         
     | 
| 
      
 10 
     | 
    
         
            +
                    module Lib
         
     | 
| 
      
 11 
     | 
    
         
            +
                      extend FFI::Library
         
     | 
| 
      
 12 
     | 
    
         
            +
             
     | 
| 
      
 13 
     | 
    
         
            +
                      ffi_lib %w[gpiod libgpiod]
         
     | 
| 
      
 14 
     | 
    
         
            +
             
     | 
| 
      
 15 
     | 
    
         
            +
                      # int gpiod_ctxless_get_value(const char *device, unsigned int offset, bool active_low,
         
     | 
| 
      
 16 
     | 
    
         
            +
                      #                             const char *consumer);
         
     | 
| 
      
 17 
     | 
    
         
            +
                      attach_function :gpiod_ctxless_get_value, %i[string uint bool string], :int
         
     | 
| 
      
 18 
     | 
    
         
            +
             
     | 
| 
      
 19 
     | 
    
         
            +
                      # int gpiod_ctxless_set_value(const char *device, unsigned int offset, int value, bool active_low,
         
     | 
| 
      
 20 
     | 
    
         
            +
                      #                             const char *consumer);
         
     | 
| 
      
 21 
     | 
    
         
            +
                      attach_function :gpiod_ctxless_set_value, %i[string uint int bool string], :int
         
     | 
| 
      
 22 
     | 
    
         
            +
                    end
         
     | 
| 
      
 23 
     | 
    
         
            +
             
     | 
| 
      
 24 
     | 
    
         
            +
                    CONSUMER = 'dredger-iot'
         
     | 
| 
      
 25 
     | 
    
         
            +
             
     | 
| 
      
 26 
     | 
    
         
            +
                    def initialize(chip: 'gpiochip0', active_low: false)
         
     | 
| 
      
 27 
     | 
    
         
            +
                      @chip = chip
         
     | 
| 
      
 28 
     | 
    
         
            +
                      @active_low = !!active_low
         
     | 
| 
      
 29 
     | 
    
         
            +
                    end
         
     | 
| 
      
 30 
     | 
    
         
            +
             
     | 
| 
      
 31 
     | 
    
         
            +
                    # pin can be Integer line offset, or a PinRef with :line
         
     | 
| 
      
 32 
     | 
    
         
            +
                    def read(pin)
         
     | 
| 
      
 33 
     | 
    
         
            +
                      line = line_from(pin)
         
     | 
| 
      
 34 
     | 
    
         
            +
                      val = Lib.gpiod_ctxless_get_value(@chip, line, @active_low, CONSUMER)
         
     | 
| 
      
 35 
     | 
    
         
            +
                      raise IOError, 'gpiod get failed' if val.negative?
         
     | 
| 
      
 36 
     | 
    
         
            +
             
     | 
| 
      
 37 
     | 
    
         
            +
                      val
         
     | 
| 
      
 38 
     | 
    
         
            +
                    end
         
     | 
| 
      
 39 
     | 
    
         
            +
             
     | 
| 
      
 40 
     | 
    
         
            +
                    def write(pin, value)
         
     | 
| 
      
 41 
     | 
    
         
            +
                      line = line_from(pin)
         
     | 
| 
      
 42 
     | 
    
         
            +
                      int_val = [1, true].include?(value) ? 1 : 0
         
     | 
| 
      
 43 
     | 
    
         
            +
                      rc = Lib.gpiod_ctxless_set_value(@chip, line, int_val, @active_low, CONSUMER)
         
     | 
| 
      
 44 
     | 
    
         
            +
                      raise IOError, 'gpiod set failed' if rc.negative?
         
     | 
| 
      
 45 
     | 
    
         
            +
             
     | 
| 
      
 46 
     | 
    
         
            +
                      rc
         
     | 
| 
      
 47 
     | 
    
         
            +
                    end
         
     | 
| 
      
 48 
     | 
    
         
            +
             
     | 
| 
      
 49 
     | 
    
         
            +
                    # no-op; ctxless helpers configure as needed per call
         
     | 
| 
      
 50 
     | 
    
         
            +
                    def set_direction(_pin, _direction); end
         
     | 
| 
      
 51 
     | 
    
         
            +
             
     | 
| 
      
 52 
     | 
    
         
            +
                    private
         
     | 
| 
      
 53 
     | 
    
         
            +
             
     | 
| 
      
 54 
     | 
    
         
            +
                    def line_from(pin)
         
     | 
| 
      
 55 
     | 
    
         
            +
                      return pin.line if pin.respond_to?(:line) && !pin.line.nil?
         
     | 
| 
      
 56 
     | 
    
         
            +
                      return Integer(pin) if pin.is_a?(Integer) || pin.to_s =~ /^\d+$/
         
     | 
| 
      
 57 
     | 
    
         
            +
             
     | 
| 
      
 58 
     | 
    
         
            +
                      raise ArgumentError, 'pin must be Integer line offset or PinRef with line'
         
     | 
| 
      
 59 
     | 
    
         
            +
                    end
         
     | 
| 
      
 60 
     | 
    
         
            +
                  end
         
     | 
| 
      
 61 
     | 
    
         
            +
                end
         
     | 
| 
      
 62 
     | 
    
         
            +
              end
         
     | 
| 
      
 63 
     | 
    
         
            +
            end
         
     | 
| 
         @@ -0,0 +1,55 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # frozen_string_literal: true
         
     | 
| 
      
 2 
     | 
    
         
            +
             
     | 
| 
      
 3 
     | 
    
         
            +
            module Dredger
         
     | 
| 
      
 4 
     | 
    
         
            +
              module IoT
         
     | 
| 
      
 5 
     | 
    
         
            +
                module Bus
         
     | 
| 
      
 6 
     | 
    
         
            +
                  # Minimal I2C abstraction. Defaults to Simulation backend.
         
     | 
| 
      
 7 
     | 
    
         
            +
                  class I2C
         
     | 
| 
      
 8 
     | 
    
         
            +
                    def initialize(backend: nil)
         
     | 
| 
      
 9 
     | 
    
         
            +
                      @backend = backend || Simulation.new
         
     | 
| 
      
 10 
     | 
    
         
            +
                    end
         
     | 
| 
      
 11 
     | 
    
         
            +
             
     | 
| 
      
 12 
     | 
    
         
            +
                    # Write bytes to device address starting at optional register
         
     | 
| 
      
 13 
     | 
    
         
            +
                    def write(addr, bytes, register: nil)
         
     | 
| 
      
 14 
     | 
    
         
            +
                      @backend.write(addr, bytes, register: register)
         
     | 
| 
      
 15 
     | 
    
         
            +
                    end
         
     | 
| 
      
 16 
     | 
    
         
            +
             
     | 
| 
      
 17 
     | 
    
         
            +
                    # Read length bytes from device address optionally starting at register
         
     | 
| 
      
 18 
     | 
    
         
            +
                    def read(addr, length, register: nil)
         
     | 
| 
      
 19 
     | 
    
         
            +
                      @backend.read(addr, length, register: register)
         
     | 
| 
      
 20 
     | 
    
         
            +
                    end
         
     | 
| 
      
 21 
     | 
    
         
            +
             
     | 
| 
      
 22 
     | 
    
         
            +
                    # Simulation backend keeps a per-address register map
         
     | 
| 
      
 23 
     | 
    
         
            +
                    class Simulation
         
     | 
| 
      
 24 
     | 
    
         
            +
                      def initialize
         
     | 
| 
      
 25 
     | 
    
         
            +
                        @devices = Hash.new { |h, k| h[k] = Hash.new(0) }
         
     | 
| 
      
 26 
     | 
    
         
            +
                      end
         
     | 
| 
      
 27 
     | 
    
         
            +
             
     | 
| 
      
 28 
     | 
    
         
            +
                      def write(addr, bytes, register: nil)
         
     | 
| 
      
 29 
     | 
    
         
            +
                        raise ArgumentError, 'bytes must be an Array of integers' unless bytes.is_a?(Array) && bytes.all?(Integer)
         
     | 
| 
      
 30 
     | 
    
         
            +
             
     | 
| 
      
 31 
     | 
    
         
            +
                        if register.nil?
         
     | 
| 
      
 32 
     | 
    
         
            +
                          # Treat as sequential write starting at register 0
         
     | 
| 
      
 33 
     | 
    
         
            +
                          bytes.each_with_index { |b, i| @devices[addr][i] = b & 0xFF }
         
     | 
| 
      
 34 
     | 
    
         
            +
                        else
         
     | 
| 
      
 35 
     | 
    
         
            +
                          bytes.each_with_index { |b, i| @devices[addr][register + i] = b & 0xFF }
         
     | 
| 
      
 36 
     | 
    
         
            +
                        end
         
     | 
| 
      
 37 
     | 
    
         
            +
                        bytes.length
         
     | 
| 
      
 38 
     | 
    
         
            +
                      end
         
     | 
| 
      
 39 
     | 
    
         
            +
             
     | 
| 
      
 40 
     | 
    
         
            +
                      def read(addr, length, register: nil)
         
     | 
| 
      
 41 
     | 
    
         
            +
                        raise ArgumentError, 'length must be positive' unless length.positive?
         
     | 
| 
      
 42 
     | 
    
         
            +
             
     | 
| 
      
 43 
     | 
    
         
            +
                        start = register || 0
         
     | 
| 
      
 44 
     | 
    
         
            +
                        (0...length).map { |i| @devices[addr][start + i] & 0xFF }
         
     | 
| 
      
 45 
     | 
    
         
            +
                      end
         
     | 
| 
      
 46 
     | 
    
         
            +
             
     | 
| 
      
 47 
     | 
    
         
            +
                      # For tests to seed device registers
         
     | 
| 
      
 48 
     | 
    
         
            +
                      def seed(addr, register, bytes)
         
     | 
| 
      
 49 
     | 
    
         
            +
                        bytes.each_with_index { |b, i| @devices[addr][register + i] = b & 0xFF }
         
     | 
| 
      
 50 
     | 
    
         
            +
                      end
         
     | 
| 
      
 51 
     | 
    
         
            +
                    end
         
     | 
| 
      
 52 
     | 
    
         
            +
                  end
         
     | 
| 
      
 53 
     | 
    
         
            +
                end
         
     | 
| 
      
 54 
     | 
    
         
            +
              end
         
     | 
| 
      
 55 
     | 
    
         
            +
            end
         
     | 
| 
         @@ -0,0 +1,52 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # frozen_string_literal: true
         
     | 
| 
      
 2 
     | 
    
         
            +
             
     | 
| 
      
 3 
     | 
    
         
            +
            require 'ffi'
         
     | 
| 
      
 4 
     | 
    
         
            +
             
     | 
| 
      
 5 
     | 
    
         
            +
            module Dredger
         
     | 
| 
      
 6 
     | 
    
         
            +
              module IoT
         
     | 
| 
      
 7 
     | 
    
         
            +
                module Bus
         
     | 
| 
      
 8 
     | 
    
         
            +
                  # Linux I2C backend using i2c-dev via ioctl
         
     | 
| 
      
 9 
     | 
    
         
            +
                  class I2cLinux
         
     | 
| 
      
 10 
     | 
    
         
            +
                    module LibC
         
     | 
| 
      
 11 
     | 
    
         
            +
                      extend FFI::Library
         
     | 
| 
      
 12 
     | 
    
         
            +
             
     | 
| 
      
 13 
     | 
    
         
            +
                      ffi_lib FFI::Library::LIBC
         
     | 
| 
      
 14 
     | 
    
         
            +
                      attach_function :ioctl, %i[int ulong ulong], :int
         
     | 
| 
      
 15 
     | 
    
         
            +
                    end
         
     | 
| 
      
 16 
     | 
    
         
            +
             
     | 
| 
      
 17 
     | 
    
         
            +
                    I2C_SLAVE = 0x0703
         
     | 
| 
      
 18 
     | 
    
         
            +
             
     | 
| 
      
 19 
     | 
    
         
            +
                    def initialize(bus_path: '/dev/i2c-1')
         
     | 
| 
      
 20 
     | 
    
         
            +
                      @bus_path = bus_path
         
     | 
| 
      
 21 
     | 
    
         
            +
                    end
         
     | 
| 
      
 22 
     | 
    
         
            +
             
     | 
| 
      
 23 
     | 
    
         
            +
                    def write(addr, bytes, register: nil)
         
     | 
| 
      
 24 
     | 
    
         
            +
                      raise ArgumentError, 'bytes must be an Array of integers' unless bytes.is_a?(Array) && bytes.all?(Integer)
         
     | 
| 
      
 25 
     | 
    
         
            +
             
     | 
| 
      
 26 
     | 
    
         
            +
                      File.open(@bus_path, 'r+b') do |f|
         
     | 
| 
      
 27 
     | 
    
         
            +
                        set_slave(f, addr)
         
     | 
| 
      
 28 
     | 
    
         
            +
                        data = register.nil? ? bytes : [register] + bytes
         
     | 
| 
      
 29 
     | 
    
         
            +
                        f.write(data.pack('C*'))
         
     | 
| 
      
 30 
     | 
    
         
            +
                      end
         
     | 
| 
      
 31 
     | 
    
         
            +
                    end
         
     | 
| 
      
 32 
     | 
    
         
            +
             
     | 
| 
      
 33 
     | 
    
         
            +
                    def read(addr, length, register: nil)
         
     | 
| 
      
 34 
     | 
    
         
            +
                      raise ArgumentError, 'length must be positive' unless length.positive?
         
     | 
| 
      
 35 
     | 
    
         
            +
             
     | 
| 
      
 36 
     | 
    
         
            +
                      File.open(@bus_path, 'r+b') do |f|
         
     | 
| 
      
 37 
     | 
    
         
            +
                        set_slave(f, addr)
         
     | 
| 
      
 38 
     | 
    
         
            +
                        f.write([register].pack('C')) unless register.nil?
         
     | 
| 
      
 39 
     | 
    
         
            +
                        f.read(length).unpack('C*')
         
     | 
| 
      
 40 
     | 
    
         
            +
                      end
         
     | 
| 
      
 41 
     | 
    
         
            +
                    end
         
     | 
| 
      
 42 
     | 
    
         
            +
             
     | 
| 
      
 43 
     | 
    
         
            +
                    private
         
     | 
| 
      
 44 
     | 
    
         
            +
             
     | 
| 
      
 45 
     | 
    
         
            +
                    def set_slave(file, addr)
         
     | 
| 
      
 46 
     | 
    
         
            +
                      rc = LibC.ioctl(file.fileno, I2C_SLAVE, addr)
         
     | 
| 
      
 47 
     | 
    
         
            +
                      raise IOError, 'ioctl(I2C_SLAVE) failed' if rc.negative?
         
     | 
| 
      
 48 
     | 
    
         
            +
                    end
         
     | 
| 
      
 49 
     | 
    
         
            +
                  end
         
     | 
| 
      
 50 
     | 
    
         
            +
                end
         
     | 
| 
      
 51 
     | 
    
         
            +
              end
         
     | 
| 
      
 52 
     | 
    
         
            +
            end
         
     | 
| 
         @@ -0,0 +1,56 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # frozen_string_literal: true
         
     | 
| 
      
 2 
     | 
    
         
            +
             
     | 
| 
      
 3 
     | 
    
         
            +
            module Dredger
         
     | 
| 
      
 4 
     | 
    
         
            +
              module IoT
         
     | 
| 
      
 5 
     | 
    
         
            +
                module Pins
         
     | 
| 
      
 6 
     | 
    
         
            +
                  # Beaglebone header label mapping placeholder.
         
     | 
| 
      
 7 
     | 
    
         
            +
                  class Beaglebone
         
     | 
| 
      
 8 
     | 
    
         
            +
                    # Provides validation and PinRef objects.
         
     | 
| 
      
 9 
     | 
    
         
            +
                    # Chip:line resolution is done at runtime by the agent or a backend.
         
     | 
| 
      
 10 
     | 
    
         
            +
                    PinRef = Struct.new(:label, :chip, :line, keyword_init: true) do
         
     | 
| 
      
 11 
     | 
    
         
            +
                      def to_s
         
     | 
| 
      
 12 
     | 
    
         
            +
                        chip && line ? "#{label}(chip#{chip}:#{line})" : label.to_s
         
     | 
| 
      
 13 
     | 
    
         
            +
                      end
         
     | 
| 
      
 14 
     | 
    
         
            +
                    end
         
     | 
| 
      
 15 
     | 
    
         
            +
             
     | 
| 
      
 16 
     | 
    
         
            +
                    KNOWN_LABELS = (
         
     | 
| 
      
 17 
     | 
    
         
            +
                      (0..46).map { |n| "P8_#{n}" } + (0..46).map { |n| "P9_#{n}" }
         
     | 
| 
      
 18 
     | 
    
         
            +
                    ).freeze
         
     | 
| 
      
 19 
     | 
    
         
            +
             
     | 
| 
      
 20 
     | 
    
         
            +
                    # Minimal built-in mapping for common pins. This can be extended.
         
     | 
| 
      
 21 
     | 
    
         
            +
                    # Mapping format: 'P9_12' => [chip_number, line_offset]
         
     | 
| 
      
 22 
     | 
    
         
            +
                    MAP = {
         
     | 
| 
      
 23 
     | 
    
         
            +
                      'P9_12' => [1, 28],
         
     | 
| 
      
 24 
     | 
    
         
            +
                      'P9_14' => [1, 18]
         
     | 
| 
      
 25 
     | 
    
         
            +
                    }.freeze
         
     | 
| 
      
 26 
     | 
    
         
            +
             
     | 
| 
      
 27 
     | 
    
         
            +
                    def self.valid_label?(label)
         
     | 
| 
      
 28 
     | 
    
         
            +
                      KNOWN_LABELS.include?(label.to_s)
         
     | 
| 
      
 29 
     | 
    
         
            +
                    end
         
     | 
| 
      
 30 
     | 
    
         
            +
             
     | 
| 
      
 31 
     | 
    
         
            +
                    def self.resolve(label)
         
     | 
| 
      
 32 
     | 
    
         
            +
                      raise ArgumentError, "Unknown pin label: #{label}" unless valid_label?(label)
         
     | 
| 
      
 33 
     | 
    
         
            +
             
     | 
| 
      
 34 
     | 
    
         
            +
                      PinRef.new(label: label.to_s, chip: nil, line: nil)
         
     | 
| 
      
 35 
     | 
    
         
            +
                    end
         
     | 
| 
      
 36 
     | 
    
         
            +
             
     | 
| 
      
 37 
     | 
    
         
            +
                    # Returns [chip_name, line] or raises if unknown
         
     | 
| 
      
 38 
     | 
    
         
            +
                    def self.chip_line_for(label)
         
     | 
| 
      
 39 
     | 
    
         
            +
                      normalized = label.to_s.upcase
         
     | 
| 
      
 40 
     | 
    
         
            +
                      raise ArgumentError, "Unknown pin label: #{label}" unless valid_label?(normalized)
         
     | 
| 
      
 41 
     | 
    
         
            +
             
     | 
| 
      
 42 
     | 
    
         
            +
                      pair = MAP[normalized]
         
     | 
| 
      
 43 
     | 
    
         
            +
                      raise ArgumentError, "No mapping for label: #{label}" if pair.nil?
         
     | 
| 
      
 44 
     | 
    
         
            +
             
     | 
| 
      
 45 
     | 
    
         
            +
                      ["gpiochip#{pair[0]}", pair[1]]
         
     | 
| 
      
 46 
     | 
    
         
            +
                    end
         
     | 
| 
      
 47 
     | 
    
         
            +
             
     | 
| 
      
 48 
     | 
    
         
            +
                    # Returns PinRef with chip and line resolved (raises if unknown)
         
     | 
| 
      
 49 
     | 
    
         
            +
                    def self.resolve_label_to_pinref(label)
         
     | 
| 
      
 50 
     | 
    
         
            +
                      chip, line = chip_line_for(label)
         
     | 
| 
      
 51 
     | 
    
         
            +
                      PinRef.new(label: label.to_s, chip: chip.sub('gpiochip', '').to_i, line: line)
         
     | 
| 
      
 52 
     | 
    
         
            +
                    end
         
     | 
| 
      
 53 
     | 
    
         
            +
                  end
         
     | 
| 
      
 54 
     | 
    
         
            +
                end
         
     | 
| 
      
 55 
     | 
    
         
            +
              end
         
     | 
| 
      
 56 
     | 
    
         
            +
            end
         
     | 
| 
         @@ -0,0 +1,28 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # frozen_string_literal: true
         
     | 
| 
      
 2 
     | 
    
         
            +
             
     | 
| 
      
 3 
     | 
    
         
            +
            module Dredger
         
     | 
| 
      
 4 
     | 
    
         
            +
              module IoT
         
     | 
| 
      
 5 
     | 
    
         
            +
                # Immutable, normalized sensor reading
         
     | 
| 
      
 6 
     | 
    
         
            +
                Reading = Struct.new(
         
     | 
| 
      
 7 
     | 
    
         
            +
                  :sensor_type, :value, :unit, :recorded_at, :calibrated, :accuracy, :metadata,
         
     | 
| 
      
 8 
     | 
    
         
            +
                  keyword_init: true
         
     | 
| 
      
 9 
     | 
    
         
            +
                ) do
         
     | 
| 
      
 10 
     | 
    
         
            +
                  def initialize(**kwargs)
         
     | 
| 
      
 11 
     | 
    
         
            +
                    super
         
     | 
| 
      
 12 
     | 
    
         
            +
                    freeze
         
     | 
| 
      
 13 
     | 
    
         
            +
                  end
         
     | 
| 
      
 14 
     | 
    
         
            +
             
     | 
| 
      
 15 
     | 
    
         
            +
                  def to_h
         
     | 
| 
      
 16 
     | 
    
         
            +
                    {
         
     | 
| 
      
 17 
     | 
    
         
            +
                      sensor_type: sensor_type,
         
     | 
| 
      
 18 
     | 
    
         
            +
                      value: value,
         
     | 
| 
      
 19 
     | 
    
         
            +
                      unit: unit,
         
     | 
| 
      
 20 
     | 
    
         
            +
                      recorded_at: recorded_at,
         
     | 
| 
      
 21 
     | 
    
         
            +
                      calibrated: calibrated,
         
     | 
| 
      
 22 
     | 
    
         
            +
                      accuracy: accuracy,
         
     | 
| 
      
 23 
     | 
    
         
            +
                      metadata: metadata
         
     | 
| 
      
 24 
     | 
    
         
            +
                    }
         
     | 
| 
      
 25 
     | 
    
         
            +
                  end
         
     | 
| 
      
 26 
     | 
    
         
            +
                end
         
     | 
| 
      
 27 
     | 
    
         
            +
              end
         
     | 
| 
      
 28 
     | 
    
         
            +
            end
         
     | 
| 
         @@ -0,0 +1,43 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # frozen_string_literal: true
         
     | 
| 
      
 2 
     | 
    
         
            +
             
     | 
| 
      
 3 
     | 
    
         
            +
            module Dredger
         
     | 
| 
      
 4 
     | 
    
         
            +
              module IoT
         
     | 
| 
      
 5 
     | 
    
         
            +
                module Scheduler
         
     | 
| 
      
 6 
     | 
    
         
            +
                  module_function
         
     | 
| 
      
 7 
     | 
    
         
            +
             
     | 
| 
      
 8 
     | 
    
         
            +
                  # Returns an Enumerator yielding next sleep seconds with jitter each cycle
         
     | 
| 
      
 9 
     | 
    
         
            +
                  # base_interval: Float seconds
         
     | 
| 
      
 10 
     | 
    
         
            +
                  # jitter_ratio: 0.0..1.0 (fraction of base interval)
         
     | 
| 
      
 11 
     | 
    
         
            +
                  def periodic_with_jitter(base_interval:, jitter_ratio: 0.1)
         
     | 
| 
      
 12 
     | 
    
         
            +
                    raise ArgumentError, 'base_interval must be > 0' unless base_interval.positive?
         
     | 
| 
      
 13 
     | 
    
         
            +
                    raise ArgumentError, 'jitter_ratio must be between 0.0 and 1.0' unless jitter_ratio.between?(0.0, 1.0)
         
     | 
| 
      
 14 
     | 
    
         
            +
             
     | 
| 
      
 15 
     | 
    
         
            +
                    Enumerator.new do |y|
         
     | 
| 
      
 16 
     | 
    
         
            +
                      loop do
         
     | 
| 
      
 17 
     | 
    
         
            +
                        jitter = ((rand * 2) - 1) * (base_interval * jitter_ratio)
         
     | 
| 
      
 18 
     | 
    
         
            +
                        y << [base_interval + jitter, 0.0].max
         
     | 
| 
      
 19 
     | 
    
         
            +
                      end
         
     | 
| 
      
 20 
     | 
    
         
            +
                    end
         
     | 
| 
      
 21 
     | 
    
         
            +
                  end
         
     | 
| 
      
 22 
     | 
    
         
            +
             
     | 
| 
      
 23 
     | 
    
         
            +
                  # Exponential backoff enumerator with max backoff and max attempts (or infinite if nil)
         
     | 
| 
      
 24 
     | 
    
         
            +
                  def exponential_backoff(initial:, factor: 2.0, max: 60.0, attempts: nil)
         
     | 
| 
      
 25 
     | 
    
         
            +
                    raise ArgumentError, 'initial must be > 0' unless initial.positive?
         
     | 
| 
      
 26 
     | 
    
         
            +
                    raise ArgumentError, 'factor must be >= 1.0' unless factor >= 1.0
         
     | 
| 
      
 27 
     | 
    
         
            +
                    raise ArgumentError, 'max must be >= initial' unless max >= initial
         
     | 
| 
      
 28 
     | 
    
         
            +
             
     | 
| 
      
 29 
     | 
    
         
            +
                    Enumerator.new do |y|
         
     | 
| 
      
 30 
     | 
    
         
            +
                      i = 0
         
     | 
| 
      
 31 
     | 
    
         
            +
                      current = initial
         
     | 
| 
      
 32 
     | 
    
         
            +
                      loop do
         
     | 
| 
      
 33 
     | 
    
         
            +
                        break if attempts && i >= attempts
         
     | 
| 
      
 34 
     | 
    
         
            +
             
     | 
| 
      
 35 
     | 
    
         
            +
                        y << current
         
     | 
| 
      
 36 
     | 
    
         
            +
                        current = [current * factor, max].min
         
     | 
| 
      
 37 
     | 
    
         
            +
                        i += 1
         
     | 
| 
      
 38 
     | 
    
         
            +
                      end
         
     | 
| 
      
 39 
     | 
    
         
            +
                    end
         
     | 
| 
      
 40 
     | 
    
         
            +
                  end
         
     | 
| 
      
 41 
     | 
    
         
            +
                end
         
     | 
| 
      
 42 
     | 
    
         
            +
              end
         
     | 
| 
      
 43 
     | 
    
         
            +
            end
         
     | 
| 
         @@ -0,0 +1,31 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # frozen_string_literal: true
         
     | 
| 
      
 2 
     | 
    
         
            +
             
     | 
| 
      
 3 
     | 
    
         
            +
            module Dredger
         
     | 
| 
      
 4 
     | 
    
         
            +
              module IoT
         
     | 
| 
      
 5 
     | 
    
         
            +
                module Sensors
         
     | 
| 
      
 6 
     | 
    
         
            +
                  class BaseSensor
         
     | 
| 
      
 7 
     | 
    
         
            +
                    def initialize(metadata: {})
         
     | 
| 
      
 8 
     | 
    
         
            +
                      @metadata = metadata
         
     | 
| 
      
 9 
     | 
    
         
            +
                    end
         
     | 
| 
      
 10 
     | 
    
         
            +
             
     | 
| 
      
 11 
     | 
    
         
            +
                    def readings
         
     | 
| 
      
 12 
     | 
    
         
            +
                      raise NotImplementedError
         
     | 
| 
      
 13 
     | 
    
         
            +
                    end
         
     | 
| 
      
 14 
     | 
    
         
            +
             
     | 
| 
      
 15 
     | 
    
         
            +
                    private
         
     | 
| 
      
 16 
     | 
    
         
            +
             
     | 
| 
      
 17 
     | 
    
         
            +
                    def reading(sensor_type:, value:, unit:, **opts)
         
     | 
| 
      
 18 
     | 
    
         
            +
                      Dredger::IoT::Reading.new(
         
     | 
| 
      
 19 
     | 
    
         
            +
                        sensor_type: sensor_type,
         
     | 
| 
      
 20 
     | 
    
         
            +
                        value: value,
         
     | 
| 
      
 21 
     | 
    
         
            +
                        unit: unit,
         
     | 
| 
      
 22 
     | 
    
         
            +
                        recorded_at: opts.fetch(:recorded_at, Time.now.utc),
         
     | 
| 
      
 23 
     | 
    
         
            +
                        calibrated: opts.fetch(:calibrated, true),
         
     | 
| 
      
 24 
     | 
    
         
            +
                        accuracy: opts[:accuracy],
         
     | 
| 
      
 25 
     | 
    
         
            +
                        metadata: @metadata.merge(opts.fetch(:metadata, {}))
         
     | 
| 
      
 26 
     | 
    
         
            +
                      )
         
     | 
| 
      
 27 
     | 
    
         
            +
                    end
         
     | 
| 
      
 28 
     | 
    
         
            +
                  end
         
     | 
| 
      
 29 
     | 
    
         
            +
                end
         
     | 
| 
      
 30 
     | 
    
         
            +
              end
         
     | 
| 
      
 31 
     | 
    
         
            +
            end
         
     | 
| 
         @@ -0,0 +1,26 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # frozen_string_literal: true
         
     | 
| 
      
 2 
     | 
    
         
            +
             
     | 
| 
      
 3 
     | 
    
         
            +
            module Dredger
         
     | 
| 
      
 4 
     | 
    
         
            +
              module IoT
         
     | 
| 
      
 5 
     | 
    
         
            +
                module Sensors
         
     | 
| 
      
 6 
     | 
    
         
            +
                  # BME280 temperature/pressure/humidity over I2C
         
     | 
| 
      
 7 
     | 
    
         
            +
                  # Provider must respond to :read_measurements(addr) -> { temperature_c:, humidity:, pressure_kpa: }
         
     | 
| 
      
 8 
     | 
    
         
            +
                  class BME280 < BaseSensor
         
     | 
| 
      
 9 
     | 
    
         
            +
                    def initialize(i2c_addr:, provider:, metadata: {})
         
     | 
| 
      
 10 
     | 
    
         
            +
                      super(metadata: metadata)
         
     | 
| 
      
 11 
     | 
    
         
            +
                      @i2c_addr = i2c_addr
         
     | 
| 
      
 12 
     | 
    
         
            +
                      @provider = provider
         
     | 
| 
      
 13 
     | 
    
         
            +
                    end
         
     | 
| 
      
 14 
     | 
    
         
            +
             
     | 
| 
      
 15 
     | 
    
         
            +
                    def readings
         
     | 
| 
      
 16 
     | 
    
         
            +
                      m = @provider.read_measurements(@i2c_addr)
         
     | 
| 
      
 17 
     | 
    
         
            +
                      [
         
     | 
| 
      
 18 
     | 
    
         
            +
                        reading(sensor_type: 'temperature', value: m[:temperature_c], unit: 'celsius'),
         
     | 
| 
      
 19 
     | 
    
         
            +
                        reading(sensor_type: 'humidity', value: m[:humidity], unit: '%'),
         
     | 
| 
      
 20 
     | 
    
         
            +
                        reading(sensor_type: 'pressure', value: m[:pressure_kpa], unit: 'kPa')
         
     | 
| 
      
 21 
     | 
    
         
            +
                      ]
         
     | 
| 
      
 22 
     | 
    
         
            +
                    end
         
     | 
| 
      
 23 
     | 
    
         
            +
                  end
         
     | 
| 
      
 24 
     | 
    
         
            +
                end
         
     | 
| 
      
 25 
     | 
    
         
            +
              end
         
     | 
| 
      
 26 
     | 
    
         
            +
            end
         
     | 
| 
         @@ -0,0 +1,157 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # frozen_string_literal: true
         
     | 
| 
      
 2 
     | 
    
         
            +
             
     | 
| 
      
 3 
     | 
    
         
            +
            module Dredger
         
     | 
| 
      
 4 
     | 
    
         
            +
              module IoT
         
     | 
| 
      
 5 
     | 
    
         
            +
                module Sensors
         
     | 
| 
      
 6 
     | 
    
         
            +
                  # Hardware provider for BME280 temperature/humidity/pressure sensor over I2C.
         
     | 
| 
      
 7 
     | 
    
         
            +
                  # Datasheet: https://www.bosch-sensortec.com/media/boschsensortec/downloads/datasheets/bst-bme280-ds002.pdf
         
     | 
| 
      
 8 
     | 
    
         
            +
                  #
         
     | 
| 
      
 9 
     | 
    
         
            +
                  # Key registers:
         
     | 
| 
      
 10 
     | 
    
         
            +
                  # - 0xD0: chip_id (should be 0x60 for BME280)
         
     | 
| 
      
 11 
     | 
    
         
            +
                  # - 0xF4: ctrl_meas (mode, oversampling)
         
     | 
| 
      
 12 
     | 
    
         
            +
                  # - 0xF5: config (standby, filter)
         
     | 
| 
      
 13 
     | 
    
         
            +
                  # - 0xF7-0xFE: measurement data (pressure, temp, humidity)
         
     | 
| 
      
 14 
     | 
    
         
            +
                  # - 0x88-0xA1, 0xE1-0xE7: calibration coefficients
         
     | 
| 
      
 15 
     | 
    
         
            +
                  class BME280Provider
         
     | 
| 
      
 16 
     | 
    
         
            +
                    CHIP_ID_REG = 0xD0
         
     | 
| 
      
 17 
     | 
    
         
            +
                    CHIP_ID_EXPECTED = 0x60
         
     | 
| 
      
 18 
     | 
    
         
            +
                    CTRL_MEAS_REG = 0xF4
         
     | 
| 
      
 19 
     | 
    
         
            +
                    CONFIG_REG = 0xF5
         
     | 
| 
      
 20 
     | 
    
         
            +
                    DATA_REG = 0xF7
         
     | 
| 
      
 21 
     | 
    
         
            +
             
     | 
| 
      
 22 
     | 
    
         
            +
                    # Calibration coefficient registers
         
     | 
| 
      
 23 
     | 
    
         
            +
                    CALIB_00_REG = 0x88 # dig_T1..dig_H1 start
         
     | 
| 
      
 24 
     | 
    
         
            +
                    CALIB_26_REG = 0xE1 # dig_H2..dig_H6 start
         
     | 
| 
      
 25 
     | 
    
         
            +
             
     | 
| 
      
 26 
     | 
    
         
            +
                    # i2c_bus: an I2C bus interface (e.g., Dredger::IoT::Bus::Auto.i2c)
         
     | 
| 
      
 27 
     | 
    
         
            +
                    def initialize(i2c_bus:)
         
     | 
| 
      
 28 
     | 
    
         
            +
                      @i2c = i2c_bus
         
     | 
| 
      
 29 
     | 
    
         
            +
                    end
         
     | 
| 
      
 30 
     | 
    
         
            +
             
     | 
| 
      
 31 
     | 
    
         
            +
                    # Read measurements from the BME280 at the given I2C address.
         
     | 
| 
      
 32 
     | 
    
         
            +
                    # Returns { temperature_c: Float, humidity: Float, pressure_kpa: Float }
         
     | 
| 
      
 33 
     | 
    
         
            +
                    def read_measurements(addr)
         
     | 
| 
      
 34 
     | 
    
         
            +
                      # Verify chip ID
         
     | 
| 
      
 35 
     | 
    
         
            +
                      chip_id = @i2c.read(addr, 1, register: CHIP_ID_REG).first
         
     | 
| 
      
 36 
     | 
    
         
            +
                      raise IOError, "BME280 not found (chip_id=0x#{chip_id.to_s(16)})" unless chip_id == CHIP_ID_EXPECTED
         
     | 
| 
      
 37 
     | 
    
         
            +
             
     | 
| 
      
 38 
     | 
    
         
            +
                      # Read calibration coefficients (needed for compensating raw data)
         
     | 
| 
      
 39 
     | 
    
         
            +
                      calib = read_calibration(addr)
         
     | 
| 
      
 40 
     | 
    
         
            +
             
     | 
| 
      
 41 
     | 
    
         
            +
                      # Configure sensor: forced mode, oversampling x1 for all
         
     | 
| 
      
 42 
     | 
    
         
            +
                      # ctrl_meas: osrs_t[7:5]=001, osrs_p[4:2]=001, mode[1:0]=01 (forced)
         
     | 
| 
      
 43 
     | 
    
         
            +
                      @i2c.write(addr, [0b00100101], register: CTRL_MEAS_REG)
         
     | 
| 
      
 44 
     | 
    
         
            +
             
     | 
| 
      
 45 
     | 
    
         
            +
                      # Wait for measurement (typical 8ms for this config)
         
     | 
| 
      
 46 
     | 
    
         
            +
                      sleep(0.01)
         
     | 
| 
      
 47 
     | 
    
         
            +
             
     | 
| 
      
 48 
     | 
    
         
            +
                      # Read raw data: 8 bytes from 0xF7
         
     | 
| 
      
 49 
     | 
    
         
            +
                      raw = @i2c.read(addr, 8, register: DATA_REG)
         
     | 
| 
      
 50 
     | 
    
         
            +
             
     | 
| 
      
 51 
     | 
    
         
            +
                      # Parse raw values (20-bit pressure, 20-bit temp, 16-bit humidity)
         
     | 
| 
      
 52 
     | 
    
         
            +
                      press_raw = (raw[0] << 12) | (raw[1] << 4) | (raw[2] >> 4)
         
     | 
| 
      
 53 
     | 
    
         
            +
                      temp_raw = (raw[3] << 12) | (raw[4] << 4) | (raw[5] >> 4)
         
     | 
| 
      
 54 
     | 
    
         
            +
                      hum_raw = (raw[6] << 8) | raw[7]
         
     | 
| 
      
 55 
     | 
    
         
            +
             
     | 
| 
      
 56 
     | 
    
         
            +
                      # Compensate using calibration (simplified integer math from datasheet)
         
     | 
| 
      
 57 
     | 
    
         
            +
                      temp_c = compensate_temperature(temp_raw, calib)
         
     | 
| 
      
 58 
     | 
    
         
            +
                      humidity = compensate_humidity(hum_raw, calib, temp_c)
         
     | 
| 
      
 59 
     | 
    
         
            +
                      pressure_pa = compensate_pressure(press_raw, calib, temp_c)
         
     | 
| 
      
 60 
     | 
    
         
            +
             
     | 
| 
      
 61 
     | 
    
         
            +
                      {
         
     | 
| 
      
 62 
     | 
    
         
            +
                        temperature_c: temp_c,
         
     | 
| 
      
 63 
     | 
    
         
            +
                        humidity: humidity,
         
     | 
| 
      
 64 
     | 
    
         
            +
                        pressure_kpa: pressure_pa / 1000.0
         
     | 
| 
      
 65 
     | 
    
         
            +
                      }
         
     | 
| 
      
 66 
     | 
    
         
            +
                    end
         
     | 
| 
      
 67 
     | 
    
         
            +
             
     | 
| 
      
 68 
     | 
    
         
            +
                    private
         
     | 
| 
      
 69 
     | 
    
         
            +
             
     | 
| 
      
 70 
     | 
    
         
            +
                    # Read calibration coefficients from sensor
         
     | 
| 
      
 71 
     | 
    
         
            +
                    def read_calibration(addr)
         
     | 
| 
      
 72 
     | 
    
         
            +
                      # Read 26 bytes from 0x88-0xA1 (dig_T1..dig_P9, dig_H1)
         
     | 
| 
      
 73 
     | 
    
         
            +
                      calib1 = @i2c.read(addr, 26, register: CALIB_00_REG)
         
     | 
| 
      
 74 
     | 
    
         
            +
                      # Read 7 bytes from 0xE1-0xE7 (dig_H2..dig_H6)
         
     | 
| 
      
 75 
     | 
    
         
            +
                      calib2 = @i2c.read(addr, 7, register: CALIB_26_REG)
         
     | 
| 
      
 76 
     | 
    
         
            +
             
     | 
| 
      
 77 
     | 
    
         
            +
                      # Parse calibration data (see datasheet for layout)
         
     | 
| 
      
 78 
     | 
    
         
            +
                      {
         
     | 
| 
      
 79 
     | 
    
         
            +
                        dig_t1: u16(calib1, 0),
         
     | 
| 
      
 80 
     | 
    
         
            +
                        dig_t2: s16(calib1, 2),
         
     | 
| 
      
 81 
     | 
    
         
            +
                        dig_t3: s16(calib1, 4),
         
     | 
| 
      
 82 
     | 
    
         
            +
                        dig_p1: u16(calib1, 6),
         
     | 
| 
      
 83 
     | 
    
         
            +
                        dig_p2: s16(calib1, 8),
         
     | 
| 
      
 84 
     | 
    
         
            +
                        dig_p3: s16(calib1, 10),
         
     | 
| 
      
 85 
     | 
    
         
            +
                        dig_p4: s16(calib1, 12),
         
     | 
| 
      
 86 
     | 
    
         
            +
                        dig_p5: s16(calib1, 14),
         
     | 
| 
      
 87 
     | 
    
         
            +
                        dig_p6: s16(calib1, 16),
         
     | 
| 
      
 88 
     | 
    
         
            +
                        dig_p7: s16(calib1, 18),
         
     | 
| 
      
 89 
     | 
    
         
            +
                        dig_p8: s16(calib1, 20),
         
     | 
| 
      
 90 
     | 
    
         
            +
                        dig_p9: s16(calib1, 22),
         
     | 
| 
      
 91 
     | 
    
         
            +
                        dig_h1: calib1[25],
         
     | 
| 
      
 92 
     | 
    
         
            +
                        dig_h2: s16(calib2, 0),
         
     | 
| 
      
 93 
     | 
    
         
            +
                        dig_h3: calib2[2],
         
     | 
| 
      
 94 
     | 
    
         
            +
                        dig_h4: (calib2[3] << 4) | (calib2[4] & 0x0F),
         
     | 
| 
      
 95 
     | 
    
         
            +
                        dig_h5: (calib2[5] << 4) | (calib2[4] >> 4),
         
     | 
| 
      
 96 
     | 
    
         
            +
                        dig_h6: s8(calib2[6])
         
     | 
| 
      
 97 
     | 
    
         
            +
                      }
         
     | 
| 
      
 98 
     | 
    
         
            +
                    end
         
     | 
| 
      
 99 
     | 
    
         
            +
             
     | 
| 
      
 100 
     | 
    
         
            +
                    # Compensate temperature (returns °C, also sets @t_fine for other compensations)
         
     | 
| 
      
 101 
     | 
    
         
            +
                    def compensate_temperature(adc_t, calib)
         
     | 
| 
      
 102 
     | 
    
         
            +
                      var1 = (((adc_t / 16_384.0) - (calib[:dig_t1] / 1024.0)) * calib[:dig_t2])
         
     | 
| 
      
 103 
     | 
    
         
            +
                      var2 = ((((adc_t / 131_072.0) - (calib[:dig_t1] / 8192.0))**2) * calib[:dig_t3])
         
     | 
| 
      
 104 
     | 
    
         
            +
                      @t_fine = (var1 + var2).to_i
         
     | 
| 
      
 105 
     | 
    
         
            +
                      @t_fine / 5120.0
         
     | 
| 
      
 106 
     | 
    
         
            +
                    end
         
     | 
| 
      
 107 
     | 
    
         
            +
             
     | 
| 
      
 108 
     | 
    
         
            +
                    # Compensate humidity (returns %)
         
     | 
| 
      
 109 
     | 
    
         
            +
                    def compensate_humidity(adc_h, calib, _temp_c)
         
     | 
| 
      
 110 
     | 
    
         
            +
                      return 0.0 if @t_fine.nil?
         
     | 
| 
      
 111 
     | 
    
         
            +
             
     | 
| 
      
 112 
     | 
    
         
            +
                      var_h = @t_fine - 76_800.0
         
     | 
| 
      
 113 
     | 
    
         
            +
                      var_h = (adc_h - ((calib[:dig_h4] * 64.0) + (calib[:dig_h5] / 16_384.0 * var_h))) *
         
     | 
| 
      
 114 
     | 
    
         
            +
                              (calib[:dig_h2] / 65_536.0 * (1.0 + (calib[:dig_h6] / 67_108_864.0 * var_h *
         
     | 
| 
      
 115 
     | 
    
         
            +
                              (1.0 + (calib[:dig_h3] / 67_108_864.0 * var_h)))))
         
     | 
| 
      
 116 
     | 
    
         
            +
                      var_h *= (1.0 - (calib[:dig_h1] * var_h / 524_288.0))
         
     | 
| 
      
 117 
     | 
    
         
            +
                      var_h = 0.0 if var_h < 0.0
         
     | 
| 
      
 118 
     | 
    
         
            +
                      var_h = 100.0 if var_h > 100.0
         
     | 
| 
      
 119 
     | 
    
         
            +
                      var_h
         
     | 
| 
      
 120 
     | 
    
         
            +
                    end
         
     | 
| 
      
 121 
     | 
    
         
            +
             
     | 
| 
      
 122 
     | 
    
         
            +
                    # Compensate pressure (returns Pa)
         
     | 
| 
      
 123 
     | 
    
         
            +
                    def compensate_pressure(adc_p, calib, _temp_c)
         
     | 
| 
      
 124 
     | 
    
         
            +
                      var1 = (@t_fine / 2.0) - 64_000.0
         
     | 
| 
      
 125 
     | 
    
         
            +
                      var2 = var1 * var1 * calib[:dig_p6] / 32_768.0
         
     | 
| 
      
 126 
     | 
    
         
            +
                      var2 += var1 * calib[:dig_p5] * 2.0
         
     | 
| 
      
 127 
     | 
    
         
            +
                      var2 = (var2 / 4.0) + (calib[:dig_p4] * 65_536.0)
         
     | 
| 
      
 128 
     | 
    
         
            +
                      var1 = ((calib[:dig_p3] * var1 * var1 / 524_288.0) + (calib[:dig_p2] * var1)) / 524_288.0
         
     | 
| 
      
 129 
     | 
    
         
            +
                      var1 = (1.0 + (var1 / 32_768.0)) * calib[:dig_p1]
         
     | 
| 
      
 130 
     | 
    
         
            +
                      return 0.0 if var1.zero?
         
     | 
| 
      
 131 
     | 
    
         
            +
             
     | 
| 
      
 132 
     | 
    
         
            +
                      pressure = 1_048_576.0 - adc_p
         
     | 
| 
      
 133 
     | 
    
         
            +
                      pressure = (pressure - (var2 / 4096.0)) * 6250.0 / var1
         
     | 
| 
      
 134 
     | 
    
         
            +
                      var1 = calib[:dig_p9] * pressure * pressure / 2_147_483_648.0
         
     | 
| 
      
 135 
     | 
    
         
            +
                      var2 = pressure * calib[:dig_p8] / 32_768.0
         
     | 
| 
      
 136 
     | 
    
         
            +
                      pressure + ((var1 + var2 + calib[:dig_p7]) / 16.0)
         
     | 
| 
      
 137 
     | 
    
         
            +
                    end
         
     | 
| 
      
 138 
     | 
    
         
            +
             
     | 
| 
      
 139 
     | 
    
         
            +
                    # Helper: read unsigned 16-bit from byte array
         
     | 
| 
      
 140 
     | 
    
         
            +
                    def u16(bytes, offset)
         
     | 
| 
      
 141 
     | 
    
         
            +
                      bytes[offset] | (bytes[offset + 1] << 8)
         
     | 
| 
      
 142 
     | 
    
         
            +
                    end
         
     | 
| 
      
 143 
     | 
    
         
            +
             
     | 
| 
      
 144 
     | 
    
         
            +
                    # Helper: read signed 16-bit from byte array
         
     | 
| 
      
 145 
     | 
    
         
            +
                    def s16(bytes, offset)
         
     | 
| 
      
 146 
     | 
    
         
            +
                      val = u16(bytes, offset)
         
     | 
| 
      
 147 
     | 
    
         
            +
                      val > 32_767 ? val - 65_536 : val
         
     | 
| 
      
 148 
     | 
    
         
            +
                    end
         
     | 
| 
      
 149 
     | 
    
         
            +
             
     | 
| 
      
 150 
     | 
    
         
            +
                    # Helper: read signed 8-bit
         
     | 
| 
      
 151 
     | 
    
         
            +
                    def s8(byte)
         
     | 
| 
      
 152 
     | 
    
         
            +
                      byte > 127 ? byte - 256 : byte
         
     | 
| 
      
 153 
     | 
    
         
            +
                    end
         
     | 
| 
      
 154 
     | 
    
         
            +
                  end
         
     | 
| 
      
 155 
     | 
    
         
            +
                end
         
     | 
| 
      
 156 
     | 
    
         
            +
              end
         
     | 
| 
      
 157 
     | 
    
         
            +
            end
         
     | 
| 
         @@ -0,0 +1,26 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # frozen_string_literal: true
         
     | 
| 
      
 2 
     | 
    
         
            +
             
     | 
| 
      
 3 
     | 
    
         
            +
            module Dredger
         
     | 
| 
      
 4 
     | 
    
         
            +
              module IoT
         
     | 
| 
      
 5 
     | 
    
         
            +
                module Sensors
         
     | 
| 
      
 6 
     | 
    
         
            +
                  # BMP180 barometric pressure/temperature sensor over I2C
         
     | 
| 
      
 7 
     | 
    
         
            +
                  # Provider must respond to :read_measurements(addr) -> { temperature_c:, pressure_pa: }
         
     | 
| 
      
 8 
     | 
    
         
            +
                  class BMP180 < BaseSensor
         
     | 
| 
      
 9 
     | 
    
         
            +
                    def initialize(i2c_addr:, provider:, metadata: {})
         
     | 
| 
      
 10 
     | 
    
         
            +
                      super(metadata: metadata)
         
     | 
| 
      
 11 
     | 
    
         
            +
                      @i2c_addr = i2c_addr
         
     | 
| 
      
 12 
     | 
    
         
            +
                      @provider = provider
         
     | 
| 
      
 13 
     | 
    
         
            +
                    end
         
     | 
| 
      
 14 
     | 
    
         
            +
             
     | 
| 
      
 15 
     | 
    
         
            +
                    def readings
         
     | 
| 
      
 16 
     | 
    
         
            +
                      m = @provider.read_measurements(@i2c_addr)
         
     | 
| 
      
 17 
     | 
    
         
            +
                      [
         
     | 
| 
      
 18 
     | 
    
         
            +
                        reading(sensor_type: 'temperature', value: m[:temperature_c], unit: 'celsius'),
         
     | 
| 
      
 19 
     | 
    
         
            +
                        reading(sensor_type: 'pressure', value: m[:pressure_pa] / 1000.0, unit: 'kPa')
         
     | 
| 
      
 20 
     | 
    
         
            +
                      ]
         
     | 
| 
      
 21 
     | 
    
         
            +
                    end
         
     | 
| 
      
 22 
     | 
    
         
            +
                  end
         
     | 
| 
      
 23 
     | 
    
         
            +
                end
         
     | 
| 
      
 24 
     | 
    
         
            +
              end
         
     | 
| 
      
 25 
     | 
    
         
            +
            end
         
     | 
| 
      
 26 
     | 
    
         
            +
            # EOF
         
     | 
| 
         @@ -0,0 +1,26 @@ 
     | 
|
| 
      
 1 
     | 
    
         
            +
            # frozen_string_literal: true
         
     | 
| 
      
 2 
     | 
    
         
            +
             
     | 
| 
      
 3 
     | 
    
         
            +
            module Dredger
         
     | 
| 
      
 4 
     | 
    
         
            +
              module IoT
         
     | 
| 
      
 5 
     | 
    
         
            +
                module Sensors
         
     | 
| 
      
 6 
     | 
    
         
            +
                  # DHT22 humidity/temperature sensor (1-wire like GPIO)
         
     | 
| 
      
 7 
     | 
    
         
            +
                  # Uses a provider interface to allow simulation in tests and hardware backends in production.
         
     | 
| 
      
 8 
     | 
    
         
            +
                  class DHT22 < BaseSensor
         
     | 
| 
      
 9 
     | 
    
         
            +
                    # provider must respond to :sample(pin_label) -> { humidity: Float, temperature_c: Float }
         
     | 
| 
      
 10 
     | 
    
         
            +
                    def initialize(pin_label:, provider:, metadata: {})
         
     | 
| 
      
 11 
     | 
    
         
            +
                      super(metadata: metadata)
         
     | 
| 
      
 12 
     | 
    
         
            +
                      @pin_label = pin_label
         
     | 
| 
      
 13 
     | 
    
         
            +
                      @provider = provider
         
     | 
| 
      
 14 
     | 
    
         
            +
                    end
         
     | 
| 
      
 15 
     | 
    
         
            +
             
     | 
| 
      
 16 
     | 
    
         
            +
                    def readings
         
     | 
| 
      
 17 
     | 
    
         
            +
                      sample = @provider.sample(@pin_label)
         
     | 
| 
      
 18 
     | 
    
         
            +
                      [
         
     | 
| 
      
 19 
     | 
    
         
            +
                        reading(sensor_type: 'humidity', value: sample[:humidity], unit: '%'),
         
     | 
| 
      
 20 
     | 
    
         
            +
                        reading(sensor_type: 'temperature', value: sample[:temperature_c], unit: 'celsius')
         
     | 
| 
      
 21 
     | 
    
         
            +
                      ]
         
     | 
| 
      
 22 
     | 
    
         
            +
                    end
         
     | 
| 
      
 23 
     | 
    
         
            +
                  end
         
     | 
| 
      
 24 
     | 
    
         
            +
                end
         
     | 
| 
      
 25 
     | 
    
         
            +
              end
         
     | 
| 
      
 26 
     | 
    
         
            +
            end
         
     |