From 0f841293bb5cf8081d3e73b67e47fec77bba3f27 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 29 Jul 2025 12:00:22 +0200 Subject: [PATCH 001/108] UI fixes for button --- SBCEye.py | 8 +++++--- bus_drivers.py | 10 ++++++---- httpserver.py | 26 ++++++++++++++++---------- load_config.py | 4 +--- 4 files changed, 28 insertions(+), 20 deletions(-) diff --git a/SBCEye.py b/SBCEye.py index 860c298..7dacc2a 100644 --- a/SBCEye.py +++ b/SBCEye.py @@ -155,7 +155,7 @@ def __delitem__(self, item): def button_control(action="toggle"): '''Set the controlled pin to a specified state''' if settings.button_out > 0: - ret = f'{settings.button_name} ' + ret = f'{settings.button_label} ' pin = settings.button_out if action.lower() in ['toggle','invert','button']: GPIO.output(pin, not GPIO.input(pin)) @@ -287,8 +287,10 @@ def handle_exit(): logging.info('Button enabled') if len(settings.button_url) > 0: logging.info(f'Web Button enabled on: /{settings.button_url}') - print(f'Controllable pin ({settings.button_name}) configured and enabled; '\ - f'(pin={settings.button_pin}, url="{settings.button_url})"') + print(f'Button controllable pin ({settings.button_name}) configured and enabled; '\ + f'(button=gpio-{settings.button_pin}, '\ + f'label="{settings.button_label}", '\ + f'url="{settings.button_url})"') # Display animation setup if disp: diff --git a/bus_drivers.py b/bus_drivers.py index 56a4e47..bc80c2c 100644 --- a/bus_drivers.py +++ b/bus_drivers.py @@ -1,14 +1,14 @@ '''Bus based hardware driver initialisation Imports and starts the optional Bus based devices. -Gracefully fails and disables funcions as appropriate if anything goes wrong +Gracefully fails and disables services as appropriate if anything goes wrong I'd probably do this differently if writing from scratch/refactoring, and -try to make better use of Importlib in SBCEye.py. +make better use of Importlib in overwatch.py. ''' # pragma pylint: disable=import-outside-toplevel -import importlib +import importlib.util def i2c_setup(screen, sensor): @@ -49,7 +49,8 @@ def i2c_setup(screen, sensor): if sensor: # BME280 I2C Tepmerature Pressure and Humidity sensor try: - import adafruit_bme280 + #import adafruit_bme280 + from adafruit_bme280 import basic as adafruit_bme280 except ImportError as error: print(error) print("ERROR: BME280 environment sensor requirements not met") @@ -84,6 +85,7 @@ def i2c_setup(screen, sensor): try: # Create the I2C BME280 sensor object bme280 = adafruit_bme280.Adafruit_BME280_I2C(i2c, address=0x76) + #bme280 = adafruit_bme280.adafruit_bme280_i2c(i2c, address=0x76) print("BME280 sensor found with address 0x76") except RuntimeError as error: try: diff --git a/httpserver.py b/httpserver.py index 998b465..4a54f2f 100644 --- a/httpserver.py +++ b/httpserver.py @@ -243,16 +243,22 @@ def _give_graphlinks(self, skip=""): return ret def _give_links(self): - # Link to the log and pin contol pages - ret = f'''{self._give_graphlinks()} - - - Log\n''' + # Links to the graph pages + ret = f'{self._give_graphlinks()}' + # Links to the log and pin contol pages + ret += f'Server\n' if http.settings.web_show_control and (http.settings.button_pin > 0): - ret += f'  '\ - f'{http.settings.button_name}\n' - ret += '\n' + _, onoff = http.button_control('status') + state = 'On' if onoff else 'Off' + ret += f'\n'\ + f''\ + f'{http.settings.button_label}\n' + ret += f'{state}\n'\ + + ret += f'\n'\ + f''\ + f' Log\n' return ret def _give_log(self, lines=25): @@ -393,7 +399,7 @@ def do_GET(self): f' with action: {action}') status, state = http.button_control(action) self._set_headers() - response = self._give_head(f" :: {http.settings.button_name}") + response = self._give_head(f" :: {http.settings.button_label}") response += f'

{status}

\n' invert_state = http.settings.pin_state_names[not state] response += f'''
diff --git a/load_config.py b/load_config.py index 274adfb..3422261 100644 --- a/load_config.py +++ b/load_config.py @@ -118,14 +118,12 @@ def __init__(self): self.button_out = button.getint("out") self.button_pin = button.getint("pin") self.button_url = button.get("url") + self.button_label = button.get("label") self.button_hold = button.getfloat("hold") if self.button_out == 0: self.button_name = 'Undefined' else: self.button_name = f'gpio-{self.button_out}' - for name, pin in self.pin_map.items(): - if pin == self.button_out: - self.button_name = name intervals = config["intervals"] self.pin_interval = intervals.getint("pin") From 6d0532868f994931b9b4c7e67084b537c1248bb6 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 29 Jul 2025 12:40:30 +0200 Subject: [PATCH 002/108] webcam part 1 --- httpserver.py | 6 ++++-- load_config.py | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/httpserver.py b/httpserver.py index 4a54f2f..eec354f 100644 --- a/httpserver.py +++ b/httpserver.py @@ -449,8 +449,10 @@ def do_GET(self): if not "deco" in exclude: response += f'

{http.settings.name}

\n' if cam and http.settings.cam_url: - response += f'Webcam\n' + response += f''\ + f'Webcam'\ + f'
\n' response += '\n' if not "env" in exclude: response += self._give_env() diff --git a/load_config.py b/load_config.py index 3422261..c1dff98 100644 --- a/load_config.py +++ b/load_config.py @@ -165,7 +165,8 @@ def __init__(self): if "webcam" in config: cam = config["webcam"] self.cam_url = cam.get("url") - self.cam_width = cam.getint("width", 50) + self.cam_home = cam.get("home") + self.cam_width = cam.getint("width") # Optional [DEBUG] section can be enabled # If this section is present it changes the operation of From 73e50cbc4b27bd19284e5be403041a6074b156d5 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 30 Jul 2025 17:01:22 +0200 Subject: [PATCH 003/108] finish webcam, daily log instead of hourly --- SBCEye.py | 16 ++++++------ defaults.ini | 67 ++++++++++++++++++++++++++++++-------------------- httpserver.py | 64 ++++++++++++++++++++++++++++++++++++----------- load_config.py | 16 +++++------- 4 files changed, 104 insertions(+), 59 deletions(-) diff --git a/SBCEye.py b/SBCEye.py index 7dacc2a..73b084e 100644 --- a/SBCEye.py +++ b/SBCEye.py @@ -78,7 +78,7 @@ datefmt=settings.short_format, handlers=[handler]) -# Older scheduler versions can log debug to 'INFO' not 'DEBUG', change threshold. +# Older scheduler versions might log debug at wrong level, change threshold. schedule_logger = logging.getLogger('schedule') schedule_logger.setLevel(level=logging.WARN) @@ -228,13 +228,13 @@ def update_data(): net.update(data) rrd.update(data) -def hourly(): +def daily(): '''Remind everybody we are alive''' myself = os.path.basename(__file__) timestamp = time.strftime(settings.long_format) uptime = timedelta(seconds=int(time.time() - psutil.boot_time())) logging.info(f'{settings.name} :: up {uptime}') - print(f'{myself} :: {timestamp} :: {settings.name} :: up {uptime}') + print(f'{myself} :: {timestamp} :: {settings.name} :: up {uptime}',flush=True) def handle_signal(sig, *_): '''Handle common signals''' @@ -243,7 +243,7 @@ def handle_signal(sig, *_): DISPLAY.join() if sig == SIGHUP: handle_restart() - elif sig == SIGINT and settings.debug: + elif sig == SIGINT and settings.debug_sigint: handle_restart() else: # calling sys.exit() will invoke handle_exit() @@ -252,7 +252,7 @@ def handle_signal(sig, *_): def handle_restart(): '''In-Place safe restart (re-reads config)''' logging.info('Safe Restarting') - print('Restart\n') + print('Restart\n',flush=True) rrd.write_updates() os.execv(sys.executable, ['python'] + sys.argv) @@ -260,7 +260,7 @@ def handle_exit(): '''Ensure we write ipending data to the RRD database as we exit''' rrd.write_updates() logging.info('Exiting') - print('Graceful Exit\n') + print('Graceful Exit\n',flush=True) # The fun starts here: @@ -336,8 +336,8 @@ def handle_exit(): register(handle_exit) # Schedule pin monitoring, database updates and logging events - if settings.log_hourly: - schedule.every().hour.at(":00").do(hourly) + if settings.log_daily: + schedule.every().day.at("00:00").do(daily) schedule.every(settings.data_interval).seconds.do(update_data) if len(settings.pin_map.keys()) > 0: schedule.every(settings.pin_interval).seconds.do(pins.update_pins) diff --git a/defaults.ini b/defaults.ini index 1f3731f..3c3ce54 100644 --- a/defaults.ini +++ b/defaults.ini @@ -9,15 +9,15 @@ # name: Used in logs and web, blank = hostname # long_format: Long time format for web, etc. time.strftime() # short_format: Short time format for logs, etc. time.strftime() -# log_hourly: Log a 'heartbeat'every hour? True/False` -# sensor: look for BME280 sensor? True/False -# screen: look for OLED display? True/False +# log_daily: Log a 'heartbeat'every day? True/False` +# sensor: Look for BME280 sensor? True/False +# screen: Look for OLED display? True/False # pin_state_names: Localisation text for pin status (text,text) # -name = +name = My Awesome SBC long_format = %H:%M:%S, %A, %d %B, %Y short_format = %d-%m-%Y %H:%M:%S -log_hourly = True +log_daily = True screen = False sensor = False pin_state_names = Off,On @@ -28,7 +28,8 @@ pin_state_names = Off,On [web] # host: Ip address to bind server to, blank = bind to all addresses # port: Port number for web server -# sensor_name: Environmental sensor name used in web panel (eg; location) +# sensor_name: Sensor name used in web panel (eg; location) +# show_cam: Show the cam (if configured) by default # allow_dump: Allows RRDB database dumps, be careful using this # since it can impact performance while running # show_control: Include a link to the web pin control 'button' @@ -36,6 +37,7 @@ pin_state_names = Off,On host = port = 7080 sensor_name = Room +show_cam = False allow_dump = True show_control = True @@ -51,7 +53,7 @@ show_control = True # half_height: Graphs matching this prefix will be drawn half-height # # default durations are mapped in rrd graph as: start='end-', 'end=now' -# for more details on how to specify the range the rrd documentation. +# for more details on how to specify the range see the rrd documentation. # https://oss.oetiker.ch/rrdtool/doc/rrdfetch.en.html#TIME%20OFFSET%20SPECIFICATION # Note: 'm' means Minutes for large values, but Months for values from 1 to 5. # @@ -82,18 +84,21 @@ half_height = pin,net [button] # We can control one pin via a either the web ui, and/or a physical button -# out: bcm pin number we want to control, '0' to disable -# pin: bcm pin number of button, '0' to disable button -# url: / for web control, blank to disable +# out: bcm pin number we want to control, '0' to disable +# pin: bcm pin number of button, '0' to disable button +# url: / for web control, blank to disable web control +# label: Used in web UI for the mane of the pin # hold: Minimum press time, seconds (float), debounces and decouples the button # eg: # out = 7 # pin = 27 # url = lamp +# label = Workshop Lamp # out = 0 pin = 0 url = +label = hold = 0.250 # @@ -111,7 +116,6 @@ hold = 0.250 # # Data Logging and Recording Settings -[intervals] # Time intervals (seconds) for the main system action schedules # pin: Pins are checked for state changes this frequently # data: Interval between main reading updates @@ -119,25 +123,23 @@ hold = 0.250 # ping: Timout for ping responses # - must be > 4 to distinguish 'unavailable' vs 'not responding' in logs # - will be reduced if it exceeds the data interval (above) -0.5s -# -pin = 2 +[intervals] +pin = 10 data = 10 rrd = 300 ping = 4 -[log] -# Logging +# Action Logging # file_dir: Folder must be writable by the SBCEye process # file_name: .log # file_count: Maximum number of old logfiles to retain # file_size: Maximum size before logfile rolls over (Kb) -# +[log] file_dir = ./data file_name = SBCEye.log file_count = 3 file_size = 1024 -[rrd] # RRD database # dir: Folder must be writable by SBCEye process # file_name: Database file name; .rrd @@ -146,44 +148,55 @@ file_size = 1024 # backup_age: Backups will not be deleted if under this age, even # if that breaks the backup_count limit. (Days) # backup_time: Time of daily backup; HH:MM -# +[rrd] dir = ./data file_name = SBCEye.rrd backup_count = 10 backup_age = 7 backup_time = 23:45 -# # OLED Status dsplay options - -[display] -# Display orientation, contrast and burn-in prevention # rotate: Is the display 'upside down'? # - generally the connections from the glass are at the bottom # contrast: This gives a limited brightness reduction (0-255) # - does not give full dimming to black # invert: Default is light text on dark background -# +[display] rotate= False contrast = 127 invert = False -[saver] # Screen saver / burn-in reducer # saver_mode: Possible values are 'off', 'blank' and 'invert' # saver_on: Start time for screensaver (hour, 24hr clock) # saver_off: End time -# +[saver] mode = off on = 20 off = 8 -[animate] # Display Animation # passtime: time between display refreshes (seconds) # passes: number of refreshes of a screen before moving to next # speed: rows to scroll on each animation step between screens -# +[animate] passtime = 3 passes = 2 speed = 16 + +# Uncomment section below to enable webcam features +# Webcam options +# url: URL of webcam image (could be a stream) +# home: URL of webcam homepage +# width: Image width (percentage of browser window) +#[webcam] +#url = https://www.example.org/webcam/static.jpg +#home = https://www.example.org/webcam/ +#width = 50 + +# Enable extra debug +# http: Log http requests to console (or syslog when run as a service) +# sigint: restart on SIGINT instead of exiting +[debug] +http = False +sigint = False diff --git a/httpserver.py b/httpserver.py index eec354f..f9cc165 100644 --- a/httpserver.py +++ b/httpserver.py @@ -47,6 +47,9 @@ def serve_http(settings, rrd, data, helpers): 'graphing and dumping functions are unavailable') http.db_dumpable = False http.db_graphable = False + if settings.cam_url: + logging.info(f"Webcam configured at: {settings.cam_url}") + http.show_cam = settings.web_show_cam # Start the server logging.info(f'HTTP server will bind to port {str(settings.web_port)} '\ @@ -70,6 +73,14 @@ def serve_forever(httpd): class _BaseRequestHandler(http.server.BaseHTTPRequestHandler): '''Handles each individual request in a new thread''' + def log_message(self, format, *args): + # This function effectively suppresses the http server log output + if http.settings.debug_http: + print(f'HTTP request:: {self.client_address[0]} : {args[0]} '\ + f'({args[1]})',flush=True) + return + + def _set_headers(self): self.send_response(200) self.send_header("Content-type", "text/html") @@ -147,6 +158,24 @@ def _give_timestamp(self): title="Project homepage on GitHub" target="_blank"> SBCEye''' + #def _give_cam(self): + # return f'''\n + # \n''' + # # style="display: block; width: {http.settings.cam_width}%" + + def _give_cam(self): + return f'''
Cam
\n + + Webcam\n + ''' + def _give_env(self): # Environmental sensor sensorlist = { @@ -229,7 +258,7 @@ def _give_graphlinks(self, skip=""): if (len(http.settings.graph_durations) > 0) and http.db_graphable: if len(skip) == 0: ret += '\n' - ret += '\n' if http.settings.web_show_control and (http.settings.button_pin > 0): _, onoff = http.button_control('status') state = 'On' if onoff else 'Off' - ret += f'\n'\ + f'\n' - ret += f'\n'\ + f'{state}\n' + if http.settings.cam_url: + action = 'Hide' if http.show_cam else 'Show' + ret += f'\n'\ + f'\n' - ret += f'\n' + ret += f'\n' return ret def _give_log(self, lines=25): @@ -439,20 +474,21 @@ def do_GET(self): response += self._give_timestamp() response += self._give_foot(refresh=60, scroll=True) self._write_dedented(response) + elif urlparse(self.path).path == '/cam_toggle': + http.show_cam = not http.show_cam + self.send_response(302) + self.send_header('Location','.') + self.end_headers() elif urlparse(self.path).path == '/': # Main Page - cam = parse_qs(urlparse(self.path).query).get('cam', None) exclude = parse_qs(urlparse(self.path).query).get('exclude', '') exclude = [item for sublist in exclude for item in sublist.split(',')] self._set_headers() response = self._give_head() if not "deco" in exclude: response += f'

{http.settings.name}

\n' - if cam and http.settings.cam_url: - response += f''\ - f'Webcam'\ - f'
\n' + if not "cam" in exclude and http.settings.cam_url and http.show_cam: + response += self._give_cam() response += '
Cam
+ # + # Webcam + #
Graphs
\n' + ret += '
\n' for duration in http.settings.graph_durations: if duration != skip: ret += f' Server
\n'\ + ret += f'
{http.settings.button_label}:'\ f''\ - f'{http.settings.button_label}{state}
Cam view:'\ + f''\ + f'{action}
\n'\ - f''\ - f' Log
\n'\ + f'
'\ + f'Action Log
\n' if not "env" in exclude: response += self._give_env() diff --git a/load_config.py b/load_config.py index c1dff98..86f8437 100644 --- a/load_config.py +++ b/load_config.py @@ -82,7 +82,7 @@ def __init__(self): self.name = general.get("name") self.long_format = general.get("long_format") self.short_format = general.get("short_format") - self.log_hourly = general.getboolean("log_hourly") + self.log_daily = general.getboolean("log_daily") self.have_sensor = general.getboolean("sensor") self.have_screen = general.getboolean("screen") self.pin_state_names = tuple(general.get("pin_state_names").split(',')) @@ -93,6 +93,7 @@ def __init__(self): self.web_host = web.get("host") self.web_port = web.getint("port") self.web_sensor_name = web.get("sensor_name") + self.web_show_cam = web.getboolean("show_cam") self.web_allow_dump = web.getboolean("allow_dump") self.web_show_control = web.getboolean("show_control") @@ -161,20 +162,15 @@ def __init__(self): self.animate_passes = animate.getint("passes") self.animate_speed = animate.getint("speed") - self.cam_url = None + self.cam_url, self.cam_home, self.cam_width = None, None, 0 if "webcam" in config: cam = config["webcam"] self.cam_url = cam.get("url") self.cam_home = cam.get("home") self.cam_width = cam.getint("width") - # Optional [DEBUG] section can be enabled - # If this section is present it changes the operation of - # SIGINT (eg Ctrl-c) to restart the service, instead of exiting - # Currently has no other configurable items - if "debug" in config: - self.debug = True - else: - self.debug = False + debug = config["debug"] + self.debug_http = debug.getboolean("http") + self.debug_sigint = debug.getboolean("sigint") print("Settings loaded from configuration file successfully") From e52add5776665d33b7be221ee30d82c3a13286f6 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 31 Jul 2025 08:26:23 +0200 Subject: [PATCH 004/108] set correct process name in syslog --- SBCEye.service | 1 + 1 file changed, 1 insertion(+) diff --git a/SBCEye.service b/SBCEye.service index 66e35af..3b6a3ce 100644 --- a/SBCEye.service +++ b/SBCEye.service @@ -7,6 +7,7 @@ Type=simple Restart=always WorkingDirectory=/home/eye/SBCEye/ ExecStart=/home/eye/SBCEye/env/bin/python /home/eye/SBCEye/SBCEye.py +SyslogIdentifier=SBCEye User=eye Group=eye From ec796be6a38eb3d3f8ef419991370c18779921cc Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 31 Jul 2025 08:33:22 +0200 Subject: [PATCH 005/108] put gpio number in pin log --- pinreader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pinreader.py b/pinreader.py index c400d8a..2381ab0 100644 --- a/pinreader.py +++ b/pinreader.py @@ -51,7 +51,7 @@ def update_pins(self): if this_pin_state != self.data[f"pin-{name}"]: # Pin has changed state, store new state and log self.data[f'pin-{name}'] = this_pin_state - logging.info(f'{name}: {self.state_names[this_pin_state]}') + logging.info(f'{name} (gpio-{pin}): {self.state_names[this_pin_state]}') def get_pin(pin): '''Read pin state, return an integer From 4470af7ccd43d88d2b3818b03dabd0b3bf0267f5 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 31 Jul 2025 08:34:08 +0200 Subject: [PATCH 006/108] ini file consistent format --- defaults.ini | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/defaults.ini b/defaults.ini index 3c3ce54..5f45f53 100644 --- a/defaults.ini +++ b/defaults.ini @@ -1,10 +1,9 @@ # -# SBCEye Default Settings +# SBCEye Default Settings # Copy this file ('defaults.ini') to 'settings.ini' and edit # -[general] # General Settings # name: Used in logs and web, blank = hostname # long_format: Long time format for web, etc. time.strftime() @@ -14,6 +13,7 @@ # screen: Look for OLED display? True/False # pin_state_names: Localisation text for pin status (text,text) # +[general] name = My Awesome SBC long_format = %H:%M:%S, %A, %d %B, %Y short_format = %d-%m-%Y %H:%M:%S @@ -25,7 +25,6 @@ pin_state_names = Off,On # # Internal Webserver -[web] # host: Ip address to bind server to, blank = bind to all addresses # port: Port number for web server # sensor_name: Sensor name used in web panel (eg; location) @@ -34,6 +33,7 @@ pin_state_names = Off,On # since it can impact performance while running # show_control: Include a link to the web pin control 'button' # +[web] host = port = 7080 sensor_name = Room @@ -41,7 +41,6 @@ show_cam = False allow_dump = True show_control = True -[graph] # RRD graphing options # durations: Default graph durations # wide: Total width in pixels @@ -57,6 +56,7 @@ show_control = True # https://oss.oetiker.ch/rrdtool/doc/rrdfetch.en.html#TIME%20OFFSET%20SPECIFICATION # Note: 'm' means Minutes for large values, but Months for values from 1 to 5. # +[graph] durations = 600s,1h,3h,1d,3d,1w,1m,3m,1y,3y wide = 1200 high = 300 @@ -66,10 +66,6 @@ area_color = #D0E0E0#FFFFFF area_depth = 0 half_height = pin,net -# -# GPIO - -[pins] # GPIO pins to monitor and log # All pins are defined using BCM GPIO numbering # https://www.raspberrypi.org/forums/viewtopic.php?t=105200 @@ -81,8 +77,8 @@ half_height = pin,net # Ventilator = 8 # Printer = 25 # +[pins] -[button] # We can control one pin via a either the web ui, and/or a physical button # out: bcm pin number we want to control, '0' to disable # pin: bcm pin number of button, '0' to disable button @@ -95,26 +91,21 @@ half_height = pin,net # url = lamp # label = Workshop Lamp # +[button] out = 0 pin = 0 url = label = hold = 0.250 -# -# Connectivity - -[ping] -# A list of targets to be used for network connectivity (ping) tests +# An optional list of targets to be used for network connectivity (ping) tests # Targets are listed one per line: # = ip address # eg: # router = 192.168.0.1 # internet = 8.8.8.8 # - -# -# Data Logging and Recording Settings +[ping] # Time intervals (seconds) for the main system action schedules # pin: Pins are checked for state changes this frequently From be65f2c14c5dee812b65fd9da8ee10a1111b0a21 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 2 Aug 2025 17:55:44 +0200 Subject: [PATCH 007/108] flush console logs --- animator.py | 4 ++-- bus_drivers.py | 1 + saver.py | 12 ++++++++---- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/animator.py b/animator.py index 6f8be23..fe3f978 100644 --- a/animator.py +++ b/animator.py @@ -100,7 +100,7 @@ def __init__(self, settings, disp, data): # Notify logs etc logging.info('Display configured and enabled') - print('Display configured and enabled') + print('Display configured and enabled',flush=True) self._splash() @@ -237,7 +237,7 @@ def animate(settings, disp, queue): def die_with_dignity(*_): '''Exit cleanly (eg without stack trace) on a sigint/sigterm''' - print('Display animator process exiting') + print('Display animator process exiting',flush=True) sys_exit() signal(SIGTERM, die_with_dignity) diff --git a/bus_drivers.py b/bus_drivers.py index bc80c2c..d9c3aa1 100644 --- a/bus_drivers.py +++ b/bus_drivers.py @@ -96,4 +96,5 @@ def i2c_setup(screen, sensor): print(failure) print("We do not have a environmental sensor") + print(flush=True) return disp, bme280 diff --git a/saver.py b/saver.py index a2b62b9..8362bce 100644 --- a/saver.py +++ b/saver.py @@ -2,6 +2,7 @@ ''' import time +import logging class Saver: '''Saver class: @@ -33,12 +34,15 @@ def __init__(self, disp, settings): if len(invert) == 1: self.invert = invert[0] if self.mode != 'off': + logging.info(f'Saver will {self.mode} display between: '\ + f'{start:02d}:00 and {end:02d}:00') print(f'Saver will {self.mode} display between: '\ - f'{start}:00 and {end}:00') + f'{start:02d}:00 and {end:02d}:00',flush=True) if (start == end)\ or start not in range(0,23)\ or end not in range(0,23): - print('start/end times identical or out of range; disabling') + logging.warning('start/end times identical or out of range; disabling saver') + print('start/end times identical or out of range; disabling saver',flush=True) self.mode = 'off' elif start < end: self.saver_map = [False]*24 @@ -55,14 +59,14 @@ def _apply_state(self, state): '''Apply the desired state to the display''' if state: self.active = True - print('Saver activated') + print('Saver activated',flush=True) if self.mode == 'invert': self.disp.invert(not self.invert) elif self.mode == 'blank': self.disp.poweroff() else: self.active = False - print('Saver deactivated') + print('Saver deactivated',flush=True) if self.mode == 'invert': self.disp.invert(self.invert) elif self.mode == 'blank': From 07b8b6d04d41f7e5117df9cf831ce48e97416614 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 2 Aug 2025 17:56:54 +0200 Subject: [PATCH 008/108] add seperate config item for backup triggers --- defaults.ini | 5 ++++- httpserver.py | 13 ++----------- load_config.py | 1 + robin.py | 18 ++++++++++++------ 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/defaults.ini b/defaults.ini index 5f45f53..ec172db 100644 --- a/defaults.ini +++ b/defaults.ini @@ -29,7 +29,9 @@ pin_state_names = Off,On # port: Port number for web server # sensor_name: Sensor name used in web panel (eg; location) # show_cam: Show the cam (if configured) by default -# allow_dump: Allows RRDB database dumps, be careful using this +# allow_dump: Allows RRDB database dumps via we:, be careful using this +# since it can impact performance while running +# allow_backup: Allows RRDB database backup triggering via web, be careful using this # since it can impact performance while running # show_control: Include a link to the web pin control 'button' # @@ -39,6 +41,7 @@ port = 7080 sensor_name = Room show_cam = False allow_dump = True +allow_backup = True show_control = True # RRD graphing options diff --git a/httpserver.py b/httpserver.py index f9cc165..834ae5e 100644 --- a/httpserver.py +++ b/httpserver.py @@ -35,7 +35,7 @@ def serve_http(settings, rrd, data, helpers): http.icon_file = 'favicon.ico' if not os.path.exists(http.icon_file): http.icon_file = f'{sys.path[0]}/{http.icon_file}' - if rrd.rrdtool: + if rrd.rrdtool and rrd.gzip: http.db_graphable = True if settings.web_allow_dump: logging.info("RRD database is dumpable via web") @@ -43,7 +43,7 @@ def serve_http(settings, rrd, data, helpers): else: http.db_dumpable = False else: - logging.warning('Commandline rrdtool not found, '\ + logging.warning('Commandline rrdtool or gzip not found, '\ 'graphing and dumping functions are unavailable') http.db_dumpable = False http.db_graphable = False @@ -158,15 +158,6 @@ def _give_timestamp(self): title="Project homepage on GitHub" target="_blank"> SBCEye''' - #def _give_cam(self): - # return f'''\n - # \n''' - # # style="display: block; width: {http.settings.cam_width}%" - def _give_cam(self): return f'''
Cam
\n diff --git a/load_config.py b/load_config.py index 86f8437..a48a655 100644 --- a/load_config.py +++ b/load_config.py @@ -95,6 +95,7 @@ def __init__(self): self.web_sensor_name = web.get("sensor_name") self.web_show_cam = web.getboolean("show_cam") self.web_allow_dump = web.getboolean("allow_dump") + self.web_allow_backup = web.getboolean("allow_backup") self.web_show_control = web.getboolean("show_control") graph = config["graph"] diff --git a/robin.py b/robin.py index f01bd33..db7a2ce 100644 --- a/robin.py +++ b/robin.py @@ -6,7 +6,6 @@ import time from pathlib import Path import logging -import gzip import subprocess import os from shutil import which @@ -37,7 +36,6 @@ def __init__(self, s, data): self.graph_args["area_depth"] = s.graph_area_depth self.half_height = s.graph_half_height - # Sensor and system sources with limits (min,max) self.data_sources = { 'env-temp': ('-40','80'), @@ -166,12 +164,16 @@ def __init__(self, s, data): str(self.db_file), f"DS:{source}:GAUGE:60:{mini}:{maxi}") - # Disable dumping if rrdtool not in path + # Disable backup and dumping if rrdtool or gzip not in path self.rrdtool = which("rrdtool") - if self.rrdtool: + self.gzip = which("gzip") + if self.rrdtool and self.gzip: print(f'Commandline rrdtool: {self.rrdtool}') + print(f'Commandline gzip: {self.gzip}') else: - print('No commandline rrdtool available, ' + 'graphing and dumping disabled') + print('No commandline rrdtool and/or gzip available: backups disabled') + logging.warning('rrdtool or gzip not found: Disabling database backups') + self.backup_count = 0 # Use a home-brew local cache self.cache = [] @@ -188,7 +190,7 @@ def _backup(self): if self.backup_count > 0: # Copy to a timestamped file self.write_updates() - suffix = time.strftime("%Y-%m-%d.%H:%M:%S.gz") + suffix = time.strftime("%Y-%m-%d.%H:%M:%S.xml.gz") if not db_lock.acquire(blocking=True, timeout=600): print('Error: Backup failed, could not acquire db lock within 600s') return @@ -228,6 +230,10 @@ def start_backups(self): if self.backup_count > 0: schedule.every().day.at(self.backup_time).do(run_threaded, self._backup) + + def dump_to_file(self, filename): + + def dump(self): '''provide a gzipped dump of database''' dump_local.zipped = bytearray() From 5659d9a0ff8fbda3d7a887063650ab4edb81414d Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 2 Aug 2025 17:58:24 +0200 Subject: [PATCH 009/108] add web backup trigger and log --- httpserver.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/httpserver.py b/httpserver.py index 834ae5e..befdc26 100644 --- a/httpserver.py +++ b/httpserver.py @@ -42,9 +42,14 @@ def serve_http(settings, rrd, data, helpers): http.db_dumpable = True else: http.db_dumpable = False + if settings.web_allow_backup: + logging.info("RRD database can be triggered via web") + http.db_backupable = True + else: + http.db_backupable = False else: logging.warning('Commandline rrdtool or gzip not found, '\ - 'graphing and dumping functions are unavailable') + 'graphing, backup and dumping functions are unavailable') http.db_dumpable = False http.db_graphable = False if settings.cam_url: @@ -56,7 +61,7 @@ def serve_http(settings, rrd, data, helpers): f'on host {settings.web_host}') httpd.server_bind() address = f"http://{httpd.server_name}:{httpd.server_port}" - print(f"Webserver starting on : {address}") + print(f"Webserver starting on : {address}",flush=True) httpd.server_activate() def serve_forever(httpd): @@ -457,6 +462,13 @@ def do_GET(self): response += self._give_dump_portal() response += self._give_foot() self._write_dedented(response) + elif (urlparse(self.path).path == '/backup') and http.db_backupable: + # trigger a backup and notify + logging.info(f"RRD database backup triggered by {self.client_address[0]}") + self.send_response(302) + self.send_header('Location','log') + self.end_headers() + http.rrd.backup() elif urlparse(self.path).path == '/log': self._set_headers() response = self._give_head() From 52aecc59414d15f49f749b1d05292e4512598461 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 2 Aug 2025 17:59:42 +0200 Subject: [PATCH 010/108] convert to xml backups, use pipe and commandline gzip, logging stuff too --- robin.py | 76 +++++++++++++++++++++++++++++--------------------------- 1 file changed, 40 insertions(+), 36 deletions(-) diff --git a/robin.py b/robin.py index db7a2ce..fb32a9a 100644 --- a/robin.py +++ b/robin.py @@ -181,29 +181,38 @@ def __init__(self, s, data): self.cache_age = s.rrd_interval # Notify - print('RRD database and cache configured and enabled') + print('RRD database and cache configured and enabled',flush=True) logging.info(f'RRD database is: {str(self.db_file)}') - def _backup(self): + def backup(self): '''Backup and rotate old backups''' if self.backup_count > 0: # Copy to a timestamped file self.write_updates() suffix = time.strftime("%Y-%m-%d.%H:%M:%S.xml.gz") - if not db_lock.acquire(blocking=True, timeout=600): - print('Error: Backup failed, could not acquire db lock within 600s') - return start = time.time() - with open(f'{self.db_file}', 'rb') as dbfile: - with gzip.GzipFile( - f'{str(self.backup_path)}/{self.backup_name}.{suffix}', - mode = 'wb', compresslevel = 6) as zipfile: - zipfile.write(dbfile.read()) - db_lock.release() - #logging.info(f'Database backup saved as: {self.backup_name}.{suffix}') - print(f'Database backup saved as: {self.backup_name}.{suffix} '\ - f'(took: {(time.time() - start):.2f}s)') + try: + backupfile = open(f'{str(self.backup_path)}/{self.backup_name}.{suffix}', 'wb') + except Exception as e: + logging.error(f'Database backup file write failed: {self.backup_name}.{suffix}\n{e}') + print(f'Database backup file write failed: {self.backup_name}.{suffix}\n{e}',flush=True) + return + else: + with backupfile: + backupfile.write(self.dump()) + size = os.stat(f'{str(self.backup_path)}/{self.backup_name}.{suffix}').st_size + if size == 0: + logging.error(f'Database backup failed: empty datafile returned') + print(f'Database backup failed: empty datafile returned',flush=True) + try: + os.remove(f'{str(self.backup_path)}/{self.backup_name}.{suffix}') + except: + pass # ignore a failure here + return + logging.info(f'Database backup saved as: {self.backup_name}.{suffix} '\ + f'(size: {size}, took: {(time.time() - start):.2f}s)') + print(f'Database backup saved as: {self.backup_name}.{suffix}',flush=True) # Process old backups now = time.time() @@ -222,38 +231,33 @@ def _backup(self): else: os.remove(f'{self.backup_path}/{name}') #logging.info(f'Removed stale backup: {name}') - print(f'Removed stale backup: {name}') + print(f'Removed stale backup: {name}',flush=True) def start_backups(self): '''Add the backup schedule job''' # Start the backup schedule, using threads since it can run for some time if self.backup_count > 0: - schedule.every().day.at(self.backup_time).do(run_threaded, self._backup) - - - def dump_to_file(self, filename): + schedule.every().day.at(self.backup_time).do(run_threaded, self.backup) def dump(self): '''provide a gzipped dump of database''' dump_local.zipped = bytearray() - if self.rrdtool: + if self.rrdtool and self.gzip: self.write_updates() - print('Dump requested') + print('Dump requested',flush=True) if not db_lock.acquire(blocking=True, timeout=60): - print('Error: Dumping failed, could not acquire db lock within 60s') + print('Error: Dumping failed, could not acquire db lock within 60s',flush=True) return dump_local.zipped dump_local.start = time.time() - dump = subprocess.check_output([self.rrdtool, 'dump', str(self.db_file)]) + with subprocess.Popen([self.rrdtool, 'dump', str(self.db_file)], \ + stdout=subprocess.PIPE) as dump_local.raw: + dump_local.zipped = subprocess.check_output(('gzip'), stdin=dump_local.raw.stdout) db_lock.release() - print(f'Dump is: {len(dump)} bytes raw and '\ - f'took {(time.time() - dump_local.start):.2f}s') - dump_local.start = time.time() - dump_local.zipped = gzip.compress(dump, compresslevel=6) - print(f'Dump compressed to {len(dump_local.zipped)} bytes '\ - f'in {(time.time() - dump_local.start):.2f}s') + print(f'Dump is: {len(dump_local.zipped)} bytes compressed, and '\ + f'took {(time.time() - dump_local.start):.2f}s',flush=True) else: - print('Dump requested but denied because commandline "rrdtool" unavailable') + print('Dump requested but denied because commandline "rrdtool" or "gzip" unavailable',flush=True) return dump_local.zipped def update(self, data): @@ -271,11 +275,11 @@ def write_updates(self): if len(self.cache) > 0: if not db_lock.acquire(blocking=True, timeout=self.cache_age): print('Error: Data Write failed, could not acquire database '\ - f'lock within write period ({self.cache_age}s)') + f'lock within write period ({self.cache_age}s)',flush=True) return # check if cache was emptied in another thread while waiting for lock if len(self.cache) > 0: - # print(f'DB WRITE:len={len(self.cache)}') + # print(f'DB WRITE:len={len(self.cache)}',flush=True) try: rrdtool.update( str(self.db_file), @@ -285,7 +289,7 @@ def write_updates(self): self.cache = [] except rrdtool.OperationalError as rrd_error: print("RRDTool update error:") - print(rrd_error) + print(rrd_error,flush=True) db_lock.release() self.last_write = time.time() @@ -337,12 +341,12 @@ def draw_graph(self, start, end, duration, graph): print(f'Graph generation failed:\n{graph_error}') print(f'cmd: {graph_error.cmd}') print(f'output: {graph_error.output}') - print(f'stdout: {graph_error.stderr}') + print(f'stdout: {graph_error.stderr}',flush=True) if len(graph_local.response) == 0: - print(f'Error: png file generation failed for : {graph} : {start}>>{end}') + print(f'Error: png file generation failed for : {graph} : {start}>>{end}',flush=True) else: - print(f'Error: No graph available for type: {graph}') + print(f'Error: No graph available for type: {graph}',flush=True) return graph_local.response def run_threaded(job_func): From 30efdb19d3c5dfeb28a3eb6bf91e93bca054975a Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 4 Aug 2025 23:47:16 +0100 Subject: [PATCH 011/108] re-fix webcam to just open a new page with the URL --- defaults.ini | 9 +-------- httpserver.py | 33 +++++++++++---------------------- load_config.py | 4 +--- 3 files changed, 13 insertions(+), 33 deletions(-) diff --git a/defaults.ini b/defaults.ini index ec172db..7f9d370 100644 --- a/defaults.ini +++ b/defaults.ini @@ -28,7 +28,6 @@ pin_state_names = Off,On # host: Ip address to bind server to, blank = bind to all addresses # port: Port number for web server # sensor_name: Sensor name used in web panel (eg; location) -# show_cam: Show the cam (if configured) by default # allow_dump: Allows RRDB database dumps via we:, be careful using this # since it can impact performance while running # allow_backup: Allows RRDB database backup triggering via web, be careful using this @@ -39,7 +38,6 @@ pin_state_names = Off,On host = port = 7080 sensor_name = Room -show_cam = False allow_dump = True allow_backup = True show_control = True @@ -178,15 +176,10 @@ passtime = 3 passes = 2 speed = 16 -# Uncomment section below to enable webcam features -# Webcam options +# Uncomment section below to enable webcam link # url: URL of webcam image (could be a stream) -# home: URL of webcam homepage -# width: Image width (percentage of browser window) #[webcam] #url = https://www.example.org/webcam/static.jpg -#home = https://www.example.org/webcam/ -#width = 50 # Enable extra debug # http: Log http requests to console (or syslog when run as a service) diff --git a/httpserver.py b/httpserver.py index befdc26..93ff63c 100644 --- a/httpserver.py +++ b/httpserver.py @@ -43,7 +43,7 @@ def serve_http(settings, rrd, data, helpers): else: http.db_dumpable = False if settings.web_allow_backup: - logging.info("RRD database can be triggered via web") + logging.info("RRD database backups can be triggered via web") http.db_backupable = True else: http.db_backupable = False @@ -54,7 +54,6 @@ def serve_http(settings, rrd, data, helpers): http.db_graphable = False if settings.cam_url: logging.info(f"Webcam configured at: {settings.cam_url}") - http.show_cam = settings.web_show_cam # Start the server logging.info(f'HTTP server will bind to port {str(settings.web_port)} '\ @@ -263,7 +262,8 @@ def _give_graphlinks(self, skip=""): else: ret += f' {duration} \n' if len(skip) > 0: - ret += ' :  Home\n' + ret += '\n\n' return ret @@ -271,24 +271,20 @@ def _give_links(self): # Links to the graph pages ret = f'{self._give_graphlinks()}' # Links to the pin contol, cam show/hide and log pages - ret += f'\n' - if http.settings.web_show_control and (http.settings.button_pin > 0): + ret += f'\n' + if http.settings.web_show_control and (http.settings.button_out > 0): _, onoff = http.button_control('status') state = 'On' if onoff else 'Off' - ret += f'\n'\ - f'\n' + f'{http.settings.button_label}: {state}\n' if http.settings.cam_url: - action = 'Hide' if http.show_cam else 'Show' - ret += f'\n'\ - f'\n' - + ret += f'\n' ret += f'\n' return ret @@ -477,11 +473,6 @@ def do_GET(self): response += self._give_timestamp() response += self._give_foot(refresh=60, scroll=True) self._write_dedented(response) - elif urlparse(self.path).path == '/cam_toggle': - http.show_cam = not http.show_cam - self.send_response(302) - self.send_header('Location','.') - self.end_headers() elif urlparse(self.path).path == '/': # Main Page exclude = parse_qs(urlparse(self.path).query).get('exclude', '') @@ -490,8 +481,6 @@ def do_GET(self): response = self._give_head() if not "deco" in exclude: response += f'

{http.settings.name}

\n' - if not "cam" in exclude and http.settings.cam_url and http.show_cam: - response += self._give_cam() response += '
Cam
- # - # Webcam - #
'\ + 'Home\n' ret += '
Server
{http.settings.button_label}:'\ + ret += f'
'\ f''\ - f'{state}
Cam view:'\ - f''\ - f'{action}
'\ + f''\ + f'Cam viewer
\n'\ - f'
'\ + f''\ f'Action Log
\n' if not "env" in exclude: response += self._give_env() diff --git a/load_config.py b/load_config.py index a48a655..4a9752a 100644 --- a/load_config.py +++ b/load_config.py @@ -163,12 +163,10 @@ def __init__(self): self.animate_passes = animate.getint("passes") self.animate_speed = animate.getint("speed") - self.cam_url, self.cam_home, self.cam_width = None, None, 0 + self.cam_url = None if "webcam" in config: cam = config["webcam"] self.cam_url = cam.get("url") - self.cam_home = cam.get("home") - self.cam_width = cam.getint("width") debug = config["debug"] self.debug_http = debug.getboolean("http") From b810124a59a6db737850aa60e93bc581e8ae35e3 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 5 Aug 2025 00:01:38 +0100 Subject: [PATCH 012/108] use smbus2 and generic bme280 libraries in place of adafruit ones --- SBCEye.py | 13 +++++++------ bus_drivers.py | 28 ++++++++++++---------------- 2 files changed, 19 insertions(+), 22 deletions(-) diff --git a/SBCEye.py b/SBCEye.py index 73b084e..b35418f 100644 --- a/SBCEye.py +++ b/SBCEye.py @@ -105,7 +105,7 @@ # # Import, setup and return hardware drivers, or 'None' if setup fails -disp, bme280 = i2c_setup(settings.have_screen, settings.have_sensor) +disp, bme = i2c_setup(settings.have_screen, settings.have_sensor) if disp: disp.contrast(settings.display_contrast) @@ -213,10 +213,11 @@ def update_system(): def update_sensors(): '''Get current environmental sensor data ''' - if bme280: - data['env-temp'] = bme280.temperature - data['env-humi'] = bme280.relative_humidity - data['env-pres'] = bme280.pressure + if bme: + bme.update_sensor() + data['env-temp'] = bme.temperature + data['env-humi'] = bme.humidity + data['env-pres'] = bme.pressure # Failed pressure measurements really foul up the graph, skip if data['env-pres'] == 0: data['env-pres'] = 'U' @@ -267,7 +268,7 @@ def handle_exit(): if __name__ == '__main__': # Log sensor status - if bme280: + if bme: logging.info('Environmental sensor configured and enabled') elif settings.have_sensor: logging.warning('Environmental data configured but no sensor detected: '\ diff --git a/bus_drivers.py b/bus_drivers.py index d9c3aa1..7e6d486 100644 --- a/bus_drivers.py +++ b/bus_drivers.py @@ -29,9 +29,9 @@ def i2c_setup(screen, sensor): # Start by trying to load the correct modules if screen or sensor: # I2C Comms + # Uses standard SMBUS lib (currently smbus2) try: - import busio - from board import SCL, SDA + import smbus2 except ImportError as error: print(error) print("ERROR: I2C bus requirements not met") @@ -48,9 +48,11 @@ def i2c_setup(screen, sensor): if sensor: # BME280 I2C Tepmerature Pressure and Humidity sensor + # Uses pimoroni library: + # https://github.com/pimoroni/bme280-python + # pip install pimoroni-bme280 try: - #import adafruit_bme280 - from adafruit_bme280 import basic as adafruit_bme280 + import bme280 except ImportError as error: print(error) print("ERROR: BME280 environment sensor requirements not met") @@ -60,7 +62,7 @@ def i2c_setup(screen, sensor): if screen or sensor: try: # Create the I2C interface object - i2c = busio.I2C(SCL, SDA) + i2c = smbus2.SMBus(1) print('We have a I2C bus') except ValueError as error: print(error) @@ -84,17 +86,11 @@ def i2c_setup(screen, sensor): if sensor: try: # Create the I2C BME280 sensor object - bme280 = adafruit_bme280.Adafruit_BME280_I2C(i2c, address=0x76) - #bme280 = adafruit_bme280.adafruit_bme280_i2c(i2c, address=0x76) - print("BME280 sensor found with address 0x76") + bmeSensor = bme280.BME280(i2c_dev=i2c) + print("BME280 sensor found") except RuntimeError as error: - try: - bme280 = adafruit_bme280.Adafruit_BME280_I2C(i2c, address=0x77) - print("BME280 sensor found with address 0x77") - except RuntimeError as failure: - print(error) - print(failure) - print("We do not have a environmental sensor") + print(error) + print("We do not have a environmental sensor") print(flush=True) - return disp, bme280 + return disp, bmeSensor From 0ad7324877139bdd7874ba39c57910eece47c1e1 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 5 Aug 2025 14:40:00 +0100 Subject: [PATCH 013/108] configurable list of links in place of single webcam link --- defaults.ini | 54 +++++++++++++++++++++++++++++--------------------- httpserver.py | 12 ++++++----- load_config.py | 32 ++++++++++++++++-------------- 3 files changed, 55 insertions(+), 43 deletions(-) diff --git a/defaults.ini b/defaults.ini index 7f9d370..4c8085d 100644 --- a/defaults.ini +++ b/defaults.ini @@ -67,6 +67,18 @@ area_color = #D0E0E0#FFFFFF area_depth = 0 half_height = pin,net +# List of fixed links for the main page's footer. +# Give one link per line: +# link_name = URL +# Underscores in the name will be converted to spaces when displayed +# eg: +# [links] +# Homepage = https://www.example.org/ +# Webcam_Viewer = https://www.example.org/webcam/?action=stream +# junk = Some Junk.. +# +[links] + # GPIO pins to monitor and log # All pins are defined using BCM GPIO numbering # https://www.raspberrypi.org/forums/viewtopic.php?t=105200 @@ -74,31 +86,13 @@ half_height = pin,net # Pins are listed one per line: # = BCM pin number # eg: +# [pins] # Lamp = 7 # Ventilator = 8 # Printer = 25 # [pins] -# We can control one pin via a either the web ui, and/or a physical button -# out: bcm pin number we want to control, '0' to disable -# pin: bcm pin number of button, '0' to disable button -# url: / for web control, blank to disable web control -# label: Used in web UI for the mane of the pin -# hold: Minimum press time, seconds (float), debounces and decouples the button -# eg: -# out = 7 -# pin = 27 -# url = lamp -# label = Workshop Lamp -# -[button] -out = 0 -pin = 0 -url = -label = -hold = 0.250 - # An optional list of targets to be used for network connectivity (ping) tests # Targets are listed one per line: # = ip address @@ -176,10 +170,24 @@ passtime = 3 passes = 2 speed = 16 -# Uncomment section below to enable webcam link -# url: URL of webcam image (could be a stream) -#[webcam] -#url = https://www.example.org/webcam/static.jpg +# We can control one pin via a either the web ui, and/or a physical button +# out: bcm pin number we want to control, '0' to disable +# pin: bcm pin number of button, '0' to disable button +# url: / for web control, blank to disable web control +# label: Used in web UI for the mane of the pin +# hold: Minimum press time, seconds (float), debounces and decouples the button +# eg: +# out = 7 +# pin = 27 +# url = lamp +# label = Workshop Lamp +# +[button] +out = 0 +pin = 0 +url = +label = +hold = 0.250 # Enable extra debug # http: Log http requests to console (or syslog when run as a service) diff --git a/httpserver.py b/httpserver.py index 93ff63c..502e346 100644 --- a/httpserver.py +++ b/httpserver.py @@ -52,8 +52,10 @@ def serve_http(settings, rrd, data, helpers): 'graphing, backup and dumping functions are unavailable') http.db_dumpable = False http.db_graphable = False - if settings.cam_url: - logging.info(f"Webcam configured at: {settings.cam_url}") + + # Note the list of link targets + for link in settings.links: + logging.info(f"Web link '{link}' points to: {settings.links[link]}") # Start the server logging.info(f'HTTP server will bind to port {str(settings.web_port)} '\ @@ -279,10 +281,10 @@ def _give_links(self): f''\ f'{http.settings.button_label}: {state}\n' - if http.settings.cam_url: + for link in http.settings.links: ret += f'\n' + f''\ + f'{link}\n' ret += f'\n' diff --git a/load_config.py b/load_config.py index 4a9752a..58958de 100644 --- a/load_config.py +++ b/load_config.py @@ -26,6 +26,8 @@ class Settings: def __init__(self): + if sys.path[0] == '': + sys.path[0] = '.' self.my_version = check_output(["git", "describe", "--tags", "--always", "--dirty"], cwd=sys.path[0]).decode('ascii').strip() @@ -108,6 +110,11 @@ def __init__(self): self.graph_area_depth = graph.get("area_depth") self.graph_half_height = graph.get("half_height").split(',') + self.links= {} + for name in config["links"]: + real = name.replace('_',' ') + self.links[real] = config.get("links",name) + self.pin_map = {} for pin in config["pins"]: self.pin_map[pin] = config.getint("pins",pin) @@ -116,17 +123,6 @@ def __init__(self): for host in config["ping"]: self.net_map[host] = config.get("ping",host) - button = config["button"] - self.button_out = button.getint("out") - self.button_pin = button.getint("pin") - self.button_url = button.get("url") - self.button_label = button.get("label") - self.button_hold = button.getfloat("hold") - if self.button_out == 0: - self.button_name = 'Undefined' - else: - self.button_name = f'gpio-{self.button_out}' - intervals = config["intervals"] self.pin_interval = intervals.getint("pin") self.data_interval = intervals.getint("data") @@ -163,10 +159,16 @@ def __init__(self): self.animate_passes = animate.getint("passes") self.animate_speed = animate.getint("speed") - self.cam_url = None - if "webcam" in config: - cam = config["webcam"] - self.cam_url = cam.get("url") + button = config["button"] + self.button_out = button.getint("out") + self.button_pin = button.getint("pin") + self.button_url = button.get("url") + self.button_label = button.get("label") + self.button_hold = button.getfloat("hold") + if self.button_out == 0: + self.button_name = 'Undefined' + else: + self.button_name = f'gpio-{self.button_out}' debug = config["debug"] self.debug_http = debug.getboolean("http") From e5ca2483058cfdc5524370078827d57f8ca845eb Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 6 Aug 2025 09:48:43 +0100 Subject: [PATCH 014/108] consistent footer for log pages --- httpserver.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/httpserver.py b/httpserver.py index 502e346..410e9ba 100644 --- a/httpserver.py +++ b/httpserver.py @@ -314,9 +314,9 @@ def _give_log(self, lines=25): Latest {lines} lines shown\n \n
25 : -  250 : -  2500 : -  Home
\n''' + 250 : + 2500\n +
Home
\n''' return ret def _give_graphs(self, start, end, stamp): From 81a1cbf80a4bbccc2813b70faf9d3a0103f3298d Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 6 Aug 2025 09:49:31 +0100 Subject: [PATCH 015/108] bus and address config settings --- SBCEye.py | 6 +++++- bus_drivers.py | 16 ++++++++++------ defaults.ini | 9 +++++++++ load_config.py | 14 ++++++++++++++ 4 files changed, 38 insertions(+), 7 deletions(-) diff --git a/SBCEye.py b/SBCEye.py index b35418f..36fc62c 100644 --- a/SBCEye.py +++ b/SBCEye.py @@ -105,7 +105,11 @@ # # Import, setup and return hardware drivers, or 'None' if setup fails -disp, bme = i2c_setup(settings.have_screen, settings.have_sensor) +disp, bme = i2c_setup(settings.have_screen, + settings.have_sensor, + settings.bus_id, + settings.sensor_addr, + settings.screen_addr) if disp: disp.contrast(settings.display_contrast) diff --git a/bus_drivers.py b/bus_drivers.py index 7e6d486..7c9c04f 100644 --- a/bus_drivers.py +++ b/bus_drivers.py @@ -11,12 +11,15 @@ import importlib.util -def i2c_setup(screen, sensor): +def i2c_setup(screen, sensor, bus_id, sensor_addr, screen_addr): '''Import and start the I2C bus devices parameters: - screen: (bool) is screen enabled in config? - sensor: (bool) is environmental sensor (bme280) enabled in config? + screen: (bool) is screen enabled in config? + sensor: (bool) is environmental sensor (bme280) enabled in config? + bus_id: (int) the I2C bus to use + sensor_addr: (int) the I2C address of the bme280 sensor + screen_addr: (int) the I2C address of the ssd1306 display returns: disp: Display driverr object or None if failed @@ -62,7 +65,7 @@ def i2c_setup(screen, sensor): if screen or sensor: try: # Create the I2C interface object - i2c = smbus2.SMBus(1) + i2c = smbus2.SMBus(bus_id) print('We have a I2C bus') except ValueError as error: print(error) @@ -72,7 +75,8 @@ def i2c_setup(screen, sensor): if screen: try: # Create the I2C display object - disp = adafruit_ssd1306.SSD1306_I2C(128, 64, i2c) + disp = adafruit_ssd1306.SSD1306_I2C(128, 64, i2c, screen_addr) # < address not in correct position? + #disp = Adafruit_SSD1306.SSD1306Base(128,64,rst=None,i2c=i2c) print("SSD1306 i2c display found") except RuntimeError as error: disp = None @@ -86,7 +90,7 @@ def i2c_setup(screen, sensor): if sensor: try: # Create the I2C BME280 sensor object - bmeSensor = bme280.BME280(i2c_dev=i2c) + bmeSensor = bme280.BME280(i2c_addr=sensor_addr, i2c_dev=i2c) print("BME280 sensor found") except RuntimeError as error: print(error) diff --git a/defaults.ini b/defaults.ini index 4c8085d..a21e061 100644 --- a/defaults.ini +++ b/defaults.ini @@ -142,6 +142,15 @@ backup_age = 7 backup_time = 23:45 # OLED Status dsplay options +# I2C Bus options +# bus_id: The I2C bus to use, integer +# sensor_addr: I2C address of the sensor +# screen_addr: I2C address of the screen +[bus] +bus_id = 1 +sensor_addr = 0x76 +screen_addr = 0x3c + # rotate: Is the display 'upside down'? # - generally the connections from the glass are at the bottom # contrast: This gives a limited brightness reduction (0-255) diff --git a/load_config.py b/load_config.py index 58958de..c136917 100644 --- a/load_config.py +++ b/load_config.py @@ -26,6 +26,14 @@ class Settings: def __init__(self): + def hexint(instring): + ''' Helper function so we can use '0xNN' hex values for some inputs''' + try: + outint = int(instring) + except ValueError: + outint = int(instring, 16) + return outint + if sys.path[0] == '': sys.path[0] = '.' self.my_version = check_output(["git", "describe", "--tags", @@ -144,6 +152,12 @@ def __init__(self): self.rrd_backup_age = int(abs(rrd.getfloat("backup_age")) * 86400) self.rrd_backup_time = rrd.get("backup_time") + bus = config["bus"] + self.bus_id = hexint(bus.get("bus_id")) + self.sensor_addr = hexint(bus.get("sensor_addr")) + self.screen_addr = hexint(bus.get("screen_addr")) + print("!!!!!!!!!!!!!!!!",self.bus_id,self.sensor_addr,self.screen_addr) + display = config["display"] self.display_rotate = display.getboolean("rotate") self.display_contrast = display.getint("contrast") From b5d57df06d55cc2d3591de97c2c7fbb93fa0405d Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 8 Aug 2025 12:23:48 +0100 Subject: [PATCH 016/108] rename screen options to display --- SBCEye.py | 14 +++++--------- defaults.ini | 8 ++++---- load_config.py | 5 ++--- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/SBCEye.py b/SBCEye.py index 36fc62c..1c9acff 100644 --- a/SBCEye.py +++ b/SBCEye.py @@ -105,16 +105,12 @@ # # Import, setup and return hardware drivers, or 'None' if setup fails -disp, bme = i2c_setup(settings.have_screen, - settings.have_sensor, - settings.bus_id, - settings.sensor_addr, - settings.screen_addr) +disp, bme = i2c_setup(settings) if disp: disp.contrast(settings.display_contrast) - disp.invert(settings.display_invert) - disp.fill(0) # Blank asap in case we are showing garbage + #disp.invert(settings.display_invert) + #disp.fill(0) # Blank asap in case we are showing garbage disp.show() if settings.button_out > 0: @@ -244,7 +240,7 @@ def daily(): def handle_signal(sig, *_): '''Handle common signals''' if DISPLAY: - # clean up the screen process + # clean up the display process DISPLAY.join() if sig == SIGHUP: handle_restart() @@ -306,7 +302,7 @@ def handle_exit(): DISPLAY.start() else: DISPLAY = None - if settings.have_screen: + if settings.have_display: logging.warning('Display configured but did not initialise properly: '\ 'Display features disabled') diff --git a/defaults.ini b/defaults.ini index a21e061..57ded89 100644 --- a/defaults.ini +++ b/defaults.ini @@ -10,7 +10,7 @@ # short_format: Short time format for logs, etc. time.strftime() # log_daily: Log a 'heartbeat'every day? True/False` # sensor: Look for BME280 sensor? True/False -# screen: Look for OLED display? True/False +# display: Look for OLED display? True/False # pin_state_names: Localisation text for pin status (text,text) # [general] @@ -18,7 +18,7 @@ name = My Awesome SBC long_format = %H:%M:%S, %A, %d %B, %Y short_format = %d-%m-%Y %H:%M:%S log_daily = True -screen = False +display = False sensor = False pin_state_names = Off,On @@ -145,11 +145,11 @@ backup_time = 23:45 # I2C Bus options # bus_id: The I2C bus to use, integer # sensor_addr: I2C address of the sensor -# screen_addr: I2C address of the screen +# display_addr: I2C address of the display [bus] bus_id = 1 sensor_addr = 0x76 -screen_addr = 0x3c +display_addr = 0x3c # rotate: Is the display 'upside down'? # - generally the connections from the glass are at the bottom diff --git a/load_config.py b/load_config.py index c136917..a7a56ea 100644 --- a/load_config.py +++ b/load_config.py @@ -94,7 +94,7 @@ def hexint(instring): self.short_format = general.get("short_format") self.log_daily = general.getboolean("log_daily") self.have_sensor = general.getboolean("sensor") - self.have_screen = general.getboolean("screen") + self.have_display = general.getboolean("display") self.pin_state_names = tuple(general.get("pin_state_names").split(',')) if self.name == "": self.name = f'{os.uname().nodename}' @@ -155,8 +155,7 @@ def hexint(instring): bus = config["bus"] self.bus_id = hexint(bus.get("bus_id")) self.sensor_addr = hexint(bus.get("sensor_addr")) - self.screen_addr = hexint(bus.get("screen_addr")) - print("!!!!!!!!!!!!!!!!",self.bus_id,self.sensor_addr,self.screen_addr) + self.display_addr = hexint(bus.get("display_addr")) display = config["display"] self.display_rotate = display.getboolean("rotate") From 60ad99d4b267be22c91f289de2f3099138e8ff2d Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 8 Aug 2025 12:25:39 +0100 Subject: [PATCH 017/108] swap to luma display drivers --- bus_drivers.py | 56 ++++++++++++++++++++++++++------------------------ 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/bus_drivers.py b/bus_drivers.py index 7c9c04f..f8b6bd5 100644 --- a/bus_drivers.py +++ b/bus_drivers.py @@ -11,43 +11,42 @@ import importlib.util -def i2c_setup(screen, sensor, bus_id, sensor_addr, screen_addr): +def i2c_setup(settings): '''Import and start the I2C bus devices parameters: - screen: (bool) is screen enabled in config? - sensor: (bool) is environmental sensor (bme280) enabled in config? - bus_id: (int) the I2C bus to use - sensor_addr: (int) the I2C address of the bme280 sensor - screen_addr: (int) the I2C address of the ssd1306 display - + settings: settings oject (from load_config) returns: disp: Display driverr object or None if failed - bme280: Sensor module object, or None if failed + bme: Sensor module object, or None if failed ''' + # booleans + sensor = settings.have_sensor + display = settings.have_display + # objects to be returned if successful disp = None - bme280 = None + bme = None # Start by trying to load the correct modules - if screen or sensor: + if display or sensor: # I2C Comms # Uses standard SMBUS lib (currently smbus2) try: import smbus2 except ImportError as error: + display = sensor = False print(error) print("ERROR: I2C bus requirements not met") - screen = sensor = False - if screen: - # I2C 128x64 OLED Display + if display: + # I2C OLED Display try: - import adafruit_ssd1306 + from luma.oled.device import ssd1306 except ImportError as error: + display = False print(error) print("ERROR: ssd1306 display requirements not met") - screen = False if sensor: # BME280 I2C Tepmerature Pressure and Humidity sensor @@ -57,44 +56,47 @@ def i2c_setup(screen, sensor, bus_id, sensor_addr, screen_addr): try: import bme280 except ImportError as error: + sensor = False print(error) print("ERROR: BME280 environment sensor requirements not met") - sensor = False # Now the actual device driver objects - if screen or sensor: + if display or sensor: try: # Create the I2C interface object - i2c = smbus2.SMBus(bus_id) + i2c = smbus2.SMBus(settings.bus_id) print('We have a I2C bus') except ValueError as error: + display = sensor = False print(error) print("No I2C bus, display and sensor functions will be disabled") - screen = sensor = False - if screen: + if display: + # for luma display rotation must be specified here, + # (value from 0 to 3, rotating 90 degrees each step) + rotate = 0 if settings.display_rotate else 2 try: - # Create the I2C display object - disp = adafruit_ssd1306.SSD1306_I2C(128, 64, i2c, screen_addr) # < address not in correct position? - #disp = Adafruit_SSD1306.SSD1306Base(128,64,rst=None,i2c=i2c) + # Create the display object + disp = ssd1306(bus=i2c, address=settings.display_addr, rotate=rotate) print("SSD1306 i2c display found") except RuntimeError as error: disp = None print(error) print("ERROR: SSD1306 i2c display failed to initialise, disabling") - if not importlib.util.find_spec("PIL"): + if not importlib.util.find_spec("luma"): disp = None - print("ERROR: PIL graphics module not found, disabling display") + print("ERROR: Luma library not found, disabling display") if sensor: try: # Create the I2C BME280 sensor object - bmeSensor = bme280.BME280(i2c_addr=sensor_addr, i2c_dev=i2c) + bme = bme280.BME280(i2c_addr=settings.sensor_addr, i2c_dev=i2c) print("BME280 sensor found") except RuntimeError as error: + bme = None print(error) print("We do not have a environmental sensor") print(flush=True) - return disp, bmeSensor + return disp, bme From b5cf182f34e7911730f69bc967af2e743c0ea35a Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 8 Aug 2025 12:39:40 +0100 Subject: [PATCH 018/108] remove invert feature, luma does not support it --- SBCEye.py | 1 - animator.py | 4 ++-- defaults.ini | 2 -- load_config.py | 1 - saver.py | 25 +++++++------------------ 5 files changed, 9 insertions(+), 24 deletions(-) diff --git a/SBCEye.py b/SBCEye.py index 1c9acff..22124e6 100644 --- a/SBCEye.py +++ b/SBCEye.py @@ -109,7 +109,6 @@ if disp: disp.contrast(settings.display_contrast) - #disp.invert(settings.display_invert) #disp.fill(0) # Blank asap in case we are showing garbage disp.show() diff --git a/animator.py b/animator.py index fe3f978..947f306 100644 --- a/animator.py +++ b/animator.py @@ -47,7 +47,7 @@ class Animator: Handles starting the display and then displays the desired information screens according to user-defined 'frame' rate. Screens are 'slid' into place to provide a pleasing animation effect - A screensaver can be invoked to blank or invert the display as the user wishes + A screensaver can be invoked to blank the display as the user wishes ''' def __init__(self, settings, disp, data): @@ -93,7 +93,7 @@ def __init__(self, settings, disp, data): # Start saver saver_settings = (settings.saver_mode, settings.saver_on, - settings.saver_off, settings.display_invert) + settings.saver_off) self.screensaver = Saver(disp, saver_settings) self.screensaver.check() schedule.every().hour.at(":00").do(self.screensaver.check) diff --git a/defaults.ini b/defaults.ini index 57ded89..3a69e8e 100644 --- a/defaults.ini +++ b/defaults.ini @@ -155,11 +155,9 @@ display_addr = 0x3c # - generally the connections from the glass are at the bottom # contrast: This gives a limited brightness reduction (0-255) # - does not give full dimming to black -# invert: Default is light text on dark background [display] rotate= False contrast = 127 -invert = False # Screen saver / burn-in reducer # saver_mode: Possible values are 'off', 'blank' and 'invert' diff --git a/load_config.py b/load_config.py index a7a56ea..fc94198 100644 --- a/load_config.py +++ b/load_config.py @@ -160,7 +160,6 @@ def hexint(instring): display = config["display"] self.display_rotate = display.getboolean("rotate") self.display_contrast = display.getint("contrast") - self.display_invert = display.getboolean("invert") saver = config["saver"] self.saver_mode = saver.get("mode") diff --git a/saver.py b/saver.py index 8362bce..db81aec 100644 --- a/saver.py +++ b/saver.py @@ -1,4 +1,4 @@ -'''Implements a screen saver/inverter for the SBCEye project +'''Implements a screen saver for the SBCEye project ''' import time @@ -7,21 +7,17 @@ class Saver: '''Saver class: Turns the display on/off between specified times - Can also invert the display as a form of burn-in protection modes: 'off': Screensaver disabled 'blank': Turn screen off - 'invert': Invert the screen parameters: disp: display driver object settings: (tuple) consisting of: - mode: (str) One of 'off', 'blank', 'invert' + mode: (str) One of 'off', 'blank' start: (int) Start time, hour, 0-23 end: (int) End time, hour, 0-23 - invert: (bool) Base invert state for the display - Optional, defaults to False ''' active = False # Current state @@ -29,10 +25,7 @@ class Saver: def __init__(self, disp, settings): self.disp = disp - (self.mode, start, end, *invert) = settings - self.invert = False - if len(invert) == 1: - self.invert = invert[0] + (self.mode, start, end) = settings if self.mode != 'off': logging.info(f'Saver will {self.mode} display between: '\ f'{start:02d}:00 and {end:02d}:00') @@ -59,17 +52,13 @@ def _apply_state(self, state): '''Apply the desired state to the display''' if state: self.active = True - print('Saver activated',flush=True) - if self.mode == 'invert': - self.disp.invert(not self.invert) - elif self.mode == 'blank': + if self.mode == 'blank': + print('Saver activated',flush=True) self.disp.poweroff() else: self.active = False - print('Saver deactivated',flush=True) - if self.mode == 'invert': - self.disp.invert(self.invert) - elif self.mode == 'blank': + if self.mode == 'blank': + print('Saver deactivated',flush=True) self.disp.poweron() def check(self): From 82afc0c34c475b7449bf6b3304db389838330077 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 8 Aug 2025 16:09:22 +0200 Subject: [PATCH 019/108] display final, gpio wip. --- SBCEye.py | 26 +++++++++++++------------- animator.py | 22 ++++++++++++++-------- bus_drivers.py | 2 +- defaults.ini | 2 +- 4 files changed, 29 insertions(+), 23 deletions(-) diff --git a/SBCEye.py b/SBCEye.py index 22124e6..44a356a 100644 --- a/SBCEye.py +++ b/SBCEye.py @@ -108,13 +108,13 @@ disp, bme = i2c_setup(settings) if disp: + # display initialisation does a 'clear()' and 'show()' disp.contrast(settings.display_contrast) - #disp.fill(0) # Blank asap in case we are showing garbage - disp.show() if settings.button_out > 0: try: - from RPi import GPIO + from gpio4 import GPIO_DEBUG_DISABLE + gpio = GPIO() except ImportError as e: print(e) print("ERROR: button & pin control requirements not met, features disabled") @@ -157,20 +157,20 @@ def button_control(action="toggle"): ret = f'{settings.button_label} ' pin = settings.button_out if action.lower() in ['toggle','invert','button']: - GPIO.output(pin, not GPIO.input(pin)) + gpio.output(pin, not gpio.input(pin)) ret += 'Toggled: ' elif action.lower() in [settings.pin_state_names[1].lower(),'on','true']: - GPIO.output(pin,True) + gpio.output(pin,True) ret += 'Switched: ' elif action.lower() in [settings.pin_state_names[0].lower(),'off','false']: - GPIO.output(pin,False) + gpio.output(pin,False) ret += 'Switched: ' elif action.lower() in ['random','easter']: - GPIO.output(pin,random.choice([True, False])) + gpio.output(pin,random.choice([True, False])) ret += 'Randomly Switched: ' else: ret += ': ' - state = GPIO.input(pin) + state = gpio.input(pin) ret += settings.pin_state_names[state] else: state = False @@ -182,7 +182,7 @@ def button_interrupt(*_): '''give a short delay, then re-read input to provide a minimum hold-down time and suppress false triggers from other gpio operations''' time.sleep(settings.button_hold) - if GPIO.input(settings.button_pin): + if gpio.input(settings.button_pin): logging.info('Button pressed') button_control() @@ -275,13 +275,13 @@ def handle_exit(): # Set button interrupt and output if we have a button and a pin to control if settings.button_out > 0: - GPIO.setmode(GPIO.BCM) # Use BCM GPIO numbering - GPIO.setup(settings.button_out, GPIO.OUT) + gpio.setmode(GPIO.BCM) # Use BCM GPIO numbering + gpio.setup(settings.button_out, GPIO.OUT) logging.info(f'Controllable pin ({settings.button_name}) enabled') if settings.button_pin > 0: - GPIO.setup(settings.button_pin, GPIO.IN) + gpio.setup(settings.button_pin, GPIO.IN) # Set up the button pin interrupt - GPIO.add_event_detect(settings.button_pin, + gpio.add_event_detect(settings.button_pin, GPIO.RISING, button_interrupt, bouncetime = int(settings.button_hold * 2000)) logging.info('Button enabled') diff --git a/animator.py b/animator.py index 947f306..755adee 100644 --- a/animator.py +++ b/animator.py @@ -60,7 +60,10 @@ def __init__(self, settings, disp, data): self.height = self.disp.height self.span = self.width*2 + self.margin - self.display_rotate = settings.display_rotate + # Display rotation handled during display init + #self.display_rotate = settings.display_rotate + + # How fast self.animate_speed = settings.animate_speed # Create image canvas (with mode '1' for 1-bit color) @@ -110,13 +113,16 @@ def _clean(self): def _show(self, xpos=0): '''Put a specific area of the canvas onto display''' - if self.display_rotate: - self.disp.image(self.image.transform((self.width,self.height), - Image.EXTENT,(xpos,0,xpos+self.width,self.height)) - .transpose(Image.ROTATE_180)) - else: - self.disp.image(self.image.transform((self.width,self.height), - Image.EXTENT,(xpos,0,xpos+self.width,self.height))) + # Display rotation handled during display init + #if self.display_rotate: + # self.disp.image(self.image.transform((self.width,self.height), + # Image.EXTENT,(xpos,0,xpos+self.width,self.height)) + # .transpose(Image.ROTATE_180)) + #else: + # self.disp.image(self.image.transform((self.width,self.height), + # Image.EXTENT,(xpos,0,xpos+self.width,self.height))) + self.disp.display(self.image.transform((self.width,self.height), + Image.EXTENT,(xpos,0,xpos+self.width,self.height))) self.disp.show() def _slideout(self): diff --git a/bus_drivers.py b/bus_drivers.py index f8b6bd5..97298c3 100644 --- a/bus_drivers.py +++ b/bus_drivers.py @@ -74,7 +74,7 @@ def i2c_setup(settings): if display: # for luma display rotation must be specified here, # (value from 0 to 3, rotating 90 degrees each step) - rotate = 0 if settings.display_rotate else 2 + rotate = 2 if settings.display_rotate else 0 try: # Create the display object disp = ssd1306(bus=i2c, address=settings.display_addr, rotate=rotate) diff --git a/defaults.ini b/defaults.ini index 3a69e8e..f46bc77 100644 --- a/defaults.ini +++ b/defaults.ini @@ -160,7 +160,7 @@ rotate= False contrast = 127 # Screen saver / burn-in reducer -# saver_mode: Possible values are 'off', 'blank' and 'invert' +# saver_mode: Possible values are 'off' or 'blank' # saver_on: Start time for screensaver (hour, 24hr clock) # saver_off: End time [saver] From 2aa6dc15a90e8658c9276137fb0046a12f3dd6d1 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 10 Aug 2025 21:26:11 +0200 Subject: [PATCH 020/108] screensaver now applied by animator --- animator.py | 23 ++++++++++------------- saver.py | 2 -- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/animator.py b/animator.py index 755adee..2a8274f 100644 --- a/animator.py +++ b/animator.py @@ -112,18 +112,14 @@ def _clean(self): self.draw.rectangle((0,0,self.span-1,self.height-1), outline=0, fill=0) def _show(self, xpos=0): - '''Put a specific area of the canvas onto display''' - # Display rotation handled during display init - #if self.display_rotate: - # self.disp.image(self.image.transform((self.width,self.height), - # Image.EXTENT,(xpos,0,xpos+self.width,self.height)) - # .transpose(Image.ROTATE_180)) - #else: - # self.disp.image(self.image.transform((self.width,self.height), - # Image.EXTENT,(xpos,0,xpos+self.width,self.height))) - self.disp.display(self.image.transform((self.width,self.height), - Image.EXTENT,(xpos,0,xpos+self.width,self.height))) - self.disp.show() + '''Put a specific area of the canvas onto display, + or blank if the screensaver is active''' + if self.screensaver.active: + self.disp.hide() + else: + self.disp.display(self.image.transform((self.width,self.height), + Image.EXTENT,(xpos,0,xpos+self.width,self.height))) + self.disp.show() def _slideout(self): '''Slide the display view across the canvas to animate between screens''' @@ -217,7 +213,8 @@ def _frame(self): def _hourly(self): '''check screensaver and totally frivously do a spash screen once an hour''' self.screensaver.check() - self._splash() + if not self.screensaver.active: + self._splash() def animate(settings, disp, queue): diff --git a/saver.py b/saver.py index db81aec..c972b6c 100644 --- a/saver.py +++ b/saver.py @@ -54,12 +54,10 @@ def _apply_state(self, state): self.active = True if self.mode == 'blank': print('Saver activated',flush=True) - self.disp.poweroff() else: self.active = False if self.mode == 'blank': print('Saver deactivated',flush=True) - self.disp.poweron() def check(self): '''Check the current state vs the time, and apply changes as From 8b8c926f151275882875c77f727a42cd07e30e7c Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 10 Aug 2025 21:29:38 +0200 Subject: [PATCH 021/108] config for gpiod pin monitor --- defaults.ini | 18 +++++++++++++----- load_config.py | 5 +++++ 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/defaults.ini b/defaults.ini index f46bc77..19201ca 100644 --- a/defaults.ini +++ b/defaults.ini @@ -79,12 +79,19 @@ half_height = pin,net # [links] +# To monitor GPIO pins a the gpiod chip path needs to be specified +# - leave commented out to disable gpio pin monitoring +# +[gpio] +#chip = /dev/gpiochip0 + # GPIO pins to monitor and log -# All pins are defined using BCM GPIO numbering -# https://www.raspberrypi.org/forums/viewtopic.php?t=105200 -# Try running `gpio readall` on the Pi itself for a map -# Pins are listed one per line: -# = BCM pin number +# - only pins from the specified gpio chip can be monitored +# - see https:// ??????????????????????????????? +# - an empty list disables pin monitoring +# +# Then pins are listed one per line: +# = pin number # eg: # [pins] # Lamp = 7 @@ -97,6 +104,7 @@ half_height = pin,net # Targets are listed one per line: # = ip address # eg: +# [ping] # router = 192.168.0.1 # internet = 8.8.8.8 # diff --git a/load_config.py b/load_config.py index fc94198..08808fc 100644 --- a/load_config.py +++ b/load_config.py @@ -123,6 +123,11 @@ def hexint(instring): real = name.replace('_',' ') self.links[real] = config.get("links",name) + try: + self.gpio_chip = config.get("gpio","chip") + except configparser.NoOptionError: + self.gpio_chip = None + self.pin_map = {} for pin in config["pins"]: self.pin_map[pin] = config.getint("pins",pin) From 1b1269f4303f63a385cc5557a951113acc3b4689 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 13 Aug 2025 02:40:57 +0200 Subject: [PATCH 022/108] gpiod pinreader, WIP --- SBCEye.py | 19 +++++++------- pinreader.py | 70 ++++++++++++++++++++++++++++++---------------------- 2 files changed, 50 insertions(+), 39 deletions(-) diff --git a/SBCEye.py b/SBCEye.py index 44a356a..f1f9bbd 100644 --- a/SBCEye.py +++ b/SBCEye.py @@ -113,7 +113,7 @@ if settings.button_out > 0: try: - from gpio4 import GPIO_DEBUG_DISABLE + from gpio4 import GPIODEBUG gpio = GPIO() except ImportError as e: print(e) @@ -275,14 +275,14 @@ def handle_exit(): # Set button interrupt and output if we have a button and a pin to control if settings.button_out > 0: - gpio.setmode(GPIO.BCM) # Use BCM GPIO numbering - gpio.setup(settings.button_out, GPIO.OUT) + gpio.setmode(gpio.BCM) # Use BCM GPIO numbering + gpio.setup(settings.button_out, gpio.OUT) logging.info(f'Controllable pin ({settings.button_name}) enabled') if settings.button_pin > 0: - gpio.setup(settings.button_pin, GPIO.IN) + gpio.setup(settings.button_pin, gpio.IN) # Set up the button pin interrupt gpio.add_event_detect(settings.button_pin, - GPIO.RISING, button_interrupt, + gpio.RISING, button_interrupt, bouncetime = int(settings.button_hold * 2000)) logging.info('Button enabled') if len(settings.button_url) > 0: @@ -321,7 +321,7 @@ def handle_exit(): net = Netreader((settings.net_map, settings.net_timeout), data) # GPIO Pin monitoring - pins = Pinreader((settings.pin_map, settings.pin_state_names), data) + pins = Pinreader((settings.gpio_chip, settings.pin_map, settings.pin_state_names), data) # RRD init now that the data{} structure is populated rrd = Robin(settings, data) @@ -336,11 +336,12 @@ def handle_exit(): register(handle_exit) # Schedule pin monitoring, database updates and logging events - if settings.log_daily: - schedule.every().day.at("00:00").do(daily) schedule.every(settings.data_interval).seconds.do(update_data) - if len(settings.pin_map.keys()) > 0: + if pins.available: + print('DEBUG::: starting pinreader schedule') schedule.every(settings.pin_interval).seconds.do(pins.update_pins) + if settings.log_daily: + schedule.every().day.at("00:00").do(daily) # We got this far... time to start the show logging.info("Init complete, starting schedules and entering service loop") diff --git a/pinreader.py b/pinreader.py index 2381ab0..f78fe2f 100644 --- a/pinreader.py +++ b/pinreader.py @@ -7,10 +7,7 @@ import os import logging - -GPIO_ROOT = '/sys/class/gpio' -export_handle = f'{GPIO_ROOT}/export' -unexport_handle = f'{GPIO_ROOT}/unexport' +import gpiod class Pinreader: '''Read and update pin status @@ -20,6 +17,7 @@ class Pinreader: parameters: settings: (tuple) consisting of: + chip: (str) path to the gpio chip device node, or None to disable map: (dict) pin names and BCM GPIO number state_names: (tuple) localised names for pin states (text,text) data: the main data{} dictionary, a key/value pair; 'pin-=value' @@ -31,21 +29,42 @@ class Pinreader: def __init__(self, settings, data): '''Setup and do initial reading''' - (self.map, self.state_names) = settings + (self._gpio_chip, self._map, self._state_names) = settings self.data = data - if not self.map: - print('No GPIO pins configured for monitoring') + self.available = False + if self._gpio_chip is None: + print('No GPIO chip specified in config, gpio monitoring disabled') + return + if not gpiod.is_gpiochip_device(self._gpio_chip): + print('ERROR: GPIO chip specified in config ({}) is not a libgpiod '\ + 'compatible device, gpio monitoring disabled'.format(self._gpio_chip)) + self._gpio_chip = None return - for pin_name, pin_number in self.map.items(): + self._lines = gpiod.Chip(self._gpio_chip).get_info().num_lines + print('GPIO chip is "{}" with {} lines'.format(self._gpio_chip, self._lines)) + if not self._test_pins(): + print('gpio monitoring disabled') + return + for pin_name, pin_number in self._map.items(): data[f'pin-{pin_name}'] = get_pin(pin_number) logging.info(f'{pin_name}: {self.state_names[data[f"pin-{pin_name}"]]}') print('GPIO monitoring configured and logging enabled') logging.info('GPIO monitoring configured and logging enabled') + self.available = True + + def _test_pins(self): + '''Check the pins listed in the pin map''' + # could check in range of the gpio chip device.. + if len(self._map) == 0: + print('No valid GPIO pins listed in config, ', end='') + return False + return True def update_pins(self): '''Check if any pins have changed state, and log if so updates the main data{} dictionary with new state no parameters, no return''' + return # <------------------------------------DEBUG for name, pin in self.map.items(): this_pin_state = get_pin(pin) if this_pin_state != self.data[f"pin-{name}"]: @@ -53,26 +72,17 @@ def update_pins(self): self.data[f'pin-{name}'] = this_pin_state logging.info(f'{name} (gpio-{pin}): {self.state_names[this_pin_state]}') -def get_pin(pin): - '''Read pin state, return an integer + with gpiod.request_lines( + self.chip_path, + consumer="SBCEye-pinreader", + config={tuple(line_offsets): None}, + ) as request: + vals = request.get_values() + print(vals, type(vals)) + for offset, val in zip(line_offsets, vals): + if val == gpiod.line.Value.ACTIVE: + print("{}={} ".format(offset, 'ON'), end="") + else: + print("{}={} ".format(offset, 'OFF'), end="") + #print() - parameters: - pin: (int) the BCM gpio pin number - - returns: - pin_state: (int) 0=low, 1=high - ''' - gpio_handle = f'{GPIO_ROOT}/gpio{str(pin)}' - exported = os.path.isdir(gpio_handle) - if not exported: - export = os.open(export_handle, os.O_WRONLY) - os.write(export, bytes(str(pin), 'ascii')) - os.close(export) - value = os.open(f'{gpio_handle}/value', os.O_RDONLY) - ret = int(os.read(value,1)) - os.close(value) - if not exported: - unexport = os.open(unexport_handle, os.O_WRONLY) - os.write(unexport, bytes(str(pin), 'ascii')) - os.close(unexport) - return int(ret) From f2707f89d94ba40aec5a0ee7e6ade613c28119d8 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 14 Aug 2025 13:08:29 +0200 Subject: [PATCH 023/108] Show active pins as bold underlined --- httpserver.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/httpserver.py b/httpserver.py index 410e9ba..a535baa 100644 --- a/httpserver.py +++ b/httpserver.py @@ -244,8 +244,9 @@ def _give_pins(self): if len(http.data.keys() & pinlist.keys()) > 0: ret += '
\n' for item,name in pinlist.items(): + em = 'style=" font-weight: bold; text-decoration: underline;"' if http.data[item] == 1 else '' ret += f'\n' + f'{http.settings.pin_state_names[http.data[item]]}\n' return ret def _give_graphlinks(self, skip=""): From 8fcdf074a51fb875d0c267a20edf0e59f94a1559 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 14 Aug 2025 13:54:12 +0200 Subject: [PATCH 024/108] dont underline --- httpserver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/httpserver.py b/httpserver.py index a535baa..361ef88 100644 --- a/httpserver.py +++ b/httpserver.py @@ -244,7 +244,7 @@ def _give_pins(self): if len(http.data.keys() & pinlist.keys()) > 0: ret += '\n' for item,name in pinlist.items(): - em = 'style=" font-weight: bold; text-decoration: underline;"' if http.data[item] == 1 else '' + em = 'style=" font-weight: bold;"' if http.data[item] == 1 else '' ret += f'\n' return ret From 3a407106e3db834b6cbf4cbe081ea78739a093c4 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 14 Aug 2025 13:58:22 +0200 Subject: [PATCH 025/108] New GPIOD pinreader --- pinreader.py | 93 ++++++++++++++++++++++++++++++++-------------------- 1 file changed, 58 insertions(+), 35 deletions(-) diff --git a/pinreader.py b/pinreader.py index f78fe2f..3054599 100644 --- a/pinreader.py +++ b/pinreader.py @@ -40,49 +40,72 @@ def __init__(self, settings, data): 'compatible device, gpio monitoring disabled'.format(self._gpio_chip)) self._gpio_chip = None return - self._lines = gpiod.Chip(self._gpio_chip).get_info().num_lines - print('GPIO chip is "{}" with {} lines'.format(self._gpio_chip, self._lines)) - if not self._test_pins(): - print('gpio monitoring disabled') + self._num_lines = gpiod.Chip(self._gpio_chip).get_info().num_lines + print('GPIO chip is "{}" with {} lines'.format(self._gpio_chip, self._num_lines)) + if not self._setup_pins(): + print('No valid GPIO pins listed in config, gpio monitoring disabled') return - for pin_name, pin_number in self._map.items(): - data[f'pin-{pin_name}'] = get_pin(pin_number) - logging.info(f'{pin_name}: {self.state_names[data[f"pin-{pin_name}"]]}') - print('GPIO monitoring configured and logging enabled') - logging.info('GPIO monitoring configured and logging enabled') + values = self._get_pins(self._pins) + if values is None: + print('GPIO pin states could not be read, gpio monitoring disabled') + return + for index, name, value in zip(self._pins, self._names, values): + data[f'pin-{name}'] = int(value) + logging.info('{} index {} configured as "{}", current value: {}' + .format(self._gpio_chip, index, name, self._state_names[int(value)])) + print('GPIO monitoring active and logging enabled') + logging.info('GPIO monitoring active and logging enabled') self.available = True - def _test_pins(self): - '''Check the pins listed in the pin map''' - # could check in range of the gpio chip device.. - if len(self._map) == 0: - print('No valid GPIO pins listed in config, ', end='') + def _setup_pins(self): + '''Check the pins listed in the pin map are validi and create lists''' + self._pins = [] + self._names = [] + for name, pin in self._map.items(): + if pin > 0 and pin < self._num_lines: + self._pins.append(pin) + self._names.append(name) + else: + print('ERROR: gpio chip index ({}) for "{}" is out of range'.format(pin, name)) + if len(self._pins) == 0: return False return True + def _get_pins(self, pins): + '''Get the value of all pins using gpiod, do not change pin state''' + values = [] + try: + with gpiod.request_lines( + self._gpio_chip, + consumer="SBCEye-pinreader", + config={tuple(pins): None}, + ) as request: + line_values = request.get_values() + for value in line_values: + if value == gpiod.line.Value.ACTIVE: + values.append(int(1)) + else: + values.append(int(0)) + except Exception as e: + print('Error getting pin values:\n{}'.format(e)) + return None + return values + def update_pins(self): '''Check if any pins have changed state, and log if so updates the main data{} dictionary with new state no parameters, no return''' - return # <------------------------------------DEBUG - for name, pin in self.map.items(): - this_pin_state = get_pin(pin) - if this_pin_state != self.data[f"pin-{name}"]: + # Update current values list + values = self._get_pins(self._pins) + if values is None: + # The try:except in the system log should show the actual errors + # Some sort of warn/fail tracking might be needed if issues occcur here a lot. + logging.info('GPIO pin read failed (see syslog)') + return + # Now go through pins, store data and see what has changed + for index, name, value in zip(self._pins, self._names, values): + if value != self.data[f"pin-{name}"]: # Pin has changed state, store new state and log - self.data[f'pin-{name}'] = this_pin_state - logging.info(f'{name} (gpio-{pin}): {self.state_names[this_pin_state]}') - - with gpiod.request_lines( - self.chip_path, - consumer="SBCEye-pinreader", - config={tuple(line_offsets): None}, - ) as request: - vals = request.get_values() - print(vals, type(vals)) - for offset, val in zip(line_offsets, vals): - if val == gpiod.line.Value.ACTIVE: - print("{}={} ".format(offset, 'ON'), end="") - else: - print("{}={} ".format(offset, 'OFF'), end="") - #print() - + self.data[f'pin-{name}'] = value + logging.info('{} ({}:{}): {}'.format(name, self._gpio_chip, index, + self._state_names[int(value)])) From ba17c688fc62f72591b32a73da6eaa1ecbc68664 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 14 Aug 2025 14:18:23 +0200 Subject: [PATCH 026/108] remove debug --- SBCEye.py | 1 - 1 file changed, 1 deletion(-) diff --git a/SBCEye.py b/SBCEye.py index f1f9bbd..d72eb4d 100644 --- a/SBCEye.py +++ b/SBCEye.py @@ -338,7 +338,6 @@ def handle_exit(): # Schedule pin monitoring, database updates and logging events schedule.every(settings.data_interval).seconds.do(update_data) if pins.available: - print('DEBUG::: starting pinreader schedule') schedule.every(settings.pin_interval).seconds.do(pins.update_pins) if settings.log_daily: schedule.every().day.at("00:00").do(daily) From 666bfd3f9281c288867946bb6233ee4d8c3fdd2f Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 14 Aug 2025 14:33:33 +0200 Subject: [PATCH 027/108] :Remove button control feature --- SBCEye.py | 66 +------------------------------------------------- httpserver.py | 39 ++--------------------------- load_config.py | 12 --------- 3 files changed, 3 insertions(+), 114 deletions(-) diff --git a/SBCEye.py b/SBCEye.py index d72eb4d..9b6a3d8 100644 --- a/SBCEye.py +++ b/SBCEye.py @@ -3,7 +3,6 @@ SBCEye: Animate the OLED display attached to my OctoPrint server with bme280 and system data Show, log and graph the environmental, system and gpio data via a web interface -Give me a on/off button + url to control the bench lights via a GPIO pin !! DISPLAY, BME280 and GPIO functionality is CURRENTLY Raspberry PI only! - Needs to be made generic for other architectures @@ -111,15 +110,6 @@ # display initialisation does a 'clear()' and 'show()' disp.contrast(settings.display_contrast) -if settings.button_out > 0: - try: - from gpio4 import GPIODEBUG - gpio = GPIO() - except ImportError as e: - print(e) - print("ERROR: button & pin control requirements not met, features disabled") - settings.button_out = 0 - # # Local Classes, Globals @@ -151,41 +141,6 @@ def __delitem__(self, item): # # Local functions -def button_control(action="toggle"): - '''Set the controlled pin to a specified state''' - if settings.button_out > 0: - ret = f'{settings.button_label} ' - pin = settings.button_out - if action.lower() in ['toggle','invert','button']: - gpio.output(pin, not gpio.input(pin)) - ret += 'Toggled: ' - elif action.lower() in [settings.pin_state_names[1].lower(),'on','true']: - gpio.output(pin,True) - ret += 'Switched: ' - elif action.lower() in [settings.pin_state_names[0].lower(),'off','false']: - gpio.output(pin,False) - ret += 'Switched: ' - elif action.lower() in ['random','easter']: - gpio.output(pin,random.choice([True, False])) - ret += 'Randomly Switched: ' - else: - ret += ': ' - state = gpio.input(pin) - ret += settings.pin_state_names[state] - else: - state = False - ret = 'Not supported, no output pin defined' - pins.update_pins() - return (ret, state) - -def button_interrupt(*_): - '''give a short delay, then re-read input to provide a minimum hold-down time - and suppress false triggers from other gpio operations''' - time.sleep(settings.button_hold) - if gpio.input(settings.button_pin): - logging.info('Button pressed') - button_control() - def update_system(): '''Get current environmental and system data, called on a schedule ''' @@ -273,25 +228,6 @@ def handle_exit(): logging.warning('Environmental data configured but no sensor detected: '\ 'Environment status and logging disabled') - # Set button interrupt and output if we have a button and a pin to control - if settings.button_out > 0: - gpio.setmode(gpio.BCM) # Use BCM GPIO numbering - gpio.setup(settings.button_out, gpio.OUT) - logging.info(f'Controllable pin ({settings.button_name}) enabled') - if settings.button_pin > 0: - gpio.setup(settings.button_pin, gpio.IN) - # Set up the button pin interrupt - gpio.add_event_detect(settings.button_pin, - gpio.RISING, button_interrupt, - bouncetime = int(settings.button_hold * 2000)) - logging.info('Button enabled') - if len(settings.button_url) > 0: - logging.info(f'Web Button enabled on: /{settings.button_url}') - print(f'Button controllable pin ({settings.button_name}) configured and enabled; '\ - f'(button=gpio-{settings.button_pin}, '\ - f'label="{settings.button_label}", '\ - f'url="{settings.button_url})"') - # Display animation setup if disp: from animator import animate @@ -327,7 +263,7 @@ def handle_exit(): rrd = Robin(settings, data) # Start the web server, it will fork into a seperate thread and run continually - serve_http(settings, rrd, data, (button_control,)) + serve_http(settings, rrd, data) # Exit handlers (needed for rrd cache write on shutdown) signal(SIGTERM, handle_signal) diff --git a/httpserver.py b/httpserver.py index 361ef88..09c44b8 100644 --- a/httpserver.py +++ b/httpserver.py @@ -17,7 +17,7 @@ # Logging import logging -def serve_http(settings, rrd, data, helpers): +def serve_http(settings, rrd, data): '''Spawns a http.server.HTTPServer in a separate thread on the given port''' handler = _BaseRequestHandler httpd = http.server.ThreadingHTTPServer((settings.web_host, settings.web_port), handler, False) @@ -31,7 +31,6 @@ def serve_http(settings, rrd, data, helpers): http.settings = settings http.rrd = rrd http.data = data - http.button_control = helpers[0] http.icon_file = 'favicon.ico' if not os.path.exists(http.icon_file): http.icon_file = f'{sys.path[0]}/{http.icon_file}' @@ -274,14 +273,7 @@ def _give_links(self): # Links to the graph pages ret = f'{self._give_graphlinks()}' # Links to the pin contol, cam show/hide and log pages - ret += f'\n' - if http.settings.web_show_control and (http.settings.button_out > 0): - _, onoff = http.button_control('status') - state = 'On' if onoff else 'Off' - ret += f'\n' + #ret += f'\n' for link in http.settings.links: ret += f'\n' for item,name in pinlist.items(): - em = 'style=" font-weight: bold;"' if http.data[item] == 1 else '' - ret += f'\n' + if http.data[item] == 'U': + em = 'style=" font-style: italic;"' + ret += f'\n' + else: + em = 'style=" font-weight: bold;"' if http.data[item] == 1 else '' + ret += f'\n' return ret def _give_graphlinks(self, skip=""): diff --git a/pinreader.py b/pinreader.py index e3ca199..3e871cd 100644 --- a/pinreader.py +++ b/pinreader.py @@ -123,7 +123,7 @@ def find_pins(device, regex, verbose=False): self = argv[0] desc = 'Display GPIO pin states and value (if availabe) for matching pins '\ - 'on the specified gpio chip. Use \'gpioinfo \' to see available pins.' + 'on the specified gpio chip. Use \'gpioinfo\' to see available pins.' elog = 'IMPORTANT: use regex wisely, DO NOT use a generic wildcard such as \'.*\'. '\ 'Requesting pins that are used by the OS may cause conflicts. eg: reading the value '\ 'of pins labelled \'SD_*\' can cause Disk I/O errors on Raspberry PI\'s.)' diff --git a/robin.py b/robin.py index 832b492..4193c3d 100644 --- a/robin.py +++ b/robin.py @@ -87,7 +87,7 @@ def __init__(self, s, data): '25', '0' ,'%3.0lf', '%3.1lf ms', '--alt-autoscale', '--units-exponent','0') # pins - for name in s.pin_map.keys(): + for name in s.pinlist.keys(): self.data_sources[f'pin-{name}'] = ('0','1') self.graph_map[f'pin-{name}'] = (f'{name} Pin State, '\ f'0 = {s.pin_state_names[0]}, 1 = {s.pin_state_names[1]}', From ec14a0f0c90078bee4b6cb7b1c4ff2913324d759 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 2 Sep 2025 21:58:25 +0200 Subject: [PATCH 040/108] use netlist not net_map --- SBCEye.py | 4 ++-- load_config.py | 6 ++---- netreader.py | 12 ++++++------ robin.py | 2 +- 4 files changed, 11 insertions(+), 13 deletions(-) diff --git a/SBCEye.py b/SBCEye.py index 03beb6c..de5ee9c 100644 --- a/SBCEye.py +++ b/SBCEye.py @@ -272,7 +272,7 @@ def handle_exit(): 'Display features disabled') print('Performing initial data update', end='') - if settings.net_map: + if settings.netlist: print(f' may take up to {settings.net_timeout}s if ping targets are down') else: print() @@ -288,7 +288,7 @@ def handle_exit(): pinmemory = setup_pins() # Network (ping) monitoring - net = Netreader((settings.net_map, settings.net_timeout), data) + net = Netreader((settings.netlist, settings.net_timeout), data) # RRD init now that the data{} structure is populated rrd = Robin(settings, data) diff --git a/load_config.py b/load_config.py index 08bfad9..e344ec7 100644 --- a/load_config.py +++ b/load_config.py @@ -123,18 +123,16 @@ def hexint(instring): real = name.replace('_',' ') self.links[real] = links.get(name) - self.pinlist = {} pins = config["pins"] for pin in pins: line = pins.get(pin).split(',') self.pinlist[pin] = (line[0], int(line[1])) - - self.net_map = {} + self.netlist = {} ping = config["ping"] for host in ping: - self.net_map[host] = ping.get(host) + self.netlist[host] = ping.get(host) intervals = config["intervals"] self.pin_interval = intervals.getint("pin") diff --git a/netreader.py b/netreader.py index 705d63c..60549bd 100644 --- a/netreader.py +++ b/netreader.py @@ -17,7 +17,7 @@ class Netreader: parameters: settings: (tuple) consisting of: - map: (dict) UI name and IP address/name + list: (dict) UI name and IP address/name timeout: (int) Timout in seconds data: the main data{} dictionary, a key/value pair; 'net-=value' will be added to it and the vaue updated with pin state changes. @@ -27,12 +27,12 @@ class Netreader: ''' def __init__(self, settings, data): '''Setup and do initial reading''' - (self.map, self.timeout) = settings + (self.list, self.timeout) = settings self.states = {} - if not self.map: + if not self.list: print('No network addresses configured for monitoring') return - for name,_ in self.map.items(): + for name,_ in self.list.items(): self.states[name] = "init" data[f'net-{name}'] = 'U' self.update(data) @@ -48,7 +48,7 @@ def _ping_runner(self, target, data): data: the main data{} dictionary no return ''' - address = self.map[target] + address = self.list[target] key = f'net-{target}' (data[key], status) = ping_target(address,self.timeout) if status: @@ -65,7 +65,7 @@ def _ping_runner(self, target, data): def update(self, data): '''Test each target in parallel via threads''' threadlist = [] - for target,_ in self.map.items(): + for target,_ in self.list.items(): gatherer = threading.Thread(target=self._ping_runner, args=[target, data]) gatherer.start() threadlist.append(gatherer) diff --git a/robin.py b/robin.py index 4193c3d..4b27a0f 100644 --- a/robin.py +++ b/robin.py @@ -81,7 +81,7 @@ def __init__(self, s, data): None, None, '%5.0lf', '%5.0lf /s', '--units-exponent','0'), } # connectivity - for host in s.net_map.keys(): + for host in s.netlist.keys(): self.data_sources[f'net-{host}'] = ('0','U') self.graph_map[f'net-{host}'] = (f'{host} Ping, milliseconds', '25', '0' ,'%3.0lf', '%3.1lf ms', '--alt-autoscale', '--units-exponent','0') From c9948945785bbc81046b5b28d16da4f695b05ef6 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 3 Sep 2025 03:02:20 +0200 Subject: [PATCH 041/108] output glitch --- bus_drivers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bus_drivers.py b/bus_drivers.py index 3a03fa3..234d302 100644 --- a/bus_drivers.py +++ b/bus_drivers.py @@ -102,5 +102,5 @@ def i2c_setup(settings): print(error) print("We do not have a environmental sensor") - print(flush=True) + print(flush=True, end='') return disp, bme From f969136efba6c7e6e40d5c42464f1c7dfbb50da4 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 3 Sep 2025 03:04:52 +0200 Subject: [PATCH 042/108] pinreader info outputs --- pinreader.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pinreader.py b/pinreader.py index 3e871cd..0fcc4bc 100644 --- a/pinreader.py +++ b/pinreader.py @@ -28,13 +28,14 @@ def __init__(self, chip, line): self.get() def __repr__(self): - return 'PinInstance(chip={} line={} consumer={} direction={} value={})'\ - .format(self.chip, self.line, self.consumer, self.direction, self.value) + consumer = None if self.consumer is None else '\'{}\''.format(self.consumer) + return 'PinInstance(chip=\'{}\' line={} consumer={} direction=\'{}\' value={})'\ + .format(self.chip, self.line, consumer, self.direction, self.value) def __str__(self): consumer = None if self.consumer is None else '\'{}\''.format(self.consumer) - value = 'n/a' if self.value is None else self.value - return 'consumer: {}, value: {}'.format(consumer, value) + return 'direction: {}, consumer: {}, value: {}'\ + .format(self.direction, consumer, self.value) def _value(self): try: From cfe92f981c5c3829540ab0e28791d02b9020fd27 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 3 Sep 2025 05:56:59 +0200 Subject: [PATCH 043/108] gpio pins working --- SBCEye.py | 13 ++++---- gpioreader.py | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 7 deletions(-) create mode 100644 gpioreader.py diff --git a/SBCEye.py b/SBCEye.py index de5ee9c..c9dc562 100644 --- a/SBCEye.py +++ b/SBCEye.py @@ -53,7 +53,7 @@ from robin import Robin from httpserver import serve_http from netreader import Netreader -from pinreader import PinReader +from gpioreader import GPIOReader from bus_drivers import i2c_setup # Re-nice to reduce blocking of other processes @@ -273,7 +273,7 @@ def handle_exit(): print('Performing initial data update', end='') if settings.netlist: - print(f' may take up to {settings.net_timeout}s if ping targets are down') + print(f' (may take up to {settings.net_timeout}s if ping targets are down)') else: print() @@ -283,9 +283,8 @@ def handle_exit(): # Populate initial sensor data update_sensors() - # GPIO monitoring - pins = PinReader(settings.pinlist, tolerant=True) - pinmemory = setup_pins() + # GPIO pin monitoring + gpio = GPIOReader(settings.pinlist, data) # Network (ping) monitoring net = Netreader((settings.netlist, settings.net_timeout), data) @@ -304,8 +303,8 @@ def handle_exit(): # Schedule pin monitoring, database updates and logging events schedule.every(settings.data_interval).seconds.do(update_data) - if pins: - schedule.every(settings.pin_interval).seconds.do(update_pins) + if gpio.available: + schedule.every(settings.pin_interval).seconds.do(gpio.update) if settings.log_daily: schedule.every().day.at("00:00").do(daily) diff --git a/gpioreader.py b/gpioreader.py new file mode 100644 index 0000000..6ba079b --- /dev/null +++ b/gpioreader.py @@ -0,0 +1,89 @@ +'''Monitor, record and log GPIO pin changes using gpiod + +provides: + GPIOReader: A class to update and log the pin statuses +''' + +import os +import logging +try: + from pinreader import PinReader + reader = True +except ImportError as e: + print('Failed to import pinreader: {}\ngpio pin monitoring disabled' + .format(e)) + reader = False + +class GPIOReader: + '''Read and update GPIO pin status + + Reads the currrent (boolean) status of a set of gipo pins defined in a dictionary + Updates the relevant entries in data{} and logs state changes + + parameters: + pinlist: dictionary of pin definitions {label: (chip,index)} from config, + this will be passed directly to the PinReader init. + data: the main data{} dictionary, a key/value pair; 'pin-=value' + will be added to it and the vaue updated with pin state changes. + + provides: + update_pins(): processes and updates the pins + ''' + + def __init__(self, pinlist, data): + '''Setup and do initial reading''' + self.available = False + self.pinlist = pinlist + self.data = data + if not reader: + print('GPIO pin reader not available, pin monitoring disabled') + return + if not self.pinlist: + print('No GPIO pins configured for monitoring, pin monitoring disabled') + return + try: + self.pins = PinReader(self.pinlist) + except ValueError as e: + print('GPIO pin setup failed: {}\nPin monitoring disabled.'.format(e)) + logging.warning('GPIO pins were specified but setup failed (see syslog), '\ + 'pin monitoring disabled') + return + self.directions = {} + self.consumers = {} + for pin in self.pins: + data[f'pin-{pin}'] = self._value_to_data(self.pins[pin].value) + self.directions[pin] = self.pins[pin].direction + self.consumers[pin] = self.pins[pin].consumer + print('Pin \'{}\': {}'.format(pin, repr(self.pins[pin])[12:-1])) + logging.info('Pin \'{}\': {}'.format(pin, repr(self.pins[pin])[12:-1])) + print('GPIO monitoring configured and logging enabled') + logging.info('GPIO monitoring configured and logging enabled') + self.available = True + + def _value_to_data(self, value): + return 'U' if value is None else int(value) + + def update(self): + '''Check if any pins have changed state, and log if so + updates the main data{} dictionary with new state + no parameters, no return''' + self.pins.update() + for pin in self.pins: + log = '' + direction = self.pins[pin].direction + if direction != self.directions[pin]: + self.directions[pin] = direction + log += ' direction to: \'{}\','.format(direction) + consumer = self.pins[pin].consumer + if consumer != self.consumers[pin]: + self.consumers[pin] = consumer + consumer = None if consumer is None else '\'{}\''.format(consumer) + log += ' consumer to: {},'.format(consumer) + value = self.pins[pin].value + if self._value_to_data(value) != self.data[f'pin-{pin}']: + self.data[f'pin-{pin}'] = self._value_to_data(value) + log += ' value to: {},'.format(value) + if len(log) != 0: + logging.info('Pin \'{}\' changed{}'.format(pin, log.rstrip(','))) + print('Pin \'{}\' changed{}'.format(pin, log.rstrip(','))) + From d20970fbc086dce451b7b4bb8940801ecbb320d1 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 3 Sep 2025 18:34:40 +0200 Subject: [PATCH 044/108] test for usable chip --- pinreader.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pinreader.py b/pinreader.py index 0fcc4bc..4c075a6 100644 --- a/pinreader.py +++ b/pinreader.py @@ -18,7 +18,10 @@ def __init__(self, chip, line): if not gpiod.is_gpiochip_device(chip): raise ValueError('\'{}\' is not a valid GPIO device'.format(chip)) self.chip = chip - self._chip = gpiod.Chip(chip) + try: + self._chip = gpiod.Chip(chip) + except PermissionError as e: + raise PermissionError('Cannot access \'{}\': {}' .format(chip, e)) if line < 0 or line >= self._chip.get_info().num_lines: raise ValueError('Requested line ({}) outside range for \'{}\' ({} lines)' .format(line, chip, self._chip.get_info().num_lines)) From 4fb6f4db4b112dadcc62e684c22166ef90739772 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 3 Sep 2025 18:36:01 +0200 Subject: [PATCH 045/108] pin and ping data into web portal --- SBCEye.py | 34 +--------------------------------- gpioreader.py | 24 +++++++++++++----------- httpserver.py | 42 +++++++++++++++++++++--------------------- 3 files changed, 35 insertions(+), 65 deletions(-) diff --git a/SBCEye.py b/SBCEye.py index c9dc562..c24ff3d 100644 --- a/SBCEye.py +++ b/SBCEye.py @@ -181,38 +181,6 @@ def update_data(): net.update(data) rrd.update(data) -def setup_pins(): - '''collects pin data, logs setup and initial state, returns initial state''' - ret = {} - if not pins: - print('NO PINS!!!!!') # <- log this too - else: - for pin in pins: - if pins[pin].value is None: - print('pin: {}, unavailable, used as: {}'.format(pin, pins[pin].consumer)) - data['pin-{}'.format(pin)] = 'U' - else: - print('pin: {}, {}, {}'.format(pin, pins[pin].direction, - settings.pin_state_names[pins[pin].value])) - data['pin-{}'.format(pin)] = pins[pin].value - ret[pin] = pins[pin].value - return ret - -def update_pins(): - '''Updates pin data, and logs state changes, - called at different schedule to other updaters''' - pins.update() - for pin in pins: - if pins[pin].value != pinmemory[pin]: - if pins[pin].value is None: - print('pin: {}, unavailable, used as: {}'.format(pin, pins[pin].consumer)) - data['pin-{}'.format(pin)] = 'U' - else: - print('pin: {}, {}, {}'.format(pin, pins[pin].direction, - settings.pin_state_names[pins[pin].value])) - data['pin-{}'.format(pin)] = pins[pin].value - pinmemory[pin] = pins[pin].value - def daily(): '''Remind everybody we are alive''' myself = os.path.basename(__file__) @@ -293,7 +261,7 @@ def handle_exit(): rrd = Robin(settings, data) # Start the web server, it will fork into a seperate thread and run continually - serve_http(settings, rrd, data) + serve_http(settings, rrd, gpio.pins, data) # Exit handlers (needed for rrd cache write on shutdown) signal(SIGTERM, handle_signal) diff --git a/gpioreader.py b/gpioreader.py index 6ba079b..35978ca 100644 --- a/gpioreader.py +++ b/gpioreader.py @@ -8,11 +8,10 @@ import logging try: from pinreader import PinReader - reader = True + readerfail = None except ImportError as e: - print('Failed to import pinreader: {}\ngpio pin monitoring disabled' - .format(e)) - reader = False + # remember why we failed, so it can be reported in log later. + readerfail = e class GPIOReader: '''Read and update GPIO pin status @@ -35,18 +34,21 @@ def __init__(self, pinlist, data): self.available = False self.pinlist = pinlist self.data = data - if not reader: - print('GPIO pin reader not available, pin monitoring disabled') - return if not self.pinlist: - print('No GPIO pins configured for monitoring, pin monitoring disabled') + print('No GPIO pins configured for monitoring') + return + if readerfail: + print('GPIOD bindings could not be imported: {}\nPin monitoring disabled' + .format(readerfail)) + logging.warning('GPIO pins were specified but pinreader failed to import, see syslog') + logging.info('Pin monitoring disabled') return try: self.pins = PinReader(self.pinlist) - except ValueError as e: + except Exception as e: print('GPIO pin setup failed: {}\nPin monitoring disabled.'.format(e)) - logging.warning('GPIO pins were specified but setup failed (see syslog), '\ - 'pin monitoring disabled') + logging.warning('GPIO pins were specified but pin setup failed, see syslog') + logging.info('Pin monitoring disabled') return self.directions = {} self.consumers = {} diff --git a/httpserver.py b/httpserver.py index a8e0f2d..3aada19 100644 --- a/httpserver.py +++ b/httpserver.py @@ -17,7 +17,7 @@ # Logging import logging -def serve_http(settings, rrd, data): +def serve_http(settings, rrd, pins, data): '''Spawns a http.server.HTTPServer in a separate thread on the given port''' handler = _BaseRequestHandler httpd = http.server.ThreadingHTTPServer((settings.web_host, settings.web_port), handler, False) @@ -30,6 +30,7 @@ def serve_http(settings, rrd, data): # there is probably a better way to do this, eg using a meta-class and inheritance http.settings = settings http.rrd = rrd + http.pins = pins http.data = data http.icon_file = 'favicon.ico' if not os.path.exists(http.icon_file): @@ -215,18 +216,18 @@ def _give_sys(self): def _give_net(self): # Network Connectivity ret = '' - netlist = {} + targetlist = {} for key in http.data.keys(): if key[0:4] == 'net-': - netlist[key] = key[4:] - if len(http.data.keys() & netlist.keys()) > 0: + targetlist[key] = key[4:] + if len(http.data.keys() & targetlist.keys()) > 0: ret += '\n' - for item,name in netlist.items(): - ret += f'' if http.data[item] == 'U': - ret += 'Fail\n' + ret += '\n' else: - ret += f'{http.data[item]:.1f}'\ + ret += f''\ '\n' @@ -243,14 +244,14 @@ def _give_pins(self): if len(http.data.keys() & pinlist.keys()) > 0: ret += '\n' for item,name in pinlist.items(): + ret += f'' if http.data[item] == 'U': - em = 'style=" font-style: italic;"' - ret += f'\n' + ret += f''\ + f'\n' else: em = 'style=" font-weight: bold;"' if http.data[item] == 1 else '' - ret += f'\n' + ret += f''\ + f'\n' return ret def _give_graphlinks(self, skip=""): @@ -260,14 +261,14 @@ def _give_graphlinks(self, skip=""): if (len(http.settings.graph_durations) > 0) and http.db_graphable: if len(skip) == 0: ret += '\n' - ret += '\n\n' + # Configured links and log page for link in http.settings.links: - ret += f'\n' - ret += f'\n' return ret From 51da9c2883e1cef3a17822830136e6b59128f117 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 3 Sep 2025 20:09:32 +0200 Subject: [PATCH 046/108] need pins structure even when failing --- gpioreader.py | 1 + 1 file changed, 1 insertion(+) diff --git a/gpioreader.py b/gpioreader.py index 35978ca..41a76cc 100644 --- a/gpioreader.py +++ b/gpioreader.py @@ -33,6 +33,7 @@ def __init__(self, pinlist, data): '''Setup and do initial reading''' self.available = False self.pinlist = pinlist + self.pins = {} self.data = data if not self.pinlist: print('No GPIO pins configured for monitoring') From 3c1ef462d21191c28311621165ccd2bbe2285c6e Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 3 Sep 2025 20:18:35 +0200 Subject: [PATCH 047/108] tooltip extra --- robin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/robin.py b/robin.py index 4b27a0f..a69041f 100644 --- a/robin.py +++ b/robin.py @@ -90,7 +90,7 @@ def __init__(self, s, data): for name in s.pinlist.keys(): self.data_sources[f'pin-{name}'] = ('0','1') self.graph_map[f'pin-{name}'] = (f'{name} Pin State, '\ - f'0 = {s.pin_state_names[0]}, 1 = {s.pin_state_names[1]}', + f'0 = {s.pin_state_names[0]}, 1 = {s.pin_state_names[1]}, None = pin n/a', '1', '0' ,'%3.1lf', '%3.0lf', '--alt-autoscale', '--units-exponent','0') # set the list of active and storable sources From 1fa18d31e84e644869672442249073e2347b9d4d Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 4 Sep 2025 01:48:45 +0200 Subject: [PATCH 048/108] note ping targets to console during startup --- netreader.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netreader.py b/netreader.py index 60549bd..0e39661 100644 --- a/netreader.py +++ b/netreader.py @@ -32,9 +32,10 @@ def __init__(self, settings, data): if not self.list: print('No network addresses configured for monitoring') return - for name,_ in self.list.items(): + for name, target in self.list.items(): self.states[name] = "init" data[f'net-{name}'] = 'U' + print(f'Ping target \'{name}\': {target}') self.update(data) print('Network monitoring configured and logging enabled') logging.info('Network monitoring configured and logging enabled') From 6b2f569c7dced4022ce8b4906c98f0fa676ebcf0 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 5 Sep 2025 15:01:40 +0200 Subject: [PATCH 049/108] add pin info http config option and embed in title --- defaults.ini | 2 ++ httpserver.py | 13 +++++++++---- load_config.py | 1 + 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/defaults.ini b/defaults.ini index ef88654..5626e7a 100644 --- a/defaults.ini +++ b/defaults.ini @@ -28,6 +28,7 @@ pin_state_names = Off,On # host: Ip address to bind server to, blank = bind to all addresses # port: Port number for web server # sensor_name: Sensor name used in web panel (eg; location) +# pin_info: Show pin direction/consumer alongside pin state # allow_dump: Allows RRDB database dumps via we:, be careful using this # since it can impact performance while running # allow_backup: Allows RRDB database backup triggering via web, be careful using this @@ -37,6 +38,7 @@ pin_state_names = Off,On host = port = 7080 sensor_name = Room +pin_info = True allow_dump = True allow_backup = True diff --git a/httpserver.py b/httpserver.py index 3aada19..b7086dd 100644 --- a/httpserver.py +++ b/httpserver.py @@ -243,15 +243,20 @@ def _give_pins(self): pinlist[key] = key[4:] if len(http.data.keys() & pinlist.keys()) > 0: ret += '\n' - for item,name in pinlist.items(): - ret += f'' + for item, name in pinlist.items(): + title = '{}:\n chip: {}\n line: {}\n direction: {}\n consumer: {}'.format( + name, http.pins[name].chip, http.pins[name].line, + http.pins[name].direction, http.pins[name].consumer) + ret += f'' + direction = ' ({})'.format(http.pins[name].direction) if http.settings.web_pin_info else '' + consumer = ' [{}]'.format(http.pins[name].consumer) if http.settings.web_pin_info else '' if http.data[item] == 'U': ret += f''\ - f'\n' + f'\n' else: em = 'style=" font-weight: bold;"' if http.data[item] == 1 else '' ret += f''\ - f'\n' + f'\n' return ret def _give_graphlinks(self, skip=""): diff --git a/load_config.py b/load_config.py index e344ec7..90ecb40 100644 --- a/load_config.py +++ b/load_config.py @@ -103,6 +103,7 @@ def hexint(instring): self.web_host = web.get("host") self.web_port = web.getint("port") self.web_sensor_name = web.get("sensor_name") + self.web_pin_info = web.getboolean("pin_info") self.web_show_cam = web.getboolean("show_cam") self.web_allow_dump = web.getboolean("allow_dump") self.web_allow_backup = web.getboolean("allow_backup") From bb059e08432206ec7e38af4fcb9239daa6085374 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 8 Sep 2025 15:29:50 +0200 Subject: [PATCH 050/108] log backup trigger reason --- httpserver.py | 2 +- robin.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/httpserver.py b/httpserver.py index b7086dd..75046af 100644 --- a/httpserver.py +++ b/httpserver.py @@ -424,7 +424,7 @@ def do_GET(self): # Raw dump download start = time.time() logging.info(f"RRD database dump requested by {self.client_address[0]}") - response = http.rrd.dump() + response = http.rrd.dump(reason=f'Web ({self.client_address[0]})') self._set_download_headers(len(response), f'{http.settings.name}-rrd-{time.strftime("%Y%m%d-%H%M%S")}.xml.gz') self.wfile.write(response) diff --git a/robin.py b/robin.py index a69041f..6a3fa5e 100644 --- a/robin.py +++ b/robin.py @@ -200,7 +200,7 @@ def backup(self): return else: with backupfile: - backupfile.write(self.dump()) + backupfile.write(self.dump(reason='Backup')) size = os.stat(f'{str(self.backup_path)}/{self.backup_name}.{suffix}').st_size if size == 0: logging.error(f'Database backup failed: empty datafile returned') @@ -240,12 +240,12 @@ def start_backups(self): schedule.every().day.at(self.backup_time).do(run_threaded, self.backup) - def dump(self): + def dump(self, reason=''): '''provide a gzipped dump of database''' dump_local.zipped = bytearray() if self.rrdtool and self.gzip: self.write_updates() - print('Dump requested',flush=True) + print('Dump requested: {}'.format(reason),flush=True) if not db_lock.acquire(blocking=True, timeout=60): print('Error: Dumping failed, could not acquire db lock within 60s',flush=True) return dump_local.zipped From a6091f4a91740c3d6afa0b491984af79e3c6fbaf Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 8 Sep 2025 16:41:03 +0200 Subject: [PATCH 051/108] pinreader instance as sub-class --- gpioreader.py | 2 +- pinreader.py | 125 ++++++++++++++++++++++++++------------------------ 2 files changed, 66 insertions(+), 61 deletions(-) diff --git a/gpioreader.py b/gpioreader.py index 41a76cc..ecf4e2c 100644 --- a/gpioreader.py +++ b/gpioreader.py @@ -57,7 +57,7 @@ def __init__(self, pinlist, data): data[f'pin-{pin}'] = self._value_to_data(self.pins[pin].value) self.directions[pin] = self.pins[pin].direction self.consumers[pin] = self.pins[pin].consumer - print('Pin \'{}\': {}'.format(pin, repr(self.pins[pin])[12:-1])) + print('Pin \'{}\': {}'.format(pin, repr(self.pins[pin])[10:-1])) logging.info('Pin \'{}\': {}'.format(pin, repr(self.pins[pin])[12:-1])) print('GPIO monitoring configured and logging enabled') logging.info('GPIO monitoring configured and logging enabled') diff --git a/pinreader.py b/pinreader.py index 4c075a6..42b9436 100644 --- a/pinreader.py +++ b/pinreader.py @@ -9,70 +9,73 @@ 'pinreader requires gpiod v2.x.x or later.' .format(gpiod.__version__)) ''' -PinInstance class - -''' -class PinInstance: - def __init__(self, chip, line): - self._pid = getpid() # record PID of process that called init() - if not gpiod.is_gpiochip_device(chip): - raise ValueError('\'{}\' is not a valid GPIO device'.format(chip)) - self.chip = chip - try: - self._chip = gpiod.Chip(chip) - except PermissionError as e: - raise PermissionError('Cannot access \'{}\': {}' .format(chip, e)) - if line < 0 or line >= self._chip.get_info().num_lines: - raise ValueError('Requested line ({}) outside range for \'{}\' ({} lines)' - .format(line, chip, self._chip.get_info().num_lines)) - self.line = line - info = self._chip.get_line_info(self.line) - self.name = info.name - self.get() - - def __repr__(self): - consumer = None if self.consumer is None else '\'{}\''.format(self.consumer) - return 'PinInstance(chip=\'{}\' line={} consumer={} direction=\'{}\' value={})'\ - .format(self.chip, self.line, consumer, self.direction, self.value) - - def __str__(self): - consumer = None if self.consumer is None else '\'{}\''.format(self.consumer) - return 'direction: {}, consumer: {}, value: {}'\ - .format(self.direction, consumer, self.value) - - def _value(self): - try: - with self._chip.request_lines(consumer='pinstance-{}'.format(self._pid), - config={self.line: None}) as request: - val = request.get_values()[0] - return 1 if val == gpiod.line.Value.ACTIVE else 0 - except: - # silently return None if the read fails - # - there are possible race conditions if this pin is simultaneously - # accessed by another program (or instance of this class..) etc. - return None - - def get(self): - line = self._chip.get_line_info(self.line) - self.direction = 'input' if line.direction == gpiod.line.Direction.INPUT else 'output' - if line.used: - self.consumer = line.consumer - self.value = None - else: - self.consumer = None - self.value = self._value() - return self.value - -''' PinReader class (dict) - ''' +INPUT = gpiod.line.Direction.INPUT +ACTIVE = gpiod.line.Value.ACTIVE class PinReader(dict): + ''' + internal instance class + ''' + class _instance: + def __init__(self, chip, line): + self._pid = getpid() # record PID of process that called init() + if not gpiod.is_gpiochip_device(chip): + raise ValueError('\'{}\' is not a valid GPIO device'.format(chip)) + self.chip = chip + try: + self._chip = gpiod.Chip(chip) + except PermissionError as e: + raise PermissionError('Cannot access \'{}\': {}' .format(chip, e)) + if line < 0 or line >= self._chip.get_info().num_lines: + raise ValueError('Requested line ({}) outside range for \'{}\' ({} lines)' + .format(line, chip, self._chip.get_info().num_lines)) + self.line = line + info = self._chip.get_line_info(self.line) + self.name = info.name + self.get() + + def __repr__(self): + consumer = None if self.consumer is None else '\'{}\''.format(self.consumer) + return '_instance(chip=\'{}\' line={} consumer={} direction=\'{}\' value={})'\ + .format(self.chip, self.line, consumer, self.direction, self.value) + + def __str__(self): + consumer = None if self.consumer is None else '\'{}\''.format(self.consumer) + return 'direction: {}, consumer: {}, value: {}'\ + .format(self.direction, consumer, self.value) + + def _value(self): + try: + with self._chip.request_lines(consumer='pinreader-{}'.format(self._pid), + config={self.line: None}) as request: + val = request.get_values()[0] + return 1 if val == ACTIVE else 0 + except: + # silently return None if the read fails + # - there are possible race conditions if this pin is simultaneously + # accessed by another program (or instance of this class..) etc. + return None + + def get(self): + line = self._chip.get_line_info(self.line) + self.direction = 'input' if line.direction == INPUT else 'output' + if line.used: + self.consumer = line.consumer + self.value = None + else: + self.consumer = None + self.value = self._value() + return self.value + + ''' class init() ''' def __init__(self, pinlist, tolerant=False): super().__init__({}) for label in pinlist: try: - super().__setitem__(label, PinInstance(pinlist[label][0], pinlist[label][1])) + super().__setitem__(label, + self._instance(pinlist[label][0], + pinlist[label][1])) except ValueError as e: if not tolerant: raise ValueError('failed to set up pin \'{}\': {}' @@ -86,11 +89,13 @@ def __str__(self): value = 'low' if pin.value == 0 else 'high' ret += '{} = {} ({})\n'.format(line, value, pin.direction) else: - ret += '{} = n/a (\'{}\')\n'.format(line, pin.consumer) + ret += '{} = - (\'{}\')\n'.format(line, pin.consumer) return ret.rstrip('\n') - def update(self): - for line in super().keys(): + def update(self, items=None): + items = [items] if type(items) == str else items + items = super().keys() if items is None else items + for line in items: super().__getitem__(line).get() ''' From 4086c2efb8c7a5679e5a2b2ca2c34a4d570e3983 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 9 Sep 2025 19:21:28 +0200 Subject: [PATCH 052/108] thread.setDaemon() depreciated --- httpserver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/httpserver.py b/httpserver.py index 75046af..a15bd3c 100644 --- a/httpserver.py +++ b/httpserver.py @@ -72,7 +72,7 @@ def serve_forever(httpd): logging.info("Http Server closing down") thread = Thread(target=serve_forever, args=(httpd, )) - thread.setDaemon(True ) + thread.daemon = True thread.start() From 0765d2bc78e7061700fd6e9e7f1f6fc97c1deed2 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 9 Sep 2025 19:33:51 +0200 Subject: [PATCH 053/108] pin control wip --- pinpopper.py | 232 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 pinpopper.py diff --git a/pinpopper.py b/pinpopper.py new file mode 100644 index 0000000..96ebc69 --- /dev/null +++ b/pinpopper.py @@ -0,0 +1,232 @@ +import gpiod + +from os import getpid +from time import sleep, asctime +from datetime import timedelta +from re import search + +# Needs gpiod bindings at V2.0 or later, standard debian12/bookworm is v1.6 +# use a virtualenv and 'pip install --upgrade gpiod' as needed. +if int(search('^[0-9]+', gpiod.__version__).group(0)) < 2: + raise ImportError('gpiod bindings library version too low ({}), '\ + 'pinreader requires gpiod v2.x.x or later.' + .format(gpiod.__version__)) + +def get_device(chip, line): + ''' Common gpiochip+line setup ''' + # Test whether chip is a gpiochip + if not gpiod.is_gpiochip_device(chip): + raise ValueError('Not a gpiochip device: \'{}\''.format(chip)) + # Test we can use gpiochip + try: + device = gpiod.Chip(chip) + except Exception as e: + raise ValueError('Failed to setup gpiochip (\'{}\') device:\n{}' + .format(chip, e)) + # Test if the line is already used by another consumer + if device.get_line_info(line).used: + raise ValueError('Cannot acquire gpiochip \'{}\' line {}, '\ + 'currently used by: \'{}\'' + .format(line, device.get_line_info(self.line).consumer)) + return device + +# Input +class input_pin: + ''' + Sets the input pin up to monitor for changes: + - pin will be used (consumed) by the process + Takes: + chip (str) + line (int) + consumer (string) + verbose (bool) + Provides: + event(timeout): + blocks and waits for events + returns None if no event within timeout + returns a string with 'rising' or 'falling' otherwise + timeout = a timedelta object or + 0 for immediate return or + None to wait indefinately + ''' + def __init__(self, chip, line, consumer, verbose=False): + self.chip = chip + self.line = line + self.consumer = consumer + self.bias = gpiod.line.Bias.DISABLED + self.edge = gpiod.line.Edge.BOTH + self.clock = gpiod.line.Clock.MONOTONIC + self.debounce = 100 + self.verbose = verbose + self.states = (gpiod.line.Value.INACTIVE, gpiod.line.Value.ACTIVE) + # Get and test the device + self.device = get_device(self.chip, self.line) + # Create a request object for the input + self.request = self.device.request_lines( + consumer=self.consumer, + config={self.line: gpiod.LineSettings( + direction=gpiod.line.Direction.INPUT, + bias=self.bias, + edge_detection=self.edge, + event_clock=self.clock, + debounce_period=timedelta(milliseconds=self.debounce))}) + if self.verbose: + print('Configured: \'{}\':{} as input' + .format(self.chip, self.line)) + + def event(self, timeout=0): + if self.request.wait_edge_events(timeout=timeout): + events = self.request.read_edge_events() + for event in events: + if event.line_offset == self.line: + if event.event_type == gpiod.edge_event.EdgeEvent.Type.RISING_EDGE: + return 'rising' + elif event.event_type == gpiod.edge_event.EdgeEvent.Type.FALLING_EDGE: + return 'falling' + return None + +# Output +class output_pin: + ''' + Sets the output pin up: + Takes: + chip (str) + line (int) + consumer (string) + lock (bool) + verbose (bool) + Provides: + set(value): sets the output value (int: 0 or 1) + get(): gets the output value (int: 0 or 1) + ''' + def __init__(self, chip, line, consumer, lock=False, verbose=False): + self.chip = chip + self.line = line + self.consumer = consumer + self.verbose = verbose + self.states = (gpiod.line.Value.INACTIVE, gpiod.line.Value.ACTIVE) + # Get and test the device + self.device = get_device(self.chip, self.line) + # Create a request object if we are a consumer, otherwise None + if lock: + self.request = self.device.request_lines(consumer=self.consumer, + config={self.line: gpiod.LineSettings(direction=gpiod.line.Direction.OUTPUT)}) + else: + self.request = None + if self.verbose: + print('Configured: \'{}\':{} as output ({})' + .format(self.chip, self.line, 'locked' if lock else 'unlocked')) + + def get(self): + if self.request: + value = self.states.index(self.request.get_value(self.line)) + else: + with self.device.request_lines( + consumer=self.consumer, + config={self.line: None}, + ) as line: + value = self.states.index(line.get_value(self.line)) + return value + + def set(self, value): + if self.request: + self.request.set_value(self.line, self.states[value]) + else: + with self.device.request_lines( + consumer=self.consumer, + config={self.line: gpiod.LineSettings(direction=gpiod.line.Direction.OUTPUT)}, + ) as line: + line.set_value(self.line, self.states[value]) + +# HTTP server +import http.server +from urllib.parse import urlparse +from threading import Thread + +def serve_http(out, host='0.0.0.0', port=7090, off='off', on='on', toggle='toggle', debug=True): + '''Spawns a http.server.HTTPServer in a separate thread on the given port''' + handler = _BaseRequestHandler + httpd = http.server.ThreadingHTTPServer((host, port), handler, False) + httpd.timeout = 0.5 + httpd.allow_reuse_address = True + # Storing attributes in the http class itself is cheeky, but simple and effective. + http.out = out + http.host = host + http.port = port + http.states = (off, on) + http.toggle = toggle + http.debug = debug + # Start the server + httpd.server_bind() + http.address = f"http://{httpd.server_name}:{httpd.server_port}" + print(f"http server: {http.address}",flush=True) + # Serve requests using threads + httpd.server_activate() + def serve_forever(httpd): + with httpd: + httpd.serve_forever() + thread = Thread(target=serve_forever, args=(httpd, )) + thread.daemon = True + thread.start() + +class _BaseRequestHandler(http.server.BaseHTTPRequestHandler): + + def log_message(self, format, *args): + # This function suppresses the http server log output + if http.debug: + print(f'HTTP request:: {self.client_address[0]} : {args[0]} '\ + f'({args[1]})',flush=True) + return + + def do_GET(self): + '''Process requests and parse their options''' + self.send_response(200) + self.send_header("Content-type", "text/html") + self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") + self.send_header("Pragma", "no-cache") + self.send_header("Expires", "0") + self.end_headers() + if urlparse(self.path).path == '/{}'.format(http.states[0]): + http.out.set(0) + elif urlparse(self.path).path == '/{}'.format(http.states[1]): + http.out.set(1) + elif urlparse(self.path).path == '/{}'.format(http.toggle): + http.out.set(1 - http.out.get()) + elif urlparse(self.path).path == '/': + pass + else: + self.send_error(404, 'No Match', 'Nothing matches the URL') + return + self.wfile.write(bytes(http.states[http.out.get()], 'utf-8')) + +# Main +if __name__ == '__main__': + from sys import argv + # demo + output_chip = '/dev/gpiochip0' + output_line = 7 + button_chip = '/dev/gpiochip0' + button_line = 27 + + consumer = '{}-{}'.format(argv[0], getpid()) + out = output_pin(output_chip, output_line, consumer, lock=False) + button = input_pin(button_chip, button_line, consumer) + looptime = timedelta(seconds=60) + + def flip(): + current = out.get() + if current == 0: + out.set(1) + return('lamp on') + else: + out.set(0) + return('lamp off') + + print('running: {}'.format(argv[0])) + + serve_http(out) + + while True: + ev = button.event(timeout=looptime) + if ev == 'rising': + print('{} : {}'.format(asctime(), flip()), flush=True) From 1f4a198729d2c4485e68c75ecc64071759faf7b1 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 9 Sep 2025 23:19:46 +0200 Subject: [PATCH 054/108] popper work --- pinpopper.py | 83 ++++++++++++++++++++++++++++++++-------------------- 1 file changed, 51 insertions(+), 32 deletions(-) diff --git a/pinpopper.py b/pinpopper.py index 96ebc69..a73e426 100644 --- a/pinpopper.py +++ b/pinpopper.py @@ -23,11 +23,6 @@ def get_device(chip, line): except Exception as e: raise ValueError('Failed to setup gpiochip (\'{}\') device:\n{}' .format(chip, e)) - # Test if the line is already used by another consumer - if device.get_line_info(line).used: - raise ValueError('Cannot acquire gpiochip \'{}\' line {}, '\ - 'currently used by: \'{}\'' - .format(line, device.get_line_info(self.line).consumer)) return device # Input @@ -53,7 +48,7 @@ def __init__(self, chip, line, consumer, verbose=False): self.chip = chip self.line = line self.consumer = consumer - self.bias = gpiod.line.Bias.DISABLED + self.bias = gpiod.line.Bias.AS_IS self.edge = gpiod.line.Edge.BOTH self.clock = gpiod.line.Clock.MONOTONIC self.debounce = 100 @@ -61,6 +56,10 @@ def __init__(self, chip, line, consumer, verbose=False): self.states = (gpiod.line.Value.INACTIVE, gpiod.line.Value.ACTIVE) # Get and test the device self.device = get_device(self.chip, self.line) + if self.device.get_line_info(line).used: + raise ValueError('Cannot acquire gpiochip \'{}\' line {}, '\ + 'currently used by: \'{}\'' + .format(chip, line, self.device.get_line_info(line).consumer)) # Create a request object for the input self.request = self.device.request_lines( consumer=self.consumer, @@ -96,8 +95,9 @@ class output_pin: lock (bool) verbose (bool) Provides: - set(value): sets the output value (int: 0 or 1) - get(): gets the output value (int: 0 or 1) + set(value): sets the output value (int: 0 or 1 for low/high) + returns True if successful, False if output is unavailable + get(): gets the output value (int: 0, 1 or 2=unavailable) ''' def __init__(self, chip, line, consumer, lock=False, verbose=False): self.chip = chip @@ -107,10 +107,15 @@ def __init__(self, chip, line, consumer, lock=False, verbose=False): self.states = (gpiod.line.Value.INACTIVE, gpiod.line.Value.ACTIVE) # Get and test the device self.device = get_device(self.chip, self.line) - # Create a request object if we are a consumer, otherwise None + # Create a request object as needed if lock: + if self.device.get_line_info(line).used: + raise ValueError('Cannot acquire gpiochip \'{}\' line {}, '\ + 'currently used by: \'{}\'' + .format(chip, line, self.device.get_line_info(line).consumer)) self.request = self.device.request_lines(consumer=self.consumer, - config={self.line: gpiod.LineSettings(direction=gpiod.line.Direction.OUTPUT)}) + config={self.line: gpiod.LineSettings( + direction=gpiod.line.Direction.OUTPUT)}) else: self.request = None if self.verbose: @@ -121,29 +126,36 @@ def get(self): if self.request: value = self.states.index(self.request.get_value(self.line)) else: - with self.device.request_lines( - consumer=self.consumer, - config={self.line: None}, - ) as line: - value = self.states.index(line.get_value(self.line)) + try: + with self.device.request_lines( + consumer=self.consumer, + config={self.line: None}, + ) as line: + value = self.states.index(line.get_value(self.line)) + except OSError: + value = 2 # 2 = unavailable return value def set(self, value): if self.request: self.request.set_value(self.line, self.states[value]) else: - with self.device.request_lines( - consumer=self.consumer, - config={self.line: gpiod.LineSettings(direction=gpiod.line.Direction.OUTPUT)}, - ) as line: - line.set_value(self.line, self.states[value]) + try: + with self.device.request_lines( + consumer=self.consumer, + config={self.line: gpiod.LineSettings(direction=gpiod.line.Direction.OUTPUT)}, + ) as line: + line.set_value(self.line, self.states[value]) + except OSError: + return False + return True # HTTP server import http.server from urllib.parse import urlparse from threading import Thread -def serve_http(out, host='0.0.0.0', port=7090, off='off', on='on', toggle='toggle', debug=True): +def serve_http(out, host='0.0.0.0', port=7090, states=('off', 'on', 'unavailable'), toggle='toggle', debug=False): '''Spawns a http.server.HTTPServer in a separate thread on the given port''' handler = _BaseRequestHandler httpd = http.server.ThreadingHTTPServer((host, port), handler, False) @@ -153,8 +165,9 @@ def serve_http(out, host='0.0.0.0', port=7090, off='off', on='on', toggle='toggl http.out = out http.host = host http.port = port - http.states = (off, on) + http.states = states http.toggle = toggle + http.lastknown = '' http.debug = debug # Start the server httpd.server_bind() @@ -193,11 +206,16 @@ def do_GET(self): elif urlparse(self.path).path == '/{}'.format(http.toggle): http.out.set(1 - http.out.get()) elif urlparse(self.path).path == '/': - pass + self.wfile.write(bytes(http.states[http.out.get()], 'utf-8')) + return else: self.send_error(404, 'No Match', 'Nothing matches the URL') return - self.wfile.write(bytes(http.states[http.out.get()], 'utf-8')) + state = http.states[http.out.get()] + self.wfile.write(bytes(state, 'utf-8')) + if state != http.lastknown: + print('{} : http ({}) : {}'.format(asctime(), self.client_address[0], state)) + http.lastknown = state # Main if __name__ == '__main__': @@ -208,25 +226,26 @@ def do_GET(self): button_chip = '/dev/gpiochip0' button_line = 27 + print('running: {}'.format(argv[0])) + consumer = '{}-{}'.format(argv[0], getpid()) - out = output_pin(output_chip, output_line, consumer, lock=False) - button = input_pin(button_chip, button_line, consumer) + out = output_pin(output_chip, output_line, consumer, lock=False, verbose=True) + button = input_pin(button_chip, button_line, consumer, verbose=True) looptime = timedelta(seconds=60) + outstates = ('off', 'on', 'unavailable') def flip(): current = out.get() if current == 0: out.set(1) - return('lamp on') - else: + elif current == 1: out.set(0) - return('lamp off') - - print('running: {}'.format(argv[0])) + return '{}'.format(outstates[out.get()]) serve_http(out) + print('Initial output: {}'.format(outstates[out.get()])) while True: ev = button.event(timeout=looptime) if ev == 'rising': - print('{} : {}'.format(asctime(), flip()), flush=True) + print('{} : button : {}'.format(asctime(), flip()), flush=True) From 7a2ac5e30bc1bb3d3e7e6d9db187e51cebaecd49 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 10 Sep 2025 00:18:15 +0200 Subject: [PATCH 055/108] be more specif on errors we ignore --- pinreader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pinreader.py b/pinreader.py index 42b9436..921630b 100644 --- a/pinreader.py +++ b/pinreader.py @@ -51,7 +51,7 @@ def _value(self): config={self.line: None}) as request: val = request.get_values()[0] return 1 if val == ACTIVE else 0 - except: + except OSError: # silently return None if the read fails # - there are possible race conditions if this pin is simultaneously # accessed by another program (or instance of this class..) etc. From 15aeb027beb4a0253dafe9de07d030f8443f6cf8 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 10 Sep 2025 13:25:33 +0200 Subject: [PATCH 056/108] unneeded --- animator.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/animator.py b/animator.py index 2a8274f..3f36262 100644 --- a/animator.py +++ b/animator.py @@ -60,9 +60,6 @@ def __init__(self, settings, disp, data): self.height = self.disp.height self.span = self.width*2 + self.margin - # Display rotation handled during display init - #self.display_rotate = settings.display_rotate - # How fast self.animate_speed = settings.animate_speed From ac37eb26dad3d423c2d19aedf95f09554a8cca11 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 10 Sep 2025 13:26:08 +0200 Subject: [PATCH 057/108] give more log by default --- httpserver.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/httpserver.py b/httpserver.py index a15bd3c..ec2a2c0 100644 --- a/httpserver.py +++ b/httpserver.py @@ -293,7 +293,7 @@ def _give_links(self): f'Action Log\n' return ret - def _give_log(self, lines=25): + def _give_log(self, lines=32): # Combine and give last (lines) lines of log parsed_lines = parse_qs(urlparse(self.path).query).get('lines', None) if isinstance(parsed_lines, list): @@ -303,8 +303,8 @@ def _give_log(self, lines=25): try: lines = int(lines) except ValueError: - lines = int(100) - lines = max(1, min(lines, 250000)) + lines = int(32) + lines = max(1, lines) # Use a shell one-liner used to extract the last {lines} of data from the logs # There is doubtless a more 'python' way to do this, but it is fast, cheap and works.. log_command = \ @@ -316,9 +316,9 @@ def _give_log(self, lines=25):
\n{log}

Latest {lines} lines shown\n \n -
25 : - 250 : - 2500
\n +
32 : + 320 : + 3200
\n \n''' return ret From 02985827c95ebb7d4be1cafc49d01ba2776e9121 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 10 Sep 2025 13:43:17 +0200 Subject: [PATCH 058/108] do better at detecting i2c failures --- bus_drivers.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/bus_drivers.py b/bus_drivers.py index 234d302..9a6b789 100644 --- a/bus_drivers.py +++ b/bus_drivers.py @@ -83,7 +83,7 @@ def i2c_setup(settings): # Create the display object disp = ssd1306(bus=i2c, address=settings.display_addr, rotate=rotate) print("SSD1306 i2c display found") - except RuntimeError as error: + except Exception as error: disp = None print(error) print("ERROR: SSD1306 i2c display failed to initialise, disabling") @@ -94,13 +94,21 @@ def i2c_setup(settings): if sensor: try: - # Create the I2C BME280 sensor object + # Create the I2C BME280 sensor object and get initial readings bme = bme280.BME280(i2c_addr=settings.sensor_addr, i2c_dev=i2c) - print("BME280 sensor found") - except RuntimeError as error: + except Exception as error: bme = None print(error) print("We do not have a environmental sensor") + try: + # Try initial reading of sensor, bus errors can cause this to fail + bme.update_sensor() + print("BME280 sensor found") + except Exception as error: + bme = None + print(error) + print("Environmental sensor failed initial update, this may be due to bus errors") + print(flush=True, end='') return disp, bme From 34e2624aa4837e424ab893032caa0ee25d7f3117 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 10 Sep 2025 19:37:43 +0200 Subject: [PATCH 059/108] nearly there --- pinpopper.py | 155 ++++++++++++++++++++++++++++++++++----------------- 1 file changed, 104 insertions(+), 51 deletions(-) diff --git a/pinpopper.py b/pinpopper.py index a73e426..4d3d439 100644 --- a/pinpopper.py +++ b/pinpopper.py @@ -1,9 +1,11 @@ import gpiod - from os import getpid from time import sleep, asctime from datetime import timedelta from re import search +from threading import Thread +import http.server +from urllib.parse import urlparse # Needs gpiod bindings at V2.0 or later, standard debian12/bookworm is v1.6 # use a virtualenv and 'pip install --upgrade gpiod' as needed. @@ -12,7 +14,7 @@ 'pinreader requires gpiod v2.x.x or later.' .format(gpiod.__version__)) -def get_device(chip, line): +def _get_device(chip, line): ''' Common gpiochip+line setup ''' # Test whether chip is a gpiochip if not gpiod.is_gpiochip_device(chip): @@ -55,7 +57,7 @@ def __init__(self, chip, line, consumer, verbose=False): self.verbose = verbose self.states = (gpiod.line.Value.INACTIVE, gpiod.line.Value.ACTIVE) # Get and test the device - self.device = get_device(self.chip, self.line) + self.device = _get_device(self.chip, self.line) if self.device.get_line_info(line).used: raise ValueError('Cannot acquire gpiochip \'{}\' line {}, '\ 'currently used by: \'{}\'' @@ -70,7 +72,7 @@ def __init__(self, chip, line, consumer, verbose=False): event_clock=self.clock, debounce_period=timedelta(milliseconds=self.debounce))}) if self.verbose: - print('Configured: \'{}\':{} as input' + print('Configured: \'{}\':{} as input (locked)' .format(self.chip, self.line)) def event(self, timeout=0): @@ -106,7 +108,7 @@ def __init__(self, chip, line, consumer, lock=False, verbose=False): self.verbose = verbose self.states = (gpiod.line.Value.INACTIVE, gpiod.line.Value.ACTIVE) # Get and test the device - self.device = get_device(self.chip, self.line) + self.device = _get_device(self.chip, self.line) # Create a request object as needed if lock: if self.device.get_line_info(line).used: @@ -143,19 +145,15 @@ def set(self, value): try: with self.device.request_lines( consumer=self.consumer, - config={self.line: gpiod.LineSettings(direction=gpiod.line.Direction.OUTPUT)}, + config={self.line: gpiod.LineSettings( + direction=gpiod.line.Direction.OUTPUT)}, ) as line: line.set_value(self.line, self.states[value]) except OSError: return False return True -# HTTP server -import http.server -from urllib.parse import urlparse -from threading import Thread - -def serve_http(out, host='0.0.0.0', port=7090, states=('off', 'on', 'unavailable'), toggle='toggle', debug=False): +def _serve_http(out, host, port, states, toggle, verbose, debug): '''Spawns a http.server.HTTPServer in a separate thread on the given port''' handler = _BaseRequestHandler httpd = http.server.ThreadingHTTPServer((host, port), handler, False) @@ -167,12 +165,14 @@ def serve_http(out, host='0.0.0.0', port=7090, states=('off', 'on', 'unavailable http.port = port http.states = states http.toggle = toggle - http.lastknown = '' + http.verbose = verbose http.debug = debug + http.lastknown = http.states[http.out.get()] # Start the server httpd.server_bind() http.address = f"http://{httpd.server_name}:{httpd.server_port}" - print(f"http server: {http.address}",flush=True) + if http.verbose: + print(f"http server: {http.address}",flush=True) # Serve requests using threads httpd.server_activate() def serve_forever(httpd): @@ -182,23 +182,27 @@ def serve_forever(httpd): thread.daemon = True thread.start() +''' Base request handler class for the http server ''' class _BaseRequestHandler(http.server.BaseHTTPRequestHandler): + ''' suppress the http server log output ''' def log_message(self, format, *args): - # This function suppresses the http server log output if http.debug: print(f'HTTP request:: {self.client_address[0]} : {args[0]} '\ f'({args[1]})',flush=True) return + ''' handle GET requests : this is where logic lives ''' def do_GET(self): - '''Process requests and parse their options''' - self.send_response(200) - self.send_header("Content-type", "text/html") - self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") - self.send_header("Pragma", "no-cache") - self.send_header("Expires", "0") - self.end_headers() + def headers(): + # common headers for non-error response + self.send_response(200) + self.send_header("Content-type", "text/html") + self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") + self.send_header("Pragma", "no-cache") + self.send_header("Expires", "0") + self.end_headers() + # parse request and process if urlparse(self.path).path == '/{}'.format(http.states[0]): http.out.set(0) elif urlparse(self.path).path == '/{}'.format(http.states[1]): @@ -206,46 +210,95 @@ def do_GET(self): elif urlparse(self.path).path == '/{}'.format(http.toggle): http.out.set(1 - http.out.get()) elif urlparse(self.path).path == '/': - self.wfile.write(bytes(http.states[http.out.get()], 'utf-8')) - return + # this could be expanded to a mini 'portal' + pass else: self.send_error(404, 'No Match', 'Nothing matches the URL') return state = http.states[http.out.get()] + headers() self.wfile.write(bytes(state, 'utf-8')) - if state != http.lastknown: + # only log if value changed + if state != http.lastknown and http.verbose: print('{} : http ({}) : {}'.format(asctime(), self.client_address[0], state)) - http.lastknown = state - -# Main -if __name__ == '__main__': - from sys import argv - # demo - output_chip = '/dev/gpiochip0' - output_line = 7 - button_chip = '/dev/gpiochip0' - button_line = 27 + http.lastknown = state - print('running: {}'.format(argv[0])) +''' The pinpopper class itself ''' +class PinPopper: + def __init__(self, outpin, inpin=None, web=None, + name='pinpopper',looptime=60, + outstates=('off', 'on', 'unavailable'), toggle='toggle', + lock=True, verbose=True, debug=False): + self._outpin = outpin + self._inpin = inpin + self._consumer = '{}-{}'.format(name, getpid()) + if type(looptime) is not timedelta: + self._looptime = timedelta(seconds=int(looptime)) + else: + self._looptime = loop + self._outstates = outstates + self._verbose = verbose - consumer = '{}-{}'.format(argv[0], getpid()) - out = output_pin(output_chip, output_line, consumer, lock=False, verbose=True) - button = input_pin(button_chip, button_line, consumer, verbose=True) - looptime = timedelta(seconds=60) - outstates = ('off', 'on', 'unavailable') + # Acquire the output pin + self._output = output_pin(outpin[0], outpin[1], self._consumer, lock, verbose) + # If input is specified; acquire it and start input handler thread + if self._inpin is not None: + self._input = input_pin(inpin[0], inpin[1], self._consumer, verbose) + inserve = Thread(target=self._serve_input, args=(verbose, )) + inserve.daemon = True + inserve.start() + # If http (host, port) is specified; start http server thread + if web is not None: + _serve_http(self._output, web[0], web[1], + outstates, toggle, verbose, debug) + # Show initial state as required + if verbose: + print('Initial output: {}'.format(outstates[self._output.get()])) - def flip(): - current = out.get() + ''' A simple function to invert the output ''' + def _flip(self): + current = self._output.get() if current == 0: - out.set(1) + self._output.set(1) elif current == 1: - out.set(0) - return '{}'.format(outstates[out.get()]) + self._output.set(0) + + def _serve_input(self, verbose=True): + ''' loop (forever) waiting for and serving the input pin events ''' + while True: + event = self._input.event(timeout=self._looptime) + if event == 'rising': + self._flip() + if self._verbose: + print('{} : button : {}'.format(asctime(), self._outstates[self._output.get()]), flush=True) + + def get(self): + return self._outstates[self._output.get()] - serve_http(out) + def set(self, action): + if action == self._outstates[0]: + self._output.set(0) + elif action == self._outstates[1]: + self._output.set(1) + elif action == 'toggle': + self._flip() + else: + raise ValueError('invalid action for output setting: {}'.format(action)) + if self._verbose: + print('{} : set(\'{}\') : {}'.format(asctime(), action, + self._outstates[self._output.get()]), flush=True) + +# Main +if __name__ == '__main__': + from sys import argv + # demo + output = ('/dev/gpiochip0', 7) + button = ('/dev/gpiochip0', 27) + web = ('0.0.0.0', 7090) + name = argv[0] - print('Initial output: {}'.format(outstates[out.get()])) + print('running: {}'.format(name)) + popper = PinPopper(output, button, web, name, lock=False, verbose=True) while True: - ev = button.event(timeout=looptime) - if ev == 'rising': - print('{} : button : {}'.format(asctime(), flip()), flush=True) + print('{} : main loop'.format(asctime())) # DEBUG.. + sleep(600) From ff452b7cbc402337893b43924e8a3b1b269e52cc Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 11 Sep 2025 13:32:48 +0200 Subject: [PATCH 060/108] fixed some fluff --- pinpopper.py | 97 +++++++++++++++++++++++++++++++--------------------- 1 file changed, 58 insertions(+), 39 deletions(-) diff --git a/pinpopper.py b/pinpopper.py index 4d3d439..f04d913 100644 --- a/pinpopper.py +++ b/pinpopper.py @@ -36,6 +36,7 @@ class input_pin: chip (str) line (int) consumer (string) + debounce (int) in ms verbose (bool) Provides: event(timeout): @@ -46,31 +47,29 @@ class input_pin: 0 for immediate return or None to wait indefinately ''' - def __init__(self, chip, line, consumer, verbose=False): + def __init__(self, chip, line, consumer, debounce, verbose=False): self.chip = chip self.line = line - self.consumer = consumer - self.bias = gpiod.line.Bias.AS_IS - self.edge = gpiod.line.Edge.BOTH - self.clock = gpiod.line.Clock.MONOTONIC - self.debounce = 100 + bias = gpiod.line.Bias.AS_IS + edge = gpiod.line.Edge.BOTH + clock = gpiod.line.Clock.MONOTONIC + debounce = timedelta(milliseconds=debounce) self.verbose = verbose - self.states = (gpiod.line.Value.INACTIVE, gpiod.line.Value.ACTIVE) # Get and test the device - self.device = _get_device(self.chip, self.line) - if self.device.get_line_info(line).used: + device = _get_device(self.chip, self.line) + if device.get_line_info(line).used: raise ValueError('Cannot acquire gpiochip \'{}\' line {}, '\ 'currently used by: \'{}\'' - .format(chip, line, self.device.get_line_info(line).consumer)) + .format(chip, line, device.get_line_info(line).consumer)) # Create a request object for the input - self.request = self.device.request_lines( - consumer=self.consumer, - config={self.line: gpiod.LineSettings( + self.request = device.request_lines( + consumer=consumer, + config={line: gpiod.LineSettings( direction=gpiod.line.Direction.INPUT, - bias=self.bias, - edge_detection=self.edge, - event_clock=self.clock, - debounce_period=timedelta(milliseconds=self.debounce))}) + bias=bias, + edge_detection=edge, + event_clock=clock, + debounce_period=debounce)}) if self.verbose: print('Configured: \'{}\':{} as input (locked)' .format(self.chip, self.line)) @@ -84,7 +83,6 @@ def event(self, timeout=0): return 'rising' elif event.event_type == gpiod.edge_event.EdgeEvent.Type.FALLING_EDGE: return 'falling' - return None # Output class output_pin: @@ -188,7 +186,7 @@ class _BaseRequestHandler(http.server.BaseHTTPRequestHandler): ''' suppress the http server log output ''' def log_message(self, format, *args): if http.debug: - print(f'HTTP request:: {self.client_address[0]} : {args[0]} '\ + print(f'DEBUG: HTTP request:: {self.client_address[0]} : {args[0]} '\ f'({args[1]})',flush=True) return @@ -226,24 +224,25 @@ def headers(): ''' The pinpopper class itself ''' class PinPopper: def __init__(self, outpin, inpin=None, web=None, - name='pinpopper',looptime=60, - outstates=('off', 'on', 'unavailable'), toggle='toggle', - lock=True, verbose=True, debug=False): + name='pinpopper', + outstates=('off', 'on', 'unavailable'), + toggle='toggle', + debounce=60, + lock=True, + verbose=True, debug=False): self._outpin = outpin self._inpin = inpin self._consumer = '{}-{}'.format(name, getpid()) - if type(looptime) is not timedelta: - self._looptime = timedelta(seconds=int(looptime)) - else: - self._looptime = loop self._outstates = outstates + self._toggle = toggle + self.cmdlist = (outstates[0], outstates[1], toggle) self._verbose = verbose - + self._debug = debug # Acquire the output pin self._output = output_pin(outpin[0], outpin[1], self._consumer, lock, verbose) # If input is specified; acquire it and start input handler thread if self._inpin is not None: - self._input = input_pin(inpin[0], inpin[1], self._consumer, verbose) + self._input = input_pin(inpin[0], inpin[1], self._consumer, debounce, verbose) inserve = Thread(target=self._serve_input, args=(verbose, )) inserve.daemon = True inserve.start() @@ -266,11 +265,12 @@ def _flip(self): def _serve_input(self, verbose=True): ''' loop (forever) waiting for and serving the input pin events ''' while True: - event = self._input.event(timeout=self._looptime) + event = self._input.event(timeout=None) if event == 'rising': self._flip() if self._verbose: - print('{} : button : {}'.format(asctime(), self._outstates[self._output.get()]), flush=True) + print('{} : button : {}'.format(asctime(), + self._outstates[self._output.get()]), flush=True) def get(self): return self._outstates[self._output.get()] @@ -280,25 +280,44 @@ def set(self, action): self._output.set(0) elif action == self._outstates[1]: self._output.set(1) - elif action == 'toggle': + elif action == self._toggle: self._flip() else: - raise ValueError('invalid action for output setting: {}'.format(action)) + # ignore invalid actions by default, unless debug is on. + if self._debug: + print('DEBUG: PinPopper: invalid action: \'{}\''.format(action)) if self._verbose: - print('{} : set(\'{}\') : {}'.format(asctime(), action, + print('{} : set(\'{}\') : output = {}'.format(asctime(), action, self._outstates[self._output.get()]), flush=True) # Main if __name__ == '__main__': - from sys import argv # demo + from sys import argv, stdin + output = ('/dev/gpiochip0', 7) button = ('/dev/gpiochip0', 27) web = ('0.0.0.0', 7090) name = argv[0] + verbose = True + + popper = PinPopper(output, button, web, name, lock=False, verbose=verbose) + + if verbose: + print('running: {}'.format(name)) + print('available commands: {}'.format(popper.cmdlist)) + + # Now loop forever passing stdin commands to pinpopper.set() + with stdin as cmds: + while True: + cmd = cmds.readline().strip() + if cmd in popper.cmdlist: + popper.set(cmd) + if verbose: + continue + if verbose: + print('{} : invalid action (\'{}\') : output = {}' + .format(asctime(), cmd, popper.get())) + else: + print(popper.get()) - print('running: {}'.format(name)) - popper = PinPopper(output, button, web, name, lock=False, verbose=True) - while True: - print('{} : main loop'.format(asctime())) # DEBUG.. - sleep(600) From efac945c61d7c0dbfc977080998b651c14e7caee Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 11 Sep 2025 16:18:31 +0200 Subject: [PATCH 061/108] stdio feedback on start --- pinpopper.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pinpopper.py b/pinpopper.py index f04d913..5696d74 100644 --- a/pinpopper.py +++ b/pinpopper.py @@ -253,6 +253,8 @@ def __init__(self, outpin, inpin=None, web=None, # Show initial state as required if verbose: print('Initial output: {}'.format(outstates[self._output.get()])) + else: + print(outstates[self._output.get()]) ''' A simple function to invert the output ''' def _flip(self): From 38de651fadcb09187f6d608871d9c30b60aac7c1 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 11 Sep 2025 18:49:53 +0200 Subject: [PATCH 062/108] rationalise how i2c devices start --- SBCEye.py | 31 ++++++++----- bme_sensor.py | 46 +++++++++++++++++++ bus_drivers.py | 114 ------------------------------------------------ i2c_bus.py | 38 ++++++++++++++++ oled_display.py | 49 +++++++++++++++++++++ 5 files changed, 154 insertions(+), 124 deletions(-) create mode 100644 bme_sensor.py delete mode 100644 bus_drivers.py create mode 100644 i2c_bus.py create mode 100644 oled_display.py diff --git a/SBCEye.py b/SBCEye.py index c24ff3d..34d03eb 100644 --- a/SBCEye.py +++ b/SBCEye.py @@ -54,7 +54,9 @@ from httpserver import serve_http from netreader import Netreader from gpioreader import GPIOReader -from bus_drivers import i2c_setup +from i2c_bus import i2c_setup +from bme_sensor import bme_setup +from oled_display import oled_setup # Re-nice to reduce blocking of other processes os.nice(10) @@ -104,16 +106,18 @@ # # Import, setup and return hardware drivers, or 'None' if setup fails -disp, bme = i2c_setup(settings) - -if disp: - # display initialisation does a 'clear()' and 'show()' - disp.contrast(settings.display_contrast) +i2c = i2c_setup(settings) # # Local Classes, Globals -display_queue = None # will be set during +# We override the dictionary class so that every time an item is +# modified it sends a a message to the display queue. +# This allows the display to run in a seperate process while keeping +# it's local data copy updated in real-time. +# The queue is initially disabled (type: None), and assigned as a +# queue object only if the display is enabled and detected. +display_queue = None class TheData(dict): '''Override the dictionary class to also send data to the queue for the display''' def __setitem__(self, item, value): @@ -125,7 +129,7 @@ def __delitem__(self, item): display_queue.put([item], None) super().__delitem__(item) -# Use a (custom overridden) dictionary to store current readings +# Use this overridden dictionary to store current readings data = TheData({}) # Counters used for incremental data need pre-populating @@ -219,15 +223,22 @@ def handle_exit(): # The fun starts here: if __name__ == '__main__': - # Log sensor status + # Environmental sensor + if i2c: + bme = bme_setup(i2c, settings) if bme: logging.info('Environmental sensor configured and enabled') elif settings.have_sensor: - logging.warning('Environmental data configured but no sensor detected: '\ + logging.warning('Environmental data configured but no sensor available: '\ 'Environment status and logging disabled') # Display animation setup + if i2c: + disp = oled_setup(i2c, settings) if disp: + # display initialisation does a 'clear()' and 'show()' + disp.contrast(settings.display_contrast) + from animator import animate display_queue = Queue() DISPLAY = Process(target=animate, args=(settings, disp, display_queue), diff --git a/bme_sensor.py b/bme_sensor.py new file mode 100644 index 0000000..84ca8e9 --- /dev/null +++ b/bme_sensor.py @@ -0,0 +1,46 @@ +'''Bus based environmental sensor initialisation + currently assumes a BME280 (temp/press/humi) sensor +''' +# pragma pylint: disable=import-outside-toplevel + +def bme_setup(i2c, settings): + '''Import and initialise BME sensor library, return the sensor object + + parameters: + i2c: i2c bus object + settings: settings oject (from load_config) + returns: + bme: Sensor module object, or None if failed + ''' + + bme = None + if settings.have_sensor: + # BME280 I2C Tepmerature Pressure and Humidity sensor + # Uses pimoroni library: + # https://github.com/pimoroni/bme280-python + # pip install pimoroni-bme280 + try: + import bme280 + except ImportError as error: + print(error) + print("ERROR: BME280 environment sensor requirements not met", flush=True) + return None + + try: + # Create the I2C BME280 sensor object and get initial readings + bme = bme280.BME280(i2c_addr=settings.sensor_addr, i2c_dev=i2c) + except Exception as error: + print(error) + print("We do not have a environmental sensor", flush=True) + return None + + try: + # Try initial reading of sensor, bus errors can cause this to fail + bme.update_sensor() + except Exception as error: + print(error) + print("Environmental sensor failed initial update, this may be due to bus errors", flush=True) + return None + + print("BME280 sensor found", flush=True) + return bme diff --git a/bus_drivers.py b/bus_drivers.py deleted file mode 100644 index 9a6b789..0000000 --- a/bus_drivers.py +++ /dev/null @@ -1,114 +0,0 @@ -'''Bus based hardware driver initialisation - -Imports and starts the optional Bus based devices. -Gracefully fails and disables services as appropriate if anything goes wrong - -I'd probably do this differently if writing from scratch/refactoring, and -make better use of Importlib in overwatch.py. -''' -# pragma pylint: disable=import-outside-toplevel - -import importlib.util - - -def i2c_setup(settings): - '''Import and start the I2C bus devices - - parameters: - settings: settings oject (from load_config) - returns: - disp: Display driverr object or None if failed - bme: Sensor module object, or None if failed - ''' - - # booleans - sensor = settings.have_sensor - display = settings.have_display - # objects to be returned if successful - disp = None - bme = None - - # Start by trying to load the correct modules - if display or sensor: - # I2C Comms - # Uses standard SMBUS lib (currently smbus2) - try: - import smbus2 - except ImportError as error: - display = sensor = False - print(error) - print("ERROR: I2C bus requirements not met") - - if display: - # I2C OLED Display - # Uses the ssd1306 driver and libs from the luma libraries - # https://github.com/rm-hull/luma.core - # https://github.com/rm-hull/luma.oled - # pip install luma.core luma.oled - try: - from luma.oled.device import ssd1306 - except ImportError as error: - display = False - print(error) - print("ERROR: ssd1306 display requirements not met") - - if sensor: - # BME280 I2C Tepmerature Pressure and Humidity sensor - # Uses pimoroni library: - # https://github.com/pimoroni/bme280-python - # pip install pimoroni-bme280 - try: - import bme280 - except ImportError as error: - sensor = False - print(error) - print("ERROR: BME280 environment sensor requirements not met") - - # Now the actual device driver objects - if display or sensor: - try: - # Create the I2C interface object - i2c = smbus2.SMBus(settings.bus_id) - print('We have a I2C bus') - except ValueError as error: - display = sensor = False - print(error) - print("No I2C bus, display and sensor functions will be disabled") - - if display: - # for luma display rotation must be specified here, - # (value from 0 to 3, rotating 90 degrees each step) - rotate = 2 if settings.display_rotate else 0 - try: - # Create the display object - disp = ssd1306(bus=i2c, address=settings.display_addr, rotate=rotate) - print("SSD1306 i2c display found") - except Exception as error: - disp = None - print(error) - print("ERROR: SSD1306 i2c display failed to initialise, disabling") - - if not importlib.util.find_spec("luma"): - disp = None - print("ERROR: Luma library not found, disabling display") - - if sensor: - try: - # Create the I2C BME280 sensor object and get initial readings - bme = bme280.BME280(i2c_addr=settings.sensor_addr, i2c_dev=i2c) - except Exception as error: - bme = None - print(error) - print("We do not have a environmental sensor") - - try: - # Try initial reading of sensor, bus errors can cause this to fail - bme.update_sensor() - print("BME280 sensor found") - except Exception as error: - bme = None - print(error) - print("Environmental sensor failed initial update, this may be due to bus errors") - - print(flush=True, end='') - return disp, bme diff --git a/i2c_bus.py b/i2c_bus.py new file mode 100644 index 0000000..fb82396 --- /dev/null +++ b/i2c_bus.py @@ -0,0 +1,38 @@ +'''I2C hardware driver initialisation + +Needed by the optional Bus based devices. +Gracefully fails and disables services as appropriate if anything goes wrong +''' +# pragma pylint: disable=import-outside-toplevel + +def i2c_setup(settings): + '''Import and start the I2C bus device + + parameters: + settings: settings (from load_config) + returns: + i2c: Bus driver object, or None if failed + ''' + + # Load the correct modules, be graceful if that fails + i2c = None + if settings.have_display or settings.have_sensor: + # I2C Comms + # Uses standard SMBUS lib (currently smbus2) + try: + import smbus2 + except ImportError as error: + print(error) + print("ERROR: I2C bus requirements (smbus2) not met", flush=True) + return None + + # Now the I2C device driver object + try: + i2c = smbus2.SMBus(settings.bus_id) + except ValueError as error: + print(error) + print("No I2C bus, display and sensor functions will be disabled", flush=True) + return None + + print('I2C bus found', flush=True) + return i2c diff --git a/oled_display.py b/oled_display.py new file mode 100644 index 0000000..502fb97 --- /dev/null +++ b/oled_display.py @@ -0,0 +1,49 @@ +'''Bus based environmental sensor initialisation +''' +# pragma pylint: disable=import-outside-toplevel + +import importlib.util + +def oled_setup(i2c, settings): + '''Import and initialise ssd1306 library, return the display object + + parameters: + i2c: i2c bus object + settings: settings oject (from load_config) + returns: + disp: Display module object, or None if failed + ''' + + disp = None + if settings.have_display: + # I2C OLED Display + # Uses the ssd1306 driver and libs from the luma libraries + # https://github.com/rm-hull/luma.core + # https://github.com/rm-hull/luma.oled + # pip install luma.core luma.oled + try: + from luma.oled.device import ssd1306 + except ImportError as error: + print(error) + print("ERROR: ssd1306 display requirements not met", flush=True) + return None + + # for luma display rotation must be specified at init, + # (value from 0 to 3, rotating 90 degrees each step) + rotate = 2 if settings.display_rotate else 0 + try: + # Create the display object + disp = ssd1306(bus=i2c, address=settings.display_addr, rotate=rotate) + print("SSD1306 i2c display found", flush=True) + except Exception as error: + print(error) + print("ERROR: SSD1306 i2c display failed to initialise, disabling", flush=True) + return None + + # check in advance that the full luma library is available, and not + # just the display driver. This is used by the animator + if not importlib.util.find_spec("luma"): + print("ERROR: Luma library not found, disabling display", flush=True) + return None + + return disp From e5d4494342f0398bca864b20ad5acf7db14ef603 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 11 Sep 2025 20:58:14 +0200 Subject: [PATCH 063/108] fix bad logic --- SBCEye.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/SBCEye.py b/SBCEye.py index 34d03eb..2eff76e 100644 --- a/SBCEye.py +++ b/SBCEye.py @@ -224,8 +224,7 @@ def handle_exit(): if __name__ == '__main__': # Environmental sensor - if i2c: - bme = bme_setup(i2c, settings) + bme = bme_setup(i2c, settings) if i2c else None if bme: logging.info('Environmental sensor configured and enabled') elif settings.have_sensor: @@ -233,8 +232,7 @@ def handle_exit(): 'Environment status and logging disabled') # Display animation setup - if i2c: - disp = oled_setup(i2c, settings) + disp = oled_setup(i2c, settings) if i2c else None if disp: # display initialisation does a 'clear()' and 'show()' disp.contrast(settings.display_contrast) From ac7b2f1b07478dfca24691d43b0de6bcdc4b639d Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 15 Sep 2025 15:34:11 +0200 Subject: [PATCH 064/108] add portal to pinpopper --- pinpopper.py | 120 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 74 insertions(+), 46 deletions(-) diff --git a/pinpopper.py b/pinpopper.py index 5696d74..b351dd6 100644 --- a/pinpopper.py +++ b/pinpopper.py @@ -40,7 +40,7 @@ class input_pin: verbose (bool) Provides: event(timeout): - blocks and waits for events + blocks and waits with timeout for events returns None if no event within timeout returns a string with 'rising' or 'falling' otherwise timeout = a timedelta object or @@ -151,7 +151,7 @@ def set(self, value): return False return True -def _serve_http(out, host, port, states, toggle, verbose, debug): +def _serve_http(out, host, port, states, toggle, portal, refresh, verbose, debug): '''Spawns a http.server.HTTPServer in a separate thread on the given port''' handler = _BaseRequestHandler httpd = http.server.ThreadingHTTPServer((host, port), handler, False) @@ -159,10 +159,10 @@ def _serve_http(out, host, port, states, toggle, verbose, debug): httpd.allow_reuse_address = True # Storing attributes in the http class itself is cheeky, but simple and effective. http.out = out - http.host = host - http.port = port http.states = states http.toggle = toggle + http.portal = portal + http.refresh = refresh http.verbose = verbose http.debug = debug http.lastknown = http.states[http.out.get()] @@ -170,7 +170,7 @@ def _serve_http(out, host, port, states, toggle, verbose, debug): httpd.server_bind() http.address = f"http://{httpd.server_name}:{httpd.server_port}" if http.verbose: - print(f"http server: {http.address}",flush=True) + print(f"HTTP server: {http.address}",flush=True) # Serve requests using threads httpd.server_activate() def serve_forever(httpd): @@ -192,30 +192,51 @@ def log_message(self, format, *args): ''' handle GET requests : this is where logic lives ''' def do_GET(self): - def headers(): - # common headers for non-error response + def common_headers(): self.send_response(200) - self.send_header("Content-type", "text/html") - self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") - self.send_header("Pragma", "no-cache") - self.send_header("Expires", "0") + self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate') + self.send_header('Pragma', 'no-cache') + self.send_header('Expires', '0') + + def redirect(): + common_headers() + self.send_header('refresh', '0; url=/') self.end_headers() + + def getval(): + # return current state and inverse + cur = alt = http.out.get() + if cur == 0: + alt = 1 + elif cur == 1: + alt = 0 + return cur, alt + # parse request and process if urlparse(self.path).path == '/{}'.format(http.states[0]): http.out.set(0) + redirect() + return elif urlparse(self.path).path == '/{}'.format(http.states[1]): http.out.set(1) + redirect() + return elif urlparse(self.path).path == '/{}'.format(http.toggle): - http.out.set(1 - http.out.get()) - elif urlparse(self.path).path == '/': - # this could be expanded to a mini 'portal' - pass - else: + http.out.set(getval()[1]) + redirect() + return + elif urlparse(self.path).path != '/': self.send_error(404, 'No Match', 'Nothing matches the URL') return - state = http.states[http.out.get()] - headers() - self.wfile.write(bytes(state, 'utf-8')) + state, alt = getval() + state = http.states[state] + alt = http.states[alt] + common_headers() + self.send_header('refresh', '{}; url=/'.format(http.refresh)) + self.end_headers() + self.wfile.write(bytes(http.portal.replace('__STATE__', state)\ + .replace('__ALT__', alt)\ + .replace('__NOW__', asctime()), 'utf-8')) # only log if value changed if state != http.lastknown and http.verbose: print('{} : http ({}) : {}'.format(asctime(), self.client_address[0], state)) @@ -225,17 +246,20 @@ def headers(): class PinPopper: def __init__(self, outpin, inpin=None, web=None, name='pinpopper', - outstates=('off', 'on', 'unavailable'), + states=('off', 'on', 'unavailable'), toggle='toggle', - debounce=60, + debounce=66, + edge='rising', + portal='__STATE__', + refresh=60, lock=True, verbose=True, debug=False): self._outpin = outpin self._inpin = inpin self._consumer = '{}-{}'.format(name, getpid()) - self._outstates = outstates + self._states = states self._toggle = toggle - self.cmdlist = (outstates[0], outstates[1], toggle) + self.cmdlist = (states[0], states[1], toggle) self._verbose = verbose self._debug = debug # Acquire the output pin @@ -243,18 +267,18 @@ def __init__(self, outpin, inpin=None, web=None, # If input is specified; acquire it and start input handler thread if self._inpin is not None: self._input = input_pin(inpin[0], inpin[1], self._consumer, debounce, verbose) - inserve = Thread(target=self._serve_input, args=(verbose, )) + inserve = Thread(target=self._serve_input, args=(edge, verbose, )) inserve.daemon = True inserve.start() # If http (host, port) is specified; start http server thread if web is not None: _serve_http(self._output, web[0], web[1], - outstates, toggle, verbose, debug) + states, toggle, portal, refresh, verbose, debug) # Show initial state as required if verbose: - print('Initial output: {}'.format(outstates[self._output.get()])) + print('Initial output: {}'.format(states[self._output.get()])) else: - print(outstates[self._output.get()]) + print(states[self._output.get()]) ''' A simple function to invert the output ''' def _flip(self): @@ -264,23 +288,23 @@ def _flip(self): elif current == 1: self._output.set(0) - def _serve_input(self, verbose=True): - ''' loop (forever) waiting for and serving the input pin events ''' + def _serve_input(self, edge, verbose): + '''service loop serving the input pin events (run in a thread)''' while True: event = self._input.event(timeout=None) - if event == 'rising': + if event == edge: self._flip() if self._verbose: print('{} : button : {}'.format(asctime(), - self._outstates[self._output.get()]), flush=True) + self._states[self._output.get()]), flush=True) def get(self): - return self._outstates[self._output.get()] + return self._states[self._output.get()] def set(self, action): - if action == self._outstates[0]: + if action == self._states[0]: self._output.set(0) - elif action == self._outstates[1]: + elif action == self._states[1]: self._output.set(1) elif action == self._toggle: self._flip() @@ -290,7 +314,7 @@ def set(self, action): print('DEBUG: PinPopper: invalid action: \'{}\''.format(action)) if self._verbose: print('{} : set(\'{}\') : output = {}'.format(asctime(), action, - self._outstates[self._output.get()]), flush=True) + self._states[self._output.get()]), flush=True) # Main if __name__ == '__main__': @@ -300,14 +324,19 @@ def set(self, action): output = ('/dev/gpiochip0', 7) button = ('/dev/gpiochip0', 27) web = ('0.0.0.0', 7090) - name = argv[0] + name = 'Lamp' + states = ('Low', 'High', 'N/A') + toggle = 'Invert' + portal = '
__NOW__
'\ + '

Lamp: __STATE__

'\ + 'switch: __ALT__

' verbose = True - popper = PinPopper(output, button, web, name, lock=False, verbose=verbose) + popper = PinPopper(output, button, web, name, states, toggle, portal=portal, lock=False, verbose=verbose) if verbose: - print('running: {}'.format(name)) - print('available commands: {}'.format(popper.cmdlist)) + print('Available commands: {}'.format(popper.cmdlist + ('exit',))) + print('Starting:: {}'.format(name)) # Now loop forever passing stdin commands to pinpopper.set() with stdin as cmds: @@ -315,11 +344,10 @@ def set(self, action): cmd = cmds.readline().strip() if cmd in popper.cmdlist: popper.set(cmd) - if verbose: - continue - if verbose: - print('{} : invalid action (\'{}\') : output = {}' - .format(asctime(), cmd, popper.get())) - else: - print(popper.get()) + elif cmd in ('exit', 'quit', 'bye'): + break + elif cmd and verbose: + print('{} : invalid action (\'{}\')' + .format(asctime(), cmd)) + print(popper.get()) From 0c67140ce280cd9986bf747e33b0bf6ab77c6235 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 16 Sep 2025 01:04:17 +0200 Subject: [PATCH 065/108] auto pin naming --- pinreader.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/pinreader.py b/pinreader.py index 921630b..95689c3 100644 --- a/pinreader.py +++ b/pinreader.py @@ -89,7 +89,7 @@ def __str__(self): value = 'low' if pin.value == 0 else 'high' ret += '{} = {} ({})\n'.format(line, value, pin.direction) else: - ret += '{} = - (\'{}\')\n'.format(line, pin.consumer) + ret += '{} = - (\'{}\', {})\n'.format(line, pin.consumer, pin.direction) return ret.rstrip('\n') def update(self, items=None): @@ -102,17 +102,18 @@ def update(self, items=None): HELPER ''' -def find_pins(device, regex, verbose=False): +def find_pins(device, regex, prefix='AUTO', verbose=False): if not gpiod.is_gpiochip_device(device): raise ValueError('\'{}\' is not a valid GPIO device'.format(device)) + num_lines = gpiod.Chip(device).get_info().num_lines pinlist = {} with gpiod.Chip(device) as chip: if verbose: print('Searching for pins on \'{}\' that match regex: {}' .format(chip.path, regex)) - for line in range(0, chip.get_info().num_lines): + for line in range(0, num_lines): name = chip.get_line_info(line).name - name = str(line) if name is None else name + name = '{}{}'.format(prefix, line) if name is None else name if search(regex, name): pinlist[name] = (chip.path, line) if verbose: @@ -144,7 +145,7 @@ def find_pins(device, regex, verbose=False): args = parser.parse_args() # Seach for pins using the device and regex determined above - pinlist = find_pins(args.chip, args.regex, args.verbose) + pinlist = find_pins(args.chip, args.regex, verbose=args.verbose) if len(pinlist) == 0: print('No matching pins, exiting') exit() From 7ff782d53d3859d0b58acb9bb053d95dcc004140 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 16 Sep 2025 13:55:57 +0200 Subject: [PATCH 066/108] writer instead of popper --- pinpopper.py | 353 --------------------------------------------------- pinwriter.py | 58 +++++++++ 2 files changed, 58 insertions(+), 353 deletions(-) delete mode 100644 pinpopper.py create mode 100644 pinwriter.py diff --git a/pinpopper.py b/pinpopper.py deleted file mode 100644 index b351dd6..0000000 --- a/pinpopper.py +++ /dev/null @@ -1,353 +0,0 @@ -import gpiod -from os import getpid -from time import sleep, asctime -from datetime import timedelta -from re import search -from threading import Thread -import http.server -from urllib.parse import urlparse - -# Needs gpiod bindings at V2.0 or later, standard debian12/bookworm is v1.6 -# use a virtualenv and 'pip install --upgrade gpiod' as needed. -if int(search('^[0-9]+', gpiod.__version__).group(0)) < 2: - raise ImportError('gpiod bindings library version too low ({}), '\ - 'pinreader requires gpiod v2.x.x or later.' - .format(gpiod.__version__)) - -def _get_device(chip, line): - ''' Common gpiochip+line setup ''' - # Test whether chip is a gpiochip - if not gpiod.is_gpiochip_device(chip): - raise ValueError('Not a gpiochip device: \'{}\''.format(chip)) - # Test we can use gpiochip - try: - device = gpiod.Chip(chip) - except Exception as e: - raise ValueError('Failed to setup gpiochip (\'{}\') device:\n{}' - .format(chip, e)) - return device - -# Input -class input_pin: - ''' - Sets the input pin up to monitor for changes: - - pin will be used (consumed) by the process - Takes: - chip (str) - line (int) - consumer (string) - debounce (int) in ms - verbose (bool) - Provides: - event(timeout): - blocks and waits with timeout for events - returns None if no event within timeout - returns a string with 'rising' or 'falling' otherwise - timeout = a timedelta object or - 0 for immediate return or - None to wait indefinately - ''' - def __init__(self, chip, line, consumer, debounce, verbose=False): - self.chip = chip - self.line = line - bias = gpiod.line.Bias.AS_IS - edge = gpiod.line.Edge.BOTH - clock = gpiod.line.Clock.MONOTONIC - debounce = timedelta(milliseconds=debounce) - self.verbose = verbose - # Get and test the device - device = _get_device(self.chip, self.line) - if device.get_line_info(line).used: - raise ValueError('Cannot acquire gpiochip \'{}\' line {}, '\ - 'currently used by: \'{}\'' - .format(chip, line, device.get_line_info(line).consumer)) - # Create a request object for the input - self.request = device.request_lines( - consumer=consumer, - config={line: gpiod.LineSettings( - direction=gpiod.line.Direction.INPUT, - bias=bias, - edge_detection=edge, - event_clock=clock, - debounce_period=debounce)}) - if self.verbose: - print('Configured: \'{}\':{} as input (locked)' - .format(self.chip, self.line)) - - def event(self, timeout=0): - if self.request.wait_edge_events(timeout=timeout): - events = self.request.read_edge_events() - for event in events: - if event.line_offset == self.line: - if event.event_type == gpiod.edge_event.EdgeEvent.Type.RISING_EDGE: - return 'rising' - elif event.event_type == gpiod.edge_event.EdgeEvent.Type.FALLING_EDGE: - return 'falling' - -# Output -class output_pin: - ''' - Sets the output pin up: - Takes: - chip (str) - line (int) - consumer (string) - lock (bool) - verbose (bool) - Provides: - set(value): sets the output value (int: 0 or 1 for low/high) - returns True if successful, False if output is unavailable - get(): gets the output value (int: 0, 1 or 2=unavailable) - ''' - def __init__(self, chip, line, consumer, lock=False, verbose=False): - self.chip = chip - self.line = line - self.consumer = consumer - self.verbose = verbose - self.states = (gpiod.line.Value.INACTIVE, gpiod.line.Value.ACTIVE) - # Get and test the device - self.device = _get_device(self.chip, self.line) - # Create a request object as needed - if lock: - if self.device.get_line_info(line).used: - raise ValueError('Cannot acquire gpiochip \'{}\' line {}, '\ - 'currently used by: \'{}\'' - .format(chip, line, self.device.get_line_info(line).consumer)) - self.request = self.device.request_lines(consumer=self.consumer, - config={self.line: gpiod.LineSettings( - direction=gpiod.line.Direction.OUTPUT)}) - else: - self.request = None - if self.verbose: - print('Configured: \'{}\':{} as output ({})' - .format(self.chip, self.line, 'locked' if lock else 'unlocked')) - - def get(self): - if self.request: - value = self.states.index(self.request.get_value(self.line)) - else: - try: - with self.device.request_lines( - consumer=self.consumer, - config={self.line: None}, - ) as line: - value = self.states.index(line.get_value(self.line)) - except OSError: - value = 2 # 2 = unavailable - return value - - def set(self, value): - if self.request: - self.request.set_value(self.line, self.states[value]) - else: - try: - with self.device.request_lines( - consumer=self.consumer, - config={self.line: gpiod.LineSettings( - direction=gpiod.line.Direction.OUTPUT)}, - ) as line: - line.set_value(self.line, self.states[value]) - except OSError: - return False - return True - -def _serve_http(out, host, port, states, toggle, portal, refresh, verbose, debug): - '''Spawns a http.server.HTTPServer in a separate thread on the given port''' - handler = _BaseRequestHandler - httpd = http.server.ThreadingHTTPServer((host, port), handler, False) - httpd.timeout = 0.5 - httpd.allow_reuse_address = True - # Storing attributes in the http class itself is cheeky, but simple and effective. - http.out = out - http.states = states - http.toggle = toggle - http.portal = portal - http.refresh = refresh - http.verbose = verbose - http.debug = debug - http.lastknown = http.states[http.out.get()] - # Start the server - httpd.server_bind() - http.address = f"http://{httpd.server_name}:{httpd.server_port}" - if http.verbose: - print(f"HTTP server: {http.address}",flush=True) - # Serve requests using threads - httpd.server_activate() - def serve_forever(httpd): - with httpd: - httpd.serve_forever() - thread = Thread(target=serve_forever, args=(httpd, )) - thread.daemon = True - thread.start() - -''' Base request handler class for the http server ''' -class _BaseRequestHandler(http.server.BaseHTTPRequestHandler): - - ''' suppress the http server log output ''' - def log_message(self, format, *args): - if http.debug: - print(f'DEBUG: HTTP request:: {self.client_address[0]} : {args[0]} '\ - f'({args[1]})',flush=True) - return - - ''' handle GET requests : this is where logic lives ''' - def do_GET(self): - def common_headers(): - self.send_response(200) - self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate') - self.send_header('Pragma', 'no-cache') - self.send_header('Expires', '0') - - def redirect(): - common_headers() - self.send_header('refresh', '0; url=/') - self.end_headers() - - def getval(): - # return current state and inverse - cur = alt = http.out.get() - if cur == 0: - alt = 1 - elif cur == 1: - alt = 0 - return cur, alt - - # parse request and process - if urlparse(self.path).path == '/{}'.format(http.states[0]): - http.out.set(0) - redirect() - return - elif urlparse(self.path).path == '/{}'.format(http.states[1]): - http.out.set(1) - redirect() - return - elif urlparse(self.path).path == '/{}'.format(http.toggle): - http.out.set(getval()[1]) - redirect() - return - elif urlparse(self.path).path != '/': - self.send_error(404, 'No Match', 'Nothing matches the URL') - return - state, alt = getval() - state = http.states[state] - alt = http.states[alt] - common_headers() - self.send_header('refresh', '{}; url=/'.format(http.refresh)) - self.end_headers() - self.wfile.write(bytes(http.portal.replace('__STATE__', state)\ - .replace('__ALT__', alt)\ - .replace('__NOW__', asctime()), 'utf-8')) - # only log if value changed - if state != http.lastknown and http.verbose: - print('{} : http ({}) : {}'.format(asctime(), self.client_address[0], state)) - http.lastknown = state - -''' The pinpopper class itself ''' -class PinPopper: - def __init__(self, outpin, inpin=None, web=None, - name='pinpopper', - states=('off', 'on', 'unavailable'), - toggle='toggle', - debounce=66, - edge='rising', - portal='__STATE__', - refresh=60, - lock=True, - verbose=True, debug=False): - self._outpin = outpin - self._inpin = inpin - self._consumer = '{}-{}'.format(name, getpid()) - self._states = states - self._toggle = toggle - self.cmdlist = (states[0], states[1], toggle) - self._verbose = verbose - self._debug = debug - # Acquire the output pin - self._output = output_pin(outpin[0], outpin[1], self._consumer, lock, verbose) - # If input is specified; acquire it and start input handler thread - if self._inpin is not None: - self._input = input_pin(inpin[0], inpin[1], self._consumer, debounce, verbose) - inserve = Thread(target=self._serve_input, args=(edge, verbose, )) - inserve.daemon = True - inserve.start() - # If http (host, port) is specified; start http server thread - if web is not None: - _serve_http(self._output, web[0], web[1], - states, toggle, portal, refresh, verbose, debug) - # Show initial state as required - if verbose: - print('Initial output: {}'.format(states[self._output.get()])) - else: - print(states[self._output.get()]) - - ''' A simple function to invert the output ''' - def _flip(self): - current = self._output.get() - if current == 0: - self._output.set(1) - elif current == 1: - self._output.set(0) - - def _serve_input(self, edge, verbose): - '''service loop serving the input pin events (run in a thread)''' - while True: - event = self._input.event(timeout=None) - if event == edge: - self._flip() - if self._verbose: - print('{} : button : {}'.format(asctime(), - self._states[self._output.get()]), flush=True) - - def get(self): - return self._states[self._output.get()] - - def set(self, action): - if action == self._states[0]: - self._output.set(0) - elif action == self._states[1]: - self._output.set(1) - elif action == self._toggle: - self._flip() - else: - # ignore invalid actions by default, unless debug is on. - if self._debug: - print('DEBUG: PinPopper: invalid action: \'{}\''.format(action)) - if self._verbose: - print('{} : set(\'{}\') : output = {}'.format(asctime(), action, - self._states[self._output.get()]), flush=True) - -# Main -if __name__ == '__main__': - # demo - from sys import argv, stdin - - output = ('/dev/gpiochip0', 7) - button = ('/dev/gpiochip0', 27) - web = ('0.0.0.0', 7090) - name = 'Lamp' - states = ('Low', 'High', 'N/A') - toggle = 'Invert' - portal = '
__NOW__
'\ - '

Lamp: __STATE__

'\ - 'switch: __ALT__

' - verbose = True - - popper = PinPopper(output, button, web, name, states, toggle, portal=portal, lock=False, verbose=verbose) - - if verbose: - print('Available commands: {}'.format(popper.cmdlist + ('exit',))) - print('Starting:: {}'.format(name)) - - # Now loop forever passing stdin commands to pinpopper.set() - with stdin as cmds: - while True: - cmd = cmds.readline().strip() - if cmd in popper.cmdlist: - popper.set(cmd) - elif cmd in ('exit', 'quit', 'bye'): - break - elif cmd and verbose: - print('{} : invalid action (\'{}\')' - .format(asctime(), cmd)) - print(popper.get()) - diff --git a/pinwriter.py b/pinwriter.py new file mode 100644 index 0000000..16a097c --- /dev/null +++ b/pinwriter.py @@ -0,0 +1,58 @@ +import gpiod +import logging +from os import getpid +from re import search + +# Needs gpiod bindings at V2.0 or later, standard debian12/bookworm is v1.6 +# use a virtualenv and 'pip install --upgrade gpiod' as needed. +if int(search('^[0-9]+', gpiod.__version__).group(0)) < 2: + raise ImportError('gpiod bindings library version too low ({}), '\ + 'pinreader requires gpiod v2.x.x or later.' + .format(gpiod.__version__)) +''' +PinReader class (dict) +''' +OUTPUT = gpiod.line.Direction.OUTPUT +ACTIVE = gpiod.line.Value.ACTIVE +INACTIVE = gpiod.line.Value.INACTIVE + +def checkPin(chip, line): + # chip: (str) gpiod chip (path or identifier) + # line: (int) Line offset on chip + if not gpiod.is_gpiochip_device(chip): + raise ValueError('\'{}\' is not a valid GPIO device'.format(chip)) + gpiochip = gpiod.Chip(chip) + if line < 0 or line >= gpiochip.get_info().num_lines: + raise ValueError('Requested line ({}) outside range for \'{}\' ({} lines)' + .format(line, chip, gpiochip.get_info().num_lines)) + info = gpiochip.get_line_info(line) + if info.used == True: + logging.warning('Could not get pin {}:{} for output, already used by: \'{}\'' + .format(chip, line, info.consumer)) + return False + return True + +def setPin(chip, line, value): + # chip: (str) gpiod chip (path or identifier) + # line: (int) Line offset on chip + # value: (int) 1 = Active, 0 = Inactive + if value == 0: + value = INACTIVE + elif value == 1: + value = ACTIVE + else: + raise ValueError('Invalid output value: {} ({})' .format(value, type(value))) + try: + with gpiod.Chip(chip).request_lines( + consumer='pinwriter-{}'.format(getpid()), + config={line: gpiod.LineSettings( + direction = OUTPUT, + output_value = value)}, + ) as request: + newval = request.get_values()[0] + except OSError as e: + # log a warning and return 'False' if the write fails + logging.warning('Could not set output value on pin {}:{} : {}' + .format(chip, line, e)) + return False + return True if newval == value else False From 0562de87b44f2196ded1abf58940e6624f21e99c Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 16 Sep 2025 14:11:19 +0200 Subject: [PATCH 067/108] config tweaks --- defaults.ini | 18 ++++++++++++++++-- load_config.py | 5 +++++ saver.py | 6 +++--- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/defaults.ini b/defaults.ini index 5626e7a..0efa612 100644 --- a/defaults.ini +++ b/defaults.ini @@ -110,6 +110,20 @@ half_height = pin,net # [pins] +# Pins to control, and their default value +# - pins listed here must be defined in [pins] list above +# - if the pin is already an output it's value will not be changed +# - if the pin is an input it will be set to an output and the initial +# value of the pin set to the value listed +# - however, if the pin is in use (consumed) by another process +# it cannot be controlled and no control options will be shown +# eg: +# [outpins] +# Lamp = 0 +# Ventilator = 1 +# +[outpins] + # An optional list of targets to be used for network connectivity (ping) tests # Targets are listed one per line: # = ip address @@ -169,11 +183,11 @@ rotate= False contrast = 127 # Screen saver / burn-in reducer -# saver_mode: Possible values are 'off' or 'blank' +# saver_mode: Possible values are 'none' or 'blank' # saver_on: Start time for screensaver (hour, 24hr clock) # saver_off: End time [saver] -mode = off +mode = none on = 20 off = 8 diff --git a/load_config.py b/load_config.py index 90ecb40..6dbf850 100644 --- a/load_config.py +++ b/load_config.py @@ -130,6 +130,11 @@ def hexint(instring): line = pins.get(pin).split(',') self.pinlist[pin] = (line[0], int(line[1])) + self.outlist = {} + outpins = config["outpins"] + for pin in outpins: + self.outlist[pin] = outpins.getint(pin) + self.netlist = {} ping = config["ping"] for host in ping: diff --git a/saver.py b/saver.py index c972b6c..4bcd29e 100644 --- a/saver.py +++ b/saver.py @@ -9,13 +9,13 @@ class Saver: Turns the display on/off between specified times modes: - 'off': Screensaver disabled + 'none': Screensaver disabled 'blank': Turn screen off parameters: disp: display driver object settings: (tuple) consisting of: - mode: (str) One of 'off', 'blank' + mode: (str) One of 'none', 'blank' start: (int) Start time, hour, 0-23 end: (int) End time, hour, 0-23 ''' @@ -26,7 +26,7 @@ def __init__(self, disp, settings): self.disp = disp (self.mode, start, end) = settings - if self.mode != 'off': + if self.mode != 'none': logging.info(f'Saver will {self.mode} display between: '\ f'{start:02d}:00 and {end:02d}:00') print(f'Saver will {self.mode} display between: '\ From 053d03957b95b144c090c7bb934b90ed5336ed2e Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 16 Sep 2025 14:15:21 +0200 Subject: [PATCH 068/108] gpio output wip --- SBCEye.py | 4 +-- gpioreader.py => gpiohandler.py | 37 ++++++++++++++++++--- pinwriter.py | 58 --------------------------------- 3 files changed, 35 insertions(+), 64 deletions(-) rename gpioreader.py => gpiohandler.py (69%) delete mode 100644 pinwriter.py diff --git a/SBCEye.py b/SBCEye.py index 2eff76e..4d8584f 100644 --- a/SBCEye.py +++ b/SBCEye.py @@ -53,7 +53,7 @@ from robin import Robin from httpserver import serve_http from netreader import Netreader -from gpioreader import GPIOReader +from gpioreader import GPIOHandler from i2c_bus import i2c_setup from bme_sensor import bme_setup from oled_display import oled_setup @@ -261,7 +261,7 @@ def handle_exit(): update_sensors() # GPIO pin monitoring - gpio = GPIOReader(settings.pinlist, data) + gpio = GPIOHandler(settings.pinlist, settings.outlist, data) # Network (ping) monitoring net = Netreader((settings.netlist, settings.net_timeout), data) diff --git a/gpioreader.py b/gpiohandler.py similarity index 69% rename from gpioreader.py rename to gpiohandler.py index ecf4e2c..e959f78 100644 --- a/gpioreader.py +++ b/gpiohandler.py @@ -13,7 +13,7 @@ # remember why we failed, so it can be reported in log later. readerfail = e -class GPIOReader: +class GPIOHandler: '''Read and update GPIO pin status Reads the currrent (boolean) status of a set of gipo pins defined in a dictionary @@ -22,14 +22,17 @@ class GPIOReader: parameters: pinlist: dictionary of pin definitions {label: (chip,index)} from config, this will be passed directly to the PinReader init. + outlist: dictionary of output enabled pins from the above list, and initial value data: the main data{} dictionary, a key/value pair; 'pin-=value' - will be added to it and the vaue updated with pin state changes. + will be added to it and the value updated with pin state changes. provides: - update_pins(): processes and updates the pins + update(): processes and updates the pin data, logs state changes + set_pin(pin,value): attempts to set the output value of a pin + - returns success/fail, used for web control ''' - def __init__(self, pinlist, data): + def __init__(self, pinlist, outlist, data): '''Setup and do initial reading''' self.available = False self.pinlist = pinlist @@ -59,6 +62,7 @@ def __init__(self, pinlist, data): self.consumers[pin] = self.pins[pin].consumer print('Pin \'{}\': {}'.format(pin, repr(self.pins[pin])[10:-1])) logging.info('Pin \'{}\': {}'.format(pin, repr(self.pins[pin])[12:-1])) + # Now set Output pins up print('GPIO monitoring configured and logging enabled') logging.info('GPIO monitoring configured and logging enabled') self.available = True @@ -90,3 +94,28 @@ def update(self): logging.info('Pin \'{}\' changed{}'.format(pin, log.rstrip(','))) print('Pin \'{}\' changed{}'.format(pin, log.rstrip(','))) + def setPin(chip, line, value): + # chip: (str) gpiod chip (path or identifier) + # line: (int) Line offset on chip + # value: (int) 1 = Active, 0 = Inactive + if value == 0: + value = INACTIVE + elif value == 1: + value = ACTIVE + else: + raise ValueError('Invalid output value: {} ({})' .format(value, type(value))) + try: + with gpiod.Chip(chip).request_lines( + consumer='pinwriter-{}'.format(getpid()), + config={line: gpiod.LineSettings( + direction = OUTPUT, + output_value = value)}, + ) as request: + newval = request.get_values()[0] + except OSError as e: + # log a warning and return 'False' if the write fails + logging.warning('Could not set output value on pin {}:{} : {}' + .format(chip, line, e)) + return False + return True if newval == value else False + diff --git a/pinwriter.py b/pinwriter.py deleted file mode 100644 index 16a097c..0000000 --- a/pinwriter.py +++ /dev/null @@ -1,58 +0,0 @@ -import gpiod -import logging -from os import getpid -from re import search - -# Needs gpiod bindings at V2.0 or later, standard debian12/bookworm is v1.6 -# use a virtualenv and 'pip install --upgrade gpiod' as needed. -if int(search('^[0-9]+', gpiod.__version__).group(0)) < 2: - raise ImportError('gpiod bindings library version too low ({}), '\ - 'pinreader requires gpiod v2.x.x or later.' - .format(gpiod.__version__)) -''' -PinReader class (dict) -''' -OUTPUT = gpiod.line.Direction.OUTPUT -ACTIVE = gpiod.line.Value.ACTIVE -INACTIVE = gpiod.line.Value.INACTIVE - -def checkPin(chip, line): - # chip: (str) gpiod chip (path or identifier) - # line: (int) Line offset on chip - if not gpiod.is_gpiochip_device(chip): - raise ValueError('\'{}\' is not a valid GPIO device'.format(chip)) - gpiochip = gpiod.Chip(chip) - if line < 0 or line >= gpiochip.get_info().num_lines: - raise ValueError('Requested line ({}) outside range for \'{}\' ({} lines)' - .format(line, chip, gpiochip.get_info().num_lines)) - info = gpiochip.get_line_info(line) - if info.used == True: - logging.warning('Could not get pin {}:{} for output, already used by: \'{}\'' - .format(chip, line, info.consumer)) - return False - return True - -def setPin(chip, line, value): - # chip: (str) gpiod chip (path or identifier) - # line: (int) Line offset on chip - # value: (int) 1 = Active, 0 = Inactive - if value == 0: - value = INACTIVE - elif value == 1: - value = ACTIVE - else: - raise ValueError('Invalid output value: {} ({})' .format(value, type(value))) - try: - with gpiod.Chip(chip).request_lines( - consumer='pinwriter-{}'.format(getpid()), - config={line: gpiod.LineSettings( - direction = OUTPUT, - output_value = value)}, - ) as request: - newval = request.get_values()[0] - except OSError as e: - # log a warning and return 'False' if the write fails - logging.warning('Could not set output value on pin {}:{} : {}' - .format(chip, line, e)) - return False - return True if newval == value else False From 25845642f264be1ed4adb5006e9c4a31de2f2a3c Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 16 Sep 2025 14:18:35 +0200 Subject: [PATCH 069/108] fix import --- SBCEye.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SBCEye.py b/SBCEye.py index 4d8584f..d6b7f9a 100644 --- a/SBCEye.py +++ b/SBCEye.py @@ -53,7 +53,7 @@ from robin import Robin from httpserver import serve_http from netreader import Netreader -from gpioreader import GPIOHandler +from gpiohandler import GPIOHandler from i2c_bus import i2c_setup from bme_sensor import bme_setup from oled_display import oled_setup From 3ff259b6244308b08ed54bacbce5148f4973e1af Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 18 Sep 2025 15:39:00 +0200 Subject: [PATCH 070/108] integrate pin setting into gpiohandler --- gpiohandler.py | 111 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 72 insertions(+), 39 deletions(-) diff --git a/gpiohandler.py b/gpiohandler.py index e959f78..60258a0 100644 --- a/gpiohandler.py +++ b/gpiohandler.py @@ -4,8 +4,10 @@ GPIOReader: A class to update and log the pin statuses ''' -import os +import gpiod import logging +from os import getpid +from re import search try: from pinreader import PinReader readerfail = None @@ -13,6 +15,21 @@ # remember why we failed, so it can be reported in log later. readerfail = e +# Needs gpiod bindings at V2.0 or later, standard debian12/bookworm is v1.6 +# use a virtualenv and 'pip install --upgrade gpiod' as needed. +if int(search('^[0-9]+', gpiod.__version__).group(0)) < 2: + readerfail = 'gpiod bindings library version too low ({}), '\ + 'pinreader requires gpiod v2.x.x or later.'\ + .format(gpiod.__version__) + +''' +PinReader class (dict) +''' +OUTPUT = gpiod.line.Direction.OUTPUT +ACTIVE = gpiod.line.Value.ACTIVE +INACTIVE = gpiod.line.Value.INACTIVE + + class GPIOHandler: '''Read and update GPIO pin status @@ -71,51 +88,67 @@ def _value_to_data(self, value): return 'U' if value is None else int(value) def update(self): - '''Check if any pins have changed state, and log if so - updates the main data{} dictionary with new state - no parameters, no return''' self.pins.update() for pin in self.pins: - log = '' - direction = self.pins[pin].direction - if direction != self.directions[pin]: - self.directions[pin] = direction - log += ' direction to: \'{}\','.format(direction) - consumer = self.pins[pin].consumer - if consumer != self.consumers[pin]: - self.consumers[pin] = consumer - consumer = None if consumer is None else '\'{}\''.format(consumer) - log += ' consumer to: {},'.format(consumer) - value = self.pins[pin].value - if self._value_to_data(value) != self.data[f'pin-{pin}']: - self.data[f'pin-{pin}'] = self._value_to_data(value) - log += ' value to: {},'.format(value) - if len(log) != 0: - logging.info('Pin \'{}\' changed{}'.format(pin, log.rstrip(','))) - print('Pin \'{}\' changed{}'.format(pin, log.rstrip(','))) + self._update_pin(pin) + + def _update_pin(self, pin): + '''Check if pin has changed state, log changes and + update the data{} dictionary with new state''' + log = '' + direction = self.pins[pin].direction + if direction != self.directions[pin]: + self.directions[pin] = direction + log += ' direction to: \'{}\','.format(direction) + consumer = self.pins[pin].consumer + if consumer != self.consumers[pin]: + self.consumers[pin] = consumer + consumer = None if consumer is None else '\'{}\''.format(consumer) + log += ' consumer to: {},'.format(consumer) + value = self.pins[pin].value + if self._value_to_data(value) != self.data[f'pin-{pin}']: + self.data[f'pin-{pin}'] = self._value_to_data(value) + log += ' value to: {},'.format(value) + if len(log) != 0: + logging.info('Pin \'{}\' changed{}'.format(pin, log.rstrip(','))) + print('Pin \'{}\' changed{}'.format(pin, log.rstrip(','))) - def setPin(chip, line, value): - # chip: (str) gpiod chip (path or identifier) - # line: (int) Line offset on chip - # value: (int) 1 = Active, 0 = Inactive + def setPin(self, label, value): + '''Sets the pin to output mode and sets it's value. + Parameters: + chip: (str) gpiod chip (path or identifier) + line: (int) Line offset on chip + value: (int) 1 = Active, 0 = Inactive + Returns: + False if the output cannot be set.''' if value == 0: value = INACTIVE elif value == 1: value = ACTIVE else: - raise ValueError('Invalid output value: {} ({})' .format(value, type(value))) - try: - with gpiod.Chip(chip).request_lines( - consumer='pinwriter-{}'.format(getpid()), - config={line: gpiod.LineSettings( - direction = OUTPUT, - output_value = value)}, - ) as request: - newval = request.get_values()[0] - except OSError as e: - # log a warning and return 'False' if the write fails - logging.warning('Could not set output value on pin {}:{} : {}' - .format(chip, line, e)) - return False + raise ValueError('Invalid output value: {} ({})' + .format(value, type(value))) + self.pins.update(label) + chip = self.pins[label].chip + line = self.pins[label].line + newval = None + if self.pins[label].consumer is not None: + logging.warning('Failed to set ouput on pin \'{}\', currently used by: \'{}\'' + .format(label, self.pins[label].consumer)) + else: + try: + with gpiod.Chip(chip).request_lines( + consumer='SBCEye-{}'.format(getpid()), + config={line: gpiod.LineSettings( + direction = OUTPUT, + output_value = value)}, + ) as request: + newval = request.get_values()[0] + except OSError as e: + # log a warning and return 'False' if the write fails + logging.warning('Could not set output value on pin {}:{} : {}' + .format(chip, line, e)) + self.pins.update(label) + self._update_pin(label) return True if newval == value else False From 6528c8c6d7389fe40c9d603a7b19a8f3b0135b2a Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 18 Sep 2025 16:29:41 +0200 Subject: [PATCH 071/108] pass full gpio to http handler --- SBCEye.py | 2 +- httpserver.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/SBCEye.py b/SBCEye.py index d6b7f9a..5ff9d22 100644 --- a/SBCEye.py +++ b/SBCEye.py @@ -270,7 +270,7 @@ def handle_exit(): rrd = Robin(settings, data) # Start the web server, it will fork into a seperate thread and run continually - serve_http(settings, rrd, gpio.pins, data) + serve_http(settings, rrd, gpio, data) # Exit handlers (needed for rrd cache write on shutdown) signal(SIGTERM, handle_signal) diff --git a/httpserver.py b/httpserver.py index ec2a2c0..bb98b77 100644 --- a/httpserver.py +++ b/httpserver.py @@ -17,7 +17,7 @@ # Logging import logging -def serve_http(settings, rrd, pins, data): +def serve_http(settings, rrd, gpio, data): '''Spawns a http.server.HTTPServer in a separate thread on the given port''' handler = _BaseRequestHandler httpd = http.server.ThreadingHTTPServer((settings.web_host, settings.web_port), handler, False) @@ -30,7 +30,7 @@ def serve_http(settings, rrd, pins, data): # there is probably a better way to do this, eg using a meta-class and inheritance http.settings = settings http.rrd = rrd - http.pins = pins + http.pins = gpio.pins http.data = data http.icon_file = 'favicon.ico' if not os.path.exists(http.icon_file): From 63d70736c7781d6ebeeb0b6b5efd862423af7fd2 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 18 Sep 2025 18:45:54 +0200 Subject: [PATCH 072/108] tweak how pins show usage --- httpserver.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/httpserver.py b/httpserver.py index bb98b77..bc8fbc6 100644 --- a/httpserver.py +++ b/httpserver.py @@ -249,10 +249,11 @@ def _give_pins(self): http.pins[name].direction, http.pins[name].consumer) ret += f'
' direction = ' ({})'.format(http.pins[name].direction) if http.settings.web_pin_info else '' + #consumer = '{}'.format(http.pins[name].consumer) if http.settings.web_pin_info else 'n/a' consumer = ' [{}]'.format(http.pins[name].consumer) if http.settings.web_pin_info else '' if http.data[item] == 'U': ret += f''\ - f'\n' + f'\n' else: em = 'style=" font-weight: bold;"' if http.data[item] == 1 else '' ret += f''\ From 4d16a2330204fbc38441115ef70c2ab6e4b28e44 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 18 Sep 2025 18:47:39 +0200 Subject: [PATCH 073/108] simple list for outpins --- SBCEye.py | 2 +- defaults.ini | 12 +++--------- load_config.py | 4 +--- 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/SBCEye.py b/SBCEye.py index 5ff9d22..43591e2 100644 --- a/SBCEye.py +++ b/SBCEye.py @@ -261,7 +261,7 @@ def handle_exit(): update_sensors() # GPIO pin monitoring - gpio = GPIOHandler(settings.pinlist, settings.outlist, data) + gpio = GPIOHandler(settings.pinlist, data) # Network (ping) monitoring net = Netreader((settings.netlist, settings.net_timeout), data) diff --git a/defaults.ini b/defaults.ini index 0efa612..792d166 100644 --- a/defaults.ini +++ b/defaults.ini @@ -110,17 +110,11 @@ half_height = pin,net # [pins] -# Pins to control, and their default value -# - pins listed here must be defined in [pins] list above -# - if the pin is already an output it's value will not be changed -# - if the pin is an input it will be set to an output and the initial -# value of the pin set to the value listed -# - however, if the pin is in use (consumed) by another process -# it cannot be controlled and no control options will be shown +# Enable output control for pins from the above list +# web = comma seperated list of pins to control via the web # eg: # [outpins] -# Lamp = 0 -# Ventilator = 1 +# web = Lamp, Ventilator # [outpins] diff --git a/load_config.py b/load_config.py index 6dbf850..0a74831 100644 --- a/load_config.py +++ b/load_config.py @@ -130,10 +130,8 @@ def hexint(instring): line = pins.get(pin).split(',') self.pinlist[pin] = (line[0], int(line[1])) - self.outlist = {} outpins = config["outpins"] - for pin in outpins: - self.outlist[pin] = outpins.getint(pin) + self.outpins = outpins.get('web').split(',') self.netlist = {} ping = config["ping"] From 9c20aacd8695c1201267b07b6dd111cdc34be323 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 18 Sep 2025 18:50:46 +0200 Subject: [PATCH 074/108] remove outlist and use pin instead of label as argument --- gpiohandler.py | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/gpiohandler.py b/gpiohandler.py index 60258a0..7d6df0e 100644 --- a/gpiohandler.py +++ b/gpiohandler.py @@ -39,7 +39,6 @@ class GPIOHandler: parameters: pinlist: dictionary of pin definitions {label: (chip,index)} from config, this will be passed directly to the PinReader init. - outlist: dictionary of output enabled pins from the above list, and initial value data: the main data{} dictionary, a key/value pair; 'pin-=value' will be added to it and the value updated with pin state changes. @@ -49,7 +48,7 @@ class GPIOHandler: - returns success/fail, used for web control ''' - def __init__(self, pinlist, outlist, data): + def __init__(self, pinlist, data): '''Setup and do initial reading''' self.available = False self.pinlist = pinlist @@ -78,20 +77,16 @@ def __init__(self, pinlist, outlist, data): self.directions[pin] = self.pins[pin].direction self.consumers[pin] = self.pins[pin].consumer print('Pin \'{}\': {}'.format(pin, repr(self.pins[pin])[10:-1])) - logging.info('Pin \'{}\': {}'.format(pin, repr(self.pins[pin])[12:-1])) + logging.info('Pin \'{}\': {}'.format(pin, repr(self.pins[pin])[10:-1])) # Now set Output pins up print('GPIO monitoring configured and logging enabled') logging.info('GPIO monitoring configured and logging enabled') self.available = True def _value_to_data(self, value): + '''Records failed reads as 'U' (unavailable) for data{} entries''' return 'U' if value is None else int(value) - def update(self): - self.pins.update() - for pin in self.pins: - self._update_pin(pin) - def _update_pin(self, pin): '''Check if pin has changed state, log changes and update the data{} dictionary with new state''' @@ -113,11 +108,16 @@ def _update_pin(self, pin): logging.info('Pin \'{}\' changed{}'.format(pin, log.rstrip(','))) print('Pin \'{}\' changed{}'.format(pin, log.rstrip(','))) - def setPin(self, label, value): + def update(self): + '''Update data{} dictionary for all pins''' + self.pins.update() + for pin in self.pins: + self._update_pin(pin) + + def setPin(self, pin, value): '''Sets the pin to output mode and sets it's value. Parameters: - chip: (str) gpiod chip (path or identifier) - line: (int) Line offset on chip + pin: the pin name from config value: (int) 1 = Active, 0 = Inactive Returns: False if the output cannot be set.''' @@ -128,13 +128,13 @@ def setPin(self, label, value): else: raise ValueError('Invalid output value: {} ({})' .format(value, type(value))) - self.pins.update(label) - chip = self.pins[label].chip - line = self.pins[label].line + self.pins.update(pin) + chip = self.pins[pin].chip + line = self.pins[pin].line newval = None - if self.pins[label].consumer is not None: + if self.pins[pin].consumer is not None: logging.warning('Failed to set ouput on pin \'{}\', currently used by: \'{}\'' - .format(label, self.pins[label].consumer)) + .format(pin, self.pins[pin].consumer)) else: try: with gpiod.Chip(chip).request_lines( @@ -148,7 +148,7 @@ def setPin(self, label, value): # log a warning and return 'False' if the write fails logging.warning('Could not set output value on pin {}:{} : {}' .format(chip, line, e)) - self.pins.update(label) - self._update_pin(label) + self.pins.update(pin) + self._update_pin(pin) return True if newval == value else False From abeeaa6edf0b10f0ef46197cf3a97735179c2c48 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 19 Sep 2025 12:16:40 +0200 Subject: [PATCH 075/108] remove pin_info option and strip output pin names --- defaults.ini | 2 -- load_config.py | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/defaults.ini b/defaults.ini index 792d166..e432177 100644 --- a/defaults.ini +++ b/defaults.ini @@ -28,7 +28,6 @@ pin_state_names = Off,On # host: Ip address to bind server to, blank = bind to all addresses # port: Port number for web server # sensor_name: Sensor name used in web panel (eg; location) -# pin_info: Show pin direction/consumer alongside pin state # allow_dump: Allows RRDB database dumps via we:, be careful using this # since it can impact performance while running # allow_backup: Allows RRDB database backup triggering via web, be careful using this @@ -38,7 +37,6 @@ pin_state_names = Off,On host = port = 7080 sensor_name = Room -pin_info = True allow_dump = True allow_backup = True diff --git a/load_config.py b/load_config.py index 0a74831..75fc1f9 100644 --- a/load_config.py +++ b/load_config.py @@ -103,7 +103,6 @@ def hexint(instring): self.web_host = web.get("host") self.web_port = web.getint("port") self.web_sensor_name = web.get("sensor_name") - self.web_pin_info = web.getboolean("pin_info") self.web_show_cam = web.getboolean("show_cam") self.web_allow_dump = web.getboolean("allow_dump") self.web_allow_backup = web.getboolean("allow_backup") @@ -131,7 +130,8 @@ def hexint(instring): self.pinlist[pin] = (line[0], int(line[1])) outpins = config["outpins"] - self.outpins = outpins.get('web').split(',') + self.outpins = map(str.strip, outpins.get('web').split(',')) + self.outpins = self.outpins & self.pinlist.keys() self.netlist = {} ping = config["ping"] From 6d3a51f0fe22f04cc1a6bdc7258b089241b39add Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 19 Sep 2025 12:17:36 +0200 Subject: [PATCH 076/108] tweak pin list again, add pin pages, wip --- httpserver.py | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/httpserver.py b/httpserver.py index bc8fbc6..701eed6 100644 --- a/httpserver.py +++ b/httpserver.py @@ -248,16 +248,24 @@ def _give_pins(self): name, http.pins[name].chip, http.pins[name].line, http.pins[name].direction, http.pins[name].consumer) ret += f'' - direction = ' ({})'.format(http.pins[name].direction) if http.settings.web_pin_info else '' - #consumer = '{}'.format(http.pins[name].consumer) if http.settings.web_pin_info else 'n/a' - consumer = ' [{}]'.format(http.pins[name].consumer) if http.settings.web_pin_info else '' + direction = '({})'.format(http.pins[name].direction[:-3]) + consumer = '{}'.format(http.pins[name].consumer) if http.data[item] == 'U': - ret += f''\ - f'\n' + ret += ''.format(consumer) + direction = '' else: - em = 'style=" font-weight: bold;"' if http.data[item] == 1 else '' - ret += f''\ - f'\n' + em = 'font-weight: bold' if http.data[item] == 1 else '' + if name in http.settings.outpins: + link = 'href="/{}" title="Pin Control" '\ + 'style="text-decoration: underline; {}"'.format(name, em) + ret += ''\ + .format(link, http.settings.pin_state_names[http.data[item]]) + else: + ret += ''\ + .format(em, http.settings.pin_state_names[http.data[item]]) + ret += '\n'.format(direction) return ret def _give_graphlinks(self, skip=""): @@ -452,6 +460,17 @@ def do_GET(self): response += self._give_timestamp() response += self._give_foot(refresh=60, scroll=True) self._write_dedented(response) + elif urlparse(self.path).path[1:] in http.settings.outpins: + pin = urlparse(self.path).path[1:] + parsed_action = urlparse(self.path).query + print(pin, parsed_action, type(parsed_action)) + self._set_headers() + response = self._give_head() + response += f'

{http.settings.name} {pin}

\n' + response += f'
{parsed_action}
\n' + response += self._give_timestamp() + response += self._give_foot(refresh=60) + self._write_dedented(response) elif urlparse(self.path).path == '/': # Main Page exclude = parse_qs(urlparse(self.path).query).get('exclude', '') From 64f168d8326e7beb055ad6695c8c9bb61f53cc4f Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 19 Sep 2025 13:42:01 +0200 Subject: [PATCH 077/108] pincontrol wip --- httpserver.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/httpserver.py b/httpserver.py index 701eed6..7adbb39 100644 --- a/httpserver.py +++ b/httpserver.py @@ -460,17 +460,36 @@ def do_GET(self): response += self._give_timestamp() response += self._give_foot(refresh=60, scroll=True) self._write_dedented(response) + elif urlparse(self.path).path[1:] in http.settings.outpins: pin = urlparse(self.path).path[1:] parsed_action = urlparse(self.path).query - print(pin, parsed_action, type(parsed_action)) + action = parsed_action.casefold() + print(pin, parsed_action, action) + if action == http.settings.pin_state_names[0].casefold(): + print('turn OFF!') + elif action == http.settings.pin_state_names[1].casefold(): + print('turn ON!') + elif action == 'input': + print('make INPUT!') + elif parsed_action != '': + self.send_error(418, 'I\'m a {}, '\ + 'I do not know how to \'{}\''\ + .format(pin, parsed_action)) + return self._set_headers() response = self._give_head() - response += f'

{http.settings.name} {pin}

\n' - response += f'
{parsed_action}
\n' + response += f'

{http.settings.name} Pin Control

\n' + response += f'
{pin} : {http.settings.pin_state_names[http.pins[pin].value]}
\n' + + response += '

Current mode: {}

\n'.format(http.pins[pin].direction) + response += '
Set {0}
\n'.format(http.settings.pin_state_names[0], pin) + response += '
Set {0}
\n'.format(http.settings.pin_state_names[1], pin) + response += '
Set mode to input
\n'.format(pin) response += self._give_timestamp() response += self._give_foot(refresh=60) self._write_dedented(response) + elif urlparse(self.path).path == '/': # Main Page exclude = parse_qs(urlparse(self.path).query).get('exclude', '') From 91291d4be5146d55ca7da966cc5318b2504ddd5e Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 19 Sep 2025 14:29:53 +0200 Subject: [PATCH 078/108] add makeInput function to gpio handler --- gpiohandler.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/gpiohandler.py b/gpiohandler.py index 7d6df0e..45d915d 100644 --- a/gpiohandler.py +++ b/gpiohandler.py @@ -25,6 +25,7 @@ ''' PinReader class (dict) ''' +INPUT = gpiod.line.Direction.INPUT OUTPUT = gpiod.line.Direction.OUTPUT ACTIVE = gpiod.line.Value.ACTIVE INACTIVE = gpiod.line.Value.INACTIVE @@ -152,3 +153,28 @@ def setPin(self, pin, value): self._update_pin(pin) return True if newval == value else False + def makeInput(self, pin): + '''Sets the pin to input mode and reads it's value to data{} dictionary. + Parameters: + pin: the pin name from config''' + self.pins.update(pin) + chip = self.pins[pin].chip + line = self.pins[pin].line + if self.pins[pin].consumer is not None: + logging.warning('Failed to set input mode on pin \'{}\', currently used by: \'{}\'' + .format(pin, self.pins[pin].consumer)) + else: + try: + with gpiod.Chip(chip).request_lines( + consumer='SBCEye-{}'.format(getpid()), + config={line: gpiod.LineSettings( + direction = INPUT)}, + ) as request: + _ = request.get_values()[0] + except OSError as e: + # log a warning and return 'False' if the write fails + logging.warning('Could not set input mode on pin {}:{} : {}' + .format(chip, line, e)) + self.pins.update(pin) + self._update_pin(pin) + return From 7cb6a7108e220de759f0821fd6d40e63f194a3a2 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 19 Sep 2025 14:30:31 +0200 Subject: [PATCH 079/108] working gpio control! still wip --- httpserver.py | 39 ++++++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/httpserver.py b/httpserver.py index 7adbb39..f04743a 100644 --- a/httpserver.py +++ b/httpserver.py @@ -30,7 +30,7 @@ def serve_http(settings, rrd, gpio, data): # there is probably a better way to do this, eg using a meta-class and inheritance http.settings = settings http.rrd = rrd - http.pins = gpio.pins + http.gpio = gpio http.data = data http.icon_file = 'favicon.ico' if not os.path.exists(http.icon_file): @@ -87,12 +87,20 @@ def log_message(self, format, *args): return - def _set_headers(self): + def _common_headers(self): self.send_response(200) + self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate') + self.send_header('Pragma', 'no-cache') + self.send_header('Expires', '0') + + def _redirect(self): + self._common_headers() + self.send_header('refresh', '0; url=/') + self.end_headers() + + def _set_headers(self): + self._common_headers() self.send_header("Content-type", "text/html") - self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") - self.send_header("Pragma", "no-cache") - self.send_header("Expires", "0") self.end_headers() def _set_png_headers(self): @@ -245,11 +253,11 @@ def _give_pins(self): ret += '
\n' for item, name in pinlist.items(): title = '{}:\n chip: {}\n line: {}\n direction: {}\n consumer: {}'.format( - name, http.pins[name].chip, http.pins[name].line, - http.pins[name].direction, http.pins[name].consumer) + name, http.gpio.pins[name].chip, http.gpio.pins[name].line, + http.gpio.pins[name].direction, http.gpio.pins[name].consumer) ret += f'' - direction = '({})'.format(http.pins[name].direction[:-3]) - consumer = '{}'.format(http.pins[name].consumer) + direction = '({})'.format(http.gpio.pins[name].direction[:-3]) + consumer = '{}'.format(http.gpio.pins[name].consumer) if http.data[item] == 'U': ret += ''.format(consumer) @@ -468,10 +476,19 @@ def do_GET(self): print(pin, parsed_action, action) if action == http.settings.pin_state_names[0].casefold(): print('turn OFF!') + http.gpio.setPin(pin, 0) + self._redirect() + return elif action == http.settings.pin_state_names[1].casefold(): print('turn ON!') + http.gpio.setPin(pin, 1) + self._redirect() + return elif action == 'input': print('make INPUT!') + http.gpio.makeInput(pin) + self._redirect() + return elif parsed_action != '': self.send_error(418, 'I\'m a {}, '\ 'I do not know how to \'{}\''\ @@ -480,9 +497,9 @@ def do_GET(self): self._set_headers() response = self._give_head() response += f'

{http.settings.name} Pin Control

\n' - response += f'
{pin} : {http.settings.pin_state_names[http.pins[pin].value]}
\n' + response += f'
{pin} : {http.settings.pin_state_names[http.gpio.pins[pin].value]}
\n' - response += '

Current mode: {}

\n'.format(http.pins[pin].direction) + response += '
Current mode: {}

\n'.format(http.gpio.pins[pin].direction) response += '
Set {0}
\n'.format(http.settings.pin_state_names[0], pin) response += '
Set {0}
\n'.format(http.settings.pin_state_names[1], pin) response += '
Set mode to input
\n'.format(pin) From fa80ac4b405fc413a7d3a8bad41503823169cf6c Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 19 Sep 2025 19:58:23 +0200 Subject: [PATCH 080/108] relative links, strip unused stuff --- httpserver.py | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/httpserver.py b/httpserver.py index f04743a..cdcf2fd 100644 --- a/httpserver.py +++ b/httpserver.py @@ -95,7 +95,7 @@ def _common_headers(self): def _redirect(self): self._common_headers() - self.send_header('refresh', '0; url=/') + self.send_header('refresh', '0; url=./') self.end_headers() def _set_headers(self): @@ -172,15 +172,6 @@ def _give_timestamp(self): title="Project homepage on GitHub" target="_blank"> SBCEye''' - def _give_cam(self): - return f'''
Cam
\n - - Webcam\n - ''' - def _give_env(self): # Environmental sensor sensorlist = { @@ -265,7 +256,7 @@ def _give_pins(self): else: em = 'font-weight: bold' if http.data[item] == 1 else '' if name in http.settings.outpins: - link = 'href="/{}" title="Pin Control" '\ + link = 'href="./{}" title="Pin Control" '\ 'style="text-decoration: underline; {}"'.format(name, em) ret += '
'\ .format(link, http.settings.pin_state_names[http.data[item]]) @@ -462,7 +453,7 @@ def do_GET(self): http.rrd.backup() elif urlparse(self.path).path == '/log': self._set_headers() - response = self._give_head() + response = self._give_head(" :: Logfile Viewer") response += f'

{http.settings.name} Log

\n' response += self._give_log() response += self._give_timestamp() @@ -495,7 +486,7 @@ def do_GET(self): .format(pin, parsed_action)) return self._set_headers() - response = self._give_head() + response = self._give_head(" :: Pin Control :: {}".format(pin)) response += f'

{http.settings.name} Pin Control

\n' response += f'
{pin} : {http.settings.pin_state_names[http.gpio.pins[pin].value]}
\n' From 35584874a966aaa4a7524f48a30cd770c97087ee Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 19 Sep 2025 23:13:48 +0200 Subject: [PATCH 081/108] pin control page fluff, still wip --- httpserver.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/httpserver.py b/httpserver.py index cdcf2fd..29a9630 100644 --- a/httpserver.py +++ b/httpserver.py @@ -57,6 +57,10 @@ def serve_http(settings, rrd, gpio, data): for link in settings.links: logging.info(f"Web link '{link}' points to: {settings.links[link]}") + # Note the controllable pins + for pin in settings.outpins: + logging.info(f"Pin '{pin}' controllable via web ui") + # Start the server logging.info(f'HTTP server will bind to port {str(settings.web_port)} '\ f'on host {settings.web_host}') @@ -464,21 +468,23 @@ def do_GET(self): pin = urlparse(self.path).path[1:] parsed_action = urlparse(self.path).query action = parsed_action.casefold() - print(pin, parsed_action, action) if action == http.settings.pin_state_names[0].casefold(): - print('turn OFF!') http.gpio.setPin(pin, 0) self._redirect() + logging.info('Pin \'{}\' set output: {} via web ({})' + .format(pin, http.settings.pin_state_names[0], self.client_address[0])) return elif action == http.settings.pin_state_names[1].casefold(): - print('turn ON!') http.gpio.setPin(pin, 1) self._redirect() + logging.info('Pin \'{}\' set output: {} via web ({})' + .format(pin, http.settings.pin_state_names[1], self.client_address[0])) return elif action == 'input': - print('make INPUT!') http.gpio.makeInput(pin) self._redirect() + logging.info('Pin \'{}\' set to input mode via web ({})' + .format(pin, self.client_address[0])) return elif parsed_action != '': self.send_error(418, 'I\'m a {}, '\ @@ -488,12 +494,12 @@ def do_GET(self): self._set_headers() response = self._give_head(" :: Pin Control :: {}".format(pin)) response += f'

{http.settings.name} Pin Control

\n' - response += f'
{pin} : {http.settings.pin_state_names[http.gpio.pins[pin].value]}
\n' - - response += '
Current mode: {}

\n'.format(http.gpio.pins[pin].direction) - response += '
Set {0}
\n'.format(http.settings.pin_state_names[0], pin) - response += '
Set {0}
\n'.format(http.settings.pin_state_names[1], pin) - response += '
Set mode to input
\n'.format(pin) + response += f'
{pin} : {http.settings.pin_state_names[http.gpio.pins[pin].value]}
\n' + response += '
Current mode: {}
\n'.format(http.gpio.pins[pin].direction) + response += '
Set output: {0}
\n'.format(http.settings.pin_state_names[0], pin) + response += '
Set output: {0}
\n'.format(http.settings.pin_state_names[1], pin) + response += '
Change mode to input and show value
\n'.format(pin) + response += '

Home
\n' response += self._give_timestamp() response += self._give_foot(refresh=60) self._write_dedented(response) From 14ddf294f75e04b8c9f8509be7398f9e56f46500 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 21 Sep 2025 15:11:32 +0200 Subject: [PATCH 082/108] move html part of pin portal to func --- httpserver.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/httpserver.py b/httpserver.py index 29a9630..d162495 100644 --- a/httpserver.py +++ b/httpserver.py @@ -368,6 +368,16 @@ def _give_dump_portal(self): ''' + def _give_pin_portal(self, pin): + ret = f'

{http.settings.name} Pin Control

\n' + ret += f'
{pin} : {http.settings.pin_state_names[http.gpio.pins[pin].value]}
\n' + ret += '
Current mode: {}
\n'.format(http.gpio.pins[pin].direction) + ret += '\n'.format(http.settings.pin_state_names[0], pin) + ret += '\n'.format(http.settings.pin_state_names[1], pin) + ret += '\n'.format(pin) + ret += '\n' + return ret + def _write_dedented(self, html): # Strip leading whitespace and write response = re.sub(r'^\s*','', html, flags=re.MULTILINE) @@ -463,7 +473,6 @@ def do_GET(self): response += self._give_timestamp() response += self._give_foot(refresh=60, scroll=True) self._write_dedented(response) - elif urlparse(self.path).path[1:] in http.settings.outpins: pin = urlparse(self.path).path[1:] parsed_action = urlparse(self.path).query @@ -493,17 +502,10 @@ def do_GET(self): return self._set_headers() response = self._give_head(" :: Pin Control :: {}".format(pin)) - response += f'

{http.settings.name} Pin Control

\n' - response += f'
{pin} : {http.settings.pin_state_names[http.gpio.pins[pin].value]}
\n' - response += '
Current mode: {}
\n'.format(http.gpio.pins[pin].direction) - response += '\n'.format(http.settings.pin_state_names[0], pin) - response += '\n'.format(http.settings.pin_state_names[1], pin) - response += '\n'.format(pin) - response += '\n' + response += self._give_pin_portal(pin) response += self._give_timestamp() response += self._give_foot(refresh=60) self._write_dedented(response) - elif urlparse(self.path).path == '/': # Main Page exclude = parse_qs(urlparse(self.path).query).get('exclude', '') From 4266996ab1b2a2e223492a1c532300b47915005b Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 21 Sep 2025 15:48:46 +0200 Subject: [PATCH 083/108] pin portal only show valid choices --- httpserver.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/httpserver.py b/httpserver.py index d162495..91625ed 100644 --- a/httpserver.py +++ b/httpserver.py @@ -369,12 +369,23 @@ def _give_dump_portal(self): ''' def _give_pin_portal(self, pin): - ret = f'

{http.settings.name} Pin Control

\n' - ret += f'
{pin} : {http.settings.pin_state_names[http.gpio.pins[pin].value]}
\n' - ret += '
Current mode: {}
\n'.format(http.gpio.pins[pin].direction) - ret += '\n'.format(http.settings.pin_state_names[0], pin) - ret += '\n'.format(http.settings.pin_state_names[1], pin) - ret += '\n'.format(pin) + ret = '

{} Pin Control

\n'.format(http.settings.name) + ret += '
{} : '.format(pin) + ret += '{}
\n'.format(http.settings.pin_state_names[http.gpio.pins[pin].value]) + ret += '
Current mode: {}
\n'\ + .format(http.gpio.pins[pin].direction) + if http.gpio.pins[pin].direction == 'input': + for state in (0, 1): + ret += '\n'\ + .format(http.settings.pin_state_names[state]) + else: + state = 1 if http.gpio.pins[pin].value == 0 else 0 + ret += '\n'\ + .format(http.settings.pin_state_names[state]) + ret += '\n' ret += '\n' return ret From a490c55a1cb85088e7b7874f738f22fe21be700d Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 22 Sep 2025 12:29:35 +0200 Subject: [PATCH 084/108] make links clearer --- httpserver.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/httpserver.py b/httpserver.py index 91625ed..53f248f 100644 --- a/httpserver.py +++ b/httpserver.py @@ -377,15 +377,17 @@ def _give_pin_portal(self, pin): if http.gpio.pins[pin].direction == 'input': for state in (0, 1): ret += '\n'\ + 'Change mode to output and set: {0}\n'\ .format(http.settings.pin_state_names[state]) else: state = 1 if http.gpio.pins[pin].value == 0 else 0 ret += '\n'\ + 'Set output: {0}\n'\ .format(http.settings.pin_state_names[state]) ret += '\n' + 'Change mode to input and get value\n' ret += '\n' return ret From b09dcf1ec5af32a95517d7a02902eb92c6f36855 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 22 Sep 2025 12:59:43 +0200 Subject: [PATCH 085/108] gpiod version test earlier --- gpiohandler.py | 9 +++------ pinreader.py | 9 ++------- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/gpiohandler.py b/gpiohandler.py index 45d915d..8ce7c7b 100644 --- a/gpiohandler.py +++ b/gpiohandler.py @@ -8,12 +8,6 @@ import logging from os import getpid from re import search -try: - from pinreader import PinReader - readerfail = None -except ImportError as e: - # remember why we failed, so it can be reported in log later. - readerfail = e # Needs gpiod bindings at V2.0 or later, standard debian12/bookworm is v1.6 # use a virtualenv and 'pip install --upgrade gpiod' as needed. @@ -21,6 +15,9 @@ readerfail = 'gpiod bindings library version too low ({}), '\ 'pinreader requires gpiod v2.x.x or later.'\ .format(gpiod.__version__) +else: + readerfail = None + from pinreader import PinReader ''' PinReader class (dict) diff --git a/pinreader.py b/pinreader.py index 95689c3..bc68d45 100644 --- a/pinreader.py +++ b/pinreader.py @@ -1,13 +1,8 @@ +# Needs gpiod bindings at V2.0 or later, standard debian12/bookworm is v1.6 +# use a virtualenv and 'pip install --upgrade gpiod' as needed. import gpiod from os import getpid -from re import search -# Needs gpiod bindings at V2.0 or later, standard debian12/bookworm is v1.6 -# use a virtualenv and 'pip install --upgrade gpiod' as needed. -if int(search('^[0-9]+', gpiod.__version__).group(0)) < 2: - raise ImportError('gpiod bindings library version too low ({}), '\ - 'pinreader requires gpiod v2.x.x or later.' - .format(gpiod.__version__)) ''' PinReader class (dict) ''' From 9ec91b73af59578d03d1577f4cb4007ec964a1ee Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 22 Sep 2025 12:59:59 +0200 Subject: [PATCH 086/108] tweaks --- httpserver.py | 2 +- robin.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/httpserver.py b/httpserver.py index 53f248f..f90cbbd 100644 --- a/httpserver.py +++ b/httpserver.py @@ -372,7 +372,7 @@ def _give_pin_portal(self, pin): ret = '

{} Pin Control

\n'.format(http.settings.name) ret += '
{} : '.format(pin) ret += '{}
\n'.format(http.settings.pin_state_names[http.gpio.pins[pin].value]) - ret += '
Current mode: {}
\n'\ + ret += '
mode: {}
\n'\ .format(http.gpio.pins[pin].direction) if http.gpio.pins[pin].direction == 'input': for state in (0, 1): diff --git a/robin.py b/robin.py index 6a3fa5e..80d4b36 100644 --- a/robin.py +++ b/robin.py @@ -55,7 +55,7 @@ def __init__(self, s, data): # Graphs and parameters self.graph_map = { 'env-temp': (f'{s.web_sensor_name} Temperature, \u00B0Centigrade', - None, None, '%3.0lf\u00B0', '%3.1lf\u00B0C'), + None, None, '%3.1lf\u00B0', '%3.1lf\u00B0C'), 'env-humi': (f'{s.web_sensor_name} Humidity, % percent', None, None, '%3.0lf', '%3.0lf%%'), 'env-pres': (f'{s.web_sensor_name} Pressure, millibars', From 08458e9cbc99614ce287a30a7e77d0116e89a7a7 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 1 Oct 2025 14:38:49 +0200 Subject: [PATCH 087/108] fluff --- gpiohandler.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/gpiohandler.py b/gpiohandler.py index 8ce7c7b..e5c2353 100644 --- a/gpiohandler.py +++ b/gpiohandler.py @@ -68,15 +68,14 @@ def __init__(self, pinlist, data): logging.warning('GPIO pins were specified but pin setup failed, see syslog') logging.info('Pin monitoring disabled') return - self.directions = {} - self.consumers = {} + self.directions = {} # used to remember directions for change detection + self.consumers = {} # used to remember consumers for change detection for pin in self.pins: data[f'pin-{pin}'] = self._value_to_data(self.pins[pin].value) self.directions[pin] = self.pins[pin].direction self.consumers[pin] = self.pins[pin].consumer print('Pin \'{}\': {}'.format(pin, repr(self.pins[pin])[10:-1])) logging.info('Pin \'{}\': {}'.format(pin, repr(self.pins[pin])[10:-1])) - # Now set Output pins up print('GPIO monitoring configured and logging enabled') logging.info('GPIO monitoring configured and logging enabled') self.available = True From d2bfba5434318e48d6f16517aaf107d38f3dece9 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 2 Oct 2025 17:58:08 +0200 Subject: [PATCH 088/108] i2c pin locker --- SBCEye.py | 9 +++++++-- i2c_bus.py | 43 ++++++++++++++++++++++++++++++++++++------- load_config.py | 14 ++++++++++---- 3 files changed, 53 insertions(+), 13 deletions(-) diff --git a/SBCEye.py b/SBCEye.py index 43591e2..47cb179 100644 --- a/SBCEye.py +++ b/SBCEye.py @@ -62,7 +62,8 @@ os.nice(10) # The setting class will also process the arguments -settings = Settings() +appname = sys.argv[0].removesuffix('.py') +settings = Settings(appname) # Let the console know we are starting print("Starting SBCEye") @@ -106,7 +107,7 @@ # # Import, setup and return hardware drivers, or 'None' if setup fails -i2c = i2c_setup(settings) +i2c, bus_lock = i2c_setup(settings) # # Local Classes, Globals @@ -211,11 +212,15 @@ def handle_restart(): logging.info('Safe Restarting') print('Restart\n',flush=True) rrd.write_updates() + if bus_lock: + bus_lock.release() os.execv(sys.executable, ['python'] + sys.argv) def handle_exit(): '''Ensure we write ipending data to the RRD database as we exit''' rrd.write_updates() + if bus_lock: + bus_lock.release() logging.info('Exiting') print('Graceful Exit\n',flush=True) diff --git a/i2c_bus.py b/i2c_bus.py index fb82396..3dea1fc 100644 --- a/i2c_bus.py +++ b/i2c_bus.py @@ -5,6 +5,35 @@ ''' # pragma pylint: disable=import-outside-toplevel +def i2c_pin_consume(settings): + '''Consume the specified pins with a gpiod request to prevent + other gpio processes from modifying them''' + if len(settings.bus_lock) != 3: + return None + try: + import gpiod + except ImportError: + print('WARNING: Cannot lock I2C pins, gpiod library unavailable') + return None + chip = settings.bus_lock[0] + lines = tuple(settings.bus_lock[1:]) + print('==LOCK===',chip,lines) + if not gpiod.is_gpiochip_device(chip): + print('WARNING: Cannot lock I2C pins, \'{}\' is not a gpiodchip device'\ + .format(chip)) + return None + try: + lock = gpiod.request_lines(chip, + consumer='{}'.format(settings.identifier), + config={lines:None}) + except Exception as e: + print('WARNING: Cannot lock I2C pins, request failed:\n{}'\ + .format(e)) + return None + print('I2C bus pins locked (\'{}\': {})'.format(chip, lines)) + return lock + + def i2c_setup(settings): '''Import and start the I2C bus device @@ -20,19 +49,19 @@ def i2c_setup(settings): # I2C Comms # Uses standard SMBUS lib (currently smbus2) try: - import smbus2 + from smbus2 import SMBus except ImportError as error: print(error) print("ERROR: I2C bus requirements (smbus2) not met", flush=True) - return None - + return None, None + # Lock the bus pins if necesscary + lock = i2c_pin_consume(settings) # Now the I2C device driver object try: - i2c = smbus2.SMBus(settings.bus_id) + i2c = SMBus(settings.bus_id) except ValueError as error: print(error) print("No I2C bus, display and sensor functions will be disabled", flush=True) - return None - + return None, None print('I2C bus found', flush=True) - return i2c + return i2c, lock diff --git a/load_config.py b/load_config.py index 75fc1f9..284608d 100644 --- a/load_config.py +++ b/load_config.py @@ -20,11 +20,12 @@ class Settings: No Methods Attributes: + appname: a short text identifier for the app Basically, look at the class and config.ini file, I'm not going to list and describe everything a second time here ;-) ''' - def __init__(self): + def __init__(self,appname=None): def hexint(instring): ''' Helper function so we can use '0xNN' hex values for some inputs''' @@ -81,11 +82,15 @@ def hexint(instring): print('\nERROR: Cannot find a configuration file, exiting') sys.exit() - config = configparser.RawConfigParser() config.optionxform = str config.read(config_file) + if appname: + self.identifier = '{}-{}'.format(appname, os.getpid()) + else: + self.identifier = os.getpid() + # Set attributes from .ini file general = config["general"] @@ -126,8 +131,8 @@ def hexint(instring): self.pinlist = {} pins = config["pins"] for pin in pins: - line = pins.get(pin).split(',') - self.pinlist[pin] = (line[0], int(line[1])) + self.pinlist[pin] = pins.get(pin).split(',') + self.pinlist[pin][1] = int(self.pinlist[pin][1]) outpins = config["outpins"] self.outpins = map(str.strip, outpins.get('web').split(',')) @@ -163,6 +168,7 @@ def hexint(instring): self.bus_id = hexint(bus.get("bus_id")) self.sensor_addr = hexint(bus.get("sensor_addr")) self.display_addr = hexint(bus.get("display_addr")) + self.bus_lock = bus.get("lock").split(',') display = config["display"] self.display_rotate = display.getboolean("rotate") From d55486e4cc1052d282dbc496aa0c5230a587e926 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 2 Oct 2025 18:05:22 +0200 Subject: [PATCH 089/108] stop passing bus to luma display --- SBCEye.py | 2 +- oled_display.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/SBCEye.py b/SBCEye.py index 47cb179..17dcdb0 100644 --- a/SBCEye.py +++ b/SBCEye.py @@ -237,7 +237,7 @@ def handle_exit(): 'Environment status and logging disabled') # Display animation setup - disp = oled_setup(i2c, settings) if i2c else None + disp = oled_setup(settings) if i2c else None if disp: # display initialisation does a 'clear()' and 'show()' disp.contrast(settings.display_contrast) diff --git a/oled_display.py b/oled_display.py index 502fb97..54b8b8a 100644 --- a/oled_display.py +++ b/oled_display.py @@ -4,11 +4,11 @@ import importlib.util -def oled_setup(i2c, settings): +def oled_setup(settings): '''Import and initialise ssd1306 library, return the display object + uses the 'luma' library, which in turn requires smbus2 parameters: - i2c: i2c bus object settings: settings oject (from load_config) returns: disp: Display module object, or None if failed @@ -33,7 +33,7 @@ def oled_setup(i2c, settings): rotate = 2 if settings.display_rotate else 0 try: # Create the display object - disp = ssd1306(bus=i2c, address=settings.display_addr, rotate=rotate) + disp = ssd1306(rotate=rotate) print("SSD1306 i2c display found", flush=True) except Exception as error: print(error) From 05fd1195f68731d116682cfd43bc64a4b6afc367 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 2 Oct 2025 18:11:58 +0200 Subject: [PATCH 090/108] simplified pinreader --- pinreader.py | 112 +++++++++++++-------------------------------------- 1 file changed, 27 insertions(+), 85 deletions(-) diff --git a/pinreader.py b/pinreader.py index bc68d45..661450b 100644 --- a/pinreader.py +++ b/pinreader.py @@ -3,17 +3,23 @@ import gpiod from os import getpid -''' -PinReader class (dict) -''' INPUT = gpiod.line.Direction.INPUT ACTIVE = gpiod.line.Value.ACTIVE + class PinReader(dict): ''' - internal instance class + PinReader class (dict) + A class derived from a dict() that can record and update the status of a list of pins + ''' class _instance: + ''' + class that holds individual pin instance data and exposes an update() method for the pin + ''' def __init__(self, chip, line): + ''' Creates an pin instance object + Takes the GPIO chip device specifier (path, or identifier with gpiod v2+) + and line offset ''' self._pid = getpid() # record PID of process that called init() if not gpiod.is_gpiochip_device(chip): raise ValueError('\'{}\' is not a valid GPIO device'.format(chip)) @@ -31,16 +37,19 @@ def __init__(self, chip, line): self.get() def __repr__(self): + ''' returns a formatted representation of the pin data ''' consumer = None if self.consumer is None else '\'{}\''.format(self.consumer) return '_instance(chip=\'{}\' line={} consumer={} direction=\'{}\' value={})'\ .format(self.chip, self.line, consumer, self.direction, self.value) def __str__(self): + ''' returns a formatted string giving direction, consumer and value ''' consumer = None if self.consumer is None else '\'{}\''.format(self.consumer) return 'direction: {}, consumer: {}, value: {}'\ .format(self.direction, consumer, self.value) def _value(self): + ''' reads the value of the pin ''' try: with self._chip.request_lines(consumer='pinreader-{}'.format(self._pid), config={self.line: None}) as request: @@ -53,6 +62,8 @@ def _value(self): return None def get(self): + ''' reads and updates the current status of the pin, + the value is only read if the pin is unused ''' line = self._chip.get_line_info(self.line) self.direction = 'input' if line.direction == INPUT else 'output' if line.used: @@ -65,100 +76,31 @@ def get(self): ''' class init() ''' def __init__(self, pinlist, tolerant=False): + ''' Main class init(): + Takes a pin list: a dictionary{} of pin labels, each item being a tuple() + of gpio chip path, and line offset. + The 'tolerant' flag prevents exceptions if an invalid pin is specified ''' super().__init__({}) + self.chips = [] for label in pinlist: try: - super().__setitem__(label, - self._instance(pinlist[label][0], - pinlist[label][1])) + super().__setitem__(label, self._instance(*pinlist[label])) except ValueError as e: if not tolerant: raise ValueError('failed to set up pin \'{}\': {}' .format(label, e)) - - def __str__(self): - ret = '' - for line in super().__iter__(): - pin = super().__getitem__(line) - if pin.value is not None: - value = 'low' if pin.value == 0 else 'high' - ret += '{} = {} ({})\n'.format(line, value, pin.direction) else: - ret += '{} = - (\'{}\', {})\n'.format(line, pin.consumer, pin.direction) - return ret.rstrip('\n') + if pinlist[label][0] not in self.chips: + self.chips.append(pinlist[label][0]) def update(self, items=None): + ''' Updates all pins (deault), a pin, or a list of pins ''' items = [items] if type(items) == str else items items = super().keys() if items is None else items for line in items: super().__getitem__(line).get() -''' - HELPER -''' - -def find_pins(device, regex, prefix='AUTO', verbose=False): - if not gpiod.is_gpiochip_device(device): - raise ValueError('\'{}\' is not a valid GPIO device'.format(device)) - num_lines = gpiod.Chip(device).get_info().num_lines - pinlist = {} - with gpiod.Chip(device) as chip: - if verbose: - print('Searching for pins on \'{}\' that match regex: {}' - .format(chip.path, regex)) - for line in range(0, num_lines): - name = chip.get_line_info(line).name - name = '{}{}'.format(prefix, line) if name is None else name - if search(regex, name): - pinlist[name] = (chip.path, line) - if verbose: - print(' adding: \'{}\' (line {})'.format(name, line)) - elif verbose: - print(' skipping: \'{}\' (line {})'.format(name, line)) - return pinlist - if __name__ == "__main__": - ''' - DEMO - ''' - - from sys import argv - from time import asctime, sleep - from argparse import ArgumentParser - - self = argv[0] - desc = 'Display GPIO pin states and value (if availabe) for matching pins '\ - 'on the specified gpio chip. Use \'gpioinfo\' to see available pins.' - elog = 'IMPORTANT: use regex wisely, DO NOT use a generic wildcard such as \'.*\'. '\ - 'Requesting pins that are used by the OS may cause conflicts. eg: reading the value '\ - 'of pins labelled \'SD_*\' can cause Disk I/O errors on Raspberry PI\'s.)' - parser = ArgumentParser(prog=self, description=desc, epilog=elog) - parser.add_argument("-i", "--interval", default=0, help="Interval between updates in seconds, default: 0 (run once)", type=float) - parser.add_argument("-c", "--chip", default="/dev/gpiochip0", help="GPIO chip device path, default: /dev/gpiochip0", type=str) - parser.add_argument("-r", "--regex", default="^GPIO[0-9]+$", help="RegEX to select pin names, default: ^GPIO[0-9]+$", type=str) - parser.add_argument("-v", "--verbose", action="store_true", help="Show chip and regex processing") - args = parser.parse_args() - - # Seach for pins using the device and regex determined above - pinlist = find_pins(args.chip, args.regex, verbose=args.verbose) - if len(pinlist) == 0: - print('No matching pins, exiting') - exit() - - # Instantiate the pinreader class on this list - pinstates = PinReader(pinlist) - - # little function to collate output - def out(): - return '{}: {}\n{}\n'.format(self, asctime(), pinstates) - - # Show initial output - print(out(), end='') - - # Now loop forever showing the values if needed - while args.interval != 0: - sleep(args.interval) - pinstates.update() - print("\033[F" * (len(pinstates) + 1), end='') - for line in out().split('\n')[:-1]: - print('\033[K{}'.format(line)) + from sys import exit + print('PinReader{} class, see inline docs') + exit() From b5e6bfa32578d189f2cfb63729802931de342100 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 2 Oct 2025 18:13:03 +0200 Subject: [PATCH 091/108] remove debug --- i2c_bus.py | 1 - 1 file changed, 1 deletion(-) diff --git a/i2c_bus.py b/i2c_bus.py index 3dea1fc..b961fee 100644 --- a/i2c_bus.py +++ b/i2c_bus.py @@ -17,7 +17,6 @@ def i2c_pin_consume(settings): return None chip = settings.bus_lock[0] lines = tuple(settings.bus_lock[1:]) - print('==LOCK===',chip,lines) if not gpiod.is_gpiochip_device(chip): print('WARNING: Cannot lock I2C pins, \'{}\' is not a gpiodchip device'\ .format(chip)) From 0b0daabc96a5eb1f75dfd443f3923ab67ff156fe Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 2 Oct 2025 18:31:37 +0200 Subject: [PATCH 092/108] update default config for pin lock --- defaults.ini | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/defaults.ini b/defaults.ini index e432177..3487ca0 100644 --- a/defaults.ini +++ b/defaults.ini @@ -47,6 +47,7 @@ allow_backup = True # ping: Timout for ping responses # - must be > 4 to distinguish 'unavailable' vs 'not responding' in logs # - will be reduced if it exceeds the data interval (above) -0.5s +# [intervals] data = 10 pin = 1 @@ -131,6 +132,7 @@ half_height = pin,net # file_name: .log # file_count: Maximum number of old logfiles to retain # file_size: Maximum size before logfile rolls over (Kb) +# [log] file_dir = ./data file_name = SBCEye.log @@ -145,6 +147,7 @@ file_size = 1024 # backup_age: Backups will not be deleted if under this age, even # if that breaks the backup_count limit. (Days) # backup_time: Time of daily backup; HH:MM +# [rrd] dir = ./data file_name = SBCEye.rrd @@ -157,10 +160,18 @@ backup_time = 23:45 # bus_id: The I2C bus to use, integer # sensor_addr: I2C address of the sensor # display_addr: I2C address of the display +# lock: device,pin,pin +# optional; 'consumes' the pins on the gpiochip device. +# The pin offsets used by the device must be known +# and will vary between different devices and hardware. +# For a Raspberry PI model3 using default I2C bus 1: +# lock = /dev/gpiochip,0,1 +# [bus] bus_id = 1 sensor_addr = 0x76 display_addr = 0x3c +lock = # The following display, saver and animate options are only used # if display is enabled in [general] config @@ -170,6 +181,7 @@ display_addr = 0x3c # - generally the connections from the glass are at the bottom # contrast: This gives a limited brightness reduction (0-255) # - does not give full dimming to black +# [display] rotate= False contrast = 127 @@ -178,6 +190,7 @@ contrast = 127 # saver_mode: Possible values are 'none' or 'blank' # saver_on: Start time for screensaver (hour, 24hr clock) # saver_off: End time +# [saver] mode = none on = 20 @@ -187,6 +200,7 @@ off = 8 # passtime: time between display refreshes (seconds) # passes: number of refreshes of a screen before moving to next # speed: rows to scroll on each animation step between screens +# [animate] passtime = 3 passes = 2 @@ -195,6 +209,7 @@ speed = 16 # Enable extra debug # http: Log http requests to console (or syslog when run as a service) # sigint: restart on SIGINT instead of exiting +# [debug] http = False sigint = False From 2df17b569bdfe620380c0f99252bc389654a423a Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 3 Oct 2025 11:24:00 +0200 Subject: [PATCH 093/108] button handler, wip --- load_config.py | 11 +++++++ watch_button.py | 84 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 watch_button.py diff --git a/load_config.py b/load_config.py index 284608d..deec8ee 100644 --- a/load_config.py +++ b/load_config.py @@ -138,6 +138,17 @@ def hexint(instring): self.outpins = map(str.strip, outpins.get('web').split(',')) self.outpins = self.outpins & self.pinlist.keys() + self.webpins = {} + webpins = config["webpins"] + for pin in webpins: + self.webpins[pin] = webpins.get(pin) + + self.buttons = {} + buttons = config["buttons"] + for pin in buttons: + self.buttons[pin] = buttons.get(pin).split(',') + self.buttons[pin][1] = int(self.buttons[pin][1]) + self.netlist = {} ping = config["ping"] for host in ping: diff --git a/watch_button.py b/watch_button.py new file mode 100644 index 0000000..4dd9b0e --- /dev/null +++ b/watch_button.py @@ -0,0 +1,84 @@ +# Needs gpiod bindings at V2.0 or later, standard debian12/bookworm is v1.6 +# use a virtualenv and 'pip install --upgrade gpiod' as needed. +import gpiod +from datetime import timedelta + +INPUT = gpiod.line.Direction.INPUT +RISING_EDGE = gpiod.edge_event.EdgeEvent.Type.RISING_EDGE +FALLING_EDGE = gpiod.edge_event.EdgeEvent.Type.FALLING_EDGE + +def _get_device(chip, line): + ''' Common gpiochip+line setup ''' + # Test whether chip is a gpiochip + if not gpiod.is_gpiochip_device(chip): + raise ValueError('Not a gpiochip device: \'{}\''.format(chip)) + # Test we can use gpiochip + try: + device = gpiod.Chip(chip) + except Exception as e: + raise ValueError('Failed to setup gpiochip (\'{}\') device:\n{}' + .format(chip, e)) + return device + + +# Input +class buttonHandler: + class _button: + ''' + Sets the input pin up to monitor for changes: + - pin will be used (consumed) by the process + Takes: + chip (str) + line (int) + consumer (string) + debounce (int) in ms + verbose (bool) + Provides: + event(): + blocks and waits for events + returns a string with 'rising' or 'falling' otherwise + ''' + def __init__(self, chip, line, consumer, debounce, verbose=False): + self.chip = chip + self.line = line + bias = gpiod.line.Bias.AS_IS + edge = gpiod.line.Edge.BOTH + clock = gpiod.line.Clock.MONOTONIC + debounce = timedelta(milliseconds=debounce) + self.verbose = verbose + # Get and test the device + device = _get_device(self.chip, self.line) + if device.get_line_info(line).used: + raise ValueError('Cannot acquire gpiochip \'{}\' line {}, '\ + 'currently used by: \'{}\'' + .format(chip, line, device.get_line_info(line).consumer)) + # Create a request object for the input + self.request = device.request_lines( + consumer=consumer, + config={line: gpiod.LineSettings( + direction=INPUT, + bias=bias, + edge_detection=edge, + event_clock=clock, + debounce_period=debounce)}) + if self.verbose: + print('Configured: \'{}\':{} as input (locked)' + .format(self.chip, self.line)) + + def release(self): + self.request.release() + print('Released lock on input: \'{}\':{}'.format(self.chip, self.line)) + + def event(self): + self.request.wait_edge_events(timeout=None): + event = self.request.read_edge_events(max_events=1)[0] + if event.line_offset == self.line: + if event.event_type == RISING_EDGE: + return 'rising' + elif event.event_type == FALLING_EDGE: + return 'falling' + + def __init__(self, settings, gpio): + # take settings (and gpio object to flip value) + # start a thread for each watched button + pass From 221f9eb8a97d4b318682e2fe34413d9bea2b8457 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 3 Oct 2025 11:36:02 +0200 Subject: [PATCH 094/108] wip --- watch_button.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/watch_button.py b/watch_button.py index 4dd9b0e..dcd3cc7 100644 --- a/watch_button.py +++ b/watch_button.py @@ -65,12 +65,8 @@ def __init__(self, chip, line, consumer, debounce, verbose=False): print('Configured: \'{}\':{} as input (locked)' .format(self.chip, self.line)) - def release(self): - self.request.release() - print('Released lock on input: \'{}\':{}'.format(self.chip, self.line)) - def event(self): - self.request.wait_edge_events(timeout=None): + self.request.wait_edge_events(timeout=None) event = self.request.read_edge_events(max_events=1)[0] if event.line_offset == self.line: if event.event_type == RISING_EDGE: From 83c48b0c3056cb34d38e1afc0860e3192eca199b Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 3 Oct 2025 16:47:22 +0200 Subject: [PATCH 095/108] better identifier --- SBCEye.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/SBCEye.py b/SBCEye.py index 17dcdb0..f3990d0 100644 --- a/SBCEye.py +++ b/SBCEye.py @@ -40,13 +40,14 @@ import sys import logging import random +import schedule +import psutil from datetime import timedelta from logging.handlers import RotatingFileHandler from atexit import register from signal import signal, SIGTERM, SIGINT, SIGHUP from multiprocessing import Process, Queue -import schedule -import psutil +from pathlib import Path # Local classes from load_config import Settings @@ -62,8 +63,7 @@ os.nice(10) # The setting class will also process the arguments -appname = sys.argv[0].removesuffix('.py') -settings = Settings(appname) +settings = Settings(appname=Path(sys.argv[0]).stem) # Let the console know we are starting print("Starting SBCEye") From 73ec4162034b01dc600ce738c4bdbfc0d1fde3aa Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 3 Oct 2025 16:49:48 +0200 Subject: [PATCH 096/108] default __main__ handlers --- animator.py | 5 +++++ bme_sensor.py | 6 ++++++ gpiohandler.py | 5 +++++ httpserver.py | 6 ++++++ i2c_bus.py | 6 ++++++ load_config.py | 6 ++++++ oled_display.py | 7 ++++++- pinreader.py | 2 +- robin.py | 6 ++++++ saver.py | 6 ++++++ 10 files changed, 53 insertions(+), 2 deletions(-) diff --git a/animator.py b/animator.py index 3f36262..01b93d0 100644 --- a/animator.py +++ b/animator.py @@ -265,3 +265,8 @@ def die_with_dignity(*_): data.pop(key, None) schedule.run_pending() sleep(0.25) + +if __name__ == "__main__": + from sys import exit + print('animator class for SBCEye, see inline docs') + exit() diff --git a/bme_sensor.py b/bme_sensor.py index 84ca8e9..5d3f6f5 100644 --- a/bme_sensor.py +++ b/bme_sensor.py @@ -44,3 +44,9 @@ def bme_setup(i2c, settings): print("BME280 sensor found", flush=True) return bme + +if __name__ == "__main__": + from sys import exit + print('bme280 sensor class for SBCEye, see inline docs') + exit() + diff --git a/gpiohandler.py b/gpiohandler.py index e5c2353..77480f5 100644 --- a/gpiohandler.py +++ b/gpiohandler.py @@ -174,3 +174,8 @@ def makeInput(self, pin): self.pins.update(pin) self._update_pin(pin) return + +if __name__ == "__main__": + from sys import exit + print('gpioHandler class for SBCEye, see inline docs') + exit() diff --git a/httpserver.py b/httpserver.py index f90cbbd..f833446 100644 --- a/httpserver.py +++ b/httpserver.py @@ -550,3 +550,9 @@ def do_GET(self): def do_HEAD(self): '''returns headers''' self._set_headers() + +if __name__ == "__main__": + from sys import exit + print('HTTPserver class for SBCEye, see inline docs') + exit() + diff --git a/i2c_bus.py b/i2c_bus.py index b961fee..8cea1fc 100644 --- a/i2c_bus.py +++ b/i2c_bus.py @@ -64,3 +64,9 @@ def i2c_setup(settings): return None, None print('I2C bus found', flush=True) return i2c, lock + +if __name__ == "__main__": + from sys import exit + print('i2c bus object class for SBCEye, see inline docs') + exit() + diff --git a/load_config.py b/load_config.py index deec8ee..fb520a4 100644 --- a/load_config.py +++ b/load_config.py @@ -200,3 +200,9 @@ def hexint(instring): self.debug_sigint = debug.getboolean("sigint") print("Settings loaded from configuration file successfully") + +if __name__ == "__main__": + from sys import exit + print('setting loader class for SBCEye, see inline docs') + exit() + diff --git a/oled_display.py b/oled_display.py index 54b8b8a..c179b56 100644 --- a/oled_display.py +++ b/oled_display.py @@ -45,5 +45,10 @@ def oled_setup(settings): if not importlib.util.find_spec("luma"): print("ERROR: Luma library not found, disabling display", flush=True) return None - return disp + +if __name__ == "__main__": + from sys import exit + print('OLED display class for SBCEye, see inline docs') + exit() + diff --git a/pinreader.py b/pinreader.py index 661450b..5125270 100644 --- a/pinreader.py +++ b/pinreader.py @@ -102,5 +102,5 @@ def update(self, items=None): if __name__ == "__main__": from sys import exit - print('PinReader{} class, see inline docs') + print('PinReader{} class for SBCEye, see inline docs') exit() diff --git a/robin.py b/robin.py index 80d4b36..0a296a0 100644 --- a/robin.py +++ b/robin.py @@ -354,3 +354,9 @@ def run_threaded(job_func): ''' job_thread = Thread(target=job_func) job_thread.start() + +if __name__ == "__main__": + from sys import exit + print('robin (rrd handler) class for SBCEye, see inline docs') + exit() + diff --git a/saver.py b/saver.py index 4bcd29e..2d97295 100644 --- a/saver.py +++ b/saver.py @@ -66,3 +66,9 @@ def check(self): hour = time.localtime()[3] if self.active != self.saver_map[hour]: self._apply_state(self.saver_map[hour]) + +if __name__ == "__main__": + from sys import exit + print('OLED screensaver class for SBCEye, see inline docs') + exit() + From 167222f70246476067cd1bb851bdb83eb26cc5da Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 3 Oct 2025 16:50:33 +0200 Subject: [PATCH 097/108] watch buttons nearly there, wip --- watch_button.py | 58 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/watch_button.py b/watch_button.py index dcd3cc7..55cd970 100644 --- a/watch_button.py +++ b/watch_button.py @@ -1,7 +1,10 @@ # Needs gpiod bindings at V2.0 or later, standard debian12/bookworm is v1.6 # use a virtualenv and 'pip install --upgrade gpiod' as needed. import gpiod +import logging from datetime import timedelta +from threading import Thread +from os import getpid INPUT = gpiod.line.Direction.INPUT RISING_EDGE = gpiod.edge_event.EdgeEvent.Type.RISING_EDGE @@ -66,6 +69,7 @@ def __init__(self, chip, line, consumer, debounce, verbose=False): .format(self.chip, self.line)) def event(self): + ''' (wait indefinately for and) return the first event in the queue ''' self.request.wait_edge_events(timeout=None) event = self.request.read_edge_events(max_events=1)[0] if event.line_offset == self.line: @@ -74,7 +78,53 @@ def event(self): elif event.event_type == FALLING_EDGE: return 'falling' - def __init__(self, settings, gpio): - # take settings (and gpio object to flip value) - # start a thread for each watched button - pass + def __init__(self, buttons, gpio, consumer=str(getpid())): + ''' Creates button objects for all the specified pins + and spawns threads to monitor them and flip the pin + when the button is pressed ''' + self.gpio = gpio + self.watched = {} + self._threads = {} + for button in buttons: + if button not in gpio.pins.keys(): + print('Cannot configure button for undefined pin \'{}\'.' + .format(button)) + continue + debounce = 66 if len(buttons[button]) < 4 else buttons[button][3] + self.watched[button] = self._button(chip=buttons[button][0], + line=buttons[button][1], + consumer=consumer, + debounce=debounce) + self._threads[button] = Thread(target=self._serve_input, + args=(button, buttons[button][2])) + self._threads[button].daemon = True + self._threads[button].start() + print('Configured button for \'{}\' on {}:{} ({})' + .format(button, buttons[button][0], + buttons[button][1], buttons[button][2])) + logging.info('Configured button for \'{}\' on {}:{}' + .format(button, buttons[button][0], buttons[button][1])) + + def _flip(self, pin): + ''' A simple function to invert the output ''' + current = self.gpio.pin[pin].get() + if current == 0: + self.gpio.setPin(pin, 1) + elif current == 1: + self.gpio.setPin(pin, 0) + + def _serve_input(self, pin, edge): + '''service loop serving the input pin events (run in a thread)''' + while True: + event = self.watched[pin].event() + if event == edge: + self._flip(pin) + if self._verbose: + print('{} : button : {}'.format(asctime(), + self._states[self._output.get()]), flush=True) + +if __name__ == "__main__": + from sys import exit + print('ButtonWatcher class for SBCEye, see inline docs') + exit() + From 8ed2716b33421ea6d1023a79973edc9c667fd7cf Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 4 Oct 2025 01:11:56 +0200 Subject: [PATCH 098/108] working pin watcher --- watch_button.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/watch_button.py b/watch_button.py index 55cd970..df8753e 100644 --- a/watch_button.py +++ b/watch_button.py @@ -107,7 +107,7 @@ def __init__(self, buttons, gpio, consumer=str(getpid())): def _flip(self, pin): ''' A simple function to invert the output ''' - current = self.gpio.pin[pin].get() + current = self.gpio.pins[pin].get() if current == 0: self.gpio.setPin(pin, 1) elif current == 1: @@ -119,9 +119,8 @@ def _serve_input(self, pin, edge): event = self.watched[pin].event() if event == edge: self._flip(pin) - if self._verbose: - print('{} : button : {}'.format(asctime(), - self._states[self._output.get()]), flush=True) + print('Button toggle for pin \'{}\''.format(pin), flush=True) + logging.info('Button toggle for pin \'{}\''.format(pin)) if __name__ == "__main__": from sys import exit From e4d1d9ff3975e5ff1e1c19158fd169506aad7bcc Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 5 Oct 2025 14:28:42 +0200 Subject: [PATCH 099/108] be clearer about saver mode --- saver.py | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/saver.py b/saver.py index 2d97295..ebbb659 100644 --- a/saver.py +++ b/saver.py @@ -9,34 +9,33 @@ class Saver: Turns the display on/off between specified times modes: - 'none': Screensaver disabled + 'disabled': Screensaver disabled 'blank': Turn screen off parameters: disp: display driver object settings: (tuple) consisting of: - mode: (str) One of 'none', 'blank' + mode: (str) One of 'disabled', 'blank' start: (int) Start time, hour, 0-23 end: (int) End time, hour, 0-23 + + provides: + check(): Checks state and applies saver as needed + called from a schedule loop by main code ''' active = False # Current state def __init__(self, disp, settings): - self.disp = disp (self.mode, start, end) = settings - if self.mode != 'none': - logging.info(f'Saver will {self.mode} display between: '\ - f'{start:02d}:00 and {end:02d}:00') - print(f'Saver will {self.mode} display between: '\ - f'{start:02d}:00 and {end:02d}:00',flush=True) + if self.mode != 'disabled': if (start == end)\ - or start not in range(0,23)\ - or end not in range(0,23): - logging.warning('start/end times identical or out of range; disabling saver') - print('start/end times identical or out of range; disabling saver',flush=True) - self.mode = 'off' + or start not in range(0,23)\ + or end not in range(0,23): + logging.warning('Saver start/end times identical or out of range; disabling') + print('Saver start/end times identical or out of range; disabling',flush=True) + self.mode = 'disabled' elif start < end: self.saver_map = [False]*24 for i in range(start, end): @@ -45,6 +44,10 @@ def __init__(self, disp, settings): self.saver_map = [True]*24 for i in range(end, start): self.saver_map[i] = False + logging.info(f'Saver will {self.mode} display between: '\ + f'{start:02d}:00 and {end:02d}:00') + print(f'Saver will {self.mode} display between: '\ + f'{start:02d}:00 and {end:02d}:00',flush=True) self.check() @@ -62,7 +65,7 @@ def _apply_state(self, state): def check(self): '''Check the current state vs the time, and apply changes as needed.''' - if self.mode != 'off': + if self.mode != 'disabled': hour = time.localtime()[3] if self.active != self.saver_map[hour]: self._apply_state(self.saver_map[hour]) From 89ad32389a433b00ef65ba95b7fd0d087fc7cd57 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 5 Oct 2025 14:29:30 +0200 Subject: [PATCH 100/108] Button handler complete --- SBCEye.py | 2 +- defaults.ini | 37 +++++++++++++++++++++++++++---------- gpiohandler.py | 13 ++++++++----- watch_button.py | 23 +++++++++-------------- 4 files changed, 45 insertions(+), 30 deletions(-) diff --git a/SBCEye.py b/SBCEye.py index f3990d0..6cce89b 100644 --- a/SBCEye.py +++ b/SBCEye.py @@ -266,7 +266,7 @@ def handle_exit(): update_sensors() # GPIO pin monitoring - gpio = GPIOHandler(settings.pinlist, data) + gpio = GPIOHandler(settings.pinlist, settings.buttons, data, settings.identifier) # Network (ping) monitoring net = Netreader((settings.netlist, settings.net_timeout), data) diff --git a/defaults.ini b/defaults.ini index 3487ca0..d29e82f 100644 --- a/defaults.ini +++ b/defaults.ini @@ -10,7 +10,7 @@ # short_format: Short time format for logs, etc. time.strftime() # log_daily: Log a 'heartbeat'every day? True/False` # sensor: Look for BME280 sensor? True/False -# display: Look for OLED display? True/False +# display: Look for OLED display? True/False # pin_state_names: Localisation text for pin status (text,text) # [general] @@ -99,27 +99,44 @@ half_height = pin,net # documentation for your Board. # - see https://raspberrypi.stackexchange.com/a/147309 # - an empty list disables pin monitoring +# # Pins are listed one per line: -# = gpiochip device path, pin number +# Pin Name = gpiochip_device_path,line_offset_number # eg: # [pins] -# Lamp = /dev/gpiochip0, 7 -# Ventilator = /dev/gpiochip0, 8 -# Printer = /dev/gpiochip0, 25 +# Lamp = /dev/gpiochip0,7 +# Ventilator = /dev/gpiochip0,8 +# Printer = /dev/gpiochip0,25 # [pins] -# Enable output control for pins from the above list +# Enable web output control for pins from the above list # web = comma seperated list of pins to control via the web # eg: # [outpins] -# web = Lamp, Ventilator +# web = Lamp,Ventilator # [outpins] + +# Enable input (button) on/off control for pins from the above list +# Each pin to be controlled is listed along with the input pin +# chip, line offset, setup and trigger details +# +# pin = device,offset,trigger[,debounce] +# The triger can either be 'rising' or 'falling' +# Debounce is in milliseconds and defaults to 66ms unless specified +# a long debounce makes a 'press and hold' effect +# eg: +# [buttons] +# lamp = /dev/gpiochip0,27,rising +# Ventilator = /dev/gpiochip0,26,rising,1000 +# +[buttons] + # An optional list of targets to be used for network connectivity (ping) tests # Targets are listed one per line: -# = ip address +# = ip address # eg: # [ping] # router = 192.168.0.1 @@ -187,12 +204,12 @@ rotate= False contrast = 127 # Screen saver / burn-in reducer -# saver_mode: Possible values are 'none' or 'blank' +# saver_mode: Possible values are 'disabled' or 'blank' # saver_on: Start time for screensaver (hour, 24hr clock) # saver_off: End time # [saver] -mode = none +mode = disabled on = 20 off = 8 diff --git a/gpiohandler.py b/gpiohandler.py index 77480f5..f665da8 100644 --- a/gpiohandler.py +++ b/gpiohandler.py @@ -18,6 +18,7 @@ else: readerfail = None from pinreader import PinReader + from watch_button import buttonHandler ''' PinReader class (dict) @@ -46,12 +47,13 @@ class GPIOHandler: - returns success/fail, used for web control ''' - def __init__(self, pinlist, data): + def __init__(self, pinlist, buttons, data, consumer): '''Setup and do initial reading''' self.available = False self.pinlist = pinlist self.pins = {} self.data = data + self._consumer = consumer if not self.pinlist: print('No GPIO pins configured for monitoring') return @@ -76,8 +78,9 @@ def __init__(self, pinlist, data): self.consumers[pin] = self.pins[pin].consumer print('Pin \'{}\': {}'.format(pin, repr(self.pins[pin])[10:-1])) logging.info('Pin \'{}\': {}'.format(pin, repr(self.pins[pin])[10:-1])) - print('GPIO monitoring configured and logging enabled') - logging.info('GPIO monitoring configured and logging enabled') + self.buttons = buttonHandler(buttons, self) + print('GPIO monitoring configured') + logging.info('GPIO monitoring configured') self.available = True def _value_to_data(self, value): @@ -135,7 +138,7 @@ def setPin(self, pin, value): else: try: with gpiod.Chip(chip).request_lines( - consumer='SBCEye-{}'.format(getpid()), + consumer=self._consumer, config={line: gpiod.LineSettings( direction = OUTPUT, output_value = value)}, @@ -162,7 +165,7 @@ def makeInput(self, pin): else: try: with gpiod.Chip(chip).request_lines( - consumer='SBCEye-{}'.format(getpid()), + consumer=self._conmsumer, config={line: gpiod.LineSettings( direction = INPUT)}, ) as request: diff --git a/watch_button.py b/watch_button.py index df8753e..74bf2b8 100644 --- a/watch_button.py +++ b/watch_button.py @@ -14,12 +14,12 @@ def _get_device(chip, line): ''' Common gpiochip+line setup ''' # Test whether chip is a gpiochip if not gpiod.is_gpiochip_device(chip): - raise ValueError('Not a gpiochip device: \'{}\''.format(chip)) + raise ValueError('Button setup: Not a gpiochip device: \'{}\''.format(chip)) # Test we can use gpiochip try: device = gpiod.Chip(chip) except Exception as e: - raise ValueError('Failed to setup gpiochip (\'{}\') device:\n{}' + raise ValueError('Button setup: Failed to setup gpiochip (\'{}\') device:\n{}' .format(chip, e)) return device @@ -35,25 +35,23 @@ class _button: line (int) consumer (string) debounce (int) in ms - verbose (bool) Provides: event(): blocks and waits for events returns a string with 'rising' or 'falling' otherwise ''' - def __init__(self, chip, line, consumer, debounce, verbose=False): + def __init__(self, chip, line, consumer, debounce): self.chip = chip self.line = line bias = gpiod.line.Bias.AS_IS edge = gpiod.line.Edge.BOTH clock = gpiod.line.Clock.MONOTONIC debounce = timedelta(milliseconds=debounce) - self.verbose = verbose # Get and test the device device = _get_device(self.chip, self.line) if device.get_line_info(line).used: - raise ValueError('Cannot acquire gpiochip \'{}\' line {}, '\ - 'currently used by: \'{}\'' + raise ValueError('Button setup: Cannot acquire gpiochip \'{}\''\ + 'line {}; currently used by: \'{}\'' .format(chip, line, device.get_line_info(line).consumer)) # Create a request object for the input self.request = device.request_lines( @@ -64,9 +62,6 @@ def __init__(self, chip, line, consumer, debounce, verbose=False): edge_detection=edge, event_clock=clock, debounce_period=debounce)}) - if self.verbose: - print('Configured: \'{}\':{} as input (locked)' - .format(self.chip, self.line)) def event(self): ''' (wait indefinately for and) return the first event in the queue ''' @@ -78,7 +73,7 @@ def event(self): elif event.event_type == FALLING_EDGE: return 'falling' - def __init__(self, buttons, gpio, consumer=str(getpid())): + def __init__(self, buttons, gpio): ''' Creates button objects for all the specified pins and spawns threads to monitor them and flip the pin when the button is pressed ''' @@ -93,16 +88,16 @@ def __init__(self, buttons, gpio, consumer=str(getpid())): debounce = 66 if len(buttons[button]) < 4 else buttons[button][3] self.watched[button] = self._button(chip=buttons[button][0], line=buttons[button][1], - consumer=consumer, + consumer=gpio._consumer, debounce=debounce) self._threads[button] = Thread(target=self._serve_input, args=(button, buttons[button][2])) self._threads[button].daemon = True self._threads[button].start() - print('Configured button for \'{}\' on {}:{} ({})' + print('Button configured for \'{}\' on {}:{} ({})' .format(button, buttons[button][0], buttons[button][1], buttons[button][2])) - logging.info('Configured button for \'{}\' on {}:{}' + logging.info('Button configured for \'{}\' on {}:{}' .format(button, buttons[button][0], buttons[button][1])) def _flip(self, pin): From fcc570c1648263003b3bed77ae8027f23b967135 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 5 Oct 2025 15:19:00 +0200 Subject: [PATCH 101/108] make debounce compulsory --- defaults.ini | 6 +++--- load_config.py | 7 ++----- watch_button.py | 3 +-- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/defaults.ini b/defaults.ini index d29e82f..5f7648f 100644 --- a/defaults.ini +++ b/defaults.ini @@ -125,11 +125,11 @@ half_height = pin,net # # pin = device,offset,trigger[,debounce] # The triger can either be 'rising' or 'falling' -# Debounce is in milliseconds and defaults to 66ms unless specified -# a long debounce makes a 'press and hold' effect +# Debounce is in milliseconds, a good default is 66ms +# - a long debounce makes a 'press and hold' effect # eg: # [buttons] -# lamp = /dev/gpiochip0,27,rising +# lamp = /dev/gpiochip0,27,rising,66 # Ventilator = /dev/gpiochip0,26,rising,1000 # [buttons] diff --git a/load_config.py b/load_config.py index fb520a4..401823e 100644 --- a/load_config.py +++ b/load_config.py @@ -134,20 +134,17 @@ def hexint(instring): self.pinlist[pin] = pins.get(pin).split(',') self.pinlist[pin][1] = int(self.pinlist[pin][1]) - outpins = config["outpins"] - self.outpins = map(str.strip, outpins.get('web').split(',')) - self.outpins = self.outpins & self.pinlist.keys() - self.webpins = {} webpins = config["webpins"] for pin in webpins: - self.webpins[pin] = webpins.get(pin) + self.webpins[pin] = webpins.get(pin).split(',') self.buttons = {} buttons = config["buttons"] for pin in buttons: self.buttons[pin] = buttons.get(pin).split(',') self.buttons[pin][1] = int(self.buttons[pin][1]) + self.buttons[pin][3] = int(self.buttons[pin][3]) self.netlist = {} ping = config["ping"] diff --git a/watch_button.py b/watch_button.py index 74bf2b8..0d51f73 100644 --- a/watch_button.py +++ b/watch_button.py @@ -85,11 +85,10 @@ def __init__(self, buttons, gpio): print('Cannot configure button for undefined pin \'{}\'.' .format(button)) continue - debounce = 66 if len(buttons[button]) < 4 else buttons[button][3] self.watched[button] = self._button(chip=buttons[button][0], line=buttons[button][1], consumer=gpio._consumer, - debounce=debounce) + debounce=buttons[button][3]) self._threads[button] = Thread(target=self._serve_input, args=(button, buttons[button][2])) self._threads[button].daemon = True From 9ed439c83c03b6a2197d04a038d76c6b5fbfb4a4 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 5 Oct 2025 15:52:02 +0200 Subject: [PATCH 102/108] log formatting --- watch_button.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/watch_button.py b/watch_button.py index 0d51f73..ba46717 100644 --- a/watch_button.py +++ b/watch_button.py @@ -93,11 +93,12 @@ def __init__(self, buttons, gpio): args=(button, buttons[button][2])) self._threads[button].daemon = True self._threads[button].start() - print('Button configured for \'{}\' on {}:{} ({})' + print('Pin \'{}\' button configured on \'{}\':{} ({})' .format(button, buttons[button][0], buttons[button][1], buttons[button][2])) - logging.info('Button configured for \'{}\' on {}:{}' - .format(button, buttons[button][0], buttons[button][1])) + logging.info('Pin \'{}\' button configured on \'{}\':{} ({})' + .format(button, buttons[button][0], + buttons[button][1], buttons[button][2])) def _flip(self, pin): ''' A simple function to invert the output ''' From 1ad1ceb1ff0981891267534dbf4cfb3585d9684d Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 5 Oct 2025 16:57:40 +0200 Subject: [PATCH 103/108] add CIDR based authorisation to pin controls --- defaults.ini | 15 ++++++++++----- httpserver.py | 41 +++++++++++++++++++++++++++++------------ 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/defaults.ini b/defaults.ini index 5f7648f..768c3ca 100644 --- a/defaults.ini +++ b/defaults.ini @@ -111,13 +111,18 @@ half_height = pin,net [pins] # Enable web output control for pins from the above list -# web = comma seperated list of pins to control via the web +# List the pin name and a (comma seperated) list of CIDR address blocks +# to allow control from. +# - Restrict to a specific IP address with a CIDR block of 32 +# - or to a local subnet with a CIDR block of 24 +# - IPV6 notation should also work (untested) +# - See: https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing # eg: -# [outpins] -# web = Lamp,Ventilator +# [webpins] +# Lamp = 10.0.0.120/24 +# Ventilator = 10.0.0.120/32 # -[outpins] - +[webpins] # Enable input (button) on/off control for pins from the above list # Each pin to be controlled is listed along with the input pin diff --git a/httpserver.py b/httpserver.py index f833446..3ce2ace 100644 --- a/httpserver.py +++ b/httpserver.py @@ -12,6 +12,7 @@ # HTTP server import http.server from urllib.parse import urlparse, parse_qs +from ipaddress import ip_address, ip_network from threading import Thread # Logging @@ -58,8 +59,15 @@ def serve_http(settings, rrd, gpio, data): logging.info(f"Web link '{link}' points to: {settings.links[link]}") # Note the controllable pins - for pin in settings.outpins: - logging.info(f"Pin '{pin}' controllable via web ui") + for pin in list(settings.webpins): + if pin not in gpio.pins.keys(): + print('Cannot configure web control for \'{}\', since it is not '\ + 'in the pin list'.format(pin)) + del settings.webpins[pin] + print('Pin \'{}\' controllable via web UI from: {}'\ + .format(pin, settings.webpins[pin])) + logging.info('Pin \'{}\' controllable via web UI from: {}'\ + .format(pin, settings.webpins[pin])) # Start the server logging.info(f'HTTP server will bind to port {str(settings.web_port)} '\ @@ -79,7 +87,6 @@ def serve_forever(httpd): thread.daemon = True thread.start() - class _BaseRequestHandler(http.server.BaseHTTPRequestHandler): '''Handles each individual request in a new thread''' @@ -259,7 +266,7 @@ def _give_pins(self): direction = '' else: em = 'font-weight: bold' if http.data[item] == 1 else '' - if name in http.settings.outpins: + if name in http.settings.webpins.keys(): link = 'href="./{}" title="Pin Control" '\ 'style="text-decoration: underline; {}"'.format(name, em) ret += '
'\ @@ -368,23 +375,23 @@ def _give_dump_portal(self): ''' - def _give_pin_portal(self, pin): + def _give_pin_portal(self, pin, control): ret = '

{} Pin Control

\n'.format(http.settings.name) ret += '
{} : '.format(pin) ret += '{}
\n'.format(http.settings.pin_state_names[http.gpio.pins[pin].value]) ret += '
mode: {}
\n'\ .format(http.gpio.pins[pin].direction) - if http.gpio.pins[pin].direction == 'input': + if http.gpio.pins[pin].direction == 'input' and control: for state in (0, 1): ret += '
'\ 'Change mode to output and set: {0}
\n'\ .format(http.settings.pin_state_names[state]) - else: - state = 1 if http.gpio.pins[pin].value == 0 else 0 + elif control: + newstate = 1 if http.gpio.pins[pin].value == 0 else 0 ret += '
'\ 'Set output: {0}
\n'\ - .format(http.settings.pin_state_names[state]) + .format(http.settings.pin_state_names[newstate]) ret += '
'\ 'Change mode to input and get value
\n' @@ -486,10 +493,20 @@ def do_GET(self): response += self._give_timestamp() response += self._give_foot(refresh=60, scroll=True) self._write_dedented(response) - elif urlparse(self.path).path[1:] in http.settings.outpins: + elif urlparse(self.path).path[1:] in http.settings.webpins.keys(): pin = urlparse(self.path).path[1:] parsed_action = urlparse(self.path).query action = parsed_action.casefold() + allowed = False + for cidr in http.settings.webpins[pin]: + if ip_address(self.client_address[0]) in ip_network(cidr,strict=False): + allowed = True + if not allowed and action != '': + self.send_error(403, 'Forbidden', + 'Your IP address is not permitted to control this pin') + print('Denied access to \'/{}\' from client at IP: {}'\ + .format(pin, self.client_address[0])) + return if action == http.settings.pin_state_names[0].casefold(): http.gpio.setPin(pin, 0) self._redirect() @@ -508,14 +525,14 @@ def do_GET(self): logging.info('Pin \'{}\' set to input mode via web ({})' .format(pin, self.client_address[0])) return - elif parsed_action != '': + elif action != '': self.send_error(418, 'I\'m a {}, '\ 'I do not know how to \'{}\''\ .format(pin, parsed_action)) return self._set_headers() response = self._give_head(" :: Pin Control :: {}".format(pin)) - response += self._give_pin_portal(pin) + response += self._give_pin_portal(pin, allowed) response += self._give_timestamp() response += self._give_foot(refresh=60) self._write_dedented(response) From 89a49fecf1d8683a94b75db585d0758593356c1a Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 6 Oct 2025 15:32:30 +0200 Subject: [PATCH 104/108] bugfix --- gpiohandler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gpiohandler.py b/gpiohandler.py index f665da8..0d2f9d5 100644 --- a/gpiohandler.py +++ b/gpiohandler.py @@ -165,7 +165,7 @@ def makeInput(self, pin): else: try: with gpiod.Chip(chip).request_lines( - consumer=self._conmsumer, + consumer=self._consumer, config={line: gpiod.LineSettings( direction = INPUT)}, ) as request: From 00db5f91b5897a053bbdb581051390ce943c9b38 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 6 Oct 2025 15:35:13 +0200 Subject: [PATCH 105/108] better action handling --- httpserver.py | 55 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/httpserver.py b/httpserver.py index 3ce2ace..14a87ea 100644 --- a/httpserver.py +++ b/httpserver.py @@ -376,12 +376,24 @@ def _give_dump_portal(self): ''' def _give_pin_portal(self, pin, control): + print(http.gpio.pins[pin]) ret = '

{} Pin Control

\n'.format(http.settings.name) - ret += '
{} : '.format(pin) - ret += '{}
\n'.format(http.settings.pin_state_names[http.gpio.pins[pin].value]) + ret += '
{}: '.format(pin) + if http.gpio.pins[pin].value is None: + ret += 'used
\n'.format(http.gpio.pins[pin].consumer) + ret += '
consumed by: \'' + ret += '{}\'
\n'\ + .format(http.gpio.pins[pin].consumer) + control = False + else: + ret += '{}\n'\ + .format(http.settings.pin_state_names[http.gpio.pins[pin].value]) ret += '
mode: {}
\n'\ .format(http.gpio.pins[pin].direction) - if http.gpio.pins[pin].direction == 'input' and control: + if http.gpio.pins[pin].value is None: + ret += '
'\ + 'Used pins cannot be controlled
' + elif http.gpio.pins[pin].direction == 'input' and control: for state in (0, 1): ret += '\n' + else: + ret += '
'\ + 'Client is not authorised to control pin
' ret += '\n' return ret @@ -497,11 +512,12 @@ def do_GET(self): pin = urlparse(self.path).path[1:] parsed_action = urlparse(self.path).query action = parsed_action.casefold() - allowed = False - for cidr in http.settings.webpins[pin]: - if ip_address(self.client_address[0]) in ip_network(cidr,strict=False): - allowed = True - if not allowed and action != '': + control = False + if http.gpio.pins[pin].value is not None: + for cidr in http.settings.webpins[pin]: + if ip_address(self.client_address[0]) in ip_network(cidr,strict=False): + control = True + if not control and action != '': self.send_error(403, 'Forbidden', 'Your IP address is not permitted to control this pin') print('Denied access to \'/{}\' from client at IP: {}'\ @@ -509,33 +525,30 @@ def do_GET(self): return if action == http.settings.pin_state_names[0].casefold(): http.gpio.setPin(pin, 0) - self._redirect() logging.info('Pin \'{}\' set output: {} via web ({})' .format(pin, http.settings.pin_state_names[0], self.client_address[0])) - return + self._redirect() elif action == http.settings.pin_state_names[1].casefold(): http.gpio.setPin(pin, 1) - self._redirect() logging.info('Pin \'{}\' set output: {} via web ({})' .format(pin, http.settings.pin_state_names[1], self.client_address[0])) - return + self._redirect() elif action == 'input': http.gpio.makeInput(pin) - self._redirect() logging.info('Pin \'{}\' set to input mode via web ({})' .format(pin, self.client_address[0])) - return + self._redirect() elif action != '': self.send_error(418, 'I\'m a {}, '\ 'I do not know how to \'{}\''\ .format(pin, parsed_action)) - return - self._set_headers() - response = self._give_head(" :: Pin Control :: {}".format(pin)) - response += self._give_pin_portal(pin, allowed) - response += self._give_timestamp() - response += self._give_foot(refresh=60) - self._write_dedented(response) + else: + self._set_headers() + response = self._give_head(" :: Pin Control :: {}".format(pin)) + response += self._give_pin_portal(pin, control) + response += self._give_timestamp() + response += self._give_foot(refresh=60) + self._write_dedented(response) elif urlparse(self.path).path == '/': # Main Page exclude = parse_qs(urlparse(self.path).query).get('exclude', '') From 1f8c05feaccce5a71b38105224c9d999e3077f94 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 6 Oct 2025 16:27:51 +0200 Subject: [PATCH 106/108] tweak --- httpserver.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/httpserver.py b/httpserver.py index 14a87ea..3527d72 100644 --- a/httpserver.py +++ b/httpserver.py @@ -376,7 +376,6 @@ def _give_dump_portal(self): ''' def _give_pin_portal(self, pin, control): - print(http.gpio.pins[pin]) ret = '

{} Pin Control

\n'.format(http.settings.name) ret += '
{}: '.format(pin) if http.gpio.pins[pin].value is None: @@ -384,7 +383,6 @@ def _give_pin_portal(self, pin, control): ret += '
consumed by: \'' ret += '{}\'
\n'\ .format(http.gpio.pins[pin].consumer) - control = False else: ret += '{}
\n'\ .format(http.settings.pin_state_names[http.gpio.pins[pin].value]) @@ -392,8 +390,8 @@ def _give_pin_portal(self, pin, control): .format(http.gpio.pins[pin].direction) if http.gpio.pins[pin].value is None: ret += '
'\ - 'Used pins cannot be controlled
' - elif http.gpio.pins[pin].direction == 'input' and control: + 'Pins used (consumed) by other processes cannot be controlled' + elif control and http.gpio.pins[pin].direction == 'input': for state in (0, 1): ret += '
'\ - f''\ - f'Cam viewer
\n'\ f''\ f'Action Log
GPIO
{name}:'\ - f'{http.settings.pin_state_names[http.data[item]]}
GPIO
{name}:'\ f'{http.settings.pin_state_names[http.data[item]]}
'\ - f''\ - f'{http.settings.button_label}: {state}
'\ f''\ @@ -418,33 +410,6 @@ def do_GET(self): self._set_icon_headers() with open(http.icon_file,'rb') as favicon: self.wfile.write(favicon.read()) - elif ((urlparse(self.path).path == '/' + http.settings.button_url) - and (len(http.settings.button_url) > 0) - and (http.settings.button_out > 0)): - # Web button control - parsed = parse_qs(urlparse(self.path).query).get('state', ['status']) - action = parsed[0] - if action != 'status': - logging.info(f'Web button triggered by: {self.client_address[0]}'\ - f' with action: {action}') - status, state = http.button_control(action) - self._set_headers() - response = self._give_head(f" :: {http.settings.button_label}") - response += f'

{status}

\n' - invert_state = http.settings.pin_state_names[not state] - response += f'''
\n''' - response += '
\n'\ - 'Home
\n' - response += self._give_timestamp() - response += '\n' - response += self._give_foot() - self._write_dedented(response) elif (urlparse(self.path).path == '/dump_gz') and http.db_dumpable: # Raw dump download start = time.time() diff --git a/load_config.py b/load_config.py index 08808fc..7b16244 100644 --- a/load_config.py +++ b/load_config.py @@ -106,7 +106,6 @@ def hexint(instring): self.web_show_cam = web.getboolean("show_cam") self.web_allow_dump = web.getboolean("allow_dump") self.web_allow_backup = web.getboolean("allow_backup") - self.web_show_control = web.getboolean("show_control") graph = config["graph"] self.graph_durations = graph.get("durations").split(',') @@ -176,17 +175,6 @@ def hexint(instring): self.animate_passes = animate.getint("passes") self.animate_speed = animate.getint("speed") - button = config["button"] - self.button_out = button.getint("out") - self.button_pin = button.getint("pin") - self.button_url = button.get("url") - self.button_label = button.get("label") - self.button_hold = button.getfloat("hold") - if self.button_out == 0: - self.button_name = 'Undefined' - else: - self.button_name = f'gpio-{self.button_out}' - debug = config["debug"] self.debug_http = debug.getboolean("http") self.debug_sigint = debug.getboolean("sigint") From 91abed24b31eac9be4c7cabed34e905fa14dfc4a Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 14 Aug 2025 14:37:11 +0200 Subject: [PATCH 028/108] fix defaults for recent changes --- defaults.ini | 66 ++++++++++++++++++++-------------------------------- 1 file changed, 25 insertions(+), 41 deletions(-) diff --git a/defaults.ini b/defaults.ini index 19201ca..fe3be25 100644 --- a/defaults.ini +++ b/defaults.ini @@ -32,7 +32,6 @@ pin_state_names = Off,On # since it can impact performance while running # allow_backup: Allows RRDB database backup triggering via web, be careful using this # since it can impact performance while running -# show_control: Include a link to the web pin control 'button' # [web] host = @@ -40,7 +39,19 @@ port = 7080 sensor_name = Room allow_dump = True allow_backup = True -show_control = True + +# Time intervals (seconds) for the main system action schedules +# data: Interval between main reading updates +# pin: Pins are checked for state changes this frequently +# rrd: Maximum age before cached RRD database updates are written +# ping: Timout for ping responses +# - must be > 4 to distinguish 'unavailable' vs 'not responding' in logs +# - will be reduced if it exceeds the data interval (above) -0.5s +[intervals] +data = 10 +pin = 1 +rrd = 300 +ping = 4 # RRD graphing options # durations: Default graph durations @@ -67,7 +78,7 @@ area_color = #D0E0E0#FFFFFF area_depth = 0 half_height = pin,net -# List of fixed links for the main page's footer. +# List of fixed links for the main page's footer (optional) # Give one link per line: # link_name = URL # Underscores in the name will be converted to spaces when displayed @@ -75,22 +86,23 @@ half_height = pin,net # [links] # Homepage = https://www.example.org/ # Webcam_Viewer = https://www.example.org/webcam/?action=stream -# junk = Some Junk.. +# Cool stuff = http://some.thing.cool/ # [links] -# To monitor GPIO pins a the gpiod chip path needs to be specified +# To monitor GPIO pins the gpiod chip path needs to be specified # - leave commented out to disable gpio pin monitoring +# eg: +#[gpio] +#chip = /dev/gpiochip0 # [gpio] -#chip = /dev/gpiochip0 # GPIO pins to monitor and log # - only pins from the specified gpio chip can be monitored # - see https:// ??????????????????????????????? # - an empty list disables pin monitoring -# -# Then pins are listed one per line: +# Pins are listed one per line: # = pin number # eg: # [pins] @@ -110,19 +122,6 @@ half_height = pin,net # [ping] -# Time intervals (seconds) for the main system action schedules -# pin: Pins are checked for state changes this frequently -# data: Interval between main reading updates -# rrd: Maximum age before cached RRD database updates are written -# ping: Timout for ping responses -# - must be > 4 to distinguish 'unavailable' vs 'not responding' in logs -# - will be reduced if it exceeds the data interval (above) -0.5s -[intervals] -pin = 10 -data = 10 -rrd = 300 -ping = 4 - # Action Logging # file_dir: Folder must be writable by the SBCEye process # file_name: .log @@ -149,8 +148,8 @@ backup_count = 10 backup_age = 7 backup_time = 23:45 -# OLED Status dsplay options # I2C Bus options +# only used if display or sensor enabled in [general] config # bus_id: The I2C bus to use, integer # sensor_addr: I2C address of the sensor # display_addr: I2C address of the display @@ -159,6 +158,10 @@ bus_id = 1 sensor_addr = 0x76 display_addr = 0x3c +# The following display, saver and animate options are only used +# if display is enabled in [general] config + +# OLED Status dsplay options # rotate: Is the display 'upside down'? # - generally the connections from the glass are at the bottom # contrast: This gives a limited brightness reduction (0-255) @@ -185,25 +188,6 @@ passtime = 3 passes = 2 speed = 16 -# We can control one pin via a either the web ui, and/or a physical button -# out: bcm pin number we want to control, '0' to disable -# pin: bcm pin number of button, '0' to disable button -# url: / for web control, blank to disable web control -# label: Used in web UI for the mane of the pin -# hold: Minimum press time, seconds (float), debounces and decouples the button -# eg: -# out = 7 -# pin = 27 -# url = lamp -# label = Workshop Lamp -# -[button] -out = 0 -pin = 0 -url = -label = -hold = 0.250 - # Enable extra debug # http: Log http requests to console (or syslog when run as a service) # sigint: restart on SIGINT instead of exiting From 83f9d37358846d3484bcd5006aa832a9d469a5a6 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 14 Aug 2025 14:51:19 +0200 Subject: [PATCH 029/108] pin numbering explanation --- defaults.ini | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/defaults.ini b/defaults.ini index fe3be25..a9018c2 100644 --- a/defaults.ini +++ b/defaults.ini @@ -100,7 +100,12 @@ half_height = pin,net # GPIO pins to monitor and log # - only pins from the specified gpio chip can be monitored -# - see https:// ??????????????????????????????? +# pins are specified with their 'index' on the gpiochip +# - in general this corresponds to the BCM pin numbering used +# in older Pi libraries. +# - for other architectures you need to reference the hardware +# documentation for your Board. +# - see https://raspberrypi.stackexchange.com/a/147309 # - an empty list disables pin monitoring # Pins are listed one per line: # = pin number From 325e71d8756cda3b5733b00220f19549bf2284aa Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 14 Aug 2025 15:11:48 +0200 Subject: [PATCH 030/108] service file comments --- SBCEye.service | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/SBCEye.service b/SBCEye.service index 3b6a3ce..281bf63 100644 --- a/SBCEye.service +++ b/SBCEye.service @@ -1,3 +1,12 @@ +# (as root) Place this file in /etc/systemd/service/ +# run 'systemctl reload-daemon' then +# 'systemctl enable --now SBCEye.service +# +# Note: assumes that SBCEye has it's own # user account ('eye') +# with a homedir at '/home/eye', the SBCEye repo checked out as `/home/eye/SBCEye' +# and that a virtual environment exists at '/home/eye/SBCEye/env' +# with the required libraries installed (see docs) + [Unit] Description=SBCEye monitoring script After=multi-user.target From d263f0c8e398a09f2d2b48780fdf2cd7d8972194 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 26 Aug 2025 14:47:29 +0200 Subject: [PATCH 031/108] comments --- bus_drivers.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bus_drivers.py b/bus_drivers.py index 97298c3..3a03fa3 100644 --- a/bus_drivers.py +++ b/bus_drivers.py @@ -41,6 +41,10 @@ def i2c_setup(settings): if display: # I2C OLED Display + # Uses the ssd1306 driver and libs from the luma libraries + # https://github.com/rm-hull/luma.core + # https://github.com/rm-hull/luma.oled + # pip install luma.core luma.oled try: from luma.oled.device import ssd1306 except ImportError as error: From 9822b632b395498ae10a1830948aa1f6cebdcdb8 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 26 Aug 2025 15:00:04 +0200 Subject: [PATCH 032/108] comments --- pinreader.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pinreader.py b/pinreader.py index 3054599..a00488f 100644 --- a/pinreader.py +++ b/pinreader.py @@ -3,6 +3,11 @@ provides: Pinreader: A class to update and log the pin statuses get_pin(pin): reads a bcm gpio pin and returns it's raw value + +requires: + the python gpiod bindings + https://pypi.org/project/gpiod/ + and gpiod system service running ''' import os From 558c65ce974f1d4b2077c6b61010ab2833b931a4 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 27 Aug 2025 10:17:02 +0200 Subject: [PATCH 033/108] logging tweak --- SBCEye.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SBCEye.py b/SBCEye.py index 9b6a3d8..b4ccd6e 100644 --- a/SBCEye.py +++ b/SBCEye.py @@ -188,8 +188,8 @@ def daily(): myself = os.path.basename(__file__) timestamp = time.strftime(settings.long_format) uptime = timedelta(seconds=int(time.time() - psutil.boot_time())) - logging.info(f'{settings.name} :: up {uptime}') - print(f'{myself} :: {timestamp} :: {settings.name} :: up {uptime}',flush=True) + logging.info(f'{settings.name} :: system uptime {uptime}') + print(f'{myself} :: {timestamp} :: {settings.name} :: system uptime {uptime}',flush=True) def handle_signal(sig, *_): '''Handle common signals''' From db95b9e883a628814cb2e22c6d6f5099941bf9e2 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 27 Aug 2025 11:03:13 +0200 Subject: [PATCH 034/108] db init log tweak --- robin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/robin.py b/robin.py index fb32a9a..832b492 100644 --- a/robin.py +++ b/robin.py @@ -134,7 +134,7 @@ def __init__(self, s, data): mini = self.data_sources[source][0] maxi = self.data_sources[source][1] ds_list.append(f'DS:{source}:GAUGE:60:{mini}:{maxi}') - print(f" data source: {source} ({mini},{maxi})") + print(f" added data source: {source} ({mini},{maxi})") args = [str(self.db_file)] if source_file.is_file(): print(f'Importing from previous {source_file}') @@ -159,7 +159,7 @@ def __init__(self, s, data): if not source in existing_sources: mini = self.data_sources[source][0] maxi = self.data_sources[source][1] - print(f"Adding: {source} ({mini},{maxi}) to {self.db_file}") + print(f"Added data source: {source} ({mini},{maxi}) to {self.db_file}") rrdtool.tune( str(self.db_file), f"DS:{source}:GAUGE:60:{mini}:{maxi}") From f0af7960ac9926b769d31678342e53d35908dffd Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 27 Aug 2025 22:56:52 +0200 Subject: [PATCH 035/108] test gpiod lib import --- pinreader.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/pinreader.py b/pinreader.py index a00488f..60c1e3c 100644 --- a/pinreader.py +++ b/pinreader.py @@ -12,7 +12,12 @@ import os import logging -import gpiod + +gpiod_available = True +try: + import gpiod +except ImportError as error: + gpiod_available = False class Pinreader: '''Read and update pin status @@ -40,6 +45,10 @@ def __init__(self, settings, data): if self._gpio_chip is None: print('No GPIO chip specified in config, gpio monitoring disabled') return + if not gpiod_available: + print('ERROR: GPIO chip specified but python gpiod library unavailable, '\ + 'gpio monitoring disabled') + return if not gpiod.is_gpiochip_device(self._gpio_chip): print('ERROR: GPIO chip specified in config ({}) is not a libgpiod '\ 'compatible device, gpio monitoring disabled'.format(self._gpio_chip)) From e96625165cfbb31d2872af7611586b4823ad0474 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 2 Sep 2025 12:35:18 +0200 Subject: [PATCH 036/108] WIP: Broken: new pinreader lib --- pinreader.py | 263 +++++++++++++++++++++++++++++---------------------- 1 file changed, 148 insertions(+), 115 deletions(-) diff --git a/pinreader.py b/pinreader.py index 60c1e3c..6a68a63 100644 --- a/pinreader.py +++ b/pinreader.py @@ -1,125 +1,158 @@ -'''Really simple and direct reading of BCM GPIO pins +import gpiod +from dataclasses import dataclass +from os import getpid +from re import search -provides: - Pinreader: A class to update and log the pin statuses - get_pin(pin): reads a bcm gpio pin and returns it's raw value +# Needs gpiod bindings at V2.0 or later, standard debian12/bookworm is v1.6 +# use a virtualenv and 'pip install --upgrade gpiod' in that. +if int(search('^[0-9]+', gpiod.__version__).group(0)) < 2: + raise ImportError('gpiod library version too low ({}), '\ + 'pinreader requires gpiod version 2 or above' + .format(gpiod.__version__)) +@dataclass +class PinInstance: + ''' Class used for a single pin instance ''' + chip: None + line: None + consumer: None + direction: None + value: None -requires: - the python gpiod bindings - https://pypi.org/project/gpiod/ - and gpiod system service running -''' + def __init__(self, chip, line): + self._pid = getpid() # record PID of process that init'd the class + if not gpiod.is_gpiochip_device(chip): + raise ValueError('\'{}\' is not a valid GPIO device'.format(chip)) + self.chip = chip + self._chip = gpiod.Chip(chip) + if line < 0 or line >= self._chip.get_info().num_lines: + raise ValueError('Requested line ({}) outside range for \'{}\' ({} lines)' + .format(line, chip, self._chip.get_info().num_lines)) + self.line = line + info = self._chip.get_line_info(self.line) + self.name = info.name + self.get() + + def __str__(self): + consumer = None if self.consumer is None else '\'{}\''.format(self.consumer) + value = 'n/a' if self.value is None else self.value + return 'consumer: {}, value: {}'.format(consumer, value) + + def _value(self): + try: + with self._chip.request_lines(consumer='pinstance-{}'.format(self._pid), + config={self.line: None}) as request: + val = request.get_values()[0] + return 1 if val == gpiod.line.Value.ACTIVE else 0 + except: + # silently return None if the read fails + # - there are possible race conditions if this pin is simultaneously + # accessed by another program (or instance of this class..) etc. + return None -import os -import logging + def get(self): + line = self._chip.get_line_info(self.line) + self.direction = 'input' if line.direction == gpiod.line.Direction.INPUT else 'output' + if line.used: + self.consumer = line.consumer + self.value = None + else: + self.consumer = None + self.value = self._value() + return self.value -gpiod_available = True -try: - import gpiod -except ImportError as error: - gpiod_available = False +# use @dataclass to make immutable +@dataclass(frozen=True) +class PinReader(dict): + def __init__(self, pinlist, tolerant=False): + super().__init__({}) + for label in pinlist: + try: + super().__setitem__(label, PinInstance(pinlist[label][0], pinlist[label][1])) + except ValueError as e: + if not tolerant: + raise ValueError('failed to set up pin \'{}\': {}' + .format(label, e)) -class Pinreader: - '''Read and update pin status + def __str__(self): + ret = '' + for line in super().__iter__(): + pin = super().__getitem__(line) + if pin.value is not None: + value = 'low' if pin.value == 0 else 'high' + ret += '{} = {} ({})\n'.format(line, value, pin.direction) + else: + ret += '{} = n/a (\'{}\')\n'.format(line, pin.consumer) + return ret - Reads the currrent (boolean) status of a set of gipo pins defined in a dictionary - Updates the relevant entries in data{} and logs state changes + def update(self): + for line in super().keys(): + super().__getitem__(line).get() - parameters: - settings: (tuple) consisting of: - chip: (str) path to the gpio chip device node, or None to disable - map: (dict) pin names and BCM GPIO number - state_names: (tuple) localised names for pin states (text,text) - data: the main data{} dictionary, a key/value pair; 'pin-=value' - will be added to it and the vaue updated with pin state changes. +''' + HELPER +''' - provides: - update_pins(): processes and updates the pins +def find_pins(device, regex, verbose=False): + if not gpiod.is_gpiochip_device(device): + raise ValueError('\'{}\' is not a valid GPIO device'.format(device)) + pinlist = {} + with gpiod.Chip(device) as chip: + if verbose: + print('Searching for pins on \'{}\' that match regex: {}' + .format(chip.path, regex)) + for line in range(0, chip.get_info().num_lines): + name = chip.get_line_info(line).name + name = str(line) if name is None else name + if search(regex, name): + pinlist[name] = (chip.path, line) + if verbose: + print(' adding: \'{}\' (line {})'.format(name, line)) + elif verbose: + print(' skipping: \'{}\' (line {})'.format(name, line)) + return pinlist + +if __name__ == "__main__": + ''' + DEMO ''' - def __init__(self, settings, data): - '''Setup and do initial reading''' - (self._gpio_chip, self._map, self._state_names) = settings - self.data = data - self.available = False - if self._gpio_chip is None: - print('No GPIO chip specified in config, gpio monitoring disabled') - return - if not gpiod_available: - print('ERROR: GPIO chip specified but python gpiod library unavailable, '\ - 'gpio monitoring disabled') - return - if not gpiod.is_gpiochip_device(self._gpio_chip): - print('ERROR: GPIO chip specified in config ({}) is not a libgpiod '\ - 'compatible device, gpio monitoring disabled'.format(self._gpio_chip)) - self._gpio_chip = None - return - self._num_lines = gpiod.Chip(self._gpio_chip).get_info().num_lines - print('GPIO chip is "{}" with {} lines'.format(self._gpio_chip, self._num_lines)) - if not self._setup_pins(): - print('No valid GPIO pins listed in config, gpio monitoring disabled') - return - values = self._get_pins(self._pins) - if values is None: - print('GPIO pin states could not be read, gpio monitoring disabled') - return - for index, name, value in zip(self._pins, self._names, values): - data[f'pin-{name}'] = int(value) - logging.info('{} index {} configured as "{}", current value: {}' - .format(self._gpio_chip, index, name, self._state_names[int(value)])) - print('GPIO monitoring active and logging enabled') - logging.info('GPIO monitoring active and logging enabled') - self.available = True - - def _setup_pins(self): - '''Check the pins listed in the pin map are validi and create lists''' - self._pins = [] - self._names = [] - for name, pin in self._map.items(): - if pin > 0 and pin < self._num_lines: - self._pins.append(pin) - self._names.append(name) - else: - print('ERROR: gpio chip index ({}) for "{}" is out of range'.format(pin, name)) - if len(self._pins) == 0: - return False - return True - - def _get_pins(self, pins): - '''Get the value of all pins using gpiod, do not change pin state''' - values = [] - try: - with gpiod.request_lines( - self._gpio_chip, - consumer="SBCEye-pinreader", - config={tuple(pins): None}, - ) as request: - line_values = request.get_values() - for value in line_values: - if value == gpiod.line.Value.ACTIVE: - values.append(int(1)) - else: - values.append(int(0)) - except Exception as e: - print('Error getting pin values:\n{}'.format(e)) - return None - return values - - def update_pins(self): - '''Check if any pins have changed state, and log if so - updates the main data{} dictionary with new state - no parameters, no return''' - # Update current values list - values = self._get_pins(self._pins) - if values is None: - # The try:except in the system log should show the actual errors - # Some sort of warn/fail tracking might be needed if issues occcur here a lot. - logging.info('GPIO pin read failed (see syslog)') - return - # Now go through pins, store data and see what has changed - for index, name, value in zip(self._pins, self._names, values): - if value != self.data[f"pin-{name}"]: - # Pin has changed state, store new state and log - self.data[f'pin-{name}'] = value - logging.info('{} ({}:{}): {}'.format(name, self._gpio_chip, index, - self._state_names[int(value)])) + from sys import argv + from time import asctime, sleep + from argparse import ArgumentParser + + self = argv[0] + desc = 'Display GPIO pin states and value (if availabe) for matching pins '\ + 'on the specified gpio chip. Use \'gpioinfo \' to see available pins.' + elog = 'IMPORTANT: use regex wisely, DO NOT use a generic wildcard such as \'.*\'. '\ + 'Requesting pins that are used by the OS may cause conflicts. eg: reading the value '\ + 'of pins labelled \'SD_*\' can cause Disk I/O errors on Raspberry PI\'s.)' + parser = ArgumentParser(prog=self, description=desc, epilog=elog) + parser.add_argument("-i", "--interval", default=0, help="Interval between updates in seconds, default: 0 (run once)", type=float) + parser.add_argument("-c", "--chip", default="/dev/gpiochip0", help="GPIO chip device path, default: /dev/gpiochip0", type=str) + parser.add_argument("-r", "--regex", default="^GPIO[0-9]+$", help="RegEX to select pin names, default: ^GPIO[0-9]+$", type=str) + parser.add_argument("-v", "--verbose", action="store_true", help="Show chip and regex processing") + args = parser.parse_args() + + # Seach for pins using the device and regex determined above + pinlist = find_pins(args.chip, args.regex, args.verbose) + if len(pinlist) == 0: + print('No matching pins, exiting') + exit() + + # Instantiate the pinreader class on this list + pinstates = PinReader(pinlist) + + # little function to collate output + def out(): + return '{}: {}\n{}'.format(self, asctime(), pinstates) + + # Show initial output + print(out(), end='') + + # Now loop forever showing the values if needed + while args.interval != 0: + sleep(args.interval) + pinstates.update() + print("\033[F" * (len(pinstates) + 1), end='') + for line in out().split('\n')[:-1]: + print('\033[K{}'.format(line)) From 738fb0718404fabd7f5afdcbf9693b01b40d55fe Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 2 Sep 2025 15:12:20 +0200 Subject: [PATCH 037/108] remove dataclass stuff --- pinreader.py | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/pinreader.py b/pinreader.py index 6a68a63..e3ca199 100644 --- a/pinreader.py +++ b/pinreader.py @@ -1,25 +1,20 @@ import gpiod -from dataclasses import dataclass from os import getpid from re import search # Needs gpiod bindings at V2.0 or later, standard debian12/bookworm is v1.6 -# use a virtualenv and 'pip install --upgrade gpiod' in that. +# use a virtualenv and 'pip install --upgrade gpiod' as needed. if int(search('^[0-9]+', gpiod.__version__).group(0)) < 2: - raise ImportError('gpiod library version too low ({}), '\ - 'pinreader requires gpiod version 2 or above' + raise ImportError('gpiod bindings library version too low ({}), '\ + 'pinreader requires gpiod v2.x.x or later.' .format(gpiod.__version__)) -@dataclass -class PinInstance: - ''' Class used for a single pin instance ''' - chip: None - line: None - consumer: None - direction: None - value: None +''' +PinInstance class +''' +class PinInstance: def __init__(self, chip, line): - self._pid = getpid() # record PID of process that init'd the class + self._pid = getpid() # record PID of process that called init() if not gpiod.is_gpiochip_device(chip): raise ValueError('\'{}\' is not a valid GPIO device'.format(chip)) self.chip = chip @@ -32,6 +27,10 @@ def __init__(self, chip, line): self.name = info.name self.get() + def __repr__(self): + return 'PinInstance(chip={} line={} consumer={} direction={} value={})'\ + .format(self.chip, self.line, self.consumer, self.direction, self.value) + def __str__(self): consumer = None if self.consumer is None else '\'{}\''.format(self.consumer) value = 'n/a' if self.value is None else self.value @@ -60,8 +59,10 @@ def get(self): self.value = self._value() return self.value -# use @dataclass to make immutable -@dataclass(frozen=True) +''' +PinReader class (dict) + +''' class PinReader(dict): def __init__(self, pinlist, tolerant=False): super().__init__({}) @@ -82,7 +83,7 @@ def __str__(self): ret += '{} = {} ({})\n'.format(line, value, pin.direction) else: ret += '{} = n/a (\'{}\')\n'.format(line, pin.consumer) - return ret + return ret.rstrip('\n') def update(self): for line in super().keys(): @@ -144,7 +145,7 @@ def find_pins(device, regex, verbose=False): # little function to collate output def out(): - return '{}: {}\n{}'.format(self, asctime(), pinstates) + return '{}: {}\n{}\n'.format(self, asctime(), pinstates) # Show initial output print(out(), end='') From b25112c56bd3509ac23f28591be981c9ae64bc3c Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 2 Sep 2025 15:55:07 +0200 Subject: [PATCH 038/108] serve pinlist from config --- defaults.ini | 23 +++++++---------------- load_config.py | 23 ++++++++++++----------- 2 files changed, 19 insertions(+), 27 deletions(-) diff --git a/defaults.ini b/defaults.ini index a9018c2..a432328 100644 --- a/defaults.ini +++ b/defaults.ini @@ -90,30 +90,21 @@ half_height = pin,net # [links] -# To monitor GPIO pins the gpiod chip path needs to be specified -# - leave commented out to disable gpio pin monitoring -# eg: -#[gpio] -#chip = /dev/gpiochip0 -# -[gpio] - # GPIO pins to monitor and log -# - only pins from the specified gpio chip can be monitored -# pins are specified with their 'index' on the gpiochip -# - in general this corresponds to the BCM pin numbering used -# in older Pi libraries. +# pins are specified with their gpio chip device and index. +# - in general the gpiochip0 pin index numbers correspond to +# the BCM pin numbering scheme used in older Pi libraries. # - for other architectures you need to reference the hardware # documentation for your Board. # - see https://raspberrypi.stackexchange.com/a/147309 # - an empty list disables pin monitoring # Pins are listed one per line: -# = pin number +# = gpiochip device path, pin number # eg: # [pins] -# Lamp = 7 -# Ventilator = 8 -# Printer = 25 +# Lamp = /dev/gpiochip0, 7 +# Ventilator = /dev/gpiochip0, 8 +# Printer = /dev/gpiochip0, 25 # [pins] diff --git a/load_config.py b/load_config.py index 7b16244..08bfad9 100644 --- a/load_config.py +++ b/load_config.py @@ -118,22 +118,23 @@ def hexint(instring): self.graph_half_height = graph.get("half_height").split(',') self.links= {} - for name in config["links"]: + links = config["links"] + for name in links: real = name.replace('_',' ') - self.links[real] = config.get("links",name) + self.links[real] = links.get(name) - try: - self.gpio_chip = config.get("gpio","chip") - except configparser.NoOptionError: - self.gpio_chip = None - self.pin_map = {} - for pin in config["pins"]: - self.pin_map[pin] = config.getint("pins",pin) + self.pinlist = {} + pins = config["pins"] + for pin in pins: + line = pins.get(pin).split(',') + self.pinlist[pin] = (line[0], int(line[1])) + self.net_map = {} - for host in config["ping"]: - self.net_map[host] = config.get("ping",host) + ping = config["ping"] + for host in ping: + self.net_map[host] = ping.get(host) intervals = config["intervals"] self.pin_interval = intervals.getint("pin") From fe388cf4a0e0fb7d92d5f690030747f265495861 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 2 Sep 2025 17:51:41 +0200 Subject: [PATCH 039/108] new pin monitor, wip, working --- SBCEye.py | 51 +++++++++++++++++++++++++++++++++++++++++---------- defaults.ini | 2 +- httpserver.py | 11 ++++++++--- pinreader.py | 2 +- robin.py | 2 +- 5 files changed, 52 insertions(+), 16 deletions(-) diff --git a/SBCEye.py b/SBCEye.py index b4ccd6e..03beb6c 100644 --- a/SBCEye.py +++ b/SBCEye.py @@ -53,7 +53,7 @@ from robin import Robin from httpserver import serve_http from netreader import Netreader -from pinreader import Pinreader +from pinreader import PinReader from bus_drivers import i2c_setup # Re-nice to reduce blocking of other processes @@ -142,8 +142,7 @@ def __delitem__(self, item): # Local functions def update_system(): - '''Get current environmental and system data, called on a schedule - ''' + '''Get current environmental and system data, called on a schedule''' data['sys-temp'] = psutil.sensors_temperatures()[cpu_thermal_device][0].current data['sys-load'] = psutil.getloadavg()[0] data["sys-freq"] = psutil.cpu_freq().current @@ -165,8 +164,7 @@ def update_system(): counter["sys-cpu-int"] = int_count def update_sensors(): - '''Get current environmental sensor data - ''' + '''Get current environmental sensor data''' if bme: bme.update_sensor() data['env-temp'] = bme.temperature @@ -183,6 +181,38 @@ def update_data(): net.update(data) rrd.update(data) +def setup_pins(): + '''collects pin data, logs setup and initial state, returns initial state''' + ret = {} + if not pins: + print('NO PINS!!!!!') # <- log this too + else: + for pin in pins: + if pins[pin].value is None: + print('pin: {}, unavailable, used as: {}'.format(pin, pins[pin].consumer)) + data['pin-{}'.format(pin)] = 'U' + else: + print('pin: {}, {}, {}'.format(pin, pins[pin].direction, + settings.pin_state_names[pins[pin].value])) + data['pin-{}'.format(pin)] = pins[pin].value + ret[pin] = pins[pin].value + return ret + +def update_pins(): + '''Updates pin data, and logs state changes, + called at different schedule to other updaters''' + pins.update() + for pin in pins: + if pins[pin].value != pinmemory[pin]: + if pins[pin].value is None: + print('pin: {}, unavailable, used as: {}'.format(pin, pins[pin].consumer)) + data['pin-{}'.format(pin)] = 'U' + else: + print('pin: {}, {}, {}'.format(pin, pins[pin].direction, + settings.pin_state_names[pins[pin].value])) + data['pin-{}'.format(pin)] = pins[pin].value + pinmemory[pin] = pins[pin].value + def daily(): '''Remind everybody we are alive''' myself = os.path.basename(__file__) @@ -253,12 +283,13 @@ def handle_exit(): # Populate initial sensor data update_sensors() + # GPIO monitoring + pins = PinReader(settings.pinlist, tolerant=True) + pinmemory = setup_pins() + # Network (ping) monitoring net = Netreader((settings.net_map, settings.net_timeout), data) - # GPIO Pin monitoring - pins = Pinreader((settings.gpio_chip, settings.pin_map, settings.pin_state_names), data) - # RRD init now that the data{} structure is populated rrd = Robin(settings, data) @@ -273,8 +304,8 @@ def handle_exit(): # Schedule pin monitoring, database updates and logging events schedule.every(settings.data_interval).seconds.do(update_data) - if pins.available: - schedule.every(settings.pin_interval).seconds.do(pins.update_pins) + if pins: + schedule.every(settings.pin_interval).seconds.do(update_pins) if settings.log_daily: schedule.every().day.at("00:00").do(daily) diff --git a/defaults.ini b/defaults.ini index a432328..ef88654 100644 --- a/defaults.ini +++ b/defaults.ini @@ -92,7 +92,7 @@ half_height = pin,net # GPIO pins to monitor and log # pins are specified with their gpio chip device and index. -# - in general the gpiochip0 pin index numbers correspond to +# - in general the gpiochip0 pin index numbers correspond to # the BCM pin numbering scheme used in older Pi libraries. # - for other architectures you need to reference the hardware # documentation for your Board. diff --git a/httpserver.py b/httpserver.py index 09c44b8..a8e0f2d 100644 --- a/httpserver.py +++ b/httpserver.py @@ -243,9 +243,14 @@ def _give_pins(self): if len(http.data.keys() & pinlist.keys()) > 0: ret += '
GPIO
{name}:'\ - f'{http.settings.pin_state_names[http.data[item]]}
{name}:'\ + f'n/a
{name}:'\ + f'{http.settings.pin_state_names[http.data[item]]}
Ping
{name}:' + for item,name in targetlist.items(): + ret += f'
{name}:
Fail
{http.data[item]:.1f}'\ ' ms'\ '
GPIO
{name}:
{name}:'\ - f'n/a
n/a [{http.pins[name].consumer}]
{name}:'\ - f'{http.settings.pin_state_names[http.data[item]]}
{http.settings.pin_state_names[http.data[item]]} ({http.pins[name].direction})
Graphs
\n' + ret += '
\n' for duration in http.settings.graph_durations: if duration != skip: - ret += f' '\ f'{duration} \n' else: - ret += f' {duration} \n' + ret += f'{duration} \n' if len(skip) > 0: ret += '
'\ 'Home\n' @@ -277,14 +278,13 @@ def _give_graphlinks(self, skip=""): def _give_links(self): # Links to the graph pages ret = f'{self._give_graphlinks()}' - # Links to the pin contol, cam show/hide and log pages - #ret += f'
'\ + ret += f'
'\ f''\ f'{link}
\n'\ - f''\ + ret += f'
\n'\ + f''\ f'Action Log
GPIO
{name}:
{name}:n/a [{http.pins[name].consumer}]
{consumer}
{http.settings.pin_state_names[http.data[item]]} ({http.pins[name].direction})
{direction}
{name}:n/a{consumer}
{consumer}
{http.settings.pin_state_names[http.data[item]]}
{name}:n/a{consumer}
{}{http.settings.pin_state_names[http.data[item]]}{direction}
{}{}'\ + '{}
GPIO
{name}:{}{}{}