From 8b385aef47cc0493b32da6c6471823fb629007b2 Mon Sep 17 00:00:00 2001 From: monsieur_h Date: Wed, 20 Dec 2017 08:18:19 +0100 Subject: [PATCH 01/40] adds a basic `OfonoBridge` class to interact with Ofono through D-Bus adds `import_sim_phonebook` to `OfonoBridge` --- apps/personal/contacts/vcard_converter.py | 11 +++++--- ofono/__init__.py | 0 ofono/bridge.py | 31 +++++++++++++++++++++++ requirements.txt | 2 ++ 4 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 ofono/__init__.py create mode 100644 ofono/bridge.py diff --git a/apps/personal/contacts/vcard_converter.py b/apps/personal/contacts/vcard_converter.py index d8d13d05..117baadf 100644 --- a/apps/personal/contacts/vcard_converter.py +++ b/apps/personal/contacts/vcard_converter.py @@ -1,5 +1,4 @@ -from helpers import setup_logger -logger = setup_logger(__name__, "warning") +import logging import vobject @@ -50,5 +49,11 @@ def from_vcards(contact_card_files): contacts = [] for file_path in contact_card_files: contacts += VCardContactConverter.parse_vcard_file(file_path) - logger.info("finished : {} contacts loaded", len(contacts)) + logging.info("finished : {} contacts loaded", len(contacts)) return [VCardContactConverter.to_zpui_contact(c) for c in contacts] + + @classmethod + def from_string(cls, vcard_string): + # type: (str) -> list + # Returns a list of ZPUI contacts from a string in vcard format + return [c for c in vobject.readComponents(vcard_string, ignoreUnreadable=True)] diff --git a/ofono/__init__.py b/ofono/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ofono/bridge.py b/ofono/bridge.py new file mode 100644 index 00000000..48f7fa54 --- /dev/null +++ b/ofono/bridge.py @@ -0,0 +1,31 @@ +import logging + +import pydbus + +from apps.personal.contacts.address_book import AddressBook +from apps.personal.contacts.vcard_converter import VCardContactConverter + +logger = logging.getLogger(__name__) +logger.setLevel(logging.WARNING) + + +class OfonoBridge(object): + """ + Generic util class to bridge between ZPUI and ofono backend through D-Bus + """ + + def __init__(self): + super(OfonoBridge, self).__init__() + self.ofono_bus = pydbus.SystemBus().get('org.ofono', '/') # todo : trycatch + + def get_sim_contacts(self): + # type: () -> list + # returns the contacts list stored on the default sim-card + vcard_string = self.ofono_bus['Phonebook'].Import(timeout=100) # todo : test this + return VCardContactConverter.from_string(vcard_string) + + def import_sim_phonebook(self): + address_book = AddressBook() + for contact in self.get_sim_contacts(): + address_book.add_contact(contact) + address_book.save_to_file() diff --git a/requirements.txt b/requirements.txt index 167bd13f..b32d5808 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ +vobject +pydbus luma.oled python-nmap smspdu From e1a63fb7f96b537a49f4cd52e6a223a3cba15de9 Mon Sep 17 00:00:00 2001 From: monsieur_h Date: Sun, 7 Jan 2018 20:33:41 +0100 Subject: [PATCH 02/40] #50 restart ofono-bridge development --- ofono/bridge.py | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/ofono/bridge.py b/ofono/bridge.py index 48f7fa54..87930f77 100644 --- a/ofono/bridge.py +++ b/ofono/bridge.py @@ -2,9 +2,6 @@ import pydbus -from apps.personal.contacts.address_book import AddressBook -from apps.personal.contacts.vcard_converter import VCardContactConverter - logger = logging.getLogger(__name__) logger.setLevel(logging.WARNING) @@ -16,16 +13,4 @@ class OfonoBridge(object): def __init__(self): super(OfonoBridge, self).__init__() - self.ofono_bus = pydbus.SystemBus().get('org.ofono', '/') # todo : trycatch - - def get_sim_contacts(self): - # type: () -> list - # returns the contacts list stored on the default sim-card - vcard_string = self.ofono_bus['Phonebook'].Import(timeout=100) # todo : test this - return VCardContactConverter.from_string(vcard_string) - - def import_sim_phonebook(self): - address_book = AddressBook() - for contact in self.get_sim_contacts(): - address_book.add_contact(contact) - address_book.save_to_file() + self.ofono_bus = pydbus.SystemBus().get('org.ofono', '/') From f43ea6736a53075acb1429fe1fb434cbd968f526 Mon Sep 17 00:00:00 2001 From: monsieur_h Date: Sat, 13 Jan 2018 17:31:13 +0100 Subject: [PATCH 03/40] #50 adds udev rule for SIM800L --- 00-ofono.rules | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 00-ofono.rules diff --git a/00-ofono.rules b/00-ofono.rules new file mode 100644 index 00000000..9c31b5ea --- /dev/null +++ b/00-ofono.rules @@ -0,0 +1,2 @@ +KERNEL=="ttyAMA0", ENV{OFONO_DRIVER}="sim900" + From 199409589816b2121f94c8a6b08038a57745f72e Mon Sep 17 00:00:00 2001 From: monsieur_h Date: Sat, 13 Jan 2018 17:32:30 +0100 Subject: [PATCH 04/40] #50 WIP ofono-bridge --- ofono/bridge.py | 124 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 121 insertions(+), 3 deletions(-) diff --git a/ofono/bridge.py b/ofono/bridge.py index 87930f77..f1945ac9 100644 --- a/ofono/bridge.py +++ b/ofono/bridge.py @@ -1,9 +1,17 @@ import logging +import os +from time import sleep import pydbus +from dbus.mainloop.glib import DBusGMainLoop +from gi.repository import GLib -logger = logging.getLogger(__name__) -logger.setLevel(logging.WARNING) +from helpers import Singleton, setup_logger + +DBusGMainLoop(set_as_default=True) # has to be called first + +logger = setup_logger(__name__, 'debug') +logging.basicConfig(level=logging.DEBUG) class OfonoBridge(object): @@ -13,4 +21,114 @@ class OfonoBridge(object): def __init__(self): super(OfonoBridge, self).__init__() - self.ofono_bus = pydbus.SystemBus().get('org.ofono', '/') + + bus = pydbus.SystemBus() + manager = bus.get('org.ofono', '/') + modem_path = manager.GetModems()[0][0] + + if modem_path != '/sim900_0': + raise ValueError("Default modem should be '/sim900_0', was '{}'".format(modem_path)) + + # self.start() #todo: check if it's the suitable place to do it + + def start(self): + self.power_on() + self._init_messages() + self._listen_messages() + + @property + def _bus(self): + # lil hack so we always have an up-to-date bus + return pydbus.SystemBus().get('org.ofono', '/sim900_0') + + @property + def message_manager(self): + return self._get_dbus_interface('MessageManager') + + def _get_dbus_interface(self, name): + ''.startswith('org.ofono') + full_name = name if name.startswith('org.ofono') else 'org.ofono.{}'.format(name) + if full_name in self._bus.GetProperties()['Interfaces']: + return self._bus[full_name] + + def power_on(self): + if self._bus.GetProperties()["Powered"]: + logger.info("Modem already powered up !") + else: + logger.info("Powering up modem...") + try: + self._bus.SetProperty("Powered", pydbus.Variant('b', True)) + sleep(2) # Let the modem some time to initialize + except Exception: + logger.error("Couldn't power up the modem !") + + def power_off(self): + self._bus.SetProperty("Powered", pydbus.Variant('b', False)) + + def send_sms(self, to, content): # todo : untested + self.message_manager.SendMessage(to, content) + print("Sending", to, content) + ConversationManager().on_new_message_sent(to, content) + + @staticmethod + def on_message_received(message, details, path=None, interface=None): # todo : untested + logger.info("Got message with path {}".format(path)) + ConversationManager().on_new_message_received(message, details) + + def _listen_messages(self): + logger.info("Connecting to dbus callbacks") + self.message_manager.IncomingMessage.connect(self.on_message_received) + self.message_manager.ImmediateMessage.connect(self.on_message_received) + + def _init_messages(self): + self.message_manager.SetProperty("UseDeliveryReports", pydbus.Variant('b', True)) + + +class ConversationManager(Singleton): + """ + Singleton dedicated to conversations. Logs every message sent and received in a flat file + for any given phone number + """ + + def __init__(self): + super(ConversationManager, self).__init__() + self.folder = os.path.expanduser("~/.phone/sms/") # todo: store as a constant somewhere + if not os.path.exists(self.folder): + os.mkdir(self.folder) + + def on_new_message_sent(self, to, content): + logger.info("Sent message to '{}'".format(to)) + self._write_log(to, self._format_log(content, from_me=True)) + + def on_new_message_received(self, content, details): + origin = details['Sender'] + logger.info("Received message from'{}'".format(origin)) + self._write_log(origin, self._format_log(content, from_me=True)) + + def _write_log(self, phone_number, log): + with open(self._get_log_path(phone_number), 'a+') as log_file: + log_file.write(log) + + def _get_log_path(self, phone_number): + file_name = "{}.txt".format(phone_number) + return os.path.join(self.folder, file_name) + + @staticmethod + def _format_log(content, from_me=True): + start_char = '>' if from_me else '<' + return "{prefix}\t{msg}\n".format(prefix=start_char, msg=content) + + +def main(): + ofono = OfonoBridge() + ofono.start() + mainloop = GLib.MainLoop() # todo : own thread + try: + mainloop.run() + except KeyboardInterrupt: + # ofono.power_off() # todo: can't be powered back up after we power it off once + logger.info("Exiting...") + + +if __name__ == '__main__': + main() From ff3d60346ceb6f8dbd4f881c895b3ad699593329 Mon Sep 17 00:00:00 2001 From: monsieur_h Date: Sat, 13 Jan 2018 17:44:36 +0100 Subject: [PATCH 05/40] fixes duplicate entry in requirements.txt --- requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b32d5808..843f8203 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ -vobject pydbus luma.oled python-nmap From b5d29b0d7dec58d6542dbb65f808348ae117c5a8 Mon Sep 17 00:00:00 2001 From: monsieur_h Date: Sat, 13 Jan 2018 19:05:44 +0100 Subject: [PATCH 06/40] adds some safeguards --- ofono/bridge.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/ofono/bridge.py b/ofono/bridge.py index f1945ac9..9cbd3f8f 100644 --- a/ofono/bridge.py +++ b/ofono/bridge.py @@ -8,8 +8,6 @@ from helpers import Singleton, setup_logger -DBusGMainLoop(set_as_default=True) # has to be called first - logger = setup_logger(__name__, 'debug') logging.basicConfig(level=logging.DEBUG) @@ -120,14 +118,17 @@ def _format_log(content, from_me=True): def main(): + DBusGMainLoop(set_as_default=True) # has to be called first ofono = OfonoBridge() - ofono.start() - mainloop = GLib.MainLoop() # todo : own thread try: + ofono.start() + mainloop = GLib.MainLoop() # todo : own thread mainloop.run() except KeyboardInterrupt: - # ofono.power_off() # todo: can't be powered back up after we power it off once - logger.info("Exiting...") + logger.info("Caught CTRL-C:exiting without powering off...") + except AttributeError: + logger.error("Error while starting ofono bridge ! Powering off...") + ofono.power_off() if __name__ == '__main__': From 67c2baaaf8e3fc3da20ae2c6edb135a3f7ecefd0 Mon Sep 17 00:00:00 2001 From: monsieur_h Date: Mon, 15 Jan 2018 13:58:58 +0100 Subject: [PATCH 07/40] fixes logger import --- apps/personal/contacts/vcard_converter.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/personal/contacts/vcard_converter.py b/apps/personal/contacts/vcard_converter.py index 117baadf..a780b1a8 100644 --- a/apps/personal/contacts/vcard_converter.py +++ b/apps/personal/contacts/vcard_converter.py @@ -1,8 +1,9 @@ -import logging - import vobject from address_book import Contact +from helpers import setup_logger + +logger = setup_logger(__name__) class VCardContactConverter(object): @@ -49,7 +50,7 @@ def from_vcards(contact_card_files): contacts = [] for file_path in contact_card_files: contacts += VCardContactConverter.parse_vcard_file(file_path) - logging.info("finished : {} contacts loaded", len(contacts)) + logger.info("finished : {} contacts loaded", len(contacts)) return [VCardContactConverter.to_zpui_contact(c) for c in contacts] @classmethod From 0966d0e8904c1165b6faab3ad751b1cf6b1d0850 Mon Sep 17 00:00:00 2001 From: monsieur_h Date: Mon, 15 Jan 2018 14:12:40 +0100 Subject: [PATCH 08/40] refactors '/sim900_0' as a member variable --- ofono/bridge.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/ofono/bridge.py b/ofono/bridge.py index 9cbd3f8f..6c132a3c 100644 --- a/ofono/bridge.py +++ b/ofono/bridge.py @@ -18,14 +18,13 @@ class OfonoBridge(object): """ def __init__(self): - super(OfonoBridge, self).__init__() - + self.modem_path = '/sim900_0' bus = pydbus.SystemBus() manager = bus.get('org.ofono', '/') modem_path = manager.GetModems()[0][0] - if modem_path != '/sim900_0': - raise ValueError("Default modem should be '/sim900_0', was '{}'".format(modem_path)) + if modem_path != self.modem_path: + raise ValueError("Default modem should be '{}', was '{}'".format(self.modem_path, modem_path)) # self.start() #todo: check if it's the suitable place to do it @@ -37,7 +36,7 @@ def start(self): @property def _bus(self): # lil hack so we always have an up-to-date bus - return pydbus.SystemBus().get('org.ofono', '/sim900_0') + return pydbus.SystemBus().get('org.ofono', self.modem_path) @property def message_manager(self): From 12d27f90eccac3c54030c72b269b693c787dd0b9 Mon Sep 17 00:00:00 2001 From: monsieur_h Date: Mon, 15 Jan 2018 14:15:24 +0100 Subject: [PATCH 09/40] refactors the modem check as a method `_check_default_modem` --- ofono/bridge.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ofono/bridge.py b/ofono/bridge.py index 6c132a3c..fc9af78f 100644 --- a/ofono/bridge.py +++ b/ofono/bridge.py @@ -19,15 +19,17 @@ class OfonoBridge(object): def __init__(self): self.modem_path = '/sim900_0' + self._check_default_modem() + + # self.start() #todo: check if it's the suitable place to do it + + def _check_default_modem(self): bus = pydbus.SystemBus() manager = bus.get('org.ofono', '/') modem_path = manager.GetModems()[0][0] - if modem_path != self.modem_path: raise ValueError("Default modem should be '{}', was '{}'".format(self.modem_path, modem_path)) - # self.start() #todo: check if it's the suitable place to do it - def start(self): self.power_on() self._init_messages() From 41ff44c9a6aa1e087d5908b9964c23867fc9aa37 Mon Sep 17 00:00:00 2001 From: monsieur_h Date: Mon, 15 Jan 2018 14:17:35 +0100 Subject: [PATCH 10/40] updates `self._bus` hack explanation --- ofono/bridge.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/ofono/bridge.py b/ofono/bridge.py index fc9af78f..35c89ca8 100644 --- a/ofono/bridge.py +++ b/ofono/bridge.py @@ -21,8 +21,6 @@ def __init__(self): self.modem_path = '/sim900_0' self._check_default_modem() - # self.start() #todo: check if it's the suitable place to do it - def _check_default_modem(self): bus = pydbus.SystemBus() manager = bus.get('org.ofono', '/') @@ -37,7 +35,8 @@ def start(self): @property def _bus(self): - # lil hack so we always have an up-to-date bus + """SystemBus().get() returns a snapshot of the current exposed methods. Having it as a property ensures we always + have the latest snapshot (as opposed to storing it on start/after-init)""" return pydbus.SystemBus().get('org.ofono', self.modem_path) @property From e1f101bf3d708a988ad82d2316f74d0a8d13b33c Mon Sep 17 00:00:00 2001 From: monsieur_h Date: Mon, 15 Jan 2018 14:17:50 +0100 Subject: [PATCH 11/40] remove development leftover --- ofono/bridge.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ofono/bridge.py b/ofono/bridge.py index 35c89ca8..05967dd1 100644 --- a/ofono/bridge.py +++ b/ofono/bridge.py @@ -44,7 +44,6 @@ def message_manager(self): return self._get_dbus_interface('MessageManager') def _get_dbus_interface(self, name): - ''.startswith('org.ofono') full_name = name if name.startswith('org.ofono') else 'org.ofono.{}'.format(name) if full_name in self._bus.GetProperties()['Interfaces']: return self._bus[full_name] From f085e7b33a46f4cbcbd447555ee9112641e49203 Mon Sep 17 00:00:00 2001 From: monsieur_h Date: Mon, 15 Jan 2018 14:25:30 +0100 Subject: [PATCH 12/40] better exception handling --- ofono/bridge.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ofono/bridge.py b/ofono/bridge.py index 05967dd1..145cb633 100644 --- a/ofono/bridge.py +++ b/ofono/bridge.py @@ -56,8 +56,9 @@ def power_on(self): try: self._bus.SetProperty("Powered", pydbus.Variant('b', True)) sleep(2) # Let the modem some time to initialize - except Exception: + except Exception as e: logger.error("Couldn't power up the modem !") + logger.exception(e) def power_off(self): self._bus.SetProperty("Powered", pydbus.Variant('b', False)) @@ -124,9 +125,10 @@ def main(): mainloop = GLib.MainLoop() # todo : own thread mainloop.run() except KeyboardInterrupt: - logger.info("Caught CTRL-C:exiting without powering off...") - except AttributeError: + logger.info("Caught CTRL-C : exiting without powering off...") + except Exception as e: logger.error("Error while starting ofono bridge ! Powering off...") + logger.exception(e) ofono.power_off() From 2a084a69c9e5cfe23ff9b85486285c8ccd6717a4 Mon Sep 17 00:00:00 2001 From: monsieur_h Date: Mon, 15 Jan 2018 14:26:24 +0100 Subject: [PATCH 13/40] replaces `print` by `logger.info` --- ofono/bridge.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ofono/bridge.py b/ofono/bridge.py index 145cb633..d4a74673 100644 --- a/ofono/bridge.py +++ b/ofono/bridge.py @@ -65,7 +65,7 @@ def power_off(self): def send_sms(self, to, content): # todo : untested self.message_manager.SendMessage(to, content) - print("Sending", to, content) + logger.info("Sending message to '{}'".format(to)) ConversationManager().on_new_message_sent(to, content) @staticmethod From 08dc9e8203604f75cfc4be6429c7e73830d36f93 Mon Sep 17 00:00:00 2001 From: monsieur_h Date: Mon, 15 Jan 2018 14:27:33 +0100 Subject: [PATCH 14/40] now raises an exception when an interface is not found --- ofono/bridge.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ofono/bridge.py b/ofono/bridge.py index d4a74673..aee8a94a 100644 --- a/ofono/bridge.py +++ b/ofono/bridge.py @@ -47,6 +47,7 @@ def _get_dbus_interface(self, name): full_name = name if name.startswith('org.ofono') else 'org.ofono.{}'.format(name) if full_name in self._bus.GetProperties()['Interfaces']: return self._bus[full_name] + raise Exception("Interface '{}' wasn't found on ofono D-Bus".format(full_name)) def power_on(self): if self._bus.GetProperties()["Powered"]: From da2fb475b56950818499e26ee969fba4ab33fc89 Mon Sep 17 00:00:00 2001 From: monsieur_h Date: Mon, 15 Jan 2018 14:34:35 +0100 Subject: [PATCH 15/40] `ConversationManager` now recursively create log folder --- ofono/bridge.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/ofono/bridge.py b/ofono/bridge.py index aee8a94a..be265919 100644 --- a/ofono/bridge.py +++ b/ofono/bridge.py @@ -1,3 +1,4 @@ +import errno import logging import os from time import sleep @@ -92,8 +93,17 @@ class ConversationManager(Singleton): def __init__(self): super(ConversationManager, self).__init__() self.folder = os.path.expanduser("~/.phone/sms/") # todo: store as a constant somewhere + self._create_folder() + + def _create_folder(self): if not os.path.exists(self.folder): - os.mkdir(self.folder) + try: + os.makedirs(self.folder) + except OSError as exc: # os.makedirs(exist_ok=True) does not exist for python <= 3.2 + if exc.errno == errno.EEXIST and os.path.isdir(self.folder): + pass + else: + raise def on_new_message_sent(self, to, content): logger.info("Sent message to '{}'".format(to)) From 0cf96f20e49ff6c8def94626a33525458515db64 Mon Sep 17 00:00:00 2001 From: CRImier Date: Fri, 15 Jun 2018 21:38:45 +0300 Subject: [PATCH 16/40] Adding a default log level - fixing tests --- apps/personal/contacts/vcard_converter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/personal/contacts/vcard_converter.py b/apps/personal/contacts/vcard_converter.py index a780b1a8..469ab88d 100644 --- a/apps/personal/contacts/vcard_converter.py +++ b/apps/personal/contacts/vcard_converter.py @@ -3,7 +3,7 @@ from address_book import Contact from helpers import setup_logger -logger = setup_logger(__name__) +logger = setup_logger(__name__, "info") class VCardContactConverter(object): From 8e50fd725e865bfbd5d219a3bd3f6c006ce81039 Mon Sep 17 00:00:00 2001 From: CRImier Date: Fri, 15 Jun 2018 21:39:21 +0300 Subject: [PATCH 17/40] Movong the ofono libraries into a separate library --- {ofono => libs/ofono}/__init__.py | 0 {ofono => libs/ofono}/bridge.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename {ofono => libs/ofono}/__init__.py (100%) rename {ofono => libs/ofono}/bridge.py (100%) diff --git a/ofono/__init__.py b/libs/ofono/__init__.py similarity index 100% rename from ofono/__init__.py rename to libs/ofono/__init__.py diff --git a/ofono/bridge.py b/libs/ofono/bridge.py similarity index 100% rename from ofono/bridge.py rename to libs/ofono/bridge.py From 01e72d3ab19c18492978853869b74edaf27518d0 Mon Sep 17 00:00:00 2001 From: CRImier Date: Sun, 17 Jun 2018 01:08:29 +0300 Subject: [PATCH 18/40] WIP, debugging ofono --- libs/__init__.py | 0 libs/ofono/bridge.py | 89 ++++++++++++++++++++++++++++++++++++++------ 2 files changed, 77 insertions(+), 12 deletions(-) create mode 100644 libs/__init__.py diff --git a/libs/__init__.py b/libs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libs/ofono/bridge.py b/libs/ofono/bridge.py index be265919..aa8008ec 100644 --- a/libs/ofono/bridge.py +++ b/libs/ofono/bridge.py @@ -21,6 +21,7 @@ class OfonoBridge(object): def __init__(self): self.modem_path = '/sim900_0' self._check_default_modem() + self.connections = [] def _check_default_modem(self): bus = pydbus.SystemBus() @@ -30,41 +31,70 @@ def _check_default_modem(self): raise ValueError("Default modem should be '{}', was '{}'".format(self.modem_path, modem_path)) def start(self): - self.power_on() + self.power_on_if_off() self._init_messages() self._listen_messages() + self._listen_calls() @property def _bus(self): - """SystemBus().get() returns a snapshot of the current exposed methods. Having it as a property ensures we always - have the latest snapshot (as opposed to storing it on start/after-init)""" + """ + SystemBus().get() returns a snapshot of the current exposed methods. + Having it as a property ensures we always have the latest snapshot + (as opposed to storing it on start/after-init) + """ return pydbus.SystemBus().get('org.ofono', self.modem_path) @property def message_manager(self): return self._get_dbus_interface('MessageManager') + @property + def voicecall_manager(self): + return self._get_dbus_interface('VoiceCallManager') + + @property + def modem(self): + return self._get_dbus_interface('Modem') + def _get_dbus_interface(self, name): full_name = name if name.startswith('org.ofono') else 'org.ofono.{}'.format(name) if full_name in self._bus.GetProperties()['Interfaces']: return self._bus[full_name] raise Exception("Interface '{}' wasn't found on ofono D-Bus".format(full_name)) - def power_on(self): + def power_on_if_off(self): if self._bus.GetProperties()["Powered"]: logger.info("Modem already powered up !") else: logger.info("Powering up modem...") try: self._bus.SetProperty("Powered", pydbus.Variant('b', True)) - sleep(2) # Let the modem some time to initialize + sleep(10) # Let the modem some time to initialize except Exception as e: logger.error("Couldn't power up the modem !") logger.exception(e) + def set_flight_mode(self, mode): + if self._bus.GetProperties()["Powered"]: + logger.info("Modem not powered up, not setting flight mode!") + return + logger.info("Changing the modem's flight mode to {}".format(mode)) + try: + self._bus.SetProperty("Online", pydbus.Variant('b', mode)) + except Exception as e: + logger.error("Couldn't change the flight mode state !") + logger.exception(e) + def power_off(self): self._bus.SetProperty("Powered", pydbus.Variant('b', False)) + def disconnect(self): + for connection in self.connections: + pass #connection.disconnect() + #self._stop_listen_messages() + #self._stop_listen_calls() + def send_sms(self, to, content): # todo : untested self.message_manager.SendMessage(to, content) logger.info("Sending message to '{}'".format(to)) @@ -75,10 +105,43 @@ def on_message_received(message, details, path=None, interface=None): # todo : logger.info("Got message with path {}".format(path)) ConversationManager().on_new_message_received(message, details) + @staticmethod + def on_call_received(*args, **kwargs): # todo : untested + logger.info("Got call, args: {}, kwargs: {}".format(args, kwargs)) + #ConversationManager().on_new_message_received(message, details) + + def debug_callback(self, name): + def wrapper(*args, **kwargs): + print("{}: a {} k {}".format(name, args, kwargs)) + return wrapper + + def add_modem_property_listener(self): + pass #self.connections.append(self.modem.PropertyChanged.connect(self.debug_callback("ModemPropertyChanged"))) + #dbus.SystemBus().add_signal_receiver( + # self.debug_callback("ModemPropertyChanged"), + # 'PropertyChanged', + # 'org.ofono.Modem', + # 'org.ofono', + # '/org/ofono/Modem') + def _listen_messages(self): - logger.info("Connecting to dbus callbacks") - self.message_manager.IncomingMessage.connect(self.on_message_received) - self.message_manager.ImmediateMessage.connect(self.on_message_received) + logger.info("Connecting to dbus message callbacks") + self.connections.append(self.message_manager.ImmediateMessage.connect(self.on_message_received)) + self.connections.append(self.message_manager.IncomingMessage.connect(self.on_message_received)) + self.add_modem_property_listener() + + def _listen_calls(self): + logger.info("Connecting to dbus voicecall callbacks") + self.connections.append(self.voicecall_manager.CallAdded.connect(self.on_call_received)) + + def _stop_listen_messages(self): + logger.info("Disconnecting from dbus message callbacks") + self.message_manager.disconnect() + + def _stop_listen_calls(self): + logger.info("Disconnecting from dbus voicecall callbacks") + #self.voicecall_manager.IncomingMessage.connect(self.on_call_received) + self.voicecall_manager.disconnect() def _init_messages(self): self.message_manager.SetProperty("UseDeliveryReports", pydbus.Variant('b', True)) @@ -111,7 +174,8 @@ def on_new_message_sent(self, to, content): def on_new_message_received(self, content, details): origin = details['Sender'] - logger.info("Received message from'{}'".format(origin)) + logger.info("Received message from '{}'".format(origin)) + print("Message: {}".format(content)) self._write_log(origin, self._format_log(content, from_me=True)) def _write_log(self, phone_number, log): @@ -140,8 +204,9 @@ def main(): except Exception as e: logger.error("Error while starting ofono bridge ! Powering off...") logger.exception(e) - ofono.power_off() - + #ofono.power_off() + ofono.disconnect() + return ofono if __name__ == '__main__': - main() + ofono = main() From 1b5c9e55e6be24fa4ee3c34a7c9a3613f35189f8 Mon Sep 17 00:00:00 2001 From: CRImier Date: Sun, 17 Jun 2018 05:00:41 +0300 Subject: [PATCH 19/40] Latest screenshot are now shown first in screenshot listing app --- apps/utils/screenshot/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/utils/screenshot/main.py b/apps/utils/screenshot/main.py index 6ba421f4..2e64e95a 100644 --- a/apps/utils/screenshot/main.py +++ b/apps/utils/screenshot/main.py @@ -39,7 +39,7 @@ def list_screenshots(): date_part = filename.split('_', 1)[-1].rsplit('.')[0] path = os.path.join(screenshot_folder, filename) mc.append([date_part, lambda x=path: show_screenshot(x)]) - mc = list(sorted(mc)) + mc = list(reversed(sorted(mc))) Menu(mc, i, o, name="Screenshot list ").activate() def callback(): From 54d548f9a50275bc384e633cb28273560845ad7e Mon Sep 17 00:00:00 2001 From: CRImier Date: Fri, 21 Dec 2018 22:46:50 +0200 Subject: [PATCH 20/40] Thinking about architecture - initial changes --- apps/phone/main.py | 155 ++++++++++----------------------------------- 1 file changed, 32 insertions(+), 123 deletions(-) mode change 100755 => 100644 apps/phone/main.py diff --git a/apps/phone/main.py b/apps/phone/main.py old mode 100755 new mode 100644 index 481bcca5..1a1e143d --- a/apps/phone/main.py +++ b/apps/phone/main.py @@ -1,137 +1,46 @@ +from apps import ZeroApp +from ui import Refresher +from ui.base_ui import BaseUIElement +class InputScreen(BaseUIElement): -from helpers import setup_logger + def __init__(self, i, o): + self.i = i + self.o = o -menu_name = "Phone" +class StatusScreen(Refresher): -from subprocess import call as os_call -from time import sleep -import traceback + def __init__(self, *args, **kwargs): + Refresher.__init__(self, self.show_status, *args, **kwargs) -from ui import Refresher, Menu, Printer, PrettyPrinter, DialogBox, ffs -from ui.experimental import NumberKeypadInputLayer -from helpers import BackgroundRunner, ExitHelper + def show_status(self): + pass -from phone import Phone, Modem, ATError +class PhoneApp(ZeroApp): + menu_name = "Phone" -logger = setup_logger(__name__, "warning") + def __init__(self, *args, **kwargs): + ZeroApp.__init__(self, *args, **kwargs) + self.input_screen = InputScreen(self.i, self.o) + self.status_screen = StatusScreen(self.i, self.o) -i = None -o = None -init = None -phone = None + def get_context(self, c): + self.context = c -def answer(): - #No context switching as for now, so all we can do is - #answer call is there's an active one - try: - phone.answer() - except ATError: - pass - -def hangup(): - try: - phone.hangup() - except ATError: - pass - -def phone_status(): - data = [] - status = phone.get_status() - callerid = {} - if status["state"] != "idle": - callerid = phone.get_caller_id() - for key, value in status.iteritems(): - if value: - data.append("{}: {}".format(key, value)) - for key, value in callerid.iteritems(): - data.append("{}: {}".format(key, value)) - return data - -def call(number): - Printer("Calling {}".format(number), i, o, 0) - try: - phone.call(number) - except ATError as e: - Printer(ffs("Calling fail! "+repr(e), o.cols), i, o, 0) - logger.error("Function stopped executing") - -def call_view(): - keymap = {"KEY_ANSWER":[call, "value"]} - NumberKeypadInputLayer(i, o, "Call number", keymap, name="Phone call layer").activate() - -def status_refresher(): - Refresher(phone_status, i, o).activate() + def switch_to_input_screen(self, digit=None): + self.input_screen.activate() + def on_call(self): + self.c.request_switch() + self.show_call_status() -def init_hardware(): - try: - global phone - phone = Phone() - modem = Modem() - phone.attach_modem(modem) - except: - deinit_hardware() - raise + def get_call_status(self): + raise NotImplementedError -def deinit_hardware(): - phone.detach_modem() - -def wait_for_connection(): - eh = ExitHelper(i).start() - while eh.do_run() and init.running and not init.failed: - sleep(1) - eh.stop() - -def check_modem_connection(): - if init.running: - return False - elif init.finished: - return True - elif init.failed: - raise Exception("Modem connection failed!") - else: - raise Exception("Phone app init runner is in invalid state! (never ran?)") - -def init_app(input, output): - global i, o, init - i = input; o = output - i.set_maskable_callback("KEY_ANSWER", answer) - i.set_nonmaskable_callback("KEY_HANGUP", hangup) - try: - #This not good enough - either make the systemctl library system-wide or add more checks - os_call(["systemctl", "stop", "serial-getty@ttyAMA0.service"]) - except Exception as e: - logger.exception(e) - init = BackgroundRunner(init_hardware) - init.run() - -def offer_retry(counter): - do_reactivate = DialogBox("ync", i, o, message="Retry?").activate() - if do_reactivate: - PrettyPrinter("Connecting, try {}...".format(counter), i, o, 0) - init.reset() - init.run() - wait_for_connection() - callback(counter) + def show_call_status(self): + status = self.get_call_status() + pass -def callback(counter=0): - try: - counter += 1 - status = check_modem_connection() - except: - if counter < 3: - PrettyPrinter("Modem connection failed =(", i, o) - offer_retry(counter) - else: - PrettyPrinter("Modem connection failed 3 times", i, o, 1) - else: - if not status: - PrettyPrinter("Connecting...", None, o, 0) - wait_for_connection() - callback(counter) - else: - contents = [["Status", status_refresher], - ["Call", call_view]] - Menu(contents, i, o).activate() + def on_start(self): + pass From becaef5fab1fd2444ead595bc94dcc17e2ce7bac Mon Sep 17 00:00:00 2001 From: CRImier Date: Sun, 6 Jan 2019 07:49:26 +0200 Subject: [PATCH 21/40] Adding the BaseViewMixin, porting UI elements to use it --- ui/base_list_ui.py | 73 +++------------------- ui/base_view_ui.py | 111 ++++++++++++++++++++++++++++++++++ ui/dialog.py | 37 +++++++----- ui/numpad_input.py | 83 +++++++++++++++---------- ui/tests/test_numpad_input.py | 24 +++++++- 5 files changed, 213 insertions(+), 115 deletions(-) create mode 100644 ui/base_view_ui.py diff --git a/ui/base_list_ui.py b/ui/base_list_ui.py index 1face2b1..9cd1d0e2 100644 --- a/ui/base_list_ui.py +++ b/ui/base_list_ui.py @@ -4,12 +4,12 @@ from copy import copy from time import sleep -from threading import Event from entry import Entry from canvas import Canvas from helpers import setup_logger from base_ui import BaseUIElement +from base_view_ui import BaseViewMixin from utils import to_be_foreground, clamp_list_index logger = setup_logger(__name__, "warning") @@ -31,7 +31,7 @@ logger.exception(e) -class BaseListUIElement(BaseUIElement): +class BaseListUIElement(BaseViewMixin, BaseUIElement): """This is a base UI element for list-like UI elements. This UI element has the ability to go into background. It's usually for the cases when an UI element can call another UI element, after the second UI element returns, @@ -47,7 +47,8 @@ class BaseListUIElement(BaseUIElement): exit_entry = ["Back", "exit"] config_key = "base_list_ui" - view_mixin = None + default_pixel_view = "SixteenPtView" + default_char_view = "TextView" def __init__(self, contents, i, o, name=None, entry_height=1, append_exit=True, exitable=True, scrolling=True, config=None, keymap=None, override_left=True): @@ -67,13 +68,11 @@ def __init__(self, contents, i, o, name=None, entry_height=1, append_exit=True, "pointer": 0 } self.reset_scrolling() - self.config = config if config is not None else global_config - self.set_view(self.config.get(self.config_key, {})) + BaseViewMixin.__init__(self, config=config) self.set_contents(contents) - self.inhibit_refresh = Event() - def set_views_dict(self): - self.views = { + def generate_views_dict(self): + return { "TextView": TextView, "EightPtView": EightPtView, "SixteenPtView": SixteenPtView, @@ -81,53 +80,6 @@ def set_views_dict(self): "PrettyGraphicalView": SixteenPtView, # Not a descriptive name - left for compatibility "SimpleGraphicalView": EightPtView # Not a descriptive name - left for compatibility } - if self.view_mixin: - class_name = self.__class__.__name__ - for view_name, view_class in self.views.items(): - if view_class.use_mixin: - name = "{}-{}".format(view_name, class_name) - logger.debug("Subclassing {} into {}".format(view_name, name)) - self.views[view_name] = type(name, (self.view_mixin, view_class), {}) - - def set_view(self, config): - view = None - self.set_views_dict() - if self.name in config.get("custom_views", {}).keys(): - view_config = config["custom_views"][self.name] - if isinstance(view_config, basestring): - if view_config not in self.views: - logger.warning('Unknown view "{}" given for UI element "{}"!'.format(view_config, self.name)) - else: - view = self.views[view_config] - elif isinstance(view_config, dict): - raise NotImplementedError - # This is the part where fine-tuning views will be possible, - # once passing args&kwargs is implemented, that is - else: - logger.error( - "Custom view description can only be a string or a dictionary; is {}!".format(type(view_config))) - elif not view and "default" in config: - view_config = config["default"] - if isinstance(view_config, basestring): - if view_config not in self.views: - logger.warning('Unknown view "{}" given for UI element "{}"!'.format(view_config, self.name)) - else: - view = self.views[view_config] - elif isinstance(view_config, dict): - raise NotImplementedError # Again, this is for fine-tuning - elif not view: - view = self.get_default_view() - self.view = view(self.o, self) - - def get_default_view(self): - """Decides on the view to use for UI element when config file has - no information on it.""" - if "b&w-pixel" in self.o.type: - return self.views["SixteenPtView"] - elif "char" in self.o.type: - return self.views["TextView"] - else: - raise ValueError("Unsupported display type: {}".format(repr(self.o.type))) def before_activate(self): """ @@ -324,17 +276,6 @@ def get_displayed_contents(self): """ return self.contents - def add_view_wrapper(self, wrapper): - self.view.wrappers.append(wrapper) - - @to_be_foreground - def refresh(self): - """ A placeholder to be used for BaseUIElement. """ - if self.inhibit_refresh.isSet(): - return False - self.view.refresh() - return True - # Views. diff --git a/ui/base_view_ui.py b/ui/base_view_ui.py new file mode 100644 index 00000000..2b1ed7a5 --- /dev/null +++ b/ui/base_view_ui.py @@ -0,0 +1,111 @@ +from threading import Event + +from helpers import setup_logger +from utils import to_be_foreground + +logger = setup_logger(__name__, "warning") + +global_config = {} + +# Documentation building process has problems with this import +try: + import ui.config_manager as config_manager +except (ImportError, AttributeError): + pass +else: + cm = config_manager.get_ui_config_manager() + cm.set_path("ui/configs") + try: + global_config = cm.get_global_config() + except OSError as e: + logger.error("Config files not available, running under ReadTheDocs?") + logger.exception(e) + + +class BaseViewMixin(object): + + config_key = None + default_pixel_view = None + default_char_view = None + view_mixin = None + + def __init__(self, **kwargs): + self.config = kwargs.pop("config", global_config) + self.set_view(self.config.get(self.config_key, {})) + self.inhibit_refresh = Event() + + def set_views_dict(self): + self.views = self.generate_views_dict() + self.add_view_mixin() + + def generate_views_dict(self): + raise NotImplementedError + + def add_view_mixin(self): + if self.view_mixin: + class_name = self.__class__.__name__ + for view_name, view_class in self.views.items(): + if view_class.use_mixin: + name = "{}-{}".format(view_name, class_name) + logger.debug("Subclassing {} into {}".format(view_name, name)) + self.views[view_name] = type(name, (self.view_mixin, view_class), {}) + + def set_view(self, config): + view = None + kwargs = {} + self.set_views_dict() + if self.name in config.get("custom_views", {}).keys(): + view_config = config["custom_views"][self.name] + if isinstance(view_config, basestring): + if view_config not in self.views: + logger.warning('Unknown view "{}" given for UI element "{}"!'.format(view_config, self.name)) + else: + view = self.views[view_config] + elif isinstance(view_config, dict): + raise NotImplementedError + # This is the part where fine-tuning views will be possible, + # once passing kwargs is implemented, that is + else: + logger.error( + "Custom view description can only be a string or a dictionary; is {}!".format(type(view_config))) + elif not view and "default" in config: + view_config = config["default"] + if isinstance(view_config, basestring): + if view_config not in self.views: + logger.warning('Unknown view "{}" given for UI element "{}"!'.format(view_config, self.name)) + else: + view = self.views[view_config] + elif isinstance(view_config, dict): + raise NotImplementedError # Again, this is for fine-tuning + elif not view: + view = self.get_default_view() + self.view = self.create_view_object(view, **kwargs) + + def create_view_object(self, view, **kwargs): + return view(self.o, self, **kwargs) + + def get_default_view(self): + """ + Decides on the view to use for UI element when the supplied config + has no information on it. + """ + if self.default_pixel_view and "b&w-pixel" in self.o.type: + return self.views[self.default_pixel_view] + elif self.default_char_view and "char" in self.o.type: + return self.views[self.default_char_view] + else: + raise ValueError("Unsupported display type: {}".format(repr(self.o.type))) + + def add_view_wrapper(self, wrapper): + """ + A function that allows overlays to set view wrappers, + allowing them to modify the on-screen image. + """ + self.view.wrappers.append(wrapper) + + @to_be_foreground + def refresh(self): + if self.inhibit_refresh.isSet(): + return False + self.view.refresh() + return True diff --git a/ui/dialog.py b/ui/dialog.py index 7109a4de..4d5dd262 100644 --- a/ui/dialog.py +++ b/ui/dialog.py @@ -2,11 +2,12 @@ from helpers import setup_logger from base_ui import BaseUIElement +from base_view_ui import BaseViewMixin from canvas import Canvas logger = setup_logger(__name__, "info") -class DialogBox(BaseUIElement): +class DialogBox(BaseViewMixin, BaseUIElement): """Implements a dialog box with given values (or some default ones if chosen).""" value_selected = False @@ -14,7 +15,11 @@ class DialogBox(BaseUIElement): default_options = {"y":["Yes", True], 'n':["No", False], 'c':["Cancel", None]} start_option = 0 - def __init__(self, values, i, o, message="Are you sure?", name="DialogBox"): + config_key = "dialog" + default_pixel_view = "GraphicalView" + default_char_view = "TextView" + + def __init__(self, values, i, o, message="Are you sure?", name="DialogBox", config={}): """Initialises the DialogBox object. Args: @@ -50,16 +55,12 @@ def __init__(self, values, i, o, message="Are you sure?", name="DialogBox"): values[i] = self.default_options[value] self.values = values self.message = message - self.set_view() + BaseViewMixin.__init__(self, config=config) - def set_view(self): - if "b&w-pixel" in self.o.type: - view_class = GraphicalView - elif "char" in self.o.type: - view_class = TextView - else: - raise ValueError("Unsupported display type: {}".format(repr(self.o.type))) - self.view = view_class(self.o, self) + def generate_views_dict(self): + return { + "TextView": TextView, + "GraphicalView": GraphicalView} def set_start_option(self, option_number): """ @@ -108,15 +109,13 @@ def accept_value(self): self.value_selected = True self.deactivate() - def refresh(self): - self.view.refresh() - class TextView(object): def __init__(self, o, el): self.o = o self.el = el + self.wrappers = [] self.process_values() def process_values(self): @@ -136,7 +135,10 @@ def process_values(self): def refresh(self): self.o.noCursor() self.o.setCursor(1, self.positions[self.el.selected_option]) - self.o.display_data(self.el.message, self.displayed_label) + data = [self.el.message, self.displayed_label] + for wrapper in self.wrappers: + data = wrapper(data) + self.o.display_data(*data) self.o.cursor() @@ -167,4 +169,7 @@ def get_image(self): return c.get_image() def refresh(self): - self.o.display_image(self.get_image()) + image = self.get_image() + for wrapper in self.wrappers: + image = wrapper(image) + self.o.display_image(image) diff --git a/ui/numpad_input.py b/ui/numpad_input.py index 7f2d4e3b..eb0fccfd 100644 --- a/ui/numpad_input.py +++ b/ui/numpad_input.py @@ -6,6 +6,7 @@ from helpers import setup_logger, remove_left_failsafe from utils import to_be_foreground, check_value_lock from base_ui import BaseUIElement +from base_view_ui import BaseViewMixin logger = setup_logger(__name__, "warning") @@ -34,7 +35,7 @@ def wrapper(self, *args, **kwargs): return decorator -class NumpadCharInput(BaseUIElement): +class NumpadCharInput(BaseViewMixin, BaseUIElement): """Implements a character input UI element for a numeric keypad, allowing to translate numbers into characters. Attributes: @@ -78,7 +79,10 @@ class NumpadCharInput(BaseUIElement): current_letter_num = 0 __locked_name__ = None - def __init__(self, i, o, message="Value:", value="", name="NumpadCharInput", mapping=None): + config_key = "dialog" + default_char_view = "TextView" + + def __init__(self, i, o, message="Value:", value="", name="NumpadCharInput", mapping=None, config={}): """Initialises the NumpadCharInput object. Args: @@ -90,6 +94,7 @@ def __init__(self, i, o, message="Value:", value="", name="NumpadCharInput", map """ BaseUIElement.__init__(self, i, o, name) + BaseViewMixin.__init__(self, config=config) self.message = message self.value = value self.position = len(self.value) @@ -101,6 +106,9 @@ def __init__(self, i, o, message="Value:", value="", name="NumpadCharInput", map self.value_lock = Lock() self.value_accepted = False + def generate_views_dict(self): + return {"TextView": TextView} + def before_foreground(self): self.value_accepted = False self.in_foreground = True @@ -289,36 +297,6 @@ def get_displayed_value(self): """ return self.value - def get_displayed_data(self): - """Experimental: not meant for 2x16 displays - - Formats the value and the message to show it on the screen, then returns a list that can be directly used by o.display_data""" - displayed_data = [self.message] - screen_rows = self.o.rows - screen_cols = self.o.cols - static_line_count = 2 #One for message, another for context key labels - value = self.get_displayed_value() - lines_taken_by_value = (len(value) / (screen_cols)) + 1 - for line_i in range(lines_taken_by_value): - displayed_data.append(value[(line_i*screen_cols):][:screen_cols]) - empty_line_count = screen_rows - (static_line_count + lines_taken_by_value) - for _ in range(empty_line_count): - displayed_data.append("") #Just empty line - third_line_length = screen_cols/3 - button_labels = [button.center(third_line_length) for button in self.bottom_row_buttons] - last_line = "".join(button_labels) - displayed_data.append(last_line) - return displayed_data - - @to_be_foreground - def refresh(self): - """Function that is called each time data has to be output on display""" - cursor_y, cursor_x = divmod(self.position, self.o.cols) - cursor_y += 1 - self.o.setCursor(cursor_y, cursor_x) - self.o.display_data(*self.get_displayed_data()) - logger.debug("{}: refreshed data on display".format(self.name)) - #Debug-related functions. def print_value(self): @@ -401,3 +379,44 @@ class NumpadKeyboardInput(NumpadCharInput): default_mapping[c] += c default_mapping["SPACE"] = " " + +# Views + +class TextView(object): + wrappers = [] + + def __init__(self, o, el): + self.el = el + self.o = o + + def get_displayed_data(self): + """Experimental: not meant for 2x16 displays + + Formats the value and the message to show it on the screen, then returns a list that can be directly used by o.display_data""" + displayed_data = [self.el.message] + screen_rows = self.o.rows + screen_cols = self.o.cols + static_line_count = 2 #One for message, another for context key labels + value = self.el.get_displayed_value() + lines_taken_by_value = (len(value) / (screen_cols)) + 1 + for line_i in range(lines_taken_by_value): + displayed_data.append(value[(line_i*screen_cols):][:screen_cols]) + empty_line_count = screen_rows - (static_line_count + lines_taken_by_value) + for _ in range(empty_line_count): + displayed_data.append("") #Just empty line + third_line_length = screen_cols/3 + button_labels = [button.center(third_line_length) for button in self.el.bottom_row_buttons] + last_line = "".join(button_labels) + displayed_data.append(last_line) + return displayed_data + + def refresh(self): + """Function that is called each time data has to be output on display""" + cursor_y, cursor_x = divmod(self.el.position, self.o.cols) + cursor_y += 1 + data = self.get_displayed_data() + for wrapper in self.wrappers: + data = wrapper(data) + self.o.setCursor(cursor_y, cursor_x) + self.o.display_data(*data) + diff --git a/ui/tests/test_numpad_input.py b/ui/tests/test_numpad_input.py index e885a23e..9720e1fb 100644 --- a/ui/tests/test_numpad_input.py +++ b/ui/tests/test_numpad_input.py @@ -26,12 +26,15 @@ def import_mock(name, *args): def get_mock_input(): return Mock(maskable_keymap=["KEY_LEFT"]) - def get_mock_output(rows=8, cols=21): m = Mock() m.configure_mock(rows=rows, cols=cols, type=["char"]) return m +def get_mock_graphical_output(width=128, height=64, mode="1", **kwargs): + m = get_mock_output(**kwargs) + m.configure_mock(width=width, height=height, device_mode=mode, type=["char", "b&w-pixel"]) + return m ni_name = "Test NumpadCharInput" @@ -136,6 +139,25 @@ def scenario(): assert o.display_data.call_count == 1 #One in to_foreground assert o.display_data.call_args[0] == ('Test:', '', '', '', '', '', '', ' Cancel OK Erase ') + def test_shows_data_on_graphical_screen(self): + """Tests whether the NumpadCharInput outputs data on a graphical screen when it's ran""" + i = get_mock_input() + o = get_mock_graphical_output() + ni = self.cls(i, o, message="Test:", name=ni_name) + + def scenario(): + ni.deactivate() + + with patch.object(ni, 'idle_loop', side_effect=scenario) as p: + ni.activate() + #The scenario should only be called once + assert ni.idle_loop.called + assert ni.idle_loop.call_count == 1 + + assert o.display_data.called + assert o.display_data.call_count == 1 #One in to_foreground + assert o.display_data.call_args[0] == ('Test:', '', '', '', '', '', '', ' Cancel OK Erase ') + class TestNumpadPasswordInput(TestNumpadCharInput): """test NumpadPasswordInput class""" From 85e74ce681300ed47cc08487c21ca607f154f43e Mon Sep 17 00:00:00 2001 From: CRImier Date: Sun, 6 Jan 2019 07:49:54 +0200 Subject: [PATCH 22/40] Fixing method's return type in declaration --- ui/canvas.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/canvas.py b/ui/canvas.py index e8cfded9..18bba5b9 100644 --- a/ui/canvas.py +++ b/ui/canvas.py @@ -435,7 +435,7 @@ def centered_text(self, text, cw=None, ch=None, font=None): self.display_if_interactive() def get_text_bounds(self, text, font=None): - # type: str -> Rect + # type: str -> tuple """ Returns the dimensions for a given text. If you use a non-default font, pass it as ``font``. From 2a40a206bacd47de9a344576f87c6b11586eb361 Mon Sep 17 00:00:00 2001 From: CRImier Date: Sun, 6 Jan 2019 07:50:30 +0200 Subject: [PATCH 23/40] Number input, overlays and value pagination work --- apps/phone/main.py | 74 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 67 insertions(+), 7 deletions(-) diff --git a/apps/phone/main.py b/apps/phone/main.py index 1a1e143d..9f17fb04 100644 --- a/apps/phone/main.py +++ b/apps/phone/main.py @@ -1,12 +1,66 @@ from apps import ZeroApp -from ui import Refresher +from ui import Refresher, NumpadCharInput, Canvas, FunctionOverlay from ui.base_ui import BaseUIElement -class InputScreen(BaseUIElement): +class InputScreen(NumpadCharInput): + + message = "Input number:" + default_pixel_view = "InputScreenView" + def __init__(self, i, o, *args, **kwargs): + kwargs["message"] = self.message + NumpadCharInput.__init__(self, i, o, *args, **kwargs) + self.value = "123456789023456789012345678901234567890" + + def generate_views_dict(self): + d = NumpadCharInput.generate_views_dict(self) + d.update({"InputScreenView":InputScreenView}) + return d + + +class InputScreenView(object): + + def __init__(self, o, el): + self.o = o + self.el = el + self.wrappers = [] + self.c = Canvas(self.o) + self.value_height = 16 + self.top_offset = 8 + self.value_font = ("Fixedsys62.ttf", self.value_height) + + def gtb(self, text, font): + return self.c.get_text_bounds(text, font=font) + + def get_onscreen_value_parts(self, value_parts): + return value_parts[-3:] + + def get_displayed_image(self): + self.c.clear() + value = self.el.get_displayed_value() + value_parts = self.paginate_value(value, self.value_font, width=self.o.width-6) + onscreen_value_parts = self.get_onscreen_value_parts(value_parts) + for i, value_part in enumerate(onscreen_value_parts): + self.c.text(value_part, (3, self.top_offset+self.value_height*i), font=self.value_font) + return self.c.get_image() + + def paginate_value(self, value, font, width=None): + width = width if width else self.o.width + value_parts = [] + while value: + counter = 0 + while self.gtb(value[:counter], font)[0] <= width and value[counter:]: + counter += 1 + value_parts.append(value[:counter]) + value = value[counter:] + print(value_parts) + return value_parts + + def refresh(self): + image = self.get_displayed_image() + for wrapper in self.wrappers: + image = wrapper(image) + self.o.display_image(image) - def __init__(self, i, o): - self.i = i - self.o = o class StatusScreen(Refresher): @@ -16,6 +70,7 @@ def __init__(self, *args, **kwargs): def show_status(self): pass + class PhoneApp(ZeroApp): menu_name = "Phone" @@ -23,7 +78,12 @@ class PhoneApp(ZeroApp): def __init__(self, *args, **kwargs): ZeroApp.__init__(self, *args, **kwargs) self.input_screen = InputScreen(self.i, self.o) - self.status_screen = StatusScreen(self.i, self.o) + self.insc_overlay = FunctionOverlay(["deactivate", self.insc_options]) + self.insc_overlay.apply_to(self.input_screen) + #self.status_screen = StatusScreen(self.i, self.o) + + def insc_options(self): + pass def get_context(self, c): self.context = c @@ -43,4 +103,4 @@ def show_call_status(self): pass def on_start(self): - pass + self.switch_to_input_screen() From 47350dcbb194c53710a1ef24b18ecbf7d636b651 Mon Sep 17 00:00:00 2001 From: CRImier Date: Sun, 6 Jan 2019 08:12:22 +0200 Subject: [PATCH 24/40] Adding BaseView class and porting everything to it, porting CharArrowKeysInput to use baseViewMixin --- ui/base_list_ui.py | 15 +++++---------- ui/base_view_ui.py | 33 +++++++++++++++++++++++++++++++++ ui/char_input.py | 32 ++++++++++++++++---------------- ui/dialog.py | 15 +++++---------- ui/numpad_input.py | 10 ++-------- 5 files changed, 61 insertions(+), 44 deletions(-) diff --git a/ui/base_list_ui.py b/ui/base_list_ui.py index 9cd1d0e2..30159373 100644 --- a/ui/base_list_ui.py +++ b/ui/base_list_ui.py @@ -9,7 +9,7 @@ from canvas import Canvas from helpers import setup_logger from base_ui import BaseUIElement -from base_view_ui import BaseViewMixin +from base_view_ui import BaseViewMixin, BaseView from utils import to_be_foreground, clamp_list_index logger = setup_logger(__name__, "warning") @@ -279,18 +279,15 @@ def get_displayed_contents(self): # Views. -class TextView(object): +class TextView(BaseView): use_mixin = True first_displayed_entry = 0 scrolling_speed_divisor = 4 fde_increment = 1 - # Default wrapper def __init__(self, o, ui_element): - self.o = o - self.el = ui_element + BaseView.__init__(self, o, ui_element) self.entry_height = self.el.entry_height - self.wrappers = [] self.setup_scrolling() def setup_scrolling(self): @@ -423,8 +420,7 @@ def refresh(self): logger.debug("{}: refreshed data on display".format(self.el.name)) self.fix_pointers_on_refresh() displayed_data = self.get_displayed_text(self.el.get_displayed_contents()) - for wrapper in self.wrappers: - displayed_data = wrapper(displayed_data) + displayed_data = self.execute_wrappers(displayed_data) self.o.noCursor() self.o.display_data(*displayed_data) self.o.setCursor(self.get_active_line_num(), 0) @@ -454,8 +450,7 @@ def refresh(self): logger.debug("{}: refreshed data on display".format(self.el.name)) self.fix_pointers_on_refresh() image = self.get_displayed_image() - for wrapper in self.wrappers: - image = wrapper(image) + image = self.execute_wrappers(image) self.o.display_image(image) def scrollbar_needed(self, contents): diff --git a/ui/base_view_ui.py b/ui/base_view_ui.py index 2b1ed7a5..bec94bac 100644 --- a/ui/base_view_ui.py +++ b/ui/base_view_ui.py @@ -109,3 +109,36 @@ def refresh(self): return False self.view.refresh() return True + + +class BaseView(object): + + def __init__(self, o, el): + self.o = o + self.el = el + self.wrappers = [] + + def execute_wrappers(self, data): + """ + For all the defined wrappers, passes the data to be displayed + (whether it's text or an image) through them. + """ + for wrapper in self.wrappers: + data = wrapper(data) + return data + + def get_displayed_image(self): + """ Needs to be implemented by child views. """ + raise NotImplementedError + + def graphical_refresh(self): + """ + A very cookie-cutter graphical refresh for a view, + might not even need to be redefined. + """ + image = self.get_displayed_image() + image = self.execute_wrappers(image) + self.o.display_image(image) + + def refresh(self): + return self.graphical_refresh() diff --git a/ui/char_input.py b/ui/char_input.py index c7d5f598..3234f4f2 100644 --- a/ui/char_input.py +++ b/ui/char_input.py @@ -7,9 +7,10 @@ from utils import to_be_foreground from canvas import Canvas from base_ui import BaseUIElement +from base_view_ui import BaseViewMixin, BaseView -class CharArrowKeysInput(BaseUIElement): +class CharArrowKeysInput(BaseViewMixin, BaseUIElement): """ Implements a character input dialog which allows to input a character string using arrow keys to scroll through characters """ @@ -37,7 +38,11 @@ class CharArrowKeysInput(BaseUIElement): cancel_flag = False charmap = "" - def __init__(self, i, o, message="Value:", value="", allowed_chars=['][S', '][c', '][C', '][s', '][n'], name="CharArrowKeysInput", initial_value=""): + config_key = "char_arrow_keys_input" + default_pixel_view = "GraphicalView" + default_char_view = "TextView" + + def __init__(self, i, o, message="Value:", value="", allowed_chars=['][S', '][c', '][C', '][s', '][n'], name="CharArrowKeysInput", initial_value="", config={}): """Initialises the CharArrowKeysInput object. Args: @@ -76,16 +81,12 @@ def __init__(self, i, o, message="Value:", value="", allowed_chars=['][S', '][c self.char_indices = [] #Fixes a bug with char_indices remaining from previous input ( 0_0 ) for char in self.value: self.char_indices.append(self.charmap.index(char)) - self.set_view() + BaseViewMixin.__init__(self, config=config) - def set_view(self): - if "b&w-pixel" in self.o.type: - view_class = GraphicalView - elif "char" in self.o.type: - view_class = TextView - else: - raise ValueError("Unsupported display type: {}".format(repr(self.o.type))) - self.view = view_class(self.o, self) + + def generate_views_dict(self): + return {"GraphicalView":GraphicalView, + "TextView": TextView} def get_return_value(self): if self.cancel_flag: @@ -200,14 +201,13 @@ def refresh(self): logger.debug("{}: refreshed data on display".format(self.name)) -class TextView(object): +class TextView(BaseView): last_displayed_char = 0 first_displayed_char = 0 def __init__(self, o, el): - self.o = o - self.el = el + BaseView.__init__(self, o, el) self.last_displayed_char = self.o.cols def get_displayed_data(self): @@ -237,7 +237,7 @@ def refresh(self): class GraphicalView(TextView): - def get_image(self): + def get_displayed_image(self): c = Canvas(self.o) #Getting displayed data, drawing it @@ -261,4 +261,4 @@ def get_image(self): return c.get_image() def refresh(self): - self.o.display_image(self.get_image()) + return self.graphical_refresh() diff --git a/ui/dialog.py b/ui/dialog.py index 4d5dd262..62b45e5d 100644 --- a/ui/dialog.py +++ b/ui/dialog.py @@ -2,7 +2,7 @@ from helpers import setup_logger from base_ui import BaseUIElement -from base_view_ui import BaseViewMixin +from base_view_ui import BaseViewMixin, BaseView from canvas import Canvas logger = setup_logger(__name__, "info") @@ -110,12 +110,10 @@ def accept_value(self): self.deactivate() -class TextView(object): +class TextView(BaseView): def __init__(self, o, el): - self.o = o - self.el = el - self.wrappers = [] + BaseView.__init__(self, o, el) self.process_values() def process_values(self): @@ -144,7 +142,7 @@ def refresh(self): class GraphicalView(TextView): - def get_image(self): + def get_displayed_image(self): c = Canvas(self.o) #Drawing text @@ -169,7 +167,4 @@ def get_image(self): return c.get_image() def refresh(self): - image = self.get_image() - for wrapper in self.wrappers: - image = wrapper(image) - self.o.display_image(image) + return self.graphical_refresh() diff --git a/ui/numpad_input.py b/ui/numpad_input.py index eb0fccfd..a7752e40 100644 --- a/ui/numpad_input.py +++ b/ui/numpad_input.py @@ -6,7 +6,7 @@ from helpers import setup_logger, remove_left_failsafe from utils import to_be_foreground, check_value_lock from base_ui import BaseUIElement -from base_view_ui import BaseViewMixin +from base_view_ui import BaseViewMixin, BaseView logger = setup_logger(__name__, "warning") @@ -382,13 +382,7 @@ class NumpadKeyboardInput(NumpadCharInput): # Views -class TextView(object): - wrappers = [] - - def __init__(self, o, el): - self.el = el - self.o = o - +class TextView(BaseView): def get_displayed_data(self): """Experimental: not meant for 2x16 displays From 917f7f6703f4520e098c848ae68c40b88cb5d07d Mon Sep 17 00:00:00 2001 From: CRImier Date: Sun, 6 Jan 2019 08:15:50 +0200 Subject: [PATCH 25/40] Ported InputScreenView to use BaseView --- apps/phone/main.py | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/apps/phone/main.py b/apps/phone/main.py index 9f17fb04..f999e046 100644 --- a/apps/phone/main.py +++ b/apps/phone/main.py @@ -1,6 +1,7 @@ from apps import ZeroApp from ui import Refresher, NumpadCharInput, Canvas, FunctionOverlay from ui.base_ui import BaseUIElement +from ui.base_view_ui import BaseView class InputScreen(NumpadCharInput): @@ -17,16 +18,15 @@ def generate_views_dict(self): return d -class InputScreenView(object): +class InputScreenView(BaseView): + + top_offset = 8 + value_height = 16 + value_font = ("Fixedsys62.ttf", value_height) def __init__(self, o, el): - self.o = o - self.el = el - self.wrappers = [] + BaseView.__init__(self, o, el) self.c = Canvas(self.o) - self.value_height = 16 - self.top_offset = 8 - self.value_font = ("Fixedsys62.ttf", self.value_height) def gtb(self, text, font): return self.c.get_text_bounds(text, font=font) @@ -55,12 +55,6 @@ def paginate_value(self, value, font, width=None): print(value_parts) return value_parts - def refresh(self): - image = self.get_displayed_image() - for wrapper in self.wrappers: - image = wrapper(image) - self.o.display_image(image) - class StatusScreen(Refresher): From 44d6f443539ec52bfeb42642166630462264b3d8 Mon Sep 17 00:00:00 2001 From: CRImier Date: Sun, 6 Jan 2019 08:22:25 +0200 Subject: [PATCH 26/40] Forgot a small thing --- ui/dialog.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ui/dialog.py b/ui/dialog.py index 62b45e5d..bb19eacb 100644 --- a/ui/dialog.py +++ b/ui/dialog.py @@ -134,8 +134,7 @@ def refresh(self): self.o.noCursor() self.o.setCursor(1, self.positions[self.el.selected_option]) data = [self.el.message, self.displayed_label] - for wrapper in self.wrappers: - data = wrapper(data) + data = self.execute_wrappers(data) self.o.display_data(*data) self.o.cursor() From b04f18593997aed8dea99489296cbcfbe147a548 Mon Sep 17 00:00:00 2001 From: CRImier Date: Sun, 6 Jan 2019 13:05:08 +0200 Subject: [PATCH 27/40] Broke the launch process, fixing --- ui/base_view_ui.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/base_view_ui.py b/ui/base_view_ui.py index bec94bac..087fe19d 100644 --- a/ui/base_view_ui.py +++ b/ui/base_view_ui.py @@ -30,7 +30,8 @@ class BaseViewMixin(object): view_mixin = None def __init__(self, **kwargs): - self.config = kwargs.pop("config", global_config) + config = kwargs.pop("config", None) + self.config = config if config is not None else global_config self.set_view(self.config.get(self.config_key, {})) self.inhibit_refresh = Event() From 9887af1f6d340834cdaee370165a67444cb6ee34 Mon Sep 17 00:00:00 2001 From: CRImier Date: Sun, 6 Jan 2019 15:55:07 +0200 Subject: [PATCH 28/40] moving BaseViewMixin.__init__ - making it possible for the view to process the mapping in view's __init__ --- ui/numpad_input.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/numpad_input.py b/ui/numpad_input.py index a7752e40..569a9684 100644 --- a/ui/numpad_input.py +++ b/ui/numpad_input.py @@ -94,7 +94,6 @@ def __init__(self, i, o, message="Value:", value="", name="NumpadCharInput", map """ BaseUIElement.__init__(self, i, o, name) - BaseViewMixin.__init__(self, config=config) self.message = message self.value = value self.position = len(self.value) @@ -105,6 +104,7 @@ def __init__(self, i, o, message="Value:", value="", name="NumpadCharInput", map self.mapping = copy(self.default_mapping) self.value_lock = Lock() self.value_accepted = False + BaseViewMixin.__init__(self, config=config) def generate_views_dict(self): return {"TextView": TextView} From 85cf72c35eb8f884ad3c2b20b7d973f3d34e56a8 Mon Sep 17 00:00:00 2001 From: CRImier Date: Sun, 6 Jan 2019 15:56:05 +0200 Subject: [PATCH 29/40] More testing and optimizations in the dialer --- apps/phone/main.py | 59 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/apps/phone/main.py b/apps/phone/main.py index f999e046..3378f313 100644 --- a/apps/phone/main.py +++ b/apps/phone/main.py @@ -1,19 +1,21 @@ from apps import ZeroApp -from ui import Refresher, NumpadCharInput, Canvas, FunctionOverlay +from ui import Refresher, NumpadNumberInput, Canvas, FunctionOverlay from ui.base_ui import BaseUIElement from ui.base_view_ui import BaseView -class InputScreen(NumpadCharInput): +from time import sleep + +class InputScreen(NumpadNumberInput): message = "Input number:" default_pixel_view = "InputScreenView" def __init__(self, i, o, *args, **kwargs): kwargs["message"] = self.message - NumpadCharInput.__init__(self, i, o, *args, **kwargs) - self.value = "123456789023456789012345678901234567890" + self.value = "00000000000000000000000000000000000000000000000000000000012345" + NumpadNumberInput.__init__(self, i, o, value=self.value, *args, **kwargs) def generate_views_dict(self): - d = NumpadCharInput.generate_views_dict(self) + d = NumpadNumberInput.generate_views_dict(self) d.update({"InputScreenView":InputScreenView}) return d @@ -27,6 +29,20 @@ class InputScreenView(BaseView): def __init__(self, o, el): BaseView.__init__(self, o, el) self.c = Canvas(self.o) + # storage for text pagination optimisation + self.width_cache = {} + self.character_width_cache = {} + self.build_charwidth_cache() + print(self.character_width_cache) + self.prev_value_parts = [] + + def build_charwidth_cache(self): + for char in "".join(self.el.mapping.values()): + self.character_width_cache[char] = self.gtb(char, font=self.value_font)[0] + + def gtw(self, text, font): + #print("{} - {}".format(text, [self.character_width_cache[c] for c in text])) + return sum([self.character_width_cache[c] for c in text]) def gtb(self, text, font): return self.c.get_text_bounds(text, font=font) @@ -37,21 +53,44 @@ def get_onscreen_value_parts(self, value_parts): def get_displayed_image(self): self.c.clear() value = self.el.get_displayed_value() - value_parts = self.paginate_value(value, self.value_font, width=self.o.width-6) + value_parts = self.paginate_value(value, self.value_font, width=self.o.width-6, cache=self.prev_value_parts) + self.prev_value_parts = value_parts onscreen_value_parts = self.get_onscreen_value_parts(value_parts) for i, value_part in enumerate(onscreen_value_parts): self.c.text(value_part, (3, self.top_offset+self.value_height*i), font=self.value_font) return self.c.get_image() - def paginate_value(self, value, font, width=None): + def paginate_value(self, value, font, width=None, cache=None): + # This function was optimised because I felt like it was kinda slow... + # Specifically, because of all the get_text_bounds calls. + # Now I'm not sure it was worth it - probably was? width = width if width else self.o.width value_parts = [] + # Let's optimise the counting a little bit and reuse the results of last + # pagination to speed this one up + if cache: + for part in cache[:-1]: #Except the last part since it's incomplete + if value.startswith(part): + value_parts.append(part) + value = value[len(part):] + # Now, onto checking the last part - maybe it's overflown, + # or maybe it's the first run and we haven't yet calculated the cache + # (i.e. someone supplied a long value to __init__ of the UI element) + # Let's go from the end - that will be faster for the most likely usecase. while value: counter = 0 - while self.gtb(value[:counter], font)[0] <= width and value[counter:]: + while counter < len(value): + shown_part = value[:-counter] if counter != 0 else value + remainder = value[-counter:] if counter != 0 else "" + width_of_next = self.width_cache.get(shown_part, None) + if width_of_next is None: + width_of_next = self.gtw(shown_part, font) + self.width_cache[shown_part] = width_of_next + if width_of_next < width: + break counter += 1 - value_parts.append(value[:counter]) - value = value[counter:] + value_parts.append(shown_part) + value = remainder print(value_parts) return value_parts From 377c14674df95734d3524332bccd0812bbb11003 Mon Sep 17 00:00:00 2001 From: CRImier Date: Sun, 6 Jan 2019 17:06:37 +0200 Subject: [PATCH 30/40] Adding BaseView functions for the usual character refresh, porting IntegerAdjust to use views --- ui/base_list_ui.py | 15 +++++++-------- ui/base_view_ui.py | 23 +++++++++++++++++++++++ ui/char_input.py | 30 ++++++++++++++++-------------- ui/dialog.py | 13 +++++++------ ui/number_input.py | 32 ++++++++++++++++++++++---------- ui/numpad_input.py | 12 +++++------- 6 files changed, 80 insertions(+), 45 deletions(-) diff --git a/ui/base_list_ui.py b/ui/base_list_ui.py index 30159373..267d0ca2 100644 --- a/ui/base_list_ui.py +++ b/ui/base_list_ui.py @@ -415,16 +415,15 @@ def render_displayed_entry_text(self, entry_num, contents): def get_active_line_num(self): return (self.el.pointer - self.first_displayed_entry) * self.entry_height - @to_be_foreground + def get_displayed_data(self): + return self.get_displayed_text(self.el.get_displayed_contents()) + + def get_cursor_pos(self): + return (self.get_active_line_num(), 0) + def refresh(self): - logger.debug("{}: refreshed data on display".format(self.el.name)) self.fix_pointers_on_refresh() - displayed_data = self.get_displayed_text(self.el.get_displayed_contents()) - displayed_data = self.execute_wrappers(displayed_data) - self.o.noCursor() - self.o.display_data(*displayed_data) - self.o.setCursor(self.get_active_line_num(), 0) - self.o.cursor() + self.character_refresh() class EightPtView(TextView): diff --git a/ui/base_view_ui.py b/ui/base_view_ui.py index 087fe19d..599fd884 100644 --- a/ui/base_view_ui.py +++ b/ui/base_view_ui.py @@ -141,5 +141,28 @@ def graphical_refresh(self): image = self.execute_wrappers(image) self.o.display_image(image) + def get_cursor_pos(self): + """ + A helper function to get cursor coordinates - if anything other than None + is returned, the cursor will be drawn in the character view. + """ + return None + + def character_refresh(self): + """ + A very cookie-cutter char display refresh for a view, + might not even need to be redefined. Draws the cursor + if ``self.get_cursor_pos()`` returns anything other than ``None``. + """ + data = self.get_displayed_data() + data = self.execute_wrappers(data) + cursor_pos = self.get_cursor_pos() + if cursor_pos is not None: + self.o.noCursor() + self.o.setCursor(*cursor_pos) + self.o.display_data(*data) + if cursor_pos is not None: + self.o.cursor() + def refresh(self): return self.graphical_refresh() diff --git a/ui/char_input.py b/ui/char_input.py index 3234f4f2..7e4d041a 100644 --- a/ui/char_input.py +++ b/ui/char_input.py @@ -140,10 +140,8 @@ def move_down(self): def move_right(self): """Moves cursor to the next element. """ self.check_for_backspace() + self.view.fix_pointers_before_refresh() self.position += 1 - if self.view.last_displayed_char < self.position: #Went too far to the part of the value that isn't currently displayed - self.view.last_displayed_char = self.position - self.view.first_displayed_char = self.position - self.o.cols self.refresh() @to_be_foreground @@ -153,10 +151,8 @@ def move_left(self): if self.position == 0: self.exit() return + self.view.fix_pointers_before_refresh() self.position -= 1 - if self.view.first_displayed_char > self.position: #Went too far back to the part that's not currently displayed - self.view.first_displayed_char = self.position - self.view.last_displayed_char = self.position + self.o.cols self.refresh() @to_be_foreground @@ -203,14 +199,21 @@ def refresh(self): class TextView(BaseView): - last_displayed_char = 0 first_displayed_char = 0 def __init__(self, o, el): BaseView.__init__(self, o, el) - self.last_displayed_char = self.o.cols + + def fix_pointers_before_refresh(self): + if self.first_displayed_char+self.o.cols < self.el.position: #Went too far to the part of the value that isn't currently displayed + self.first_displayed_char = self.el.position - self.o.cols + if self.first_displayed_char > self.el.position: #Went too far back to the part that's not currently displayed + self.first_displayed_char = self.el.position def get_displayed_data(self): + return self.convert_chars_to_hd44780_charset( *self.get_displayed_text() ) + + def get_displayed_text(self): """ Formats the value and the message to show it on the screen, then returns a list that can be directly used by o.display_data. @@ -227,12 +230,11 @@ def convert_chars_to_hd44780_charset(self, message, value): value = value.replace(' ', chr(255)) #Displaying all spaces as black boxes return message, value + def get_cursor_pos(self): + return (1, self.el.position-self.first_displayed_char) + def refresh(self): - self.o.noCursor() - #self.o.cursor()# Only needed for testing TextView on luma.oled - displayed_data = self.convert_chars_to_hd44780_charset( *self.get_displayed_data() ) - self.o.display_data(*displayed_data) - self.o.cursor() + return self.character_refresh() class GraphicalView(TextView): @@ -241,7 +243,7 @@ def get_displayed_image(self): c = Canvas(self.o) #Getting displayed data, drawing it - lines = self.get_displayed_data() + lines = self.get_displayed_text() for i, line in enumerate(lines): y = (i*self.o.char_height - 1) if i != 0 else 0 c.text(line, (2, y)) diff --git a/ui/dialog.py b/ui/dialog.py index bb19eacb..1c15da70 100644 --- a/ui/dialog.py +++ b/ui/dialog.py @@ -130,13 +130,14 @@ def process_values(self): self.positions.append(current_position) current_position += len(label) + 1 + def get_cursor_pos(self): + return (1, self.positions[self.el.selected_option]) + + def get_displayed_data(self): + return [self.el.message, self.displayed_label] + def refresh(self): - self.o.noCursor() - self.o.setCursor(1, self.positions[self.el.selected_option]) - data = [self.el.message, self.displayed_label] - data = self.execute_wrappers(data) - self.o.display_data(*data) - self.o.cursor() + return self.character_refresh() class GraphicalView(TextView): diff --git a/ui/number_input.py b/ui/number_input.py index 323a98e1..38491b5d 100644 --- a/ui/number_input.py +++ b/ui/number_input.py @@ -5,8 +5,9 @@ from utils import to_be_foreground from base_ui import BaseUIElement +from base_view_ui import BaseViewMixin, BaseView -class IntegerAdjustInput(BaseUIElement): +class IntegerAdjustInput(BaseViewMixin, BaseUIElement): """Implements a simple number input dialog which allows you to increment/decrement a number using which can be used to navigate through your application, output a list of values or select actions to perform. Is one of the most used elements, used both in system core and in most of the applications. Attributes: @@ -23,6 +24,8 @@ class IntegerAdjustInput(BaseUIElement): initial_number = 0 number = 0 selected_number = None + config_key = "integer_adjust" + default_char_view = "TextView" def __init__(self, number, i, o, message="Pick a number:", interval=1, name="IntegerAdjustInput", mode="normal", max=None, min=None): """Initialises the IntegerAdjustInput object. @@ -53,16 +56,27 @@ def __init__(self, number, i, o, message="Pick a number:", interval=1, name="Int self.message = message self.mode = mode self.interval = interval + BaseViewMixin.__init__(self) def get_return_value(self): return self.selected_number + def generate_views_dict(self): + return { + "TextView": TextView} + def idle_loop(self): sleep(0.1) + def get_displayed_number(self): + if self.mode == "hex": + return hex(self.number) + else: + return str(self.number) + def print_number(self): """ A debug method. Useful for hooking up to an input event so that you can see current number value. """ - logger.info(self.number) + logger.info(self.get_displayed_number()) def decrement(self, multiplier=1): """Decrements the number by selected ``interval``""" @@ -117,15 +131,13 @@ def clamp(self): if self.max is not None and self.number > self.max: self.number = self.max + +class TextView(BaseView): + def get_displayed_data(self): - if self.mode == "hex": - number_str = hex(self.number) - else: - number_str = str(self.number) + number_str = self.el.get_displayed_number() number_str = number_str.rjust(self.o.cols) - return [self.message, number_str] + return [self.el.message, number_str] - @to_be_foreground def refresh(self): - logger.debug("{0}: refreshed data on display".format(self.name)) - self.o.display_data(*self.get_displayed_data()) + return self.character_refresh() diff --git a/ui/numpad_input.py b/ui/numpad_input.py index 569a9684..d4022beb 100644 --- a/ui/numpad_input.py +++ b/ui/numpad_input.py @@ -404,13 +404,11 @@ def get_displayed_data(self): displayed_data.append(last_line) return displayed_data - def refresh(self): - """Function that is called each time data has to be output on display""" + def get_cursor_pos(self): cursor_y, cursor_x = divmod(self.el.position, self.o.cols) cursor_y += 1 - data = self.get_displayed_data() - for wrapper in self.wrappers: - data = wrapper(data) - self.o.setCursor(cursor_y, cursor_x) - self.o.display_data(*data) + return (cursor_y, cursor_x) + def refresh(self): + """Function that is called each time data has to be output on display""" + return self.character_refresh() From 1d29e7f94148be358c779625d460bfaa4293a4f7 Mon Sep 17 00:00:00 2001 From: CRImier Date: Sun, 6 Jan 2019 17:50:07 +0200 Subject: [PATCH 31/40] Moving overlays to use update_keymap --- ui/overlays.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/ui/overlays.py b/ui/overlays.py index 5282d94a..cc099207 100644 --- a/ui/overlays.py +++ b/ui/overlays.py @@ -59,20 +59,13 @@ def __init__(self, callback, key = "KEY_F5", **kwargs): BaseOverlayWithTimeout.__init__(self, **kwargs) def apply_to(self, ui_el): - self.wrap_generate_keymap(ui_el) + self.update_keymap(ui_el) self.wrap_view(ui_el) BaseOverlayWithTimeout.apply_to(self, ui_el) - def wrap_generate_keymap(self, ui_el): - generate_keymap = ui_el.generate_keymap - @wraps(generate_keymap) - def wrapper(*args, **kwargs): - keymap = generate_keymap(*args, **kwargs) - key, callback = self.get_key_and_callback() - keymap[key] = ui_el.process_callback(callback) - return keymap - ui_el.generate_keymap = wrapper - ui_el.set_default_keymap() + def update_keymap(self, ui_el): + key, callback = self.get_key_and_callback() + ui_el.update_keymap({key:callback}) def wrap_view(self, ui_el): def wrapper(image): @@ -136,6 +129,9 @@ def __init__(self, keymap, labels=["Exit", "Options"], **kwargs): self.labels = labels BaseOverlayWithTimeout.__init__(self, **kwargs) + def update_keymap(self, ui_el): + ui_el.update_keymap(self.keymap) + def wrap_generate_keymap(self, ui_el): generate_keymap = ui_el.generate_keymap @wraps(generate_keymap) From f92f021721fc8cabff946803f6ac3f94b985c640 Mon Sep 17 00:00:00 2001 From: CRImier Date: Sun, 6 Jan 2019 17:55:54 +0200 Subject: [PATCH 32/40] Updating the overlay options for better button usage --- apps/phone/main.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/phone/main.py b/apps/phone/main.py index 3378f313..df268fb9 100644 --- a/apps/phone/main.py +++ b/apps/phone/main.py @@ -11,8 +11,8 @@ class InputScreen(NumpadNumberInput): default_pixel_view = "InputScreenView" def __init__(self, i, o, *args, **kwargs): kwargs["message"] = self.message - self.value = "00000000000000000000000000000000000000000000000000000000012345" - NumpadNumberInput.__init__(self, i, o, value=self.value, *args, **kwargs) + kwargs["value"] = "00000000000000000000000000000000000000000000000000000000012345" + NumpadNumberInput.__init__(self, i, o, *args, **kwargs) def generate_views_dict(self): d = NumpadNumberInput.generate_views_dict(self) @@ -111,7 +111,7 @@ class PhoneApp(ZeroApp): def __init__(self, *args, **kwargs): ZeroApp.__init__(self, *args, **kwargs) self.input_screen = InputScreen(self.i, self.o) - self.insc_overlay = FunctionOverlay(["deactivate", self.insc_options]) + self.insc_overlay = FunctionOverlay(["deactivate", "backspace"], labels=["Exit", "Backspace"]) self.insc_overlay.apply_to(self.input_screen) #self.status_screen = StatusScreen(self.i, self.o) From 378593e92a39febe2556a209d68c9ed5f39b6084 Mon Sep 17 00:00:00 2001 From: CRImier Date: Mon, 7 Jan 2019 00:45:41 +0200 Subject: [PATCH 33/40] Adding a bunch of functions to work with coordinates, adding tests, moving cflip out of canvas.py, exposting all these functions (and more) in ui/__init__.py --- ui/__init__.py | 3 +- ui/canvas.py | 95 +++++-------------------------- ui/coords.py | 120 ++++++++++++++++++++++++++++++++++++++++ ui/tests/test_coords.py | 84 ++++++++++++++++++++++++++++ 4 files changed, 220 insertions(+), 82 deletions(-) create mode 100644 ui/coords.py create mode 100644 ui/tests/test_coords.py diff --git a/ui/__init__.py b/ui/__init__.py index 6e25d8b6..f703d322 100644 --- a/ui/__init__.py +++ b/ui/__init__.py @@ -20,7 +20,8 @@ from scrollable_element import TextReader from loading_indicators import ProgressBar, LoadingBar, TextProgressBar, GraphicalProgressBar, CircularProgressBar, IdleDottedMessage, Throbber from numbered_menu import NumberedMenu -from canvas import Canvas, MockOutput +from canvas import Canvas, MockOutput, Rect, Image, ImageOps +from coords import check_coordinates, check_coordinate_pairs, offset_points, get_bounds_for_points, expand_coords, convert_flat_list_into_pairs, multiply_points, convert_flat_list_into_pairs from date_picker import DatePicker from time_picker import TimePicker from grid_menu import GridMenu diff --git a/ui/canvas.py b/ui/canvas.py index 18bba5b9..e0e321ad 100644 --- a/ui/canvas.py +++ b/ui/canvas.py @@ -2,7 +2,9 @@ from PIL import Image, ImageDraw, ImageOps, ImageFont -from ui.utils import is_sequence_not_string as issequence, Rect +from ui.utils import Rect + +from coords import check_coordinates, check_coordinate_pairs, convert_flat_list_into_pairs fonts_dir = "ui/fonts/" font_cache = {} @@ -127,7 +129,7 @@ def point(self, coord_pairs, **kwargs): * ``fill``: point color (default: white, as default canvas color) """ - coord_pairs = self.check_coordinate_pairs(coord_pairs) + coord_pairs = check_coordinate_pairs(self, coord_pairs) fill = kwargs.pop("fill", self.default_color) self.draw.point(coord_pairs, fill=fill, **kwargs) self.display_if_interactive() @@ -144,7 +146,7 @@ def line(self, coords, **kwargs): * ``width``: line width (default: 0, which results in a single-pixel-wide line) """ fill = kwargs.pop("fill", self.default_color) - coords = self.check_coordinates(coords) + coords = check_coordinates(self, coords) self.draw.line(coords, fill=fill, **kwargs) self.display_if_interactive() @@ -168,7 +170,7 @@ def text(self, text, coords, **kwargs): fill = kwargs.pop("fill", self.default_color) font = kwargs.pop("font", self.default_font) font = self.decypher_font_reference(font) - coords = self.check_coordinates(coords) + coords = check_coordinates(self, coords) if text: # Errors out on empty text self.draw.text(coords, text, fill=fill, font=font, **kwargs) self.display_if_interactive() @@ -194,7 +196,7 @@ def vertical_text(self, text, coords, **kwargs): font = kwargs.pop("font", self.default_font) charheight = kwargs.pop("charheight", None) font = self.decypher_font_reference(font) - coords = self.check_coordinates(coords) + coords = check_coordinates(self, coords) char_coords = list(coords) if not charheight: # Auto-determining charheight if not available _, charheight = self.draw.textsize("H", font=font) @@ -228,7 +230,7 @@ def custom_shape_text(self, text, coords_cb, **kwargs): font = self.decypher_font_reference(font) for i, char in enumerate(text): coords = coords_cb(i, char) - coords = self.check_coordinates(coords) + coords = check_coordinates(self, coords) self.draw.text(coords, char, fill=fill, font=font, **kwargs) self.display_if_interactive() @@ -244,7 +246,7 @@ def rectangle(self, coords, **kwargs): * ``outline``: outline color (default: white, as default canvas color) * ``fill``: fill color (default: None, as in, transparent) """ - coords = self.check_coordinates(coords) + coords = check_coordinates(self, coords) outline = kwargs.pop("outline", self.default_color) fill = kwargs.pop("fill", None) self.draw.rectangle(coords, outline=outline, fill=fill, **kwargs) @@ -261,7 +263,7 @@ def polygon(self, coord_pairs, **kwargs): * ``outline``: outline color (default: white, as default canvas color) * ``fill``: fill color (default: None, as in, transparent) """ - coord_pairs = self.check_coordinate_pairs(coord_pairs) + coord_pairs = check_coordinate_pairs(self, coord_pairs) outline = kwargs.pop("outline", self.default_color) fill = kwargs.pop("fill", None) self.draw.polygon(coord_pairs, outline=outline, fill=fill, **kwargs) @@ -281,7 +283,7 @@ def circle(self, coords, **kwargs): assert(len(coords) == 3), "Expects three arguments - x center, y center and radius!" radius = coords[2] coords = coords[:2] - coords = self.check_coordinates(coords) + coords = check_coordinates(self, coords) outline = kwargs.pop("outline", self.default_color) fill = kwargs.pop("fill", None) ellipse_coords = (coords[0]-radius, coords[1]-radius, coords[0]+radius, coords[1]+radius) @@ -300,7 +302,7 @@ def ellipse(self, coords, **kwargs): * ``outline``: outline color (default: white, as default canvas color) * ``fill``: fill color (default: None, as in, transparent) """ - coords = self.check_coordinates(coords) + coords = check_coordinates(self, coords) outline = kwargs.pop("outline", self.default_color) fill = kwargs.pop("fill", None) self.draw.ellipse(coords, outline=outline, fill=fill, **kwargs) @@ -353,73 +355,10 @@ def clear(self, coords=None, fill=None): coords = (0, 0, self.width, self.height) if fill is None: fill = self.background_color - coords = self.check_coordinates(coords) + coords = check_coordinates(self, coords) self.rectangle(coords, fill=fill, outline=fill) # paint the background black first self.display_if_interactive() - def check_coordinates(self, coords, check_count=True): - # type: tuple -> tuple - """ - A helper function to check and reformat coordinates supplied to - functions. Currently, accepts integer coordinates, as well as strings - - denoting offsets from opposite sides of the screen. - """ - # Checking for string offset coordinates - # First, we need to make coords into a mutable sequence - thus, a list - coords = list(coords) - for i, c in enumerate(coords): - sign = "+" - if isinstance(c, basestring): - if c.startswith("-"): - sign = "-" - c = c[1:] - assert c.isdigit(), "A numeric string expected, received: {}".format(coords[i]) - offset = int(c) - dim = self.size[i % 2] - if sign == "+": - coords[i] = dim + offset - elif sign == "-": - coords[i] = dim - offset - elif isinstance(c, float): - logger.warning("Received {} as a coordinate - pixel offsets can't be float, converting to int".format(c)) - coords[i] = int(c) - # Restoring the status-quo - coords = tuple(coords) - # Now all the coordinates should be integers - if something slipped by the checks, - # it's of type we don't process and we should raise an exception now - for c in coords: - assert isinstance(c, int), "{} not an integer or 'x' string!".format(c) - if len(coords) == 2: - return coords - elif len(coords) == 4: - x1, y1, x2, y2 = coords - # Not sure those checks make sense - #assert (x2 >= x1), "x2 ({}) is smaller than x1 ({}), rearrange?".format(x2, x1) - #assert (y2 >= y1), "y2 ({}) is smaller than y1 ({}), rearrange?".format(y2, y1) - return coords - else: - if check_count: - raise ValueError("Invalid number of coordinates!") - else: - return coords - - def check_coordinate_pairs(self, coord_pairs): - # type: tuple -> tuple - """ - A helper function to check and reformat coordinate pairs supplied to - functions. Each pair is checked by ``check_coordinates``. - """ - if not all([issequence(c) for c in coord_pairs]): - # Didn't get pairs of coordinates - converting into pairs - # But first, sanity checks - assert (len(coord_pairs) % 2 == 0), "Odd number of coordinates supplied! ({})".format(coord_pairs) - assert all([isinstance(c, (int, basestring)) for i in coord_pairs]), "Coordinates are non-uniform! ({})".format(coord_pairs) - coord_pairs = convert_flat_list_into_pairs(coord_pairs) - coord_pairs = list(coord_pairs) - for i, coord_pair in enumerate(coord_pairs): - coord_pairs[i] = self.check_coordinates(coord_pair) - return tuple(coord_pairs) - def centered_text(self, text, cw=None, ch=None, font=None): # type: str -> None """ @@ -473,7 +412,7 @@ def invert_rect(self, coords): highlighting a part of the image, for example. """ - coords = self.check_coordinates(coords) + coords = check_coordinates(self, coords) image_subset = self.image.crop(coords) if image_subset.mode == "1": @@ -520,9 +459,3 @@ def __init__(self, width=128, height=64, type=None, device_mode='1'): def display_image(self, *args): return True - -def convert_flat_list_into_pairs(l): - pl = [] - for i in range(len(l)/2): - pl.append((l[i*2], l[i*2+1])) - return pl diff --git a/ui/coords.py b/ui/coords.py new file mode 100644 index 00000000..6c13f13b --- /dev/null +++ b/ui/coords.py @@ -0,0 +1,120 @@ +from ui.utils import is_sequence_not_string as issequence + +def check_coordinates(canvas, coords, check_count=True): + # type: tuple -> tuple + """ + A helper function to check and reformat coordinates supplied to + functions. Currently, accepts integer coordinates, as well as strings + - denoting offsets from opposite sides of the screen. + """ + # Checking for string offset coordinates + # First, we need to make coords into a mutable sequence - thus, a list + coords = list(coords) + for i, c in enumerate(coords): + sign = "+" + if isinstance(c, basestring): + if c.startswith("-"): + sign = "-" + c = c[1:] + assert c.isdigit(), "A numeric string expected, received: {}".format(coords[i]) + offset = int(c) + dim = canvas.size[i % 2] + if sign == "+": + coords[i] = dim + offset + elif sign == "-": + coords[i] = dim - offset + elif isinstance(c, float): + logger.warning("Received {} as a coordinate - pixel offsets can't be float, converting to int".format(c)) + coords[i] = int(c) + # Restoring the status-quo + coords = tuple(coords) + # Now all the coordinates should be integers - if something slipped by the checks, + # it's of type we don't process and we should raise an exception now + for c in coords: + assert isinstance(c, int), "{} not an integer or 'x' string!".format(c) + if len(coords) == 2: + return coords + elif len(coords) == 4: + x1, y1, x2, y2 = coords + # Not sure those checks make sense + #assert (x2 >= x1), "x2 ({}) is smaller than x1 ({}), rearrange?".format(x2, x1) + #assert (y2 >= y1), "y2 ({}) is smaller than y1 ({}), rearrange?".format(y2, y1) + return coords + else: + if check_count: + raise ValueError("Invalid number of coordinates!") + else: + return coords + +def check_coordinate_pairs(canvas, coord_pairs): + # type: tuple -> tuple + """ + A helper function to check and reformat coordinate pairs supplied to + functions. Each pair is checked by ``check_coordinates``. + """ + if not all([issequence(c) for c in coord_pairs]): + # Didn't get pairs of coordinates - converting into pairs + # But first, sanity checks + assert (len(coord_pairs) % 2 == 0), "Odd number of coordinates supplied! ({})".format(coord_pairs) + assert all([isinstance(c, (int, basestring)) for i in coord_pairs]), "Coordinates are non-uniform! ({})".format(coord_pairs) + coord_pairs = convert_flat_list_into_pairs(coord_pairs) + coord_pairs = list(coord_pairs) + for i, coord_pair in enumerate(coord_pairs): + coord_pairs[i] = check_coordinates(canvas, coord_pair) + return tuple(coord_pairs) + +def offset_points(points, offset): + """ + Given a list/tuple of points and a two-integer offset tuple + (``(x, y)``), will offset all the points by that ``x`` and ``y`` + and return a tuple. + """ + return tuple([(p[0]+offset[0], p[1]+offset[1]) for p in points]) + +def multiply_points(points, mul): + """ + Given a list/tuple of points and an integer/float multiplier, + will multiply all the point coordinates (both x and y) + and return them. + """ + return tuple([(int(p[0]*mul), int(p[1]*mul)) for p in points]) + +def expand_coords(coords, expand): + """ + Expands 4 coordinate values by either a single number or a tuple - + depends on what you supply to it. + """ + assert len(coords) == 4, "Need to supply 4 numbers as 'coords' - received {}".format(coords) + if isinstance(expand, int): + c = coords; e = expand + return (c[0]-e, c[1]-e, c[2]+e, c[3]+e) + elif isinstance(expand, (tuple, list)): + assert len(expand) == 4, "Need to supply 4 numbers as 'expand' if iterable - received {}".format(coords) + c = coords; e = expand + return (c[0]-e[0], c[1]-e[1], c[2]+e[2], c[3]+e[3]) + else: + raise ValueError("Can't do anything with {} as 'expand'".format(expand)) + +def get_bounds_for_points(points): + """ + Given a list/tuple of points (assumed to form a polygon), will return two numbers: + + * ``x``: width of the polygon made from the points (difference between rightmost and leftmost + 1) + * ``y``, height of the polygon made from the points (difference between topmost and bottommost + 1) + """ + lx = [p[0] for p in points] + ly = [p[1] for p in points] + dx = max(lx)-min(lx) + 1 + dy = max(ly)-min(ly) + 1 + return dx, dy + +def convert_flat_list_into_pairs(l): + """ + Given an iterable of elements, will pair them together and return + a tuple of two-element tuples. If the list has an odd number + of elements, will silently reject the last element. + """ + pl = [] + for i in range(len(l)/2): + pl.append((l[i*2], l[i*2+1])) + return tuple(pl) diff --git a/ui/tests/test_coords.py b/ui/tests/test_coords.py new file mode 100644 index 00000000..a7058229 --- /dev/null +++ b/ui/tests/test_coords.py @@ -0,0 +1,84 @@ +"""tests for coords""" +import os +import unittest + +from mock import patch, Mock + +try: + from ui import Canvas, coords +except ImportError: + print("Absolute imports failed, trying relative imports") + os.sys.path.append(os.path.dirname(os.path.abspath('.'))) + # Store original __import__ + orig_import = __import__ + + def import_mock(name, *args): + if name in ['helpers']: + return Mock() + elif name == 'ui.utils': + import utils + return utils + return orig_import(name, *args) + + with patch('__builtin__.__import__', side_effect=import_mock): + from canvas import Canvas + import coords + + +def get_mock_output(width=128, height=64, mode="1"): + m = Mock() + m.configure_mock(width=width, height=height, device_mode=mode, type=["b&w-pixel"]) + return m + + +class TestCanvas(unittest.TestCase): + """Tests the coords functions""" + + def test_coords_filtering(self): + """tests whether the coordinate filtering works""" + w = 128 + h = 64 + c = Canvas(get_mock_output(width=w, height=h)) + assert (coords.check_coordinates(c, (0, 1)) == (0, 1)) + assert (coords.check_coordinates(c, ("-2", "-3")) == (w-2, h-3)) + assert (coords.check_coordinates(c, (0, 1, 2, 3)) == (0, 1, 2, 3)) + assert (coords.check_coordinates(c, (0, 1, "-2", "-3")) == (0, 1, w-2, h-3)) + assert (coords.check_coordinates(c, ("-0", "-1", "-2", "-3")) == (w, h-1, w-2, h-3)) + assert (coords.check_coordinates(c, ("-0", "1", "-2", "-3")) == (w, h+1, w-2, h-3)) + + def test_cflip(self): + """ Tests convert_flat_list_into_pairs """ + cflip = coords.convert_flat_list_into_pairs + assert cflip((1, 2, 3, 4)) == ((1, 2), (3, 4)) + assert cflip((1, 2, 3, 4, 5, 6)) == ((1, 2), (3, 4), (5, 6)) + assert cflip((1, 2, 3, 4, 5)) == ((1, 2), (3, 4)) + + def test_get_bounds_for_points(self): + """ Tests convert_flat_list_into_pairs """ + gbfp = coords.get_bounds_for_points + assert gbfp(((1, 2), (3, 4), (5, 6))) == (5, 5) + assert gbfp(((1, 2), (3, 4))) == (3, 3) + + def test_expand_coords(self): + """ Tests expand_coords """ + ec = coords.expand_coords + assert ec((1, 2, 3, 4), 1) == (0, 1, 4, 5) + assert ec((0, 10, 20, 30), 1) == (-1, 9, 21, 31) + + def test_multiply_points(self): + """ Tests multiply_points """ + mp = coords.multiply_points + assert mp(((1, 2), (3, 4)), 1) == ((1, 2), (3, 4)) + assert mp(((1, 2), (3, 4)), 2) == ((2, 4), (6, 8)) + assert mp(((1, 2), (3, 4)), 2.5) == ((2, 5), (7, 10)) + + def test_offset_points(self): + """ Tests offset_points """ + op = coords.offset_points + assert op(((1, 2), (3, 4)), (10, 10)) == ((11, 12), (13, 14)) + assert op(((1, 2), (3, 4)), (10, 20)) == ((11, 22), (13, 24)) + assert op(((1, 2), (3, 4)), (20, 20)) == ((21, 22), (23, 24)) + + +if __name__ == '__main__': + unittest.main() From c787dc3253f47a7a4e36c9127b4b6b0ec9505297 Mon Sep 17 00:00:00 2001 From: CRImier Date: Mon, 7 Jan 2019 00:47:15 +0200 Subject: [PATCH 34/40] Calculate FunctionOverlay labels depending on len of labels and not num_keys --- ui/overlays.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/overlays.py b/ui/overlays.py index cc099207..c32322cc 100644 --- a/ui/overlays.py +++ b/ui/overlays.py @@ -143,7 +143,7 @@ def wrapper(*args, **kwargs): ui_el.set_default_keymap() def draw_icon(self, c): - half_line_length = c.o.cols/self.num_keys + half_line_length = c.o.cols/len(self.labels) last_line = "".join([label.center(half_line_length) for label in self.labels]) c.clear((self.right_offset, str(-self.bottom_offset), c.width-self.right_offset, c.height)) c.text(last_line, (self.right_offset, str(-self.bottom_offset)), font=self.font) From ec176966c5c21d20f1e6fb3e878b38b159a966d2 Mon Sep 17 00:00:00 2001 From: CRImier Date: Mon, 7 Jan 2019 00:48:02 +0200 Subject: [PATCH 35/40] Numpad*Input now backgroundable --- ui/numpad_input.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/ui/numpad_input.py b/ui/numpad_input.py index d4022beb..39826a38 100644 --- a/ui/numpad_input.py +++ b/ui/numpad_input.py @@ -116,10 +116,6 @@ def before_foreground(self): def before_activate(self): self.o.cursor() - @property - def is_active(self): - return self.in_foreground - def after_activate(self): self.o.noCursor() self.i.remove_streaming() From 886a243ff19a1d73aba214c4a210880dccb4b7d3 Mon Sep 17 00:00:00 2001 From: CRImier Date: Mon, 7 Jan 2019 00:48:50 +0200 Subject: [PATCH 36/40] More phone app work - status screen ready --- apps/phone/graphics.py | 55 ++++++++++++ apps/phone/main.py | 194 ++++++++++++++++++++++++----------------- apps/phone/views.py | 78 +++++++++++++++++ 3 files changed, 246 insertions(+), 81 deletions(-) create mode 100644 apps/phone/graphics.py create mode 100644 apps/phone/views.py diff --git a/apps/phone/graphics.py b/apps/phone/graphics.py new file mode 100644 index 00000000..749398d0 --- /dev/null +++ b/apps/phone/graphics.py @@ -0,0 +1,55 @@ +# This function allows to insert polygons as a list of points whose code is +# laid out in a shape somewhat similar to the polygon's shape. The problem +# with that is - order of points in the polygon list matters when drawing it, +# so we have to reorder the points - otherwise they'll become an untangled mess. +# So, that's what this function does - untangles the polygons according to +# a given mapping. + +def untangle_points_by_mapping(points, mapping): + new_points = [p for p in points] + for dest, so in enumerate(mapping): + so = int(so) + new_points[dest] = points[so] + return new_points + +# Graphics + +arrow = ( # indices: + (3, 0), # 0 +(0, 3),(2, 3),(4, 3),(6, 3), # 1 2 3 4 + (2, 8),(4, 8) # 5 6 +) + +mapping = "0436521" + +arrow = untangle_points_by_mapping(arrow, mapping) + +cross = ( # indices: + (1, 0), (7, 0), # 0 1 +(0, 1), (8, 1), # 2 3 + (4, 3), # 4 + (3, 4), (5, 4), # 5 6 + (4, 5), # 7 +(0, 7), (8, 7), # 8 9 + (1, 8), (7, 8) # 10 11 +) + +mapping = (0, 4, 1, 3, 6, 9, 11, 7, 10, 8, 5, 2) + +cross = untangle_points_by_mapping(cross, mapping) + +phone_handset = ( + (4, 0), # 0 + (8, 1), # 1 + (4, 3), # 2 + (7, 4), # 3 + + (2, 11), # 4 + (4, 12), # 5 +(0, 13), # 6 + (3, 15) # 7 +) + +mapping = "01324576" + +phone_handset = untangle_points_by_mapping(phone_handset, mapping) diff --git a/apps/phone/main.py b/apps/phone/main.py index df268fb9..c8bac440 100644 --- a/apps/phone/main.py +++ b/apps/phone/main.py @@ -1,9 +1,15 @@ +from time import sleep + from apps import ZeroApp -from ui import Refresher, NumpadNumberInput, Canvas, FunctionOverlay +from ui import Refresher, NumpadNumberInput, Canvas, MockOutput, FunctionOverlay, \ + offset_points, get_bounds_for_points, expand_coords, multiply_points, check_coordinates, \ + convert_flat_list_into_pairs as cflip from ui.base_ui import BaseUIElement from ui.base_view_ui import BaseView -from time import sleep +import graphics +from views import InputScreenView + class InputScreen(NumpadNumberInput): @@ -20,88 +26,105 @@ def generate_views_dict(self): return d -class InputScreenView(BaseView): - - top_offset = 8 - value_height = 16 - value_font = ("Fixedsys62.ttf", value_height) - - def __init__(self, o, el): - BaseView.__init__(self, o, el) - self.c = Canvas(self.o) - # storage for text pagination optimisation - self.width_cache = {} - self.character_width_cache = {} - self.build_charwidth_cache() - print(self.character_width_cache) - self.prev_value_parts = [] - - def build_charwidth_cache(self): - for char in "".join(self.el.mapping.values()): - self.character_width_cache[char] = self.gtb(char, font=self.value_font)[0] - - def gtw(self, text, font): - #print("{} - {}".format(text, [self.character_width_cache[c] for c in text])) - return sum([self.character_width_cache[c] for c in text]) - - def gtb(self, text, font): - return self.c.get_text_bounds(text, font=font) - - def get_onscreen_value_parts(self, value_parts): - return value_parts[-3:] - - def get_displayed_image(self): - self.c.clear() - value = self.el.get_displayed_value() - value_parts = self.paginate_value(value, self.value_font, width=self.o.width-6, cache=self.prev_value_parts) - self.prev_value_parts = value_parts - onscreen_value_parts = self.get_onscreen_value_parts(value_parts) - for i, value_part in enumerate(onscreen_value_parts): - self.c.text(value_part, (3, self.top_offset+self.value_height*i), font=self.value_font) - return self.c.get_image() - - def paginate_value(self, value, font, width=None, cache=None): - # This function was optimised because I felt like it was kinda slow... - # Specifically, because of all the get_text_bounds calls. - # Now I'm not sure it was worth it - probably was? - width = width if width else self.o.width - value_parts = [] - # Let's optimise the counting a little bit and reuse the results of last - # pagination to speed this one up - if cache: - for part in cache[:-1]: #Except the last part since it's incomplete - if value.startswith(part): - value_parts.append(part) - value = value[len(part):] - # Now, onto checking the last part - maybe it's overflown, - # or maybe it's the first run and we haven't yet calculated the cache - # (i.e. someone supplied a long value to __init__ of the UI element) - # Let's go from the end - that will be faster for the most likely usecase. - while value: - counter = 0 - while counter < len(value): - shown_part = value[:-counter] if counter != 0 else value - remainder = value[-counter:] if counter != 0 else "" - width_of_next = self.width_cache.get(shown_part, None) - if width_of_next is None: - width_of_next = self.gtw(shown_part, font) - self.width_cache[shown_part] = width_of_next - if width_of_next < width: - break - counter += 1 - value_parts.append(shown_part) - value = remainder - print(value_parts) - return value_parts - - class StatusScreen(Refresher): + arrow_x = 15 + arrow_y = 40 + handset_x = 2 + handset_y = 10 + arrow_offset = 2 + counter = 0 + number_frame = (10, 45, "-10", "-1") + number_height = 16 + number_font = ("Fixedsys62.ttf", number_height) + def __init__(self, *args, **kwargs): Refresher.__init__(self, self.show_status, *args, **kwargs) + self.c = Canvas(self.o) + self.prepare_number() + self.predraw_arrow() + self.predraw_cross() + self.predraw_handset() + self.status = {"number":"25250034", "accepted":True, "time":385} + + def prepare_number(self): + frame_coords = check_coordinates(self.c, self.number_frame) + nw, nh = get_bounds_for_points(cflip(frame_coords)) + self.number_c = Canvas(MockOutput(width=nw, height=nh, device_mode=self.o.device_mode)) + + def predraw_arrow(self): + aw, ah = get_bounds_for_points(graphics.arrow) + arrow_c = Canvas(MockOutput(width=aw, height=ah, device_mode=self.o.device_mode)) + arrow_c.polygon(graphics.arrow, fill="white") + self.arrow_img = arrow_c.get_image() + + def predraw_cross(self): + aw, ah = get_bounds_for_points(graphics.cross) + cross_c = Canvas(MockOutput(width=aw, height=ah, device_mode=self.o.device_mode)) + cross_c.polygon(graphics.cross, fill="white") + self.cross_img = cross_c.get_image() + + def predraw_handset(self): + handset = multiply_points(graphics.phone_handset, 2) + aw, ah = get_bounds_for_points(handset) + handset_c = Canvas(MockOutput(width=aw, height=ah, device_mode=self.o.device_mode)) + handset_c.polygon(handset, fill="white") + self.handset_img = handset_c.get_image() + + def draw_arrow(self, c, flipped=False): + coords = (self.arrow_x, self.arrow_y, self.arrow_x+self.arrow_img.width, self.arrow_y+self.arrow_img.height) + clear_coords = expand_coords(coords, self.arrow_offset) + c.clear(clear_coords) + img = self.arrow_img if not flipped else self.arrow_img.rotate(180) + c.image.paste(img, (self.arrow_x, self.arrow_y)) + + def draw_cross(self, c, flipped=False): + coords = (self.arrow_x, self.arrow_y, self.arrow_x+self.cross_img.width, self.arrow_y+self.cross_img.height) + clear_coords = expand_coords(coords, self.arrow_offset) + c.clear(clear_coords) + c.image.paste(self.cross_img, (self.arrow_x, self.arrow_y)) + + def draw_handset(self, c, flipped=False): + coords = (self.handset_x, self.handset_y, self.handset_x+self.handset_img.width, self.handset_y+self.handset_img.height) + c.image.paste(self.handset_img, (self.handset_x, self.handset_y)) + + def get_status(self): + states = ["incoming", "outgoing", "missed"] + self.status["state"] = states[self.counter] + self.counter += 1 + if self.counter == 3: self.counter = 0 + return self.status + + def draw_state(self, c, status): + if status["state"] == "incoming": + self.draw_arrow(c, flipped=True) + elif status["state"] == "outgoing": + self.draw_arrow(c) + elif status["state"] == "missed": + self.draw_cross(c) + + def draw_text_state(self, c, status): + if not status["accepted"]: + self.c.text(status["state"].capitalize(), (30, 9), font=(self.number_font[0], 16)) + else: + status["time"] = status["time"] + 1 + time_str = "{:02d}:{:02d}".format(*divmod(status["time"], 60)) + self.c.text(time_str, (30, 9), font=(self.number_font[0], 16)) + + def draw_number(self, c, status): + self.number_c.clear() + self.number_c.centered_text(status["number"], font=self.number_font) + c.image.paste(self.number_c.image, self.number_frame[:2]) + c.rectangle(self.number_frame) def show_status(self): - pass + self.c.clear() + status = self.get_status() + self.draw_number(self.c, status) + self.draw_handset(self.c) + self.draw_state(self.c, status) + self.draw_text_state(self.c, status) + return self.c.get_image() class PhoneApp(ZeroApp): @@ -111,12 +134,12 @@ class PhoneApp(ZeroApp): def __init__(self, *args, **kwargs): ZeroApp.__init__(self, *args, **kwargs) self.input_screen = InputScreen(self.i, self.o) - self.insc_overlay = FunctionOverlay(["deactivate", "backspace"], labels=["Exit", "Backspace"]) + self.insc_overlay = FunctionOverlay({"KEY_F1":"deactivate", "KEY_F2":"backspace", "KEY_ENTER":self.insc_options}, labels=["Cancel", "Menu", "Bckspc"]) self.insc_overlay.apply_to(self.input_screen) - #self.status_screen = StatusScreen(self.i, self.o) + self.status_screen = StatusScreen(self.i, self.o, keymap={"KEY_ANSWER":self.accept_call, "KEY_HANGUP":self.reject_call}) def insc_options(self): - pass + self.switch_to_status_screen() def get_context(self, c): self.context = c @@ -124,6 +147,15 @@ def get_context(self, c): def switch_to_input_screen(self, digit=None): self.input_screen.activate() + def switch_to_status_screen(self, digit=None): + self.status_screen.activate() + + def accept_call(self): + self.status_screen.status["accepted"] = True + + def reject_call(self): + self.status_screen.status["accepted"] = False + def on_call(self): self.c.request_switch() self.show_call_status() diff --git a/apps/phone/views.py b/apps/phone/views.py new file mode 100644 index 00000000..91378ea1 --- /dev/null +++ b/apps/phone/views.py @@ -0,0 +1,78 @@ +from ui.base_view_ui import BaseView +from ui import Canvas +from helpers import setup_logger + +logger = setup_logger(__name__, "warning") + + +class InputScreenView(BaseView): + + top_offset = 8 + value_height = 16 + value_font = ("Fixedsys62.ttf", value_height) + + def __init__(self, o, el): + BaseView.__init__(self, o, el) + self.c = Canvas(self.o) + # storage for text pagination optimisation + self.width_cache = {} + self.character_width_cache = {} + self.build_charwidth_cache() + self.prev_value_parts = [] + + def build_charwidth_cache(self): + for char in "".join(self.el.mapping.values()): + self.character_width_cache[char] = self.gtb(char, font=self.value_font)[0] + + def gtw(self, text, font): + #print("{} - {}".format(text, [self.character_width_cache[c] for c in text])) + return sum([self.character_width_cache[c] for c in text]) + + def gtb(self, text, font): + return self.c.get_text_bounds(text, font=font) + + def get_onscreen_value_parts(self, value_parts): + return value_parts[-3:] + + def get_displayed_image(self): + self.c.clear() + value = self.el.get_displayed_value() + value_parts = self.paginate_value(value, self.value_font, width=self.o.width-6, cache=self.prev_value_parts) + self.prev_value_parts = value_parts + onscreen_value_parts = self.get_onscreen_value_parts(value_parts) + for i, value_part in enumerate(onscreen_value_parts): + self.c.text(value_part, (3, self.top_offset+self.value_height*i), font=self.value_font) + return self.c.get_image() + + def paginate_value(self, value, font, width=None, cache=None): + # This function was optimised because I felt like it was kinda slow... + # Specifically, because of all the get_text_bounds calls. + # Now I'm not sure it was worth it - probably was? + width = width if width else self.o.width + value_parts = [] + # Let's optimise the counting a little bit and reuse the results of last + # pagination to speed this one up + if cache: + for part in cache[:-1]: #Except the last part since it's incomplete + if value.startswith(part): + value_parts.append(part) + value = value[len(part):] + # Now, onto checking the last part - maybe it's overflown, + # or maybe it's the first run and we haven't yet calculated the cache + # (i.e. someone supplied a long value to __init__ of the UI element) + # Let's go from the end - that will be faster for the most likely usecase. + while value: + counter = 0 + while counter < len(value): + shown_part = value[:-counter] if counter != 0 else value + remainder = value[-counter:] if counter != 0 else "" + width_of_next = self.width_cache.get(shown_part, None) + if width_of_next is None: + width_of_next = self.gtw(shown_part, font) + self.width_cache[shown_part] = width_of_next + if width_of_next < width: + break + counter += 1 + value_parts.append(shown_part) + value = remainder + return value_parts From 801fb09bc9062297dd80ef63eeb6eca553db0e25 Mon Sep 17 00:00:00 2001 From: CRImier Date: Mon, 7 Jan 2019 00:52:12 +0200 Subject: [PATCH 37/40] Forgot to remove outdated test, adding --- ui/tests/test_canvas.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/ui/tests/test_canvas.py b/ui/tests/test_canvas.py index affdd19f..33b0aa42 100644 --- a/ui/tests/test_canvas.py +++ b/ui/tests/test_canvas.py @@ -51,18 +51,6 @@ def test_base_image(self): assert(c.image == i) assert(c.size == (w, h)) - def test_coords_filtering(self): - """tests whether the coordinate filtering works""" - w = 128 - h = 64 - c = Canvas(get_mock_output(width=w, height=h), name=c_name) - assert (c.check_coordinates((0, 1)) == (0, 1)) - assert (c.check_coordinates(("-2", "-3")) == (w-2, h-3)) - assert (c.check_coordinates((0, 1, 2, 3)) == (0, 1, 2, 3)) - assert (c.check_coordinates((0, 1, "-2", "-3")) == (0, 1, w-2, h-3)) - assert (c.check_coordinates(("-0", "-1", "-2", "-3")) == (w, h-1, w-2, h-3)) - assert (c.check_coordinates(("-0", "1", "-2", "-3")) == (w, h+1, w-2, h-3)) - def test_howto_example_drawing_basics(self): """tests the first canvas example from howto""" test_image = get_image("canvas_1.png") From 3a3500b243be67e5e6f665e049d04c5b19cd1ea2 Mon Sep 17 00:00:00 2001 From: CRImier Date: Mon, 7 Jan 2019 00:59:41 +0200 Subject: [PATCH 38/40] Making the Numpad*Input use the keymap for action buttons (as it should've been done initially), changing the action_key leakage test to mapping leakage test --- ui/numpad_input.py | 19 ++++++++----------- ui/tests/test_numpad_input.py | 30 ++++++++++++++++++------------ 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/ui/numpad_input.py b/ui/numpad_input.py index 39826a38..b3e02ae1 100644 --- a/ui/numpad_input.py +++ b/ui/numpad_input.py @@ -60,11 +60,11 @@ class NumpadCharInput(BaseViewMixin, BaseUIElement): } action_keys = { - "ENTER":"accept_value", - "F1":"deactivate", - "LEFT":"deactivate_if_first", - "RIGHT":"skip", - "F2":"backspace", + "KEY_F1":"deactivate", + "KEY_F2":"backspace", + "KEY_ENTER":"accept_value", + "KEY_LEFT":"deactivate_if_first", + "KEY_RIGHT":"skip" } bottom_row_buttons = ["Cancel", "OK", "Erase"] @@ -93,11 +93,11 @@ def __init__(self, i, o, message="Value:", value="", name="NumpadCharInput", map * ``mapping``: alternative key-to-characters mapping to use """ + self.action_keys = copy(self.action_keys) BaseUIElement.__init__(self, i, o, name) self.message = message self.value = value self.position = len(self.value) - self.action_keys = copy(self.action_keys) if mapping is not None: self.mapping = copy(mapping) else: @@ -148,10 +148,6 @@ def process_streaming_keycode(self, key_name, *args): header = "KEY_" key = key_name[len(header):] logger.debug("Received "+key_name) - if key in self.action_keys: - #Is one of the action keys - getattr(self, self.action_keys[key])() - return if key in self.mapping: #It's one of the keys we can process #NO INSERT IN MIDDLE/START SUPPORT @@ -276,12 +272,13 @@ def skip(self): #Functions that set up the input listener def generate_keymap(self): - return {} + return self.action_keys @to_be_foreground def configure_input(self): self.i.clear_keymap() remove_left_failsafe(self.i) + self.i.set_keymap(self.keymap) self.i.set_streaming(self.process_streaming_keycode) #Functions that are responsible for input to display diff --git a/ui/tests/test_numpad_input.py b/ui/tests/test_numpad_input.py index 9720e1fb..400d7d9c 100644 --- a/ui/tests/test_numpad_input.py +++ b/ui/tests/test_numpad_input.py @@ -49,19 +49,19 @@ def test_constructor(self): ni = self.cls(get_mock_input(), get_mock_output(), name=ni_name) self.assertIsNotNone(ni) - def test_action_keys_leakage(self): - """tests whether the action key settings of one NumpadCharInput leaks into another""" + def test_mapping_leakage(self): + """tests whether the mapping of one NumpadCharInput leaks into another""" i = get_mock_input() o = get_mock_output() i1 = self.cls(i, o, name=ni_name + "1") - i1.action_keys["F1"] = "accept_value" + i1.mapping["0"] = "a" i2 = self.cls(i, o, name=ni_name + "2") - i1.action_keys["F1"] = "accept_value" - i2.action_keys["ENTER"] = "deactivate" + i1.mapping["0"] = "s" + i2.mapping["1"] = "d" i3 = self.cls(i, o, name=ni_name + "3") - assert (i1.action_keys != i2.action_keys) - assert (i2.action_keys != i3.action_keys) - assert (i1.action_keys != i3.action_keys) + assert (i1.mapping != i2.mapping) + assert (i2.mapping != i3.mapping) + assert (i1.mapping != i3.mapping) def test_f1_left_returns_none(self): ni = self.cls(get_mock_input(), get_mock_output(), name=ni_name) @@ -69,7 +69,7 @@ def test_f1_left_returns_none(self): # Checking at the start of the list def scenario(): - ni.process_streaming_keycode("KEY_LEFT") + ni.keymap["KEY_LEFT"]() assert not ni.in_foreground with patch.object(ni, 'idle_loop', side_effect=scenario) as p: @@ -80,7 +80,7 @@ def scenario(): def scenario(): for i in range(3): ni.process_streaming_keycode("KEY_1") - ni.process_streaming_keycode("KEY_F1") + ni.keymap["KEY_F1"]() assert not ni.in_foreground with patch.object(ni, 'idle_loop', side_effect=scenario) as p: @@ -97,7 +97,10 @@ def test_entering_value(self): def scenario(): for key in key_sequence: - ni.process_streaming_keycode("KEY_{}".format(key)) + if len(str(key)) > 1: + ni.keymap["KEY_{}".format(key)]() + else: + ni.process_streaming_keycode("KEY_{}".format(key)) assert not ni.in_foreground # Should not be active with patch.object(ni, 'idle_loop', side_effect=scenario) as p: @@ -113,7 +116,10 @@ def test_entering_value_with_backspaces(self): expected_output = "hello!" def scenario(): for key in key_sequence: - ni.process_streaming_keycode("KEY_{}".format(key)) + if len(str(key)) > 1: + ni.keymap["KEY_{}".format(key)]() + else: + ni.process_streaming_keycode("KEY_{}".format(key)) assert not ni.in_foreground # Should not be active with patch.object(ni, 'idle_loop', side_effect=scenario) as p: From 65e450ea3812776217e4c5cb22ed4955eae1af00 Mon Sep 17 00:00:00 2001 From: CRImier Date: Mon, 7 Jan 2019 13:18:39 +0200 Subject: [PATCH 39/40] Accidentally changed context names, fixing the hardcoded exclusive context name --- context_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/context_manager.py b/context_manager.py index 454832d0..e45da5cb 100644 --- a/context_manager.py +++ b/context_manager.py @@ -223,7 +223,7 @@ class ContextManager(object): exclusive_context = None fallback_context = "main" initial_contexts = ["main"] - allowed_exclusive_contexts = ["apps/lockscreen"] + allowed_exclusive_contexts = ["apps.lockscreen"] def __init__(self): self.contexts = {} From bb9e45690f4b347e1a305b494df59fd7e3b6591c Mon Sep 17 00:00:00 2001 From: CRImier Date: Mon, 7 Jan 2019 13:19:24 +0200 Subject: [PATCH 40/40] Adding "start context" function - for custom apps --- context_manager.py | 26 ++++++++++++++++++++------ main.py | 1 + 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/context_manager.py b/context_manager.py index e45da5cb..621c2f98 100644 --- a/context_manager.py +++ b/context_manager.py @@ -223,6 +223,7 @@ class ContextManager(object): exclusive_context = None fallback_context = "main" initial_contexts = ["main"] + start_context = "main" allowed_exclusive_contexts = ["apps.lockscreen"] def __init__(self): @@ -248,6 +249,14 @@ def create_initial_contexts(self): c = self.create_context(context_alias) c.threaded = False + def switch_to_start_context(self): + """ + Switches to the defined start context - usually "main", + but could also be some other context defined by someone + integrating ZPUI into their workflow. + """ + self.unsafe_switch_to_context(self.start_context, do_raise=False) + def get_context_names(self): """ Gets names of all contexts available. @@ -290,7 +299,7 @@ def switch_to_context(self, context_alias): else: return True - def unsafe_switch_to_context(self, context_alias): + def unsafe_switch_to_context(self, context_alias, do_raise=True): """ This is a non-thread-safe context switch function. Not to be used directly - is only for internal usage. In case an exception is raised, sets things as they @@ -309,10 +318,12 @@ def unsafe_switch_to_context(self, context_alias): except: logger.exception("Also couldn't activate IO for the previous context: {}!".format(previous_context)) self.failsafe_switch_to_fallback_context() - raise + if do_raise: + raise self.current_context = previous_context # Passing the exception back to the caller - raise + if do_raise: + raise # Activating the context - restoring everything if it fails try: self.contexts[context_alias].activate() @@ -324,17 +335,20 @@ def unsafe_switch_to_context(self, context_alias): except: logger.exception("Also couldn't activate IO for the previous context: {}!".format(previous_context)) self.failsafe_switch_to_fallback_context() - raise + if do_raise: + raise # Activating the previous context itself try: self.contexts[previous_context].activate() except: logger.exception("Also couldn't activate context for the previous context: {}!".format(previous_context)) self.failsafe_switch_to_fallback_context() - raise + if do_raise: + raise self.current_context = previous_context # Passing the exception back to the caller - raise + if do_raise: + raise else: logger.debug("Switched to {} context!".format(context_alias)) diff --git a/main.py b/main.py index a98d08c7..c07c8afd 100755 --- a/main.py +++ b/main.py @@ -136,6 +136,7 @@ def launch(name=None, **kwargs): # Load all apps app_menu = app_man.load_all_apps() runner = app_menu.activate + cm.switch_to_start_context() else: # If using autocompletion from main folder, it might # append a / at the name end, which isn't acceptable