diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 90a56f2c..faf52fe4 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -34,11 +34,18 @@ jobs: with: images: jblance/mppsolar + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Build and push Docker image uses: docker/build-push-action@v6 with: context: . file: ./Dockerfile + platforms: linux/amd64,linux/arm64,linux/arm/v8 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/Dockerfile b/Dockerfile index eb43e9c3..e2866c37 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,14 @@ FROM python:3.12 RUN pip install --upgrade pip RUN python -V RUN python -c 'import platform;print(platform.machine())' -RUN pip install https://github.com/mosquito/cysystemd/releases/download/1.6.2/cysystemd-1.6.2-cp312-cp312-manylinux_2_28_x86_64.whl +ARG TARGETARCH +RUN echo $TARGETARCH +# RUN if [ "$TARGETARCH" = "arm64" ] ; then \ +# pip install https://github.com/mosquito/cysystemd/releases/download/1.6.2/cysystemd-1.6.2-cp312-cp312-manylinux_2_28_aarch64.whl ; \ +# elif [ "$TARGETARCH" = "arm" ] ; then \ +# pip install https://github.com/mosquito/cysystemd/releases/download/1.6.2/cysystemd-1.6.2-cp312-cp312-manylinux_2_28_aarch64.whl ; \ +# else \ +# pip install https://github.com/mosquito/cysystemd/releases/download/1.6.2/cysystemd-1.6.2-cp312-cp312-manylinux_2_28_x86_64.whl ; \ +# fi COPY . /mpp-solar/ RUN pip install /mpp-solar/ diff --git a/docs/configfile.md b/docs/configfile.md index 9594661d..16977b09 100644 --- a/docs/configfile.md +++ b/docs/configfile.md @@ -20,6 +20,9 @@ mqtt_port=1883 mqtt_user=username mqtt_pass=password +# Daemon log file path and name can be configured, defaults to /var/log/mpp-solar.log +log_file = /custom/path/to/mpp-solar.log + ### The section name needs to be unique ### There can be multiple sections which are processed sequentially without pause ### The pause occurs after all sections are processed, before the next loop diff --git a/docs/daemon.md b/docs/daemon.md new file mode 100644 index 00000000..0e523e9b --- /dev/null +++ b/docs/daemon.md @@ -0,0 +1,113 @@ +# MPP-Solar Daemon with Configurable PID File + +## Usage Examples + +### Basic daemon operation (uses default PID file location): +```bash +# Start daemon +mpp-solar --daemon -C /etc/mpp-solar/mpp-solar.conf + +# Stop daemon +mpp-solar --daemon-stop +``` + +### Custom PID file location and naming: +```bash +# Start daemon with custom PID file +mpp-solar --daemon --pidfile /home/user/solar/mpp-solar.pid -C /etc/mpp-solar/mpp-solar.conf + +# Stop daemon using custom PID file +mpp-solar --daemon-stop --pidfile /home/user/solar/mpp-solar.pid +``` + +### For non-root users: +```bash +# Start daemon (automatically uses /tmp or XDG_RUNTIME_DIR) +mpp-solar --daemon --pidfile ~/.config/mpp-solar.pid -C ~/mpp-solar.conf + +# Stop daemon +mpp-solar --daemon-stop --pidfile ~/.config/mpp-solar.pid +``` + +### For PyInstaller distributions: +```bash +# The daemon will automatically detect PyInstaller and use appropriate defaults +./mpp-solar --daemon --pidfile ./mpp-solar.pid -C ./config/mpp-solar.conf + +# Stop PyInstaller daemon +./mpp-solar --daemon-stop --pidfile ./mpp-solar.pid +``` + +### Docker containers: +```bash +# Use a mounted volume for persistent PID file +docker run -v /host/path:/app/data your-mpp-solar-image \ + --daemon --pidfile /app/data/mpp-solar.pid -C /app/config/mpp-solar.conf +``` + +## Default PID File Locations + +The daemon automatically chooses appropriate default locations: + +- **Root user**: `/var/run/mpp-solar.pid` +- **Non-root user with XDG_RUNTIME_DIR**: `$XDG_RUNTIME_DIR/mpp-solar.pid` +- **Non-root user without XDG_RUNTIME_DIR**: `/tmp/mpp-solar.pid` +- **PyInstaller bundle**: `/tmp/mpp-solar.pid` (can be overridden) + +## Benefits of Configurable PID File + +1. **Multi-instance support**: Run multiple daemon instances with different configurations +2. **Permission flexibility**: Use locations where the user has write access +3. **Container-friendly**: Easy to mount volumes for persistent PID files +4. **Development**: Use local directories during development/testing +5. **System integration**: Integrate with existing system management tools + +## Advanced Examples + +### Multiple daemon instances: +```bash +# Start first instance for battery monitoring +mpp-solar --daemon --pidfile /var/run/mpp-solar-battery.pid \ + -C /etc/mpp-solar/battery.conf + +# Start second instance for inverter monitoring +mpp-solar --daemon --pidfile /var/run/mpp-solar-inverter.pid \ + -C /etc/mpp-solar/inverter.conf +``` + +### Systemd service integration: +```ini +[Unit] +Description=MPP Solar Monitor +After=network.target + +[Service] +Type=forking +ExecStart=/usr/local/bin/mpp-solar --daemon --pidfile /var/run/mpp-solar.pid -C /etc/mpp-solar/mpp-solar.conf +ExecStop=/usr/local/bin/mpp-solar --daemon-stop --pidfile /var/run/mpp-solar.pid +PIDFile=/var/run/mpp-solar.pid +Restart=always +User=mpp-solar +Group=mpp-solar + +[Install] +WantedBy=multi-user.target +``` + +### OpenRC service integration: +```bash +#!/sbin/openrc-run + +name="mpp-solar daemon" +description="MPP Solar Monitor Daemon" + +pidfile="/var/run/${RC_SVCNAME}.pid" +command="/usr/bin/mpp-solar" +command_args="--pidfile ${pidfile} -C /etc/mpp-solar/mpp-solar.conf" +command_args_background="--daemon" +#command_background="yes" + +stop_pre() { + /usr/bin/mpp-solar --daemon-stop --pidfile "${pidfile}" +} +``` \ No newline at end of file diff --git a/makefile b/makefile index 6ae2ccfe..43ccc3f2 100644 --- a/makefile +++ b/makefile @@ -1,13 +1,13 @@ -mppsolar-integration-tests: +integration-tests: python3 -m unittest discover -s tests/integration -f -v -mppsolar-unit-tests: +unit-tests: python3 -m unittest discover -s tests/unit -f -v test: python3 -m unittest discover -s tests -f -tests: mppsolar-unit-tests mppsolar-integration-tests +tests: unit-tests integration-tests pypi: rm -rf dist/* diff --git a/mppsolar/__init__.py b/mppsolar/__init__.py index d77a1c60..12a134de 100755 --- a/mppsolar/__init__.py +++ b/mppsolar/__init__.py @@ -1,13 +1,17 @@ # !/usr/bin/python3 +import os import logging import time +import sys from argparse import ArgumentParser from platform import python_version from mppsolar.version import __version__ # noqa: F401 -from mppsolar.helpers import get_device_class -from mppsolar.daemon import get_daemon +from mppsolar.helpers import get_device_class, daemonize, setup_daemon_logging, log_pyinstaller_context +from mppsolar.pyinstaller_runtime import spawn_pyinstaller_subprocess, is_pyinstaller_bundle, has_been_spawned + +from mppsolar.daemon import get_daemon, detect_daemon_type from mppsolar.daemon import DaemonType from mppsolar.libs.mqttbrokerc import MqttBroker from mppsolar.outputs import get_outputs, list_outputs @@ -18,6 +22,73 @@ FORMAT = "%(asctime)-15s:%(levelname)s:%(module)s:%(funcName)s@%(lineno)d: %(message)s" logging.basicConfig(format=FORMAT) +def log_process_info(label, log_func=None): + + if log_func is None: + log_func = print + + pid = os.getpid() + ppid = os.getppid() + + try: + pgid = os.getpgid(0) + sid = os.getsid(0) + except: + pgid = "unknown" + sid = "unknown" + + is_leader = (pid == pgid) + + log_func(f"[{label}] PID: {pid}, PPID: {ppid}, PGID: {pgid}, SID: {sid}, Leader: {is_leader}") + + try: + with open(f'/proc/{pid}/cmdline', 'r') as f: + cmdline = f.read().replace('\0', ' ').strip() + log_func(f"[{label}] Command: {cmdline}") + except: + log_func(f"[{label}] Command: {' '.join(sys.argv)}") + +def setup_daemon_if_requested(args, log_file_path="/var/log/mpp-solar.log"): + + + if args.daemon: + log.info("Daemon mode requested") + + try: + daemon_type = detect_daemon_type() + log.info(f"Detected daemon type: {daemon_type}") + except Exception as e: + log.warning(f"Failed to detect daemon type: {e}, falling back to OpenRC") + daemon_type = DaemonType.OPENRC + + daemon = get_daemon(daemontype=daemon_type) + + if hasattr(daemon, 'set_pid_file_path') and args.pidfile: + daemon.set_pid_file_path(args.pidfile) + log.info(f"Using custom PID file: {args.pidfile}") + elif hasattr(daemon, 'pid_file_path'): + daemon.pid_file_path = "/tmp/mpp-solar.pid" if os.geteuid() != 0 else "/var/run/mpp-solar.pid" + log.info(f"Using default PID file: {daemon.pid_file_path}") + + daemon.keepalive = 60 + + if not setup_daemon_logging("/var/log/mpp-solar.log"): + log.warning("Failed to setup file logging, continuing with console logging") + + log.info("Attempting traditional daemonization...") + try: + daemonize() + log.info("Daemonized successfully") + except Exception as e: + log.error(f"Failed to daemonize process: {e}") + log.info("Continuing in foreground mode") + + return daemon + else: + log.info("Daemon mode NOT requested. Using DISABLED daemon.") + daemon = get_daemon(daemontype=DaemonType.DISABLED) + log_process_info("DAEMON_DISABLED_CREATED", log.info) + return daemon def main(): description = f"Solar Device Command Utility, version: {__version__}, python version: {python_version()}" @@ -39,6 +110,11 @@ def main(): help="overrides the device communications port type", default=None, ) + parser.add_argument( + "--dev", + help="Device identifier for prometheus output labeling for complex installations (default: None)", + default=None, + ) if parser.prog == "jkbms": parser.add_argument( "-P", @@ -192,6 +268,16 @@ def main(): action="store_true", help="Enable Debug and above (i.e. all) messages", ) + parser.add_argument( + "--pidfile", + help="Specifies the PID file location for daemon mode (default: /var/run/mpp-solar.pid, /tmp/mpp-solar.pid for PyInstaller)", + default=None, + ) + parser.add_argument( + "--daemon-stop", + action="store_true", + help="Stop a running daemon (requires --pidfile if using non-default location)" + ) parser.add_argument("-I", "--info", action="store_true", help="Enable Info and above level messages") args = parser.parse_args() @@ -199,7 +285,20 @@ def main(): if prog_name is None: prog_name = "mpp-solar" s_prog_name = prog_name.replace("-", "") - # log_name = s_prog_name.upper() + log_file_path = "/var/log/mpp-solar.log" + + log_pyinstaller_context() + # --- Optional PyInstaller bootstrap cleanup --- + # To enable single-process daemon spawn logic (avoids PyInstaller parent): + ################################################################# + if spawn_pyinstaller_subprocess(args): + sys.exit(0) + ################################################################# + + from mppsolar.pyinstaller_runtime import setup_spawned_environment + setup_spawned_environment() + + # logging (DEBUG, INFO, WARNING, ERROR, CRITICAL) # Turn on debug if needed @@ -239,6 +338,84 @@ def main(): # port: 1883 # user: null # pass: null + # Handle daemon setup and stop requests + + #### Extra Logging + def log_process_info(label, log_func=None): + """Log detailed process information for debugging""" + if log_func is None: + log_func = print + + pid = os.getpid() + ppid = os.getppid() + + # Get process group and session info + try: + pgid = os.getpgid(0) + sid = os.getsid(0) + except: + pgid = "unknown" + sid = "unknown" + + # Check if we're the process group leader + is_leader = (pid == pgid) + + log_func(f"[{label}] PID: {pid}, PPID: {ppid}, PGID: {pgid}, SID: {sid}, Leader: {is_leader}") + + # Log command line that started this process + try: + with open(f'/proc/{pid}/cmdline', 'r') as f: + cmdline = f.read().replace('\0', ' ').strip() + log_func(f"[{label}] Command: {cmdline}") + except: + log_func(f"[{label}] Command: {' '.join(sys.argv)}") + ####### + + + # Handle daemon stop request + if args.daemon_stop: + pid_file_path = args.pidfile + if pid_file_path is None: + # Use default based on environment + if os.geteuid() != 0: # Non-root check + pid_file_path = "/tmp/mpp-solar.pid" + else: + pid_file_path = "/var/run/mpp-solar.pid" + + log.info(f"Attempting to stop daemon using PID file: {pid_file_path}") + + try: + daemon_type = detect_daemon_type() + daemon_class = get_daemon(daemontype=daemon_type).__class__ + if hasattr(daemon_class, 'stop_daemon'): + success = daemon_class.stop_daemon(pid_file_path) + if success: + print("Daemon stopped successfully") + else: + print("Failed to stop daemon") + sys.exit(0 if success else 1) + else: + print("Daemon stop functionality not available for this daemon type") + sys.exit(1) + except Exception as e: + print(f"Error stopping daemon: {e}") + sys.exit(1) + + + # ------------------------ + # Daemon setup and logging + # ------------------------ + daemon = setup_daemon_if_requested(args, log_file_path=log_file_path) + log.info(daemon) + + # Notify systemd/init + daemon.initialize() + log_process_info("AFTER_DAEMON_INITIALIZE", log.info) + daemon.notify("Service Initializing ...") + log_process_info("AFTER_DAEMON_NOTIFY", log.info) + + + # mqttbroker setup mqtt_broker = MqttBroker( config={ "name": args.mqttbroker, @@ -263,32 +440,10 @@ def main(): mqtt_topic = args.mqtttopic push_url = args.pushurl prom_output_dir = args.prom_output_dir + dev = args.dev _commands = [] - # Initialize Daemon - if not args.daemon: - daemon = get_daemon(daemontype=DaemonType.DISABLED) - else: - daemon = get_daemon(daemontype=DaemonType.SYSTEMD) - daemon.keepalive = 60 - log.info(daemon) - - - # if args.daemon: - # try: - # import systemd.daemon - # except ImportError: - # print("You are missing dependencies in order to be able to use the --daemon flag.") - # print("To install them, use that command:") - # print(" python -m pip install 'mppsolar[systemd]'") - # exit(1) - - # Tell systemd that our service is ready - daemon.initialize() - daemon.notify("Service Initializing ...") - # set some default-defaults - pause = 60 # If config file specified, process if args.configfile: @@ -314,6 +469,7 @@ def main(): mqtt_broker.update("port", config["SETUP"].getint("mqtt_port", fallback=None)) mqtt_broker.update("username", config["SETUP"].get("mqtt_user", fallback=None)) mqtt_broker.update("password", config["SETUP"].get("mqtt_pass", fallback=None)) + log_file_path = config["SETUP"].get("log_file", fallback="/var/log/mpp-solar.log") sections.remove("SETUP") # Process 'command' sections @@ -336,6 +492,7 @@ def main(): push_url = config[section].get("push_url", fallback=push_url) prom_output_dir = config[section].get("prom_output_dir", fallback=prom_output_dir) mqtt_topic = config[section].get("mqtt_topic", fallback=mqtt_topic) + section_dev = config[section].get("dev", fallback=None) # device_class = get_device_class(_type) log.debug(f"device_class {device_class}") @@ -359,7 +516,7 @@ def main(): commands = _command.split("#") for command in commands: - _commands.append((device, command, tag, outputs, filter, excl_filter)) + _commands.append((device, command, tag, outputs, filter, excl_filter, section_dev)) log.debug(f"Commands from config file {_commands}") if args.daemon: @@ -395,7 +552,6 @@ def main(): push_url=push_url, prom_output_dir=prom_output_dir, ) - # # determine whether to run command or call helper function commands = [] @@ -426,14 +582,14 @@ def main(): tag = args.tag else: tag = command - _commands.append((device, command, tag, outputs, filter, excl_filter)) + _commands.append((device, command, tag, outputs, filter, excl_filter, dev)) log.debug(f"Commands {_commands}") while True: # Loop through the configured commands if not args.daemon: log.info(f"Looping {len(_commands)} commands") - for _device, _command, _tag, _outputs, filter, excl_filter in _commands: + for _device, _command, _tag, _outputs, filter, excl_filter, dev in _commands: # for item in mppUtilArray: # Tell systemd watchdog we are still alive daemon.watchdog() @@ -465,6 +621,7 @@ def main(): filter=filter, excl_filter=excl_filter, keep_case=keep_case, + dev=dev, # ADD: Pass dev parameter to output ) # Tell systemd watchdog we are still alive if args.daemon: @@ -479,3 +636,4 @@ def main(): if __name__ == "__main__": main() + diff --git a/mppsolar/daemon/__init__.py b/mppsolar/daemon/__init__.py index d8eed7f5..b8b2d9e1 100644 --- a/mppsolar/daemon/__init__.py +++ b/mppsolar/daemon/__init__.py @@ -1,9 +1,10 @@ -from enum import Enum, auto +from enum import Enum class DaemonType(Enum): """ Daemon types implemented """ DISABLED = "disabled" SYSTEMD = "systemd" + OPENRC = "openrc" def get_daemon(daemontype): match daemontype: @@ -13,5 +14,24 @@ def get_daemon(daemontype): case DaemonType.SYSTEMD: from .daemon_systemd import DaemonSystemd as daemon return daemon() + case DaemonType.OPENRC: + from .daemon_openrc import DaemonOpenRC as daemon + return daemon() case _: - raise Exception(f"unknown daemontype {daemontype}") \ No newline at end of file + raise Exception(f"unknown daemontype {daemontype}") + +def detect_daemon_type(): + """ Auto-detect the appropriate daemon type for the system """ + import os + import shutil + + # Check if systemd is available + if shutil.which('systemctl') and os.path.exists('/run/systemd/system'): + return DaemonType.SYSTEMD + + # Check if OpenRC is available + if shutil.which('rc-service') or os.path.exists('/sbin/openrc'): + return DaemonType.OPENRC + + # Default to disabled/generic daemon + return DaemonType.OPENRC # Use OpenRC implementation as fallback since it handles signals diff --git a/mppsolar/daemon/daemon.py b/mppsolar/daemon/daemon.py index e0c6f9eb..1f94e367 100644 --- a/mppsolar/daemon/daemon.py +++ b/mppsolar/daemon/daemon.py @@ -9,14 +9,16 @@ class Daemon: """ abstraction to support different daemon approaches / solutions """ def __str__(self): - return f"Daemon name: {self.type}" + return f"Daemon name: {self.__class__.__name__}" def initialize(self): """ Daemon initialization activities """ + log.debug("Base Daemon initialized") self._notify(self._Notification.READY) self._lastNotify = time() def watchdog(self): + log.debug("Base Daemon watchdog ping") elapsed = time() - self._lastNotify if (elapsed) > self.keepalive: self._notify(self._Notification.WATCHDOG) @@ -35,3 +37,10 @@ def log(self, message=None): # Print log message if message is not None: self._journal(message) + + # These methods should be overridden by subclasses + def _notify(self, notification, message=None): + pass + + def _journal(self, message): + pass \ No newline at end of file diff --git a/mppsolar/daemon/daemon_disabled.py b/mppsolar/daemon/daemon_disabled.py index 55f6a5f2..7c6a9c23 100644 --- a/mppsolar/daemon/daemon_disabled.py +++ b/mppsolar/daemon/daemon_disabled.py @@ -28,7 +28,9 @@ def __init__(self): # self.notify(f"got daemon type: {self.type}, keepalive: {self.keepalive}") def _dummyNotify(self, *args, **kwargs): + log.debug(f"DaemonDummy notified: args={args}, kwargs={kwargs}") # Print log message # if args: # print(args[0]) return + diff --git a/mppsolar/daemon/daemon_openrc.py b/mppsolar/daemon/daemon_openrc.py new file mode 100644 index 00000000..34730f71 --- /dev/null +++ b/mppsolar/daemon/daemon_openrc.py @@ -0,0 +1,347 @@ +""" daemon_openrc.py """ +import logging +import os +import signal +import sys +import time +import atexit +from enum import Enum +from pathlib import Path + +try: + import psutil + HAS_PSUTIL = True +except ImportError: + HAS_PSUTIL = False + +from mppsolar.daemon.daemon import Daemon + +# Set-up logger +log = logging.getLogger("daemon_openrc") + + +class OpenRCNotification(Enum): + READY = "READY" + STATUS = "STATUS" + STOPPING = "STOPPING" + WATCHDOG = "WATCHDOG" + +class DaemonOpenRC(Daemon): + """ OpenRC daemon implementation with signal handling and configurable PID management """ + + def __str__(self): + return f"Daemon OpenRC (PID file: {self.pid_file_path})" + + def __init__(self, pid_file_path=None): + import logging + log = logging.getLogger("daemon_openrc") + + pid = os.getpid() + ppid = os.getppid() + log.info(f"[OPENRC_INIT] Creating OpenRC daemon: PID={pid}, PPID={ppid}") + + self._Notification = OpenRCNotification + self.keepalive = 60 + self._lastNotify = time.time() + self._pid_file = None + self._running = True + + # Set PID file location with smart defaults + if pid_file_path: + self.pid_file_path = pid_file_path + else: + # Auto-determine based on permissions and environment + if os.geteuid() == 0: # Running as root + self.pid_file_path = "/var/run/mpp-solar.pid" + else: # Non-root user + # Try user-specific locations + if 'XDG_RUNTIME_DIR' in os.environ: + self.pid_file_path = os.path.join(os.environ['XDG_RUNTIME_DIR'], "mpp-solar.pid") + else: + self.pid_file_path = "/tmp/mpp-solar.pid" + + log.info(f"PID file will be created at: {self.pid_file_path}") + + # Register cleanup function to run on exit + atexit.register(self._cleanup_pid_file) + + # Set up signal handlers + signal.signal(signal.SIGTERM, self._signal_handler) + signal.signal(signal.SIGINT, self._signal_handler) + + # Optional: Handle SIGHUP for config reload + signal.signal(signal.SIGHUP, self._sighup_handler) + + def set_pid_file_path(self, path): + """Allow external setting of PID file path""" + old_path = self.pid_file_path + self.pid_file_path = path + log.info(f"PID file path changed from {old_path} to {self.pid_file_path}") + + def _signal_handler(self, signum, frame): + """ Handle SIGTERM and SIGINT for clean shutdown """ + log.info(f"Received signal {signum}, initiating clean shutdown...") + self._running = False + self.stop() + self._cleanup_pid_file() + sys.exit(0) + + def _sighup_handler(self, signum, frame): + """ Handle SIGHUP for potential config reload """ + log.info("Received SIGHUP - config reload not implemented yet") + # Future: implement config reload functionality + + def _check_existing_daemon(self): + """Check if daemon with PID from file is actually running""" + try: + with open(self.pid_file_path, 'r') as pid_file: + content = pid_file.read().strip() + if not content: + log.warning("PID file is empty") + return False + + pid = int(content) + log.debug(f"Checking if PID {pid} is running...") + + if HAS_PSUTIL: + # Use psutil if available for more reliable process checking + try: + process = psutil.Process(pid) + if process.is_running(): + log.info(f"Process {pid} is running: {process.name()}") + return True + else: + log.info(f"Process {pid} is not running") + return False + except psutil.NoSuchProcess: + log.info(f"No process found with PID {pid}") + return False + else: + # Fallback to os.kill method + try: + # Send signal 0 to check if process exists without killing it + os.kill(pid, 0) + log.info(f"Process {pid} exists and is running") + return True + except OSError: + log.info(f"No process found with PID {pid}") + return False + + except (ValueError, FileNotFoundError, PermissionError) as e: + log.warning(f"Could not check existing daemon: {e}") + return False + + def _create_pid_file(self): + """ Create PID file with current process ID """ + pid = os.getpid() + log.info(f"Creating PID file {self.pid_file_path} with PID {pid}") + + try: + # Ensure directory exists and is writable + pid_dir = Path(self.pid_file_path).parent + pid_dir.mkdir(parents=True, exist_ok=True) + log.debug(f"PID directory ensured: {pid_dir}") + + # Check directory permissions + if not os.access(pid_dir, os.W_OK): + log.error(f"No write permission for PID directory: {pid_dir}") + return False + + # Check if file already exists and handle appropriately + if os.path.exists(self.pid_file_path): + log.warning(f"PID file {self.pid_file_path} already exists, checking if daemon is running...") + if self._check_existing_daemon(): + log.error("Another daemon instance is already running") + return False + else: + log.info("Removing stale PID file") + try: + os.remove(self.pid_file_path) + except Exception as e: + log.error(f"Failed to remove stale PID file: {e}") + return False + + # Create PID file with explicit write and flush + try: + # Use atomic write operation where possible + temp_pid_file = f"{self.pid_file_path}.tmp" + + with open(temp_pid_file, 'w') as pid_file: + pid_file.write(str(pid)) + pid_file.flush() # Ensure data is written to disk + os.fsync(pid_file.fileno()) # Force kernel to write to disk + + # Atomically move temp file to final location + os.rename(temp_pid_file, self.pid_file_path) + + log.info(f"Successfully created PID file: {self.pid_file_path} with PID {pid}") + + # Verify the file was written correctly + with open(self.pid_file_path, 'r') as verify_file: + written_pid = verify_file.read().strip() + if written_pid != str(pid): + log.error(f"PID file verification failed: expected {pid}, got '{written_pid}'") + return False + log.debug(f"PID file verification successful: {written_pid}") + + return True + + except Exception as e: + log.error(f"Failed to write to PID file {self.pid_file_path}: {e}") + # Clean up temporary file if it exists + temp_pid_file = f"{self.pid_file_path}.tmp" + if os.path.exists(temp_pid_file): + try: + os.remove(temp_pid_file) + except: + pass + return False + + except Exception as e: + log.error(f"Failed to create PID file {self.pid_file_path}: {e}") + return False + + def _cleanup_pid_file(self): + """ Remove PID file on shutdown """ + try: + if os.path.exists(self.pid_file_path): + # Verify it's our PID before removing + try: + with open(self.pid_file_path, 'r') as pid_file: + content = pid_file.read().strip() + if content and int(content) == os.getpid(): + os.remove(self.pid_file_path) + log.info(f"Removed PID file: {self.pid_file_path}") + else: + log.warning(f"PID file contains different PID ({content}), not removing") + except (ValueError, FileNotFoundError): + # File doesn't exist or has invalid content, try to remove anyway + os.remove(self.pid_file_path) + log.info(f"Removed PID file: {self.pid_file_path}") + except Exception as e: + log.error(f"Failed to remove PID file {self.pid_file_path}: {e}") + + def initialize(self): + """Initialize daemon and create PID file""" + log.info("Initializing OpenRC daemon...") + + pid = os.getpid() + ppid = os.getppid() + log.info(f"[OPENRC_INITIALIZE] Before PID file creation: PID={pid}, PPID={ppid}") + + + # Create PID file + if not self._create_pid_file(): + log.error("Failed to create PID file, daemon cannot start") + sys.exit(1) + log.info(f"[OPENRC_INITIALIZE] After PID file creation: PID={pid}, PPID={ppid}") + + # Call parent initialization + super().initialize() + log.info("OpenRC daemon initialized successfully") + + def stop(self): + """Stop daemon and clean up""" + log.info("Stopping OpenRC daemon...") + self._running = False + super().stop() + + def is_running(self): + """Check if daemon should continue running""" + return self._running + + @classmethod + def stop_daemon(cls, pid_file_path): + """Class method to stop a running daemon by PID file""" + log.info(f"Attempting to stop daemon using PID file: {pid_file_path}") + + try: + if not os.path.exists(pid_file_path): + log.error(f"PID file not found: {pid_file_path}") + return False + + with open(pid_file_path, 'r') as pid_file: + content = pid_file.read().strip() + if not content: + log.error("PID file is empty") + return False + + pid = int(content) + log.info(f"Found PID {pid} in file, attempting to terminate...") + + # Check if process exists before trying to kill it + try: + os.kill(pid, 0) # Signal 0 just checks if process exists + except OSError: + log.warning(f"Process {pid} not found, removing stale PID file") + try: + os.remove(pid_file_path) + except: + pass + return True # Consider this success since daemon isn't running + + # Try graceful shutdown first (SIGTERM) + try: + log.info(f"Sending SIGTERM to PID {pid}") + os.kill(pid, signal.SIGTERM) + + # Wait a bit for graceful shutdown + for i in range(10): # Wait up to 10 seconds + time.sleep(1) + try: + os.kill(pid, 0) # Check if still running + except OSError: + # Process has terminated + log.info(f"Process {pid} terminated gracefully") + # Clean up PID file if it still exists + if os.path.exists(pid_file_path): + try: + os.remove(pid_file_path) + log.info(f"Removed PID file: {pid_file_path}") + except: + pass + return True + + # If still running, try SIGKILL + log.warning(f"Process {pid} didn't respond to SIGTERM, sending SIGKILL") + os.kill(pid, signal.SIGKILL) + + # Wait for force kill + for i in range(5): # Wait up to 5 seconds + time.sleep(1) + try: + os.kill(pid, 0) # Check if still running + except OSError: + # Process has terminated + log.info(f"Process {pid} terminated forcefully") + # Clean up PID file + if os.path.exists(pid_file_path): + try: + os.remove(pid_file_path) + log.info(f"Removed PID file: {pid_file_path}") + except: + pass + return True + + log.error(f"Failed to terminate process {pid}") + return False + + except OSError as e: + log.error(f"Failed to send signal to process {pid}: {e}") + return False + + except (ValueError, FileNotFoundError, PermissionError) as e: + log.error(f"Error stopping daemon: {e}") + return False + + def _notify(self, notification, message=None): + """Handle daemon notifications""" + if message: + log.info(f"Daemon notification: {notification.value} - {message}") + else: + log.debug(f"Daemon notification: {notification.value}") + + def _journal(self, message): + """Log message to system journal (or regular log)""" + log.info(message) + diff --git a/mppsolar/helpers.py b/mppsolar/helpers.py index 70a608a0..b8079df4 100644 --- a/mppsolar/helpers.py +++ b/mppsolar/helpers.py @@ -1,4 +1,6 @@ #!/usr/bin/env python3 +import os +import sys import logging import importlib @@ -127,3 +129,122 @@ def compute_crc(self, data): def crc_hex(self, data): crc = self.compute_crc(data) return format(crc, '04x').upper() + +def log_pyinstaller_context(): + """ + Log context info if running inside a PyInstaller bundle. + """ + if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): + log.info("Running from PyInstaller bundle. An initial loader process may appear in pstree.") + log.debug(f"PyInstaller context: sys.executable={sys.executable}, _MEIPASS={sys._MEIPASS}") + + +def daemonize(): + """ + Properly daemonize the process (Unix double-fork) + Enhanced for PyInstaller compatibility + """ + import logging + from mppsolar.pyinstaller_runtime import is_pyinstaller_bundle, has_been_spawned + + log = logging.getLogger("helpers") + pid = os.getpid() + ppid = os.getppid() + log.info(f"[DAEMONIZE] Before fork PID: {pid}, PPID: {ppid}") + + # Special handling for PyInstaller spawned processes + if is_pyinstaller_bundle() and has_been_spawned(): + log.info("[DAEMONIZE] Running in spawned PyInstaller process - using modified daemonization") + + # We're already in a subprocess, so we can do a simpler daemonization + # Just do a single fork and session setup + try: + pid = os.fork() + if pid > 0: + log.info(f"[DAEMONIZE] Fork successful, parent exiting. Child PID: {pid}") + sys.exit(0) + except OSError as e: + log.error(f"Fork failed: {e}") + sys.exit(1) + + # Set up daemon environment + os.chdir("/") + os.setsid() + os.umask(0) + + # Redirect standard file descriptors + sys.stdout.flush() + sys.stderr.flush() + + log.info(f"[DAEMONIZE] PyInstaller daemon process ready. PID: {os.getpid()}") + return + + # Standard daemonization for non-PyInstaller or direct execution + try: + pid = os.fork() + if pid > 0: + log.info(f"[DAEMONIZE] First fork successful, parent exiting. Child PID: {pid}") + sys.exit(0) + except OSError as e: + log.error(f"First fork failed: {e}") + sys.exit(1) + + os.chdir("/") + os.setsid() + os.umask(0) + + try: + pid = os.fork() + if pid > 0: + log.info(f"[DAEMONIZE] Second fork successful, intermediate parent exiting. Child PID: {pid}") + sys.exit(0) + except OSError as e: + log.error(f"Second fork failed: {e}") + sys.exit(1) + + # Redirect standard file descriptors to /dev/null + sys.stdout.flush() + sys.stderr.flush() +# with open('/dev/null', 'r') as si: # Disabled while testing pyinstaller code +# os.dup2(si.fileno(), sys.stdin.fileno()) +# with open('/dev/null', 'a+') as so: +# os.dup2(so.fileno(), sys.stdout.fileno()) +# with open('/dev/null', 'a+') as se: +# os.dup2(se.fileno(), sys.stderr.fileno()) + + log.info(f"[DAEMONIZE] Daemon process forked successfully. PID: {os.getpid()}") + + +def has_been_spawned(): +# return os.environ.get("MPP_SOLAR_SPAWNED") == "1" + val = os.environ.get("MPP_SOLAR_SPAWNED") + log.info(f"has_been_spawned(): MPP_SOLAR_SPAWNED={val}") + return val == "1" + + +def setup_daemon_logging(log_file="/var/log/mpp-solar.log"): + """Setup logging for daemon mode""" + try: + # Create log directory if it doesn't exist + log_dir = os.path.dirname(log_file) + os.makedirs(log_dir, exist_ok=True) + + # Setup file logging + file_handler = logging.FileHandler(log_file) + file_handler.setLevel(logging.INFO) + + # Setup formatter + formatter = logging.Formatter( + '%(asctime)s:%(levelname)s:%(module)s:%(funcName)s@%(lineno)d: %(message)s' + ) + file_handler.setFormatter(formatter) + + # Get root logger and add handler + root_logger = logging.getLogger() + root_logger.addHandler(file_handler) + + return True + except Exception as e: + print(f"Failed to setup daemon logging: {e}") + return False + diff --git a/mppsolar/inout/__init__.py b/mppsolar/inout/__init__.py index 7c4419c9..35239557 100644 --- a/mppsolar/inout/__init__.py +++ b/mppsolar/inout/__init__.py @@ -11,6 +11,7 @@ class PortType(Enum): USB = auto() ESP32 = auto() SERIAL = auto() + JKSERIAL = auto() JKBLE = auto() MQTT = auto() VSERIAL = auto() @@ -62,6 +63,9 @@ def get_port_type(port): elif "vserial" in port: log.debug("port matches vserial") return PortType.VSERIAL + elif "jkserial" in port: + log.debug("port matches jkserial") + return PortType.JKSERIAL elif "serial" in port: log.debug("port matches serial") return PortType.SERIAL @@ -113,6 +117,12 @@ def get_port(*args, **kwargs): _port = SerialIO(device_path=port, serial_baud=baud) + elif port_type == PortType.JKSERIAL: + log.info("Using jkserialio for communications") + from mppsolar.inout.jkserialio import JKSerialIO + + _port = JKSerialIO(device_path=port, serial_baud=baud) + elif port_type == PortType.DALYSERIAL: log.info("Using dalyserialio for communications") from mppsolar.inout.dalyserialio import DalySerialIO diff --git a/mppsolar/inout/jkserialio.py b/mppsolar/inout/jkserialio.py new file mode 100644 index 00000000..b6486301 --- /dev/null +++ b/mppsolar/inout/jkserialio.py @@ -0,0 +1,61 @@ +import logging +import serial +import time + +from .baseio import BaseIO +from ..helpers import get_kwargs + +log = logging.getLogger("JKSerialIO") + + +class JKSerialIO(BaseIO): + def __init__(self, *args, **kwargs) -> None: + self._serial_port = get_kwargs(kwargs, "device_path") + self._serial_baud = get_kwargs(kwargs, "serial_baud") + self.no_data_counter = 0 + + def pattern_matched(self, data): + if len(data) >= 5: + return ( + data[-5] == 0x68 and + data[-4] == 0x00 and + data[-3] == 0x00 + # data[-2] and data[-1] can be anything, so no specific check needed + ) + return False + + def send_and_receive(self, *args, **kwargs) -> dict: + full_command = get_kwargs(kwargs, "full_command") + response_line = None + log.debug(f"port {self._serial_port}, baudrate {self._serial_baud}") + try: + with serial.serial_for_url(self._serial_port, self._serial_baud) as s: + log.debug("Executing command via jkserialio...") + s.timeout = 1 + s.write_timeout = 1 + s.flushInput() + s.flushOutput() + s.write(full_command) + time.sleep(0.1) + + while self.no_data_counter < 5: # Try up to 5 times with no new data before exiting + if s.in_waiting > 0: + if response_line is None: + response_line = bytearray() + response_line.extend(s.read(s.in_waiting)) + self.no_data_counter = 0 # Reset counter if data was received + + # Check if the last 5 bytes match the pattern + if self.pattern_matched(response_line): + log.debug("JK serial end frame pattern matched.") + break # Exit the loop if the pattern is matched + else: + self.no_data_counter += 1 + time.sleep(0.01) + + log.debug("serial response was: %s", response_line) + return response_line + except Exception as e: + log.warning(f"Serial read error: {e}") + log.info("Command execution failed") + return {"ERROR": ["Serial command execution failed", ""]} diff --git a/mppsolar/main.py b/mppsolar/main.py index 57a0ea0d..33785939 100644 --- a/mppsolar/main.py +++ b/mppsolar/main.py @@ -3,3 +3,4 @@ if __name__ == "__main__": main() + diff --git a/mppsolar/outputs/prom.py b/mppsolar/outputs/prom.py index e5ee6cf8..748ca626 100644 --- a/mppsolar/outputs/prom.py +++ b/mppsolar/outputs/prom.py @@ -36,7 +36,7 @@ def output(self, *args, **kwargs): filter = config.get("filter", None) excl_filter = config.get("excl_filter", None) name = config.get("name", "mpp_solar") - dev = config.get("dev", "None") + dev = config.get("dev", dev) else: # get formatting info remove_spaces = True @@ -52,6 +52,10 @@ def output(self, *args, **kwargs): if name == "unnamed": name = "mpp_solar" + + if dev is None: + dev = "None" + # remove raw response data.pop("raw_response", None) data.pop("_command_description", None) diff --git a/mppsolar/outputs/prom_file.py b/mppsolar/outputs/prom_file.py index 27693543..48583024 100644 --- a/mppsolar/outputs/prom_file.py +++ b/mppsolar/outputs/prom_file.py @@ -16,10 +16,16 @@ def output(self, *args, **kwargs): # We override the method only to get the PushGateway URL from the config self.prom_output_dir = kwargs["prom_output_dir"] self.cmd = get_kwargs(kwargs, "data").get("_command", "").lower() + self.name = get_kwargs(kwargs, "name") return super().output(*args, **kwargs) def handle_output(self, content: str) -> None: - file_path = f"{self.prom_output_dir.rstrip('/')}/mpp-solar-{self.cmd}.prom" + if self.name != "unnamed": + self.filename = "{0}-{1}".format(self.name, self.cmd) + else: + self.filename = self.cmd + + file_path = f"{self.prom_output_dir.rstrip('/')}/mpp-solar-{self.filename}.prom" with open(file_path, "w") as f: f.write(content) diff --git a/mppsolar/protocols/abstractprotocol.py b/mppsolar/protocols/abstractprotocol.py index 7fcac828..5cf0706b 100644 --- a/mppsolar/protocols/abstractprotocol.py +++ b/mppsolar/protocols/abstractprotocol.py @@ -123,6 +123,12 @@ def process_response( # Just ignore these ones log.debug(f"Discarding {data_name}:{raw_value}") return [(None, raw_value, data_units, extra_info)] + if data_type == 'ack': + log.debug(f'ack mode {raw_value} {type(data_units)}') + key = raw_value.decode() + r = data_units.get(key) + print(data_name, r, "", extra_info) + return [(data_name, r, "", extra_info)] if data_type == "option": try: key = int(raw_value) @@ -392,7 +398,7 @@ def decode(self, response, command) -> dict: msgs[item_name] = [item_value, ""] else: print(f"item type {item_type} not defined") - elif command_defn["type"] == "SETTER": + elif command_defn["type"] in ["SETTER", "BLE_SETTER"]: # _key = "{}".format(command_defn["name"]).lower().replace(" ", "_") _key = command_defn["name"] msgs[_key] = [result, ""] @@ -525,6 +531,18 @@ def decode(self, response, command) -> dict: data_type = response_defn[0] # 1 data_name = response_defn[2] # 2 data_units = response_defn[3] # 3 + elif response_type == "BLE_SETTER": + #[["option", "DSP Has Bootstrap", ["No", "Yes"]]], + #["ack", "Command execution", {"NAK": "Failed", "ACK": "Successful"}] + raw_value = response + response_defn = command_defn["response"][0] + log.debug(f"Got defn {response_defn}") + # length = response_defn[1] #0 + data_type = response_defn[0] # 1 + data_name = response_defn[1] # 2 + data_units = response_defn[2] # 3 + else: + log.warning(f'Unknown response type {response_type}') # Check for lookup if data_type.startswith("lookup"): diff --git a/mppsolar/protocols/jk02.py b/mppsolar/protocols/jk02.py index 1a34439a..d8c361cc 100644 --- a/mppsolar/protocols/jk02.py +++ b/mppsolar/protocols/jk02.py @@ -134,7 +134,7 @@ "description": "Set cell over voltage protection", "help": " -- example setCellOVP3.65", "type": "SETTER", - "response_type": "POSITIONAL", + "response_type": "BLE_SETTER", "response": [ ["ack", "Command execution", {"NAK": "Failed", "ACK": "Successful"}] ], @@ -145,6 +145,82 @@ ], "regex": "setCellOVP(\\d\\.\\d*)$", }, + "setChargingOn" : { + "name": "setChargingOn", + "command_code": "1D", + "record_type": "2", + "description": "Set Charging On", + "type": "SETTER", + "response_type": "BLE_SETTER", + "response": [ + ["ack", "Command execution", {"NAK": "Failed", "ACK": "Successful"}] + ], + "test_responses": [ + b'\xaaU\x90\xeb\xc8\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00D' + ], + #"regex": "setChargingOn$", + }, + "setChargingOff" : { + "name": "setChargingOff", + "command_code": "1D", + "record_type": "2", + "description": "Set Charging Off", + "type": "SETTER", + "response_type": "BLE_SETTER", + "response": [ + ["ack", "Command execution", {"NAK": "Failed", "ACK": "Successful"}] + ], + "test_responses": [ + b'\xaaU\x90\xeb\xc8\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00D', + ], + #"regex": "setChargingOn$", + }, + "setDischargingOn" : { + "name": "setDischargingOn", + "command_code": "1E", + "record_type": "2", + "description": "Set Discharging On", + "type": "SETTER", + "response_type": "BLE_SETTER", + "response": [ + ["ack", "Command execution", {"NAK": "Failed", "ACK": "Successful"}] + ], + "test_responses": [ + b'\xaaU\x90\xeb\xc8\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00D' + ], + #"regex": "setChargingOn$", + }, + "setDischargingOff" : { + "name": "setDischargingOff", + "command_code": "1E", + "record_type": "2", + "description": "Set Discharging Off", + "type": "SETTER", + "response_type": "BLE_SETTER", + "response": [ + ["ack", "Command execution", {"NAK": "Failed", "ACK": "Successful"}] + ], + "test_responses": [ + b'\xaaU\x90\xeb\xc8\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00D', + ], + #"regex": "setChargingOn$", + }, + "setBalanceStart": { + "name": "setBalanceStart", + "command_code": "26", + "record_type": "2", + "description": "Set balancer start voltage", + "help": " -- example setBalanceStart2.5", + "type": "BLE_SETTER", + "response_type": "POSITIONAL", + "response": [ + ["ack", "Command execution", {"NAK": "Failed", "ACK": "Successful"}] + ], + "test_responses": [ + b'\xaaU\x90\xeb\xc8\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00D', + ], + "regex": "setBalanceStart(\\d\\.\\d*)$", + }, } diff --git a/mppsolar/protocols/jkabstractprotocol.py b/mppsolar/protocols/jkabstractprotocol.py index b18fcd19..077c93fd 100644 --- a/mppsolar/protocols/jkabstractprotocol.py +++ b/mppsolar/protocols/jkabstractprotocol.py @@ -8,6 +8,8 @@ log = logging.getLogger("jkAbstractProtocol") SOR = bytes.fromhex("55aaeb90") +XSOR = b'\xaaU\x90\xeb' + COMMANDS = { "getInfo": { @@ -90,9 +92,21 @@ def get_full_command(self, command) -> bytes: log.debug(f"cmd with SOR: {cmd}") # then has command code cmd[4] = int(self._command_defn["command_code"], 16) - if self._command_defn["type"] == "SETTER": + if self._command_defn["type"] in ["SETTER", "BLE_SETTER"]: cmd[5] = 0x04 - value = struct.pack(" 0: responses.append(response) return responses + if self._command_defn is not None and self._command_defn["response_type"] == "BLE_SETTER": + log.debug("BLE_SETTER") + if response == b'\xaaU\x90\xeb\xc8\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00D': + responses.append(b"ACK") + else: + responses.append(b"(NAK\x73\x73\r") + return responses else: return bytearray(response) def is_record_start(self, record): - if record.startswith(SOR): + if record.startswith(SOR) or record.startswith(XSOR): log.debug("SOR found in record") return True return False def wipe_to_start(self, record): - sor_loc = record.find(SOR) + sor_loc = max(record.find(SOR), record.find(XSOR)) if sor_loc == -1: log.debug("SOR not found in record") return bytearray() @@ -166,9 +187,9 @@ def is_record_complete(self, record): log.debug("No SOR found in record looking for completeness") return False # check that length one of the valid lengths (300, 320) - if len(record) == 300 or len(record) == 320: + if len(record) == 300 or len(record) == 320 or len(record) == 20: # check the crc/checksum is correct for the record data - crc = record[299] + crc = record[-1] calcCrc = crc8(record[:-1]) # print (crc, calcCrc) if crc == calcCrc: diff --git a/mppsolar/protocols/jkpb.py b/mppsolar/protocols/jkpb.py new file mode 100644 index 00000000..d6eae442 --- /dev/null +++ b/mppsolar/protocols/jkpb.py @@ -0,0 +1,124 @@ +import logging + +from .jkabstractprotocol import jkAbstractProtocol +# from .protocol_helpers import crc8 + + +log = logging.getLogger("jkpb") + +# BMS Info +# CMD: 01 10 161C 0001 02 0000 D3CD +# RSP: 55AAEB9003054A4B5F5042324131365331355000000031352E584100000031352E3130000000450BCE00C401000041545220424D5300000000000000000031323334000000000000000000000000323430353130000033313231333438303038350030303000000000000000000000000000000000003430393600000000000000000000000000000000000000000000000000000000FEFFFFFF2FE9010200000000901F00000000C0D8E7FE1F00000100000000000000000100CF030000000000000000000000000000DF07000000000000000000000000000001CF03000000000000000000000000000A00000101000000000000000000000000000000000000000000000008070000323201000000000000000000000000000000000000FE9FE9FF07000000000000005F0110161C0001C447 + +# Additional Info +# CMD: 01 10 161E 0001 02 0000 D22F +# RSP: 55AAEB900105E40C00005A0A0000960A0000420E0000AA0D000005000000AB0D00008C0A0000AC0D0000480D0000C4090000F04902001E0000003C000000F04902001E0000003C00000005000000D0070000580200002602000058020000260200009CFFFFFFCEFFFFFFE80300002003000008000000010000000100000001000000605B0300DC050000160D00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000500000060E3160050033C3218FEFFFFFF3FE9010200000000500110161E00016587 + +# Cell Readings +# CMD: 01 10 1620 0001 02 0000 D6F1 +# RSP: 55AAEB9002051D0D1D0D1D0D1C0D1C0D1D0D1C0D1C0D000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FF0000001D0D01000400420042004E00520057005700640066000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002C0100000000E4680000604F0700B1450000280126010000000000000058B9F30200605B030065000000E0135601640000007A0BCE0001010000000000000000000000000000FF0001000000E8032F00000066F43F40000000007D0A000000010101000600001E020000000000002C011E011E01B503ED19ED08AE1C00008051010000000000000000000000000000FEFF7FDD0F0100B007000000B5011016200001044B + +COMMANDS = { + "getBalancerData": { + "name": "getBalancerData", + "command_code": "0110161C0001020000D3CD", + "description": "Get Balancer Data", + "help": " -- Get Balancer Data", + "type": "QUERY", + "checksum_required": "True", + "response_type": "POSITIONAL", + "response": [ + ["Hex2Str", 308, "Response", ""], + ], + "test_responses": [ + bytes.fromhex( + "55AAEB9003054A4B5F5042324131365331355000000031352E584100000031352E3130000000450BCE00C401000041545220424D5300000000000000000031323334000000000000000000000000323430353130000033313231333438303038350030303000000000000000000000000000000000003430393600000000000000000000000000000000000000000000000000000000FEFFFFFF2FE9010200000000901F00000000C0D8E7FE1F00000100000000000000000100CF030000000000000000000000000000DF07000000000000000000000000000001CF03000000000000000000000000000A00000101000000000000000000000000000000000000000000000008070000323201000000000000000000000000000000000000FE9FE9FF07000000000000005F0110161C0001C447" + ), + ], + }, + "getAdditionalInfo": { + "name": "getAdditionalInfo", + "command_code": "0110161E0001020000D22F", + "description": "Get Additional Info", + "help": " -- Get Additional Info", + "type": "QUERY", + "checksum_required": "True", + "response_type": "POSITIONAL", + "response": [ + ["Hex2Str", 308, "Response", ""], + ], + "test_responses": [ + bytes.fromhex( + "55AAEB9002051D0D1D0D1D0D1C0D1C0D1D0D1C0D1C0D000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FF0000001D0D01000400420042004E00520057005700640066000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002C0100000000E4680000604F0700B1450000280126010000000000000058B9F30200605B030065000000E0135601640000007A0BCE0001010000000000000000000000000000FF0001000000E8032F00000066F43F40000000007D0A000000010101000600001E020000000000002C011E011E01B503ED19ED08AE1C00008051010000000000000000000000000000FEFF7FDD0F0100B007000000B5011016200001044B" + ), + ], + }, + "getCellData": { + "name": "getCellData", + "command_code": "011016200001020000D6F1", + "description": "Get Cell Data", + "help": " -- Get Cell Data", + "type": "QUERY", + "checksum_required": "True", + "response_type": "POSITIONAL", + "response": [ + ["Hex2Str", 308, "Response", ""], + ], + "test_responses": [ + bytes.fromhex( + "55AAEB900105E40C00005A0A0000960A0000420E0000AA0D000005000000AB0D00008C0A0000AC0D0000480D0000C4090000F04902001E0000003C000000F04902001E0000003C00000005000000D0070000580200002602000058020000260200009CFFFFFFCEFFFFFFE80300002003000008000000010000000100000001000000605B0300DC050000160D00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000500000060E3160050033C3218FEFFFFFF3FE9010200000000500110161E00016587" + ), + ], + }, +} + + +class jkpb(jkAbstractProtocol): + def __str__(self): + return "JKBMS PB model communication protocol handler" + + def __init__(self, *args, **kwargs) -> None: + super().__init__() + self._protocol_id = b"JKPB" + self.COMMANDS = COMMANDS + self.STATUS_COMMANDS = [ + "getBalancerData", + ] + self.SETTINGS_COMMANDS = [ + "", + ] + self.DEFAULT_COMMAND = "getBalancerData" + self._command_defn = None + + def get_full_command(self, command) -> bytes: + """ + Override the default get_full_command as its different for JK485 + """ + log.info("Using protocol %s with %i commands", self._protocol_id, len(self.COMMANDS)) + # These need to be set to allow other functions to work` + self._command = command + self._command_defn = self.get_command_defn(command) + # End of required variables setting + if self._command_defn is None: + # Maybe return a default here? + return None + if "command_code" in self._command_defn: + # full command is 7 bytes long + cmd = bytearray.fromhex(self._command_defn["command_code"]) + # 011016 1C 0001020000D3CD + # # address code + # address_code = 0x01 + # function_code = 0x10 + # starting_register = b'\x16\x1c' + # cmd[0] = address_code + # cmd[1] = function_code + # cmd[2:3] = starting_register + # log.debug("cmd with header: %s", cmd) + + # # frame data 0001020000 + # cmd[4:8] = bytes.fromhex("0001020000") + # log.debug("cmd with command code and frame data: %s", cmd) + # # checksum 0xff + # cmd[-1] = crc8(cmd) + log.debug("cmd with crc: %s", cmd) + return cmd diff --git a/mppsolar/pyinstaller_runtime.py b/mppsolar/pyinstaller_runtime.py new file mode 100644 index 00000000..4c07376e --- /dev/null +++ b/mppsolar/pyinstaller_runtime.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +import os +import sys +import time +import subprocess +import logging +import shutil +import tempfile +import atexit + +log = logging.getLogger(__name__) + + +def is_pyinstaller_bundle(): + # True if running in a PyInstaller bundle + return getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS') + + +def has_been_spawned(): + val = os.environ.get("MPP_SOLAR_SPAWNED") + log.info(f"has_been_spawned(): MPP_SOLAR_SPAWNED={val}") + return val == "1" + + + +def copy_essential_files(): + """ + Copy essential files from PyInstaller temp dir to a permanent location + Returns the permanent directory path + """ + if not is_pyinstaller_bundle(): + return None + + try: + # Create a permanent directory for our files + permanent_dir = tempfile.mkdtemp(prefix="mpp_solar_", suffix="_daemon") + log.info(f"Created permanent directory: {permanent_dir}") + + # Copy the entire extracted directory to permanent location + meipass_contents = os.listdir(sys._MEIPASS) + for item in meipass_contents: + src = os.path.join(sys._MEIPASS, item) + dst = os.path.join(permanent_dir, item) + + if os.path.isdir(src): + shutil.copytree(src, dst, symlinks=True) + else: + shutil.copy2(src, dst) + + # Make sure the main script is executable + main_script = os.path.join(permanent_dir, 'mpp-solar') + if os.path.exists(main_script): + os.chmod(main_script, 0o755) + + return permanent_dir + + except Exception as e: + log.error(f"Failed to copy essential files: {e}") + return None + + +def setup_permanent_environment(permanent_dir): + """Set up environment to use permanent directory instead of _MEIPASS""" + if permanent_dir: + # Update sys.path to use permanent directory + if sys._MEIPASS in sys.path: + sys.path.remove(sys._MEIPASS) + sys.path.insert(0, permanent_dir) + + # Update _MEIPASS to point to permanent directory + sys._MEIPASS = permanent_dir + + # Register cleanup for permanent directory + atexit.register(cleanup_permanent_directory, permanent_dir) + + log.info(f"Environment updated to use permanent directory: {permanent_dir}") + + +def cleanup_permanent_directory(permanent_dir): + """Clean up the permanent directory on exit""" + try: + if os.path.exists(permanent_dir): + shutil.rmtree(permanent_dir) + log.info(f"Cleaned up permanent directory: {permanent_dir}") + except Exception as e: + log.warning(f"Failed to clean up permanent directory {permanent_dir}: {e}") + + + + +def spawn_pyinstaller_subprocess(args): + """ + Handles PyInstaller bootstrap-spawn logic to prevent premature termination + of daemonized processes. + Returns True if a subprocess is spawned and parent should exit. + """ + if args.daemon and is_pyinstaller_bundle() and not has_been_spawned(): + log.warning("Running from PyInstaller — spawning subprocess to survive bootstrap parent") + + # Create permanent copy of extracted files + permanent_dir = copy_essential_files() + if not permanent_dir: + log.error("Failed to create permanent copy of files") + return False + + new_env = os.environ.copy() + new_env["MPP_SOLAR_SPAWNED"] = "1" + new_env["MPP_SOLAR_PERMANENT_DIR"] = permanent_dir + executable = os.path.join(permanent_dir, os.path.basename(sys.executable)) + if not os.path.exists(executable): + executable = sys.executable + +# cmd = [executable] + sys.argv[1:] + cmd = [executable] + [arg for arg in sys.argv[1:] if arg != "--daemon"] + ["--daemon"] + + log.info(f"Launching child with cmd: {' '.join(cmd)}") + log.debug(f"Spawning child subprocess: {cmd}") + log.debug(f"Working directory: {permanent_dir}") + + try: + proc = subprocess.Popen( + cmd, + env=new_env, + cwd=permanent_dir, + start_new_session=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL + ) + + # Wait a bit to ensure child process starts successfully + for i in range(10): + if proc.poll() is not None: + if proc.returncode == 0: + log.info("Child process daemonized and exited cleanly (expected for daemon mode)") + else: + log.error(f"Child process exited prematurely with code: {proc.returncode}") + cleanup_permanent_directory(permanent_dir) + return True + time.sleep(0.5) + + log.info(f"Child process started successfully with PID: {proc.pid}") + log.info("Parent process exiting - child will continue as daemon") + return True + + except Exception as e: + log.error(f"Failed to spawn subprocess: {e}") + cleanup_permanent_directory(permanent_dir) + return False + + return False + + +def setup_spawned_environment(): + """ + Set up environment for spawned process to use permanent directory + """ + if has_been_spawned() and is_pyinstaller_bundle(): + permanent_dir = os.environ.get("MPP_SOLAR_PERMANENT_DIR") + if permanent_dir and os.path.exists(permanent_dir): + setup_permanent_environment(permanent_dir) + log.info("Spawned process environment configured") + return True + else: + log.warning("Spawned process but permanent directory not found") + return False diff --git a/mppsolar/version.py b/mppsolar/version.py index 052352ed..81934bae 100644 --- a/mppsolar/version.py +++ b/mppsolar/version.py @@ -1 +1 @@ -__version__ = "0.16.39" +__version__ = "0.16.56-dev" diff --git a/pyproject.toml b/pyproject.toml index b4e66105..cb3b3a22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "mppsolar" -version = "0.16.39" +version = "0.16.56" description = "Package to communicate with Solar inverters and BMSs" authors = ["John Blance"] readme = "README.md" diff --git a/tests/integration/test_cmd_mppsolar.py b/tests/integration/test_cmd_mppsolar.py index d5756fa9..0afe8d3f 100644 --- a/tests/integration/test_cmd_mppsolar.py +++ b/tests/integration/test_cmd_mppsolar.py @@ -10,7 +10,7 @@ def test_run_mppsolar(self): try: expected = "serial_number=9293333010501\n" result = subprocess.run( - ["mpp-solar", "-c", "QID", "-p", "test0", "-o", "simple"], check=True, capture_output=True, text=True + ["poetry", "run", "mpp-solar", "-c", "QID", "-p", "test0", "-o", "simple"], check=True, capture_output=True, text=True ) # print(result.stdout) self.assertEqual(result.stdout, expected) @@ -28,7 +28,7 @@ def test_run_mppsolar_screen(self): protocol_id PI30 --------------------------------------------------------------------------------\n\n\n""" result = subprocess.run( - ["mpp-solar", "-c", "QPI", "-p", "test0"], check=True, capture_output=True, text=True + ["poetry", "run", "mpp-solar", "-c", "QPI", "-p", "test0"], check=True, capture_output=True, text=True ) # print(result.stdout) self.assertEqual(result.stdout, expected) @@ -78,7 +78,7 @@ def test_run_mppsolar_test(self): "is_reserved": {"value": 0, "unit": "bool"}, } result = subprocess.run( - ["mpp-solar", "-c", "QPIGS", "-p", "test", "-o", "json_units"], + ["poetry", "run", "mpp-solar", "-c", "QPIGS", "-p", "test", "-o", "json_units"], check=True, capture_output=True, text=True, @@ -96,7 +96,7 @@ def test_mppsolar_mqtt(self): try: expected = "mqtt debug output only as broker name is 'screen' - topic: 'QPI/status/protocol_id/value', payload: 'PI30'\n" result = subprocess.run( - ["mpp-solar", "-p", "test", "-o", "mqtt", "-q", "screen"], + ["poetry", "run", "mpp-solar", "-p", "test", "-o", "mqtt", "-q", "screen"], check=True, capture_output=True, text=True, diff --git a/tests/integration/test_cmd_mppsolar_pi30revo.py b/tests/integration/test_cmd_mppsolar_pi30revo.py index 0dd96e18..a4aea60c 100644 --- a/tests/integration/test_cmd_mppsolar_pi30revo.py +++ b/tests/integration/test_cmd_mppsolar_pi30revo.py @@ -33,7 +33,7 @@ def do_test(self, command, expected, respno=0): try: # print(command, end=" ") result = subprocess.run( - [ + ["poetry", "run", "mppsolar", "-p", "test0", diff --git a/tests/integration/test_protocol_pi16.py b/tests/integration/test_protocol_pi16.py index 5dc0c460..5611c896 100644 --- a/tests/integration/test_protocol_pi16.py +++ b/tests/integration/test_protocol_pi16.py @@ -153,7 +153,7 @@ def test_pi16_getdevice_id(self): try: expected = "PI16:000:00000.27\n" result = subprocess.run( - ["mpp-solar", "-p", "test", "-P", "pi16", "--getDeviceId", "-o", "value"], + ["poetry", "run", "mpp-solar", "-p", "test", "-P", "pi16", "--getDeviceId", "-o", "value"], check=True, capture_output=True, text=True, diff --git a/tests/integration/test_protocol_pi17.py b/tests/integration/test_protocol_pi17.py index 48b47d67..0416da87 100644 --- a/tests/integration/test_protocol_pi17.py +++ b/tests/integration/test_protocol_pi17.py @@ -141,7 +141,7 @@ def do_test(self, command, expected, respno=0): # print(command, end=" ") respno += 1 result = subprocess.run( - [ + ["poetry", "run", "mppsolar", "-p", "test0", @@ -180,7 +180,7 @@ def test_pi17_getdevice_id(self): try: expected = "17:050\n" result = subprocess.run( - ["mpp-solar", "-p", "test", "-P", "pi17", "--getDeviceId", "-o", "value"], + ["poetry", "run", "mpp-solar", "-p", "test", "-P", "pi17", "--getDeviceId", "-o", "value"], check=True, capture_output=True, text=True, diff --git a/tests/integration/test_protocol_pi17m058.py b/tests/integration/test_protocol_pi17m058.py index 13e3cb3d..53bbb9a7 100644 --- a/tests/integration/test_protocol_pi17m058.py +++ b/tests/integration/test_protocol_pi17m058.py @@ -35,7 +35,7 @@ def do_test(self, command, expected, respno=0): try: # print(command, end=" ") result = subprocess.run( - [ + ["poetry", "run", "mppsolar", "-p", "test0", @@ -74,7 +74,7 @@ def test_pi17_getdevice_id(self): try: expected = "17:050\n" result = subprocess.run( - ["mpp-solar", "-p", "test", "-P", "pi17", "--getDeviceId", "-o", "value"], + ["poetry", "run", "mpp-solar", "-p", "test", "-P", "pi17", "--getDeviceId", "-o", "value"], check=True, capture_output=True, text=True, diff --git a/tests/integration/test_protocol_pi18.py b/tests/integration/test_protocol_pi18.py index 78b6a30c..a32f3629 100644 --- a/tests/integration/test_protocol_pi18.py +++ b/tests/integration/test_protocol_pi18.py @@ -207,7 +207,7 @@ def test_pi18_getdevice_id(self): try: expected = "18:5220\n" result = subprocess.run( - ["mpp-solar", "-p", "test", "-P", "pi18", "--getDeviceId", "-o", "value"], + ["poetry", "run", "mpp-solar", "-p", "test", "-P", "pi18", "--getDeviceId", "-o", "value"], check=True, capture_output=True, text=True, diff --git a/tests/integration/test_protocol_pi18lvx.py b/tests/integration/test_protocol_pi18lvx.py index bb37ee35..387945f5 100644 --- a/tests/integration/test_protocol_pi18lvx.py +++ b/tests/integration/test_protocol_pi18lvx.py @@ -398,7 +398,7 @@ def test_pi18lvx_getdevice_id(self): try: expected = "18:5402\n" result = subprocess.run( - ["mpp-solar", "-p", "test", "-P", "pi18lvx", "--getDeviceId", "-o", "value"], + ["poetry", "run", "mpp-solar", "-p", "test", "-P", "pi18lvx", "--getDeviceId", "-o", "value"], check=True, capture_output=True, text=True, diff --git a/tests/integration/test_protocol_pi18sv.py b/tests/integration/test_protocol_pi18sv.py index 8832ae81..445bc9b5 100644 --- a/tests/integration/test_protocol_pi18sv.py +++ b/tests/integration/test_protocol_pi18sv.py @@ -398,7 +398,7 @@ def test_pi18sv_getdevice_id(self): try: expected = "18:5402\n" result = subprocess.run( - [ + ["poetry", "run", "mpp-solar", "-p", "test", diff --git a/tests/integration/test_protocol_pi30.py b/tests/integration/test_protocol_pi30.py index 2e3a5084..88ff15a6 100644 --- a/tests/integration/test_protocol_pi30.py +++ b/tests/integration/test_protocol_pi30.py @@ -287,7 +287,7 @@ def test_pi30_getdevice_id(self): try: expected = "PI30:044:MKS2-8000\n" result = subprocess.run( - ["mpp-solar", "-p", "test", "-P", "pi30", "--getDeviceId", "-o", "value"], + ["poetry", "run", "mpp-solar", "-p", "test", "-P", "pi30", "--getDeviceId", "-o", "value"], check=True, capture_output=True, text=True, diff --git a/tests/integration/test_protocol_pi30max.py b/tests/integration/test_protocol_pi30max.py index 5a8d1195..3640baeb 100644 --- a/tests/integration/test_protocol_pi30max.py +++ b/tests/integration/test_protocol_pi30max.py @@ -20,7 +20,7 @@ def test_pi30max_getdevice_id(self): try: expected = "PI30:044:MKS2-8000\n" result = subprocess.run( - ["mpp-solar", "-p", "test", "-P", "pi30max", "--getDeviceId", "-o", "value"], + ["poetry", "run", "mpp-solar", "-p", "test", "-P", "pi30max", "--getDeviceId", "-o", "value"], check=True, capture_output=True, text=True, diff --git a/tests/integration/test_protocol_pi30mst.py b/tests/integration/test_protocol_pi30mst.py index afeef56d..57998614 100644 --- a/tests/integration/test_protocol_pi30mst.py +++ b/tests/integration/test_protocol_pi30mst.py @@ -20,7 +20,7 @@ def test_pi30mst_getdevice_id(self): try: expected = "PI30:044:MKS2-8000\n" result = subprocess.run( - ["mpp-solar", "-p", "test", "-P", "pi30mst", "--getDeviceId", "-o", "value"], + ["poetry", "run", "mpp-solar", "-p", "test", "-P", "pi30mst", "--getDeviceId", "-o", "value"], check=True, capture_output=True, text=True, diff --git a/tests/integration/test_protocols.py b/tests/integration/test_protocols.py index e96f04a1..c2875fa9 100644 --- a/tests/integration/test_protocols.py +++ b/tests/integration/test_protocols.py @@ -16,7 +16,7 @@ class testProtocols(unittest.TestCase): def test_protocols_count(self): # print(len(PROTOCOLS)) - assert len(PROTOCOLS) == 24, len(PROTOCOLS) + assert len(PROTOCOLS) == 25, len(PROTOCOLS) def test_protocols_init(self): for protocol in PROTOCOLS: diff --git a/tests/unit/test_protocol_jk02.py b/tests/unit/test_protocol_jk02.py new file mode 100644 index 00000000..bd00812c --- /dev/null +++ b/tests/unit/test_protocol_jk02.py @@ -0,0 +1,125 @@ +""" tests / unit / test_protocol_pi30revo.py """ +import unittest + +from mppsolar.protocols.jk02 import jk02 as proto + +protocol = proto() + +def set_balance_start(value): + assert value > 1500 and value < 4500 + # see https://github.com/jblance/mpp-solar/issues/114 + setBalanceStart2500 = bytearray([0xAA, 0x55, 0x90, 0xEB, 0x26, 0x04, 0xc4, 0x09, 0x00, 0x00, 0x23, 0xB2, 0xCD, 0x31, 0x2D, 0x28, 0xF2, 0x6B, 0x04, 0xFA]) + x = setBalanceStart2500.copy() + x[7] = int(value / 256) + x[6] = value % 256 + x[-1] = sum(x[0:-1])%256 + #print('%x %x %x' % (x[6], x[7], x[-1])) + # assert x == setBalanceStart2500 + # print(setBalanceStart2500) + return x + + +class TestJk02(unittest.TestCase): + """ unit tests of jk02 protocol """ + maxDiff = None + + def test_get_full_command_get_cell_data(self): + """ test for full command generation for getCellData""" + result = protocol.get_full_command("getCellData") + expected = b'\xaaU\x90\xeb\x96\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x10' + # print(result) + # print(expected) + self.assertEqual(expected, result) + + + def test_get_full_command_set_cell_ovp(self): + """ test for full command generation for setCellOVP""" + result = protocol.get_full_command("setCellOVP3.65") + expected = b'\xaaU\x90\xeb\x04\x04B\x0e\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xd2' + # print() + # print('result', result) + # print('expect', bytearray(expected)) + # print(set_balance_start(2500)) + self.assertEqual(expected, result) + + def test_get_full_command_set_balance_start(self): + """ test for full command generation for setBalanceStart2.5""" + # return + result = protocol.get_full_command("setBalanceStart2.5") + expected = b'\xaaU\x90\xeb&\x04\xc4\t\x00\x00#\xb2\xcd1-(\xf2k\x04\xfa' + # print() + # print("result", result) + # print("expect", bytearray(expected)) + # print("bal2.5", set_balance_start(2500)) + # for i, x in enumerate(result): + # print(i, hex(result[i]), hex(expected[i]), result[i]==expected[i]) + self.assertEqual(expected, result) + + def test_get_full_command_set_charging_on(self): + """ test for full command generation for setChargingOn""" + result = protocol.get_full_command("setChargingOn") + expected = b'\xaaU\x90\xeb\x1d\x04\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x9c' + # print() + # print("result", result) + # print("expect", bytearray(expected)) + # for i, x in enumerate(result): + # print(i, hex(result[i]), hex(expected[i]), result[i]==expected[i]) + self.assertEqual(expected, result) + + def test_get_full_command_set_charging_off(self): + """ test for full command generation for setChargingOff """ + result = protocol.get_full_command("setChargingOff") + expected = b'\xaaU\x90\xeb\x1d\x04\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x9b' + # print() + # print("result", result) + # print("expect", bytearray(expected)) + # for i, x in enumerate(result): + # print(i, hex(result[i]), hex(expected[i]), result[i]==expected[i]) + self.assertEqual(expected, result) + + def test_get_full_command_set_discharging_on(self): + """ test for full command generation for setDischargingOn""" + result = protocol.get_full_command("setDischargingOn") + expected = b'\xaaU\x90\xeb\x1e\x04\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x9d' + # print() + # print("result", result) + # print("expect", bytearray(expected)) + # for i, x in enumerate(result): + # print(i, hex(result[i]), hex(expected[i]), result[i]==expected[i]) + self.assertEqual(expected, result) + + def test_get_full_command_set_discharging_off(self): + """ test for full command generation for setDishargingOff """ + result = protocol.get_full_command("setDischargingOff") + expected = b'\xaaU\x90\xeb\x1e\x04\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x9c' + # print() + # print("result", result) + # print("expect", bytearray(expected)) + # for i, x in enumerate(result): + # print(i, hex(result[i]), hex(expected[i]), result[i]==expected[i]) + self.assertEqual(expected, result) + + # def test_checksum_pset(self): + # """ test for correct checksum for PSET command """ + # command = 'PSET120103 56.3 54.6 43.8 42.6 040 020 2020 02 18 17 06 00' + # byte_cmd = bytes(command, "utf-8") + # result = protocol.get_chk(byte_cmd) + # expected = 0xf7 # note documentation has f6 but tests have shown this is incorrect + # # print(hex(result)) + # # print(expected) + # self.assertEqual(expected, result) + + # def test_check_response_valid_qlith0(self): + # """ test for correctly validating valid qlith0 response """ + # response = b'(052.5 000.0 000.0 032 036 000 070.0 007.0 057.0 004.6 0 5\x82\xe0\r' + # result = protocol.check_response_valid(response)[0] + # # print(result) + # self.assertTrue(result) + + # def test_decode_qlith0(self): + # """ test successful decode of QLITH0 response """ + # expected = {'_command': 'QLITH0', '_command_description': 'Read lithium battery information', 'raw_response': ['(052.5 000.0 000.0 032 036 000 070.0 007.0 057.0 004.6 0 5\x82à\r', ''], 'Battery voltage from BMS': [52.5, 'V'], 'Battery charging current from BMS': [0.0, 'A'], 'Battery discharge current from BMS': [0.0, 'A'], 'Battery temperature': [32, '0.1°C'], 'Battery capacity from BMS': [36, '%'], 'Battery wearout': [0, '%'], 'Battery max charging current': [70.0, 'A'], 'Battery max discharge current': [7.0, 'A'], 'Battery max charge voltage': [57.0, 'V'], 'Battery min discharge voltage': [4.6, 'V'], 'Fault Code from BMS': ['No warning', ''], 'Warning Code fom BMS': ['Battery overcurrent', '']} + # response = b'(052.5 000.0 000.0 032 036 000 070.0 007.0 057.0 004.6 0 5\x82\xe0\r' + # result = protocol.decode(response, "QLITH0") + # # print(result) + # self.assertEqual(expected, result) diff --git a/tests/unit/test_protocol_jkpb.py b/tests/unit/test_protocol_jkpb.py new file mode 100644 index 00000000..0da0d83d --- /dev/null +++ b/tests/unit/test_protocol_jkpb.py @@ -0,0 +1,57 @@ +""" tests / unit / test_protocol_jkpb.py """ +import unittest + +from mppsolar.protocols.jkpb import jkpb as Proto + +# import construct as cs +proto = Proto() + + +class TestProtocolJkpb(unittest.TestCase): + """ exercise different functions in JKPB protocol """ + + # def test_check_crc(self): + # """ test a for correct CRC validation """ + # _result = proto.check_crc(response=b"(0 100 0 0 1 532 532 450 0000 0030\x0e\x5E\n", command_definition=proto.get_command_definition('QBMS')) + # # print(_result) + # self.assertTrue(_result) + + # def test_check_crc_incorrect(self): + # """ test an exception is raised if CRC validation fails """ + # self.assertRaises(InvalidCRC, proto.check_crc, response=b"(0 100 0 0 1 532 532 450 0000 0030\x0e\x5D\n", command_definition=proto.get_command_definition('QBMS')) + + # def test_trim(self): + # """ test protocol does a correct trim operation """ + # _result = proto.trim_response(response=b"(0 100 0 0 1 532 532 450 0000 0030\x0e\x5E\n", command_definition=proto.get_command_definition('QBMS')) + # expected = b'0 100 0 0 1 532 532 450 0000 0030' + # # print(_result) + # self.assertEqual(_result, expected) + + # def test_check_valid_ok(self): + # """ test protocol returns true for a correct response validation check """ + # _result = proto.check_valid(response=b"(0 100 0 0 1 532 532 450 0000 0030\x0e\x5E\n") + # expected = True + # # print(_result) + # self.assertEqual(_result, expected) + + # def test_check_valid_none(self): + # """ test protocol returns false for a None response validation check """ + # self.assertRaises(InvalidResponse, proto.check_valid, response=None) + + # def test_check_valid_short(self): + # """ test protocol returns false for a short response validation check """ + # self.assertRaises(InvalidResponse, proto.check_valid, response=b"(0") + + # def test_check_valid_missing(self): + # """ test protocol returns false for a response missing start char validation check """ + # self.assertRaises(InvalidResponse, proto.check_valid, response=b"0 100 0 0 1 532 532 450 0000 0030\x0e\x5E\n") + + + def test_full_command_getBalancerData(self): + """ test a for correct full command for getBalancerData """ + _result = proto.get_full_command(command="getBalancerData") + expected = bytearray.fromhex('011016 1C 0001020000D3CD') + # print() + # print(expected) + # print(_result) + self.assertEqual(_result, expected)