diff --git a/Software/Source/debian-base/pijuice-poweroff.service b/Software/Source/debian-base/pijuice-poweroff.service new file mode 100644 index 00000000..ee62fa6c --- /dev/null +++ b/Software/Source/debian-base/pijuice-poweroff.service @@ -0,0 +1,13 @@ +[Unit] +Description=PiJuice poweroff +Before=poweroff.target halt.target +DefaultDependencies=no + +[Service] +Type=oneshot +User=pijuice +WorkingDirectory=/var/lib/pijuice/ +ExecStart=/usr/bin/pijuice_sys poweroff + +[Install] +WantedBy=poweroff.target halt.target diff --git a/Software/Source/debian-base/pijuice.service b/Software/Source/debian-base/pijuice.service index c0a68018..aea5f9dd 100644 --- a/Software/Source/debian-base/pijuice.service +++ b/Software/Source/debian-base/pijuice.service @@ -1,13 +1,12 @@ [Unit] -Description=PiJuice status service +Description=PiJuice system daemon After=network.target [Service] Type=idle User=pijuice WorkingDirectory=/var/lib/pijuice/ -ExecStart=/usr/bin/pijuice_sys.py -ExecStopPost=/usr/bin/pijuice_sys.py stop +ExecStart=/usr/bin/pijuice_sys daemon Restart=always [Install] diff --git a/Software/Source/pijuice_cmd/__init__.py b/Software/Source/pijuice_cmd/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/Software/Source/pijuice_cmd/__main__.py b/Software/Source/pijuice_cmd/__main__.py new file mode 100644 index 00000000..197e566f --- /dev/null +++ b/Software/Source/pijuice_cmd/__main__.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 +from pijuice_cmd.cli import main + +if __name__ == "__main__": + main() diff --git a/Software/Source/pijuice_cmd/cli.py b/Software/Source/pijuice_cmd/cli.py new file mode 100644 index 00000000..355ab77a --- /dev/null +++ b/Software/Source/pijuice_cmd/cli.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +import argparse +import os +import sys + +from pijuice import PiJuice + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="PiJuice Cmd") + subparsers = parser.add_subparsers( + help="sub-command help", dest="command", required=True + ) + + parser.add_argument( + "-B", + "--i2c-bus", + metavar="BUS", + type=int, + default=os.environ.get("PIJUICE_I2C_BUS", 1), + help="I2C Bus (default: %(default)s)", + ) + parser.add_argument( + "-A", + "--i2c-address", + metavar="ADDRESS", + type=int, + default=os.environ.get("PIJUICE_I2C_ADDRESS", 0x14), + help="I2C Address (default: %(default)s)", + ) + parser.add_argument( + "-q", + "--quiet", + action="store_true", + help="Disable output", + ) + + parser_wakeup_on_charge = subparsers.add_parser( + "wakeup-on-charge", help="configure wakeup-on-charge" + ) + parser_wakeup_on_charge.add_argument( + "-D", + "--disabled", + action="store_true", + help="Whether to disable wakeup-on-charge", + ) + parser_wakeup_on_charge.add_argument( + "-T", + "--trigger-level", + metavar="TRIGGER_LEVEL", + type=int, + default=100, + help="Battery-charge to wakeup at (default: %(default)s)", + ) + + parser_system_power_switch = subparsers.add_parser( + "system-power-switch", help="configure system-power-switch" + ) + parser_system_power_switch.add_argument( + "state", + type=int, + choices=[0, 500, 2100], + help="Current limit for VSYS pin (in mA)", + ) + + def validate_power_off_delay(value): + try: + value = int(value) + except ValueError: + raise argparse.ArgumentTypeError(f"invalid int value: '{value}'") + if not 0 <= value <= 65535: + raise argparse.ArgumentTypeError(f"not within range: {value}") + return value + + parser_power_off = subparsers.add_parser("power-off", help="configure power-off") + parser_power_off.add_argument( + "-D", + "--delay", + metavar="DELAY", + type=validate_power_off_delay, + default=0, + help="Delay before powering off (0 to 65535) (default: %(default)s)", + ) + + def validate_led_blink_count(value): + try: + value = int(value) + except ValueError: + raise argparse.ArgumentTypeError(f"invalid int value: '{value}'") + if not 0 < value < 255: + raise argparse.ArgumentTypeError(f"not within range: {value}") + return value + + def validate_led_blink_rgb(value): + values = value.split(",") + if not len(values) == 3: + raise argparse.ArgumentTypeError(f"value not in format 'R,G,B': '{value}'") + try: + values = [int(v) for v in values] + except ValueError: + raise argparse.ArgumentTypeError(f"invalid int values: '{value}'") + return values + + def validate_led_blink_period(value): + try: + value = int(value) + except ValueError: + raise argparse.ArgumentTypeError(f"invalid int value: '{value}'") + if not 10 <= value < 2550: + raise argparse.ArgumentTypeError(f"not within range: {value}") + return value + + parser_led_blink = subparsers.add_parser("led-blink", help="run led-blink pattern") + parser_led_blink.add_argument( + "-L", + "--led", + metavar="LED", + type=str, + choices=["D1", "D2"], + default="D2", + help="Led to blink (default: %(default)s)", + ) + parser_led_blink.add_argument( + "-C", + "--count", + metavar="COUNT", + type=validate_led_blink_count, + default=1, + help="Amount of repetitions (default: %(default)s)", + ) + parser_led_blink.add_argument( + "--rgb1", + metavar="R,G,B", + type=validate_led_blink_rgb, + default="150,0,0", + help="RGB components of first blink (default: %(default)s)", + ) + parser_led_blink.add_argument( + "--period1", + metavar="PERIOD", + type=validate_led_blink_period, + default=200, + help="Duration of first blink in milliseconds (default: %(default)s)", + ) + parser_led_blink.add_argument( + "--rgb2", + metavar="R,G,B", + type=validate_led_blink_rgb, + default="0,100,0", + help="RGB components of second blink (default: %(default)s)", + ) + parser_led_blink.add_argument( + "--period2", + metavar="PERIOD", + type=validate_led_blink_period, + default=200, + help="Duration of second blink in milliseconds (default: %(default)s)", + ) + + return parser.parse_args() + + +def main(): + args = parse_args() + try: + pijuice = PiJuice( + bus=args.i2c_bus, + address=args.i2c_address, + ) + except: + print("Failed to connect to PiJuice") + sys.exit(1) + + if args.command == "wakeup-on-charge": + if args.disabled: + pijuice.power.SetWakeUpOnCharge(arg="DISABLED") + if not args.quiet: + print("Disabled wakeup-on-charge") + else: + pijuice.power.SetWakeUpOnCharge(arg=args.trigger_level) + if not args.quiet: + print( + f"Enabled wakeup-on-charge with trigger-level: {args.trigger_level}" + ) + elif args.command == "system-power-switch": + pijuice.power.SetSystemPowerSwitch(state=args.state) + if not args.quiet: + print(f"Set System Power-Switch to: {args.state}mA") + elif args.command == "power-off": + pijuice.power.SetPowerOff(delay=args.delay) + if not args.quiet: + print(f"Set PowerOff with delay: {args.delay}s") + elif args.command == "led-blink": + pijuice.status.SetLedBlink( + led=args.led, + count=args.count, + rgb1=args.rgb1, + period1=args.period1, + rgb2=args.rgb2, + period2=args.period2, + ) + + +if __name__ == "__main__": + main() diff --git a/Software/Source/pijuice_sys/__init__.py b/Software/Source/pijuice_sys/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/Software/Source/pijuice_sys/__main__.py b/Software/Source/pijuice_sys/__main__.py new file mode 100755 index 00000000..3ed316fb --- /dev/null +++ b/Software/Source/pijuice_sys/__main__.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 +from pijuice_sys.cli import main + +if __name__ == "__main__": + main() diff --git a/Software/Source/pijuice_sys/cli.py b/Software/Source/pijuice_sys/cli.py new file mode 100644 index 00000000..d75315b7 --- /dev/null +++ b/Software/Source/pijuice_sys/cli.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +import argparse +import asyncio +import logging +import pathlib +import sys +import os + +from pijuice_sys.daemon import ( + PiJuiceSys, + PiJuiceSysInterfaceError, + PiJuiceSysConfigValidationError, +) + + +logging.basicConfig(level=logging.WARN) +logger = logging.getLogger(__package__) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="PiJuice System Daemon") + subparsers = parser.add_subparsers( + help="sub-command help", dest="command", required=True + ) + + parser.add_argument( + "-v", + "--verbose", + action="count", + default=0, + help="Increase verbosity level", + ) + parser.add_argument( + "-B", + "--i2c-bus", + metavar="BUS", + type=int, + default=os.environ.get("PIJUICE_I2C_BUS", 1), + help="I2C Bus (default: %(default)s)", + ) + parser.add_argument( + "-A", + "--i2c-address", + metavar="ADDRESS", + type=int, + default=os.environ.get("PIJUICE_I2C_ADDRESS", 0x14), + help="I2C Address (default: %(default)s)", + ) + parser.add_argument( + "-C", + "--config-file", + metavar="FILE", + dest="config_file_path", + type=pathlib.Path, + default=os.environ.get( + "PIJUICE_CONFIG_FILE", "/var/lib/pijuice/pijuice_config.JSON" + ), + help="Path to read Configuration file from (default: %(default)s)", + ) + + parser_daemon = subparsers.add_parser("daemon", help="run daemon") + parser_daemon.add_argument( + "--pid-file", + metavar="FILE", + dest="pid_file_path", + type=pathlib.Path, + default=os.environ.get("PIJUICE_PID_FILE", "/tmp/pijuice_sys.pid"), + help="Path to create pid-file at (default: %(default)s)", + ) + parser_daemon.add_argument( + "--poll-interval", + metavar="INTERVAL", + type=float, + default=os.environ.get("PIJUICE_POLL_INTERVAL", 5.0), + help="Interval at which to poll status from pijuice (default: %(default)s)", + ) + parser_daemon.add_argument( + "--button-poll-interval", + metavar="INTERVAL", + type=float, + default=os.environ.get("PIJUICE_BUTTON_POLL_INTERVAL", 1.0), + help="Interval at which to poll button-events from pijuice (default: %(default)s)", + ) + + parser_poweroff = subparsers.add_parser("poweroff", help="execute poweroff command") + return parser.parse_args() + + +async def run(): + args = parse_args() + if args.verbose == 1: + logger.setLevel(logging.INFO) + elif args.verbose == 2: + logger.setLevel(logging.DEBUG) + + try: + pijuice_sys = PiJuiceSys( + i2c_bus=args.i2c_bus, + i2c_address=args.i2c_address, + config_file_path=args.config_file_path, + ) + except PiJuiceSysInterfaceError: + logger.error("failed to initialize PiJuice interface") + sys.exit(1) + except PiJuiceSysConfigValidationError as e: + logger.error(f"config validation failed: {e}") + sys.exit(1) + + if args.command == "poweroff": + await pijuice_sys.execute_poweroff_command() + elif args.command == "daemon": + logger.debug("starting daemon") + await pijuice_sys.run_daemon( + pid_file_path=args.pid_file_path, + poll_interval=args.poll_interval, + button_poll_interval=args.button_poll_interval, + ) + + +def main(): + try: + asyncio.run(run()) + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() diff --git a/Software/Source/pijuice_sys/daemon.py b/Software/Source/pijuice_sys/daemon.py new file mode 100644 index 00000000..2eef5982 --- /dev/null +++ b/Software/Source/pijuice_sys/daemon.py @@ -0,0 +1,338 @@ +import asyncio +import grp +import importlib.resources +import json +import logging +import os +import pathlib +import pwd +import signal +import stat +import subprocess +import sys + +import marshmallow + +from pijuice import PiJuice +from pijuice_sys.schema import ConfigSchema +from pijuice_sys.utils import pidfile + + +logger = logging.getLogger(__name__) + + +class PiJuiceSysInterfaceError(Exception): + pass + + +class PiJuiceSysConfigValidationError(Exception): + pass + + +class PiJuiceSys: + def __init__(self, i2c_bus, i2c_address, config_file_path): + try: + self.pijuice = PiJuice(bus=i2c_bus, address=i2c_address) + except: + raise PiJuiceSysInterfaceError() + self.config_file_path = config_file_path + self._daemon_task = None + self.config = {} + self._button_config = {} + + async def load_config(self): + logger.debug(f"loading config: {self.config_file_path}") + if self.config_file_path: + try: + with open(self.config_file_path, "r") as config_file: + config_data = json.load(config_file) + self.config = ConfigSchema().load(config_data) + logger.debug(self.config) + except json.JSONDecodeError as e: + raise PiJuiceSysConfigValidationError("failed to decode JSON") + except marshmallow.ValidationError as e: + raise PiJuiceSysConfigValidationError(e) + + async def load_button_config(self): + logger.debug("loading button config") + for button in self.pijuice.config.buttons: + button_config = self.pijuice.config.GetButtonConfiguration(button) + if button_config["error"] == "NO_ERROR": + self._button_config[button] = button_config["data"] + + async def configure(self): + logger.debug("configuring") + watchdog_config = self.config["system_task"]["watchdog"] + await self.configure_watchdog( + enabled=watchdog_config["enabled"], + period=watchdog_config.get("period", 0), + ) + wakeup_on_charge_config = self.config["system_task"]["wakeup_on_charge"] + await self.configure_wakeup_on_charge( + enabled=wakeup_on_charge_config["enabled"], + trigger_level=wakeup_on_charge_config.get("trigger_level", 0), + ) + + async def reconfigure(self, fail_on_error=False): + logger.debug("reconfiguring") + try: + await self.load_config() + await self.load_button_config() + await self.configure() + except PiJuiceSysConfigValidationError as e: + if fail_on_error: + logger.error( + f"config validation failed! exiting... validation error: {e}" + ) + self._daemon_task.cancel() + sys.exit(1) + else: + logger.warning( + f"config validation failed! ignoring... validation error: {e}" + ) + + async def configure_watchdog(self, enabled, period): + logger.debug(f"configuring watchdog - enabled: {enabled} period: {period}") + if not enabled: + period = 0 + result = self.pijuice.power.SetWatchdog(period) + if result["error"] != "NO_ERROR": + await asyncio.sleep(0.05) + self.pijuice.power.SetWatchdog(period) + + async def configure_wakeup_on_charge(self, enabled, trigger_level): + logger.debug( + "configuring wakeup-on-charge" + f" - enabled: {enabled} trigger_level: {trigger_level}" + ) + if enabled: + self.pijuice.power.SetWakeUpOnCharge(trigger_level) + else: + self.pijuice.power.SetWakeUpOnCharge("DISABLED") + + async def _execute_event_function( + self, event, function, param, fallback_to_daemon_user=True + ): + def demote(uid, gid): + def result(): + logger.debug(f"Demoting to {uid}:{gid}") + os.setgid(gid) + os.setuid(uid) + + return result + + script_path = None + execute_uid = None + execute_gid = None + logger.info( + f"executing event function - event: {event} function: {function} param: {param}" + ) + # resolve function to file + user_function = self.config.get("user_functions", {}).get(function) + if user_function: + script_path = pathlib.Path(user_function) + elif function.startswith("SYS_FUNC"): + script_resource_path = importlib.resources.path( + "pijuice_sys.scripts", f"{function}.sh" + ) + with script_resource_path as p: + script_path = p + if not script_path: + logger.warning(f"failed to resolve {function}-script") + return + logger.debug(f"resolved {function}-script to: {script_path}") + # check if file exists + if not script_path.is_file(): + logger.warning(f"file not found: {script_path}") + return + # check if script is owner-executable + script_stat = script_path.stat() + if script_stat.st_mode & stat.S_IXUSR == 0: + logger.warning(f"file not executable: {script_path}") + return + # determine script-owner uid and gid + script_user_id = script_stat.st_uid + script_user_name = pwd.getpwuid(script_user_id).pw_name + script_group_id = script_stat.st_gid + # determine script-owner groups + script_user_group_ids = [ + g.gr_gid for g in grp.getgrall() if script_user_name in g.gr_mem + ] + # determine daemon uid and gid + daemon_user_id = os.geteuid() + daemon_user_name = pwd.getpwuid(daemon_user_id).pw_name + daemon_group_id = os.getegid() + daemon_group_name = grp.getgrgid(daemon_group_id).gr_name + # check if owned by root + if script_user_name == "root": + logger.warning(f"file owned by root. this is not allowed: {script_path}") + elif ( + script_user_id == daemon_user_id + or script_group_id == daemon_group_id + or daemon_group_id in script_user_group_ids + ): + execute_uid = script_user_id + execute_gid = script_group_id + else: + logger.warning( + f"file is neither owned by '{daemon_user_name}'" + f"nor is the owner '{script_user_name}' member of '{daemon_group_name}'-group" + ) + if execute_uid is None and fallback_to_daemon_user: + logger.warning(f"falling back to daemon-user: {daemon_user_name}") + execute_uid = daemon_user_id + execute_gid = daemon_group_id + elif execute_uid is None: + logger.warning("failed to resolve user to execute script") + return + subprocess.Popen( + args=[str(script_path), str(event), str(param)], + preexec_fn=demote(uid=execute_uid, gid=execute_gid), + ) + + async def _handle_system_event(self, event, param=None): + logger.debug(f"handling event: {event} - param: {param}") + if self.config["system_events"][event]["enabled"]: + function = self.config["system_events"][event]["function"] + logger.debug(f"resolved function: {function}") + if function == "NO_FUNC": + return + await self._execute_event_function(event, function, param) + + async def _button_events_daemon(self, poll_interval): + while True: + await asyncio.sleep(poll_interval) + result = self.pijuice.status.GetButtonEvents() + logger.debug(f"button-events: {result}") + if result["error"] != "NO_ERROR": + continue + button_events = result["data"] + for button in self.pijuice.config.buttons: + event = button_events[button] + if event == "NO_EVENT": + continue + function = self._button_config[button][event]["function"] + if function == "USER_EVENT": + continue + self.pijuice.status.AcceptButtonEvent(button) + await self._execute_event_function(event, function, param=button) + + async def _charge_level_daemon(self, poll_interval): + last_charge_level = None + while True: + await asyncio.sleep(poll_interval) + if not self.config["system_task"]["min_charge"]["enabled"]: + continue + result = self.pijuice.status.GetChargeLevel() + logger.debug(f"charge-level: {result}") + if result["error"] != "NO_ERROR": + continue + charge_level = float(result["data"]) + logger.info(f"charge-level: {charge_level}%") + threshold = self.config["system_task"]["min_charge"]["threshold"] + if ( + charge_level == 0 + or (charge_level < threshold) + and (last_charge_level is not None) + and (0 <= (last_charge_level - charge_level) < 3) + ): + logger.info(f"charge-level hit threshold of {threshold}%") + await self._handle_system_event(event="low_charge", param=charge_level) + last_charge_level = charge_level + + async def _battery_voltage_daemon(self, poll_interval): + while True: + await asyncio.sleep(poll_interval) + if not self.config["system_task"]["min_bat_voltage"]["enabled"]: + continue + result = self.pijuice.status.GetBatteryVoltage() + logger.debug(f"battery-voltage: {result}") + if result["error"] != "NO_ERROR": + continue + battery_voltage = float(result["data"]) / 1000 + logger.info(f"battery-voltage: {battery_voltage}V") + threshold = self.config["system_task"]["min_bat_voltage"]["threshold"] + if battery_voltage < threshold: + logger.info(f"battery-voltage hit threshold of {threshold}V") + await self._handle_system_event( + event="low_battery_voltage", param=battery_voltage + ) + + async def _power_inputs_daemon(self, poll_interval): + NO_POWER_STATUSES = ["NOT_PRESENT", "BAD"] + NO_POWER_THRESHOLD = 2 + no_power_count = 0 + while True: + await asyncio.sleep(poll_interval) + if not self.config["system_events"]["no_power"]["enabled"]: + continue + result = self.pijuice.status.GetStatus() + logger.debug(f"power-inputs: {result}") + if result["error"] != "NO_ERROR": + continue + status = result["data"] + power_usb = status["powerInput"] + power_io = status["powerInput5vIo"] + logger.info(f"power_input - usb: {power_usb} io: {power_io}") + if power_usb in NO_POWER_STATUSES and power_io in NO_POWER_STATUSES: + no_power_count += 1 + else: + no_power_count = 0 + if no_power_count >= NO_POWER_THRESHOLD: + await self._handle_system_event("no_power") + + async def _fault_flags_daemon(self, poll_interval): + while True: + await asyncio.sleep(poll_interval) + result = self.pijuice.status.GetFaultStatus() + logger.debug(f"fault-flags: {result}") + if result["error"] != "NO_ERROR": + continue + faults = result["data"] + for fault in self.pijuice.status.faultEvents + self.pijuice.status.faults: + if ( + fault in faults + and self.config["system_events"][fault]["enabled"] + and self.config["system_events"][fault]["function"] != "USER_EVENT" + ): + self.pijuice.status.ResetFaultFlags([fault]) + await self._handle_system_event(event=fault, param=faults[fault]) + + async def _run_daemon(self, poll_interval, button_poll_interval): + logger.debug("installing SIGHUP handler") + loop = asyncio.get_event_loop() + loop.add_signal_handler( + signal.SIGHUP, lambda: asyncio.create_task(pijuice_sys.reconfigure()) + ) + await self.reconfigure(fail_on_error=True) + logger.debug("running daemons") + await asyncio.gather( + self._button_events_daemon(button_poll_interval), + self._charge_level_daemon(poll_interval), + self._battery_voltage_daemon(poll_interval), + self._power_inputs_daemon(poll_interval), + self._fault_flags_daemon(poll_interval), + ) + + async def run_daemon(self, pid_file_path, poll_interval, button_poll_interval): + with pidfile(pid_file_path): + self._daemon_task = asyncio.create_task( + self._run_daemon( + poll_interval=poll_interval, + button_poll_interval=button_poll_interval, + ) + ) + try: + logger.debug("running daemon_task") + await self._daemon_task + except asyncio.CancelledError: + pass + + async def execute_poweroff_command(self): + logger.debug("executing poweroff command") + await self.load_config() + await self.configure_watchdog(enabled=False, period=0) + if self.config["system_task"]["ext_halt_power_off"]["enabled"]: + delay = self.config["system_task"]["ext_halt_power_off"]["period"] + logger.debug(f"setting poweroff - delay: {delay}") + self.pijuice.power.SetPowerOff(delay=delay) diff --git a/Software/Source/pijuice_sys/schema.py b/Software/Source/pijuice_sys/schema.py new file mode 100644 index 00000000..3d90c3c2 --- /dev/null +++ b/Software/Source/pijuice_sys/schema.py @@ -0,0 +1,219 @@ +from marshmallow import ( + Schema, + ValidationError, + fields, + validate, + validates_schema, +) + + +class SystemTaskWatchdogSchema(Schema): + enabled = fields.Boolean(missing=False) + period = fields.Int(validate=validate.Range(min=1, max=65535)) + + @validates_schema + def validate_function(self, data, **kwargs): + if data.get("enabled", False) and not "period" in data: + raise ValidationError("period must be provided") + + +class SystemTaskMinBatVoltageSchema(Schema): + enabled = fields.Boolean(missing=False) + threshold = fields.Int(validate=validate.Range(min=0, max=10)) + + @validates_schema + def validate_function(self, data, **kwargs): + if data.get("enabled", False) and not "threshold" in data: + raise ValidationError("threshold must be provided") + + +class SystemTaskMinChargeSchema(Schema): + enabled = fields.Boolean(missing=False) + threshold = fields.Int(validate=validate.Range(min=0, max=100)) + + @validates_schema + def validate_function(self, data, **kwargs): + if data.get("enabled", False) and not "threshold" in data: + raise ValidationError("threshold must be provided") + + +class SystemTaskWakeupOnChargeSchema(Schema): + enabled = fields.Boolean(missing=False) + trigger_level = fields.Int(validate=validate.Range(min=0, max=100)) + + @validates_schema + def validate_function(self, data, **kwargs): + if data.get("enabled", False) and not "trigger_level" in data: + raise ValidationError("trigger_level must be provided") + + +class SystemTaskExtHaltPowerOff(Schema): + enabled = fields.Boolean(missing=False) + period = fields.Int(validate=validate.Range(min=10, max=65535)) + + @validates_schema + def validate_function(self, data, **kwargs): + if data.get("enabled", False) and not "period" in data: + raise ValidationError("period must be provided") + + +class SystemTaskSchema(Schema): + enabled = fields.Boolean(missing=False) + watchdog = fields.Nested( + SystemTaskWatchdogSchema, + missing=SystemTaskWatchdogSchema().load({}), + ) + min_bat_voltage = fields.Nested( + SystemTaskMinBatVoltageSchema, + missing=SystemTaskMinBatVoltageSchema().load({}), + ) + min_charge = fields.Nested( + SystemTaskMinChargeSchema, + missing=SystemTaskMinChargeSchema().load({}), + ) + wakeup_on_charge = fields.Nested( + SystemTaskWakeupOnChargeSchema, + missing=SystemTaskWakeupOnChargeSchema().load({}), + ) + ext_halt_power_off = fields.Nested( + SystemTaskExtHaltPowerOff, + missing=SystemTaskExtHaltPowerOff().load({}), + ) + + +SystemEventsFunctionField = fields.Str( + validate=validate.OneOf( + [ + "NO_FUNC", + "SYS_FUNC_HALT", + "SYS_FUNC_HALT_POW_OFF", + "SYS_FUNC_SYS_OFF_HALT", + "SYS_FUNC_REBOOT", + "USER_EVENT", + "USER_FUNC1", + "USER_FUNC2", + "USER_FUNC3", + "USER_FUNC4", + "USER_FUNC5", + "USER_FUNC6", + "USER_FUNC7", + "USER_FUNC8", + "USER_FUNC9", + "USER_FUNC10", + "USER_FUNC11", + "USER_FUNC12", + "USER_FUNC13", + "USER_FUNC14", + "USER_FUNC15", + ] + ) +) + +SystemEventsUserFunctionField = fields.Str( + validate=validate.OneOf( + [ + "NO_FUNC", + "USER_EVENT", + "USER_FUNC1", + "USER_FUNC2", + "USER_FUNC3", + "USER_FUNC4", + "USER_FUNC5", + "USER_FUNC6", + "USER_FUNC7", + "USER_FUNC8", + "USER_FUNC9", + "USER_FUNC10", + "USER_FUNC11", + "USER_FUNC12", + "USER_FUNC13", + "USER_FUNC14", + "USER_FUNC15", + ] + ) +) + + +class SystemEventsFunctionSchema(Schema): + enabled = fields.Boolean(missing=False) + function = SystemEventsFunctionField + + @validates_schema + def validate_function(self, data, **kwargs): + if data.get("enabled", False) and not "function" in data: + raise ValidationError("function must be provided") + + +class SystemEventsUserFunctionSchema(Schema): + enabled = fields.Boolean(missing=False) + function = SystemEventsUserFunctionField + + @validates_schema + def validate_function(self, data, **kwargs): + if data.get("enabled", False) and not "function" in data: + raise ValidationError("function must be provided") + + +class SystemEventsSchema(Schema): + no_power = fields.Nested( + SystemEventsFunctionSchema, + missing=SystemEventsFunctionSchema().load({}), + ) + low_charge = fields.Nested( + SystemEventsFunctionSchema, + missing=SystemEventsFunctionSchema().load({}), + ) + low_battery_voltage = fields.Nested( + SystemEventsFunctionSchema, + missing=SystemEventsFunctionSchema().load({}), + ) + button_power_off = fields.Nested( + SystemEventsUserFunctionSchema, + missing=SystemEventsUserFunctionSchema().load({}), + ) + forced_power_off = fields.Nested( + SystemEventsUserFunctionSchema, + missing=SystemEventsUserFunctionSchema().load({}), + ) + forced_sys_power_off = fields.Nested( + SystemEventsUserFunctionSchema, + missing=SystemEventsUserFunctionSchema().load({}), + ) + watchdog_reset = fields.Nested( + SystemEventsUserFunctionSchema, + missing=SystemEventsUserFunctionSchema().load({}), + ) + + +class UserFunctionsSchema(Schema): + USER_FUNC1 = fields.Str() + USER_FUNC2 = fields.Str() + USER_FUNC3 = fields.Str() + USER_FUNC4 = fields.Str() + USER_FUNC5 = fields.Str() + USER_FUNC6 = fields.Str() + USER_FUNC7 = fields.Str() + USER_FUNC8 = fields.Str() + USER_FUNC9 = fields.Str() + USER_FUNC10 = fields.Str() + USER_FUNC11 = fields.Str() + USER_FUNC12 = fields.Str() + USER_FUNC13 = fields.Str() + USER_FUNC14 = fields.Str() + USER_FUNC15 = fields.Str() + SYS_FUNC_HALT = fields.Str() + SYS_FUNC_HALT_POW_OFF = fields.Str() + SYS_FUNC_SYS_OFF_HALT = fields.Str() + SYS_FUNC_REBOOT = fields.Str() + + +class ConfigSchema(Schema): + system_task = fields.Nested( + SystemTaskSchema, + missing=SystemTaskSchema().load({}), + ) + system_events = fields.Nested( + SystemEventsSchema, + missing=SystemEventsSchema().load({}), + ) + user_functions = fields.Nested(UserFunctionsSchema) diff --git a/Software/Source/pijuice_sys/scripts/SYS_FUNC_HALT.sh b/Software/Source/pijuice_sys/scripts/SYS_FUNC_HALT.sh new file mode 100755 index 00000000..1f94c216 --- /dev/null +++ b/Software/Source/pijuice_sys/scripts/SYS_FUNC_HALT.sh @@ -0,0 +1,3 @@ +#!/bin/sh +pijuice_cmd led-blink --count 3 +sudo systemctl poweroff diff --git a/Software/Source/pijuice_sys/scripts/SYS_FUNC_HALT_POW_OFF.sh b/Software/Source/pijuice_sys/scripts/SYS_FUNC_HALT_POW_OFF.sh new file mode 100755 index 00000000..843e6649 --- /dev/null +++ b/Software/Source/pijuice_sys/scripts/SYS_FUNC_HALT_POW_OFF.sh @@ -0,0 +1,5 @@ +#!/bin/sh +pijuice_cmd system-power-switch 0 +pijuice_cmd power-off --delay 60 +pijuice_cmd led-blink --count 3 +sudo systemctl poweroff diff --git a/Software/Source/pijuice_sys/scripts/SYS_FUNC_REBOOT.sh b/Software/Source/pijuice_sys/scripts/SYS_FUNC_REBOOT.sh new file mode 100755 index 00000000..4454c349 --- /dev/null +++ b/Software/Source/pijuice_sys/scripts/SYS_FUNC_REBOOT.sh @@ -0,0 +1,2 @@ +#!/bin/sh +sudo systemctl reboot diff --git a/Software/Source/pijuice_sys/scripts/SYS_FUNC_SYS_OFF_HALT.sh b/Software/Source/pijuice_sys/scripts/SYS_FUNC_SYS_OFF_HALT.sh new file mode 100755 index 00000000..d26acd7b --- /dev/null +++ b/Software/Source/pijuice_sys/scripts/SYS_FUNC_SYS_OFF_HALT.sh @@ -0,0 +1,4 @@ +#!/bin/sh +pijuice_cmd system-power-switch 0 +pijuice_cmd led-blink --count 3 +sudo systemctl poweroff diff --git a/Software/Source/pijuice_sys/scripts/__init__.py b/Software/Source/pijuice_sys/scripts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/Software/Source/pijuice_sys/utils.py b/Software/Source/pijuice_sys/utils.py new file mode 100644 index 00000000..35f06dba --- /dev/null +++ b/Software/Source/pijuice_sys/utils.py @@ -0,0 +1,19 @@ +import os +import logging + +from contextlib import contextmanager + + +logger = logging.getLogger(__name__) + + +@contextmanager +def pidfile(filename): + try: + logger.debug(f"creating pid-file: {filename}") + with open(filename, "w") as f: + f.write(str(os.getpid())) + yield + finally: + logger.debug(f"deleting pid-file: {filename}") + os.unlink(filename) diff --git a/Software/Source/setup.py b/Software/Source/setup.py index 3455d257..9ff90121 100644 --- a/Software/Source/setup.py +++ b/Software/Source/setup.py @@ -1,12 +1,9 @@ #!/usr/bin/env python3 +import setuptools +import functools -from distutils.core import setup -#from distutils.command.install_data import install_data -#from distutils.dep_util import newer -#from distutils.log import info import glob import os -#import sys def set_desktop_entry_versions(version): entries = ("data/pijuice-gui.desktop", "data/pijuice-tray.desktop") @@ -22,45 +19,58 @@ def set_desktop_entry_versions(version): version = os.environ.get('PIJUICE_VERSION') +build_base = int(os.environ.get('PIJUICE_BUILD_BASE', 0)) != 0 -if int(os.environ.get('PIJUICE_BUILD_BASE', 0)) > 0: - name = "pijuice-base" - data_files = [ - ('share/pijuice/data/firmware', glob.glob('data/firmware/*')), - ('/etc/sudoers.d', ['data/020_pijuice-nopasswd']), - ('bin', ['bin/pijuiceboot']), - ('bin', ['bin/pijuice_cli']), - ] - scripts = ['src/pijuice_sys.py', 'src/pijuice_cli.py'] - description = "Software package for PiJuice" - py_modules=['pijuice'] -else: - name = "pijuice-gui" - py_modules = None - data_files= [ - ('share/applications', ['data/pijuice-gui.desktop']), - ('/etc/xdg/autostart', ['data/pijuice-tray.desktop']), - ('share/pijuice/data/images', glob.glob('data/images/*')), - ('/etc/X11/Xsession.d', ['data/36x11-pijuice_xhost']), - ('bin', ['bin/pijuice_gui']), - ] - scripts = ['src/pijuice_tray.py', 'src/pijuice_gui.py'] - description = "GUI package for PiJuice" - -try: - set_desktop_entry_versions(version) -except: - pass - -setup( - name=name, +setup = functools.partial( + setuptools.setup, version=version, author="Ton van Overbeek", author_email="tvoverbeek@gmail.com", - description=description, url="https://github.com/PiSupply/PiJuice/", license='GPL v2', - py_modules=py_modules, - data_files=data_files, - scripts=scripts, +) + +if build_base: + setup( + name="pijuice-base", + description="Software package for PiJuice", + install_requires=["smbus", "urwid", "marshmallow"], + py_modules=['pijuice'], + packages=setuptools.find_packages(), + include_package_data=True, + data_files=[ + ('share/pijuice/data/firmware', glob.glob('data/firmware/*')), + ('/etc/sudoers.d', ['data/020_pijuice-nopasswd']), + ('bin', ['bin/pijuiceboot']), + ('bin', ['bin/pijuice_cli']), + ], + scripts = ['src/pijuice_cli.py'], + package_data={ + "pijuice_sys.scripts": ["*.sh"], + }, + entry_points={ + 'console_scripts': [ + "pijuice_cmd=pijuice_cmd.__main__:main", + "pijuice_sys=pijuice_sys.__main__:main", + ] + }, + ) + +else: + try: + set_desktop_entry_versions(version) + except: + pass + setup( + name="pijuice-gui", + description="GUI package for PiJuice", + install_requires=["smbus", "urwid"], + data_files=[ + ('share/applications', ['data/pijuice-gui.desktop']), + ('/etc/xdg/autostart', ['data/pijuice-tray.desktop']), + ('share/pijuice/data/images', glob.glob('data/images/*')), + ('/etc/X11/Xsession.d', ['data/36x11-pijuice_xhost']), + ('bin', ['bin/pijuice_gui']), + ], + scripts=['src/pijuice_tray.py', 'src/pijuice_gui.py'], ) diff --git a/Software/Source/src/pijuice_sys.py b/Software/Source/src/pijuice_sys.py deleted file mode 100755 index d589925a..00000000 --- a/Software/Source/src/pijuice_sys.py +++ /dev/null @@ -1,347 +0,0 @@ -#!/usr/bin/python3 -# -*- coding: utf-8 -*- -from __future__ import print_function - -import calendar -import datetime -import getopt -import grp -import json -import logging -import os -import pwd -import signal -import stat -import subprocess -import sys -import time -import re - -from pijuice import PiJuice - -pijuice = None -btConfig = {} - -configPath = '/var/lib/pijuice/pijuice_config.JSON' # os.getcwd() + '/pijuice_config.JSON' -configData = {'system_task': {'enabled': False}} -status = {} -sysEvEn = False -minChgEn = False -minBatVolEn = False -lowChgEn = False -lowBatVolEn = False -chargeLevel = 50 -noPowEn = False -noPowCnt = 100 -dopoll = True -PID_FILE = '/tmp/pijuice_sys.pid' -HALT_FILE = '/tmp/pijuice_halt.flag' - -def _SystemHalt(event): - if (event in ('low_charge', 'low_battery_voltage', 'no_power') - and configData.get('system_task', {}).get('wakeup_on_charge', {}).get('enabled', False) - and 'trigger_level' in configData['system_task']['wakeup_on_charge']): - - try: - tl = float(configData['system_task']['wakeup_on_charge']['trigger_level']) - pijuice.power.SetWakeUpOnCharge(tl) - except: - tl = None - pijuice.status.SetLedBlink('D2', 3, [150, 0, 0], 200, [0, 100, 0], 200) - # Setting halt flag for 'pijuice_sys.py stop' - with open(HALT_FILE, 'w') as f: - pass - subprocess.call(["sudo", "halt"]) - -def ExecuteFunc(func, event, param): - if func == 'SYS_FUNC_HALT': - _SystemHalt(event) - elif func == 'SYS_FUNC_HALT_POW_OFF': - pijuice.power.SetSystemPowerSwitch(0) - pijuice.power.SetPowerOff(60) - _SystemHalt(event) - elif func == 'SYS_FUNC_SYS_OFF_HALT': - pijuice.power.SetSystemPowerSwitch(0) - _SystemHalt(event) - elif func == 'SYS_FUNC_REBOOT': - subprocess.call(["sudo", "reboot"]) - elif ('USER_FUNC' in func) and ('user_functions' in configData) and (func in configData['user_functions']): - function=configData['user_functions'][func] - # Check function is defined - if function == "": - return - # Remove possible argumemts - cmd = function.split()[0] - - # Check cmd is an executable file and the file owner belongs - # to the pijuice group. - # If so, execute the command as the file owner - - try: - statinfo = os.stat(cmd) - except: - # File not found - return - # Get owner and ownergroup names - owner = pwd.getpwuid(statinfo.st_uid).pw_name - ownergroup = grp.getgrgid(statinfo.st_gid).gr_name - # Do not allow programs owned by root - if owner == 'root': - print("root owned " + cmd + " not allowed") - return - # Check cmd has executable permission - if statinfo.st_mode & stat.S_IXUSR == 0: - print(cmd + " is not executable") - return - # Owner of cmd must belong to mygroup ('pijuice') - mygroup = grp.getgrgid(os.getegid()).gr_name - # Find all groups owner belongs too - groups = [g.gr_name for g in grp.getgrall() if owner in g.gr_mem] - groups.append(ownergroup) # append primary group - # Does owner belong to mygroup? - found = 0 - for g in groups: - if g == mygroup: - found = 1 - break - if found == 0: - print(cmd + " owner ('" + owner + "') does not belong to '" + mygroup + "'") - return - # All checks passed - cmd = "sudo -u " + owner + " " + cmd + " {event} {param}".format( - event=str(event), - param=str(param)) - try: - os.system(cmd) - except: - print('Failed to execute user func') - - -def _EvalButtonEvents(): - btEvents = pijuice.status.GetButtonEvents() - if btEvents['error'] == 'NO_ERROR': - for b in pijuice.config.buttons: - ev = btEvents['data'][b] - if ev != 'NO_EVENT': - if btConfig[b][ev]['function'] != 'USER_EVENT': - pijuice.status.AcceptButtonEvent(b) - if btConfig[b][ev]['function'] != 'NO_FUNC': - ExecuteFunc(btConfig[b][ev]['function'], ev, b) - return True - else: - return False - - -def _EvalCharge(): - global lowChgEn - charge = pijuice.status.GetChargeLevel() - if charge['error'] == 'NO_ERROR': - level = float(charge['data']) - global chargeLevel - if ('threshold' in configData['system_task']['min_charge']): - th = float(configData['system_task']['min_charge']['threshold']) - if level == 0 or ((level < th) and ((chargeLevel-level) >= 0 and (chargeLevel-level) < 3)): - if lowChgEn: - # energy is low, take action - ExecuteFunc(configData['system_events']['low_charge']['function'], - 'low_charge', level) - - chargeLevel = level - return True - else: - return False - - -def _EvalBatVoltage(): - global lowBatVolEn - bv = pijuice.status.GetBatteryVoltage() - if bv['error'] == 'NO_ERROR': - v = float(bv['data']) / 1000 - try: - th = float(configData['system_task'].get('min_bat_voltage', {}).get('threshold')) - except ValueError: - th = None - if th is not None and v < th: - if lowBatVolEn: - # Battery voltage below thresholdw, take action - ExecuteFunc(configData['system_events']['low_battery_voltage']['function'], 'low_battery_voltage', v) - - return True - else: - return False - - -def _EvalPowerInputs(status): - global noPowCnt - NO_POWER_STATUSES = ['NOT_PRESENT', 'BAD'] - if status['powerInput'] in NO_POWER_STATUSES and status['powerInput5vIo'] in NO_POWER_STATUSES: - noPowCnt = noPowCnt + 1 - else: - noPowCnt = 0 - if noPowCnt == 2: - # unplugged - ExecuteFunc(configData['system_events']['no_power']['function'], - 'no_power', '') - - -def _EvalFaultFlags(): - faults = pijuice.status.GetFaultStatus() - if faults['error'] == 'NO_ERROR': - faults = faults['data'] - for f in (pijuice.status.faultEvents + pijuice.status.faults): - if f in faults: - if sysEvEn and (f in configData['system_events']) and ('enabled' in configData['system_events'][f]) and configData['system_events'][f]['enabled']: - if configData['system_events'][f]['function'] != 'USER_EVENT': - pijuice.status.ResetFaultFlags([f]) - ExecuteFunc(configData['system_events'][f]['function'], - f, faults[f]) - return True - else: - return False - - -def reload_settings(signum=None, frame=None): - global pijuice - global configData - global btConfig - global sysEvEn - global minChgEn - global minBatVolEn - global lowChgEn - global lowBatVolEn - global noPowEn - - with open(configPath, 'r') as outputConfig: - config_dict = json.load(outputConfig) - configData.update(config_dict) - - try: - for b in pijuice.config.buttons: - conf = pijuice.config.GetButtonConfiguration(b) - if conf['error'] == 'NO_ERROR': - btConfig[b] = conf['data'] - except: - pass - - sysEvEn = 'system_events' in configData - minChgEn = configData.get('system_task', {}).get('min_charge', {}).get('enabled', False) - minBatVolEn = configData.get('system_task', {}).get('min_bat_voltage', {}).get('enabled', False) - lowChgEn = sysEvEn and configData.get('system_events', {}).get('low_charge', {}).get('enabled', False) - lowBatVolEn = sysEvEn and configData.get('system_events', {}).get('low_battery_voltage', {}).get('enabled', False) - noPowEn = sysEvEn and configData.get('system_events', {}).get('no_power', {}).get('enabled', False) - - # Update watchdog setting - if (('watchdog' in configData['system_task']) - and ('enabled' in configData['system_task']['watchdog']) - and configData['system_task']['watchdog']['enabled'] - and ('period' in configData['system_task']['watchdog']) - ): - try: - p = int(configData['system_task']['watchdog']['period']) - ret = pijuice.power.SetWatchdog(p) - except: - p = None - else: - # Disable watchdog - ret = pijuice.power.SetWatchdog(0) - if ret['error'] != 'NO_ERROR': - time.sleep(0.05) - pijuice.power.SetWatchdog(0) - -def main(): - global pijuice - global btConfig - global configData - global status - global chargeLevel - global sysEvEn - global minChgEn - global minBatVolEn - global lowChgEn - global lowBatVolEn - global noPowEn - - pid = str(os.getpid()) - with open(PID_FILE, 'w') as pid_f: - pid_f.write(pid) - - if not os.path.exists(configPath): - with open(configPath, 'w+') as conf_f: - conf_f.write(json.dumps(configData)) - - try: - pijuice = PiJuice(1, 0x14) - #pijuice = PiJuice(3, 0x14) - except: - sys.exit(0) - - reload_settings() - - # Handle SIGHUP signal to reload settings - signal.signal(signal.SIGHUP, reload_settings) - - if len(sys.argv) > 1 and str(sys.argv[1]) == 'stop': - isHalting = False - if os.path.exists(HALT_FILE): # Created in _SystemHalt() called in main pijuice_sys process - isHalting = True - os.remove(HALT_FILE) - - try: - if (('watchdog' in configData['system_task']) - and ('enabled' in configData['system_task']['watchdog']) - and configData['system_task']['watchdog']['enabled'] - ): - # Disabling watchdog - ret = pijuice.power.SetWatchdog(0) - if ret['error'] != 'NO_ERROR': - time.sleep(0.05) - ret = pijuice.power.SetWatchdog(0) - except: - pass - sysJobTargets = subprocess.check_output(["sudo", "systemctl", "list-jobs"]).decode('utf-8') - reboot = True if re.search('reboot.target.*start', sysJobTargets) is not None else False # reboot.target exists - swStop = True if re.search('(?:halt|shutdown).target.*start', sysJobTargets) is not None else False # shutdown | halt exists - causePowerOff = True if (swStop and not reboot) else False - ret = pijuice.status.GetStatus() - if ( ret['error'] == 'NO_ERROR' - and not isHalting - and causePowerOff # proper time to power down (!rebooting) - and configData.get('system_task',{}).get('ext_halt_power_off', {}).get('enabled',False) - ): - # Set duration for when pijuice will cut power (Recommended 30+ sec, for halt to complete) - try: - powerOffDelay = int(configData['system_task']['ext_halt_power_off'].get('period',30)) - pijuice.power.SetPowerOff(powerOffDelay) - except ValueError: - pass - sys.exit(0) - - # Watchdog already updated in reload_settings() - - timeCnt = 5 - - while dopoll: - if configData.get('system_task', {}).get('enabled'): - ret = pijuice.status.GetStatus() - if ret['error'] == 'NO_ERROR': - status = ret['data'] - if status['isButton']: - _EvalButtonEvents() - - timeCnt -= 1 - if timeCnt == 0: - timeCnt = 5 - if ('isFault' in status) and status['isFault']: - _EvalFaultFlags() - if minChgEn: - _EvalCharge() - if minBatVolEn: - _EvalBatVoltage() - if noPowEn: - _EvalPowerInputs(status) - - time.sleep(1) - - -if __name__ == '__main__': - main()