|
| 1 | +# SPDX-FileCopyrightText: 2025 John Park for Adafruit Industries |
| 2 | +# |
| 3 | +# SPDX-License-Identifier: MIT |
| 4 | +''' |
| 5 | +Espresso Tank Meter |
| 6 | +Feather ESP32-S2 with RCWL-1601 Ultrasonic distance sensor |
| 7 | +''' |
| 8 | + |
| 9 | +import time |
| 10 | +import os |
| 11 | +import ssl |
| 12 | +import microcontroller |
| 13 | +import supervisor |
| 14 | +import socketpool |
| 15 | +import wifi |
| 16 | +import board |
| 17 | +import alarm |
| 18 | +import neopixel |
| 19 | +import adafruit_hcsr04 |
| 20 | +import adafruit_minimqtt.adafruit_minimqtt as MQTT |
| 21 | +from adafruit_io.adafruit_io import IO_MQTT |
| 22 | +import adafruit_requests |
| 23 | +import adafruit_max1704x |
| 24 | + |
| 25 | +# Initialize the sonar sensor |
| 26 | +sonar = adafruit_hcsr04.HCSR04(trigger_pin=board.A0, echo_pin=board.A1) |
| 27 | + |
| 28 | +# Initialize the battery monitor |
| 29 | +i2c = board.I2C() # uses board.SCL and board.SDA |
| 30 | +battery_monitor = adafruit_max1704x.MAX17048(i2c) |
| 31 | + |
| 32 | +# Define colors (hex values) |
| 33 | +WHITE = 0xFFFFFF |
| 34 | +BLUE = 0x0000FF |
| 35 | +GREEN = 0x00FF00 |
| 36 | +YELLOW = 0xFFFF00 |
| 37 | +RED = 0xFF0000 |
| 38 | +PINK = 0xbb00bb |
| 39 | +CYAN = 0x00bbbb |
| 40 | +OFF = 0x000000 |
| 41 | + |
| 42 | +# Initialize the NeoPixel |
| 43 | +pixel = neopixel.NeoPixel(board.NEOPIXEL, 1, brightness=0.25) |
| 44 | +# Show yellow on startup |
| 45 | +pixel.fill(YELLOW) |
| 46 | + |
| 47 | +# Operating hours (24-hour format with minutes, e.g., "6:35" and "16:00") |
| 48 | +OPENING_TIME = "6:00" |
| 49 | +CLOSING_TIME = "22:30" |
| 50 | +# Normal operation check interval |
| 51 | +NORMAL_CHECK_MINUTES = 5 |
| 52 | +# Sleep duration in seconds during operating hours |
| 53 | +SLEEP_DURATION = 60 * NORMAL_CHECK_MINUTES |
| 54 | +# Display duration in seconds |
| 55 | +DISPLAY_DURATION = 1 |
| 56 | +# Number of samples to average |
| 57 | +NUM_SAMPLES = 5 |
| 58 | + |
| 59 | +def parse_time(time_str): |
| 60 | + """Convert time string (HH:MM format) to hours and minutes.""" |
| 61 | + # pylint: disable=redefined-outer-name |
| 62 | + parts = time_str.split(':') |
| 63 | + return int(parts[0]), int(parts[1]) |
| 64 | + |
| 65 | +def get_average_distance(): |
| 66 | + """Take multiple distance readings and return the average.""" |
| 67 | + distances = [] |
| 68 | + for _ in range(NUM_SAMPLES): |
| 69 | + try: |
| 70 | + distance = sonar.distance |
| 71 | + distances.append(distance) |
| 72 | + time.sleep(0.1) # Short delay between readings |
| 73 | + except RuntimeError: |
| 74 | + print("Error reading distance") |
| 75 | + continue |
| 76 | + |
| 77 | + # Only average valid readings |
| 78 | + if distances: |
| 79 | + return sum(distances) / len(distances) |
| 80 | + return None |
| 81 | + |
| 82 | +def set_pixel_color(distance): |
| 83 | + """Set NeoPixel color based on distance.""" |
| 84 | + if distance is None: |
| 85 | + pixel.fill(OFF) |
| 86 | + return |
| 87 | + |
| 88 | + if distance < 2: |
| 89 | + pixel.fill(WHITE) |
| 90 | + elif 2 <= distance < 10: |
| 91 | + pixel.fill(BLUE) |
| 92 | + elif 10 <= distance < 16: |
| 93 | + pixel.fill(GREEN) |
| 94 | + elif 18 <= distance < 20: |
| 95 | + pixel.fill(YELLOW) |
| 96 | + else: # distance >= 22 |
| 97 | + pixel.fill(RED) |
| 98 | + |
| 99 | +# Wait for things to settle before reading sonar |
| 100 | +time.sleep(0.1) |
| 101 | + |
| 102 | +# Get average distance |
| 103 | +avg_distance = get_average_distance() |
| 104 | + |
| 105 | +if avg_distance is not None: |
| 106 | + |
| 107 | + if avg_distance >= 22: |
| 108 | + # pylint: disable=invalid-name |
| 109 | + avg_distance = 22 |
| 110 | + print(f"Average distance: {avg_distance:.1f} cm") |
| 111 | + # Set color based on average distance |
| 112 | + set_pixel_color(avg_distance) |
| 113 | + |
| 114 | + # Check battery status |
| 115 | + battery_voltage = battery_monitor.cell_voltage |
| 116 | + battery_percent = battery_monitor.cell_percent |
| 117 | + print(f"Battery: {battery_percent:.1f}% ({battery_voltage:.2f}V)") |
| 118 | + |
| 119 | + # Try connecting to WiFi |
| 120 | + try: |
| 121 | + |
| 122 | + print("Connecting to %s" % os.getenv("CIRCUITPY_WIFI_SSID")) |
| 123 | + # Show pink while attempting to connect |
| 124 | + pixel.fill(PINK) |
| 125 | + wifi.radio.connect(os.getenv("CIRCUITPY_WIFI_SSID"), os.getenv("CIRCUITPY_WIFI_PASSWORD")) |
| 126 | + print("Connected to %s" % os.getenv("CIRCUITPY_WIFI_SSID")) |
| 127 | + # Show cyan on successful connection |
| 128 | + pixel.fill(CYAN) |
| 129 | + time.sleep(1) # Brief pause to show the connection success |
| 130 | + # pylint: disable=broad-except |
| 131 | + except Exception as e: |
| 132 | + print("Failed to connect to WiFi. Error:", e, "\nBoard will hard reset in 30 seconds.") |
| 133 | + pixel.fill(OFF) |
| 134 | + time.sleep(10) |
| 135 | + microcontroller.reset() |
| 136 | + |
| 137 | + # Create a socket pool |
| 138 | + pool = socketpool.SocketPool(wifi.radio) |
| 139 | + requests = adafruit_requests.Session(pool, ssl.create_default_context()) |
| 140 | + |
| 141 | + # Initialize a new MQTT Client object |
| 142 | + mqtt_client = MQTT.MQTT( |
| 143 | + broker="io.adafruit.com", |
| 144 | + username=os.getenv("ADAFRUIT_AIO_USERNAME"), |
| 145 | + password=os.getenv("ADAFRUIT_AIO_KEY"), |
| 146 | + socket_pool=pool, |
| 147 | + ssl_context=ssl.create_default_context(), |
| 148 | + ) |
| 149 | + |
| 150 | + # Initialize Adafruit IO MQTT "helper" |
| 151 | + io = IO_MQTT(mqtt_client) |
| 152 | + |
| 153 | + try: |
| 154 | + # If Adafruit IO is not connected... |
| 155 | + if not io.is_connected: |
| 156 | + print("Connecting to Adafruit IO...") |
| 157 | + io.connect() |
| 158 | + |
| 159 | + # Get current time from AIO time service |
| 160 | + aio_username = os.getenv("ADAFRUIT_AIO_USERNAME") |
| 161 | + aio_key = os.getenv("ADAFRUIT_AIO_KEY") |
| 162 | + timezone = os.getenv("TIMEZONE") |
| 163 | + # pylint: disable=line-too-long |
| 164 | + TIME_URL = f"https://io.adafruit.com/api/v2/{aio_username}/integrations/time/strftime?x-aio-key={aio_key}&tz={timezone}" |
| 165 | + TIME_URL += "&fmt=%25Y-%25m-%25d+%25H%3A%25M%3A%25S.%25L+%25j+%25u+%25z+%25Z" |
| 166 | + |
| 167 | + print("Getting time from Adafruit IO...") |
| 168 | + response = requests.get(TIME_URL) |
| 169 | + time_str = response.text.strip() # Remove any leading/trailing whitespace |
| 170 | + print("Current time:", time_str) |
| 171 | + |
| 172 | + # Parse the current time from the time string |
| 173 | + time_parts = time_str.split() |
| 174 | + current_time = time_parts[1].split(':') |
| 175 | + current_hour = int(current_time[0]) |
| 176 | + current_minute = int(current_time[1]) |
| 177 | + |
| 178 | + # Get opening and closing hours and minutes |
| 179 | + opening_hour, opening_minute = parse_time(OPENING_TIME) |
| 180 | + closing_hour, closing_minute = parse_time(CLOSING_TIME) |
| 181 | + |
| 182 | + # Convert all times to minutes for easier comparison |
| 183 | + current_minutes = current_hour * 60 + current_minute |
| 184 | + opening_minutes = opening_hour * 60 + opening_minute |
| 185 | + closing_minutes = closing_hour * 60 + closing_minute |
| 186 | + |
| 187 | + # Check if we're within operating hours |
| 188 | + if opening_minutes <= current_minutes < closing_minutes: |
| 189 | + print(f"Within operating hours ({OPENING_TIME} to {CLOSING_TIME}), proceeding with measurement") |
| 190 | + |
| 191 | + # Explicitly pump the message loop |
| 192 | + io.loop() |
| 193 | + |
| 194 | + # Send the distance data |
| 195 | + print(f"Publishing {avg_distance:.1f} to espresso water level feed") |
| 196 | + io.publish("espresso-water-tank-level", f"{avg_distance:.1f}") |
| 197 | + |
| 198 | + # Send the battery data |
| 199 | + print(f"Publishing {battery_percent:.1f} to battery level feed") |
| 200 | + io.publish("espresso-water-sensor-battery", f"{battery_percent:.1f}") |
| 201 | + |
| 202 | + |
| 203 | + # Make sure the message gets sent |
| 204 | + io.loop() |
| 205 | + |
| 206 | + print("Water level sent successfully") |
| 207 | + |
| 208 | + # Keep NeoPixel lit for DISPLAY_DURATION seconds |
| 209 | + time.sleep(DISPLAY_DURATION) |
| 210 | + |
| 211 | + # Use normal check interval during operating hours |
| 212 | + # # pylint: disable=invalid-name |
| 213 | + sleep_seconds = SLEEP_DURATION |
| 214 | + print(f"Next check in {NORMAL_CHECK_MINUTES} minutes") |
| 215 | + else: |
| 216 | + print(f"Outside operating hours ({OPENING_TIME} to {CLOSING_TIME}), going back to sleep") |
| 217 | + # Calculate time until next opening |
| 218 | + if current_minutes >= closing_minutes: |
| 219 | + # After closing, calculate time until opening tomorrow |
| 220 | + minutes_until_open = (24 * 60 - current_minutes) + opening_minutes |
| 221 | + else: |
| 222 | + # Before opening, calculate time until opening today |
| 223 | + minutes_until_open = opening_minutes - current_minutes |
| 224 | + |
| 225 | + # Convert minutes to seconds for sleep duration |
| 226 | + sleep_seconds = minutes_until_open * 60 |
| 227 | + hours_until_open = minutes_until_open // 60 |
| 228 | + minutes_remaining = minutes_until_open % 60 |
| 229 | + if minutes_remaining: |
| 230 | + print(f"Sleeping until {OPENING_TIME} ({hours_until_open} hours, {minutes_remaining} minutes)") |
| 231 | + else: |
| 232 | + print(f"Sleeping until {OPENING_TIME} ({hours_until_open} hours)") |
| 233 | + |
| 234 | + response.close() |
| 235 | + |
| 236 | + # pylint: disable=broad-except |
| 237 | + except Exception as e: |
| 238 | + print("Failed to get or send data, or connect. Error:", e, |
| 239 | + "\nBoard will hard reset in 30 seconds.") |
| 240 | + pixel.fill(OFF) |
| 241 | + time.sleep(30) |
| 242 | + microcontroller.reset() |
| 243 | + |
| 244 | +else: |
| 245 | + print("Failed to get valid distance readings") |
| 246 | + pixel.fill(OFF) |
| 247 | + # pylint: disable=invalid-name |
| 248 | + sleep_seconds = SLEEP_DURATION # Use normal interval if we couldn't get readings |
| 249 | + |
| 250 | +# Prepare for deep sleep |
| 251 | +pixel.brightness = 0 # Turn off NeoPixel |
| 252 | + |
| 253 | +# Flush the serial output before sleep |
| 254 | +# pylint: disable=pointless-statement |
| 255 | +supervisor.runtime.serial_bytes_available |
| 256 | +time.sleep(0.05) |
| 257 | + |
| 258 | +# Create time alarm |
| 259 | +time_alarm = alarm.time.TimeAlarm(monotonic_time=time.monotonic() + sleep_seconds) |
| 260 | + |
| 261 | +# Enter deep sleep |
| 262 | +alarm.exit_and_deep_sleep_until_alarms(time_alarm) |
0 commit comments