From 47042f0946b5d1987aacdbfebe10992bde280eb8 Mon Sep 17 00:00:00 2001
From: Simone Margaritelli <evilsocket@gmail.com>
Date: Wed, 2 Oct 2019 19:01:07 +0200
Subject: [PATCH] new: implemented plugin system (closes #54)

---
 README.md                                     |   6 +
 sdcard/rootfs/root/pwnagotchi/config.yml      |   4 +-
 sdcard/rootfs/root/pwnagotchi/scripts/main.py |  77 +++++----
 .../pwnagotchi/scripts/pwnagotchi/agent.py    |  24 ++-
 .../pwnagotchi/scripts/pwnagotchi/ai/epoch.py |   4 +-
 .../pwnagotchi/scripts/pwnagotchi/ai/train.py |  30 +++-
 .../scripts/pwnagotchi/mesh/utils.py          |   4 +-
 .../scripts/pwnagotchi/plugins/__init__.py    |  45 +++++
 .../pwnagotchi/plugins/default/example.py     | 162 ++++++++++++++++++
 .../pwnagotchi/plugins/default/ups_lite.py    |  65 +++++++
 .../scripts/pwnagotchi/ui/display.py          |   4 +-
 .../pwnagotchi/scripts/pwnagotchi/ui/state.py |   3 +
 .../pwnagotchi/scripts/pwnagotchi/ui/view.py  |  17 +-
 13 files changed, 397 insertions(+), 48 deletions(-)
 create mode 100644 sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/plugins/__init__.py
 create mode 100644 sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/plugins/default/example.py
 create mode 100644 sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/plugins/default/ups_lite.py

diff --git a/README.md b/README.md
index 45644e4..b0331f8 100644
--- a/README.md
+++ b/README.md
@@ -148,6 +148,12 @@ Now you can use the `preview.py`-script to preview the changes:
 # Now open http://localhost:8080 and http://localhost:8081
 ```
 
+### Plugins
+
+Pwnagotchi has a simple plugins system that you can use to customize your unit and its behaviour. You can place your plugins anywhere
+as python files and then edit the `config.yml` file (`main.plugins` value) to point to their containing folder. Check the [plugins folder](https://github.com/evilsocket/pwnagotchi/tree/master/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/plugins) for a list of default 
+plugins and all the callbacks that you can define for your own customizations.
+
 ### Random Info
 
 - `hostname` sets the unit name.
diff --git a/sdcard/rootfs/root/pwnagotchi/config.yml b/sdcard/rootfs/root/pwnagotchi/config.yml
index 8da7bc9..7cf1c6f 100644
--- a/sdcard/rootfs/root/pwnagotchi/config.yml
+++ b/sdcard/rootfs/root/pwnagotchi/config.yml
@@ -2,6 +2,8 @@
 main:
     # currently implemented: en (default), de, nl, it
     lang: en
+    # custom plugins path, if null only default plugins with be loaded
+    plugins: null
     # monitor interface to use
     iface: mon0
     # command to run to bring the mon interface up in case it's not up already
@@ -21,7 +23,7 @@ main:
 
 ai:
     # if false, only the default 'personality' will be used
-    enabled: true
+    enabled: false
     path: /root/brain.nn
     # 1.0 - laziness = probability of start training
     laziness: 0.1
diff --git a/sdcard/rootfs/root/pwnagotchi/scripts/main.py b/sdcard/rootfs/root/pwnagotchi/scripts/main.py
index 10e0490..09f6ec3 100755
--- a/sdcard/rootfs/root/pwnagotchi/scripts/main.py
+++ b/sdcard/rootfs/root/pwnagotchi/scripts/main.py
@@ -5,7 +5,7 @@ import time
 import traceback
 
 import core
-import pwnagotchi
+import pwnagotchi, pwnagotchi.plugins as plugins
 
 from pwnagotchi.log import SessionParser
 from pwnagotchi.voice import Voice
@@ -25,39 +25,49 @@ args = parser.parse_args()
 if args.do_clear:
     print("clearing the display ...")
     with open(args.config, 'rt') as fp:
-      config = yaml.safe_load(fp)
-      cleardisplay=config['ui']['display']['type']
-      if cleardisplay in ('inkyphat', 'inky'):
-              print("inky display")
-              from inky import InkyPHAT
-              epd = InkyPHAT(config['ui']['display']['color'])
-              epd.set_border(InkyPHAT.BLACK)
-              self._render_cb = self._inky_render
-      elif cleardisplay in ('papirus', 'papi'):
-              print("papirus display")
-              from pwnagotchi.ui.papirus.epd import EPD
-              os.environ['EPD_SIZE'] = '2.0'
-              epd = EPD()
-              epd.clear()
-      elif cleardisplay in ('waveshare_1', 'ws_1', 'waveshare1', 'ws1'):
-              print("waveshare v1 display")
-              from pwnagotchi.ui.waveshare.v1.epd2in13 import EPD
-              epd = EPD()
-              epd.init(epd.lut_full_update)
-              epd.Clear(0xFF)
-      elif cleardisplay in ('waveshare_2', 'ws_2', 'waveshare2', 'ws2'):
-              print("waveshare v2 display")
-              from pwnagotchi.ui.waveshare.v2.waveshare import EPD
-              epd = EPD()
-              epd.init(epd.FULL_UPDATE)
-              epd.Clear(0xff)
-      else:
-              print("unknown display type %s" % cleardisplay)
-      quit()
+        config = yaml.safe_load(fp)
+        cleardisplay = config['ui']['display']['type']
+        if cleardisplay in ('inkyphat', 'inky'):
+            print("inky display")
+            from inky import InkyPHAT
+
+            epd = InkyPHAT(config['ui']['display']['color'])
+            epd.set_border(InkyPHAT.BLACK)
+            self._render_cb = self._inky_render
+        elif cleardisplay in ('papirus', 'papi'):
+            print("papirus display")
+            from pwnagotchi.ui.papirus.epd import EPD
+
+            os.environ['EPD_SIZE'] = '2.0'
+            epd = EPD()
+            epd.clear()
+        elif cleardisplay in ('waveshare_1', 'ws_1', 'waveshare1', 'ws1'):
+            print("waveshare v1 display")
+            from pwnagotchi.ui.waveshare.v1.epd2in13 import EPD
+
+            epd = EPD()
+            epd.init(epd.lut_full_update)
+            epd.Clear(0xFF)
+        elif cleardisplay in ('waveshare_2', 'ws_2', 'waveshare2', 'ws2'):
+            print("waveshare v2 display")
+            from pwnagotchi.ui.waveshare.v2.waveshare import EPD
+
+            epd = EPD()
+            epd.init(epd.FULL_UPDATE)
+            epd.Clear(0xff)
+        else:
+            print("unknown display type %s" % cleardisplay)
+        quit()
 
 with open(args.config, 'rt') as fp:
     config = yaml.safe_load(fp)
 
+plugins.load_from_path(plugins.default_path)
+if 'plugins' in config['main'] and config['main']['plugins'] is not None:
+    plugins.load_from_path(config['main']['plugins'])
+
+plugins.on('loaded')
+
 display = Display(config=config, state={'name': '%s>' % pwnagotchi.name()})
 agent = Agent(view=display, config=config)
 
@@ -65,6 +75,9 @@ core.log("%s@%s (v%s)" % (pwnagotchi.name(), agent._identity, pwnagotchi.version
 # for key, value in config['personality'].items():
 #    core.log("  %s: %s" % (key, value))
 
+for _, plugin in plugins.loaded.items():
+    core.log("plugin '%s' v%s loaded from %s" % (plugin.__name__, plugin.__version__, plugin.__file__))
+
 if args.do_manual:
     core.log("entering manual mode ...")
 
@@ -112,13 +125,15 @@ core.logfile = config['main']['log']
 
 agent.start_ai()
 agent.setup_events()
-agent.set_ready()
+agent.set_starting()
 agent.start_monitor_mode()
 agent.start_event_polling()
 
 # print initial stats
 agent.next_epoch()
 
+agent.set_ready()
+
 while True:
     try:
         # recon on all channels
diff --git a/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/agent.py b/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/agent.py
index 1fa5112..bf9ecca 100644
--- a/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/agent.py
+++ b/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/agent.py
@@ -8,6 +8,7 @@ import _thread
 
 import core
 
+import pwnagotchi.plugins as plugins
 from bettercap.client import Client
 from pwnagotchi.mesh.utils import AsyncAdvertiser
 from pwnagotchi.ai.train import AsyncTrainer
@@ -47,29 +48,35 @@ class Agent(Client, AsyncAdvertiser, AsyncTrainer):
     def supported_channels(self):
         return self._supported_channels
 
-    def on_ai_ready(self):
-        self._view.on_ai_ready()
+    def set_starting(self):
+        self._view.on_starting()
 
     def set_ready(self):
-        self._view.on_starting()
+        plugins.on('ready', self)
 
     def set_free_channel(self, channel):
         self._view.on_free_channel(channel)
+        plugins.on('free_channel', self, channel)
 
     def set_bored(self):
         self._view.on_bored()
+        plugins.on('bored', self)
 
     def set_sad(self):
         self._view.on_sad()
+        plugins.on('sad', self)
 
     def set_excited(self):
         self._view.on_excited()
+        plugins.on('excited', self)
 
     def set_lonely(self):
         self._view.on_lonely()
+        plugins.on('lonely', self)
 
     def set_rebooting(self):
         self._view.on_rebooting()
+        plugins.on('rebooting', self)
 
     def setup_events(self):
         core.log("connecting to %s ..." % self.url)
@@ -128,6 +135,7 @@ class Agent(Client, AsyncAdvertiser, AsyncTrainer):
         self.start_advertising()
 
     def wait_for(self, t, sleeping=True):
+        plugins.on('sleep' if sleeping else 'wait', self, t)
         self._view.wait(t, sleeping)
         self._epoch.track(sleep=True, inc=t)
 
@@ -179,6 +187,7 @@ class Agent(Client, AsyncAdvertiser, AsyncTrainer):
 
     def set_access_points(self, aps):
         self._access_points = aps
+        plugins.on('wifi_update', self, aps)
         self._epoch.observe(aps, self._advertiser.peers() if self._advertiser is not None else ())
         return self._access_points
 
@@ -338,6 +347,7 @@ class Agent(Client, AsyncAdvertiser, AsyncTrainer):
                         if apsta is None:
                             core.log("!!! captured new handshake: %s !!!" % key)
                             self._last_pwnd = ap_mac
+                            plugins.on('handshake', self, ap_mac, sta_mac)
                         else:
                             (ap, sta) = apsta
                             self._last_pwnd = ap['hostname'] if ap['hostname'] != '' and ap[
@@ -346,6 +356,7 @@ class Agent(Client, AsyncAdvertiser, AsyncTrainer):
                                 ap['channel'],
                                 sta['mac'], sta['vendor'],
                                 ap['hostname'], ap['mac'], ap['vendor']))
+                            plugins.on('handshake', self, ap, sta)
 
             except Exception as e:
                 core.log("error: %s" % e)
@@ -419,6 +430,7 @@ class Agent(Client, AsyncAdvertiser, AsyncTrainer):
             except Exception as e:
                 self._on_error(ap['mac'], e)
 
+            plugins.on('association', self, ap)
             if throttle > 0:
                 time.sleep(throttle)
             self._view.on_normal()
@@ -439,6 +451,7 @@ class Agent(Client, AsyncAdvertiser, AsyncTrainer):
             except Exception as e:
                 self._on_error(sta['mac'], e)
 
+            plugins.on('deauthentication', self, ap, sta)
             if throttle > 0:
                 time.sleep(throttle)
             self._view.on_normal()
@@ -470,6 +483,9 @@ class Agent(Client, AsyncAdvertiser, AsyncTrainer):
                 self._current_channel = channel
                 self._epoch.track(hop=True)
                 self._view.set('channel', '%d' % channel)
+
+                plugins.on('channel_hop', self, channel)
+
             except Exception as e:
                 core.log("error: %s" % e)
 
@@ -509,6 +525,8 @@ class Agent(Client, AsyncAdvertiser, AsyncTrainer):
             core.log("%d epochs with activity -> excited" % self._epoch.active_for)
             self.set_excited()
 
+        plugins.on('epoch', self, self._epoch.epoch - 1, self._epoch.data())
+
         if self._epoch.blind_for >= self._config['main']['mon_max_blind_epochs']:
             core.log("%d epochs without visible access points -> rebooting ..." % self._epoch.blind_for)
             self._reboot()
diff --git a/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/ai/epoch.py b/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/ai/epoch.py
index bdd8c2e..8508eb9 100644
--- a/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/ai/epoch.py
+++ b/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/ai/epoch.py
@@ -7,6 +7,7 @@ import pwnagotchi.mesh.wifi as wifi
 
 from pwnagotchi.ai.reward import RewardFunction
 
+
 class Epoch(object):
     def __init__(self, config):
         self.epoch = 0
@@ -92,7 +93,8 @@ class Epoch(object):
             try:
                 peers_per_chan[peer.last_channel - 1] += 1.0
             except IndexError as e:
-                core.log("got peer data on channel %d, we can store %d channels" % (peer.last_channel, wifi.NumChannels))
+                core.log(
+                    "got peer data on channel %d, we can store %d channels" % (peer.last_channel, wifi.NumChannels))
 
         # normalize
         aps_per_chan = [e / num_aps for e in aps_per_chan]
diff --git a/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/ai/train.py b/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/ai/train.py
index 1312715..34140dd 100644
--- a/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/ai/train.py
+++ b/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/ai/train.py
@@ -7,6 +7,7 @@ import json
 
 import core
 
+import pwnagotchi.plugins as plugins
 import pwnagotchi.ai as ai
 from pwnagotchi.ai.epoch import Epoch
 
@@ -68,14 +69,14 @@ class Stats(object):
             core.log("[ai] saving %s" % self.path)
 
             data = json.dumps({
-                    'born_at': self.born_at,
-                    'epochs_lived': self.epochs_lived,
-                    'epochs_trained': self.epochs_trained,
-                    'rewards': {
-                        'best': self.best_reward,
-                        'worst': self.worst_reward
-                    }
-                })
+                'born_at': self.born_at,
+                'epochs_lived': self.epochs_lived,
+                'epochs_trained': self.epochs_trained,
+                'rewards': {
+                    'best': self.best_reward,
+                    'worst': self.worst_reward
+                }
+            })
 
             temp = "%s.tmp" % self.path
             with open(temp, 'wt') as fp:
@@ -98,6 +99,11 @@ class AsyncTrainer(object):
         self._is_training = training
         self._training_epochs = for_epochs
 
+        if training:
+            plugins.on('ai_training_start', self, for_epochs)
+        else:
+            plugins.on('ai_training_end', self)
+
     def is_training(self):
         return self._is_training
 
@@ -123,8 +129,10 @@ class AsyncTrainer(object):
 
     def on_ai_training_step(self, _locals, _globals):
         self._model.env.render()
+        plugins.on('ai_training_step', self, _locals, _globals)
 
     def on_ai_policy(self, new_params):
+        plugins.on('ai_policy', self, new_params)
         core.log("[ai] setting new policy:")
         for name, value in new_params.items():
             if name in self._config['personality']:
@@ -139,13 +147,19 @@ class AsyncTrainer(object):
         self.run('set wifi.sta.ttl %d' % self._config['personality']['sta_ttl'])
         self.run('set wifi.rssi.min %d' % self._config['personality']['min_rssi'])
 
+    def on_ai_ready(self):
+        self._view.on_ai_ready()
+        plugins.on('ai_ready', self)
+
     def on_ai_best_reward(self, r):
         core.log("[ai] best reward so far: %s" % r)
         self._view.on_motivated(r)
+        plugins.on('ai_best_reward', self, r)
 
     def on_ai_worst_reward(self, r):
         core.log("[ai] worst reward so far: %s" % r)
         self._view.on_demotivated(r)
+        plugins.on('ai_worst_reward', self, r)
 
     def _ai_worker(self):
         self._model = ai.load(self._config, self, self._epoch)
diff --git a/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/mesh/utils.py b/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/mesh/utils.py
index c99cd57..8c84b94 100644
--- a/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/mesh/utils.py
+++ b/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/mesh/utils.py
@@ -1,7 +1,7 @@
 import _thread
 
 import core
-import pwnagotchi
+import pwnagotchi, pwnagotchi.plugins as plugins
 from pwnagotchi.mesh import get_identity
 
 
@@ -37,6 +37,8 @@ class AsyncAdvertiser(object):
 
     def _on_new_unit(self, peer):
         self._view.on_new_peer(peer)
+        plugins.on('peer_detected', self, peer)
 
     def _on_lost_unit(self, peer):
         self._view.on_lost_peer(peer)
+        plugins.on('peer_lost', self, peer)
diff --git a/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/plugins/__init__.py b/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/plugins/__init__.py
new file mode 100644
index 0000000..6f84a90
--- /dev/null
+++ b/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/plugins/__init__.py
@@ -0,0 +1,45 @@
+import os
+import glob
+import importlib, importlib.util
+
+# import core
+
+default_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "default")
+loaded = {}
+
+
+def dummy_callback():
+    pass
+
+
+def on(event_name, *args, **kwargs):
+    global loaded
+    cb_name = 'on_%s' % event_name
+    for _, plugin in loaded.items():
+        if cb_name in plugin.__dict__:
+            # print("calling %s %s(%s)" %(cb_name, args, kwargs))
+            plugin.__dict__[cb_name](*args, **kwargs)
+
+
+def load_from_file(filename):
+    plugin_name = os.path.basename(filename.replace(".py", ""))
+    spec = importlib.util.spec_from_file_location(plugin_name, filename)
+    instance = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(instance)
+    return plugin_name, instance
+
+
+def load_from_path(path):
+    global loaded
+
+    for filename in glob.glob(os.path.join(path, "*.py")):
+        name, plugin = load_from_file(filename)
+        if name in loaded:
+            raise Exception("plugin %s already loaded from %s" % (name, plugin.__file__))
+        elif not plugin.__enabled__:
+            # print("plugin %s is not enabled" % name)
+            pass
+        else:
+            loaded[name] = plugin
+
+    return loaded
diff --git a/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/plugins/default/example.py b/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/plugins/default/example.py
new file mode 100644
index 0000000..60efbd2
--- /dev/null
+++ b/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/plugins/default/example.py
@@ -0,0 +1,162 @@
+__author__ = 'evilsocket@gmail.com'
+__version__ = '1.0.0'
+__name__ = 'hello_world'
+__license__ = 'GPL3'
+__description__ = 'An example plugin for pwnagotchi that implements all the available callbacks.'
+__enabled__ = False  # IMPORTANT: set this to True to enable your plugin.
+
+from pwnagotchi.ui.components import LabeledValue
+from pwnagotchi.ui.view import BLACK
+import pwnagotchi.ui.fonts as fonts
+import core
+
+
+# called when the plugin is loaded
+def on_loaded():
+    core.log("WARNING: plugin %s should be disabled!" % __name__)
+
+
+# called to setup the ui elements
+def on_ui_setup(ui):
+    # add custom UI elements
+    ui.add_element('ups', LabeledValue(color=BLACK, label='UPS', value='0%/0V', position=(ui.width() / 2 - 25, 0),
+                                       label_font=fonts.Bold, text_font=fonts.Medium))
+
+
+# called when the ui is updated
+def on_ui_update(ui):
+    # update those elements
+    some_voltage = 0.1
+    some_capacity = 100.0
+
+    ui.set('ups', "%4.2fV/%2i%%" % (some_voltage, some_capacity))
+
+
+# called when the hardware display setup is done, display is an hardware specific object
+def on_display_setup(display):
+    pass
+
+
+# called when everything is ready and the main loop is about to start
+def on_ready(agent):
+    core.log("unit is ready")
+    # you can run custom bettercap commands if you want
+    #   agent.run('ble.recon on')
+    # or set a custom state
+    #   agent.set_bored()
+
+
+# called when the AI finished loading
+def on_ai_ready(agent):
+    pass
+
+
+# called when the AI finds a new set of parameters
+def on_ai_policy(agent, policy):
+    pass
+
+
+# called when the AI starts training for a given number of epochs
+def on_ai_training_start(agent, epochs):
+    pass
+
+
+# called after the AI completed a training epoch
+def on_ai_training_step(agent, _locals, _globals):
+    pass
+
+
+# called when the AI has done training
+def on_ai_training_end(agent):
+    pass
+
+
+# called when the AI got the best reward so far
+def on_ai_best_reward(agent, reward):
+    pass
+
+
+# called when the AI got the best reward so far
+def on_ai_worst_reward(agent, reward):
+    pass
+
+
+# called when a non overlapping wifi channel is found to be free
+def on_free_channel(agent, channel):
+    pass
+
+
+# called when the status is set to bored
+def on_bored(agent):
+    pass
+
+
+# called when the status is set to sad
+def on_sad(agent):
+    pass
+
+
+# called when the status is set to excited
+def on_excited(agent):
+    pass
+
+
+# called when the status is set to lonely
+def on_lonely(agent):
+    pass
+
+
+# called when the agent is rebooting the board
+def on_rebooting(agent):
+    pass
+
+
+# called when the agent is waiting for t seconds
+def on_wait(agent, t):
+    pass
+
+
+# called when the agent is sleeping for t seconds
+def on_sleep(agent, t):
+    pass
+
+
+# called when the agent refreshed its access points list
+def on_wifi_update(agent, access_points):
+    pass
+
+
+# called when the agent is sending an association frame
+def on_association(agent, access_point):
+    pass
+
+
+# callend when the agent is deauthenticating a client station from an AP
+def on_deauthentication(agent, access_point, client_station):
+    pass
+
+
+# callend when the agent is tuning on a specific channel
+def on_channel_hop(agent, channel):
+    pass
+
+
+# called when a new handshake is captured, access_point and client_station are json objects
+# if the agent could match the BSSIDs to the current list, otherwise they are just the strings of the BSSIDs
+def on_handshake(agent, access_point, client_station):
+    pass
+
+
+# called when an epoch is over (where an epoch is a single loop of the main algorithm)
+def on_epoch(agent, epoch, epoch_data):
+    pass
+
+
+# called when a new peer is detected
+def on_peer_detected(agent, peer):
+    pass
+
+
+# called when a known peer is lost
+def on_peer_lost(agent, peer):
+    pass
diff --git a/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/plugins/default/ups_lite.py b/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/plugins/default/ups_lite.py
new file mode 100644
index 0000000..7ddcecd
--- /dev/null
+++ b/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/plugins/default/ups_lite.py
@@ -0,0 +1,65 @@
+# Based on UPS Lite v1.1 from https://github.com/xenDE
+#
+# funtions for get UPS status - needs enable "i2c" in raspi-config
+#
+# https://github.com/linshuqin329/UPS-Lite
+#
+# For Raspberry Pi Zero Ups Power Expansion Board with Integrated Serial Port S3U4
+# https://www.ebay.de/itm/For-Raspberry-Pi-Zero-Ups-Power-Expansion-Board-with-Integrated-Serial-Port-S3U4/323873804310
+# https://www.aliexpress.com/item/32888533624.html
+__author__ = 'evilsocket@gmail.com'
+__version__ = '1.0.0'
+__name__ = 'ups_lite'
+__license__ = 'GPL3'
+__description__ = 'A plugin that will add a voltage indicator for the UPS Lite v1.1'
+__enabled__ = False
+
+import struct
+
+from pwnagotchi.ui.components import LabeledValue
+from pwnagotchi.ui.view import BLACK
+import pwnagotchi.ui.fonts as fonts
+
+
+# TODO: add enable switch in config.yml an cleanup all to the best place
+class UPS:
+    def __init__(self):
+        # only import when the module is loaded and enabled
+        import smbus
+        # 0 = /dev/i2c-0 (port I2C0), 1 = /dev/i2c-1 (port I2C1)
+        self._bus = smbus.SMBus(1)
+
+    def voltage(self):
+        try:
+            address = 0x36
+            read = self._bus.read_word_data(address, 2)
+            swapped = struct.unpack("<H", struct.pack(">H", read))[0]
+            return swapped * 1.25 / 1000 / 16
+        except:
+            return 0.0
+
+    def capacity(self):
+        try:
+            address = 0x36
+            read = self._bus.read_word_data(address, 4)
+            swapped = struct.unpack("<H", struct.pack(">H", read))[0]
+            return swapped / 256
+        except:
+            return 0.0
+
+
+ups = None
+
+
+def on_loaded():
+    global ups
+    ups = UPS()
+
+
+def on_ui_setup(ui):
+    ui.add_element('ups', LabeledValue(color=BLACK, label='UPS', value='0%/0V', position=(ui.width() / 2 - 25, 0),
+                                       label_font=fonts.Bold, text_font=fonts.Medium))
+
+
+def on_ui_update(ui):
+    ui.set('ups', "%4.2fV/%2i%%" % (ups.voltage(), ups.capacity()))
diff --git a/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/ui/display.py b/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/ui/display.py
index 4c693a6..657aefb 100644
--- a/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/ui/display.py
+++ b/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/ui/display.py
@@ -4,7 +4,7 @@ from threading import Lock
 import shutil
 import core
 import os
-import pwnagotchi
+import pwnagotchi, pwnagotchi.plugins as plugins
 
 from pwnagotchi.ui.view import WHITE, View
 
@@ -146,6 +146,8 @@ class Display(View):
         else:
             core.log("unknown display type %s" % self._display_type)
 
+        plugins.on('display_setup', self._display)
+
         self.on_render(self._on_view_rendered)
 
     def image(self):
diff --git a/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/ui/state.py b/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/ui/state.py
index 71b7d4e..c5b66be 100644
--- a/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/ui/state.py
+++ b/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/ui/state.py
@@ -7,6 +7,9 @@ class State(object):
         self._lock = Lock()
         self._listeners = {}
 
+    def add_element(self, key, elem):
+        self._state[key] = elem
+
     def add_listener(self, key, cb):
         with self._lock:
             self._listeners[key] = cb
diff --git a/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/ui/view.py b/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/ui/view.py
index 19e7999..3ae9089 100644
--- a/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/ui/view.py
+++ b/sdcard/rootfs/root/pwnagotchi/scripts/pwnagotchi/ui/view.py
@@ -4,7 +4,7 @@ import time
 from PIL import Image, ImageDraw
 
 import core
-import pwnagotchi
+import pwnagotchi.plugins as plugins
 from pwnagotchi.voice import Voice
 
 import pwnagotchi.ui.fonts as fonts
@@ -41,7 +41,7 @@ def setup_display_specifics(config):
         name_pos = (int(width / 2) - 15, int(height * .15))
         status_pos = (int(width / 2) - 15, int(height * .30))
 
-    elif config['ui']['display']['type'] in ('ws_1', 'ws1', 'waveshare_1', 'waveshare1', 
+    elif config['ui']['display']['type'] in ('ws_1', 'ws1', 'waveshare_1', 'waveshare1',
                                              'ws_2', 'ws2', 'waveshare_2', 'waveshare2'):
         fonts.setup(10, 9, 10, 35)
 
@@ -105,8 +105,19 @@ class View(object):
         for key, value in state.items():
             self._state.set(key, value)
 
+        plugins.on('ui_setup', self)
+
         _thread.start_new_thread(self._refresh_handler, ())
 
+    def add_element(self, key, elem):
+        self._state.add_element(key, elem)
+
+    def width(self):
+        return self._width
+
+    def height(self):
+        return self._height
+
     def on_state_change(self, key, cb):
         self._state.add_listener(key, cb)
 
@@ -294,6 +305,8 @@ class View(object):
             self._canvas = Image.new('1', (self._width, self._height), WHITE)
             drawer = ImageDraw.Draw(self._canvas)
 
+            plugins.on('ui_update', self)
+
             for key, lv in self._state.items():
                 lv.draw(self._canvas, drawer)