From 341326af2025f1d9f9416c27e19d3b1682d764af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lauri=20V=C3=B5sandi?= Date: Wed, 19 Dec 2018 23:27:27 +0200 Subject: [PATCH] Fixed clock drift and added setup wizard --- boot.py | 17 --- firmware/main.py | 162 +++++++++++++++++++++++++ firmware/picoweb.py | 279 +++++++++++++++++++++++++++++++++++++++++++ firmware/timezone.py | 75 ++++++++++++ main.py | 72 ----------- 5 files changed, 516 insertions(+), 89 deletions(-) delete mode 100644 boot.py create mode 100644 firmware/main.py create mode 100644 firmware/picoweb.py create mode 100644 firmware/timezone.py delete mode 100644 main.py diff --git a/boot.py b/boot.py deleted file mode 100644 index 7947e7d..0000000 --- a/boot.py +++ /dev/null @@ -1,17 +0,0 @@ - -# Disable AP -import network -ap_if = network.WLAN(network.AP_IF) -ap_if.active(False) -print("Access point disabled") - -# Connect to wireless network as client -sta_if = network.WLAN(network.STA_IF) -sta_if.active(True) -sta_if.connect("Robootikaklubi", "u4HNj3sgYK") -while not sta_if.isconnected(): - pass - -# Clean up -import gc -gc.collect() diff --git a/firmware/main.py b/firmware/main.py new file mode 100644 index 0000000..d15b0ab --- /dev/null +++ b/firmware/main.py @@ -0,0 +1,162 @@ +import gc +import network +import picoweb +import json +from time import sleep_ms +from timezone import TIMEZONES + +app = picoweb.WebApp(__name__) +ap_if = network.WLAN(network.AP_IF) +sta_if = network.WLAN(network.STA_IF) +nets = sta_if.scan() +sta_if.active(True) +config = dict() +try: + with open("config.json") as fh: + config = json.loads(fh.read()) + sta_if.connect(config.get("ssid"), config.get("password")) +except OSError: + pass + +print("Scanning for wireless networks...") + +@app.route("/connect") +def index(req, resp): + if req.method == "POST": + yield from req.read_form_data() + else: + req.parse_qs() + yield from picoweb.start_response(resp) + with open("config.json", "w") as fh: + fh.write(json.dumps(req.form)) + yield from resp.awrite("Setting saved please power cycle device") + + +@app.route("/") +def index(req, resp): + print("Serving index") + yield from picoweb.start_response(resp) + yield from resp.awrite("") + yield from resp.awrite("") + yield from resp.awrite("") + yield from resp.awrite("") + yield from resp.awrite("") + yield from resp.awrite("Welcome to NixiESP12 configuration wizard") + yield from resp.awrite("

Detected wireless networks:

") + yield from resp.awrite("
") + yield from resp.awrite("

Select wireless network:

") + yield from resp.awrite("") + yield from resp.awrite("

Wireless password is applicable:

") + yield from resp.awrite("") + yield from resp.awrite("

Timezone:

") + yield from resp.awrite("") + yield from resp.awrite("

NTP resynchronization interval:

") + yield from resp.awrite("") + yield from resp.awrite("

 

