Initial commit
This commit is contained in:
commit
9d032a29d2
|
@ -0,0 +1,2 @@
|
|||
.vscode/
|
||||
*.swp
|
|
@ -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.
|
|
@ -0,0 +1,3 @@
|
|||
# aquifer
|
||||
|
||||
A Raspberry Pi Pico W project for marshalling a car into a garage.
|
|
@ -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/
|
|
@ -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()
|
|
@ -0,0 +1 @@
|
|||
from .server import Server
|
|
@ -0,0 +1,8 @@
|
|||
config = {
|
||||
'hostname': '',
|
||||
}
|
||||
|
||||
secrets = {
|
||||
'ssid': 'ssid',
|
||||
'password': 'password'
|
||||
}
|
|
@ -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')
|
|
@ -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)))
|
|
@ -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
|
|
@ -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()
|
|
@ -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
|
|
@ -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)
|
|
@ -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')
|
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
|
@ -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>
|
Loading…
Reference in New Issue