commit 9d032a29d26229748d616774ddf8050bc37e72c2 Author: Mike Cifelli Date: Mon Oct 23 15:43:36 2023 -0400 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..084ac1e --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.vscode/ +*.swp diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c939536 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Mike Cifelli + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..68be770 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# aquifer + +A Raspberry Pi Pico W project for marshalling a car into a garage. diff --git a/install-on-device-fs b/install-on-device-fs new file mode 100755 index 0000000..387f748 --- /dev/null +++ b/install-on-device-fs @@ -0,0 +1,54 @@ +#!/usr/bin/env bash + +DEVICES=$(mpremote connect list | grep MicroPython | cut -d " " -f 1) + +if [ -z $DEVICES ] ; then + echo "No MicroPython devices found in FS mode" + exit 1 +fi + +DEVICE=${DEVICES[0]} + +echo "Copying firmware files to ${DEVICE}" + +function create_directory { + echo -n "> creating directory $1" + + RESULT=$(mpremote connect ${DEVICE} mkdir $1) + ERROR=$? + + + if [ $ERROR -eq 0 ] ; then + echo " .. done!" + else + if [[ "$RESULT" == *"EEXIST"* ]] ; then + echo " .. already exists, skipping." + else + echo " .. failed!" + echo "! it looks like this device is already in use - is Thonny running?" + exit 1 + fi + fi +} + +function copy { + for file in $1 + do + echo -n "> copying file $file" + mpremote connect ${DEVICE} cp $file $2 > /dev/null + if [ $? -eq 0 ] ; then + echo " .. done!" + else + echo " .. failed!" + fi + done +} + + +create_directory net +create_directory sensors +create_directory www + +copy "main.py" : +copy "net/*.py" :net/ +copy "www/*" :www/ diff --git a/main.py b/main.py new file mode 100644 index 0000000..f5811e5 --- /dev/null +++ b/main.py @@ -0,0 +1,139 @@ +import time + +from machine import Pin +from machine import ADC +from net import logging +from net import ntp +from net import Server +from net import templates +from net import util +from net.config import config + + +class Marshaller(Server): + + def __init__(self): + self.switch = Pin(8, Pin.IN, Pin.PULL_UP) + self.red_led = Pin(11, Pin.OUT) + self.blue_led = Pin(13, Pin.OUT) + self.green_led = Pin(20, Pin.OUT) + self.yellow_led = Pin(18, Pin.OUT) + self.v = Pin(27, Pin.OUT) + self.a = ADC(2) + self.last_value = -1 + + self.v.on() + + super().__init__() + + self.last_http_activation_ticks = time.ticks_ms() + self.http_activation_interval_in_seconds = 2 * 60 + self.is_http_activation = False + self.ntp_interval_in_seconds = 3 * 60 * 60 + self.ntp_ticks = time.ticks_ms() + self.distance_interval_in_milliseconds = 50 + self.distance_ticks = time.ticks_ms() + self.isWaterPresent = False + + ntp.sync() + + def work(self): + ticks = time.ticks_ms() + + if self.is_scanning(): + self.v.on() + if util.millisecondsElapsed(ticks, self.distance_ticks) > self.distance_interval_in_milliseconds: + self.distance_ticks = ticks + self.show_color() + + if self.is_http_activation and util.secondsElapsed(ticks, self.last_http_activation_ticks) > self.http_activation_interval_in_seconds: + self.is_http_activation = False + else: + self.v.off() + self.leds_off() + super().work() + + if util.secondsElapsed(ticks, self.ntp_ticks) > self.ntp_interval_in_seconds: + self.ntp_ticks = ticks + ntp.sync() + + def is_scanning(self): + return self.switch.value() == 0 or self.is_http_activation + + def get_buffered_distance_in_inches(self): + distance = self.get_distance_in_inches() + + if abs(distance - self.last_value) > 0.25: + self.last_value = distance + return distance + + return self.last_value + + # TODO - don't convert distances to inches, convert thresholds in the opposite direction + def get_distance_in_inches(self): + return self.a.read_u16() / 65535 * 1024 * 5 * 0.03937008 + + def handlePath(self, path): + if (path == 'on'): + return self.http_activation() + + return self.getPathData(path) + + def getPathData(self, path): + if path.endswith('.txt') or path.endswith('.ico'): + with open(f'www/{path}', 'rb') as f: + return f.read() + + return templates.render( + f'www/{path or self.default_path}', + hostname=config['hostname'], + datetime=util.datetime(), + is_active=self.is_scanning() + ) + + def http_activation(self): + self.is_http_activation = True + self.last_http_activation_ticks = time.ticks_ms() + + # todo - no content status + return "" + + def show_color(self): + distance_in_inches = self.get_buffered_distance_in_inches() + + if distance_in_inches < 15: + self.leds_off() + self.red_led.on() + elif distance_in_inches < 20: + self.leds_off() + self.green_led.on() + self.yellow_led.on() + elif distance_in_inches < 25: + self.leds_off() + self.green_led.on() + elif distance_in_inches < 40: + self.leds_off() + self.yellow_led.on() + else: + self.leds_off() + self.blue_led.on() + + def leds_off(self): + self.red_led.off() + self.blue_led.off() + self.green_led.off() + self.yellow_led.off() + + def cleanup(self): + self.v.off() + self.leds_off() + super().cleanup() + + +def main(): + logging.log_file = 'www/log.txt' + Marshaller().run() + + +if __name__ == '__main__': + main() diff --git a/net/__init__.py b/net/__init__.py new file mode 100644 index 0000000..b7f2cf5 --- /dev/null +++ b/net/__init__.py @@ -0,0 +1 @@ +from .server import Server diff --git a/net/config.sample.py b/net/config.sample.py new file mode 100644 index 0000000..e3a09e2 --- /dev/null +++ b/net/config.sample.py @@ -0,0 +1,8 @@ +config = { + 'hostname': '', +} + +secrets = { + 'ssid': 'ssid', + 'password': 'password' +} diff --git a/net/http.py b/net/http.py new file mode 100644 index 0000000..15edf03 --- /dev/null +++ b/net/http.py @@ -0,0 +1,6 @@ +okResponse = 'HTTP/1.1 200 OK\r\ncontent-type: text/html\r\n\r\n'.encode('ascii') +okTextResponse = 'HTTP/1.1 200 OK\r\ncontent-type: text/plain\r\n\r\n'.encode('ascii') +okJsonResponse = 'HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\r\n'.encode('ascii') +okIconResponse = 'HTTP/1.1 200 OK\r\ncontent-type: image/x-icon\r\n\r\n'.encode('ascii') +notFoundResponse = 'HTTP/1.1 404 Not Found\r\n\r\n'.encode('ascii') +serverErrorResponse = 'HTTP/1.1 500 Internal Server Error\r\n\r\n'.encode('ascii') diff --git a/net/logging.py b/net/logging.py new file mode 100644 index 0000000..675683d --- /dev/null +++ b/net/logging.py @@ -0,0 +1,127 @@ +import machine +import os +import gc + +from . import util + +log_file = 'log.txt' + +LOG_INFO = 0b00001 +LOG_WARNING = 0b00010 +LOG_ERROR = 0b00100 +LOG_DEBUG = 0b01000 +LOG_EXCEPTION = 0b10000 +LOG_ALL = LOG_INFO | LOG_WARNING | LOG_ERROR | LOG_DEBUG | LOG_EXCEPTION + +_logging_types = LOG_ALL + +# the log file will be truncated if it exceeds _log_truncate_at bytes in +# size. the defaults values are designed to limit the log to at most +# three blocks on the Pico +_log_truncate_at = 11 * 1024 +_log_truncate_to = 8 * 1024 + + +def file_size(file): + try: + return os.stat(file)[6] + except OSError: + return None + + +def set_truncate_thresholds(truncate_at, truncate_to): + global _log_truncate_at + global _log_truncate_to + _log_truncate_at = truncate_at + _log_truncate_to = truncate_to + + +def enable_logging_types(types): + global _logging_types + _logging_types = _logging_types | types + + +def disable_logging_types(types): + global _logging_types + _logging_types = _logging_types & ~types + + +# truncates the log file down to a target size while maintaining +# clean line breaks +def truncate(file, target_size): + # get the current size of the log file + size = file_size(file) + + # calculate how many bytes we're aiming to discard + discard = size - target_size + if discard <= 0: + return + + with open(file, 'rb') as infile: + with open(file + '.tmp', 'wb') as outfile: + # skip a bunch of the input file until we've discarded + # at least enough + while discard > 0: + chunk = infile.read(1024) + discard -= len(chunk) + + # try to find a line break nearby to split first chunk on + break_position = max( + chunk.find(b'\n', -discard), # search forward + chunk.rfind(b'\n', 0, -discard) # search backwards + ) + if break_position != -1: # if we found a line break.. + outfile.write(chunk[break_position + 1:]) + + # now copy the rest of the file + while True: + chunk = infile.read(1024) + if not chunk: + break + outfile.write(chunk) + + # delete the old file and replace with the new + os.remove(file) + os.rename(file + '.tmp', file) + + +def log(level, text): + log_entry = '{0} [{1:8} /{2:>4}kB] {3}'.format( + util.datetime(), + level, + round(gc.mem_free() / 1024), + text + ) + + print(log_entry) + + with open(log_file, 'a') as logfile: + logfile.write(log_entry + '\n') + + if _log_truncate_at and file_size(log_file) > _log_truncate_at: + truncate(log_file, _log_truncate_to) + + +def info(*items): + if _logging_types & LOG_INFO: + log('info', ' '.join(map(str, items))) + + +def warn(*items): + if _logging_types & LOG_WARNING: + log('warning', ' '.join(map(str, items))) + + +def error(*items): + if _logging_types & LOG_ERROR: + log('error', ' '.join(map(str, items))) + + +def debug(*items): + if _logging_types & LOG_DEBUG: + log('debug', ' '.join(map(str, items))) + + +def exception(*items): + if _logging_types & LOG_EXCEPTION: + log('exception', ' '.join(map(str, items))) diff --git a/net/ntp.py b/net/ntp.py new file mode 100644 index 0000000..c154309 --- /dev/null +++ b/net/ntp.py @@ -0,0 +1,41 @@ +import machine +import struct +import time +import usocket + +from . import logging +from . import util + + +def sync(): + if fetch(timeout=3): + logging.info(f'time updated to {util.datetime()}') + else: + logging.error(f'failed to update time') + + +def fetch(synch_with_rtc=True, timeout=10): + ntp_host = 'time.cifelli.xyz' + + timestamp = None + try: + query = bytearray(48) + query[0] = 0x1b + address = usocket.getaddrinfo(ntp_host, 123)[0][-1] + socket = usocket.socket(usocket.AF_INET, usocket.SOCK_DGRAM) + socket.settimeout(timeout) + socket.sendto(query, address) + data = socket.recv(48) + socket.close() + local_epoch = 2208988800 + timestamp = struct.unpack("!I", data[40:44])[0] - local_epoch + timestamp = time.gmtime(timestamp) + except Exception as e: + return None + + if synch_with_rtc: + machine.RTC().datetime(( + timestamp[0], timestamp[1], timestamp[2], timestamp[6], + timestamp[3], timestamp[4], timestamp[5], 0)) + + return timestamp diff --git a/net/server.py b/net/server.py new file mode 100644 index 0000000..acdca36 --- /dev/null +++ b/net/server.py @@ -0,0 +1,99 @@ +import io +import select +import socket +import sys + +from . import logging +from . import wifi +from . import http + + +class Server: + + def __init__(self): + self.wlan = wifi.connect() + self.socket = socket.socket() + self.poller = select.poll() + self.default_path = 'index.html' + + def cleanup(self): + self.socket.close() + self.wlan.disconnect() + + def run(self): + try: + addr = self.listen() + self.poller.register(self.socket, select.POLLIN) + + logging.info(f'listening on {addr}') + + while True: + try: + self.serve() + self.work() + except Exception as e: + self.logException(e) + finally: + self.cleanup() + + def logException(self, e): + buf = io.StringIO() + sys.print_exception(e, buf) + logging.debug(f'exception:', buf.getvalue()) + + def listen(self): + addr = socket.getaddrinfo('0.0.0.0', 80)[0][-1] + self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.socket.bind(addr) + self.socket.listen(1) + + return addr + + def serve(self): + evts = self.poller.poll(500) + + for sock, _evt in evts: + try: + conn, addr = sock.accept() + logging.info(f'client connected from {addr}') + request = conn.recv(1024).decode('utf-8').strip() + + self.handleRequest(conn, request) + except: + conn.write(http.serverErrorResponse) + raise + finally: + conn.close() + + def handleRequest(self, conn, request): + [method, path, _protocol] = request.partition('\n')[0].split() + + logging.info(f'{method} {path}') + + try: + if method == 'GET': + response = self.handlePath(path.strip('/')) + + conn.write(self.getPathContentType(path)) + conn.write(response) + else: + conn.write(http.notFoundResponse) + except OSError: + conn.write(http.notFoundResponse) + + def getPathContentType(self, path): + if path.endswith('.txt'): + return http.okTextResponse + elif path.endswith('.json'): + return http.okJsonResponse + elif path.endswith('.ico'): + return http.okIconResponse + + return http.okResponse + + def handlePath(self, _path): + return '' + + def work(self): + if not self.wlan.isconnected(): + self.wlan = wifi.connect() diff --git a/net/templates.py b/net/templates.py new file mode 100644 index 0000000..f2be3d5 --- /dev/null +++ b/net/templates.py @@ -0,0 +1,41 @@ +def render(template, **kwargs): + [startString, endString] = ['{{', '}}'] + [startLength, endLength] = [len(startString), len(endString)] + + with open(template) as f: + data = f.read() + tokenCaret = 0 + result = '' + isRendering = True + + while isRendering: + start = data.find(startString, tokenCaret) + end = data.find(endString, start) + + isRendering = start != -1 and end != -1 + + if isRendering: + token = data[start + startLength:end].strip() + + result = ( + result + + data[tokenCaret:start] + + replaceToken(token, kwargs) + ) + + tokenCaret = end + endLength + else: + result = result + data[tokenCaret:] + + return result + + +def replaceToken(token, values): + result = str(values[token]) if token in values else '' + result = result.replace('&', '&') + result = result.replace('"', '"') + result = result.replace("'", ''') + result = result.replace('>', '>') + result = result.replace('<', '<') + + return result diff --git a/net/util.py b/net/util.py new file mode 100644 index 0000000..4170548 --- /dev/null +++ b/net/util.py @@ -0,0 +1,21 @@ +import machine +import time + + +def datetime(): + dt = machine.RTC().datetime() + + return '{0:04d}-{1:02d}-{2:02d} {4:02d}:{5:02d}:{6:02d} UTC'.format(*dt) + + +def datetimeISO8601(): + dt = machine.RTC().datetime() + + return '{0:04d}-{1:02d}-{2:02d}T{4:02d}:{5:02d}:{6:02d}Z'.format(*dt) + + +def secondsElapsed(ticks1, ticks2): + return time.ticks_diff(ticks1, ticks2) / 1000 + +def millisecondsElapsed(ticks1, ticks2): + return time.ticks_diff(ticks1, ticks2) diff --git a/net/wifi.py b/net/wifi.py new file mode 100644 index 0000000..3eccfca --- /dev/null +++ b/net/wifi.py @@ -0,0 +1,49 @@ +import network +import rp2 +import time + +from . import logging +from .config import secrets + + +class WifiConnectionError(RuntimeError): + pass + + +def connect(): + while True: + try: + return connectToWifi() + except WifiConnectionError as e: + logging.error(e.value) + time.sleep(180) + + +def connectToWifi(): + rp2.country('US') + + wlan = network.WLAN(network.STA_IF) + wlan.active(True) + wlan.config(pm=0xa11140) + wlan.connect(secrets['ssid'], secrets['password']) + + wait_for_connection(wlan) + + logging.info('connected') + logging.info(f'ip = {wlan.ifconfig()[0]}') + + return wlan + + +def wait_for_connection(wlan): + maxWait = 10 + + while maxWait > 0: + if wlan.status() < 0 or wlan.status() >= 3: + break + maxWait -= 1 + logging.info('waiting for connection...') + time.sleep(1) + + if wlan.status() != 3: + raise WifiConnectionError('network connection failed') diff --git a/www/favicon.ico b/www/favicon.ico new file mode 100644 index 0000000..1d4aa63 Binary files /dev/null and b/www/favicon.ico differ diff --git a/www/index.html b/www/index.html new file mode 100644 index 0000000..8171ee1 --- /dev/null +++ b/www/index.html @@ -0,0 +1,22 @@ + + + + {{hostname}} + + + + +

{{hostname}}

+

{{datetime}}

+

Active: {{is_active}}

+ + +