Initial commit

This commit is contained in:
Mike Cifelli 2023-10-23 15:43:36 -04:00
commit 9d032a29d2
Signed by: mike
GPG Key ID: 6B08C6BE47D08E4C
16 changed files with 634 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.vscode/
*.swp

21
LICENSE Normal file
View File

@ -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.

3
README.md Normal file
View File

@ -0,0 +1,3 @@
# aquifer
A Raspberry Pi Pico W project for marshalling a car into a garage.

54
install-on-device-fs Executable file
View File

@ -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/

139
main.py Normal file
View File

@ -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()

1
net/__init__.py Normal file
View File

@ -0,0 +1 @@
from .server import Server

8
net/config.sample.py Normal file
View File

@ -0,0 +1,8 @@
config = {
'hostname': '',
}
secrets = {
'ssid': 'ssid',
'password': 'password'
}

6
net/http.py Normal file
View File

@ -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')

127
net/logging.py Normal file
View File

@ -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)))

41
net/ntp.py Normal file
View File

@ -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

99
net/server.py Normal file
View File

@ -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()

41
net/templates.py Normal file
View File

@ -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('&', '&amp;')
result = result.replace('"', '&quot;')
result = result.replace("'", '&apos;')
result = result.replace('>', '&gt;')
result = result.replace('<', '&lt;')
return result

21
net/util.py Normal file
View File

@ -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)

49
net/wifi.py Normal file
View File

@ -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')

BIN
www/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

22
www/index.html Normal file
View File

@ -0,0 +1,22 @@
<html>
<head>
<title>{{hostname}}</title>
<style>
body {
margin: 20px;
font-family: sans-serif;
text-align: center;
background-color: #333;
color: #ddd;
}
</style>
</head>
<body>
<h2>{{hostname}}</h2>
<p>{{datetime}}</p>
<p>Active: {{is_active}}</p>
</body>
</html>