├── .coveragerc ├── .github └── workflows │ ├── build.yml │ ├── qa.yml │ └── test.yml ├── .gitignore ├── .stickler.yml ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── bh1745 └── __init__.py ├── check.sh ├── documentation └── REFERENCE.md ├── examples ├── README.md ├── detect.py ├── hex-colour.py ├── raw-colour.py ├── setup.cfg └── two-sensors.py ├── install.sh ├── pyproject.toml ├── requirements-dev.txt ├── tests ├── test_features.py ├── test_setup.py └── tools.py ├── tox.ini └── uninstall.sh /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = bh1745 3 | omit = 4 | .tox/* 5 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | test: 11 | name: Python ${{ matrix.python }} 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python: ['3.9', '3.10', '3.11'] 16 | 17 | env: 18 | RELEASE_FILE: ${{ github.event.repository.name }}-${{ github.event.release.tag_name || github.sha }}-py${{ matrix.python }} 19 | 20 | steps: 21 | - name: Checkout Code 22 | uses: actions/checkout@v4 23 | 24 | - name: Set up Python ${{ matrix.python }} 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: ${{ matrix.python }} 28 | 29 | - name: Install Dependencies 30 | run: | 31 | make dev-deps 32 | 33 | - name: Build Packages 34 | run: | 35 | make build 36 | 37 | - name: Upload Packages 38 | uses: actions/upload-artifact@v4 39 | with: 40 | name: ${{ env.RELEASE_FILE }} 41 | path: dist/ 42 | -------------------------------------------------------------------------------- /.github/workflows/qa.yml: -------------------------------------------------------------------------------- 1 | name: QA 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | test: 11 | name: linting & spelling 12 | runs-on: ubuntu-latest 13 | env: 14 | TERM: xterm-256color 15 | 16 | steps: 17 | - name: Checkout Code 18 | uses: actions/checkout@v4 19 | 20 | - name: Set up Python '3,11' 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: '3.11' 24 | 25 | - name: Install Dependencies 26 | run: | 27 | make dev-deps 28 | 29 | - name: Run Quality Assurance 30 | run: | 31 | make qa 32 | 33 | - name: Run Code Checks 34 | run: | 35 | make check 36 | 37 | - name: Run Bash Code Checks 38 | run: | 39 | make shellcheck 40 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | test: 11 | name: Python ${{ matrix.python }} 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python: ['3.9', '3.10', '3.11'] 16 | 17 | steps: 18 | - name: Checkout Code 19 | uses: actions/checkout@v3 20 | 21 | - name: Set up Python ${{ matrix.python }} 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: ${{ matrix.python }} 25 | 26 | - name: Install Dependencies 27 | run: | 28 | make dev-deps 29 | 30 | - name: Run Tests 31 | run: | 32 | make pytest 33 | 34 | - name: Coverage 35 | if: ${{ matrix.python == '3.9' }} 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | run: | 39 | python -m pip install coveralls 40 | coveralls --service=github 41 | 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | _build/ 3 | *.o 4 | *.so 5 | *.a 6 | *.py[cod] 7 | *.egg-info 8 | *.swp 9 | dist/ 10 | __pycache__ 11 | .DS_Store 12 | *.deb 13 | *.dsc 14 | *.build 15 | *.changes 16 | *.orig.* 17 | packaging/*tar.xz 18 | library/debian/ 19 | .coverage 20 | .pytest_cache 21 | .tox 22 | .vscode 23 | -------------------------------------------------------------------------------- /.stickler.yml: -------------------------------------------------------------------------------- 1 | --- 2 | linters: 3 | flake8: 4 | python: 3 5 | max-line-length: 160 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 1.0.0 2 | ----- 3 | 4 | * Repackage to hatch/pyproject.toml 5 | * Require Python >= 3.7 (drop 2.x support) 6 | 7 | 0.0.4 8 | ----- 9 | 10 | * Migrate to new i2cdevice API 11 | 12 | 0.0.3 13 | ----- 14 | 15 | * Automagically call setup if not called by user 16 | * Allow setup() to try alternate i2c addresses 17 | * Added .ready() to determine if sensor is setup 18 | 19 | 0.0.2 20 | ----- 21 | 22 | * Bumped i2cdevice dependency to >=0.0.4 23 | 24 | 0.0.1 25 | ----- 26 | 27 | * Initial Release 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Pimoroni Ltd. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | LIBRARY_NAME := $(shell hatch project metadata name 2> /dev/null) 2 | LIBRARY_VERSION := $(shell hatch version 2> /dev/null) 3 | 4 | .PHONY: usage install uninstall check pytest qa build-deps check tag wheel sdist clean dist testdeploy deploy 5 | usage: 6 | ifdef LIBRARY_NAME 7 | @echo "Library: ${LIBRARY_NAME}" 8 | @echo "Version: ${LIBRARY_VERSION}\n" 9 | else 10 | @echo "WARNING: You should 'make dev-deps'\n" 11 | endif 12 | @echo "Usage: make , where target is one of:\n" 13 | @echo "install: install the library locally from source" 14 | @echo "uninstall: uninstall the local library" 15 | @echo "dev-deps: install Python dev dependencies" 16 | @echo "check: perform basic integrity checks on the codebase" 17 | @echo "qa: run linting and package QA" 18 | @echo "pytest: run Python test fixtures" 19 | @echo "clean: clean Python build and dist directories" 20 | @echo "build: build Python distribution files" 21 | @echo "testdeploy: build and upload to test PyPi" 22 | @echo "deploy: build and upload to PyPi" 23 | @echo "tag: tag the repository with the current version\n" 24 | 25 | version: 26 | @hatch version 27 | 28 | install: 29 | ./install.sh --unstable 30 | 31 | uninstall: 32 | ./uninstall.sh 33 | 34 | dev-deps: 35 | python3 -m pip install -r requirements-dev.txt 36 | sudo apt install dos2unix shellcheck 37 | 38 | check: 39 | @bash check.sh 40 | 41 | shellcheck: 42 | shellcheck *.sh 43 | 44 | qa: 45 | tox -e qa 46 | 47 | pytest: 48 | tox -e py 49 | 50 | nopost: 51 | @bash check.sh --nopost 52 | 53 | tag: version 54 | git tag -a "v${LIBRARY_VERSION}" -m "Version ${LIBRARY_VERSION}" 55 | 56 | build: check 57 | @hatch build 58 | 59 | clean: 60 | -rm -r dist 61 | 62 | testdeploy: build 63 | twine upload --repository testpypi dist/* 64 | 65 | deploy: nopost build 66 | twine upload dist/* 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BH1745 Colour Sensor 2 | 3 | [![Build Status](https://img.shields.io/github/actions/workflow/status/pimoroni/bh1745-python/test.yml?branch=main)](https://github.com/pimoroni/bh1745-python/actions/workflows/test.yml) 4 | [![Coverage Status](https://coveralls.io/repos/github/pimoroni/bh1745-python/badge.svg?branch=main)](https://coveralls.io/github/pimoroni/bh1745-python?branch=main) 5 | [![PyPi Package](https://img.shields.io/pypi/v/bh1745.svg)](https://pypi.python.org/pypi/bh1745) 6 | [![Python Versions](https://img.shields.io/pypi/pyversions/bh1745.svg)](https://pypi.python.org/pypi/bh1745) 7 | 8 | Most suited to detecting the illuminance and colour temperature of ambient light, the BH1745 senses Red, Green and Blue light and converts it to 16bit digital values. 9 | 10 | ## Installing 11 | 12 | ### Full install (recommended): 13 | 14 | We've created an easy installation script that will install all pre-requisites and get your BH1745 15 | up and running with minimal efforts. To run it, fire up Terminal which you'll find in Menu -> Accessories -> Terminal 16 | on your Raspberry Pi desktop, as illustrated below: 17 | 18 | ![Finding the terminal](http://get.pimoroni.com/resources/github-repo-terminal.png) 19 | 20 | In the new terminal window type the command exactly as it appears below (check for typos) and follow the on-screen instructions: 21 | 22 | ```bash 23 | git clone https://github.com/pimoroni/bh1745-python 24 | cd bh1745-python 25 | ./install.sh 26 | ``` 27 | 28 | **Note** Libraries will be installed in the "pimoroni" virtual environment, you will need to activate it to run examples: 29 | 30 | ``` 31 | source ~/.virtualenvs/pimoroni/bin/activate 32 | ``` 33 | 34 | ### Development: 35 | 36 | If you want to contribute, or like living on the edge of your seat by having the latest code, you can install the development version like so: 37 | 38 | ```bash 39 | git clone https://github.com/pimoroni/bh1745-python 40 | cd bh1745-python 41 | ./install.sh --unstable 42 | ``` 43 | 44 | The install script should do it for you, but in some cases you might have to enable the i2c bus. 45 | 46 | On a Raspberry Pi you can do that like so: 47 | 48 | ``` 49 | sudo raspi-config nonint do_i2c 0 50 | ``` 51 | -------------------------------------------------------------------------------- /bh1745/__init__.py: -------------------------------------------------------------------------------- 1 | """Library for the BH1745 colour sensor.""" 2 | import time 3 | 4 | from i2cdevice import BitField, Device, Register 5 | from i2cdevice.adapter import LookupAdapter, U16ByteSwapAdapter 6 | 7 | __version__ = "1.0.0" 8 | 9 | I2C_ADDRESSES = [0x38, 0x39] 10 | BH1745_RESET_TIMEOUT_SEC = 2 11 | 12 | 13 | class BH1745TimeoutError(Exception): # noqa D101 14 | pass 15 | 16 | 17 | class BH1745: 18 | """BH1745 colour sensor.""" 19 | 20 | def __init__(self, i2c_addr=0x38, i2c_dev=None): 21 | """Initialise sensor. 22 | 23 | :param i2c_addr: i2c address of sensor 24 | :param i2c_dev: SMBus-compatible instance 25 | 26 | """ 27 | self._i2c_addr = i2c_addr 28 | self._i2c_dev = i2c_dev 29 | self._is_setup = False 30 | # Device definition 31 | self._bh1745 = Device(I2C_ADDRESSES, i2c_dev=self._i2c_dev, bit_width=8, registers=( 32 | # Part ID should be 0b001011 or 0x0B 33 | Register("SYSTEM_CONTROL", 0x40, fields=( 34 | BitField("sw_reset", 0b10000000), 35 | BitField("int_reset", 0b01000000), 36 | BitField("part_id", 0b00111111, read_only=True) 37 | )), 38 | 39 | Register("MODE_CONTROL1", 0x41, fields=( 40 | BitField("measurement_time_ms", 0b00000111, adapter=LookupAdapter({ 41 | 160: 0b000, 42 | 320: 0b001, 43 | 640: 0b010, 44 | 1280: 0b011, 45 | 2560: 0b100, 46 | 5120: 0b101 47 | })), 48 | )), 49 | 50 | Register("MODE_CONTROL2", 0x42, fields=( 51 | BitField("valid", 0b10000000, read_only=True), 52 | BitField("rgbc_en", 0b00010000), 53 | BitField("adc_gain_x", 0b00000011, adapter=LookupAdapter({ 54 | 1: 0b00, 2: 0b01, 16: 0b10})) 55 | )), 56 | 57 | Register("MODE_CONTROL3", 0x44, fields=( 58 | BitField("on", 0b11111111, adapter=LookupAdapter({True: 2, False: 0})), 59 | )), 60 | 61 | Register("COLOUR_DATA", 0x50, fields=( 62 | BitField("red", 0xFFFF000000000000, adapter=U16ByteSwapAdapter()), 63 | BitField("green", 0x0000FFFF00000000, adapter=U16ByteSwapAdapter()), 64 | BitField("blue", 0x00000000FFFF0000, adapter=U16ByteSwapAdapter()), 65 | BitField("clear", 0x000000000000FFFF, adapter=U16ByteSwapAdapter()) 66 | ), bit_width=64, read_only=True), 67 | 68 | Register("DINT_DATA", 0x58, fields=( 69 | BitField("data", 0xFFFF, adapter=U16ByteSwapAdapter()), 70 | ), bit_width=16), 71 | 72 | Register("INTERRUPT", 0x60, fields=( 73 | BitField("status", 0b10000000, read_only=True), 74 | BitField("latch", 0b00010000, adapter=LookupAdapter({0: 1, 1: 0})), 75 | BitField("source", 0b00001100, read_only=True, adapter=LookupAdapter({ 76 | "red": 0b00, 77 | "green": 0b01, 78 | "blue": 0b10, 79 | "clear": 0b11 80 | })), 81 | BitField("enable", 0b00000001) 82 | )), 83 | 84 | # 00: Interrupt status is toggled at each measurement end 85 | # 01: Interrupt status is updated at each measurement end 86 | # 10: Interrupt status is updated if 4 consecutive threshold judgements are the same 87 | # 11: Blah blah ditto above except for 8 consecutive judgements 88 | Register("PERSISTENCE", 0x61, fields=( 89 | BitField("mode", 0b00000011, adapter=LookupAdapter({ 90 | "toggle": 0b00, 91 | "update": 0b01, 92 | "update_on_4": 0b10, 93 | "update_on_8": 0b11 94 | })), 95 | )), 96 | 97 | # High threshold defaults to 0xFFFF 98 | # Low threshold defaults to 0x0000 99 | Register("THRESHOLD", 0x62, fields=( 100 | BitField("high", 0xFFFF0000, adapter=U16ByteSwapAdapter()), 101 | BitField("low", 0x0000FFFF, adapter=U16ByteSwapAdapter()) 102 | ), bit_width=32), 103 | 104 | # Default MANUFACTURER ID is 0xE0h 105 | Register("MANUFACTURER", 0x92, fields=( 106 | BitField("id", 0xFF), 107 | ), read_only=True, volatile=False) 108 | )) 109 | 110 | self._bh1745.select_address(self._i2c_addr) 111 | 112 | # TODO : Integrate into i2cdevice so that LookupAdapter fields can always be exported to constants 113 | # Iterate through all register fields and export their lookup tables to constants 114 | for register in self._bh1745.registers: 115 | register = self._bh1745.registers[register] 116 | for field in register.fields: 117 | field = register.fields[field] 118 | if isinstance(field.adapter, LookupAdapter): 119 | for key in field.adapter.lookup_table: 120 | name = "BH1745_{register}_{field}_{key}".format( 121 | register=register.name, 122 | field=field.name, 123 | key=key 124 | ).upper() 125 | globals()[name] = key 126 | 127 | """ 128 | Approximate compensation for the spectral response performance curves 129 | """ 130 | self._channel_compensation = (2.2, 1.0, 1.8, 10.0) 131 | self._enable_channel_compensation = True 132 | 133 | # Public API methods 134 | def ready(self): 135 | """Return true if setup has been successful.""" 136 | return self._is_setup 137 | 138 | def setup(self, i2c_addr=None, timeout=BH1745_RESET_TIMEOUT_SEC): 139 | """Set up the bh1745 sensor. 140 | 141 | :param i2c_addr: Optional i2c_addr to switch to 142 | 143 | """ 144 | if self._is_setup: 145 | return True 146 | 147 | if timeout <= 0: 148 | raise ValueError("Device timeout period must be greater than 0") 149 | 150 | if i2c_addr is not None: 151 | self._bh1745.select_address(i2c_addr) 152 | 153 | try: 154 | self._bh1745.get("SYSTEM_CONTROL") 155 | except IOError: 156 | raise RuntimeError("BH1745 not found: IO error attempting to query device!") 157 | 158 | if self._bh1745.get("SYSTEM_CONTROL").part_id != 0b001011 or self._bh1745.get("MANUFACTURER").id != 0xE0: 159 | raise RuntimeError("BH1745 not found: Manufacturer or Part ID mismatch!") 160 | 161 | self._is_setup = True 162 | 163 | self._bh1745.set("SYSTEM_CONTROL", sw_reset=1) 164 | 165 | t_start = time.time() 166 | 167 | pending_reset = True 168 | 169 | while time.time() - t_start < timeout: 170 | if not self._bh1745.get("SYSTEM_CONTROL").sw_reset: 171 | pending_reset = False 172 | break 173 | time.sleep(0.01) 174 | 175 | if pending_reset: 176 | raise BH1745TimeoutError("Timeout waiting for BH1745 to reset.") 177 | 178 | self._bh1745.set("SYSTEM_CONTROL", int_reset=0) 179 | self._bh1745.set("MODE_CONTROL1", measurement_time_ms=320) 180 | self._bh1745.set("MODE_CONTROL2", adc_gain_x=1, rgbc_en=1) 181 | self._bh1745.set("MODE_CONTROL3", on=1) 182 | self._bh1745.set("THRESHOLD", low=0xFFFF, high=0x0000) 183 | self._bh1745.set("INTERRUPT", latch=1) 184 | 185 | time.sleep(0.320) 186 | 187 | def set_measurement_time_ms(self, time_ms): 188 | """Set the measurement time in milliseconds. 189 | 190 | :param time_ms: The time in milliseconds: 160, 320, 640, 1280, 2560, 5120 191 | 192 | """ 193 | self.setup() 194 | self._bh1745.set("MODE_CONTROL1", measurement_time_ms=time_ms) 195 | 196 | def set_adc_gain_x(self, gain_x): 197 | """Set the ADC gain multiplier. 198 | 199 | :param gain_x: Must be either 1, 2 or 16 200 | 201 | """ 202 | self.setup() 203 | self._bh1745.set("MODE_CONTROL2", adc_gain_x=gain_x) 204 | 205 | def set_leds(self, state): 206 | """Toggle the onboard LEDs. 207 | 208 | :param state: Either 1 for on, or 0 for off 209 | 210 | """ 211 | self.setup() 212 | self._bh1745.set("INTERRUPT", enable=1 if state else 0) 213 | 214 | def set_channel_compensation(self, r, g, b, c): 215 | """Set the channel compensation scale factors. 216 | 217 | :param r: multiplier for red channel 218 | :param g: multiplier for green channel 219 | :param b: multiplier for blue channel 220 | :param c: multiplier for clear channel 221 | 222 | If you intend to measure a particular class of objects, say a set of matching wooden blocks with similar reflectivity and paint finish 223 | you should calibrate the channel compensation until you see colour values that broadly represent the colour of the objects you"re testing. 224 | 225 | The default values were derived by testing a set of 5 Red, Green, Blue, Yellow and Orange wooden blocks. 226 | 227 | These scale factors are applied in `get_rgbc_raw` right after the raw values are read from the sensor. 228 | 229 | """ 230 | self._channel_compensation = (r, g, b, c) 231 | 232 | def enable_white_balance(self, enable): 233 | """Enable scale compensation for the channels. 234 | 235 | :param enable: True to enable, False to disable 236 | 237 | See: `set_channel_compensation` for details. 238 | 239 | """ 240 | self._enable_channel_compensation = True if enable else False 241 | 242 | def get_rgbc_raw(self): 243 | """Return the raw Red, Green, Blue and Clear readings.""" 244 | self.setup() 245 | colour_data = self._bh1745.get("COLOUR_DATA") 246 | r, g, b, c = colour_data.red, colour_data.green, colour_data.blue, colour_data.clear 247 | 248 | if self._enable_channel_compensation: 249 | cr, cg, cb, cc = self._channel_compensation 250 | r, g, b, c = r * cr, g * cg, b * cb, c * cc 251 | 252 | return (r, g, b, c) 253 | 254 | def get_rgb_clamped(self): 255 | """Return an RGB value scaled against max(r, g, b). 256 | 257 | This will clamp/saturate one of the colour channels, providing a clearer idea 258 | of what primary colour an object is most likely to be. 259 | 260 | However the resulting colour reading will not be accurate for other purposes. 261 | 262 | """ 263 | r, g, b, c = self.get_rgbc_raw() 264 | 265 | div = max(r, g, b) 266 | 267 | if div > 0: 268 | r, g, b = [int((x / float(div)) * 255) for x in (r, g, b)] 269 | return (r, g, b) 270 | 271 | return (0, 0, 0) 272 | 273 | def get_rgb_scaled(self): 274 | """Return an RGB value scaled against the clear channel.""" 275 | r, g, b, c = self.get_rgbc_raw() 276 | 277 | if c > 0: 278 | r, g, b = [min(255, int((x / float(c)) * 255)) for x in (r, g, b)] 279 | return (r, g, b) 280 | 281 | return (0, 0, 0) 282 | -------------------------------------------------------------------------------- /check.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This script handles some basic QA checks on the source 4 | 5 | NOPOST=$1 6 | LIBRARY_NAME=$(hatch project metadata name) 7 | LIBRARY_VERSION=$(hatch version | awk -F "." '{print $1"."$2"."$3}') 8 | POST_VERSION=$(hatch version | awk -F "." '{print substr($4,0,length($4))}') 9 | TERM=${TERM:="xterm-256color"} 10 | 11 | success() { 12 | echo -e "$(tput setaf 2)$1$(tput sgr0)" 13 | } 14 | 15 | inform() { 16 | echo -e "$(tput setaf 6)$1$(tput sgr0)" 17 | } 18 | 19 | warning() { 20 | echo -e "$(tput setaf 1)$1$(tput sgr0)" 21 | } 22 | 23 | while [[ $# -gt 0 ]]; do 24 | K="$1" 25 | case $K in 26 | -p|--nopost) 27 | NOPOST=true 28 | shift 29 | ;; 30 | *) 31 | if [[ $1 == -* ]]; then 32 | printf "Unrecognised option: %s\n" "$1"; 33 | exit 1 34 | fi 35 | POSITIONAL_ARGS+=("$1") 36 | shift 37 | esac 38 | done 39 | 40 | inform "Checking $LIBRARY_NAME $LIBRARY_VERSION\n" 41 | 42 | inform "Checking for trailing whitespace..." 43 | if grep -IUrn --color "[[:blank:]]$" --exclude-dir=dist --exclude-dir=.tox --exclude-dir=.git --exclude=PKG-INFO; then 44 | warning "Trailing whitespace found!" 45 | exit 1 46 | else 47 | success "No trailing whitespace found." 48 | fi 49 | printf "\n" 50 | 51 | inform "Checking for DOS line-endings..." 52 | if grep -lIUrn --color $'\r' --exclude-dir=dist --exclude-dir=.tox --exclude-dir=.git --exclude=Makefile; then 53 | warning "DOS line-endings found!" 54 | exit 1 55 | else 56 | success "No DOS line-endings found." 57 | fi 58 | printf "\n" 59 | 60 | inform "Checking CHANGELOG.md..." 61 | if ! grep "^${LIBRARY_VERSION}" CHANGELOG.md > /dev/null 2>&1; then 62 | warning "Changes missing for version ${LIBRARY_VERSION}! Please update CHANGELOG.md." 63 | exit 1 64 | else 65 | success "Changes found for version ${LIBRARY_VERSION}." 66 | fi 67 | printf "\n" 68 | 69 | inform "Checking for git tag ${LIBRARY_VERSION}..." 70 | if ! git tag -l | grep -E "${LIBRARY_VERSION}$"; then 71 | warning "Missing git tag for version ${LIBRARY_VERSION}" 72 | fi 73 | printf "\n" 74 | 75 | if [[ $NOPOST ]]; then 76 | inform "Checking for .postN on library version..." 77 | if [[ "$POST_VERSION" != "" ]]; then 78 | warning "Found .$POST_VERSION on library version." 79 | inform "Please only use these for testpypi releases." 80 | exit 1 81 | else 82 | success "OK" 83 | fi 84 | fi 85 | -------------------------------------------------------------------------------- /documentation/REFERENCE.md: -------------------------------------------------------------------------------- 1 | # bh1745 2 | 3 | `setup()` 4 | 5 | Set up the sensor with initial values and configuration. Should be called before using any other methods, but is called automatically for you. 6 | 7 | `ready()` 8 | 9 | Returns `True` if the sensor instance has successfully been `setup()` and False otherwise. 10 | 11 | `get_rgbc_raw()` 12 | 13 | Return the Red, Green, Blue and Clear values from the sensor, with compensation applied. 14 | 15 | `get_rgb_clamped()` 16 | 17 | Return an RGB value scaled against `max(r, g, b)`. 18 | 19 | This will clamp/saturate one of the colour channels, providing a clearer idea of what primary colour an object is most likely to be. 20 | 21 | However the resulting colour reaidng will not be accurate for other purposes. 22 | 23 | IE: a value of `(255,0,128)` would produce a result of approximately `(1.0, 0.0, 0.5)` since `max(255,0,128) == 255`, `255/255 = 1`, `0/255 = 0` and `128/255 = 0.50196`. 24 | 25 | `get_rgb_scaled()` 26 | 27 | Return an RGB value scaled against the clear (unfiltered) channel. 28 | 29 | `set_channel_compensation(r, g, b, c)` 30 | 31 | Set compensation scale factors for each channel. 32 | 33 | If you intend to measure a particular class of objects, say a set of matching wooden blocks with similar reflectivity and paint finish, you should calibrate the channel compensation until you see colour values that broadly represent the colour of the objects you're testing. 34 | 35 | The default values were derived by testing a set of 5 Red, Green, Blue, Yellow and Orange wooden blocks. 36 | 37 | These scale factors are applied in `get_rgbc_raw` right after the raw values are read from the sensor. 38 | 39 | `enable_white_balance(enable)` 40 | 41 | Enable scale compensation with the values set via `set_channel_compensation`. 42 | 43 | `set_leds(state)` 44 | 45 | Set the onboard LEDs to state, 1/True for on, 0/False for off. 46 | 47 | `set_adc_gain_x(gain_x)` 48 | 49 | Set the ADC gain multiplier, must be either 1, 2 or 16 (for 1x, 2x or 16x) 50 | 51 | `set_measurement_time_ms(time_ms)` 52 | 53 | Set the measurement time in milliseconds, must be either 160, 320, 640, 1280, 2560 or 5120. The longer the measurement time, the more saturated your colour readings will be. 54 | 55 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # bh1745 examples 2 | 3 | ## hex-colour.py 4 | 5 | Converts the colour reading into a hex #FFFFFF colour code. 6 | 7 | ## raw-colour.py 8 | 9 | Returns the raw colour values from the sensor. 10 | 11 | ## two-sensors.py 12 | 13 | Demonstrates how to use two sensors simultaneously. You will need to *very carefully* cut the cuttable trace on the back of one of your sensors to change the i2c address. 14 | -------------------------------------------------------------------------------- /examples/detect.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import bh1745 3 | 4 | colour = bh1745.BH1745() 5 | 6 | for i2c_addr in bh1745.I2C_ADDRESSES: 7 | try: 8 | colour.setup(i2c_addr=i2c_addr) 9 | print(f"Found bh1745 on 0x{i2c_addr:02x}") 10 | break 11 | except RuntimeError: 12 | pass 13 | 14 | if not colour.ready(): 15 | raise RuntimeError("No bh1745 found!") 16 | -------------------------------------------------------------------------------- /examples/hex-colour.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import time 3 | 4 | from bh1745 import BH1745 5 | 6 | bh1745 = BH1745() 7 | 8 | bh1745.setup() 9 | bh1745.set_leds(1) 10 | 11 | time.sleep(1.0) # Skip the reading that happened before the LEDs were enabled 12 | 13 | try: 14 | while True: 15 | r, g, b = bh1745.get_rgb_scaled() 16 | print(f"#{r:02x}{g:02x}{b:02x}") 17 | time.sleep(0.5) 18 | 19 | except KeyboardInterrupt: 20 | bh1745.set_leds(0) 21 | -------------------------------------------------------------------------------- /examples/raw-colour.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import time 3 | 4 | from bh1745 import BH1745 5 | 6 | bh1745 = BH1745() 7 | 8 | bh1745.setup() 9 | bh1745.set_leds(1) 10 | 11 | time.sleep(1.0) # Skip the reading that happened before the LEDs were enabled 12 | 13 | try: 14 | while True: 15 | r, g, b, c = bh1745.get_rgbc_raw() 16 | print(f"RGBC: {r:10.1f} {g:10.1f} {b:10.1f} {c:10.1f}") 17 | time.sleep(0.5) 18 | 19 | except KeyboardInterrupt: 20 | bh1745.set_leds(0) 21 | -------------------------------------------------------------------------------- /examples/setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = 3 | E501 # line too long 4 | # Don't require docstrings in example code 5 | D100 # Missing docstring in public module 6 | D103 # Missing docstring in public function 7 | -------------------------------------------------------------------------------- /examples/two-sensors.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import time 3 | 4 | from bh1745 import BH1745 5 | 6 | bh1745_a = BH1745(0x38) # Stock BH1745 breakout or jumper soldered 7 | bh1745_b = BH1745(0x39) # Cuttable trace cut 8 | 9 | bh1745_a.setup() 10 | bh1745_b.setup() 11 | 12 | bh1745_a.set_leds(1) 13 | bh1745_b.set_leds(1) 14 | 15 | try: 16 | while True: 17 | r, g, b = bh1745_a.get_rgb_scaled() 18 | print(f"A: #{r:02x}{g:02x}{b:02x}") 19 | r, g, b = bh1745_b.get_rgb_scaled() 20 | print(f"B: #{r:02x}{g:02x}{b:02x}") 21 | time.sleep(1.0) 22 | 23 | except KeyboardInterrupt: 24 | bh1745_a.set_leds(0) 25 | bh1745_b.set_leds(0) 26 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | LIBRARY_NAME=$(grep -m 1 name pyproject.toml | awk -F" = " '{print substr($2,2,length($2)-2)}') 3 | CONFIG_FILE=config.txt 4 | CONFIG_DIR="/boot/firmware" 5 | DATESTAMP=$(date "+%Y-%m-%d-%H-%M-%S") 6 | CONFIG_BACKUP=false 7 | APT_HAS_UPDATED=false 8 | RESOURCES_TOP_DIR="$HOME/Pimoroni" 9 | VENV_BASH_SNIPPET="$RESOURCES_TOP_DIR/auto_venv.sh" 10 | VENV_DIR="$HOME/.virtualenvs/pimoroni" 11 | USAGE="./install.sh (--unstable)" 12 | POSITIONAL_ARGS=() 13 | FORCE=false 14 | UNSTABLE=false 15 | PYTHON="python" 16 | CMD_ERRORS=false 17 | 18 | 19 | user_check() { 20 | if [ "$(id -u)" -eq 0 ]; then 21 | fatal "Script should not be run as root. Try './install.sh'\n" 22 | fi 23 | } 24 | 25 | confirm() { 26 | if $FORCE; then 27 | true 28 | else 29 | read -r -p "$1 [y/N] " response < /dev/tty 30 | if [[ $response =~ ^(yes|y|Y)$ ]]; then 31 | true 32 | else 33 | false 34 | fi 35 | fi 36 | } 37 | 38 | success() { 39 | echo -e "$(tput setaf 2)$1$(tput sgr0)" 40 | } 41 | 42 | inform() { 43 | echo -e "$(tput setaf 6)$1$(tput sgr0)" 44 | } 45 | 46 | warning() { 47 | echo -e "$(tput setaf 1)⚠ WARNING:$(tput sgr0) $1" 48 | } 49 | 50 | fatal() { 51 | echo -e "$(tput setaf 1)⚠ FATAL:$(tput sgr0) $1" 52 | exit 1 53 | } 54 | 55 | find_config() { 56 | if [ ! -f "$CONFIG_DIR/$CONFIG_FILE" ]; then 57 | CONFIG_DIR="/boot" 58 | if [ ! -f "$CONFIG_DIR/$CONFIG_FILE" ]; then 59 | fatal "Could not find $CONFIG_FILE!" 60 | fi 61 | fi 62 | inform "Using $CONFIG_FILE in $CONFIG_DIR" 63 | } 64 | 65 | venv_bash_snippet() { 66 | inform "Checking for $VENV_BASH_SNIPPET\n" 67 | if [ ! -f "$VENV_BASH_SNIPPET" ]; then 68 | inform "Creating $VENV_BASH_SNIPPET\n" 69 | mkdir -p "$RESOURCES_TOP_DIR" 70 | cat << EOF > "$VENV_BASH_SNIPPET" 71 | # Add "source $VENV_BASH_SNIPPET" to your ~/.bashrc to activate 72 | # the Pimoroni virtual environment automagically! 73 | VENV_DIR="$VENV_DIR" 74 | if [ ! -f \$VENV_DIR/bin/activate ]; then 75 | printf "Creating user Python environment in \$VENV_DIR, please wait...\n" 76 | mkdir -p \$VENV_DIR 77 | python3 -m venv --system-site-packages \$VENV_DIR 78 | fi 79 | printf " ↓ ↓ ↓ ↓ Hello, we've activated a Python venv for you. To exit, type \"deactivate\".\n" 80 | source \$VENV_DIR/bin/activate 81 | EOF 82 | fi 83 | } 84 | 85 | venv_check() { 86 | PYTHON_BIN=$(which "$PYTHON") 87 | if [[ $VIRTUAL_ENV == "" ]] || [[ $PYTHON_BIN != $VIRTUAL_ENV* ]]; then 88 | printf "This script should be run in a virtual Python environment.\n" 89 | if confirm "Would you like us to create and/or use a default one?"; then 90 | printf "\n" 91 | if [ ! -f "$VENV_DIR/bin/activate" ]; then 92 | inform "Creating a new virtual Python environment in $VENV_DIR, please wait...\n" 93 | mkdir -p "$VENV_DIR" 94 | /usr/bin/python3 -m venv "$VENV_DIR" --system-site-packages 95 | venv_bash_snippet 96 | # shellcheck disable=SC1091 97 | source "$VENV_DIR/bin/activate" 98 | else 99 | inform "Activating existing virtual Python environment in $VENV_DIR\n" 100 | printf "source \"%s/bin/activate\"\n" "$VENV_DIR" 101 | # shellcheck disable=SC1091 102 | source "$VENV_DIR/bin/activate" 103 | fi 104 | else 105 | printf "\n" 106 | fatal "Please create and/or activate a virtual Python environment and try again!\n" 107 | fi 108 | fi 109 | printf "\n" 110 | } 111 | 112 | check_for_error() { 113 | if [ $? -ne 0 ]; then 114 | CMD_ERRORS=true 115 | warning "^^^ 😬 previous command did not exit cleanly!" 116 | fi 117 | } 118 | 119 | function do_config_backup { 120 | if [ ! $CONFIG_BACKUP == true ]; then 121 | CONFIG_BACKUP=true 122 | FILENAME="config.preinstall-$LIBRARY_NAME-$DATESTAMP.txt" 123 | inform "Backing up $CONFIG_DIR/$CONFIG_FILE to $CONFIG_DIR/$FILENAME\n" 124 | sudo cp "$CONFIG_DIR/$CONFIG_FILE" "$CONFIG_DIR/$FILENAME" 125 | mkdir -p "$RESOURCES_TOP_DIR/config-backups/" 126 | cp $CONFIG_DIR/$CONFIG_FILE "$RESOURCES_TOP_DIR/config-backups/$FILENAME" 127 | if [ -f "$UNINSTALLER" ]; then 128 | echo "cp $RESOURCES_TOP_DIR/config-backups/$FILENAME $CONFIG_DIR/$CONFIG_FILE" >> "$UNINSTALLER" 129 | fi 130 | fi 131 | } 132 | 133 | function apt_pkg_install { 134 | PACKAGES_NEEDED=() 135 | PACKAGES_IN=("$@") 136 | # Check the list of packages and only run update/install if we need to 137 | for ((i = 0; i < ${#PACKAGES_IN[@]}; i++)); do 138 | PACKAGE="${PACKAGES_IN[$i]}" 139 | if [ "$PACKAGE" == "" ]; then continue; fi 140 | printf "Checking for %s\n" "$PACKAGE" 141 | dpkg -L "$PACKAGE" > /dev/null 2>&1 142 | if [ "$?" == "1" ]; then 143 | PACKAGES_NEEDED+=("$PACKAGE") 144 | fi 145 | done 146 | PACKAGES="${PACKAGES_NEEDED[*]}" 147 | if ! [ "$PACKAGES" == "" ]; then 148 | printf "\n" 149 | inform "Installing missing packages: $PACKAGES" 150 | if [ ! $APT_HAS_UPDATED ]; then 151 | sudo apt update 152 | APT_HAS_UPDATED=true 153 | fi 154 | # shellcheck disable=SC2086 155 | sudo apt install -y $PACKAGES 156 | check_for_error 157 | if [ -f "$UNINSTALLER" ]; then 158 | echo "apt uninstall -y $PACKAGES" >> "$UNINSTALLER" 159 | fi 160 | fi 161 | } 162 | 163 | function pip_pkg_install { 164 | # A null Keyring prevents pip stalling in the background 165 | PYTHON_KEYRING_BACKEND=keyring.backends.null.Keyring $PYTHON -m pip install --upgrade "$@" 166 | check_for_error 167 | } 168 | 169 | while [[ $# -gt 0 ]]; do 170 | K="$1" 171 | case $K in 172 | -u|--unstable) 173 | UNSTABLE=true 174 | shift 175 | ;; 176 | -f|--force) 177 | FORCE=true 178 | shift 179 | ;; 180 | -p|--python) 181 | PYTHON=$2 182 | shift 183 | shift 184 | ;; 185 | *) 186 | if [[ $1 == -* ]]; then 187 | printf "Unrecognised option: %s\n" "$1"; 188 | printf "Usage: %s\n" "$USAGE"; 189 | exit 1 190 | fi 191 | POSITIONAL_ARGS+=("$1") 192 | shift 193 | esac 194 | done 195 | 196 | printf "Installing %s...\n\n" "$LIBRARY_NAME" 197 | 198 | user_check 199 | venv_check 200 | 201 | if [ ! -f "$(which "$PYTHON")" ]; then 202 | fatal "Python path %s not found!\n" "$PYTHON" 203 | fi 204 | 205 | PYTHON_VER=$($PYTHON --version) 206 | 207 | inform "Checking Dependencies. Please wait..." 208 | 209 | # Install toml and try to read pyproject.toml into bash variables 210 | 211 | pip_pkg_install toml 212 | 213 | CONFIG_VARS=$( 214 | $PYTHON - < "$UNINSTALLER" 257 | printf "It's recommended you run these steps manually.\n" 258 | printf "If you want to run the full script, open it in\n" 259 | printf "an editor and remove 'exit 1' from below.\n" 260 | exit 1 261 | source $VIRTUAL_ENV/bin/activate 262 | EOF 263 | 264 | printf "\n" 265 | 266 | inform "Installing for $PYTHON_VER...\n" 267 | 268 | # Install apt packages from pyproject.toml / tool.pimoroni.apt_packages 269 | apt_pkg_install "${APT_PACKAGES[@]}" 270 | 271 | printf "\n" 272 | 273 | if $UNSTABLE; then 274 | warning "Installing unstable library from source.\n" 275 | pip_pkg_install . 276 | else 277 | inform "Installing stable library from pypi.\n" 278 | pip_pkg_install "$LIBRARY_NAME" 279 | fi 280 | 281 | # shellcheck disable=SC2181 # One of two commands run, depending on --unstable flag 282 | if [ $? -eq 0 ]; then 283 | success "Done!\n" 284 | echo "$PYTHON -m pip uninstall $LIBRARY_NAME" >> "$UNINSTALLER" 285 | fi 286 | 287 | find_config 288 | 289 | printf "\n" 290 | 291 | # Run the setup commands from pyproject.toml / tool.pimoroni.commands 292 | 293 | inform "Running setup commands...\n" 294 | for ((i = 0; i < ${#SETUP_CMDS[@]}; i++)); do 295 | CMD="${SETUP_CMDS[$i]}" 296 | # Attempt to catch anything that touches config.txt and trigger a backup 297 | if [[ "$CMD" == *"raspi-config"* ]] || [[ "$CMD" == *"$CONFIG_DIR/$CONFIG_FILE"* ]] || [[ "$CMD" == *"\$CONFIG_DIR/\$CONFIG_FILE"* ]]; then 298 | do_config_backup 299 | fi 300 | if [[ ! "$CMD" == printf* ]]; then 301 | printf "Running: \"%s\"\n" "$CMD" 302 | fi 303 | eval "$CMD" 304 | check_for_error 305 | done 306 | 307 | printf "\n" 308 | 309 | # Add the config.txt entries from pyproject.toml / tool.pimoroni.configtxt 310 | 311 | for ((i = 0; i < ${#CONFIG_TXT[@]}; i++)); do 312 | CONFIG_LINE="${CONFIG_TXT[$i]}" 313 | if ! [ "$CONFIG_LINE" == "" ]; then 314 | do_config_backup 315 | inform "Adding $CONFIG_LINE to $CONFIG_DIR/$CONFIG_FILE" 316 | sudo sed -i "s/^#$CONFIG_LINE/$CONFIG_LINE/" $CONFIG_DIR/$CONFIG_FILE 317 | if ! grep -q "^$CONFIG_LINE" $CONFIG_DIR/$CONFIG_FILE; then 318 | printf "%s \n" "$CONFIG_LINE" | sudo tee --append $CONFIG_DIR/$CONFIG_FILE 319 | fi 320 | fi 321 | done 322 | 323 | printf "\n" 324 | 325 | # Just a straight copy of the examples/ dir into ~/Pimoroni/board/examples 326 | 327 | if [ -d "examples" ]; then 328 | if confirm "Would you like to copy examples to $RESOURCES_DIR?"; then 329 | inform "Copying examples to $RESOURCES_DIR" 330 | cp -r examples/ "$RESOURCES_DIR" 331 | echo "rm -r $RESOURCES_DIR" >> "$UNINSTALLER" 332 | success "Done!" 333 | fi 334 | fi 335 | 336 | printf "\n" 337 | 338 | # Use pdoc to generate basic documentation from the installed module 339 | 340 | if confirm "Would you like to generate documentation?"; then 341 | inform "Installing pdoc. Please wait..." 342 | pip_pkg_install pdoc 343 | inform "Generating documentation.\n" 344 | if $PYTHON -m pdoc "$LIBRARY_NAME" -o "$RESOURCES_DIR/docs" > /dev/null; then 345 | inform "Documentation saved to $RESOURCES_DIR/docs" 346 | success "Done!" 347 | else 348 | warning "Error: Failed to generate documentation." 349 | fi 350 | fi 351 | 352 | printf "\n" 353 | 354 | if [ "$CMD_ERRORS" = true ]; then 355 | warning "One or more setup commands appear to have failed." 356 | printf "This might prevent things from working properly.\n" 357 | printf "Make sure your OS is up to date and try re-running this installer.\n" 358 | printf "If things still don't work, report this or find help at %s.\n\n" "$GITHUB_URL" 359 | else 360 | success "\nAll done!" 361 | fi 362 | 363 | printf "If this is your first time installing you should reboot for hardware changes to take effect.\n" 364 | printf "Find uninstall steps in %s\n\n" "$UNINSTALLER" 365 | 366 | if [ "$CMD_ERRORS" = true ]; then 367 | exit 1 368 | else 369 | exit 0 370 | fi 371 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling", "hatch-fancy-pypi-readme"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "bh1745" 7 | dynamic = ["version", "readme"] 8 | description = "Python library for the BH1745 colour sensor" 9 | license = {file = "LICENSE"} 10 | requires-python = ">= 3.7" 11 | authors = [ 12 | { name = "Philip Howard", email = "phil@pimoroni.com" }, 13 | ] 14 | maintainers = [ 15 | { name = "Philip Howard", email = "phil@pimoroni.com" }, 16 | ] 17 | keywords = [ 18 | "Pi", 19 | "Raspberry", 20 | ] 21 | classifiers = [ 22 | "Development Status :: 4 - Beta", 23 | "Intended Audience :: Developers", 24 | "License :: OSI Approved :: MIT License", 25 | "Operating System :: POSIX :: Linux", 26 | "Programming Language :: Python :: 3", 27 | "Programming Language :: Python :: 3.7", 28 | "Programming Language :: Python :: 3.8", 29 | "Programming Language :: Python :: 3.9", 30 | "Programming Language :: Python :: 3.10", 31 | "Programming Language :: Python :: 3.11", 32 | "Programming Language :: Python :: 3 :: Only", 33 | "Topic :: Software Development", 34 | "Topic :: Software Development :: Libraries", 35 | "Topic :: System :: Hardware", 36 | ] 37 | dependencies = [ 38 | "i2cdevice >= 1.0.0" 39 | ] 40 | 41 | [project.urls] 42 | GitHub = "https://www.github.com/pimoroni/bh1745-python" 43 | Homepage = "https://www.pimoroni.com" 44 | 45 | [tool.hatch.version] 46 | path = "bh1745/__init__.py" 47 | 48 | [tool.hatch.build] 49 | include = [ 50 | "bh1745", 51 | "README.md", 52 | "CHANGELOG.md", 53 | "LICENSE" 54 | ] 55 | 56 | [tool.hatch.build.targets.sdist] 57 | include = [ 58 | "*" 59 | ] 60 | exclude = [ 61 | ".*", 62 | "dist" 63 | ] 64 | 65 | [tool.hatch.metadata.hooks.fancy-pypi-readme] 66 | content-type = "text/markdown" 67 | fragments = [ 68 | { path = "README.md" }, 69 | { text = "\n" }, 70 | { path = "CHANGELOG.md" } 71 | ] 72 | 73 | [tool.ruff] 74 | exclude = [ 75 | '.tox', 76 | '.egg', 77 | '.git', 78 | '__pycache__', 79 | 'build', 80 | 'dist' 81 | ] 82 | line-length = 200 83 | 84 | [tool.codespell] 85 | skip = """ 86 | ./.tox,\ 87 | ./.egg,\ 88 | ./.git,\ 89 | ./__pycache__,\ 90 | ./build,\ 91 | ./dist.\ 92 | """ 93 | 94 | [tool.isort] 95 | line_length = 200 96 | 97 | [tool.check-manifest] 98 | ignore = [ 99 | '.stickler.yml', 100 | 'boilerplate.md', 101 | 'check.sh', 102 | 'install.sh', 103 | 'uninstall.sh', 104 | 'Makefile', 105 | 'tox.ini', 106 | 'tests/*', 107 | 'examples/*', 108 | '.coveragerc', 109 | 'requirements-dev.txt' 110 | ] 111 | 112 | [tool.pimoroni] 113 | apt_packages = [] 114 | configtxt = [] 115 | commands = [ 116 | "sudo raspi-config nonint do_i2c 0" 117 | ] 118 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | check-manifest 2 | ruff 3 | codespell 4 | isort 5 | twine 6 | hatch 7 | hatch-fancy-pypi-readme 8 | tox 9 | pdoc 10 | -------------------------------------------------------------------------------- /tests/test_features.py: -------------------------------------------------------------------------------- 1 | # noqa D100 2 | import sys 3 | 4 | import mock 5 | 6 | 7 | def _setup(): 8 | global bh1745 9 | from tools import SMBusFakeDeviceNoTimeout 10 | 11 | from bh1745 import BH1745 12 | 13 | smbus = mock.Mock() 14 | smbus.SMBus = SMBusFakeDeviceNoTimeout 15 | sys.modules["smbus2"] = smbus 16 | bh1745 = BH1745() 17 | 18 | 19 | def test_set_adc_gain_x(): 20 | """Test setting adc gain amount.""" 21 | _setup() 22 | bh1745.setup(timeout=0.01) 23 | 24 | bh1745.set_adc_gain_x(1) 25 | assert bh1745._bh1745.MODE_CONTROL2.get_adc_gain_x() == 1 26 | 27 | # Should snap to 16x 28 | bh1745.set_adc_gain_x(15) 29 | assert bh1745._bh1745.MODE_CONTROL2.get_adc_gain_x() == 16 30 | 31 | 32 | def test_get_rgbc_raw(): 33 | """Test retrieving raw RGBC data against mocked values.""" 34 | from tools import BH1745_COLOUR_DATA 35 | 36 | _setup() 37 | bh1745.setup(timeout=0.01) 38 | 39 | # White balance will change the BH1745_COLOUR_DATA 40 | # and make our test fail. Disable it in this case. 41 | bh1745.enable_white_balance(False) 42 | 43 | colour_data = bh1745.get_rgbc_raw() 44 | 45 | assert colour_data == BH1745_COLOUR_DATA 46 | 47 | 48 | def test_get_rgbc_clamped(): 49 | """Test retrieving raw RGBC data against mocked values.""" 50 | from tools import BH1745_COLOUR_DATA 51 | 52 | _setup() 53 | bh1745.setup(timeout=0.01) 54 | 55 | # White balance will change the BH1745_COLOUR_DATA 56 | # and make our test fail. Disable it in this case. 57 | bh1745.enable_white_balance(False) 58 | 59 | colour_data = bh1745.get_rgb_clamped() 60 | 61 | r, g, b, c = BH1745_COLOUR_DATA 62 | 63 | scale = max(r, g, b) 64 | 65 | scaled_data = [int((x / float(scale)) * 255) for x in BH1745_COLOUR_DATA[0:3]] 66 | 67 | assert list(colour_data) == scaled_data 68 | 69 | 70 | def test_get_rgbc_scaled(): 71 | """Test retrieving raw RGBC data against mocked values.""" 72 | from tools import BH1745_COLOUR_DATA 73 | 74 | _setup() 75 | bh1745.setup(timeout=0.01) 76 | 77 | # White balance will change the BH1745_COLOUR_DATA 78 | # and make our test fail. Disable it in this case. 79 | bh1745.enable_white_balance(False) 80 | 81 | colour_data = bh1745.get_rgb_scaled() 82 | 83 | scaled_data = [int((x / float(BH1745_COLOUR_DATA[3])) * 255) for x in BH1745_COLOUR_DATA[0:3]] 84 | 85 | assert list(colour_data) == scaled_data 86 | 87 | 88 | def test_set_measurement_time_ms(): 89 | """Test setting measurement time to valid and snapped value.""" 90 | _setup() 91 | 92 | bh1745.set_measurement_time_ms(320) 93 | assert bh1745._bh1745.MODE_CONTROL1.get_measurement_time_ms() == 320 94 | 95 | # Should snap to 160 96 | bh1745.set_measurement_time_ms(100) 97 | assert bh1745._bh1745.MODE_CONTROL1.get_measurement_time_ms() == 160 98 | -------------------------------------------------------------------------------- /tests/test_setup.py: -------------------------------------------------------------------------------- 1 | # noqa D100 2 | import sys 3 | 4 | import mock 5 | import pytest 6 | 7 | 8 | def test_setup_id_mismatch(): 9 | """Test an attempt to set up the BH1745 with invalid sensor present.""" 10 | sys.modules["smbus2"] = mock.MagicMock() 11 | from bh1745 import BH1745 12 | 13 | bh1745 = BH1745() 14 | with pytest.raises(RuntimeError): 15 | bh1745.setup() 16 | 17 | 18 | def test_setup_not_present(): 19 | """Test an attempt to set up the BH1745 with no sensor present.""" 20 | from tools import SMBusFakeDeviceIOError 21 | 22 | from bh1745 import BH1745 23 | 24 | smbus = mock.Mock() 25 | smbus.SMBus = SMBusFakeDeviceIOError 26 | sys.modules["smbus2"] = smbus 27 | bh1745 = BH1745() 28 | with pytest.raises(RuntimeError): 29 | bh1745.setup() 30 | 31 | 32 | def test_setup_mock_invalid_timeout(): 33 | """Test an attempt to set up the BH1745 with a reset timeout.""" 34 | from tools import SMBusFakeDevice 35 | 36 | from bh1745 import BH1745 37 | 38 | smbus = mock.Mock() 39 | smbus.SMBus = SMBusFakeDevice 40 | sys.modules["smbus2"] = smbus 41 | 42 | with pytest.raises(ValueError): 43 | bh1745 = BH1745() 44 | bh1745.setup(timeout=0) 45 | 46 | with pytest.raises(ValueError): 47 | bh1745 = BH1745() 48 | bh1745.setup(timeout=-1) 49 | 50 | 51 | def test_setup_mock_timeout(): 52 | """Test an attempt to set up the BH1745 with a reset timeout.""" 53 | from tools import SMBusFakeDevice 54 | 55 | from bh1745 import BH1745, BH1745TimeoutError 56 | 57 | smbus = mock.Mock() 58 | smbus.SMBus = SMBusFakeDevice 59 | sys.modules["smbus2"] = smbus 60 | bh1745 = BH1745() 61 | with pytest.raises(BH1745TimeoutError): 62 | bh1745.setup(timeout=0.01) 63 | 64 | 65 | def test_setup_mock_present(): 66 | """Test an attempt to set up a present and working (mocked) BH1745.""" 67 | from tools import SMBusFakeDeviceNoTimeout 68 | 69 | from bh1745 import BH1745 70 | 71 | smbus = mock.Mock() 72 | smbus.SMBus = SMBusFakeDeviceNoTimeout 73 | sys.modules["smbus2"] = smbus 74 | bh1745 = BH1745() 75 | bh1745.setup(timeout=0.01) 76 | 77 | 78 | def test_i2c_addr(): 79 | """Test various valid and invalid i2c addresses for BH1745.""" 80 | from tools import SMBusFakeDeviceNoTimeout 81 | 82 | from bh1745 import BH1745 83 | 84 | smbus = mock.Mock() 85 | smbus.SMBus = SMBusFakeDeviceNoTimeout 86 | sys.modules["smbus2"] = smbus 87 | 88 | with pytest.raises(ValueError): 89 | bh1745 = BH1745(i2c_addr=0x40) 90 | 91 | with pytest.raises(ValueError): 92 | bh1745 = BH1745() 93 | bh1745.setup(i2c_addr=0x40) 94 | 95 | bh1745 = BH1745(i2c_addr=0x38) 96 | bh1745 = BH1745(i2c_addr=0x39) 97 | 98 | del bh1745 99 | 100 | 101 | def test_is_setup(): 102 | """Test ready() returns correct state.""" 103 | from tools import SMBusFakeDeviceNoTimeout 104 | 105 | from bh1745 import BH1745 106 | 107 | smbus = mock.Mock() 108 | smbus.SMBus = SMBusFakeDeviceNoTimeout 109 | sys.modules["smbus2"] = smbus 110 | 111 | bh1745 = BH1745() 112 | assert bh1745.ready() is False 113 | 114 | bh1745.setup() 115 | assert bh1745.ready() is True 116 | -------------------------------------------------------------------------------- /tests/tools.py: -------------------------------------------------------------------------------- 1 | """Test tools for the BH1745 colour sensor.""" 2 | import struct 3 | 4 | from i2cdevice import MockSMBus 5 | 6 | BH1745_COLOUR_DATA = (666, 777, 888, 999) 7 | 8 | 9 | class SMBusFakeDeviceIOError(MockSMBus): 10 | """Mock a BH1745 that returns an IOError in all cases.""" 11 | 12 | def write_i2c_block_data(self, i2c_address, register, values): 13 | """Raise an IO Error for any write attempt.""" 14 | raise IOError("IOError: Fake Device Not Found") 15 | 16 | def read_i2c_block_data(self, i2c_address, register, length): 17 | """Raise an IO Error for any read attempt.""" 18 | raise IOError("IOError: Fake Device Not Found") 19 | 20 | 21 | class SMBusFakeDevice(MockSMBus): 22 | """Mock a BH1745 with fake register data.""" 23 | 24 | def __init__(self, i2c_bus): 25 | """Initialise device mock. 26 | 27 | :param i2c_bus: i2c bus ID 28 | 29 | """ 30 | MockSMBus.__init__(self, i2c_bus) 31 | self.regs[0x40] = 0b001011 # Fake part number 32 | self.regs[0x92] = 0xE0 # Fake manufacturer ID 33 | 34 | colour_data = struct.pack("=3.1 14 | pytest-cov 15 | build 16 | 17 | [testenv:qa] 18 | commands = 19 | check-manifest 20 | python -m build --no-isolation 21 | python -m twine check dist/* 22 | isort --check . 23 | ruff check . 24 | codespell . 25 | deps = 26 | check-manifest 27 | ruff 28 | codespell 29 | isort 30 | twine 31 | build 32 | hatch 33 | hatch-fancy-pypi-readme 34 | 35 | -------------------------------------------------------------------------------- /uninstall.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | FORCE=false 4 | LIBRARY_NAME=$(grep -m 1 name pyproject.toml | awk -F" = " '{print substr($2,2,length($2)-2)}') 5 | RESOURCES_DIR=$HOME/Pimoroni/$LIBRARY_NAME 6 | PYTHON="python" 7 | 8 | 9 | venv_check() { 10 | PYTHON_BIN=$(which $PYTHON) 11 | if [[ $VIRTUAL_ENV == "" ]] || [[ $PYTHON_BIN != $VIRTUAL_ENV* ]]; then 12 | printf "This script should be run in a virtual Python environment.\n" 13 | exit 1 14 | fi 15 | } 16 | 17 | user_check() { 18 | if [ "$(id -u)" -eq 0 ]; then 19 | printf "Script should not be run as root. Try './uninstall.sh'\n" 20 | exit 1 21 | fi 22 | } 23 | 24 | confirm() { 25 | if $FORCE; then 26 | true 27 | else 28 | read -r -p "$1 [y/N] " response < /dev/tty 29 | if [[ $response =~ ^(yes|y|Y)$ ]]; then 30 | true 31 | else 32 | false 33 | fi 34 | fi 35 | } 36 | 37 | prompt() { 38 | read -r -p "$1 [y/N] " response < /dev/tty 39 | if [[ $response =~ ^(yes|y|Y)$ ]]; then 40 | true 41 | else 42 | false 43 | fi 44 | } 45 | 46 | success() { 47 | echo -e "$(tput setaf 2)$1$(tput sgr0)" 48 | } 49 | 50 | inform() { 51 | echo -e "$(tput setaf 6)$1$(tput sgr0)" 52 | } 53 | 54 | warning() { 55 | echo -e "$(tput setaf 1)$1$(tput sgr0)" 56 | } 57 | 58 | printf "%s Python Library: Uninstaller\n\n" "$LIBRARY_NAME" 59 | 60 | user_check 61 | venv_check 62 | 63 | printf "Uninstalling for Python 3...\n" 64 | $PYTHON -m pip uninstall "$LIBRARY_NAME" 65 | 66 | if [ -d "$RESOURCES_DIR" ]; then 67 | if confirm "Would you like to delete $RESOURCES_DIR?"; then 68 | rm -r "$RESOURCES_DIR" 69 | fi 70 | fi 71 | 72 | printf "Done!\n" 73 | --------------------------------------------------------------------------------