dredger-iot 0.2.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6710d17818b007805d0634e65fde9048c5fb191b9a11d4c5ac3d09aeccf9483e
4
- data.tar.gz: cb7d2e68d335ee490f902b9eaed4b30b6499b8a688336f47678619e862b48cf6
3
+ metadata.gz: 701ad99dd1f271df310ba3dffbb3295084b54e64689f4d3ed77f9b6ab9e31df5
4
+ data.tar.gz: e9b5ba95c4d2af83b529e6f66af5c81fb83c43f3f679e89e894209661651f93e
5
5
  SHA512:
6
- metadata.gz: c14e577283263ea3a3674280cf416bde53ce0c172776fe85c2a9bde00835d7b42fd5badf5bcff6e8dc16b041abae9d810ad7fe510f2afbb86a1c7ffcf0b9d35b
7
- data.tar.gz: bb7fc628012d3f9e26d83a792e24fdf20493de7cf984dee32a17658221aa53a7e5b208c49bbd528bb431320354edec09b3dd4506b52fff1b05ae6b4965de1daf
6
+ metadata.gz: 5362ae41f8f92d403809728857c139b00e3d8141e8f83725a3cc302eb7e7ace2c32b8bd7306fba5e83c2ee197e42c633b7beaeb9a84b9b77f1d95299e80aad1d
7
+ data.tar.gz: 220b5834cd302cef375c3922211c055788855d4b516b7260aa952d13fc312a3f9c028248863906e93a38badbd32839d2083783bb1431ba5fd1ada0b4df92bca5
data/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ 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
+
10
22
  ## [0.2.1] - 2025-10-05
11
23
 
12
24
  ### Added
@@ -70,7 +82,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
70
82
  - RuboCop configuration and compliance
71
83
  - Comprehensive documentation and usage examples
72
84
 
73
- [Unreleased]: https://github.com/TheMadBotterINC/dredger-iot/compare/v0.2.1...HEAD
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
74
87
  [0.2.1]: https://github.com/TheMadBotterINC/dredger-iot/compare/v0.2.0...v0.2.1
75
88
  [0.2.0]: https://github.com/TheMadBotterINC/dredger-iot/compare/v0.1.2...v0.2.0
76
89
  [0.1.2]: https://github.com/TheMadBotterINC/dredger-iot/compare/v0.1.1...v0.1.2
data/README.md CHANGED
@@ -113,15 +113,24 @@ If you run on a development host (no /dev/gpiochip0), Auto will default to the s
113
113
 
114
114
  Dredger-IoT includes drivers for popular embedded sensors:
115
115
 
116
+ **Environmental Sensors:**
116
117
  - **DHT22** - GPIO humidity/temperature sensor
117
118
  - **BME280** - I2C temperature/humidity/pressure sensor
118
- - **DS18B20** - 1-Wire digital temperature sensor
119
119
  - **BMP180** - I2C barometric pressure/temperature sensor
120
120
  - **MCP9808** - I2C high-accuracy temperature sensor
121
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:**
122
126
  - **BH1750** - I2C ambient light sensor (lux)
123
127
  - **TSL2561** - I2C ambient light sensor (lux)
128
+ - **ADXL345** - I2C 3-axis accelerometer (vibration monitoring) (NEW)
129
+
130
+ **Industrial Sensors:**
124
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)
125
134
 
126
135
  Sensors use a provider pattern for testability and hardware abstraction.
127
136
 
@@ -245,6 +254,101 @@ temp = sensor.readings.first
245
254
  puts "#{temp.value}°C"
246
255
  ```
247
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
+
248
352
  ### Multiple Sensors with Scheduled Polling
249
353
 
250
354
  ```ruby path=null start=null
@@ -596,6 +700,25 @@ reading.timestamp # Time object when reading was taken
596
700
  dredger read ina219 0x40 --shunt 0.1
597
701
  ```
598
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
+
599
722
  ### Scheduling
600
723
 
601
724
  #### `Dredger::IoT::Scheduler.periodic_with_jitter`
@@ -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
@@ -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'
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Dredger
4
4
  module IoT
5
- VERSION = '0.2.1'
5
+ VERSION = '0.3.0'
6
6
  end
7
7
  end
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.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - The Mad Botter INC
@@ -53,6 +53,8 @@ files:
53
53
  - lib/dredger/iot/reading.rb
54
54
  - lib/dredger/iot/scheduler.rb
55
55
  - lib/dredger/iot/sensors.rb
56
+ - lib/dredger/iot/sensors/adxl345.rb
57
+ - lib/dredger/iot/sensors/adxl345_provider.rb
56
58
  - lib/dredger/iot/sensors/base_sensor.rb
57
59
  - lib/dredger/iot/sensors/bh1750.rb
58
60
  - lib/dredger/iot/sensors/bh1750_provider.rb
@@ -66,10 +68,16 @@ files:
66
68
  - lib/dredger/iot/sensors/ina219.rb
67
69
  - lib/dredger/iot/sensors/ina219_provider.rb
68
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
69
75
  - lib/dredger/iot/sensors/sht31.rb
70
76
  - lib/dredger/iot/sensors/sht31_provider.rb
71
77
  - lib/dredger/iot/sensors/tsl2561.rb
72
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
73
81
  - lib/dredger/iot/version.rb
74
82
  homepage: https://github.com/TheMadBotterINC/dredger-iot
75
83
  licenses:
@@ -98,7 +106,7 @@ post_install_message: "\n ═════════════════
98
106
  \ | \n |_____________________________| \n
99
107
  \ ~~~ \\ // ~~~ \n \\_______________//
100
108
  \ \n ═══════════════════════════════════════════════════════════════════\nHardware
101
- Integration for Embedded Linux v0.2.1\n ═══════════════════════════════════════════════════════════════════\n\n
109
+ Integration for Embedded Linux v0.3.0\n ═══════════════════════════════════════════════════════════════════\n\n
102
110
  \ \U0001F389 Thanks for installing!\n\n \U0001F4DA Hardware Setup (kernel modules
103
111
  & permissions):\n https://github.com/TheMadBotterINC/dredger-iot#hardware-setup\n\n
104
112
  \ \U0001F680 Quick Start:\n require 'dredger/iot'\n gpio = Dredger::IoT::Bus::Auto.gpio\n