commit 6e4e87e6c4ecf6c170a201dd7454cdd0ed5f3601 Author: Marek Ventur Date: Sun Jul 15 11:53:48 2018 +0100 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e43b0f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/app_library/main.py b/app_library/main.py new file mode 100644 index 0000000..aa716e7 --- /dev/null +++ b/app_library/main.py @@ -0,0 +1,67 @@ +"""switches between app libraries, updates and installs apps. + +To publish apps use https://badge.emfcamp.org""" + +___license___ = "MIT" +___name___ = "App Library" +___dependencies___ = ["wifi", "dialogs"] +___bootstrapped___ = True + +import pyb +import ugfx +import os +#import http_client +import wifi +import dialogs +#from app import App, get_local_apps, get_public_apps, get_public_app_categories, empty_local_app_cache +#import filesystem + +TEMP_FILE = ".temp_download" + +ugfx.init() + +### VIEWS ### + +def clear(): + ugfx.clear(ugfx.html_color(0x7c1143)) + +def store(): + None + +def update(): + None + +def remove(): + None + +def settings(): + None + +def main_menu(): + while True: + clear() + + print() + + menu_items = [ + {"title": "Install Apps", "function": store}, + {"title": "Update", "function": update}, + {"title": "Manage Apps", "function": remove}, + {"title": "Settings", "function": settings} + ] + + option = dialogs.prompt_option(menu_items, none_text="Exit", text="What do you want to do?", title="TiLDA App Library") + + if option: + option["function"]() + else: + return + +main_menu() + +#if App("home").loadable: +# main_menu() +#else: +# for app_name in ["changename", "snake", "alistair~selectwifi", "sponsors", "home"]: +# install(App(app_name)) +# pyb.hard_reset() diff --git a/boot.py b/boot.py new file mode 100644 index 0000000..a4e9da4 --- /dev/null +++ b/boot.py @@ -0,0 +1,29 @@ +import pyb, os, micropython + +micropython.alloc_emergency_exception_buf(100) + +root = os.listdir() + +def app(a): + if (a in root) and ("main.py" in os.listdir(a)): + return a + "/main.py" + +def file(file, remove): + try: + with open(file, 'r') as f: + a = f.read().strip() + if remove: + os.remove(file) + return app(a) + except Exception as e: + print(e) + +def any_home(): + return app(next(a for a in root if a.startswith("home"))) + +start = None +if "main.py" in root: + start = "main.py" +start = file("once.txt", True) or file("default_app.txt", False) or any_home() or "bootstrap.py" + +pyb.main(start) diff --git a/home_default/bg.gif b/home_default/bg.gif new file mode 100644 index 0000000..e9055b6 Binary files /dev/null and b/home_default/bg.gif differ diff --git a/home_default/main.py b/home_default/main.py new file mode 100644 index 0000000..6c04be5 --- /dev/null +++ b/home_default/main.py @@ -0,0 +1,20 @@ +"""Default homescreen + +This is the default homescreen for the Tilda Mk4. +It gets automatically installed when a badge is +newly activated or reset. +""" + +___name___ = "Homescreen (Default)" +___license___ = "GPL" +___categories___ = ["homescreen"] +___launchable___ = False +___bootstrapped___ = True + +print("there") +import ugfx, homescreen + +homescreen.init(color = 0xe4ffdb) + +ugfx.display_image(0, 0, "home_default/bg.gif") +ugfx.text(20, 20, homescreen.name(), ugfx.BLACK) diff --git a/launcher/main.py b/launcher/main.py new file mode 100644 index 0000000..81d737b --- /dev/null +++ b/launcher/main.py @@ -0,0 +1,9 @@ +"""Launcher for apps currently installed""" + +___name___ = "Launcher" +___license___ = "GPL" +___categories___ = ["System"] +___launchable___ = False +___bootstrapped___ = True + +print("launcher") diff --git a/lib/apps.py b/lib/apps.py new file mode 100644 index 0000000..5d96408 --- /dev/null +++ b/lib/apps.py @@ -0,0 +1,179 @@ +"""Model and Helpers for TiLDA apps and the App Library API""" + +___license___ = "MIT" +___dependencies___ = ["http"] + +import os +import ure +import http_client +import filesystem +import gc + +ATTRIBUTE_MATCHER = ure.compile("^\s*###\s*([^:]*?)\s*:\s*(.*)\s*$") # Yeah, regex! +CATEGORY_ALL = "all" +CATEGORY_NOT_SET = "uncategorised" + +class App: + """Models an app and provides some helper functions""" + def __init__(self, folder_name, api_information = None): + self.folder_name = self.name = folder_name.lower() + self.user = EMF_USER + if USER_NAME_SEPARATOR in folder_name: + [self.user, self.name] = folder_name.split(USER_NAME_SEPARATOR, 1) + self.user = self.user.lower() + self.name = self.name.lower() + + self._attributes = None # Load lazily + self.api_information = api_information + + @property + def folder_path(self): + return "apps/" + self.folder_name + + @property + def main_path(self): + return self.folder_path + "/main.py" + + @property + def loadable(self): + return filesystem.is_file(self.main_path) and os.stat(self.main_path)[6] > 0 + + @property + def description(self): + """either returns a local attribute or uses api_information""" + if self.api_information and "description" in self.api_information: + return self.api_information["description"] + return self.get_attribute("description") or "" + + @property + def files(self): + """returns a list of file dicts or returns False if the information is not available""" + if self.api_information and "files" in self.api_information: + return self.api_information["files"] + return False + + @property + def category(self): + return self.get_attribute("Category", CATEGORY_NOT_SET).lower() + + @property + def title(self): + return self.get_attribute("appname") or self.name + + @property + def user_and_title(self): + if self.user == EMF_USER: + return self.name + else: + return "%s by %s" % (self.title, self.user) + + def matches_category(self, category): + """returns True if provided category matches the category of this app""" + category = category.lower() + return category == CATEGORY_ALL or category == self.category + + @property + def attributes(self): + """Returns all attribues of this app + + The result is cached for the lifetime of this object + """ + if self._attributes == None: + self._attributes = {} + if self.loadable: + with open(self.main_path) as file: + for line in file: + match = ATTRIBUTE_MATCHER.match(line) + if match: + self._attributes[match.group(1).strip().lower()] = match.group(2).strip() + else: + break + return self._attributes + + def get_attribute(self, attribute, default=None): + """Returns the value of an attribute, or a specific default value if attribute is not found""" + attribute = attribute.lower() # attributes are case insensitive + if attribute in self.attributes: + return self.attributes[attribute] + else: + return default + + def fetch_api_information(self): + """Queries the API for information about this app, returns False if app is not publicly listed""" + with http_client.get("http://api.badge.emfcamp.org/api/app/%s/%s" % (self.user, self.name)) as response: + if response.status == 404: + return False + self.api_information = response.raise_for_status().json() + return self.api_information + + def __str__(self): + return self.user_and_title + + def __repr__(self): + return "" % (self.folder_name) + + +def app_by_name_and_user(name, user): + """Returns an user object""" + if user.lower() == EMF_USER: + return App(name) + else: + return App(user + USER_NAME_SEPARATOR + name) + +def app_by_api_response(response): + if response["user"].lower() == EMF_USER: + return App(response["name"], response) + else: + return App(response["user"] + USER_NAME_SEPARATOR + response["name"], response) + +def get_local_apps(category=CATEGORY_ALL): + """Returns a list of apps that can be found in the apps folder""" + apps = [App(folder_name) for folder_name in os.listdir("apps") if filesystem.is_dir("apps/" + folder_name)] + return [app for app in apps if app.matches_category(category)] + +_public_apps_cache = None +def fetch_public_app_api_information(uncached=False): + """Returns a dict category => list of apps + + Uses cached version unless the uncached parameter is set + """ + global _public_apps_cache + if not _public_apps_cache or uncached: + response = {} + for category, apps in http_client.get("http://api.badge.emfcamp.org/api/apps").raise_for_status().json().items(): + response[category] = [app_by_api_response(app) for app in apps] + + _public_apps_cache = response + return _public_apps_cache + +def get_public_app_categories(uncached=False): + """Returns a list of all categories used on the app library""" + return list(fetch_public_app_api_information(uncached).keys()) + +def get_public_apps(category=CATEGORY_ALL, uncached=False): + """Returns a list of all public apps in one category""" + category = category.lower() + api_information = fetch_public_app_api_information(uncached) + return api_information[category] if category in api_information else [] + +_category_cache = None +def get_local_app_categories(uncached=False): + """Returns a list of all app categories the user's apps are currently using + + Uses cached version unless the uncached parameter is set + """ + global _category_cache + if not _category_cache or uncached: + _category_cache = ["all"] + for app in get_local_apps(): + if app.category not in _category_cache: + _category_cache.append(app.category) + + return _category_cache + +def empty_local_app_cache(): + """If you're tight on memory you can clean up the local cache""" + global _public_apps_cache, _category_cache + _public_apps_cache = None + _category_cache = None + gc.collect() diff --git a/lib/buttons.py b/lib/buttons.py new file mode 100644 index 0000000..14616b1 --- /dev/null +++ b/lib/buttons.py @@ -0,0 +1,127 @@ +"""Convenience methods for dealing with the TiLDA buttons""" + +___license___ = "MIT" + +import pyb + +CONFIG = { + "JOY_UP": pyb.Pin.PULL_DOWN, + "JOY_DOWN": pyb.Pin.PULL_DOWN, + "JOY_RIGHT": pyb.Pin.PULL_DOWN, + "JOY_LEFT": pyb.Pin.PULL_DOWN, + "JOY_CENTER": pyb.Pin.PULL_DOWN, + "BTN_MENU": pyb.Pin.PULL_UP, + "BTN_A": pyb.Pin.PULL_UP, + "BTN_B": pyb.Pin.PULL_UP +} + +_tilda_pins = {} +_tilda_interrupts = {} +_tilda_bounce = {} + +def _get_pin(button): + if button not in _tilda_pins: + raise ValueError("Please call button.init() first before using any other button functions") + return _tilda_pins[button] + +def init(buttons = CONFIG.keys()): + """Inits all pins used by the TiLDA badge""" + global _tilda_pins + for button in buttons: + _tilda_pins[button] = pyb.Pin(button, pyb.Pin.IN) + _tilda_pins[button].init(pyb.Pin.IN, CONFIG[button]) + +def is_pressed(button): + pin = _get_pin(button) + if pin.pull() == pyb.Pin.PULL_DOWN: + return pin.value() > 0 + else: + return pin.value() == 0 + +def is_triggered(button, interval = 30): + """Use this function if you want buttons as a trigger for something in a loop + + It blocks for a while before returning a True and ignores trailing edge highs + for a certain time to filter out bounce on both edges + """ + global _tilda_bounce + if is_pressed(button): + if button in _tilda_bounce: + if pyb.millis() > _tilda_bounce[button]: + del _tilda_bounce[button] + else: + return False # The button might have bounced back to high + + # Wait for a while to avoid bounces to low + pyb.delay(interval) + + # Wait until button is released again + while is_pressed(button): + pyb.wfi() + + _tilda_bounce[button] = pyb.millis() + interval + return True + +def has_interrupt(button): + global _tilda_interrupts + _get_pin(button) + if button in _tilda_interrupts: + return True + else: + return False + + +def enable_interrupt(button, interrupt, on_press = True, on_release = False): + """Attaches an interrupt to a button + + on_press defines whether it should be called when the button is pressed + on_release defines whether it should be called when the button is releaseed + + The callback function must accept exactly 1 argument, which is the line that + triggered the interrupt. + """ + global _tilda_interrupts + pin = _get_pin(button) + if button in _tilda_interrupts: + # If someone tries to set an interrupt on a pin that already + # has one that's totally ok, but we need to remove the old one + # first + disable_interrupt(button) + + if not (on_press or on_release): + return + + mode = None; + if on_press and on_release: + mode = pyb.ExtInt.IRQ_RISING_FALLING + else: + if pin.pull() == pyb.Pin.PULL_DOWN: + mode = pyb.ExtInt.IRQ_RISING if on_press else pyb.ExtInt.IRQ_FALLING + else: + mode = pyb.ExtInt.IRQ_FALLING if on_press else pyb.ExtInt.IRQ_RISING + + _tilda_interrupts[button] = { + "interrupt": pyb.ExtInt(pin, mode, pin.pull(), interrupt), + "mode": mode, + "pin": pin + } + +def disable_interrupt(button): + global _tilda_interrupts + if button in _tilda_interrupts: + interrupt = _tilda_interrupts[button] + pyb.ExtInt(interrupt["pin"], interrupt["mode"], interrupt["pin"].pull(), None) + del _tilda_interrupts[button] + init([button]) + +def disable_all_interrupt(): + for interrupt in _tilda_interrupts: + disable_interrupt(interrupt) + +def enable_menu_reset(): + import onboard + enable_interrupt("BTN_MENU", lambda t:onboard.semihard_reset(), on_release = True) + +def disable_menu_reset(): + disable_interrupt("BTN_MENU") + diff --git a/lib/database.py b/lib/database.py new file mode 100644 index 0000000..2dbb247 --- /dev/null +++ b/lib/database.py @@ -0,0 +1,88 @@ +"""A simple key/value store backed by a json file + +Keys need to be convertable to str +Values can be anything json can store, including a dict + +Usage: + +import database +with database.open() as db: + print(db.get("hello", "default")) + db.set("foo", "world") + db.delete("bar") + +Or, to make things even easier, there are three static function: + +import database +print(database.get("hello", "default")) +database.set("foo", "world") +database.delete("bar") +""" + +___license___ = "MIT" + +import os, json + +class Database: + def __init__(self, filename = "config.json"): + self.filename = filename + self.dirty = False + try: + with open(filename, "rt") as file: + self.data = json.loads(file.read()) + except (OSError, ValueError): + print("Database %s doesn't exists or is invalid, creating new" % (filename)) + self.data = {} + self.dirty = True + self.flush() + + def set(self, key, value): + """Sets a value for a given key. + + 'key' gets converted into a string + 'value' can be anything that json can store, including a dict + """ + self.data[key] = value + self.dirty = True + + def get(self, key, default_value = None): + """Returns the value for a given key. + + If key is not found 'default_value' will be returned + """ + return self.data[key] if key in self.data else default_value + + def delete(self, key): + """Deletes a key/value pair""" + if key in self.data: + del self.data[key] + self.dirty = True + + + def flush(self): + """Writes changes to flash""" + if self.dirty: + with open(self.filename, "wt") as file: + file.write(json.dumps(self.data)) + file.flush() + os.sync() + self.dirty = False + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.flush() + + +def get(key, default_value = None, *args): + with Database(*args) as db: + return db.get(key, default_value) + +def set(key, value, *args): + with Database(*args) as db: + return db.set(key, value) + +def delete(key, *args): + with Database(*args) as db: + return db.delete(key) diff --git a/lib/dialogs.py b/lib/dialogs.py new file mode 100644 index 0000000..c2bde11 --- /dev/null +++ b/lib/dialogs.py @@ -0,0 +1,207 @@ +"""Some basic UGFX powered dialogs""" + +___license___ = "MIT" +___dependencies___ = ["buttons"] + +import ugfx +import buttons +import pyb + +default_style_badge = ugfx.Style() +default_style_badge.set_focus(ugfx.RED) +default_style_badge.set_enabled([ugfx.WHITE, ugfx.html_color(0x3C0246), ugfx.GREY, ugfx.RED]) +default_style_badge.set_background(ugfx.html_color(0x3C0246)) + +default_style_dialog = ugfx.Style() +default_style_dialog.set_enabled([ugfx.BLACK, ugfx.html_color(0xA66FB0), ugfx.html_color(0xdedede), ugfx.RED]) +default_style_dialog.set_background(ugfx.html_color(0xFFFFFF)) + + +TILDA_COLOR = ugfx.html_color(0x7c1143); + +def notice(text, title="TiLDA", close_text="Close", width = 260, height = 180, font=ugfx.FONT_SMALL, style=None): + prompt_boolean(text, title = title, true_text = close_text, false_text = None, width = width, height = height, font=font, style=style) + +def prompt_boolean(text, title="TiLDA", true_text="Yes", false_text="No", width = 260, height = 180, font=ugfx.FONT_SMALL, style=None): + """A simple one and two-options dialog + + if 'false_text' is set to None only one button is displayed. + If both 'true_text' and 'false_text' are given a boolean is returned + """ + global default_style_dialog + if style == None: + style = default_style_dialog + ugfx.set_default_font(ugfx.FONT_MEDIUM_BOLD) + window = ugfx.Container((ugfx.width() - width) // 2, (ugfx.height() - height) // 2, width, height, style=style) + window.show() + ugfx.set_default_font(font) + window.text(5, 10, title, TILDA_COLOR) + window.line(0, 30, width, 30, ugfx.BLACK) + + if false_text: + true_text = "A: " + true_text + false_text = "B: " + false_text + + ugfx.set_default_font(font) + label = ugfx.Label(5, 30, width - 10, height - 80, text = text, parent=window) + ugfx.set_default_font(ugfx.FONT_MEDIUM_BOLD) + button_yes = ugfx.Button(5, height - 40, width // 2 - 15 if false_text else width - 15, 30 , true_text, parent=window) + button_no = ugfx.Button(width // 2 + 5, height - 40, width // 2 - 15, 30 , false_text, parent=window) if false_text else None + + try: + buttons.init() + + button_yes.attach_input(ugfx.BTN_A,0) + if button_no: button_no.attach_input(ugfx.BTN_B,0) + + window.show() + + while True: + pyb.wfi() + if buttons.is_triggered("BTN_A"): return True + if buttons.is_triggered("BTN_B"): return False + + finally: + window.hide() + window.destroy() + button_yes.destroy() + if button_no: button_no.destroy() + label.destroy() + +def prompt_text(description, init_text = "", true_text="OK", false_text="Back", width = 300, height = 200, font=ugfx.FONT_MEDIUM_BOLD, style=default_style_badge): + """Shows a dialog and keyboard that allows the user to input/change a string + + Returns None if user aborts with button B + """ + + window = ugfx.Container(int((ugfx.width()-width)/2), int((ugfx.height()-height)/2), width, height, style=style) + + if false_text: + true_text = "M: " + true_text + false_text = "B: " + false_text + + if buttons.has_interrupt("BTN_MENU"): + buttons.disable_interrupt("BTN_MENU") + + ugfx.set_default_font(ugfx.FONT_MEDIUM) + kb = ugfx.Keyboard(0, int(height/2), width, int(height/2), parent=window) + edit = ugfx.Textbox(5, int(height/2)-30, int(width*4/5)-10, 25, text = init_text, parent=window) + ugfx.set_default_font(ugfx.FONT_SMALL) + button_yes = ugfx.Button(int(width*4/5), int(height/2)-30, int(width*1/5)-3, 25 , true_text, parent=window) + button_no = ugfx.Button(int(width*4/5), int(height/2)-30-30, int(width/5)-3, 25 , false_text, parent=window) if false_text else None + ugfx.set_default_font(font) + label = ugfx.Label(int(width/10), int(height/10), int(width*4/5), int(height*2/5)-60, description, parent=window) + + try: + buttons.init() + + button_yes.attach_input(ugfx.BTN_MENU,0) + if button_no: button_no.attach_input(ugfx.BTN_B,0) + + window.show() + edit.set_focus() + while True: + pyb.wfi() + ugfx.poll() + #if buttons.is_triggered("BTN_A"): return edit.text() + if buttons.is_triggered("BTN_B"): return None + if buttons.is_triggered("BTN_MENU"): return edit.text() + + finally: + window.hide() + window.destroy() + button_yes.destroy() + if button_no: button_no.destroy() + label.destroy() + kb.destroy() + edit.destroy(); + return + +def prompt_option(options, index=0, text = "Please select one of the following:", title=None, select_text="OK", none_text=None): + """Shows a dialog prompting for one of multiple options + + If none_text is specified the user can use the B or Menu button to skip the selection + if title is specified a blue title will be displayed about the text + """ + ugfx.set_default_font(ugfx.FONT_SMALL) + window = ugfx.Container(5, 5, ugfx.width() - 10, ugfx.height() - 10) + window.show() + + list_y = 30 + if title: + window.text(5, 10, title, TILDA_COLOR) + window.line(0, 25, ugfx.width() - 10, 25, ugfx.BLACK) + window.text(5, 30, text, ugfx.BLACK) + list_y = 50 + else: + window.text(5, 10, text, ugfx.BLACK) + + options_list = ugfx.List(5, list_y, ugfx.width() - 25, 180 - list_y, parent = window) + + for option in options: + if isinstance(option, dict) and option["title"]: + options_list.add_item(option["title"]) + else: + options_list.add_item(str(option)) + options_list.selected_index(index) + + select_text = "A: " + select_text + if none_text: + none_text = "B: " + none_text + + button_select = ugfx.Button(5, ugfx.height() - 50, 140 if none_text else ugfx.width() - 25, 30 , select_text, parent=window) + button_none = ugfx.Button(ugfx.width() - 160, ugfx.height() - 50, 140, 30 , none_text, parent=window) if none_text else None + + try: + buttons.init() + + while True: + pyb.wfi() + ugfx.poll() + if buttons.is_triggered("BTN_A"): return options[options_list.selected_index()] + if button_none and buttons.is_triggered("BTN_B"): return None + if button_none and buttons.is_triggered("BTN_MENU"): return None + + finally: + window.hide() + window.destroy() + options_list.destroy() + button_select.destroy() + if button_none: button_none.destroy() + ugfx.poll() + +class WaitingMessage: + """Shows a dialog with a certain message that can not be dismissed by the user""" + def __init__(self, text = "Please Wait...", title="TiLDA"): + self.window = ugfx.Container(30, 30, ugfx.width() - 60, ugfx.height() - 60) + self.window.show() + self.window.text(5, 10, title, TILDA_COLOR) + self.window.line(0, 30, ugfx.width() - 60, 30, ugfx.BLACK) + self.label = ugfx.Label(5, 40, self.window.width() - 10, ugfx.height() - 40, text = text, parent=self.window) + + # Indicator to show something is going on + self.indicator = ugfx.Label(ugfx.width() - 100, 0, 20, 20, text = "...", parent=self.window) + self.timer = pyb.Timer(3) + self.timer.init(freq=3) + self.timer.callback(lambda t: self.indicator.visible(not self.indicator.visible())) + + def destroy(self): + self.timer.deinit() + self.label.destroy() + self.indicator.destroy() + self.window.destroy() + + @property + def text(self): + return self.label.text() + + @text.setter + def text(self, value): + self.label.text(value) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.destroy() + diff --git a/lib/homescreen.py b/lib/homescreen.py new file mode 100644 index 0000000..95b4293 --- /dev/null +++ b/lib/homescreen.py @@ -0,0 +1,45 @@ +"""Shared functionality for home screen apps + +Apps in the "homescreen" should behave in a similar manner to not confuse users. + +In particular, they *should*: + +* Call "homescreen.init()" at the beginning. This will initiate ugfx, clear the screen and + initiate button handline. +* Use "pyb.wfi()" as much as possible to avoid draining the battery. +* Not use + +They also *may*: + +* Display a name, returned by "homescreen.name()" +* Display network strength "homescreen.mobile_strength()" (0-1, might return "None" if no SIM card found) +* Display network strength "homescreen.wifi_strength()" (0-1, might return "None" if not connected) +* Display remaining battery "homescreen.battery()" (0-1) +""" + +__license___ = "MIT" +__dependencies___ = ["database", "buttons"] + +import database, ugfx, buttons + +def init(color = 0xFFFFFF): + ugfx.init() + ugfx.clear(ugfx.html_color(color)) + buttons.init() + #buttons.enable_interrupt() + +def menu(): + ugfx.clear() + +def name(): + return database.get("homescreen.name", "Marek") + +def mobile_strength(): + return 0.75 + +def wifi_strength(): + return 0.65 + +def battery(): + return 0.65 + diff --git a/lib/http.py b/lib/http.py new file mode 100644 index 0000000..378f89a --- /dev/null +++ b/lib/http.py @@ -0,0 +1,3 @@ +"""HTTP library specially tied to TiLDAs functionality""" + +___license___ = "MIT" diff --git a/lib/wifi.py b/lib/wifi.py new file mode 100644 index 0000000..703c641 --- /dev/null +++ b/lib/wifi.py @@ -0,0 +1,153 @@ +"""Handles connecting to a wifi access point based on a valid wifi.json file""" + +___license___ = "MIT" +___dependencies___ = ["dialogs"] + +import network +import os +import json +import pyb +import dialogs + +_nic = None + +def nic(): + global _nic + if not _nic: + _nic = network.CC3100() + return _nic + +def connection_details(): + data = None + try: + if "wifi.json" in os.listdir(): + with open("wifi.json") as f: + data = json.loads(f.read()) + if 'ssid' not in data or not data['ssid']: + data = None + except ValueError as e: + print(e) + + return data + +def ssid(): + return connection_details()["ssid"] + +def connect(wait=True, timeout=10, show_wait_message=False, prompt_on_fail=True, dialog_title='TiLDA'): + retry_connect = True + + while retry_connect: + if nic().is_connected(): + return + + details = connection_details() + if not details: + if prompt_on_fail: + choose_wifi(dialog_title=dialog_title) + else: + raise OSError("No valid wifi configuration") + + if not wait: + connect_wifi(details, timeout=None, wait=False) + return + else: + try: + if show_wait_message: + with dialogs.WaitingMessage(text="Connecting to '%s'...\n(%ss timeout)" % (details['ssid'], timeout), title=dialog_title): + connect_wifi(details, timeout=timeout, wait=True) + else: + connect_wifi(details, timeout=timeout, wait=True) + except OSError: + if prompt_on_fail: + retry_connect = dialogs.prompt_boolean( + text="Failed to connect to '%s'" % details['ssid'], + title=dialog_title, + true_text="Try again", + false_text="Forget it", + ) + if not retry_connect: + os.remove('wifi.json') + os.sync() + # We would rather let you choose a new network here, but + # scanning doesn't work after a connect at the moment + pyb.hard_reset() + else: + raise + +def connect_wifi(details, timeout, wait=False): + if 'pw' in details: + nic().connect(details['ssid'], details['pw'], timeout=timeout) + else: + nic().connect(details['ssid'], timeout=timeout) + + if wait: + while not nic().is_connected(): + nic().update() + pyb.delay(100) + +def is_connected(): + return nic().is_connected() + +def get_security_level(ap): + n = nic() + levels = {} + try: + levels = { + n.SCAN_SEC_OPEN: 0, # I am awful + n.SCAN_SEC_WEP: 'WEP', + n.SCAN_SEC_WPA: 'WPA', + n.SCAN_SEC_WPA2: 'WPA2', + } + except AttributeError: + print("Firmware too old to query wifi security level, please upgrade.") + return None + + return levels.get(ap.get('security', None), None) + +def choose_wifi(dialog_title='TiLDA'): + filtered_aps = [] + with dialogs.WaitingMessage(text='Scanning for networks...', title=dialog_title): + visible_aps = nic().list_aps() + visible_aps.sort(key=lambda x:x['rssi'], reverse=True) + # We'll get one result for each AP, so filter dupes + for ap in visible_aps: + title = ap['ssid'] + security = get_security_level(ap) + if security: + title = title + ' (%s)' % security + ap = { + 'title': title, + 'ssid': ap['ssid'], + 'security': security, + } + if ap['ssid'] not in [ a['ssid'] for a in filtered_aps ]: + filtered_aps.append(ap) + del visible_aps + + ap = dialogs.prompt_option( + filtered_aps, + text='Choose wifi network', + title=dialog_title + ) + if ap: + key = None + if ap['security'] != 0: + # Backward compat + if ap['security'] == None: + ap['security'] = 'wifi' + + key = dialogs.prompt_text( + "Enter %s key" % ap['security'], + width = 310, + height = 220 + ) + with open("wifi.json", "wt") as file: + if key: + conn_details = {"ssid": ap['ssid'], "pw": key} + else: + conn_details = {"ssid": ap['ssid']} + + file.write(json.dumps(conn_details)) + os.sync() + # We can't connect after scanning for some bizarre reason, so we reset instead + pyb.hard_reset() diff --git a/settings/main.py b/settings/main.py new file mode 100644 index 0000000..c663d03 --- /dev/null +++ b/settings/main.py @@ -0,0 +1,9 @@ +"""Generic setting used by different apps""" + +___name___ = "Settings" +___license___ = "GPL" +___categories___ = ["System"] +___launchable___ = True +___bootstrapped___ = True + +print("settings")