dredger-iot 0.2.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +21 -1
- data/README.md +167 -1
- data/bin/dredger +48 -0
- data/lib/dredger/iot/bus/gpio_label_adapter.rb +14 -4
- data/lib/dredger/iot/pins/raspberry_pi.rb +54 -0
- data/lib/dredger/iot/pins.rb +1 -0
- data/lib/dredger/iot/sensors/adxl345.rb +28 -0
- data/lib/dredger/iot/sensors/adxl345_provider.rb +103 -0
- data/lib/dredger/iot/sensors/neo6m.rb +35 -0
- data/lib/dredger/iot/sensors/neo6m_provider.rb +140 -0
- data/lib/dredger/iot/sensors/scd30.rb +28 -0
- data/lib/dredger/iot/sensors/scd30_provider.rb +142 -0
- data/lib/dredger/iot/sensors/yf_s201.rb +32 -0
- data/lib/dredger/iot/sensors/yf_s201_provider.rb +88 -0
- data/lib/dredger/iot/sensors.rb +8 -0
- data/lib/dredger/iot/version.rb +1 -1
- metadata +11 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 701ad99dd1f271df310ba3dffbb3295084b54e64689f4d3ed77f9b6ab9e31df5
|
|
4
|
+
data.tar.gz: e9b5ba95c4d2af83b529e6f66af5c81fb83c43f3f679e89e894209661651f93e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5362ae41f8f92d403809728857c139b00e3d8141e8f83725a3cc302eb7e7ace2c32b8bd7306fba5e83c2ee197e42c633b7beaeb9a84b9b77f1d95299e80aad1d
|
|
7
|
+
data.tar.gz: 220b5834cd302cef375c3922211c055788855d4b516b7260aa952d13fc312a3f9c028248863906e93a38badbd32839d2083783bb1431ba5fd1ada0b4df92bca5
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
|
|
11
|
+
## [0.3.0] - 2025-10-06
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- New sensors for industrial IoT applications:
|
|
15
|
+
- **ADXL345** - I2C 3-axis accelerometer for vibration monitoring (±2g, ±4g, ±8g, ±16g ranges)
|
|
16
|
+
- **SCD30** - I2C NDIR CO2 sensor with integrated temperature and humidity (400-10,000 ppm range)
|
|
17
|
+
- **YF-S201** - GPIO hall effect flow meter for liquid flow measurement (1-30 L/min range)
|
|
18
|
+
- **NEO-6M** - UART/Serial GPS module with NMEA 0183 parsing for location tracking
|
|
19
|
+
- README: comprehensive usage examples for all new sensors
|
|
20
|
+
- README: organized sensor list by category (Environmental, Light & Motion, Industrial)
|
|
21
|
+
|
|
22
|
+
## [0.2.1] - 2025-10-05
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
- Examples: Raspberry Pi GPIO blink script (GPIO17)
|
|
26
|
+
- Docs: Raspberry Pi OS instructions to enable I2C and 1-Wire
|
|
27
|
+
|
|
10
28
|
## [0.2.0] - 2025-10-05
|
|
11
29
|
|
|
12
30
|
### Added
|
|
@@ -64,7 +82,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
64
82
|
- RuboCop configuration and compliance
|
|
65
83
|
- Comprehensive documentation and usage examples
|
|
66
84
|
|
|
67
|
-
[Unreleased]: https://github.com/TheMadBotterINC/dredger-iot/compare/v0.
|
|
85
|
+
[Unreleased]: https://github.com/TheMadBotterINC/dredger-iot/compare/v0.3.0...HEAD
|
|
86
|
+
[0.3.0]: https://github.com/TheMadBotterINC/dredger-iot/compare/v0.2.1...v0.3.0
|
|
87
|
+
[0.2.1]: https://github.com/TheMadBotterINC/dredger-iot/compare/v0.2.0...v0.2.1
|
|
68
88
|
[0.2.0]: https://github.com/TheMadBotterINC/dredger-iot/compare/v0.1.2...v0.2.0
|
|
69
89
|
[0.1.2]: https://github.com/TheMadBotterINC/dredger-iot/compare/v0.1.1...v0.1.2
|
|
70
90
|
[0.1.1]: https://github.com/TheMadBotterINC/dredger-iot/compare/v0.1.0...v0.1.1
|
data/README.md
CHANGED
|
@@ -70,6 +70,26 @@ Environment overrides:
|
|
|
70
70
|
- `DREDGER_IOT_GPIO_BACKEND`: `simulation` | `libgpiod`
|
|
71
71
|
- `DREDGER_IOT_I2C_BACKEND`: `simulation` | `linux`
|
|
72
72
|
|
|
73
|
+
## Raspberry Pi GPIO label mapping
|
|
74
|
+
|
|
75
|
+
When the libgpiod backend is selected via Auto, Dredger-IoT resolves Raspberry Pi labels to the corresponding chip:line before accessing the GPIO line. Accepted labels:
|
|
76
|
+
- GPIO17 or BCM17 (Broadcom numbering)
|
|
77
|
+
- PIN11 or BOARD11 (header pin numbers)
|
|
78
|
+
|
|
79
|
+
On most Raspberry Pi boards, GPIO lines are exposed on gpiochip0 and the line offset matches the BCM number. The adapter will translate labels accordingly.
|
|
80
|
+
|
|
81
|
+
Example:
|
|
82
|
+
|
|
83
|
+
```ruby path=null start=null
|
|
84
|
+
require 'dredger/iot'
|
|
85
|
+
|
|
86
|
+
gpio = Dredger::IoT::Bus::Auto.gpio # picks libgpiod on RPi, otherwise simulation
|
|
87
|
+
|
|
88
|
+
# Use Raspberry Pi labels
|
|
89
|
+
gpio.set_direction('GPIO17', :out)
|
|
90
|
+
gpio.write('GPIO17', 1)
|
|
91
|
+
```
|
|
92
|
+
|
|
73
93
|
## Beaglebone P9_XX label mapping
|
|
74
94
|
|
|
75
95
|
When the libgpiod backend is selected via Auto, Dredger-IoT resolves Beaglebone labels like `P9_12` to the corresponding `gpiochipN:line` before accessing the GPIO line. A minimal built-in table is provided and can be extended in future releases.
|
|
@@ -93,15 +113,24 @@ If you run on a development host (no /dev/gpiochip0), Auto will default to the s
|
|
|
93
113
|
|
|
94
114
|
Dredger-IoT includes drivers for popular embedded sensors:
|
|
95
115
|
|
|
116
|
+
**Environmental Sensors:**
|
|
96
117
|
- **DHT22** - GPIO humidity/temperature sensor
|
|
97
118
|
- **BME280** - I2C temperature/humidity/pressure sensor
|
|
98
|
-
- **DS18B20** - 1-Wire digital temperature sensor
|
|
99
119
|
- **BMP180** - I2C barometric pressure/temperature sensor
|
|
100
120
|
- **MCP9808** - I2C high-accuracy temperature sensor
|
|
101
121
|
- **SHT31** - I2C temperature/humidity sensor
|
|
122
|
+
- **DS18B20** - 1-Wire digital temperature sensor
|
|
123
|
+
- **SCD30** - I2C NDIR CO2/temperature/humidity sensor (NEW)
|
|
124
|
+
|
|
125
|
+
**Light & Motion Sensors:**
|
|
102
126
|
- **BH1750** - I2C ambient light sensor (lux)
|
|
103
127
|
- **TSL2561** - I2C ambient light sensor (lux)
|
|
128
|
+
- **ADXL345** - I2C 3-axis accelerometer (vibration monitoring) (NEW)
|
|
129
|
+
|
|
130
|
+
**Industrial Sensors:**
|
|
104
131
|
- **INA219** - I2C bus voltage/current monitor
|
|
132
|
+
- **YF-S201** - GPIO hall effect flow meter (liquid flow) (NEW)
|
|
133
|
+
- **NEO-6M** - UART/Serial GPS module (location tracking) (NEW)
|
|
105
134
|
|
|
106
135
|
Sensors use a provider pattern for testability and hardware abstraction.
|
|
107
136
|
|
|
@@ -225,6 +254,101 @@ temp = sensor.readings.first
|
|
|
225
254
|
puts "#{temp.value}°C"
|
|
226
255
|
```
|
|
227
256
|
|
|
257
|
+
### ADXL345 Accelerometer (Vibration Monitoring)
|
|
258
|
+
|
|
259
|
+
```ruby path=null start=null
|
|
260
|
+
require 'dredger/iot'
|
|
261
|
+
|
|
262
|
+
# Set up I2C bus and ADXL345 provider
|
|
263
|
+
i2c = Dredger::IoT::Bus::Auto.i2c
|
|
264
|
+
provider = Dredger::IoT::Sensors::ADXL345Provider.new(i2c_bus: i2c, range: 2) # ±2g
|
|
265
|
+
|
|
266
|
+
# Create sensor instance (default I2C address 0x53)
|
|
267
|
+
sensor = Dredger::IoT::Sensors::ADXL345.new(
|
|
268
|
+
i2c_addr: 0x53,
|
|
269
|
+
provider: provider,
|
|
270
|
+
metadata: { location: 'motor_mount' }
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
readings = sensor.readings
|
|
274
|
+
readings.each { |r| puts "#{r.sensor_type}: #{r.value} #{r.unit}" }
|
|
275
|
+
# => acceleration_x: 0.024 g
|
|
276
|
+
# => acceleration_y: -0.012 g
|
|
277
|
+
# => acceleration_z: 0.980 g
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
### SCD30 CO2 Sensor
|
|
281
|
+
|
|
282
|
+
```ruby path=null start=null
|
|
283
|
+
require 'dredger/iot'
|
|
284
|
+
|
|
285
|
+
# Set up I2C bus and SCD30 provider
|
|
286
|
+
i2c = Dredger::IoT::Bus::Auto.i2c
|
|
287
|
+
provider = Dredger::IoT::Sensors::SCD30Provider.new(i2c_bus: i2c, interval: 2)
|
|
288
|
+
|
|
289
|
+
# Create sensor instance (default I2C address 0x61)
|
|
290
|
+
sensor = Dredger::IoT::Sensors::SCD30.new(
|
|
291
|
+
i2c_addr: 0x61,
|
|
292
|
+
provider: provider,
|
|
293
|
+
metadata: { location: 'greenhouse' }
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
readings = sensor.readings
|
|
297
|
+
readings.each { |r| puts "#{r.sensor_type}: #{r.value} #{r.unit}" }
|
|
298
|
+
# => co2: 412.5 ppm
|
|
299
|
+
# => temperature: 23.45 celsius
|
|
300
|
+
# => humidity: 45.2 %
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
### YF-S201 Flow Meter
|
|
304
|
+
|
|
305
|
+
```ruby path=null start=null
|
|
306
|
+
require 'dredger/iot'
|
|
307
|
+
|
|
308
|
+
# Set up GPIO bus and flow meter provider
|
|
309
|
+
gpio = Dredger::IoT::Bus::Auto.gpio
|
|
310
|
+
provider = Dredger::IoT::Sensors::YFS201Provider.new(gpio_bus: gpio, calibration_factor: 7.5)
|
|
311
|
+
|
|
312
|
+
# Create sensor instance
|
|
313
|
+
sensor = Dredger::IoT::Sensors::YFS201.new(
|
|
314
|
+
pin_label: 'P9_12',
|
|
315
|
+
provider: provider,
|
|
316
|
+
sample_duration: 1.0, # Count pulses for 1 second
|
|
317
|
+
metadata: { location: 'main_line' }
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
readings = sensor.readings
|
|
321
|
+
flow = readings.first
|
|
322
|
+
puts "Flow: #{flow.value} #{flow.unit}"
|
|
323
|
+
puts "Pulses: #{flow.metadata[:pulses]}"
|
|
324
|
+
# => Flow: 5.25 L/min
|
|
325
|
+
# => Pulses: 656
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
### NEO-6M GPS Module
|
|
329
|
+
|
|
330
|
+
```ruby path=null start=null
|
|
331
|
+
require 'dredger/iot'
|
|
332
|
+
|
|
333
|
+
# Set up GPS provider
|
|
334
|
+
provider = Dredger::IoT::Sensors::NEO6MProvider.new(baud_rate: 9600, timeout: 5)
|
|
335
|
+
|
|
336
|
+
# Create sensor instance
|
|
337
|
+
sensor = Dredger::IoT::Sensors::NEO6M.new(
|
|
338
|
+
device: '/dev/ttyAMA0', # Serial device
|
|
339
|
+
provider: provider,
|
|
340
|
+
metadata: { vehicle: 'truck_1' }
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
readings = sensor.readings
|
|
344
|
+
readings.each { |r| puts "#{r.sensor_type}: #{r.value} #{r.unit}" }
|
|
345
|
+
# => latitude: 37.774929 degrees
|
|
346
|
+
# => longitude: -122.419418 degrees
|
|
347
|
+
# => altitude: 52.4 m
|
|
348
|
+
# => speed: 12.5 km/h
|
|
349
|
+
# => gps_quality: 8 satellites
|
|
350
|
+
```
|
|
351
|
+
|
|
228
352
|
### Multiple Sensors with Scheduled Polling
|
|
229
353
|
|
|
230
354
|
```ruby path=null start=null
|
|
@@ -353,6 +477,29 @@ ls /sys/bus/w1/devices/
|
|
|
353
477
|
# Should show devices like: 28-00000xxxxxx
|
|
354
478
|
```
|
|
355
479
|
|
|
480
|
+
#### Raspberry Pi OS: Enable I2C and 1-Wire
|
|
481
|
+
|
|
482
|
+
On Raspberry Pi OS you can enable I2C and 1-Wire via raspi-config or by editing /boot/config.txt.
|
|
483
|
+
|
|
484
|
+
Option A: raspi-config (recommended)
|
|
485
|
+
```bash path=null start=null
|
|
486
|
+
sudo raspi-config
|
|
487
|
+
# Interface Options → I2C → Enable
|
|
488
|
+
# Interface Options → 1-Wire → Enable
|
|
489
|
+
sudo reboot
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
Option B: edit /boot/config.txt
|
|
493
|
+
```bash path=null start=null
|
|
494
|
+
# Enable I2C
|
|
495
|
+
sudo sed -i 's/^#\?dtparam=i2c_arm=.*/dtparam=i2c_arm=on/' /boot/config.txt
|
|
496
|
+
# Enable 1-Wire on default BCM4 (PIN7)
|
|
497
|
+
echo 'dtoverlay=w1-gpio,gpiopin=4' | sudo tee -a /boot/config.txt
|
|
498
|
+
sudo reboot
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
Note: Dredger-IoT accepts Raspberry Pi labels like GPIO17, BCM17, and PIN11.
|
|
502
|
+
|
|
356
503
|
#### Beaglebone Black Device Tree
|
|
357
504
|
|
|
358
505
|
For Beaglebone Black, you may need to enable device tree overlays:
|
|
@@ -553,6 +700,25 @@ reading.timestamp # Time object when reading was taken
|
|
|
553
700
|
dredger read ina219 0x40 --shunt 0.1
|
|
554
701
|
```
|
|
555
702
|
|
|
703
|
+
- **`ADXL345`** - 3-axis accelerometer (I2C)
|
|
704
|
+
- Parameters: `i2c_addr` (default: `0x53`), `provider`
|
|
705
|
+
- Returns: acceleration_x (g), acceleration_y (g), acceleration_z (g)
|
|
706
|
+
- Ranges: ±2g, ±4g, ±8g, ±16g (configurable in provider)
|
|
707
|
+
|
|
708
|
+
- **`SCD30`** - NDIR CO2 sensor (I2C)
|
|
709
|
+
- Parameters: `i2c_addr` (default: `0x61`), `provider`
|
|
710
|
+
- Returns: co2 (ppm), temperature (celsius), humidity (%)
|
|
711
|
+
- Range: 400-10,000 ppm CO2
|
|
712
|
+
|
|
713
|
+
- **`YFS201`** - Hall effect flow meter (GPIO)
|
|
714
|
+
- Parameters: `pin_label`, `provider`, `sample_duration` (default: `1.0`)
|
|
715
|
+
- Returns: flow_rate (L/min)
|
|
716
|
+
- Range: 1-30 L/min
|
|
717
|
+
|
|
718
|
+
- **`NEO6M`** - GPS module (UART/Serial)
|
|
719
|
+
- Parameters: `device` (default: `'/dev/ttyAMA0'`), `provider`
|
|
720
|
+
- Returns: latitude (degrees), longitude (degrees), altitude (m), speed (km/h), gps_quality (satellites)
|
|
721
|
+
|
|
556
722
|
### Scheduling
|
|
557
723
|
|
|
558
724
|
#### `Dredger::IoT::Scheduler.periodic_with_jitter`
|
data/bin/dredger
CHANGED
|
@@ -32,6 +32,8 @@ class DredgerCLI
|
|
|
32
32
|
test_i2c
|
|
33
33
|
when 'info'
|
|
34
34
|
show_info
|
|
35
|
+
when 'doctor'
|
|
36
|
+
doctor
|
|
35
37
|
else
|
|
36
38
|
puts parser
|
|
37
39
|
exit 1
|
|
@@ -53,6 +55,7 @@ class DredgerCLI
|
|
|
53
55
|
opts.separator ' read SENSOR [OPTIONS] Read from a sensor'
|
|
54
56
|
opts.separator ' test-gpio PIN Test GPIO pin'
|
|
55
57
|
opts.separator ' test-i2c Scan I2C bus'
|
|
58
|
+
opts.separator ' doctor Check system prerequisites'
|
|
56
59
|
opts.separator ' info Show system information'
|
|
57
60
|
opts.separator ''
|
|
58
61
|
opts.separator 'Options:'
|
|
@@ -270,6 +273,51 @@ class DredgerCLI
|
|
|
270
273
|
puts " DREDGER_IOT_I2C_BACKEND: #{ENV['DREDGER_IOT_I2C_BACKEND'] || '(not set)'}"
|
|
271
274
|
end
|
|
272
275
|
|
|
276
|
+
def doctor
|
|
277
|
+
puts 'Doctor: checking system prerequisites...'
|
|
278
|
+
puts
|
|
279
|
+
check_device_node('/dev/gpiochip0', 'GPIO (libgpiod) device')
|
|
280
|
+
check_device_node('/dev/i2c-1', 'I2C device (bus 1)')
|
|
281
|
+
check_path('/sys/bus/w1/devices', '1-Wire bus (DS18B20) directory')
|
|
282
|
+
|
|
283
|
+
puts
|
|
284
|
+
puts 'Kernel modules:'
|
|
285
|
+
mods = read_proc_modules
|
|
286
|
+
puts " i2c-dev: #{mods.include?('i2c_dev') ? 'loaded' : 'missing'}"
|
|
287
|
+
puts " w1-gpio: #{mods.include?('w1_gpio') ? 'loaded' : 'missing'}"
|
|
288
|
+
puts " w1-therm: #{mods.include?('w1_therm') ? 'loaded' : 'missing'}"
|
|
289
|
+
|
|
290
|
+
puts
|
|
291
|
+
puts 'Recommendations:'
|
|
292
|
+
puts " sudo apt-get install gpiod i2c-tools"
|
|
293
|
+
puts " sudo usermod -a -G gpio $USER # for GPIO access"
|
|
294
|
+
puts " sudo usermod -a -G i2c $USER # for I2C access"
|
|
295
|
+
puts " sudo modprobe i2c-dev"
|
|
296
|
+
puts " sudo modprobe w1-gpio; sudo modprobe w1-therm"
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def check_device_node(path, desc)
|
|
300
|
+
if File.exist?(path)
|
|
301
|
+
puts " ✔ #{desc}: present (#{path})"
|
|
302
|
+
else
|
|
303
|
+
puts " ✖ #{desc}: missing (#{path})"
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def check_path(path, desc)
|
|
308
|
+
if Dir.exist?(path)
|
|
309
|
+
puts " ✔ #{desc}: present (#{path})"
|
|
310
|
+
else
|
|
311
|
+
puts " ✖ #{desc}: missing (#{path})"
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def read_proc_modules
|
|
316
|
+
File.read('/proc/modules')
|
|
317
|
+
rescue StandardError
|
|
318
|
+
''
|
|
319
|
+
end
|
|
320
|
+
|
|
273
321
|
def setup_backends
|
|
274
322
|
case @options[:backend]
|
|
275
323
|
when 'simulation'
|
|
@@ -5,9 +5,11 @@ module Dredger
|
|
|
5
5
|
module Bus
|
|
6
6
|
# Adapts label strings (e.g. 'P9_12') to PinRef with chip:line for libgpiod backends
|
|
7
7
|
class GPIOLabelAdapter
|
|
8
|
-
|
|
8
|
+
# mapper can be a single mapper module/class or an Array of them.
|
|
9
|
+
# Defaults to both Beaglebone and RaspberryPi mappers.
|
|
10
|
+
def initialize(backend:, mapper: [Dredger::IoT::Pins::Beaglebone, Dredger::IoT::Pins::RaspberryPi])
|
|
9
11
|
@backend = backend
|
|
10
|
-
@
|
|
12
|
+
@mappers = Array(mapper)
|
|
11
13
|
end
|
|
12
14
|
|
|
13
15
|
def set_direction(pin, direction)
|
|
@@ -24,16 +26,24 @@ module Dredger
|
|
|
24
26
|
|
|
25
27
|
private
|
|
26
28
|
|
|
29
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
27
30
|
def resolve(pin)
|
|
28
31
|
# Already a PinRef with line
|
|
29
32
|
return pin if pin.respond_to?(:line) && !pin.line.nil?
|
|
33
|
+
|
|
30
34
|
# Numeric line
|
|
31
35
|
return Integer(pin) if pin.is_a?(Integer) || pin.to_s =~ /^\d+$/
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
|
|
37
|
+
# Try all mappers in order
|
|
38
|
+
@mappers.each do |m|
|
|
39
|
+
if m.respond_to?(:resolve_label_to_pinref) && m.respond_to?(:valid_label?) && m.valid_label?(pin)
|
|
40
|
+
return m.resolve_label_to_pinref(pin)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
34
43
|
|
|
35
44
|
raise ArgumentError, 'Unsupported pin format'
|
|
36
45
|
end
|
|
46
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
37
47
|
end
|
|
38
48
|
end
|
|
39
49
|
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dredger
|
|
4
|
+
module IoT
|
|
5
|
+
module Pins
|
|
6
|
+
# Raspberry Pi header/BCM label mapping.
|
|
7
|
+
# Supports labels like:
|
|
8
|
+
# - GPIO17, BCM17
|
|
9
|
+
# - PIN11 (a.k.a. BOARD11)
|
|
10
|
+
class RaspberryPi
|
|
11
|
+
PinRef = Struct.new(:label, :chip, :line, keyword_init: true) do
|
|
12
|
+
def to_s
|
|
13
|
+
chip && line ? "#{label}(chip#{chip}:#{line})" : label.to_s
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Subset of BOARD pin to BCM mapping for common usable GPIOs on 40-pin header
|
|
18
|
+
BOARD_TO_BCM = {
|
|
19
|
+
3 => 2, 5 => 3, 7 => 4, 8 => 14, 10 => 15, 11 => 17, 12 => 18, 13 => 27,
|
|
20
|
+
15 => 22, 16 => 23, 18 => 24, 19 => 10, 21 => 9, 22 => 25, 23 => 11,
|
|
21
|
+
24 => 8, 26 => 7, 29 => 5, 31 => 6, 32 => 12, 33 => 13, 35 => 19,
|
|
22
|
+
36 => 16, 37 => 26, 38 => 20, 40 => 21
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
# Accept variants like GPIO17, BCM17, PIN11, BOARD11
|
|
26
|
+
def self.valid_label?(label)
|
|
27
|
+
s = label.to_s.upcase
|
|
28
|
+
return true if s.match?(/^(GPIO|BCM)\d+$/)
|
|
29
|
+
return true if s.match?(/^(PIN|BOARD)\d+$/)
|
|
30
|
+
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.resolve_label_to_pinref(label)
|
|
35
|
+
s = label.to_s.upcase
|
|
36
|
+
if s =~ /^(GPIO|BCM)(\d+)$/
|
|
37
|
+
bcm = Regexp.last_match(2).to_i
|
|
38
|
+
return PinRef.new(label: label.to_s, chip: 0, line: bcm)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
if s =~ /^(PIN|BOARD)(\d+)$/
|
|
42
|
+
board = Regexp.last_match(2).to_i
|
|
43
|
+
bcm = BOARD_TO_BCM[board]
|
|
44
|
+
raise ArgumentError, "Unknown/unsupported board pin: #{label}" if bcm.nil?
|
|
45
|
+
|
|
46
|
+
return PinRef.new(label: label.to_s, chip: 0, line: bcm)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
raise ArgumentError, "Unknown Raspberry Pi pin label: #{label}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
data/lib/dredger/iot/pins.rb
CHANGED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dredger
|
|
4
|
+
module IoT
|
|
5
|
+
module Sensors
|
|
6
|
+
# ADXL345 3-axis digital accelerometer sensor (I2C/SPI)
|
|
7
|
+
# Measures acceleration/vibration in x, y, z axes
|
|
8
|
+
# Uses a provider interface to allow simulation in tests and hardware backends in production.
|
|
9
|
+
class ADXL345 < BaseSensor
|
|
10
|
+
# provider must respond to :read_measurements(i2c_addr) -> { x_g:, y_g:, z_g: }
|
|
11
|
+
def initialize(i2c_addr: 0x53, provider:, metadata: {})
|
|
12
|
+
super(metadata: metadata)
|
|
13
|
+
@i2c_addr = i2c_addr
|
|
14
|
+
@provider = provider
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def readings
|
|
18
|
+
sample = @provider.read_measurements(@i2c_addr)
|
|
19
|
+
[
|
|
20
|
+
reading(sensor_type: 'acceleration_x', value: sample[:x_g], unit: 'g'),
|
|
21
|
+
reading(sensor_type: 'acceleration_y', value: sample[:y_g], unit: 'g'),
|
|
22
|
+
reading(sensor_type: 'acceleration_z', value: sample[:z_g], unit: 'g')
|
|
23
|
+
]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dredger
|
|
4
|
+
module IoT
|
|
5
|
+
module Sensors
|
|
6
|
+
# Hardware provider for ADXL345 3-axis accelerometer over I2C.
|
|
7
|
+
# Datasheet: https://www.analog.com/media/en/technical-documentation/data-sheets/ADXL345.pdf
|
|
8
|
+
#
|
|
9
|
+
# Key features:
|
|
10
|
+
# - ±2g, ±4g, ±8g, ±16g selectable measurement ranges
|
|
11
|
+
# - 10-bit to 13-bit resolution
|
|
12
|
+
# - I2C (up to 400 kHz) or SPI interface
|
|
13
|
+
# - Default I2C address: 0x53 (ALT address: 0x1D if SDO/ALT pulled high)
|
|
14
|
+
#
|
|
15
|
+
# Key registers:
|
|
16
|
+
# - 0x00: DEVID (should be 0xE5)
|
|
17
|
+
# - 0x2D: POWER_CTL (power modes)
|
|
18
|
+
# - 0x31: DATA_FORMAT (range and resolution)
|
|
19
|
+
# - 0x32-0x37: DATAX0, DATAX1, DATAY0, DATAY1, DATAZ0, DATAZ1 (acceleration data)
|
|
20
|
+
class ADXL345Provider
|
|
21
|
+
DEVID_REG = 0x00
|
|
22
|
+
DEVID_EXPECTED = 0xE5
|
|
23
|
+
POWER_CTL_REG = 0x2D
|
|
24
|
+
DATA_FORMAT_REG = 0x31
|
|
25
|
+
DATAX0_REG = 0x32
|
|
26
|
+
|
|
27
|
+
# Measurement mode bit (POWER_CTL register)
|
|
28
|
+
MEASURE_BIT = 0x08
|
|
29
|
+
|
|
30
|
+
# i2c_bus: an I2C bus interface (e.g., Dredger::IoT::Bus::Auto.i2c)
|
|
31
|
+
# range: measurement range in g (2, 4, 8, or 16)
|
|
32
|
+
def initialize(i2c_bus:, range: 2)
|
|
33
|
+
@i2c = i2c_bus
|
|
34
|
+
@range = range
|
|
35
|
+
@scale_factor = calculate_scale_factor(range)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Read acceleration measurements from the ADXL345 at the given I2C address.
|
|
39
|
+
# Returns { x_g: Float, y_g: Float, z_g: Float }
|
|
40
|
+
def read_measurements(addr)
|
|
41
|
+
# Verify device ID
|
|
42
|
+
dev_id = @i2c.read(addr, 1, register: DEVID_REG).first
|
|
43
|
+
raise IOError, "ADXL345 not found (devid=0x#{dev_id.to_s(16)})" unless dev_id == DEVID_EXPECTED
|
|
44
|
+
|
|
45
|
+
# Configure sensor (one-time setup)
|
|
46
|
+
configure_sensor(addr)
|
|
47
|
+
|
|
48
|
+
# Read 6 bytes of acceleration data (X, Y, Z as 16-bit signed integers)
|
|
49
|
+
raw = @i2c.read(addr, 6, register: DATAX0_REG)
|
|
50
|
+
|
|
51
|
+
# Parse 16-bit signed values (little-endian)
|
|
52
|
+
x_raw = to_signed16(raw[0] | (raw[1] << 8))
|
|
53
|
+
y_raw = to_signed16(raw[2] | (raw[3] << 8))
|
|
54
|
+
z_raw = to_signed16(raw[4] | (raw[5] << 8))
|
|
55
|
+
|
|
56
|
+
# Convert to g units using scale factor
|
|
57
|
+
{
|
|
58
|
+
x_g: (x_raw * @scale_factor).round(3),
|
|
59
|
+
y_g: (y_raw * @scale_factor).round(3),
|
|
60
|
+
z_g: (z_raw * @scale_factor).round(3)
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# Configure sensor for measurement mode with specified range
|
|
67
|
+
def configure_sensor(addr)
|
|
68
|
+
# Set data format: range bits [1:0]
|
|
69
|
+
# ±2g: 0b00, ±4g: 0b01, ±8g: 0b10, ±16g: 0b11
|
|
70
|
+
range_bits = case @range
|
|
71
|
+
when 2 then 0b00
|
|
72
|
+
when 4 then 0b01
|
|
73
|
+
when 8 then 0b10
|
|
74
|
+
when 16 then 0b11
|
|
75
|
+
else raise ArgumentError, "Invalid range: #{@range}g (must be 2, 4, 8, or 16)"
|
|
76
|
+
end
|
|
77
|
+
@i2c.write(addr, [range_bits], register: DATA_FORMAT_REG)
|
|
78
|
+
|
|
79
|
+
# Enable measurement mode
|
|
80
|
+
@i2c.write(addr, [MEASURE_BIT], register: POWER_CTL_REG)
|
|
81
|
+
|
|
82
|
+
# Wait for sensor to stabilize
|
|
83
|
+
sleep(0.01)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Calculate scale factor for converting raw values to g
|
|
87
|
+
# ADXL345 uses 10-bit resolution in full resolution mode
|
|
88
|
+
# Scale factor: range / 512 (for 10-bit)
|
|
89
|
+
def calculate_scale_factor(range)
|
|
90
|
+
# In full resolution mode, scale is ~3.9 mg/LSB regardless of range
|
|
91
|
+
# In fixed 10-bit mode, scale depends on range
|
|
92
|
+
# Using simplified calculation: range / 512
|
|
93
|
+
range / 512.0
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Convert unsigned 16-bit to signed 16-bit
|
|
97
|
+
def to_signed16(val)
|
|
98
|
+
val > 32_767 ? val - 65_536 : val
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dredger
|
|
4
|
+
module IoT
|
|
5
|
+
module Sensors
|
|
6
|
+
# NEO-6M GPS module (UART/Serial)
|
|
7
|
+
# Provides location, altitude, speed, and satellite information
|
|
8
|
+
# Uses a provider interface to allow simulation in tests and hardware backends in production.
|
|
9
|
+
class NEO6M < BaseSensor
|
|
10
|
+
# provider must respond to :read_position(device) -> { latitude:, longitude:, altitude:, speed:, satellites: }
|
|
11
|
+
def initialize(device: '/dev/ttyAMA0', provider:, metadata: {})
|
|
12
|
+
super(metadata: metadata)
|
|
13
|
+
@device = device
|
|
14
|
+
@provider = provider
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def readings
|
|
18
|
+
sample = @provider.read_position(@device)
|
|
19
|
+
[
|
|
20
|
+
reading(sensor_type: 'latitude', value: sample[:latitude], unit: 'degrees'),
|
|
21
|
+
reading(sensor_type: 'longitude', value: sample[:longitude], unit: 'degrees'),
|
|
22
|
+
reading(sensor_type: 'altitude', value: sample[:altitude], unit: 'm'),
|
|
23
|
+
reading(sensor_type: 'speed', value: sample[:speed], unit: 'km/h'),
|
|
24
|
+
reading(
|
|
25
|
+
sensor_type: 'gps_quality',
|
|
26
|
+
value: sample[:satellites],
|
|
27
|
+
unit: 'satellites',
|
|
28
|
+
metadata: { fix_quality: sample[:fix_quality] }
|
|
29
|
+
)
|
|
30
|
+
]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'io/wait'
|
|
4
|
+
|
|
5
|
+
module Dredger
|
|
6
|
+
module IoT
|
|
7
|
+
module Sensors
|
|
8
|
+
# Hardware provider for NEO-6M GPS module via UART/Serial with NMEA parsing.
|
|
9
|
+
# Datasheet: https://www.u-blox.com/sites/default/files/products/documents/NEO-6_DataSheet_(GPS.G6-HW-09005).pdf
|
|
10
|
+
#
|
|
11
|
+
# Key features:
|
|
12
|
+
# - 50-channel GPS receiver
|
|
13
|
+
# - UART interface (default: 9600 baud, 8N1)
|
|
14
|
+
# - NMEA 0183 protocol output
|
|
15
|
+
# - Update rate: 1-5 Hz (default: 1 Hz)
|
|
16
|
+
# - Cold start: ~27s, Warm start: ~1s
|
|
17
|
+
#
|
|
18
|
+
# NMEA Sentences:
|
|
19
|
+
# - $GPGGA: Global Positioning System Fix Data (position, altitude, satellites)
|
|
20
|
+
# - $GPRMC: Recommended Minimum Navigation Information (position, speed, date/time)
|
|
21
|
+
# - $GPGSA: GPS DOP and Active Satellites
|
|
22
|
+
# - $GPGSV: GPS Satellites in View
|
|
23
|
+
class NEO6MProvider
|
|
24
|
+
# baud_rate: serial baud rate (default: 9600)
|
|
25
|
+
# timeout: read timeout in seconds (default: 5)
|
|
26
|
+
def initialize(baud_rate: 9600, timeout: 5)
|
|
27
|
+
@baud_rate = baud_rate
|
|
28
|
+
@timeout = timeout
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Read GPS position by parsing NMEA sentences from the serial device.
|
|
32
|
+
# Returns { latitude: Float, longitude: Float, altitude: Float, speed: Float, satellites: Integer, fix_quality: Integer }
|
|
33
|
+
#
|
|
34
|
+
# @param device [String] Serial device path (e.g., '/dev/ttyAMA0', '/dev/ttyUSB0')
|
|
35
|
+
def read_position(device)
|
|
36
|
+
File.open(device, 'r+') do |serial|
|
|
37
|
+
configure_serial(serial)
|
|
38
|
+
|
|
39
|
+
# Read NMEA sentences until we have both GGA and RMC (or timeout)
|
|
40
|
+
gga_data = nil
|
|
41
|
+
rmc_data = nil
|
|
42
|
+
start_time = Time.now
|
|
43
|
+
|
|
44
|
+
while (gga_data.nil? || rmc_data.nil?) && (Time.now - start_time < @timeout)
|
|
45
|
+
line = read_line(serial, @timeout)
|
|
46
|
+
next unless line
|
|
47
|
+
|
|
48
|
+
if line.start_with?('$GPGGA') || line.start_with?('$GNGGA')
|
|
49
|
+
gga_data = parse_gga(line)
|
|
50
|
+
elsif line.start_with?('$GPRMC') || line.start_with?('$GNRMC')
|
|
51
|
+
rmc_data = parse_rmc(line)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
raise IOError, 'GPS timeout: no valid NMEA sentences received' if gga_data.nil? && rmc_data.nil?
|
|
56
|
+
|
|
57
|
+
# Merge data from both sentences (GGA has altitude/satellites, RMC has speed)
|
|
58
|
+
{
|
|
59
|
+
latitude: gga_data&.dig(:latitude) || rmc_data&.dig(:latitude) || 0.0,
|
|
60
|
+
longitude: gga_data&.dig(:longitude) || rmc_data&.dig(:longitude) || 0.0,
|
|
61
|
+
altitude: gga_data&.dig(:altitude) || 0.0,
|
|
62
|
+
speed: rmc_data&.dig(:speed) || 0.0,
|
|
63
|
+
satellites: gga_data&.dig(:satellites) || 0,
|
|
64
|
+
fix_quality: gga_data&.dig(:fix_quality) || 0
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# Configure serial port (Linux termios settings)
|
|
72
|
+
def configure_serial(serial)
|
|
73
|
+
# Set raw mode, no echo, baud rate
|
|
74
|
+
# This would typically use `stty` or `termios` gem
|
|
75
|
+
# For simplicity, assuming device is already configured
|
|
76
|
+
# In production, use: system("stty -F #{device} #{@baud_rate} raw -echo")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Read a line from serial with timeout
|
|
80
|
+
def read_line(serial, timeout)
|
|
81
|
+
return nil unless serial.wait_readable(timeout)
|
|
82
|
+
|
|
83
|
+
serial.gets&.chomp
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Parse $GPGGA sentence: Global Positioning System Fix Data
|
|
87
|
+
# Format: $GPGGA,hhmmss.ss,llll.ll,a,yyyyy.yy,a,x,xx,x.x,x.x,M,x.x,M,x.x,xxxx*hh
|
|
88
|
+
# Example: $GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47
|
|
89
|
+
def parse_gga(sentence)
|
|
90
|
+
parts = sentence.split(',')
|
|
91
|
+
return nil if parts.size < 15 || parts[6].to_i.zero? # Check fix quality
|
|
92
|
+
|
|
93
|
+
{
|
|
94
|
+
latitude: parse_coordinate(parts[2], parts[3]),
|
|
95
|
+
longitude: parse_coordinate(parts[4], parts[5]),
|
|
96
|
+
fix_quality: parts[6].to_i,
|
|
97
|
+
satellites: parts[7].to_i,
|
|
98
|
+
altitude: parts[9].to_f
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Parse $GPRMC sentence: Recommended Minimum Navigation Information
|
|
103
|
+
# Format: $GPRMC,hhmmss.ss,A,llll.ll,a,yyyyy.yy,a,x.x,x.x,ddmmyy,x.x,a*hh
|
|
104
|
+
# Example: $GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A
|
|
105
|
+
def parse_rmc(sentence)
|
|
106
|
+
parts = sentence.split(',')
|
|
107
|
+
return nil if parts.size < 12 || parts[2] != 'A' # Check if data is valid
|
|
108
|
+
|
|
109
|
+
{
|
|
110
|
+
latitude: parse_coordinate(parts[3], parts[4]),
|
|
111
|
+
longitude: parse_coordinate(parts[5], parts[6]),
|
|
112
|
+
speed: parts[7].to_f * 1.852 # Convert knots to km/h
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Parse NMEA coordinate format (ddmm.mmmm) to decimal degrees
|
|
117
|
+
# @param coord_str [String] Coordinate string (e.g., "4807.038")
|
|
118
|
+
# @param direction [String] Direction (N/S for latitude, E/W for longitude)
|
|
119
|
+
def parse_coordinate(coord_str, direction)
|
|
120
|
+
return 0.0 if coord_str.nil? || coord_str.empty?
|
|
121
|
+
|
|
122
|
+
# Determine if latitude or longitude based on length
|
|
123
|
+
# Latitude: ddmm.mmmm (2 digit degrees)
|
|
124
|
+
# Longitude: dddmm.mmmm (3 digit degrees)
|
|
125
|
+
degree_digits = coord_str.length >= 5 && coord_str[4] == '.' ? 2 : 3
|
|
126
|
+
|
|
127
|
+
degrees = coord_str[0, degree_digits].to_f
|
|
128
|
+
minutes = coord_str[degree_digits..-1].to_f
|
|
129
|
+
|
|
130
|
+
decimal_degrees = degrees + (minutes / 60.0)
|
|
131
|
+
|
|
132
|
+
# Apply direction (negative for South and West)
|
|
133
|
+
decimal_degrees *= -1 if %w[S W].include?(direction)
|
|
134
|
+
|
|
135
|
+
decimal_degrees.round(6)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dredger
|
|
4
|
+
module IoT
|
|
5
|
+
module Sensors
|
|
6
|
+
# SCD30 NDIR CO2 sensor with integrated temperature and humidity sensor
|
|
7
|
+
# Measures CO2 concentration, temperature, and humidity
|
|
8
|
+
# Uses a provider interface to allow simulation in tests and hardware backends in production.
|
|
9
|
+
class SCD30 < BaseSensor
|
|
10
|
+
# provider must respond to :read_measurements(i2c_addr) -> { co2_ppm:, temperature_c:, humidity: }
|
|
11
|
+
def initialize(i2c_addr: 0x61, provider:, metadata: {})
|
|
12
|
+
super(metadata: metadata)
|
|
13
|
+
@i2c_addr = i2c_addr
|
|
14
|
+
@provider = provider
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def readings
|
|
18
|
+
sample = @provider.read_measurements(@i2c_addr)
|
|
19
|
+
[
|
|
20
|
+
reading(sensor_type: 'co2', value: sample[:co2_ppm], unit: 'ppm'),
|
|
21
|
+
reading(sensor_type: 'temperature', value: sample[:temperature_c], unit: 'celsius'),
|
|
22
|
+
reading(sensor_type: 'humidity', value: sample[:humidity], unit: '%')
|
|
23
|
+
]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dredger
|
|
4
|
+
module IoT
|
|
5
|
+
module Sensors
|
|
6
|
+
# Hardware provider for SCD30 CO2, temperature, and humidity sensor over I2C.
|
|
7
|
+
# Datasheet: https://sensirion.com/media/documents/4EAF6AF8/61652C3C/Sensirion_CO2_Sensors_SCD30_Datasheet.pdf
|
|
8
|
+
#
|
|
9
|
+
# Key features:
|
|
10
|
+
# - NDIR CO2 sensor (400-10,000 ppm range)
|
|
11
|
+
# - Integrated SHT31 temperature and humidity sensor
|
|
12
|
+
# - I2C interface (default address: 0x61)
|
|
13
|
+
# - Automatic self-calibration
|
|
14
|
+
# - Measurement interval: 2-1800 seconds
|
|
15
|
+
#
|
|
16
|
+
# Key commands:
|
|
17
|
+
# - 0x0010: Start continuous measurement
|
|
18
|
+
# - 0x0104: Stop continuous measurement
|
|
19
|
+
# - 0x0202: Set measurement interval
|
|
20
|
+
# - 0x0300: Data ready status
|
|
21
|
+
# - 0x0027: Read measurement (18 bytes: CO2, temp, humidity)
|
|
22
|
+
class SCD30Provider
|
|
23
|
+
CMD_START_MEASUREMENT = 0x0010
|
|
24
|
+
CMD_STOP_MEASUREMENT = 0x0104
|
|
25
|
+
CMD_SET_INTERVAL = 0x0202
|
|
26
|
+
CMD_DATA_READY = 0x0300
|
|
27
|
+
CMD_READ_MEASUREMENT = 0x0027
|
|
28
|
+
|
|
29
|
+
# i2c_bus: an I2C bus interface (e.g., Dredger::IoT::Bus::Auto.i2c)
|
|
30
|
+
# interval: measurement interval in seconds (2-1800)
|
|
31
|
+
# ambient_pressure: ambient pressure compensation in mBar (700-1400, 0=disable)
|
|
32
|
+
def initialize(i2c_bus:, interval: 2, ambient_pressure: 0)
|
|
33
|
+
@i2c = i2c_bus
|
|
34
|
+
@interval = interval
|
|
35
|
+
@ambient_pressure = ambient_pressure
|
|
36
|
+
@initialized = false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Read measurements from the SCD30 at the given I2C address.
|
|
40
|
+
# Returns { co2_ppm: Float, temperature_c: Float, humidity: Float }
|
|
41
|
+
def read_measurements(addr)
|
|
42
|
+
# Initialize sensor on first read
|
|
43
|
+
initialize_sensor(addr) unless @initialized
|
|
44
|
+
|
|
45
|
+
# Wait for data ready
|
|
46
|
+
wait_for_data_ready(addr)
|
|
47
|
+
|
|
48
|
+
# Read 18 bytes of measurement data
|
|
49
|
+
# Format: [CO2_MSB, CO2_LSB, CO2_CRC] [CO2_MSB, CO2_LSB, CO2_CRC] [T_MSB, T_LSB, T_CRC] [T_MSB, T_LSB, T_CRC] [H_MSB, H_LSB, H_CRC] [H_MSB, H_LSB, H_CRC]
|
|
50
|
+
# Actually: 3 float32 values (4 bytes each + CRC after every 2 bytes = 6 bytes per value)
|
|
51
|
+
@i2c.write(addr, [CMD_READ_MEASUREMENT >> 8, CMD_READ_MEASUREMENT & 0xFF])
|
|
52
|
+
sleep(0.01) # Wait for response
|
|
53
|
+
raw = @i2c.read(addr, 18)
|
|
54
|
+
|
|
55
|
+
# Parse float32 values with CRC validation
|
|
56
|
+
co2_ppm = parse_float32_with_crc(raw, 0)
|
|
57
|
+
temp_c = parse_float32_with_crc(raw, 6)
|
|
58
|
+
humidity = parse_float32_with_crc(raw, 12)
|
|
59
|
+
|
|
60
|
+
{
|
|
61
|
+
co2_ppm: co2_ppm.round(1),
|
|
62
|
+
temperature_c: temp_c.round(2),
|
|
63
|
+
humidity: humidity.round(1)
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# Initialize sensor with measurement interval and start continuous measurement
|
|
70
|
+
def initialize_sensor(addr)
|
|
71
|
+
# Set measurement interval
|
|
72
|
+
write_command_with_arg(addr, CMD_SET_INTERVAL, @interval)
|
|
73
|
+
sleep(0.01)
|
|
74
|
+
|
|
75
|
+
# Start continuous measurement with optional pressure compensation
|
|
76
|
+
write_command_with_arg(addr, CMD_START_MEASUREMENT, @ambient_pressure)
|
|
77
|
+
sleep(0.02)
|
|
78
|
+
|
|
79
|
+
@initialized = true
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Wait for data to be ready (poll data ready status)
|
|
83
|
+
def wait_for_data_ready(addr, timeout: 2.0)
|
|
84
|
+
start_time = Time.now
|
|
85
|
+
loop do
|
|
86
|
+
@i2c.write(addr, [CMD_DATA_READY >> 8, CMD_DATA_READY & 0xFF])
|
|
87
|
+
sleep(0.01)
|
|
88
|
+
status = @i2c.read(addr, 3)
|
|
89
|
+
# Data ready when bit 0 of word is 1
|
|
90
|
+
data_ready = (status[1] & 0x01) == 1
|
|
91
|
+
return if data_ready
|
|
92
|
+
|
|
93
|
+
raise IOError, 'SCD30 data ready timeout' if Time.now - start_time > timeout
|
|
94
|
+
|
|
95
|
+
sleep(0.1)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Write a command with a 16-bit argument and CRC
|
|
100
|
+
def write_command_with_arg(addr, command, arg)
|
|
101
|
+
cmd_msb = command >> 8
|
|
102
|
+
cmd_lsb = command & 0xFF
|
|
103
|
+
arg_msb = arg >> 8
|
|
104
|
+
arg_lsb = arg & 0xFF
|
|
105
|
+
crc = calculate_crc8([arg_msb, arg_lsb])
|
|
106
|
+
@i2c.write(addr, [cmd_msb, cmd_lsb, arg_msb, arg_lsb, crc])
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Parse a float32 value from 6 bytes (4 data + 2 CRC)
|
|
110
|
+
# Format: [MSB0, LSB0, CRC0, MSB1, LSB1, CRC1]
|
|
111
|
+
def parse_float32_with_crc(data, offset)
|
|
112
|
+
# Verify CRCs
|
|
113
|
+
crc0 = calculate_crc8([data[offset], data[offset + 1]])
|
|
114
|
+
crc1 = calculate_crc8([data[offset + 3], data[offset + 4]])
|
|
115
|
+
raise IOError, 'SCD30 CRC error' unless crc0 == data[offset + 2] && crc1 == data[offset + 5]
|
|
116
|
+
|
|
117
|
+
# Combine bytes into uint32 and convert to float32
|
|
118
|
+
bytes = [
|
|
119
|
+
data[offset],
|
|
120
|
+
data[offset + 1],
|
|
121
|
+
data[offset + 3],
|
|
122
|
+
data[offset + 4]
|
|
123
|
+
].pack('C4').unpack1('N')
|
|
124
|
+
[bytes].pack('L>').unpack1('g')
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Calculate CRC-8 checksum (polynomial: 0x31, init: 0xFF)
|
|
128
|
+
def calculate_crc8(data)
|
|
129
|
+
crc = 0xFF
|
|
130
|
+
data.each do |byte|
|
|
131
|
+
crc ^= byte
|
|
132
|
+
8.times do
|
|
133
|
+
crc = (crc & 0x80) != 0 ? ((crc << 1) ^ 0x31) : (crc << 1)
|
|
134
|
+
crc &= 0xFF
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
crc
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dredger
|
|
4
|
+
module IoT
|
|
5
|
+
module Sensors
|
|
6
|
+
# YF-S201 water flow sensor (hall effect pulse counter)
|
|
7
|
+
# Measures liquid flow rate by counting pulses from hall effect sensor
|
|
8
|
+
# Uses a provider interface to allow simulation in tests and hardware backends in production.
|
|
9
|
+
class YFS201 < BaseSensor
|
|
10
|
+
# provider must respond to :read_flow_rate(pin_label, duration) -> { flow_rate_lpm: Float, pulses: Integer }
|
|
11
|
+
def initialize(pin_label:, provider:, sample_duration: 1.0, metadata: {})
|
|
12
|
+
super(metadata: metadata)
|
|
13
|
+
@pin_label = pin_label
|
|
14
|
+
@provider = provider
|
|
15
|
+
@sample_duration = sample_duration
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def readings
|
|
19
|
+
sample = @provider.read_flow_rate(@pin_label, @sample_duration)
|
|
20
|
+
[
|
|
21
|
+
reading(
|
|
22
|
+
sensor_type: 'flow_rate',
|
|
23
|
+
value: sample[:flow_rate_lpm],
|
|
24
|
+
unit: 'L/min',
|
|
25
|
+
metadata: { pulses: sample[:pulses], duration: @sample_duration }
|
|
26
|
+
)
|
|
27
|
+
]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dredger
|
|
4
|
+
module IoT
|
|
5
|
+
module Sensors
|
|
6
|
+
# Hardware provider for YF-S201 water flow sensor via GPIO pulse counting.
|
|
7
|
+
# Datasheet: https://www.hobbytronics.co.uk/download/YF-S201.pdf
|
|
8
|
+
#
|
|
9
|
+
# Key features:
|
|
10
|
+
# - Hall effect sensor with digital output
|
|
11
|
+
# - Flow rate range: 1-30 L/min
|
|
12
|
+
# - Pulse frequency: ~4.5 * flow_rate (L/min)
|
|
13
|
+
# - Working voltage: 5V DC
|
|
14
|
+
# - Thread size: G1/2" (DN15)
|
|
15
|
+
#
|
|
16
|
+
# Calibration:
|
|
17
|
+
# - Frequency (Hz) = 7.5 * flow rate (L/min) (official spec)
|
|
18
|
+
# - In practice: F = K * Q where K ≈ 4.5-7.5 depending on unit
|
|
19
|
+
# - Default calibration factor: 7.5 (pulses per liter)
|
|
20
|
+
class YFS201Provider
|
|
21
|
+
# gpio_bus: a GPIO bus interface (e.g., Dredger::IoT::Bus::Auto.gpio)
|
|
22
|
+
# calibration_factor: pulses per liter (default: 7.5 for YF-S201)
|
|
23
|
+
def initialize(gpio_bus:, calibration_factor: 7.5)
|
|
24
|
+
@gpio = gpio_bus
|
|
25
|
+
@calibration_factor = calibration_factor
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Read flow rate by counting pulses over the specified duration.
|
|
29
|
+
# Returns { flow_rate_lpm: Float, pulses: Integer }
|
|
30
|
+
#
|
|
31
|
+
# @param pin_label [String] GPIO pin label (e.g., 'P9_12', 'GPIO17')
|
|
32
|
+
# @param duration [Float] Sampling duration in seconds
|
|
33
|
+
def read_flow_rate(pin_label, duration)
|
|
34
|
+
# Configure pin as input
|
|
35
|
+
@gpio.set_direction(pin_label, :in)
|
|
36
|
+
|
|
37
|
+
# Count rising edge pulses over the duration
|
|
38
|
+
pulses = count_pulses(pin_label, duration)
|
|
39
|
+
|
|
40
|
+
# Calculate flow rate in L/min
|
|
41
|
+
# pulses_per_second = pulses / duration
|
|
42
|
+
# liters_per_second = pulses_per_second / calibration_factor
|
|
43
|
+
# liters_per_minute = liters_per_second * 60
|
|
44
|
+
flow_rate_lpm = (pulses / duration / @calibration_factor * 60.0).round(2)
|
|
45
|
+
|
|
46
|
+
{
|
|
47
|
+
flow_rate_lpm: flow_rate_lpm,
|
|
48
|
+
pulses: pulses
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# Count rising edge transitions on the pin over the specified duration.
|
|
55
|
+
# This is a simplified implementation - production code should use:
|
|
56
|
+
# - Hardware interrupts (kernel module or pigpio daemon)
|
|
57
|
+
# - Edge detection via sysfs GPIO (epoll)
|
|
58
|
+
# - High-priority thread for accurate timing
|
|
59
|
+
def count_pulses(pin_label, duration)
|
|
60
|
+
pulses = 0
|
|
61
|
+
last_state = @gpio.read(pin_label)
|
|
62
|
+
start_time = Time.now
|
|
63
|
+
|
|
64
|
+
# Poll GPIO at high frequency to detect edges
|
|
65
|
+
# Note: This is CPU-intensive and timing-dependent
|
|
66
|
+
# For production, use hardware interrupts or kernel GPIO edge detection
|
|
67
|
+
while Time.now - start_time < duration
|
|
68
|
+
current_state = @gpio.read(pin_label)
|
|
69
|
+
|
|
70
|
+
# Detect rising edge (0 -> 1 transition)
|
|
71
|
+
if current_state == 1 && last_state == 0
|
|
72
|
+
pulses += 1
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
last_state = current_state
|
|
76
|
+
|
|
77
|
+
# Small sleep to reduce CPU usage
|
|
78
|
+
# Trade-off: Higher sleep = lower CPU, but may miss pulses
|
|
79
|
+
# For accurate counting, use interrupts instead
|
|
80
|
+
sleep(0.0001) # 0.1ms
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
pulses
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
data/lib/dredger/iot/sensors.rb
CHANGED
|
@@ -17,3 +17,11 @@ require_relative 'sensors/tsl2561'
|
|
|
17
17
|
require_relative 'sensors/tsl2561_provider'
|
|
18
18
|
require_relative 'sensors/ina219'
|
|
19
19
|
require_relative 'sensors/ina219_provider'
|
|
20
|
+
require_relative 'sensors/adxl345'
|
|
21
|
+
require_relative 'sensors/adxl345_provider'
|
|
22
|
+
require_relative 'sensors/scd30'
|
|
23
|
+
require_relative 'sensors/scd30_provider'
|
|
24
|
+
require_relative 'sensors/yf_s201'
|
|
25
|
+
require_relative 'sensors/yf_s201_provider'
|
|
26
|
+
require_relative 'sensors/neo6m'
|
|
27
|
+
require_relative 'sensors/neo6m_provider'
|
data/lib/dredger/iot/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: dredger-iot
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- The Mad Botter INC
|
|
@@ -49,9 +49,12 @@ files:
|
|
|
49
49
|
- lib/dredger/iot/bus/i2c_linux.rb
|
|
50
50
|
- lib/dredger/iot/pins.rb
|
|
51
51
|
- lib/dredger/iot/pins/beaglebone.rb
|
|
52
|
+
- lib/dredger/iot/pins/raspberry_pi.rb
|
|
52
53
|
- lib/dredger/iot/reading.rb
|
|
53
54
|
- lib/dredger/iot/scheduler.rb
|
|
54
55
|
- lib/dredger/iot/sensors.rb
|
|
56
|
+
- lib/dredger/iot/sensors/adxl345.rb
|
|
57
|
+
- lib/dredger/iot/sensors/adxl345_provider.rb
|
|
55
58
|
- lib/dredger/iot/sensors/base_sensor.rb
|
|
56
59
|
- lib/dredger/iot/sensors/bh1750.rb
|
|
57
60
|
- lib/dredger/iot/sensors/bh1750_provider.rb
|
|
@@ -65,10 +68,16 @@ files:
|
|
|
65
68
|
- lib/dredger/iot/sensors/ina219.rb
|
|
66
69
|
- lib/dredger/iot/sensors/ina219_provider.rb
|
|
67
70
|
- lib/dredger/iot/sensors/mcp9808.rb
|
|
71
|
+
- lib/dredger/iot/sensors/neo6m.rb
|
|
72
|
+
- lib/dredger/iot/sensors/neo6m_provider.rb
|
|
73
|
+
- lib/dredger/iot/sensors/scd30.rb
|
|
74
|
+
- lib/dredger/iot/sensors/scd30_provider.rb
|
|
68
75
|
- lib/dredger/iot/sensors/sht31.rb
|
|
69
76
|
- lib/dredger/iot/sensors/sht31_provider.rb
|
|
70
77
|
- lib/dredger/iot/sensors/tsl2561.rb
|
|
71
78
|
- lib/dredger/iot/sensors/tsl2561_provider.rb
|
|
79
|
+
- lib/dredger/iot/sensors/yf_s201.rb
|
|
80
|
+
- lib/dredger/iot/sensors/yf_s201_provider.rb
|
|
72
81
|
- lib/dredger/iot/version.rb
|
|
73
82
|
homepage: https://github.com/TheMadBotterINC/dredger-iot
|
|
74
83
|
licenses:
|
|
@@ -97,7 +106,7 @@ post_install_message: "\n ═════════════════
|
|
|
97
106
|
\ | \n |_____________________________| \n
|
|
98
107
|
\ ~~~ \\ // ~~~ \n \\_______________//
|
|
99
108
|
\ \n ═══════════════════════════════════════════════════════════════════\nHardware
|
|
100
|
-
Integration for Embedded Linux v0.
|
|
109
|
+
Integration for Embedded Linux v0.3.0\n ═══════════════════════════════════════════════════════════════════\n\n
|
|
101
110
|
\ \U0001F389 Thanks for installing!\n\n \U0001F4DA Hardware Setup (kernel modules
|
|
102
111
|
& permissions):\n https://github.com/TheMadBotterINC/dredger-iot#hardware-setup\n\n
|
|
103
112
|
\ \U0001F680 Quick Start:\n require 'dredger/iot'\n gpio = Dredger::IoT::Bus::Auto.gpio\n
|