diff --git a/Actuators/BasicStepperDriver/BasicStepperDriver/BasicStepperDriver.py b/Actuators/BasicStepperDriver/BasicStepperDriver/BasicStepperDriver.py new file mode 100644 index 0000000..2380fc7 --- /dev/null +++ b/Actuators/BasicStepperDriver/BasicStepperDriver/BasicStepperDriver.py @@ -0,0 +1,466 @@ +# FILE: BasicStepperDriver.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: MicroPython stepper motor library with acceleration/deceleration and multi-axis support +# LAST UPDATED: 2026-05-25 + +import math +import time +from machine import Pin + +MULTISTEPPER_MAX_STEPPERS = 10 + + +class BasicStepper: + """ + Stepper motor controller with acceleration/deceleration support. + + Supports stepper drivers (step/dir), 2/3/4-wire full and half-step motors, + and functional (callback) interfaces. Multiple simultaneous steppers are + supported by calling run() on each in your main loop. + + Algorithm based on "Generate stepper-motor speed profiles in real time" + by David Austin. + """ + + # Motor interface types + FUNCTION = 0 # Callback functions (forward/backward) + DRIVER = 1 # Step/Direction driver (A4988, DRV8825, TMC2208, ...) + FULL2WIRE = 2 # 2-wire bipolar + FULL3WIRE = 3 # 3-wire (e.g. HDD spindle) + FULL4WIRE = 4 # 4-wire full step + HALF3WIRE = 6 # 3-wire half step + HALF4WIRE = 8 # 4-wire half step + + _DIRECTION_CCW = 0 + _DIRECTION_CW = 1 + + # Step sequencing tables — class-level tuples, allocated once + _STEP2_SEQ = (0b10, 0b11, 0b01, 0b00) + _STEP3_SEQ = (0b100, 0b001, 0b010) + _STEP4_SEQ = (0b0101, 0b0110, 0b1010, 0b1001) + _STEP6_SEQ = (0b100, 0b101, 0b001, 0b011, 0b010, 0b110) + _STEP8_SEQ = (0b0001, 0b0101, 0b0100, 0b0110, + 0b0010, 0b1010, 0b1000, 0b1001) + + def __init__(self, interface=4, pin1=2, pin2=3, pin3=4, pin4=5, enable=True): + """ + Standard constructor. + interface : motor interface type (BasicStepper.DRIVER, BasicStepper.FULL4WIRE, ...) + pin1-pin4 : GPIO pin numbers + enable : call enableOutputs() at construction time (default True) + + Functional constructor — pass callables instead of interface type: + interface : forward() callback + pin1 : backward() callback + """ + if callable(interface): + self._interface = self.FUNCTION + self._forward_cb = interface + self._backward_cb = pin1 + self._pin_nums = [0, 0, 0, 0] + else: + self._interface = interface + self._forward_cb = None + self._backward_cb = None + self._pin_nums = [pin1, pin2, pin3, pin4] + + # Cache pin count — interface never changes after construction + if self._interface in (self.FULL4WIRE, self.HALF4WIRE): + self._numpins = 4 + elif self._interface in (self.FULL3WIRE, self.HALF3WIRE): + self._numpins = 3 + else: + self._numpins = 2 + + self._pin = [None, None, None, None] + self._pinInverted = [0, 0, 0, 0] + + self._currentPos = 0 + self._targetPos = 0 + self._speed = 0.0 + self._maxSpeed = 0.0 + self._acceleration = 0.0 + self._sqrt_twoa = 1.0 + self._stepInterval = 0 + self._minPulseWidth = 1 + self._enablePin = None + self._enableInverted = False + self._lastStepTime = 0 + self._direction = self._DIRECTION_CCW + self._n = 0 + self._c0 = 0.0 + self._cn = 0.0 + self._cmin = 1.0 + + if enable: + self.enableOutputs() + + self.setAcceleration(1) + self.setMaxSpeed(1) + + # ------------------------------------------------------------------ + # Position / target + # ------------------------------------------------------------------ + + def moveTo(self, absolute): + """Set absolute target position (steps). Triggers speed recompute.""" + if self._targetPos != absolute: + self._targetPos = absolute + self.computeNewSpeed() + + def move(self, relative): + """Set target position relative to current position.""" + self.moveTo(self._currentPos + relative) + + def distanceToGo(self): + """Steps remaining to target. Positive = CW.""" + return self._targetPos - self._currentPos + + def targetPosition(self): + """Most recently set target position in steps.""" + return self._targetPos + + def currentPosition(self): + """Current motor position in steps.""" + return self._currentPos + + def setCurrentPosition(self, position): + """Reset current position to given value without moving. Also zeroes speed.""" + self._targetPos = position + self._currentPos = position + self._n = 0 + self._stepInterval = 0 + self._speed = 0.0 + + # ------------------------------------------------------------------ + # Speed / acceleration + # ------------------------------------------------------------------ + + def setMaxSpeed(self, speed): + """Set maximum speed in steps/second. Must be > 0.""" + if speed < 0.0: + speed = -speed + if self._maxSpeed != speed: + self._maxSpeed = speed + self._cmin = 1000000.0 / speed + if self._n > 0 and self._acceleration > 0: + self._n = int((self._speed * self._speed) / (2.0 * self._acceleration)) + self.computeNewSpeed() + + def maxSpeed(self): + """Return currently configured maximum speed.""" + return self._maxSpeed + + def setAcceleration(self, acceleration): + """ + Set acceleration/deceleration in steps/second^2. Must be > 0. + Expensive call (computes sqrt). Don't call more often than needed. + """ + if acceleration == 0.0: + return + if acceleration < 0.0: + acceleration = -acceleration + if self._acceleration != acceleration: + if self._acceleration > 0: + self._n = int(self._n * (self._acceleration / acceleration)) + self._c0 = 0.676 * math.sqrt(2.0 / acceleration) * 1000000.0 # Eq. 15 + self._acceleration = acceleration + self.computeNewSpeed() + + def acceleration(self): + """Return currently configured acceleration.""" + return self._acceleration + + def setSpeed(self, speed): + """Set constant speed for use with runSpeed(). Positive = CW. Clamped to ±maxSpeed.""" + if speed == self._speed: + return + speed = max(-self._maxSpeed, min(self._maxSpeed, speed)) + if speed == 0.0: + self._stepInterval = 0 + else: + self._stepInterval = abs(1000000.0 / speed) + self._direction = self._DIRECTION_CW if speed > 0.0 else self._DIRECTION_CCW + self._speed = speed + + def speed(self): + """Return most recently set speed.""" + return self._speed + + def computeNewSpeed(self): + """ + Recompute instantaneous step interval based on position and acceleration. + Called internally after each step and after parameter changes. + """ + distanceTo = self.distanceToGo() + stepsToStop = int((self._speed * self._speed) / (2.0 * self._acceleration)) \ + if self._acceleration > 0 else 0 # Eq. 16 + + if distanceTo == 0 and stepsToStop <= 1: + self._stepInterval = 0 + self._speed = 0.0 + self._n = 0 + return self._stepInterval + + if distanceTo > 0: + if self._n > 0: + if stepsToStop >= distanceTo or self._direction == self._DIRECTION_CCW: + self._n = -stepsToStop + elif self._n < 0: + if stepsToStop < distanceTo and self._direction == self._DIRECTION_CW: + self._n = -self._n + elif distanceTo < 0: + if self._n > 0: + if stepsToStop >= -distanceTo or self._direction == self._DIRECTION_CW: + self._n = -stepsToStop + elif self._n < 0: + if stepsToStop < -distanceTo and self._direction == self._DIRECTION_CCW: + self._n = -self._n + + if self._n == 0: + self._cn = self._c0 + self._direction = self._DIRECTION_CW if distanceTo > 0 else self._DIRECTION_CCW + else: + self._cn = self._cn - ((2.0 * self._cn) / ((4.0 * self._n) + 1)) # Eq. 13 + self._cn = max(self._cn, self._cmin) + + self._n += 1 + self._stepInterval = self._cn + self._speed = 1000000.0 / self._cn + if self._direction == self._DIRECTION_CCW: + self._speed = -self._speed + + return self._stepInterval + + # ------------------------------------------------------------------ + # Run functions (call frequently in main loop) + # ------------------------------------------------------------------ + + def run(self): + """ + Step motor once if due, with acceleration/deceleration toward target. + Call as frequently as possible in main loop. + Returns True if motor is still running toward target. + """ + if self.runSpeed(): + self.computeNewSpeed() + return self._speed != 0.0 or self.distanceToGo() != 0 + + def runSpeed(self): + """ + Step motor once if due, at constant speed set by setSpeed(). + Returns True if a step occurred. + """ + if not self._stepInterval: + return False + now = time.ticks_us() + if time.ticks_diff(now, self._lastStepTime) >= self._stepInterval: + if self._direction == self._DIRECTION_CW: + self._currentPos += 1 + else: + self._currentPos -= 1 + self._do_step(self._currentPos) + self._lastStepTime = now + return True + return False + + def runToPosition(self): + """Blocking: move to target position with acceleration/deceleration.""" + while self.run(): + time.sleep_us(0) # yield for ESP8266 watchdog + + def runToNewPosition(self, position): + """Blocking: set new target and move there with acceleration/deceleration.""" + self.moveTo(position) + self.runToPosition() + + def runSpeedToPosition(self): + """Non-blocking constant-speed run toward target. Returns True if stepped.""" + if self._targetPos == self._currentPos: + return False + self._direction = self._DIRECTION_CW if self._targetPos > self._currentPos else self._DIRECTION_CCW + return self.runSpeed() + + def stop(self): + """Decelerate to stop as quickly as possible given current acceleration.""" + if self._speed != 0.0 and self._acceleration > 0: + stepsToStop = int((self._speed * self._speed) / (2.0 * self._acceleration)) + 1 + self.move(stepsToStop if self._speed > 0 else -stepsToStop) + + def isRunning(self): + """Return True if motor is moving or has not reached target.""" + return not (self._speed == 0.0 and self._targetPos == self._currentPos) + + # ------------------------------------------------------------------ + # Step output + # ------------------------------------------------------------------ + + def setOutputPins(self, mask): + """ + Set motor output pins according to bitmask. + Bit 0 → pin[0], bit 1 → pin[1], etc. Respects pin inversion. + Can be overridden for serial or other output implementations. + """ + for i in range(self._numpins): + if self._pin[i] is not None: + self._pin[i].value((1 if (mask & (1 << i)) else 0) ^ self._pinInverted[i]) + + def stepForward(self): + """Manual single step CW. Returns updated position.""" + self._currentPos += 1 + self._do_step(self._currentPos) + self._lastStepTime = time.ticks_us() + return self._currentPos + + def stepBackward(self): + """Manual single step CCW. Returns updated position.""" + self._currentPos -= 1 + self._do_step(self._currentPos) + self._lastStepTime = time.ticks_us() + return self._currentPos + + def _do_step(self, step): + iface = self._interface + if iface == self.FUNCTION: self._step0(step) + elif iface == self.DRIVER: self._step1(step) + elif iface == self.FULL2WIRE: self._step2(step) + elif iface == self.FULL3WIRE: self._step3(step) + elif iface == self.FULL4WIRE: self._step4(step) + elif iface == self.HALF3WIRE: self._step6(step) + elif iface == self.HALF4WIRE: self._step8(step) + + def _step0(self, step): + if self._speed > 0: + self._forward_cb() + else: + self._backward_cb() + + def _step1(self, step): + # pin[0] = STEP, pin[1] = DIR + self.setOutputPins(0b10 if self._direction else 0b00) # DIR first + self.setOutputPins(0b11 if self._direction else 0b01) # STEP HIGH + time.sleep_us(self._minPulseWidth) + self.setOutputPins(0b10 if self._direction else 0b00) # STEP LOW + + def _step2(self, step): + self.setOutputPins(self._STEP2_SEQ[step & 0x3]) + + def _step3(self, step): + self.setOutputPins(self._STEP3_SEQ[step % 3]) + + def _step4(self, step): + self.setOutputPins(self._STEP4_SEQ[step & 0x3]) + + def _step6(self, step): + self.setOutputPins(self._STEP6_SEQ[step % 6]) + + def _step8(self, step): + self.setOutputPins(self._STEP8_SEQ[step & 0x7]) + + # ------------------------------------------------------------------ + # Enable / disable + # ------------------------------------------------------------------ + + def enableOutputs(self): + """Set motor pins to OUTPUT and assert enable pin (if set).""" + if not self._interface: + return + for i in range(self._numpins): + self._pin[i] = Pin(self._pin_nums[i], Pin.OUT) + if self._enablePin is not None: + self._enablePin.value(1 ^ int(self._enableInverted)) + + def disableOutputs(self): + """Set all motor pins LOW and de-assert enable pin to save power.""" + if not self._interface: + return + self.setOutputPins(0) + if self._enablePin is not None: + self._enablePin.value(0 ^ int(self._enableInverted)) + + def setMinPulseWidth(self, minWidth): + """Set minimum STEP pulse width in microseconds (DRIVER mode).""" + self._minPulseWidth = minWidth + + def setEnablePin(self, enablePin=None): + """Set enable pin number. Pass None to disable. Pin is asserted immediately.""" + if enablePin is not None: + self._enablePin = Pin(enablePin, Pin.OUT) + self._enablePin.value(1 ^ int(self._enableInverted)) + else: + self._enablePin = None + + def setPinsInverted(self, *args): + """ + Invert step/dir/enable signals. + 3-arg form (directionInvert, stepInvert, enableInvert) — DRIVER mode + 5-arg form (pin1, pin2, pin3, pin4, enableInvert) — multi-wire modes + """ + if len(args) == 3: + self._pinInverted[0] = int(args[1]) # STEP pin + self._pinInverted[1] = int(args[0]) # DIR pin + self._enableInverted = bool(args[2]) + elif len(args) == 5: + for i in range(4): + self._pinInverted[i] = int(args[i]) + self._enableInverted = bool(args[4]) + + +# ---------------------------------------------------------------------- + +class MultiStepper: + """ + Coordinate up to MULTISTEPPER_MAX_STEPPERS BasicStepper instances. + + Computes individual constant speeds so all steppers reach their target + positions at the same time — useful for XY plotters, 3D printers, etc. + + Note: only constant speed is used (no acceleration during coordinated moves). + """ + + def __init__(self): + self._steppers = [] + self._num_steppers = 0 + + def addStepper(self, stepper): + """Add a BasicStepper to the managed set. Returns False if limit exceeded.""" + if self._num_steppers >= MULTISTEPPER_MAX_STEPPERS: + return False + self._steppers.append(stepper) + self._num_steppers += 1 + return True + + def moveTo(self, absolute): + """ + Set target positions for all managed steppers. + Speeds adjusted so all arrive simultaneously. + absolute: list/tuple of positions, one per stepper in order added. + """ + longestTime = 0.0 + for i in range(self._num_steppers): + dist = absolute[i] - self._steppers[i].currentPosition() + spd = self._steppers[i].maxSpeed() + if spd > 0: + t = abs(dist) / spd + if t > longestTime: + longestTime = t + + if longestTime > 0.0: + for i in range(self._num_steppers): + dist = absolute[i] - self._steppers[i].currentPosition() + self._steppers[i].moveTo(absolute[i]) + self._steppers[i].setSpeed(dist / longestTime) + + def run(self): + """Call runSpeed() on each stepper not yet at target. Returns True if any still running.""" + ret = False + for i in range(self._num_steppers): + if self._steppers[i].distanceToGo() != 0: + self._steppers[i].runSpeed() + ret = True + return ret + + def runSpeedToPosition(self): + """Blocking: run all steppers until every target position is reached.""" + while self.run(): + pass diff --git a/Actuators/BasicStepperDriver/BasicStepperDriver/Examples/BasicStepperDriver-blocking.py b/Actuators/BasicStepperDriver/BasicStepperDriver/Examples/BasicStepperDriver-blocking.py new file mode 100644 index 0000000..aa1a2bc --- /dev/null +++ b/Actuators/BasicStepperDriver/BasicStepperDriver/Examples/BasicStepperDriver-blocking.py @@ -0,0 +1,19 @@ +# FILE: BasicStepperDriver-blocking.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Blocking movement — motor moves to each position before continuing +# WORKS WITH: Basic Stepper Driver: www.solde.red/333250 +# LAST UPDATED: 2026-05-25 + +from BasicStepperDriver import BasicStepper + +# 4-wire stepper connected to pins 12, 13, 14, 15 +stepper = BasicStepper(BasicStepper.FULL4WIRE, 12, 13, 14, 15) + +stepper.setMaxSpeed(200.0) +stepper.setAcceleration(100.0) + +while True: + stepper.runToNewPosition(0) + stepper.runToNewPosition(500) + stepper.runToNewPosition(100) + stepper.runToNewPosition(120) diff --git a/Actuators/BasicStepperDriver/BasicStepperDriver/Examples/BasicStepperDriver-bounce.py b/Actuators/BasicStepperDriver/BasicStepperDriver/Examples/BasicStepperDriver-bounce.py new file mode 100644 index 0000000..b18d51b --- /dev/null +++ b/Actuators/BasicStepperDriver/BasicStepperDriver/Examples/BasicStepperDriver-bounce.py @@ -0,0 +1,21 @@ +# FILE: BasicStepperDriver-bounce.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Bounce between two positions with acceleration and deceleration +# WORKS WITH: Basic Stepper Driver: www.solde.red/333250 +# LAST UPDATED: 2026-05-25 + +from BasicStepperDriver import BasicStepper + +# 4-wire stepper connected to pins 12, 13, 14, 15 +stepper = BasicStepper(BasicStepper.FULL4WIRE, 12, 13, 14, 15) + +stepper.setMaxSpeed(100) +stepper.setAcceleration(20) +stepper.moveTo(500) + +while True: + # Reverse direction when target is reached + if stepper.distanceToGo() == 0: + stepper.moveTo(-stepper.currentPosition()) + + stepper.run() diff --git a/Actuators/BasicStepperDriver/BasicStepperDriver/Examples/BasicStepperDriver-constantSpeed.py b/Actuators/BasicStepperDriver/BasicStepperDriver/Examples/BasicStepperDriver-constantSpeed.py new file mode 100644 index 0000000..e8ee5f3 --- /dev/null +++ b/Actuators/BasicStepperDriver/BasicStepperDriver/Examples/BasicStepperDriver-constantSpeed.py @@ -0,0 +1,16 @@ +# FILE: BasicStepperDriver-constantSpeed.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Constant speed stepper motor control — no acceleration +# WORKS WITH: Basic Stepper Driver: www.solde.red/333250 +# LAST UPDATED: 2026-05-25 + +from BasicStepperDriver import BasicStepper + +# 4-wire stepper connected to pins 12, 13, 14, 15 +stepper = BasicStepper(BasicStepper.FULL4WIRE, 12, 13, 14, 15) + +stepper.setMaxSpeed(1000) +stepper.setSpeed(400) # steps/second, positive = CW + +while True: + stepper.runSpeed() diff --git a/Actuators/BasicStepperDriver/BasicStepperDriver/Examples/BasicStepperDriver-multipleSteppers.py b/Actuators/BasicStepperDriver/BasicStepperDriver/Examples/BasicStepperDriver-multipleSteppers.py new file mode 100644 index 0000000..f75a600 --- /dev/null +++ b/Actuators/BasicStepperDriver/BasicStepperDriver/Examples/BasicStepperDriver-multipleSteppers.py @@ -0,0 +1,34 @@ +# FILE: BasicStepperDriver-multipleSteppers.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Three independent stepper motors running simultaneously at different speeds +# WORKS WITH: Basic Stepper Driver: www.solde.red/333250 +# LAST UPDATED: 2026-05-25 + +from BasicStepperDriver import BasicStepper + +# Each motor requires its own board and 4 GPIO pins +stepper1 = BasicStepper(BasicStepper.FULL4WIRE, 12, 13, 14, 15) +stepper2 = BasicStepper(BasicStepper.FULL4WIRE, 16, 17, 18, 19) +stepper3 = BasicStepper(BasicStepper.FULL2WIRE, 21, 22) + +stepper1.setMaxSpeed(200.0) +stepper1.setAcceleration(100.0) +stepper1.moveTo(24) + +stepper2.setMaxSpeed(300.0) +stepper2.setAcceleration(100.0) +stepper2.moveTo(1000000) + +stepper3.setMaxSpeed(300.0) +stepper3.setAcceleration(100.0) +stepper3.moveTo(1000000) + +while True: + # Reverse stepper1 when it reaches target + if stepper1.distanceToGo() == 0: + stepper1.moveTo(-stepper1.currentPosition()) + + # Call run() on each motor every loop iteration + stepper1.run() + stepper2.run() + stepper3.run() diff --git a/Actuators/BasicStepperDriver/BasicStepperDriver/Examples/BasicStepperDriver-overshoot.py b/Actuators/BasicStepperDriver/BasicStepperDriver/Examples/BasicStepperDriver-overshoot.py new file mode 100644 index 0000000..0bdf3fe --- /dev/null +++ b/Actuators/BasicStepperDriver/BasicStepperDriver/Examples/BasicStepperDriver-overshoot.py @@ -0,0 +1,19 @@ +# FILE: BasicStepperDriver-overshoot.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Demonstrates overshoot handling — motor accelerates past intermediate point then corrects +# WORKS WITH: Basic Stepper Driver: www.solde.red/333250 +# LAST UPDATED: 2026-05-25 + +from BasicStepperDriver import BasicStepper + +# 4-wire stepper connected to pins 12, 13, 14, 15 +stepper = BasicStepper(BasicStepper.FULL4WIRE, 12, 13, 14, 15) + +stepper.setMaxSpeed(150) +stepper.setAcceleration(100) + +while True: + stepper.moveTo(500) + while stepper.currentPosition() != 300: # Run at full speed to position 300 + stepper.run() + stepper.runToNewPosition(0) # Overshoot then back to 0 diff --git a/Actuators/BasicStepperDriver/BasicStepperDriver/Examples/BasicStepperDriver-proportionalControl.py b/Actuators/BasicStepperDriver/BasicStepperDriver/Examples/BasicStepperDriver-proportionalControl.py new file mode 100644 index 0000000..851f590 --- /dev/null +++ b/Actuators/BasicStepperDriver/BasicStepperDriver/Examples/BasicStepperDriver-proportionalControl.py @@ -0,0 +1,24 @@ +# FILE: BasicStepperDriver-proportionalControl.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Potentiometer controls motor position — motor follows pot value +# WORKS WITH: Basic Stepper Driver: www.solde.red/333250 +# LAST UPDATED: 2026-05-25 + +# Wiring: connect a 10k linear potentiometer between 3.3V and GND, +# wiper to GPIO34 (or any ADC-capable pin on ESP32) +# Note: ESP32 ADC returns 0-4095 (12-bit), unlike Arduino (0-1023) + +from BasicStepperDriver import BasicStepper +from machine import ADC, Pin + +ANALOG_IN = ADC(Pin(34)) +ANALOG_IN.atten(ADC.ATTN_11DB) # Full 0-3.3V range + +stepper = BasicStepper(BasicStepper.FULL4WIRE, 12, 13, 14, 15) +stepper.setMaxSpeed(1000) + +while True: + analog_in = ANALOG_IN.read() # 0-4095 + stepper.moveTo(analog_in) + stepper.setSpeed(100) + stepper.runSpeedToPosition() diff --git a/Actuators/BasicStepperDriver/BasicStepperDriver/Examples/BasicStepperDriver-quickstop.py b/Actuators/BasicStepperDriver/BasicStepperDriver/Examples/BasicStepperDriver-quickstop.py new file mode 100644 index 0000000..e528f3e --- /dev/null +++ b/Actuators/BasicStepperDriver/BasicStepperDriver/Examples/BasicStepperDriver-quickstop.py @@ -0,0 +1,26 @@ +# FILE: BasicStepperDriver-quickstop.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Demonstrates stop() — decelerates as fast as possible mid-motion +# WORKS WITH: Basic Stepper Driver: www.solde.red/333250 +# LAST UPDATED: 2026-05-25 + +from BasicStepperDriver import BasicStepper + +# 4-wire stepper connected to pins 12, 13, 14, 15 +stepper = BasicStepper(BasicStepper.FULL4WIRE, 12, 13, 14, 15) + +stepper.setMaxSpeed(500) +stepper.setAcceleration(100) + +while True: + stepper.moveTo(500) + while stepper.currentPosition() != 300: # Full speed to 300 + stepper.run() + stepper.stop() # Decelerate to stop as fast as possible + stepper.runToPosition() # Complete the deceleration + + stepper.moveTo(-500) + while stepper.currentPosition() != 0: # Full speed back to 0 + stepper.run() + stepper.stop() + stepper.runToPosition() diff --git a/Actuators/BasicStepperDriver/BasicStepperDriver/Examples/BasicStepperDriver-random.py b/Actuators/BasicStepperDriver/BasicStepperDriver/Examples/BasicStepperDriver-random.py new file mode 100644 index 0000000..00536bb --- /dev/null +++ b/Actuators/BasicStepperDriver/BasicStepperDriver/Examples/BasicStepperDriver-random.py @@ -0,0 +1,21 @@ +# FILE: BasicStepperDriver-random.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Random speed, acceleration and target position — motor keeps changing behaviour +# WORKS WITH: Basic Stepper Driver: www.solde.red/333250 +# LAST UPDATED: 2026-05-25 + +from BasicStepperDriver import BasicStepper +import time +import random + +# 4-wire stepper connected to pins 12, 13, 14, 15 +stepper = BasicStepper(BasicStepper.FULL4WIRE, 12, 13, 14, 15) + +while True: + if stepper.distanceToGo() == 0: + time.sleep_ms(1000) + stepper.moveTo(random.randint(0, 199)) + stepper.setMaxSpeed(random.randint(1, 200)) + stepper.setAcceleration(random.randint(1, 200)) + + stepper.run() diff --git a/Actuators/BasicStepperDriver/README.md b/Actuators/BasicStepperDriver/README.md new file mode 100644 index 0000000..8ee428f --- /dev/null +++ b/Actuators/BasicStepperDriver/README.md @@ -0,0 +1,6 @@ +# How to install + +After [**installing the mpremote package**](https://docs.micropython.org/en/latest/reference/mpremote.html), +flash the module using: + + mpremote mip install github:SolderedElectronics/Soldered-MicroPython-Modules/Actuators/BasicStepperDriver diff --git a/Actuators/BasicStepperDriver/package.json b/Actuators/BasicStepperDriver/package.json new file mode 100644 index 0000000..5d81094 --- /dev/null +++ b/Actuators/BasicStepperDriver/package.json @@ -0,0 +1,42 @@ +{ + "urls": [ + [ + "BasicStepperDriver.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Actuators/BasicStepperDriver/BasicStepperDriver/BasicStepperDriver.py" + ], + [ + "Examples/BasicStepperDriver-constantSpeed.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Actuators/BasicStepperDriver/BasicStepperDriver/Examples/BasicStepperDriver-constantSpeed.py" + ], + [ + "Examples/BasicStepperDriver-blocking.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Actuators/BasicStepperDriver/BasicStepperDriver/Examples/BasicStepperDriver-blocking.py" + ], + [ + "Examples/BasicStepperDriver-bounce.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Actuators/BasicStepperDriver/BasicStepperDriver/Examples/BasicStepperDriver-bounce.py" + ], + [ + "Examples/BasicStepperDriver-multipleSteppers.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Actuators/BasicStepperDriver/BasicStepperDriver/Examples/BasicStepperDriver-multipleSteppers.py" + ], + [ + "Examples/BasicStepperDriver-overshoot.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Actuators/BasicStepperDriver/BasicStepperDriver/Examples/BasicStepperDriver-overshoot.py" + ], + [ + "Examples/BasicStepperDriver-proportionalControl.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Actuators/BasicStepperDriver/BasicStepperDriver/Examples/BasicStepperDriver-proportionalControl.py" + ], + [ + "Examples/BasicStepperDriver-quickstop.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Actuators/BasicStepperDriver/BasicStepperDriver/Examples/BasicStepperDriver-quickstop.py" + ], + [ + "Examples/BasicStepperDriver-random.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Actuators/BasicStepperDriver/BasicStepperDriver/Examples/BasicStepperDriver-random.py" + ] + ], + "deps": [], + "version": "1.0" +} diff --git a/Actuators/ButtonLedBuzzerBoard/ButtonLedBuzzerBoard/ButtonLedBuzzerBoard.py b/Actuators/ButtonLedBuzzerBoard/ButtonLedBuzzerBoard/ButtonLedBuzzerBoard.py new file mode 100644 index 0000000..aaec03d --- /dev/null +++ b/Actuators/ButtonLedBuzzerBoard/ButtonLedBuzzerBoard/ButtonLedBuzzerBoard.py @@ -0,0 +1,109 @@ +# FILE: ButtonLedBuzzerBoard.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: MicroPython library for the Soldered Button, LED & Buzzer Board with Qwiic +# LAST UPDATED: 2026-05-20 + +from machine import I2C, Pin +from os import uname + +# Default I2C address +BLB_DEFAULT_ADDRESS = 0x30 + +# Register addresses +BLB_REG_BUTTONS = 0x00 +BLB_REG_LED = 0x01 +BLB_REG_BUZZER = 0x02 + +# Number of LEDs +BLB_NUM_LEDS = 3 + + +class ButtonLedBuzzerBoard: + """ + MicroPython class for the Soldered Button, LED & Buzzer Board (ATtiny404). + Communicates over I2C. + """ + + def __init__(self, i2c=None, address=BLB_DEFAULT_ADDRESS): + """ + Initialize the ButtonLedBuzzerBoard. + + :param i2c: Initialized I2C object (optional, auto-detected on known boards) + :param address: I2C address of the device (default 0x30) + """ + if i2c is not None: + self.i2c = i2c + else: + if uname().sysname in ("esp32", "Soldered Dasduino CONNECTPLUS"): + self.i2c = I2C(0, scl=Pin(22), sda=Pin(21)) + elif uname().sysname == "esp8266": + self.i2c = I2C(scl=Pin(5), sda=Pin(4)) + else: + raise Exception("Board not recognized, please pass an I2C object manually") + + self.address = address + self._led_buf = bytearray(BLB_NUM_LEDS * 3) + + def _writeLEDs(self): + try: + self.i2c.writeto(self.address, bytes([BLB_REG_LED]) + bytes(self._led_buf)) + except: + pass + + def setLED(self, index, r, g, b): + """Set single LED by index (0-2) to given RGB color.""" + if index >= BLB_NUM_LEDS: + return + self._led_buf[index * 3] = r + self._led_buf[index * 3 + 1] = g + self._led_buf[index * 3 + 2] = b + self._writeLEDs() + + def setAllLEDs(self, r, g, b): + """Set all LEDs to same RGB color.""" + for i in range(BLB_NUM_LEDS): + self._led_buf[i * 3] = r + self._led_buf[i * 3 + 1] = g + self._led_buf[i * 3 + 2] = b + self._writeLEDs() + + def setLEDs(self, r1, g1, b1, r2, g2, b2, r3, g3, b3): + """Set all three LEDs to individual RGB colors.""" + self._led_buf[0] = r1 + self._led_buf[1] = g1 + self._led_buf[2] = b1 + self._led_buf[3] = r2 + self._led_buf[4] = g2 + self._led_buf[5] = b2 + self._led_buf[6] = r3 + self._led_buf[7] = g3 + self._led_buf[8] = b3 + self._writeLEDs() + + def setBuzzer(self, freq): + """Set buzzer frequency in Hz. Pass 0 to turn off.""" + try: + self.i2c.writeto(self.address, bytes([BLB_REG_BUZZER, (freq >> 8) & 0xFF, freq & 0xFF])) + except: + pass + + def readButtons(self): + """Read raw button state byte. Bit0=BTN1, Bit1=BTN2, Bit2=BTN3.""" + try: + self.i2c.writeto(self.address, bytes([BLB_REG_BUTTONS])) + data = self.i2c.readfrom(self.address, 1) + return data[0] + except: + return 0 + + def isButton1Pressed(self): + """Return True if button 1 is pressed.""" + return bool(self.readButtons() & 0x01) + + def isButton2Pressed(self): + """Return True if button 2 is pressed.""" + return bool(self.readButtons() & 0x02) + + def isButton3Pressed(self): + """Return True if button 3 is pressed.""" + return bool(self.readButtons() & 0x04) diff --git a/Actuators/ButtonLedBuzzerBoard/ButtonLedBuzzerBoard/Examples/ButtonLedBuzzerBoard-buttonInteraction.py b/Actuators/ButtonLedBuzzerBoard/ButtonLedBuzzerBoard/Examples/ButtonLedBuzzerBoard-buttonInteraction.py new file mode 100644 index 0000000..afebc89 --- /dev/null +++ b/Actuators/ButtonLedBuzzerBoard/ButtonLedBuzzerBoard/Examples/ButtonLedBuzzerBoard-buttonInteraction.py @@ -0,0 +1,39 @@ +# FILE: ButtonLedBuzzerBoard-buttonInteraction.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Press a button to light its LED and sound the buzzer. +# BTN1 -> LED1 red 1000Hz, BTN2 -> LED2 green 2000Hz, BTN3 -> LED3 blue 3000Hz. +# Multiple buttons can be pressed simultaneously; buzzer plays highest button's tone. +# WORKS WITH: Button, LED & Buzzer Board: solde.red/333182 +# LAST UPDATED: 2026-05-20 + +from machine import I2C, Pin +import time +from ButtonLedBuzzerBoard import * + +# Initialize I2C and the board +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +board = ButtonLedBuzzerBoard(i2c) + +board.setAllLEDs(0, 0, 0) +board.setBuzzer(0) + +# ------------------------------------------------------------------------- +# Main loop +# ------------------------------------------------------------------------- +while True: + buttons = board.readButtons() + + btn1 = bool(buttons & 0x01) + btn2 = bool(buttons & 0x02) + btn3 = bool(buttons & 0x04) + + board.setLED(0, 255 if btn3 else 0, 0, 0) + board.setLED(1, 0, 255 if btn2 else 0, 0) + board.setLED(2, 0, 0, 255 if btn1 else 0) + + if btn3: board.setBuzzer(3000) + elif btn2: board.setBuzzer(2000) + elif btn1: board.setBuzzer(1000) + else: board.setBuzzer(0) + + time.sleep_ms(20) diff --git a/Actuators/ButtonLedBuzzerBoard/ButtonLedBuzzerBoard/Examples/ButtonLedBuzzerBoard-buttons.py b/Actuators/ButtonLedBuzzerBoard/ButtonLedBuzzerBoard/Examples/ButtonLedBuzzerBoard-buttons.py new file mode 100644 index 0000000..5f0ff72 --- /dev/null +++ b/Actuators/ButtonLedBuzzerBoard/ButtonLedBuzzerBoard/Examples/ButtonLedBuzzerBoard-buttons.py @@ -0,0 +1,38 @@ +# FILE: ButtonLedBuzzerBoard-buttons.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Button test - prints press and release events to serial. +# WORKS WITH: Button, LED & Buzzer Board: solde.red/333182 +# LAST UPDATED: 2026-05-20 + +from machine import I2C, Pin +import time +from ButtonLedBuzzerBoard import * + +# Initialize I2C and the board +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +board = ButtonLedBuzzerBoard(i2c) + +# ------------------------------------------------------------------------- +# Setup +# ------------------------------------------------------------------------- +print("Button, LED & Buzzer Board - Button Test") +print("Press any button...") + +# ------------------------------------------------------------------------- +# Main loop +# ------------------------------------------------------------------------- +last_buttons = 0 + +while True: + buttons = board.readButtons() + + if buttons != last_buttons: + for i in range(3): + mask = 1 << i + if (buttons & mask) and not (last_buttons & mask): + print("BTN{} pressed".format(i + 1)) + elif not (buttons & mask) and (last_buttons & mask): + print("BTN{} released".format(i + 1)) + last_buttons = buttons + + time.sleep_ms(20) diff --git a/Actuators/ButtonLedBuzzerBoard/ButtonLedBuzzerBoard/Examples/ButtonLedBuzzerBoard-buzzer.py b/Actuators/ButtonLedBuzzerBoard/ButtonLedBuzzerBoard/Examples/ButtonLedBuzzerBoard-buzzer.py new file mode 100644 index 0000000..b147e5d --- /dev/null +++ b/Actuators/ButtonLedBuzzerBoard/ButtonLedBuzzerBoard/Examples/ButtonLedBuzzerBoard-buzzer.py @@ -0,0 +1,27 @@ +# FILE: ButtonLedBuzzerBoard-buzzer.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Buzzer test - plays a sweep of frequencies. +# WORKS WITH: Button, LED & Buzzer Board: solde.red/333182 +# LAST UPDATED: 2026-05-20 + +from machine import I2C, Pin +import time +from ButtonLedBuzzerBoard import * + +# Initialize I2C and the board +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +board = ButtonLedBuzzerBoard(i2c) + +FREQUENCIES = [500, 1000, 2000, 3000, 4000] + +# ------------------------------------------------------------------------- +# Main loop +# ------------------------------------------------------------------------- +while True: + for freq in FREQUENCIES: + board.setBuzzer(freq) + time.sleep_ms(400) + board.setBuzzer(0) + time.sleep_ms(200) + + time.sleep_ms(1000) diff --git a/Actuators/ButtonLedBuzzerBoard/ButtonLedBuzzerBoard/Examples/ButtonLedBuzzerBoard-fullDemo.py b/Actuators/ButtonLedBuzzerBoard/ButtonLedBuzzerBoard/Examples/ButtonLedBuzzerBoard-fullDemo.py new file mode 100644 index 0000000..5646b9f --- /dev/null +++ b/Actuators/ButtonLedBuzzerBoard/ButtonLedBuzzerBoard/Examples/ButtonLedBuzzerBoard-fullDemo.py @@ -0,0 +1,59 @@ +# FILE: ButtonLedBuzzerBoard-fullDemo.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Full demo for Soldered Button, LED & Buzzer Board. +# Prints button states, cycles LED colors every 2s, short beep on each change. +# WORKS WITH: Button, LED & Buzzer Board: solde.red/333182 +# LAST UPDATED: 2026-05-20 + +from machine import I2C, Pin +import time +from ButtonLedBuzzerBoard import * + +# Initialize I2C and the board +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +board = ButtonLedBuzzerBoard(i2c) + +COLORS = [ + (255, 0, 0), # red + ( 0, 255, 0), # green + ( 0, 0, 255), # blue + (255, 255, 0), # yellow + ( 0, 255, 255), # cyan + (255, 0, 255), # magenta + (255, 255, 255), # white + ( 0, 0, 0), # off +] + +# ------------------------------------------------------------------------- +# Setup +# ------------------------------------------------------------------------- +print("Button, LED & Buzzer Board - Full Demo") +print("-----------------------------------------------") + +board.setAllLEDs(0, 0, 0) +time.sleep_ms(2000) + +# ------------------------------------------------------------------------- +# Main loop +# ------------------------------------------------------------------------- +color_idx = 0 +last_color_change = time.ticks_ms() + +while True: + btn1 = "ON " if board.isButton1Pressed() else "off" + btn2 = "ON " if board.isButton2Pressed() else "off" + btn3 = "ON " if board.isButton3Pressed() else "off" + print("BTN1:{} BTN2:{} BTN3:{}".format(btn1, btn2, btn3)) + + if time.ticks_diff(time.ticks_ms(), last_color_change) > 2000: + r, g, b = COLORS[color_idx] + board.setAllLEDs(r, g, b) + + board.setBuzzer(2000) + time.sleep_ms(100) + board.setBuzzer(0) + + color_idx = (color_idx + 1) % len(COLORS) + last_color_change = time.ticks_ms() + + time.sleep_ms(100) diff --git a/Actuators/ButtonLedBuzzerBoard/ButtonLedBuzzerBoard/Examples/ButtonLedBuzzerBoard-leds.py b/Actuators/ButtonLedBuzzerBoard/ButtonLedBuzzerBoard/Examples/ButtonLedBuzzerBoard-leds.py new file mode 100644 index 0000000..1a6945a --- /dev/null +++ b/Actuators/ButtonLedBuzzerBoard/ButtonLedBuzzerBoard/Examples/ButtonLedBuzzerBoard-leds.py @@ -0,0 +1,33 @@ +# FILE: ButtonLedBuzzerBoard-leds.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: LED test - cycles each LED through red/green/blue individually, then all together. +# WORKS WITH: Button, LED & Buzzer Board: solde.red/333182 +# LAST UPDATED: 2026-05-20 + +from machine import I2C, Pin +import time +from ButtonLedBuzzerBoard import * + +# Initialize I2C and the board +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +board = ButtonLedBuzzerBoard(i2c) + +board.setAllLEDs(0, 0, 0) + +# ------------------------------------------------------------------------- +# Main loop +# ------------------------------------------------------------------------- +while True: + # Test each LED individually + for i in range(3): + board.setAllLEDs(0, 0, 0) + board.setLED(i, 255, 0, 0); time.sleep_ms(400) # red + board.setLED(i, 0, 255, 0); time.sleep_ms(400) # green + board.setLED(i, 0, 0, 255); time.sleep_ms(400) # blue + + # All LEDs together + board.setAllLEDs(255, 0, 0); time.sleep_ms(500) # red + board.setAllLEDs( 0, 255, 0); time.sleep_ms(500) # green + board.setAllLEDs( 0, 0, 255); time.sleep_ms(500) # blue + board.setAllLEDs(255, 255, 255); time.sleep_ms(500) # white + board.setAllLEDs( 0, 0, 0); time.sleep_ms(500) # off diff --git a/Actuators/ButtonLedBuzzerBoard/package.json b/Actuators/ButtonLedBuzzerBoard/package.json new file mode 100644 index 0000000..683c472 --- /dev/null +++ b/Actuators/ButtonLedBuzzerBoard/package.json @@ -0,0 +1,30 @@ +{ + "urls": [ + [ + "ButtonLedBuzzerBoard.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Actuators/ButtonLedBuzzerBoard/ButtonLedBuzzerBoard/ButtonLedBuzzerBoard.py" + ], + [ + "Examples/ButtonLedBuzzerBoard-fullDemo.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Actuators/ButtonLedBuzzerBoard/ButtonLedBuzzerBoard/Examples/ButtonLedBuzzerBoard-fullDemo.py" + ], + [ + "Examples/ButtonLedBuzzerBoard-buzzer.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Actuators/ButtonLedBuzzerBoard/ButtonLedBuzzerBoard/Examples/ButtonLedBuzzerBoard-buzzer.py" + ], + [ + "Examples/ButtonLedBuzzerBoard-buttons.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Actuators/ButtonLedBuzzerBoard/ButtonLedBuzzerBoard/Examples/ButtonLedBuzzerBoard-buttons.py" + ], + [ + "Examples/ButtonLedBuzzerBoard-buttonInteraction.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Actuators/ButtonLedBuzzerBoard/ButtonLedBuzzerBoard/Examples/ButtonLedBuzzerBoard-buttonInteraction.py" + ], + [ + "Examples/ButtonLedBuzzerBoard-leds.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Actuators/ButtonLedBuzzerBoard/ButtonLedBuzzerBoard/Examples/ButtonLedBuzzerBoard-leds.py" + ] + ], + "deps": [], + "version": "1.0" +} diff --git a/Actuators/MAX7219/MAX7219/Examples/max7219-basic.py b/Actuators/MAX7219/MAX7219/Examples/max7219-basic.py new file mode 100644 index 0000000..b592ce5 --- /dev/null +++ b/Actuators/MAX7219/MAX7219/Examples/max7219-basic.py @@ -0,0 +1,109 @@ +# FILE: max7219-basic.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Demonstrates scrolling text, static text, and pixel drawing +# on a MAX7219/MAX7221 LED matrix display. +# WORKS WITH: 8x8 LED matrix MAX7219: www.solde.red/333151 +# LAST UPDATED: 2026-05-06 + +from machine import SPI, Pin +import time +from max7219 import (MAX7219, PAROLA_HW, GENERIC_HW, + INTENSITY, ON, OFF, TSL) + +# ------------------------------------------------------------------------- +# Configuration — adjust these for your hardware +# ------------------------------------------------------------------------- +NUM_DEVICES = 3 # Number of 8x8 modules daisy-chained +CS_PIN = 5 # Chip select GPIO pin (LOAD) +MODULE_TYPE = PAROLA_HW # Parola hardware modules + +# ------------------------------------------------------------------------- +# Initialize SPI and the display +# ------------------------------------------------------------------------- +spi = SPI(1, baudrate=8_000_000, polarity=0, phase=0, + sck=Pin(18), mosi=Pin(23)) # CLK_PIN=18 (SCK), DATA_PIN=23 (MOSI) +cs = Pin(CS_PIN, Pin.OUT, value=1) + +mx = MAX7219(MODULE_TYPE, spi, cs, NUM_DEVICES) + +# ------------------------------------------------------------------------- +# Helper: draw a simple 8-row tall smiley face on device 0 +# ------------------------------------------------------------------------- +SMILEY = [ + 0b00111100, # col 0 + 0b01000010, # col 1 + 0b10100101, # col 2 + 0b10000001, # col 3 + 0b10100101, # col 4 + 0b10011001, # col 5 + 0b01000010, # col 6 + 0b00111100, # col 7 +] + +def draw_smiley(): + mx.clear() + for col, val in enumerate(SMILEY): + mx.setColumn(0, col, val) # device 0, columns 0-7 + +def draw_checkerboard(): + mx.clear() + for dev in range(NUM_DEVICES): + for r in range(8): + # Alternating bits per row, inverted every row + mx.setRow(dev, r, 0xAA if r % 2 == 0 else 0x55) + +# ------------------------------------------------------------------------- +# Setup +# ------------------------------------------------------------------------- +print("MAX7219 MicroPython demo") +print("Devices: {}".format(NUM_DEVICES)) +print("-----------------------------------------------") + +# Set a comfortable brightness (0-15) +mx.control(INTENSITY, 4) + +# ------------------------------------------------------------------------- +# Demo 1: Static text +# ------------------------------------------------------------------------- +print("Static text...") +mx.clear() +mx.printText("Hi!") +time.sleep(2) + +# ------------------------------------------------------------------------- +# Demo 2: Smiley face bitmap +# ------------------------------------------------------------------------- +print("Smiley face...") +draw_smiley() +time.sleep(2) + +# ------------------------------------------------------------------------- +# Demo 3: Checkerboard pattern +# ------------------------------------------------------------------------- +print("Checkerboard...") +draw_checkerboard() +time.sleep(2) + +# ------------------------------------------------------------------------- +# Demo 4: Pixel sweep — light up every pixel one at a time +# ------------------------------------------------------------------------- +print("Pixel sweep...") +mx.clear() +col_count = mx.getColumnCount() +for r in range(8): + for c in range(col_count): + mx.setPoint(r, c, True) + time.sleep_ms(10) +time.sleep(1) + +# ------------------------------------------------------------------------- +# Demo 5: Scrolling text — loops forever +# ------------------------------------------------------------------------- +MESSAGE = "Hello from MicroPython!" +SCROLL_DELAY = 40 # ms per column shift — lower = faster + +print("Scrolling: '{}'".format(MESSAGE)) +print("(loops forever — press Ctrl+C to stop)") + +while True: + mx.scrollText(MESSAGE, delay_ms=SCROLL_DELAY) \ No newline at end of file diff --git a/Actuators/MAX7219/MAX7219/max7219.py b/Actuators/MAX7219/MAX7219/max7219.py new file mode 100644 index 0000000..8597d43 --- /dev/null +++ b/Actuators/MAX7219/MAX7219/max7219.py @@ -0,0 +1,914 @@ +# FILE: max7219.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: MicroPython library for the MAX7219/MAX7221 LED matrix controller. +# LAST UPDATED: 2026-05-06 + +from machine import SPI, Pin +from os import uname +import time + +# --------------------------------------------------------------------------- +# Module type constants +# Each module type sets different hardware mapping flags: +# _hw_dig_rows — MAX72xx digits map to rows (True) or columns (False) +# _hw_rev_cols — columns are reversed +# _hw_rev_rows — rows are reversed +# --------------------------------------------------------------------------- +GENERIC_HW = 0 # DR0CR1RR0 — common cheap eBay modules +FC16_HW = 1 # DR1CR0RR0 — FC-16 modules (4-in-1 sets) +PAROLA_HW = 2 # DR1CR1RR0 — Parola custom modules +ICSTATION_HW = 3 # DR1CR1RR1 — ICStation kit modules + +DR0CR0RR0_HW = 4 +DR0CR0RR1_HW = 5 +DR0CR1RR0_HW = 6 # same as GENERIC_HW +DR0CR1RR1_HW = 7 +DR1CR0RR0_HW = 8 # same as FC16_HW +DR1CR0RR1_HW = 9 +DR1CR1RR0_HW = 10 # same as PAROLA_HW +DR1CR1RR1_HW = 11 # same as ICSTATION_HW + +# Control request constants +SHUTDOWN = 0 +SCANLIMIT = 1 +INTENSITY = 2 +TEST = 3 +DECODE = 4 +UPDATE = 10 +WRAPAROUND = 11 + +# Control value constants +OFF = 0 +ON = 1 + +# Transform type constants +TSL = 0 # Shift Left +TSR = 1 # Shift Right +TSU = 2 # Shift Up +TSD = 3 # Shift Down +TFLR = 4 # Flip Left-Right +TFUD = 5 # Flip Up-Down +TRC = 6 # Rotate Clockwise +TINV = 7 # Invert + +# Display geometry +ROW_SIZE = 8 +COL_SIZE = 8 +MAX_INTENSITY = 0xF +MAX_SCANLIMIT = 7 + +# MAX72xx opcodes +OP_NOOP = 0 +OP_DIGIT0 = 1 +OP_DECODEMODE = 9 +OP_INTENSITY = 10 +OP_SCANLIMIT = 11 +OP_SHUTDOWN = 12 +OP_DISPLAYTEST = 15 + +ALL_CHANGED = 0xFF +ALL_CLEAR = 0x00 + +# Built-in 5x8 variable-width font (version 2 format, ASCII 0-255) +# Each entry: (width, col0, col1, ...) — stored as a flat bytearray +# Format header: 'F', version=2, firstASCII_hi, firstASCII_lo, +# lastASCII_hi, lastASCII_lo, height +# Characters follow as: width_byte, col_bytes... +_SYSFONT = bytearray([ + ord('F'), 2, 0, 0, 0, 255, 8, + 0, # 0 + 5, 62, 91, 79, 91, 62, # 1 + 5, 62, 107, 79, 107, 62, # 2 + 5, 28, 62, 124, 62, 28, # 3 + 5, 24, 60, 126, 60, 24, # 4 + 5, 28, 87, 125, 87, 28, # 5 + 5, 28, 94, 127, 94, 28, # 6 + 4, 0, 24, 60, 24, # 7 + 5, 255, 231, 195, 231, 255, # 8 + 4, 0, 24, 36, 24, # 9 + 5, 255, 231, 219, 231, 255, # 10 + 5, 48, 72, 58, 6, 14, # 11 + 5, 38, 41, 121, 41, 38, # 12 + 5, 64, 127, 5, 5, 7, # 13 + 5, 64, 127, 5, 37, 63, # 14 + 5, 90, 60, 231, 60, 90, # 15 + 5, 127, 62, 28, 28, 8, # 16 + 5, 8, 28, 28, 62, 127, # 17 + 5, 20, 34, 127, 34, 20, # 18 + 5, 255, 255, 255, 255, 255, # 19 + 5, 240, 240, 240, 240, 240, # 20 + 3, 255, 255, 255, # 21 + 5, 0, 0, 0, 255, 255, # 22 + 5, 15, 15, 15, 15, 15, # 23 + 5, 8, 4, 126, 4, 8, # 24 + 5, 16, 32, 126, 32, 16, # 25 + 5, 8, 8, 42, 28, 8, # 26 + 5, 8, 28, 42, 8, 8, # 27 + 5, 170, 0, 85, 0, 170, # 28 + 5, 170, 85, 170, 85, 170, # 29 + 5, 48, 56, 62, 56, 48, # 30 + 5, 6, 14, 62, 14, 6, # 31 + 2, 0, 0, # 32 space + 1, 95, # 33 ! + 3, 7, 0, 7, # 34 " + 5, 20, 127, 20, 127, 20, # 35 # + 5, 68, 74, 255, 74, 50, # 36 $ + 5, 99, 19, 8, 100, 99, # 37 % + 5, 54, 73, 73, 54, 72, # 38 & + 1, 7, # 39 ' + 3, 62, 65, 65, # 40 ( + 3, 65, 65, 62, # 41 ) + 5, 8, 42, 28, 42, 8, # 42 * + 5, 8, 8, 62, 8, 8, # 43 + + 2, 96, 224, # 44 , + 4, 8, 8, 8, 8, # 45 - + 2, 96, 96, # 46 . + 5, 96, 16, 8, 4, 3, # 47 / + 5, 62, 81, 73, 69, 62, # 48 0 + 3, 4, 2, 127, # 49 1 + 5, 113, 73, 73, 73, 70, # 50 2 + 5, 65, 73, 73, 73, 54, # 51 3 + 5, 15, 8, 8, 8, 127, # 52 4 + 5, 79, 73, 73, 73, 49, # 53 5 + 5, 62, 73, 73, 73, 48, # 54 6 + 5, 3, 1, 1, 1, 127, # 55 7 + 5, 54, 73, 73, 73, 54, # 56 8 + 5, 6, 73, 73, 73, 62, # 57 9 + 2, 108, 108, # 58 : + 2, 108, 236, # 59 ; + 3, 8, 20, 34, # 60 < + 4, 20, 20, 20, 20, # 61 = + 3, 34, 20, 8, # 62 > + 5, 1, 89, 9, 9, 6, # 63 ? + 5, 62, 65, 93, 89, 78, # 64 @ + 5, 126, 9, 9, 9, 126, # 65 A + 5, 127, 73, 73, 73, 54, # 66 B + 5, 62, 65, 65, 65, 65, # 67 C + 5, 127, 65, 65, 65, 62, # 68 D + 5, 127, 73, 73, 73, 65, # 69 E + 5, 127, 9, 9, 9, 1, # 70 F + 5, 62, 65, 65, 73, 121, # 71 G + 5, 127, 8, 8, 8, 127, # 72 H + 3, 65, 127, 65, # 73 I + 5, 48, 65, 65, 65, 63, # 74 J + 5, 127, 8, 20, 34, 65, # 75 K + 5, 127, 64, 64, 64, 64, # 76 L + 5, 127, 2, 12, 2, 127, # 77 M + 5, 127, 4, 8, 16, 127, # 78 N + 5, 62, 65, 65, 65, 62, # 79 O + 5, 127, 9, 9, 9, 6, # 80 P + 5, 62, 65, 65, 97, 126, # 81 Q + 5, 127, 9, 25, 41, 70, # 82 R + 5, 70, 73, 73, 73, 49, # 83 S + 5, 1, 1, 127, 1, 1, # 84 T + 5, 63, 64, 64, 64, 63, # 85 U + 5, 31, 32, 64, 32, 31, # 86 V + 5, 63, 64, 56, 64, 63, # 87 W + 5, 99, 20, 8, 20, 99, # 88 X + 5, 3, 4, 120, 4, 3, # 89 Y + 5, 97, 81, 73, 69, 67, # 90 Z + 3, 127, 65, 65, # 91 [ + 5, 3, 4, 8, 16, 96, # 92 backslash + 3, 65, 65, 127, # 93 ] + 5, 4, 2, 1, 2, 4, # 94 ^ + 4, 128, 128, 128, 128, # 95 _ + 3, 1, 2, 4, # 96 ` + 4, 56, 68, 68, 124, # 97 a + 4, 127, 68, 68, 56, # 98 b + 4, 56, 68, 68, 68, # 99 c + 4, 56, 68, 68, 127, # 100 d + 4, 56, 84, 84, 88, # 101 e + 4, 4, 126, 5, 1, # 102 f + 4, 24, 164, 164, 124, # 103 g + 4, 127, 4, 4, 120, # 104 h + 1, 125, # 105 i + 3, 132, 133, 124, # 106 j + 4, 127, 16, 40, 68, # 107 k + 1, 127, # 108 l + 5, 124, 4, 120, 4, 120, # 109 m + 4, 124, 4, 4, 120, # 110 n + 4, 56, 68, 68, 56, # 111 o + 4, 252, 36, 36, 24, # 112 p + 4, 24, 36, 36, 252, # 113 q + 4, 124, 4, 4, 8, # 114 r + 4, 88, 84, 84, 52, # 115 s + 3, 4, 127, 4, # 116 t + 4, 60, 64, 64, 124, # 117 u + 4, 28, 32, 64, 124, # 118 v + 5, 60, 64, 48, 64, 60, # 119 w + 4, 108, 16, 16, 108, # 120 x + 4, 28, 160, 160, 124, # 121 y + 4, 100, 84, 84, 76, # 122 z + 4, 8, 54, 65, 65, # 123 { + 1, 127, # 124 | + 4, 65, 65, 54, 8, # 125 } + 4, 2, 1, 2, 1, # 126 ~ + 5, 127, 65, 65, 65, 127, # 127 +]) + +# Characters 128-255 are left as zero-width (empty) in this compact table +# The font lookup will return 0 columns for anything above 127 +_FONT_FIRST = 0 +_FONT_LAST = 127 +_FONT_HEIGHT = 8 +_FONT_DATA_OFFSET = 7 # bytes consumed by the header + + +def _font_char_offset(c): + """Return the byte offset of character c in _SYSFONT, or -1 if not found.""" + if c < _FONT_FIRST or c > _FONT_LAST: + return -1 + offset = _FONT_DATA_OFFSET + for i in range(_FONT_FIRST, c): + width = _SYSFONT[offset] + offset += width + 1 # skip width byte + column data + return offset + + +class MAX7219: + """ + MicroPython class for the MAX7219/MAX7221 LED matrix controller. + + Supports single and chained 8x8 LED matrix modules. Communication + is over hardware SPI. Pixel coordinate (0,0) is the top-right corner; + column numbers increase leftward, row numbers increase downward. + """ + + def __init__(self, module_type, spi, cs_pin, num_devices=1): + """ + Initialize the MAX72xx controller. + + :param module_type: One of the module type constants (e.g. FC16_HW, GENERIC_HW) + :param spi: Initialized machine.SPI object + :param cs_pin: machine.Pin object configured as output for chip select + :param num_devices: Number of daisy-chained modules (default 1) + """ + self._spi = spi + self._cs = cs_pin + self._cs.value(1) + + self._max_devices = num_devices + self._update_enabled = True + self._wrap_around = False + + # Per-device buffers: list of dicts with 'dig' (bytearray[8]) and 'changed' (int) + self._matrix = [ + {'dig': bytearray(ROW_SIZE), 'changed': ALL_CLEAR} + for _ in range(num_devices) + ] + + # SPI send buffer: 2 bytes per device (opcode + data) + self._spi_data = bytearray(num_devices * 2) + + # Set hardware mapping flags based on module type + self._set_module_parameters(module_type) + + # Initialize the MAX72xx hardware + self._send_control(OP_DISPLAYTEST, 0) # no test + self._send_control(OP_SCANLIMIT, ROW_SIZE - 1) # show all rows + self._send_control(OP_INTENSITY, MAX_INTENSITY // 2) # medium brightness + self._send_control(OP_DECODEMODE, 0) # no BCD decode + self.clear() + self._send_control(OP_SHUTDOWN, 1) # normal operation + + # ------------------------------------------------------------------------- + # Hardware mapping + # ------------------------------------------------------------------------- + + def _set_module_parameters(self, mod): + """Configure internal flags based on module hardware type.""" + if mod in (DR0CR0RR0_HW,): + self._hw_dig_rows = False; self._hw_rev_cols = False; self._hw_rev_rows = False + elif mod in (DR0CR0RR1_HW,): + self._hw_dig_rows = False; self._hw_rev_cols = False; self._hw_rev_rows = True + elif mod in (DR0CR1RR0_HW, GENERIC_HW): + self._hw_dig_rows = False; self._hw_rev_cols = True; self._hw_rev_rows = False + elif mod in (DR0CR1RR1_HW,): + self._hw_dig_rows = False; self._hw_rev_cols = True; self._hw_rev_rows = True + elif mod in (DR1CR0RR0_HW, FC16_HW): + self._hw_dig_rows = True; self._hw_rev_cols = False; self._hw_rev_rows = False + elif mod in (DR1CR0RR1_HW,): + self._hw_dig_rows = True; self._hw_rev_cols = False; self._hw_rev_rows = True + elif mod in (DR1CR1RR0_HW, PAROLA_HW): + self._hw_dig_rows = True; self._hw_rev_cols = True; self._hw_rev_rows = False + elif mod in (DR1CR1RR1_HW, ICSTATION_HW): + self._hw_dig_rows = True; self._hw_rev_cols = True; self._hw_rev_rows = True + else: + # Default to GENERIC if unknown + self._hw_dig_rows = False; self._hw_rev_cols = True; self._hw_rev_rows = False + + def _hw_row(self, r): + return (ROW_SIZE - 1 - r) if self._hw_rev_rows else r + + def _hw_col(self, c): + return (COL_SIZE - 1 - c) if self._hw_rev_cols else c + + # ------------------------------------------------------------------------- + # SPI communication + # ------------------------------------------------------------------------- + + def _spi_clear(self): + """Fill the SPI buffer with NOOPs.""" + for i in range(len(self._spi_data)): + self._spi_data[i] = OP_NOOP + + def _spi_offset(self, dev, x): + """Return the byte index in _spi_data for device dev, byte x (0=opcode, 1=data).""" + return ((self._max_devices - 1 - dev) * 2) + x + + def _spi_send(self): + """Shift out the SPI buffer to all chained devices.""" + self._cs.value(0) + self._spi.write(self._spi_data) + self._cs.value(1) + + def _send_control(self, opcode, value): + """Send the same opcode+value to every device in the chain.""" + self._spi_clear() + for dev in range(self._max_devices): + self._spi_data[self._spi_offset(dev, 0)] = opcode + self._spi_data[self._spi_offset(dev, 1)] = value & 0xFF + self._spi_send() + + # ------------------------------------------------------------------------- + # Buffer flush + # ------------------------------------------------------------------------- + + def _flush_buffer(self, buf): + """Send any changed rows for a single device to the hardware.""" + if buf >= self._max_devices: + return + for i in range(ROW_SIZE): + if self._matrix[buf]['changed'] & (1 << i): + self._spi_clear() + row_data = self._matrix[buf]['dig'][i] + # Rotate bit 0 to bit 7 (hardware requirement) + lsb = row_data & 1 + row_data = ((row_data >> 1) | (lsb << 7)) & 0xFF + self._spi_data[self._spi_offset(buf, 0)] = OP_DIGIT0 + i + self._spi_data[self._spi_offset(buf, 1)] = row_data + self._spi_send() + self._matrix[buf]['changed'] = ALL_CLEAR + + def _flush_buffer_all(self): + """Send all changed rows for all devices, row by row for efficiency.""" + for i in range(ROW_SIZE): + changed = False + self._spi_clear() + for dev in range(self._max_devices): + if self._matrix[dev]['changed'] & (1 << i): + row_data = self._matrix[dev]['dig'][i] + lsb = row_data & 1 + row_data = ((row_data >> 1) | (lsb << 7)) & 0xFF + self._spi_data[self._spi_offset(dev, 0)] = OP_DIGIT0 + i + self._spi_data[self._spi_offset(dev, 1)] = row_data + changed = True + if changed: + self._spi_send() + for dev in range(self._max_devices): + self._matrix[dev]['changed'] = ALL_CLEAR + + # ------------------------------------------------------------------------- + # Control + # ------------------------------------------------------------------------- + + def control(self, mode, value, start_dev=None, end_dev=None): + """ + Set a control parameter for one or all devices. + + :param mode: One of the control constants (SHUTDOWN, INTENSITY, etc.) + :param value: ON/OFF or a numeric value + :param start_dev: First device index (optional, default all) + :param end_dev: Last device index (optional, default all) + """ + if start_dev is None: + start_dev = 0 + if end_dev is None: + end_dev = self._max_devices - 1 + + if mode == UPDATE: + self._update_enabled = (value == ON) + if self._update_enabled: + self._flush_buffer_all() + return + + if mode == WRAPAROUND: + self._wrap_around = (value == ON) + return + + # Map to hardware opcode + if mode == SHUTDOWN: + opcode = OP_SHUTDOWN + param = 0 if value == ON else 1 # 0=shutdown, 1=normal + elif mode == SCANLIMIT: + opcode = OP_SCANLIMIT + param = min(value, MAX_SCANLIMIT) + elif mode == INTENSITY: + opcode = OP_INTENSITY + param = min(value, MAX_INTENSITY) + elif mode == DECODE: + opcode = OP_DECODEMODE + param = 0xFF if value == ON else 0 + elif mode == TEST: + opcode = OP_DISPLAYTEST + param = 1 if value == ON else 0 + else: + return + + self._spi_clear() + for dev in range(start_dev, end_dev + 1): + self._spi_data[self._spi_offset(dev, 0)] = opcode + self._spi_data[self._spi_offset(dev, 1)] = param + self._spi_send() + + def setIntensity(self, intensity, dev=None): + """ + Set LED brightness. + + :param intensity: Value 0-15 + :param dev: Device index, or None for all devices + """ + if dev is None: + self.control(INTENSITY, intensity) + else: + self.control(INTENSITY, intensity, dev, dev) + + def getDeviceCount(self): + """:return: Number of devices in the chain.""" + return self._max_devices + + def getColumnCount(self): + """:return: Total number of columns across all devices.""" + return self._max_devices * COL_SIZE + + # ------------------------------------------------------------------------- + # Clear + # ------------------------------------------------------------------------- + + def clear(self, start_dev=None, end_dev=None): + """ + Clear all LEDs in one or all devices. + + :param start_dev: First device (default 0) + :param end_dev: Last device (default last) + """ + if start_dev is None: + start_dev = 0 + if end_dev is None: + end_dev = self._max_devices - 1 + for buf in range(start_dev, end_dev + 1): + for i in range(ROW_SIZE): + self._matrix[buf]['dig'][i] = 0 + self._matrix[buf]['changed'] = ALL_CHANGED + if self._update_enabled: + self._flush_buffer_all() + + # ------------------------------------------------------------------------- + # Row and column access + # ------------------------------------------------------------------------- + + def _bit_reverse(self, b): + """Reverse the bit order of a byte.""" + b = ((b & 0xF0) >> 4) | ((b & 0x0F) << 4) + b = ((b & 0xCC) >> 2) | ((b & 0x33) << 2) + b = ((b & 0xAA) >> 1) | ((b & 0x55) << 1) + return b & 0xFF + + def getRow(self, buf, r): + """ + Read the LED state for a row in a device buffer. + + :param buf: Device index + :param r: Row [0..7] + :return: Byte with one bit per LED + """ + if buf >= self._max_devices or r >= ROW_SIZE: + return 0 + if self._hw_dig_rows: + v = self._matrix[buf]['dig'][self._hw_row(r)] + return self._bit_reverse(v) if self._hw_rev_cols else v + else: + # dig entries represent columns; assemble the row from each column bit + mask = 1 << self._hw_col(r) + value = 0 + for i in range(COL_SIZE): + if self._matrix[buf]['dig'][self._hw_row(i)] & mask: + value |= (1 << i) + return value + + def setRow(self, buf, r, value): + """ + Set all LEDs in a row of a device. + + :param buf: Device index + :param r: Row [0..7] + :param value: Byte bitmask — bit N on = LED N lit + :return: True on success + """ + if buf >= self._max_devices or r >= ROW_SIZE: + return False + if self._hw_dig_rows: + self._matrix[buf]['dig'][self._hw_row(r)] = ( + self._bit_reverse(value) if self._hw_rev_cols else value + ) & 0xFF + self._matrix[buf]['changed'] |= (1 << self._hw_row(r)) + else: + mask = 1 << self._hw_col(r) + for i in range(ROW_SIZE): + if value & (1 << i): + self._matrix[buf]['dig'][self._hw_row(i)] |= mask + else: + self._matrix[buf]['dig'][self._hw_row(i)] &= ~mask & 0xFF + self._matrix[buf]['changed'] = ALL_CHANGED + if self._update_enabled: + self._flush_buffer(buf) + return True + + def getColumn(self, buf, c=None): + """ + Read the LED state for a column. + + Can be called as getColumn(absolute_col) or getColumn(buf, col_within_buf). + """ + if c is None: + # absolute column mode + abs_col = buf + buf = abs_col // COL_SIZE + c = abs_col % COL_SIZE + if buf >= self._max_devices or c >= COL_SIZE: + return 0 + if self._hw_dig_rows: + mask = 1 << self._hw_col(c) + value = 0 + for i in range(ROW_SIZE): + if self._matrix[buf]['dig'][self._hw_row(i)] & mask: + value |= (1 << i) + return value + else: + v = self._matrix[buf]['dig'][self._hw_row(c)] + return self._bit_reverse(v) if self._hw_rev_cols else v + + def setColumn(self, buf, c=None, value=None): + """ + Set all LEDs in a column. + + Can be called as setColumn(absolute_col, value) or setColumn(buf, col, value). + """ + if value is None: + # two-argument absolute mode: setColumn(abs_col, value) + abs_col = buf + value = c + buf = abs_col // COL_SIZE + c = abs_col % COL_SIZE + if buf >= self._max_devices or c >= COL_SIZE: + return False + if self._hw_dig_rows: + mask = 1 << self._hw_col(c) + for i in range(ROW_SIZE): + if value & (1 << i): + self._matrix[buf]['dig'][self._hw_row(i)] |= mask + else: + self._matrix[buf]['dig'][self._hw_row(i)] &= ~mask & 0xFF + self._matrix[buf]['changed'] = ALL_CHANGED + else: + self._matrix[buf]['dig'][self._hw_row(c)] = ( + self._bit_reverse(value) if self._hw_rev_cols else value + ) & 0xFF + self._matrix[buf]['changed'] |= (1 << self._hw_row(c)) + if self._update_enabled: + self._flush_buffer(buf) + return True + + # ------------------------------------------------------------------------- + # Pixel access + # ------------------------------------------------------------------------- + + def getPoint(self, r, c): + """ + Read a single LED state. + + :param r: Row [0..7] + :param c: Absolute column [0..getColumnCount()-1] + :return: True if LED is on + """ + buf = c // COL_SIZE + c_loc = c % COL_SIZE + if buf >= self._max_devices or r >= ROW_SIZE or c_loc >= COL_SIZE: + return False + if self._hw_dig_rows: + return bool(self._matrix[buf]['dig'][self._hw_row(r)] & (1 << self._hw_col(c_loc))) + else: + return bool(self._matrix[buf]['dig'][self._hw_row(c_loc)] & (1 << self._hw_col(r))) + + def setPoint(self, r, c, state): + """ + Set a single LED on or off. + + :param r: Row [0..7] + :param c: Absolute column [0..getColumnCount()-1] + :param state: True = on, False = off + :return: True on success + """ + buf = c // COL_SIZE + c_loc = c % COL_SIZE + if buf >= self._max_devices or r >= ROW_SIZE or c_loc >= COL_SIZE: + return False + if self._hw_dig_rows: + row_hw = self._hw_row(r) + col_hw = self._hw_col(c_loc) + if state: + self._matrix[buf]['dig'][row_hw] |= (1 << col_hw) + else: + self._matrix[buf]['dig'][row_hw] &= ~(1 << col_hw) & 0xFF + self._matrix[buf]['changed'] |= (1 << row_hw) + else: + row_hw = self._hw_row(c_loc) + col_hw = self._hw_col(r) + if state: + self._matrix[buf]['dig'][row_hw] |= (1 << col_hw) + else: + self._matrix[buf]['dig'][row_hw] &= ~(1 << col_hw) & 0xFF + self._matrix[buf]['changed'] |= (1 << row_hw) + if self._update_enabled: + self._flush_buffer(buf) + return True + + # ------------------------------------------------------------------------- + # Buffer block operations + # ------------------------------------------------------------------------- + + def getBuffer(self, col, size): + """ + Read a block of column data starting at col. + + :param col: Starting absolute column + :param size: Number of columns to read + :return: bytearray of column values, or None on error + """ + if col >= self.getColumnCount(): + return None + result = bytearray(size) + for i in range(size): + result[i] = self.getColumn(col - i) if col >= 0 else 0 + col -= 1 + return result + + def setBuffer(self, col, data): + """ + Write a block of column data starting at col. + + :param col: Starting absolute column + :param data: Iterable of column byte values + :return: True on success + """ + if col >= self.getColumnCount(): + return False + old_update = self._update_enabled + self._update_enabled = False + for v in data: + self.setColumn(col, v) + col -= 1 + self._update_enabled = old_update + if self._update_enabled: + self._flush_buffer_all() + return True + + # ------------------------------------------------------------------------- + # Update control + # ------------------------------------------------------------------------- + + def update(self, buf=None): + """ + Force an update to the hardware. + + :param buf: Device index to update, or None for all devices. + """ + if buf is None: + self._flush_buffer_all() + else: + self._flush_buffer(buf) + + def setUpdate(self, on): + """Enable or disable auto-updates after every change.""" + self.control(UPDATE, ON if on else OFF) + + def setWraparound(self, on): + """Enable or disable column wrap-around during shifts.""" + self.control(WRAPAROUND, ON if on else OFF) + + # ------------------------------------------------------------------------- + # Transforms + # ------------------------------------------------------------------------- + + def transform(self, ttype, start_dev=None, end_dev=None): + """ + Apply a transformation to all (or a range of) device buffers. + + :param ttype: One of TSL, TSR, TSU, TSD, TFLR, TFUD, TRC, TINV + :param start_dev: First device (default 0) + :param end_dev: Last device (default last) + :return: True on success + """ + if start_dev is None: + start_dev = 0 + if end_dev is None: + end_dev = self._max_devices - 1 + + old_update = self._update_enabled + self._update_enabled = False + + if ttype == TSL: + col_data = self.getColumn(end_dev, COL_SIZE - 1) if self._wrap_around else 0 + for buf in range(end_dev, start_dev - 1, -1): + self._transform_buffer(buf, ttype) + next_col = self.getColumn(buf - 1, COL_SIZE - 1) if buf > start_dev else col_data + self.setColumn(buf, 0, next_col) + self.setColumn(start_dev, 0, col_data) + + elif ttype == TSR: + col_data = self.getColumn(start_dev, 0) if self._wrap_around else 0 + for buf in range(start_dev, end_dev + 1): + self._transform_buffer(buf, ttype) + next_col = self.getColumn(buf + 1, 0) if buf < end_dev else col_data + self.setColumn(buf, COL_SIZE - 1, next_col) + self.setColumn(end_dev, COL_SIZE - 1, col_data) + + elif ttype == TFLR: + # Reverse device order then reverse columns within each device + devs = list(range(start_dev, end_dev + 1)) + for i in range(len(devs) // 2): + a, b = devs[i], devs[-(i + 1)] + self._matrix[a], self._matrix[b] = self._matrix[b], self._matrix[a] + for buf in range(start_dev, end_dev + 1): + self._transform_buffer(buf, ttype) + + else: + for buf in range(start_dev, end_dev + 1): + self._transform_buffer(buf, ttype) + + self._update_enabled = old_update + if self._update_enabled: + self._flush_buffer_all() + return True + + def _transform_buffer(self, buf, ttype): + """Apply a transformation to one device buffer in place.""" + m = self._matrix[buf]['dig'] + + if ttype == TSL: + for i in range(ROW_SIZE): + if self._hw_rev_cols: + m[i] = (m[i] >> 1) & 0xFF + else: + m[i] = (m[i] << 1) & 0xFF + + elif ttype == TSR: + for i in range(ROW_SIZE): + if self._hw_rev_cols: + m[i] = (m[i] << 1) & 0xFF + else: + m[i] = (m[i] >> 1) & 0xFF + + elif ttype == TSU: + t = self.getRow(buf, 0) if self._wrap_around else 0 + if self._hw_dig_rows: + for i in range(ROW_SIZE - 1): + m[i] = m[i + 1] + else: + for i in range(ROW_SIZE - 1, -1, -1): + m[i] = (m[i] << 1) & 0xFF + self.setRow(buf, ROW_SIZE - 1, t) + + elif ttype == TSD: + t = self.getRow(buf, ROW_SIZE - 1) if self._wrap_around else 0 + if self._hw_dig_rows: + for i in range(ROW_SIZE - 1, 0, -1): + m[i] = m[i - 1] + else: + for i in range(ROW_SIZE): + m[i] = (m[i] >> 1) & 0xFF + self.setRow(buf, 0, t) + + elif ttype == TFLR: + if self._hw_dig_rows: + for i in range(ROW_SIZE): + m[i] = self._bit_reverse(m[i]) + else: + for i in range(ROW_SIZE // 2): + m[i], m[ROW_SIZE - 1 - i] = m[ROW_SIZE - 1 - i], m[i] + + elif ttype == TFUD: + if self._hw_dig_rows: + for i in range(ROW_SIZE // 2): + m[i], m[ROW_SIZE - 1 - i] = m[ROW_SIZE - 1 - i], m[i] + else: + for i in range(ROW_SIZE): + m[i] = self._bit_reverse(m[i]) + + elif ttype == TRC: + t = [self.getColumn(buf, COL_SIZE - 1 - i) for i in range(ROW_SIZE)] + for i in range(ROW_SIZE): + self.setRow(buf, i, t[i]) + + elif ttype == TINV: + for i in range(ROW_SIZE): + m[i] = (~m[i]) & 0xFF + + self._matrix[buf]['changed'] = ALL_CHANGED + + # ------------------------------------------------------------------------- + # Font / character rendering + # ------------------------------------------------------------------------- + + def getChar(self, c, max_size=8): + """ + Retrieve the column bitmap data for a character from the built-in font. + + :param c: Character code (int or single-char string) + :param max_size: Maximum number of columns to return + :return: bytearray of column data, empty if not found + """ + if isinstance(c, str): + c = ord(c) + offset = _font_char_offset(c) + if offset == -1: + return bytearray() + width = _SYSFONT[offset] + size = min(max_size, width) + return bytearray(_SYSFONT[offset + 1: offset + 1 + size]) + + def setChar(self, col, c): + """ + Render a character from the built-in font at the given absolute column. + Columns are filled right-to-left (col, col-1, col-2, ...). + + :param col: Rightmost absolute column for the character + :param c: Character to display (int or single-char string) + :return: Width in columns of the character, 0 if not found + """ + if isinstance(c, str): + c = ord(c) + offset = _font_char_offset(c) + if offset == -1: + return 0 + width = _SYSFONT[offset] + old_update = self._update_enabled + self._update_enabled = False + for i in range(width): + col_data = _SYSFONT[offset + 1 + i] + self.setColumn(col - i, col_data) + self._update_enabled = old_update + if self._update_enabled: + self._flush_buffer_all() + return width + + def printText(self, text, col=None): + """ + Render a string on the display, right-to-left starting at col. + + :param text: String to display + :param col: Starting column (default = rightmost column of last device) + """ + if col is None: + col = self.getColumnCount() - 1 + old_update = self._update_enabled + self._update_enabled = False + for ch in text: + width = self.setChar(col, ch) + col -= width + 1 # one blank column gap between characters + self._update_enabled = old_update + if self._update_enabled: + self._flush_buffer_all() + + def scrollText(self, text, delay_ms=50, blank_cols=1): + """ + Scroll a text string across the display from right to left. + + :param text: String to scroll + :param delay_ms: Milliseconds between each column shift + :param blank_cols: Number of blank columns between characters (default 1) + """ + # Build the full pixel column data for the message + columns = [] + for ch in text: + data = self.getChar(ch) + columns.extend(data) + columns.extend([0] * blank_cols) # gap between characters + + # Pad with blank columns so the text fully scrolls off the left + columns.extend([0] * self.getColumnCount()) + + # Scroll by shifting one column at a time + col_count = self.getColumnCount() + for start in range(len(columns)): + old_update = self._update_enabled + self._update_enabled = False + col = col_count - 1 + for i in range(col_count): + src_idx = start + i + v = columns[src_idx] if src_idx < len(columns) else 0 + self.setColumn(col, v) + col -= 1 + self._update_enabled = old_update + self._flush_buffer_all() + time.sleep_ms(delay_ms) \ No newline at end of file diff --git a/Actuators/MAX7219/package.json b/Actuators/MAX7219/package.json new file mode 100644 index 0000000..c044406 --- /dev/null +++ b/Actuators/MAX7219/package.json @@ -0,0 +1,14 @@ +{ + "urls": [ + [ + "max7219.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Actuators/MAX7219/MAX7219/max7219.py" + ], + [ + "Examples/max7219-basic.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Actuators/MAX7219/MAX7219/Examples/max7219-basic.py" + ] + ], + "deps": [], + "version": "1.0" +} diff --git a/Actuators/MCP4018/MCP4018/Examples/mcp4018-getDigipotI2C.py b/Actuators/MCP4018/MCP4018/Examples/mcp4018-getDigipotI2C.py new file mode 100644 index 0000000..c4fff31 --- /dev/null +++ b/Actuators/MCP4018/MCP4018/Examples/mcp4018-getDigipotI2C.py @@ -0,0 +1,15 @@ +# FILE: mcp4018-getDigipotI2C.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Example - set wiper and read back raw value via I2C +# LAST UPDATED: 2026-05-26 + +from mcp4018 import MCP4018 +from time import sleep + +digipot = MCP4018() + +while True: + for pct in [0, 25, 50, 75, 100]: + digipot.setWiperPercent(pct) + print("Set: {}% | Raw value: {}".format(pct, digipot.getWiperValue())) + sleep(5) diff --git a/Actuators/MCP4018/MCP4018/Examples/mcp4018-serialControlI2C.py b/Actuators/MCP4018/MCP4018/Examples/mcp4018-serialControlI2C.py new file mode 100644 index 0000000..e164c02 --- /dev/null +++ b/Actuators/MCP4018/MCP4018/Examples/mcp4018-serialControlI2C.py @@ -0,0 +1,24 @@ +# FILE: mcp4018-serialControlI2C.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Example - control wiper via serial +/- commands +# LAST UPDATED: 2026-05-26 + +import sys +from mcp4018 import MCP4018 + + +def printWiper(digipot): + print("Wiper value: {}".format(digipot.getWiperValue())) + + +digipot = MCP4018() +print("Send '+' to increment, '-' to decrement.") +printWiper(digipot) + +while True: + ch = sys.stdin.read(1) + if ch == '+': + digipot.increment() + elif ch == '-': + digipot.decrement() + printWiper(digipot) diff --git a/Actuators/MCP4018/MCP4018/Examples/mcp4018-setDigipotI2C.py b/Actuators/MCP4018/MCP4018/Examples/mcp4018-setDigipotI2C.py new file mode 100644 index 0000000..25393e5 --- /dev/null +++ b/Actuators/MCP4018/MCP4018/Examples/mcp4018-setDigipotI2C.py @@ -0,0 +1,15 @@ +# FILE: mcp4018-setDigipotI2C.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Example - set wiper percent via I2C +# LAST UPDATED: 2026-05-26 + +from mcp4018 import MCP4018 +from time import sleep + +digipot = MCP4018() + +while True: + for pct in [0, 25, 50, 75, 100]: + digipot.setWiperPercent(pct) + print("Wiper set to {}%".format(pct)) + sleep(5) diff --git a/Actuators/MCP4018/MCP4018/mcp4018.py b/Actuators/MCP4018/MCP4018/mcp4018.py new file mode 100644 index 0000000..b821790 --- /dev/null +++ b/Actuators/MCP4018/MCP4018/mcp4018.py @@ -0,0 +1,112 @@ +# FILE: mcp4018.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: MicroPython driver for MCP4018 digital potentiometer +# LAST UPDATED: 2026-05-26 + +from machine import I2C, Pin +from os import uname + +MCP4018_ADDR = 0x2F +MCP4018_MAX_VALUE = 127 + + +class MCP4018: + """MicroPython driver for MCP4018 digital potentiometer (I2C).""" + + def __init__(self, i2c=None): + """ + Initialize MCP4018. + + :param i2c: Initialized I2C object (optional, auto-detected on known boards) + """ + if i2c is not None: + self.i2c = i2c + else: + if uname().sysname in ("esp32", "esp8266", "Soldered Dasduino CONNECTPLUS"): + self.i2c = I2C(0, scl=Pin(22), sda=Pin(21)) + else: + raise Exception("Board not recognized, enter I2C pins manually") + + self._addr = MCP4018_ADDR + self._value = self._readWiper() + + def _readWiper(self): + """Read current wiper value from chip.""" + try: + data = self.i2c.readfrom(self._addr, 1) + return data[0] & 0x7F + except OSError as e: + raise Exception("I2C read error: {}".format(e)) + + def _writeWiper(self, value): + """Write wiper value to chip.""" + try: + self.i2c.writeto(self._addr, bytes([value & 0x7F])) + except OSError as e: + raise Exception("I2C write error: {}".format(e)) + + def setWiperPercent(self, percent: float) -> bool: + """ + Set wiper position as percentage. + + :param percent: float, 0.0-100.0 + :returns: bool, True on success, False if out of range + """ + if percent < 0.0 or percent > 100.0: + return False + self._value = int((percent / 100.0) * MCP4018_MAX_VALUE) + self._writeWiper(self._value) + return True + + def setWiperValue(self, value: int) -> bool: + """ + Set wiper position as raw value. + + :param value: int, 0-127 + :returns: bool, True on success, False if out of range + """ + if value < 0 or value > MCP4018_MAX_VALUE: + return False + self._value = value + self._writeWiper(self._value) + return True + + def getWiperPercent(self) -> float: + """ + Get wiper position as percentage. + + :returns: float, 0.0-100.0 + """ + return (self._value / MCP4018_MAX_VALUE) * 100.0 + + def getWiperValue(self) -> int: + """ + Get wiper position as raw value. + + :returns: int, 0-127 + """ + return self._value + + def increment(self) -> bool: + """ + Increment wiper by one step. + + :returns: bool, True on success, False if already at max + """ + if self._value >= MCP4018_MAX_VALUE: + return False + self._value += 1 + self._writeWiper(self._value) + return True + + def decrement(self) -> bool: + """ + Decrement wiper by one step. + + :returns: bool, True on success, False if already at min + """ + if self._value <= 0: + return False + self._value -= 1 + self._writeWiper(self._value) + return True diff --git a/Actuators/MCP4018/README.md b/Actuators/MCP4018/README.md new file mode 100644 index 0000000..5ae9862 --- /dev/null +++ b/Actuators/MCP4018/README.md @@ -0,0 +1,14 @@ +# How to install + +--- + +After [**installing the mpremote package**](https://docs.micropython.org/en/latest/reference/mpremote.html), flash a module to the board using the following command: + +```sh + mpremote mip install github:SolderedElectronics/Soldered-MicroPython-Modules/Actuators/mcp4018 +``` +Or, if you're running a Windows OS: + +```sh + python -m mpremote mip install github:SolderedElectronics/Soldered-MicroPython-Modules/Actuators/mcp4018 +``` diff --git a/Actuators/MCP4018/package.json b/Actuators/MCP4018/package.json new file mode 100644 index 0000000..c5e86d5 --- /dev/null +++ b/Actuators/MCP4018/package.json @@ -0,0 +1,10 @@ +{ + "urls": [ + ["mcp4018.py", "github:SolderedElectronics/Soldered-MicroPython-Modules/Actuators/MCP4018/MCP4018/mcp4018.py"], + ["Examples/mcp4018-setDigipotI2C.py", "github:SolderedElectronics/Soldered-MicroPython-Modules/Actuators/MCP4018/MCP4018/Examples/mcp4018-setDigipotI2C.py"], + ["Examples/mcp4018-getDigipotI2C.py", "github:SolderedElectronics/Soldered-MicroPython-Modules/Actuators/MCP4018/MCP4018/Examples/mcp4018-getDigipotI2C.py"], + ["Examples/mcp4018-serialControlI2C.py", "github:SolderedElectronics/Soldered-MicroPython-Modules/Actuators/MCP4018/MCP4018/Examples/mcp4018-serialControlI2C.py"] + ], + "deps": [], + "version": "1.0" +} diff --git a/Actuators/MCP47A1/MCP47A1/Examples/mcp47a1-getVoltage.py b/Actuators/MCP47A1/MCP47A1/Examples/mcp47a1-getVoltage.py new file mode 100644 index 0000000..b90a413 --- /dev/null +++ b/Actuators/MCP47A1/MCP47A1/Examples/mcp47a1-getVoltage.py @@ -0,0 +1,36 @@ +# FILE: mcp47a1-getVoltage.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: This example sets desired voltage on DACs output and then reads set voltage from DAC and prints it. +# You can use a voltmeter to measure volttage on DACs output. +# WORKS WITH: DAC 6-Bit 1-Channel MCP47A1 Breakout: www.solde.red/333052 +# LAST UPDATED: 2026-04-29 + +import time +from mcp47a1 import MCP47A1 + +# If you aren't using the Qwiic connector, manually enter your I2C pins: +# i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +# mcp47a1 = MCP47A1(i2c) + +# Initialize DAC over Qwiic +mcp47a1 = MCP47A1() + +def printVoltage(v): + print("DAC output: {:.2f} V".format(v)) + +while True: + mcp47a1.setVoltage(0) + printVoltage(mcp47a1.getVoltage()) + time.sleep(2) + + mcp47a1.setVoltage(1) + printVoltage(mcp47a1.getVoltage()) + time.sleep(2) + + mcp47a1.setVoltage(2.5) + printVoltage(mcp47a1.getVoltage()) + time.sleep(2) + + mcp47a1.setVoltage(3.3) + printVoltage(mcp47a1.getVoltage()) + time.sleep(2) \ No newline at end of file diff --git a/Actuators/MCP47A1/MCP47A1/Examples/mcp47a1-setVoltage.py b/Actuators/MCP47A1/MCP47A1/Examples/mcp47a1-setVoltage.py new file mode 100644 index 0000000..86c0fa5 --- /dev/null +++ b/Actuators/MCP47A1/MCP47A1/Examples/mcp47a1-setVoltage.py @@ -0,0 +1,36 @@ +# FILE: mcp47a1-setVoltage.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: This example shows how to set voltage at DACs output. You can measure voltage that you set with a +# voltmeter at DACs output. It will cycle between four voltages: 0V, 1V, 2.5V and 3.3V. +# WORKS WITH: DAC 6-Bit 1-Channel MCP47A1 Breakout: www.solde.red/333052 +# LAST UPDATED: 2026-04-29 + +import time +from mcp47a1 import MCP47A1 + +# If you aren't using the Qwiic connector, manually enter your I2C pins: +# i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +# mcp47a1 = MCP47A1(i2c) + +# Initialize DAC over Qwiic +mcp47a1 = MCP47A1() + +def printVoltage(v): + print("DAC output: {:.2f} V".format(v)) + +while True: + mcp47a1.setVoltage(0) + printVoltage(0) + time.sleep(2) + + mcp47a1.setVoltage(1) + printVoltage(1) + time.sleep(2) + + mcp47a1.setVoltage(2.5) + printVoltage(2.5) + time.sleep(2) + + mcp47a1.setVoltage(3.3) + printVoltage(3.3) + time.sleep(2) \ No newline at end of file diff --git a/Actuators/MCP47A1/MCP47A1/Examples/mcp47a1-waveformGenerator.py b/Actuators/MCP47A1/MCP47A1/Examples/mcp47a1-waveformGenerator.py new file mode 100644 index 0000000..8078262 --- /dev/null +++ b/Actuators/MCP47A1/MCP47A1/Examples/mcp47a1-waveformGenerator.py @@ -0,0 +1,54 @@ +# FILE: mcp47a1-waveformGenerator.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: This example shows how to generate simple wavefroms like sinewave, triangle wave and sawtooth wave. +# You will need 330 Ohm resistor and LED. Connect LED and resistor at the output of a DAC. +# You can alternatively use oscilloscope or small speaker. +# WORKS WITH: DAC 6-Bit 1-Channel MCP47A1 Breakout: www.solde.red/333052 +# LAST UPDATED: 2026-04-29 + +import time +import math +from mcp47a1 import MCP47A1 + +# If you aren't using the Qwiic connector, manually enter your I2C pins: +# i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +# mcp47a1 = MCP47A1(i2c) + +# Initialize DAC over Qwiic +mcp47a1 = MCP47A1() + +selectedWaveform = 0 # 0=sine, 1=triangle, 2=sawtooth +waveformLUT = [0] * 65 # 65 samples (0–64) + +def make_lut(waveform): + global waveformLUT + + if waveform == 0: # Sine + for i in range(65): + waveformLUT[i] = int(32 * math.sin(2 * math.pi * i / 65) + 32) + + elif waveform == 1: # Triangle + n = 0 + direction = 2 + for i in range(65): + if i == 32: + direction = -2 + n += direction + waveformLUT[i] = n + + elif waveform == 2: # Sawtooth + for i in range(65): + waveformLUT[i] = i + +make_lut(selectedWaveform) + +k = 0 + +while True: + mcp47a1.writeByte(waveformLUT[k]) + + k += 1 + if k > 64: + k = 0 + + time.sleep_ms(100) \ No newline at end of file diff --git a/Actuators/MCP47A1/MCP47A1/mcp47a1.py b/Actuators/MCP47A1/MCP47A1/mcp47a1.py new file mode 100644 index 0000000..139d740 --- /dev/null +++ b/Actuators/MCP47A1/MCP47A1/mcp47a1.py @@ -0,0 +1,99 @@ +# FILE: mcp47a1.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: MicroPython library for the MCP47A1 DAC +# LAST UPDATED: 2026-04-29 + +from machine import I2C, Pin +from os import uname + +MCP47A1_I2C_ADDR = 0x2E + +class MCP47A1: + """ + MicroPython class for the MCP47A1 DAC. + """ + + dacSupply = 3.3 + + def __init__(self, i2c=None, address=MCP47A1_I2C_ADDR): + """ + Initialize the MCP47A1 DAC. + + :param i2c: Initialized I2C object (optional, auto-detected on known boards) + :param address: I2C address of the device (default 0x5C) + """ + if i2c is not None: + self.i2c = i2c + else: + if uname().sysname in ("esp32", "esp8266", "Soldered Dasduino CONNECTPLUS"): + self.i2c = I2C(0, scl=Pin(22), sda=Pin(21)) + else: + raise Exception("Board not recognized, enter I2C pins manually") + + self.address = address + + + def writeByte(self, val): + """ + Set voltage at with digital word (byte). + + :param val: DAC digital word (byte) in range from 0 to 64 + """ + try: + buf = bytes([0x00, int(val)]) + self.i2c.writeto(self.address, buf) + return True + except Exception as e: + print("Write error:", e) + return False + + + def readByte(self): + """ + Get digital word from DAC (byte) that represents output voltage. + + :return: Digital word in range from 0 (0V) to 64 (VCC) + """ + try: + self.i2c.writeto(self.address, bytes([0x00])) + data = self.i2c.readfrom(self.address, 1) + return True, data[0] + except Exception as e: + print("Read error:", e) + return False, 0 + + def setVoltage(self, voltage): + """ + Set DAC output voltage. + + :param voltage: Voltage at DACs output in range from 0V to VCC + """ + if voltage < 0 or voltage > self.dacSupply: + return False + + byte = int(voltage / self.dacSupply * 64) + return self._writeByte(byte) + + def dacVcc(self, vcc): + """ + Set supply voltage of DAC (needed for calculation). + + Use if only if you are VCC pin instead of easyC connector. + Default value is 3.3V (easyC * voltage). + + :param vcc: Supply voltage of DAC (in range from 1.8V to 5.5V) + """ + if (vcc > 5 or vcc < 1.8): + return + self.dacSupply = vcc + + def getVoltage(self): + """ + Get currently set voltage at the DACs output. + + :return: Voltage at DAC output + """ + success, byte = self._readByte() + if not success: + return None + return byte / 64 * self.dacSupply \ No newline at end of file diff --git a/Actuators/MCP47A1/package.json b/Actuators/MCP47A1/package.json new file mode 100644 index 0000000..d957429 --- /dev/null +++ b/Actuators/MCP47A1/package.json @@ -0,0 +1,22 @@ +{ + "urls": [ + [ + "mcp47a1.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/MCP47A1/MCP47A1/mcp47a1.py" + ], + [ + "Examples/mcp47a1-setVoltage.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/MCP47A1/MCP47A1/Examples/mcp47a1-setVoltage.py" + ], + [ + "Examples/mcp47a1-getVoltage.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/MCP47A1/MCP47A1/Examples/mcp47a1-getVoltage.py" + ], + [ + "Examples/mcp47a1-waveformGenerator.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/MCP47A1/MCP47A1/Examples/mcp47a1-waveformGenerator.py" + ] + ], + "deps": [], + "version": "1.0" +} \ No newline at end of file diff --git a/Actuators/PCF85063A/PCF85063A/Examples/pcf85063a-alarmInterrupt.py b/Actuators/PCF85063A/PCF85063A/Examples/pcf85063a-alarmInterrupt.py new file mode 100644 index 0000000..575233a --- /dev/null +++ b/Actuators/PCF85063A/PCF85063A/Examples/pcf85063a-alarmInterrupt.py @@ -0,0 +1,62 @@ +# FILE: pcf85063a-alarmInterrupt.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Sets the time and alarm and goes to sleep. Wakes up on RTC interrupt. +# File must be saved as main.py on MicroPython device. +# WORKS WITH: PCF85063A RTC Expander breakout: www.solde.red/333051 +# LAST UPDATED: 2026-04-30 + +from machine import I2C, Pin, deepsleep +import time +import machine +import esp32 +from pcf85063a import PCF85063A + +# Set up wake up pin +wake_pin = Pin(2, Pin.IN, Pin.PULL_UP) +esp32.wake_on_ext0(pin=wake_pin, level=esp32.WAKEUP_ALL_LOW) + +# If you aren't using the Qwiic connector, manually enter your I2C pins: +# i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +# rtc = PCF85063A(i2c) + +# Init RTC +rtc = PCF85063A() + +def print_current_time(): + year, month, day, weekday, hour, minute, second = rtc.get_time() + weekdays = ["Sunday", "Monday", "Tuesday", "Wednesday", + "Thursday", "Friday", "Saturday"] + print("{} , {:02d}.{:02d}.{:04d} {:02d}:{:02d}:{:02d}".format( + weekdays[weekday], day, month, year, hour, minute, second + )) + +def check_alarm(): + alarm_day, alarm_weekday, alarm_hour, alarm_minute, alarm_second = rtc.get_alarm() + weekdays = ["Sunday", "Monday", "Tuesday", "Wednesday", + "Thursday", "Friday", "Saturday"] + print("Alarm is set to match:") + if alarm_weekday != 99: print(weekdays[alarm_weekday], end=", ") + if alarm_day != 99: print("Date:", alarm_day, end=" ") + if alarm_hour != 99: print("hour:", alarm_hour, end=" ") + if alarm_minute != 99: print("minute:", alarm_minute, end=" ") + if alarm_second != 99: print("second:", alarm_second, end=" ") + print() + + +if machine.reset_cause() == machine.DEEPSLEEP_RESET: + print("Woke from deep sleep — skipping RTC init") + rtc.clear_alarm() +else: + print("Fresh boot — setting time and alarm") + rtc.set_time(0, 54, 6, 6, 16, 5, 2020) + rtc.set_alarm(30, 54, 99, 99, 99) + check_alarm() + + +while True: + print_current_time() + print("Entering sleep mode in 1 second") + time.sleep(1) + print("Going to deep sleep...") + time.sleep_ms(100) + deepsleep() \ No newline at end of file diff --git a/Actuators/PCF85063A/PCF85063A/Examples/pcf85063a-basic.py b/Actuators/PCF85063A/PCF85063A/Examples/pcf85063a-basic.py new file mode 100644 index 0000000..ebfc829 --- /dev/null +++ b/Actuators/PCF85063A/PCF85063A/Examples/pcf85063a-basic.py @@ -0,0 +1,30 @@ +# FILE: pcf85063a-basic.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Sets the time and reads the time each second. +# WORKS WITH: PCF85063A RTC Expander breakout: www.solde.red/333051 +# LAST UPDATED: 2026-04-30 + +from pcf85063a import PCF85063A +import time + +# If you aren't using the Qwiic connector, manually enter your I2C pins: +# i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +# rtc = PCF85063A(i2c) + +# Init RTC +rtc = PCF85063A() + +rtc.set_time(0, 0, 12, 4, 30, 4, 2026) + +print("Time set!\n") + +while True: + t = rtc.get_time() + + year, month, day, weekday, hour, minute, second = t + + print("{:04d}-{:02d}-{:02d} {:02d}:{:02d}:{:02d} (wd: {})".format( + year, month, day, hour, minute, second, weekday + )) + + time.sleep(1) \ No newline at end of file diff --git a/Actuators/PCF85063A/PCF85063A/Examples/pcf85063a-timer.py b/Actuators/PCF85063A/PCF85063A/Examples/pcf85063a-timer.py new file mode 100644 index 0000000..0bd30d6 --- /dev/null +++ b/Actuators/PCF85063A/PCF85063A/Examples/pcf85063a-timer.py @@ -0,0 +1,61 @@ +# FILE: pcf85063a-timer.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Sets the time and timer for 5 seconds. +# WORKS WITH: PCF85063A RTC Expander breakout: www.solde.red/333051 +# LAST UPDATED: 2026-04-30 + +from pcf85063a import PCF85063A, PCF85063A_TIMER_CLOCK_1HZ +import time + +# If you aren't using the Qwiic connector, manually enter your I2C pins: +# i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +# rtc = PCF85063A(i2c) + +# Init RTC +rtc = PCF85063A() + +countdown_time = 5 # seconds + +print("Now is:") +rtc.set_time(0, 54, 6, 6, 16, 5, 2020) + +def print_current_time(): + year, month, day, weekday, hour, minute, second = rtc.get_time() + + weekdays = [ + "Sunday", "Monday", "Tuesday", "Wednesday", + "Thursday", "Friday", "Saturday" + ] + + print( + "{} , {:02d}.{:02d}.{:04d} {:02d}:{:02d}:{:02d}".format( + weekdays[weekday], + day, month, year, + hour, minute, second + ) + ) + + +while True: + print_current_time() + + print( + "Setting timer countdown, waking up in", + countdown_time, + "seconds." + ) + + rtc.set_timer( + PCF85063A_TIMER_CLOCK_1HZ, + countdown_time, + int_enable=False, + int_pulse=False + ) + + print("Waiting for a countdown") + + while not rtc.get_timer_flag(): + print(".", end="") + time.sleep(1) + + print("\nInterrupt triggered on:") \ No newline at end of file diff --git a/Actuators/PCF85063A/PCF85063A/pcf85063a.py b/Actuators/PCF85063A/PCF85063A/pcf85063a.py new file mode 100644 index 0000000..9a9ee28 --- /dev/null +++ b/Actuators/PCF85063A/PCF85063A/pcf85063a.py @@ -0,0 +1,358 @@ +# FILE: pcf85063a.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: MicroPython library for the PCF85063A RTC. +# LAST UPDATED: 2026-04-30 + +from machine import I2C, Pin +from os import uname + +PCF85063A_I2C_ADDR = 0x51 + +# ctrl & status reg +PCF85063A_CTRL_1 = 0x0 +PCF85063A_CTRL_2 = 0x01 +PCF85063A_OFFSET = 0x02 +PCF85063A_RAM_by = 0x03 +# time & date reg +PCF85063A_SECOND_ADDR = 0x04 +PCF85063A_MINUTE_ADDR = 0x05 +PCF85063A_HOUR_ADDR = 0x06 +PCF85063A_DAY_ADDR = 0x07 +PCF85063A_WDAY_ADDR = 0x08 +PCF85063A_MONTH_ADDR = 0x09 +PCF85063A_YEAR_ADDR = 0x0A +# alarm reg +PCF85063A_SECOND_ALARM = 0x0B +PCF85063A_MINUTE_ALARM = 0x0C +PCF85063A_HOUR_ALARM = 0x0D +PCF85063A_DAY_ALARM = 0x0E +PCF85063A_WDAY_ALARM = 0x0F +# timer reg +PCF85063A_TIMER_VAL = 0x10 +PCF85063A_TIMER_MODE = 0x11 +PCF85063A_TIMER_TCF = 0x08 +PCF85063A_TIMER_TE = 0x04 +PCF85063A_TIMER_TIE = 0x02 +PCF85063A_TIMER_TI_TP = 0x01 + +PCF85063A_ALARM = 0x80 +PCF85063A_ALARM_AIE = 0x80 +PCF85063A_ALARM_AF = 0x40 +PCF85063A_CTRL_2_DEFAULT = 0x00 +PCF85063A_TIMER_FLAG = 0x08 + +PCF85063A_TIMER_CLOCK_4096HZ = 0 +PCF85063A_TIMER_CLOCK_64HZ = 1 +PCF85063A_TIMER_CLOCK_1HZ = 2 +PCF85063A_TIMER_CLOCK_1PER60HZ = 3 + + +def bcd_to_dec(bcd): + return ((bcd >> 4) * 10) + (bcd & 0x0F) + + +def dec_to_bcd(val): + return ((val // 10) << 4) | (val % 10) + + +def constrain(val, min_val, max_val): + return min(max_val, max(min_val, val)) + + +class PCF85063A: + """ + MicroPython class for the PCF85063A real-time clock. + Supports timekeeping, alarm generation, and countdown timer with + interrupt output on the INT pin. + """ + + def __init__(self, i2c=None, address=PCF85063A_I2C_ADDR): + """ + Initialize the PCF85063A RTC. + + :param i2c: Initialized I2C object (optional, auto-detected on known boards) + :param address: I2C address of the device (default 0x51) + """ + if i2c is not None: + self.i2c = i2c + else: + if uname().sysname in ("esp32", "esp8266", "Soldered Dasduino CONNECTPLUS"): + self.i2c = I2C(0, scl=Pin(22), sda=Pin(21)) + else: + raise Exception("Board not recognized, enter I2C pins manually") + + self.address = address + + # ---- time ---- + + def get_time(self): + """ + Read the current date and time from the RTC. + + :return: Tuple (year, month, day, weekday, hour, minute, second) + weekday is 0=Sunday, 6=Saturday + """ + data = self.i2c.readfrom_mem(self.address, PCF85063A_SECOND_ADDR, 7) + + second = bcd_to_dec(data[0] & 0x7F) + minute = bcd_to_dec(data[1] & 0x7F) + hour = bcd_to_dec(data[2] & 0x3F) + day = bcd_to_dec(data[3] & 0x3F) + weekday = bcd_to_dec(data[4] & 0x07) + month = bcd_to_dec(data[5] & 0x1F) + year = bcd_to_dec(data[6]) + 1970 + + return (year, month, day, weekday, hour, minute, second) + + def set_time(self, second, minute, hour, weekday, day, month, year): + """ + Set the current date and time on the RTC. + + :param second: Seconds (0-59) + :param minute: Minutes (0-59) + :param hour: Hours (0-23) + :param weekday: Day of week (0=Sunday, 6=Saturday) + :param day: Day of month (1-31) + :param month: Month (1-12) + :param year: Full year (e.g. 2025); stored as offset from 1970 + """ + year_rtc = year - 1970 + + data = bytes([ + dec_to_bcd(second), + dec_to_bcd(minute), + dec_to_bcd(hour), + dec_to_bcd(day), + dec_to_bcd(weekday), + dec_to_bcd(month), + dec_to_bcd(year_rtc) + ]) + + self.i2c.writeto_mem(self.address, PCF85063A_SECOND_ADDR, data) + + def get_second(self): + """ + Read the current seconds value. + + :return: Seconds (0-59) + """ + return self.get_time()[6] + + def get_minute(self): + """ + Read the current minutes value. + + :return: Minutes (0-59) + """ + return self.get_time()[5] + + def get_hour(self): + """ + Read the current hours value. + + :return: Hours (0-23) + """ + return self.get_time()[4] + + def get_weekday(self): + """ + Read the current day of the week. + + :return: Weekday (0=Sunday, 6=Saturday) + """ + return self.get_time()[3] + + def get_day(self): + """ + Read the current day of the month. + + :return: Day (1-31) + """ + return self.get_time()[2] + + def get_month(self): + """ + Read the current month. + + :return: Month (1-12) + """ + return self.get_time()[1] + + def get_year(self): + """ + Read the current year. + + :return: Full year (e.g. 2025) + """ + return self.get_time()[0] + + # ---- alarm ---- + + def set_alarm(self, alarm_second, alarm_minute, alarm_hour, alarm_day, alarm_weekday): + """ + Configure the alarm and enable the alarm interrupt on the INT pin. + + Pass 99 for any field to ignore it in the alarm match — for example, + passing 99 for alarm_hour makes the alarm fire every hour. + + :param alarm_second: Seconds to match (0-59), or 99 to ignore + :param alarm_minute: Minutes to match (0-59), or 99 to ignore + :param alarm_hour: Hours to match (0-23), or 99 to ignore + :param alarm_day: Day of month to match (1-31), or 99 to ignore + :param alarm_weekday: Day of week to match (0=Sunday, 6=Saturday), or 99 to ignore + """ + def encode_alarm(val, min_val, max_val): + if val < 99: + val = max(min_val, min(val, max_val)) + return dec_to_bcd(val) & ~PCF85063A_ALARM + else: + return PCF85063A_ALARM + + # Enable alarm interrupt and clear any pending alarm flag + control_2 = PCF85063A_CTRL_2_DEFAULT | PCF85063A_ALARM_AIE + control_2 &= ~PCF85063A_ALARM_AF + self.i2c.writeto_mem(self.address, PCF85063A_CTRL_2, bytes([control_2])) + + data = bytes([ + encode_alarm(alarm_second, 0, 59), + encode_alarm(alarm_minute, 0, 59), + encode_alarm(alarm_hour, 0, 23), + encode_alarm(alarm_day, 1, 31), + encode_alarm(alarm_weekday, 0, 6), + ]) + + self.i2c.writeto_mem(self.address, PCF85063A_SECOND_ALARM, data) + + def get_alarm(self): + """ + Read the currently configured alarm fields. + + Fields that are disabled (AEN bit set) are returned as 99. + + :return: Tuple (alarm_day, alarm_weekday, alarm_hour, alarm_minute, alarm_second) + Any field set to 99 is not active in the alarm match. + """ + def decode_alarm(val, mask): + if val & PCF85063A_ALARM: + return 99 + return bcd_to_dec(val & mask) + + data = self.i2c.readfrom_mem(self.address, PCF85063A_SECOND_ALARM, 5) + + alarm_second = decode_alarm(data[0], 0x7F) + alarm_minute = decode_alarm(data[1], 0x7F) + alarm_hour = decode_alarm(data[2], 0x3F) + alarm_day = decode_alarm(data[3], 0x3F) + alarm_weekday = decode_alarm(data[4], 0x07) + + return (alarm_day, alarm_weekday, alarm_hour, alarm_minute, alarm_second) + + def get_alarm_second(self): + """ + Read the alarm seconds field. + + :return: Seconds (0-59), or 99 if this field is disabled + """ + return self.get_alarm()[4] + + def get_alarm_minute(self): + """ + Read the alarm minutes field. + + :return: Minutes (0-59), or 99 if this field is disabled + """ + return self.get_alarm()[3] + + def get_alarm_hour(self): + """ + Read the alarm hours field. + + :return: Hours (0-23), or 99 if this field is disabled + """ + return self.get_alarm()[2] + + def get_alarm_weekday(self): + """ + Read the alarm weekday field. + + :return: Weekday (0=Sunday, 6=Saturday), or 99 if this field is disabled + """ + return self.get_alarm()[1] + + def get_alarm_day(self): + """ + Read the alarm day-of-month field. + + :return: Day (1-31), or 99 if this field is disabled + """ + return self.get_alarm()[0] + + def clear_alarm(self): + """ + Clear the alarm flag (AF bit) in Control_2 to de-assert the INT pin. + + Call this after waking from deep sleep triggered by an alarm, otherwise + the INT pin stays low and the ESP32 will wake immediately on the next + deep sleep call instead of waiting for the next alarm. + """ + ctrl_2 = self.i2c.readfrom_mem(self.address, PCF85063A_CTRL_2, 1)[0] + ctrl_2 &= ~PCF85063A_ALARM_AF + self.i2c.writeto_mem(self.address, PCF85063A_CTRL_2, bytes([ctrl_2])) + + # ---- timer ---- + + def set_timer(self, source_clock, value, int_enable=False, int_pulse=False): + """ + Configure and start the countdown timer. + + The timer counts down from value at the selected clock frequency and + optionally pulses or holds the INT pin low when it reaches zero. + + :param source_clock: Clock source — one of PCF85063A_TIMER_CLOCK_4096HZ, + PCF85063A_TIMER_CLOCK_64HZ, PCF85063A_TIMER_CLOCK_1HZ, + or PCF85063A_TIMER_CLOCK_1PER60HZ + :param value: Countdown start value (0-255) + :param int_enable: True to assert INT pin when timer expires (default False) + :param int_pulse: True for pulse mode on INT, False for level mode (default False) + """ + # Disable timer before reconfiguring + self.i2c.writeto_mem(self.address, PCF85063A_TIMER_MODE, bytes([0x18])) + + # Clear Control_2 + self.i2c.writeto_mem(self.address, PCF85063A_CTRL_2, bytes([0x00])) + + # Build timer mode register + timer_mode = 0 + timer_mode |= PCF85063A_TIMER_TE # enable timer + + if int_enable: + timer_mode |= PCF85063A_TIMER_TIE # interrupt enable + + if int_pulse: + timer_mode |= PCF85063A_TIMER_TI_TP # pulse mode + + timer_mode |= (source_clock << 3) # clock source + + data = bytes([value, timer_mode]) + self.i2c.writeto_mem(self.address, PCF85063A_TIMER_VAL, data) + + def get_timer_flag(self): + """ + Check whether the timer has expired by reading the TF flag in Control_2. + + The flag is not cleared automatically — use reset() or reconfigure the + timer to clear it. + + :return: True if the timer has expired, False otherwise + """ + ctrl_2 = self.i2c.readfrom_mem(self.address, PCF85063A_CTRL_2, 1)[0] + return bool(ctrl_2 & PCF85063A_TIMER_FLAG) + + def reset(self): + """ + Perform a software reset of the RTC by setting the SR bit in Control_1. + + This clears all registers to their power-on default values, including + time, alarm, and timer configuration. + """ + self.i2c.writeto_mem(self.address, PCF85063A_CTRL_1, bytes([0x18])) \ No newline at end of file diff --git a/Actuators/PCF85063A/package.json b/Actuators/PCF85063A/package.json new file mode 100644 index 0000000..15e217f --- /dev/null +++ b/Actuators/PCF85063A/package.json @@ -0,0 +1,22 @@ +{ + "urls": [ + [ + "pcf85063a.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Actuators/PCF85063A/PCF85063A/pcf85063a.py" + ], + [ + "Examples/pcf85063a-alarmInterrupt.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Actuators/PCF85063A/PCF85063A/Examples/pcf85063a-alarmInterrupt.py" + ], + [ + "Examples/pcf85063a-basic.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Actuators/PCF85063A/PCF85063A/Examples/pcf85063a-basic.py" + ], + [ + "Examples/pcf85063a-timer.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Actuators/PCF85063A/PCF85063A/Examples/pcf85063a-timer.py" + ] + ], + "deps": [], + "version": "1.0" +} diff --git a/Actuators/Relay/Relay/Examples/relay-1ch.py b/Actuators/Relay/Relay/Examples/relay-1ch.py new file mode 100644 index 0000000..6bcedc3 --- /dev/null +++ b/Actuators/Relay/Relay/Examples/relay-1ch.py @@ -0,0 +1,37 @@ +# FILE: Relay-1ch.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: 1-channel relay control example - toggles relay on and off. +# WORKS WITH: Relay board (1CH): solde.red/333025 +# LAST UPDATED: 2026-05-21 + +# Connecting diagram: +# +# Relay board Dasduino +# Qwiic--------->Qwiic + +from machine import I2C, Pin +import time +from Relay import Relay, CHANNEL1 + +# Initialize I2C and relay board +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +relay = Relay(i2c) + +# ------------------------------------------------------------------------- +# Setup +# ------------------------------------------------------------------------- +print("Relay Board - 1 Channel Control") + +# ------------------------------------------------------------------------- +# Main loop +# ------------------------------------------------------------------------- +while True: + # Turn on relay 1 + relay.relayControl(CHANNEL1, 1) + print("Channel 1 state: {}".format(relay.getChannelState(CHANNEL1))) + time.sleep_ms(1500) + + # Turn off relay 1 + relay.relayControl(CHANNEL1, 0) + print("Channel 1 state: {}".format(relay.getChannelState(CHANNEL1))) + time.sleep_ms(1500) diff --git a/Actuators/Relay/Relay/Examples/relay-2ch.py b/Actuators/Relay/Relay/Examples/relay-2ch.py new file mode 100644 index 0000000..72484c0 --- /dev/null +++ b/Actuators/Relay/Relay/Examples/relay-2ch.py @@ -0,0 +1,47 @@ +# FILE: Relay-2ch.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: 2-channel relay control example - toggles each relay on and off in sequence. +# WORKS WITH: Relay board (2CH): solde.red/333025 +# LAST UPDATED: 2026-05-21 + +# Connecting diagram: +# +# Relay board Dasduino +# Qwiic--------->Qwiic + +from machine import I2C, Pin +import time +from Relay import Relay, CHANNEL1, CHANNEL2 + +# Initialize I2C and relay board +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +relay = Relay(i2c) + +# ------------------------------------------------------------------------- +# Setup +# ------------------------------------------------------------------------- +print("Relay Board - 2 Channel Control") + +# ------------------------------------------------------------------------- +# Main loop +# ------------------------------------------------------------------------- +while True: + # Turn on relay 1 + relay.relayControl(CHANNEL1, 1) + print("Channel 1 state: {}".format(relay.getChannelState(CHANNEL1))) + time.sleep_ms(1500) + + # Turn off relay 1 + relay.relayControl(CHANNEL1, 0) + print("Channel 1 state: {}".format(relay.getChannelState(CHANNEL1))) + time.sleep_ms(1500) + + # Turn on relay 2 + relay.relayControl(CHANNEL2, 1) + print("Channel 2 state: {}".format(relay.getChannelState(CHANNEL2))) + time.sleep_ms(1500) + + # Turn off relay 2 + relay.relayControl(CHANNEL2, 0) + print("Channel 2 state: {}".format(relay.getChannelState(CHANNEL2))) + time.sleep_ms(1500) diff --git a/Actuators/Relay/Relay/Examples/relay-4ch.py b/Actuators/Relay/Relay/Examples/relay-4ch.py new file mode 100644 index 0000000..ca9c65b --- /dev/null +++ b/Actuators/Relay/Relay/Examples/relay-4ch.py @@ -0,0 +1,40 @@ +# FILE: Relay-4ch.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: 4-channel relay control example - toggles each relay on and off in sequence. +# WORKS WITH: Relay board (4CH): solde.red/333025 +# LAST UPDATED: 2026-05-21 + +# Connecting diagram: +# +# Relay board Dasduino +# Qwiic--------->Qwiic + +from machine import I2C, Pin +import time +from Relay import Relay, CHANNEL1, CHANNEL2, CHANNEL3, CHANNEL4 + +# Initialize I2C and relay board +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +relay = Relay(i2c) + +# ------------------------------------------------------------------------- +# Setup +# ------------------------------------------------------------------------- +print("Relay Board - 4 Channel Control") + +# ------------------------------------------------------------------------- +# Main loop +# ------------------------------------------------------------------------- +channels = [CHANNEL1, CHANNEL2, CHANNEL3, CHANNEL4] + +while True: + for ch_num, channel in enumerate(channels, start=1): + # Turn on channel + relay.relayControl(channel, 1) + print("Channel {} state: {}".format(ch_num, relay.getChannelState(channel))) + time.sleep_ms(1500) + + # Turn off channel + relay.relayControl(channel, 0) + print("Channel {} state: {}".format(ch_num, relay.getChannelState(channel))) + time.sleep_ms(1500) diff --git a/Actuators/Relay/Relay/relay.py b/Actuators/Relay/Relay/relay.py new file mode 100644 index 0000000..58f6369 --- /dev/null +++ b/Actuators/Relay/Relay/relay.py @@ -0,0 +1,64 @@ +# FILE: Relay.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: MicroPython library for the Soldered Relay board with Qwiic/I2C +# LAST UPDATED: 2026-05-21 + +from machine import I2C, Pin +from os import uname + +# Default I2C address +RELAY_DEFAULT_ADDRESS = 0x30 + +# Relay channels +CHANNEL1 = 0 +CHANNEL2 = 1 +CHANNEL3 = 2 +CHANNEL4 = 8 + + +class Relay: + """ + MicroPython class for the Soldered Relay board (Qwiic/I2C version). + Supports 1, 2, and 4 channel relay boards. + Communicates over I2C. + """ + + def __init__(self, i2c=None, address=RELAY_DEFAULT_ADDRESS): + """ + Initialize the Relay board. + + :param i2c: Initialized I2C object (optional, auto-detected on known boards) + :param address: I2C address of the device (default 0x30, range 0x30-0x37) + """ + if i2c is not None: + self.i2c = i2c + else: + if uname().sysname in ("esp32", "Soldered Dasduino CONNECTPLUS"): + self.i2c = I2C(0, scl=Pin(22), sda=Pin(21)) + elif uname().sysname == "esp8266": + self.i2c = I2C(scl=Pin(5), sda=Pin(4)) + else: + raise Exception("Board not recognized, please pass an I2C object manually") + + self.address = address + self._channelState = [0, 0, 0, 0] + + def relayControl(self, channel, mode): + """ + Turn a relay channel on or off. + + :param channel: CHANNEL1, CHANNEL2, CHANNEL3, or CHANNEL4 + :param mode: 1 to turn on, 0 to turn off + """ + try: + self.i2c.writeto(self.address, bytes([channel, mode])) + except: + pass + self._setChannelState(channel, mode) + + def getChannelState(self, channel): + """Return the last set state of a relay channel (1=on, 0=off).""" + return self._channelState[3 if channel == CHANNEL4 else channel] + + def _setChannelState(self, channel, mode): + self._channelState[3 if channel == CHANNEL4 else channel] = mode diff --git a/Actuators/Relay/package.json b/Actuators/Relay/package.json new file mode 100644 index 0000000..0658d69 --- /dev/null +++ b/Actuators/Relay/package.json @@ -0,0 +1,22 @@ +{ + "urls": [ + [ + "Relay.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Actuators/Relay/Relay/Relay.py" + ], + [ + "Examples/Relay-1ch.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Actuators/Relay/Relay/Examples/Relay-1ch.py" + ], + [ + "Examples/Relay-2ch.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Actuators/Relay/Relay/Examples/Relay-2ch.py" + ], + [ + "Examples/Relay-4ch.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Actuators/Relay/Relay/Examples/Relay-4ch.py" + ] + ], + "deps": [], + "version": "1.0" +} diff --git a/Actuators/WS2812Grid/WS2812Grid/Examples/Animations.py b/Actuators/WS2812Grid/WS2812Grid/Examples/Animations.py new file mode 100644 index 0000000..eb84ce3 --- /dev/null +++ b/Actuators/WS2812Grid/WS2812Grid/Examples/Animations.py @@ -0,0 +1,68 @@ +# FILE: Animations.py +# AUTHOR: Josip Šimun Kuči @ Soldered +# BRIEF: Demonstrates row sweep and colour-cycle animations on the +# Soldered 8x8 WS2812B LED grid. +# Connect the grid data line to the pin defined below. +# WORKS WITH: Soldered WS2812B LED Grid: www.soldered.com +# LAST UPDATED: 2026-05-12 + +from machine import Pin +from WS2812Grid import WS2812Grid +import time + +PIN = Pin(6, Pin.OUT) + +grid = WS2812Grid(PIN) +grid.begin() +grid.setBrightness(40) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def colorWheel(pos): + """Simple HSV-to-RGB wheel: hue 0-255.""" + pos = 255 - pos + if pos < 85: + return WS2812Grid.Color(255 - pos * 3, 0, pos * 3) + if pos < 170: + pos -= 85 + return WS2812Grid.Color(0, pos * 3, 255 - pos * 3) + pos -= 170 + return WS2812Grid.Color(pos * 3, 255 - pos * 3, 0) + + +# --------------------------------------------------------------------------- +# Animations +# --------------------------------------------------------------------------- + +def rowSweep(): + for y in range(8): + grid.clear() + for x in range(8): + grid.setPixel(x, y, 0, 180, 255) + grid.show() + time.sleep_ms(80) + + +def rainbowGrid(cycles): + for _ in range(cycles): + for hue in range(256): + for y in range(8): + for x in range(8): + offset = (x + y * 2) & 0xFF + grid.setPixel(x, y, colorWheel((hue + offset) & 0xFF)) + grid.show() + time.sleep_ms(10) + + +# --------------------------------------------------------------------------- +# Main loop +# --------------------------------------------------------------------------- + +while True: + rowSweep() + time.sleep_ms(200) + rainbowGrid(2) + time.sleep_ms(200) diff --git a/Actuators/WS2812Grid/WS2812Grid/Examples/SetPixel.py b/Actuators/WS2812Grid/WS2812Grid/Examples/SetPixel.py new file mode 100644 index 0000000..49f0f2a --- /dev/null +++ b/Actuators/WS2812Grid/WS2812Grid/Examples/SetPixel.py @@ -0,0 +1,38 @@ +# FILE: SetPixel.py +# AUTHOR: Josip Šimun Kuči @ Soldered +# BRIEF: Basic usage: set individual pixels by (x, y) coordinates on the +# Soldered 8x8 WS2812B LED grid. +# Connect the grid data line to the pin defined below. +# (0, 0) is top-left. X increases to the right, Y increases downward. +# WORKS WITH: Soldered WS2812B LED Grid: www.soldered.com +# LAST UPDATED: 2026-05-12 + +from machine import Pin +from WS2812Grid import WS2812Grid + +PIN = Pin(6, Pin.OUT) + +grid = WS2812Grid(PIN) +grid.begin() + +# Set overall brightness (0 = off, 255 = full). +# Keep this low to avoid excessive current draw. +grid.setBrightness(40) + +# Draw a red pixel at the top-left corner +grid.setPixel(0, 0, 255, 0, 0) + +# Draw a green pixel at the top-right corner +grid.setPixel(7, 0, 0, 255, 0) + +# Draw a blue pixel at the bottom-left corner +grid.setPixel(0, 7, 0, 0, 255) + +# Draw a white pixel in the centre using a packed color +grid.setPixel(3, 3, WS2812Grid.Color(255, 255, 255)) +grid.setPixel(4, 4, WS2812Grid.Color(255, 255, 255)) + +# Push all changes to the hardware +grid.show() + +# Nothing to do — pixels stay lit diff --git a/Actuators/WS2812Grid/WS2812Grid/WS2812Grid.py b/Actuators/WS2812Grid/WS2812Grid/WS2812Grid.py new file mode 100644 index 0000000..1b0c1d0 --- /dev/null +++ b/Actuators/WS2812Grid/WS2812Grid/WS2812Grid.py @@ -0,0 +1,174 @@ +# FILE: WS2812Grid.py +# AUTHOR: Josip Šimun Kuči @ Soldered +# BRIEF: MicroPython driver for the Soldered WS2812B 8x8 LED grid. +# Provides (x, y) coordinate interface over a serpentine WS2812B grid. +# Even rows run left-to-right, odd rows run right-to-left. +# Uses MicroPython's built-in neopixel module for LED control. +# WORKS WITH: Soldered WS2812B LED Grid: www.soldered.com +# LAST UPDATED: 2026-05-12 + +import neopixel +from machine import Pin + +WS2812GRID_DEFAULT_WIDTH = 8 +WS2812GRID_DEFAULT_HEIGHT = 8 + + +class WS2812Grid: + """ + MicroPython driver for the Soldered WS2812B 8x8 LED grid. + + Supports grids of any size that are multiples of 8 panels. + (0, 0) is top-left. X increases to the right, Y increases downward. + + Usage: + from machine import Pin + from WS2812Grid import WS2812Grid + + grid = WS2812Grid(Pin(6, Pin.OUT)) + grid.begin() + grid.setBrightness(40) + grid.setPixel(0, 0, 255, 0, 0) + grid.show() + """ + + def __init__(self, pin, width=WS2812GRID_DEFAULT_WIDTH, height=WS2812GRID_DEFAULT_HEIGHT): + """ + Initialize the WS2812B grid driver. + + :param pin: machine.Pin object connected to the grid data line + :param width: Number of columns (default 8) + :param height: Number of rows (default 8) + """ + self._width = width + self._height = height + self._brightness = 255 + self._np = neopixel.NeoPixel(pin, width * height) + + def begin(self): + """Initialize the LED driver. Clears the display and calls show().""" + self.clear() + self.show() + + # ── Brightness ──────────────────────────────────────────────────────────── + + def setBrightness(self, brightness): + """ + Set overall brightness scale applied when setting pixels. + + :param brightness: 0 (off) to 255 (full brightness) + """ + self._brightness = max(0, min(255, brightness)) + + # ── Pixel control ───────────────────────────────────────────────────────── + + def setPixel(self, x, y, r_or_color, g=None, b=None): + """ + Set a single LED by grid coordinates. + + Two calling conventions: + setPixel(x, y, r, g, b) — separate RGB components + setPixel(x, y, color) — packed 0x00RRGGBB color + + Call show() afterwards to push changes to hardware. + """ + idx = self.xyToIndex(x, y) + if idx == 0xFFFF: + return + if g is None: + color = r_or_color + r = (color >> 16) & 0xFF + g = (color >> 8) & 0xFF + b = color & 0xFF + else: + r = r_or_color + self._np[idx] = self._scaledColor(r, g, b) + + def getPixel(self, x, y): + """ + Read back the stored color of a single LED. + + :return: Packed color (0x00RRGGBB), or 0 if coordinates are out of range + """ + idx = self.xyToIndex(x, y) + if idx == 0xFFFF: + return 0 + r, g, b = self._np[idx] + return (r << 16) | (g << 8) | b + + def fill(self, r_or_color, g=None, b=None): + """ + Fill every LED with the same color. + + Two calling conventions: + fill(r, g, b) — separate RGB components + fill(color) — packed 0x00RRGGBB color + + Call show() afterwards to push changes to hardware. + """ + if g is None: + color = r_or_color + r = (color >> 16) & 0xFF + g = (color >> 8) & 0xFF + b = color & 0xFF + else: + r = r_or_color + self._np.fill(self._scaledColor(r, g, b)) + + def clear(self): + """Turn all LEDs off (does not call show()).""" + self._np.fill((0, 0, 0)) + + def show(self): + """Push buffered color data to the hardware.""" + self._np.write() + + # ── Index mapping ───────────────────────────────────────────────────────── + + def xyToIndex(self, x, y): + """ + Convert (x, y) grid coordinates to a linear LED index. + + Panels are 8x8 and chained column-first. Within each panel wiring is + serpentine: even local rows left-to-right, odd rows right-to-left. + + :return: LED index (0 to width*height-1), or 0xFFFF if out of range + """ + if x >= self._width or y >= self._height: + return 0xFFFF + + PANEL_SIZE = 8 + panelCol = x // PANEL_SIZE + panelRow = y // PANEL_SIZE + localX = x % PANEL_SIZE + localY = y % PANEL_SIZE + + numPanelRows = self._height // PANEL_SIZE + panelIndex = panelCol * numPanelRows + panelRow + ledBase = panelIndex * (PANEL_SIZE * PANEL_SIZE) + + if localY % 2 == 0: + localIndex = localY * PANEL_SIZE + localX + else: + localIndex = localY * PANEL_SIZE + (PANEL_SIZE - 1 - localX) + + return ledBase + localIndex + + # ── Static helpers ──────────────────────────────────────────────────────── + + @staticmethod + def Color(r, g, b): + """ + Pack RGB components into a 32-bit color value (0x00RRGGBB). + + :return: Packed color integer + """ + return (r << 16) | (g << 8) | b + + # ── Private helpers ─────────────────────────────────────────────────────── + + def _scaledColor(self, r, g, b): + if self._brightness == 255: + return (r, g, b) + br = self._brightness + return (r * br // 255, g * br // 255, b * br // 255) diff --git a/Actuators/WS2812Grid/package.json b/Actuators/WS2812Grid/package.json new file mode 100644 index 0000000..0f99792 --- /dev/null +++ b/Actuators/WS2812Grid/package.json @@ -0,0 +1,18 @@ +{ + "urls": [ + [ + "WS2812Grid.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Actuators/WS2812Grid/WS2812Grid/WS2812Grid.py" + ], + [ + "Examples/SetPixel.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Actuators/WS2812Grid/WS2812Grid/Examples/SetPixel.py" + ], + [ + "Examples/Animations.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Actuators/WS2812Grid/WS2812Grid/Examples/Animations.py" + ] + ], + "deps": [], + "version": "1.0" +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bc10156 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,178 @@ +# Soldered MicroPython Modules — Project Instructions + +## Behavior Rules +- Expert embedded software developer role +- Always ask 5+ questions before starting any port or implementation +- Never edit files directly unless explicitly told — provide code blocks first, user decides +- If something should work, say so; do NOT suggest random fixes — hardware could be at fault +- Do not commit to git, run, or build anything — user does it themselves +- If unsure about anything, ask — do not assume +- Only create example files the user explicitly provides or describes — do not invent extras + +--- + +## Repository Structure + +``` +Category/ModuleName/ +├── package.json +├── README.md +└── ModuleName/ + ├── ModuleName.py ← main class (PascalCase, matches dir) + ├── SubclassName.py ← subclasses if needed + └── Examples/ + ├── ModuleName-featureNative.py + └── ModuleName-featureI2C.py +``` + +Categories: `Sensors/`, `Actuators/`, `Communication/`, `Displays/` + +--- + +## File Naming Conventions + +| Type | Convention | Examples | +|------|-----------|---------| +| Module .py (descriptive) | `PascalCase.py` | `SimpleSensor.py`, `ObstacleSensor.py` | +| Module .py (chip/IC name) | `lowercase.py` | `bmp388.py`, `ads1x15.py` | +| Folder name (chip/IC name) | `UPPERCASE` (match chip name) | `LSM9DS1/`, `BMP388/` — folder uppercase, .py inside lowercase | +| Folder name (descriptive) | `PascalCase` (match .py name) | `SimpleSensor/`, `ObstacleSensor/` | +| Support/constants | merge into main .py, no separate file | — | +| Example files | `ModuleName-featureVariant.py` | `SimpleSensor-rainNative.py` | +| Example variant suffixes | `Native` for GPIO/ADC, `I2C` for I2C/easyC | `SimpleSensor-rainI2C.py` | + +**Never rename chip/IC files** (bmp388, ads1x15, etc.) — they follow their own lowercase convention. + +--- + +## Code Conventions + +### File Header (every .py file) +```python +# FILE: ModuleName.py +# AUTHOR: Name @ Soldered +# BRIEF: One-line description +# LAST UPDATED: YYYY-MM-DD +``` + +### Class Structure +```python +class ModuleName: + """Docstring.""" + + def __init__(self, analog_pin=None, digital_pin=None, i2c=None, address=DEFAULT_ADDR): + if analog_pin is not None: + # Native mode + self.native = True + ... + else: + # I2C mode + self.native = False + if i2c is not None: + self.i2c = i2c + else: + if uname().sysname in ("esp32", "esp8266", "Soldered Dasduino CONNECTPLUS"): + self.i2c = I2C(0, scl=Pin(22), sda=Pin(21)) + else: + raise Exception("Board not recognized, enter I2C pins manually") +``` + +### Naming +- Classes: `PascalCase` +- Public methods: `camelCase` verbs — `getRawReading()`, `setThreshold()`, `isRaining()` +- Private methods/attrs: `_underscore` — `_read8()`, `_analogPin` +- Constants: `UPPER_SNAKE_CASE`, defined at top of main module file + +### I2C Auto-Detection +```python +from os import uname +if uname().sysname in ("esp32", "esp8266", "Soldered Dasduino CONNECTPLUS"): + self.i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +``` +Default I2C pins: **SCL = 22, SDA = 21** (ESP32). + +### ADC +- ESP32: `ADC_MAX = 4095` (12-bit), use `atten(ADC.ATTN_11DB)` +- Others / ATtiny on easyC board: `ADC_MAX = 1023` (10-bit) +- Detect via `uname().sysname` + +### Error Handling +```python +# Init failures → raise Exception +raise Exception("Sensor init failed! Check wiring.") + +# I2C errors → raise with context +except OSError as e: + raise Exception("I2C read error: {}".format(e)) + +# Non-critical ops → return bool +return True / False +``` + +### Docstrings (Google-style with :param) +```python +def setThreshold(self, threshold: float) -> bool: + """ + Set detection threshold as percentage. + + :param threshold: float, threshold 0.0-100.0 + :returns: bool, True on success, False if out of range + """ +``` + +--- + +## Dual-Mode Pattern (Native + I2C) + +Most descriptive-name sensors support both native GPIO/ADC and I2C (easyC) modes. + +- **easyC = I2C** — always call it I2C in this repo, not easyC +- Default I2C address: `0x30`, selectable `0x30–0x37` via onboard switches +- I2C read raw: `readfrom(addr, 2)` → `(data[1] << 8) | data[0]` (little-endian 16-bit) +- No Qwiic inheritance needed for sensors with their own base class — implement I2C directly + +--- + +## package.json Format +```json +{ + "urls": [ + ["ModuleName.py", "github:SolderedElectronics/Soldered-MicroPython-Modules/Category/ModuleName/ModuleName/ModuleName.py"], + ["Examples/ModuleName-featureNative.py", "github:..."] + ], + "deps": [], + "version": "1.0" +} +``` + +## README.md Format (installation only) +```markdown +# How to install + +After [**installing the mpremote package**](...): + + mpremote mip install github:SolderedElectronics/Soldered-MicroPython-Modules/Category/ModuleName + +Or Windows: + + python -m mpremote mip install github:SolderedElectronics/Soldered-MicroPython-Modules/Category/ModuleName +``` + +--- + +## When Porting an Arduino Library + +1. Ask 5+ clarifying questions first (category, modes, features to include, naming, examples) +2. Study the Arduino source — fetch all .h and .cpp files before writing anything +3. Understand the I2C protocol byte layout from the source before guessing +4. Plan the full file structure and get approval before writing code +5. Write: main class + subclasses + constants (in main file) + examples + package.json + README.md +6. Add module to root `README.md` alphabetically in the correct category section +7. Thonny users need: base .py file + specific subclass .py — mention this + +--- + +## Root README.md + +Add new modules alphabetically under the correct section in `/README.md`. +Format: `- [ModuleName](Category/ModuleName/)` diff --git a/Communication/Inputronic-BRIDGE/Inputronic-BRIDGE/Examples/interruptEvents.py b/Communication/Inputronic-BRIDGE/Inputronic-BRIDGE/Examples/interruptEvents.py new file mode 100644 index 0000000..973ad04 --- /dev/null +++ b/Communication/Inputronic-BRIDGE/Inputronic-BRIDGE/Examples/interruptEvents.py @@ -0,0 +1,80 @@ +# FILE: interruptEvents.py +# AUTHOR: Josip Šimun Kuči @ Soldered +# BRIEF: Interrupt-driven event reading from the Inputronic BRIDGE. +# The BRIDGE firmware pulses the interrupt pin when new HID data +# is available. The driver skips bus transactions when quiet, +# saving CPU and bus bandwidth. +# Uncomment the protocol block that matches your wiring. +# WORKS WITH: Inputronic BRIDGE: www.soldered.com +# LAST UPDATED: 2026-04-30 + +from machine import I2C, SPI, UART, Pin +from InputronicBridge import InputronicBridge, PROTOCOL_I2C, PROTOCOL_UART, PROTOCOL_SPI +import time + +# Pin on this MCU connected to the BRIDGE interrupt output. +interruptPin = Pin(5, Pin.IN, Pin.PULL_UP) + +# Optional flag set by the user callback and checked in the main loop. +newDataFlag = False + + +def onBridgeDataReady(): + global newDataFlag + newDataFlag = True + + +# ---- I2C ---- +i2c = I2C(0, scl=Pin(9), sda=Pin(8)) +bridge = InputronicBridge( + PROTOCOL_I2C, + i2c=i2c, + i2cAddr=0x50, + interruptPin=interruptPin, + activeHigh=False, # falling edge + interruptCallback=onBridgeDataReady, +) + +# ---- UART ---- +# uart = UART(1, baudrate=115200, tx=Pin(12), rx=Pin(10)) +# bridge = InputronicBridge( +# PROTOCOL_UART, uart=uart, +# interruptPin=interruptPin, activeHigh=False, +# interruptCallback=onBridgeDataReady, +# ) + +# ---- SPI ---- +# spi = SPI(1, baudrate=1000000, polarity=0, phase=0, sck=Pin(18), mosi=Pin(23), miso=Pin(19)) +# cs = Pin(10, Pin.OUT, value=1) +# bridge = InputronicBridge( +# PROTOCOL_SPI, spi=spi, spiCs=cs, +# interruptPin=interruptPin, activeHigh=False, +# interruptCallback=onBridgeDataReady, +# ) + +print("BRIDGE connected — interrupt mode active.") + +while True: + # pollEvents() returns immediately with no bus traffic unless the + # interrupt flag has been set by the BRIDGE. + events = bridge.pollEvents() + + if events.keyboard.valid: + print("Keyboard: ", end="") + for i in range(events.keyboard.keyCount): + print(events.keyboard.keys[i], end="") + print() + + if events.mouse.valid: + m = events.mouse + print("Mouse X:{} Y:{} L:{} R:{} M:{} Scroll:{}".format( + m.x, m.y, int(m.btnLeft), int(m.btnRight), int(m.btnMiddle), m.scroll)) + + if events.midi.valid: + mid = events.midi + print("MIDI {:02X} {:02X} {:02X}".format(mid.b1, mid.b2, mid.b3)) + + if newDataFlag: + newDataFlag = False + # events above already contain the latest data; use this flag + # to trigger additional work on data arrival if needed. diff --git a/Communication/Inputronic-BRIDGE/Inputronic-BRIDGE/Examples/pollingEvents.py b/Communication/Inputronic-BRIDGE/Inputronic-BRIDGE/Examples/pollingEvents.py new file mode 100644 index 0000000..bce6d24 --- /dev/null +++ b/Communication/Inputronic-BRIDGE/Inputronic-BRIDGE/Examples/pollingEvents.py @@ -0,0 +1,42 @@ +# FILE: pollingEvents.py +# AUTHOR: Josip Šimun Kuči @ Soldered +# BRIEF: Poll keyboard, mouse, and MIDI events from the Inputronic BRIDGE. +# Uncomment the protocol block that matches your wiring. +# WORKS WITH: Inputronic BRIDGE: www.soldered.com +# LAST UPDATED: 2026-04-30 + +from machine import I2C, SPI, UART, Pin +from InputronicBridge import InputronicBridge, PROTOCOL_I2C, PROTOCOL_UART, PROTOCOL_SPI +import time + +# ---- I2C ---- +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +bridge = InputronicBridge(PROTOCOL_I2C, i2c=i2c, i2cAddr=0x50) + +# ---- UART ---- +# uart = UART(1, baudrate=115200, tx=Pin(15), rx=Pin(14)) +# bridge = InputronicBridge(PROTOCOL_UART, uart=uart) + +# ---- SPI ---- +# spi = SPI(1, baudrate=1000000, polarity=0, phase=0, sck=Pin(18), mosi=Pin(23), miso=Pin(19)) +# cs = Pin(5, Pin.OUT, value=1) +# bridge = InputronicBridge(PROTOCOL_SPI, spi=spi, spiCs=cs) + +print("BRIDGE connected.") + +while True: + events = bridge.pollEvents() + + if events.keyboard.valid: + for i in range(events.keyboard.keyCount): + print(events.keyboard.keys[i], end="") + print() + + if events.mouse.valid: + m = events.mouse + print("Mouse X:{} Y:{} L:{} R:{} Scroll:{}".format( + m.x, m.y, int(m.btnLeft), int(m.btnRight), m.scroll)) + + if events.midi.valid: + mid = events.midi + print("MIDI {:02X} {:02X} {:02X}".format(mid.b1, mid.b2, mid.b3)) diff --git a/Communication/Inputronic-BRIDGE/Inputronic-BRIDGE/Examples/rawHIDReadings.py b/Communication/Inputronic-BRIDGE/Inputronic-BRIDGE/Examples/rawHIDReadings.py new file mode 100644 index 0000000..c6b8e43 --- /dev/null +++ b/Communication/Inputronic-BRIDGE/Inputronic-BRIDGE/Examples/rawHIDReadings.py @@ -0,0 +1,37 @@ +# FILE: rawHIDReadings.py +# AUTHOR: Josip Šimun Kuči @ Soldered +# BRIEF: Read raw HID reports from the Inputronic BRIDGE. +# Enables continuous raw HID polling and prints each report's hex payload. +# Uncomment the protocol block that matches your wiring. +# WORKS WITH: Inputronic BRIDGE: www.soldered.com +# LAST UPDATED: 2026-04-30 + +from machine import I2C, SPI, UART, Pin +from InputronicBridge import InputronicBridge, PROTOCOL_I2C, PROTOCOL_UART, PROTOCOL_SPI +import time + +# ---- I2C ---- +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +bridge = InputronicBridge(PROTOCOL_I2C, i2c=i2c, i2cAddr=0x50) + +# ---- UART ---- +# uart = UART(1, baudrate=115200, tx=Pin(15), rx=Pin(14)) +# bridge = InputronicBridge(PROTOCOL_UART, uart=uart) + +# ---- SPI ---- +# spi = SPI(1, baudrate=1000000, polarity=0, phase=0, sck=Pin(18), mosi=Pin(23), miso=Pin(19)) +# cs = Pin(5, Pin.OUT, value=1) +# bridge = InputronicBridge(PROTOCOL_SPI, spi=spi, spiCs=cs) + +print("BRIDGE connected.") + +# Push raw HID bytes on every poll. +bridge.setHidRawPolling(True) + +while True: + events = bridge.pollEvents() + + if events.hidRaw.valid: + print("HID RAW HEX:", events.hidRaw.hex) + + time.sleep_ms(30) diff --git a/Communication/Inputronic-BRIDGE/Inputronic-BRIDGE/InputronicBridge.py b/Communication/Inputronic-BRIDGE/Inputronic-BRIDGE/InputronicBridge.py new file mode 100644 index 0000000..c74aa0b --- /dev/null +++ b/Communication/Inputronic-BRIDGE/Inputronic-BRIDGE/InputronicBridge.py @@ -0,0 +1,602 @@ +# FILE: InputronicBridge.py +# AUTHOR: Josip Šimun Kuči @ Soldered +# BRIEF: MicroPython driver for the Soldered Inputronic BRIDGE +# Supports UART, I2C, and SPI transports. +# Parses keyboard, mouse, MIDI, descriptor, and raw HID events. +# WORKS WITH: Inputronic BRIDGE: www.soldered.com +# LAST UPDATED: 2026-04-30 + +import time +from machine import I2C, SPI, UART, Pin + +# Protocol constants +PROTOCOL_UART = 0 +PROTOCOL_I2C = 1 +PROTOCOL_SPI = 2 + +_SPI_MAX_LEN = 64 +_I2C_MAX_LEN = 48 +_UART_BUF_MAX = 512 +_SPI_BUF_MAX = 256 + + +class KeyboardEvent: + def __init__(self): + self.payload = "" + self.key = 0 + self.keys = [""] * 8 + self.keyCount = 0 + self.valid = False + + +class MouseEvent: + def __init__(self): + self.x = 0 + self.y = 0 + self.scroll = 0 + self.btnLeft = False + self.btnRight = False + self.btnMiddle = False + self.btnBackward = False + self.btnForward = False + self.btnScrollWheel = False + self.valid = False + + +class MIDIEvent: + def __init__(self): + self.b1 = 0 + self.b2 = 0 + self.b3 = 0 + self.valid = False + + +class DescriptorEvent: + def __init__(self): + self.hex = "" + self.valid = False + + +class HidRawEvent: + def __init__(self): + self.hex = "" + self.valid = False + + +class EventBundle: + def __init__(self): + self.keyboard = KeyboardEvent() + self.mouse = MouseEvent() + self.midi = MIDIEvent() + self.descriptor = DescriptorEvent() + self.hidRaw = HidRawEvent() + + +class InputronicBridge: + """ + MicroPython driver for the Soldered Inputronic BRIDGE. + + Supports UART, I2C, and SPI transports. + + Usage (I2C example): + from machine import I2C, Pin + from InputronicBridge import InputronicBridge, PROTOCOL_I2C + + i2c = I2C(0, scl=Pin(22), sda=Pin(21)) + bridge = InputronicBridge(PROTOCOL_I2C, i2c=i2c) + while True: + events = bridge.pollEvents() + if events.keyboard.valid: + print(events.keyboard.payload) + """ + + def __init__( + self, + protocol, + i2c=None, + i2cAddr=0x50, + uart=None, + spi=None, + spiCs=None, + spiHz=1000000, + interruptPin=None, + activeHigh=True, + interruptCallback=None, + ): + """ + Initialize the Inputronic BRIDGE. + + :param protocol: PROTOCOL_UART, PROTOCOL_I2C, or PROTOCOL_SPI + :param i2c: Initialized I2C object (PROTOCOL_I2C) + :param i2cAddr: I2C slave address (default 0x50) + :param uart: Initialized UART object (PROTOCOL_UART) + :param spi: Initialized SPI object (PROTOCOL_SPI) + :param spiCs: Pin object for SPI chip-select (PROTOCOL_SPI) + :param spiHz: SPI clock frequency in Hz (default 1 MHz) + :param interruptPin: Pin object for interrupt input (optional) + :param activeHigh: True = interrupt on rising edge, False = falling + :param interruptCallback: Callable invoked from IRQ context on data ready + """ + self._protocol = protocol + self._i2c = i2c + self._i2cAddr = i2cAddr + self._uart = uart + self._spi = spi + self._spiCs = spiCs + self._spiHz = spiHz + + self._interruptPin = interruptPin + self._activeHigh = activeHigh + self._interruptFlag = False + self._interruptEnabled = interruptPin is not None + self._userCallback = interruptCallback + + self._latest = EventBundle() + + self._requestDescPending = False + self._requestHidRawPending = False + self._pollHidRawEnabled = False + self._expectingHidRawOnly = False + + self._uartBuffer = "" + self._spiBuffer = "" + self._spiFrameStartMs = 0 + self._spiPendingAck = False + self._spiPendingCommand = "" + + if self._interruptEnabled: + self._configureInterrupt(interruptPin, activeHigh) + + if not self._checkConnection(): + raise Exception("Inputronic BRIDGE not found — check wiring and protocol") + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def configureI2cAddress(self, addr): + """Change the I2C slave address.""" + self._i2cAddr = addr + + def requestDescriptor(self): + """Request the USB descriptor on the next poll.""" + self._requestDescPending = True + + def requestHidRawOnce(self): + """Request one raw HID report on the next poll.""" + self._requestHidRawPending = True + self._expectingHidRawOnly = True + + def setHidRawPolling(self, enabled): + """Enable or disable continuous raw HID polling.""" + self._pollHidRawEnabled = enabled + self._expectingHidRawOnly = False + + def setInterruptMode(self, enable, pin=None, activeHigh=True): + """Enable or disable interrupt-driven polling.""" + self._interruptEnabled = enable + if enable and pin is not None: + self._interruptPin = pin + self._configureInterrupt(pin, activeHigh) + elif not enable and self._interruptPin is not None: + self._interruptPin.irq(handler=None) + + def onDataReady(self, callback): + """Register a callback invoked from IRQ context when the BRIDGE signals data.""" + self._userCallback = callback + + def feedLine(self, line): + """Feed a raw framed line directly into the parser.""" + self._parseMessage(line) + + def pollEvents(self): + """ + Poll the transport for new events. + + Returns an EventBundle. Check the .valid field on each sub-event before use. + In interrupt mode the method returns immediately with no bus traffic unless + the interrupt flag is set. + """ + if self._protocol == PROTOCOL_UART: + self._pollUart() + elif self._protocol == PROTOCOL_I2C: + self._pollI2c() + elif self._protocol == PROTOCOL_SPI: + self._pollSpiTransport() + + out = self._latest + self._latest = EventBundle() + return out + + # ------------------------------------------------------------------ + # Connection check + # ------------------------------------------------------------------ + + def _checkConnection(self): + if self._protocol == PROTOCOL_I2C: + return self._pingI2c() + elif self._protocol == PROTOCOL_SPI: + return self._pingSpi() + elif self._protocol == PROTOCOL_UART: + return self._pingUart() + return False + + def _pingI2c(self): + if self._i2c is None: + return False + for _ in range(10): + try: + self._i2c.writeto(self._i2cAddr, b"PING") + except OSError: + time.sleep_ms(50) + continue + for _ in range(10): + time.sleep_ms(5) + try: + raw = self._i2c.readfrom(self._i2cAddr, _I2C_MAX_LEN) + except OSError: + continue + if len(raw) > 1: + payloadLen = raw[0] + if 0 < payloadLen < _I2C_MAX_LEN and payloadLen <= len(raw) - 1: + msg = bytes(raw[1 : payloadLen + 1]).decode("utf-8", "ignore").strip() + if msg == "TS;PONG;TE": + try: + self._i2c.writeto(self._i2cAddr, b"ACK") + except OSError: + pass + return True + return False + + def _pingSpi(self): + if self._spi is None or self._spiCs is None: + return False + tx = bytearray(_SPI_MAX_LEN) + rx = bytearray(_SPI_MAX_LEN) + tx[0:4] = b"PING" + self._spiCs(0) + self._spi.write_readinto(tx, rx) + self._spiCs(1) + time.sleep_ms(50) + tx = bytearray(_SPI_MAX_LEN) + rx = bytearray(_SPI_MAX_LEN) + self._spiCs(0) + self._spi.write_readinto(tx, rx) + self._spiCs(1) + payloadLen = rx[0] + if 0 < payloadLen < _SPI_MAX_LEN: + msg = bytes(rx[1 : payloadLen + 1]).decode("utf-8", "ignore") + if msg == "TS;PONG;TE": + tx = bytearray(_SPI_MAX_LEN) + tx[0:3] = b"ACK" + rx = bytearray(_SPI_MAX_LEN) + self._spiCs(0) + self._spi.write_readinto(tx, rx) + self._spiCs(1) + return True + return False + + def _pingUart(self): + if self._uart is None: + return False + self._uart.write(b"PING\n") + deadline = time.ticks_add(time.ticks_ms(), 500) + buf = "" + while time.ticks_diff(deadline, time.ticks_ms()) > 0: + while self._uart.any(): + b = self._uart.read(1) + if b: + buf += b.decode("utf-8", "ignore") + if "TS;PONG;TE" in buf: + return True + time.sleep_ms(10) + return False + + # ------------------------------------------------------------------ + # Interrupt + # ------------------------------------------------------------------ + + def _configureInterrupt(self, pin, activeHigh): + trigger = Pin.IRQ_RISING if activeHigh else Pin.IRQ_FALLING + pin.irq(trigger=trigger, handler=self._isrHandler) + + def _isrHandler(self, pin): + self._interruptFlag = True + if self._userCallback is not None: + self._userCallback() + + # ------------------------------------------------------------------ + # Transport pollers + # ------------------------------------------------------------------ + + def _pollUart(self): + if self._uart is None: + return + + flag = self._interruptFlag + if flag: + self._interruptFlag = False + + if self._interruptEnabled and not flag: + return + + while self._uart.any(): + b = self._uart.read(1) + if b: + self._uartBuffer += b.decode("utf-8", "ignore") + + if len(self._uartBuffer) > _UART_BUF_MAX: + idx = self._uartBuffer.rfind("TS;") + if idx >= 0: + self._uartBuffer = self._uartBuffer[idx:] + else: + self._uartBuffer = "" + + while True: + tePos = self._uartBuffer.find(";TE") + if tePos == -1: + break + fullMsg = self._uartBuffer[: tePos + 3] + self.feedLine(fullMsg) + self._uartBuffer = self._uartBuffer[tePos + 3:].lstrip() + + def _pollI2c(self): + if self._i2c is None: + return + + flag = self._interruptFlag + if flag: + self._interruptFlag = False + + if self._interruptEnabled and not flag and not self._requestDescPending and not self._requestHidRawPending: + return + + if not self._interruptEnabled: + if self._requestDescPending: + self._sendI2cCommand(b"REQ:DESC") + self._requestDescPending = False + if self._requestHidRawPending or self._pollHidRawEnabled: + self._sendI2cCommand(b"REQ:HIDRAW") + self._requestHidRawPending = False + + try: + raw = self._i2c.readfrom(self._i2cAddr, _I2C_MAX_LEN) + except OSError: + return + + shouldAck = False + if len(raw) > 1: + payloadLen = raw[0] + if payloadLen > 0: + shouldAck = True + if 0 < payloadLen < _I2C_MAX_LEN and payloadLen <= len(raw) - 1: + msg = bytes(raw[1 : payloadLen + 1]).decode("utf-8", "ignore").strip() + if msg.startswith("TS;") and msg.endswith(";TE"): + self.feedLine(msg) + + if shouldAck: + self._sendI2cCommand(b"ACK") + + def _pollSpiTransport(self): + if self._spi is None or self._spiCs is None: + return + + flag = self._interruptFlag + if flag: + self._interruptFlag = False + + if self._interruptEnabled and not flag and not self._requestDescPending and not self._requestHidRawPending: + return + + if self._interruptEnabled and flag: + time.sleep_us(50) + + if not self._interruptEnabled: + if self._requestDescPending: + self._spiPendingCommand = "REQ:DESC" + self._requestDescPending = False + if self._requestHidRawPending or self._pollHidRawEnabled: + self._spiPendingCommand = "REQ:HIDRAW" + self._requestHidRawPending = False + + self._doSpiPoll() + + def _doSpiPoll(self): + tx = bytearray(_SPI_MAX_LEN) + rx = bytearray(_SPI_MAX_LEN) + + if self._spiPendingAck: + tx[0:3] = b"ACK" + self._spiPendingAck = False + elif self._spiPendingCommand: + cmd = self._spiPendingCommand.encode() + tx[: len(cmd)] = cmd + self._spiPendingCommand = "" + + self._spiCs(0) + self._spi.write_readinto(tx, rx) + self._spiCs(1) + + payloadLen = rx[0] + if 0 < payloadLen < _SPI_MAX_LEN: + chunk = bytes(rx[1 : payloadLen + 1]).decode("utf-8", "ignore") + self._spiPendingAck = True + self._spiBuffer += chunk + + if len(self._spiBuffer) > _SPI_BUF_MAX: + idx = self._spiBuffer.rfind("TS;") + if idx >= 0: + self._spiBuffer = self._spiBuffer[idx:] + else: + self._spiBuffer = "" + + while True: + tsPos = self._spiBuffer.find("TS;") + if tsPos < 0: + self._spiBuffer = "" + self._spiFrameStartMs = 0 + break + if tsPos > 0: + self._spiBuffer = self._spiBuffer[tsPos:] + if self._spiFrameStartMs == 0: + self._spiFrameStartMs = time.ticks_ms() + tePos = self._spiBuffer.find(";TE") + if tePos < 0: + if self._spiFrameStartMs and time.ticks_diff(time.ticks_ms(), self._spiFrameStartMs) > 30: + self._spiBuffer = self._spiBuffer[3:] + self._spiFrameStartMs = 0 + continue + break + fullMsg = self._spiBuffer[: tePos + 3] + self.feedLine(fullMsg) + self._spiBuffer = self._spiBuffer[tePos + 3:] + self._spiFrameStartMs = 0 + + # ------------------------------------------------------------------ + # I2C command helper + # ------------------------------------------------------------------ + + def _sendI2cCommand(self, cmd): + try: + self._i2c.writeto(self._i2cAddr, cmd) + except OSError: + pass + + # ------------------------------------------------------------------ + # Message parser + # ------------------------------------------------------------------ + + def _parseMessage(self, msgIn): + msg = msgIn.strip() + if "TS;MIDI;" in msg: + self._parseMidi(msg) + elif not self._expectingHidRawOnly and msg.startswith("TS;M;"): + self._parseMouse(msg) + elif not self._expectingHidRawOnly and msg.startswith("TS;K;"): + self._parseKeyboard(msg) + elif "TS;DESC;" in msg: + self._parseDescriptor(msg) + elif "TS;HIDRAW;" in msg: + self._parseHidRaw(msg) + self._expectingHidRawOnly = False + + def _parseKeyboard(self, msg): + kPos = msg.find(";K;") + end = msg.find(";TE") + if kPos == -1 or end == -1 or end <= kPos: + return + keyStr = msg[kPos + 3 : end].replace("\\;", ";") + kb = self._latest.keyboard + kb.payload = keyStr + kb.keyCount = 0 + kb.keys = [""] * 8 + kb.key = keyStr[0] if len(keyStr) == 1 else 0 + + i = 0 + while i < len(keyStr) and kb.keyCount < 8: + if keyStr[i] == "<": + close = keyStr.find(">", i + 1) + if close > i: + kb.keys[kb.keyCount] = keyStr[i : close + 1] + kb.keyCount += 1 + i = close + 1 + continue + kb.keys[kb.keyCount] = keyStr[i] + kb.keyCount += 1 + i += 1 + + if kb.keyCount == 0 and len(keyStr) == 1: + kb.keys[0] = keyStr + kb.keyCount = 1 + + kb.valid = True + + def _parseMidi(self, msgIn): + start = msgIn.find("TS;MIDI;") + if start < 0: + return + payload = msgIn[start + 8:] + tePos = payload.find(";TE") + if tePos >= 0: + payload = payload[:tePos] + + pipe = payload.find("|") + first = payload[:pipe] if pipe >= 0 else payload + + parts = [] + for token in first.split(";"): + if token: + try: + parts.append(int(token, 16)) + except ValueError: + pass + + if len(parts) == 3: + self._latest.midi.b1 = parts[0] + self._latest.midi.b2 = parts[1] + self._latest.midi.b3 = parts[2] + self._latest.midi.valid = True + + def _parseMouse(self, msgIn): + msg = msgIn.strip() + mPos = msg.find(";M;") + end = msg.find(";TE") + if mPos == -1 or end == -1 or end <= mPos: + return + body = msg[mPos + 3 : end] + + vals = [] + val = 0 + neg = False + inNum = False + for ch in body: + if ch == "-": + neg = True + val = 0 + inNum = True + elif "0" <= ch <= "9": + val = val * 10 + (ord(ch) - ord("0")) + inNum = True + elif ch == ";": + if inNum: + vals.append(-val if neg else val) + val = 0 + neg = False + inNum = False + if inNum: + vals.append(-val if neg else val) + + if len(vals) >= 9: + m = self._latest.mouse + m.x = vals[0] + m.y = vals[1] + m.scroll = vals[2] + m.btnLeft = bool(vals[3]) + m.btnRight = bool(vals[4]) + m.btnMiddle = bool(vals[5]) + m.btnBackward = bool(vals[6]) + m.btnForward = bool(vals[7]) + m.btnScrollWheel = bool(vals[8]) + m.valid = True + + def _parseDescriptor(self, msg): + dPos = msg.find(";DESC;") + end = msg.find(";TE") + if dPos == -1 or end == -1 or end <= dPos: + return + start = dPos + 6 + if end > start: + self._latest.descriptor.hex = msg[start:end] + self._latest.descriptor.valid = True + + def _parseHidRaw(self, msg): + hPos = msg.find(";HIDRAW;") + end = msg.find(";TE") + if hPos == -1 or end == -1 or end <= hPos: + return + start = hPos + 8 + if end > start: + hexStr = msg[start:end] + if hexStr: + self._latest.hidRaw.hex = hexStr + self._latest.hidRaw.valid = True diff --git a/Communication/Inputronic-BRIDGE/package.json b/Communication/Inputronic-BRIDGE/package.json new file mode 100644 index 0000000..edb3211 --- /dev/null +++ b/Communication/Inputronic-BRIDGE/package.json @@ -0,0 +1,22 @@ +{ + "urls": [ + [ + "InputronicBridge.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/Inputronic-BRIDGE/Inputronic-BRIDGE/InputronicBridge.py" + ], + [ + "Examples/pollingEvents.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/Inputronic-BRIDGE/Inputronic-BRIDGE/Examples/pollingEvents.py" + ], + [ + "Examples/interruptEvents.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/Inputronic-BRIDGE/Inputronic-BRIDGE/Examples/interruptEvents.py" + ], + [ + "Examples/rawHIDReadings.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/Inputronic-BRIDGE/Inputronic-BRIDGE/Examples/rawHIDReadings.py" + ] + ], + "deps": [], + "version": "1.0" +} diff --git a/Communication/Inputronic-GRID/Inputronic-GRID/Examples/ButtonLEDControl.py b/Communication/Inputronic-GRID/Inputronic-GRID/Examples/ButtonLEDControl.py new file mode 100644 index 0000000..149773b --- /dev/null +++ b/Communication/Inputronic-GRID/Inputronic-GRID/Examples/ButtonLEDControl.py @@ -0,0 +1,57 @@ +# FILE: ButtonLEDControl.py +# AUTHOR: Josip Šimun Kuči @ Soldered +# BRIEF: Example showing button-driven LED toggle on the Inputronic GRID. +# Each button toggles its corresponding LED on or off. +# First press turns the LED on in a row-specific color; +# second press turns it off. +# Demonstrates reading individual pads by row/col coordinate +# and reacting with per-LED color control using the same coordinates. +# WORKS WITH: Inputronic GRID: www.soldered.com +# LAST UPDATED: 2026-05-12 + +from machine import I2C, Pin +from InputronicGrid import InputronicGrid +import time + +# If you aren't using the Qwiic connector, manually enter your I2C pins: +# i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +# grid = InputronicGrid(i2c=i2c) + +grid = InputronicGrid() +grid.clearLEDs() +print("Press any button to toggle its LED.") + +# Toggle state per button, indexed by [row][col] +ledOn = [[False] * 4 for _ in range(4)] + +# Colors per row (R, G, B) +rowColor = [ + (255, 0, 0), # row 0 — red + ( 0, 255, 0), # row 1 — green + ( 0, 0, 255), # row 2 — blue + (255, 128, 0), # row 3 — orange +] + +# Previous pad state per [row][col] for edge detection +prevState = [[False] * 4 for _ in range(4)] + +while True: + for row in range(4): + for col in range(4): + pressed = grid.readPad(row, col) + + if pressed and not prevState[row][col]: + ledOn[row][col] = not ledOn[row][col] + + if ledOn[row][col]: + r, g, b = rowColor[row] + grid.setLED(row, col, r, g, b, 255) + else: + grid.setLED(row, col, 0, 0, 0, 255) + + print("Button (row {}, col {}) → LED {}".format( + row, col, "ON" if ledOn[row][col] else "OFF")) + + prevState[row][col] = pressed + + time.sleep_ms(20) diff --git a/Communication/Inputronic-GRID/Inputronic-GRID/Examples/ChangeAddress.py b/Communication/Inputronic-GRID/Inputronic-GRID/Examples/ChangeAddress.py new file mode 100644 index 0000000..2131249 --- /dev/null +++ b/Communication/Inputronic-GRID/Inputronic-GRID/Examples/ChangeAddress.py @@ -0,0 +1,41 @@ +# FILE: ChangeAddress.py +# AUTHOR: Josip Šimun Kuči @ Soldered +# BRIEF: Example showing how to change the I2C address of the Inputronic GRID. +# Sends a new I2C address to the device. The firmware validates the address, +# stores it in EEPROM, and re-initializes on the new address — no reset required. +# The address is retained across power cycles. +# To restore the default address (0x30), call setAddress(0x30) while +# connected on whatever address the device currently uses. +# WORKS WITH: Inputronic GRID: www.soldered.com +# LAST UPDATED: 2026-05-12 + +from machine import I2C, Pin +from InputronicGrid import InputronicGrid +import time + +# ── Configuration ───────────────────────────────────────────────────────────── +# Current address the device is on (change if you already moved it) +CURRENT_ADDRESS = 0x30 + +# New address to assign (must be a valid 7-bit I2C address: 0x08-0x77) +NEW_ADDRESS = 0x31 +# ───────────────────────────────────────────────────────────────────────────── + +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +grid = InputronicGrid(i2c=i2c, address=CURRENT_ADDRESS) + +print("Device found at 0x{:02X}. Changing address to 0x{:02X} ...".format( + CURRENT_ADDRESS, NEW_ADDRESS)) + +grid.setAddress(NEW_ADDRESS) + +# Give the firmware time to apply the change +time.sleep_ms(50) + +# Probe device on new address +try: + i2c.writeto(NEW_ADDRESS, b"") + print("Success! Device now at 0x{:02X}".format(NEW_ADDRESS)) + grid.clearLEDs() +except OSError: + print("Device not found on new address. Re-flash firmware and try again.") diff --git a/Communication/Inputronic-GRID/Inputronic-GRID/Examples/ReadButtons.py b/Communication/Inputronic-GRID/Inputronic-GRID/Examples/ReadButtons.py new file mode 100644 index 0000000..f61eacf --- /dev/null +++ b/Communication/Inputronic-GRID/Inputronic-GRID/Examples/ReadButtons.py @@ -0,0 +1,31 @@ +# FILE: ReadButtons.py +# AUTHOR: Josip Šimun Kuči @ Soldered +# BRIEF: Example showing how to read all 16 buttons on the Inputronic GRID. +# Reads all 16 button states every 50 ms and prints a 4x4 grid map. +# Pressed buttons show 'X'; released buttons show '.'. +# WORKS WITH: Inputronic GRID: www.soldered.com +# LAST UPDATED: 2026-05-12 + +from machine import I2C, Pin +from InputronicGrid import InputronicGrid +import time + +# If you aren't using the Qwiic connector, manually enter your I2C pins: +# i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +# grid = InputronicGrid(i2c=i2c) + +grid = InputronicGrid() + +print("Inputronic GRID ready.\n") +grid.clearLEDs() + +while True: + print("+---------+") + for row in range(4): + print("| ", end="") + for col in range(4): + print("X " if grid.readPad(row, col) else ". ", end="") + print("|") + print("+---------+\n") + + time.sleep_ms(50) diff --git a/Communication/Inputronic-GRID/Inputronic-GRID/Examples/SetLEDs.py b/Communication/Inputronic-GRID/Inputronic-GRID/Examples/SetLEDs.py new file mode 100644 index 0000000..90effae --- /dev/null +++ b/Communication/Inputronic-GRID/Inputronic-GRID/Examples/SetLEDs.py @@ -0,0 +1,39 @@ +# FILE: SetLEDs.py +# AUTHOR: Josip Šimun Kuči @ Soldered +# BRIEF: Example showing LED control on the Inputronic GRID. +# Demonstrates three LED control methods in sequence: +# 1. Sweep each LED individually through red, green, and blue. +# 2. Fill all 16 LEDs with a single color (solid fills). +# 3. Animate a color rotation across all 16 LEDs using a bitmask. +# WORKS WITH: Inputronic GRID: www.soldered.com +# LAST UPDATED: 2026-05-12 + +from machine import I2C, Pin +from InputronicGrid import InputronicGrid +import time + +# If you aren't using the Qwiic connector, manually enter your I2C pins: +# i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +# grid = InputronicGrid(i2c=i2c) + +grid = InputronicGrid() +grid.clearLEDs() + +while True: + # ── 1. Sweep individual LEDs ────────────────────────────────────────── + for row in range(4): + for col in range(4): + grid.setLED(row, col, 255, 0, 0, 255) # red + time.sleep_ms(80) + grid.setLED(row, col, 0, 255, 0, 255) # green + time.sleep_ms(80) + grid.setLED(row, col, 0, 0, 255, 255) # blue + time.sleep_ms(80) + grid.setLED(row, col, 0, 0, 0, 255) # off + + # ── 2. Solid fills ──────────────────────────────────────────────────── + grid.setAllLEDs(255, 0, 0 ); time.sleep_ms(400) # red + grid.setAllLEDs(0, 255, 0 ); time.sleep_ms(400) # green + grid.setAllLEDs(0, 0, 255); time.sleep_ms(400) # blue + grid.setAllLEDs(255, 255, 255); time.sleep_ms(400) # white + grid.clearLEDs(); time.sleep_ms(300) diff --git a/Communication/Inputronic-GRID/Inputronic-GRID/InputronicGrid.py b/Communication/Inputronic-GRID/Inputronic-GRID/InputronicGrid.py new file mode 100644 index 0000000..ed23b5e --- /dev/null +++ b/Communication/Inputronic-GRID/Inputronic-GRID/InputronicGrid.py @@ -0,0 +1,201 @@ +# FILE: InputronicGrid.py +# AUTHOR: Josip Šimun Kuči @ Soldered +# BRIEF: MicroPython driver for the Soldered Inputronic GRID 4x4 button+LED pad. +# Communicates over I2C with the ATtiny firmware on the device. +# WORKS WITH: Inputronic GRID: www.soldered.com +# LAST UPDATED: 2026-05-12 + +from machine import I2C, Pin +from os import uname + +INPUTRONIC_GRID_DEFAULT_ADDR = 0x30 + +_CMD_SET_LED = 0x01 # Set one LED: [ledIdx, R, G, B] +_CMD_SET_ALL_LEDS = 0x02 # Set all LEDs to same color: [R, G, B] +_CMD_GET_BUTTON = 0x03 # Query single button state: [buttonIdx] +_CMD_GET_ALL_PADS = 0x04 # Query all button states +_CMD_SET_ADDR = 0x05 # Write new I2C address to EEPROM: [newAddr] +_CMD_SET_LED_MASK = 0x06 # Set LEDs by bitmask: [maskHi, maskLo, R, G, B] + + +class InputronicGrid: + """ + MicroPython driver for the Soldered Inputronic GRID 4x4 button+LED pad. + + Usage: + from machine import I2C, Pin + from InputronicGrid import InputronicGrid + + i2c = I2C(0, scl=Pin(22), sda=Pin(21)) + grid = InputronicGrid(i2c=i2c) + pressed = grid.readPad(0, 0) + grid.setLED(0, 0, 255, 0, 0) + """ + + def __init__(self, i2c=None, address=INPUTRONIC_GRID_DEFAULT_ADDR): + """ + Initialize the Inputronic GRID. + + :param i2c: Initialized I2C object (auto-detected on known boards if None) + :param address: I2C address of the device (default 0x30) + """ + if i2c is not None: + self._i2c = i2c + else: + if uname().sysname in ("esp32", "esp8266", "Soldered Dasduino CONNECTPLUS"): + self._i2c = I2C(0, scl=Pin(22), sda=Pin(21)) + else: + raise Exception("Board not recognized, pass an initialized I2C object") + + self._addr = address + + # Probe device + try: + self._i2c.writeto(self._addr, b"") + except OSError: + raise Exception("Inputronic GRID not found. Check wiring.") + + # ── Button reading ──────────────────────────────────────────────────────── + + def readPad(self, row, col): + """ + Read the state of a single pad by grid position. + + :param row: Row index, 0-3 (top to bottom) + :param col: Column index, 0-3 (left to right) + :return: True if pad is pressed + """ + return self.readButton(self._idx(row, col)) + + def readButton(self, index): + """ + Read the state of a single button by flat index (row-major: index = row*4 + col). + + :param index: Button index, 0-15 + :return: True if button is pressed + """ + if index >= 16: + return False + try: + self._i2c.writeto(self._addr, bytes([_CMD_GET_BUTTON, index])) + data = self._i2c.readfrom(self._addr, 1) + return data[0] != 0 + except OSError: + return False + + def readAllPads(self): + """ + Read the state of all 16 pads in one I2C transaction. + + :return: 16-bit bitmask of pressed buttons; bit i corresponds to button i + """ + try: + self._i2c.writeto(self._addr, bytes([_CMD_GET_ALL_PADS])) + data = self._i2c.readfrom(self._addr, 2) + return (data[0] << 8) | data[1] + except OSError: + return 0 + + # ── LED control ─────────────────────────────────────────────────────────── + + def setLED(self, row, col, r, g, b, intensity=128): + """ + Set one LED color by grid position. + + :param row: Row index, 0-3 + :param col: Column index, 0-3 + :param r: Red component (0-255) + :param g: Green component (0-255) + :param b: Blue component (0-255) + :param intensity: Brightness scale (0=off, 255=full, default 128) + """ + self.setLEDByIndex(self._ledIdx(row, col), r, g, b, intensity) + + def setLEDByIndex(self, index, r, g, b, intensity=128): + """ + Set one LED color by flat index. + + :param index: LED index, 0-15 + :param r: Red component (0-255) + :param g: Green component (0-255) + :param b: Blue component (0-255) + :param intensity: Brightness scale (0=off, 255=full, default 128) + """ + if index >= 16: + return + try: + self._i2c.writeto(self._addr, bytes([ + _CMD_SET_LED, + index, + self._scale(r, intensity), + self._scale(g, intensity), + self._scale(b, intensity), + ])) + except OSError: + pass + + def setAllLEDs(self, r, g, b): + """ + Set all 16 LEDs to the same color. + + :param r: Red component (0-255) + :param g: Green component (0-255) + :param b: Blue component (0-255) + """ + try: + self._i2c.writeto(self._addr, bytes([_CMD_SET_ALL_LEDS, r, g, b])) + except OSError: + pass + + def setLEDMask(self, mask, r, g, b): + """ + Set LEDs by bitmask. Only masked LEDs receive the color; others turn off. + + :param mask: 16-bit bitmask of LEDs to light (bit i = LED i) + :param r: Red component (0-255) + :param g: Green component (0-255) + :param b: Blue component (0-255) + """ + try: + self._i2c.writeto(self._addr, bytes([ + _CMD_SET_LED_MASK, + (mask >> 8) & 0xFF, + mask & 0xFF, + r, g, b, + ])) + except OSError: + pass + + def clearLEDs(self): + """Turn all LEDs off.""" + self.setAllLEDs(0, 0, 0) + + # ── Address change ──────────────────────────────────────────────────────── + + def setAddress(self, newAddr): + """ + Change the device I2C address, persist to EEPROM, and re-initialize. + Device is available on new address within ~1 ms. Ignored if newAddr + is outside valid 7-bit I2C range (0x08-0x77). + + :param newAddr: New I2C address (0x08-0x77) + """ + if newAddr < 0x08 or newAddr > 0x77: + return + try: + self._i2c.writeto(self._addr, bytes([_CMD_SET_ADDR, newAddr])) + except OSError: + pass + self._addr = newAddr + + # ── Private helpers ─────────────────────────────────────────────────────── + + def _idx(self, row, col): + return row * 4 + col + + def _ledIdx(self, row, col): + # Serpentine: even rows left→right, odd rows right→left + return (row * 4 + col) if (row % 2 == 0) else (row * 4 + (3 - col)) + + def _scale(self, value, intensity): + return (value * intensity) // 255 diff --git a/Communication/Inputronic-GRID/package.json b/Communication/Inputronic-GRID/package.json new file mode 100644 index 0000000..81a73e8 --- /dev/null +++ b/Communication/Inputronic-GRID/package.json @@ -0,0 +1,26 @@ +{ + "urls": [ + [ + "InputronicGrid.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/Inputronic-GRID/Inputronic-GRID/InputronicGrid.py" + ], + [ + "Examples/ReadButtons.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/Inputronic-GRID/Inputronic-GRID/Examples/ReadButtons.py" + ], + [ + "Examples/SetLEDs.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/Inputronic-GRID/Inputronic-GRID/Examples/SetLEDs.py" + ], + [ + "Examples/ButtonLEDControl.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/Inputronic-GRID/Inputronic-GRID/Examples/ButtonLEDControl.py" + ], + [ + "Examples/ChangeAddress.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/Inputronic-GRID/Inputronic-GRID/Examples/ChangeAddress.py" + ] + ], + "deps": [], + "version": "1.0" +} diff --git a/Communication/Inputronic-KEYBOARD/Inputronic-KEYBOARD/Examples/keyboard_poll.py b/Communication/Inputronic-KEYBOARD/Inputronic-KEYBOARD/Examples/keyboard_poll.py index cc150f2..ea85a43 100644 --- a/Communication/Inputronic-KEYBOARD/Inputronic-KEYBOARD/Examples/keyboard_poll.py +++ b/Communication/Inputronic-KEYBOARD/Inputronic-KEYBOARD/Examples/keyboard_poll.py @@ -6,7 +6,7 @@ from machine import I2C, Pin from os import uname -from inputronic_keyboard import InputronicKeyboard +from InputronicKeyboard import InputronicKeyboard def make_i2c(): diff --git a/Communication/Inputronic-KEYBOARD/Inputronic-KEYBOARD/Examples/oled_type.py b/Communication/Inputronic-KEYBOARD/Inputronic-KEYBOARD/Examples/oled_type.py index 7ef8981..dd38da2 100644 --- a/Communication/Inputronic-KEYBOARD/Inputronic-KEYBOARD/Examples/oled_type.py +++ b/Communication/Inputronic-KEYBOARD/Inputronic-KEYBOARD/Examples/oled_type.py @@ -3,7 +3,7 @@ # LAST UPDATED: 2026-02-11 import time -from inputronic_keyboard import InputronicKeyboard +from InputronicKeyboard import InputronicKeyboard from ssd1306 import SSD1306 SCREEN_WIDTH = 128 diff --git a/Communication/Inputronic-KEYBOARD/Inputronic-KEYBOARD/Examples/serial_type.py b/Communication/Inputronic-KEYBOARD/Inputronic-KEYBOARD/Examples/serial_type.py index be2c075..dfd413e 100644 --- a/Communication/Inputronic-KEYBOARD/Inputronic-KEYBOARD/Examples/serial_type.py +++ b/Communication/Inputronic-KEYBOARD/Inputronic-KEYBOARD/Examples/serial_type.py @@ -6,7 +6,7 @@ from machine import I2C, Pin from os import uname -from inputronic_keyboard import InputronicKeyboard +from InputronicKeyboard import InputronicKeyboard def make_i2c(): diff --git a/Communication/Inputronic-KEYBOARD/Inputronic-KEYBOARD/inputronic_keyboard.py b/Communication/Inputronic-KEYBOARD/Inputronic-KEYBOARD/InputronicKeyboard.py similarity index 99% rename from Communication/Inputronic-KEYBOARD/Inputronic-KEYBOARD/inputronic_keyboard.py rename to Communication/Inputronic-KEYBOARD/Inputronic-KEYBOARD/InputronicKeyboard.py index 187b603..f8b8109 100644 --- a/Communication/Inputronic-KEYBOARD/Inputronic-KEYBOARD/inputronic_keyboard.py +++ b/Communication/Inputronic-KEYBOARD/Inputronic-KEYBOARD/InputronicKeyboard.py @@ -1,4 +1,4 @@ -# FILE: inputronic_keyboard.py +# FILE: InputronicKeyboard.py # AUTHOR: Marko Toldi @ Soldered # BRIEF: MicroPython driver for Soldered Inputronic Keyboard (TI TCA8418) # LAST UPDATED: 2026-02-11 @@ -7,8 +7,8 @@ from machine import I2C, Pin from os import uname -from inputronic_keymap import INPUTRONIC_KEYMAP_UPPER, INPUTRONIC_KEYMAP_LOWER -from inputronic_shiftmap import inputronic_apply_shift +from InputronicKeymap import INPUTRONIC_KEYMAP_UPPER, INPUTRONIC_KEYMAP_LOWER +from InputronicShiftmap import inputronic_apply_shift # Default I2C address diff --git a/Communication/Inputronic-KEYBOARD/Inputronic-KEYBOARD/inputronic_keymap.py b/Communication/Inputronic-KEYBOARD/Inputronic-KEYBOARD/InputronicKeymap.py similarity index 98% rename from Communication/Inputronic-KEYBOARD/Inputronic-KEYBOARD/inputronic_keymap.py rename to Communication/Inputronic-KEYBOARD/Inputronic-KEYBOARD/InputronicKeymap.py index cbf7e62..8ab20cb 100644 --- a/Communication/Inputronic-KEYBOARD/Inputronic-KEYBOARD/inputronic_keymap.py +++ b/Communication/Inputronic-KEYBOARD/Inputronic-KEYBOARD/InputronicKeymap.py @@ -1,4 +1,4 @@ -# FILE: inputronic_keymap.py +# FILE: InputronicKeymap.py # AUTHOR: Marko Toldi @ Soldered # BRIEF: Default keymaps for Soldered Inputronic Keyboard (8x10) # LAST UPDATED: 2026-02-11 diff --git a/Communication/Inputronic-KEYBOARD/Inputronic-KEYBOARD/inputronic_shiftmap.py b/Communication/Inputronic-KEYBOARD/Inputronic-KEYBOARD/InputronicShiftmap.py similarity index 95% rename from Communication/Inputronic-KEYBOARD/Inputronic-KEYBOARD/inputronic_shiftmap.py rename to Communication/Inputronic-KEYBOARD/Inputronic-KEYBOARD/InputronicShiftmap.py index 39e70dc..ad682c9 100644 --- a/Communication/Inputronic-KEYBOARD/Inputronic-KEYBOARD/inputronic_shiftmap.py +++ b/Communication/Inputronic-KEYBOARD/Inputronic-KEYBOARD/InputronicShiftmap.py @@ -1,4 +1,4 @@ -# FILE: inputronic_shiftmap.py +# FILE: InputronicShiftmap.py # AUTHOR: Marko Toldi @ Soldered # BRIEF: SHIFT character mapping for Soldered Inputronic Keyboard # LAST UPDATED: 2026-02-11 diff --git a/Communication/Inputronic-KEYBOARD/package.json b/Communication/Inputronic-KEYBOARD/package.json index ca8848b..d047df0 100644 --- a/Communication/Inputronic-KEYBOARD/package.json +++ b/Communication/Inputronic-KEYBOARD/package.json @@ -1,16 +1,16 @@ { "urls": [ [ - "inputronic_keyboard.py", - "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/Inputronic-KEYBOARD/Inputronic-KEYBOARD/inputronic_keyboard.py" + "InputronicKeyboard.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/Inputronic-KEYBOARD/Inputronic-KEYBOARD/InputronicKeyboard.py" ], [ - "inputronic_keymap.py", - "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/Inputronic-KEYBOARD/Inputronic-KEYBOARD/inputronic_keymap.py" + "InputronicKeymap.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/Inputronic-KEYBOARD/Inputronic-KEYBOARD/InputronicKeymap.py" ], [ - "inputronic_shiftmap.py", - "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/Inputronic-KEYBOARD/Inputronic-KEYBOARD/inputronic_shiftmap.py" + "InputronicShiftmap.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/Inputronic-KEYBOARD/Inputronic-KEYBOARD/InputronicShiftmap.py" ], [ "Examples/keyboard_poll.py", @@ -27,4 +27,4 @@ ], "deps": [], "version": "1.0" -} \ No newline at end of file +} diff --git a/Communication/MCP2518/MCP2518/Examples/mcp2518fd-fd-recv-filt.py b/Communication/MCP2518/MCP2518/Examples/mcp2518fd-fd-recv-filt.py new file mode 100644 index 0000000..3b78866 --- /dev/null +++ b/Communication/MCP2518/MCP2518/Examples/mcp2518fd-fd-recv-filt.py @@ -0,0 +1,61 @@ +# FILE: mcp2518fd-fd-recv-filt.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: CAN FD message receive example with filter and mask using MCP2518FD. +# WORKS WITH: CAN Bus Breakout: solde.red/333020 +# LAST UPDATED: 2026-05-22 + +# Connecting diagram: +# +# CAN Bus Breakout Dasduino +# NCS--------------->5 +# SDI--------------->23 (MOSI) +# SDO--------------->19 (MISO) +# SCK--------------->18 +# GND--------------->GND +# VCC--------------->5V +# INT--------------->not connected (optional) + +from machine import SPI, Pin +import time +from mcp2518fd import MCP2518FD, CAN_OK, CAN_MSGAVAIL, CAN_NORMAL_MODE, CAN_125K_500K, MCP2518FD_20MHz + +MAX_DATA_SIZE = 64 + +# Change these to match your wiring +PIN_CS = 5 +PIN_MOSI = 23 +PIN_MISO = 19 +PIN_SCK = 18 + +spi = SPI(1, baudrate=4000000, polarity=0, phase=0, firstbit=SPI.MSB, + sck=Pin(PIN_SCK), mosi=Pin(PIN_MOSI), miso=Pin(PIN_MISO)) + +CAN = MCP2518FD(cs_pin=PIN_CS, spi=spi) + +# ------------------------------------------------------------------------- +# Setup +# ------------------------------------------------------------------------- +print("MCP2518FD CAN FD Bus - Receive with Filter Example") + +CAN.setMode(CAN_NORMAL_MODE) + +while CAN.begin(CAN_125K_500K, MCP2518FD_20MHz) != CAN_OK: + print("CAN init fail, retry...") + time.sleep_ms(100) + +print("CAN init ok!") + +# Accept only messages with ID 0x04 +# filter=0x04, mask=0x7FF (all 11 bits must match) +CAN.init_Filt_Mask(0, 0, 0x04, 0x7FF) + +# ------------------------------------------------------------------------- +# Main loop +# ------------------------------------------------------------------------- +while True: + if CAN.checkReceive() == CAN_MSGAVAIL: + length, buf = CAN.readMsgBuf() + can_id = CAN.getCanId() + print("Get Data From id:", can_id) + print("Len =", length) + print("\t".join(str(buf[i]) for i in range(length))) diff --git a/Communication/MCP2518/MCP2518/Examples/mcp2518fd-fd-recv-int.py b/Communication/MCP2518/MCP2518/Examples/mcp2518fd-fd-recv-int.py new file mode 100644 index 0000000..687fca8 --- /dev/null +++ b/Communication/MCP2518/MCP2518/Examples/mcp2518fd-fd-recv-int.py @@ -0,0 +1,70 @@ +# FILE: mcp2518fd-fd-recv-int.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: CAN FD message receive example using hardware interrupt on MCP2518FD. +# WORKS WITH: CAN Bus Breakout: solde.red/333020 +# LAST UPDATED: 2026-05-22 + +# Connecting diagram: +# +# CAN Bus Breakout Dasduino +# NCS--------------->5 +# SDI--------------->23 (MOSI) +# SDO--------------->19 (MISO) +# SCK--------------->18 +# INT--------------->4 +# GND--------------->GND +# VCC--------------->5V + +from machine import SPI, Pin +import time +from mcp2518fd import MCP2518FD, CAN_OK, CAN_MSGAVAIL, CAN_NORMAL_MODE, CAN_125K_500K, MCP2518FD_20MHz + +MAX_DATA_SIZE = 64 + +# Change these to match your wiring +PIN_CS = 5 +PIN_MOSI = 23 +PIN_MISO = 19 +PIN_SCK = 18 +PIN_INT = 4 + +spi = SPI(1, baudrate=4000000, polarity=0, phase=0, firstbit=SPI.MSB, + sck=Pin(PIN_SCK), mosi=Pin(PIN_MOSI), miso=Pin(PIN_MISO)) + +CAN = MCP2518FD(cs_pin=PIN_CS, spi=spi) + +flag_recv = False + +def can_isr(pin): + global flag_recv + flag_recv = True + +# ------------------------------------------------------------------------- +# Setup +# ------------------------------------------------------------------------- +print("MCP2518FD CAN FD Bus - Receive with Interrupt Example") + +CAN.setMode(CAN_NORMAL_MODE) + +while CAN.begin(CAN_125K_500K, MCP2518FD_20MHz) != CAN_OK: + print("CAN init fail, retry...") + time.sleep_ms(100) + +print("CAN init ok!") + +int_pin = Pin(PIN_INT, Pin.IN) +int_pin.irq(trigger=Pin.IRQ_FALLING, handler=can_isr) + +# ------------------------------------------------------------------------- +# Main loop +# ------------------------------------------------------------------------- +while True: + if flag_recv: + flag_recv = False + # Drain FIFO — INT stays low while messages remain + while CAN.checkReceive() == CAN_MSGAVAIL: + length, buf = CAN.readMsgBuf() + can_id = CAN.getCanId() + print("Get Data From id:", can_id) + print("Len =", length) + print("\t".join(str(buf[i]) for i in range(length))) diff --git a/Communication/MCP2518/MCP2518/Examples/mcp2518fd-fd-recv.py b/Communication/MCP2518/MCP2518/Examples/mcp2518fd-fd-recv.py new file mode 100644 index 0000000..e1d1007 --- /dev/null +++ b/Communication/MCP2518/MCP2518/Examples/mcp2518fd-fd-recv.py @@ -0,0 +1,57 @@ +# FILE: mcp2518fd-fd-recv.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: CAN FD message receive example using MCP2518FD. +# WORKS WITH: CAN Bus Breakout: solde.red/333020 +# LAST UPDATED: 2026-05-22 + +# Connecting diagram: +# +# CAN Bus Breakout Dasduino +# NCS--------------->5 +# SDI--------------->23 (MOSI) +# SDO--------------->19 (MISO) +# SCK--------------->18 +# GND--------------->GND +# VCC--------------->5V +# INT--------------->not connected (optional) + +from machine import SPI, Pin +import time +from mcp2518fd import MCP2518FD, CAN_OK, CAN_MSGAVAIL, CAN_NORMAL_MODE, CAN_125K_500K, MCP2518FD_20MHz + +MAX_DATA_SIZE = 64 + +# Change these to match your wiring +PIN_CS = 5 +PIN_MOSI = 23 +PIN_MISO = 19 +PIN_SCK = 18 + +spi = SPI(1, baudrate=4000000, polarity=0, phase=0, firstbit=SPI.MSB, + sck=Pin(PIN_SCK), mosi=Pin(PIN_MOSI), miso=Pin(PIN_MISO)) + +CAN = MCP2518FD(cs_pin=PIN_CS, spi=spi) + +# ------------------------------------------------------------------------- +# Setup +# ------------------------------------------------------------------------- +print("MCP2518FD CAN FD Bus - Receive Example") + +CAN.setMode(CAN_NORMAL_MODE) + +while CAN.begin(CAN_125K_500K, MCP2518FD_20MHz) != CAN_OK: + print("CAN init fail, retry...") + time.sleep_ms(100) + +print("CAN init ok!") + +# ------------------------------------------------------------------------- +# Main loop +# ------------------------------------------------------------------------- +while True: + if CAN.checkReceive() == CAN_MSGAVAIL: + length, buf = CAN.readMsgBuf() + can_id = CAN.getCanId() + print("Get Data From id:", can_id) + print("Len =", length) + print("\t".join(str(buf[i]) for i in range(length))) diff --git a/Communication/MCP2518/MCP2518/Examples/mcp2518fd-fd-send.py b/Communication/MCP2518/MCP2518/Examples/mcp2518fd-fd-send.py new file mode 100644 index 0000000..abc8828 --- /dev/null +++ b/Communication/MCP2518/MCP2518/Examples/mcp2518fd-fd-send.py @@ -0,0 +1,58 @@ +# FILE: mcp2518fd-fd-send.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: CAN FD message send example using MCP2518FD. +# WORKS WITH: CAN Bus Breakout: solde.red/333020 +# LAST UPDATED: 2026-05-21 + +# Connecting diagram: +# +# CAN Bus Breakout Dasduino +# NCS--------------->5 +# SDI--------------->23 (MOSI) +# SDO--------------->19 (MISO) +# SCK--------------->18 +# GND--------------->GND +# VCC--------------->5V +# INT--------------->not connected (optional) + +from machine import SPI, Pin +import time +from mcp2518fd import MCP2518FD, CAN_OK, CAN_NORMAL_MODE, CAN_125K_500K, MCP2518FD_20MHz + +MAX_DATA_SIZE = 64 + +# Change these to match your wiring +PIN_CS = 5 +PIN_MOSI = 23 +PIN_MISO = 19 +PIN_SCK = 18 + +spi = SPI(1, baudrate=4000000, polarity=0, phase=0, firstbit=SPI.MSB, + sck=Pin(PIN_SCK), mosi=Pin(PIN_MOSI), miso=Pin(PIN_MISO)) + +CAN = MCP2518FD(cs_pin=PIN_CS, spi=spi) + +# ------------------------------------------------------------------------- +# Setup +# ------------------------------------------------------------------------- +print("MCP2518FD CAN FD Bus - Send Example") + +CAN.setMode(CAN_NORMAL_MODE) + +while CAN.begin(CAN_125K_500K, MCP2518FD_20MHz) != CAN_OK: + print("CAN init fail, retry...") + time.sleep_ms(100) + +print("CAN init ok!") + +stmp = bytes(range(MAX_DATA_SIZE)) + +# ------------------------------------------------------------------------- +# Main loop +# ------------------------------------------------------------------------- +while True: + CAN.sendMsgBuf(0x01, 0, MAX_DATA_SIZE, stmp) + time.sleep_ms(10) + CAN.sendMsgBuf(0x04, 0, MAX_DATA_SIZE, stmp) + time.sleep_ms(500) + print("CAN BUS sendMsgBuf ok!") diff --git a/Communication/MCP2518/MCP2518/Examples/mcp2518fd-recv-filt.py b/Communication/MCP2518/MCP2518/Examples/mcp2518fd-recv-filt.py new file mode 100644 index 0000000..654d61e --- /dev/null +++ b/Communication/MCP2518/MCP2518/Examples/mcp2518fd-recv-filt.py @@ -0,0 +1,57 @@ +# FILE: mcp2518fd-recv-filt.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: CAN 2.0 message receive example with filter and mask using MCP2518FD. +# WORKS WITH: CAN Bus Breakout: solde.red/333020 +# LAST UPDATED: 2026-05-22 + +# Connecting diagram: +# +# CAN Bus Breakout Dasduino +# NCS--------------->5 +# SDI--------------->23 (MOSI) +# SDO--------------->19 (MISO) +# SCK--------------->18 +# GND--------------->GND +# VCC--------------->5V +# INT--------------->not connected (optional) + +from machine import SPI, Pin +import time +from mcp2518fd import MCP2518FD, CAN_OK, CAN_MSGAVAIL, CAN_125KBPS, MCP2518FD_20MHz + +# Change these to match your wiring +PIN_CS = 5 +PIN_MOSI = 23 +PIN_MISO = 19 +PIN_SCK = 18 + +spi = SPI(1, baudrate=4000000, polarity=0, phase=0, firstbit=SPI.MSB, + sck=Pin(PIN_SCK), mosi=Pin(PIN_MOSI), miso=Pin(PIN_MISO)) + +CAN = MCP2518FD(cs_pin=PIN_CS, spi=spi) + +# ------------------------------------------------------------------------- +# Setup +# ------------------------------------------------------------------------- +print("MCP2518FD CAN Bus - Receive with Filter Example") + +while CAN.begin(CAN_125KBPS, MCP2518FD_20MHz) != CAN_OK: + print("CAN init fail, retry...") + time.sleep_ms(100) + +print("CAN init ok!") + +# Accept only messages with ID 0x04 +# filter=0x04, mask=0x7FF (all 11 bits must match) +CAN.init_Filt_Mask(0, 0, 0x04, 0x7FF) + +# ------------------------------------------------------------------------- +# Main loop +# ------------------------------------------------------------------------- +while True: + if CAN.checkReceive() == CAN_MSGAVAIL: + length, buf = CAN.readMsgBuf() + can_id = CAN.getCanId() + print("Get Data From id:", can_id) + print("Len =", length) + print("\t".join(str(buf[i]) for i in range(length))) diff --git a/Communication/MCP2518/MCP2518/Examples/mcp2518fd-recv-int.py b/Communication/MCP2518/MCP2518/Examples/mcp2518fd-recv-int.py new file mode 100644 index 0000000..e6d41a2 --- /dev/null +++ b/Communication/MCP2518/MCP2518/Examples/mcp2518fd-recv-int.py @@ -0,0 +1,66 @@ +# FILE: mcp2518fd-recv-int.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: CAN 2.0 message receive example using hardware interrupt on MCP2518FD. +# WORKS WITH: CAN Bus Breakout: solde.red/333020 +# LAST UPDATED: 2026-05-22 + +# Connecting diagram: +# +# CAN Bus Breakout Dasduino +# NCS--------------->5 +# SDI--------------->23 (MOSI) +# SDO--------------->19 (MISO) +# SCK--------------->18 +# INT--------------->4 +# GND--------------->GND +# VCC--------------->5V + +from machine import SPI, Pin +import time +from mcp2518fd import MCP2518FD, CAN_OK, CAN_MSGAVAIL, CAN_125KBPS, MCP2518FD_20MHz + +# Change these to match your wiring +PIN_CS = 5 +PIN_MOSI = 23 +PIN_MISO = 19 +PIN_SCK = 18 +PIN_INT = 4 + +spi = SPI(1, baudrate=4000000, polarity=0, phase=0, firstbit=SPI.MSB, + sck=Pin(PIN_SCK), mosi=Pin(PIN_MOSI), miso=Pin(PIN_MISO)) + +CAN = MCP2518FD(cs_pin=PIN_CS, spi=spi) + +flag_recv = False + +def can_isr(pin): + global flag_recv + flag_recv = True + +# ------------------------------------------------------------------------- +# Setup +# ------------------------------------------------------------------------- +print("MCP2518FD CAN Bus - Receive with Interrupt Example") + +while CAN.begin(CAN_125KBPS, MCP2518FD_20MHz) != CAN_OK: + print("CAN init fail, retry...") + time.sleep_ms(100) + +print("CAN init ok!") + +int_pin = Pin(PIN_INT, Pin.IN) +int_pin.irq(trigger=Pin.IRQ_FALLING, handler=can_isr) + +# ------------------------------------------------------------------------- +# Main loop +# ------------------------------------------------------------------------- +while True: + if flag_recv: + flag_recv = False + # Drain FIFO — INT stays low while messages remain + while CAN.checkReceive() == CAN_MSGAVAIL: + length, buf = CAN.readMsgBuf() + can_id = CAN.getCanId() + print("Get Data From id:", can_id) + print("Len =", length) + print("\t".join(str(buf[i]) for i in range(length))) diff --git a/Communication/MCP2518/MCP2518/Examples/mcp2518fd-recv.py b/Communication/MCP2518/MCP2518/Examples/mcp2518fd-recv.py new file mode 100644 index 0000000..34dddda --- /dev/null +++ b/Communication/MCP2518/MCP2518/Examples/mcp2518fd-recv.py @@ -0,0 +1,53 @@ +# FILE: mcp2518fd-recv.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: CAN message receive example using MCP2518FD. +# WORKS WITH: CAN Bus Breakout: solde.red/333020 +# LAST UPDATED: 2026-05-21 + +# Connecting diagram: +# +# CAN Bus Breakout Dasduino +# NCS--------------->5 +# SDI--------------->23 (MOSI) +# SDO--------------->19 (MISO) +# SCK--------------->18 +# GND--------------->GND +# VCC--------------->5V +# INT--------------->not connected (optional) + +from machine import SPI, Pin +import time +from mcp2518fd import MCP2518FD, CAN_OK, CAN_MSGAVAIL, CAN_125KBPS, MCP2518FD_20MHz + +# Change these to match your wiring +PIN_CS = 5 +PIN_MOSI = 23 +PIN_MISO = 19 +PIN_SCK = 18 + +spi = SPI(1, baudrate=4000000, polarity=0, phase=0, firstbit=SPI.MSB, + sck=Pin(PIN_SCK), mosi=Pin(PIN_MOSI), miso=Pin(PIN_MISO)) + +CAN = MCP2518FD(cs_pin=PIN_CS, spi=spi) + +# ------------------------------------------------------------------------- +# Setup +# ------------------------------------------------------------------------- +print("MCP2518FD CAN Bus - Receive Example") + +while CAN.begin(CAN_125KBPS, MCP2518FD_20MHz) != CAN_OK: + print("CAN init fail, retry...") + time.sleep_ms(100) + +print("CAN init ok!") + +# ------------------------------------------------------------------------- +# Main loop +# ------------------------------------------------------------------------- +while True: + if CAN.checkReceive() == CAN_MSGAVAIL: + length, buf = CAN.readMsgBuf() + can_id = CAN.getCanId() + print("Get Data From id:", can_id) + print("Len =", length) + print("\t".join(str(buf[i]) for i in range(length))) diff --git a/Communication/MCP2518/MCP2518/Examples/mcp2518fd-send.py b/Communication/MCP2518/MCP2518/Examples/mcp2518fd-send.py new file mode 100644 index 0000000..0b5acd3 --- /dev/null +++ b/Communication/MCP2518/MCP2518/Examples/mcp2518fd-send.py @@ -0,0 +1,56 @@ +# FILE: mcp2518fd-send.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: CAN message send example using MCP2518FD. +# WORKS WITH: CAN Bus Breakout: solde.red/333020 +# LAST UPDATED: 2026-05-21 + +# Connecting diagram: +# +# CAN Bus Breakout Dasduino +# NCS--------------->5 +# SDI--------------->23 (MOSI) +# SDO--------------->19 (MISO) +# SCK--------------->18 +# GND--------------->GND +# VCC--------------->5V +# INT--------------->not connected (optional) + +from machine import SPI, Pin +import time +from mcp2518fd import MCP2518FD, CAN_OK, CAN_125KBPS, MCP2518FD_20MHz + +# Change these to match your wiring +PIN_CS = 5 +PIN_MOSI = 23 +PIN_MISO = 19 +PIN_SCK = 18 + +spi = SPI(1, baudrate=4000000, polarity=0, phase=0, firstbit=SPI.MSB, + sck=Pin(PIN_SCK), mosi=Pin(PIN_MOSI), miso=Pin(PIN_MISO)) + +CAN = MCP2518FD(cs_pin=PIN_CS, spi=spi) + +# ------------------------------------------------------------------------- +# Setup +# ------------------------------------------------------------------------- +print("MCP2518FD CAN Bus - Send Example") + +while CAN.begin(CAN_125KBPS, MCP2518FD_20MHz) != CAN_OK: + print("CAN init fail, retry...") + time.sleep_ms(100) + +print("CAN init ok!") + +stmp = bytes([0, 1, 2, 3, 4, 5, 6, 7]) + +# ------------------------------------------------------------------------- +# Main loop +# ------------------------------------------------------------------------- +while True: + # sendMsgBuf(id, ext, length, data) + # ext=0 -> standard 11-bit ID, ext=1 -> extended 29-bit ID + CAN.sendMsgBuf(0x01, 0, 8, stmp) + time.sleep_ms(10) + CAN.sendMsgBuf(0x04, 0, 8, stmp) + time.sleep_ms(500) + print("CAN BUS sendMsgBuf ok!") diff --git a/Communication/MCP2518/MCP2518/mcp2518fd.py b/Communication/MCP2518/MCP2518/mcp2518fd.py new file mode 100644 index 0000000..37632dc --- /dev/null +++ b/Communication/MCP2518/MCP2518/mcp2518fd.py @@ -0,0 +1,1086 @@ +# FILE: mcp2518fd.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: MicroPython driver for MCP2518FD CAN FD controller via SPI +# WORKS WITH: CAN Bus Breakout: solde.red/333020 +# LAST UPDATED: 2026-05-21 + +from machine import SPI, Pin +import time + +# =========================================================================== +# Return codes +# =========================================================================== +CAN_OK = 0 +CAN_FAILINIT = 1 +CAN_FAILTX = 2 +CAN_MSGAVAIL = 3 +CAN_NOMSG = 4 +CAN_CTRLERROR = 5 +CAN_GETTXBFTIMEOUT = 6 +CAN_SENDMSGTIMEOUT = 7 +CAN_FAIL = 0xFF + +# =========================================================================== +# Clock frequencies +# =========================================================================== +MCP2518FD_40MHz = 1 +MCP2518FD_20MHz = 2 +MCP2518FD_10MHz = 3 + +# =========================================================================== +# Legacy baud rate constants (passed to begin()) +# =========================================================================== +CAN_5KBPS = 1 +CAN_10KBPS = 2 +CAN_20KBPS = 3 +CAN_25KBPS = 4 +CAN_31K25BPS = 5 +CAN_33KBPS = 6 +CAN_40KBPS = 7 +CAN_50KBPS = 8 +CAN_80KBPS = 9 +CAN_83K3BPS = 10 +CAN_95KBPS = 11 +CAN_100KBPS = 12 +CAN_125KBPS = 13 +CAN_200KBPS = 14 +CAN_250KBPS = 15 +CAN_500KBPS = 16 +CAN_666KBPS = 17 +CAN_800KBPS = 18 +CAN_1000KBPS = 19 + +# Pre-defined CAN FD dual-rate constants: (factor << 24) | arb_bitrate +CAN_125K_500K = (4 << 24) | 125000 +CAN_250K_500K = (2 << 24) | 250000 +CAN_250K_750K = (3 << 24) | 250000 +CAN_250K_1M = (4 << 24) | 250000 +CAN_250K_2M = (8 << 24) | 250000 +CAN_500K_1M = (2 << 24) | 500000 +CAN_500K_2M = (4 << 24) | 500000 +CAN_500K_4M = (8 << 24) | 500000 +CAN_1000K_4M = (4 << 24) | 1000000 +CAN_1000K_8M = (8 << 24) | 1000000 + +# =========================================================================== +# Operation modes +# =========================================================================== +CAN_NORMAL_MODE = 0x00 +CAN_SLEEP_MODE = 0x01 +CAN_INTERNAL_LOOPBACK_MODE = 0x02 +CAN_LISTEN_ONLY_MODE = 0x03 +CAN_CONFIGURATION_MODE = 0x04 +CAN_EXTERNAL_LOOPBACK_MODE = 0x05 +CAN_CLASSIC_MODE = 0x06 +CAN_RESTRICTED_MODE = 0x07 +CAN_INVALID_MODE = 0xFF + +# =========================================================================== +# SSP modes +# =========================================================================== +CAN_SSP_MODE_OFF = 0 +CAN_SSP_MODE_MANUAL = 1 +CAN_SSP_MODE_AUTO = 2 + +# =========================================================================== +# DLC constants +# =========================================================================== +CAN_DLC_0 = 0 +CAN_DLC_1 = 1 +CAN_DLC_2 = 2 +CAN_DLC_3 = 3 +CAN_DLC_4 = 4 +CAN_DLC_5 = 5 +CAN_DLC_6 = 6 +CAN_DLC_7 = 7 +CAN_DLC_8 = 8 +CAN_DLC_12 = 9 +CAN_DLC_16 = 10 +CAN_DLC_20 = 11 +CAN_DLC_24 = 12 +CAN_DLC_32 = 13 +CAN_DLC_48 = 14 +CAN_DLC_64 = 15 + +# FIFO payload sizes +CAN_PLSIZE_8 = 0 +CAN_PLSIZE_12 = 1 +CAN_PLSIZE_16 = 2 +CAN_PLSIZE_20 = 3 +CAN_PLSIZE_24 = 4 +CAN_PLSIZE_32 = 5 +CAN_PLSIZE_48 = 6 +CAN_PLSIZE_64 = 7 + +# =========================================================================== +# FIFO / filter channel constants +# =========================================================================== +CAN_FIFO_CH0 = 0 +CAN_FIFO_CH1 = 1 +CAN_FIFO_CH2 = 2 +CAN_TXQUEUE_CH0 = 0 +CAN_FILTER0 = 0 + +# =========================================================================== +# Event / interrupt flag constants +# =========================================================================== +CAN_TX_FIFO_NOT_FULL_EVENT = 0x01 +CAN_TX_FIFO_HALF_FULL_EVENT = 0x02 +CAN_TX_FIFO_EMPTY_EVENT = 0x04 +CAN_TX_FIFO_ATTEMPTS_EXHAUSTED_EVENT = 0x10 +CAN_TX_FIFO_ALL_EVENTS = 0x17 + +CAN_RX_FIFO_NOT_EMPTY_EVENT = 0x01 +CAN_RX_FIFO_HALF_FULL_EVENT = 0x02 +CAN_RX_FIFO_FULL_EVENT = 0x04 +CAN_RX_FIFO_OVERFLOW_EVENT = 0x08 +CAN_RX_FIFO_ALL_EVENTS = 0x0F + +CAN_RX_FIFO_NOT_EMPTY = 0x01 + +CAN_TX_EVENT = 0x0001 +CAN_RX_EVENT = 0x0002 +CAN_ALL_EVENTS = 0xFF1F + +# =========================================================================== +# Error state constants +# =========================================================================== +CAN_ERROR_FREE_STATE = 0 +CAN_ERROR_ALL = 0x3F +CAN_TX_RX_WARNING_STATE = 0x01 +CAN_RX_WARNING_STATE = 0x02 +CAN_TX_WARNING_STATE = 0x04 +CAN_RX_BUS_PASSIVE_STATE = 0x08 +CAN_TX_BUS_PASSIVE_STATE = 0x10 +CAN_TX_BUS_OFF_STATE = 0x20 + +# =========================================================================== +# GPIO constants +# =========================================================================== +GPIO_PIN_0 = 0 +GPIO_PIN_1 = 1 +GPIO_MODE_INT = 0 +GPIO_MODE_GPIO = 1 +GPIO_OUTPUT = 0 +GPIO_INPUT = 1 +GPIO_LOW = 0 +GPIO_HIGH = 1 + +# =========================================================================== +# SPI instructions +# =========================================================================== +_INSTR_RESET = 0x00 +_INSTR_READ = 0x03 +_INSTR_READ_CRC = 0x0B +_INSTR_WRITE = 0x02 +_INSTR_WRITE_CRC = 0x0A +_INSTR_WRITE_SAFE = 0x0C + +# =========================================================================== +# Register addresses +# =========================================================================== +_REG_CiCON = 0x000 +_REG_CiNBTCFG = 0x004 +_REG_CiDBTCFG = 0x008 +_REG_CiTDC = 0x00C +_REG_CiTBC = 0x010 +_REG_CiTSCON = 0x014 +_REG_CiVEC = 0x018 +_REG_CiINT = 0x01C +_REG_CiINTENABLE = 0x01E +_REG_CiRXIF = 0x020 +_REG_CiTXIF = 0x024 +_REG_CiRXOVIF = 0x028 +_REG_CiTXATIF = 0x02C +_REG_CiTXREQ = 0x030 +_REG_CiTREC = 0x034 +_REG_CiBDIAG0 = 0x038 +_REG_CiBDIAG1 = 0x03C +_REG_CiTEFCON = 0x040 +_REG_CiTEFSTA = 0x044 +_REG_CiTEFUA = 0x048 +_REG_CiFIFOBA = 0x04C +_REG_CiFIFOCON = 0x050 +_REG_CiFIFOSTA = 0x054 +_REG_CiFIFOUA = 0x058 +_FIFO_OFFSET = 12 # 3 registers * 4 bytes +_FIFO_TOTAL_CH = 32 +_REG_CiFLTCON = _REG_CiFIFOCON + _FIFO_OFFSET * _FIFO_TOTAL_CH +_REG_CiFLTOBJ = _REG_CiFLTCON + _FIFO_TOTAL_CH +_REG_CiMASK = _REG_CiFLTOBJ + 4 +_FILTER_OFFSET = 8 # 2 registers * 4 bytes +_REG_OSC = 0xE00 +_REG_IOCON = 0xE04 +_REG_CRC_REG = 0xE08 +_REG_ECCCON = 0xE0C +_REG_ECCSTA = 0xE10 +_REG_DEVID = 0xE14 + +_RAM_START = 0x400 +_RAM_SIZE = 2048 + +_APP_TX_FIFO = CAN_FIFO_CH2 +_APP_RX_FIFO = CAN_FIFO_CH1 + +_MAX_TXQUEUE_ATTEMPTS = 50 +_SPI_CHUNK = 96 + +# =========================================================================== +# Register reset values +# =========================================================================== +_canControlResetValues = ( + 0x04980760, 0x003E0F0F, 0x000E0303, 0x00021000, + 0x00000000, 0x00000000, 0x40400040, 0x00000000, + 0x00000000, 0x00000000, 0x00000000, 0x00000000, + 0x00000000, 0x00200000, 0x00000000, 0x00000000, + 0x00000400, 0x00000000, 0x00000000, 0x00000000, +) +_canFifoResetValues = (0x00600400, 0x00000000, 0x00000000) + +# =========================================================================== +# CRC-16 lookup table +# =========================================================================== +_crc16_table = ( + 0x0000, 0x8005, 0x800F, 0x000A, 0x801B, 0x001E, 0x0014, 0x8011, 0x8033, + 0x0036, 0x003C, 0x8039, 0x0028, 0x802D, 0x8027, 0x0022, 0x8063, 0x0066, + 0x006C, 0x8069, 0x0078, 0x807D, 0x8077, 0x0072, 0x0050, 0x8055, 0x805F, + 0x005A, 0x804B, 0x004E, 0x0044, 0x8041, 0x80C3, 0x00C6, 0x00CC, 0x80C9, + 0x00D8, 0x80DD, 0x80D7, 0x00D2, 0x00F0, 0x80F5, 0x80FF, 0x00FA, 0x80EB, + 0x00EE, 0x00E4, 0x80E1, 0x00A0, 0x80A5, 0x80AF, 0x00AA, 0x80BB, 0x00BE, + 0x00B4, 0x80B1, 0x8093, 0x0096, 0x009C, 0x8099, 0x0088, 0x808D, 0x8087, + 0x0082, 0x8183, 0x0186, 0x018C, 0x8189, 0x0198, 0x819D, 0x8197, 0x0192, + 0x01B0, 0x81B5, 0x81BF, 0x01BA, 0x81AB, 0x01AE, 0x01A4, 0x81A1, 0x01E0, + 0x81E5, 0x81EF, 0x01EA, 0x81FB, 0x01FE, 0x01F4, 0x81F1, 0x81D3, 0x01D6, + 0x01DC, 0x81D9, 0x01C8, 0x81CD, 0x81C7, 0x01C2, 0x0140, 0x8145, 0x814F, + 0x014A, 0x815B, 0x015E, 0x0154, 0x8151, 0x8173, 0x0176, 0x017C, 0x8179, + 0x0168, 0x816D, 0x8167, 0x0162, 0x8123, 0x0126, 0x012C, 0x8129, 0x0138, + 0x813D, 0x8137, 0x0132, 0x0110, 0x8115, 0x811F, 0x011A, 0x810B, 0x010E, + 0x0104, 0x8101, 0x8303, 0x0306, 0x030C, 0x8309, 0x0318, 0x831D, 0x8317, + 0x0312, 0x0330, 0x8335, 0x833F, 0x033A, 0x832B, 0x032E, 0x0324, 0x8321, + 0x0360, 0x8365, 0x836F, 0x036A, 0x837B, 0x037E, 0x0374, 0x8371, 0x8353, + 0x0356, 0x035C, 0x8359, 0x0348, 0x834D, 0x8347, 0x0342, 0x03C0, 0x83C5, + 0x83CF, 0x03CA, 0x83DB, 0x03DE, 0x03D4, 0x83D1, 0x83F3, 0x03F6, 0x03FC, + 0x83F9, 0x03E8, 0x83ED, 0x83E7, 0x03E2, 0x83A3, 0x03A6, 0x03AC, 0x83A9, + 0x03B8, 0x83BD, 0x83B7, 0x03B2, 0x0390, 0x8395, 0x839F, 0x039A, 0x838B, + 0x038E, 0x0384, 0x8381, 0x0280, 0x8285, 0x828F, 0x028A, 0x829B, 0x029E, + 0x0294, 0x8291, 0x82B3, 0x02B6, 0x02BC, 0x82B9, 0x02A8, 0x82AD, 0x82A7, + 0x02A2, 0x82E3, 0x02E6, 0x02EC, 0x82E9, 0x02F8, 0x82FD, 0x82F7, 0x02F2, + 0x02D0, 0x82D5, 0x82DF, 0x02DA, 0x82CB, 0x02CE, 0x02C4, 0x82C1, 0x8243, + 0x0246, 0x024C, 0x8249, 0x0258, 0x825D, 0x8257, 0x0252, 0x0270, 0x8275, + 0x827F, 0x027A, 0x826B, 0x026E, 0x0264, 0x8261, 0x0220, 0x8225, 0x822F, + 0x022A, 0x823B, 0x023E, 0x0234, 0x8231, 0x8213, 0x0216, 0x021C, 0x8219, + 0x0208, 0x820D, 0x8207, 0x0202, +) + +# =========================================================================== +# Helper functions +# =========================================================================== + +def BITRATE(arbitration, factor): + """Build a CAN FD bitrate value: (factor << 24) | arbitration.""" + return ((factor & 0xFF) << 24) | (arbitration & 0xFFFFF) + +def dlc2len(dlc): + """Convert DLC code to byte count.""" + if dlc <= CAN_DLC_8: + return dlc + return {CAN_DLC_12: 12, CAN_DLC_16: 16, CAN_DLC_20: 20, CAN_DLC_24: 24, + CAN_DLC_32: 32, CAN_DLC_48: 48, CAN_DLC_64: 64}.get(dlc, 0) + +def len2dlc(length): + """Convert byte count to DLC code.""" + if length <= 8: return length + if length <= 12: return CAN_DLC_12 + if length <= 16: return CAN_DLC_16 + if length <= 20: return CAN_DLC_20 + if length <= 24: return CAN_DLC_24 + if length <= 32: return CAN_DLC_32 + if length <= 48: return CAN_DLC_48 + return CAN_DLC_64 + +def _crc16(data): + crc = 0xFFFF + for b in data: + idx = ((crc >> 8) ^ b) & 0xFF + crc = ((crc << 8) ^ _crc16_table[idx]) & 0xFFFF + return crc + + +# =========================================================================== +# MCP2518FD driver +# =========================================================================== + +class MCP2518FD: + """ + MicroPython driver for the MCP2518FD CAN FD controller. + Communicates over SPI. CS pin is controlled manually. + """ + + def __init__(self, cs_pin, spi=None, spi_id=1, baudrate=4000000): + """ + :param cs_pin: GPIO pin number for SPI chip select (active low) + :param spi: Initialized machine.SPI object (optional) + :param spi_id: SPI bus ID used when spi is None + :param baudrate: SPI clock speed when spi is None + """ + self._cs = Pin(cs_pin, Pin.OUT, value=1) + if spi is not None: + self._spi = spi + else: + self._spi = SPI(spi_id, baudrate=baudrate, polarity=0, phase=0, + firstbit=SPI.MSB) + + self._mode = CAN_CLASSIC_MODE + + # Bit time calculation state + self._sys_clock = 0 + self._data_factor = 0 + self._brp = 0 + self._arb_ps1 = 0 + self._arb_ps2 = 0 + self._arb_sjw = 0 + self._data_ps1 = 0 + self._data_ps2 = 0 + self._data_sjw = 0 + self._tdco = 0 + + # Last received message state + self.can_id = 0 + self.ext_flg = 0 + self.rtr = 0 + + # ----------------------------------------------------------------------- + # Low-level SPI primitives + # ----------------------------------------------------------------------- + + def _transfer(self, tx): + rx = bytearray(len(tx)) + self._cs.value(0) + self._spi.write_readinto(bytes(tx), rx) + self._cs.value(1) + return rx + + def _read_byte(self, addr): + rx = self._transfer(bytes([(_INSTR_READ << 4) | ((addr >> 8) & 0xF), + addr & 0xFF, 0x00])) + return rx[2] + + def _write_byte(self, addr, val): + self._transfer(bytes([(_INSTR_WRITE << 4) | ((addr >> 8) & 0xF), + addr & 0xFF, val & 0xFF])) + + def _read_word(self, addr): + rx = self._transfer(bytes([(_INSTR_READ << 4) | ((addr >> 8) & 0xF), + addr & 0xFF, 0, 0, 0, 0])) + return rx[2] | (rx[3] << 8) | (rx[4] << 16) | (rx[5] << 24) + + def _write_word(self, addr, val): + val &= 0xFFFFFFFF + self._transfer(bytes([(_INSTR_WRITE << 4) | ((addr >> 8) & 0xF), + addr & 0xFF, + val & 0xFF, (val >> 8) & 0xFF, + (val >> 16) & 0xFF, (val >> 24) & 0xFF])) + + def _read_half_word(self, addr): + rx = self._transfer(bytes([(_INSTR_READ << 4) | ((addr >> 8) & 0xF), + addr & 0xFF, 0, 0])) + return rx[2] | (rx[3] << 8) + + def _write_half_word(self, addr, val): + self._transfer(bytes([(_INSTR_WRITE << 4) | ((addr >> 8) & 0xF), + addr & 0xFF, val & 0xFF, (val >> 8) & 0xFF])) + + def _read_byte_array(self, addr, n): + tx = bytearray(n + 2) + tx[0] = (_INSTR_READ << 4) | ((addr >> 8) & 0xF) + tx[1] = addr & 0xFF + rx = self._transfer(tx) + return bytes(rx[2:]) + + def _write_byte_array(self, addr, data): + tx = bytearray(2 + len(data)) + tx[0] = (_INSTR_WRITE << 4) | ((addr >> 8) & 0xF) + tx[1] = addr & 0xFF + tx[2:] = data + self._transfer(tx) + + def _read_word_array(self, addr, n_words): + raw = self._read_byte_array(addr, n_words * 4) + words = [] + for i in range(n_words): + o = i * 4 + words.append(raw[o] | (raw[o+1] << 8) | (raw[o+2] << 16) | (raw[o+3] << 24)) + return words + + def _write_word_array(self, addr, words): + buf = bytearray(len(words) * 4) + for i, w in enumerate(words): + o = i * 4 + buf[o] = w & 0xFF + buf[o+1] = (w >> 8) & 0xFF + buf[o+2] = (w >> 16) & 0xFF + buf[o+3] = (w >> 24) & 0xFF + self._write_byte_array(addr, buf) + + def _write_byte_safe(self, addr, val): + tx = bytearray(5) + tx[0] = (_INSTR_WRITE_SAFE << 4) | ((addr >> 8) & 0xF) + tx[1] = addr & 0xFF + tx[2] = val & 0xFF + crc = _crc16(tx[:3]) + tx[3] = (crc >> 8) & 0xFF + tx[4] = crc & 0xFF + self._transfer(tx) + + def _write_word_safe(self, addr, val): + tx = bytearray(8) + tx[0] = (_INSTR_WRITE_SAFE << 4) | ((addr >> 8) & 0xF) + tx[1] = addr & 0xFF + tx[2] = val & 0xFF + tx[3] = (val >> 8) & 0xFF + tx[4] = (val >> 16) & 0xFF + tx[5] = (val >> 24) & 0xFF + crc = _crc16(tx[:6]) + tx[6] = (crc >> 8) & 0xFF + tx[7] = crc & 0xFF + self._transfer(tx) + + # ----------------------------------------------------------------------- + # Device init helpers + # ----------------------------------------------------------------------- + + def _reset(self): + self._transfer(bytes([(_INSTR_RESET << 4) & 0xFF, 0x00])) + time.sleep_ms(10) + + def _ecc_enable(self): + d = self._read_byte(_REG_ECCCON) + self._write_byte(_REG_ECCCON, d | 0x01) + + def _ram_init(self, val=0xFF): + chunk = bytes([val] * _SPI_CHUNK) + addr = _RAM_START + for _ in range(_RAM_SIZE // _SPI_CHUNK): + self._write_byte_array(addr, chunk) + addr += _SPI_CHUNK + + # ----------------------------------------------------------------------- + # CAN controller configuration + # ----------------------------------------------------------------------- + + def _configure(self, iso_crc=1, store_in_tef=0): + word = _canControlResetValues[_REG_CiCON // 4] + word = (word & ~(1 << 5)) | ((iso_crc & 1) << 5) + word = (word & ~(1 << 19)) | ((store_in_tef & 1) << 19) + self._write_word(_REG_CiCON, word) + + def _tx_fifo_configure(self, channel, fifo_size=7, payload_size=CAN_PLSIZE_64, + priority=1, rtr_enable=0, attempts=0): + word = _canFifoResetValues[0] + word |= (1 << 7) + word = (word & ~(1 << 6)) | ((rtr_enable & 1) << 6) + word = (word & ~(0x1F << 16)) | ((priority & 0x1F) << 16) + word = (word & ~(0x3 << 21)) | ((attempts & 0x3) << 21) + word = (word & ~(0x1F << 24)) | ((fifo_size & 0x1F) << 24) + word = (word & ~(0x7 << 29)) | ((payload_size & 0x7) << 29) + self._write_word(_REG_CiFIFOCON + channel * _FIFO_OFFSET, word) + + def _rx_fifo_configure(self, channel, fifo_size=15, payload_size=CAN_PLSIZE_64, timestamp=0): + word = _canFifoResetValues[0] + word &= ~(1 << 7) + word = (word & ~(1 << 5)) | ((timestamp & 1) << 5) + word = (word & ~(0x1F << 24)) | ((fifo_size & 0x1F) << 24) + word = (word & ~(0x7 << 29)) | ((payload_size & 0x7) << 29) + self._write_word(_REG_CiFIFOCON + channel * _FIFO_OFFSET, word) + + # ----------------------------------------------------------------------- + # Filter / mask + # ----------------------------------------------------------------------- + + def _filter_object_configure(self, filt, sid=0, eid=0, sid11=0, exide=0): + word = ((sid & 0x7FF) | + ((eid & 0x3FFFF) << 11) | + ((sid11 & 1) << 29) | + ((exide & 1) << 30)) + self._write_word(_REG_CiFLTOBJ + filt * _FILTER_OFFSET, word) + + def _filter_mask_configure(self, filt, msid=0, meid=0, msid11=0, mide=0): + word = ((msid & 0x7FF) | + ((meid & 0x3FFFF) << 11) | + ((msid11 & 1) << 29) | + ((mide & 1) << 30)) + self._write_word(_REG_CiMASK + filt * _FILTER_OFFSET, word) + + def _filter_to_fifo_link(self, filt, channel, enable=True): + self._write_byte(_REG_CiFLTCON + filt, + (channel & 0x1F) | (0x80 if enable else 0x00)) + + def _filter_disable(self, filt): + a = _REG_CiFLTCON + filt + self._write_byte(a, self._read_byte(a) & ~0x80) + + # ----------------------------------------------------------------------- + # Bit time calculation + # ----------------------------------------------------------------------- + + def _calc_bittime(self, arb_bitrate, tol_ppm=10000): + MAX_BRP = 256 + MAX_ARB_PS1 = 256 + MAX_ARB_PS2 = 128 + MAX_DATA_PS1 = 32 + MAX_DATA_PS2 = 16 + + if self._data_factor <= 1: + max_tq = MAX_ARB_PS1 + MAX_ARB_PS2 + 1 + brp = MAX_BRP + smallest_err = 0xFFFFFFFF + best_brp = 1 + best_tq = 4 + tq = self._sys_clock // arb_bitrate // brp + + while tq <= max_tq + 1 and brp > 0: + if 4 <= tq <= max_tq: + err = self._sys_clock - arb_bitrate * tq * brp + if err <= smallest_err: + smallest_err = err; best_brp = brp; best_tq = tq + if 3 <= tq < max_tq: + err = arb_bitrate * (tq + 1) * brp - self._sys_clock + if err <= smallest_err: + smallest_err = err; best_brp = brp; best_tq = tq + 1 + brp -= 1 + tq = (self._sys_clock // arb_bitrate // brp) if brp else max_tq + 1 + + ps2 = best_tq // 5 + if ps2 == 0: ps2 = 1 + elif ps2 > MAX_ARB_PS2: ps2 = MAX_ARB_PS2 + ps1 = best_tq - ps2 - 1 + if ps1 > MAX_ARB_PS1: + ps2 += ps1 - MAX_ARB_PS1 + ps1 = MAX_ARB_PS1 + + self._brp = best_brp + self._arb_ps1 = ps1 + self._arb_ps2 = ps2 + self._arb_sjw = ps2 + self._data_ps1 = ps1 + self._data_ps2 = ps2 + self._data_sjw = ps2 + + else: + data_bitrate = arb_bitrate * self._data_factor + max_data_tq = MAX_DATA_PS1 + MAX_DATA_PS2 + smallest_err = 0xFFFFFFFF + best_brp = MAX_BRP + best_data_tq = max_data_tq + data_tq = 4 + brp = self._sys_clock // data_bitrate // data_tq + + while data_tq <= max_data_tq and brp > 0: + if brp <= MAX_BRP: + err = self._sys_clock - data_bitrate * data_tq * brp + if err <= smallest_err: + smallest_err = err; best_brp = brp; best_data_tq = data_tq + if brp < MAX_BRP: + err = data_bitrate * data_tq * (brp + 1) - self._sys_clock + if err <= smallest_err: + smallest_err = err; best_brp = brp + 1; best_data_tq = data_tq + data_tq += 1 + brp = (self._sys_clock // data_bitrate // data_tq) if data_tq else 0 + + data_ps2 = best_data_tq // 5 + if data_ps2 == 0: data_ps2 = 1 + data_ps1 = best_data_tq - data_ps2 - 1 + if data_ps1 > MAX_DATA_PS1: + data_ps2 += data_ps1 - MAX_DATA_PS1 + data_ps1 = MAX_DATA_PS1 + + tdco = best_brp * data_ps1 + self._tdco = min(tdco, 63) + self._data_ps1 = data_ps1 + self._data_ps2 = data_ps2 + self._data_sjw = data_ps2 + + arb_tq = best_data_tq * self._data_factor + arb_ps2 = arb_tq // 5 + if arb_ps2 == 0: arb_ps2 = 1 + arb_ps1 = arb_tq - arb_ps2 - 1 + if arb_ps1 > MAX_ARB_PS1: + arb_ps2 += arb_ps1 - MAX_ARB_PS1 + arb_ps1 = MAX_ARB_PS1 + + self._brp = best_brp + self._arb_ps1 = arb_ps1 + self._arb_ps2 = arb_ps2 + self._arb_sjw = arb_ps2 + + def _bittime_configure_nominal(self): + word = (((self._arb_sjw - 1) & 0x7F) | + (((self._arb_ps2 - 1) & 0x7F) << 8) | + (((self._arb_ps1 - 1) & 0xFF) << 16)| + (((self._brp - 1) & 0xFF) << 24)) + self._write_word(_REG_CiNBTCFG, word) + + def _bittime_configure_data(self, ssp_mode=CAN_SSP_MODE_AUTO): + word = (((self._data_sjw - 1) & 0xF) | + (((self._data_ps2 - 1) & 0xF) << 8) | + (((self._data_ps1 - 1) & 0x1F) << 16)| + (((self._brp - 1) & 0xFF) << 24)) + self._write_word(_REG_CiDBTCFG, word) + + tdc = _canControlResetValues[_REG_CiTDC // 4] + tdc = (tdc & ~(0x3 << 16)) | ((ssp_mode & 0x3) << 16) + tdc = (tdc & ~(0x7F << 8)) | ((self._tdco & 0x7F) << 8) + self._write_word(_REG_CiTDC, tdc) + + def _bittime_configure(self, speedset, ssp_mode, clk): + self._data_factor = (speedset >> 24) & 0xFF + arb_bitrate = speedset & 0xFFFFF + if clk == MCP2518FD_10MHz: + self._sys_clock = 10_000_000 + elif clk == MCP2518FD_20MHz: + self._sys_clock = 20_000_000 + else: + self._sys_clock = 40_000_000 + self._calc_bittime(arb_bitrate) + self._bittime_configure_nominal() + self._bittime_configure_data(ssp_mode) + + # ----------------------------------------------------------------------- + # Operation mode + # ----------------------------------------------------------------------- + + def _op_mode_select(self, mode): + d = self._read_byte(_REG_CiCON + 3) + self._write_byte(_REG_CiCON + 3, (d & ~0x07) | (mode & 0x07)) + + def _op_mode_get(self): + return (self._read_byte(_REG_CiCON + 2) >> 5) & 0x7 + + # ----------------------------------------------------------------------- + # GPIO + # ----------------------------------------------------------------------- + + def _gpio_mode_configure(self, gpio0=GPIO_MODE_INT, gpio1=GPIO_MODE_INT): + a = _REG_IOCON + 3 + d = self._read_byte(a) + d = (d & ~0x01) | (gpio0 & 0x01) + d = (d & ~0x02) | ((gpio1 & 0x01) << 1) + self._write_byte(a, d) + + # ----------------------------------------------------------------------- + # Events / interrupts + # ----------------------------------------------------------------------- + + def _tx_channel_event_enable(self, channel, flags): + a = _REG_CiFIFOCON + channel * _FIFO_OFFSET + self._write_byte(a, self._read_byte(a) | (flags & CAN_TX_FIFO_ALL_EVENTS)) + + def _rx_channel_event_enable(self, channel, flags): + if channel == CAN_TXQUEUE_CH0: + return + a = _REG_CiFIFOCON + channel * _FIFO_OFFSET + self._write_byte(a, self._read_byte(a) | (flags & CAN_RX_FIFO_ALL_EVENTS)) + + def _module_event_enable(self, flags): + w = self._read_half_word(_REG_CiINTENABLE) + self._write_half_word(_REG_CiINTENABLE, w | (flags & CAN_ALL_EVENTS)) + + # ----------------------------------------------------------------------- + # TX channel status / load / update + # ----------------------------------------------------------------------- + + def _tx_channel_event_get(self, channel): + a = _REG_CiFIFOSTA + channel * _FIFO_OFFSET + return self._read_byte(a) & CAN_TX_FIFO_ALL_EVENTS + + def _tx_channel_event_attempt_clear(self, channel): + a = _REG_CiFIFOSTA + channel * _FIFO_OFFSET + self._write_byte(a, self._read_byte(a) & ~CAN_TX_FIFO_ATTEMPTS_EXHAUSTED_EVENT) + + def _tx_channel_update(self, channel, flush): + # Write to byte 1 of CiFIFOCON (bits 8-15): + # bit 0 of byte = UINC (bit 8 of word) + # bit 1 of byte = TxRequest (bit 9 of word) + a = _REG_CiFIFOCON + channel * _FIFO_OFFSET + 1 + self._write_byte(a, 0x03 if flush else 0x01) + + def _tx_channel_load(self, channel, id_word, ctrl_word, data, flush): + fifo_regs = self._read_word_array(_REG_CiFIFOCON + channel * _FIFO_OFFSET, 3) + + if not (fifo_regs[0] & (1 << 7)): + return -2 + + if dlc2len(ctrl_word & 0xF) < len(data): + return -3 + + ua = fifo_regs[2] & 0xFFF + a = ua + _RAM_START + + buf = bytearray(8 + len(data)) + buf[0] = id_word & 0xFF; buf[1] = (id_word >> 8) & 0xFF + buf[2] = (id_word >> 16) & 0xFF; buf[3] = (id_word >> 24) & 0xFF + buf[4] = ctrl_word & 0xFF; buf[5] = (ctrl_word >> 8) & 0xFF + buf[6] = (ctrl_word >> 16) & 0xFF; buf[7] = (ctrl_word >> 24) & 0xFF + buf[8:] = data + + pad = (4 - len(buf) % 4) % 4 + if pad: + buf += bytes(pad) + + self._write_byte_array(a, buf) + self._tx_channel_update(channel, flush) + return 0 + + def _tx_message_queue(self, id_word, ctrl_word, data): + for _ in range(_MAX_TXQUEUE_ATTEMPTS): + if self._tx_channel_event_get(_APP_TX_FIFO) & CAN_TX_FIFO_NOT_FULL_EVENT: + return self._tx_channel_load(_APP_TX_FIFO, id_word, ctrl_word, data, True) + return -2 + + # ----------------------------------------------------------------------- + # RX channel status / receive + # ----------------------------------------------------------------------- + + def _rx_channel_event_get(self, channel): + if channel == CAN_TXQUEUE_CH0: + return 0 + a = _REG_CiFIFOSTA + channel * _FIFO_OFFSET + return self._read_byte(a) & CAN_RX_FIFO_ALL_EVENTS + + def _rx_channel_status_get(self, channel): + a = _REG_CiFIFOSTA + channel * _FIFO_OFFSET + return self._read_byte(a) & 0x0F + + def _rx_channel_update(self, channel): + a = _REG_CiFIFOCON + channel * _FIFO_OFFSET + 1 + self._write_byte(a, 0x01) # UINC + + def _rx_message_get(self, channel, max_bytes=64): + fifo_regs = self._read_word_array(_REG_CiFIFOCON + channel * _FIFO_OFFSET, 3) + timestamp_en = bool(fifo_regs[0] & (1 << 5)) + + ua = fifo_regs[2] & 0xFFF + a = ua + _RAM_START + + n = max_bytes + 8 + (4 if timestamp_en else 0) + pad = (4 - n % 4) % 4 + n = min(n + pad, 76) + + ba = self._read_byte_array(a, n) + + id_word = ba[0] | (ba[1] << 8) | (ba[2] << 16) | (ba[3] << 24) + ctrl_word = ba[4] | (ba[5] << 8) | (ba[6] << 16) | (ba[7] << 24) + + ide = bool(ctrl_word & (1 << 4)) + rtr = bool(ctrl_word & (1 << 5)) + dlc = ctrl_word & 0xF + + sid = id_word & 0x7FF + eid = (id_word >> 11) & 0x3FFFF + can_id = (eid | (sid << 18)) if ide else sid + + data_off = 12 if timestamp_en else 8 + n_data = dlc2len(dlc) + data = bytes(ba[data_off:data_off + n_data]) + + self._rx_channel_update(channel) + return can_id, int(ide), int(rtr), dlc, data + + # ----------------------------------------------------------------------- + # Error state + # ----------------------------------------------------------------------- + + def _error_state_get(self): + return self._read_byte(_REG_CiTREC + 2) & CAN_ERROR_ALL + + def _error_count_state_get(self): + w = self._read_word(_REG_CiTREC) + return (w >> 8) & 0xFF, w & 0xFF, (w >> 16) & CAN_ERROR_ALL + + # ----------------------------------------------------------------------- + # Low-power mode + # ----------------------------------------------------------------------- + + def _lpm_enable(self): + self._write_byte(_REG_OSC, self._read_byte(_REG_OSC) | 0x08) + + def _lpm_disable(self): + self._write_byte(_REG_OSC, self._read_byte(_REG_OSC) & ~0x08) + + # ----------------------------------------------------------------------- + # Internal init sequence + # ----------------------------------------------------------------------- + + @staticmethod + def _compat_speed(speedset): + if speedset > 0x100: + return speedset + _map = { + CAN_5KBPS: BITRATE(5000, 0), + CAN_10KBPS: BITRATE(10000, 0), + CAN_20KBPS: BITRATE(20000, 0), + CAN_25KBPS: BITRATE(25000, 0), + CAN_31K25BPS: BITRATE(31250, 0), + CAN_33KBPS: BITRATE(33000, 0), + CAN_40KBPS: BITRATE(40000, 0), + CAN_50KBPS: BITRATE(50000, 0), + CAN_80KBPS: BITRATE(80000, 0), + CAN_83K3BPS: BITRATE(83300, 0), + CAN_95KBPS: BITRATE(95000, 0), + CAN_100KBPS: BITRATE(100000, 0), + CAN_125KBPS: BITRATE(125000, 0), + CAN_200KBPS: BITRATE(200000, 0), + CAN_250KBPS: BITRATE(250000, 0), + CAN_500KBPS: BITRATE(500000, 0), + CAN_666KBPS: BITRATE(666000, 0), + CAN_800KBPS: BITRATE(800000, 0), + CAN_1000KBPS: BITRATE(1000000, 0), + } + return _map.get(speedset, BITRATE(500000, 0)) + + def _init(self, speedset, clock): + self._reset() + self._ecc_enable() + self._ram_init(0xFF) + self._configure(iso_crc=1, store_in_tef=0) + + self._tx_fifo_configure(_APP_TX_FIFO, fifo_size=7, payload_size=CAN_PLSIZE_64, priority=1) + self._rx_fifo_configure(_APP_RX_FIFO, fifo_size=15, payload_size=CAN_PLSIZE_64) + + self._filter_object_configure(CAN_FILTER0) + self._filter_mask_configure(CAN_FILTER0) + self._filter_to_fifo_link(CAN_FILTER0, _APP_RX_FIFO, True) + + self._bittime_configure(speedset, CAN_SSP_MODE_AUTO, clock) + + self._gpio_mode_configure(GPIO_MODE_INT, GPIO_MODE_INT) + self._rx_channel_event_enable(_APP_RX_FIFO, CAN_RX_FIFO_NOT_EMPTY_EVENT) + self._module_event_enable(CAN_TX_EVENT | CAN_RX_EVENT) + + self._op_mode_select(self._mode) + time.sleep_ms(5) + + original = self._read_byte(_REG_ECCCON) + test_val = original ^ 0x01 + self._write_byte(_REG_ECCCON, test_val) + time.sleep_ms(2) + readback = self._read_byte(_REG_ECCCON) + self._write_byte(_REG_ECCCON, original) + + if readback != test_val: + return CAN_FAILINIT + return CAN_OK + + # ----------------------------------------------------------------------- + # Internal send helper + # ----------------------------------------------------------------------- + + def _send_msg(self, data, can_id, ext, rtr): + n = len(data) + dlc = len2dlc(n) + + if ext: + id_word = ((can_id >> 18) & 0x7FF) | (((can_id & 0x3FFFF)) << 11) + else: + id_word = can_id & 0x7FF + + ctrl_word = dlc & 0xF + if ext: ctrl_word |= (1 << 4) + if rtr: ctrl_word |= (1 << 5) + ctrl_word |= (1 << 6) # BRS + if n > 8: ctrl_word |= (1 << 7) # FDF + + err = self._tx_message_queue(id_word, ctrl_word, bytes(data[:n])) + if err < 0: + if err == -2: return CAN_SENDMSGTIMEOUT + if err == -3: return CAN_FAILTX + return CAN_FAILINIT + return CAN_OK + + # ----------------------------------------------------------------------- + # Public API + # ----------------------------------------------------------------------- + + def begin(self, speedset, clockset=MCP2518FD_20MHz): + """Initialize the CAN controller. + + :param speedset: Baud rate — use CAN_125KBPS etc., or BITRATE(arb, factor) for CAN FD + :param clockset: Oscillator frequency — MCP2518FD_40MHz / _20MHz / _10MHz + :returns: CAN_OK on success, CAN_FAILINIT on failure + """ + speedset = self._compat_speed(speedset) + return self._init(speedset, clockset) + + def sendMsgBuf(self, can_id, ext, rtr_or_len, len_or_buf, buf=None, wait_sent=True): + """Send a CAN message. + + 4-arg form: sendMsgBuf(id, ext, length, data) — rtr=0 + 5-arg form: sendMsgBuf(id, ext, rtr, length, data) + """ + if buf is None: + length = rtr_or_len + data = len_or_buf + rtr = 0 + else: + rtr = rtr_or_len + length = len_or_buf + data = buf + return self._send_msg(data[:length], can_id, int(ext), int(rtr)) + + def trySendMsgBuf(self, can_id, ext, rtr, length, data, itx_buf=0xFF): + """Send without waiting. Returns CAN_OK or error code.""" + return self._send_msg(data[:length], can_id, int(ext), int(rtr)) + + def checkReceive(self): + """Returns CAN_MSGAVAIL if message waiting, CAN_NOMSG otherwise.""" + status = self._rx_channel_status_get(_APP_RX_FIFO) + return CAN_MSGAVAIL if (status & CAN_RX_FIFO_NOT_EMPTY) else CAN_NOMSG + + def readMsgBuf(self, buf=None): + """Read received message. Updates can_id, ext_flg, rtr. + + :param buf: optional bytearray to fill (ignored; returned as bytes) + :returns: (length, data_bytes) + """ + self.can_id, self.ext_flg, self.rtr, dlc, data = \ + self._rx_message_get(_APP_RX_FIFO, 64) + return len(data), data + + def readMsgBufID(self, buf=None): + """Read received message with ID. + + :returns: (can_id, length, data_bytes) + """ + length, data = self.readMsgBuf() + return self.can_id, length, data + + def getCanId(self): + """Return CAN ID of last received message.""" + return self.can_id + + def isRemoteRequest(self): + """Return 1 if last received message was RTR.""" + return self.rtr + + def isExtendedFrame(self): + """Return 1 if last received message had extended ID.""" + return self.ext_flg + + def checkError(self): + """Return current error state flags.""" + return self._error_state_get() + + def readRxTxStatus(self): + """Return RX FIFO event flags.""" + return self._rx_channel_event_get(_APP_RX_FIFO) + + def clearBufferTransmitIfFlags(self, flags=0): + """Clear TX attempt exhausted flag.""" + self._tx_channel_event_attempt_clear(_APP_TX_FIFO) + + def enableTxInterrupt(self, enable=True): + """Enable or disable TX interrupt.""" + if enable: + self._module_event_enable(CAN_TX_EVENT) + + def init_Mask(self, num, ext, ul_data): + """Configure acceptance mask for filter num. + + :param num: Filter number (0-31) + :param ext: 1 = extended ID mask, 0 = standard ID mask + :param ul_data: Mask bits + """ + self._op_mode_select(CAN_CONFIGURATION_MODE) + self._filter_mask_configure(num, msid=ul_data, mide=ext) + self._op_mode_select(self._mode) + + def init_Filt(self, num, ext, ul_data): + """Configure acceptance filter for filter num. + + :param num: Filter number (0-31) + :param ext: 1 = extended ID, 0 = standard ID + :param ul_data: Filter ID bits + """ + self._op_mode_select(CAN_CONFIGURATION_MODE) + if ext: + self._filter_object_configure(num, eid=ul_data, exide=1) + else: + self._filter_object_configure(num, sid=ul_data, exide=0) + self._op_mode_select(self._mode) + + def init_Filt_Mask(self, num, ext, f, m): + """Configure filter and mask together and link to RX FIFO. + + :param num: Filter number + :param ext: Extended ID flag (used as MEID value) + :param f: Filter SID value + :param m: Mask MSID value + """ + self._filter_disable(num) + self._filter_object_configure(num, sid=f, eid=ext) + self._filter_mask_configure(num, msid=m, meid=ext, mide=1) + self._filter_to_fifo_link(num, _APP_RX_FIFO, True) + + def setSleepWakeup(self, enable): + """Enable or disable low-power wake-up mode.""" + if enable: + self._lpm_enable() + else: + self._lpm_disable() + + def sleep(self): + """Put device into sleep mode.""" + if self.getMode() != CAN_SLEEP_MODE: + self._op_mode_select(CAN_SLEEP_MODE) + return CAN_OK + + def wake(self): + """Wake device from sleep, restoring previous mode.""" + if self.getMode() != self._mode: + self._op_mode_select(self._mode) + return CAN_OK + + def getMode(self): + """Return current operation mode.""" + return self._op_mode_get() + + def setMode(self, op_mode): + """Set operation mode without persisting (use __setMode to persist).""" + if op_mode != CAN_SLEEP_MODE: + self._mode = op_mode + return CAN_OK + + def __setMode(self, op_mode): + """Set and persist operation mode.""" + if op_mode != CAN_SLEEP_MODE: + self._mode = op_mode + return self._op_mode_select(self._mode) + + def mcpPinMode(self, pin, mode): + """Configure MCP GPIO pin as interrupt output or GPIO.""" + a = _REG_IOCON + 3 + d = self._read_byte(a) + if pin == GPIO_PIN_0: + d = (d & ~0x01) | (mode & 0x01) + elif pin == GPIO_PIN_1: + d = (d & ~0x02) | ((mode & 0x01) << 1) + self._write_byte(a, d) + + def mcpDigitalWrite(self, pin, state): + """Write HIGH or LOW to MCP GPIO output pin.""" + a = _REG_IOCON + 1 + d = self._read_byte(a) + if pin == GPIO_PIN_0: + d = (d & ~0x01) | (state & 0x01) + elif pin == GPIO_PIN_1: + d = (d & ~0x02) | ((state & 0x01) << 1) + self._write_byte(a, d) + + def mcpDigitalRead(self, pin): + """Read state of MCP GPIO input pin.""" + d = self._read_byte(_REG_IOCON + 2) + if pin == GPIO_PIN_0: + return d & 0x01 + if pin == GPIO_PIN_1: + return (d >> 1) & 0x01 + return -1 diff --git a/Communication/MCP2518/package.json b/Communication/MCP2518/package.json new file mode 100644 index 0000000..5d61b90 --- /dev/null +++ b/Communication/MCP2518/package.json @@ -0,0 +1,42 @@ +{ + "urls": [ + [ + "mcp2518fd.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/MCP2518/MCP2518/mcp2518fd.py" + ], + [ + "Examples/mcp2518fd-send.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/MCP2518/MCP2518/Examples/mcp2518fd-send.py" + ], + [ + "Examples/mcp2518fd-recv.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/MCP2518/MCP2518/Examples/mcp2518fd-recv.py" + ], + [ + "Examples/mcp2518fd-fd-send.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/MCP2518/MCP2518/Examples/mcp2518fd-fd-send.py" + ], + [ + "Examples/mcp2518fd-fd-recv.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/MCP2518/MCP2518/Examples/mcp2518fd-fd-recv.py" + ], + [ + "Examples/mcp2518fd-recv-filt.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/MCP2518/MCP2518/Examples/mcp2518fd-recv-filt.py" + ], + [ + "Examples/mcp2518fd-fd-recv-filt.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/MCP2518/MCP2518/Examples/mcp2518fd-fd-recv-filt.py" + ], + [ + "Examples/mcp2518fd-recv-int.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/MCP2518/MCP2518/Examples/mcp2518fd-recv-int.py" + ], + [ + "Examples/mcp2518fd-fd-recv-int.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/MCP2518/MCP2518/Examples/mcp2518fd-fd-recv-int.py" + ] + ], + "deps": [], + "version": "1.0" +} diff --git a/Communication/RFID/RFID/Examples/RFID-I2CExample.py b/Communication/RFID/RFID/Examples/RFID-I2CExample.py index c42c72f..a2bf5a2 100644 --- a/Communication/RFID/RFID/Examples/RFID-I2CExample.py +++ b/Communication/RFID/RFID/Examples/RFID-I2CExample.py @@ -5,7 +5,7 @@ # WORKS WITH: 125kHz RFID tag reader board: www.solde.red/333273 # LAST UPDATED: 2025-10-06 # Import needed libraries -from rfid import RFID +from RFID import RFID from machine import I2C, Pin i2c = I2C(0, scl=Pin(22), sda=Pin(21)) diff --git a/Communication/RFID/RFID/Examples/RFID-UARTExample.py b/Communication/RFID/RFID/Examples/RFID-UARTExample.py index dace2ad..6dd74b2 100644 --- a/Communication/RFID/RFID/Examples/RFID-UARTExample.py +++ b/Communication/RFID/RFID/Examples/RFID-UARTExample.py @@ -5,7 +5,7 @@ # WORKS WITH: 125kHz RFID tag reader board: www.solde.red/333154 # LAST UPDATED: 2025-10-06 # Import needed libraries -from rfid import RFID +from RFID import RFID from machine import I2C, Pin """ diff --git a/Communication/RFID/RFID/rfid.py b/Communication/RFID/RFID/rfid.py index 405eaf3..b9f5879 100644 --- a/Communication/RFID/RFID/rfid.py +++ b/Communication/RFID/RFID/rfid.py @@ -1,4 +1,4 @@ -# FILE: rfid.py +# FILE: RFID.py # AUTHOR: Josip Šimun Kuči @ Soldered # BRIEF: A MicroPython module for the RFID 125kHz reader board # LAST UPDATED: 2025-10-06 diff --git a/Communication/RFID/package.json b/Communication/RFID/package.json index 13dc980..3277b3a 100644 --- a/Communication/RFID/package.json +++ b/Communication/RFID/package.json @@ -1,8 +1,8 @@ { "urls": [ [ - "rfid.py", - "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/RFID/RFID/rfid.py" + "RFID.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/RFID/RFID/RFID.py" ], [ "Examples/RFID-UARTExample.py", diff --git a/Communication/TCA9548A/TCA9548A/Examples/tca9548a-multipleVsSingle.py b/Communication/TCA9548A/TCA9548A/Examples/tca9548a-multipleVsSingle.py new file mode 100644 index 0000000..e4f4f54 --- /dev/null +++ b/Communication/TCA9548A/TCA9548A/Examples/tca9548a-multipleVsSingle.py @@ -0,0 +1,49 @@ +# FILE: tca9548a-multipleVsSingle.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Demonstrates the difference between single and multiple channel operation. +# First opens each channel individually (closing before the next), then opens +# all channels one by one without closing, accumulating them. The register value +# is printed after each step to show the bitmask building up. +# WORKS WITH: I2C Multiplexer TCA9548A Breakout: www.solde.red/333077 +# LAST UPDATED: 2026-04-30 + +from machine import I2C, Pin +from tca9548a import TCA9548A +import time + +# If you aren't using the Qwiic connector, manually enter your I2C pins: +# i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +# mux = TCA9548A(i2c) + +# Initialize expander over Qwiic +mux = TCA9548A() + + +mux.closeAll() # Set a known base state on startup + +while True: + print("--- Opening single channels ---") + for channel in range(8): + print(f"Opening << Channel: {channel}") + mux.openChannel(channel) + time.sleep_ms(500) + + print(f"Register = Value: {mux.readRegister()}") + time.sleep_ms(500) + + print(f"Closing >> Channel: {channel}") + mux.closeChannel(channel) + time.sleep_ms(500) + + print("--- Opening multiple channels ---") + for channel in range(8): + print(f"Opening << Channel: {channel}") + mux.openChannel(channel) + time.sleep_ms(500) + + print(f"Register = Value: {mux.readRegister()}") + time.sleep_ms(500) + + print("Closing >> channels") + mux.closeAll() + time.sleep_ms(500) \ No newline at end of file diff --git a/Communication/TCA9548A/TCA9548A/Examples/tca9548a-portScanner.py b/Communication/TCA9548A/TCA9548A/Examples/tca9548a-portScanner.py new file mode 100644 index 0000000..22f8580 --- /dev/null +++ b/Communication/TCA9548A/TCA9548A/Examples/tca9548a-portScanner.py @@ -0,0 +1,40 @@ +# FILE: tca9548a-portScanner.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Scans all 8 TCA9548A channels for connected I2C devices. +# Opens each channel in turn, probes all 128 addresses, and prints any +# devices found. Repeats the full scan every 5 seconds. +# WORKS WITH: I2C Multiplexer TCA9548A Breakout: www.solde.red/333077 +# LAST UPDATED: 2026-04-30 + +from machine import I2C, Pin +from tca9548a import TCA9548A +import time + +# If you aren't using the Qwiic connector, manually enter your I2C pins: +# i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +# mux = TCA9548A(i2c) + +# Initialize expander over Qwiic +mux = TCA9548A() + +while True: + for channel in range(8): + mux.openChannel(channel) + print(f"TCA Port #{channel}") + + for addr in range(128): + # Don't report on the TCA9548A itself + if addr == 0x70: + continue + + try: + mux.i2c.writeto(addr, b"") + print(f" Found I2C 0x{addr:02X}") + except OSError: + pass # No device at this address + + mux.closeChannel(channel) + time.sleep(1) + + print("\nScan completed.") + time.sleep(5) \ No newline at end of file diff --git a/Communication/TCA9548A/TCA9548A/Examples/tca9548a-writeRegister.py b/Communication/TCA9548A/TCA9548A/Examples/tca9548a-writeRegister.py new file mode 100644 index 0000000..3fa555e --- /dev/null +++ b/Communication/TCA9548A/TCA9548A/Examples/tca9548a-writeRegister.py @@ -0,0 +1,46 @@ +# FILE: tca9548a-writeRegister.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Advanced example of how to write to the TCA9548A channel register directly. +# Opens channels 0, 3, 4 & 7 simultaneously using a single writeRegister() call, +# reads back the register to verify, then closes all channels and halts. +# WORKS WITH: I2C Multiplexer TCA9548A Breakout: www.solde.red/333077 +# LAST UPDATED: 2026-04-30 + +from machine import I2C, Pin +from tca9548a import TCA9548A, TCA_CHANNEL_0, TCA_CHANNEL_3, TCA_CHANNEL_4, TCA_CHANNEL_7 +import time + +# If you aren't using the Qwiic connector, manually enter your I2C pins: +# i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +# mux = TCA9548A(i2c) + +# Initialize expander over Qwiic +mux = TCA9548A() + + +mux.closeAll() # Set a known base state on startup + +print("\n--- Calculate Channel Byte (153) ---") +time.sleep_ms(500) + +print("Adding Channels 0, 3, 4 & 7") +buff = 0x00 +buff |= TCA_CHANNEL_0 +buff |= TCA_CHANNEL_3 +buff |= TCA_CHANNEL_4 +buff |= TCA_CHANNEL_7 # Enable channels in buff variable +time.sleep_ms(500) + +print(f"Writing Register: {buff}") +mux.writeRegister(buff) # Write buff variable to register +time.sleep_ms(500) + +print(f"Reading Register: {mux.readRegister()}") # Read data from register +time.sleep_ms(500) + +print("Closing Channels") +mux.closeAll() + +# Halt — equivalent to Arduino's while(1==1) +while True: + pass \ No newline at end of file diff --git a/Communication/TCA9548A/TCA9548A/tca9548a.py b/Communication/TCA9548A/TCA9548A/tca9548a.py new file mode 100644 index 0000000..8c20c99 --- /dev/null +++ b/Communication/TCA9548A/TCA9548A/tca9548a.py @@ -0,0 +1,120 @@ +# FILE: tca9548a.py +# AUTHOR: Ported to MicroPython — styled after pcal6416a.py by Fran Fodor @ Soldered +# BRIEF: MicroPython library for the TCA9548A 8-channel I2C multiplexer +# LAST UPDATED: 2026-04-30 + +from machine import I2C, Pin +from os import uname + +# I2C address (A0/A1/A2 pins set bits 0-2 of the address) +TCA9548A_I2C_ADDR = 0x70 # Default: A0=A1=A2=GND + +# Channel bitmasks for writeRegister() +TCA_CHANNEL_0 = 0x01 +TCA_CHANNEL_1 = 0x02 +TCA_CHANNEL_2 = 0x04 +TCA_CHANNEL_3 = 0x08 +TCA_CHANNEL_4 = 0x10 +TCA_CHANNEL_5 = 0x20 +TCA_CHANNEL_6 = 0x40 +TCA_CHANNEL_7 = 0x80 + + +class TCA9548A: + """ + MicroPython class for the TCA9548A 8-channel I2C multiplexer. + Connects one I2C master to up to 8 downstream I2C buses via channel selection. + """ + + def __init__(self, i2c=None, address=TCA9548A_I2C_ADDR): + """ + Initialize the TCA9548A multiplexer. + + :param i2c: Initialized I2C object (optional, auto-detected on known boards) + :param address: I2C address of the device (default 0x70) + """ + if i2c is not None: + self.i2c = i2c + else: + if uname().sysname in ("esp32", "esp8266", "Soldered Dasduino CONNECTPLUS"): + self.i2c = I2C(0, scl=Pin(22), sda=Pin(21)) + else: + raise Exception("Board not recognized, enter I2C pins manually") + + self.address = address + + # Shadow register mirrors the chip's channel control byte + # Power-on reset default: all channels disabled + self._channels = 0x00 + + def _writeByte(self, val): + try: + self.i2c.writeto(self.address, bytes([val])) + return True + except: + return False + + def _readByte(self): + try: + data = self.i2c.readfrom(self.address, 1) + return True, data[0] + except: + return False, 0 + + def openChannel(self, channel): + """ + Connect a downstream channel to the upstream I2C bus. + Multiple channels can be open simultaneously. + + :param channel: Channel number 0-7 + """ + if channel > 7: + return + self._channels |= 1 << channel + self._writeByte(self._channels) + + def closeChannel(self, channel): + """ + Disconnect a downstream channel from the upstream I2C bus. + + :param channel: Channel number 0-7 + """ + if channel > 7: + return + self._channels ^= 1 << channel + self._writeByte(self._channels) + + def closeAll(self): + """ + Disconnect all downstream channels from the upstream I2C bus. + """ + self._channels = 0x00 + self._writeByte(self._channels) + + def openAll(self): + """ + Connect all downstream channels to the upstream I2C bus simultaneously. + """ + self._channels = 0xFF + self._writeByte(self._channels) + + def writeRegister(self, value): + """ + Directly write a value to the TCA9548A channel control register. + Each bit corresponds to one channel (bit 0 = channel 0, etc.). + + :param value: 8-bit bitmask of channels to enable (use TCA_CHANNEL_x constants) + """ + self._channels = value + self._writeByte(self._channels) + + def readRegister(self): + """ + Read the current state of the TCA9548A channel control register. + + :return: 8-bit channel bitmask, or 255 on I2C error + """ + ok, val = self._readByte() + if not ok: + return 255 + return val \ No newline at end of file diff --git a/Communication/TCA9548A/package.json b/Communication/TCA9548A/package.json new file mode 100644 index 0000000..2404beb --- /dev/null +++ b/Communication/TCA9548A/package.json @@ -0,0 +1,22 @@ +{ + "urls": [ + [ + "tca9548a.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/TCA9548A/TCA9548A/tca9548a.py" + ], + [ + "Examples/tca9548a-writeRegister.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/TCA9548A/TCA9548A/Examples/tca9548a-writeRegister.py" + ], + [ + "Examples/tca9548a-portScanner.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/TCA9548A/TCA9548A/Examples/tca9548a-portScanner.py" + ], + [ + "Examples/tca9548a-multipleVsSingle.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/TCA9548A/TCA9548A/Examples/tca9548a-multipleVsSingle.py" + ] + ], + "deps": [], + "version": "1.0" +} diff --git a/Communication/uBloxGNSS/package.json b/Communication/uBloxGNSS/package.json new file mode 100644 index 0000000..93b213e --- /dev/null +++ b/Communication/uBloxGNSS/package.json @@ -0,0 +1,74 @@ +{ + "urls": [ + [ + "gnss_ublox.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/uBloxGNSS/uBloxGNSS/gnss_ublox.py" + ], + [ + "Examples/GetPosition.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/uBloxGNSS/uBloxGNSS/Examples/GetPosition.py" + ], + [ + "Examples/AltitudeMSL.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/uBloxGNSS/uBloxGNSS/Examples/AltitudeMSL.py" + ], + [ + "Examples/FixType.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/uBloxGNSS/uBloxGNSS/Examples/FixType.py" + ], + [ + "Examples/GetDateTime.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/uBloxGNSS/uBloxGNSS/Examples/GetDateTime.py" + ], + [ + "Examples/SpeedHeadingPrecision.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/uBloxGNSS/uBloxGNSS/Examples/SpeedHeadingPrecision.py" + ], + [ + "Examples/NMEARead.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/uBloxGNSS/uBloxGNSS/Examples/NMEARead.py" + ], + [ + "Examples/GetProtocolVersion.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/uBloxGNSS/uBloxGNSS/Examples/GetProtocolVersion.py" + ], + [ + "Examples/ModuleInfo.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/uBloxGNSS/uBloxGNSS/Examples/ModuleInfo.py" + ], + [ + "Examples/MeasurementAndNavigationRate.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/uBloxGNSS/uBloxGNSS/Examples/MeasurementAndNavigationRate.py" + ], + [ + "Examples/DynamicModel.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/uBloxGNSS/uBloxGNSS/Examples/DynamicModel.py" + ], + [ + "Examples/PowerOff.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/uBloxGNSS/uBloxGNSS/Examples/PowerOff.py" + ], + [ + "Examples/PowerSaveMode.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/uBloxGNSS/uBloxGNSS/Examples/PowerSaveMode.py" + ], + [ + "Examples/ChangeI2CAddress.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/uBloxGNSS/uBloxGNSS/Examples/ChangeI2CAddress.py" + ], + [ + "Examples/GetUnixEpochAndMicros.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/uBloxGNSS/uBloxGNSS/Examples/GetUnixEpochAndMicros.py" + ], + [ + "Examples/GetLeapSecondInfo.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/uBloxGNSS/uBloxGNSS/Examples/GetLeapSecondInfo.py" + ], + [ + "Examples/FactoryDefaultviaI2C.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Communication/uBloxGNSS/uBloxGNSS/Examples/FactoryDefaultviaI2C.py" + ] + ], + "deps": [], + "version": "1.0" +} diff --git a/Communication/uBloxGNSS/uBloxGNSS/Examples/AltitudeMSL.py b/Communication/uBloxGNSS/uBloxGNSS/Examples/AltitudeMSL.py new file mode 100644 index 0000000..8275d3b --- /dev/null +++ b/Communication/uBloxGNSS/uBloxGNSS/Examples/AltitudeMSL.py @@ -0,0 +1,28 @@ +# FILE: AltitudeMSL.py +# AUTHOR: Josip Šimun Kuči @ Soldered +# BRIEF: Read altitude above mean sea level from a u-blox GNSS module +# LAST UPDATED: 2026-05-22 + +from machine import I2C, Pin +import time +from gnss_ublox import SolderedGNSS + +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +gnss = SolderedGNSS() + +if not gnss.begin(i2c): + print("u-blox GNSS not detected. Check wiring.") + raise SystemExit + +print("u-blox GNSS ready.\n") + +while True: + if gnss.getPVT(): + alt_ellipsoid = gnss.getAltitude() + alt_msl = gnss.getAltitudeMSL() + print(f"Altitude (ellipsoid): {alt_ellipsoid / 1000:.3f} m " + f"Altitude (MSL): {alt_msl / 1000:.3f} m") + else: + print("Waiting for valid PVT data...") + + time.sleep(1) diff --git a/Communication/uBloxGNSS/uBloxGNSS/Examples/ChangeI2CAddress.py b/Communication/uBloxGNSS/uBloxGNSS/Examples/ChangeI2CAddress.py new file mode 100644 index 0000000..f9bfa09 --- /dev/null +++ b/Communication/uBloxGNSS/uBloxGNSS/Examples/ChangeI2CAddress.py @@ -0,0 +1,36 @@ +# FILE: ChangeI2CAddress.py +# AUTHOR: Josip Šimun Kuči @ Soldered +# BRIEF: Change the I2C address of a u-blox GNSS module and reconnect +# LAST UPDATED: 2026-05-22 + +from machine import I2C, Pin +import time +from gnss_ublox import SolderedGNSS + +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +gnss = SolderedGNSS() + +OLD_ADDR = 0x42 +NEW_ADDR = 0x43 + +if not gnss.begin(i2c, addr=OLD_ADDR): + print(f"u-blox GNSS not found at 0x{OLD_ADDR:02X}. Check wiring.") + raise SystemExit + +print(f"Connected at 0x{OLD_ADDR:02X}.") +print(f"Changing I2C address to 0x{NEW_ADDR:02X}...") + +if gnss.setI2CAddress(NEW_ADDR): + print("Address change command sent. Reconnecting...") + time.sleep_ms(500) + + gnss2 = SolderedGNSS() + if gnss2.begin(i2c, addr=NEW_ADDR): + print(f"Successfully reconnected at 0x{NEW_ADDR:02X}.") + # Restore original address + gnss2.setI2CAddress(OLD_ADDR) + print(f"Restored address to 0x{OLD_ADDR:02X}.") + else: + print(f"Could not connect at 0x{NEW_ADDR:02X}.") +else: + print("Failed to send address change command.") diff --git a/Communication/uBloxGNSS/uBloxGNSS/Examples/DynamicModel.py b/Communication/uBloxGNSS/uBloxGNSS/Examples/DynamicModel.py new file mode 100644 index 0000000..d0253b9 --- /dev/null +++ b/Communication/uBloxGNSS/uBloxGNSS/Examples/DynamicModel.py @@ -0,0 +1,45 @@ +# FILE: DynamicModel.py +# AUTHOR: Josip Šimun Kuči @ Soldered +# BRIEF: Read and set the dynamic platform model on a u-blox GNSS module +# LAST UPDATED: 2026-05-22 + +from machine import I2C, Pin +import time +from gnss_ublox import SolderedGNSS, DYN_MODEL_PORTABLE, DYN_MODEL_PEDESTRIAN, DYN_MODEL_AUTOMOTIVE + +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +gnss = SolderedGNSS() + +if not gnss.begin(i2c): + print("u-blox GNSS not detected. Check wiring.") + raise SystemExit + +print("u-blox GNSS ready.\n") + +MODEL_NAMES = { + 0: "Portable", + 2: "Stationary", + 3: "Pedestrian", + 4: "Automotive", + 5: "Sea", + 6: "Airborne <1g", + 7: "Airborne <2g", + 8: "Airborne <4g", + 9: "Wrist", + 10: "Bike", +} + +current = gnss.getDynamicModel() +print(f"Current dynamic model: {current} ({MODEL_NAMES.get(current, 'Unknown')})") + +# Switch to pedestrian model +gnss.setDynamicModel(DYN_MODEL_PEDESTRIAN) +time.sleep_ms(100) + +current = gnss.getDynamicModel() +print(f"Updated dynamic model : {current} ({MODEL_NAMES.get(current, 'Unknown')})") + +# Restore portable model +gnss.setDynamicModel(DYN_MODEL_PORTABLE) +time.sleep_ms(100) +print("Restored to Portable model.") diff --git a/Communication/uBloxGNSS/uBloxGNSS/Examples/FactoryDefaultviaI2C.py b/Communication/uBloxGNSS/uBloxGNSS/Examples/FactoryDefaultviaI2C.py new file mode 100644 index 0000000..70328ca --- /dev/null +++ b/Communication/uBloxGNSS/uBloxGNSS/Examples/FactoryDefaultviaI2C.py @@ -0,0 +1,29 @@ +# FILE: FactoryDefaultviaI2C.py +# AUTHOR: Josip Šimun Kuči @ Soldered +# BRIEF: Reset a u-blox GNSS module to factory defaults over I2C +# LAST UPDATED: 2026-05-22 + +from machine import I2C, Pin +import time +from gnss_ublox import SolderedGNSS + +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +gnss = SolderedGNSS() + +if not gnss.begin(i2c): + print("u-blox GNSS not detected. Check wiring.") + raise SystemExit + +print("u-blox GNSS ready.") +print("Sending factory reset command...") + +gnss.factoryReset() + +# Module resets and re-initialises — wait before reconnecting +print("Waiting for module to restart...") +time.sleep(3) + +if gnss.begin(i2c): + print("Module successfully reset to factory defaults and is ready.") +else: + print("Could not reconnect after factory reset.") diff --git a/Communication/uBloxGNSS/uBloxGNSS/Examples/FixType.py b/Communication/uBloxGNSS/uBloxGNSS/Examples/FixType.py new file mode 100644 index 0000000..67047e2 --- /dev/null +++ b/Communication/uBloxGNSS/uBloxGNSS/Examples/FixType.py @@ -0,0 +1,37 @@ +# FILE: FixType.py +# AUTHOR: Josip Šimun Kuči @ Soldered +# BRIEF: Read and display the current GNSS fix type +# LAST UPDATED: 2026-05-22 + +from machine import I2C, Pin +import time +from gnss_ublox import SolderedGNSS + +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +gnss = SolderedGNSS() + +if not gnss.begin(i2c): + print("u-blox GNSS not detected. Check wiring.") + raise SystemExit + +FIX_NAMES = { + 0: "No fix", + 1: "Dead reckoning", + 2: "2D fix", + 3: "3D fix", + 4: "GNSS + dead reckoning", + 5: "Time only", +} + +print("u-blox GNSS ready.\n") + +while True: + if gnss.getPVT(): + fix = gnss.getFixType() + siv = gnss.getSIV() + label = FIX_NAMES.get(fix, f"Unknown ({fix})") + print(f"Fix type: {fix} ({label}) Satellites in view: {siv}") + else: + print("Waiting for PVT...") + + time.sleep(1) diff --git a/Communication/uBloxGNSS/uBloxGNSS/Examples/GetDateTime.py b/Communication/uBloxGNSS/uBloxGNSS/Examples/GetDateTime.py new file mode 100644 index 0000000..569547c --- /dev/null +++ b/Communication/uBloxGNSS/uBloxGNSS/Examples/GetDateTime.py @@ -0,0 +1,36 @@ +# FILE: GetDateTime.py +# AUTHOR: Josip Šimun Kuči @ Soldered +# BRIEF: Read UTC date and time from a u-blox GNSS module +# LAST UPDATED: 2026-05-22 + +from machine import I2C, Pin +import time +from gnss_ublox import SolderedGNSS + +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +gnss = SolderedGNSS() + +if not gnss.begin(i2c): + print("u-blox GNSS not detected. Check wiring.") + raise SystemExit + +print("u-blox GNSS ready.\n") + +while True: + if gnss.getPVT(): + if gnss.getDateValid() and gnss.getTimeValid(): + year = gnss.getYear() + month = gnss.getMonth() + day = gnss.getDay() + hour = gnss.getHour() + minute = gnss.getMinute() + second = gnss.getSecond() + nano = gnss.getNanosecond() + print(f"{year:04d}-{month:02d}-{day:02d} " + f"{hour:02d}:{minute:02d}:{second:02d}.{nano // 1000000:03d} UTC") + else: + print("Date/time not yet valid") + else: + print("Waiting for PVT...") + + time.sleep(1) diff --git a/Communication/uBloxGNSS/uBloxGNSS/Examples/GetLeapSecondInfo.py b/Communication/uBloxGNSS/uBloxGNSS/Examples/GetLeapSecondInfo.py new file mode 100644 index 0000000..0fd032f --- /dev/null +++ b/Communication/uBloxGNSS/uBloxGNSS/Examples/GetLeapSecondInfo.py @@ -0,0 +1,37 @@ +# FILE: GetLeapSecondInfo.py +# AUTHOR: Josip Šimun Kuči @ Soldered +# BRIEF: Read leap second event information from a u-blox GNSS module +# LAST UPDATED: 2026-05-22 + +from machine import I2C, Pin +from gnss_ublox import SolderedGNSS, LS_SRC_GPS, LS_SRC_GLONASS, LS_SRC_BEIDOU, LS_SRC_GALILEO + +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +gnss = SolderedGNSS() + +if not gnss.begin(i2c): + print("u-blox GNSS not detected. Check wiring.") + raise SystemExit + +print("u-blox GNSS ready.\n") + +SOURCE_NAMES = { + 0: "Default (firmware)", + 1: "GLONASS", + 2: "GPS", + 3: "SBAS", + 4: "BeiDou", + 5: "Galileo", + 6: "Aided", + 7: "Configured", + 255: "Unknown", +} + +if gnss.getLeapSecondEvent(): + li, timeToEvent = gnss.getLeapIndicator() + currLs, src = gnss.getCurrentLeapSeconds() + + print(f"Current leap seconds : {currLs} (source: {SOURCE_NAMES.get(src, src)})") + print(f"Leap indicator : {li} Time to next event: {timeToEvent} s") +else: + print("Could not retrieve leap second information.") diff --git a/Communication/uBloxGNSS/uBloxGNSS/Examples/GetPosition.py b/Communication/uBloxGNSS/uBloxGNSS/Examples/GetPosition.py new file mode 100644 index 0000000..31e8ff1 --- /dev/null +++ b/Communication/uBloxGNSS/uBloxGNSS/Examples/GetPosition.py @@ -0,0 +1,32 @@ +# FILE: GetPosition.py +# AUTHOR: Josip Šimun Kuči @ Soldered +# BRIEF: Read latitude, longitude, altitude, and fix info from a u-blox GNSS module +# LAST UPDATED: 2026-05-22 + +from machine import I2C, Pin +import time +from gnss_ublox import SolderedGNSS + +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +gnss = SolderedGNSS() + +if not gnss.begin(i2c): + print("u-blox GNSS not detected. Check wiring.") + raise SystemExit + +print("u-blox GNSS ready.\n") + +while True: + if gnss.getPVT(): + lat = gnss.getLatitude() + lon = gnss.getLongitude() + alt = gnss.getAltitude() + siv = gnss.getSIV() + fix = gnss.getFixType() + + print(f"Lat: {lat / 1e7:.7f} Lon: {lon / 1e7:.7f} Alt: {alt / 1000:.3f} m " + f"Fix: {fix} Satellites: {siv}") + else: + print("Waiting for valid PVT data...") + + time.sleep(1) diff --git a/Communication/uBloxGNSS/uBloxGNSS/Examples/GetProtocolVersion.py b/Communication/uBloxGNSS/uBloxGNSS/Examples/GetProtocolVersion.py new file mode 100644 index 0000000..c726808 --- /dev/null +++ b/Communication/uBloxGNSS/uBloxGNSS/Examples/GetProtocolVersion.py @@ -0,0 +1,25 @@ +# FILE: GetProtocolVersion.py +# AUTHOR: Josip Šimun Kuči @ Soldered +# BRIEF: Read the UBX protocol version from a u-blox GNSS module +# LAST UPDATED: 2026-05-22 + +from machine import I2C, Pin +import time +from gnss_ublox import SolderedGNSS + +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +gnss = SolderedGNSS() + +if not gnss.begin(i2c): + print("u-blox GNSS not detected. Check wiring.") + raise SystemExit + +print("u-blox GNSS ready.\n") + +high = gnss.getProtocolVersionHigh() +low = gnss.getProtocolVersionLow() + +if high is not None: + print(f"Protocol version: {high}.{low:02d}") +else: + print("Could not retrieve protocol version.") diff --git a/Communication/uBloxGNSS/uBloxGNSS/Examples/GetUnixEpochAndMicros.py b/Communication/uBloxGNSS/uBloxGNSS/Examples/GetUnixEpochAndMicros.py new file mode 100644 index 0000000..80cd0e4 --- /dev/null +++ b/Communication/uBloxGNSS/uBloxGNSS/Examples/GetUnixEpochAndMicros.py @@ -0,0 +1,29 @@ +# FILE: GetUnixEpochAndMicros.py +# AUTHOR: Josip Šimun Kuči @ Soldered +# BRIEF: Read Unix epoch time and sub-second microseconds from a u-blox GNSS module +# LAST UPDATED: 2026-05-22 + +from machine import I2C, Pin +import time +from gnss_ublox import SolderedGNSS + +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +gnss = SolderedGNSS() + +if not gnss.begin(i2c): + print("u-blox GNSS not detected. Check wiring.") + raise SystemExit + +print("u-blox GNSS ready.\n") + +while True: + if gnss.getPVT(): + if gnss.getTimeFullyResolved(): + epoch, micros = gnss.getUnixEpoch() + print(f"Unix epoch: {epoch} Microseconds: {micros:06d}") + else: + print("Time not fully resolved yet") + else: + print("Waiting for PVT...") + + time.sleep(1) diff --git a/Communication/uBloxGNSS/uBloxGNSS/Examples/MeasurementAndNavigationRate.py b/Communication/uBloxGNSS/uBloxGNSS/Examples/MeasurementAndNavigationRate.py new file mode 100644 index 0000000..c987b64 --- /dev/null +++ b/Communication/uBloxGNSS/uBloxGNSS/Examples/MeasurementAndNavigationRate.py @@ -0,0 +1,31 @@ +# FILE: MeasurementAndNavigationRate.py +# AUTHOR: Josip Šimun Kuči @ Soldered +# BRIEF: Read and set measurement and navigation rates on a u-blox GNSS module +# LAST UPDATED: 2026-05-22 + +from machine import I2C, Pin +import time +from gnss_ublox import SolderedGNSS + +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +gnss = SolderedGNSS() + +if not gnss.begin(i2c): + print("u-blox GNSS not detected. Check wiring.") + raise SystemExit + +print("u-blox GNSS ready.\n") + +measRate = gnss.getMeasurementRate() +navRate = gnss.getNavigationRate() +print(f"Current measurement rate: {measRate} ms Navigation rate: {navRate} cycles") + +# Set 5 Hz (200 ms measurement period, 1 nav solution per measurement) +gnss.setNavigationFrequency(5) +time.sleep_ms(100) + +measRate = gnss.getMeasurementRate() +navRate = gnss.getNavigationRate() +navFreq = gnss.getNavigationFrequency() +print(f"Updated measurement rate : {measRate} ms Navigation rate: {navRate} cycles " + f"Nav frequency: {navFreq} Hz") diff --git a/Communication/uBloxGNSS/uBloxGNSS/Examples/ModuleInfo.py b/Communication/uBloxGNSS/uBloxGNSS/Examples/ModuleInfo.py new file mode 100644 index 0000000..c180994 --- /dev/null +++ b/Communication/uBloxGNSS/uBloxGNSS/Examples/ModuleInfo.py @@ -0,0 +1,25 @@ +# FILE: ModuleInfo.py +# AUTHOR: Josip Šimun Kuči @ Soldered +# BRIEF: Read software version, hardware version and extension strings from a u-blox GNSS module +# LAST UPDATED: 2026-05-22 + +from machine import I2C, Pin +from gnss_ublox import SolderedGNSS + +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +gnss = SolderedGNSS() + +if not gnss.begin(i2c): + print("u-blox GNSS not detected. Check wiring.") + raise SystemExit + +print("u-blox GNSS ready.\n") + +info = gnss.getModuleInfo() +if info: + print(f"SW version : {info.get('swVersion', 'N/A')}") + print(f"HW version : {info.get('hwVersion', 'N/A')}") + for i, ext in enumerate(info.get('extensions', [])): + print(f"Extension {i}: {ext}") +else: + print("Could not retrieve module info.") diff --git a/Communication/uBloxGNSS/uBloxGNSS/Examples/NMEARead.py b/Communication/uBloxGNSS/uBloxGNSS/Examples/NMEARead.py new file mode 100644 index 0000000..d8cce42 --- /dev/null +++ b/Communication/uBloxGNSS/uBloxGNSS/Examples/NMEARead.py @@ -0,0 +1,27 @@ +# FILE: NMEARead.py +# AUTHOR: Josip Šimun Kuči @ Soldered +# BRIEF: Read and print raw NMEA sentences from a u-blox GNSS module +# LAST UPDATED: 2026-05-22 + +from machine import I2C, Pin +import time +from gnss_ublox import SolderedGNSS, COM_TYPE_NMEA + +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +gnss = SolderedGNSS() + +if not gnss.begin(i2c): + print("u-blox GNSS not detected. Check wiring.") + raise SystemExit + +# Enable NMEA output on I2C port +gnss.setI2COutput(COM_TYPE_NMEA) + +print("u-blox GNSS ready. Printing NMEA sentences:\n") + +while True: + gnss.checkUblox() + sentences = gnss.readNMEA() + for sentence in sentences: + print(sentence) + time.sleep_ms(50) diff --git a/Communication/uBloxGNSS/uBloxGNSS/Examples/PowerOff.py b/Communication/uBloxGNSS/uBloxGNSS/Examples/PowerOff.py new file mode 100644 index 0000000..a4b1237 --- /dev/null +++ b/Communication/uBloxGNSS/uBloxGNSS/Examples/PowerOff.py @@ -0,0 +1,30 @@ +# FILE: PowerOff.py +# AUTHOR: Josip Šimun Kuči @ Soldered +# BRIEF: Put the u-blox GNSS module into backup mode for a set duration +# LAST UPDATED: 2026-05-22 + +from machine import I2C, Pin +import time +from gnss_ublox import SolderedGNSS + +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +gnss = SolderedGNSS() + +if not gnss.begin(i2c): + print("u-blox GNSS not detected. Check wiring.") + raise SystemExit + +print("u-blox GNSS ready.") +print("Powering off for 10 seconds...") + +# Power off for 10 000 ms; the module wakes up automatically after the duration +gnss.powerOff(10000) + +print("Power-off command sent. Waiting for module to wake up...") +time.sleep(12) + +# Re-initialise after wake-up +if gnss.begin(i2c): + print("Module woke up and is ready again.") +else: + print("Could not communicate with module after wake-up.") diff --git a/Communication/uBloxGNSS/uBloxGNSS/Examples/PowerSaveMode.py b/Communication/uBloxGNSS/uBloxGNSS/Examples/PowerSaveMode.py new file mode 100644 index 0000000..9221207 --- /dev/null +++ b/Communication/uBloxGNSS/uBloxGNSS/Examples/PowerSaveMode.py @@ -0,0 +1,31 @@ +# FILE: PowerSaveMode.py +# AUTHOR: Josip Šimun Kuči @ Soldered +# BRIEF: Enable and disable continuous power-save (1 Hz cyclic tracking) on a u-blox GNSS module +# LAST UPDATED: 2026-05-22 + +from machine import I2C, Pin +import time +from gnss_ublox import SolderedGNSS + +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +gnss = SolderedGNSS() + +if not gnss.begin(i2c): + print("u-blox GNSS not detected. Check wiring.") + raise SystemExit + +print("u-blox GNSS ready.\n") + +print(f"Power save mode active: {gnss.getPowerSaveMode()}") + +print("Enabling power save mode...") +gnss.powerSaveMode(True) +time.sleep_ms(100) +print(f"Power save mode active: {gnss.getPowerSaveMode()}") + +time.sleep(5) + +print("Disabling power save mode...") +gnss.powerSaveMode(False) +time.sleep_ms(100) +print(f"Power save mode active: {gnss.getPowerSaveMode()}") diff --git a/Communication/uBloxGNSS/uBloxGNSS/Examples/SpeedHeadingPrecision.py b/Communication/uBloxGNSS/uBloxGNSS/Examples/SpeedHeadingPrecision.py new file mode 100644 index 0000000..4250683 --- /dev/null +++ b/Communication/uBloxGNSS/uBloxGNSS/Examples/SpeedHeadingPrecision.py @@ -0,0 +1,36 @@ +# FILE: SpeedHeadingPrecision.py +# AUTHOR: Josip Šimun Kuči @ Soldered +# BRIEF: Read ground speed, heading, and accuracy estimates from a u-blox GNSS module +# LAST UPDATED: 2026-05-22 + +from machine import I2C, Pin +import time +from gnss_ublox import SolderedGNSS + +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +gnss = SolderedGNSS() + +if not gnss.begin(i2c): + print("u-blox GNSS not detected. Check wiring.") + raise SystemExit + +print("u-blox GNSS ready.\n") + +while True: + if gnss.getPVT(): + speed = gnss.getGroundSpeed() # mm/s + heading = gnss.getHeading() # degrees * 1e-5 + sAcc = gnss.getSpeedAccuracy() # mm/s + hAcc = gnss.getHeadingAccuracy() # degrees * 1e-5 + hPosAcc = gnss.getHorizontalAccuracy() # mm + vPosAcc = gnss.getVerticalAccuracy() # mm + pDOP = gnss.getPDOP() # * 0.01 + + print(f"Speed: {speed / 1000:.3f} m/s (±{sAcc / 1000:.3f}) " + f"Heading: {heading / 1e5:.5f}° (±{hAcc / 1e5:.5f}) " + f"H-Acc: {hPosAcc / 1000:.3f} m V-Acc: {vPosAcc / 1000:.3f} m " + f"pDOP: {pDOP * 0.01:.2f}") + else: + print("Waiting for PVT...") + + time.sleep(1) diff --git a/Communication/uBloxGNSS/uBloxGNSS/gnss_ublox.py b/Communication/uBloxGNSS/uBloxGNSS/gnss_ublox.py new file mode 100644 index 0000000..cd7965f --- /dev/null +++ b/Communication/uBloxGNSS/uBloxGNSS/gnss_ublox.py @@ -0,0 +1,833 @@ +# FILE: gnss_ublox.py +# AUTHOR: Josip Šimun Kuči @ Soldered +# BRIEF: MicroPython driver for Soldered u-blox GPS/GNSS breakouts +# LAST UPDATED: 2026-05-22 + +import time +import struct + +# --------------------------------------------------------------------------- +# Port identifiers +# --------------------------------------------------------------------------- +COM_PORT_I2C = 0 +COM_PORT_UART1 = 1 +COM_PORT_USB = 3 + +# Output / input protocol bitmask flags +COM_TYPE_UBX = 0x01 +COM_TYPE_NMEA = 0x02 +COM_TYPE_RTCM3 = 0x20 + +# --------------------------------------------------------------------------- +# Dynamic platform models +# --------------------------------------------------------------------------- +DYN_MODEL_PORTABLE = 0 +DYN_MODEL_STATIONARY = 2 +DYN_MODEL_PEDESTRIAN = 3 +DYN_MODEL_AUTOMOTIVE = 4 +DYN_MODEL_SEA = 5 +DYN_MODEL_AIRBORNE1g = 6 +DYN_MODEL_AIRBORNE2g = 7 +DYN_MODEL_AIRBORNE4g = 8 +DYN_MODEL_WRIST = 9 +DYN_MODEL_BIKE = 10 +DYN_MODEL_UNKNOWN = 255 + +# --------------------------------------------------------------------------- +# Power management wakeup sources (UBX-RXM-PMREQ) +# --------------------------------------------------------------------------- +VAL_RXM_PMREQ_WAKEUPSOURCE_UARTRX = 0x00000008 +VAL_RXM_PMREQ_WAKEUPSOURCE_EXTINT0 = 0x00000020 +VAL_RXM_PMREQ_WAKEUPSOURCE_EXTINT1 = 0x00000040 +VAL_RXM_PMREQ_WAKEUPSOURCE_SPICS = 0x00000080 + +# --------------------------------------------------------------------------- +# Configuration save sub-section masks (UBX-CFG-CFG) +# --------------------------------------------------------------------------- +VAL_CFG_SUBSEC_IOPORT = 0x00000001 +VAL_CFG_SUBSEC_MSGCONF = 0x00000002 +VAL_CFG_SUBSEC_INFMSG = 0x00000004 +VAL_CFG_SUBSEC_NAVCONF = 0x00000008 +VAL_CFG_SUBSEC_RXMCONF = 0x00000010 + +# --------------------------------------------------------------------------- +# Leap second source identifiers +# --------------------------------------------------------------------------- +LS_SRC_DEFAULT = 0 +LS_SRC_GLONASS = 1 +LS_SRC_GPS = 2 +LS_SRC_SBAS = 3 +LS_SRC_BEIDOU = 4 +LS_SRC_GALILEO = 5 +LS_SRC_AIDED = 6 +LS_SRC_CONFIGURED = 7 +LS_SRC_UNKNOWN = 255 + +# --------------------------------------------------------------------------- +# Unix epoch look-up tables (same values as SparkFun C++ library) +# --------------------------------------------------------------------------- +_DAYS_FROM_1970_TO_2020 = 18262 + +_DAYS_SINCE_2020 = ( + 0, 366, 731, 1096, 1461, 1827, 2192, 2557, 2922, 3288, + 3653, 4018, 4383, 4749, 5114, 5479, 5844, 6210, 6575, 6940, + 7305, 7671, 8036, 8401, 8766, 9132, 9497, 9862, 10227, 10593, + 10958, 11323, 11688, 12054, 12419, 12784, 13149, 13515, 13880, 14245, + 14610, 14976, 15341, 15706, 16071, 16437, 16802, 17167, 17532, 17898, + 18263, 18628, 18993, 19359, 19724, 20089, 20454, 20820, 21185, 21550, + 21915, 22281, 22646, 23011, 23376, 23742, 24107, 24472, 24837, 25203, + 25568, 25933, 26298, 26664, 27029, 27394, 27759, 28125, 28490, 28855, +) + +# [0] = leap year, [1] = normal year +_DAYS_SINCE_MONTH = ( + (0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335), + (0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334), +) + +# --------------------------------------------------------------------------- +# UBX class / ID constants +# --------------------------------------------------------------------------- +_CLS_NAV = 0x01 +_CLS_RXM = 0x02 +_CLS_CFG = 0x06 +_CLS_MON = 0x0A + +_ID_NAV_PVT = 0x07 +_ID_NAV_TIMELS = 0x26 + +_ID_CFG_PRT = 0x00 +_ID_CFG_RST = 0x04 +_ID_CFG_RATE = 0x08 +_ID_CFG_CFG = 0x09 +_ID_CFG_RXM = 0x11 +_ID_CFG_NAV5 = 0x24 + +_ID_MON_VER = 0x04 + +_ID_RXM_PMREQ = 0x41 + +# ACK class +_CLS_ACK = 0x05 +_ID_ACK_ACK = 0x01 +_ID_ACK_NACK = 0x00 + + +def _ubxChecksum(data): + """Fletcher-8 checksum over class+id+len(2)+payload bytes.""" + a = 0 + b = 0 + for byte in data: + a = (a + byte) & 0xFF + b = (b + a) & 0xFF + return a, b + + +class SolderedGNSS: + """MicroPython driver for Soldered u-blox GPS/GNSS breakout boards. + + Communicates with u-blox modules via I2C (DDC interface, default + address 0x42) using the UBX binary protocol. + """ + + def __init__(self): + self._i2c = None + self._addr = 0x42 + + # Cached NAV-PVT fields + self._pvt = { + 'iTOW': 0, + 'year': 0, 'month': 0, 'day': 0, + 'hour': 0, 'min': 0, 'sec': 0, 'nano': 0, + 'valid': 0, 'flags': 0, 'flags2': 0, + 'fixType': 0, 'numSV': 0, + 'lon': 0, 'lat': 0, 'height': 0, 'hMSL': 0, + 'hAcc': 0, 'vAcc': 0, + 'gSpeed': 0, 'headMot': 0, 'sAcc': 0, 'headAcc': 0, + 'pDOP': 0, + } + + # Cached NAV-TIMELS fields + self._timels = { + 'srcOfCurrLs': 0, 'currLs': 0, + 'lsChange': 0, 'timeToLsEvent': 0, + 'valid': 0, + } + + # Protocol version cache (filled by getProtocolVersion) + self._protoHigh = 0 + self._protoLow = 0 + self._protoQueried = False + + # Module info cache (filled by getModuleInfo) + self.minfo = { + 'swVersion': '', + 'hwVersion': '', + 'extensions': [], + } + + # NMEA receive buffer + self._nmeaBuf = '' + self._nmeaLines = [] + + # I2C stream re-assembly buffer for partial UBX frames + self._rxBuf = bytearray() + + # ----------------------------------------------------------------------- + # Initialisation + # ----------------------------------------------------------------------- + + def begin(self, i2c, addr=0x42, timeout_ms=1100): + """Connect to the u-blox module. Returns True if the module responds.""" + self._i2c = i2c + self._addr = addr + try: + self._i2c.readfrom(self._addr, 1) + except OSError: + return False + # Try to get one valid PVT packet as a liveness check + return self._pollAndWait(_CLS_NAV, _ID_NAV_PVT, timeout_ms=timeout_ms) is not None + + def isConnected(self, timeout_ms=1100): + """Return True if the module is still reachable.""" + return self._pollAndWait(_CLS_NAV, _ID_NAV_PVT, timeout_ms=timeout_ms) is not None + + # ----------------------------------------------------------------------- + # NAV-PVT — position/velocity/time + # ----------------------------------------------------------------------- + + def getPVT(self, timeout_ms=1100): + """Poll the module and cache fresh PVT data. Returns True on success.""" + payload = self._pollAndWait(_CLS_NAV, _ID_NAV_PVT, timeout_ms=timeout_ms) + if payload is None or len(payload) < 84: + return False + p = payload + self._pvt['iTOW'] = struct.unpack_from('> 6) & 0x03 + + def getUnixEpoch(self): + """Compute Unix epoch from cached PVT data. + + Returns (epoch_seconds, microseconds). + """ + year = self._pvt['year'] + month = self._pvt['month'] + day = self._pvt['day'] + hour = self._pvt['hour'] + minute = self._pvt['min'] + sec = self._pvt['sec'] + nano = self._pvt['nano'] + + epoch = _DAYS_FROM_1970_TO_2020 + y = year - 2020 + if 0 <= y < len(_DAYS_SINCE_2020): + epoch += _DAYS_SINCE_2020[y] + + leap = (year % 4 == 0) + m = max(0, min(month - 1, 11)) + epoch += _DAYS_SINCE_MONTH[0 if leap else 1][m] + epoch += day - 1 + epoch *= 86400 + epoch += hour * 3600 + minute * 60 + sec + + if nano < 0: + epoch -= 1 + nanos = 1000000000 + nano + else: + nanos = nano + micros = nanos // 1000 + return epoch, micros + + # ----------------------------------------------------------------------- + # NAV-TIMELS — leap second event information + # ----------------------------------------------------------------------- + + def getLeapSecondEvent(self, timeout_ms=1100): + """Poll and cache leap second event info. Returns True on success.""" + payload = self._pollAndWait(_CLS_NAV, _ID_NAV_TIMELS, timeout_ms=timeout_ms) + if payload is None or len(payload) < 24: + return False + self._timels['srcOfCurrLs'] = payload[8] + self._timels['currLs'] = struct.unpack_from(' 0: + return 1, t + else: + return 2, t + + def getCurrentLeapSeconds(self): + """Return (currLs, source) from cached NAV-TIMELS data.""" + return self._timels['currLs'], self._timels['srcOfCurrLs'] + + # ----------------------------------------------------------------------- + # MON-VER — module / protocol version + # ----------------------------------------------------------------------- + + def getProtocolVersion(self, timeout_ms=1100): + """Poll MON-VER and extract the protocol version. Returns True on success.""" + payload = self._pollAndWait(_CLS_MON, _ID_MON_VER, timeout_ms=timeout_ms) + if payload is None or len(payload) < 40: + return False + # Extension strings start at offset 40, each 30 bytes + ext_count = (len(payload) - 40) // 30 + for i in range(ext_count): + start = 40 + i * 30 + ext = payload[start:start + 30].split(b'\x00')[0].decode('ascii', 'ignore') + if ext.startswith('PROTVER='): + parts = ext[8:].split('.') + try: + self._protoHigh = int(parts[0]) + self._protoLow = int(parts[1]) if len(parts) > 1 else 0 + except (ValueError, IndexError): + pass + break + self._protoQueried = True + return True + + def getProtocolVersionHigh(self, timeout_ms=1100): + """Return major protocol version number (e.g. 27 for v27.30).""" + if not self._protoQueried: + self.getProtocolVersion(timeout_ms) + return self._protoHigh + + def getProtocolVersionLow(self, timeout_ms=1100): + """Return minor protocol version number (e.g. 30 for v27.30).""" + if not self._protoQueried: + self.getProtocolVersion(timeout_ms) + return self._protoLow + + def getModuleInfo(self, timeout_ms=1100): + """Poll MON-VER and populate the minfo dict. Returns True on success.""" + payload = self._pollAndWait(_CLS_MON, _ID_MON_VER, timeout_ms=timeout_ms) + if payload is None or len(payload) < 40: + return False + self.minfo['swVersion'] = payload[0:30].split(b'\x00')[0].decode('ascii', 'ignore') + self.minfo['hwVersion'] = payload[30:40].split(b'\x00')[0].decode('ascii', 'ignore') + ext_count = (len(payload) - 40) // 30 + exts = [] + for i in range(ext_count): + start = 40 + i * 30 + ext = payload[start:start + 30].split(b'\x00')[0].decode('ascii', 'ignore') + exts.append(ext) + if ext.startswith('PROTVER='): + parts = ext[8:].split('.') + try: + self._protoHigh = int(parts[0]) + self._protoLow = int(parts[1]) if len(parts) > 1 else 0 + except (ValueError, IndexError): + pass + self.minfo['extensions'] = exts + self._protoQueried = True + return True + + # ----------------------------------------------------------------------- + # CFG-RATE — measurement and navigation rate + # ----------------------------------------------------------------------- + + def getMeasurementRate(self, timeout_ms=1100): + """Return current measurement interval in ms.""" + payload = self._pollAndWait(_CLS_CFG, _ID_CFG_RATE, timeout_ms=timeout_ms) + if payload is None or len(payload) < 6: + return 0 + return struct.unpack_from(' 0: + chunk = min(avail, 64) + try: + data = self._readFromStream(chunk) + except OSError: + break + for b in data: + c = chr(b) + if c == '$': + self._nmeaBuf = '$' + elif self._nmeaBuf: + if c == '\n': + self._nmeaLines.append(self._nmeaBuf.rstrip('\r')) + self._nmeaBuf = '' + else: + self._nmeaBuf += c + avail -= chunk + + def readNMEA(self): + """Return all buffered NMEA sentences and clear the buffer.""" + lines = self._nmeaLines + self._nmeaLines = [] + return lines + + # ----------------------------------------------------------------------- + # Internal UBX protocol implementation + # ----------------------------------------------------------------------- + + def _sendUBX(self, cls, id_, payload=b''): + """Assemble and send a UBX frame over I2C.""" + plen = len(payload) + frame = bytearray([0xB5, 0x62, cls, id_, plen & 0xFF, (plen >> 8) & 0xFF]) + frame += payload + ck_a, ck_b = _ubxChecksum(frame[2:]) + frame += bytes([ck_a, ck_b]) + self._i2c.writeto(self._addr, frame) + + def _bytesAvailable(self): + """Return number of bytes waiting in the module's I2C output buffer.""" + try: + data = self._i2c.readfrom_mem(self._addr, 0xFD, 2) + n = (data[0] << 8) | data[1] + return 0 if n == 0xFFFF else n + except OSError: + return 0 + + def _readFromStream(self, n): + """Read n bytes from register 0xFF (the data stream).""" + result = bytearray() + remaining = n + while remaining > 0: + chunk = min(remaining, 32) + # Write register address 0xFF with repeated start, then read + self._i2c.writeto(self._addr, bytes([0xFF]), False) + result += self._i2c.readfrom(self._addr, chunk) + remaining -= chunk + return bytes(result) + + def _parseUBXFromBuf(self, buf, cls, id_): + """Scan buf for a matching UBX frame. Returns (payload, consumed_end) or (None, 0).""" + i = 0 + while i < len(buf) - 5: + if buf[i] == 0xB5 and buf[i + 1] == 0x62: + if i + 6 > len(buf): + break + msg_cls = buf[i + 2] + msg_id = buf[i + 3] + msg_len = buf[i + 4] | (buf[i + 5] << 8) + total = i + 6 + msg_len + 2 + if total > len(buf): + break # frame incomplete — wait for more bytes + + payload = buf[i + 6: i + 6 + msg_len] + ck_a = buf[total - 2] + ck_b = buf[total - 1] + exp_a, exp_b = _ubxChecksum(buf[i + 2: i + 6 + msg_len]) + if ck_a == exp_a and ck_b == exp_b: + if msg_cls == cls and msg_id == id_: + return bytes(payload), total + i += 1 + else: + i += 1 + return None, 0 + + def _waitForUBX(self, cls, id_, timeout_ms): + """Wait up to timeout_ms for a UBX frame matching cls/id_. Returns payload or None.""" + deadline = time.ticks_add(time.ticks_ms(), timeout_ms) + buf = bytearray(self._rxBuf) + + while time.ticks_diff(deadline, time.ticks_ms()) > 0: + avail = self._bytesAvailable() + if avail > 0: + chunk = min(avail, 64) + try: + buf += self._readFromStream(chunk) + except OSError: + time.sleep_ms(5) + continue + + payload, end = self._parseUBXFromBuf(buf, cls, id_) + if payload is not None: + self._rxBuf = bytearray(buf[end:]) + return payload + + # Trim oversized buffer (keep most-recent 512 bytes) + if len(buf) > 512: + buf = buf[-512:] + + time.sleep_ms(5) + + self._rxBuf = bytearray() + return None + + def _pollAndWait(self, cls, id_, poll_payload=b'', timeout_ms=1100): + """Send a UBX poll request and wait for the matching response.""" + try: + self._sendUBX(cls, id_, poll_payload) + except OSError: + return None + return self._waitForUBX(cls, id_, timeout_ms) + + def _waitForAck(self, cls, id_, timeout_ms): + """Wait for ACK-ACK or ACK-NACK for a given cls/id. Returns True on ACK.""" + deadline = time.ticks_add(time.ticks_ms(), timeout_ms) + buf = bytearray(self._rxBuf) + + while time.ticks_diff(deadline, time.ticks_ms()) > 0: + avail = self._bytesAvailable() + if avail > 0: + chunk = min(avail, 64) + try: + buf += self._readFromStream(chunk) + except OSError: + time.sleep_ms(5) + continue + + # Scan for ACK + i = 0 + while i < len(buf) - 5: + if buf[i] == 0xB5 and buf[i + 1] == 0x62: + if i + 6 > len(buf): + break + msg_cls = buf[i + 2] + msg_id = buf[i + 3] + msg_len = buf[i + 4] | (buf[i + 5] << 8) + total = i + 6 + msg_len + 2 + if total > len(buf): + break + if msg_cls == _CLS_ACK and msg_len == 2: + ack_cls = buf[i + 6] + ack_id = buf[i + 7] + if ack_cls == cls and ack_id == id_: + self._rxBuf = bytearray(buf[total:]) + return msg_id == _ID_ACK_ACK + i += 1 + else: + i += 1 + + if len(buf) > 512: + buf = buf[-512:] + + time.sleep_ms(5) + + self._rxBuf = bytearray() + return False + + def _sendAndAck(self, cls, id_, payload, timeout_ms=1100): + """Send a UBX command and wait for ACK. Returns True on ACK-ACK.""" + try: + self._sendUBX(cls, id_, payload) + except OSError: + return False + return self._waitForAck(cls, id_, timeout_ms) diff --git a/README.md b/README.md index 859ad51..d21aa17 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ Each module in the library is designed to be lightweight, readable, and compatib ### Sensors - [AD8495](Sensors/AD8495/) +- [ADS1219](Sensors/ADS1219/) +- [ADS1X15](Sensors/ADS1X15/) - [AK09918 (3-Axis Digital Compass)](Sensors/AK09918/) - [APDS9960](Sensors/APDS9960/) - [BHI385](Sensors/BHI385/) @@ -29,27 +31,49 @@ Each module in the library is designed to be lightweight, readable, and compatib - [BMP180](Sensors/BMP180/) - [BMP280](Sensors/BMP280/) - [BMP388](Sensors/BMP388/) +- [BQ27441](Sensors/BQ27441/) - [DE2120](Sensors/DE2120/) +- [ElectrochemicalGasSensor](Sensors/ElectrochemicalGasSensor/) - [HallEffect](Sensors/HallEffect/) +- [HX711](Sensors/HX711/) - [IIS2DULPX](Sensors/IIS2DULPX/) +- [INA219](Sensors/INA219/) - [LaserDistanceSensor](Sensors/LaserDistanceSensor/) +- [LSM9DS1](Sensors/LSM9DS1/) - [LTR507](Sensors/LTR507/) +- [MQSensors](Sensors/MQSensors/) - [ObstacleSensor](Sensors/ObstacleSensor/) - [PirSensor](Sensors/PirSensor/) - [RotaryEncoder](Sensors/RotaryEncoder/) +- [SCD43](Sensors/SCD43/) - [SHTC3](Sensors/SHTC3/) +- [SimpleSensor](Sensors/SimpleSensor/) +- [SliderPotentiometer](Sensors/SliderPotentiometer/) - [TMP117](Sensors/TMP117/) - [UltrasonicSensor](Sensors/UltrasonicSensor/) ### Actuators +- [BasicStepperDriver](Actuators/BasicStepperDriver/) +- [ButtonLedBuzzerBoard](Actuators/ButtonLedBuzzerBoard/) - [DRV8825](Actuators/DRV8825/) - [DS3234](Actuators/DS3234/) +- [MAX7219](Actuators/MAX7219/) +- [MCP47A1](Actuators/MCP47A1/) - [MCP23017](Actuators/MCP23017/) +- [MCP4018](Actuators/MCP4018/) +- [PCF85063A](Actuators/PCF85063A/) +- [Relay](Actuators/Relay/) - [WS2812](Actuators/WS2812/) +- [WS2812Grid](Actuators/WS2812Grid/) ### Communication +- [Inputronic-BRIDGE](Communication/Inputronic-BRIDGE/) +- [Inputronic-GRID](Communication/Inputronic-GRID/) +- [MCP2518](Communication/MCP2518/) - [PCAL6416A](Communication/PCAL6416A/) - [RFID](Communication/RFID/) +- [TCA9548A](Communication/TCA9548A/) +- [uBloxGNSS](Communication/uBloxGNSS/) ### Displays - [LCD-I2C](Displays/LCD-I2C/) @@ -133,7 +157,7 @@ Module_Name/ ## About Soldered -soldered-logo +soldered-logo At Soldered, we design and manufacture a wide selection of electronic products to help you turn your ideas into acts and bring you one step closer to your final project. Our products are intented for makers and crafted in-house by our experienced team in Osijek, Croatia. We believe that sharing is a crucial element for improvement and innovation, and we work hard to stay connected with all our makers regardless of their skill or experience level. Therefore, all our products are open-source. Finally, we always have your back. If you face any problem concerning either your shopping experience or your electronics project, our team will help you deal with it, offering efficient customer service and cost-free technical support anytime. Some of those might be useful for you: diff --git a/Sensors/ADS1219/ADS1219/Examples/ads1219-continuous.py b/Sensors/ADS1219/ADS1219/Examples/ads1219-continuous.py new file mode 100644 index 0000000..3f13037 --- /dev/null +++ b/Sensors/ADS1219/ADS1219/Examples/ads1219-continuous.py @@ -0,0 +1,42 @@ +# FILE: ads1219-continuous.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Continuous ADC conversion example for the ADS1219. +# Puts the ADC into continuous mode — conversions run back-to-back +# automatically. Polls the data-ready flag and prints the differential +# voltage between AIN0(+) and AIN1(-) in millivolts. +# WORKS WITH: 24-Bit ADC ADS1219 Breakout: solde.red/333380 +# LAST UPDATED: 2026-05-19 +# +# Wiring: +# - Connect ADS1219 via Qwiic / I2C (default address 0x40) +# - Connect signal to AIN0 and AIN1 (or GND for AIN1) +# - External reference voltage on REFP/REFN (3.3V assumed) + +from machine import I2C, Pin +import time +from ads1219 import * + +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +adc = ADS1219(i2c) + +print("ADS1219 Continuous Example") + +while not adc.begin(): + print("ADS1219 not found. Check wiring! Retrying...") + time.sleep_ms(1000) + +adc.setVoltageReference(ADS1219_VREF_EXTERNAL) +adc.setConversionMode(ADS1219_MODE_CONTINUOUS) +adc.startSync() + +print("ADS1219 initialized") +print("Reading differential voltage AIN0(+) vs AIN1(-)") + +while True: + while not adc.dataReady(): + time.sleep_ms(10) + + adc.readConversion() + mV = adc.getConversionMillivolts(3300.0) + + print("Voltage (mV): {:.3f}".format(mV)) diff --git a/Sensors/ADS1219/ADS1219/Examples/ads1219-interrupt.py b/Sensors/ADS1219/ADS1219/Examples/ads1219-interrupt.py new file mode 100644 index 0000000..d24b1e2 --- /dev/null +++ b/Sensors/ADS1219/ADS1219/Examples/ads1219-interrupt.py @@ -0,0 +1,51 @@ +# FILE: ads1219-interrupt.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: DRDY pin interrupt-driven reads example for the ADS1219. +# Uses DRDY falling-edge interrupt instead of polling the status register. +# More efficient at high sample rates (1000 SPS). +# Open Serial Plotter to visualize readings. +# WORKS WITH: 24-Bit ADC ADS1219 Breakout: solde.red/333380 +# LAST UPDATED: 2026-05-19 +# +# Wiring: +# - Connect ADS1219 via Qwiic / I2C (default address 0x40) +# - Connect DRDY breakout pad to an interrupt-capable pin (default pin 4) +# - Connect signal to AIN0, GND to board GND +# - External reference voltage on REFP/REFN (3.3V assumed) + +from machine import I2C, Pin +import time +from ads1219 import * + +INTERRUPT_PIN = 4 # Change to match your wiring + +i2c = I2C(0, scl=Pin(22), sda=Pin(21), freq=400000) +adc = ADS1219(i2c) + +interrupt_seen = False + +def data_ready_isr(pin): + global interrupt_seen + interrupt_seen = True + +while not adc.begin(): + print("ADS1219 not found. Check wiring! Retrying...") + time.sleep_ms(1000) + +adc.setVoltageReference(ADS1219_VREF_EXTERNAL) +adc.setMux(ADS1219_MUX_SINGLE_0) +adc.setGain(ADS1219_GAIN_1) +adc.setDataRate(ADS1219_DR_1000SPS) +adc.setConversionMode(ADS1219_MODE_CONTINUOUS) + +drdy_pin = Pin(INTERRUPT_PIN, Pin.IN) +drdy_pin.irq(trigger=Pin.IRQ_FALLING, handler=data_ready_isr) + +adc.startSync() + +while True: + if interrupt_seen: + interrupt_seen = False + adc.readConversion() + mV = adc.getConversionMillivolts(3300.0) + print("{:.3f}".format(mV)) diff --git a/Sensors/ADS1219/ADS1219/Examples/ads1219-multiplexer.py b/Sensors/ADS1219/ADS1219/Examples/ads1219-multiplexer.py new file mode 100644 index 0000000..dfe1d66 --- /dev/null +++ b/Sensors/ADS1219/ADS1219/Examples/ads1219-multiplexer.py @@ -0,0 +1,52 @@ +# FILE: ads1219-multiplexer.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Input multiplexer configuration example for the ADS1219. +# Demonstrates single-ended and differential channel selection. +# This example reads AIN0 vs GND. Change setMux() to switch channels. +# WORKS WITH: 24-Bit ADC ADS1219 Breakout: solde.red/333380 +# LAST UPDATED: 2026-05-19 +# +# Available mux options: +# ADS1219_MUX_DIFF_P0_N1 AIN0(+) vs AIN1(-) (default) +# ADS1219_MUX_DIFF_P2_N3 AIN2(+) vs AIN3(-) +# ADS1219_MUX_DIFF_P1_N2 AIN1(+) vs AIN2(-) +# ADS1219_MUX_SINGLE_0 AIN0 vs GND +# ADS1219_MUX_SINGLE_1 AIN1 vs GND +# ADS1219_MUX_SINGLE_2 AIN2 vs GND +# ADS1219_MUX_SINGLE_3 AIN3 vs GND +# ADS1219_MUX_SHORTED AVDD/2 vs AVDD/2 (offset measurement) +# +# Wiring: +# - Connect ADS1219 via Qwiic / I2C (default address 0x40) +# - Connect signal to AIN0, GND to board GND +# - External reference voltage on REFP/REFN (3.3V assumed) + +from machine import I2C, Pin +import time +from ads1219 import * + +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +adc = ADS1219(i2c) + +print("ADS1219 Multiplexer Example") + +while not adc.begin(): + print("ADS1219 not found. Check wiring! Retrying...") + time.sleep_ms(1000) + +adc.setVoltageReference(ADS1219_VREF_EXTERNAL) +adc.setMux(ADS1219_MUX_SINGLE_0) # AIN0 vs GND + +print("ADS1219 initialized") +print("Reading AIN0 vs GND") + +while True: + adc.startSync() + + while not adc.dataReady(): + time.sleep_ms(10) + + adc.readConversion() + mV = adc.getConversionMillivolts(3300.0) + + print("Voltage (mV): {:.3f}".format(mV)) diff --git a/Sensors/ADS1219/ADS1219/Examples/ads1219-single-shot.py b/Sensors/ADS1219/ADS1219/Examples/ads1219-single-shot.py new file mode 100644 index 0000000..18107bb --- /dev/null +++ b/Sensors/ADS1219/ADS1219/Examples/ads1219-single-shot.py @@ -0,0 +1,44 @@ +# FILE: ads1219-single-shot.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Single-shot ADC conversion example for the ADS1219. +# Triggers one conversion per loop iteration, waits for the result, +# then prints the differential voltage AIN0(+) vs AIN1(-) in millivolts. +# WORKS WITH: 24-Bit ADC ADS1219 Breakout: solde.red/333380 +# LAST UPDATED: 2026-05-19 +# +# Wiring: +# - Connect ADS1219 via Qwiic / I2C (default address 0x40) +# - Connect signal to AIN0 and AIN1 (or GND for AIN1) +# - External reference voltage on REFP/REFN (3.3V assumed) + +from machine import I2C, Pin +import time +from ads1219 import * + +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +adc = ADS1219(i2c) + +print("ADS1219 Single-Shot Example") + +while not adc.begin(): + print("ADS1219 not found. Check wiring! Retrying...") + time.sleep_ms(1000) + +adc.setVoltageReference(ADS1219_VREF_EXTERNAL) + +print("ADS1219 initialized") +print("Reading differential voltage AIN0(+) vs AIN1(-)") + +while True: + if not adc.startSync(): + print("Failed to start conversion. Check wiring! Retrying...") + time.sleep_ms(1000) + continue + + while not adc.dataReady(): + time.sleep_ms(10) + + adc.readConversion() + mV = adc.getConversionMillivolts(3300.0) + + print("Voltage (mV): {:.3f}".format(mV)) diff --git a/Sensors/ADS1219/ADS1219/ads1219.py b/Sensors/ADS1219/ADS1219/ads1219.py new file mode 100644 index 0000000..89ceb68 --- /dev/null +++ b/Sensors/ADS1219/ADS1219/ads1219.py @@ -0,0 +1,334 @@ +# FILE: ads1219.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: MicroPython library for the ADS1219 24-bit 4-channel ADC +# LAST UPDATED: 2026-05-19 + +from machine import I2C, Pin +from os import uname +import time + +# Default I2C address (A1=GND, A0=GND). Range 0x40-0x4F depending on A1/A0 pin strapping. +ADS1219_DEFAULT_ADDR = 0x40 + +# Commands +ADS1219_CMD_RESET = 0x06 # Soft reset — config returns to 0x00 +ADS1219_CMD_START_SYNC = 0x08 # Start or restart conversion +ADS1219_CMD_POWERDOWN = 0x02 # Enter power-down mode +ADS1219_CMD_RDATA = 0x10 # Read conversion result (3 bytes, big-endian) + +# Register addresses +ADS1219_REG_CFG_WRITE = 0x40 # Write configuration register +ADS1219_REG_CFG_READ = 0x20 # Read configuration register +ADS1219_REG_STATUS = 0x24 # Read status register + +########################### +# Mux constants # +########################### +ADS1219_MUX_DIFF_P0_N1 = 0 # Differential: AINP=AIN0, AINN=AIN1 (default) +ADS1219_MUX_DIFF_P2_N3 = 1 # Differential: AINP=AIN2, AINN=AIN3 +ADS1219_MUX_DIFF_P1_N2 = 2 # Differential: AINP=AIN1, AINN=AIN2 +ADS1219_MUX_SINGLE_0 = 3 # Single-ended: AINP=AIN0, AINN=AGND +ADS1219_MUX_SINGLE_1 = 4 # Single-ended: AINP=AIN1, AINN=AGND +ADS1219_MUX_SINGLE_2 = 5 # Single-ended: AINP=AIN2, AINN=AGND +ADS1219_MUX_SINGLE_3 = 6 # Single-ended: AINP=AIN3, AINN=AGND +ADS1219_MUX_SHORTED = 7 # Shorted: AINP=AINN=AVDD/2 (offset measurement) + +########################### +# Gain constants # +########################### +ADS1219_GAIN_1 = 0 # Gain = 1, full-scale = VREF (default) +ADS1219_GAIN_4 = 1 # Gain = 4, full-scale = VREF/4 + +########################### +# Data rate constants # +########################### +ADS1219_DR_20SPS = 0 # 20 samples per second (default) +ADS1219_DR_90SPS = 1 # 90 samples per second +ADS1219_DR_330SPS = 2 # 330 samples per second +ADS1219_DR_1000SPS = 3 # 1000 samples per second + +########################### +# Conversion mode consts # +########################### +ADS1219_MODE_SINGLE_SHOT = 0 # One conversion per startSync() call (default) +ADS1219_MODE_CONTINUOUS = 1 # Back-to-back conversions until powerDown() + +########################### +# Voltage reference consts# +########################### +ADS1219_VREF_INTERNAL = 0 # Internal 2.048V reference (default) +ADS1219_VREF_EXTERNAL = 1 # External reference on REFP/REFN pins + + +class ADS1219: + """ + MicroPython class for the TI ADS1219 24-bit 4-channel ADC. + Communicates over I2C. + """ + + def __init__(self, i2c=None, address=ADS1219_DEFAULT_ADDR): + """ + Initialize the ADS1219. + + :param i2c: Initialized I2C object (optional, auto-detected on known boards) + :param address: I2C address of the device (default 0x40) + """ + if i2c is not None: + self.i2c = i2c + else: + if uname().sysname in ("esp32", "Soldered Dasduino CONNECTPLUS"): + self.i2c = I2C(0, scl=Pin(22), sda=Pin(21)) + elif uname().sysname == "esp8266": + self.i2c = I2C(scl=Pin(5), sda=Pin(4)) + else: + raise Exception("Board not recognized, please pass an I2C object manually") + + self.address = address + self._gain = ADS1219_GAIN_1 + self._result = 0 + + # ------------------------------------------------------------------------- + # Initialization and control + # ------------------------------------------------------------------------- + + def begin(self): + """ + Initialize: soft reset, verify config register reads 0x00. + + :return: True if device found and reset successful, False otherwise + """ + if not self.reset(): + return False + time.sleep_ms(1) # tRSSTA: >100us after reset per datasheet + cfg = self._readRegister(ADS1219_REG_CFG_READ) + if cfg is None: + return False + return cfg == 0x00 + + def reset(self): + """ + Soft reset. Config register returns to default (0x00). + + :return: True on success + """ + return self._writeCommand(ADS1219_CMD_RESET) + + def startSync(self): + """ + Start or restart a conversion. + Single-shot mode: triggers one conversion. + Continuous mode: restarts conversion sequence. + + :return: True on success + """ + return self._writeCommand(ADS1219_CMD_START_SYNC) + + def powerDown(self): + """ + Enter power-down mode. Stops conversions and powers down analog front-end. + + :return: True on success + """ + return self._writeCommand(ADS1219_CMD_POWERDOWN) + + # ------------------------------------------------------------------------- + # Conversion + # ------------------------------------------------------------------------- + + def readConversion(self): + """ + Read 24-bit conversion result from the ADC and store internally. + Call after dataReady() returns True. + + :return: True on success + """ + raw = self._readBytes(ADS1219_CMD_RDATA, 3) + if raw is None: + return False + val = (raw[0] << 16) | (raw[1] << 8) | raw[2] + # Sign-extend 24-bit 2's complement to Python int + if val & 0x00800000: + val |= 0xFF000000 + val -= 0x100000000 + self._result = val + return True + + def getConversionRaw(self): + """ + Get raw 24-bit signed conversion result (not adjusted for gain). + + :return: Signed integer (24-bit 2's complement, sign-extended) + """ + return self._result + + def getConversionMillivolts(self, ref_millivolts=2048.0): + """ + Convert stored result to millivolts. + + :param ref_millivolts: Reference voltage in mV. Use 2048.0 for internal reference, + or (REFP - REFN) in mV for external reference. + :return: Voltage in millivolts, adjusted for gain + """ + mv = self._result / 8388608.0 * ref_millivolts # 2^23 = 8388608 + if self._gain == ADS1219_GAIN_4: + mv /= 4.0 + return mv + + def dataReady(self): + """ + Check if a conversion result is ready to read. + + :return: True if DRDY flag (bit 7) is set in status register + """ + status = self._readRegister(ADS1219_REG_STATUS) + if status is None: + return False + return bool(status & 0x80) + + # ------------------------------------------------------------------------- + # Configuration + # ------------------------------------------------------------------------- + + def setMux(self, mux=ADS1219_MUX_DIFF_P0_N1): + """ + Configure the input multiplexer. + + :param mux: ADS1219_MUX_* constant (default: differential AIN0/AIN1) + :return: True on success + """ + cfg = self._readRegister(ADS1219_REG_CFG_READ) + if cfg is None: + return False + cfg = (cfg & 0x1F) | ((mux & 0x07) << 5) + return self._writeRegister(ADS1219_REG_CFG_WRITE, cfg) + + def setGain(self, gain=ADS1219_GAIN_1): + """ + Configure the PGA gain. + + :param gain: ADS1219_GAIN_1 or ADS1219_GAIN_4 (default: 1) + :return: True on success + """ + cfg = self._readRegister(ADS1219_REG_CFG_READ) + if cfg is None: + return False + cfg = (cfg & 0xEF) | ((gain & 0x01) << 4) + self._gain = gain + return self._writeRegister(ADS1219_REG_CFG_WRITE, cfg) + + def setDataRate(self, rate=ADS1219_DR_20SPS): + """ + Configure the data rate. + + :param rate: ADS1219_DR_* constant (default: 20 SPS) + :return: True on success + """ + cfg = self._readRegister(ADS1219_REG_CFG_READ) + if cfg is None: + return False + cfg = (cfg & 0xF3) | ((rate & 0x03) << 2) + return self._writeRegister(ADS1219_REG_CFG_WRITE, cfg) + + def setConversionMode(self, mode=ADS1219_MODE_SINGLE_SHOT): + """ + Configure conversion mode. + + :param mode: ADS1219_MODE_SINGLE_SHOT or ADS1219_MODE_CONTINUOUS (default: single-shot) + :return: True on success + """ + cfg = self._readRegister(ADS1219_REG_CFG_READ) + if cfg is None: + return False + cfg = (cfg & 0xFD) | ((mode & 0x01) << 1) + return self._writeRegister(ADS1219_REG_CFG_WRITE, cfg) + + def setVoltageReference(self, vref=ADS1219_VREF_INTERNAL): + """ + Configure voltage reference source. + + :param vref: ADS1219_VREF_INTERNAL (2.048V) or ADS1219_VREF_EXTERNAL (default: internal) + :return: True on success + """ + cfg = self._readRegister(ADS1219_REG_CFG_READ) + if cfg is None: + return False + cfg = (cfg & 0xFE) | (vref & 0x01) + return self._writeRegister(ADS1219_REG_CFG_WRITE, cfg) + + def getConfigReg(self): + """ + Read the full configuration register byte. + + :return: Config register value (int), or None on error + """ + return self._readRegister(ADS1219_REG_CFG_READ) + + def setConfigReg(self, cfg_byte): + """ + Write the configuration register directly. + Also updates internal gain cache used by getConversionMillivolts(). + + :param cfg_byte: Register byte value + :return: True on success + """ + self._gain = (cfg_byte >> 4) & 0x01 + return self._writeRegister(ADS1219_REG_CFG_WRITE, cfg_byte) + + # ------------------------------------------------------------------------- + # Private: I2C helpers + # ------------------------------------------------------------------------- + + def _writeCommand(self, cmd): + """ + Send a single-byte command. + + :param cmd: Command byte + :return: True on success + """ + try: + self.i2c.writeto(self.address, bytes([cmd])) + return True + except: + return False + + def _writeRegister(self, reg, data): + """ + Write a register address followed by one data byte. + + :param reg: Register address byte + :param data: Data byte + :return: True on success + """ + try: + self.i2c.writeto(self.address, bytes([reg, data])) + return True + except: + return False + + def _readRegister(self, reg): + """ + Write register address then read one byte (repeated start). + + :param reg: Register address byte + :return: Register value (int), or None on error + """ + try: + self.i2c.writeto(self.address, bytes([reg]), False) # repeated start + data = self.i2c.readfrom(self.address, 1) + return data[0] + except: + return None + + def _readBytes(self, reg, length): + """ + Write register/command byte then read multiple bytes (repeated start). + + :param reg: Register/command byte + :param length: Number of bytes to read + :return: bytes object, or None on error + """ + try: + self.i2c.writeto(self.address, bytes([reg]), False) # repeated start + return self.i2c.readfrom(self.address, length) + except: + return None diff --git a/Sensors/ADS1219/README.md b/Sensors/ADS1219/README.md new file mode 100644 index 0000000..c334d06 --- /dev/null +++ b/Sensors/ADS1219/README.md @@ -0,0 +1,14 @@ +# How to install + +--- + +After [**installing the mpremote package**](https://docs.micropython.org/en/latest/reference/mpremote.html), flash a module to the board using the following command: + +```sh + mpremote mip install github:SolderedElectronics/Soldered-Micropython-modules/Sensors/ADS1219 +``` +Or, if you're running a Windows OS: + +```sh + python -m mpremote mip install github:SolderedElectronics/Soldered-Micropython-modules/Sensors/ADS1219 +``` diff --git a/Sensors/ADS1219/package.json b/Sensors/ADS1219/package.json new file mode 100644 index 0000000..6f717d1 --- /dev/null +++ b/Sensors/ADS1219/package.json @@ -0,0 +1,26 @@ +{ + "urls": [ + [ + "ads1219.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/ADS1219/ADS1219/ads1219.py" + ], + [ + "Examples/ads1219-single-shot.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/ADS1219/ADS1219/Examples/ads1219-single-shot.py" + ], + [ + "Examples/ads1219-multiplexer.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/ADS1219/ADS1219/Examples/ads1219-multiplexer.py" + ], + [ + "Examples/ads1219-interrupt.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/ADS1219/ADS1219/Examples/ads1219-interrupt.py" + ], + [ + "Examples/ads1219-continuous.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/ADS1219/ADS1219/Examples/ads1219-continuous.py" + ] + ], + "deps": [], + "version": "1.0" +} diff --git a/Sensors/ADS1X15/ADS1X15/Examples/ads1x15-continuous.py b/Sensors/ADS1X15/ADS1X15/Examples/ads1x15-continuous.py new file mode 100644 index 0000000..9e715ca --- /dev/null +++ b/Sensors/ADS1X15/ADS1X15/Examples/ads1x15-continuous.py @@ -0,0 +1,36 @@ +# FILE: ads1x15-continuous.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Continuous conversion mode with the async interface. +# Starts a conversion, polls isReady(), reads getValue() without blocking. +# WORKS WITH: ADS1015 / ADS1115 ADC Breakout +# LAST UPDATED: 2026-05-22 +# +# Wiring: +# - Connect ADS1015/ADS1115 via Qwiic / I2C (default address 0x48) + +from machine import I2C, Pin +import time +from ads1x15 import * + +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +adc = ADS1115(i2c) + +print("ADS1X15 Continuous Mode Example") + +while not adc.begin(): + print("ADS1X15 not found. Check wiring! Retrying...") + time.sleep_ms(1000) + +adc.setGain(0) +adc.setMode(0) # continuous conversion +adc.requestADC(0) # start first conversion on AIN0 + +print("ADS1X15 initialized, continuous mode") + +while True: + if adc.isReady(): + raw = adc.getValue() + v = adc.toVoltage(raw) + print("AIN0: raw={:6d} voltage={:.4f} V".format(raw, v)) + adc.requestADC(0) # re-arm next conversion + time.sleep_ms(10) diff --git a/Sensors/ADS1X15/ADS1X15/Examples/ads1x15-differential.py b/Sensors/ADS1X15/ADS1X15/Examples/ads1x15-differential.py new file mode 100644 index 0000000..93619ef --- /dev/null +++ b/Sensors/ADS1X15/ADS1X15/Examples/ads1x15-differential.py @@ -0,0 +1,34 @@ +# FILE: ads1x15-differential.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Hardware differential reads on ADS1015/ADS1115. +# AIN0(+) vs AIN1(-) is the most common differential pair. +# WORKS WITH: ADS1015 / ADS1115 ADC Breakout +# LAST UPDATED: 2026-05-22 +# +# Wiring: +# - Connect ADS1015/ADS1115 via Qwiic / I2C (default address 0x48) +# - Connect signal+ to AIN0, signal- to AIN1 + +from machine import I2C, Pin +import time +from ads1x15 import * + +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +adc = ADS1115(i2c) + +print("ADS1X15 Differential Example") + +while not adc.begin(): + print("ADS1X15 not found. Check wiring! Retrying...") + time.sleep_ms(1000) + +adc.setGain(2) # ±2.048 V — good for most differential signals + +print("ADS1X15 initialized") +print("Reading AIN0(+) vs AIN1(-)") + +while True: + raw = adc.readADC_Differential_0_1() + v = adc.toVoltage(raw) + print("Diff 0-1: raw={:6d} voltage={:.4f} V".format(raw, v)) + time.sleep_ms(500) diff --git a/Sensors/ADS1X15/ADS1X15/Examples/ads1x15-simple.py b/Sensors/ADS1X15/ADS1X15/Examples/ads1x15-simple.py new file mode 100644 index 0000000..5146370 --- /dev/null +++ b/Sensors/ADS1X15/ADS1X15/Examples/ads1x15-simple.py @@ -0,0 +1,32 @@ +# FILE: ads1x15-simple.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Minimal test — reads all 4 single-ended channels and prints voltage. +# WORKS WITH: ADS1015 / ADS1115 ADC Breakout +# LAST UPDATED: 2026-05-22 +# +# Wiring: +# - Connect ADS1015/ADS1115 via Qwiic / I2C (default address 0x48) +# - ADDR pin → GND for 0x48 + +from machine import I2C, Pin +import time +from ads1x15 import * + +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +adc = ADS1115(i2c) # swap for ADS1015 if using 12-bit variant + +print("ADS1X15 Simple Example") + +while not adc.begin(): + print("ADS1X15 not found. Check wiring! Retrying...") + time.sleep_ms(1000) + +adc.setGain(0) # ±6.144 V full scale + +print("ADS1X15 initialized") + +while True: + raw = adc.readADC(0) + v = adc.toVoltage(raw) + print("AIN0: raw={:6d} voltage={:.4f} V".format(raw, v)) + time.sleep_ms(500) diff --git a/Sensors/ADS1X15/ADS1X15/ads1x15.py b/Sensors/ADS1X15/ADS1X15/ads1x15.py new file mode 100644 index 0000000..bc2d575 --- /dev/null +++ b/Sensors/ADS1X15/ADS1X15/ads1x15.py @@ -0,0 +1,537 @@ +# FILE: ads1x15.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: MicroPython library for ADS1015 (12-bit) and ADS1115 (16-bit) 4-channel ADC +# LAST UPDATED: 2026-05-22 + +from machine import I2C, Pin +from os import uname +import time + +ADS1X15_DEFAULT_ADDR = 0x48 # ADDR pin → GND; options: 0x48, 0x49, 0x4A, 0x4B + +# Register addresses +ADS1X15_REG_CONVERT = 0x00 +ADS1X15_REG_CONFIG = 0x01 +ADS1X15_REG_LOW_THRESHOLD = 0x02 +ADS1X15_REG_HIGH_THRESHOLD = 0x03 + +# Bit 15 — OS +ADS1X15_OS_START_SINGLE = 0x8000 +ADS1X15_OS_NOT_BUSY = 0x8000 + +# Bits 14:12 — MUX +ADS1X15_MUX_DIFF_0_1 = 0x0000 # AIN0(+) vs AIN1(-) +ADS1X15_MUX_DIFF_0_3 = 0x1000 # AIN0(+) vs AIN3(-) +ADS1X15_MUX_DIFF_1_3 = 0x2000 # AIN1(+) vs AIN3(-) +ADS1X15_MUX_DIFF_2_3 = 0x3000 # AIN2(+) vs AIN3(-) + +# Bits 11:9 — PGA +ADS1X15_PGA_6_144V = 0x0000 # ±6.144 V (default) +ADS1X15_PGA_4_096V = 0x0200 # ±4.096 V +ADS1X15_PGA_2_048V = 0x0400 # ±2.048 V +ADS1X15_PGA_1_024V = 0x0600 # ±1.024 V +ADS1X15_PGA_0_512V = 0x0800 # ±0.512 V +ADS1X15_PGA_0_256V = 0x0A00 # ±0.256 V + +# Bit 8 — mode +ADS1X15_MODE_CONTINUOUS = 0x0000 +ADS1X15_MODE_SINGLE = 0x0100 # default + +# Bits 7:5 — data rate index (0=slowest … 7=fastest, 4=default) +# ADS1015: 0=128, 1=250, 2=490, 3=920, 4=1600, 5=2400, 6=3300, 7=3300 SPS +# ADS1115: 0=8, 1=16, 2=32, 3=64, 4=128, 5=250, 6=475, 7=860 SPS + +# Bit 4 — comparator mode +ADS1X15_COMP_MODE_TRADITIONAL = 0x0000 # default +ADS1X15_COMP_MODE_WINDOW = 0x0010 + +# Bit 3 — comparator polarity +ADS1X15_COMP_POL_ACTIV_LOW = 0x0000 # default +ADS1X15_COMP_POL_ACTIV_HIGH = 0x0008 + +# Bit 2 — comparator latch +ADS1X15_COMP_NON_LATCH = 0x0000 # default +ADS1X15_COMP_LATCH = 0x0004 + +# Bits 1:0 — comparator queue +ADS1X15_COMP_QUE_1_CONV = 0x0000 # assert after 1 conversion +ADS1X15_COMP_QUE_2_CONV = 0x0001 # assert after 2 conversions +ADS1X15_COMP_QUE_4_CONV = 0x0002 # assert after 4 conversions +ADS1X15_COMP_QUE_NONE = 0x0003 # disable comparator (default) + +ADS1015_CONVERSION_DELAY = 1 # ms +ADS1115_CONVERSION_DELAY = 8 # ms + +ADS1X15_OK = 0 +ADS1X15_INVALID_VOLTAGE = -100 +ADS1X15_INVALID_GAIN = 0xFF +ADS1X15_INVALID_MODE = 0xFE + + +class ADS1X15: + """ + Base class for ADS1015 and ADS1115. Do not instantiate directly. + """ + + def __init__(self, i2c=None, address=ADS1X15_DEFAULT_ADDR): + """ + :param i2c: Initialized I2C object (optional, auto-detected on known boards) + :param address: I2C address (0x48–0x4B, set by ADDR pin strapping) + """ + if i2c is not None: + self.i2c = i2c + else: + if uname().sysname in ("esp32", "Soldered Dasduino CONNECTPLUS"): + self.i2c = I2C(0, scl=Pin(22), sda=Pin(21)) + elif uname().sysname == "esp8266": + self.i2c = I2C(scl=Pin(5), sda=Pin(4)) + else: + raise Exception("Board not recognized, please pass an I2C object manually") + + self.address = address + self._err = ADS1X15_OK + self._reset() + + # ------------------------------------------------------------------------- + # Init + # ------------------------------------------------------------------------- + + def begin(self): + """ + Verify device is reachable on the I2C bus. + + :return: True if device responds, False otherwise + """ + if self.address < 0x48 or self.address > 0x4B: + return False + try: + self.i2c.readfrom(self.address, 1) + return True + except: + return False + + def _reset(self): + """Reset config fields to safe defaults.""" + self.setGain(0) # ±6.144 V — widest, safest range + self.setMode(1) # single-shot + self.setDataRate(4) # mid-speed default + self._compMode = 0 + self._compPol = 0 # active low + self._compLatch = 0 # non-latching + self._compQueConvert = 3 # comparator disabled + + # ------------------------------------------------------------------------- + # Gain + # ------------------------------------------------------------------------- + + def setGain(self, gain=0): + """ + Set PGA gain. Invalid values fall back to 0 (safest/widest range). + + :param gain: 0=±6.144V, 1=±4.096V, 2=±2.048V, 4=±1.024V, 8=±0.512V, 16=±0.256V + """ + _map = { + 0: ADS1X15_PGA_6_144V, + 1: ADS1X15_PGA_4_096V, + 2: ADS1X15_PGA_2_048V, + 4: ADS1X15_PGA_1_024V, + 8: ADS1X15_PGA_0_512V, + 16: ADS1X15_PGA_0_256V, + } + self._gain = _map.get(gain, ADS1X15_PGA_6_144V) + + def getGain(self): + """ + Get current gain setting. + + :return: Gain value (0/1/2/4/8/16), or ADS1X15_INVALID_GAIN on error + """ + _map = { + ADS1X15_PGA_6_144V: 0, + ADS1X15_PGA_4_096V: 1, + ADS1X15_PGA_2_048V: 2, + ADS1X15_PGA_1_024V: 4, + ADS1X15_PGA_0_512V: 8, + ADS1X15_PGA_0_256V: 16, + } + val = _map.get(self._gain) + if val is None: + self._err = ADS1X15_INVALID_GAIN + return ADS1X15_INVALID_GAIN + return val + + def getMaxVoltage(self): + """ + Get full-scale voltage for the current gain setting. + + :return: Full-scale voltage in Volts, or ADS1X15_INVALID_VOLTAGE on error + """ + _map = { + ADS1X15_PGA_6_144V: 6.144, + ADS1X15_PGA_4_096V: 4.096, + ADS1X15_PGA_2_048V: 2.048, + ADS1X15_PGA_1_024V: 1.024, + ADS1X15_PGA_0_512V: 0.512, + ADS1X15_PGA_0_256V: 0.256, + } + val = _map.get(self._gain) + if val is None: + self._err = ADS1X15_INVALID_VOLTAGE + return ADS1X15_INVALID_VOLTAGE + return val + + def toVoltage(self, raw=1): + """ + Convert a raw ADC value to Volts. + + :param raw: Signed raw value from readADC() or getValue() + :return: Voltage in Volts + """ + if raw == 0: + return 0.0 + volts = self.getMaxVoltage() + if volts < 0: + return volts + volts *= raw + # ADS1115: 15-bit mantissa (32767); ADS1015: 11-bit mantissa (2047) + volts /= 32767.0 if self._bitShift == 0 else 2047.0 + return volts + + # ------------------------------------------------------------------------- + # Mode + # ------------------------------------------------------------------------- + + def setMode(self, mode=1): + """ + Set conversion mode. + + :param mode: 0 = continuous, 1 = single-shot (default) + """ + self._mode = ADS1X15_MODE_CONTINUOUS if mode == 0 else ADS1X15_MODE_SINGLE + + def getMode(self): + """ + Get conversion mode. + + :return: 0 = continuous, 1 = single-shot, ADS1X15_INVALID_MODE on error + """ + if self._mode == ADS1X15_MODE_CONTINUOUS: + return 0 + if self._mode == ADS1X15_MODE_SINGLE: + return 1 + self._err = ADS1X15_INVALID_MODE + return ADS1X15_INVALID_MODE + + # ------------------------------------------------------------------------- + # Data rate + # ------------------------------------------------------------------------- + + def setDataRate(self, rate=4): + """ + Set data rate index (0=slowest … 7=fastest, 4=default). Invalid → 4. + Actual SPS differs between ADS1015 and ADS1115 — see module constants. + + :param rate: 0–7 + """ + self._datarate = (rate if rate <= 7 else 4) << 5 + + def getDataRate(self): + """ + Get current data rate index. + + :return: 0–7 + """ + return (self._datarate >> 5) & 0x07 + + # ------------------------------------------------------------------------- + # Single-ended reads (blocking) + # ------------------------------------------------------------------------- + + def readADC(self, pin): + """ + Read one single-ended channel, blocking until conversion completes. + + :param pin: Channel 0–3 + :return: Signed raw ADC value, or 0 for invalid pin + """ + if pin >= self._maxPorts: + return 0 + return self._readADC((4 + pin) << 12) + + # ------------------------------------------------------------------------- + # Differential reads (blocking) + # ------------------------------------------------------------------------- + + def readADC_Differential_0_1(self): + """Read AIN0(+) vs AIN1(-) hardware differential (blocking).""" + return self._readADC(ADS1X15_MUX_DIFF_0_1) + + def readADC_Differential_0_3(self): + """Read AIN0(+) vs AIN3(-) hardware differential (blocking).""" + return self._readADC(ADS1X15_MUX_DIFF_0_3) + + def readADC_Differential_1_3(self): + """Read AIN1(+) vs AIN3(-) hardware differential (blocking).""" + return self._readADC(ADS1X15_MUX_DIFF_1_3) + + def readADC_Differential_2_3(self): + """Read AIN2(+) vs AIN3(-) hardware differential (blocking).""" + return self._readADC(ADS1X15_MUX_DIFF_2_3) + + def readADC_Differential_0_2(self): + """ + AIN0 vs AIN2 differential via two single-ended reads (not hardware differential). + Not usable in async mode. + """ + return self.readADC(2) - self.readADC(0) + + def readADC_Differential_1_2(self): + """ + AIN1 vs AIN2 differential via two single-ended reads (not hardware differential). + Not usable in async mode. + """ + return self.readADC(2) - self.readADC(1) + + # ------------------------------------------------------------------------- + # Async interface — requestADC → isBusy/isReady → getValue + # ------------------------------------------------------------------------- + + def requestADC(self, pin): + """ + Start a non-blocking single-ended conversion. Poll isBusy() / isReady(), + then call getValue() for the result. + + :param pin: Channel 0–3 + """ + if pin >= self._maxPorts: + return + self._requestADC((4 + pin) << 12) + + def requestADC_Differential_0_1(self): + """Start non-blocking AIN0(+) vs AIN1(-) differential conversion.""" + self._requestADC(ADS1X15_MUX_DIFF_0_1) + + def requestADC_Differential_0_3(self): + """Start non-blocking AIN0(+) vs AIN3(-) differential conversion.""" + self._requestADC(ADS1X15_MUX_DIFF_0_3) + + def requestADC_Differential_1_3(self): + """Start non-blocking AIN1(+) vs AIN3(-) differential conversion.""" + self._requestADC(ADS1X15_MUX_DIFF_1_3) + + def requestADC_Differential_2_3(self): + """Start non-blocking AIN2(+) vs AIN3(-) differential conversion.""" + self._requestADC(ADS1X15_MUX_DIFF_2_3) + + def isBusy(self): + """ + Check if a conversion is in progress. + + :return: True while converting, False when result is ready + """ + return (self._readRegister(ADS1X15_REG_CONFIG) & ADS1X15_OS_NOT_BUSY) == 0 + + def isReady(self): + """ + Check if conversion result is ready. + + :return: True if result ready + """ + return not self.isBusy() + + def getValue(self): + """ + Read the conversion register. Call after isBusy() returns False. + + :return: Signed raw ADC value (12-bit for ADS1015, 16-bit for ADS1115) + """ + raw = self._readRegister(ADS1X15_REG_CONVERT) + # Sign-extend uint16 → signed int + if raw >= 0x8000: + raw -= 0x10000 + if self._bitShift: + raw >>= self._bitShift # arithmetic right-shift preserves sign in Python + return raw + + def getLastValue(self): + """Alias for getValue().""" + return self.getValue() + + # ------------------------------------------------------------------------- + # Comparator + # ------------------------------------------------------------------------- + + def setComparatorMode(self, mode): + """ + Set comparator mode. + + :param mode: 0 = traditional (> high → on, < low → off), + 1 = window (> high or < low → on) + """ + self._compMode = 0 if mode == 0 else 1 + + def getComparatorMode(self): + """Get comparator mode (0=traditional, 1=window).""" + return self._compMode + + def setComparatorPolarity(self, pol): + """ + Set ALERT/RDY pin polarity. + + :param pol: 0 = active low (default), 1 = active high + """ + self._compPol = 0 if pol == 0 else 1 + + def getComparatorPolarity(self): + """Get comparator polarity (0=active low, 1=active high).""" + return self._compPol + + def setComparatorLatch(self, latch): + """ + Set comparator latch behavior. + + :param latch: 0 = non-latching (default), 1 = latching + """ + self._compLatch = 0 if latch == 0 else 1 + + def getComparatorLatch(self): + """Get comparator latch (0=non-latching, 1=latching).""" + return self._compLatch + + def setComparatorQueConvert(self, mode): + """ + Set how many conversions trigger the ALERT/RDY pin. + + :param mode: 0=after 1, 1=after 2, 2=after 4, 3=disable (default) + """ + self._compQueConvert = mode if mode < 3 else 3 + + def getComparatorQueConvert(self): + """Get comparator queue setting (0–3).""" + return self._compQueConvert + + def setComparatorThresholdLow(self, lo): + """ + Set low threshold for the comparator. + + :param lo: Signed 16-bit threshold value + """ + self._writeRegister(ADS1X15_REG_LOW_THRESHOLD, lo & 0xFFFF) + + def getComparatorThresholdLow(self): + """ + Get low threshold register value. + + :return: Signed 16-bit threshold + """ + raw = self._readRegister(ADS1X15_REG_LOW_THRESHOLD) + return raw if raw < 0x8000 else raw - 0x10000 + + def setComparatorThresholdHigh(self, hi): + """ + Set high threshold for the comparator. + + :param hi: Signed 16-bit threshold value + """ + self._writeRegister(ADS1X15_REG_HIGH_THRESHOLD, hi & 0xFFFF) + + def getComparatorThresholdHigh(self): + """ + Get high threshold register value. + + :return: Signed 16-bit threshold + """ + raw = self._readRegister(ADS1X15_REG_HIGH_THRESHOLD) + return raw if raw < 0x8000 else raw - 0x10000 + + # ------------------------------------------------------------------------- + # Error + # ------------------------------------------------------------------------- + + def getError(self): + """ + Get and clear the last error code. + + :return: ADS1X15_OK, ADS1X15_INVALID_GAIN, ADS1X15_INVALID_VOLTAGE, or ADS1X15_INVALID_MODE + """ + err = self._err + self._err = ADS1X15_OK + return err + + # ------------------------------------------------------------------------- + # Private + # ------------------------------------------------------------------------- + + def _readADC(self, readmode): + """Blocking conversion: write config, wait, return signed raw value.""" + self._requestADC(readmode) + if self._mode == ADS1X15_MODE_SINGLE: + while self.isBusy(): + time.sleep_ms(1) + else: + time.sleep_ms(self._conversionDelay) + return self.getValue() + + def _requestADC(self, readmode): + """Write config register to trigger one conversion.""" + config = ADS1X15_OS_START_SINGLE + config |= readmode + config |= self._gain + config |= self._mode + config |= self._datarate + config |= ADS1X15_COMP_MODE_WINDOW if self._compMode else ADS1X15_COMP_MODE_TRADITIONAL + config |= ADS1X15_COMP_POL_ACTIV_HIGH if self._compPol else ADS1X15_COMP_POL_ACTIV_LOW + config |= ADS1X15_COMP_LATCH if self._compLatch else ADS1X15_COMP_NON_LATCH + config |= self._compQueConvert + self._writeRegister(ADS1X15_REG_CONFIG, config) + + def _writeRegister(self, reg, value): + """Write 16-bit value to a register (MSB first).""" + try: + self.i2c.writeto(self.address, bytes([reg, (value >> 8) & 0xFF, value & 0xFF])) + return True + except: + return False + + def _readRegister(self, reg): + """Write register address, read back 2-byte big-endian value.""" + try: + self.i2c.writeto(self.address, bytes([reg])) + data = self.i2c.readfrom(self.address, 2) + return (data[0] << 8) | data[1] + except: + return 0x0000 + + +class ADS1015(ADS1X15): + """ + 12-bit, 4-channel ADC with PGA and comparator. + Conversion delay: 1 ms. Data rates: 128–3300 SPS. + """ + + def __init__(self, i2c=None, address=ADS1X15_DEFAULT_ADDR): + """ + :param i2c: Initialized I2C object (optional) + :param address: I2C address (default 0x48) + """ + self._conversionDelay = ADS1015_CONVERSION_DELAY + self._bitShift = 4 # 12-bit result stored in bits 15:4 of the conversion register + self._maxPorts = 4 + super().__init__(i2c, address) + + +class ADS1115(ADS1X15): + """ + 16-bit, 4-channel ADC with PGA and comparator. + Conversion delay: 8 ms. Data rates: 8–860 SPS. + """ + + def __init__(self, i2c=None, address=ADS1X15_DEFAULT_ADDR): + """ + :param i2c: Initialized I2C object (optional) + :param address: I2C address (default 0x48) + """ + self._conversionDelay = ADS1115_CONVERSION_DELAY + self._bitShift = 0 # full 16-bit result + self._maxPorts = 4 + super().__init__(i2c, address) diff --git a/Sensors/ADS1X15/package.json b/Sensors/ADS1X15/package.json new file mode 100644 index 0000000..28b2d78 --- /dev/null +++ b/Sensors/ADS1X15/package.json @@ -0,0 +1,22 @@ +{ + "urls": [ + [ + "ads1x15.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/ADS1X15/ADS1X15/ads1x15.py" + ], + [ + "Examples/ads1x15-simple.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/ADS1X15/ADS1X15/Examples/ads1x15-simple.py" + ], + [ + "Examples/ads1x15-differential.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/ADS1X15/ADS1X15/Examples/ads1x15-differential.py" + ], + [ + "Examples/ads1x15-continuous.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/ADS1X15/ADS1X15/Examples/ads1x15-continuous.py" + ] + ], + "deps": [], + "version": "1.0" +} diff --git a/Sensors/BQ27441/BQ27441/Examples/bq27441-GpoutBatLow.py b/Sensors/BQ27441/BQ27441/Examples/bq27441-GpoutBatLow.py new file mode 100644 index 0000000..cf740b3 --- /dev/null +++ b/Sensors/BQ27441/BQ27441/Examples/bq27441-GpoutBatLow.py @@ -0,0 +1,103 @@ +# FILE: bq27441-GpoutBatLow.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Demonstrates how to use the BQ27441's BAT_LOW function on GPOUT. +# In this mode GPOUT will become active whenever the battery goes +# below a set threshold. +# +# NOTE: It is IMPORTANT to connect the battery because this module gets +# power from the battery and will not work without it! +# WORKS WITH: Fuel gauge BQ27441 breakout: solde.red/333065 +# LAST UPDATED: 2026-05-06 + +from machine import I2C, Pin +import time +from bq27441 import BQ27441, AVG, FULL, REMAIN, BAT_LOW + +# Set BATTERY_CAPACITY to the design capacity of your battery in mAh +BATTERY_CAPACITY = 600 + +SOCI_SET = 15 # Interrupt set threshold at 15% +SOCI_CLR = 20 # Interrupt clear threshold at 20% +SOCF_SET = 5 # Final threshold set at 5% +SOCF_CLR = 10 # Final threshold clear at 10% + +# Pin connected to BQ27441's GPOUT pin +GPOUT_PIN = 2 + +# Initialize I2C and the fuel gauge +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +lipo = BQ27441(i2c) + +# Initialize the GPOUT pin as input with pull-up +gpout = Pin(GPOUT_PIN, Pin.IN, Pin.PULL_UP) + + +def setup_bq27441(): + # Use lipo.begin() to initialize the BQ27441-G1A and confirm + # that it's connected and communicating. + if not lipo.begin(): + print("Error: Unable to communicate with BQ27441.") + print(" Check wiring and try again.") + print(" (Battery must be plugged into Fuel Gauge!)") + while True: + pass + + print("Connected to BQ27441!") + + # In this example we manually enter and exit config mode. By controlling + # config mode manually, you can set the chip up faster -- completing all + # of the setup in a single config mode sweep. + lipo.enterConfig() # Must be in config mode to set values below + lipo.setCapacity(BATTERY_CAPACITY) # Set the battery capacity + lipo.setGPOUTPolarity(False) # Set GPOUT to active-low + lipo.setGPOUTFunction(BAT_LOW) # Set GPOUT to BAT_LOW mode + lipo.setSOC1Thresholds(SOCI_SET, SOCI_CLR) # Set SOCI set and clear thresholds + lipo.setSOCFThresholds(SOCF_SET, SOCF_CLR) # Set SOCF set and clear thresholds + lipo.exitConfig() + + # Read back from the chip to confirm the changes + if lipo.GPOUTPolarity(): + print("GPOUT set to active-HIGH") + else: + print("GPOUT set to active-LOW") + + if lipo.GPOUTFunction(): + print("GPOUT function set to BAT_LOW") + else: + print("GPOUT function set to SOC_INT") + + print("SOC1 Set Threshold: {}%".format(lipo.SOC1SetThreshold())) + print("SOC1 Clear Threshold: {}%".format(lipo.SOC1ClearThreshold())) + print("SOCF Set Threshold: {}%".format(lipo.SOCFSetThreshold())) + print("SOCF Clear Threshold: {}%".format(lipo.SOCFClearThreshold())) + + +def print_battery_stats(): + soc = lipo.soc() # Read state-of-charge (%) + volts = lipo.voltage() # Read battery voltage (mV) + current = lipo.current(AVG) # Read average current (mA) + full_cap = lipo.capacity(FULL) # Read full capacity (mAh) + capacity = lipo.capacity(REMAIN) # Read remaining capacity (mAh) + pwr = lipo.power() # Read average power draw (mW) + health = lipo.soh() # Read state-of-health (%) + + print("{}% | {} mV | {} mA | {}/{} mAh | {} mW | {}% | flags: 0x{:04X}".format( + soc, volts, current, capacity, full_cap, pwr, health, lipo.flags() + )) + + +# Run setup +setup_bq27441() + +# Main loop +while True: + print_battery_stats() + + # GPOUT is active-low — if it goes low, a threshold was crossed + if not gpout.value(): + if lipo.socfFlag(): + print("") + elif lipo.socFlag(): + print("") + + time.sleep(1) \ No newline at end of file diff --git a/Sensors/BQ27441/BQ27441/Examples/bq27441-GpoutSocInt.py b/Sensors/BQ27441/BQ27441/Examples/bq27441-GpoutSocInt.py new file mode 100644 index 0000000..4448c90 --- /dev/null +++ b/Sensors/BQ27441/BQ27441/Examples/bq27441-GpoutSocInt.py @@ -0,0 +1,111 @@ +# FILE: bq27441-GpoutSocInt.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Demonstrates how to use the BQ27441's SOC_INT function on GPOUT. +# In this mode GPOUT will pulse every time the state-of-charge (SoC) +# goes up or down by a set percentage interval. +# +# NOTE: It is IMPORTANT to connect the battery because this module gets +# power from the battery and will not work without it! +# WORKS WITH: Fuel gauge BQ27441 breakout: solde.red/333065 +# LAST UPDATED: 2026-05-06 + +from machine import I2C, Pin +import time +from bq27441 import BQ27441, AVG, FULL, REMAIN, SOC_INT + +# Set BATTERY_CAPACITY to the design capacity of your battery in mAh +BATTERY_CAPACITY = 600 + +# Pin connected to BQ27441's GPOUT pin +GPOUT_PIN = 2 + +# Percentage change interval that triggers a GPOUT pulse +PERCENTAGE_INTERVAL = 1 + +# Initialize I2C and the fuel gauge +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +lipo = BQ27441(i2c) + +# Initialize the GPOUT pin as input with pull-up +gpout = Pin(GPOUT_PIN, Pin.IN, Pin.PULL_UP) + + +def setup_bq27441(): + # Use lipo.begin() to initialize the BQ27441-G1A and confirm + # that it's connected and communicating. + if not lipo.begin(): + print("Error: Unable to communicate with BQ27441.") + print(" Check wiring and try again.") + print(" (Battery must be plugged into Fuel Gauge!)") + while True: + pass + + print("Connected to BQ27441!") + + # In this example we manually enter and exit config mode. By controlling + # config mode manually, you can set the chip up faster -- completing all + # of the setup in a single config mode sweep. + lipo.enterConfig() # Must be in config mode to set values below + lipo.setCapacity(BATTERY_CAPACITY) # Set the battery capacity + lipo.setGPOUTPolarity(False) # Set GPOUT to active-low + lipo.setGPOUTFunction(SOC_INT) # Set GPOUT to SOC_INT mode + lipo.setSOCIDelta(PERCENTAGE_INTERVAL) # Set percentage change interval + lipo.exitConfig() # Exit config mode to save changes + + # Read back from the chip to confirm the changes + if lipo.GPOUTPolarity(): + print("GPOUT set to active-HIGH") + else: + print("GPOUT set to active-LOW") + + if lipo.GPOUTFunction(): + print("GPOUT function set to BAT_LOW") + else: + print("GPOUT function set to SOC_INT") + + print("SOCI Delta: {}".format(lipo.sociDelta())) + print() + + # Use lipo.pulseGPOUT() to trigger a test pulse on GPOUT. + # This only works in SOC_INT mode. + print("Testing GPOUT Pulse") + lipo.pulseGPOUT() + + timeout = 10000 # The pulse can take a while — max 10 seconds + while gpout.value() and timeout > 0: + time.sleep_ms(1) + timeout -= 1 + + if timeout > 0: + print("GPOUT test successful! ({} ms)".format(10000 - timeout)) + print("GPOUT will pulse whenever the SoC value changes by SOCI delta.") + print("Or when the battery changes from charging to discharging, or vice-versa.") + print() + else: + print("GPOUT didn't pulse.") + print("Make sure it's connected to pin {} and reset.".format(GPOUT_PIN)) + while True: + pass + + +def print_battery_stats(): + soc = lipo.soc() # Read state-of-charge (%) + volts = lipo.voltage() # Read battery voltage (mV) + current = lipo.current(AVG) # Read average current (mA) + full_cap = lipo.capacity(FULL) # Read full capacity (mAh) + capacity = lipo.capacity(REMAIN) # Read remaining capacity (mAh) + pwr = lipo.power() # Read average power draw (mW) + health = lipo.soh() # Read state-of-health (%) + + print("[{}] {}% | {} mV | {} mA | {}/{} mAh | {} mW | {}%".format( + time.ticks_ms() // 1000, soc, volts, current, capacity, full_cap, pwr, health + )) + + +# Run setup +setup_bq27441() + +# Main loop — print stats whenever GPOUT pulses (SOC_INT triggered) +while True: + if gpout.value() == 0: # GPOUT is active-low + print_battery_stats() \ No newline at end of file diff --git a/Sensors/BQ27441/BQ27441/Examples/bq27441-basicReading.py b/Sensors/BQ27441/BQ27441/Examples/bq27441-basicReading.py new file mode 100644 index 0000000..e792d0e --- /dev/null +++ b/Sensors/BQ27441/BQ27441/Examples/bq27441-basicReading.py @@ -0,0 +1,59 @@ +# FILE: bq27441-basicReading.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Basic example showing how to read all battery stats from the BQ27441. +# +# NOTE: It is IMPORTANT to connect the battery because this module gets +# power from the battery and will not work without it! +# WORKS WITH: Fuel gauge BQ27441 breakout: solde.red/333065 +# LAST UPDATED: 2026-05-06 + +from machine import I2C, Pin +import time +from bq27441 import BQ27441, AVG, FULL, REMAIN, FILTERED + +# Set BATTERY_CAPACITY to the design capacity of your battery in mAh +BATTERY_CAPACITY = 1200 + +# Initialize I2C and the fuel gauge +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +lipo = BQ27441(i2c) + +# Use lipo.begin() to initialize the BQ27441-G1A and confirm +# that it's connected and communicating. +if not lipo.begin(): + print("Error: Unable to communicate with BQ27441.") + print(" Check wiring and try again.") + print(" (Battery must be plugged into Fuel Gauge!)") + while True: + pass + +print("Connected to BQ27441!") + +# Set the battery design capacity +lipo.enterConfig() +lipo.setCapacity(BATTERY_CAPACITY) +lipo.exitConfig() + + +def print_battery_stats(): + soc = lipo.soc(FILTERED) # Read state-of-charge (%) + volts = lipo.voltage() # Read battery voltage (mV) + current = lipo.current(AVG) # Read average current (mA) + full_cap = lipo.capacity(FULL) # Read full capacity (mAh) + capacity = lipo.capacity(REMAIN) # Read remaining capacity (mAh) + pwr = lipo.power() # Read average power draw (mW) + health = lipo.soh() # Read state-of-health (%) + + print("SOC: {}%".format(soc)) + print("Voltage: {} mV".format(volts)) + print("Current: {} mA".format(current)) + print("Capacity: {} / {} mAh".format(capacity, full_cap)) + print("Power: {} mW".format(pwr)) + print("Health: {}%".format(health)) + print() + + +# Main loop — print battery stats every second +while True: + print_battery_stats() + time.sleep(1) \ No newline at end of file diff --git a/Sensors/BQ27441/BQ27441/Examples/bq27441-extendedBatteryConfiguration.py b/Sensors/BQ27441/BQ27441/Examples/bq27441-extendedBatteryConfiguration.py new file mode 100644 index 0000000..9b31807 --- /dev/null +++ b/Sensors/BQ27441/BQ27441/Examples/bq27441-extendedBatteryConfiguration.py @@ -0,0 +1,79 @@ +# FILE: bq27441-extendedBatteryConfiguration.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Shows how the BQ27441-G1 can be used for extended battery configuration, +# including setting taper rate and terminate voltage. +# +# NOTE: It is IMPORTANT to connect the battery because this module gets +# power from the battery and will not work without it! +# WORKS WITH: Fuel gauge BQ27441 breakout: solde.red/333065 +# LAST UPDATED: 2026-05-06 + +from machine import I2C, Pin +import time +from bq27441 import BQ27441, AVG, FULL, REMAIN + +# Set BATTERY_CAPACITY to the design capacity of your battery in mAh +BATTERY_CAPACITY = 600 + +# Lowest operational voltage in mV +TERMINATE_VOLTAGE = 3000 + +# Current at which the charger stops charging the battery in mA +TAPER_CURRENT = 60 + +# Initialize I2C and the fuel gauge +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +lipo = BQ27441(i2c) + +# Use lipo.begin() to initialize the BQ27441-G1A and confirm +# that it's connected and communicating. +if not lipo.begin(): + print("Error: Unable to communicate with BQ27441.") + print(" Check wiring and try again.") + print(" (Battery must be plugged into Fuel Gauge!)") + while True: + pass + +print("Connected to BQ27441!") +print("Writing gauge config") + +# To configure the values below, you must be in config mode +lipo.enterConfig() + +# Set the battery capacity +lipo.setCapacity(BATTERY_CAPACITY) + +# Taper Rate = Design Capacity / (0.1 * Taper Current) +lipo.setTaperRate(10 * BATTERY_CAPACITY // TAPER_CURRENT) + +# Exit config mode to save changes +lipo.exitConfig() + + +def print_battery_stats(): + soc = lipo.soc() # Read state-of-charge (%) + volts = lipo.voltage() # Read battery voltage (mV) + current = lipo.current(AVG) # Read average current (mA) + full_cap = lipo.capacity(FULL) # Read full capacity (mAh) + capacity = lipo.capacity(REMAIN) # Read remaining capacity (mAh) + pwr = lipo.power() # Read average power draw (mW) + health = lipo.soh() # Read state-of-health (%) + + line = "[{}] {}% | {} mV | {} mA | {}/{} mAh | {} mW | {}%".format( + time.ticks_ms() // 1000, soc, volts, current, capacity, full_cap, pwr, health + ) + + if lipo.chgFlag(): # Fast charging allowed + line += " CHG" + if lipo.fcFlag(): # Full charge detected + line += " FC" + if lipo.dsgFlag(): # Battery is discharging + line += " DSG" + + print(line) + + +# Main loop — print battery stats every second +while True: + print_battery_stats() + time.sleep(1) \ No newline at end of file diff --git a/Sensors/BQ27441/BQ27441/bq27441.py b/Sensors/BQ27441/BQ27441/bq27441.py new file mode 100644 index 0000000..47df32c --- /dev/null +++ b/Sensors/BQ27441/BQ27441/bq27441.py @@ -0,0 +1,795 @@ +# FILE: bq27441.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: MicroPython library for the BQ27441-G1A LiPo Fuel Gauge +# LAST UPDATED: 2026-05-06 + +from machine import I2C, Pin +from os import uname +import time + +# Default I2C address +BQ27441_I2C_ADDRESS = 0x55 + +# Device ID +BQ27441_DEVICE_ID = 0x0421 + +# Unseal key +BQ27441_UNSEAL_KEY = 0x8000 + +# I2C timeout (ms) +BQ27441_I2C_TIMEOUT = 2000 + +########################### +# Standard Commands # +########################### +BQ27441_COMMAND_CONTROL = 0x00 +BQ27441_COMMAND_TEMP = 0x02 +BQ27441_COMMAND_VOLTAGE = 0x04 +BQ27441_COMMAND_FLAGS = 0x06 +BQ27441_COMMAND_NOM_CAPACITY = 0x08 +BQ27441_COMMAND_AVAIL_CAPACITY = 0x0A +BQ27441_COMMAND_REM_CAPACITY = 0x0C +BQ27441_COMMAND_FULL_CAPACITY = 0x0E +BQ27441_COMMAND_AVG_CURRENT = 0x10 +BQ27441_COMMAND_STDBY_CURRENT = 0x12 +BQ27441_COMMAND_MAX_CURRENT = 0x14 +BQ27441_COMMAND_AVG_POWER = 0x18 +BQ27441_COMMAND_SOC = 0x1C +BQ27441_COMMAND_INT_TEMP = 0x1E +BQ27441_COMMAND_SOH = 0x20 +BQ27441_COMMAND_REM_CAP_UNFL = 0x28 +BQ27441_COMMAND_REM_CAP_FIL = 0x2A +BQ27441_COMMAND_FULL_CAP_UNFL = 0x2C +BQ27441_COMMAND_FULL_CAP_FIL = 0x2E +BQ27441_COMMAND_SOC_UNFL = 0x30 + +########################### +# Control Sub-commands # +########################### +BQ27441_CONTROL_STATUS = 0x00 +BQ27441_CONTROL_DEVICE_TYPE = 0x01 +BQ27441_CONTROL_FW_VERSION = 0x02 +BQ27441_CONTROL_DM_CODE = 0x04 +BQ27441_CONTROL_PREV_MACWRITE = 0x07 +BQ27441_CONTROL_CHEM_ID = 0x08 +BQ27441_CONTROL_BAT_INSERT = 0x0C +BQ27441_CONTROL_BAT_REMOVE = 0x0D +BQ27441_CONTROL_SET_HIBERNATE = 0x11 +BQ27441_CONTROL_CLEAR_HIBERNATE = 0x12 +BQ27441_CONTROL_SET_CFGUPDATE = 0x13 +BQ27441_CONTROL_SHUTDOWN_ENABLE = 0x1B +BQ27441_CONTROL_SHUTDOWN = 0x1C +BQ27441_CONTROL_SEALED = 0x20 +BQ27441_CONTROL_PULSE_SOC_INT = 0x23 +BQ27441_CONTROL_RESET = 0x41 +BQ27441_CONTROL_SOFT_RESET = 0x42 +BQ27441_CONTROL_EXIT_CFGUPDATE = 0x43 +BQ27441_CONTROL_EXIT_RESIM = 0x44 + +########################### +# Control Status Bits # +########################### +BQ27441_STATUS_SHUTDOWNEN = (1 << 15) +BQ27441_STATUS_WDRESET = (1 << 14) +BQ27441_STATUS_SS = (1 << 13) +BQ27441_STATUS_CALMODE = (1 << 12) +BQ27441_STATUS_CCA = (1 << 11) +BQ27441_STATUS_BCA = (1 << 10) +BQ27441_STATUS_QMAX_UP = (1 << 9) +BQ27441_STATUS_RES_UP = (1 << 8) +BQ27441_STATUS_INITCOMP = (1 << 7) +BQ27441_STATUS_HIBERNATE = (1 << 6) +BQ27441_STATUS_SLEEP = (1 << 4) +BQ27441_STATUS_LDMD = (1 << 3) +BQ27441_STATUS_RUP_DIS = (1 << 2) +BQ27441_STATUS_VOK = (1 << 1) + +########################### +# Flags Bits # +########################### +BQ27441_FLAG_OT = (1 << 15) +BQ27441_FLAG_UT = (1 << 14) +BQ27441_FLAG_FC = (1 << 9) +BQ27441_FLAG_CHG = (1 << 8) +BQ27441_FLAG_OCVTAKEN = (1 << 7) +BQ27441_FLAG_ITPOR = (1 << 5) +BQ27441_FLAG_CFGUPMODE = (1 << 4) +BQ27441_FLAG_BAT_DET = (1 << 3) +BQ27441_FLAG_SOC1 = (1 << 2) +BQ27441_FLAG_SOCF = (1 << 1) +BQ27441_FLAG_DSG = (1 << 0) + +########################### +# Extended Data Commands # +########################### +BQ27441_EXTENDED_OPCONFIG = 0x3A +BQ27441_EXTENDED_CAPACITY = 0x3C +BQ27441_EXTENDED_DATACLASS = 0x3E +BQ27441_EXTENDED_DATABLOCK = 0x3F +BQ27441_EXTENDED_BLOCKDATA = 0x40 +BQ27441_EXTENDED_CHECKSUM = 0x60 +BQ27441_EXTENDED_CONTROL = 0x61 + +########################### +# Configuration Class IDs # +########################### +BQ27441_ID_SAFETY = 2 +BQ27441_ID_CHG_TERMINATION = 36 +BQ27441_ID_CONFIG_DATA = 48 +BQ27441_ID_DISCHARGE = 49 +BQ27441_ID_REGISTERS = 64 +BQ27441_ID_POWER = 68 +BQ27441_ID_IT_CFG = 80 +BQ27441_ID_CURRENT_THRESH = 81 +BQ27441_ID_STATE = 82 +BQ27441_ID_R_A_RAM = 89 +BQ27441_ID_CALIB_DATA = 104 +BQ27441_ID_CC_CAL = 105 +BQ27441_ID_CURRENT = 107 +BQ27441_ID_CODES = 112 + +########################### +# OpConfig Bits # +########################### +BQ27441_OPCONFIG_BIE = (1 << 13) +BQ27441_OPCONFIG_BI_PU_EN = (1 << 12) +BQ27441_OPCONFIG_GPIOPOL = (1 << 11) +BQ27441_OPCONFIG_SLEEP = (1 << 5) +BQ27441_OPCONFIG_RMFCC = (1 << 4) +BQ27441_OPCONFIG_BATLOWEN = (1 << 2) +BQ27441_OPCONFIG_TEMPS = (1 << 0) + +# current() type constants +AVG = 0 # Average Current (default) +STBY = 1 # Standby Current +MAX = 2 # Max Current + +# capacity() type constants +REMAIN = 0 # Remaining Capacity (default) +FULL = 1 # Full Capacity +AVAIL = 2 # Available Capacity +AVAIL_FULL = 3 # Full Available Capacity +REMAIN_F = 4 # Remaining Capacity Filtered +REMAIN_UF = 5 # Remaining Capacity Unfiltered +FULL_F = 6 # Full Capacity Filtered +FULL_UF = 7 # Full Capacity Unfiltered +DESIGN = 8 # Design Capacity + +# soc() type constants +FILTERED = 0 # State of Charge Filtered (default) +UNFILTERED = 1 # State of Charge Unfiltered + +# soh() type constants +PERCENT = 0 # State of Health Percentage (default) +SOH_STAT = 1 # State of Health Status Bits + +# temperature() type constants +BATTERY = 0 # Battery Temperature (default) +INTERNAL_TEMP = 1 # Internal IC Temperature + +# setGPOUTFunction() type constants +SOC_INT = 0 # GPOUT set to SOC_INT functionality +BAT_LOW = 1 # GPOUT set to BAT_LOW functionality + + +class BQ27441: + """ + MicroPython class for the BQ27441-G1A LiPo Fuel Gauge. + Communicates over I2C to read battery voltage, current, capacity, + state of charge, state of health, and temperature. + """ + + def __init__(self, i2c=None, address=BQ27441_I2C_ADDRESS): + """ + Initialize the BQ27441 fuel gauge. + + :param i2c: Initialized I2C object (optional, auto-detected on known boards) + :param address: I2C address of the device (default 0x55) + """ + if i2c is not None: + self.i2c = i2c + else: + if uname().sysname in ("esp32", "Soldered Dasduino CONNECTPLUS"): + self.i2c = I2C(0, scl=Pin(22), sda=Pin(21)) + elif uname().sysname == "esp8266": + self.i2c = I2C(scl=Pin(5), sda=Pin(4)) + else: + raise Exception("Board not recognized, please pass an I2C object manually") + + self.address = address + self._seal_flag = False # Track if IC was sealed before config entry + self._user_config_control = False # Track if user is managing config mode + + # ------------------------------------------------------------------------- + # Initialization + # ------------------------------------------------------------------------- + + def begin(self): + """ + Verify communication with the BQ27441. Must be called before any other method. + + :return: True if device ID matches, False otherwise + """ + return self.deviceType() == BQ27441_DEVICE_ID + + def setCapacity(self, capacity): + """ + Configure the design capacity of the connected battery. + + :param capacity: Battery capacity in mAh (uint16) + :return: True on success + """ + data = bytes([capacity >> 8, capacity & 0xFF]) + return self._writeExtendedData(BQ27441_ID_STATE, 10, data) + + def setDesignEnergy(self, energy): + """ + Configure the design energy of the connected battery. + + :param energy: Battery energy in mWh (uint16) + :return: True on success + """ + data = bytes([energy >> 8, energy & 0xFF]) + return self._writeExtendedData(BQ27441_ID_STATE, 12, data) + + def setTerminateVoltage(self, voltage): + """ + Configure the terminate voltage (lowest operational battery voltage). + + :param voltage: Voltage in mV, clamped to 2500-3700 + :return: True on success + """ + voltage = max(2500, min(3700, voltage)) + data = bytes([voltage >> 8, voltage & 0xFF]) + return self._writeExtendedData(BQ27441_ID_STATE, 16, data) + + def setTaperRate(self, rate): + """ + Configure the taper rate of the connected battery. + + :param rate: Taper rate in units of 0.1h, max 2000 + :return: True on success + """ + rate = min(2000, rate) + data = bytes([rate >> 8, rate & 0xFF]) + return self._writeExtendedData(BQ27441_ID_STATE, 27, data) + + # ------------------------------------------------------------------------- + # Battery Characteristics + # ------------------------------------------------------------------------- + + def voltage(self): + """ + Read the battery voltage. + + :return: Voltage in mV + """ + return self._readWord(BQ27441_COMMAND_VOLTAGE) + + def current(self, measure=AVG): + """ + Read the specified current measurement. + + :param measure: AVG, STBY, or MAX (default AVG) + :return: Current in mA, positive = charging + """ + if measure == AVG: + raw = self._readWord(BQ27441_COMMAND_AVG_CURRENT) + elif measure == STBY: + raw = self._readWord(BQ27441_COMMAND_STDBY_CURRENT) + elif measure == MAX: + raw = self._readWord(BQ27441_COMMAND_MAX_CURRENT) + else: + return 0 + # Convert unsigned 16-bit to signed + return raw if raw < 0x8000 else raw - 0x10000 + + def capacity(self, measure=REMAIN): + """ + Read the specified capacity measurement. + + :param measure: REMAIN, FULL, AVAIL, AVAIL_FULL, REMAIN_F, REMAIN_UF, + FULL_F, FULL_UF, or DESIGN (default REMAIN) + :return: Capacity in mAh + """ + if measure == REMAIN: + return self._readWord(BQ27441_COMMAND_REM_CAPACITY) + elif measure == FULL: + return self._readWord(BQ27441_COMMAND_FULL_CAPACITY) + elif measure == AVAIL: + return self._readWord(BQ27441_COMMAND_NOM_CAPACITY) + elif measure == AVAIL_FULL: + return self._readWord(BQ27441_COMMAND_AVAIL_CAPACITY) + elif measure == REMAIN_F: + return self._readWord(BQ27441_COMMAND_REM_CAP_FIL) + elif measure == REMAIN_UF: + return self._readWord(BQ27441_COMMAND_REM_CAP_UNFL) + elif measure == FULL_F: + return self._readWord(BQ27441_COMMAND_FULL_CAP_FIL) + elif measure == FULL_UF: + return self._readWord(BQ27441_COMMAND_FULL_CAP_UNFL) + elif measure == DESIGN: + return self._readWord(BQ27441_EXTENDED_CAPACITY) + return 0 + + def power(self): + """ + Read the average power. + + :return: Power in mW, positive = charging + """ + raw = self._readWord(BQ27441_COMMAND_AVG_POWER) + return raw if raw < 0x8000 else raw - 0x10000 + + def soc(self, measure=FILTERED): + """ + Read the state of charge. + + :param measure: FILTERED or UNFILTERED (default FILTERED) + :return: State of charge in % + """ + if measure == FILTERED: + return self._readWord(BQ27441_COMMAND_SOC) + elif measure == UNFILTERED: + return self._readWord(BQ27441_COMMAND_SOC_UNFL) + return 0 + + def soh(self, measure=PERCENT): + """ + Read the state of health. + + :param measure: PERCENT or SOH_STAT (default PERCENT) + :return: State of health in % or status bits + """ + raw = self._readWord(BQ27441_COMMAND_SOH) + soh_status = (raw >> 8) & 0xFF + soh_percent = raw & 0xFF + return soh_percent if measure == PERCENT else soh_status + + def temperature(self, measure=BATTERY): + """ + Read the specified temperature. + + :param measure: BATTERY or INTERNAL_TEMP (default BATTERY) + :return: Temperature in units of 0.1 K (divide by 10, subtract 273.15 for °C) + """ + if measure == BATTERY: + return self._readWord(BQ27441_COMMAND_TEMP) + elif measure == INTERNAL_TEMP: + return self._readWord(BQ27441_COMMAND_INT_TEMP) + return 0 + + # ------------------------------------------------------------------------- + # GPOUT Control + # ------------------------------------------------------------------------- + + def GPOUTPolarity(self): + """ + Get the GPOUT polarity setting. + + :return: True if active-high, False if active-low + """ + return bool(self._opConfig() & BQ27441_OPCONFIG_GPIOPOL) + + def setGPOUTPolarity(self, active_high): + """ + Set GPOUT polarity to active-high or active-low. + + :param active_high: True for active-high, False for active-low + :return: True on success + """ + old = self._opConfig() + if (active_high and (old & BQ27441_OPCONFIG_GPIOPOL)) or \ + (not active_high and not (old & BQ27441_OPCONFIG_GPIOPOL)): + return True + new = (old | BQ27441_OPCONFIG_GPIOPOL) if active_high \ + else (old & ~BQ27441_OPCONFIG_GPIOPOL) + return self._writeOpConfig(new) + + def GPOUTFunction(self): + """ + Get the GPOUT function setting. + + :return: True if BAT_LOW, False if SOC_INT + """ + return bool(self._opConfig() & BQ27441_OPCONFIG_BATLOWEN) + + def setGPOUTFunction(self, function): + """ + Set GPOUT function to BAT_LOW or SOC_INT. + + :param function: BAT_LOW or SOC_INT + :return: True on success + """ + old = self._opConfig() + if (function and (old & BQ27441_OPCONFIG_BATLOWEN)) or \ + (not function and not (old & BQ27441_OPCONFIG_BATLOWEN)): + return True + new = (old | BQ27441_OPCONFIG_BATLOWEN) if function \ + else (old & ~BQ27441_OPCONFIG_BATLOWEN) + return self._writeOpConfig(new) + + def SOC1SetThreshold(self): + """ + Get the SOC1 set threshold (threshold to set the alert flag). + + :return: State of charge percentage 0-100 + """ + return self._readExtendedData(BQ27441_ID_DISCHARGE, 0) + + def SOC1ClearThreshold(self): + """ + Get the SOC1 clear threshold (threshold to clear the alert flag). + + :return: State of charge percentage 0-100 + """ + return self._readExtendedData(BQ27441_ID_DISCHARGE, 1) + + def setSOC1Thresholds(self, set_thr, clear_thr): + """ + Set the SOC1 set and clear thresholds. clear_thr should be > set_thr. + + :param set_thr: Set threshold percentage 0-100 + :param clear_thr: Clear threshold percentage 0-100 + :return: True on success + """ + data = bytes([max(0, min(100, set_thr)), max(0, min(100, clear_thr))]) + return self._writeExtendedData(BQ27441_ID_DISCHARGE, 0, data) + + def SOCFSetThreshold(self): + """ + Get the SOCF set threshold (threshold to set the final alert flag). + + :return: State of charge percentage 0-100 + """ + return self._readExtendedData(BQ27441_ID_DISCHARGE, 2) + + def SOCFClearThreshold(self): + """ + Get the SOCF clear threshold (threshold to clear the final alert flag). + + :return: State of charge percentage 0-100 + """ + return self._readExtendedData(BQ27441_ID_DISCHARGE, 3) + + def setSOCFThresholds(self, set_thr, clear_thr): + """ + Set the SOCF set and clear thresholds. clear_thr should be > set_thr. + + :param set_thr: Set threshold percentage 0-100 + :param clear_thr: Clear threshold percentage 0-100 + :return: True on success + """ + data = bytes([max(0, min(100, set_thr)), max(0, min(100, clear_thr))]) + return self._writeExtendedData(BQ27441_ID_DISCHARGE, 2, data) + + def socFlag(self): + """ + Check if the SOC1 flag is set. + + :return: True if flag is set + """ + return bool(self.flags() & BQ27441_FLAG_SOC1) + + def socfFlag(self): + """ + Check if the SOCF flag is set. + + :return: True if flag is set + """ + return bool(self.flags() & BQ27441_FLAG_SOCF) + + def itporFlag(self): + """ + Check if the ITPOR flag is set. + + :return: True if flag is set + """ + return bool(self.flags() & BQ27441_FLAG_ITPOR) + + def fcFlag(self): + """ + Check if the FC (fully charged) flag is set. + + :return: True if flag is set + """ + return bool(self.flags() & BQ27441_FLAG_FC) + + def chgFlag(self): + """ + Check if the CHG (charging) flag is set. + + :return: True if flag is set + """ + return bool(self.flags() & BQ27441_FLAG_CHG) + + def dsgFlag(self): + """ + Check if the DSG (discharging) flag is set. + + :return: True if flag is set + """ + return bool(self.flags() & BQ27441_FLAG_DSG) + + def sociDelta(self): + """ + Get the SOC_INT interval delta. + + :return: Interval percentage value between 1 and 100 + """ + return self._readExtendedData(BQ27441_ID_STATE, 26) + + def setSOCIDelta(self, delta): + """ + Set the SOC_INT interval delta. + + :param delta: Percentage interval between 0 and 100 + :return: True on success + """ + soci = max(0, min(100, delta)) + return self._writeExtendedData(BQ27441_ID_STATE, 26, bytes([soci])) + + def pulseGPOUT(self): + """ + Pulse the GPOUT pin. Device must be in SOC_INT mode. + + :return: True on success + """ + return self._executeControlWord(BQ27441_CONTROL_PULSE_SOC_INT) + + # ------------------------------------------------------------------------- + # Control Sub-commands + # ------------------------------------------------------------------------- + + def deviceType(self): + """ + Read the device type — should return 0x0421. + + :return: 16-bit device type value + """ + return self._readControlWord(BQ27441_CONTROL_DEVICE_TYPE) + + def enterConfig(self, user_control=True): + """ + Enter configuration update mode. + + :param user_control: True if the sketch is managing config mode entry/exit + :return: True on success + """ + if user_control: + self._user_config_control = True + + if self._sealed(): + self._seal_flag = True + self._unseal() + + if self._executeControlWord(BQ27441_CONTROL_SET_CFGUPDATE): + timeout = BQ27441_I2C_TIMEOUT + while timeout > 0 and not (self.flags() & BQ27441_FLAG_CFGUPMODE): + time.sleep_ms(1) + timeout -= 1 + if timeout > 0: + return True + + return False + + def exitConfig(self, resim=True): + """ + Exit configuration update mode. + + :param resim: True to perform OCV resimulation on exit (recommended) + :return: True on success + """ + if resim: + if self._softReset(): + timeout = BQ27441_I2C_TIMEOUT + while timeout > 0 and (self.flags() & BQ27441_FLAG_CFGUPMODE): + time.sleep_ms(1) + timeout -= 1 + if timeout > 0: + if self._seal_flag: + self._seal() + return True + return False + else: + return self._executeControlWord(BQ27441_CONTROL_EXIT_CFGUPDATE) + + def flags(self): + """ + Read the flags register. + + :return: 16-bit flags value + """ + return self._readWord(BQ27441_COMMAND_FLAGS) + + def status(self): + """ + Read the CONTROL_STATUS subcommand. + + :return: 16-bit status value + """ + return self._readControlWord(BQ27441_CONTROL_STATUS) + + # ------------------------------------------------------------------------- + # Private: Seal / Unseal + # ------------------------------------------------------------------------- + + def _sealed(self): + return bool(self.status() & BQ27441_STATUS_SS) + + def _seal(self): + return self._readControlWord(BQ27441_CONTROL_SEALED) + + def _unseal(self): + if self._readControlWord(BQ27441_UNSEAL_KEY): + return self._readControlWord(BQ27441_UNSEAL_KEY) + return False + + # ------------------------------------------------------------------------- + # Private: OpConfig + # ------------------------------------------------------------------------- + + def _opConfig(self): + return self._readWord(BQ27441_EXTENDED_OPCONFIG) + + def _writeOpConfig(self, value): + data = bytes([value >> 8, value & 0xFF]) + return self._writeExtendedData(BQ27441_ID_REGISTERS, 0, data) + + def _softReset(self): + return self._executeControlWord(BQ27441_CONTROL_SOFT_RESET) + + # ------------------------------------------------------------------------- + # Private: Low-level word and control word access + # ------------------------------------------------------------------------- + + def _readWord(self, sub_address): + try: + data = self.i2c.readfrom_mem(self.address, sub_address, 2) + return (data[1] << 8) | data[0] + except: + return 0 + + def _readControlWord(self, function): + try: + cmd = bytes([function & 0xFF, (function >> 8) & 0xFF]) + self.i2c.writeto_mem(self.address, BQ27441_COMMAND_CONTROL, cmd) + data = self.i2c.readfrom_mem(self.address, BQ27441_COMMAND_CONTROL, 2) + return (data[1] << 8) | data[0] + except: + return 0 + + def _executeControlWord(self, function): + try: + cmd = bytes([function & 0xFF, (function >> 8) & 0xFF]) + self.i2c.writeto_mem(self.address, BQ27441_COMMAND_CONTROL, cmd) + return True + except: + return False + + # ------------------------------------------------------------------------- + # Private: Extended data block access + # ------------------------------------------------------------------------- + + def _blockDataControl(self): + try: + self.i2c.writeto_mem(self.address, BQ27441_EXTENDED_CONTROL, bytes([0x00])) + return True + except: + return False + + def _blockDataClass(self, class_id): + try: + self.i2c.writeto_mem(self.address, BQ27441_EXTENDED_DATACLASS, bytes([class_id])) + return True + except: + return False + + def _blockDataOffset(self, offset): + try: + self.i2c.writeto_mem(self.address, BQ27441_EXTENDED_DATABLOCK, bytes([offset])) + return True + except: + return False + + def _blockDataChecksum(self): + try: + data = self.i2c.readfrom_mem(self.address, BQ27441_EXTENDED_CHECKSUM, 1) + return data[0] + except: + return 0 + + def _readBlockData(self, offset): + try: + addr = BQ27441_EXTENDED_BLOCKDATA + offset + data = self.i2c.readfrom_mem(self.address, addr, 1) + return data[0] + except: + return 0 + + def _writeBlockData(self, offset, value): + try: + addr = BQ27441_EXTENDED_BLOCKDATA + offset + self.i2c.writeto_mem(self.address, addr, bytes([value])) + return True + except: + return False + + def _computeBlockChecksum(self): + try: + data = self.i2c.readfrom_mem(self.address, BQ27441_EXTENDED_BLOCKDATA, 32) + csum = (255 - sum(data)) & 0xFF + return csum + except: + return 0 + + def _writeBlockChecksum(self, csum): + try: + self.i2c.writeto_mem(self.address, BQ27441_EXTENDED_CHECKSUM, bytes([csum])) + return True + except: + return False + + def _readExtendedData(self, class_id, offset): + """ + Read a single byte from extended data memory. + + :param class_id: Data class ID + :param offset: Byte offset within the class + :return: Byte value read + """ + if not self._user_config_control: + self.enterConfig(False) + + if not self._blockDataControl(): + return 0 + if not self._blockDataClass(class_id): + return 0 + + self._blockDataOffset(offset // 32) + self._computeBlockChecksum() + + ret = self._readBlockData(offset % 32) + + if not self._user_config_control: + self.exitConfig() + + return ret + + def _writeExtendedData(self, class_id, offset, data): + """ + Write bytes to extended data memory. + + :param class_id: Data class ID + :param offset: Byte offset within the class + :param data: bytes object to write (max 32 bytes) + :return: True on success + """ + if len(data) > 32: + return False + + if not self._user_config_control: + self.enterConfig(False) + + if not self._blockDataControl(): + return False + if not self._blockDataClass(class_id): + return False + + self._blockDataOffset(offset // 32) + self._computeBlockChecksum() + + for i, byte in enumerate(data): + self._writeBlockData((offset % 32) + i, byte) + + new_csum = self._computeBlockChecksum() + self._writeBlockChecksum(new_csum) + + if not self._user_config_control: + self.exitConfig() + + return True + + +# Convenience instance — mirrors the Arduino library's global `lipo` object +lipo = BQ27441() \ No newline at end of file diff --git a/Sensors/BQ27441/package.json b/Sensors/BQ27441/package.json new file mode 100644 index 0000000..65ec385 --- /dev/null +++ b/Sensors/BQ27441/package.json @@ -0,0 +1,26 @@ +{ + "urls": [ + [ + "bq27441.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/BQ27441/BQ27441/bq27441.py" + ], + [ + "Examples/bq27441-basicReading.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/BQ27441/BQ27441/Examples/bq27441-basicReading.py" + ], + [ + "Examples/bq27441-extendedBatteryConfiguration.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/BQ27441/BQ27441/Examples/bq27441-extendedBatteryConfiguration.py" + ], + [ + "Examples/bq27441-GpoutBatLow.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/BQ27441/BQ27441/Examples/bq27441-GpoutBatLow.py" + ], + [ + "Examples/bq27441-GpoutSocInt.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/BQ27441/BQ27441/Examples/bq27441-GpoutSocInt.py" + ] + ], + "deps": [], + "version": "1.0" +} diff --git a/Sensors/ElectrochemicalGasSensor/ElectrochemicalGasSensor/ElectrochemicalGasSensor.py b/Sensors/ElectrochemicalGasSensor/ElectrochemicalGasSensor/ElectrochemicalGasSensor.py new file mode 100644 index 0000000..16ee97d --- /dev/null +++ b/Sensors/ElectrochemicalGasSensor/ElectrochemicalGasSensor/ElectrochemicalGasSensor.py @@ -0,0 +1,380 @@ +# FILE: ElectrochemicalGasSensor.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: MicroPython library for the Soldered Electrochemical Gas Sensor breakout (ADS1115 + LMP91000) +# LAST UPDATED: 2026-05-21 + +from machine import I2C, Pin +from os import uname +import time + +from ads1115 import ADS1115, ADS_GAIN_6_144V, ADS_GAIN_4_096V, ADS_GAIN_2_048V +from lmp91000 import LMP91000 + +# =========================================================================== +# Board constants +# =========================================================================== +_DEFAULT_ADC_ADDR = 0x49 +_REF_VOLTAGE = 2.5 # External reference voltage on board (V) + +# =========================================================================== +# LMP91000 TIA gain config values +# =========================================================================== +TIA_GAIN_EXTERNAL = 0x00 +TIA_GAIN_2_75_KOHM = 0x01 +TIA_GAIN_3_5_KOHM = 0x02 +TIA_GAIN_7_KOHM = 0x03 +TIA_GAIN_14_KOHM = 0x04 +TIA_GAIN_35_KOHM = 0x05 +TIA_GAIN_120_KOHM = 0x06 +TIA_GAIN_350_KOHM = 0x07 + +# RLOAD +RLOAD_10_OHM = 0x00 +RLOAD_33_OHM = 0x01 +RLOAD_50_OHM = 0x02 +RLOAD_100_OHM = 0x03 + +# Reference source +REF_INTERNAL = 0x00 +REF_EXTERNAL = 0x01 + +# Internal zero +INTERNAL_ZERO_20_PERCENT = 0x00 +INTERNAL_ZERO_50_PERCENT = 0x01 +INTERNAL_ZERO_67_PERCENT = 0x02 +INTERNAL_ZERO_BYPASSED = 0x03 + +# Bias sign +BIAS_SIGN_NEGATIVE = 0x00 +BIAS_SIGN_POSITIVE = 0x01 + +# Bias percentage +BIAS_0_PERCENT = 0x00 +BIAS_1_PERCENT = 0x01 +BIAS_2_PERCENT = 0x02 +BIAS_4_PERCENT = 0x03 +BIAS_6_PERCENT = 0x04 +BIAS_8_PERCENT = 0x05 +BIAS_10_PERCENT = 0x06 +BIAS_12_PERCENT = 0x07 +BIAS_14_PERCENT = 0x08 +BIAS_16_PERCENT = 0x09 +BIAS_18_PERCENT = 0x0A +BIAS_20_PERCENT = 0x0B +BIAS_22_PERCENT = 0x0C +BIAS_24_PERCENT = 0x0D + +# FET short +FET_SHORT_DISABLED = 0x00 +FET_SHORT_ENABLED = 0x01 + +# Operation mode +OP_MODE_DEEP_SLEEP = 0x00 +OP_MODE_2LEAD_GROUND_CELL = 0x01 +OP_MODE_STANDBY = 0x02 +OP_MODE_3LEAD_AMP_CELL = 0x03 +OP_MODE_TEMPERATURE_TIA_OFF = 0x06 +OP_MODE_TEMPERATURE_TIA_ON = 0x07 + +# =========================================================================== +# Lookup tables +# =========================================================================== +_TIA_GAIN_TABLE = { + TIA_GAIN_EXTERNAL: -1, + TIA_GAIN_2_75_KOHM: 2750.0, + TIA_GAIN_3_5_KOHM: 3500.0, + TIA_GAIN_7_KOHM: 7000.0, + TIA_GAIN_14_KOHM: 14000.0, + TIA_GAIN_35_KOHM: 35000.0, + TIA_GAIN_120_KOHM: 120000.0, + TIA_GAIN_350_KOHM: 350000.0, +} + +_INTERNAL_ZERO_TABLE = { + INTERNAL_ZERO_20_PERCENT: 20.0, + INTERNAL_ZERO_50_PERCENT: 50.0, + INTERNAL_ZERO_67_PERCENT: 67.0, + INTERNAL_ZERO_BYPASSED: -1, +} + + +# =========================================================================== +# Sensor configuration class +# =========================================================================== +class SensorConfig: + """Holds configuration parameters for a specific electrochemical gas sensor.""" + + def __init__(self, nano_amperes_per_ppm, internal_zero_calibration, ads_gain, + tia_gain, rload, ref_source, internal_zero, bias_sign, bias, + fet_short, op_mode): + self.nanoAmperesPerPPM = nano_amperes_per_ppm + self.internalZeroCalibration = internal_zero_calibration + self.adsGain = ads_gain + self.TIA_GAIN_IN_KOHMS = tia_gain + self.RLOAD = rload + self.REF_SOURCE = ref_source + self.INTERNAL_ZERO = internal_zero + self.BIAS_SIGN = bias_sign + self.BIAS = bias + self.FET_SHORT = fet_short + self.OP_MODE = op_mode + + +# =========================================================================== +# Predefined sensor configurations +# =========================================================================== + +# SGX-4CO — Carbon Monoxide +SENSOR_CO = SensorConfig( + nano_amperes_per_ppm=70.0, + internal_zero_calibration=0, + ads_gain=ADS_GAIN_4_096V, + tia_gain=TIA_GAIN_14_KOHM, + rload=RLOAD_10_OHM, + ref_source=REF_EXTERNAL, + internal_zero=INTERNAL_ZERO_20_PERCENT, + bias_sign=BIAS_SIGN_NEGATIVE, + bias=BIAS_0_PERCENT, + fet_short=FET_SHORT_DISABLED, + op_mode=OP_MODE_3LEAD_AMP_CELL, +) + +# SGX-4NO2 — Nitrogen Dioxide +SENSOR_NO2 = SensorConfig( + nano_amperes_per_ppm=-600.0, + internal_zero_calibration=0, + ads_gain=ADS_GAIN_2_048V, + tia_gain=TIA_GAIN_35_KOHM, + rload=RLOAD_10_OHM, + ref_source=REF_EXTERNAL, + internal_zero=INTERNAL_ZERO_67_PERCENT, + bias_sign=BIAS_SIGN_NEGATIVE, + bias=BIAS_0_PERCENT, + fet_short=FET_SHORT_DISABLED, + op_mode=OP_MODE_3LEAD_AMP_CELL, +) + +# SGX-4SO2 — Sulphur Dioxide +SENSOR_SO2 = SensorConfig( + nano_amperes_per_ppm=400.0, + internal_zero_calibration=0, + ads_gain=ADS_GAIN_4_096V, + tia_gain=TIA_GAIN_120_KOHM, + rload=RLOAD_10_OHM, + ref_source=REF_EXTERNAL, + internal_zero=INTERNAL_ZERO_20_PERCENT, + bias_sign=BIAS_SIGN_POSITIVE, + bias=BIAS_0_PERCENT, + fet_short=FET_SHORT_DISABLED, + op_mode=OP_MODE_3LEAD_AMP_CELL, +) + +# SGX-403-20 — Ozone +SENSOR_O3 = SensorConfig( + nano_amperes_per_ppm=-1000.0, + internal_zero_calibration=-0.0012, + ads_gain=ADS_GAIN_2_048V, + tia_gain=TIA_GAIN_35_KOHM, + rload=RLOAD_10_OHM, + ref_source=REF_EXTERNAL, + internal_zero=INTERNAL_ZERO_67_PERCENT, + bias_sign=BIAS_SIGN_NEGATIVE, + bias=BIAS_0_PERCENT, + fet_short=FET_SHORT_DISABLED, + op_mode=OP_MODE_3LEAD_AMP_CELL, +) + +# SGX-4NO-250 — Nitric Oxide +SENSOR_NO = SensorConfig( + nano_amperes_per_ppm=400.0, + internal_zero_calibration=0, + ads_gain=ADS_GAIN_4_096V, + tia_gain=TIA_GAIN_120_KOHM, + rload=RLOAD_10_OHM, + ref_source=REF_EXTERNAL, + internal_zero=INTERNAL_ZERO_20_PERCENT, + bias_sign=BIAS_SIGN_POSITIVE, + bias=BIAS_12_PERCENT, + fet_short=FET_SHORT_DISABLED, + op_mode=OP_MODE_3LEAD_AMP_CELL, +) + +# SGX-4H2S-100 — Hydrogen Sulphide +SENSOR_H2S = SensorConfig( + nano_amperes_per_ppm=1200.0, + internal_zero_calibration=0, + ads_gain=ADS_GAIN_4_096V, + tia_gain=TIA_GAIN_7_KOHM, + rload=RLOAD_10_OHM, + ref_source=REF_EXTERNAL, + internal_zero=INTERNAL_ZERO_20_PERCENT, + bias_sign=BIAS_SIGN_POSITIVE, + bias=BIAS_0_PERCENT, + fet_short=FET_SHORT_DISABLED, + op_mode=OP_MODE_3LEAD_AMP_CELL, +) + +# SGX-4NH3-300 — Ammonia +SENSOR_NH3 = SensorConfig( + nano_amperes_per_ppm=40.0, + internal_zero_calibration=0, + ads_gain=ADS_GAIN_4_096V, + tia_gain=TIA_GAIN_35_KOHM, + rload=RLOAD_10_OHM, + ref_source=REF_EXTERNAL, + internal_zero=INTERNAL_ZERO_20_PERCENT, + bias_sign=BIAS_SIGN_POSITIVE, + bias=BIAS_0_PERCENT, + fet_short=FET_SHORT_DISABLED, + op_mode=OP_MODE_3LEAD_AMP_CELL, +) + +# SGX-4CL2 — Chlorine +SENSOR_CL2 = SensorConfig( + nano_amperes_per_ppm=600.0, + internal_zero_calibration=0, + ads_gain=ADS_GAIN_4_096V, + tia_gain=TIA_GAIN_120_KOHM, + rload=RLOAD_33_OHM, + ref_source=REF_EXTERNAL, + internal_zero=INTERNAL_ZERO_20_PERCENT, + bias_sign=BIAS_SIGN_POSITIVE, + bias=BIAS_0_PERCENT, + fet_short=FET_SHORT_DISABLED, + op_mode=OP_MODE_3LEAD_AMP_CELL, +) + + +# =========================================================================== +# ElectrochemicalGasSensor +# =========================================================================== +class ElectrochemicalGasSensor: + """ + MicroPython class for the Soldered Electrochemical Gas Sensor breakout. + Combines ADS1115 (ADC) and LMP91000 (analog frontend) over I2C. + Supports CO, NO2, SO2, O3, NO, H2S, NH3, CL2 sensor types. + """ + + def __init__(self, sensor_type, i2c=None, adc_addr=_DEFAULT_ADC_ADDR, config_pin=None): + """ + Initialize the sensor. + + :param sensor_type: SensorConfig instance (SENSOR_CO, SENSOR_NO2, etc.) + :param i2c: Initialized I2C object (optional, auto-detected on known boards) + :param adc_addr: I2C address of ADS1115 (default 0x49) + :param config_pin: GPIO pin number for LMP91000 MENB (None if wired to GND) + """ + self._type = sensor_type + self._adcAddr = adc_addr + self._tiaGainInKOhms = 0.0 + self._internalZeroPercent = 0.0 + + if i2c is not None: + self._i2c = i2c + else: + if uname().sysname in ("esp32", "Soldered Dasduino CONNECTPLUS"): + self._i2c = I2C(0, scl=Pin(22), sda=Pin(21)) + elif uname().sysname == "esp8266": + self._i2c = I2C(scl=Pin(5), sda=Pin(4)) + else: + raise Exception("Board not recognized, please pass an I2C object manually") + + # MENB: HIGH = LMP91000 I2C disabled (normal op), LOW = enabled (config mode) + self._configPin = Pin(config_pin, Pin.OUT, value=1) if config_pin is not None else None + + def begin(self): + """ + Initialize ADS1115 and LMP91000, configure the analog frontend. + Must be called before making any readings. + + :returns: True if successful, False if initialization failed + """ + self._lmp = LMP91000(self._i2c) + self._ads = ADS1115(self._i2c, self._adcAddr) + + result = self._ads.begin() + self._ads.setGain(self._type.adsGain) + self._ads.setDataRate(0) # slowest for best precision + + result = result and bool(self._configureLMP()) + return result + + def configureLMP(self): + """Re-configure the LMP91000. Useful after power cycle.""" + return self._configureLMP() + + def _configureLMP(self): + if self._configPin is not None: + self._configPin.value(0) # MENB LOW — enable I2C config + + tiacn = (self._type.TIA_GAIN_IN_KOHMS << 2) | self._type.RLOAD + refcn = ((self._type.REF_SOURCE << 7) | + (self._type.INTERNAL_ZERO << 5) | + (self._type.BIAS_SIGN << 4) | + self._type.BIAS) + modecn = (self._type.FET_SHORT << 7) | self._type.OP_MODE + + res = self._lmp.configure(tiacn, refcn, modecn) + + self._tiaGainInKOhms = _TIA_GAIN_TABLE.get(self._type.TIA_GAIN_IN_KOHMS, -1) + self._internalZeroPercent = _INTERNAL_ZERO_TABLE.get(self._type.INTERNAL_ZERO, -1) + + if self._configPin is not None: + self._configPin.value(1) # MENB HIGH — disable I2C config + + return res + + def getVoltage(self): + """Read current voltage from ADS1115 channel 0. Returns float in volts.""" + raw = self._ads.readADC(0) + return self._ads.toVoltage(raw) + + def getPPM(self): + """Calculate and return gas concentration in PPM.""" + voltage = self.getVoltage() + + volts_no_ref = voltage - (_REF_VOLTAGE * (self._internalZeroPercent / 100.0)) + volts_no_ref += self._type.internalZeroCalibration + + current = volts_no_ref / self._tiaGainInKOhms + ppm = current / (self._type.nanoAmperesPerPPM * 1e-9) + + if ppm < 0: + ppm = 0.0 + return ppm + + def getPPB(self): + """Calculate and return gas concentration in PPB.""" + return self.getPPM() * 1000.0 + + def getAveragedPPM(self, num_measurements=5, seconds_delay=2): + """ + Average multiple PPM readings. Blocking. + + :param num_measurements: Number of readings to average + :param seconds_delay: Seconds to wait between each reading + :returns: Averaged PPM value + """ + total = 0.0 + for _ in range(num_measurements): + total += self.getPPM() + time.sleep(seconds_delay) + return total / num_measurements + + def getAveragedPPB(self, num_measurements=5, seconds_delay=2): + """ + Average multiple PPB readings. Blocking. + + :param num_measurements: Number of readings to average + :param seconds_delay: Seconds to wait between each reading + :returns: Averaged PPB value + """ + return self.getAveragedPPM(num_measurements, seconds_delay) * 1000.0 + + def setCustomTiaGain(self, tia_gain): + """Set custom TIA gain in Ohms (for external resistor use).""" + self._tiaGainInKOhms = tia_gain + + def setCustomZeroCalibration(self, calibration): + """Set custom zero-point calibration voltage offset.""" + self._type.internalZeroCalibration = calibration diff --git a/Sensors/ElectrochemicalGasSensor/ElectrochemicalGasSensor/Examples/electrochemical-averagedMeasurement.py b/Sensors/ElectrochemicalGasSensor/ElectrochemicalGasSensor/Examples/electrochemical-averagedMeasurement.py new file mode 100644 index 0000000..67d5120 --- /dev/null +++ b/Sensors/ElectrochemicalGasSensor/ElectrochemicalGasSensor/Examples/electrochemical-averagedMeasurement.py @@ -0,0 +1,53 @@ +# FILE: electrochemical-averagedMeasurement.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Averaged PPM reading example using the built-in averaging function. +# WORKS WITH: Electrochemical Gas Sensor Breakout: solde.red/333218 +# LAST UPDATED: 2026-05-21 + +# Connecting diagram: +# +# Electrochemical Gas Sensor Dasduino +# Qwiic------------------------>Qwiic +# LMPEN------------------------>GND or GPIO pin (see config_pin parameter below) + +from machine import I2C, Pin +import time +from ElectrochemicalGasSensor import ElectrochemicalGasSensor, SENSOR_O3 + +# How many readings to average and how many seconds to wait between them +NUM_READINGS = 5 +SECS_BETWEEN_READINGS = 3 + +# Initialize I2C +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) + +# Create sensor object - change SENSOR_O3 to match your sensor type +# Available types: SENSOR_CO, SENSOR_NO2, SENSOR_SO2, SENSOR_O3, +# SENSOR_NO, SENSOR_H2S, SENSOR_NH3, SENSOR_CL2 +sensor = ElectrochemicalGasSensor(SENSOR_O3, i2c) + +# ------------------------------------------------------------------------- +# Setup +# ------------------------------------------------------------------------- +print("Electrochemical Gas Sensor - Averaged Measurement Example") + +if not sensor.begin(): + print("ERROR: Can't init the sensor! Check connections!") + while True: + time.sleep_ms(100) + +print("Sensor initialized successfully!") + +# ------------------------------------------------------------------------- +# Main loop +# ------------------------------------------------------------------------- +while True: + # getAveragedPPM is a blocking call — it waits between each reading + reading = sensor.getAveragedPPB(NUM_READINGS, SECS_BETWEEN_READINGS) + + # If PPM is more relevant for your sensor, use: + # reading = sensor.getAveragedPPM(NUM_READINGS, SECS_BETWEEN_READINGS) + + print("Sensor reading: {:.5f} PPB".format(reading)) + + time.sleep_ms(2500) diff --git a/Sensors/ElectrochemicalGasSensor/ElectrochemicalGasSensor/Examples/electrochemical-calibrateSensor.py b/Sensors/ElectrochemicalGasSensor/ElectrochemicalGasSensor/Examples/electrochemical-calibrateSensor.py new file mode 100644 index 0000000..304394d --- /dev/null +++ b/Sensors/ElectrochemicalGasSensor/ElectrochemicalGasSensor/Examples/electrochemical-calibrateSensor.py @@ -0,0 +1,53 @@ +# FILE: electrochemical-calibrateSensor.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Sensor zero-point calibration example. +# WORKS WITH: Electrochemical Gas Sensor Breakout: solde.red/333218 +# LAST UPDATED: 2026-05-21 + +# Connecting diagram: +# +# Electrochemical Gas Sensor Dasduino +# Qwiic------------------------>Qwiic +# LMPEN------------------------>32 (GPIO pin, required for configuration) + +from machine import I2C, Pin +import time +from ElectrochemicalGasSensor import ElectrochemicalGasSensor, SENSOR_O3 + +# This value is added to the voltage read from the ADC. +# In an environment with 0 of the target gas, the reading after calibration +# should be just barely 0 PPB — adjust this value until that is the case. +CUSTOM_CALIBRATION = 0 + +# Initialize I2C +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) + +# Custom ADC address (0x49 default) and GPIO pin for LMPEN +sensor = ElectrochemicalGasSensor(SENSOR_O3, i2c, adc_addr=0x49, config_pin=32) + +# ------------------------------------------------------------------------- +# Setup +# ------------------------------------------------------------------------- +print("Electrochemical Gas Sensor - Calibration Example") + +if not sensor.begin(): + print("ERROR: Can't init the sensor! Check connections!") + while True: + time.sleep_ms(100) + +# Apply the zero-point calibration offset +sensor.setCustomZeroCalibration(CUSTOM_CALIBRATION) + +print("Sensor initialized successfully!") + +# ------------------------------------------------------------------------- +# Main loop +# ------------------------------------------------------------------------- +while True: + reading = sensor.getPPB() + + # O3 is typically measured in PPB + # Switch to getPPM() if PPM is more relevant for your use case + print("Sensor reading: {:.5f} PPB".format(reading)) + + time.sleep_ms(2500) diff --git a/Sensors/ElectrochemicalGasSensor/ElectrochemicalGasSensor/Examples/electrochemical-customConfig.py b/Sensors/ElectrochemicalGasSensor/ElectrochemicalGasSensor/Examples/electrochemical-customConfig.py new file mode 100644 index 0000000..0d2bc20 --- /dev/null +++ b/Sensors/ElectrochemicalGasSensor/ElectrochemicalGasSensor/Examples/electrochemical-customConfig.py @@ -0,0 +1,75 @@ +# FILE: electrochemical-customConfig.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Custom LMP91000 configuration example for any electrochemical sensor. +# WORKS WITH: Electrochemical Gas Sensor Breakout: solde.red/333218 +# LAST UPDATED: 2026-05-21 + +# Connecting diagram: +# +# Electrochemical Gas Sensor Dasduino +# Qwiic------------------------>Qwiic +# LMPEN------------------------>GPIO pin (see config_pin parameter below) + +from machine import I2C, Pin +import time +from ElectrochemicalGasSensor import ( + ElectrochemicalGasSensor, SensorConfig, + TIA_GAIN_35_KOHM, RLOAD_10_OHM, REF_EXTERNAL, + INTERNAL_ZERO_67_PERCENT, BIAS_SIGN_NEGATIVE, BIAS_0_PERCENT, + FET_SHORT_DISABLED, OP_MODE_3LEAD_AMP_CELL, +) +from ads1115 import ADS_GAIN_2_048V + +# Custom sensor configuration — edit these values to match your sensor's datasheet. +# +# nanoAmperesPerPPM: Sensitivity from the sensor datasheet (nA/PPM) +# internalZeroCalibration: Voltage offset added to zero the reading in clean air +# adsGain: ADS1115 full-scale range (ADS_GAIN_6_144V .. ADS_GAIN_0_256V) +# tia_gain: TIA feedback resistor (TIA_GAIN_2_75_KOHM .. TIA_GAIN_350_KOHM) +# rload: Load resistor (RLOAD_10_OHM .. RLOAD_100_OHM) +# ref_source: Reference voltage source (REF_INTERNAL / REF_EXTERNAL) +# internal_zero: Internal zero percentage (INTERNAL_ZERO_20/50/67_PERCENT or _BYPASSED) +# bias_sign: Bias polarity (BIAS_SIGN_NEGATIVE / BIAS_SIGN_POSITIVE) +# bias: Bias percentage of Vref (BIAS_0_PERCENT .. BIAS_24_PERCENT) +# fet_short: FET short (FET_SHORT_DISABLED / FET_SHORT_ENABLED) +# op_mode: Operating mode (OP_MODE_3LEAD_AMP_CELL for normal operation) +SENSOR_CUSTOM = SensorConfig( + nano_amperes_per_ppm=-1000.0, + internal_zero_calibration=-0.0012, + ads_gain=ADS_GAIN_2_048V, + tia_gain=TIA_GAIN_35_KOHM, + rload=RLOAD_10_OHM, + ref_source=REF_EXTERNAL, + internal_zero=INTERNAL_ZERO_67_PERCENT, + bias_sign=BIAS_SIGN_NEGATIVE, + bias=BIAS_0_PERCENT, + fet_short=FET_SHORT_DISABLED, + op_mode=OP_MODE_3LEAD_AMP_CELL, +) + +# Initialize I2C +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) + +sensor = ElectrochemicalGasSensor(SENSOR_CUSTOM, i2c) + +# ------------------------------------------------------------------------- +# Setup +# ------------------------------------------------------------------------- +print("Electrochemical Gas Sensor - Custom Config Example") + +if not sensor.begin(): + print("ERROR: Can't init the sensor! Check connections!") + while True: + time.sleep_ms(100) + +print("Sensor initialized successfully!") + +# ------------------------------------------------------------------------- +# Main loop +# ------------------------------------------------------------------------- +while True: + reading = sensor.getPPB() + + print("Sensor reading: {:.5f} PPB".format(reading)) + + time.sleep_ms(2500) diff --git a/Sensors/ElectrochemicalGasSensor/ElectrochemicalGasSensor/Examples/electrochemical-singleSensor.py b/Sensors/ElectrochemicalGasSensor/ElectrochemicalGasSensor/Examples/electrochemical-singleSensor.py new file mode 100644 index 0000000..4a3dbd6 --- /dev/null +++ b/Sensors/ElectrochemicalGasSensor/ElectrochemicalGasSensor/Examples/electrochemical-singleSensor.py @@ -0,0 +1,51 @@ +# FILE: electrochemical-singleSensor.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Single sensor PPM reading example. +# WORKS WITH: Electrochemical Gas Sensor Breakout: solde.red/333218 +# LAST UPDATED: 2026-05-21 + +# Connecting diagram: +# +# Electrochemical Gas Sensor Dasduino +# Qwiic------------------------>Qwiic +# LMPEN------------------------>GND or GPIO pin (see config_pin parameter below) + +from machine import I2C, Pin +import time +from ElectrochemicalGasSensor import ElectrochemicalGasSensor, SENSOR_O3 + +# Initialize I2C +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) + +# Create sensor object - change SENSOR_O3 to match your sensor type +# Available types: SENSOR_CO, SENSOR_NO2, SENSOR_SO2, SENSOR_O3, +# SENSOR_NO, SENSOR_H2S, SENSOR_NH3, SENSOR_CL2 +sensor = ElectrochemicalGasSensor(SENSOR_O3, i2c) + +# If using a custom ADC address or a GPIO pin for LMP91000 MENB: +# sensor = ElectrochemicalGasSensor(SENSOR_O3, i2c, adc_addr=0x4B, config_pin=5) + +# ------------------------------------------------------------------------- +# Setup +# ------------------------------------------------------------------------- +print("Electrochemical Gas Sensor - Single Sensor Example") + +if not sensor.begin(): + print("ERROR: Can't init the sensor! Check connections!") + while True: + time.sleep_ms(100) + +print("Sensor initialized successfully!") + +# ------------------------------------------------------------------------- +# Main loop +# ------------------------------------------------------------------------- +while True: + reading = sensor.getPPB() + + # If PPM is more relevant for your sensor, use: + # reading = sensor.getPPM() + + print("Sensor reading: {:.5f} PPB".format(reading)) + + time.sleep_ms(2500) diff --git a/Sensors/ElectrochemicalGasSensor/ElectrochemicalGasSensor/Examples/electrochemical-twoSensors.py b/Sensors/ElectrochemicalGasSensor/ElectrochemicalGasSensor/Examples/electrochemical-twoSensors.py new file mode 100644 index 0000000..c122529 --- /dev/null +++ b/Sensors/ElectrochemicalGasSensor/ElectrochemicalGasSensor/Examples/electrochemical-twoSensors.py @@ -0,0 +1,63 @@ +# FILE: electrochemical-twoSensors.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Two O3 sensor PPB reading example using two breakouts on one I2C bus. +# WORKS WITH: Electrochemical Gas Sensor Breakout: solde.red/333218 +# LAST UPDATED: 2026-05-21 + +# Connecting diagram: +# +# Sensor 1 Dasduino +# Qwiic------------------------>Qwiic +# LMPEN------------------------>25 (GPIO pin, required for I2C configuration) +# +# Sensor 2 Dasduino +# Qwiic------------------------>Qwiic (same bus) +# LMPEN------------------------>32 (GPIO pin, required for I2C configuration) +# +# Set different ADC I2C addresses on each breakout using the address jumpers. +# Both LMP91000 chips share address 0x48 — the LMPEN pin selects which one +# is active during configuration, so a GPIO pin is required for each sensor. + +from machine import I2C, Pin +import time +from ElectrochemicalGasSensor import ElectrochemicalGasSensor, SENSOR_O3 + +# Initialize shared I2C bus +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) + +# Sensor 1: ADC address 0x4A, LMPEN on pin 25 +sensor1 = ElectrochemicalGasSensor(SENSOR_O3, i2c, adc_addr=0x4A, config_pin=25) + +# Sensor 2: ADC address 0x49, LMPEN on pin 32 +sensor2 = ElectrochemicalGasSensor(SENSOR_O3, i2c, adc_addr=0x49, config_pin=32) + +# ------------------------------------------------------------------------- +# Setup +# ------------------------------------------------------------------------- +print("Electrochemical Gas Sensor - Two Sensors Example") + +if not sensor1.begin(): + print("ERROR: Can't init sensor 1! Check connections!") + while True: + time.sleep_ms(100) + +print("Sensor 1 initialized successfully!") + +if not sensor2.begin(): + print("ERROR: Can't init sensor 2! Check connections!") + while True: + time.sleep_ms(100) + +print("Sensor 2 initialized successfully!") + +# ------------------------------------------------------------------------- +# Main loop +# ------------------------------------------------------------------------- +while True: + reading1 = sensor1.getPPB() + reading2 = sensor2.getPPB() + + print("Sensor 1 reading: {:.5f} PPB".format(reading1)) + print("Sensor 2 reading: {:.5f} PPB".format(reading2)) + + time.sleep_ms(2500) diff --git a/Sensors/ElectrochemicalGasSensor/ElectrochemicalGasSensor/ads1115.py b/Sensors/ElectrochemicalGasSensor/ElectrochemicalGasSensor/ads1115.py new file mode 100644 index 0000000..67d47ec --- /dev/null +++ b/Sensors/ElectrochemicalGasSensor/ElectrochemicalGasSensor/ads1115.py @@ -0,0 +1,101 @@ +# FILE: ads1115.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: MicroPython driver for ADS1115 16-bit 4-channel ADC (helper for ElectrochemicalGasSensor) +# LAST UPDATED: 2026-05-21 + +import time + +# Registers +_REG_CONVERT = 0x00 +_REG_CONFIG = 0x01 + +# Config register bits +_OS_START_SINGLE = 0x8000 +_OS_NOT_BUSY = 0x8000 +_MODE_SINGLE = 0x0100 + +# PGA gain register values +_PGA_6_144V = 0x0000 +_PGA_4_096V = 0x0200 +_PGA_2_048V = 0x0400 +_PGA_1_024V = 0x0600 +_PGA_0_512V = 0x0800 +_PGA_0_256V = 0x0A00 + +# Comparator disabled + default polarity (matches ADS1X15 reset() defaults) +_COMP_DEFAULTS = 0x000B + +# User-facing gain index constants (passed to setGain) +ADS_GAIN_6_144V = 0 +ADS_GAIN_4_096V = 1 +ADS_GAIN_2_048V = 2 +ADS_GAIN_1_024V = 4 +ADS_GAIN_0_512V = 8 +ADS_GAIN_0_256V = 16 + +_PGA_TABLE = { + ADS_GAIN_6_144V: (_PGA_6_144V, 6.144), + ADS_GAIN_4_096V: (_PGA_4_096V, 4.096), + ADS_GAIN_2_048V: (_PGA_2_048V, 2.048), + ADS_GAIN_1_024V: (_PGA_1_024V, 1.024), + ADS_GAIN_0_512V: (_PGA_0_512V, 0.512), + ADS_GAIN_0_256V: (_PGA_0_256V, 0.256), +} + + +class ADS1115: + """16-bit 4-channel ADC over I2C. Used internally by ElectrochemicalGasSensor.""" + + def __init__(self, i2c, address): + self._i2c = i2c + self._address = address + self._gain_reg = _PGA_6_144V + self._max_voltage = 6.144 + self._datarate = 4 << 5 # index 4 default + + def begin(self): + """Return True if ADS1115 is found on I2C bus.""" + return self._address in self._i2c.scan() + + def setGain(self, gain): + """Set PGA gain. Use ADS_GAIN_* constants.""" + self._gain_reg, self._max_voltage = _PGA_TABLE.get(gain, (_PGA_6_144V, 6.144)) + + def setDataRate(self, rate): + """Set data rate index 0-7. 0 = slowest, 7 = fastest.""" + if rate > 7: + rate = 4 + self._datarate = rate << 5 + + def readADC(self, pin): + """Single-ended read on pin 0-3. Returns signed int16.""" + readmode = (4 + pin) << 12 + config = (_OS_START_SINGLE | readmode | self._gain_reg | + _MODE_SINGLE | self._datarate | _COMP_DEFAULTS) + self._writeRegister(_REG_CONFIG, config) + while not self._conversionDone(): + time.sleep_ms(1) + return self._getValue() + + def toVoltage(self, val): + """Convert raw int16 to voltage in volts.""" + if val == 0: + return 0.0 + return (self._max_voltage * val) / 32767.0 + + def _conversionDone(self): + return bool(self._readRegister(_REG_CONFIG) & _OS_NOT_BUSY) + + def _getValue(self): + raw = self._readRegister(_REG_CONVERT) + if raw & 0x8000: + raw -= 0x10000 + return raw + + def _writeRegister(self, reg, value): + self._i2c.writeto(self._address, bytes([reg, (value >> 8) & 0xFF, value & 0xFF])) + + def _readRegister(self, reg): + self._i2c.writeto(self._address, bytes([reg])) + data = self._i2c.readfrom(self._address, 2) + return (data[0] << 8) | data[1] diff --git a/Sensors/ElectrochemicalGasSensor/ElectrochemicalGasSensor/lmp91000.py b/Sensors/ElectrochemicalGasSensor/ElectrochemicalGasSensor/lmp91000.py new file mode 100644 index 0000000..a0656c0 --- /dev/null +++ b/Sensors/ElectrochemicalGasSensor/ElectrochemicalGasSensor/lmp91000.py @@ -0,0 +1,55 @@ +# FILE: lmp91000.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: MicroPython driver for LMP91000 analog frontend (helper for ElectrochemicalGasSensor) +# LAST UPDATED: 2026-05-21 + +_LMP_ADDR = 0x48 +_STATUS_REG = 0x00 +_LOCK_REG = 0x01 +_TIACN_REG = 0x10 +_REFCN_REG = 0x11 +_MODECN_REG = 0x12 + +_WRITE_LOCK = 0x01 +_WRITE_UNLOCK = 0x00 +_READY = 0x01 + + +class LMP91000: + """Analog frontend for electrochemical sensors over I2C. Used internally by ElectrochemicalGasSensor.""" + + def __init__(self, i2c): + self._i2c = i2c + + def write(self, reg, data): + """Write data byte to register, return read-back value.""" + self._i2c.writeto(_LMP_ADDR, bytes([reg, data])) + return self.read(reg) + + def read(self, reg): + """Read one byte from register.""" + self._i2c.writeto(_LMP_ADDR, bytes([reg])) + return self._i2c.readfrom(_LMP_ADDR, 1)[0] + + def status(self): + """Return status register. 0x01 = ready.""" + return self.read(_STATUS_REG) + + def lock(self): + """Lock configuration registers.""" + return self.write(_LOCK_REG, _WRITE_LOCK) + + def unlock(self): + """Unlock configuration registers.""" + return self.write(_LOCK_REG, _WRITE_UNLOCK) + + def configure(self, tiacn, refcn, modecn): + """Configure TIA, reference, and mode registers. Returns 1 on success, 0 if not ready.""" + if self.status() == _READY: + self.unlock() + self.write(_TIACN_REG, tiacn) + self.write(_REFCN_REG, refcn) + self.write(_MODECN_REG, modecn) + self.lock() + return 1 + return 0 diff --git a/Sensors/ElectrochemicalGasSensor/package.json b/Sensors/ElectrochemicalGasSensor/package.json new file mode 100644 index 0000000..a704030 --- /dev/null +++ b/Sensors/ElectrochemicalGasSensor/package.json @@ -0,0 +1,38 @@ +{ + "urls": [ + [ + "ElectrochemicalGasSensor.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/ElectrochemicalGasSensor/ElectrochemicalGasSensor/ElectrochemicalGasSensor.py" + ], + [ + "ads1115.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/ElectrochemicalGasSensor/ElectrochemicalGasSensor/ads1115.py" + ], + [ + "lmp91000.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/ElectrochemicalGasSensor/ElectrochemicalGasSensor/lmp91000.py" + ], + [ + "Examples/electrochemical-singleSensor.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/ElectrochemicalGasSensor/ElectrochemicalGasSensor/Examples/electrochemical-singleSensor.py" + ], + [ + "Examples/electrochemical-averagedMeasurement.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/ElectrochemicalGasSensor/ElectrochemicalGasSensor/Examples/electrochemical-averagedMeasurement.py" + ], + [ + "Examples/electrochemical-calibrateSensor.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/ElectrochemicalGasSensor/ElectrochemicalGasSensor/Examples/electrochemical-calibrateSensor.py" + ], + [ + "Examples/electrochemical-customConfig.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/ElectrochemicalGasSensor/ElectrochemicalGasSensor/Examples/electrochemical-customConfig.py" + ], + [ + "Examples/electrochemical-twoSensors.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/ElectrochemicalGasSensor/ElectrochemicalGasSensor/Examples/electrochemical-twoSensors.py" + ] + ], + "deps": [], + "version": "1.0" +} diff --git a/Sensors/HX711/HX711/Examples/hx711-basic.py b/Sensors/HX711/HX711/Examples/hx711-basic.py new file mode 100644 index 0000000..5e171f4 --- /dev/null +++ b/Sensors/HX711/HX711/Examples/hx711-basic.py @@ -0,0 +1,37 @@ +# FILE: hx711-basic.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Basic HX711 raw reading example - prints raw ADC value to serial. +# WORKS WITH: HX711 Load Cell amplifier: solde.red/333005 +# LAST UPDATED: 2026-05-21 + +# Connecting diagram: +# +# HX711 load-cell amplifier Dasduino +# Qwiic----------------------->Qwiic +# +# HX711 load-cell amplifier Load-cell +# (E+)------------------------>RED +# (E-)------------------------>BLACK +# (A-)------------------------>GREEN +# (A+)------------------------>WHITE + +from machine import I2C, Pin +import time +from hx711 import HX711 + +# Initialize I2C and HX711 +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +hx711 = HX711(i2c) + +# ------------------------------------------------------------------------- +# Setup +# ------------------------------------------------------------------------- +print("HX711 Load Cell - Basic Raw Reading") + +# ------------------------------------------------------------------------- +# Main loop +# ------------------------------------------------------------------------- +while True: + reading = hx711.getRawReading() + print("HX711 Reading: {}".format(reading)) + time.sleep_ms(200) diff --git a/Sensors/HX711/HX711/Examples/hx711-calibrate.py b/Sensors/HX711/HX711/Examples/hx711-calibrate.py new file mode 100644 index 0000000..6108ac9 --- /dev/null +++ b/Sensors/HX711/HX711/Examples/hx711-calibrate.py @@ -0,0 +1,49 @@ +# FILE: hx711-calibrate.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Calibration example - zeroes the load cell and prints offset-corrected readings. +# WORKS WITH: HX711 Load Cell amplifier: solde.red/333005 +# LAST UPDATED: 2026-05-21 + +# Connecting diagram: +# +# HX711 load-cell amplifier Dasduino +# Qwiic----------------------->Qwiic +# +# HX711 load-cell amplifier Load-cell +# (E+)------------------------>RED +# (E-)------------------------>BLACK +# (A-)------------------------>GREEN +# (A+)------------------------>WHITE + +from machine import I2C, Pin +import time +from hx711 import HX711 + +# Initialize I2C and HX711 +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +hx711 = HX711(i2c) + +# ------------------------------------------------------------------------- +# Setup +# ------------------------------------------------------------------------- +print("HX711 Load Cell - Calibration Example") + +# While calibrating - don't put any load on the load cell! +# It has to measure the signal without any weight so we know where zero is. +# 15 measurements are averaged to reduce noise. +print("Calibrating zero - keep load cell unloaded...") +hx711.setZero() +print("Zero set! You can now place weight on the load cell.") + +# ------------------------------------------------------------------------- +# Main loop +# ------------------------------------------------------------------------- +while True: + # Read the calibrated (offset-corrected) value + # You may also call getOffsettedReading(n) to average n readings + reading = hx711.getOffsettedReading() + + # Print the reading + print("HX711 Reading: {}".format(reading)) + + time.sleep_ms(200) diff --git a/Sensors/HX711/HX711/Examples/hx711-deepSleep.py b/Sensors/HX711/HX711/Examples/hx711-deepSleep.py new file mode 100644 index 0000000..2bcfadd --- /dev/null +++ b/Sensors/HX711/HX711/Examples/hx711-deepSleep.py @@ -0,0 +1,51 @@ +# FILE: hx711-deepSleep.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Deep sleep example - wakes HX711, reads once, then sleeps for 15 seconds. +# WORKS WITH: HX711 Load Cell amplifier: solde.red/333005 +# LAST UPDATED: 2026-05-21 + +# Connecting diagram: +# +# HX711 load-cell amplifier Dasduino +# Qwiic----------------------->Qwiic +# +# HX711 load-cell amplifier Load-cell +# (E+)------------------------>RED +# (E-)------------------------>BLACK +# (A-)------------------------>GREEN +# (A+)------------------------>WHITE + +from machine import I2C, Pin +import time +from hx711 import HX711 + +# Initialize I2C and HX711 +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +hx711 = HX711(i2c) + +# ------------------------------------------------------------------------- +# Setup +# ------------------------------------------------------------------------- +print("HX711 Load Cell - Deep Sleep Example") + +# ------------------------------------------------------------------------- +# Main loop +# ------------------------------------------------------------------------- +while True: + # Wake up the HX711 from deep sleep + hx711.setDeepSleep(False) + + # Wait until it initializes fully + time.sleep_ms(200) + + # Make raw reading and store in variable + reading = hx711.getRawReading() + + # Print the reading + print("HX711 Reading: {}".format(reading)) + + # Place the HX711 in deep sleep + hx711.setDeepSleep(True) + + # Wait a long while until the next reading + time.sleep_ms(15000) diff --git a/Sensors/HX711/HX711/hx711.py b/Sensors/HX711/HX711/hx711.py new file mode 100644 index 0000000..b12f1fc --- /dev/null +++ b/Sensors/HX711/HX711/hx711.py @@ -0,0 +1,123 @@ +# FILE: hx711.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: MicroPython library for the Soldered HX711 Load Cell amplifier with Qwiic/I2C +# LAST UPDATED: 2026-05-21 + +from machine import I2C, Pin +from os import uname + +# Default I2C address +HX711_DEFAULT_ADDRESS = 0x30 + +# Gain settings +GAIN_128 = 1 # Channel A, gain 128 (default) +GAIN_64 = 3 # Channel A, gain 64 +GAIN_32 = 2 # Channel B, gain 32 + +# Command bytes sent to board firmware +_SET_GAIN_32 = 1 +_SET_GAIN_64 = 2 +_SET_GAIN_128 = 3 +_SET_SLEEP_ON = 4 +_SET_SLEEP_OFF = 5 + + +class HX711: + """ + MicroPython class for the Soldered HX711 Load Cell amplifier (Qwiic/I2C version). + Communicates over I2C. + """ + + def __init__(self, i2c=None, address=HX711_DEFAULT_ADDRESS): + """ + Initialize the HX711. + + :param i2c: Initialized I2C object (optional, auto-detected on known boards) + :param address: I2C address of the device (default 0x30) + """ + if i2c is not None: + self.i2c = i2c + else: + if uname().sysname in ("esp32", "Soldered Dasduino CONNECTPLUS"): + self.i2c = I2C(0, scl=Pin(22), sda=Pin(21)) + elif uname().sysname == "esp8266": + self.i2c = I2C(scl=Pin(5), sda=Pin(4)) + else: + raise Exception("Board not recognized, please pass an I2C object manually") + + self.address = address + self._gain = GAIN_128 + self._scale = 1.0 + self._offset = 0.0 + + def getRawReading(self): + """Read raw 32-bit signed value from HX711 over I2C.""" + try: + data = self.i2c.readfrom(self.address, 4) + value = (data[0] << 24) | (data[1] << 16) | (data[2] << 8) | data[3] + if value & 0x80000000: + value -= 0x100000000 + return value + except: + return 0 + + def getAveragedReading(self, numReadings=1): + """Average numReadings raw readings and return as integer.""" + total = 0.0 + for _ in range(numReadings): + total += self.getRawReading() + return int(total / numReadings) + + def getOffsettedReading(self, numReadings=1): + """Return averaged reading minus offset.""" + return self.getAveragedReading(numReadings) - self._offset + + def getReadingInUnits(self, numReadings=1): + """Return offsetted reading divided by scale (in user-defined units).""" + return self.getOffsettedReading(numReadings) / self._scale + + def setZero(self): + """Set zero offset using 15 averaged readings. Load cell must be unloaded when calling this.""" + self.setOffset(self.getAveragedReading(15)) + + def setGain(self, gain): + """Set ADC gain. Use GAIN_128, GAIN_64, or GAIN_32.""" + self._gain = gain + if gain == GAIN_32: + cmd = _SET_GAIN_32 + elif gain == GAIN_64: + cmd = _SET_GAIN_64 + else: + cmd = _SET_GAIN_128 + try: + self.i2c.writeto(self.address, bytes([cmd])) + except: + pass + + def getGain(self): + """Return currently set gain value.""" + return self._gain + + def setScale(self, scale): + """Set scale divisor for getReadingInUnits.""" + self._scale = scale + + def getScale(self): + """Return currently set scale.""" + return self._scale + + def setOffset(self, offset): + """Set offset for getOffsettedReading and getReadingInUnits.""" + self._offset = offset + + def getOffset(self): + """Return currently set offset.""" + return self._offset + + def setDeepSleep(self, sleep): + """Put HX711 to sleep (True) or wake it (False).""" + cmd = _SET_SLEEP_ON if sleep else _SET_SLEEP_OFF + try: + self.i2c.writeto(self.address, bytes([cmd])) + except: + pass diff --git a/Sensors/HX711/package.json b/Sensors/HX711/package.json new file mode 100644 index 0000000..0e613b8 --- /dev/null +++ b/Sensors/HX711/package.json @@ -0,0 +1,22 @@ +{ + "urls": [ + [ + "hx711.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/HX711/HX711/hx711.py" + ], + [ + "Examples/hx711-basic.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/HX711/HX711/Examples/hx711-basic.py" + ], + [ + "Examples/hx711-deepSleep.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/HX711/HX711/Examples/hx711-deepSleep.py" + ], + [ + "Examples/hx711-calibrate.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/HX711/HX711/Examples/hx711-calibrate.py" + ] + ], + "deps": [], + "version": "1.0" +} diff --git a/Sensors/INA219/INA219/Examples/ina219-simple.py b/Sensors/INA219/INA219/Examples/ina219-simple.py new file mode 100644 index 0000000..958d3d7 --- /dev/null +++ b/Sensors/INA219/INA219/Examples/ina219-simple.py @@ -0,0 +1,107 @@ +# FILE: ina219-simple.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Simple example reading voltage and current. +# WORKS WITH: Voltage & Current Sensor INA219 Breakout: solde.red/333075 +# LAST UPDATED: 2026-05-06 + +from machine import I2C, Pin +import time +from ina219 import * + +# Initialize I2C and the INA219 +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +ina = INA219(i2c) + + +def check_config(): + """Print the current INA219 configuration to the serial terminal.""" + + print("Mode: ", end="") + mode = ina.getMode() + if mode == INA219_MODE_POWER_DOWN: print("Power-Down") + elif mode == INA219_MODE_SHUNT_TRIG: print("Shunt Voltage, Triggered") + elif mode == INA219_MODE_BUS_TRIG: print("Bus Voltage, Triggered") + elif mode == INA219_MODE_SHUNT_BUS_TRIG: print("Shunt and Bus, Triggered") + elif mode == INA219_MODE_ADC_OFF: print("ADC Off") + elif mode == INA219_MODE_SHUNT_CONT: print("Shunt Voltage, Continuous") + elif mode == INA219_MODE_BUS_CONT: print("Bus Voltage, Continuous") + elif mode == INA219_MODE_SHUNT_BUS_CONT: print("Shunt and Bus, Continuous") + else: print("unknown") + + print("Range: ", end="") + rng = ina.getRange() + if rng == INA219_RANGE_16V: print("16V") + elif rng == INA219_RANGE_32V: print("32V") + else: print("unknown") + + print("Gain: ", end="") + gain = ina.getGain() + if gain == INA219_GAIN_40MV: print("+/- 40mV") + elif gain == INA219_GAIN_80MV: print("+/- 80mV") + elif gain == INA219_GAIN_160MV: print("+/- 160mV") + elif gain == INA219_GAIN_320MV: print("+/- 320mV") + else: print("unknown") + + print("Bus resolution: ", end="") + bus_res = ina.getBusRes() + if bus_res == INA219_BUS_RES_9BIT: print("9-bit") + elif bus_res == INA219_BUS_RES_10BIT: print("10-bit") + elif bus_res == INA219_BUS_RES_11BIT: print("11-bit") + elif bus_res == INA219_BUS_RES_12BIT: print("12-bit") + else: print("unknown") + + print("Shunt resolution: ", end="") + shunt_res = ina.getShuntRes() + if shunt_res == INA219_SHUNT_RES_9BIT_1S: print("9-bit / 1 sample") + elif shunt_res == INA219_SHUNT_RES_10BIT_1S: print("10-bit / 1 sample") + elif shunt_res == INA219_SHUNT_RES_11BIT_1S: print("11-bit / 1 sample") + elif shunt_res == INA219_SHUNT_RES_12BIT_1S: print("12-bit / 1 sample") + elif shunt_res == INA219_SHUNT_RES_12BIT_2S: print("12-bit / 2 samples") + elif shunt_res == INA219_SHUNT_RES_12BIT_4S: print("12-bit / 4 samples") + elif shunt_res == INA219_SHUNT_RES_12BIT_8S: print("12-bit / 8 samples") + elif shunt_res == INA219_SHUNT_RES_12BIT_16S: print("12-bit / 16 samples") + elif shunt_res == INA219_SHUNT_RES_12BIT_32S: print("12-bit / 32 samples") + elif shunt_res == INA219_SHUNT_RES_12BIT_64S: print("12-bit / 64 samples") + elif shunt_res == INA219_SHUNT_RES_12BIT_128S: print("12-bit / 128 samples") + else: print("unknown") + + print("Max possible current: {:.5f} A".format(ina.getMaxPossibleCurrent())) + print("Max current: {:.5f} A".format(ina.getMaxCurrent())) + print("Max shunt voltage: {:.5f} V".format(ina.getMaxShuntVoltage())) + print("Max power: {:.5f} W".format(ina.getMaxPower())) + + +# ------------------------------------------------------------------------- +# Setup +# ------------------------------------------------------------------------- +print("Initialize INA219") +print("-----------------------------------------------") + +# Default INA219 address is 0x40 +if not ina.begin(): + print("Error: Unable to communicate with INA219.") + print(" Check wiring and try again.") + while True: + pass + +# Configure INA219 +ina.configure(INA219_RANGE_32V, INA219_GAIN_320MV, INA219_BUS_RES_12BIT, INA219_SHUNT_RES_12BIT_1S) + +# Calibrate INA219 — Rshunt = 0.1 Ohm, max expected current = 2 A +ina.calibrate(0.1, 2) + +# Display configuration +check_config() + +print("-----------------------------------------------") + +# ------------------------------------------------------------------------- +# Main loop +# ------------------------------------------------------------------------- +while True: + print("Bus voltage: {:.5f} V".format(ina.readBusVoltage())) + print("Bus power: {:.5f} W".format(ina.readBusPower())) + print("Shunt voltage: {:.5f} V".format(ina.readShuntVoltage())) + print("Shunt current: {:.5f} A".format(ina.readShuntCurrent())) + print() + time.sleep(1) \ No newline at end of file diff --git a/Sensors/INA219/INA219/ina219.py b/Sensors/INA219/INA219/ina219.py new file mode 100644 index 0000000..85c4395 --- /dev/null +++ b/Sensors/INA219/INA219/ina219.py @@ -0,0 +1,340 @@ +# FILE: ina219.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: MicroPython library for the INA219 Monitor +# LAST UPDATED: 2026-05-06 + +from machine import I2C, Pin +from os import uname +import math +import time + +# Default I2C address +INA219_ADDRESS = 0x40 + +# Register addresses +INA219_REG_CONFIG = 0x00 +INA219_REG_SHUNTVOLTAGE = 0x01 +INA219_REG_BUSVOLTAGE = 0x02 +INA219_REG_POWER = 0x03 +INA219_REG_CURRENT = 0x04 +INA219_REG_CALIBRATION = 0x05 + +########################### +# Range constants # +########################### +INA219_RANGE_16V = 0b0 # Bus voltage range 16V +INA219_RANGE_32V = 0b1 # Bus voltage range 32V (default) + +########################### +# Gain constants # +########################### +INA219_GAIN_40MV = 0b00 # PGA gain ±40mV +INA219_GAIN_80MV = 0b01 # PGA gain ±80mV +INA219_GAIN_160MV = 0b10 # PGA gain ±160mV +INA219_GAIN_320MV = 0b11 # PGA gain ±320mV (default) + +########################### +# Bus resolution constants# +########################### +INA219_BUS_RES_9BIT = 0b0000 # 9-bit bus resolution +INA219_BUS_RES_10BIT = 0b0001 # 10-bit bus resolution +INA219_BUS_RES_11BIT = 0b0010 # 11-bit bus resolution +INA219_BUS_RES_12BIT = 0b0011 # 12-bit bus resolution (default) + +########################### +# Shunt resolution consts # +########################### +INA219_SHUNT_RES_9BIT_1S = 0b0000 # 9-bit, 1 sample +INA219_SHUNT_RES_10BIT_1S = 0b0001 # 10-bit, 1 sample +INA219_SHUNT_RES_11BIT_1S = 0b0010 # 11-bit, 1 sample +INA219_SHUNT_RES_12BIT_1S = 0b0011 # 12-bit, 1 sample (default) +INA219_SHUNT_RES_12BIT_2S = 0b1001 # 12-bit, 2 samples averaged +INA219_SHUNT_RES_12BIT_4S = 0b1010 # 12-bit, 4 samples averaged +INA219_SHUNT_RES_12BIT_8S = 0b1011 # 12-bit, 8 samples averaged +INA219_SHUNT_RES_12BIT_16S = 0b1100 # 12-bit, 16 samples averaged +INA219_SHUNT_RES_12BIT_32S = 0b1101 # 12-bit, 32 samples averaged +INA219_SHUNT_RES_12BIT_64S = 0b1110 # 12-bit, 64 samples averaged +INA219_SHUNT_RES_12BIT_128S = 0b1111 # 12-bit, 128 samples averaged + +########################### +# Mode constants # +########################### +INA219_MODE_POWER_DOWN = 0b000 # Power-down +INA219_MODE_SHUNT_TRIG = 0b001 # Shunt voltage, triggered +INA219_MODE_BUS_TRIG = 0b010 # Bus voltage, triggered +INA219_MODE_SHUNT_BUS_TRIG = 0b011 # Shunt and bus, triggered +INA219_MODE_ADC_OFF = 0b100 # ADC off (disabled) +INA219_MODE_SHUNT_CONT = 0b101 # Shunt voltage, continuous +INA219_MODE_BUS_CONT = 0b110 # Bus voltage, continuous +INA219_MODE_SHUNT_BUS_CONT = 0b111 # Shunt and bus, continuous (default) + + +class INA219: + """ + MicroPython class for the INA219 Zero-Drift, Bi-directional + Current/Power Monitor. Communicates over I2C. + """ + + def __init__(self, i2c=None, address=INA219_ADDRESS): + """ + Initialize the INA219. + + :param i2c: Initialized I2C object (optional, auto-detected on known boards) + :param address: I2C address of the device (default 0x40) + """ + if i2c is not None: + self.i2c = i2c + else: + if uname().sysname in ("esp32", "Soldered Dasduino CONNECTPLUS"): + self.i2c = I2C(0, scl=Pin(22), sda=Pin(21)) + elif uname().sysname == "esp8266": + self.i2c = I2C(scl=Pin(5), sda=Pin(4)) + else: + raise Exception("Board not recognized, please pass an I2C object manually") + + self.address = address + + # Internal calibration values + self._current_lsb = 0.0 + self._power_lsb = 0.0 + self._v_shunt_max = 0.0 + self._v_bus_max = 0.0 + self._r_shunt = 0.0 + + # ------------------------------------------------------------------------- + # Initialization and configuration + # ------------------------------------------------------------------------- + + def begin(self): + """ + Verify communication with the INA219. + + :return: True if device is reachable, False otherwise + """ + try: + self.i2c.readfrom(self.address, 1) + return True + except: + return False + + def configure(self, + range=INA219_RANGE_32V, + gain=INA219_GAIN_320MV, + bus_res=INA219_BUS_RES_12BIT, + shunt_res=INA219_SHUNT_RES_12BIT_1S, + mode=INA219_MODE_SHUNT_BUS_CONT): + """ + Configure the INA219 measurement range, gain, resolution, and mode. + + :param range: INA219_RANGE_16V or INA219_RANGE_32V (default) + :param gain: INA219_GAIN_40MV / 80MV / 160MV / 320MV (default) + :param bus_res: INA219_BUS_RES_9BIT to 12BIT (default 12BIT) + :param shunt_res: INA219_SHUNT_RES_* constant (default 12BIT_1S) + :param mode: INA219_MODE_* constant (default SHUNT_BUS_CONT) + :return: True on success + """ + config = (range << 13) | (gain << 11) | (bus_res << 7) | (shunt_res << 3) | mode + + if range == INA219_RANGE_32V: + self._v_bus_max = 32.0 + elif range == INA219_RANGE_16V: + self._v_bus_max = 16.0 + + if gain == INA219_GAIN_320MV: + self._v_shunt_max = 0.32 + elif gain == INA219_GAIN_160MV: + self._v_shunt_max = 0.16 + elif gain == INA219_GAIN_80MV: + self._v_shunt_max = 0.08 + elif gain == INA219_GAIN_40MV: + self._v_shunt_max = 0.04 + + self._writeRegister16(INA219_REG_CONFIG, config) + return True + + def calibrate(self, r_shunt=0.1, i_max_expected=2.0): + """ + Calibrate the INA219 for a specific shunt resistor and expected max current. + + :param r_shunt: Shunt resistor value in Ohms (default 0.1) + :param i_max_expected: Maximum expected current in Amps (default 2.0) + :return: True on success + """ + self._r_shunt = r_shunt + + minimum_lsb = i_max_expected / 32767.0 + + # Round up to nearest 0.0001 A step + self._current_lsb = math.ceil(minimum_lsb / 0.0001) * 0.0001 + + self._power_lsb = self._current_lsb * 20.0 + + calibration_value = int(0.04096 / (self._current_lsb * r_shunt)) + + self._writeRegister16(INA219_REG_CALIBRATION, calibration_value) + return True + + # ------------------------------------------------------------------------- + # Measurements + # ------------------------------------------------------------------------- + + def readBusPower(self): + """ + Read the calculated bus power. + + :return: Power in Watts + """ + return self._readRegister16(INA219_REG_POWER) * self._power_lsb + + def readShuntCurrent(self): + """ + Read the calculated shunt current. + + :return: Current in Amps + """ + return self._readRegister16(INA219_REG_CURRENT) * self._current_lsb + + def readShuntVoltage(self): + """ + Read the shunt voltage. + + :return: Voltage in Volts (raw register value / 100000) + """ + return self._readRegister16(INA219_REG_SHUNTVOLTAGE) / 100000.0 + + def readBusVoltage(self): + """ + Read the bus voltage. The raw register value is right-shifted by 3 + (lowest 3 bits are status flags) and scaled by 4 mV/LSB. + + :return: Voltage in Volts + """ + raw = self._readRegister16(INA219_REG_BUSVOLTAGE) + # Treat as unsigned for bus voltage register + if raw < 0: + raw += 65536 + raw >>= 3 + return raw * 0.004 + + # ------------------------------------------------------------------------- + # Configuration readback + # ------------------------------------------------------------------------- + + def getRange(self): + """ + Read the voltage range setting from the config register. + + :return: INA219_RANGE_16V or INA219_RANGE_32V + """ + value = self._readRegister16(INA219_REG_CONFIG) + return (value & 0b0010000000000000) >> 13 + + def getGain(self): + """ + Read the PGA gain setting from the config register. + + :return: INA219_GAIN_* constant + """ + value = self._readRegister16(INA219_REG_CONFIG) + return (value & 0b0001100000000000) >> 11 + + def getBusRes(self): + """ + Read the bus ADC resolution setting from the config register. + + :return: INA219_BUS_RES_* constant + """ + value = self._readRegister16(INA219_REG_CONFIG) + return (value & 0b0000011110000000) >> 7 + + def getShuntRes(self): + """ + Read the shunt ADC resolution setting from the config register. + + :return: INA219_SHUNT_RES_* constant + """ + value = self._readRegister16(INA219_REG_CONFIG) + return (value & 0b0000000001111000) >> 3 + + def getMode(self): + """ + Read the operating mode from the config register. + + :return: INA219_MODE_* constant + """ + value = self._readRegister16(INA219_REG_CONFIG) + return value & 0b0000000000000111 + + # ------------------------------------------------------------------------- + # Limit helpers + # ------------------------------------------------------------------------- + + def getMaxPossibleCurrent(self): + """ + Calculate the maximum possible current based on shunt voltage and resistor. + + :return: Maximum possible current in Amps + """ + return self._v_shunt_max / self._r_shunt + + def getMaxCurrent(self): + """ + Calculate the maximum current the calibration supports. + + :return: Maximum current in Amps + """ + max_current = self._current_lsb * 32767 + max_possible = self.getMaxPossibleCurrent() + return min(max_current, max_possible) + + def getMaxShuntVoltage(self): + """ + Calculate the maximum shunt voltage based on calibration. + + :return: Maximum shunt voltage in Volts + """ + max_voltage = self.getMaxCurrent() * self._r_shunt + return min(max_voltage, self._v_shunt_max) + + def getMaxPower(self): + """ + Calculate the maximum power based on calibration and bus voltage range. + + :return: Maximum power in Watts + """ + return self.getMaxCurrent() * self._v_bus_max + + # ------------------------------------------------------------------------- + # Private: I2C register access + # ------------------------------------------------------------------------- + + def _readRegister16(self, reg): + """ + Read a 16-bit signed value from a register. + + :param reg: Register address + :return: Signed 16-bit integer + """ + try: + self.i2c.writeto(self.address, bytes([reg])) + time.sleep_ms(1) + data = self.i2c.readfrom(self.address, 2) + raw = (data[0] << 8) | data[1] + # Convert to signed 16-bit + return raw if raw < 0x8000 else raw - 0x10000 + except: + return 0 + + def _writeRegister16(self, reg, val): + """ + Write a 16-bit value to a register (MSB first). + + :param reg: Register address + :param val: 16-bit value to write + """ + try: + msb = (val >> 8) & 0xFF + lsb = val & 0xFF + self.i2c.writeto(self.address, bytes([reg, msb, lsb])) + except: + pass \ No newline at end of file diff --git a/Sensors/INA219/package.json b/Sensors/INA219/package.json new file mode 100644 index 0000000..a463f3e --- /dev/null +++ b/Sensors/INA219/package.json @@ -0,0 +1,14 @@ +{ + "urls": [ + [ + "ina219.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/INA219/INA219/ina219.py" + ], + [ + "Examples/ina219-simple.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/INA219/INA219/Examples/ina219-simple.py" + ] + ], + "deps": [], + "version": "1.0" +} diff --git a/Sensors/LSM9DS1/LSM9DS1/Examples/lsm9ds1-accelerometerI2C.py b/Sensors/LSM9DS1/LSM9DS1/Examples/lsm9ds1-accelerometerI2C.py new file mode 100644 index 0000000..7732af4 --- /dev/null +++ b/Sensors/LSM9DS1/LSM9DS1/Examples/lsm9ds1-accelerometerI2C.py @@ -0,0 +1,22 @@ +# FILE: lsm9ds1-accelerometerI2C.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Read acceleration from LSM9DS1 via I2C and print to console +# LAST UPDATED: 2026-06-02 + +from lsm9ds1 import LSM9DS1 +import time + +imu = LSM9DS1() + +print("Accelerometer sample rate =", imu.accelerationSampleRate(), "Hz") +print("Acceleration in G's") +print("X\tY\tZ") + +while True: + if imu.accelAvailable(): + imu.readAccel() + print("{:.4f}\t{:.4f}\t{:.4f}".format( + imu.calcAccel(imu.ax), + imu.calcAccel(imu.ay), + imu.calcAccel(imu.az))) + time.sleep_ms(10) diff --git a/Sensors/LSM9DS1/LSM9DS1/Examples/lsm9ds1-basicI2C.py b/Sensors/LSM9DS1/LSM9DS1/Examples/lsm9ds1-basicI2C.py new file mode 100644 index 0000000..41b3940 --- /dev/null +++ b/Sensors/LSM9DS1/LSM9DS1/Examples/lsm9ds1-basicI2C.py @@ -0,0 +1,80 @@ +# FILE: lsm9ds1-basicI2C.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Read all axes from LSM9DS1 via I2C, print values and calculated attitude/heading +# LAST UPDATED: 2026-06-02 + +from lsm9ds1 import LSM9DS1 +import time +import math + +# Magnetic declination for your location (degrees). +# Find yours at: http://www.ngdc.noaa.gov/geomag-web/#declination +DECLINATION = 5.37 # Osijek, Croatia + +PRINT_SPEED_MS = 250 + +imu = LSM9DS1() + +lastPrint = time.ticks_ms() + + +def printGyro(): + print("G: {:.2f}, {:.2f}, {:.2f} deg/s".format( + imu.calcGyro(imu.gx), + imu.calcGyro(imu.gy), + imu.calcGyro(imu.gz))) + + +def printAccel(): + print("A: {:.2f}, {:.2f}, {:.2f} g".format( + imu.calcAccel(imu.ax), + imu.calcAccel(imu.ay), + imu.calcAccel(imu.az))) + + +def printMag(): + print("M: {:.2f}, {:.2f}, {:.2f} gauss".format( + imu.calcMag(imu.mx), + imu.calcMag(imu.my), + imu.calcMag(imu.mz))) + + +def printAttitude(ax, ay, az, mx, my, mz): + roll = math.atan2(ay, az) + pitch = math.atan2(-ax, math.sqrt(ay * ay + az * az)) + + if my == 0: + heading = math.pi if mx < 0 else 0.0 + else: + heading = math.atan2(mx, my) + + heading -= DECLINATION * math.pi / 180.0 + if heading > math.pi: + heading -= 2 * math.pi + elif heading < -math.pi: + heading += 2 * math.pi + + print("Pitch, Roll: {:.2f}, {:.2f}".format( + pitch * 180.0 / math.pi, + roll * 180.0 / math.pi)) + print("Heading: {:.2f}".format(heading * 180.0 / math.pi)) + + +while True: + if imu.gyroAvailable(): + imu.readGyro() + if imu.accelAvailable(): + imu.readAccel() + if imu.magAvailable(): + imu.readMag() + + if time.ticks_diff(time.ticks_ms(), lastPrint) >= PRINT_SPEED_MS: + printGyro() + printAccel() + printMag() + # Mag x and y are swapped relative to accel (matches Arduino library behavior) + printAttitude( + imu.calcAccel(imu.ax), imu.calcAccel(imu.ay), imu.calcAccel(imu.az), + -imu.calcMag(imu.my), -imu.calcMag(imu.mx), imu.calcMag(imu.mz)) + print() + lastPrint = time.ticks_ms() diff --git a/Sensors/LSM9DS1/LSM9DS1/Examples/lsm9ds1-interruptsI2C.py b/Sensors/LSM9DS1/LSM9DS1/Examples/lsm9ds1-interruptsI2C.py new file mode 100644 index 0000000..c4666dd --- /dev/null +++ b/Sensors/LSM9DS1/LSM9DS1/Examples/lsm9ds1-interruptsI2C.py @@ -0,0 +1,96 @@ +# FILE: lsm9ds1-interruptsI2C.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Use LSM9DS1 hardware interrupts for threshold and data-ready events +# LAST UPDATED: 2026-06-02 +# +# Interrupt pins (change to match your wiring, must support interrupts): +# INT1_PIN → INT1-A on breakout (gyro/accel threshold, active low) +# INT2_PIN → INT2-A on breakout (accel/gyro data ready, active low) +# INTM_PIN → INT-M on breakout (mag threshold, active low) +# RDYM_PIN → RDY-M on breakout (mag data ready, active high) +# NOTE: These pins are 3.3V only on the breakout! + +from lsm9ds1 import (LSM9DS1, + XG_INT1, XG_INT2, + ZHIE_G, XHIE_XL, XIEN, + INT1_IG_G, INT_IG_XL, INT_DRDY_XL, INT_DRDY_G, + INT_ACTIVE_LOW, INT_PUSH_PULL, + X_AXIS, Z_AXIS) +from machine import Pin +import time + +# Change these to the GPIO pins connected to the interrupt outputs +INT1_PIN = Pin(2, Pin.IN, Pin.PULL_UP) +INT2_PIN = Pin(4, Pin.IN, Pin.PULL_UP) +INTM_PIN = Pin(5, Pin.IN, Pin.PULL_UP) +RDYM_PIN = Pin(14, Pin.IN) + +imu = LSM9DS1(auto_begin=False) +imu.settings.gyro.latchInterrupt = False +imu.settings.gyro.scale = 245 +imu.settings.gyro.sampleRate = 1 # 14.9 Hz +imu.settings.accel.scale = 2 +imu.settings.mag.scale = 4 +imu.settings.mag.sampleRate = 0 # 0.625 Hz +imu.begin() + +# INT1 fires when gyro Z or accel X exceed threshold (active low, not latched) +imu.configGyroInt(ZHIE_G, False, False) +imu.configGyroThs(500, Z_AXIS, 10, True) +imu.configAccelInt(XHIE_XL, False) +imu.configAccelThs(20, X_AXIS, 1, False) +imu.configInt(XG_INT1, INT1_IG_G | INT_IG_XL, INT_ACTIVE_LOW, INT_PUSH_PULL) + +# INT2 fires when new accel or gyro data is available (active low) +imu.configInt(XG_INT2, INT_DRDY_XL | INT_DRDY_G, INT_ACTIVE_LOW, INT_PUSH_PULL) + +# INT-M fires when magnetometer X exceeds threshold (active low, latched) +imu.configMagInt(XIEN, INT_ACTIVE_LOW, True) +imu.configMagThs(10000) + +lastPrint = time.ticks_ms() + +while True: + # Print all sensor values every second + if time.ticks_diff(time.ticks_ms(), lastPrint) >= 1000: + imu.readAccel() + imu.readGyro() + imu.readMag() + print("A: {:.2f}, {:.2f}, {:.2f}".format( + imu.calcAccel(imu.ax), imu.calcAccel(imu.ay), imu.calcAccel(imu.az))) + print("G: {:.2f}, {:.2f}, {:.2f}".format( + imu.calcGyro(imu.gx), imu.calcGyro(imu.gy), imu.calcGyro(imu.gz))) + print("M: {:.2f}, {:.2f}, {:.2f}".format( + imu.calcMag(imu.mx), imu.calcMag(imu.my), imu.calcMag(imu.mz))) + lastPrint = time.ticks_ms() + + # INT2: new accel/gyro data available (active low) + if not INT2_PIN.value(): + if imu.accelAvailable(): + imu.readAccel() + if imu.gyroAvailable(): + imu.readGyro() + + # INT1: gyro/accel threshold exceeded (active low, stays low while exceeded) + if not INT1_PIN.value(): + start = time.ticks_ms() + print("\tINT1 Active!") + print("\t\tGyro int: 0x{:02X}".format(imu.getGyroIntSrc())) + print("\t\tAccel int: 0x{:02X}".format(imu.getAccelIntSrc())) + while not INT1_PIN.value(): + imu.getGyroIntSrc() + imu.getAccelIntSrc() + print("\tINT1 Duration: {} ms".format(time.ticks_diff(time.ticks_ms(), start))) + + # INT-M: magnetometer threshold exceeded (active low, latched) + if not INTM_PIN.value(): + start = time.ticks_ms() + print("\t\tMag int: 0x{:02X}".format(imu.getMagIntSrc())) + while not INTM_PIN.value(): + pass + print("\t\tINTM Duration: {} ms".format(time.ticks_diff(time.ticks_ms(), start))) + + # RDY-M: new magnetometer data available (active high) + if RDYM_PIN.value(): + if imu.magAvailable(): + imu.readMag() diff --git a/Sensors/LSM9DS1/LSM9DS1/Examples/lsm9ds1-magnetometerI2C.py b/Sensors/LSM9DS1/LSM9DS1/Examples/lsm9ds1-magnetometerI2C.py new file mode 100644 index 0000000..b04e70d --- /dev/null +++ b/Sensors/LSM9DS1/LSM9DS1/Examples/lsm9ds1-magnetometerI2C.py @@ -0,0 +1,22 @@ +# FILE: lsm9ds1-magnetometerI2C.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Read magnetic field from LSM9DS1 via I2C and print to console +# LAST UPDATED: 2026-06-02 + +from lsm9ds1 import LSM9DS1 +import time + +imu = LSM9DS1() + +print("Magnetic field sample rate =", imu.magneticFieldSampleRate(), "Hz") +print("Magnetic Field in Gauss") +print("X\tY\tZ") + +while True: + if imu.magAvailable(): + imu.readMag() + print("{:.4f}\t{:.4f}\t{:.4f}".format( + imu.calcMag(imu.mx), + imu.calcMag(imu.my), + imu.calcMag(imu.mz))) + time.sleep_ms(10) diff --git a/Sensors/LSM9DS1/LSM9DS1/Examples/lsm9ds1-settingsI2C.py b/Sensors/LSM9DS1/LSM9DS1/Examples/lsm9ds1-settingsI2C.py new file mode 100644 index 0000000..1248eab --- /dev/null +++ b/Sensors/LSM9DS1/LSM9DS1/Examples/lsm9ds1-settingsI2C.py @@ -0,0 +1,82 @@ +# FILE: lsm9ds1-settingsI2C.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Configure LSM9DS1 with custom settings before init, print data rates and values +# LAST UPDATED: 2026-06-02 + +from lsm9ds1 import LSM9DS1 +import time + +PRINT_RATE_MS = 500 + +imu = LSM9DS1(auto_begin=False) + +# Gyroscope settings +imu.settings.gyro.enabled = True +imu.settings.gyro.scale = 245 # 245, 500, or 2000 dps +imu.settings.gyro.sampleRate = 3 # 1=14.9 2=59.5 3=119 4=238 5=476 6=952 Hz +imu.settings.gyro.bandwidth = 0 +imu.settings.gyro.lowPowerEnable = False +imu.settings.gyro.HPFEnable = True +imu.settings.gyro.HPFCutoff = 1 # cutoff depends on ODR, see datasheet +imu.settings.gyro.flipX = False +imu.settings.gyro.flipY = False +imu.settings.gyro.flipZ = False + +# Accelerometer settings +imu.settings.accel.enabled = True +imu.settings.accel.enableX = True +imu.settings.accel.enableY = True +imu.settings.accel.enableZ = True +imu.settings.accel.scale = 8 # 2, 4, 8, or 16 g +imu.settings.accel.sampleRate = 1 # 1=10 2=50 3=119 4=238 5=476 6=952 Hz +imu.settings.accel.bandwidth = 0 # -1=ODR-dependent 0=408 1=211 2=105 3=50 Hz +imu.settings.accel.highResEnable = False +imu.settings.accel.highResBandwidth = 0 + +# Magnetometer settings +imu.settings.mag.enabled = True +imu.settings.mag.scale = 12 # 4, 8, 12, or 16 Gauss +imu.settings.mag.sampleRate = 5 # 0=0.625 1=1.25 2=2.5 3=5 4=10 5=20 6=40 7=80 Hz +imu.settings.mag.tempCompensationEnable = False +imu.settings.mag.XYPerformance = 3 # 0=low 1=med 2=high 3=ultra +imu.settings.mag.ZPerformance = 3 +imu.settings.mag.lowPowerEnable = False +imu.settings.mag.operatingMode = 0 # 0=continuous 1=single 2=power-down + +imu.settings.temp.enabled = True + +status = imu.begin() +print("LSM9DS1 WHO_AM_I: 0x{:04X} (should be 0x683D)".format(status)) + +startTime = time.ticks_ms() +accelCount = gyroCount = magCount = tempCount = 0 +lastPrint = time.ticks_ms() + +while True: + if imu.accelAvailable(): + imu.readAccel() + accelCount += 1 + if imu.gyroAvailable(): + imu.readGyro() + gyroCount += 1 + if imu.magAvailable(): + imu.readMag() + magCount += 1 + if imu.tempAvailable(): + imu.readTemp() + tempCount += 1 + + if time.ticks_diff(time.ticks_ms(), lastPrint) >= PRINT_RATE_MS: + runTime = time.ticks_diff(time.ticks_ms(), startTime) / 1000.0 + print("A: {:.4f}, {:.4f}, {:.4f} g\t| {:.1f} Hz".format( + imu.calcAccel(imu.ax), imu.calcAccel(imu.ay), imu.calcAccel(imu.az), + accelCount / runTime)) + print("G: {:.4f}, {:.4f}, {:.4f} dps\t| {:.1f} Hz".format( + imu.calcGyro(imu.gx), imu.calcGyro(imu.gy), imu.calcGyro(imu.gz), + gyroCount / runTime)) + print("M: {:.4f}, {:.4f}, {:.4f} Gs\t| {:.1f} Hz".format( + imu.calcMag(imu.mx), imu.calcMag(imu.my), imu.calcMag(imu.mz), + magCount / runTime)) + print("T: {} C\t\t\t\t| {:.1f} Hz".format(imu.temperature, tempCount / runTime)) + print() + lastPrint = time.ticks_ms() diff --git a/Sensors/LSM9DS1/LSM9DS1/lsm9ds1.py b/Sensors/LSM9DS1/LSM9DS1/lsm9ds1.py new file mode 100644 index 0000000..8c7c9cd --- /dev/null +++ b/Sensors/LSM9DS1/LSM9DS1/lsm9ds1.py @@ -0,0 +1,801 @@ +# FILE: lsm9ds1.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: MicroPython driver for LSM9DS1 9-axis IMU (accel/gyro/mag) +# LAST UPDATED: 2026-06-02 + +from machine import I2C, Pin +from os import uname +import struct +import time + +# I2C addresses +_AG_ADDR = 0x6B +_M_ADDR = 0x1E + +# AG registers +_ACT_THS = 0x04 +_ACT_DUR = 0x05 +_INT_GEN_CFG_XL = 0x06 +_INT_GEN_THS_X_XL = 0x07 +_INT_GEN_DUR_XL = 0x0A +_INT1_CTRL = 0x0C +_INT2_CTRL = 0x0D +_WHO_AM_I_XG = 0x0F +_CTRL_REG1_G = 0x10 +_CTRL_REG2_G = 0x11 +_CTRL_REG3_G = 0x12 +_ORIENT_CFG_G = 0x13 +_INT_GEN_SRC_G = 0x14 +_OUT_TEMP_L = 0x15 +_STATUS_REG_0 = 0x17 +_OUT_X_L_G = 0x18 +_CTRL_REG4 = 0x1E +_CTRL_REG5_XL = 0x1F +_CTRL_REG6_XL = 0x20 +_CTRL_REG7_XL = 0x21 +_CTRL_REG8 = 0x22 +_CTRL_REG9 = 0x23 +_INT_GEN_SRC_XL = 0x26 +_STATUS_REG_1 = 0x27 +_OUT_X_L_XL = 0x28 +_FIFO_CTRL = 0x2E +_FIFO_SRC = 0x2F +_INT_GEN_CFG_G = 0x30 +_INT_GEN_THS_XH_G = 0x31 +_INT_GEN_DUR_G = 0x37 + +# Magnetometer registers +_WHO_AM_I_M = 0x0F +_CTRL_REG1_M = 0x20 +_CTRL_REG2_M = 0x21 +_CTRL_REG3_M = 0x22 +_CTRL_REG4_M = 0x23 +_CTRL_REG5_M = 0x24 +_STATUS_REG_M = 0x27 +_OUT_X_L_M = 0x28 +_INT_CFG_M = 0x30 +_INT_SRC_M = 0x31 +_INT_THS_L_M = 0x32 +_INT_THS_H_M = 0x33 + +# Sensitivity constants (datasheet Table 3) +_SENS_ACCEL = {2: 0.000061, 4: 0.000122, 8: 0.000244, 16: 0.000732} +_SENS_GYRO = {245: 0.00875, 500: 0.0175, 2000: 0.07} +_SENS_MAG = {4: 0.00014, 8: 0.00029, 12: 0.00043, 16: 0.00058} + +# Interrupt select +XG_INT1 = _INT1_CTRL +XG_INT2 = _INT2_CTRL + +# Interrupt generators (OR these together) +INT_DRDY_XL = 0x01 +INT_DRDY_G = 0x02 +INT1_BOOT = 0x04 +INT2_DRDY_TEMP = 0x04 +INT_FTH = 0x08 +INT_OVR = 0x10 +INT_FSS5 = 0x20 +INT_IG_XL = 0x40 +INT1_IG_G = 0x80 +INT2_INACT = 0x80 + +# Accelerometer interrupt generators +XLIE_XL = 0x01 +XHIE_XL = 0x02 +YLIE_XL = 0x04 +YHIE_XL = 0x08 +ZLIE_XL = 0x10 +ZHIE_XL = 0x20 +GEN_6D = 0x40 + +# Gyroscope interrupt generators +XLIE_G = 0x01 +XHIE_G = 0x02 +YLIE_G = 0x04 +YHIE_G = 0x08 +ZLIE_G = 0x10 +ZHIE_G = 0x20 + +# Magnetometer interrupt generators +ZIEN = 0x20 +YIEN = 0x40 +XIEN = 0x80 + +# Interrupt active level +INT_ACTIVE_HIGH = 0 +INT_ACTIVE_LOW = 1 + +# Interrupt output type +INT_PUSH_PULL = 0 +INT_OPEN_DRAIN = 1 + +# FIFO modes +FIFO_OFF = 0 +FIFO_THS = 1 +FIFO_CONT_TRIGGER = 3 +FIFO_OFF_TRIGGER = 4 +FIFO_CONT = 6 + +# Axes +X_AXIS = 0 +Y_AXIS = 1 +Z_AXIS = 2 +ALL_AXIS = 3 + + +class _GyroSettings: + def __init__(self): + self.enabled = True + self.scale = 245 + self.sampleRate = 6 + self.bandwidth = 0 + self.lowPowerEnable = False + self.HPFEnable = False + self.HPFCutoff = 0 + self.flipX = False + self.flipY = False + self.flipZ = False + self.enableX = True + self.enableY = True + self.enableZ = True + self.latchInterrupt = True + + +class _AccelSettings: + def __init__(self): + self.enabled = True + self.scale = 2 + self.sampleRate = 6 + self.enableX = True + self.enableY = True + self.enableZ = True + self.bandwidth = -1 + self.highResEnable = False + self.highResBandwidth = 0 + + +class _MagSettings: + def __init__(self): + self.enabled = True + self.scale = 4 + self.sampleRate = 7 + self.tempCompensationEnable = False + self.XYPerformance = 3 + self.ZPerformance = 3 + self.lowPowerEnable = False + self.operatingMode = 0 + + +class _TempSettings: + def __init__(self): + self.enabled = True + + +class IMUSettings: + def __init__(self): + self.gyro = _GyroSettings() + self.accel = _AccelSettings() + self.mag = _MagSettings() + self.temp = _TempSettings() + + +class LSM9DS1: + """MicroPython driver for the LSM9DS1 9-axis IMU.""" + + def __init__(self, i2c=None, auto_begin=True): + """ + Initialize the LSM9DS1. + + :param i2c: I2C object; if None, auto-initializes on ESP32 (SCL=22, SDA=21) + :param auto_begin: if True, call begin() immediately with default settings + """ + if i2c is not None: + self.i2c = i2c + else: + if uname().sysname in ("esp32", "esp8266", "Soldered Dasduino CONNECTPLUS"): + self.i2c = I2C(0, scl=Pin(22), sda=Pin(21)) + else: + raise Exception("Board not recognized, enter I2C pins manually") + + self.settings = IMUSettings() + + self.ax = self.ay = self.az = 0 + self.gx = self.gy = self.gz = 0 + self.mx = self.my = self.mz = 0 + self.temperature = 0 + + self.gBias = [0.0, 0.0, 0.0] + self.aBias = [0.0, 0.0, 0.0] + self.mBias = [0.0, 0.0, 0.0] + self.gBiasRaw = [0, 0, 0] + self.aBiasRaw = [0, 0, 0] + self.mBiasRaw = [0, 0, 0] + self._autoCalc = False + + self.gRes = 0.0 + self.aRes = 0.0 + self.mRes = 0.0 + + if auto_begin: + self.begin() + + def begin(self): + """ + Initialize sensor with current settings. Call after configuring settings if auto_begin=False. + + :returns: WHO_AM_I combined value (0x683D on success) + """ + self._constrainScales() + self._calcgRes() + self._calcaRes() + self._calcmRes() + + ag_id = self._readReg(_AG_ADDR, _WHO_AM_I_XG) + m_id = self._readReg(_M_ADDR, _WHO_AM_I_M) + + if ag_id != 0x68 or m_id != 0x3D: + raise Exception("LSM9DS1 not found! Check wiring.") + + self._initGyro() + self._initAccel() + self._initMag() + + return (ag_id << 8) | m_id + + def _initGyro(self): + reg = 0 + if self.settings.gyro.enabled: + reg = (self.settings.gyro.sampleRate & 0x07) << 5 + if self.settings.gyro.scale == 500: + reg |= (0x1 << 3) + elif self.settings.gyro.scale == 2000: + reg |= (0x3 << 3) + reg |= (self.settings.gyro.bandwidth & 0x3) + self._writeReg(_AG_ADDR, _CTRL_REG1_G, reg) + + self._writeReg(_AG_ADDR, _CTRL_REG2_G, 0x00) + + reg = 0x80 if self.settings.gyro.lowPowerEnable else 0 + if self.settings.gyro.HPFEnable: + reg |= 0x40 | (self.settings.gyro.HPFCutoff & 0x0F) + self._writeReg(_AG_ADDR, _CTRL_REG3_G, reg) + + reg = 0 + if self.settings.gyro.enableZ: reg |= 0x20 + if self.settings.gyro.enableY: reg |= 0x10 + if self.settings.gyro.enableX: reg |= 0x08 + if self.settings.gyro.latchInterrupt: reg |= 0x02 + self._writeReg(_AG_ADDR, _CTRL_REG4, reg) + + reg = 0 + if self.settings.gyro.flipX: reg |= 0x20 + if self.settings.gyro.flipY: reg |= 0x10 + if self.settings.gyro.flipZ: reg |= 0x08 + self._writeReg(_AG_ADDR, _ORIENT_CFG_G, reg) + + def _initAccel(self): + reg = 0 + if self.settings.accel.enableZ: reg |= 0x20 + if self.settings.accel.enableY: reg |= 0x10 + if self.settings.accel.enableX: reg |= 0x08 + self._writeReg(_AG_ADDR, _CTRL_REG5_XL, reg) + + reg = 0 + if self.settings.accel.enabled: + reg |= (self.settings.accel.sampleRate & 0x07) << 5 + scale = self.settings.accel.scale + if scale == 4: reg |= (0x2 << 3) + elif scale == 8: reg |= (0x3 << 3) + elif scale == 16: reg |= (0x1 << 3) + if self.settings.accel.bandwidth >= 0: + reg |= 0x04 | (self.settings.accel.bandwidth & 0x03) + self._writeReg(_AG_ADDR, _CTRL_REG6_XL, reg) + + reg = 0 + if self.settings.accel.highResEnable: + reg |= 0x80 | ((self.settings.accel.highResBandwidth & 0x3) << 5) + self._writeReg(_AG_ADDR, _CTRL_REG7_XL, reg) + + def _initMag(self): + reg = 0 + if self.settings.mag.tempCompensationEnable: reg |= 0x80 + reg |= (self.settings.mag.XYPerformance & 0x3) << 5 + reg |= (self.settings.mag.sampleRate & 0x7) << 2 + self._writeReg(_M_ADDR, _CTRL_REG1_M, reg) + + reg = 0 + scale = self.settings.mag.scale + if scale == 8: reg |= (0x1 << 5) + elif scale == 12: reg |= (0x2 << 5) + elif scale == 16: reg |= (0x3 << 5) + self._writeReg(_M_ADDR, _CTRL_REG2_M, reg) + + reg = 0 + if self.settings.mag.lowPowerEnable: reg |= 0x20 + reg |= (self.settings.mag.operatingMode & 0x3) + self._writeReg(_M_ADDR, _CTRL_REG3_M, reg) + + self._writeReg(_M_ADDR, _CTRL_REG4_M, (self.settings.mag.ZPerformance & 0x3) << 2) + self._writeReg(_M_ADDR, _CTRL_REG5_M, 0x00) + + def _constrainScales(self): + if self.settings.gyro.scale not in (245, 500, 2000): + self.settings.gyro.scale = 245 + if self.settings.accel.scale not in (2, 4, 8, 16): + self.settings.accel.scale = 2 + if self.settings.mag.scale not in (4, 8, 12, 16): + self.settings.mag.scale = 4 + + def _calcgRes(self): + self.gRes = _SENS_GYRO.get(self.settings.gyro.scale, _SENS_GYRO[245]) + + def _calcaRes(self): + self.aRes = _SENS_ACCEL.get(self.settings.accel.scale, _SENS_ACCEL[2]) + + def _calcmRes(self): + self.mRes = _SENS_MAG.get(self.settings.mag.scale, _SENS_MAG[4]) + + # --- Available checks --- + + def accelAvailable(self): + """ + :returns: bool, True if new accelerometer data ready + """ + return bool(self._readReg(_AG_ADDR, _STATUS_REG_1) & 0x01) + + def gyroAvailable(self): + """ + :returns: bool, True if new gyroscope data ready + """ + return bool(self._readReg(_AG_ADDR, _STATUS_REG_1) & 0x02) + + def tempAvailable(self): + """ + :returns: bool, True if new temperature data ready + """ + return bool(self._readReg(_AG_ADDR, _STATUS_REG_1) & 0x04) + + def magAvailable(self, axis=ALL_AXIS): + """ + :param axis: X_AXIS, Y_AXIS, Z_AXIS, or ALL_AXIS (default) + :returns: bool, True if new magnetometer data ready on given axis + """ + status = self._readReg(_M_ADDR, _STATUS_REG_M) + if axis == ALL_AXIS: + return bool(status & 0x07) + return bool((status >> axis) & 0x01) + + # --- Read sensors --- + + def readAccel(self): + """Read accelerometer, store raw signed int16 values in ax, ay, az.""" + data = self._readRegs(_AG_ADDR, _OUT_X_L_XL, 6) + self.ax, self.ay, self.az = struct.unpack('> 8) + + # --- Calc (raw to physical units) --- + + def calcAccel(self, raw): + """ + Convert raw accelerometer reading to g's. + + :param raw: int16 raw value (ax, ay, or az) + :returns: float in g + """ + return self.aRes * raw + + def calcGyro(self, raw): + """ + Convert raw gyroscope reading to degrees/second. + + :param raw: int16 raw value (gx, gy, or gz) + :returns: float in dps + """ + return self.gRes * raw + + def calcMag(self, raw): + """ + Convert raw magnetometer reading to Gauss. + + :param raw: int16 raw value (mx, my, or mz) + :returns: float in Gauss + """ + return self.mRes * raw + + # --- Scale / ODR setters --- + + def setGyroScale(self, scale): + """ + Set gyroscope full-scale range. + + :param scale: 245, 500, or 2000 (dps) + """ + reg = self._readReg(_AG_ADDR, _CTRL_REG1_G) & 0xE7 + if scale == 500: + reg |= (0x1 << 3) + self.settings.gyro.scale = 500 + elif scale == 2000: + reg |= (0x3 << 3) + self.settings.gyro.scale = 2000 + else: + self.settings.gyro.scale = 245 + self._writeReg(_AG_ADDR, _CTRL_REG1_G, reg) + self._calcgRes() + + def setAccelScale(self, scale): + """ + Set accelerometer full-scale range. + + :param scale: 2, 4, 8, or 16 (g) + """ + reg = self._readReg(_AG_ADDR, _CTRL_REG6_XL) & 0xE7 + if scale == 4: + reg |= (0x2 << 3) + self.settings.accel.scale = 4 + elif scale == 8: + reg |= (0x3 << 3) + self.settings.accel.scale = 8 + elif scale == 16: + reg |= (0x1 << 3) + self.settings.accel.scale = 16 + else: + self.settings.accel.scale = 2 + self._writeReg(_AG_ADDR, _CTRL_REG6_XL, reg) + self._calcaRes() + + def setMagScale(self, scale): + """ + Set magnetometer full-scale range. + + :param scale: 4, 8, 12, or 16 (Gauss) + """ + reg = self._readReg(_M_ADDR, _CTRL_REG2_M) & 0x9F + if scale == 8: + reg |= (0x1 << 5) + self.settings.mag.scale = 8 + elif scale == 12: + reg |= (0x2 << 5) + self.settings.mag.scale = 12 + elif scale == 16: + reg |= (0x3 << 5) + self.settings.mag.scale = 16 + else: + self.settings.mag.scale = 4 + self._writeReg(_M_ADDR, _CTRL_REG2_M, reg) + self._calcmRes() + + def setGyroODR(self, rate): + """ + Set gyroscope output data rate. + + :param rate: 1-6 (14.9 / 59.5 / 119 / 238 / 476 / 952 Hz) + """ + if rate & 0x07: + reg = (self._readReg(_AG_ADDR, _CTRL_REG1_G) & 0x1F) | ((rate & 0x07) << 5) + self.settings.gyro.sampleRate = rate & 0x07 + self._writeReg(_AG_ADDR, _CTRL_REG1_G, reg) + + def setAccelODR(self, rate): + """ + Set accelerometer output data rate. + + :param rate: 1-6 (10 / 50 / 119 / 238 / 476 / 952 Hz) + """ + if rate & 0x07: + reg = (self._readReg(_AG_ADDR, _CTRL_REG6_XL) & 0x1F) | ((rate & 0x07) << 5) + self.settings.accel.sampleRate = rate & 0x07 + self._writeReg(_AG_ADDR, _CTRL_REG6_XL, reg) + + def setMagODR(self, rate): + """ + Set magnetometer output data rate. + + :param rate: 0-7 (0.625 / 1.25 / 2.5 / 5 / 10 / 20 / 40 / 80 Hz) + """ + reg = (self._readReg(_M_ADDR, _CTRL_REG1_M) & 0xE3) | ((rate & 0x07) << 2) + self.settings.mag.sampleRate = rate & 0x07 + self._writeReg(_M_ADDR, _CTRL_REG1_M, reg) + + # --- Sample rate helpers --- + + def accelerationSampleRate(self): + """ + :returns: float, accelerometer sample rate in Hz + """ + rates = {1: 10.0, 2: 50.0, 3: 119.0, 4: 238.0, 5: 476.0, 6: 952.0} + return rates.get(self.settings.accel.sampleRate, 0.0) + + def gyroscopeSampleRate(self): + """ + :returns: float, gyroscope sample rate in Hz + """ + rates = {1: 14.9, 2: 59.5, 3: 119.0, 4: 238.0, 5: 476.0, 6: 952.0} + return rates.get(self.settings.gyro.sampleRate, 0.0) + + def magneticFieldSampleRate(self): + """ + :returns: float, magnetometer sample rate in Hz + """ + rates = {0: 0.625, 1: 1.25, 2: 2.5, 3: 5.0, 4: 10.0, 5: 20.0, 6: 40.0, 7: 80.0} + return rates.get(self.settings.mag.sampleRate, 0.0) + + # --- Interrupt configuration --- + + def configInt(self, interrupt, generator, activeLow=INT_ACTIVE_LOW, pushPull=INT_PUSH_PULL): + """ + Configure INT1 or INT2 interrupt output. + + :param interrupt: XG_INT1 or XG_INT2 + :param generator: OR'd combination of interrupt generator values + :param activeLow: INT_ACTIVE_LOW or INT_ACTIVE_HIGH + :param pushPull: INT_PUSH_PULL or INT_OPEN_DRAIN + """ + self._writeReg(_AG_ADDR, interrupt, generator) + reg = self._readReg(_AG_ADDR, _CTRL_REG8) + if activeLow: + reg |= 0x20 + else: + reg &= ~0x20 + if pushPull: + reg &= ~0x10 + else: + reg |= 0x10 + self._writeReg(_AG_ADDR, _CTRL_REG8, reg & 0xFF) + + def configAccelInt(self, generator, andInterrupts=False): + """ + Configure accelerometer interrupt generator. + + :param generator: OR'd combination of XHIE_XL/XLIE_XL/YHIE_XL/etc. + :param andInterrupts: True = AND combination, False = OR + """ + self._writeReg(_AG_ADDR, _INT_GEN_CFG_XL, generator | (0x80 if andInterrupts else 0)) + + def configAccelThs(self, threshold, axis, duration=0, wait=False): + """ + Configure accelerometer interrupt threshold. + + :param threshold: 0-255, raw threshold (equivalent raw accel = threshold * 128) + :param axis: X_AXIS, Y_AXIS, or Z_AXIS + :param duration: samples threshold must be exceeded before interrupt fires + :param wait: if True, wait duration samples before clearing interrupt + """ + self._writeReg(_AG_ADDR, _INT_GEN_THS_X_XL + axis, threshold) + self._writeReg(_AG_ADDR, _INT_GEN_DUR_XL, (duration & 0x7F) | (0x80 if wait else 0)) + + def configGyroInt(self, generator, aoi, latch): + """ + Configure gyroscope interrupt generator. + + :param generator: OR'd combination of ZHIE_G/YHIE_G/XHIE_G/etc. + :param aoi: True = AND combination, False = OR + :param latch: True to latch interrupt until cleared + """ + reg = generator + if aoi: reg |= 0x80 + if latch: reg |= 0x40 + self._writeReg(_AG_ADDR, _INT_GEN_CFG_G, reg) + + def configGyroThs(self, threshold, axis, duration, wait): + """ + Configure gyroscope interrupt threshold. + + :param threshold: 0-0x7FFF, raw gyroscope value + :param axis: X_AXIS, Y_AXIS, or Z_AXIS + :param duration: samples threshold must be exceeded + :param wait: if True, wait duration samples before clearing + """ + self._writeReg(_AG_ADDR, _INT_GEN_THS_XH_G + (axis * 2), (threshold >> 8) & 0x7F) + self._writeReg(_AG_ADDR, _INT_GEN_THS_XH_G + (axis * 2) + 1, threshold & 0xFF) + self._writeReg(_AG_ADDR, _INT_GEN_DUR_G, (duration & 0x7F) | (0x80 if wait else 0)) + + def configMagInt(self, generator, activeLow, latch=True): + """ + Configure magnetometer interrupt. + + :param generator: OR'd combination of XIEN/YIEN/ZIEN + :param activeLow: INT_ACTIVE_LOW or INT_ACTIVE_HIGH + :param latch: True to latch interrupt + """ + config = generator & 0xE0 + if activeLow == INT_ACTIVE_HIGH: config |= 0x04 + if not latch: config |= 0x02 + if generator: config |= 0x01 + self._writeReg(_M_ADDR, _INT_CFG_M, config) + + def configMagThs(self, threshold): + """ + Configure magnetometer interrupt threshold. + + :param threshold: 0-0x7FFF, raw magnetometer value + """ + self._writeReg(_M_ADDR, _INT_THS_H_M, (threshold >> 8) & 0x7F) + self._writeReg(_M_ADDR, _INT_THS_L_M, threshold & 0xFF) + + def configInactivity(self, duration, threshold, sleepOn): + """ + Configure inactivity interrupt parameters. + + :param duration: inactivity duration (ODR-dependent) + :param threshold: activity threshold 0-127 + :param sleepOn: True = sleep gyro on inactivity, False = power down + """ + self._writeReg(_AG_ADDR, _ACT_THS, (threshold & 0x7F) | (0x80 if sleepOn else 0)) + self._writeReg(_AG_ADDR, _ACT_DUR, duration) + + def getGyroIntSrc(self): + """ + :returns: gyroscope interrupt source register value, 0 if interrupt not active + """ + src = self._readReg(_AG_ADDR, _INT_GEN_SRC_G) + return (src & 0x3F) if (src & 0x40) else 0 + + def getAccelIntSrc(self): + """ + :returns: accelerometer interrupt source register value, 0 if interrupt not active + """ + src = self._readReg(_AG_ADDR, _INT_GEN_SRC_XL) + return (src & 0x3F) if (src & 0x40) else 0 + + def getMagIntSrc(self): + """ + :returns: magnetometer interrupt source register value, 0 if interrupt not active + """ + src = self._readReg(_M_ADDR, _INT_SRC_M) + return (src & 0xFE) if (src & 0x01) else 0 + + def getInactivity(self): + """ + :returns: inactivity interrupt status byte + """ + return self._readReg(_AG_ADDR, _STATUS_REG_0) & 0x10 + + # --- FIFO --- + + def enableFIFO(self, enable=True): + """ + Enable or disable the FIFO. + + :param enable: True to enable, False to disable + """ + reg = self._readReg(_AG_ADDR, _CTRL_REG9) + reg = (reg | 0x02) if enable else (reg & ~0x02) + self._writeReg(_AG_ADDR, _CTRL_REG9, reg & 0xFF) + + def setFIFO(self, mode, threshold): + """ + Configure FIFO mode and threshold. + + :param mode: FIFO_OFF, FIFO_THS, FIFO_CONT, FIFO_CONT_TRIGGER, or FIFO_OFF_TRIGGER + :param threshold: 0-31 + """ + self._writeReg(_AG_ADDR, _FIFO_CTRL, ((mode & 0x7) << 5) | min(threshold, 0x1F)) + + def getFIFOSamples(self): + """ + :returns: int, number of samples currently in FIFO + """ + return self._readReg(_AG_ADDR, _FIFO_SRC) & 0x3F + + # --- Gyro sleep --- + + def sleepGyro(self, enable=True): + """ + Sleep or wake the gyroscope. + + :param enable: True to sleep gyro, False to wake + """ + reg = self._readReg(_AG_ADDR, _CTRL_REG9) + reg = (reg | 0x40) if enable else (reg & ~0x40) + self._writeReg(_AG_ADDR, _CTRL_REG9, reg & 0xFF) + + # --- Calibration --- + + def calibrate(self, autoCalc=True): + """ + Collect 32 FIFO samples of accel/gyro and compute bias offsets. + + :param autoCalc: if True, bias is automatically subtracted from future reads + """ + gBiasTemp = [0, 0, 0] + aBiasTemp = [0, 0, 0] + + self.enableFIFO(True) + self.setFIFO(FIFO_THS, 0x1F) + while self.getFIFOSamples() < 0x1F: + pass + + samples = self.getFIFOSamples() + for _ in range(samples): + self.readGyro() + gBiasTemp[0] += self.gx + gBiasTemp[1] += self.gy + gBiasTemp[2] += self.gz + self.readAccel() + aBiasTemp[0] += self.ax + aBiasTemp[1] += self.ay + aBiasTemp[2] += self.az - int(1.0 / self.aRes) + + for i in range(3): + self.gBiasRaw[i] = gBiasTemp[i] // samples + self.gBias[i] = self.calcGyro(self.gBiasRaw[i]) + self.aBiasRaw[i] = aBiasTemp[i] // samples + self.aBias[i] = self.calcAccel(self.aBiasRaw[i]) + + self.enableFIFO(False) + self.setFIFO(FIFO_OFF, 0x00) + + if autoCalc: + self._autoCalc = True + + def calibrateMag(self, loadIn=True): + """ + Collect 128 magnetometer samples and compute hard-iron offset bias. + + :param loadIn: if True, write offsets to sensor hardware offset registers + """ + magMin = [0, 0, 0] + magMax = [0, 0, 0] + + for _ in range(128): + while not self.magAvailable(): + pass + self.readMag() + for j, v in enumerate([self.mx, self.my, self.mz]): + if v > magMax[j]: magMax[j] = v + if v < magMin[j]: magMin[j] = v + + for j in range(3): + self.mBiasRaw[j] = (magMax[j] + magMin[j]) // 2 + self.mBias[j] = self.calcMag(self.mBiasRaw[j]) + if loadIn: + self._magOffset(j, self.mBiasRaw[j]) + + def _magOffset(self, axis, offset): + base = 0x05 + self._writeReg(_M_ADDR, base + (2 * axis), offset & 0xFF) + self._writeReg(_M_ADDR, base + (2 * axis) + 1, (offset >> 8) & 0xFF) + + # --- Low-level I2C --- + + def _readReg(self, addr, reg): + try: + return self.i2c.readfrom_mem(addr, reg, 1)[0] + except OSError as e: + raise Exception("I2C read error: {}".format(e)) + + def _readRegs(self, addr, reg, length): + # 0x80 | reg sets auto-increment bit required by LSM9DS1 for multi-byte reads + try: + return self.i2c.readfrom_mem(addr, 0x80 | reg, length) + except OSError as e: + raise Exception("I2C read error: {}".format(e)) + + def _writeReg(self, addr, reg, value): + try: + self.i2c.writeto_mem(addr, reg, bytes([value])) + except OSError as e: + raise Exception("I2C write error: {}".format(e)) diff --git a/Sensors/LSM9DS1/README.md b/Sensors/LSM9DS1/README.md new file mode 100644 index 0000000..99bad63 --- /dev/null +++ b/Sensors/LSM9DS1/README.md @@ -0,0 +1,9 @@ +# How to install + +After [**installing the mpremote package**](https://docs.micropython.org/en/latest/reference/packages.html): + + mpremote mip install github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/lsm9ds1 + +Or Windows: + + python -m mpremote mip install github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/lsm9ds1 diff --git a/Sensors/LSM9DS1/package.json b/Sensors/LSM9DS1/package.json new file mode 100644 index 0000000..d6abd34 --- /dev/null +++ b/Sensors/LSM9DS1/package.json @@ -0,0 +1,12 @@ +{ + "urls": [ + ["lsm9ds1.py", "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/LSM9DS1/LSM9DS1/lsm9ds1.py"], + ["Examples/lsm9ds1-accelerometerI2C.py", "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/LSM9DS1/LSM9DS1/Examples/lsm9ds1-accelerometerI2C.py"], + ["Examples/lsm9ds1-magnetometerI2C.py", "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/LSM9DS1/LSM9DS1/Examples/lsm9ds1-magnetometerI2C.py"], + ["Examples/lsm9ds1-basicI2C.py", "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/LSM9DS1/LSM9DS1/Examples/lsm9ds1-basicI2C.py"], + ["Examples/lsm9ds1-settingsI2C.py", "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/LSM9DS1/LSM9DS1/Examples/lsm9ds1-settingsI2C.py"], + ["Examples/lsm9ds1-interruptsI2C.py", "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/LSM9DS1/LSM9DS1/Examples/lsm9ds1-interruptsI2C.py"] + ], + "deps": [], + "version": "1.0" +} diff --git a/Sensors/MQSensors/MQSensors/Examples/MQSensor-calibrationNative.py b/Sensors/MQSensors/MQSensors/Examples/MQSensor-calibrationNative.py new file mode 100644 index 0000000..29e833e --- /dev/null +++ b/Sensors/MQSensors/MQSensors/Examples/MQSensor-calibrationNative.py @@ -0,0 +1,35 @@ +# FILE: MQSensor-calibrationNative.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: An example for continuously printing R0 calibration readings (native) +# Run this in clean air to determine the correct R0 for your sensor. +# WORKS WITH: MQ-135 gas sensor breakout: www.solde.red/333208 +# LAST UPDATED: 2026-04-30 + +from MQSensor import MQ135 # Import the MQ135 class +import time # Module used to pause the board + +# Connect the sensor AO pin to an ADC-capable GPIO pin. +# Change ANALOG_PIN to match your wiring. +ANALOG_PIN = 34 # ESP32 default + +# Create an instance of the MQ135 sensor object (native mode) +sensor = MQ135(analog_pin=ANALOG_PIN) + +print("MQ135 - Calibration") +print("Note: make sure you are in clean air and the sensor has been pre-heating for ~24 hours") +print("Counter | R0 value") + +counter = 1 + +# Continuously print R0 readings - average them manually to get your calibration value +while True: + sensor.update() + # Calculate a single R0 reading from clean air + if sensor._sensor_volt > 0: + RS_air = (sensor._voltage_res * sensor._RL / sensor._sensor_volt) - sensor._RL + if RS_air < 0: + RS_air = 0.0 + r0 = RS_air / sensor._Rs_R0_ratio + print(counter, "|", r0) + counter += 1 + time.sleep(0.4) diff --git a/Sensors/MQSensors/MQSensors/Examples/MQSensor-digitalInputNative.py b/Sensors/MQSensors/MQSensors/Examples/MQSensor-digitalInputNative.py new file mode 100644 index 0000000..e8fa127 --- /dev/null +++ b/Sensors/MQSensors/MQSensors/Examples/MQSensor-digitalInputNative.py @@ -0,0 +1,24 @@ +# FILE: MQSensor-digitalInputNative.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: An example showing how to use the digital output pin to detect high gas concentration +# WORKS WITH: MQ-135 gas sensor breakout: www.solde.red/333208 +# LAST UPDATED: 2026-04-30 + +from MQSensor import MQ135 # Import the MQ135 class +import time # Module used to pause the board + +# Connect sensor AO to ANALOG_PIN and DO to DIGITAL_PIN. +# Change both pins to match your wiring. +ANALOG_PIN = 34 # ESP32 default +DIGITAL_PIN = 2 # ESP32 default + +# Create an instance of the MQ135 sensor with both analog and digital pins +sensor = MQ135(analog_pin=ANALOG_PIN, digital_pin=DIGITAL_PIN) + +# Infinite loop +while True: + if sensor.digitalRead(): + print("Alarm: high concentration detected") + else: + print("Status: Normal") + time.sleep(1) diff --git a/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq131I2C.py b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq131I2C.py new file mode 100644 index 0000000..d7c405c --- /dev/null +++ b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq131I2C.py @@ -0,0 +1,31 @@ +# FILE: MQSensor-mq131I2C.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: An example showing how to read O3 concentration from the MQ-131 sensor (Qwiic) +# WORKS WITH: MQ-131 gas sensor with Qwiic: www.solde.red/333205 +# LAST UPDATED: 2026-04-30 + +from MQSensor import MQ131 # Import the MQ131 class +import time # Module used to pause the board +from machine import I2C, Pin + +# Create an instance of the MQ131 sensor object (Qwiic mode) +sensor = MQ131() + +# You can also define a custom I2C communication and address if needed: +# i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +# sensor = MQ131(i2c=i2c, address=0x30) + +# Calibrate the sensor in clean air +# Note: sensor must be pre-heated for ~48h before calibration +# This only needs to run once - you can save R0 and restore it with sensor.setR0() +print("Calibrating, please wait...") +if not sensor.calibrate(10): + print("Calibration error - check wiring and try again") + raise SystemExit +print("Calibration done! R0 =", sensor.getR0()) + +# Infinite loop +while True: + sensor.update() # Read voltage from sensor + print("O3:", sensor.readSensor(), "ppb") + time.sleep(0.5) diff --git a/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq131Native.py b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq131Native.py new file mode 100644 index 0000000..d7a7080 --- /dev/null +++ b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq131Native.py @@ -0,0 +1,30 @@ +# FILE: MQSensor-mq131Native.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: An example showing how to read O3 concentration from the MQ-131 sensor (native) +# WORKS WITH: MQ-131 gas sensor breakout: www.solde.red/333205 +# LAST UPDATED: 2026-04-30 + +from MQSensor import MQ131 # Import the MQ131 class +import time # Module used to pause the board + +# Connect the sensor AO pin to an ADC-capable GPIO pin. +# Change ANALOG_PIN to match your wiring. +ANALOG_PIN = 34 # ESP32 default + +# Create an instance of the MQ131 sensor object (native mode) +sensor = MQ131(analog_pin=ANALOG_PIN) + +# Calibrate the sensor in clean air +# Note: sensor must be pre-heated for ~48h before calibration +# This only needs to run once - you can save R0 and restore it with sensor.setR0() +print("Calibrating, please wait...") +if not sensor.calibrate(10): + print("Calibration error - check wiring and try again") + raise SystemExit +print("Calibration done! R0 =", sensor.getR0()) + +# Infinite loop +while True: + sensor.update() # Read voltage from sensor + print("O3:", sensor.readSensor(), "ppb") + time.sleep(0.5) diff --git a/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq135I2C.py b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq135I2C.py new file mode 100644 index 0000000..1d056de --- /dev/null +++ b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq135I2C.py @@ -0,0 +1,31 @@ +# FILE: MQSensor-mq135I2C.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: An example showing how to read NH3 concentration from the MQ-135 sensor (Qwiic) +# WORKS WITH: MQ-135 gas sensor with Qwiic: www.solde.red/333208 +# LAST UPDATED: 2026-04-30 + +from MQSensor import MQ135 # Import the MQ135 class +import time # Module used to pause the board +from machine import I2C, Pin + +# Create an instance of the MQ135 sensor object (Qwiic mode) +sensor = MQ135() + +# You can also define a custom I2C communication and address if needed: +# i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +# sensor = MQ135(i2c=i2c, address=0x30) + +# Calibrate the sensor in clean air +# Note: sensor must be pre-heated for ~48h before calibration +# This only needs to run once - you can save R0 and restore it with sensor.setR0() +print("Calibrating, please wait...") +if not sensor.calibrate(10): + print("Calibration error - check wiring and try again") + raise SystemExit +print("Calibration done! R0 =", sensor.getR0()) + +# Infinite loop +while True: + sensor.update() # Read voltage from sensor + print("NH3:", sensor.readSensor(), "ppm") + time.sleep(0.5) diff --git a/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq135Native.py b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq135Native.py new file mode 100644 index 0000000..a37d352 --- /dev/null +++ b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq135Native.py @@ -0,0 +1,30 @@ +# FILE: MQSensor-mq135Native.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: An example showing how to read NH3 concentration from the MQ-135 sensor (native) +# WORKS WITH: MQ-135 gas sensor breakout: www.solde.red/333208 +# LAST UPDATED: 2026-04-30 + +from MQSensor import MQ135 # Import the MQ135 class +import time # Module used to pause the board + +# Connect the sensor AO pin to an ADC-capable GPIO pin. +# Change ANALOG_PIN to match your wiring. +ANALOG_PIN = 34 # ESP32 default + +# Create an instance of the MQ135 sensor object (native mode) +sensor = MQ135(analog_pin=ANALOG_PIN) + +# Calibrate the sensor in clean air +# Note: sensor must be pre-heated for ~48h before calibration +# This only needs to run once - you can save R0 and restore it with sensor.setR0() +print("Calibrating, please wait...") +if not sensor.calibrate(10): + print("Calibration error - check wiring and try again") + raise SystemExit +print("Calibration done! R0 =", sensor.getR0()) + +# Infinite loop +while True: + sensor.update() # Read voltage from sensor + print("NH3:", sensor.readSensor(), "ppm") + time.sleep(0.5) diff --git a/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq137I2C.py b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq137I2C.py new file mode 100644 index 0000000..c47e8dd --- /dev/null +++ b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq137I2C.py @@ -0,0 +1,31 @@ +# FILE: MQSensor-mq137I2C.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: An example showing how to read NH3 concentration from the MQ-137 sensor (Qwiic) +# WORKS WITH: MQ-137 gas sensor with Qwiic: www.solde.red/333206 +# LAST UPDATED: 2026-04-30 + +from MQSensor import MQ137 # Import the MQ137 class +import time # Module used to pause the board +from machine import I2C, Pin + +# Create an instance of the MQ137 sensor object (Qwiic mode) +sensor = MQ137() + +# You can also define a custom I2C communication and address if needed: +# i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +# sensor = MQ137(i2c=i2c, address=0x30) + +# Calibrate the sensor in clean air +# Note: sensor must be pre-heated for ~48h before calibration +# This only needs to run once - you can save R0 and restore it with sensor.setR0() +print("Calibrating, please wait...") +if not sensor.calibrate(10): + print("Calibration error - check wiring and try again") + raise SystemExit +print("Calibration done! R0 =", sensor.getR0()) + +# Infinite loop +while True: + sensor.update() # Read voltage from sensor + print("NH3:", sensor.readSensor(), "ppm") + time.sleep(0.5) diff --git a/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq137Native.py b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq137Native.py new file mode 100644 index 0000000..c3dbe28 --- /dev/null +++ b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq137Native.py @@ -0,0 +1,30 @@ +# FILE: MQSensor-mq137Native.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: An example showing how to read NH3 concentration from the MQ-137 sensor (native) +# WORKS WITH: MQ-137 gas sensor breakout: www.solde.red/333206 +# LAST UPDATED: 2026-04-30 + +from MQSensor import MQ137 # Import the MQ137 class +import time # Module used to pause the board + +# Connect the sensor AO pin to an ADC-capable GPIO pin. +# Change ANALOG_PIN to match your wiring. +ANALOG_PIN = 34 # ESP32 default + +# Create an instance of the MQ137 sensor object (native mode) +sensor = MQ137(analog_pin=ANALOG_PIN) + +# Calibrate the sensor in clean air +# Note: sensor must be pre-heated for ~48h before calibration +# This only needs to run once - you can save R0 and restore it with sensor.setR0() +print("Calibrating, please wait...") +if not sensor.calibrate(10): + print("Calibration error - check wiring and try again") + raise SystemExit +print("Calibration done! R0 =", sensor.getR0()) + +# Infinite loop +while True: + sensor.update() # Read voltage from sensor + print("NH3:", sensor.readSensor(), "ppm") + time.sleep(0.5) diff --git a/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq138CustomConfigI2C.py b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq138CustomConfigI2C.py new file mode 100644 index 0000000..16b225d --- /dev/null +++ b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq138CustomConfigI2C.py @@ -0,0 +1,40 @@ +# FILE: MQSensor-mq138CustomConfigI2C.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: An example showing how to use a custom regression config on the MQ-138 sensor (Qwiic) +# This example measures alcohol instead of the default toluene. +# WORKS WITH: MQ-138 gas sensor with Qwiic: www.solde.red/333207 +# LAST UPDATED: 2026-04-30 + +from MQSensor import MQ138, REGRESSION_LINEAR # Import MQ138 and regression constant +import time # Module used to pause the board +from machine import I2C, Pin + +# Create an instance of the MQ138 sensor object (Qwiic mode) +sensor = MQ138() + +# You can also define a custom I2C communication and address if needed: +# i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +# sensor = MQ138(i2c=i2c, address=0x30) + +# Override the regression model to measure alcohol instead of the default toluene +# MQ-138 regression coefficients: +# Gas | a | b +# Alcohol | -0.46099 | 0.0681 +# Acetone | -0.52356 | 0.49225 +# Toluene | -0.4434 | 0.15397 +sensor.setRegressionModel(REGRESSION_LINEAR, -0.46099, 0.0681) + +# Calibrate the sensor in clean air +# Note: sensor must be pre-heated for ~48h before calibration +# This only needs to run once - you can save R0 and restore it with sensor.setR0() +print("Calibrating, please wait...") +if not sensor.calibrate(10): + print("Calibration error - check wiring and try again") + raise SystemExit +print("Calibration done! R0 =", sensor.getR0()) + +# Infinite loop +while True: + sensor.update() # Read voltage from sensor + print("Alcohol:", sensor.readSensor(), "ppm") + time.sleep(0.5) diff --git a/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq138CustomConfigNative.py b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq138CustomConfigNative.py new file mode 100644 index 0000000..0bf6532 --- /dev/null +++ b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq138CustomConfigNative.py @@ -0,0 +1,39 @@ +# FILE: MQSensor-mq138CustomConfigNative.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: An example showing how to use a custom regression config on the MQ-138 sensor (native) +# This example measures alcohol instead of the default toluene. +# WORKS WITH: MQ-138 gas sensor breakout: www.solde.red/333207 +# LAST UPDATED: 2026-04-30 + +from MQSensor import MQ138, REGRESSION_LINEAR # Import MQ138 and regression constant +import time # Module used to pause the board + +# Connect the sensor AO pin to an ADC-capable GPIO pin. +# Change ANALOG_PIN to match your wiring. +ANALOG_PIN = 34 # ESP32 default + +# Create an instance of the MQ138 sensor object (native mode) +sensor = MQ138(analog_pin=ANALOG_PIN) + +# Override the regression model to measure alcohol instead of the default toluene +# MQ-138 regression coefficients: +# Gas | a | b +# Alcohol | -0.46099 | 0.0681 +# Acetone | -0.52356 | 0.49225 +# Toluene | -0.4434 | 0.15397 +sensor.setRegressionModel(REGRESSION_LINEAR, -0.46099, 0.0681) + +# Calibrate the sensor in clean air +# Note: sensor must be pre-heated for ~48h before calibration +# This only needs to run once - you can save R0 and restore it with sensor.setR0() +print("Calibrating, please wait...") +if not sensor.calibrate(10): + print("Calibration error - check wiring and try again") + raise SystemExit +print("Calibration done! R0 =", sensor.getR0()) + +# Infinite loop +while True: + sensor.update() # Read voltage from sensor + print("Alcohol:", sensor.readSensor(), "ppm") + time.sleep(0.5) diff --git a/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq138I2C.py b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq138I2C.py new file mode 100644 index 0000000..3a9a7df --- /dev/null +++ b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq138I2C.py @@ -0,0 +1,31 @@ +# FILE: MQSensor-mq138I2C.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: An example showing how to read toluene concentration from the MQ-138 sensor (Qwiic) +# WORKS WITH: MQ-138 gas sensor with Qwiic: www.solde.red/333207 +# LAST UPDATED: 2026-04-30 + +from MQSensor import MQ138 # Import the MQ138 class +import time # Module used to pause the board +from machine import I2C, Pin + +# Create an instance of the MQ138 sensor object (Qwiic mode) +sensor = MQ138() + +# You can also define a custom I2C communication and address if needed: +# i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +# sensor = MQ138(i2c=i2c, address=0x30) + +# Calibrate the sensor in clean air +# Note: sensor must be pre-heated for ~48h before calibration +# This only needs to run once - you can save R0 and restore it with sensor.setR0() +print("Calibrating, please wait...") +if not sensor.calibrate(10): + print("Calibration error - check wiring and try again") + raise SystemExit +print("Calibration done! R0 =", sensor.getR0()) + +# Infinite loop +while True: + sensor.update() # Read voltage from sensor + print("Toluene:", sensor.readSensor(), "ppm") + time.sleep(0.5) diff --git a/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq138Native.py b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq138Native.py new file mode 100644 index 0000000..c74cda4 --- /dev/null +++ b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq138Native.py @@ -0,0 +1,30 @@ +# FILE: MQSensor-mq138Native.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: An example showing how to read toluene concentration from the MQ-138 sensor (native) +# WORKS WITH: MQ-138 gas sensor breakout: www.solde.red/333207 +# LAST UPDATED: 2026-04-30 + +from MQSensor import MQ138 # Import the MQ138 class +import time # Module used to pause the board + +# Connect the sensor AO pin to an ADC-capable GPIO pin. +# Change ANALOG_PIN to match your wiring. +ANALOG_PIN = 34 # ESP32 default + +# Create an instance of the MQ138 sensor object (native mode) +sensor = MQ138(analog_pin=ANALOG_PIN) + +# Calibrate the sensor in clean air +# Note: sensor must be pre-heated for ~48h before calibration +# This only needs to run once - you can save R0 and restore it with sensor.setR0() +print("Calibrating, please wait...") +if not sensor.calibrate(10): + print("Calibration error - check wiring and try again") + raise SystemExit +print("Calibration done! R0 =", sensor.getR0()) + +# Infinite loop +while True: + sensor.update() # Read voltage from sensor + print("Toluene:", sensor.readSensor(), "ppm") + time.sleep(0.5) diff --git a/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq2I2C.py b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq2I2C.py new file mode 100644 index 0000000..491ad45 --- /dev/null +++ b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq2I2C.py @@ -0,0 +1,31 @@ +# FILE: MQSensor-mq2I2C.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: An example showing how to read LPG concentration from the MQ-2 sensor (Qwiic) +# WORKS WITH: MQ-2 gas sensor with Qwiic: www.solde.red/333196 +# LAST UPDATED: 2026-04-30 + +from MQSensor import MQ2 # Import the MQ2 class +import time # Module used to pause the board +from machine import I2C, Pin + +# Create an instance of the MQ2 sensor object (Qwiic mode) +sensor = MQ2() + +# You can also define a custom I2C communication and address if needed: +# i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +# sensor = MQ2(i2c=i2c, address=0x30) + +# Calibrate the sensor in clean air +# Note: sensor must be pre-heated for ~48h before calibration +# This only needs to run once - you can save R0 and restore it with sensor.setR0() +print("Calibrating, please wait...") +if not sensor.calibrate(10): + print("Calibration error - check wiring and try again") + raise SystemExit +print("Calibration done! R0 =", sensor.getR0()) + +# Infinite loop +while True: + sensor.update() # Read voltage from sensor + print("LPG:", sensor.readSensor(), "ppm") + time.sleep(0.5) diff --git a/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq2Native.py b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq2Native.py new file mode 100644 index 0000000..c15f346 --- /dev/null +++ b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq2Native.py @@ -0,0 +1,30 @@ +# FILE: MQSensor-mq2Native.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: An example showing how to read LPG concentration from the MQ-2 sensor (native) +# WORKS WITH: MQ-2 gas sensor breakout: www.solde.red/333196 +# LAST UPDATED: 2026-04-30 + +from MQSensor import MQ2 # Import the MQ2 class +import time # Module used to pause the board + +# Connect the sensor AO pin to an ADC-capable GPIO pin. +# Change ANALOG_PIN to match your wiring. +ANALOG_PIN = 34 # ESP32 default + +# Create an instance of the MQ2 sensor object (native mode) +sensor = MQ2(analog_pin=ANALOG_PIN) + +# Calibrate the sensor in clean air +# Note: sensor must be pre-heated for ~48h before calibration +# This only needs to run once - you can save R0 and restore it with sensor.setR0() +print("Calibrating, please wait...") +if not sensor.calibrate(10): + print("Calibration error - check wiring and try again") + raise SystemExit +print("Calibration done! R0 =", sensor.getR0()) + +# Infinite loop +while True: + sensor.update() # Read voltage from sensor + print("LPG:", sensor.readSensor(), "ppm") + time.sleep(0.5) diff --git a/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq3I2C.py b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq3I2C.py new file mode 100644 index 0000000..5f29b91 --- /dev/null +++ b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq3I2C.py @@ -0,0 +1,31 @@ +# FILE: MQSensor-mq3I2C.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: An example showing how to read alcohol concentration from the MQ-3 sensor (Qwiic) +# WORKS WITH: MQ-3 gas sensor with Qwiic: www.solde.red/333197 +# LAST UPDATED: 2026-04-30 + +from MQSensor import MQ3 # Import the MQ3 class +import time # Module used to pause the board +from machine import I2C, Pin + +# Create an instance of the MQ3 sensor object (Qwiic mode) +sensor = MQ3() + +# You can also define a custom I2C communication and address if needed: +# i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +# sensor = MQ3(i2c=i2c, address=0x30) + +# Calibrate the sensor in clean air +# Note: sensor must be pre-heated for ~48h before calibration +# This only needs to run once - you can save R0 and restore it with sensor.setR0() +print("Calibrating, please wait...") +if not sensor.calibrate(10): + print("Calibration error - check wiring and try again") + raise SystemExit +print("Calibration done! R0 =", sensor.getR0()) + +# Infinite loop +while True: + sensor.update() # Read voltage from sensor + print("Alcohol:", sensor.readSensor(), "mg/L") + time.sleep(0.5) diff --git a/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq3Native.py b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq3Native.py new file mode 100644 index 0000000..64ba014 --- /dev/null +++ b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq3Native.py @@ -0,0 +1,30 @@ +# FILE: MQSensor-mq3Native.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: An example showing how to read alcohol concentration from the MQ-3 sensor (native) +# WORKS WITH: MQ-3 gas sensor breakout: www.solde.red/333197 +# LAST UPDATED: 2026-04-30 + +from MQSensor import MQ3 # Import the MQ3 class +import time # Module used to pause the board + +# Connect the sensor AO pin to an ADC-capable GPIO pin. +# Change ANALOG_PIN to match your wiring. +ANALOG_PIN = 34 # ESP32 default + +# Create an instance of the MQ3 sensor object (native mode) +sensor = MQ3(analog_pin=ANALOG_PIN) + +# Calibrate the sensor in clean air +# Note: sensor must be pre-heated for ~48h before calibration +# This only needs to run once - you can save R0 and restore it with sensor.setR0() +print("Calibrating, please wait...") +if not sensor.calibrate(10): + print("Calibration error - check wiring and try again") + raise SystemExit +print("Calibration done! R0 =", sensor.getR0()) + +# Infinite loop +while True: + sensor.update() # Read voltage from sensor + print("Alcohol:", sensor.readSensor(), "mg/L") + time.sleep(0.5) diff --git a/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq4I2C.py b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq4I2C.py new file mode 100644 index 0000000..4529be8 --- /dev/null +++ b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq4I2C.py @@ -0,0 +1,31 @@ +# FILE: MQSensor-mq4I2C.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: An example showing how to read CH4 concentration from the MQ-4 sensor (Qwiic) +# WORKS WITH: MQ-4 gas sensor with Qwiic: www.solde.red/333198 +# LAST UPDATED: 2026-04-30 + +from MQSensor import MQ4 # Import the MQ4 class +import time # Module used to pause the board +from machine import I2C, Pin + +# Create an instance of the MQ4 sensor object (Qwiic mode) +sensor = MQ4() + +# You can also define a custom I2C communication and address if needed: +# i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +# sensor = MQ4(i2c=i2c, address=0x30) + +# Calibrate the sensor in clean air +# Note: sensor must be pre-heated for ~48h before calibration +# This only needs to run once - you can save R0 and restore it with sensor.setR0() +print("Calibrating, please wait...") +if not sensor.calibrate(10): + print("Calibration error - check wiring and try again") + raise SystemExit +print("Calibration done! R0 =", sensor.getR0()) + +# Infinite loop +while True: + sensor.update() # Read voltage from sensor + print("CH4:", sensor.readSensor(), "ppm") + time.sleep(0.5) diff --git a/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq4Native.py b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq4Native.py new file mode 100644 index 0000000..850663f --- /dev/null +++ b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq4Native.py @@ -0,0 +1,30 @@ +# FILE: MQSensor-mq4Native.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: An example showing how to read CH4 concentration from the MQ-4 sensor (native) +# WORKS WITH: MQ-4 gas sensor breakout: www.solde.red/333198 +# LAST UPDATED: 2026-04-30 + +from MQSensor import MQ4 # Import the MQ4 class +import time # Module used to pause the board + +# Connect the sensor AO pin to an ADC-capable GPIO pin. +# Change ANALOG_PIN to match your wiring. +ANALOG_PIN = 34 # ESP32 default + +# Create an instance of the MQ4 sensor object (native mode) +sensor = MQ4(analog_pin=ANALOG_PIN) + +# Calibrate the sensor in clean air +# Note: sensor must be pre-heated for ~48h before calibration +# This only needs to run once - you can save R0 and restore it with sensor.setR0() +print("Calibrating, please wait...") +if not sensor.calibrate(10): + print("Calibration error - check wiring and try again") + raise SystemExit +print("Calibration done! R0 =", sensor.getR0()) + +# Infinite loop +while True: + sensor.update() # Read voltage from sensor + print("CH4:", sensor.readSensor(), "ppm") + time.sleep(0.5) diff --git a/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq5I2C.py b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq5I2C.py new file mode 100644 index 0000000..56292cb --- /dev/null +++ b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq5I2C.py @@ -0,0 +1,31 @@ +# FILE: MQSensor-mq5I2C.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: An example showing how to read LPG concentration from the MQ-5 sensor (Qwiic) +# WORKS WITH: MQ-5 gas sensor with Qwiic: www.solde.red/333199 +# LAST UPDATED: 2026-04-30 + +from MQSensor import MQ5 # Import the MQ5 class +import time # Module used to pause the board +from machine import I2C, Pin + +# Create an instance of the MQ5 sensor object (Qwiic mode) +sensor = MQ5() + +# You can also define a custom I2C communication and address if needed: +# i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +# sensor = MQ5(i2c=i2c, address=0x30) + +# Calibrate the sensor in clean air +# Note: sensor must be pre-heated for ~48h before calibration +# This only needs to run once - you can save R0 and restore it with sensor.setR0() +print("Calibrating, please wait...") +if not sensor.calibrate(10): + print("Calibration error - check wiring and try again") + raise SystemExit +print("Calibration done! R0 =", sensor.getR0()) + +# Infinite loop +while True: + sensor.update() # Read voltage from sensor + print("LPG:", sensor.readSensor(), "ppm") + time.sleep(0.5) diff --git a/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq5Native.py b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq5Native.py new file mode 100644 index 0000000..e5a0b7e --- /dev/null +++ b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq5Native.py @@ -0,0 +1,30 @@ +# FILE: MQSensor-mq5Native.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: An example showing how to read CH4 concentration from the MQ-5 sensor (native) +# WORKS WITH: MQ-5 gas sensor breakout: www.solde.red/333199 +# LAST UPDATED: 2026-04-30 + +from MQSensor import MQ5 # Import the MQ5 class +import time # Module used to pause the board + +# Connect the sensor AO pin to an ADC-capable GPIO pin. +# Change ANALOG_PIN to match your wiring. +ANALOG_PIN = 34 # ESP32 default + +# Create an instance of the MQ5 sensor object (native mode) +sensor = MQ5(analog_pin=ANALOG_PIN) + +# Calibrate the sensor in clean air +# Note: sensor must be pre-heated for ~48h before calibration +# This only needs to run once - you can save R0 and restore it with sensor.setR0() +print("Calibrating, please wait...") +if not sensor.calibrate(10): + print("Calibration error - check wiring and try again") + raise SystemExit +print("Calibration done! R0 =", sensor.getR0()) + +# Infinite loop +while True: + sensor.update() # Read voltage from sensor + print("CH4:", sensor.readSensor(), "ppm") + time.sleep(0.5) diff --git a/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq6I2C.py b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq6I2C.py new file mode 100644 index 0000000..345bcff --- /dev/null +++ b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq6I2C.py @@ -0,0 +1,31 @@ +# FILE: MQSensor-mq6I2C.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: An example showing how to read LPG concentration from the MQ-6 sensor (Qwiic) +# WORKS WITH: MQ-6 gas sensor with Qwiic: www.solde.red/333200 +# LAST UPDATED: 2026-04-30 + +from MQSensor import MQ6 # Import the MQ6 class +import time # Module used to pause the board +from machine import I2C, Pin + +# Create an instance of the MQ6 sensor object (Qwiic mode) +sensor = MQ6() + +# You can also define a custom I2C communication and address if needed: +# i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +# sensor = MQ6(i2c=i2c, address=0x30) + +# Calibrate the sensor in clean air +# Note: sensor must be pre-heated for ~48h before calibration +# This only needs to run once - you can save R0 and restore it with sensor.setR0() +print("Calibrating, please wait...") +if not sensor.calibrate(10): + print("Calibration error - check wiring and try again") + raise SystemExit +print("Calibration done! R0 =", sensor.getR0()) + +# Infinite loop +while True: + sensor.update() # Read voltage from sensor + print("LPG:", sensor.readSensor(), "ppm") + time.sleep(0.5) diff --git a/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq6Native.py b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq6Native.py new file mode 100644 index 0000000..34bebd9 --- /dev/null +++ b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq6Native.py @@ -0,0 +1,30 @@ +# FILE: MQSensor-mq6Native.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: An example showing how to read LPG concentration from the MQ-6 sensor (native) +# WORKS WITH: MQ-6 gas sensor breakout: www.solde.red/333200 +# LAST UPDATED: 2026-04-30 + +from MQSensor import MQ6 # Import the MQ6 class +import time # Module used to pause the board + +# Connect the sensor AO pin to an ADC-capable GPIO pin. +# Change ANALOG_PIN to match your wiring. +ANALOG_PIN = 34 # ESP32 default + +# Create an instance of the MQ6 sensor object (native mode) +sensor = MQ6(analog_pin=ANALOG_PIN) + +# Calibrate the sensor in clean air +# Note: sensor must be pre-heated for ~48h before calibration +# This only needs to run once - you can save R0 and restore it with sensor.setR0() +print("Calibrating, please wait...") +if not sensor.calibrate(10): + print("Calibration error - check wiring and try again") + raise SystemExit +print("Calibration done! R0 =", sensor.getR0()) + +# Infinite loop +while True: + sensor.update() # Read voltage from sensor + print("LPG:", sensor.readSensor(), "ppm") + time.sleep(0.5) diff --git a/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq7I2C.py b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq7I2C.py new file mode 100644 index 0000000..45d5bb7 --- /dev/null +++ b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq7I2C.py @@ -0,0 +1,31 @@ +# FILE: MQSensor-mq7I2C.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: An example showing how to read CO concentration from the MQ-7 sensor (Qwiic) +# WORKS WITH: MQ-7 gas sensor with Qwiic: www.solde.red/333201 +# LAST UPDATED: 2026-04-30 + +from MQSensor import MQ7 # Import the MQ7 class +import time # Module used to pause the board +from machine import I2C, Pin + +# Create an instance of the MQ7 sensor object (Qwiic mode) +sensor = MQ7() + +# You can also define a custom I2C communication and address if needed: +# i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +# sensor = MQ7(i2c=i2c, address=0x30) + +# Calibrate the sensor in clean air +# Note: sensor must be pre-heated for ~48h before calibration +# This only needs to run once - you can save R0 and restore it with sensor.setR0() +print("Calibrating, please wait...") +if not sensor.calibrate(10): + print("Calibration error - check wiring and try again") + raise SystemExit +print("Calibration done! R0 =", sensor.getR0()) + +# Infinite loop +while True: + sensor.update() # Read voltage from sensor + print("CO:", sensor.readSensor(), "ppm") + time.sleep(0.5) diff --git a/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq7Native.py b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq7Native.py new file mode 100644 index 0000000..e2a85ae --- /dev/null +++ b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq7Native.py @@ -0,0 +1,30 @@ +# FILE: MQSensor-mq7Native.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: An example showing how to read CO concentration from the MQ-7 sensor (native) +# WORKS WITH: MQ-7 gas sensor breakout: www.solde.red/333201 +# LAST UPDATED: 2026-04-30 + +from MQSensor import MQ7 # Import the MQ7 class +import time # Module used to pause the board + +# Connect the sensor AO pin to an ADC-capable GPIO pin. +# Change ANALOG_PIN to match your wiring. +ANALOG_PIN = 34 # ESP32 default + +# Create an instance of the MQ7 sensor object (native mode) +sensor = MQ7(analog_pin=ANALOG_PIN) + +# Calibrate the sensor in clean air +# Note: sensor must be pre-heated for ~48h before calibration +# This only needs to run once - you can save R0 and restore it with sensor.setR0() +print("Calibrating, please wait...") +if not sensor.calibrate(10): + print("Calibration error - check wiring and try again") + raise SystemExit +print("Calibration done! R0 =", sensor.getR0()) + +# Infinite loop +while True: + sensor.update() # Read voltage from sensor + print("CO:", sensor.readSensor(), "ppm") + time.sleep(0.5) diff --git a/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq8I2C.py b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq8I2C.py new file mode 100644 index 0000000..c199eb1 --- /dev/null +++ b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq8I2C.py @@ -0,0 +1,31 @@ +# FILE: MQSensor-mq8I2C.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: An example showing how to read H2 concentration from the MQ-8 sensor (Qwiic) +# WORKS WITH: MQ-8 gas sensor with Qwiic: www.solde.red/333202 +# LAST UPDATED: 2026-04-30 + +from MQSensor import MQ8 # Import the MQ8 class +import time # Module used to pause the board +from machine import I2C, Pin + +# Create an instance of the MQ8 sensor object (Qwiic mode) +sensor = MQ8() + +# You can also define a custom I2C communication and address if needed: +# i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +# sensor = MQ8(i2c=i2c, address=0x30) + +# Calibrate the sensor in clean air +# Note: sensor must be pre-heated for ~48h before calibration +# This only needs to run once - you can save R0 and restore it with sensor.setR0() +print("Calibrating, please wait...") +if not sensor.calibrate(10): + print("Calibration error - check wiring and try again") + raise SystemExit +print("Calibration done! R0 =", sensor.getR0()) + +# Infinite loop +while True: + sensor.update() # Read voltage from sensor + print("H2:", sensor.readSensor(), "ppm") + time.sleep(0.5) diff --git a/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq8Native.py b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq8Native.py new file mode 100644 index 0000000..9532653 --- /dev/null +++ b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq8Native.py @@ -0,0 +1,30 @@ +# FILE: MQSensor-mq8Native.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: An example showing how to read H2 concentration from the MQ-8 sensor (native) +# WORKS WITH: MQ-8 gas sensor breakout: www.solde.red/333202 +# LAST UPDATED: 2026-04-30 + +from MQSensor import MQ8 # Import the MQ8 class +import time # Module used to pause the board + +# Connect the sensor AO pin to an ADC-capable GPIO pin. +# Change ANALOG_PIN to match your wiring. +ANALOG_PIN = 34 # ESP32 default + +# Create an instance of the MQ8 sensor object (native mode) +sensor = MQ8(analog_pin=ANALOG_PIN) + +# Calibrate the sensor in clean air +# Note: sensor must be pre-heated for ~48h before calibration +# This only needs to run once - you can save R0 and restore it with sensor.setR0() +print("Calibrating, please wait...") +if not sensor.calibrate(10): + print("Calibration error - check wiring and try again") + raise SystemExit +print("Calibration done! R0 =", sensor.getR0()) + +# Infinite loop +while True: + sensor.update() # Read voltage from sensor + print("H2:", sensor.readSensor(), "ppm") + time.sleep(0.5) diff --git a/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq9AllI2C.py b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq9AllI2C.py new file mode 100644 index 0000000..cfed9a7 --- /dev/null +++ b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq9AllI2C.py @@ -0,0 +1,50 @@ +# FILE: MQSensor-mq9AllI2C.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: An example showing how to read LPG, CH4 and CO from the MQ-9 sensor (Qwiic) +# WORKS WITH: MQ-9 gas sensor with Qwiic: www.solde.red/333203 +# LAST UPDATED: 2026-04-30 + +from MQSensor import MQ9 # Import the MQ9 class +import time # Module used to pause the board +from machine import I2C, Pin + +# Create an instance of the MQ9 sensor object (Qwiic mode) +sensor = MQ9() + +# You can also define a custom I2C communication and address if needed: +# i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +# sensor = MQ9(i2c=i2c, address=0x30) + +# Calibrate the sensor in clean air +# Note: sensor must be pre-heated for ~48h before calibration +# This only needs to run once - you can save R0 and restore it with sensor.setR0() +print("Calibrating, please wait...") +if not sensor.calibrate(10): + print("Calibration error - check wiring and try again") + raise SystemExit +print("Calibration done! R0 =", sensor.getR0()) + +# MQ-9 can measure multiple gases by swapping the regression coefficients: +# Gas | a | b +# LPG | 1000.5 | -2.186 +# CH4 | 4269.6 | -2.648 +# CO | 599.65 | -2.244 + +# Infinite loop +while True: + sensor.update() # Read voltage from sensor once, reuse for all three gases + + sensor.setA(1000.5) + sensor.setB(-2.186) + lpg = sensor.readSensor() + + sensor.setA(4269.6) + sensor.setB(-2.648) + ch4 = sensor.readSensor() + + sensor.setA(599.65) + sensor.setB(-2.244) + co = sensor.readSensor() + + print("| LPG:", lpg, "ppm | CH4:", ch4, "ppm | CO:", co, "ppm |") + time.sleep(1) diff --git a/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq9AllNative.py b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq9AllNative.py new file mode 100644 index 0000000..ecf7215 --- /dev/null +++ b/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq9AllNative.py @@ -0,0 +1,49 @@ +# FILE: MQSensor-mq9AllNative.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: An example showing how to read LPG, CH4 and CO from the MQ-9 sensor (native) +# WORKS WITH: MQ-9 gas sensor breakout: www.solde.red/333203 +# LAST UPDATED: 2026-04-30 + +from MQSensor import MQ9 # Import the MQ9 class +import time # Module used to pause the board + +# Connect the sensor AO pin to an ADC-capable GPIO pin. +# Change ANALOG_PIN to match your wiring. +ANALOG_PIN = 34 # ESP32 default + +# Create an instance of the MQ9 sensor object (native mode) +sensor = MQ9(analog_pin=ANALOG_PIN) + +# Calibrate the sensor in clean air +# Note: sensor must be pre-heated for ~48h before calibration +# This only needs to run once - you can save R0 and restore it with sensor.setR0() +print("Calibrating, please wait...") +if not sensor.calibrate(10): + print("Calibration error - check wiring and try again") + raise SystemExit +print("Calibration done! R0 =", sensor.getR0()) + +# MQ-9 can measure multiple gases by swapping the regression coefficients: +# Gas | a | b +# LPG | 1000.5 | -2.186 +# CH4 | 4269.6 | -2.648 +# CO | 599.65 | -2.244 + +# Infinite loop +while True: + sensor.update() # Read voltage from sensor once, reuse for all three gases + + sensor.setA(1000.5) + sensor.setB(-2.186) + lpg = sensor.readSensor() + + sensor.setA(4269.6) + sensor.setB(-2.648) + ch4 = sensor.readSensor() + + sensor.setA(599.65) + sensor.setB(-2.244) + co = sensor.readSensor() + + print("| LPG:", lpg, "ppm | CH4:", ch4, "ppm | CO:", co, "ppm |") + time.sleep(1) diff --git a/Sensors/MQSensors/MQSensors/mqsensor.py b/Sensors/MQSensors/MQSensors/mqsensor.py new file mode 100644 index 0000000..a5caf6d --- /dev/null +++ b/Sensors/MQSensors/MQSensors/mqsensor.py @@ -0,0 +1,326 @@ +# FILE: MQSensor.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: A MicroPython module for the MQ gas sensor family, supports native and Qwiic modes +# LAST UPDATED: 2026-04-30 + +import math +from os import uname +from machine import Pin, ADC +from Qwiic import Qwiic + +REGRESSION_EXPONENTIAL = 1 +REGRESSION_LINEAR = 0 + + +class MQSensor(Qwiic): + """ + Base class for MQ gas sensor family. + Supports native (analog+digital GPIO pins) and Qwiic (I2C) modes. + """ + + def __init__( + self, + regression_method, + Rs_R0_ratio, + a, + b, + analog_pin=None, + digital_pin=None, + i2c=None, + address=0x30, + RL=10.0, + R0=10.0, + ): + """ + Initialize the MQ gas sensor. + + Args: + regression_method (int): REGRESSION_EXPONENTIAL or REGRESSION_LINEAR + Rs_R0_ratio (float): Rs/R0 ratio in clean air for calibration + a (float): regression coefficient a + b (float): regression coefficient b + analog_pin (int, optional): GPIO pin number for analog reading (native mode) + digital_pin (int, optional): GPIO pin number for digital reading (native mode) + i2c (I2C, optional): I2C object for Qwiic mode + address (int): I2C address (default 0x30) + RL (float): load resistance in kΩ (default 10) + R0 (float): sensor baseline resistance in kΩ (set via calibrate()) + """ + self._regression_method = regression_method + self._Rs_R0_ratio = Rs_R0_ratio + self._a = a + self._b = b + self._RL = RL + self._R0 = R0 + self._sensor_volt = 0.0 + self._digital_pin = None + + if analog_pin is not None: + self.native = True + sysname = uname().sysname + if sysname in ("esp32", "Soldered Dasduino CONNECTPLUS"): + self._voltage_res = 3.3 + self._adc_max = 4095 + self._analog_pin = ADC(Pin(analog_pin)) + self._analog_pin.atten(ADC.ATTN_11DB) + elif sysname == "esp8266": + self._voltage_res = 3.3 + self._adc_max = 1023 + self._analog_pin = ADC(Pin(analog_pin)) + else: + self._voltage_res = 5.0 + self._adc_max = 1023 + self._analog_pin = ADC(Pin(analog_pin)) + if digital_pin is not None: + self._digital_pin = Pin(digital_pin, Pin.IN) + else: + # Qwiic board uses 5V reference, 10-bit ADC + self._voltage_res = 5.0 + self._adc_max = 1023 + super().__init__(i2c=i2c, address=address, native=False) + + def _read_voltage(self): + if self.native: + total = 0 + for _ in range(2): + total += self._analog_pin.read() + return (total / 2.0) * self._voltage_res / self._adc_max + else: + data = self.read_register(0, 2) + if len(data) < 2: + return 0.0 + adc = data[0] | (data[1] << 8) + return adc / 1024.0 * self._voltage_res + + def update(self): + """Read and store current sensor voltage. Call before readSensor() or calibrate().""" + self._sensor_volt = self._read_voltage() + + def readSensor(self): + """ + Calculate gas concentration in PPM based on last update() call. + + Returns: + float: Gas concentration in PPM. + """ + if self._sensor_volt <= 0 or self._R0 <= 0: + return 0.0 + RS = (self._voltage_res * self._RL / self._sensor_volt) - self._RL + if RS < 0: + RS = 0.0 + ratio = RS / self._R0 + if ratio <= 0: + return 0.0 + if self._regression_method == REGRESSION_EXPONENTIAL: + ppm = self._a * math.pow(ratio, self._b) + else: + if self._a == 0: + return 0.0 + ppm_log = (math.log10(ratio) - self._b) / self._a + ppm = math.pow(10, ppm_log) + return max(0.0, ppm) + + def calibrate(self, num_samples=10): + """ + Calibrate the sensor in clean air. Sets R0 based on averaged readings. + + Args: + num_samples (int): Number of readings to average (default 10). + + Returns: + bool: True if calibration succeeded, False on error. + """ + r0_sum = 0.0 + for _ in range(num_samples): + self.update() + if self._sensor_volt <= 0: + return False + RS_air = (self._voltage_res * self._RL / self._sensor_volt) - self._RL + if RS_air < 0: + RS_air = 0.0 + r0 = RS_air / self._Rs_R0_ratio + if r0 < 0: + r0 = 0.0 + r0_sum += r0 + r0_avg = r0_sum / num_samples + if r0_avg == 0 or math.isinf(r0_avg): + return False + self._R0 = r0_avg + return True + + def setR0(self, R0): + """Set R0 directly (e.g. from a stored calibration value).""" + self._R0 = R0 + + def getR0(self): + """Return current R0 value in kΩ.""" + return self._R0 + + def setRL(self, RL): + """Set load resistance in kΩ.""" + self._RL = RL + + def getRL(self): + """Return current load resistance in kΩ.""" + return self._RL + + def setA(self, a): + """Set regression coefficient a.""" + self._a = a + + def setB(self, b): + """Set regression coefficient b.""" + self._b = b + + def setRegressionModel(self, method, a, b): + """ + Override the regression model coefficients. + + Args: + method (int): REGRESSION_EXPONENTIAL or REGRESSION_LINEAR + a (float): coefficient a + b (float): coefficient b + """ + self._regression_method = method + self._a = a + self._b = b + + def digitalRead(self): + """ + Read the digital output pin (native mode only). + + Returns: + bool: True if gas threshold exceeded. + + Raises: + Exception: If no digital pin was configured. + """ + if self._digital_pin is not None: + return bool(self._digital_pin.value()) + raise Exception("No digital pin configured") + + +class MQ2(MQSensor): + """MQ-2: LPG, propane, hydrogen, smoke. Default coefficients for LPG.""" + + def __init__(self, analog_pin=None, digital_pin=None, i2c=None, address=0x30): + super().__init__( + REGRESSION_EXPONENTIAL, 9.83, 574.25, -2.222, analog_pin, digital_pin, i2c, address + ) + + +class MQ3(MQSensor): + """MQ-3: alcohol, benzene, hexane. Default coefficients for alcohol.""" + + def __init__(self, analog_pin=None, digital_pin=None, i2c=None, address=0x30): + super().__init__( + REGRESSION_EXPONENTIAL, 60.0, 0.3934, -1.504, analog_pin, digital_pin, i2c, address + ) + + +class MQ4(MQSensor): + """MQ-4: methane, natural gas. Default coefficients for CH4.""" + + def __init__(self, analog_pin=None, digital_pin=None, i2c=None, address=0x30): + super().__init__( + REGRESSION_EXPONENTIAL, 4.4, 1012.7, -2.786, analog_pin, digital_pin, i2c, address + ) + + +class MQ5(MQSensor): + """MQ-5: natural gas, LPG. Default coefficients for LPG.""" + + def __init__(self, analog_pin=None, digital_pin=None, i2c=None, address=0x30): + super().__init__( + REGRESSION_EXPONENTIAL, 6.5, 80.897, -2.431, analog_pin, digital_pin, i2c, address + ) + + +class MQ6(MQSensor): + """MQ-6: LPG, butane. Default coefficients for LPG.""" + + def __init__(self, analog_pin=None, digital_pin=None, i2c=None, address=0x30): + super().__init__( + REGRESSION_EXPONENTIAL, 10.0, 1009.2, -2.35, analog_pin, digital_pin, i2c, address + ) + + +class MQ7(MQSensor): + """MQ-7: carbon monoxide. Default coefficients for CO.""" + + def __init__(self, analog_pin=None, digital_pin=None, i2c=None, address=0x30): + super().__init__( + REGRESSION_EXPONENTIAL, 27.5, 99.042, -1.518, analog_pin, digital_pin, i2c, address + ) + + +class MQ8(MQSensor): + """MQ-8: hydrogen. Default coefficients for H2.""" + + def __init__(self, analog_pin=None, digital_pin=None, i2c=None, address=0x30): + super().__init__( + REGRESSION_EXPONENTIAL, 70.0, 976.97, -0.688, analog_pin, digital_pin, i2c, address + ) + + +class MQ9(MQSensor): + """MQ-9: CO, flammable gases. Default coefficients for LPG.""" + + def __init__(self, analog_pin=None, digital_pin=None, i2c=None, address=0x30): + super().__init__( + REGRESSION_EXPONENTIAL, 9.6, 1000.5, -2.186, analog_pin, digital_pin, i2c, address + ) + + +class MQ131(MQSensor): + """MQ-131: ozone. Default coefficients for O3.""" + + def __init__(self, analog_pin=None, digital_pin=None, i2c=None, address=0x30): + super().__init__( + REGRESSION_LINEAR, 1.0, 0.41195, -0.4708, analog_pin, digital_pin, i2c, address + ) + + +class MQ135(MQSensor): + """MQ-135: NH3, NOx, benzene, CO2. Default coefficients for NH3.""" + + def __init__(self, analog_pin=None, digital_pin=None, i2c=None, address=0x30): + super().__init__( + REGRESSION_LINEAR, 1.0, -0.47712, 0.4491, analog_pin, digital_pin, i2c, address + ) + + +class MQ136(MQSensor): + """MQ-136: hydrogen sulfide. No default coefficients - use setRegressionModel().""" + + def __init__(self, analog_pin=None, digital_pin=None, i2c=None, address=0x30): + super().__init__( + REGRESSION_LINEAR, 1.0, 0.0, 0.0, analog_pin, digital_pin, i2c, address + ) + + +class MQ137(MQSensor): + """MQ-137: ammonia. Default coefficients for NH3.""" + + def __init__(self, analog_pin=None, digital_pin=None, i2c=None, address=0x30): + super().__init__( + REGRESSION_LINEAR, 1.0, -0.26406, -0.24143, analog_pin, digital_pin, i2c, address + ) + + +class MQ138(MQSensor): + """MQ-138: VOCs, toluene, acetone. Default coefficients for toluene.""" + + def __init__(self, analog_pin=None, digital_pin=None, i2c=None, address=0x30): + super().__init__( + REGRESSION_LINEAR, 1.0, -0.4434, 0.15397, analog_pin, digital_pin, i2c, address + ) + + +class MQ214(MQSensor): + """MQ-214: methane, natural gas. No default coefficients - use setRegressionModel().""" + + def __init__(self, analog_pin=None, digital_pin=None, i2c=None, address=0x30): + super().__init__( + REGRESSION_LINEAR, 1.0, 0.0, 0.0, analog_pin, digital_pin, i2c, address + ) diff --git a/Sensors/MQSensors/package.json b/Sensors/MQSensors/package.json new file mode 100644 index 0000000..7925478 --- /dev/null +++ b/Sensors/MQSensors/package.json @@ -0,0 +1,126 @@ +{ + "urls": [ + [ + "MQSensor.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/MQSensors/MQSensors/MQSensor.py" + ], + [ + "Examples/MQSensor-mq2I2C.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq2I2C.py" + ], + [ + "Examples/MQSensor-mq3I2C.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq3I2C.py" + ], + [ + "Examples/MQSensor-mq4I2C.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq4I2C.py" + ], + [ + "Examples/MQSensor-mq5I2C.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq5I2C.py" + ], + [ + "Examples/MQSensor-mq6I2C.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq6I2C.py" + ], + [ + "Examples/MQSensor-mq7I2C.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq7I2C.py" + ], + [ + "Examples/MQSensor-mq8I2C.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq8I2C.py" + ], + [ + "Examples/MQSensor-mq9AllI2C.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq9AllI2C.py" + ], + [ + "Examples/MQSensor-mq131I2C.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq131I2C.py" + ], + [ + "Examples/MQSensor-mq135I2C.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq135I2C.py" + ], + [ + "Examples/MQSensor-mq137I2C.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq137I2C.py" + ], + [ + "Examples/MQSensor-mq138I2C.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq138I2C.py" + ], + [ + "Examples/MQSensor-mq138CustomConfigI2C.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq138CustomConfigI2C.py" + ], + [ + "Examples/MQSensor-mq2Native.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq2Native.py" + ], + [ + "Examples/MQSensor-mq3Native.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq3Native.py" + ], + [ + "Examples/MQSensor-mq4Native.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq4Native.py" + ], + [ + "Examples/MQSensor-mq5Native.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq5Native.py" + ], + [ + "Examples/MQSensor-mq6Native.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq6Native.py" + ], + [ + "Examples/MQSensor-mq7Native.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq7Native.py" + ], + [ + "Examples/MQSensor-mq8Native.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq8Native.py" + ], + [ + "Examples/MQSensor-mq9AllNative.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq9AllNative.py" + ], + [ + "Examples/MQSensor-mq131Native.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq131Native.py" + ], + [ + "Examples/MQSensor-mq135Native.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq135Native.py" + ], + [ + "Examples/MQSensor-mq137Native.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq137Native.py" + ], + [ + "Examples/MQSensor-mq138Native.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq138Native.py" + ], + [ + "Examples/MQSensor-mq138CustomConfigNative.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/MQSensors/MQSensors/Examples/MQSensor-mq138CustomConfigNative.py" + ], + [ + "Examples/MQSensor-digitalInputNative.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/MQSensors/MQSensors/Examples/MQSensor-digitalInputNative.py" + ], + [ + "Examples/MQSensor-calibrationNative.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/MQSensors/MQSensors/Examples/MQSensor-calibrationNative.py" + ], + [ + "Qwiic.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Qwiic/Qwiic.py" + ] + ], + "deps": [], + "version": "0.1" +} diff --git a/Sensors/SCD43/SCD43/Examples/AlternateWire.py b/Sensors/SCD43/SCD43/Examples/AlternateWire.py new file mode 100644 index 0000000..bdc130c --- /dev/null +++ b/Sensors/SCD43/SCD43/Examples/AlternateWire.py @@ -0,0 +1,39 @@ +""" +AlternateWire.py - Use the SCD43 on a secondary I2C bus. + +Some microcontrollers expose multiple hardware I2C peripherals. +Pass the desired I2C instance to sensor.begin() to use a bus other +than the default. + +If your board only has one I2C bus, use I2C(0) and adjust the pins. + +Hardware connections: + - Connect the SCD43 breakout to the secondary I2C pins of your board. +""" + +from machine import I2C, Pin +import time +from scd43 import SCD43 + +# Secondary I2C bus — adjust id and pins to match your board +i2c1 = I2C(1, scl=Pin(3), sda=Pin(2)) +sensor = SCD43() + +print("SCD43 - Alternate I2C Bus") + +if not sensor.begin(i2c1): + print("Sensor not found on secondary I2C bus. Check wiring. Halting.") + raise SystemExit + +print("Sensor initialized on secondary I2C bus.") + +while True: + if sensor.readMeasurement(): + print() + print("CO2 (ppm): ", sensor.getCO2()) + print("Temperature (C):", round(sensor.getTemperature(), 1)) + print("Humidity (%RH): ", round(sensor.getHumidity(), 1)) + else: + print(".", end="") + + time.sleep_ms(500) diff --git a/Sensors/SCD43/SCD43/Examples/BasicReadings.py b/Sensors/SCD43/SCD43/Examples/BasicReadings.py new file mode 100644 index 0000000..596cfdc --- /dev/null +++ b/Sensors/SCD43/SCD43/Examples/BasicReadings.py @@ -0,0 +1,35 @@ +""" +BasicReadings.py - Read CO2 concentration, temperature, and relative humidity +from the SCD43 sensor using periodic measurements. + +The SCD43 outputs a new measurement every 5 seconds. +This example polls for fresh data and prints it. + +Hardware connections: + - Connect the SCD43 breakout to your board via the Qwiic / I2C connector. +""" + +from machine import I2C, Pin +import time +from scd43 import SCD43 + +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +sensor = SCD43() + +if not sensor.begin(i2c): + print("Sensor not found. Check wiring. Halting.") + raise SystemExit + +print("SCD43 - Basic Readings") +# The SCD43 produces a new reading every 5 seconds. + +while True: + if sensor.readMeasurement(): # returns True when fresh data is available + print() + print("CO2 (ppm): ", sensor.getCO2()) + print("Temperature (C):", round(sensor.getTemperature(), 1)) + print("Humidity (%RH): ", round(sensor.getHumidity(), 1)) + else: + print(".", end="") + + time.sleep_ms(500) diff --git a/Sensors/SCD43/SCD43/Examples/DisableAutoCalibration.py b/Sensors/SCD43/SCD43/Examples/DisableAutoCalibration.py new file mode 100644 index 0000000..39283dd --- /dev/null +++ b/Sensors/SCD43/SCD43/Examples/DisableAutoCalibration.py @@ -0,0 +1,67 @@ +""" +DisableAutoCalibration.py - Disable the SCD43's automatic self-calibration (ASC). + +ASC is enabled by default and keeps the sensor accurate over time by assuming +periodic exposure to a known CO2 concentration (400 ppm outdoor fresh air by +default). In controlled environments where the sensor is never exposed to fresh +air, ASC should be disabled to prevent drift. + +This example disables ASC, saves the setting to EEPROM so it survives a power +cycle, then resumes periodic measurements. + +Hardware connections: + - Connect the SCD43 breakout to your board via the Qwiic / I2C connector. +""" + +from machine import I2C, Pin +import time +from scd43 import SCD43 + +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +sensor = SCD43() + +print("SCD43 - Disable Auto Calibration") + +if not sensor.begin(i2c): + print("Sensor not found. Check wiring. Halting.") + raise SystemExit + +# Configuration changes require idle mode — stop periodic measurements first. +if not sensor.stopPeriodicMeasurement(): + print("Could not stop periodic measurement. Halting.") + raise SystemExit + +# Check the current ASC state. +print("ASC enabled before change:", "yes" if sensor.getAutomaticSelfCalibrationEnabled() else "no") + +# Disable ASC. +if not sensor.setAutomaticSelfCalibrationEnabled(False): + print("Failed to disable ASC.") +else: + print("ASC disabled successfully.") + +# Persist the setting so it survives a power cycle. +# Avoid calling persistSettings() on every boot — the EEPROM has a limited +# write-cycle endurance (~2000 writes). +if not sensor.persistSettings(): + print("Failed to persist settings.") +else: + print("Settings saved to EEPROM.") + +# Resume periodic measurements. +if not sensor.startPeriodicMeasurement(): + print("Could not restart periodic measurement. Halting.") + raise SystemExit + +print("Periodic measurements restarted.") + +while True: + if sensor.readMeasurement(): + print() + print("CO2 (ppm): ", sensor.getCO2()) + print("Temperature (C):", round(sensor.getTemperature(), 1)) + print("Humidity (%RH): ", round(sensor.getHumidity(), 1)) + else: + print(".", end="") + + time.sleep_ms(500) diff --git a/Sensors/SCD43/SCD43/Examples/LowPowerReadings.py b/Sensors/SCD43/SCD43/Examples/LowPowerReadings.py new file mode 100644 index 0000000..cf6455f --- /dev/null +++ b/Sensors/SCD43/SCD43/Examples/LowPowerReadings.py @@ -0,0 +1,48 @@ +""" +LowPowerReadings.py - Read CO2, temperature, and humidity using the SCD43's +low-power periodic measurement mode. + +In low-power mode the sensor updates approximately every 30 seconds instead +of every 5 seconds, significantly reducing average current consumption — +useful for battery-powered devices. + +Hardware connections: + - Connect the SCD43 breakout to your board via the Qwiic / I2C connector. +""" + +from machine import I2C, Pin +import time +from scd43 import SCD43 + +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +sensor = SCD43() + +print("SCD43 - Low Power Readings") + +if not sensor.begin(i2c): + print("Sensor not found. Check wiring. Halting.") + raise SystemExit + +# begin() starts normal periodic measurement by default. +# Switch to low-power mode: stop normal periodic measurement first, +# then start the low-power variant. +if not sensor.stopPeriodicMeasurement(): + print("Could not stop periodic measurement. Halting.") + raise SystemExit + +if not sensor.startLowPowerPeriodicMeasurement(): + print("Could not start low-power measurement. Halting.") + raise SystemExit + +print("Low-power mode active. New reading every ~30 seconds.") + +while True: + if sensor.readMeasurement(): # returns True when fresh data is available + print() + print("CO2 (ppm): ", sensor.getCO2()) + print("Temperature (C):", round(sensor.getTemperature(), 1)) + print("Humidity (%RH): ", round(sensor.getHumidity(), 1)) + else: + print(".", end="") + + time.sleep_ms(1000) diff --git a/Sensors/SCD43/SCD43/Examples/PersistSettings.py b/Sensors/SCD43/SCD43/Examples/PersistSettings.py new file mode 100644 index 0000000..da3795b --- /dev/null +++ b/Sensors/SCD43/SCD43/Examples/PersistSettings.py @@ -0,0 +1,63 @@ +""" +PersistSettings.py - Save sensor configuration to EEPROM so it survives power cycles. + +By default, settings such as temperature offset, sensor altitude, and ASC +enable/disable state are stored in RAM only and reset to their defaults +whenever the sensor is powered off. Calling persistSettings() writes the +current configuration to the sensor's internal EEPROM. + +The EEPROM is rated for at least 2000 write cycles. Only call persistSettings() +after a configuration change — never in a loop. + +Hardware connections: + - Connect the SCD43 breakout to your board via the Qwiic / I2C connector. +""" + +from machine import I2C, Pin +import time +from scd43 import SCD43 + +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +sensor = SCD43() + +print("SCD43 - Persist Settings") + +if not sensor.begin(i2c): + print("Sensor not found. Check wiring. Halting.") + raise SystemExit + +# Configuration changes require idle mode. +if not sensor.stopPeriodicMeasurement(): + print("Could not stop periodic measurement. Halting.") + raise SystemExit + +# Apply desired configuration. +sensor.setTemperatureOffset(4.0) # degrees Celsius +sensor.setSensorAltitude(0) # metres above sea level +sensor.setAutomaticSelfCalibrationEnabled(True) +sensor.setAutomaticSelfCalibrationTarget(400) # ppm (outdoor fresh air) + +print("Configuration applied.") + +# Write the configuration to EEPROM. +# This call blocks for ~800 ms. +if sensor.persistSettings(): + print("Settings saved to EEPROM. They will persist after power cycle.") +else: + print("Failed to save settings.") + +# Resume periodic measurements. +if not sensor.startPeriodicMeasurement(): + print("Could not restart periodic measurement. Halting.") + raise SystemExit + +while True: + if sensor.readMeasurement(): + print() + print("CO2 (ppm): ", sensor.getCO2()) + print("Temperature (C):", round(sensor.getTemperature(), 1)) + print("Humidity (%RH): ", round(sensor.getHumidity(), 1)) + else: + print(".", end="") + + time.sleep_ms(500) diff --git a/Sensors/SCD43/SCD43/Examples/SelfTestFactoryReset.py b/Sensors/SCD43/SCD43/Examples/SelfTestFactoryReset.py new file mode 100644 index 0000000..9da6249 --- /dev/null +++ b/Sensors/SCD43/SCD43/Examples/SelfTestFactoryReset.py @@ -0,0 +1,53 @@ +""" +SelfTestFactoryReset.py - Run the SCD43's built-in self-test and optionally +perform a factory reset. + +The self-test verifies sensor hardware integrity and is useful as an +end-of-line production test. It takes approximately 10 seconds to complete. + +The factory reset erases all EEPROM settings and calibration history, +returning the sensor to its original factory state. Use it when a sensor +needs to be recalibrated from scratch. It takes approximately 1200 ms. + +Hardware connections: + - Connect the SCD43 breakout to your board via the Qwiic / I2C connector. +""" + +from machine import I2C, Pin +import time +from scd43 import SCD43 + +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +sensor = SCD43() + +print("SCD43 - Self Test and Factory Reset") + +if not sensor.begin(i2c): + print("Sensor not found. Check wiring. Halting.") + raise SystemExit + +# Both commands require idle mode. +if not sensor.stopPeriodicMeasurement(): + print("Could not stop periodic measurement. Halting.") + raise SystemExit + +# --- Self test --- +# This blocks for ~10 seconds. +print("Running self-test (this takes ~10 seconds)...") + +if sensor.performSelfTest(): + print("Self-test passed. Sensor is functioning correctly.") +else: + print("Self-test failed. The sensor may be damaged.") + +# --- Factory reset --- +# Uncomment to erase all settings and calibration data. +# WARNING: this cannot be undone. +# +# print("Performing factory reset (~1200 ms)...") +# if sensor.performFactoryReset(): +# print("Factory reset complete.") +# else: +# print("Factory reset failed.") + +# Nothing further to do. diff --git a/Sensors/SCD43/SCD43/Examples/SensorVariant.py b/Sensors/SCD43/SCD43/Examples/SensorVariant.py new file mode 100644 index 0000000..dcd7d26 --- /dev/null +++ b/Sensors/SCD43/SCD43/Examples/SensorVariant.py @@ -0,0 +1,45 @@ +""" +SensorVariant.py - Read the SCD4x sensor variant identifier and serial number. + +The Sensirion SCD4x family includes the SCD40, SCD41, SCD42, and SCD43. +This example reads the variant code reported by the sensor to confirm which +model is connected, and also prints the unique 48-bit serial number. + +Hardware connections: + - Connect the SCD43 breakout to your board via the Qwiic / I2C connector. +""" + +from machine import I2C, Pin +import time +from scd43 import SCD43, VARIANT_SCD40, VARIANT_SCD41, VARIANT_SCD42, VARIANT_SCD43 + +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +sensor = SCD43() + +print("SCD43 - Sensor Variant and Serial Number") + +if not sensor.begin(i2c): + print("Sensor not found. Check wiring. Halting.") + raise SystemExit + +# Both commands require idle mode — stop periodic measurements first. +if not sensor.stopPeriodicMeasurement(): + print("Could not stop periodic measurement. Halting.") + raise SystemExit + +# Read and display the sensor variant. +variant = sensor.getSensorVariant() +variant_names = { + VARIANT_SCD40: "SCD40", + VARIANT_SCD41: "SCD41", + VARIANT_SCD42: "SCD42", + VARIANT_SCD43: "SCD43", +} +print("Sensor variant:", variant_names.get(variant, "Unknown (0x{:04X})".format(variant))) + +# Read and display the unique serial number. +serial = sensor.getSerialNumber() +if serial is not None: + print("Serial number: 0x{:012X}".format(serial)) +else: + print("Failed to read serial number.") diff --git a/Sensors/SCD43/SCD43/Examples/SignalCompensation.py b/Sensors/SCD43/SCD43/Examples/SignalCompensation.py new file mode 100644 index 0000000..e14c2de --- /dev/null +++ b/Sensors/SCD43/SCD43/Examples/SignalCompensation.py @@ -0,0 +1,78 @@ +""" +SignalCompensation.py - Configure temperature offset, sensor altitude, and +ambient pressure compensation on the SCD43. + +These three settings allow the sensor to compensate for environmental factors +that affect measurement accuracy: + + - Temperature offset: corrects for heat from nearby components. + - Sensor altitude: accounts for lower atmospheric pressure at elevation. + - Ambient pressure: overrides altitude with a live pressure reading for + more precise compensation. + +All three require idle mode; stop periodic measurements before changing them. + +Hardware connections: + - Connect the SCD43 breakout to your board via the Qwiic / I2C connector. +""" + +from machine import I2C, Pin +import time +from scd43 import SCD43 + +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +sensor = SCD43() + +print("SCD43 - Signal Compensation") + +if not sensor.begin(i2c): + print("Sensor not found. Check wiring. Halting.") + raise SystemExit + +# Configuration requires idle mode. +if not sensor.stopPeriodicMeasurement(): + print("Could not stop periodic measurement. Halting.") + raise SystemExit + +# --- Temperature offset --- +print("Temperature offset before:", round(sensor.getTemperatureOffset(), 2), "C") +sensor.setTemperatureOffset(4.0) # default is 4 C; adjust for your enclosure +print("Temperature offset after: ", round(sensor.getTemperatureOffset(), 2), "C") + +# --- Sensor altitude --- +print("Sensor altitude before:", sensor.getSensorAltitude(), "m") +sensor.setSensorAltitude(150) # set to your installation altitude in metres +print("Sensor altitude after: ", sensor.getSensorAltitude(), "m") + +# --- Ambient pressure (overrides altitude-based compensation) --- +# Provide a live pressure reading from a barometer for best accuracy. +if sensor.setAmbientPressure(101300): # 101300 Pa = standard sea-level pressure + print("Ambient pressure set to 101300 Pa.") + +# Save the temperature offset and altitude to EEPROM. +# Ambient pressure is not persisted and must be re-set after each power cycle. +if sensor.persistSettings(): + print("Temperature offset and altitude saved to EEPROM.") + +# Read and print the serial number while we are in idle mode. +serial = sensor.getSerialNumber() +if serial is not None: + print("Sensor serial number: 0x{:012X}".format(serial)) + +# Resume periodic measurements. +if not sensor.startPeriodicMeasurement(): + print("Could not restart periodic measurement. Halting.") + raise SystemExit + +print("Periodic measurements restarted.") + +while True: + if sensor.readMeasurement(): + print() + print("CO2 (ppm): ", sensor.getCO2()) + print("Temperature (C):", round(sensor.getTemperature(), 1)) + print("Humidity (%RH): ", round(sensor.getHumidity(), 1)) + else: + print(".", end="") + + time.sleep_ms(500) diff --git a/Sensors/SCD43/SCD43/Examples/SingleShot.py b/Sensors/SCD43/SCD43/Examples/SingleShot.py new file mode 100644 index 0000000..b1a0211 --- /dev/null +++ b/Sensors/SCD43/SCD43/Examples/SingleShot.py @@ -0,0 +1,68 @@ +""" +SingleShot.py - Take on-demand single-shot measurements with the SCD43. + +In single-shot mode the sensor takes one measurement at a time instead of +running continuous periodic measurements. This mode is useful for +power-constrained applications where the sensor can be put to sleep between +readings. + +Measurement types available: + - measureSingleShot(): CO2 + temperature + humidity (~5 s) + - measureSingleShotRhtOnly(): temperature + humidity only (~50 ms) + +After each measurement the sensor returns to idle mode and can be powered +down with powerDown() / woken with wakeUp(). + +Hardware connections: + - Connect the SCD43 breakout to your board via the Qwiic / I2C connector. +""" + +from machine import I2C, Pin +import time +from scd43 import SCD43 + +i2c = I2C(0, scl=Pin(22), sda=Pin(21)) +sensor = SCD43() + +if not sensor.begin(i2c): + print("Sensor not found. Check wiring. Halting.") + raise SystemExit + +# begin() starts periodic measurements by default. +# Stop them to enter idle / single-shot mode. +if not sensor.stopPeriodicMeasurement(): + print("Could not stop periodic measurement. Halting.") + raise SystemExit + +print("SCD43 - Single Shot Measurements") +print("Sensor in idle mode. Ready for single-shot measurements.") + +while True: + # Full measurement: CO2 + temperature + humidity. Blocks ~5 seconds. + print("\nTriggering full single-shot measurement...") + + if sensor.measureAndReadSingleShot(): + print("CO2 (ppm): ", sensor.getCO2()) + print("Temperature (C):", round(sensor.getTemperature(), 1)) + print("Humidity (%RH): ", round(sensor.getHumidity(), 1)) + else: + print("Full measurement failed.") + + # Fast RH + temperature only measurement. Blocks ~50 ms. + # CO2 value is not updated by this command. + print("\nTriggering RH + temperature only measurement...") + + if sensor.measureSingleShotRhtOnly(): + # Read the result into the internal cache, then retrieve it. + if sensor.readMeasurement(): + print("Temperature (C):", round(sensor.getTemperature(), 1)) + print("Humidity (%RH): ", round(sensor.getHumidity(), 1)) + else: + print("RH-only measurement failed.") + + # Optional: power down the sensor between measurements to save energy. + # sensor.powerDown() + # ... do other work or sleep ... + # sensor.wakeUp() + + time.sleep_ms(2000) diff --git a/Sensors/SCD43/SCD43/scd43.py b/Sensors/SCD43/SCD43/scd43.py new file mode 100644 index 0000000..3c1cb83 --- /dev/null +++ b/Sensors/SCD43/SCD43/scd43.py @@ -0,0 +1,375 @@ +# FILE: scd43.py +# AUTHOR: Josip Šimun Kuči @ Soldered +# BRIEF: MicroPython library for the SCD43 CO2 Sensor +# LAST UPDATED: 2026-05-20 +import time +from machine import I2C + +_I2C_ADDR = 0x62 + +_CMD_START_PERIODIC = 0x21B1 +_CMD_READ_MEAS = 0xEC05 +_CMD_STOP_PERIODIC = 0x3F86 +_CMD_SET_TEMP_OFFSET = 0x241D +_CMD_GET_TEMP_OFFSET = 0x2318 +_CMD_SET_ALTITUDE = 0x2427 +_CMD_GET_ALTITUDE = 0x2322 +_CMD_SET_PRESSURE = 0xE000 +_CMD_SET_ASC_ENABLED = 0x2416 +_CMD_GET_ASC_ENABLED = 0x2313 +_CMD_SET_ASC_TARGET = 0x243A +_CMD_GET_ASC_TARGET = 0x233F +_CMD_START_LP_PERIODIC = 0x21AC +_CMD_DATA_READY = 0xE4B8 +_CMD_PERSIST = 0x3615 +_CMD_SERIAL_NUMBER = 0x3682 +_CMD_SELF_TEST = 0x3639 +_CMD_FACTORY_RESET = 0x3632 +_CMD_REINIT = 0x3646 +_CMD_SENSOR_VARIANT = 0x202F +_CMD_SINGLE_SHOT = 0x219D +_CMD_SINGLE_SHOT_RHT = 0x2196 +_CMD_POWER_DOWN = 0x36E0 +_CMD_WAKE_UP = 0x36F6 + +VARIANT_SCD40 = 0x0000 +VARIANT_SCD41 = 0x1000 +VARIANT_SCD42 = 0x2000 +VARIANT_SCD43 = 0x5000 +VARIANT_UNKNOWN = 0xF000 + + +def _crc8(data): + crc = 0xFF + for byte in data: + crc ^= byte + for _ in range(8): + if crc & 0x80: + crc = ((crc << 1) ^ 0x31) & 0xFF + else: + crc = (crc << 1) & 0xFF + return crc + + +class SCD43: + def __init__(self): + self._i2c = None + self._co2 = 0 + self._temperature = 0.0 + self._humidity = 0.0 + + def begin(self, i2c): + self._i2c = i2c + # Stop any in-progress measurement (error ignored — sensor may be idle) + try: + self._sendCmd(_CMD_STOP_PERIODIC) + time.sleep_ms(500) + except OSError: + pass + # Verify communication by reading serial number + try: + self._sendCmd(_CMD_SERIAL_NUMBER) + time.sleep_ms(1) + self._readWords(3) + except OSError: + return False + return self.startPeriodicMeasurement() + + # ------------------------------------------------------------------------- + # Measurement control + # ------------------------------------------------------------------------- + + def startPeriodicMeasurement(self): + try: + self._sendCmd(_CMD_START_PERIODIC) + return True + except OSError: + return False + + def stopPeriodicMeasurement(self): + try: + self._sendCmd(_CMD_STOP_PERIODIC) + time.sleep_ms(500) + return True + except OSError: + return False + + def startLowPowerPeriodicMeasurement(self): + try: + self._sendCmd(_CMD_START_LP_PERIODIC) + return True + except OSError: + return False + + # ------------------------------------------------------------------------- + # Data acquisition + # ------------------------------------------------------------------------- + + def readMeasurement(self): + if not self.getDataReadyStatus(): + return False + try: + self._sendCmd(_CMD_READ_MEAS) + time.sleep_ms(1) + words = self._readWords(3) + except OSError: + return False + self._co2 = words[0] + self._temperature = -45.0 + (175.0 * words[1] / 65535.0) + self._humidity = 100.0 * words[2] / 65535.0 + return True + + def getDataReadyStatus(self): + try: + self._sendCmd(_CMD_DATA_READY) + time.sleep_ms(1) + words = self._readWords(1) + except OSError: + return False + return (words[0] & 0x07FF) != 0 + + # ------------------------------------------------------------------------- + # Measurement getters + # ------------------------------------------------------------------------- + + def getCO2(self): + return self._co2 + + def getTemperature(self): + return self._temperature + + def getHumidity(self): + return self._humidity + + # ------------------------------------------------------------------------- + # Signal compensation + # ------------------------------------------------------------------------- + + def setTemperatureOffset(self, offsetCelsius): + raw = int(round(offsetCelsius * 65535.0 / 175.0)) + raw = max(0, min(0xFFFF, raw)) + try: + self._sendCmdWithWord(_CMD_SET_TEMP_OFFSET, raw) + time.sleep_ms(1) + return True + except OSError: + return False + + def getTemperatureOffset(self): + try: + self._sendCmd(_CMD_GET_TEMP_OFFSET) + time.sleep_ms(1) + words = self._readWords(1) + except OSError: + return 0.0 + return 175.0 * words[0] / 65535.0 + + def setSensorAltitude(self, altitudeMeters): + try: + self._sendCmdWithWord(_CMD_SET_ALTITUDE, altitudeMeters) + time.sleep_ms(1) + return True + except OSError: + return False + + def getSensorAltitude(self): + try: + self._sendCmd(_CMD_GET_ALTITUDE) + time.sleep_ms(1) + words = self._readWords(1) + except OSError: + return 0 + return words[0] + + def setAmbientPressure(self, pressurePa): + raw = int(round(pressurePa / 100.0)) + raw = max(0, min(0xFFFF, raw)) + try: + self._sendCmdWithWord(_CMD_SET_PRESSURE, raw) + time.sleep_ms(1) + return True + except OSError: + return False + + # ------------------------------------------------------------------------- + # Automatic self-calibration (ASC) + # ------------------------------------------------------------------------- + + def setAutomaticSelfCalibrationEnabled(self, enabled): + try: + self._sendCmdWithWord(_CMD_SET_ASC_ENABLED, 1 if enabled else 0) + time.sleep_ms(1) + return True + except OSError: + return False + + def getAutomaticSelfCalibrationEnabled(self): + try: + self._sendCmd(_CMD_GET_ASC_ENABLED) + time.sleep_ms(1) + words = self._readWords(1) + except OSError: + return False + return words[0] != 0 + + def setAutomaticSelfCalibrationTarget(self, targetPpm): + try: + self._sendCmdWithWord(_CMD_SET_ASC_TARGET, targetPpm) + time.sleep_ms(1) + return True + except OSError: + return False + + def getAutomaticSelfCalibrationTarget(self): + try: + self._sendCmd(_CMD_GET_ASC_TARGET) + time.sleep_ms(1) + words = self._readWords(1) + except OSError: + return 0 + return words[0] + + # ------------------------------------------------------------------------- + # Configuration persistence + # ------------------------------------------------------------------------- + + def persistSettings(self): + try: + self._sendCmd(_CMD_PERSIST) + time.sleep_ms(800) + return True + except OSError: + return False + + # ------------------------------------------------------------------------- + # Device information + # ------------------------------------------------------------------------- + + def getSerialNumber(self): + try: + self._sendCmd(_CMD_SERIAL_NUMBER) + time.sleep_ms(1) + words = self._readWords(3) + except OSError: + return None + return (words[0] << 32) | (words[1] << 16) | words[2] + + # ------------------------------------------------------------------------- + # Self-test and maintenance + # ------------------------------------------------------------------------- + + def performSelfTest(self): + try: + self._sendCmd(_CMD_SELF_TEST) + time.sleep_ms(10000) + words = self._readWords(1) + except OSError: + return False + return words[0] == 0 + + def performFactoryReset(self): + try: + self._sendCmd(_CMD_FACTORY_RESET) + time.sleep_ms(1200) + return True + except OSError: + return False + + def reinit(self): + try: + self._sendCmd(_CMD_REINIT) + time.sleep_ms(30) + return True + except OSError: + return False + + # ------------------------------------------------------------------------- + # Single-shot mode (SCD43) + # ------------------------------------------------------------------------- + + def measureSingleShot(self): + try: + self._sendCmd(_CMD_SINGLE_SHOT) + time.sleep_ms(5000) + return True + except OSError: + return False + + def measureSingleShotRhtOnly(self): + try: + self._sendCmd(_CMD_SINGLE_SHOT_RHT) + time.sleep_ms(50) + return True + except OSError: + return False + + def measureAndReadSingleShot(self): + if not self.measureSingleShot(): + return False + while not self.getDataReadyStatus(): + time.sleep_ms(100) + return self.readMeasurement() + + def powerDown(self): + try: + self._sendCmd(_CMD_POWER_DOWN) + time.sleep_ms(1) + return True + except OSError: + return False + + def wakeUp(self): + # Sensor does not ACK this command — suppress the OSError + try: + self._sendCmd(_CMD_WAKE_UP) + except OSError: + pass + time.sleep_ms(30) + return True + + # ------------------------------------------------------------------------- + # Sensor variant + # ------------------------------------------------------------------------- + + def getSensorVariant(self): + try: + self._sendCmd(_CMD_SENSOR_VARIANT) + time.sleep_ms(1) + words = self._readWords(1) + except OSError: + return VARIANT_UNKNOWN + masked = words[0] & 0xF000 + if masked == VARIANT_SCD40: + return VARIANT_SCD40 + elif masked == VARIANT_SCD41: + return VARIANT_SCD41 + elif masked == VARIANT_SCD42: + return VARIANT_SCD42 + elif masked == VARIANT_SCD43: + return VARIANT_SCD43 + return VARIANT_UNKNOWN + + # ------------------------------------------------------------------------- + # Internal helpers + # ------------------------------------------------------------------------- + + def _sendCmd(self, cmd): + self._i2c.writeto(_I2C_ADDR, bytes([cmd >> 8, cmd & 0xFF])) + + def _sendCmdWithWord(self, cmd, word): + hi = (word >> 8) & 0xFF + lo = word & 0xFF + crc = _crc8([hi, lo]) + self._i2c.writeto(_I2C_ADDR, bytes([cmd >> 8, cmd & 0xFF, hi, lo, crc])) + + def _readWords(self, count): + data = self._i2c.readfrom(_I2C_ADDR, count * 3) + words = [] + for i in range(count): + hi = data[i * 3] + lo = data[i * 3 + 1] + crc = data[i * 3 + 2] + if _crc8([hi, lo]) != crc: + raise OSError("CRC mismatch") + words.append((hi << 8) | lo) + return words diff --git a/Sensors/SCD43/package.json b/Sensors/SCD43/package.json new file mode 100644 index 0000000..b77c3be --- /dev/null +++ b/Sensors/SCD43/package.json @@ -0,0 +1,46 @@ +{ + "urls": [ + [ + "scd43.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/SCD43/SCD43/scd43.py" + ], + [ + "Examples/BasicReadings.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/SCD43/SCD43/Examples/BasicReadings.py" + ], + [ + "Examples/AlternateWire.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/SCD43/SCD43/Examples/AlternateWire.py" + ], + [ + "Examples/DisableAutoCalibration.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/SCD43/SCD43/Examples/DisableAutoCalibration.py" + ], + [ + "Examples/LowPowerReadings.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/SCD43/SCD43/Examples/LowPowerReadings.py" + ], + [ + "Examples/PersistSettings.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/SCD43/SCD43/Examples/PersistSettings.py" + ], + [ + "Examples/SelfTestFactoryReset.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/SCD43/SCD43/Examples/SelfTestFactoryReset.py" + ], + [ + "Examples/SensorVariant.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/SCD43/SCD43/Examples/SensorVariant.py" + ], + [ + "Examples/SignalCompensation.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/SCD43/SCD43/Examples/SignalCompensation.py" + ], + [ + "Examples/SingleShot.py", + "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/SCD43/SCD43/Examples/SingleShot.py" + ] + ], + "deps": [], + "version": "1.0" +} diff --git a/Sensors/SimpleSensor/README.md b/Sensors/SimpleSensor/README.md new file mode 100644 index 0000000..10c4be6 --- /dev/null +++ b/Sensors/SimpleSensor/README.md @@ -0,0 +1,15 @@ +# How to install + +--- + +After [**installing the mpremote package**](https://docs.micropython.org/en/latest/reference/mpremote.html): + +```sh + mpremote mip install github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/SimpleSensor +``` + +Or Windows: + +```sh + python -m mpremote mip install github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/SimpleSensor +``` diff --git a/Sensors/SimpleSensor/SimpleSensor/Examples/SimpleSensor-fireI2C.py b/Sensors/SimpleSensor/SimpleSensor/Examples/SimpleSensor-fireI2C.py new file mode 100644 index 0000000..718e959 --- /dev/null +++ b/Sensors/SimpleSensor/SimpleSensor/Examples/SimpleSensor-fireI2C.py @@ -0,0 +1,32 @@ +# FILE: SimpleSensor-fireI2C.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Simple Fire Sensor example using easyC (I2C) mode +# WORKS WITH: Simple Fire Sensor easyC: www.solde.red/333013 +# LAST UPDATED: 2026-05-25 + +from SimpleFireSensor import SimpleFireSensor +import time + +# Initialize sensor in easyC mode +# I2C auto-detected on pins 21 (SDA) / 22 (SCL) on ESP32 +# For a different I2C address (0x30-0x37), pass address parameter: +# sensor = SimpleFireSensor(address=0x31) +sensor = SimpleFireSensor() + +# Optional: adjust detection threshold (default 50%) +# sensor.setThreshold(40.0) + +# Optional: invert the onboard LED behavior +# sensor.invertLED(True) + +# Calibration: expose sensor to a flame at the detection distance you want as 100%, +# note the getValue() reading, then call calibrate() with that value +# sensor.calibrate(80.0) + +while True: + raw = sensor.getRawReading() + value = sensor.getValue() + fire = sensor.isFireDetected() + + print("Raw: {:4d} Fire%: {:6.2f}% Fire detected: {}".format(raw, value, fire)) + time.sleep(1) diff --git a/Sensors/SimpleSensor/SimpleSensor/Examples/SimpleSensor-fireNative.py b/Sensors/SimpleSensor/SimpleSensor/Examples/SimpleSensor-fireNative.py new file mode 100644 index 0000000..ebf27ea --- /dev/null +++ b/Sensors/SimpleSensor/SimpleSensor/Examples/SimpleSensor-fireNative.py @@ -0,0 +1,31 @@ +# FILE: SimpleSensor-fireNative.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Simple Fire Sensor example using native GPIO/ADC mode +# WORKS WITH: Simple Fire Sensor: www.solde.red/333013 +# LAST UPDATED: 2026-05-25 + +from SimpleFireSensor import SimpleFireSensor +import time + +# Initialize sensor in native mode +# analog_pin: connect sensor AO pin to an ADC-capable GPIO +# digital_pin: connect sensor DO pin to any GPIO +sensor = SimpleFireSensor(analog_pin=34, digital_pin=35) + +# Optional: adjust threshold percentage (default 50%) +# Threshold only affects getValue()-based detection, not the digital pin +# sensor.setThreshold(40.0) + +# Calibration: hold flame at max detection range, note the getValue() reading, +# then call calibrate() with that value to rescale readings to 0-100% +# sensor.calibrate(80.0) + +while True: + raw = sensor.getRawReading() + resistance = sensor.getResistance() + value = sensor.getValue() + fire = sensor.isFireDetected() + + print("Raw: {:4d} Resistance: {:8.1f} Ohm Fire%: {:6.2f}% Fire detected: {}".format( + raw, resistance, value, fire)) + time.sleep(1) diff --git a/Sensors/SimpleSensor/SimpleSensor/Examples/SimpleSensor-lightI2C.py b/Sensors/SimpleSensor/SimpleSensor/Examples/SimpleSensor-lightI2C.py new file mode 100644 index 0000000..9adfe0e --- /dev/null +++ b/Sensors/SimpleSensor/SimpleSensor/Examples/SimpleSensor-lightI2C.py @@ -0,0 +1,32 @@ +# FILE: SimpleSensor-lightI2C.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Simple Light Sensor example using easyC (I2C) mode +# WORKS WITH: Simple Light Sensor easyC: www.solde.red/333012 +# LAST UPDATED: 2026-05-25 + +from SimpleLightSensor import SimpleLightSensor +import time + +# Initialize sensor in easyC mode +# I2C auto-detected on pins 21 (SDA) / 22 (SCL) on ESP32 +# For a different I2C address (0x30-0x37), pass address parameter: +# sensor = SimpleLightSensor(address=0x31) +sensor = SimpleLightSensor() + +# Optional: adjust detection threshold (default 50%) +# sensor.setThreshold(30.0) + +# Optional: invert the onboard LED behavior +# sensor.invertLED(True) + +# Calibration: expose sensor to the maximum light level you want as 100%, +# note the getValue() reading, then call calibrate() with that value +# sensor.calibrate(90.0) + +while True: + raw = sensor.getRawReading() + value = sensor.getValue() + light = sensor.isLightDetected() + + print("Raw: {:4d} Light%: {:6.2f}% Light detected: {}".format(raw, value, light)) + time.sleep(1) diff --git a/Sensors/SimpleSensor/SimpleSensor/Examples/SimpleSensor-lightNative.py b/Sensors/SimpleSensor/SimpleSensor/Examples/SimpleSensor-lightNative.py new file mode 100644 index 0000000..11b3701 --- /dev/null +++ b/Sensors/SimpleSensor/SimpleSensor/Examples/SimpleSensor-lightNative.py @@ -0,0 +1,31 @@ +# FILE: SimpleSensor-lightNative.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Simple Light Sensor example using native GPIO/ADC mode +# WORKS WITH: Simple Light Sensor: www.solde.red/333012 +# LAST UPDATED: 2026-05-25 + +from SimpleLightSensor import SimpleLightSensor +import time + +# Initialize sensor in native mode +# analog_pin: connect sensor AO pin to an ADC-capable GPIO +# digital_pin: connect sensor DO pin to any GPIO +sensor = SimpleLightSensor(analog_pin=34, digital_pin=35) + +# Optional: adjust threshold percentage (default 50%) +# Threshold only affects getValue()-based detection, not the digital pin +# sensor.setThreshold(30.0) + +# Calibration: expose sensor to maximum light level, note the getValue() reading, +# then call calibrate() with that value to rescale readings to 0-100% +# sensor.calibrate(90.0) + +while True: + raw = sensor.getRawReading() + resistance = sensor.getResistance() + value = sensor.getValue() + light = sensor.isLightDetected() + + print("Raw: {:4d} Resistance: {:8.1f} Ohm Light%: {:6.2f}% Light detected: {}".format( + raw, resistance, value, light)) + time.sleep(1) diff --git a/Sensors/SimpleSensor/SimpleSensor/Examples/SimpleSensor-rainI2C.py b/Sensors/SimpleSensor/SimpleSensor/Examples/SimpleSensor-rainI2C.py new file mode 100644 index 0000000..551155f --- /dev/null +++ b/Sensors/SimpleSensor/SimpleSensor/Examples/SimpleSensor-rainI2C.py @@ -0,0 +1,32 @@ +# FILE: SimpleSensor-rainI2C.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Simple Rain Sensor example using easyC (I2C) mode +# WORKS WITH: Simple Rain Sensor easyC: www.solde.red/333010 +# LAST UPDATED: 2026-05-25 + +from SimpleRainSensor import SimpleRainSensor +import time + +# Initialize sensor in easyC mode +# I2C auto-detected on pins 21 (SDA) / 22 (SCL) on ESP32 +# For a different I2C address (0x30-0x37), pass address parameter: +# sensor = SimpleRainSensor(address=0x31) +sensor = SimpleRainSensor() + +# Optional: adjust detection threshold (default 50%) +# sensor.setThreshold(60.0) + +# Optional: invert the onboard LED behavior +# sensor.invertLED(True) + +# Calibration: submerge sensor completely in water, +# note the getValue() reading, then call calibrate() with that value +# sensor.calibrate(85.0) + +while True: + raw = sensor.getRawReading() + value = sensor.getValue() + raining = sensor.isRaining() + + print("Raw: {:4d} Rain%: {:6.2f}% Raining: {}".format(raw, value, raining)) + time.sleep(1) diff --git a/Sensors/SimpleSensor/SimpleSensor/Examples/SimpleSensor-rainNative.py b/Sensors/SimpleSensor/SimpleSensor/Examples/SimpleSensor-rainNative.py new file mode 100644 index 0000000..745a593 --- /dev/null +++ b/Sensors/SimpleSensor/SimpleSensor/Examples/SimpleSensor-rainNative.py @@ -0,0 +1,31 @@ +# FILE: SimpleSensor-rainNative.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Simple Rain Sensor example using native GPIO/ADC mode +# WORKS WITH: Simple Rain Sensor: www.solde.red/333010 +# LAST UPDATED: 2026-05-25 + +from SimpleRainSensor import SimpleRainSensor +import time + +# Initialize sensor in native mode +# analog_pin: connect sensor AO pin to an ADC-capable GPIO +# digital_pin: connect sensor DO pin to any GPIO +sensor = SimpleRainSensor(analog_pin=34, digital_pin=35) + +# Optional: adjust threshold percentage (default 50%) +# Threshold only affects getValue()-based detection, not the digital pin +# sensor.setThreshold(60.0) + +# Calibration: submerge sensor in water, note the getValue() reading, +# then call calibrate() with that value to rescale readings to 0-100% +# sensor.calibrate(85.0) + +while True: + raw = sensor.getRawReading() + resistance = sensor.getResistance() + value = sensor.getValue() + raining = sensor.isRaining() + + print("Raw: {:4d} Resistance: {:8.1f} Ohm Rain%: {:6.2f}% Raining: {}".format( + raw, resistance, value, raining)) + time.sleep(1) diff --git a/Sensors/SimpleSensor/SimpleSensor/Examples/SimpleSensor-soilI2C.py b/Sensors/SimpleSensor/SimpleSensor/Examples/SimpleSensor-soilI2C.py new file mode 100644 index 0000000..e79fde4 --- /dev/null +++ b/Sensors/SimpleSensor/SimpleSensor/Examples/SimpleSensor-soilI2C.py @@ -0,0 +1,32 @@ +# FILE: SimpleSensor-soilI2C.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Simple Soil Humidity Sensor example using easyC (I2C) mode +# WORKS WITH: Simple Soil Humidity Sensor easyC: www.solde.red/333011 +# LAST UPDATED: 2026-05-25 + +from SimpleSoilSensor import SimpleSoilSensor +import time + +# Initialize sensor in easyC mode +# I2C auto-detected on pins 21 (SDA) / 22 (SCL) on ESP32 +# For a different I2C address (0x30-0x37), pass address parameter: +# sensor = SimpleSoilSensor(address=0x31) +sensor = SimpleSoilSensor() + +# Optional: adjust detection threshold (default 50%) +# sensor.setThreshold(40.0) + +# Optional: invert the onboard LED behavior +# sensor.invertLED(True) + +# Calibration: insert sensor completely into moist soil or water, +# note the getValue() reading, then call calibrate() with that value +# sensor.calibrate(85.0) + +while True: + raw = sensor.getRawReading() + value = sensor.getValue() + moist = sensor.isMoist() + + print("Raw: {:4d} Moisture%: {:6.2f}% Moist: {}".format(raw, value, moist)) + time.sleep(1) diff --git a/Sensors/SimpleSensor/SimpleSensor/Examples/SimpleSensor-soilNative.py b/Sensors/SimpleSensor/SimpleSensor/Examples/SimpleSensor-soilNative.py new file mode 100644 index 0000000..60d5f4d --- /dev/null +++ b/Sensors/SimpleSensor/SimpleSensor/Examples/SimpleSensor-soilNative.py @@ -0,0 +1,31 @@ +# FILE: SimpleSensor-soilNative.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Simple Soil Humidity Sensor example using native GPIO/ADC mode +# WORKS WITH: Simple Soil Humidity Sensor: www.solde.red/333011 +# LAST UPDATED: 2026-05-25 + +from SimpleSoilSensor import SimpleSoilSensor +import time + +# Initialize sensor in native mode +# analog_pin: connect sensor AO pin to an ADC-capable GPIO +# digital_pin: connect sensor DO pin to any GPIO +sensor = SimpleSoilSensor(analog_pin=34, digital_pin=35) + +# Optional: adjust threshold percentage (default 50%) +# Threshold only affects getValue()-based detection, not the digital pin +# sensor.setThreshold(40.0) + +# Calibration: insert sensor into moist soil, note the getValue() reading, +# then call calibrate() with that value to rescale readings to 0-100% +# sensor.calibrate(85.0) + +while True: + raw = sensor.getRawReading() + resistance = sensor.getResistance() + value = sensor.getValue() + moist = sensor.isMoist() + + print("Raw: {:4d} Resistance: {:8.1f} Ohm Moisture%: {:6.2f}% Moist: {}".format( + raw, resistance, value, moist)) + time.sleep(1) diff --git a/Sensors/SimpleSensor/SimpleSensor/SimpleFireSensor.py b/Sensors/SimpleSensor/SimpleSensor/SimpleFireSensor.py new file mode 100644 index 0000000..8d44ead --- /dev/null +++ b/Sensors/SimpleSensor/SimpleSensor/SimpleFireSensor.py @@ -0,0 +1,40 @@ +# FILE: SimpleFireSensor.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: MicroPython library for Soldered Simple Fire Sensor +# LAST UPDATED: 2026-05-25 + +from SimpleSensor import SimpleSensor +from SimpleSensor import SIMPLE_SENSOR_I2C_ADDR + + +class SimpleFireSensor(SimpleSensor): + """ + MicroPython class for Soldered Simple Fire/Flame Sensor. + Detects infrared radiation from flames using a photodiode. + Works in native mode (ADC + digital pin) or easyC I2C mode. + """ + + def __init__(self, analog_pin=None, digital_pin=None, i2c=None, address=SIMPLE_SENSOR_I2C_ADDR): + """ + Initialize the fire sensor. + Pass analog_pin for native mode, or leave empty for easyC I2C mode. + + :param analog_pin: GPIO pin number for analog input, native mode (optional) + :param digital_pin: GPIO pin number for digital input, native mode (optional) + :param i2c: Initialized I2C object for easyC mode (optional, auto-detected on ESP32) + :param address: I2C address for easyC mode, default 0x30, selectable 0x30-0x37 + """ + super().__init__(analog_pin=analog_pin, digital_pin=digital_pin, i2c=i2c, address=address) + + def isFireDetected(self) -> bool: + """ + Check if fire or flame is detected above threshold. + In native mode with digital pin: reads digital output (active low). + In native mode without digital pin: compares getValue() against threshold. + In easyC mode: compares getValue() against threshold. + + :returns: bool, True if fire detected + """ + if self.native and self._digitalPin is not None: + return not self._digitalPin.value() + return self.getValue() > self._threshold diff --git a/Sensors/SimpleSensor/SimpleSensor/SimpleLightSensor.py b/Sensors/SimpleSensor/SimpleSensor/SimpleLightSensor.py new file mode 100644 index 0000000..0c1a083 --- /dev/null +++ b/Sensors/SimpleSensor/SimpleSensor/SimpleLightSensor.py @@ -0,0 +1,40 @@ +# FILE: SimpleLightSensor.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: MicroPython library for Soldered Simple Light Sensor +# LAST UPDATED: 2026-05-25 + +from SimpleSensor import SimpleSensor +from SimpleSensor import SIMPLE_SENSOR_I2C_ADDR + + +class SimpleLightSensor(SimpleSensor): + """ + MicroPython class for Soldered Simple Light Sensor. + Detects ambient light level using a photoresistor (LDR). + Works in native mode (ADC + digital pin) or easyC I2C mode. + """ + + def __init__(self, analog_pin=None, digital_pin=None, i2c=None, address=SIMPLE_SENSOR_I2C_ADDR): + """ + Initialize the light sensor. + Pass analog_pin for native mode, or leave empty for easyC I2C mode. + + :param analog_pin: GPIO pin number for analog input, native mode (optional) + :param digital_pin: GPIO pin number for digital input, native mode (optional) + :param i2c: Initialized I2C object for easyC mode (optional, auto-detected on ESP32) + :param address: I2C address for easyC mode, default 0x30, selectable 0x30-0x37 + """ + super().__init__(analog_pin=analog_pin, digital_pin=digital_pin, i2c=i2c, address=address) + + def isLightDetected(self) -> bool: + """ + Check if light is detected above threshold. + In native mode with digital pin: reads digital output (active low). + In native mode without digital pin: compares getValue() against threshold. + In easyC mode: compares getValue() against threshold. + + :returns: bool, True if light detected + """ + if self.native and self._digitalPin is not None: + return not self._digitalPin.value() + return self.getValue() > self._threshold diff --git a/Sensors/SimpleSensor/SimpleSensor/SimpleRainSensor.py b/Sensors/SimpleSensor/SimpleSensor/SimpleRainSensor.py new file mode 100644 index 0000000..999c277 --- /dev/null +++ b/Sensors/SimpleSensor/SimpleSensor/SimpleRainSensor.py @@ -0,0 +1,40 @@ +# FILE: SimpleRainSensor.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: MicroPython library for Soldered Simple Rain Sensor +# LAST UPDATED: 2026-05-25 + +from SimpleSensor import SimpleSensor +from SimpleSensor import SIMPLE_SENSOR_I2C_ADDR + + +class SimpleRainSensor(SimpleSensor): + """ + MicroPython class for Soldered Simple Rain Sensor. + Detects rain and moisture using resistive sensing. + Works in native mode (ADC + digital pin) or easyC I2C mode. + """ + + def __init__(self, analog_pin=None, digital_pin=None, i2c=None, address=SIMPLE_SENSOR_I2C_ADDR): + """ + Initialize the rain sensor. + Pass analog_pin for native mode, or leave empty for easyC I2C mode. + + :param analog_pin: GPIO pin number for analog input, native mode (optional) + :param digital_pin: GPIO pin number for digital input, native mode (optional) + :param i2c: Initialized I2C object for easyC mode (optional, auto-detected on ESP32) + :param address: I2C address for easyC mode, default 0x30, selectable 0x30-0x37 + """ + super().__init__(analog_pin=analog_pin, digital_pin=digital_pin, i2c=i2c, address=address) + + def isRaining(self) -> bool: + """ + Check if rain is currently detected. + In native mode with digital pin: reads digital output (active low). + In native mode without digital pin: compares getValue() against threshold. + In easyC mode: compares getValue() against threshold. + + :returns: bool, True if rain detected + """ + if self.native and self._digitalPin is not None: + return not self._digitalPin.value() + return self.getValue() > self._threshold diff --git a/Sensors/SimpleSensor/SimpleSensor/SimpleSensor.py b/Sensors/SimpleSensor/SimpleSensor/SimpleSensor.py new file mode 100644 index 0000000..f75b0f6 --- /dev/null +++ b/Sensors/SimpleSensor/SimpleSensor/SimpleSensor.py @@ -0,0 +1,207 @@ +# FILE: SimpleSensor.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: MicroPython base class for Soldered Simple Sensor series (rain, soil, light, fire) +# LAST UPDATED: 2026-05-25 + +from machine import ADC, Pin, I2C +from os import uname + +# I2C Addresses (selectable via onboard switches, 0x30-0x37) +SIMPLE_SENSOR_I2C_ADDR = 0x30 + +# ADC Resolution +ADC_MAX_ESP32 = 4095 # ESP32 12-bit ADC +ADC_MAX_OTHER = 1023 # 10-bit ADC (also ATtiny on easyC board) + +# Pull-up resistor value in ohms (used for resistance calculation) +RES = 10000 + +# Default detection threshold percentage +DEFAULT_THRESHOLD = 50.0 + +# easyC I2C command bytes sent to onboard ATtiny firmware +CMD_SET_THRESHOLD = 0x01 +CMD_SET_LED = 0x02 + + +class SimpleSensor: + """ + MicroPython base class for Soldered Simple Sensor series. + Supports resistive sensors: rain, soil moisture, light, fire. + Works in native mode (ADC + digital pin) or easyC I2C mode. + """ + + def __init__(self, analog_pin=None, digital_pin=None, i2c=None, address=SIMPLE_SENSOR_I2C_ADDR): + """ + Initialize the sensor. + Pass analog_pin for native mode, or leave empty for easyC I2C mode. + + :param analog_pin: GPIO pin number for analog input, native mode (optional) + :param digital_pin: GPIO pin number for digital input, native mode (optional) + :param i2c: Initialized I2C object for easyC mode (optional, auto-detected on ESP32) + :param address: I2C address for easyC mode, default 0x30, selectable 0x30-0x37 + """ + if analog_pin is not None: + # Native mode - use ADC and optional digital pin directly + self.native = True + self._analogPin = ADC(Pin(analog_pin)) + if uname().sysname in ("esp32", "Soldered Dasduino CONNECTPLUS"): + self._analogPin.atten(ADC.ATTN_11DB) + self._adcMax = ADC_MAX_ESP32 + else: + self._adcMax = ADC_MAX_OTHER + if digital_pin is not None: + self._digitalPin = Pin(digital_pin, Pin.IN) + else: + self._digitalPin = None + else: + # easyC / I2C mode - sensor board has onboard ATtiny with 10-bit ADC + self.native = False + self._adcMax = ADC_MAX_OTHER + self._digitalPin = None + if i2c is not None: + self.i2c = i2c + else: + if uname().sysname in ("esp32", "esp8266", "Soldered Dasduino CONNECTPLUS"): + self.i2c = I2C(0, scl=Pin(22), sda=Pin(21)) + else: + raise Exception("Board not recognized, enter I2C pins manually") + self.address = address + + self._highPercentage = 100.0 + self._threshold = DEFAULT_THRESHOLD + self._rawThreshold = int((1.0 - DEFAULT_THRESHOLD / 100.0) * self._adcMax) + self._ledInverted = False + + def getRawReading(self) -> int: + """ + Get raw ADC reading from sensor. + + :returns: int, raw ADC value (0 to ADC_MAX) + """ + if self.native: + return self._analogPin.read() + else: + try: + data = self.i2c.readfrom(self.address, 2) + return (data[1] << 8) | data[0] + except OSError as e: + raise Exception("I2C read error: {}".format(e)) + + def getResistance(self) -> float: + """ + Calculate sensor resistance in ohms using 10kOhm pull-up divider formula. + Note: meaningful in native mode only, assumes pull-up resistor circuit. + + :returns: float, resistance in ohms + """ + raw = self.getRawReading() + if raw >= self._adcMax: + return float("inf") + if raw == 0: + return 0.0 + return RES * (raw / float(self._adcMax - raw)) + + def getValue(self) -> float: + """ + Get sensor reading as percentage (0.0-100.0%). + Higher value means more detected: more rain, more moisture, more light, more heat. + + :returns: float, percentage 0.0-100.0 + """ + raw = self.getRawReading() + pct = (self._adcMax - raw) / float(self._adcMax) * 100.0 + return min(100.0, pct / self._highPercentage * 100.0) + + def setThreshold(self, threshold: float) -> bool: + """ + Set detection threshold as percentage. + + :param threshold: float, threshold percentage 0.0-100.0 + :returns: bool, True on success, False if out of range + """ + if threshold < 0.0 or threshold > 100.0: + return False + self._threshold = threshold + self._rawThreshold = int((1.0 - threshold / 100.0) * self._adcMax) + if not self.native: + try: + raw = self._rawThreshold + self.i2c.writeto(self.address, bytes([CMD_SET_THRESHOLD, (raw >> 8) & 0xFF, raw & 0xFF])) + except OSError as e: + raise Exception("I2C write error: {}".format(e)) + return True + + def getThreshold(self) -> float: + """ + Get current detection threshold as percentage. + + :returns: float, threshold percentage + """ + return self._threshold + + def setRawThreshold(self, raw_value: int) -> bool: + """ + Set detection threshold as raw ADC value. + + :param raw_value: int, raw ADC threshold 0 to ADC_MAX + :returns: bool, True on success, False if out of range + """ + if raw_value < 0 or raw_value > self._adcMax: + return False + self._rawThreshold = raw_value + self._threshold = (1.0 - raw_value / float(self._adcMax)) * 100.0 + if not self.native: + try: + self.i2c.writeto( + self.address, + bytes([CMD_SET_THRESHOLD, (raw_value >> 8) & 0xFF, raw_value & 0xFF]), + ) + except OSError as e: + raise Exception("I2C write error: {}".format(e)) + return True + + def getRawThreshold(self) -> int: + """ + Get current detection threshold as raw ADC value. + + :returns: int, raw ADC threshold + """ + return self._rawThreshold + + def invertLED(self, invert: bool) -> None: + """ + Invert onboard LED behavior. + Default: LED turns on when reading exceeds threshold. + Inverted: LED turns off when reading exceeds threshold. + Note: Only effective in easyC I2C mode, LED is controlled by onboard ATtiny. + + :param invert: bool, True to invert LED behavior + """ + self._ledInverted = invert + if not self.native: + try: + self.i2c.writeto(self.address, bytes([CMD_SET_LED, 1 if invert else 0])) + except OSError as e: + raise Exception("I2C write error: {}".format(e)) + + def calibrate(self, high_percentage: float) -> None: + """ + Calibrate the sensor maximum reading. + Place sensor in maximum-stimulus condition (fully wet, full light, etc.), + read getValue(), then pass that value here to rescale to 100%. + + :param high_percentage: float, current getValue() reading to map to 100% + """ + self._highPercentage = high_percentage + self._rawThreshold = int( + (1.0 - self._threshold / 100.0) * self._adcMax * (self._highPercentage / 100.0) + ) + + def getDigitalPin(self): + """ + Get the digital Pin object (native mode only). + + :returns: machine.Pin object or None + """ + return self._digitalPin diff --git a/Sensors/SimpleSensor/SimpleSensor/SimpleSoilSensor.py b/Sensors/SimpleSensor/SimpleSensor/SimpleSoilSensor.py new file mode 100644 index 0000000..88c6220 --- /dev/null +++ b/Sensors/SimpleSensor/SimpleSensor/SimpleSoilSensor.py @@ -0,0 +1,40 @@ +# FILE: SimpleSoilSensor.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: MicroPython library for Soldered Simple Soil Humidity Sensor +# LAST UPDATED: 2026-05-25 + +from SimpleSensor import SimpleSensor +from SimpleSensor import SIMPLE_SENSOR_I2C_ADDR + + +class SimpleSoilSensor(SimpleSensor): + """ + MicroPython class for Soldered Simple Soil Humidity Sensor. + Detects soil moisture level using resistive sensing. + Works in native mode (ADC + digital pin) or easyC I2C mode. + """ + + def __init__(self, analog_pin=None, digital_pin=None, i2c=None, address=SIMPLE_SENSOR_I2C_ADDR): + """ + Initialize the soil humidity sensor. + Pass analog_pin for native mode, or leave empty for easyC I2C mode. + + :param analog_pin: GPIO pin number for analog input, native mode (optional) + :param digital_pin: GPIO pin number for digital input, native mode (optional) + :param i2c: Initialized I2C object for easyC mode (optional, auto-detected on ESP32) + :param address: I2C address for easyC mode, default 0x30, selectable 0x30-0x37 + """ + super().__init__(analog_pin=analog_pin, digital_pin=digital_pin, i2c=i2c, address=address) + + def isMoist(self) -> bool: + """ + Check if soil moisture is detected above threshold. + In native mode with digital pin: reads digital output (active low). + In native mode without digital pin: compares getValue() against threshold. + In easyC mode: compares getValue() against threshold. + + :returns: bool, True if moisture detected + """ + if self.native and self._digitalPin is not None: + return not self._digitalPin.value() + return self.getValue() > self._threshold diff --git a/Sensors/SimpleSensor/package.json b/Sensors/SimpleSensor/package.json new file mode 100644 index 0000000..3f1b18c --- /dev/null +++ b/Sensors/SimpleSensor/package.json @@ -0,0 +1,19 @@ +{ + "urls": [ + ["SimpleSensor.py", "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/SimpleSensor/SimpleSensor/SimpleSensor.py"], + ["SimpleRainSensor.py", "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/SimpleSensor/SimpleSensor/SimpleRainSensor.py"], + ["SimpleSoilSensor.py", "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/SimpleSensor/SimpleSensor/SimpleSoilSensor.py"], + ["SimpleLightSensor.py", "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/SimpleSensor/SimpleSensor/SimpleLightSensor.py"], + ["SimpleFireSensor.py", "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/SimpleSensor/SimpleSensor/SimpleFireSensor.py"], + ["Examples/SimpleSensor-rainI2C.py", "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/SimpleSensor/SimpleSensor/Examples/SimpleSensor-rainI2C.py"], + ["Examples/SimpleSensor-rainNative.py", "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/SimpleSensor/SimpleSensor/Examples/SimpleSensor-rainNative.py"], + ["Examples/SimpleSensor-soilI2C.py", "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/SimpleSensor/SimpleSensor/Examples/SimpleSensor-soilI2C.py"], + ["Examples/SimpleSensor-soilNative.py", "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/SimpleSensor/SimpleSensor/Examples/SimpleSensor-soilNative.py"], + ["Examples/SimpleSensor-lightI2C.py", "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/SimpleSensor/SimpleSensor/Examples/SimpleSensor-lightI2C.py"], + ["Examples/SimpleSensor-lightNative.py", "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/SimpleSensor/SimpleSensor/Examples/SimpleSensor-lightNative.py"], + ["Examples/SimpleSensor-fireI2C.py", "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/SimpleSensor/SimpleSensor/Examples/SimpleSensor-fireI2C.py"], + ["Examples/SimpleSensor-fireNative.py", "github:SolderedElectronics/Soldered-MicroPython-Modules/Sensors/SimpleSensor/SimpleSensor/Examples/SimpleSensor-fireNative.py"] + ], + "deps": [], + "version": "1.0" +} diff --git a/Sensors/SliderPotentiometer/SliderPotentiometer/Examples/SliderPotentiometer-readValue.py b/Sensors/SliderPotentiometer/SliderPotentiometer/Examples/SliderPotentiometer-readValue.py new file mode 100644 index 0000000..38120c7 --- /dev/null +++ b/Sensors/SliderPotentiometer/SliderPotentiometer/Examples/SliderPotentiometer-readValue.py @@ -0,0 +1,19 @@ +# FILE: SliderPotentiometer-readValue.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Reads the value of potentiometer. +# WORKS WITH: SliderPotentiometer : www.solde.red/333130 +# LAST UPDATED: 2026-04-30 + +import time +from SliderPotentiometer import AnalogSliderPotentiometer + +# Change to your wiring +slider = AnalogSliderPotentiometer(pin=34) + +while True: + print("Raw value of slider potentiometer:", slider.get_value()) + print("Minimum value of slider potentiometer:", slider.min_value()) + print("Maximum value of slider potentiometer:", slider.max_value()) + print("Percent value of slider potentiometer:", slider.get_percentage()) + print() + time.sleep(1) \ No newline at end of file diff --git a/Sensors/SliderPotentiometer/SliderPotentiometer/Examples/SliderPotentiometer-readValueQwiic.py b/Sensors/SliderPotentiometer/SliderPotentiometer/Examples/SliderPotentiometer-readValueQwiic.py new file mode 100644 index 0000000..d864b76 --- /dev/null +++ b/Sensors/SliderPotentiometer/SliderPotentiometer/Examples/SliderPotentiometer-readValueQwiic.py @@ -0,0 +1,18 @@ +# FILE: SliderPotentiometer-readValueQwiic.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: Reads the value of potentiometer. +# WORKS WITH: SliderPotentiometer with Qwiic : www.solde.red/333131 +# LAST UPDATED: 2026-04-30 + +import time +from SliderPotentiometer import QwiicSliderPotentiometer + +slider = QwiicSliderPotentiometer() + +while True: + print("Raw value of slider potentiometer:", slider.get_value()) + print("Minimum value of slider potentiometer:", slider.min_value()) + print("Maximum value of slider potentiometer:", slider.max_value()) + print("Percent value of slider potentiometer:", slider.get_percentage()) + print() + time.sleep(1) \ No newline at end of file diff --git a/Sensors/SliderPotentiometer/SliderPotentiometer/SliderPotentiometer.py b/Sensors/SliderPotentiometer/SliderPotentiometer/SliderPotentiometer.py new file mode 100644 index 0000000..4f7b3a0 --- /dev/null +++ b/Sensors/SliderPotentiometer/SliderPotentiometer/SliderPotentiometer.py @@ -0,0 +1,137 @@ +# FILE: SliderPotentiometer.py +# AUTHOR: Fran Fodor @ Soldered +# BRIEF: MicroPython library for the Slider Potentiometer with Qwiic. +# LAST UPDATED: 2026-04-30 + +from machine import ADC, Pin, I2C +from os import uname +import struct + + +class SliderPotentiometer: + """ + Base class for slider potentiometer implementations. + Provides a common interface for analog and Qwiic variants. + """ + + def get_value(self): + """ + Read the raw value of the potentiometer. + + :return: Raw potentiometer reading + """ + raise NotImplementedError + + def min_value(self): + """ + Get the minimum possible potentiometer reading. + + :return: Minimum value (always 0) + """ + return 0 + + def max_value(self): + """ + Get the maximum possible potentiometer reading. + + :return: Maximum value + """ + raise NotImplementedError + + def get_percentage(self): + """ + Get the current potentiometer position as a percentage. + + :return: Position as integer percentage (0-100) + """ + return int(100 * (self.get_value() / self.max_value())) + + +class AnalogSliderPotentiometer(SliderPotentiometer): + """ + Slider potentiometer read via onboard ADC pin. + """ + + def __init__(self, pin): + """ + Initialize the analog slider potentiometer. + + :param pin: GPIO pin number connected to the potentiometer wiper + :param calibrated_min: Raw ADC value at physical minimum (default 0) + :param calibrated_max: Raw ADC value at physical maximum (default 65535); + tune this down if the slider saturates before full travel + """ + self._adc = ADC(Pin(pin)) + + def get_value(self): + """ + Read the raw 16-bit ADC value. + + :return: Raw ADC reading (0-65535) + """ + return self._adc.read_u16() + + def max_value(self): + """ + Get the calibrated maximum value. + + :return: Calibrated maximum ADC reading + """ + return 65536 + + def get_percentage(self): + """ + Get the current potentiometer position as a percentage, clamped to calibrated range. + + :return: Position as integer percentage (0-100) + """ + raw = self.get_value() + clamped = max(self.min_value, min(self.max_value, raw)) + return int(100 * (clamped - self.min_value) / self.max_value) + + +class QwiicSliderPotentiometer(SliderPotentiometer): + """ + Slider potentiometer read via Qwiic (I2C) interface. + Compatible with the Soldered easyC slider potentiometer breakout. + + Default I2C address: 0x30 + """ + + _ANALOG_READ_REG = 0x00 + _DEFAULT_ADDRESS = 0x30 + + def __init__(self, i2c=None, address=_DEFAULT_ADDRESS): + """ + Initialize the Qwiic slider potentiometer. + + :param i2c: Initialized I2C object (optional, auto-detected on known boards) + :param address: I2C address of the device (default 0x51) + """ + if i2c is not None: + self.i2c = i2c + else: + if uname().sysname in ("esp32", "esp8266", "Soldered Dasduino CONNECTPLUS"): + self.i2c = I2C(0, scl=Pin(22), sda=Pin(21)) + else: + raise Exception("Board not recognized, enter I2C pins manually") + + self.address = address + + def get_value(self): + """ + Read the raw potentiometer value over Qwiic. + + :return: Raw potentiometer reading (0-1024) + """ + self._i2c.writeto(self._address, bytes([self._ANALOG_READ_REG])) + raw = self._i2c.readfrom(self._address, 2) + return struct.unpack_from('