Marshaller [main]

Initial commit

=> 9d032a29d26229748d616774ddf8050bc37e72c2

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

 + + +
Proxy Information
Original URL
gemini://git.cifelli.xyz/marshaller/main/cdiff/9d032a29d26229748d616774ddf8050bc37e72c2
Status Code
Success (20)
Meta
text/gemini; charset=utf-8
Capsule Response Time
563.520088 milliseconds
Gemini-to-HTML Time
1.183891 milliseconds

This content has been proxied by September (3851b).