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
|