") + yield from resp.awrite("") + yield from resp.awrite("
") + yield from resp.awrite("") + yield from resp.awrite("") + +timed_out = True +if config: + print("Connecting to", config.get("ssid")) + for j in range(0,30): + if sta_if.isconnected(): + ap_if.active(False) + timed_out = False + break + sleep_ms(200) + +if timed_out: + ap_if.active(True) + print("Starting setup wizard on", ap_if.ifconfig()) + app.run() + + +TIMEZONE = TIMEZONES[int(config.get("timezone", 30))][1] +print("Using timezone", TIMEZONES[int(config.get("timezone", 30))]) +RESYNC = int(config.get("interval")) # Resync once in 8 hours +print("NTP resynchronization interval", RESYNC, "seconds") +DEBUG = False + +print("Press Ctrl-C now abort main.py execution and to retain keyboard input") +sleep_ms(2000) + +import time +import ntptime +from machine import Pin, Timer + +# Note that keyboard input is lost beyond this point! +clock = Pin(3, mode=Pin.OUT) +latch = Pin(0, mode=Pin.OUT) +data = Pin(2, mode=Pin.OUT) +blink = False +lookup = 11, 9, 12, 8, 0, 4, 1, 3, 2, 10 +countdown = 0 + +def bitbang_bit(value): + if value & 1: + data.on() + else: + data.off() + clock.on() + clock.off() + +def bitbang_digit(digit): + bitbang_bit(blink) + for i in range(0,4): + bitbang_bit(lookup[digit] << i >> 3) + bitbang_bit(blink) + bitbang_bit(blink) + bitbang_bit(blink) + +def dump_time(hour, minute, second): + if DEBUG: + print("Time is %02d:%02d:%02d" % (hour, minute, second)) + bitbang_digit(hour // 10) + bitbang_digit(hour % 10) + bitbang_digit(minute // 10) + bitbang_digit(minute % 10) + bitbang_digit(second // 10) + bitbang_digit(second % 10) + +# RTC accuracy is still garbage, time.ticks_ms() which is bound to CPU ticks seems to be more accurate +# https://forum.micropython.org/viewtopic.php?t=3251#p19092 + +while True: + if countdown <= 0: + try: + ticks_then, time_then = time.ticks_ms(), ntptime.time() + except OSError: + print("Resync failed") + else: + countdown = RESYNC + print("Resync done") + else: + year, month, day, hour, minute, second, _, _ = time.localtime(time_then + (time.ticks_ms() - ticks_then) // 1000) + sleep_ms(500-(time.ticks_ms() - ticks_then) % 1000) + blink = True + dump_time((hour + TIMEZONE) % 24, minute, second) + latch.on() + latch.off() + countdown -= 1 + + year, month, day, hour, minute, second, _, _ = time.localtime(time_then + (time.ticks_ms() - ticks_then) // 1000) + sleep_ms(1001-(time.ticks_ms() - ticks_then) % 1000) + blink = False + dump_time((hour + TIMEZONE) % 24, minute, second) + latch.on() + latch.off() + +main() diff --git a/firmware/picoweb.py b/firmware/picoweb.py new file mode 100644 index 0000000..76d1908 --- /dev/null +++ b/firmware/picoweb.py @@ -0,0 +1,279 @@ +# Picoweb web pico-framework for MicroPython +# Copyright (c) 2014-2018 Paul Sokolovsky +# SPDX-License-Identifier: MIT +import sys +import gc +import micropython +import utime +import uio +import ure as re +import uerrno +import uasyncio as asyncio + +def unquote_plus(s): + # TODO: optimize + s = s.replace("+", " ") + arr = s.split("%") + arr2 = [chr(int(x[:2], 16)) + x[2:] for x in arr[1:]] + return arr[0] + "".join(arr2) + +def parse_qs(s): + res = {} + if s: + pairs = s.split("&") + for p in pairs: + vals = [unquote_plus(x) for x in p.split("=", 1)] + if len(vals) == 1: + vals.append(True) + old = res.get(vals[0]) + if old is not None: + if not isinstance(old, list): + old = [old] + res[vals[0]] = old + old.append(vals[1]) + else: + res[vals[0]] = vals[1] + return res + +def get_mime_type(fname): + # Provide minimal detection of important file + # types to keep browsers happy + if fname.endswith(".html"): + return "text/html" + if fname.endswith(".css"): + return "text/css" + if fname.endswith(".png") or fname.endswith(".jpg"): + return "image" + return "text/plain" + +def sendstream(writer, f): + buf = bytearray(64) + while True: + l = f.readinto(buf) + if not l: + break + yield from writer.awrite(buf, 0, l) + + +def jsonify(writer, dict): + import ujson + yield from start_response(writer, "application/json") + yield from writer.awrite(ujson.dumps(dict)) + +def start_response(writer, content_type="text/html", status="200", headers=None): + yield from writer.awrite("HTTP/1.0 %s NA\r\n" % status) + yield from writer.awrite("Content-Type: ") + yield from writer.awrite(content_type) + if not headers: + yield from writer.awrite("\r\n\r\n") + return + yield from writer.awrite("\r\n") + if isinstance(headers, bytes) or isinstance(headers, str): + yield from writer.awrite(headers) + else: + for k, v in headers.items(): + yield from writer.awrite(k) + yield from writer.awrite(": ") + yield from writer.awrite(v) + yield from writer.awrite("\r\n") + yield from writer.awrite("\r\n") + +def http_error(writer, status): + yield from start_response(writer, status=status) + yield from writer.awrite(status) + + +class HTTPRequest: + + def __init__(self): + pass + + def read_form_data(self): + size = int(self.headers[b"Content-Length"]) + data = yield from self.reader.read(size) + form = parse_qs(data.decode()) + self.form = form + + def parse_qs(self): + form = parse_qs(self.qs) + self.form = form + + +class WebApp: + + def __init__(self, pkg, routes=None): + if routes: + self.url_map = routes + else: + self.url_map = [] + if pkg and pkg != "__main__": + self.pkg = pkg.split(".", 1)[0] + else: + self.pkg = None + self.mounts = [] + self.inited = False + # Instantiated lazily + self.template_loader = None + self.headers_mode = "parse" + + def parse_headers(self, reader): + headers = {} + while True: + l = yield from reader.readline() + if l == b"\r\n": + break + k, v = l.split(b":", 1) + headers[k] = v.strip() + return headers + + def _handle(self, reader, writer): + close = True + try: + request_line = yield from reader.readline() + if request_line == b"": + yield from writer.aclose() + return + req = HTTPRequest() + # TODO: bytes vs str + request_line = request_line.decode() + method, path, proto = request_line.split() + path = path.split("?", 1) + qs = "" + if len(path) > 1: + qs = path[1] + path = path[0] + + #print("================") + #print(req, writer) + #print(req, (method, path, qs, proto), req.headers) + + # Find which mounted subapp (if any) should handle this request + app = self + while True: + found = False + for subapp in app.mounts: + root = subapp.url + #print(path, "vs", root) + if path[:len(root)] == root: + app = subapp + found = True + path = path[len(root):] + if not path.startswith("/"): + path = "/" + path + break + if not found: + break + + # We initialize apps on demand, when they really get requests + if not app.inited: + app.init() + + # Find handler to serve this request in app's url_map + found = False + for e in app.url_map: + pattern = e[0] + handler = e[1] + extra = {} + if len(e) > 2: + extra = e[2] + + if path == pattern: + found = True + break + elif not isinstance(pattern, str): + # Anything which is non-string assumed to be a ducktype + # pattern matcher, whose .match() method is called. (Note: + # Django uses .search() instead, but .match() is more + # efficient and we're not exactly compatible with Django + # URL matching anyway.) + m = pattern.match(path) + if m: + req.url_match = m + found = True + break + + if not found: + headers_mode = "skip" + else: + headers_mode = extra.get("headers", self.headers_mode) + + if headers_mode == "skip": + while True: + l = yield from reader.readline() + if l == b"\r\n": + break + elif headers_mode == "parse": + req.headers = yield from self.parse_headers(reader) + else: + assert headers_mode == "leave" + + if found: + req.method = method + req.path = path + req.qs = qs + req.reader = reader + close = yield from handler(req, writer) + else: + yield from start_response(writer, status="404") + yield from writer.awrite("404\r\n") + #print(req, "After response write") + except Exception as e: + pass + + if close is not False: + yield from writer.aclose() + + def mount(self, url, app): + "Mount a sub-app at the url of current app." + # Inspired by Bottle. It might seem that dispatching to + # subapps would rather be handled by normal routes, but + # arguably, that's less efficient. Taking into account + # that paradigmatically there's difference between handing + # an action and delegating responisibilities to another + # app, Bottle's way was followed. + app.url = url + self.mounts.append(app) + + def route(self, url, **kwargs): + def _route(f): + self.url_map.append((url, f, kwargs)) + return f + return _route + + def add_url_rule(self, url, func, **kwargs): + # Note: this method skips Flask's "endpoint" argument, + # because it's alleged bloat. + self.url_map.append((url, func, kwargs)) + + def _load_template(self, tmpl_name): + if self.template_loader is None: + import utemplate.source + self.template_loader = utemplate.source.Loader(self.pkg, "templates") + return self.template_loader.load(tmpl_name) + + def render_template(self, writer, tmpl_name, args=()): + tmpl = self._load_template(tmpl_name) + for s in tmpl(*args): + yield from writer.awrite(s) + + def render_str(self, tmpl_name, args=()): + #TODO: bloat + tmpl = self._load_template(tmpl_name) + return ''.join(tmpl(*args)) + + def init(self): + """Initialize a web application. This is for overriding by subclasses. + This is good place to connect to/initialize a database, for example.""" + self.inited = True + + def run(self, host="0.0.0.0", port=80, lazy_init=False): + gc.collect() + self.init() + if not lazy_init: + for app in self.mounts: + app.init() + loop = asyncio.get_event_loop() + loop.create_task(asyncio.start_server(self._handle, host, port)) + loop.run_forever() + loop.close() + diff --git a/firmware/timezone.py b/firmware/timezone.py new file mode 100644 index 0000000..d3a4c66 --- /dev/null +++ b/firmware/timezone.py @@ -0,0 +1,75 @@ +TIMEZONES = ( + (0, -12, "(GMT-12:00) International Date Line West"), + (0, -11, "(GMT-11:00) Midway Island, Samoa"), + (0, -10, "(GMT-10:00) Hawaii"), + (1, -9, "(GMT-09:00) Alaska"), + (1, -8, "(GMT-08:00) Pacific Time (US & Canada)"), + (1, -8, "(GMT-08:00) Tijuana, Baja California"), + (0, -7, "(GMT-07:00) Arizona"), + (1, -7, "(GMT-07:00) Chihuahua, La Paz, Mazatlan"), + (1, -7, "(GMT-07:00) Mountain Time (US & Canada)"), + (0, -6, "(GMT-06:00) Central America"), + (1, -6, "(GMT-06:00) Central Time (US & Canada)"), + (1, -6, "(GMT-06:00) Guadalajara, Mexico City, Monterrey"), + (0, -6, "(GMT-06:00) Saskatchewan"), + (0, -5, "(GMT-05:00) Bogota, Lima, Quito, Rio Branco"), + (1, -5, "(GMT-05:00) Eastern Time (US & Canada)"), + (1, -5, "(GMT-05:00) Indiana (East)"), + (1, -4, "(GMT-04:00) Atlantic Time (Canada)"), + (0, -4, "(GMT-04:00) Caracas, La Paz"), + (0, -4, "(GMT-04:00) Manaus"), + (1, -4, "(GMT-04:00) Santiago"), + (1, -3, "(GMT-03:00) Brasilia"), + (0, -3, "(GMT-03:00) Buenos Aires, Georgetown"), + (1, -3, "(GMT-03:00) Greenland"), + (1, -3, "(GMT-03:00) Montevideo"), + (1, -2, "(GMT-02:00) Mid-Atlantic"), + (0, -1, "(GMT-01:00) Cape Verde Is."), + (1, -1, "(GMT-01:00) Azores"), + (0, 0, "(GMT+00:00) Casablanca, Monrovia, Reykjavik"), + (1, 0, "(GMT+00:00) Greenwich Mean Time : Dublin, Edinburgh, Lisbon, London"), + (1, 1, "(GMT+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna"), + (1, 1, "(GMT+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague"), + (1, 1, "(GMT+01:00) Brussels, Copenhagen, Madrid, Paris"), + (1, 1, "(GMT+01:00) Sarajevo, Skopje, Warsaw, Zagreb"), + (1, 1, "(GMT+01:00) West Central Africa"), + (1, 2, "(GMT+02:00) Amman"), + (1, 2, "(GMT+02:00) Athens, Bucharest, Istanbul"), + (1, 2, "(GMT+02:00) Beirut"), + (1, 2, "(GMT+02:00) Cairo"), + (0, 2, "(GMT+02:00) Harare, Pretoria"), + (1, 2, "(GMT+02:00) Helsinki, Kyiv, Riga, Sofia, Tallinn, Vilnius"), + (1, 2, "(GMT+02:00) Jerusalem"), + (1, 2, "(GMT+02:00) Minsk"), + (1, 2, "(GMT+02:00) Windhoek"), + (0, 3, "(GMT+03:00) Kuwait, Riyadh, Baghdad"), + (1, 3, "(GMT+03:00) Moscow, St. Petersburg, Volgograd"), + (0, 3, "(GMT+03:00) Nairobi"), + (0, 3, "(GMT+03:00) Tbilisi"), + (0, 4, "(GMT+04:00) Abu Dhabi, Muscat"), + (1, 4, "(GMT+04:00) Baku"), + (1, 4, "(GMT+04:00) Yerevan"), + (1, 5, "(GMT+05:00) Yekaterinburg"), + (0, 5, "(GMT+05:00) Islamabad, Karachi, Tashkent"), + (1, 6, "(GMT+06:00) Almaty, Novosibirsk"), + (0, 6, "(GMT+06:00) Astana, Dhaka"), + (0, 7, "(GMT+07:00) Bangkok, Hanoi, Jakarta"), + (1, 7, "(GMT+07:00) Krasnoyarsk"), + (0, 8, "(GMT+08:00) Beijing, Chongqing, Hong Kong, Urumqi"), + (0, 8, "(GMT+08:00) Kuala Lumpur, Singapore"), + (0, 8, "(GMT+08:00) Irkutsk, Ulaan Bataar"), + (0, 8, "(GMT+08:00) Perth"), + (0, 8, "(GMT+08:00) Taipei"), + (0, 9, "(GMT+09:00) Osaka, Sapporo, Tokyo"), + (0, 9, "(GMT+09:00) Seoul"), + (1, 9, "(GMT+09:00) Yakutsk"), + (0, 10, "(GMT+10:00) Brisbane"), + (1, 10, "(GMT+10:00) Canberra, Melbourne, Sydney"), + (1, 10, "(GMT+10:00) Hobart"), + (0, 10, "(GMT+10:00) Guam, Port Moresby"), + (1, 10, "(GMT+10:00) Vladivostok"), + (1, 11, "(GMT+11:00) Magadan, Solomon Is., New Caledonia"), + (1, 12, "(GMT+12:00) Auckland, Wellington"), + (0, 12, "(GMT+12:00) Fiji, Kamchatka, Marshall Is."), + (0, 13, "(GMT+13:00) Nuku'alofa") +) diff --git a/main.py b/main.py deleted file mode 100644 index ecf6481..0000000 --- a/main.py +++ /dev/null @@ -1,72 +0,0 @@ - -import ntptime -import esp -from time import sleep, localtime -from machine import Pin, Timer - -TIMEZONE = 3 -RESYNC = 7200 # Resync once in two hours - -print("Press Ctrl-C now abort main.py execution and to retain keyboard input") -sleep(1) - -# Configure powersave -esp.sleep_type(esp.SLEEP_LIGHT) - -# Note that keyboard input is lost beyond this point! -clock = Pin(3, mode=Pin.OUT) -latch = Pin(0, mode=Pin.OUT) -data = Pin(2, mode=Pin.OUT) -blink = 0 -lookup = 11, 9, 12, 8, 0, 4, 1, 3, 2, 10 -countdown = 0 - -def bitbang_bit(value): - if value & 1: - data.on() - else: - data.off() - clock.on() - clock.off() - -def bitbang_digit(digit): - bitbang_bit(blink) - for i in range(0,4): - bitbang_bit(lookup[digit] << i >> 3) - bitbang_bit(blink) - bitbang_bit(blink) - bitbang_bit(blink) - -timer = Timer(-1) - -def schedule(delay=0): - global countdown - if countdown <= 0: - ntptime.settime() - countdown = RESYNC - print("Resync") - countdown -= 1 - if delay: - timer.init(period=1000, mode=Timer.ONE_SHOT, callback=dump) - else: - dump() - -def dump_time(hour, minute, second): - print("Time is %02d:%02d:%02d" % (hour, minute, second)) - bitbang_digit(hour // 10) - bitbang_digit(hour % 10) - bitbang_digit(minute // 10) - bitbang_digit(minute % 10) - bitbang_digit(second // 10) - bitbang_digit(second % 10) - -def dump(t=None): - global blink - year, month, day, hour, minute, second, _, millis = localtime() - dump_time((hour + TIMEZONE) % 24, minute, second) - latch.on() - latch.off() - blink = 1-blink - schedule(1000-millis) - -schedule()