├── .github └── workflows │ └── flowzone.yml ├── .versionbot └── CHANGELOG.yml ├── CHANGELOG.md ├── Dockerfile.template ├── README.md ├── VERSION ├── balena.yml ├── build-images.sh ├── idetect.py ├── information.py ├── logo.png ├── reading.py ├── repo.yml ├── sensor.py └── transformers.py /.github/workflows/flowzone.yml: -------------------------------------------------------------------------------- 1 | name: Flowzone 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize, closed] 6 | branches: 7 | - "main" 8 | - "master" 9 | 10 | jobs: 11 | flowzone: 12 | name: Flowzone 13 | uses: product-os/flowzone/.github/workflows/flowzone.yml@master 14 | secrets: inherit 15 | with: 16 | balena_slugs: 'balenalabs/sensor,balenalabs/sensor-aarch64,balenalabs/sensor-armv6hf,balenalabs/sensor-armv7hf' 17 | -------------------------------------------------------------------------------- /.versionbot/CHANGELOG.yml: -------------------------------------------------------------------------------- 1 | - commits: 2 | - subject: Delete docker-compose.yml 3 | hash: e6cf9d84277f33ed4a3f28bf88c82e28b697da57 4 | body: "" 5 | footer: 6 | Change-type: patch 7 | change-type: patch 8 | author: Alan Boris 9 | nested: [] 10 | version: 0.0.4 11 | title: "" 12 | date: 2023-07-21T04:59:25.633Z 13 | - commits: 14 | - subject: Fix typo, force rebuild 15 | hash: 11a6966c04819369b19d74a6941fd86d706f4b85 16 | body: "" 17 | footer: 18 | Change-type: patch 19 | change-type: patch 20 | author: Alan Boris 21 | nested: [] 22 | version: 0.0.3 23 | title: "" 24 | date: 2023-07-21T00:20:16.516Z 25 | - commits: 26 | - subject: moving from balenablocks to balena-labs-projects 27 | hash: fc6c8e485ecb0b3d9678c46b8bb355ec56da6190 28 | body: "" 29 | footer: 30 | Signed-off-by: Flynn Joffray 31 | signed-off-by: Flynn Joffray 32 | Change-type: patch 33 | change-type: patch 34 | author: Flynn Joffray 35 | nested: [] 36 | - subject: Pizero build script 37 | hash: 7a516d1a425bb24eb51f3e4c4ce292cc32c34093 38 | body: "" 39 | footer: 40 | Change-type: patch 41 | change-type: patch 42 | Signed-off-by: Phil Wilson 43 | signed-off-by: Phil Wilson 44 | author: Phil Wilson 45 | nested: [] 46 | - subject: Add repo file 47 | hash: ea6d8d13ba795cea380c4e4e055bff93ac56bb2d 48 | body: "" 49 | footer: 50 | Change-type: patch 51 | change-type: patch 52 | Signed-off-by: Phil Wilson 53 | signed-off-by: Phil Wilson 54 | author: Phil Wilson 55 | nested: [] 56 | - subject: Added build script 57 | hash: 25936ce0f9d86faf983e2f6f6b8e7af0811b24e5 58 | body: "" 59 | footer: 60 | Change-type: patch 61 | change-type: patch 62 | Signed-off-by: Phil Wilson 63 | signed-off-by: Phil Wilson 64 | author: Phil Wilson 65 | nested: [] 66 | version: 0.0.2 67 | title: "" 68 | date: 2022-11-16T01:55:48.321Z 69 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file 4 | automatically by Versionist. DO NOT EDIT THIS FILE MANUALLY! 5 | This project adheres to [Semantic Versioning](http://semver.org/). 6 | 7 | # v0.0.4 8 | ## (2023-07-21) 9 | 10 | * Delete docker-compose.yml [Alan Boris] 11 | 12 | # v0.0.3 13 | ## (2023-07-21) 14 | 15 | * Fix typo, force rebuild [Alan Boris] 16 | 17 | # v0.0.2 18 | ## (2022-11-16) 19 | 20 | * moving from balenablocks to balena-labs-projects [Flynn Joffray] 21 | * Pizero build script [Phil Wilson] 22 | * Add repo file [Phil Wilson] 23 | * Added build script [Phil Wilson] 24 | -------------------------------------------------------------------------------- /Dockerfile.template: -------------------------------------------------------------------------------- 1 | FROM balenalib/%%BALENA_ARCH%%-debian-python 2 | 3 | RUN install_packages \ 4 | nano \ 5 | i2c-tools \ 6 | kmod \ 7 | libiio0 \ 8 | libiio-utils \ 9 | python3-libiio 10 | 11 | WORKDIR /usr/src/app 12 | 13 | RUN pip3 install smbus2 paho-mqtt requests 14 | 15 | COPY *.py ./ 16 | 17 | CMD ["python3", "sensor.py"] 18 | 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sensor-block 2 | Auto-detects connected i2c sensors and publsihes data on HTTP or MQTT. 3 | 4 | ## Features 5 | - Uses Indusrial IO (iio) to communicate with sensors, utilizing drivers already in the kernel to talk to the sensor directly 6 | - Data published via mqtt and/or http 7 | - Provides raw sensor data or "tranforms" the sensor data into a more standardized format 8 | - json output can either be one measurement per sensor or all sensor fields in one list 9 | 10 | ## Usage 11 | 12 | **Docker compose file** 13 | 14 | To use this image, create a container in your `docker-compose.yml` file as shown below: 15 | 16 | ``` 17 | services: 18 | sensor: 19 | image: bh.cr/balenalabs/sensor- 20 | privileged: true 21 | labels: 22 | io.balena.features.kernel-modules: '1' 23 | io.balena.features.sysfs: '1' 24 | io.balena.features.supervisor-api: '1' 25 | expose: 26 | - '7575' # Only needed if using http server 27 | ``` 28 | 29 | ## Overview/Compatibility 30 | This block utilizes the [Linux Industrial I/O Subsystem](https://wiki.analog.com/software/linux/docs/iio/iio) ("iio") which is a kernel subsystem that allows for ease of implementing drivers for sensors and other similar devices such as ADCs, DACs, etc. You can see a list of available iio drivers [here](https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/tree/drivers/iio?h=linux-5.4.y) but in order to save space, most OSes do not include all these drivers. The easiest way to check if your board supports a driver is to use the modinfo command on a running device. For instance: 31 | ``` 32 | modinfo ti-ads1015 33 | ``` 34 | This command searches the running kernel for all the drivers it includes and prints out a description of any that are found to match. (Use the info in the Kconfig file in each folder of the driver list above to find the proper driver name to use with this command) BalenaOS for Raspberry Pi 3 includes a small subset of these drivers which are listed in the table below. As we continue to test sensors and improve the block, more sensors should be supported and the chart will be updated accordingly. Also note that as kernel and OS versions change, the supported drivers may change somewhat as well. It's best therefore to use modinfo as described above to test compatibility with any sensor being considered for use with this block. 35 | 36 | | Sensor Model | Sensor Name | Driver Name | Address(es) | Tested? | 37 | | ------------ | ----------- | ----------- | ----------- | ------- | 38 | | AD5301 | Analog Devices AD5446 and similar single channel DACs driver, TI DACs | ads5446 | 0xC, 0xD, 0xE, 0xF | Not tested | 39 | | APDS9960 | Avago APDS9960 gesture/RGB/ALS/proximity sensor | apds9960 | 0x39 | Yes, NOT working | 40 | | BME680 | Bosch Sensortec BME680 sensor | bme680 | 0x76, 0x77 | Yes, works | 41 | | BMP180 | Bosch Sensortec BMP180 sensor | bmp280 | 0x77 | Not tested | 42 | | BMP280 | Bosch Sensortec BMP280 sensor | bmp280 | 0x76, 0x77 | Yes, works | 43 | | BME280 | Bosch Sensortec BME280 sensor | bmp280 | 0x76, 0x77 | Yes, works | 44 | | HDC1000 | TI HDC100x relative humidity and temperature sensor | hdc100x | 0x40 - 0x43 | Not tested | 45 | | HTU21 | Measurement Specialties HTU21 humidity & temperature sensor | htu21 | 0x40 | Yes, works | 46 | | MS8607 | TE Connectivity PHT sensor | htu21 | 0x40, 0x76 | Yes, works partially (no pressure reading) | 47 | | MCP342x | Microchip Technology MCP3421/2/3/4/5/6/7/8 ADC | mcp3422 | 0x68 - 0x6F | Not tested | 48 | | ADS1015 | Texas Instruments ADS1015 ADC | ti-ads1015 | 0x48 - 0x4B | Yes, NOT working | 49 | | TSL4531 | TAOS TSL4531 ambient light sensors | tsl4531 | 0x29 | Not tested | 50 | | VEML6070 | VEML6070 UV A light sensor | veml6070 | 0x38, 0x39 | Yes, works | 51 | 52 | By default, the block searches for sensors on SMBus number 1 (/dev/i2c-1) however you can set the bus number (an integer value) using the `BUS_NUMBER` service variable. 53 | 54 | ### Publishing Data 55 | 56 | The sensor data is available in json format either as an mqtt payload and/or via the built-in http server. If you include an mqtt broker container named "mqtt" in your application, the block will automatically publish to that. If you provide an address for the `MQTT_ADDRESS` service variable, it will publish to that broker instead. The default interval for publishing data is eight seconds, which you can override with the `MQTT_PUB_INTERVAL` service variable by providing a value in seconds. The default topic is set to `sensors` (which is compatible with the [connector block](https://github.com/balena-labs-projects/connector#mqtt) ) which can be overridden by setting the `MQTT_PUB_TOPIC` service variable. 57 | 58 | If no mqtt broker is set, the http server will be available on port 7575. To force the http server to be active even with mqtt, set the `ALWAYS_USE_HTTPSERVER` service variable to True. 59 | 60 | The http data defaults to only be available to other containers in the application via `sensor:7575` - if you want this to be available externally, you'll need to map port 7575 to an external port in your docker-compose file. 61 | 62 | ## Data 63 | 64 | The JSON for raw sensor data is available in one of two formats and is determined by the `COLLAPSE_FIELDS` service variable. The default value of `0` (zero) causes each sensor to output a separate measurement: 65 | ``` 66 | [{"measurement": "htu21", "fields": {"humidityrelative": 29700, "temp": 23356}}, {"measurement": "bmp280", "fields": {"pressure": 99.911941406, "temp": 23710}}, {"measurement": "short_UUID", "fields": {"short_uuid": "0f33e61"}}] 67 | ``` 68 | 69 | Changing `COLLAPSE_FIELDS` to `1` collapses all of the field values into one list like this: 70 | ``` 71 | {"short_uuid": "0f33e61", "humidityrelative": 29700, "temp": 23356, "pressure": 99.911941406} 72 | ``` 73 | Note that if two sensors output the same field name, it will only show up once in the list from one of the sensors. This feature is best used when the field names from sensors do not overlap. 74 | 75 | The above examples display the raw data from the sensor as exposed by the driver, which is the default setting for the block. In many cases, the values and names need transformations to be useful. You can change the `RAW_VALUES` service variable from the default value of `1` to `0` (zero) to output transformed data instead. All the transformations are defined per-sensor in the `transformations.py` file which you can edit to your needs. We've included some basic ones for you. For example, here is the raw output of a bme680: 76 | ``` 77 | {"short_uuid": "0f33e61", "humidityrelative": 35.524, "pressure": 1005.48, "resistance": 52046.0, "temp": 24620.0} 78 | ``` 79 | Here is the transformed output with `RAW_VALUES` set to `0`: 80 | ``` 81 | {"short_uuid": "0f33e61", "pressure": 1005.4, "resistance": 65454.0, "humidity": 35.541, "temperature": 24.54} 82 | ``` 83 | 84 | When using transformed data outputs, you can change the temperature field from Celsius to Farenheit by setting the `TEMP_UNIT` variable to `F` (the default is `C`) 85 | 86 | Note that the device's short UUID is always included in the data output, which can be useful for aggregating data from multiple devices. The short UUID is a string value so it will not show up in Grafana dashboards based on the [dashboard block](https://github.com/balena-labs-projects/dashboard). 87 | 88 | ## Use with other blocks 89 | 90 | The sensor block works well with our [connector block](https://github.com/balena-labs-projects/connector) and [dashboard block](https://github.com/balena-labs-projects/dashboard). See the latest version of [balenaSense](https://github.com/balena-labs-projects/balena-sense) for an example of using all of these blocks together to read data from one or more sensor. 91 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.0.4 -------------------------------------------------------------------------------- /balena.yml: -------------------------------------------------------------------------------- 1 | name: sensor 2 | description: >- 3 | detect and publish data from supported i2c sensors on the Raspberry Pi via 4 | http and/or mqtt 5 | version: 0.0.4 6 | type: sw.application 7 | assets: 8 | repository: 9 | type: blob.asset 10 | data: 11 | url: 'https://github.com/balena-labs-projects/sensor' 12 | logo: 13 | type: blob.asset 14 | data: 15 | url: 'https://raw.githubusercontent.com/balena-labs-projects/sensor/master/logo.png' 16 | data: 17 | defaultDeviceType: raspberrypi3 18 | supportedDeviceTypes: 19 | - raspberry-pi 20 | - raspberrypi4-64 21 | - fincm3 22 | - raspberrypi3 23 | - raspberrypi3-64 24 | - raspberrypi400-64 25 | -------------------------------------------------------------------------------- /build-images.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | BLOCK_NAME="sensor" 4 | 5 | function build_and_push_image () { 6 | local DOCKER_REPO=$1 7 | local BALENA_MACHINE_NAME=$2 8 | local DOCKER_ARCH=$3 9 | local BALENA_ARCH=$4 10 | 11 | echo "Building for machine name $BALENA_MACHINE_NAME, platform $DOCKER_ARCH, pushing to $DOCKER_REPO/$BLOCK_NAME" 12 | 13 | sed "s/%%BALENA_MACHINE_NAME%%/$BALENA_MACHINE_NAME/g" ./Dockerfile.template > ./Dockerfile.$BALENA_MACHINE_NAME 14 | sed -i.bak "s/%%BALENA_ARCH%%/$BALENA_ARCH/g" ./Dockerfile.$BALENA_MACHINE_NAME && rm ./Dockerfile.$BALENA_MACHINE_NAME.bak 15 | docker buildx build -t $DOCKER_REPO/$BLOCK_NAME:$BALENA_MACHINE_NAME --load --platform $DOCKER_ARCH --file Dockerfile.$BALENA_MACHINE_NAME . 16 | 17 | echo "Publishing..." 18 | docker push $DOCKER_REPO/$BLOCK_NAME:$BALENA_MACHINE_NAME 19 | 20 | echo "Cleaning up..." 21 | rm Dockerfile.$BALENA_MACHINE_NAME 22 | } 23 | 24 | function create_and_push_manifest() { 25 | local DOCKER_REPO=$1 26 | docker manifest rm $DOCKER_REPO/$BLOCK_NAME:latest || true 27 | docker manifest create $DOCKER_REPO/$BLOCK_NAME:latest \ 28 | --amend $DOCKER_REPO/$BLOCK_NAME:aarch64-$VERSION \ 29 | --amend $DOCKER_REPO/$BLOCK_NAME:raspberrypi3 \ 30 | --amend $DOCKER_REPO/$BLOCK_NAME:raspberrypi4-64 31 | docker manifest push $DOCKER_REPO/$BLOCK_NAME:latest 32 | } 33 | 34 | # YOu can pass in a repo (such as a test docker repo) or accept the default 35 | DOCKER_REPO=${1:-bh.cr/balenalabs} 36 | 37 | build_and_push_image $DOCKER_REPO "raspberry-pi" "linux/arm/v6" "rpi" 38 | build_and_push_image $DOCKER_REPO "raspberrypi4-64" "linux/arm64" "aarch64" 39 | build_and_push_image $DOCKER_REPO "raspberrypi3" "linux/arm/v7" "armv7hf" 40 | create_and_push_manifest $DOCKER_REPO 41 | -------------------------------------------------------------------------------- /idetect.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import subprocess 3 | import sys 4 | from smbus2 import SMBus 5 | import errno 6 | import os 7 | import iio 8 | import json 9 | import time 10 | 11 | # Decimal address values 12 | devices = { 13 | 12: "ad5446", 14 | 13: "ad5446", 15 | 14: "ad5446", 16 | 15: "ad5446", 17 | 41: "tsl4531", 18 | 56: "veml6070", 19 | 57: "multiple", 20 | 64: "multiple", 21 | 65: "hdc100x", 22 | 66: "hdc100x", 23 | 67: "hdc100x", 24 | 64: "multiple", 25 | 72: "ti-ads1015", 26 | 73: "ti-ads1015", 27 | 74: "ti-ads1015", 28 | 75: "ti-ads1015", 29 | 104: "mcp3422", 30 | 105: "mcp3422", 31 | 106: "mcp3422", 32 | 107: "mcp3422", 33 | 108: "mcp3422", 34 | 109: "mcp3422", 35 | 110: "mcp3422", 36 | 111: "mcp3422", 37 | 118: "multiple", 38 | 119: "multiple" 39 | } 40 | 41 | del_drivers = { 42 | "ad5446.c": "ad5446.c", 43 | "apds9960": "apds9960", 44 | "bme680": "bme680-i2c", 45 | "bmp280": "bmp280-i2c", 46 | "dht11": "dht11", 47 | "hdc100": "hdc100", 48 | "htu21": "htu21", 49 | "mcp320x": "mcp320x", 50 | "mcp3422": "mcp3422", 51 | "ti-ads1015": "ti-ads1015", 52 | "tsl4531": "tsl4531", 53 | "veml6070": "veml6070" 54 | } 55 | 56 | bosch_chip_id = { 57 | 0x61: "bme680", 58 | 0x55: "bmp180", 59 | 0x58: "bmp280", 60 | 0x60: "bme280" 61 | } 62 | 63 | def read_chip_id(bus, device, loc): 64 | chip_id = -1 65 | try: 66 | chip_id = bus.read_byte_data(device, loc) 67 | except Exception as e: 68 | print("Error while reading chip ID of device at address {0}: {1}".format(e, hex(device))) 69 | 70 | if chip_id == -1: # Wait a tiny bit and try one more time 71 | time.sleep(2) 72 | try: 73 | chip_id = bus.read_byte_data(device, loc) 74 | except Exception as e: 75 | print("Error again while reading chip ID of device at address {0}: {1}".format(e, hex(device))) 76 | 77 | return chip_id 78 | 79 | def detect_iio_sensors(): 80 | bus_number = int(os.getenv('BUS_NUMBER', '1')) # default 1 indicates /dev/i2c-1 81 | bus = SMBus(bus_number) 82 | device_count = 0 83 | active = [] 84 | chip_id = 0 85 | 86 | print("======== Searching i2c bus for devices... ========") 87 | for device in range(3, 128): 88 | try: 89 | bus.write_byte(device, 0) 90 | print("Found device at {0}".format(hex(device))) 91 | active.append(device) 92 | device_count = device_count + 1 93 | except IOError as e: 94 | if e.errno != errno.EREMOTEIO: 95 | #print("Warning: {0} on address {1}".format(e, hex(device))) 96 | if e.errno == 16: # device busy 97 | print("Found (busy) device at {0}".format(hex(device))) 98 | active.append(device) 99 | device_count = device_count + 1 100 | except Exception as e: # exception if read_byte fails 101 | print("Error while searching for devices on i2c: {0} on address {1}".format(e, hex(device))) 102 | 103 | # use i2cdetect to see if HTU21D is on address 64 (0x40) since nothing else seems to detect it 104 | if 64 not in active: 105 | p = subprocess.Popen(['i2cdetect', '-y','1'],stdout=subprocess.PIPE,) 106 | for i in range(0,6): 107 | line = str(p.stdout.readline()) 108 | #print(line) 109 | if line.find("40: 40") > 0: 110 | active.append(64) 111 | print("Found device (via i2cdetect) at 0x40") 112 | if device_count > 0: 113 | # We want to remove any existing devices if they are present 114 | # First uninstantiate the i2c bus 115 | print("======== Removing existing devices from the i2c bus... ========") 116 | for device in active: 117 | print("Deleting device found at {0}.".format(hex(device))) 118 | delete_device = "echo {0} > /sys/bus/i2c/devices/i2c-1/delete_device".format(hex(device)) 119 | os_out = os.system(delete_device) 120 | if os_out > 0: 121 | print("Delete device {0} exit code: {1}".format(hex(device), os_out)) 122 | 123 | print("======== Unloading any existing modules... ========") 124 | # Next unload any present devices using modprobe -rv 125 | output = subprocess.check_output("lsmod").decode() # TODO: replace check_output with run variant 126 | d = [] 127 | i = 0 128 | for line in output.split('\n'): 129 | i = i + 1 130 | if i > 1: 131 | line = line.strip() 132 | if line: 133 | lsmod_module = line.split() # splits string at whitespaces 134 | find_underscore = lsmod_module[0].find("_") # equals -1 if no underscore 135 | if find_underscore > 0: 136 | mod_name = lsmod_module[0][0:find_underscore] # strip underscore and everything following 137 | else: 138 | mod_name = lsmod_module[0] 139 | d.append(mod_name) 140 | # remove duplicates from list 141 | dd = [] 142 | [dd.append(x) for x in d if x not in dd] 143 | # find in dict 144 | #print("Currently loaded modules: {0}".format(dd)) 145 | for x in dd: 146 | if x in del_drivers: 147 | print("Unloading module {0} as {1}.".format(x, del_drivers[x])) 148 | subprocess.run(["modprobe", "-r", x]) 149 | 150 | # Remove unrecognized devices 151 | #print("Active: {0}".format(active)) 152 | #print("Keys: {0}".format(devices.keys())) 153 | new_active = [] 154 | for x in active: 155 | if x in devices.keys(): 156 | new_active.append(x) 157 | else: 158 | print("Device at {0} not in known supported drivers.".format(hex(x))) 159 | 160 | print("New active: {0}".format(new_active)) 161 | 162 | # Now, load all the devices found 163 | print("======== Loading devices found... ========") 164 | subprocess.run(["modprobe", "crc8"]) 165 | subprocess.run(["modprobe", "industrialio"]) 166 | new_active_count = 0 167 | for device in new_active: 168 | if devices[device] != "multiple": 169 | print("Loading device {0} on address {1}.".format(devices[device], hex(device))) 170 | subprocess.run(["modprobe", devices[device]]) 171 | new_device = "echo {0} {1} > /sys/bus/i2c/devices/i2c-1/new_device".format(devices[device], hex(device)) 172 | os_out = os.system(new_device) 173 | if os_out > 0: 174 | print("New device {0} exit code: {1}".format(hex(device), os_out)) 175 | new_active_count = new_active_count + 1 176 | else: 177 | load_device = "" 178 | mod_device = "" 179 | if device == 57: 180 | if read_chip_id(bus, device, 146) == 0xAB: 181 | mod_device = "apds9960" 182 | else: 183 | mod_device = "veml6070" 184 | 185 | elif device == 64: 186 | #print("hello64") 187 | chip_id = read_chip_id(bus, device, 255) 188 | #print("chipid = {0}".format(chip_id)) 189 | if chip_id == 0x1000 or chip_id == 0x1050: 190 | mod_device = "hdc100x" 191 | else: 192 | mod_device = "htu21" 193 | 194 | elif ((device == 118) and (64 not in new_active)) or (device == 119): 195 | chip_id = read_chip_id(bus, device, 208) 196 | if chip_id > 0: 197 | load_device = bosch_chip_id[read_chip_id(bus, device, 208)] 198 | if load_device == "bme680": 199 | mod_device = "bme680-i2c" 200 | else: 201 | mod_device = "bmp280-i2c" 202 | 203 | if mod_device != "": 204 | if load_device == "": 205 | load_device = mod_device 206 | subprocess.run(["modprobe", mod_device]) 207 | print("Loading device {0} (chip ID {1}) on address {2}.".format(mod_device, chip_id, hex(device))) 208 | new_device = "echo {0} {1} > /sys/bus/i2c/devices/i2c-1/new_device".format(load_device, hex(device)) 209 | os_out = os.system(new_device) 210 | if os_out > 0: 211 | print("New device exit code: {0}".format(os_out)) 212 | new_active_count = new_active_count + 1 213 | 214 | print("Loaded {0} of {1} device(s) found".format(new_active_count, device_count)) 215 | bus.close() 216 | bus = None 217 | return new_active_count 218 | 219 | else: # no devices found 220 | bus.close() 221 | bus = None 222 | return 0 223 | 224 | -------------------------------------------------------------------------------- /information.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import subprocess 3 | import sys 4 | from smbus2 import SMBus 5 | import errno 6 | import os 7 | import iio 8 | import json 9 | 10 | 11 | def _create_context(): 12 | 13 | 14 | return iio.Context() 15 | 16 | 17 | class Information: 18 | """Class for retrieving the iio information.""" 19 | 20 | def __init__(self, context): 21 | """ 22 | Class constructor. 23 | Args: 24 | context: type=iio.Context 25 | Context used for retrieving the information. 26 | """ 27 | self.context = context 28 | 29 | def write_information(self): 30 | """Write the information about the current context.""" 31 | self._context_info() 32 | 33 | def _context_info(self): 34 | print("IIO context created: " + self.context.name) 35 | print("Backend version: %u.%u (git tag: %s)" % self.context.version) 36 | print("Backend description string: " + self.context.description) 37 | 38 | if len(self.context.attrs) > 0: 39 | print("IIO context has %u attributes: " % len(self.context.attrs)) 40 | 41 | for attr, value in self.context.attrs.items(): 42 | print("\t" + attr + ": " + value) 43 | 44 | print("IIO context has %u devices:" % len(self.context.devices)) 45 | 46 | for dev in self.context.devices: 47 | self._device_info(dev) 48 | 49 | def _device_info(self, dev): 50 | print("\t" + dev.id + ": " + dev.name) 51 | 52 | if dev is iio.Trigger: 53 | print("Found trigger! Rate: %u HZ" % dev.frequency) 54 | 55 | print("\t\t%u channels found: " % len(dev.channels)) 56 | for channel in dev.channels: 57 | self._channel_info(channel) 58 | 59 | if len(dev.attrs) > 0: 60 | print("\t\t%u device-specific attributes found: " % len(dev.attrs)) 61 | for device_attr in dev.attrs: 62 | self._device_attribute_info(dev, device_attr) 63 | 64 | if len(dev.debug_attrs) > 0: 65 | print("\t\t%u debug attributes found: " % len(dev.debug_attrs)) 66 | for debug_attr in dev.debug_attrs: 67 | self._device_debug_attribute_info(dev, debug_attr) 68 | 69 | def _channel_info(self, channel): 70 | print("\t\t\t%s: %s (%s)" % (channel.id, channel.name or "", "output" if channel.output else "input")) 71 | if len(channel.attrs) > 0: 72 | print("\t\t\t%u channel-specific attributes found: " % len(channel.attrs)) 73 | for channel_attr in channel.attrs: 74 | self._channel_attribute_info(channel, channel_attr) 75 | 76 | @staticmethod 77 | def _channel_attribute_info(channel, channel_attr): 78 | try: 79 | print("\t\t\t\t" + channel_attr + ", value: " + channel.attrs[channel_attr].value) 80 | except OSError as err: 81 | print("Unable to read " + channel_attr + ": " + err.strerror) 82 | 83 | @staticmethod 84 | def _device_attribute_info(dev, device_attr): 85 | try: 86 | print("\t\t\t" + device_attr + ", value: " + dev.attrs[device_attr].value) 87 | except OSError as err: 88 | print("Unable to read " + device_attr + ": " + err.strerror) 89 | 90 | @staticmethod 91 | def _device_debug_attribute_info(dev, debug_attr): 92 | try: 93 | print("\t\t\t" + debug_attr + ", value: " + dev.debug_attrs[debug_attr].value) 94 | except OSError as err: 95 | print("Unable to read " + debug_attr + ": " + err.strerror) 96 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-labs-projects/sensor/13568fc2a51808564d290e83142b17ead63a197e/logo.png -------------------------------------------------------------------------------- /reading.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import subprocess 3 | import sys 4 | from smbus2 import SMBus 5 | import errno 6 | import os 7 | import iio 8 | import json 9 | 10 | from transformers import device_transform 11 | 12 | class Reading: 13 | """Class for retrieving readings from devices.""" 14 | 15 | def __init__(self, context): 16 | """ 17 | Class constructor. 18 | Args: 19 | context: type=iio.Context 20 | Context used for retrieving the information. 21 | """ 22 | self.context = context 23 | 24 | def write_reading(self): 25 | """Write the readings for the current context.""" 26 | 27 | sensor_id = os.getenv('SENSOR_ID', '') 28 | UUID = os.environ.get('RESIN_DEVICE_UUID')[:7] # First seven chars of device UUID 29 | 30 | if os.getenv('COLLAPSE_FIELDS', 0) == "1": # return just a dict of fields 31 | reading = {} 32 | for dev in self.context.devices: 33 | device_fields = self._device_read(dev) 34 | if os.getenv('RAW_VALUES', '1') == "1": 35 | new_fields = device_fields 36 | else: 37 | # send the device name and fields to the transform function 38 | new_fields = device_transform(dev.name, device_fields) 39 | 40 | # If sensor ID defined, add to fields: 41 | if sensor_id != '': 42 | reading.update({"sensor_id": sensor_id}) 43 | 44 | # Add short UUID as field 45 | reading.update({"short_uuid": UUID}) 46 | 47 | reading.update(new_fields) # merges dicts, but overwrites equal values 48 | 49 | else: # return measurements and fields in a list 50 | reading = [] 51 | for dev in self.context.devices: 52 | device_fields = self._device_read(dev) 53 | if os.getenv('RAW_VALUES', '1') == "1": 54 | new_fields = device_fields 55 | else: 56 | # send the device name and fields to the transform function 57 | new_fields = device_transform(dev.name, device_fields) 58 | reading2 = {"measurement": dev.name, "fields": new_fields} 59 | reading.append(reading2) 60 | 61 | # If sensor ID defined, add to fields: 62 | if sensor_id != '': 63 | reading3 = {"measurement": "sensor_ID", "fields": {"sensor_id": sensor_id}} 64 | reading.append(reading3) 65 | 66 | # Add short UUID to fields: 67 | reading3 = {"measurement": "short_UUID", "fields": {"short_uuid": UUID}} 68 | reading.append(reading3) 69 | 70 | return reading 71 | 72 | def _device_read(self, dev): 73 | reads = {} 74 | 75 | for channel in dev.channels: 76 | if not channel.output: 77 | chan = channel.id 78 | if len(channel.attrs) > 0: 79 | for channel_attr in channel.attrs: 80 | if channel_attr == "input" or channel_attr == "raw": 81 | attr_value = self._channel_attribute_value(channel, channel_attr) 82 | try: 83 | reads[chan] = float(attr_value) 84 | except: 85 | reads[chan] = attr_value 86 | 87 | return reads 88 | 89 | @staticmethod 90 | def _channel_attribute_value(channel, channel_attr): 91 | v = 0 92 | try: 93 | v = channel.attrs[channel_attr].value 94 | except OSError as err: 95 | print("Unable to read channel attribute " + channel_attr + ": " + err.strerror) 96 | 97 | return v 98 | 99 | class IIO_READER: 100 | data = None 101 | 102 | def __init__(self): 103 | print("initializing reading") 104 | 105 | def get_readings(self, context): 106 | reading = Reading(context) 107 | x = reading.write_reading() 108 | return x 109 | -------------------------------------------------------------------------------- /repo.yml: -------------------------------------------------------------------------------- 1 | type: generic -------------------------------------------------------------------------------- /sensor.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import sys 3 | import errno 4 | import os 5 | import iio 6 | import json 7 | import time 8 | from http.server import HTTPServer, BaseHTTPRequestHandler 9 | import paho.mqtt.client as mqtt 10 | import socket 11 | import threading 12 | import requests 13 | 14 | import idetect 15 | from reading import IIO_READER 16 | from information import Information 17 | 18 | 19 | def mqtt_detect(): 20 | 21 | # Use the supervisor api to get services 22 | # See https://www.balena.io/docs/reference/supervisor/supervisor-api/ 23 | 24 | address = os.getenv('BALENA_SUPERVISOR_ADDRESS', '') 25 | api_key = os.getenv('BALENA_SUPERVISOR_API_KEY', '') 26 | app_name = os.getenv('BALENA_APP_NAME', '') 27 | 28 | url = "{0}/v2/applications/state?apikey={1}".format(address, api_key) 29 | 30 | try: 31 | r = requests.get(url).json() 32 | except Exception as e: 33 | print("Error looking for MQTT service: {0}".format(str(e))) 34 | return False 35 | else: 36 | services = r[app_name]['services'].keys() 37 | 38 | if "mqtt" in services: 39 | return True 40 | else: 41 | return False 42 | 43 | 44 | class balenaSense(): 45 | readfrom = 'unset' 46 | 47 | def __init__(self): 48 | print("Initializing sensors...") 49 | # First, use iio to detect supported sensors 50 | self.device_count = idetect.detect_iio_sensors() 51 | 52 | if self.device_count > 0: 53 | self.readfrom = "iio_sensors" 54 | self.context = _create_context() 55 | self.sensor = IIO_READER() 56 | # Print the iio info 57 | information = Information(self.context) 58 | information.write_information() 59 | 60 | # More sensor types can be added here 61 | # make sure to change the value of self.readfrom 62 | 63 | 64 | # If this is still unset, no sensors were found; quit! 65 | if self.readfrom == 'unset': 66 | print('No suitable sensors found! Exiting.') 67 | sys.exit() 68 | 69 | def sample(self): 70 | if self.readfrom == 'sense-hat': 71 | return self.apply_offsets(self.sense_hat_reading()) 72 | elif self.readfrom == 'iio_sensors': 73 | return self.sensor.get_readings(self.context) 74 | else: 75 | return self.sensor.get_readings(self.sensor) 76 | 77 | 78 | def _create_context(): 79 | 80 | return iio.Context() 81 | 82 | # Simple webserver 83 | def background_web(server_socket): 84 | balenasense = balenaSense() 85 | while True: 86 | # Wait for client connections 87 | client_connection, client_address = server_socket.accept() 88 | 89 | # Get the client request 90 | request = client_connection.recv(1024).decode() 91 | print(request) 92 | 93 | # Send HTTP response 94 | response = 'HTTP/1.0 200 OK\n\n'+ json.dumps(balenasense.sample()) 95 | client_connection.sendall(response.encode()) 96 | client_connection.close() 97 | 98 | 99 | 100 | if __name__ == "__main__": 101 | 102 | mqtt_address = os.getenv('MQTT_ADDRESS', 'none') 103 | use_httpserver = os.getenv('ALWAYS_USE_HTTPSERVER', 0) 104 | publish_interval = os.getenv('MQTT_PUB_INTERVAL', '8') 105 | publish_topic = os.getenv('MQTT_PUB_TOPIC', 'sensors') 106 | 107 | try: 108 | interval = float(publish_interval) 109 | except Exception as e: 110 | print("Error converting MQTT_PUB_INTERVAL: Must be integer or float! Using default.") 111 | interval = 8 112 | 113 | if use_httpserver == "1": 114 | enable_httpserver = "True" 115 | else: 116 | enable_httpserver = "False" 117 | 118 | 119 | if mqtt_detect() and mqtt_address == "none": 120 | mqtt_address = "mqtt" 121 | 122 | if mqtt_address != "none": 123 | print("Starting mqtt client, publishing to {0}:1883".format(mqtt_address)) 124 | print("Using MQTT publish interval: {0} sec(s)".format(interval)) 125 | client = mqtt.Client() 126 | try: 127 | client.connect(mqtt_address, 1883, 60) 128 | except Exception as e: 129 | print("Error connecting to mqtt. ({0})".format(str(e))) 130 | mqtt_address = "none" 131 | enable_httpserver = "True" 132 | else: 133 | client.loop_start() 134 | balenasense = balenaSense() 135 | else: 136 | enable_httpserver = "True" 137 | 138 | if enable_httpserver == "True": 139 | SERVER_HOST = '0.0.0.0' 140 | SERVER_PORT = 7575 141 | 142 | # Create socket 143 | server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 144 | server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 145 | server_socket.bind((SERVER_HOST, SERVER_PORT)) 146 | server_socket.listen(1) 147 | print("HTTP server listening on port {0}...".format(SERVER_PORT)) 148 | 149 | t = threading.Thread(target=background_web, args=(server_socket,)) 150 | t.start() 151 | 152 | while True: 153 | if mqtt_address != "none": 154 | client.publish(publish_topic, json.dumps(balenasense.sample())) 155 | time.sleep(interval) 156 | -------------------------------------------------------------------------------- /transformers.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | def device_transform(device_name, fields): 4 | 5 | # Create a dict copy to work on... 6 | # So we're not iterating a changing dict 7 | new_fields = fields.copy() 8 | 9 | if device_name == "bme680": 10 | print("Transforming {0} value(s)...".format(device_name)) 11 | for field in fields: 12 | if field == "humidityrelative": 13 | new_fields["humidity"] = new_fields.pop("humidityrelative") 14 | elif field == "temp": 15 | x = fields[field] 16 | if os.getenv('TEMP_UNIT', 'C') == 'F': 17 | new_fields[field]= ((x/1000) * 1.8) + 32 18 | else: 19 | new_fields[field] = x/1000 20 | new_fields["temperature"] = new_fields.pop("temp") 21 | 22 | elif device_name == "bme280": 23 | print("Transforming {0} value(s)...".format(device_name)) 24 | for field in fields: 25 | if field == "humidityrelative": 26 | new_fields[field] = fields[field]/1000 27 | new_fields["humidity"] = new_fields.pop("humidityrelative") 28 | elif field == "temp": 29 | x = fields[field] 30 | if os.getenv('TEMP_UNIT', 'C') == 'F': 31 | new_fields[field]= ((x/1000) * 1.8) + 32 32 | else: 33 | new_fields[field] = x/1000 34 | new_fields["temperature"] = new_fields.pop("temp") 35 | elif field == "pressure": 36 | x = fields[field] 37 | new_fields[field] = x * 10 38 | 39 | elif device_name == "bmp280": 40 | print("Transforming {0} value(s)...".format(device_name)) 41 | for field in fields: 42 | if field == "temp": 43 | x = fields[field] 44 | if os.getenv('TEMP_UNIT', 'C') == 'F': 45 | new_fields[field] = ((x/1000) * 1.8) + 32 46 | else: 47 | new_fields[field] = x/1000 48 | new_fields["temperature"] = new_fields.pop("temp") 49 | elif field == "pressure": 50 | x = fields[field] 51 | new_fields[field] = x * 10 52 | 53 | elif device_name == "htu21": 54 | print("Transforming {0} value(s)...".format(device_name)) 55 | for field in fields: 56 | if field == "temp": 57 | x = fields[field] 58 | if os.getenv('TEMP_UNIT', 'C') == 'F': 59 | new_fields[field]= ((x/1000) * 1.8) + 32 60 | else: 61 | new_fields[field] = x/1000 62 | new_fields["temperature"] = new_fields.pop("temp") 63 | if field == "humidityrelative": 64 | x = fields[field] 65 | new_fields[field] = x/1000 66 | new_fields["humidity"] = new_fields.pop("humidityrelative") 67 | 68 | return new_fields 69 | --------------------------------------------------------------------------------