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.
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'bus/gpio'
4
+ require_relative 'bus/i2c'
5
+ require_relative 'bus/auto'
@@ -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,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'pins/beaglebone'
@@ -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