├── .env ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── Makefile ├── README.md ├── argonone.yaml ├── argonone ├── __init__.py └── cmdline.py ├── debian ├── argon1.argonone.dbus ├── argon1.argonone.logrotate ├── argon1.argonone.rsyslogd ├── argon1.argonone.service ├── argon1.argonone.sudoers ├── argon1.docs ├── argon1.install ├── argon1.links ├── argon1.manpages ├── argon1.postinst ├── argon1.sysuser ├── argon1.templates ├── changelog ├── control ├── copyright ├── lxplug-argon1.install ├── lxplug-argon1.postinst ├── lxplug-argon1.templates └── rules ├── git-dch.sh ├── lxpanel ├── Makefile ├── argonone.c └── icons │ ├── argonone-fan-high_16.png │ ├── argonone-fan-high_24.png │ ├── argonone-fan-medium_16.png │ ├── argonone-fan-medium_24.png │ ├── argonone-fan-paused_16.png │ ├── argonone-fan-paused_24.png │ ├── argonone-fan_16.png │ └── argonone-fan_24.png ├── man └── argonctl.1 ├── mypy.ini └── setup.py /.env: -------------------------------------------------------------------------------- 1 | PYTHONPATH=argonone 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ######################### 2 | # Project-specific 3 | 4 | # For debian build directories, see "Debian" section 5 | 6 | /attic/ 7 | 8 | 9 | ######################### 10 | # Debian 11 | 12 | # dpkg build directories (project-specific) 13 | /debian/python3-argonone/ 14 | /debian/argon1/ 15 | /debian/lxplug-argon1/ 16 | 17 | # auto-generated files 18 | /debian/tmp/ 19 | /debian/files 20 | /debian/*.substvars 21 | 22 | # debhelper 23 | /debian/.debhelper/ 24 | /debian/*.debhelper 25 | /debian/*.debhelper.log 26 | /debian/debhelper-build-stamp 27 | 28 | # pybuild 29 | .pybuild/ 30 | 31 | 32 | ######################### 33 | # C/C++ 34 | 35 | # Object files 36 | *.o 37 | *.so 38 | 39 | # clangd language server 40 | compile_commands.json 41 | .clangd/ 42 | 43 | 44 | ######################### 45 | # Python 46 | 47 | # Byte-compiled / optimized / DLL files 48 | __pycache__/ 49 | *.py[cod] 50 | *$py.class 51 | 52 | # C extensions 53 | *.so 54 | 55 | # Distribution / packaging 56 | .Python 57 | build/ 58 | develop-eggs/ 59 | dist/ 60 | downloads/ 61 | eggs/ 62 | .eggs/ 63 | lib/ 64 | lib64/ 65 | parts/ 66 | sdist/ 67 | var/ 68 | wheels/ 69 | pip-wheel-metadata/ 70 | share/python-wheels/ 71 | *.egg-info/ 72 | .installed.cfg 73 | *.egg 74 | MANIFEST 75 | 76 | # PyInstaller 77 | # Usually these files are written by a python script from a template 78 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 79 | *.manifest 80 | *.spec 81 | 82 | # Installer logs 83 | pip-log.txt 84 | pip-delete-this-directory.txt 85 | 86 | # Unit test / coverage reports 87 | htmlcov/ 88 | .tox/ 89 | .nox/ 90 | .coverage 91 | .coverage.* 92 | .cache 93 | nosetests.xml 94 | coverage.xml 95 | *.cover 96 | *.py,cover 97 | .hypothesis/ 98 | .pytest_cache/ 99 | 100 | # Translations 101 | *.mo 102 | *.pot 103 | 104 | # Flask stuff: 105 | instance/ 106 | .webassets-cache 107 | 108 | # Sphinx documentation 109 | docs/_build/ 110 | 111 | # PyBuilder 112 | target/ 113 | 114 | # IPython 115 | profile_default/ 116 | ipython_config.py 117 | 118 | # pyenv 119 | .python-version 120 | 121 | # pipenv 122 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 123 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 124 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 125 | # install all needed dependencies. 126 | #Pipfile.lock 127 | 128 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 129 | __pypackages__/ 130 | 131 | # SageMath parsed files 132 | *.sage.py 133 | 134 | # Environments 135 | # .env 136 | .venv 137 | env/ 138 | venv/ 139 | ENV/ 140 | env.bak/ 141 | venv.bak/ 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.pythonPath": "/usr/bin/python3", 3 | "editor.tabSize": 2, 4 | "editor.insertSpaces": true, 5 | "python.linting.flake8Args": [ 6 | "--extend-ignore=E111,E114", 7 | "--max-line-length=120" 8 | ], 9 | "python.linting.mypyEnabled": true 10 | } 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Spiros Papadimitriou 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: 2 | $(MAKE) -C lxpanel all 3 | 4 | %: 5 | $(MAKE) -C lxpanel $@ 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # argon1 2 | 3 | ![LXPanel plugin default view](../assets/lxplug-argon1-default.png?raw=true)   4 | ![LXPanel plugin medium speed](../assets/lxplug-argon1-medium.png?raw=true)   5 | ![LXPanel plugin high speed](../assets/lxplug-argon1-high.png?raw=true) 6 | 7 | 8 | This package provides temperature-based fan control and power button monitoring for the Argon One case for Raspberry Pi 4. It is a complete re-write and does not share any code with the official Argon40 packages (which were only used to figure out relatively simple hardware protocols). 9 | 10 | 11 | > ## DISCLAIMER 12 | > 13 | > **The package is in no way affiliated or endorsed by Argon40, and is _not_ officially supported.** 14 | > Use it at your own discretion and risk. 15 | 16 | I just got an Argon One case recently, I had a free weekend, and this provided an excellent excuse to play with setuptools and dpkg (which I always wanted but never got around to doing). This was a personal "distraction" project, which you _may_ find useful. As such, feel free to do as you wish with it, but do **not** expect any support or serious maintenance from me! 17 | 18 | # Differences from official scripts 19 | 20 | The main differences from the Argon40 script are: 21 | 22 | * The daemon is much more configurable, via `/etc/argonone.yaml`. 23 | * The daemon registers a system D-Bus service, which publishes notification signals, as well as methods to query and control it. 24 | * An LXPanel plugin is available, to show fan status and easily pause/unpause fan from a desktop session. 25 | * The daemon does not run as root. 26 | * Users can be selectively granted permission to control the daemon (all users can query it). 27 | * The `argonctl` commandline utility provides an easy way to query/control over D-Bus. 28 | * The daemon is installed as a systemd service. 29 | * The package is "debianized" natively, and can be easily installed via `dpkg` or `apt`. 30 | 31 | # Installation 32 | 33 | Simply download the `.deb` file and install it via 34 | 35 | ```shell 36 | sudo apt install argon1_x.y.z_all.deb 37 | ``` 38 | 39 | where `x.y.z` is the package version. 40 | 41 | If the installer detects user accounts other than `pi`, it will prompt you to grant permission to control the daemon. If you wish to add users yourself (e.g., if you want to selectively add a subset of user accounts, or if you create additional user accounts at a later time), you simply need to add them to the `argonone` group via 42 | 43 | ```shell 44 | sudo usermod -a -G argonone username 45 | ``` 46 | 47 | where `username` should be replaced with the actual username of the user you wish to grant control permissions to. 48 | 49 | ## LXPanel plugin 50 | 51 | If you also wish to install the LXPanel plugin, then *after* installing the `argon1_x.y.z_all.deb` package, also install the corresponding `lxplug-argon1_x.y.z_armhf.deb` package, in the same way. 52 | 53 | > **Please make sure that you install matching `x.y.z` versions of `argon1` and `lxplug-argon1` DEBs.** 54 | 55 | Package dependencies *should* ensure minimum for compatibility, but it's always better to keep versions in-sync. 56 | 57 | 58 | ## `apt` vs `dpkg` 59 | 60 | You can also use `dpkg` to install the `.deb` packages. However, in that case, you must manually install any missing dependencies. The easiest way to do that is *after* installing the `.deb`, e.g., 61 | 62 | ```shell 63 | sudo dpkg -i packagefile.deb 64 | sudo apt install -f 65 | ``` 66 | 67 | Alternatively, you can list the dependencies by, e.g., 68 | ```shell 69 | dpkg-deb --show --showformat='${Depends}\n' packagefile.deb 70 | ``` 71 | and then manually install the ones missing, in advance. 72 | 73 | Of course, if you are installing `lxplug-argon1`, then `apt` cannot install the `argon1` dependency, since it's not in any APT repositories. 74 | 75 | # Usage 76 | 77 | ## Command line 78 | 79 | You can query the daemon using the `argonctl` utility command: 80 | 81 | * `argonctl speed` shows the current fan speed setting. 82 | * `argonctl temp` shows the last CPU temperature measurement. 83 | * `argonctl pause` pauses temperature-based fan control; the fan will stay at whatever speed it was at the time the command was executed. 84 | * `argonctl resume` resumes temperature-based fan control. 85 | * `argonctl set_speed NNN` will set the fan speed to the requested value (must be between 0..100); if temperature-based fan control is not paused, then the daemon may change it the next time the temperature is measured (by default, this happens every 10 seconds). 86 | * `argonctl lut` shows the currently configured fan speed lookup table (LUT). 87 | 88 | There are a few additional commands that are probably less useful. If you wish to shutdown the daemon, please do so via systemd, e.g., `sudo systemctl stop argonone`. If you use `argonctl shutdown` directly, systemd will think the daemon crashed and will attempt to restart it. 89 | 90 | ## LXPanel plugin UI 91 | 92 | ![LXPanel plugin screenshot](../assets/lxplug-argon1.png?raw=true) 93 | 94 | Use of the panel plugin should be self-explanatory. It can be added just like any other plugin (right-click on panel and select "Add/Remove Panel Items"). 95 | 96 | Clicking on the plugin icon will toggle fan control (specifically, if fan control was on, then it will pause fan control and set it's speed to zero, otherwise it will unpause fan control, which should eventually adjust speed according to current temperature). A context menu with a few other common actions is accessible via right-click. 97 | 98 | Configuration of the plugin is fairly basic: you can choose whether you want a label in the panel (if hidden, you have to mouse over for tooltip) and whether you want temperature information to be displayed as well. 99 | 100 | ### Restarting `lxpanel` 101 | 102 | Please note that, as `lxpanel` is a monolithic app (which dynamically loads plugins from `.so` files at startup), you may need to restart it for any changes to be reflected. You can always log out and back in, but a less annoying way is via 103 | 104 | ``` 105 | lxpanelctl restart 106 | ``` 107 | 108 | > You may have to do this after installing or upgrading the `lxplug-argon1` package. 109 | > You may also have to do this if you (re)start the `argonone` system daemon. 110 | 111 | 112 | # Daemon configuration 113 | 114 | All configuration can be found in `/etc/argonone.yaml`, which should be self-explanatory. 115 | 116 | The default values should be fine and should not need to be adjusted. The setting you are more likely to want to experiment with is the temperature-based fan control lookup table (LUT). 117 | 118 | If you modify the configuration file, then you need to restart the daemon for the changes to take effect, via 119 | 120 | ```shell 121 | sudo systemctl argonone restart 122 | ``` 123 | 124 | Finally, note that the `enabled` configuration values simply determine the _initial_ "paused"/"unpaused" state of each daemon component each time the daemon starts up. However, this state can be toggled while the server is running, via the `argonctl` utility. For all other settings you _must_ restart the daemon (after editing `/etc/argonone.yaml`) to change them. 125 | 126 | # Troubleshooting and monitoring 127 | 128 | As stated earlier, you are on your own here! :) However, you may wish to start by inspecting the systemd logs, e.g., via 129 | 130 | ```shell 131 | systemctl status argonone 132 | ``` 133 | 134 | Furthermore, if you wish to monitor all daemon events, you can do so via, e.g., 135 | 136 | ```shell 137 | dbus-monitor --system "sender='net.clusterhack.ArgonOne'" 138 | ``` 139 | 140 | # Hardware protocol 141 | 142 | The hardware protocol is not officially documented but can be inferred from the official scripts. Some aspects are rather awkward (probably this is a "home-brew" protocol, not based on some standard IC for e.g., PWM control, and not intended for public consumption?). In particular: 143 | 144 | * The hardware uses both a GPIO pin (as an interrupt signal, from board to Pi communication), as well as I2C (device is a slave, for Pi to board communication). 145 | 146 | * A signal is sent over GPIO whenever the power button is pressed. This is a pulse, whose width encodes the press-type parameter: 10-30msec means "reboot" while 30-50msec means "shutdown". Note that pulse widths do not have anything to do with physical button presses ("reboot" is triggered by a double-press, whereas "shutdown" is triggered by a press between 3-5 sec). 147 | * After a "reboot" request, the board will not do anything else. However, it seems it will remember this and, after a "power off request" (see below) is received, it will restore power after cutting it briefly. 148 | * After a "shutdown" request, the board will wait for a brief period of time and then cut off power anyway. Therefore, it is critical that the system `shutdown` command is issued ASAP. 149 | Unfortunately, it seems that this poweroff delay is hardcoded into the board's firmware. 150 | 151 | > If your shutdown sequence takes longer than 1-2sec (e.g., if you mount network drives that need to be flushed and umounted, for instance), it would be better to avoid using the case's power button to initiate a shutdown. 152 | 153 | * On I2C, the device address is 0x1a (26) and only register 0x00 is used for everything. The register can only be written and it is "overloaded" for various things: 154 | * Values between 0-100 (inclusive) are interpreted as fan speeds. 155 | * The value 0xff (255) is interpreted as a "power off request". This is apparently intended to be sent by a systemd/initd shutdown script, so that the board knows to cut power even if the shutdown is performed by issuing a `shutdown` command manually (but, see above for exception). 156 | 157 | <rant> For what it's worth, IMHO the hardware protocol incorporates unnecessary, hardcoded logic. It would be better to delegate all logic to the Raspberry Pi: 158 | 159 | * Button presses should merely convey information about the event, but **not** initiate any actions. Those should be initiated by the Pi. In fact, I see no reason why the button press patterns should be hardcoded in the firmware; the button signal could be passed verbatim to the Pi's GPIO without any interpretation (maybe debouncing, but that's it). This would further allow easily changing or adding button press patterns in software. 160 | * Instead of the doubly-overloaded I2C register 0x00 and command 0xFF (with semantics of "turn off power and stay off, except if a "reboot" button press was sent \[how long ago?\], in which case turn power off and then back on"), the board could use another register (e.g., 0x01) for simple power-related commands. These should include at least a "power off and stay off" command code, as well as a "power off then back on" command. If implementing some minimal logic was desired (e.g., so that customers don't get disappointed if the button does nothing without software installed), then perhaps a "disable power actions" command code should also be included (which control software would issue immediately upon starting up -- and, perhaps, a separate "enable power actions" command could be optionally issued when control software exits). 161 | 162 | But, anyway, the protocol is what it is, and the overall experience is not at all bad, despite some minor shortcomings! The case design itself (and it's cooling capacity) is great, and that's what really matters. </rant> -------------------------------------------------------------------------------- /argonone.yaml: -------------------------------------------------------------------------------- 1 | power_button: 2 | enabled: True 3 | reboot_cmd: sudo /sbin/reboot 4 | shutdown_cmd: sudo /sbin/shutdown -h now 5 | fan_control: 6 | enabled: True 7 | poll_interval_sec: 10.0 8 | hysteresis_sec: 30.0 9 | speed_lut: 10 | - default: 0 11 | - 50: 2 12 | - 55: 3 13 | - 56: 10 14 | - 57: 30 15 | - 58: 55 16 | - 59: 75 17 | - 60: 100 18 | -------------------------------------------------------------------------------- /argonone/__init__.py: -------------------------------------------------------------------------------- 1 | # (c) 2020- Spiros Papadimitriou 2 | # 3 | # This file is released under the MIT License: 4 | # https://opensource.org/licenses/MIT 5 | # This software is distributed on an "AS IS" basis, 6 | # WITHOUT WARRANTY OF ANY KIND, either express or implied. 7 | 8 | import smbus 9 | import RPi.GPIO as GPIO 10 | from threading import Thread, Lock 11 | import os 12 | from contextlib import contextmanager, nullcontext 13 | from enum import Enum 14 | import shlex 15 | import subprocess 16 | import time 17 | import yaml 18 | import logging 19 | 20 | from typing import Generic, TypeVar, Sequence, List, Dict, Iterator, Tuple, Union, Optional, ContextManager 21 | 22 | from gi.repository import GLib 23 | import dbus 24 | import dbus.service 25 | import dbus.mainloop.glib 26 | 27 | __all__ = [ 28 | 'ArgonOneHardware', 'BUTTON_PRESS', 'get_pi_temperature', 'StepFunction', 29 | 'ArgonDaemon', 'dbus_proxy', 'NOTIFY', 30 | ] 31 | 32 | dbus.mainloop.glib.threads_init() 33 | log = logging.getLogger("argononed") 34 | 35 | NOTIFY = Enum('NOTIFY', [ 36 | ('VALUE_TEMPERATURE', "temperature"), 37 | ('VALUE_FAN_SPEED', "fan_speed"), 38 | ('VALUE_FAN_CONTROL_ENABLED', "fan_control_enabled"), 39 | ('VALUE_POWER_CONTROL_ENABLED', "power_control_enabled"), 40 | ('EVENT_SHUTDOWN', "shutdown_request"), 41 | ('EVENT_REBOOT', "reboot_request"), 42 | ('EVENT_FAN_SPEED_LUT_CHANGED', "fan_speed_lut_changed"), 43 | ]) 44 | 45 | BUTTON_PRESS = Enum('BUTTON_PRESS', [ 46 | 'SHUTDOWN', 47 | 'REBOOT', 48 | ]) 49 | 50 | ############################################################################ 51 | # Constants (private) 52 | 53 | _SHUTDOWN_BCM_PIN = 4 54 | _SHUTDOWN_GPIO_TIMEOUT_MS = 10000 55 | _SMBUS_DEV = 1 if GPIO.RPI_INFO['P1_REVISION'] > 1 else 0 56 | _SMBUS_ADDRESS = 0x1a 57 | _SMBUS_REGISTER = 0x00 58 | _SMBUS_VALUE_ACK = 0x00 # Official scripts use 0x00, other values could work? 59 | _SMBUS_VALUE_POWEROFF = 0xff 60 | _VCGENCMD_PATH = '/usr/bin/vcgencmd' 61 | _SYSFS_TEMPERATURE_PATH = '/sys/class/thermal/thermal_zone0/temp' 62 | _CONFIG_LOCATIONS = [ 63 | '/etc/argonone.yaml', 64 | '$HOME/.config/argonone.yaml', # XXX - is this safe?? 65 | ] 66 | 67 | 68 | ############################################################################ 69 | # Hardware API (GPIO & I2C) 70 | 71 | class ArgonOneBoard: 72 | _fan_speed: Optional[int] 73 | _bus_mutex: Union[ContextManager, Lock] 74 | 75 | def __init__(self, initial_speed: Optional[int] = 0, bus_mutex: Optional[Lock] = None): 76 | self._bus_mutex = bus_mutex if bus_mutex is not None else nullcontext() 77 | # Set up I2C and initialize fan speed 78 | self._bus = smbus.SMBus(_SMBUS_DEV) 79 | if initial_speed is not None: 80 | self.fan_speed = initial_speed # sets self._fan_speed, and also issues I2C command 81 | else: 82 | self._fan_speed = None # self._fan_speed still needs to be defined 83 | # Set up GPIO pin to listen for power button presses 84 | GPIO.setwarnings(False) 85 | GPIO.setmode(GPIO.BCM) 86 | GPIO.setup(_SHUTDOWN_BCM_PIN, GPIO.IN, pull_up_down=GPIO.PUD_DOWN) 87 | 88 | def _bus_write(self, value: int, register: int = _SMBUS_REGISTER): 89 | # Could raise IOError, according to "official" scripts 90 | self._bus.write_byte_data(_SMBUS_ADDRESS, register, int(value)) 91 | 92 | @property 93 | def is_threadsafe(self) -> bool: 94 | return not isinstance(self._bus_mutex, nullcontext) # type: ignore 95 | 96 | @property 97 | def fan_speed(self) -> Optional[int]: 98 | # Since modifying fan_speed property involves bus write, we also protect this access... 99 | with self._bus_mutex: 100 | return self._fan_speed 101 | 102 | @fan_speed.setter 103 | def fan_speed(self, value: int) -> None: 104 | # Threshold speed value between 0 and 100 (inclusive) 105 | value = int(max(min(value, 100), 0)) 106 | # Send I2C command 107 | with self._bus_mutex: 108 | try: 109 | self._bus_write(value) 110 | self._fan_speed = value # Only update if write was successful 111 | except IOError: 112 | log.warn("Fan control I2C command failed") 113 | 114 | # XXX Originally assumed this would serve as an "ACK", to prevent board 115 | # from cutting power, but that is not the case. In fact, the board will 116 | # not only cut power after a short, fixed time, but it will also stop 117 | # reading from the I2C bus. By the time the write times out, it is too 118 | # late to start a shutdown and avoid a hard crash. 119 | # 120 | # def power_ack(self) -> None: 121 | # # Send acknowledgment of power button press 122 | # with self._bus_mutex: 123 | # self._bus_write(_SMBUS_VALUE_ACK) 124 | 125 | def power_off(self) -> None: 126 | # Send request to turn power off 127 | with self._bus_mutex: 128 | self._bus_write(_SMBUS_VALUE_POWEROFF) 129 | 130 | def wait_for_button(self, timeout: int = _SHUTDOWN_GPIO_TIMEOUT_MS) -> Optional[BUTTON_PRESS]: 131 | # Logic based on Argon's scripts; it appears that: 132 | # - if pulse duration is between 10-30msec, then should reboot 133 | # - if pulse duration is betweenm 30-50msec, then should shutdown 134 | # - otherwise, nothing should be done 135 | # Both ranges are inclusive-exlcuside 136 | if GPIO.wait_for_edge(_SHUTDOWN_BCM_PIN, GPIO.RISING, timeout=timeout) is None: 137 | return None # Timed out 138 | rise_time = time.time() 139 | if GPIO.wait_for_edge(_SHUTDOWN_BCM_PIN, GPIO.FALLING, timeout=500) is None: 140 | log.warn("Power button monitor giving up on pulse that seems to exceed 500msec!") 141 | return None 142 | pulse_time = time.time() - rise_time 143 | if 0.01 <= pulse_time < 0.03: 144 | return BUTTON_PRESS.REBOOT 145 | elif 0.03 <= pulse_time < 0.05: 146 | return BUTTON_PRESS.SHUTDOWN 147 | else: 148 | return None 149 | 150 | def close(self) -> None: 151 | self._bus.close() 152 | 153 | def __del__(self): 154 | self.close() 155 | 156 | 157 | ############################################################################ 158 | # Auxilliary classes and functions 159 | 160 | def _is_monotone_increasing(seq: Sequence) -> bool: 161 | return all(seq[i-1] < seq[i] for i in range(1, len(seq))) 162 | 163 | 164 | # XXX failed to get this working 165 | # from abc import abstractmethod, ABCMeta 166 | # class Comparable(metaclass=ABCMeta): 167 | # @abstractmethod 168 | # def __lt__(self, other: Any) -> bool: ... 169 | 170 | K = TypeVar('K') # bound=Comparable) 171 | V = TypeVar('V') 172 | 173 | ItemIterator = Iterator[Tuple[Union[K, None], V]] 174 | 175 | class StepFunction(Generic[K, V]): # noqa: E302 176 | 177 | @classmethod 178 | def from_config_lut(cls, lut: Sequence[Dict[Union[str, K], V]]) -> 'StepFunction[K, V]': 179 | # Check arguments 180 | if len(lut) < 1: 181 | raise ValueError("LUT spec is empty!") 182 | if not all(len(d) == 1 for d in lut): # lut must be sequence of singleton dicts 183 | raise ValueError("LUT entries must consist of a single temp:speed pair") 184 | if 'default' not in lut[0]: # Works because we know that len(lut[0]) == 1 185 | raise ValueError("First LUT entry must specify default value") 186 | # Convert LUT to parallel lists (for "normal" constructor) 187 | thresholds: List[K] = [] 188 | values: List[V] = [] 189 | # XXX - is list(d.items())[0] less abstruse than next(iter(d.items())) ? 190 | lut_pairs = (next(iter(d.items())) for d in lut) 191 | for x, y in lut_pairs: 192 | if x != 'default': 193 | assert not isinstance(x, str) 194 | thresholds.append(x) 195 | values.append(y) 196 | # Construct step function object 197 | return cls(thresholds, values) 198 | 199 | @classmethod 200 | def from_iterator(cls, lut_iter: ItemIterator) -> 'StepFunction[K, V]': 201 | thresholds = [] 202 | values = [] 203 | for thr, val in lut_iter: 204 | if thr is not None: 205 | thresholds.append(thr) 206 | values.append(val) 207 | return cls(thresholds, values) 208 | 209 | def __init__(self, thresholds: Sequence[K], values: Sequence[V]): 210 | if len(values) != len(thresholds) + 1: 211 | raise ValueError("Number of thresholds and values do not match") 212 | if not _is_monotone_increasing(thresholds): 213 | raise ValueError("Threshold values are not sorted and/or not distinct") 214 | self._values: Sequence[V] = values 215 | self._thresholds: Sequence[K] = thresholds 216 | 217 | def __call__(self, x: K) -> V: 218 | for i, xi in enumerate(self._thresholds): 219 | if x < xi: # type: ignore # XXX see above for "Comparable" attempt 220 | return self._values[i] 221 | return self._values[-1] 222 | 223 | def items(self) -> ItemIterator[K, V]: 224 | yield (None, self._values[0]) 225 | yield from zip(self._thresholds, self._values[1:]) # XXX use itertools.islice? 226 | 227 | 228 | # vcgencmd-based implementation 229 | # def get_pi_temperature() -> Optional[float]: 230 | # result = subprocess.run([_VCGENCMD_PATH, 'measure_temp'], capture_output=True) 231 | # output = result.stdout.strip() 232 | # if output.startswith(b'temp='): 233 | # return float(output[len('temp='):-len('\'C')]) 234 | # return None # Failed to parse temperature value 235 | 236 | 237 | # sysfs-based implementation (using path found in gpiozero library) 238 | def get_pi_temperature() -> Optional[float]: 239 | try: 240 | with open(_SYSFS_TEMPERATURE_PATH, 'r') as fp: 241 | return int(fp.read().strip()) / 1000.0 242 | except (IOError, ValueError): 243 | return None 244 | 245 | 246 | ############################################################################ 247 | # Power button monitoring and control 248 | 249 | # Point-of-authority for power-button. 250 | # Monitors power button signals, and controls power state. 251 | # Anything related to power button should be delegated here. 252 | class PowerControlThread(Thread): 253 | def __init__(self, daemon: 'ArgonDaemon', argon_board: ArgonOneBoard, 254 | reboot_cmd: str, shutdown_cmd: str, *args, **kwargs): 255 | super().__init__(*args, **kwargs) 256 | self.argon_daemon = daemon # XXX use weakref? 257 | self._argon_board = argon_board 258 | assert self._argon_board.is_threadsafe 259 | self._reboot_cmdargs = shlex.split(reboot_cmd) 260 | self._shutdown_cmdargs = shlex.split(shutdown_cmd) 261 | self._control_enabled = True 262 | 263 | @property 264 | def control_enabled(self) -> bool: 265 | return self._control_enabled 266 | 267 | def disable_control(self) -> None: 268 | self._control_enabled = False 269 | self.argon_daemon.notify(NOTIFY.VALUE_POWER_CONTROL_ENABLED, False) 270 | log.info("Power button control disabled") 271 | 272 | def enable_control(self) -> None: 273 | self._control_enabled = True 274 | self.argon_daemon.notify(NOTIFY.VALUE_POWER_CONTROL_ENABLED, True) 275 | log.info("Power button control enabled") 276 | 277 | def run(self): 278 | log.info("Power button monitoring and control thread starting") 279 | self._stop_requested = False 280 | while not self._stop_requested: 281 | log.info("DBG: calling .wait_for_button") 282 | button_press = self._argon_board.wait_for_button() 283 | log.info("DBG: button_press = %s", button_press) 284 | # XXX Originally assumed this would serve as an "ACK", 285 | # but that is not the case (see comment above) 286 | # if button_press is not None: 287 | # self._argon_board.power_ack() 288 | if button_press == BUTTON_PRESS.REBOOT: 289 | log.info("Power button reboot detected") 290 | self.argon_daemon.notify(NOTIFY.EVENT_REBOOT) 291 | if self._control_enabled: 292 | log.info("Issuing reboot command") 293 | subprocess.run(self._reboot_cmdargs) 294 | elif button_press == BUTTON_PRESS.SHUTDOWN: 295 | log.info("Power button shutdown detected") 296 | self.argon_daemon.notify(NOTIFY.EVENT_SHUTDOWN) 297 | if not self._control_enabled: 298 | log.warn("Ignoring disabled power control; ArgonOne will cut power in a hurry anyway") 299 | log.info("Issuing shutdown command") 300 | subprocess.run(self._shutdown_cmdargs) 301 | log.info("Power button monitoring and control thread exiting") 302 | 303 | def stop(self): 304 | self._stop_requested = True 305 | 306 | 307 | ############################################################################ 308 | # Temperature monitoring and fan control 309 | 310 | LUTFunction = StepFunction[float, int] 311 | LUTItemIterator = ItemIterator[float, int] 312 | 313 | # Point-of-authority for fan and temperature. 314 | # Monitors temperature, and controls fan. 315 | # Anything related to fan and temperature should be delegated here. 316 | class FanControlThread(Thread): # noqa: E302 317 | def __init__(self, daemon: 'ArgonDaemon', argon_board: ArgonOneBoard, fan_speed_lut: LUTFunction, 318 | hysteresis_sec: float, poll_interval_sec: float, *args, **kwargs): 319 | super().__init__(*args, **kwargs) 320 | self.argon_daemon = daemon # XXX use weakref? 321 | self._argon_board = argon_board 322 | assert self._argon_board.is_threadsafe 323 | self._fan_speed_lut = fan_speed_lut # Need to guard direct access with mutex 324 | self._fan_speed_lut_mutex = Lock() 325 | self._poll_interval = poll_interval_sec 326 | self._hysteresis = hysteresis_sec # How long to wait before reducing speed 327 | self._temperature = get_pi_temperature() 328 | self._control_enabled = True 329 | 330 | @property 331 | def temperature(self) -> Optional[float]: 332 | return self._temperature 333 | 334 | @property 335 | def fan_speed(self) -> Optional[int]: 336 | return self._argon_board.fan_speed 337 | 338 | @fan_speed.setter 339 | def fan_speed(self, value: int) -> None: 340 | self._argon_board.fan_speed = value 341 | # Read fan_speed back, as it's possible it wasn't actually changed 342 | self.argon_daemon.notify(NOTIFY.VALUE_FAN_SPEED, self._argon_board.fan_speed) 343 | 344 | @property 345 | def fan_speed_lut(self) -> LUTItemIterator: 346 | with self._fan_speed_lut_mutex: 347 | return self._fan_speed_lut.items() 348 | 349 | @fan_speed_lut.setter 350 | def fan_speed_lut(self, lut: Union[LUTFunction, LUTItemIterator]) -> None: 351 | if not isinstance(lut, StepFunction): 352 | lut = StepFunction.from_iterator(lut) 353 | with self._fan_speed_lut_mutex: 354 | self._fan_speed_lut = lut 355 | self.argon_daemon.notify(NOTIFY.EVENT_FAN_SPEED_LUT_CHANGED) 356 | 357 | @property 358 | def control_enabled(self) -> bool: 359 | return self._control_enabled 360 | 361 | def enable_control(self) -> None: 362 | self._control_enabled = True 363 | self.argon_daemon.notify(NOTIFY.VALUE_FAN_CONTROL_ENABLED, True) 364 | log.info("Fan control disabled") 365 | 366 | def disable_control(self) -> None: 367 | self._control_enabled = False 368 | self.argon_daemon.notify(NOTIFY.VALUE_FAN_CONTROL_ENABLED, False) 369 | log.info("Fan control enabled") 370 | 371 | def run(self) -> None: 372 | log.info("Fan control and temperature monitoring thread starting") 373 | self._stop_requested = False 374 | while not self._stop_requested: 375 | self._temperature = get_pi_temperature() 376 | if self._temperature is None: 377 | log.warn("Failed to read temperature") 378 | else: 379 | self.argon_daemon.notify(NOTIFY.VALUE_TEMPERATURE, self._temperature) 380 | if self._control_enabled: 381 | with self._fan_speed_lut_mutex: 382 | speed = round(self._fan_speed_lut(self._temperature)) 383 | if speed != self.fan_speed: 384 | log.info(f"Adjusting fan speed to {speed} for temperature {self._temperature}") 385 | self.fan_speed = speed 386 | # TODO - Implement hysteresis 387 | time.sleep(self._poll_interval) 388 | log.info("Fan control and temperature monitoring thread exiting") 389 | 390 | def stop(self) -> None: 391 | self._stop_requested = True 392 | 393 | 394 | ############################################################################ 395 | # D-Bus service 396 | 397 | class ArgonOneException(dbus.DBusException): 398 | _dbus_error_name = 'net.clusterhack.ArgonOneException' 399 | 400 | 401 | # XXX python-dbus does not like type annotations 402 | class ArgonOne(dbus.service.Object): 403 | def __init__(self, conn, daemon: 'ArgonDaemon', object_path: str = '/net/clusterhack/ArgonOne'): 404 | super().__init__(conn, object_path) 405 | self.argon_daemon = daemon 406 | 407 | @dbus.service.method("net.clusterhack.ArgonOne", 408 | in_signature='', out_signature='i') 409 | def GetFanSpeed(self): 410 | return self.argon_daemon.fan_speed 411 | 412 | @dbus.service.method("net.clusterhack.ArgonOne", 413 | in_signature='i', out_signature='') 414 | def SetFanSpeed(self, speed: int): 415 | self.argon_daemon.fan_speed = speed 416 | 417 | @dbus.service.method("net.clusterhack.ArgonOne", 418 | in_signature='', out_signature='d') 419 | def GetTemperature(self): 420 | return self.argon_daemon.temperature 421 | 422 | @dbus.service.method("net.clusterhack.ArgonOne", 423 | in_signature='', out_signature='b') 424 | def GetFanControlEnabled(self): 425 | return self.argon_daemon.fan_control_enabled 426 | 427 | @dbus.service.method("net.clusterhack.ArgonOne", 428 | in_signature='b', out_signature='') 429 | def SetFanControlEnabled(self, enable): 430 | if enable: 431 | self.argon_daemon.enable_fan_control() 432 | else: 433 | self.argon_daemon.disable_fan_control() 434 | 435 | @dbus.service.method("net.clusterhack.ArgonOne", 436 | in_signature='', out_signature='a(dd)') 437 | def GetFanSpeedLUT(self): 438 | lut_list = list(self.argon_daemon.fan_speed_lut) 439 | # None doesn't match D-Bus return signature, so replace with -1 440 | assert lut_list[0][0] is None 441 | lut_list[0] = (-1, lut_list[0][1]) 442 | return lut_list 443 | 444 | @dbus.service.method("net.clusterhack.ArgonOne", 445 | in_signature='a(dd)', out_signature='') 446 | def SetFanSpeedLUT(self, lut_pairs): 447 | if len(lut_pairs) < 1 or lut_pairs[0][0] != -1: 448 | raise ArgonOneException("First LUT entry must be default value, with threshold of -1") 449 | lut_pairs[0][0] = None # Couldn't do None with a clean D-Bus signature 450 | try: 451 | lut = StepFunction.from_iterator(lut_pairs) 452 | except ValueError as exc: 453 | raise ArgonOneException(f"Failed to parse LUT: {str(exc)}") 454 | self.argon_daemon.fan_speed_lut = lut 455 | 456 | @dbus.service.method("net.clusterhack.ArgonOne", 457 | in_signature='', out_signature='b') 458 | def GetPowerControlEnabled(self): 459 | return self.argon_daemon.power_control_enabled 460 | 461 | @dbus.service.method("net.clusterhack.ArgonOne", 462 | in_signature='b', out_signature='') 463 | def SetPowerControlEnabled(self, enable): 464 | if enable: 465 | self.argon_daemon.enable_power_control() 466 | else: 467 | self.argon_daemon.disable_power_control() 468 | 469 | @dbus.service.method("net.clusterhack.ArgonOne", 470 | in_signature='', out_signature='') 471 | def Shutdown(self): 472 | self.argon_daemon.stop() 473 | 474 | @dbus.service.signal("net.clusterhack.ArgonOne", signature='sv') 475 | def NotifyValue(self, name, value): 476 | pass 477 | 478 | @dbus.service.signal("net.clusterhack.ArgonOne", signature='s') 479 | def NotifyEvent(self, name): 480 | pass 481 | 482 | 483 | # Point-of-authority for D-Bus. 484 | # "Monitors" D-Bus, and "controls" signal emmissions. 485 | # Anything related to D-Bus should be delegated here. 486 | class DBusServerThread(Thread): 487 | def __init__(self, daemon: 'ArgonDaemon', *args, **kwargs): 488 | super().__init__(*args, **kwargs) 489 | self.argon_daemon = daemon # XXX use weakref? 490 | self.argon_obj = None 491 | 492 | def notify(self, notify_type: NOTIFY, value: Optional[Union[bool, int, str]] = None) -> None: 493 | if self.argon_obj is None: 494 | return 495 | if value is not None: 496 | self.argon_obj.NotifyValue(notify_type.value, value) 497 | else: 498 | self.argon_obj.NotifyEvent(notify_type.value) 499 | 500 | def run(self) -> None: 501 | log.info("D-Bus server initialization") 502 | dbus_loop = dbus.mainloop.glib.DBusGMainLoop() 503 | system_bus = dbus.SystemBus(mainloop=dbus_loop) 504 | try: 505 | name = dbus.service.BusName("net.clusterhack.ArgonOne", system_bus) # noqa: F841 506 | self.argon_obj = ArgonOne(system_bus, self.argon_daemon) 507 | self.mainloop = GLib.MainLoop() 508 | log.info("D-Bus server thread starting") 509 | self.mainloop.run() 510 | finally: 511 | system_bus.close() 512 | log.info("D-Bus server thread exiting") 513 | 514 | def stop(self) -> None: 515 | self.mainloop.quit() # XXX - use GLib.idle_add ? 516 | 517 | 518 | # Coordinates the three types of monitor & control threads, 519 | # delegating requests accordingly. 520 | class ArgonDaemon: 521 | @staticmethod 522 | def load_config() -> Optional[dict]: 523 | config = None 524 | for config_location in _CONFIG_LOCATIONS: 525 | config_path = os.path.expandvars(config_location) 526 | if os.path.isfile(config_path): 527 | log.info(f"Loading config file from {config_path}") 528 | with open(config_path, 'r') as fp: 529 | config = yaml.load(fp) 530 | break 531 | if config is None: 532 | raise RuntimeError("No configuration file found!") 533 | return config # type: ignore 534 | 535 | def __init__(self): 536 | # Load configuration and extract relevant parameters 537 | config_yaml = self.load_config() 538 | power_config = config_yaml['power_button'] 539 | fan_config = config_yaml['fan_control'] 540 | # Initialize members 541 | self._argon_board = ArgonOneBoard(initial_speed=0, bus_mutex=Lock()) 542 | fan_lut = StepFunction.from_config_lut(fan_config['speed_lut']) 543 | hysteresis = fan_config.get('hysteresis_sec', 30.0) 544 | poll_interval = fan_config.get('poll_interval_sec', 10.0) 545 | fan_control_enabled = fan_config.get('enabled', True) 546 | self._fan_control_thread = FanControlThread(self, self._argon_board, fan_lut, hysteresis, poll_interval) 547 | if not fan_control_enabled: 548 | self._fan_control_thread.pause_control() 549 | reboot_cmd = power_config.get('reboot_cmd', 'sudo reboot') 550 | shutdown_cmd = power_config.get('shutdown_cmd', 'sudo shutdown -h now') 551 | power_control_enabled = power_config.get('enabled', True) 552 | self._power_control_thread = PowerControlThread(self, self._argon_board, reboot_cmd, shutdown_cmd) 553 | if not power_control_enabled: 554 | self._power_control_thread.disable_control() 555 | self._dbus_thread = DBusServerThread(self) 556 | 557 | @property 558 | def fan_speed(self) -> Optional[int]: 559 | return self._fan_control_thread.fan_speed # type: ignore 560 | 561 | @fan_speed.setter 562 | def fan_speed(self, value: int) -> None: 563 | self._fan_control_thread.fan_speed = value 564 | 565 | @property 566 | def temperature(self) -> Optional[float]: 567 | return self._fan_control_thread.temperature # type: ignore 568 | 569 | @property 570 | def fan_control_enabled(self) -> bool: 571 | return self._fan_control_thread.control_enabled # type: ignore 572 | 573 | def disable_fan_control(self) -> None: 574 | self._fan_control_thread.disable_control() 575 | 576 | def enable_fan_control(self) -> None: 577 | self._fan_control_thread.enable_control() 578 | 579 | @property 580 | def fan_speed_lut(self) -> LUTItemIterator: 581 | return self._fan_control_thread.fan_speed_lut # type: ignore 582 | 583 | @fan_speed_lut.setter 584 | def fan_speed_lut(self, lut: Union[LUTFunction, LUTItemIterator]) -> None: 585 | self._fan_control_thread.fan_speed_lut = lut 586 | 587 | @property 588 | def power_control_enabled(self) -> bool: 589 | return self._power_control_thread.control_enabled # type: ignore 590 | 591 | def disable_power_control(self) -> None: 592 | self._power_control_thread.disable_control() 593 | 594 | def enable_power_control(self) -> None: 595 | self._power_control_thread.enable_control() 596 | 597 | def notify(self, notify_type: NOTIFY, value: Optional[Union[bool, float, int]] = None) -> None: 598 | self._dbus_thread.notify(notify_type, value) 599 | 600 | def start(self) -> None: 601 | log.info("Daemon starting") 602 | self._dbus_thread.start() 603 | self._power_control_thread.start() 604 | self._fan_control_thread.start() 605 | 606 | def stop(self) -> None: 607 | log.info("Daemon stopping") 608 | # Stop in reverse start order 609 | self._fan_control_thread.stop() 610 | self._power_control_thread.stop() 611 | self._dbus_thread.stop() 612 | 613 | def wait(self) -> None: 614 | self._fan_control_thread.join() 615 | self._power_control_thread.join() 616 | self._dbus_thread.join() 617 | 618 | def close(self) -> None: 619 | self._argon_board.close() 620 | 621 | 622 | @contextmanager 623 | def dbus_proxy(dbus_loop: Optional[dbus.mainloop.NativeMainLoop] = None) -> dbus.proxies.Interface: 624 | # mainloop must be specified if one will be used 625 | system_bus = dbus.SystemBus(mainloop=dbus_loop) 626 | try: 627 | proxy = system_bus.get_object('net.clusterhack.ArgonOne', 628 | '/net/clusterhack/ArgonOne') 629 | iface = dbus.Interface(proxy, 'net.clusterhack.ArgonOne') 630 | yield iface 631 | finally: 632 | system_bus.close() 633 | -------------------------------------------------------------------------------- /argonone/cmdline.py: -------------------------------------------------------------------------------- 1 | # (c) 2020- Spiros Papadimitriou 2 | # 3 | # This file is released under the MIT License: 4 | # https://opensource.org/licenses/MIT 5 | # This software is distributed on an "AS IS" basis, 6 | # WITHOUT WARRANTY OF ANY KIND, either express or implied. 7 | 8 | import sys 9 | from . import ArgonOneBoard, ArgonDaemon, dbus_proxy 10 | 11 | from typing import Any, Optional, Union, Callable, Sequence, Dict 12 | 13 | 14 | def _error_message(msg: str) -> None: 15 | print("ERROR:", msg, file=sys.stderr) 16 | 17 | 18 | def _error_exit(error_msg: str, exit_status: int = 1, usage: Optional[Callable] = None) -> None: 19 | if usage is not None: 20 | usage(file=sys.stderr) 21 | _error_message(error_msg) 22 | sys.exit(exit_status) 23 | 24 | 25 | ############################################################################ 26 | # argonctl utility 27 | 28 | # Simple class to describe how commandline arguments should be parsed, 29 | # and how return values should be presented 30 | class _CmdInfo(object): 31 | __slots__ = ['dbus_method', 'arg_fmt', 'return_fmt'] 32 | 33 | def __init__(self, dbus_method: str, arg_fmt: Any = None, return_fmt: Optional[Callable] = None): 34 | # We allow arg_fmt to be a single non-sequence item, to avoid singleton literal clutter 35 | if arg_fmt is None: 36 | arg_fmt = () 37 | if not isinstance(arg_fmt, (tuple, list)): 38 | arg_fmt = (arg_fmt,) 39 | arg_fmt = tuple(arg_fmt) # ensure immutable 40 | 41 | # Validate arg_fmt first 42 | if arg_fmt is not None: 43 | val_seen = False 44 | for val_or_func in arg_fmt: 45 | if callable(val_or_func): 46 | if val_seen: 47 | raise ValueError('Callables cannot follow values in arg_fmt') 48 | else: # not is_func 49 | val_seen = True 50 | 51 | self.dbus_method = dbus_method 52 | self.arg_fmt = arg_fmt 53 | self.return_fmt = return_fmt 54 | 55 | @property 56 | def num_user_args(self) -> int: 57 | return sum(callable(af) for af in self.arg_fmt) # XXX ugh? 58 | 59 | def call_dbus(self, dbus_proxy, argv: Sequence[str]) -> str: 60 | if len(argv) != self.num_user_args: 61 | raise ValueError("Wrong number of user-provided arguments (argv)") 62 | # Construct argument list for method call 63 | dbus_args = [] 64 | for i, af in enumerate(self.arg_fmt): 65 | if callable(af): 66 | try: 67 | dbus_args.append(af(argv[i])) 68 | except: # noqa: E722 69 | raise ValueError(f"Failed to convert arg{i} value for {self.dbus_method}") 70 | else: 71 | dbus_args.append(af) 72 | # Issue RPC and format return value (if needed) 73 | dbus_func = getattr(dbus_proxy, self.dbus_method) 74 | retval = dbus_func(*dbus_args) 75 | if retval is not None and self.return_fmt is not None: 76 | retval = self.return_fmt(retval) 77 | return retval # type: ignore 78 | 79 | 80 | def _enabled_fmt(val) -> str: 81 | return 'enabled' if val else 'disabled' 82 | 83 | def _lut_fmt(pairs) -> str: # noqa: E302 84 | return '\n'.join(f"{x if x != -1 else 'default'}: {int(y)}" for x, y in pairs) 85 | 86 | # Dictionary values are either _CmdInfo or strings. A string value 87 | # denotes an alias and should be equal to another key of the dictionary. 88 | _argonctl_cmds: Dict[str, Union[str, _CmdInfo]] = { # noqa: E305 89 | 'temp': _CmdInfo('GetTemperature'), 90 | 'temperature': 'temp', 91 | 92 | 'speed': _CmdInfo('GetFanSpeed'), 93 | 'fan_speed': 'speed', 94 | 'set_speed': _CmdInfo('SetFanSpeed', int), 95 | 96 | 'pause': _CmdInfo('SetFanControlEnabled', False), 97 | 'pause_fan': 'pause', 98 | 'resume': _CmdInfo('SetFanControlEnabled', True), 99 | 'resume_fan': 'resume', 100 | 'fan_status': _CmdInfo('GetFanControlEnabled', None, _enabled_fmt), 101 | 'fan_enabled': 'fan_status', 102 | 103 | 'lut': _CmdInfo('GetFanSpeedLUT', None, _lut_fmt), 104 | 'fan_lut': 'lut', 105 | 106 | 'pause_button': _CmdInfo('SetPowerControlEnabled', False), 107 | 'resume_button': _CmdInfo('SetPowerControlEnabled', True), 108 | 'button_status': _CmdInfo('GetPowerControlEnabled', None, _enabled_fmt), 109 | 'button_enabled': 'button_status', 110 | 111 | 'shutdown': _CmdInfo('Shutdown'), 112 | } 113 | 114 | def _argonctl_print_usage(program_name=None, file=sys.stderr): # noqa: E302 115 | if program_name is None: 116 | program_name = sys.argv[0] 117 | print(f"USAGE: {program_name} command [parameter]\n", file=file) 118 | # Collect aliases 119 | aliases = {} 120 | for cmd_name, cmd_info in _argonctl_cmds.items(): # XXX assumes order-preserving dicts (py >= 3.7) 121 | if isinstance(cmd_info, str): 122 | # TODO Assumes non-recursive aliases 123 | aliases[cmd_info].append(cmd_name) 124 | else: 125 | aliases[cmd_name] = [] 126 | # Print list of commands 127 | print("COMMANDS", file=file) 128 | for cmd_name, alias_list in aliases.items(): 129 | print(" " + " | ".join([cmd_name] + alias_list), file=file) 130 | print(file=file) 131 | 132 | def argonctl_main() -> None: # noqa: E302 133 | # Check and parse arguments 134 | if len(sys.argv) < 2: 135 | _error_exit("Command name is missing", usage=_argonctl_print_usage) 136 | cmd_name: str = sys.argv[1] 137 | # Handle "help" separately 138 | if cmd_name == 'help': 139 | _argonctl_print_usage() 140 | sys.exit(0) 141 | try: 142 | # Look up _CmdInfo, resolving aliases 143 | cmd_info: Union[str, _CmdInfo] = cmd_name 144 | while isinstance(cmd_info, str): 145 | cmd_info = _argonctl_cmds[cmd_info] 146 | except KeyError: 147 | _error_exit(f"Unrecognized command {cmd_name}", usage=_argonctl_print_usage) 148 | # Make RPC call and print any result 149 | assert isinstance(cmd_info, _CmdInfo) 150 | with dbus_proxy() as dbus: 151 | retval = cmd_info.call_dbus(dbus, sys.argv[2:]) 152 | if retval is not None: 153 | print(retval) 154 | 155 | 156 | ############################################################################ 157 | # argononed system daemon 158 | 159 | # Utility function to try and pick a good logging format 160 | def _is_started_by_system() -> bool: 161 | try: 162 | from psutil import Process 163 | except ImportError: 164 | return True 165 | parent_name = Process(Process().ppid()).name() 166 | return any(parent_name.endswith(progname) for progname in ('systemd', 'upstart', 'init')) 167 | 168 | 169 | def argondaemon_main() -> None: 170 | import logging 171 | log_format = '%(levelname)s: %(message)s' 172 | if not _is_started_by_system(): 173 | log_format = '%(asctime)s: ' + log_format 174 | logging.basicConfig(format=log_format, datefmt='%m/%d/%Y %H:%M:%S', level=logging.INFO) 175 | daemon = ArgonDaemon() 176 | try: 177 | daemon.start() 178 | daemon.wait() 179 | finally: 180 | daemon.close() 181 | 182 | 183 | ############################################################################ 184 | # systemd shutdown script 185 | 186 | def argonshutdown_main() -> None: 187 | # Systemd shutdown script (runs after daemon is shut down) 188 | argon_board = ArgonOneBoard(initial_speed=None) # no mutex necessary 189 | if len(sys.argv) > 1 and sys.argv[1] in ('poweroff', 'halt'): 190 | # The button press "ACK" command (set fan speed to zero) 191 | # will have already been sent by the daemon 192 | argon_board.power_off() 193 | -------------------------------------------------------------------------------- /debian/argon1.argonone.dbus: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 21 | 22 | 24 | 25 | 27 | 28 | 30 | 31 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /debian/argon1.argonone.logrotate: -------------------------------------------------------------------------------- 1 | /var/log/argonone.log 2 | { 3 | daily 4 | rotate 14 5 | missingok 6 | notifempty 7 | compress 8 | delaycompress 9 | postrotate 10 | /usr/lib/rsyslog/rsyslog-rotate 11 | endscript 12 | } 13 | 14 | -------------------------------------------------------------------------------- /debian/argon1.argonone.rsyslogd: -------------------------------------------------------------------------------- 1 | :programname, isequal, "argonone" -/var/log/argonone.log 2 | & stop 3 | -------------------------------------------------------------------------------- /debian/argon1.argonone.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Fan and power control for ArgonOne case 3 | After=network.target 4 | 5 | [Service] 6 | User=argonone 7 | Group=argonone 8 | SyslogIdentifier=argonone 9 | ExecStart=/usr/bin/argononed 10 | ExecStop=/usr/bin/argonctl shutdown 11 | KillMode=process 12 | Restart=on-failure 13 | RestartSec=50s 14 | 15 | [Install] 16 | WantedBy=multi-user.target 17 | 18 | -------------------------------------------------------------------------------- /debian/argon1.argonone.sudoers: -------------------------------------------------------------------------------- 1 | argonone ALL=(root) NOPASSWD: /sbin/shutdown, (root) NOPASSWD: /sbin/reboot 2 | -------------------------------------------------------------------------------- /debian/argon1.docs: -------------------------------------------------------------------------------- 1 | README.md 2 | LICENSE 3 | -------------------------------------------------------------------------------- /debian/argon1.install: -------------------------------------------------------------------------------- 1 | #!/usr/bin/dh-exec 2 | argonone.yaml => etc/argonone.yaml 3 | debian/argon1.argonone.sudoers => etc/sudoers.d/010_argonone 4 | debian/argon1.argonone.rsyslogd => etc/rsyslog.d/argonone.conf 5 | debian/argon1.argonone.dbus => etc/dbus-1/system.d/argonone.conf 6 | # 7 | usr/bin/* 8 | usr/lib/python*/dist-packages 9 | -------------------------------------------------------------------------------- /debian/argon1.links: -------------------------------------------------------------------------------- 1 | usr/bin/argonone-shutdown lib/systemd/system-shutdown/argonone 2 | -------------------------------------------------------------------------------- /debian/argon1.manpages: -------------------------------------------------------------------------------- 1 | man/argonctl.1 2 | -------------------------------------------------------------------------------- /debian/argon1.postinst: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | . /usr/share/debconf/confmodule 6 | 7 | if [ -n "$DEBIAN_SCRIPT_DEBUG" ]; then set -v -x; DEBIAN_SCRIPT_TRACE=1; fi 8 | ${DEBIAN_SCRIPT_TRACE:+ echo "#42#DEBUG# RUNNING $0 $*" 1>&2 } 9 | 10 | #DEBHELPER# 11 | 12 | # Change owner of /usr/bin/argonone-shutdown to argonone (script is setuid) 13 | echo "Setting /usr/bin/argonone-shutdown to suid argonone" 14 | chown argonone.argonone /usr/bin/argonone-shutdown 15 | chmod u+s /usr/bin/argonone-shutdown 16 | 17 | # Add user "argonone" to the "i2c" group, so daemon can access 18 | echo "Adding user argonone to gpio and i2c groups" 19 | usermod -a -G gpio,i2c argonone 20 | 21 | # Prints out the usernames od all user accounts (one per line) 22 | user_account_names() { 23 | getent passwd | while read -r line; do 24 | readarray -d: -t fields <<<"$line" 25 | if [ "${fields[2]}" -ge 1000 -a "${fields[2]}" -lt 60000 ]; then 26 | echo "${fields[0]}" 27 | fi 28 | done 29 | } 30 | 31 | # Add user "pi" to "argonone" group (if user exists) 32 | if getent passwd pi >/dev/null; then 33 | echo "Adding user pi to argonone group" 34 | usermod -a -G argonone pi 35 | fi 36 | 37 | # If there are user accounts other than "pi", offer to add them to "argonone" group 38 | if user_account_names | grep -v pi >/dev/null; then 39 | db_input high argon1/add-all-user-accounts || true 40 | db_go || true 41 | db_get argon1/add-all-user-accounts || true 42 | if [ "$RET" = "true" ]; then 43 | user_account_names | grep -v pi | while read -r username; do 44 | echo "Adding user ${username} to argonone group" 45 | usermod -a -G argonone ${username} 46 | done 47 | fi 48 | fi 49 | 50 | # Display information message about adding users to group later 51 | db_input high argon1/add-user-information || true 52 | db_go 53 | -------------------------------------------------------------------------------- /debian/argon1.sysuser: -------------------------------------------------------------------------------- 1 | argonone defaults 2 | -------------------------------------------------------------------------------- /debian/argon1.templates: -------------------------------------------------------------------------------- 1 | Template: argon1/add-all-user-accounts 2 | Type: boolean 3 | Default: true 4 | Description: Add all user accounts to the argonone permissions group? 5 | Only users in the "argonone" group can invoke argonctl. 6 | The "pi" user is automatically added (if it exists). 7 | . 8 | However, it seems that you have created additional user accounts. 9 | If you would like them to be able to control the fan settings, 10 | these should be added to the group. 11 | 12 | Template: argon1/add-user-information 13 | Type: note 14 | Description: Manually adding users to argonone permissions group 15 | If you wish to add a user manually at a later time, you just need to 16 | sudo usermod -a -G argonone 17 | replacing with the actual username you wish to add. 18 | -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- 1 | argon1 (0.2.1) unstable; urgency=low 2 | 3 | * Fix case button press handling 4 | 5 | -- Spiros Papadimitriou Thu, 9 Jul 2020 13:48:30 -0400 6 | 7 | argon1 (0.2) unstable; urgency=low 8 | 9 | * Make lxplugin icon change color based on fan speed 10 | * Updated README.md 11 | * Add basic configuration to lxpanel plugin 12 | * Add DEB for lxpanel plugin 13 | * Fix argonctl cmds for button-based power control 14 | * Manpage for argonctl and argon1 DEB doc update 15 | * Initial commit of LXPanel plugin 16 | * Cleaned up internals of argonctl_main 17 | * Add Get/SetFanSpeedLUT D-Bus endpoints 18 | * Fix rsyslogd config file 19 | * Replace XML-RPC server with system DBus service. 20 | 21 | -- Spiros Papadimitriou Fri, 3 Jul 2020 12:58:52 -0400 22 | 23 | argon1 (0.1) unstable; urgency=low 24 | 25 | * Adjust default LUT so fan doesn't start as often 26 | * Tweak system logging setup 27 | * Clean up config file locations list 28 | * Fixed a couple of deadlocks; clean shutdown now works 29 | * Add some quick documentation in README.md 30 | * Fixes to DEB package postinst script 31 | * Clean up debian rules file 32 | * Initial debian version which builds successfully 33 | * Add basic logging support 34 | * Switch from jsonrpclib to Python-builtin xmlrpclib 35 | * Initial commit 36 | -- Spiros Papadimitriou Thu, 25 Jun 2020 07:57:30 -0400 37 | 38 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: argon1 2 | Section: admin 3 | Priority: optional 4 | Maintainer: Spiros Papadimitriou 5 | Build-Depends: 6 | debhelper-compat (= 12), 7 | dh-exec, 8 | dh-sysuser, 9 | dh-python, 10 | python3-all, 11 | python3-setuptools, 12 | lxpanel-dev 13 | VCS-Git: https://github.com/spapadim/argon1.git 14 | VCS-Browser: https://github.com/spapadim/argon1 15 | Standards-Version: 4.5.0 16 | Rules-Requires-Root: no 17 | Homepage: https://github.com/spapadim/argon1 18 | 19 | Package: argon1 20 | Architecture: all 21 | Pre-Depends: 22 | ${misc:Pre-Depends} 23 | Depends: 24 | lsb-base, 25 | ${misc:Depends}, 26 | ${python3:Depends}, 27 | python3-rpi.gpio, 28 | python3-smbus, 29 | python3-yaml, 30 | python3-dbus 31 | Suggests: 32 | rsyslog, 33 | python3-psutil 34 | Description: Alternative implementation for Argon One case fan and power control 35 | This is an alternative implementation for fan and power control of 36 | the Argon One Raspberry Pi case. 37 | . 38 | This is a personal mini-project; it is not affiliated in any way with Argon40, 39 | nor endorsed or supported by them. 40 | 41 | Package: lxplug-argon1 42 | Architecture: armhf 43 | Pre-Depends: 44 | ${misc:Pre-Depends} 45 | Depends: 46 | lsb-base, 47 | ${misc:Depends}, 48 | ${shlibs:Depends}, 49 | lxpanel, 50 | argon1 (>= 0.2) 51 | Description: LXPanel plugin for Argon One fan control service 52 | An LXPanel plugin for the alternative implementation of fan and power control 53 | service for the Argon One Raspberry Pi case. It displays current fan speed, 54 | and allows authorized users to pause or resume temperature-based control. 55 | . 56 | This is a personal mini-project; it is not affiliated in any way with Argon40, 57 | nor endorsed or supported by them. 58 | 59 | -------------------------------------------------------------------------------- /debian/copyright: -------------------------------------------------------------------------------- 1 | Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ 2 | 3 | Files: * 4 | Copyright: 2020, Spiros Papadimitriou 5 | License: MIT 6 | 7 | License: MIT 8 | MIT License 9 | . 10 | Permission is hereby granted, free of charge, to any person obtaining a copy 11 | of this software and associated documentation files (the "Software"), to deal 12 | in the Software without restriction, including without limitation the rights 13 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | copies of the Software, and to permit persons to whom the Software is 15 | furnished to do so, subject to the following conditions: 16 | . 17 | The above copyright notice and this permission notice shall be included in all 18 | copies or substantial portions of the Software. 19 | . 20 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | SOFTWARE. 27 | -------------------------------------------------------------------------------- /debian/lxplug-argon1.install: -------------------------------------------------------------------------------- 1 | usr/lib/*/lxpanel/plugins/argonone.so 2 | usr/share/icons/hicolor/*/status/*.png 3 | -------------------------------------------------------------------------------- /debian/lxplug-argon1.postinst: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | . /usr/share/debconf/confmodule 4 | 5 | if [ -n "$DEBIAN_SCRIPT_DEBUG" ]; then set -v -x; DEBIAN_SCRIPT_TRACE=1; fi 6 | ${DEBIAN_SCRIPT_TRACE:+ echo "#42#DEBUG# RUNNING $0 $*" 1>&2 } 7 | 8 | gtk-update-icon-cache /usr/share/icons/hicolor 9 | 10 | # Display information message about restarting lxpanel 11 | db_input high lxplug-argon1/restart-lxpanel-information || true 12 | db_go 13 | 14 | exit 0 15 | -------------------------------------------------------------------------------- /debian/lxplug-argon1.templates: -------------------------------------------------------------------------------- 1 | Template: lxplug-argon1/restart-lxpanel-information 2 | Type: note 3 | Description: Restarting lxpanel 4 | If lxpanel is already running, it will not pick up new plugins. Therefore, 5 | if you are already logged in to a graphical session, you need to restart it. 6 | You can either open a terminal and issue the command 7 | lxpanelctl restart 8 | or you can simply log out and log back in. 9 | 10 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | 3 | export PYBUILD_DESTDIR=debian/tmp 4 | 5 | %: 6 | dh $@ --with python3,sysuser --buildsystem=pybuild 7 | 8 | ################################################################ 9 | # Add "secondary" makefile build (must use overrides) 10 | 11 | override_dh_clean: 12 | dh_clean -O--buildsystem=pybuild 13 | $(MAKE) clean 14 | 15 | override_dh_auto_build: 16 | dh_auto_build -O--buildsystem=pybuild 17 | $(MAKE) all DESTDIR=${CURDIR}/debian/tmp 18 | 19 | override_dh_auto_install: 20 | dh_auto_install -O--buildsystem=pybuild 21 | $(MAKE) install DESTDIR=${CURDIR}/debian/tmp 22 | 23 | ################################################################ 24 | # lxpanel plugins are not shared libraries 25 | 26 | override_dh_makeshlibs: 27 | dh_makeshlibs -X/plugins/ 28 | 29 | override_dh_shlibdeps: 30 | dh_shlibdeps -X/plugins/ 31 | 32 | ################################################################ 33 | 34 | override_dh_fixperms: 35 | dh_fixperms 36 | # TODO this is lost when files are copied over to final pkg dir 37 | chmod u+s debian/tmp/usr/bin/argonone-shutdown 38 | 39 | override_dh_installsystemd: 40 | dh_installsystemd --name=argonone 41 | 42 | override_dh_installlogrotate: 43 | dh_installlogrotate --name=argonone 44 | -------------------------------------------------------------------------------- /git-dch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Utility script to generate debian changelog from git log 3 | 4 | # (c) 2020- Spiros Papadimitriou 5 | # 6 | # This file is released under the MIT License: 7 | # https://opensource.org/licenses/MIT 8 | # This software is distributed on an "AS IS" basis, 9 | # WITHOUT WARRANTY OF ANY KIND, either express or implied. 10 | 11 | 12 | # Auxiliary functions 13 | 14 | msg() { 15 | echo "$@" 1>&2 16 | } 17 | 18 | die() { 19 | msg "$@" 20 | exit 1 21 | } 22 | 23 | usage() { 24 | echo <&2 25 | Usage: $0 [-l | -nNUM ] [-r] [-a] [HEADVERSION] 26 | -nNUM keeps only NUM latest versions 27 | -l shorthand for -n1 28 | -r replace current debian/changelog contents; if missing, prepends 29 | -a also include commits with "#nodch" in subject 30 | -h this help message 31 | EOF 32 | } 33 | 34 | check_version_tag() { 35 | [[ $1 =~ ^v[[:digit:]]+\.[[:digit:]]+(\.[[:digit:]]+)?$ ]] 36 | } 37 | 38 | 39 | # Parse arguments and validate inputs 40 | 41 | [ -d debian ] || die "ERROR: debian folder does not exist; is pwd wrong?" 42 | 43 | [ -f debian/control ] || die "ERROR: debian/control does not exist" 44 | pkgname=`cat debian/control | grep '^Source: ' | sed 's/^Source: //'` 45 | 46 | replace_changelog=0 47 | keep_last="" 48 | gitlog_dch_filter_args="--grep=#nodch\b --invert-grep" 49 | while getopts ":hln:ra" opt; do 50 | case $opt in 51 | a) 52 | gitlog_dch_filter_args="" 53 | ;; 54 | n) 55 | [ -z "$keep_last" ] || die "ERROR: Cannot specify -n/-l multiple times" 56 | keep_last=$OPTARG 57 | ;; 58 | l) 59 | [ -z "$keep_last" ] || die "ERROR: Cannot specify -n/-l multiple times" 60 | keep_last=1 61 | ;; 62 | r) 63 | replace_changelog=1 64 | ;; 65 | h) 66 | usage 67 | exit 0 68 | ;; 69 | \?) 70 | die "ERROR: Invalid option -$OPTARG" 71 | ;; 72 | :) 73 | die "ERROR: Option -$OPTARG requires an argument" 74 | ;; 75 | esac 76 | done 77 | shift $((OPTIND-1)) 78 | 79 | head_versiontag="$1" 80 | 81 | if [ -n "$keep_last" ]; then # XXX - no short-circuit eval by test? 82 | if [ "$keep_last" -eq 1 -a -z "$head_versiontag" ]; then 83 | msg "WARNING: Last version only with no future tag for HEAD; are you sure?" 84 | fi 85 | fi 86 | 87 | # TODO - should we make -r and -n/-l mutually exclusive ? 88 | 89 | 90 | # Scan git version tags 91 | 92 | tags=("") # ignored; added just so indices of parallel arrays line up 93 | revs=(`git rev-list --max-parents=0 HEAD`) # initial revision 94 | # Find previous versions 95 | for version_tag in `git tag -l v* | sort -V`; do 96 | check_version_tag "$version_tag" || die "ERROR: Invalid git version tag $version_tag" 97 | tags+=("$version_tag") 98 | revs+=(`git rev-parse "$version_tag"`) 99 | done 100 | # Add entry for next (planned) version, if specified 101 | if [ -n "$head_versiontag" ]; then 102 | check_version_tag "$head_versiontag" || die "ERROR: Invalid planned version tag format $head_versiontag" 103 | tags+=("$head_versiontag") 104 | revs+=(`git rev-parse HEAD`) 105 | else 106 | msg "WARNING: Will not add version info for HEAD" 107 | fi 108 | 109 | # length of revs; always one more than number of versions in changelog 110 | num_revs=${#revs[@]} 111 | 112 | if [ "$num_revs" -lt 2 ]; then 113 | die "ERROR: No prior versions and no planned tag for HEAD; nothing to output!" 114 | fi 115 | 116 | if [ -n "$keep_last" ]; then # XXX - test doesn't do short-circuit ? 117 | if [ "$num_revs" -le "$keep_last" ]; then 118 | die "ERROR: Fewer actual versions than the number you asked to keep" 119 | fi 120 | fi 121 | 122 | # Construct output 123 | 124 | if [ -z "$keep_last" ]; then 125 | keep_last=$((num_revs-1)) # keep all 126 | fi 127 | 128 | for (( i=$((num_revs-keep_last)); i < $num_revs; i++ )); do 129 | version="${tags[$i]#v}" 130 | full_rev_range="${revs[$i-1]}..${revs[$i]}" 131 | last_rev_range="${revs[$i]}^..${revs[$i]}" # Just last revision 132 | echo -e "$pkgname ($version) unstable; urgency=low\n" 133 | git log --pretty='format: * %s' $gitlog_dch_filter_args "$full_rev_range" 134 | # We semi-arbitrarily pick commiter of version tag revision as maintainer 135 | git log --pretty='format:%n -- %aN <%aE> %aD%n%n' "$last_rev_range" 136 | done 137 | 138 | # Append current debian/changelog, if present and not in overwrite mode 139 | if [ -f debian/changelog -a "$replace_changelog" -ne 1 ]; then 140 | cat debian/changelog 141 | fi 142 | 143 | -------------------------------------------------------------------------------- /lxpanel/Makefile: -------------------------------------------------------------------------------- 1 | # (c) 2020- Spiros Papadimitriou 2 | # 3 | # This file is released under the MIT License: 4 | # https://opensource.org/licenses/MIT 5 | # This software is distributed on an "AS IS" basis, 6 | # WITHOUT WARRANTY OF ANY KIND, either express or implied. 7 | 8 | 9 | # Set defaults, if not already defined 10 | INSTALL ?= /usr/bin/install 11 | INSTALL_DATA ?= $(INSTALL) -m 0644 12 | CLANG ?= /usr/bin/clang-9 13 | 14 | ICON_PREFIX := $(DESTDIR)/usr/share/icons/hicolor 15 | 16 | 17 | all: argonone.so 18 | 19 | .PHONY: clean distclean install uninstall 20 | 21 | argonone.so: argonone.c 22 | $(CC) $(CFLAGS) -Wall `pkg-config lxpanel --cflags` -shared -fPIC argonone.c -o argonone.so `pkg-config --libs lxpanel` 23 | 24 | clean: 25 | rm -f argonone.so 26 | 27 | distclean: clean 28 | rm -f compile_commands.json 29 | 30 | install: SHELL:=/bin/bash 31 | install: all 32 | # Install shared object 33 | $(INSTALL) -d $(DESTDIR)`pkg-config --variable=pluginsdir lxpanel`/ 34 | # Install icons 35 | $(INSTALL) -m 0644 argonone.so $(DESTDIR)`pkg-config --variable=pluginsdir lxpanel`/ 36 | for sz in 16 24; do \ 37 | $(INSTALL) -d $(ICON_PREFIX)/$${sz}x$${sz}/status; \ 38 | for icon in argonone-fan{,-paused,-medium,-high}; do \ 39 | $(INSTALL_DATA) icons/$${icon}_$${sz}.png $(ICON_PREFIX)/$${sz}x$${sz}/status/$${icon}.png; \ 40 | done; \ 41 | done 42 | 43 | uninstall: SHELL:=/bin/bash 44 | uninstall: 45 | # Remove shared object 46 | rm -v $(DESTDIR)`pkg-config --variable=pluginsdir lxpanel`/argonone.so 47 | # Remove icons 48 | for sz in 16 24; do \ 49 | for icon in argonone-fan{,-paused,-medium,-high}; do \ 50 | rm -v $(ICON_PREFIX)/$${sz}x$${sz}/status/$${icon}.png; \ 51 | done; \ 52 | done 53 | 54 | compile_commands.json: SHELL:=/bin/bash 55 | compile_commands.json: argonone.c 56 | $(CLANG) -MJ >( sed -e '1s/^/[\n/' -e '$$s/,$$/\n]/' > compile_commands.json ) \ 57 | $(CFLAGS) -Wall `pkg-config lxpanel --cflags` -shared -fPIC argonone.c -o argonone.so `pkg-config --libs lxpanel` 58 | # $(CLANG) -MJ argonone.so.json $(CFLAGS) -Wall `pkg-config lxpanel --cflags` -shared -fPIC argonone.c -o argonone.so `pkg-config --libs lxpanel` 59 | # sed -e '1s/^/[\n/' -e '$$s/,$/\n]/' *.so.json > compile_commands.json 60 | # rm *.so.json 61 | -------------------------------------------------------------------------------- /lxpanel/argonone.c: -------------------------------------------------------------------------------- 1 | /* 2 | * (c) 2020- Spiros Papadimitriou 3 | * 4 | * This file is released under the MIT License: 5 | * https://opensource.org/licenses/MIT 6 | * This software is distributed on an "AS IS" basis, 7 | * WITHOUT WARRANTY OF ANY KIND, either express or implied. 8 | */ 9 | 10 | #include 11 | #include 12 | #include 13 | #include 14 | #include 15 | 16 | #include 17 | 18 | #include 19 | 20 | #define DEBUG_ON 21 | #ifdef DEBUG_ON 22 | #define DEBUG(fmt,args...) g_message("DBG: " fmt,##args) 23 | #else 24 | #define DEBUG 25 | #endif 26 | 27 | /* argonone event names -- should match definitions in Python module */ 28 | #define NOTIFY_VALUE_TEMPERATURE "temperature" /* float */ 29 | #define NOTIFY_VALUE_FAN_SPEED "fan_speed" /* int */ 30 | #define NOTIFY_VALUE_FAN_CONTROL_ENABLED "fan_control_enabled" /* bool */ 31 | #define NOTIFY_VALUE_POWER_CONTROL_ENABLED "power_control_enabled" /* bool */ 32 | 33 | 34 | /* Plug-in global data */ 35 | typedef struct { 36 | LXPanel *panel; 37 | config_setting_t *settings; 38 | 39 | GtkWidget *plugin; 40 | GtkWidget *tray_icon; 41 | GtkWidget *tray_label; 42 | GtkWidget *popup_menu; 43 | 44 | GDBusProxy *proxy; 45 | 46 | /* "Model" part of UI */ 47 | gboolean show_label; 48 | gboolean include_temperature; 49 | gint32 fan_speed; 50 | gdouble temperature; 51 | gboolean is_fan_control_enabled; 52 | } ArgonOnePlugin; 53 | 54 | 55 | static void argonone_update_view (ArgonOnePlugin *aone, gboolean fan_changed, gboolean config_updated); 56 | 57 | 58 | /*********************************************** 59 | * Plugin D-Bus connection(s) */ 60 | 61 | static void argonone_dbus_signal(GDBusProxy *proxy, gchar *sender_name, 62 | gchar *signal_name, GVariant *parameters, gpointer user_data) { 63 | ArgonOnePlugin *aone = (ArgonOnePlugin *)user_data; 64 | gboolean fan_changed = FALSE; 65 | 66 | if (sender_name == NULL) return; /* Synthesized event from object manager */ 67 | 68 | if (!g_strcmp0(signal_name, "NotifyValue")) { 69 | gchar *event_name; 70 | GVariant *value_var; 71 | g_variant_get(parameters, "(sv)", &event_name, &value_var); 72 | if (!g_strcmp0(event_name, NOTIFY_VALUE_FAN_SPEED)) { 73 | g_variant_get(value_var, "i", &(aone->fan_speed)); 74 | fan_changed = TRUE; 75 | } else if (!g_strcmp0(event_name, NOTIFY_VALUE_FAN_CONTROL_ENABLED)) { 76 | g_variant_get(value_var, "b", &(aone->is_fan_control_enabled)); 77 | fan_changed = TRUE; 78 | } else if (!g_strcmp0(event_name, NOTIFY_VALUE_TEMPERATURE)) { 79 | g_variant_get(value_var, "d", &(aone->temperature)); 80 | } 81 | g_free(event_name); 82 | } 83 | 84 | /* Refresh UI view */ 85 | argonone_update_view(aone, fan_changed, FALSE); 86 | } 87 | 88 | GVariant *argonone_dbus_method_call_sync(GDBusProxy *proxy, const gchar *method_name, GVariant *parameters) { 89 | GError *error = NULL; 90 | 91 | 92 | if (parameters != NULL && !g_variant_is_of_type(parameters, G_VARIANT_TYPE_TUPLE)) { 93 | GVariant **t = (GVariant *[]){ parameters }; 94 | parameters = g_variant_new_tuple(t, 1); 95 | } 96 | 97 | GVariant *retval = g_dbus_proxy_call_sync(proxy, method_name, parameters, 0, -1, NULL, &error); 98 | if (error) { 99 | DEBUG("Failed to call %s method: %s", method_name, error->message); 100 | g_error_free(error); 101 | } 102 | return retval; 103 | } 104 | 105 | 106 | gboolean argonone_dbus_query_sync(GDBusProxy *proxy, const gchar *method_name, const gchar *return_format_string, gpointer value) { 107 | GVariant *retval; 108 | g_assert(value != NULL); 109 | if (!(retval = argonone_dbus_method_call_sync(proxy, method_name, NULL))) 110 | return FALSE; 111 | g_variant_get(retval, return_format_string, value); 112 | g_variant_unref(retval); 113 | return TRUE; 114 | } 115 | 116 | /*********************************************** 117 | * Plugin popup menu */ 118 | 119 | /* TODO should we use async call for menu handlers, esp since there is no return value? */ 120 | static void _argonone_set_fan(ArgonOnePlugin *aone, gboolean enabled, gint32 fan_speed) { 121 | argonone_dbus_method_call_sync(aone->proxy, "SetFanControlEnabled", g_variant_new_boolean(enabled)); 122 | if (fan_speed >= 0) /* -1 denotes "keep current" */ 123 | argonone_dbus_method_call_sync(aone->proxy, "SetFanSpeed", g_variant_new_int32(fan_speed)); 124 | /* aone "model" will be updated via received signal, which acts as ACK */ 125 | } 126 | 127 | static void argonone_resume_fan(GtkWidget *widget, ArgonOnePlugin *aone) { 128 | _argonone_set_fan(aone, TRUE, -1); 129 | } 130 | 131 | static void argonone_pause_off_fan(GtkWidget *widget, ArgonOnePlugin *aone) { 132 | _argonone_set_fan(aone, FALSE, 0); 133 | } 134 | 135 | static void argonone_pause_max_fan(GtkWidget *widget, ArgonOnePlugin *aone) { 136 | _argonone_set_fan(aone, FALSE, 100); 137 | } 138 | 139 | static void argonone_pause_current_fan(GtkWidget *widget, ArgonOnePlugin *aone) { 140 | _argonone_set_fan(aone, FALSE, -1); 141 | } 142 | 143 | static void argonone_build_popup_menu(ArgonOnePlugin* aone) { 144 | aone->popup_menu = gtk_menu_new(); 145 | 146 | GtkWidget *resume_item = gtk_menu_item_new_with_label ("Resume"); 147 | gtk_menu_shell_append(GTK_MENU_SHELL(aone->popup_menu), resume_item); 148 | g_signal_connect(resume_item, "activate", G_CALLBACK(argonone_resume_fan), aone); 149 | 150 | GtkWidget *pause_off_item = gtk_menu_item_new_with_label ("Hold stopped"); 151 | gtk_menu_shell_append(GTK_MENU_SHELL(aone->popup_menu), pause_off_item); 152 | g_signal_connect(pause_off_item, "activate", G_CALLBACK(argonone_pause_off_fan), aone); 153 | 154 | GtkWidget *pause_max_item = gtk_menu_item_new_with_label ("Hold maximum"); 155 | gtk_menu_shell_append(GTK_MENU_SHELL(aone->popup_menu), pause_max_item); 156 | g_signal_connect(pause_max_item, "activate", G_CALLBACK(argonone_pause_max_fan), aone); 157 | 158 | GtkWidget *pause_current_item = gtk_menu_item_new_with_label("Hold current"); 159 | gtk_menu_shell_append(GTK_MENU_SHELL(aone->popup_menu), pause_current_item); 160 | g_signal_connect(pause_current_item, "activate", G_CALLBACK(argonone_pause_current_fan), aone); 161 | 162 | gtk_widget_show_all(aone->popup_menu); 163 | } 164 | 165 | static void argonone_popup_menu_set_position(GtkMenu *menu, gint *px, gint *py, gboolean *push_in, gpointer user_data) { 166 | ArgonOnePlugin *aone = (ArgonOnePlugin *)user_data; 167 | 168 | /* Determine the coordinates */ 169 | lxpanel_plugin_popup_set_position_helper (aone->panel, aone->plugin, GTK_WIDGET(menu), px, py); 170 | *push_in = TRUE; 171 | } 172 | 173 | /*********************************************** 174 | * Configuration settings */ 175 | 176 | static void argonone_update_from_settings(ArgonOnePlugin *aone) { 177 | int value; 178 | if (config_setting_lookup_int (aone->settings, "ShowLabel", &value)) 179 | aone->show_label = (value == 1); 180 | if (config_setting_lookup_int (aone->settings, "IncludeTemperature", &value)) 181 | aone->include_temperature = (value == 1); 182 | } 183 | 184 | /* Handler for system config changed message from panel */ 185 | static void argonone_configuration_changed(LXPanel *panel, GtkWidget *widget) 186 | { 187 | ArgonOnePlugin *aone = lxpanel_plugin_get_data(widget); 188 | argonone_update_from_settings(aone); 189 | argonone_update_view(aone, FALSE, TRUE); 190 | } 191 | 192 | static gboolean argonone_apply_configuration(gpointer user_data) 193 | { 194 | ArgonOnePlugin *aone = lxpanel_plugin_get_data((GtkWidget *)user_data); 195 | 196 | config_group_set_int(aone->settings, "ShowLabel", (int)aone->show_label); 197 | config_group_set_int(aone->settings, "IncludeTemperature", (int)aone->include_temperature); 198 | 199 | argonone_update_view(aone, FALSE, TRUE); 200 | 201 | return TRUE; 202 | } 203 | 204 | static GtkWidget *argonone_configure_dialog(LXPanel *panel, GtkWidget *widget) 205 | { 206 | ArgonOnePlugin *aone = lxpanel_plugin_get_data(widget); 207 | 208 | /* No chance we will reject settings, so we use aone->{show_label,include_temperature} */ 209 | return lxpanel_generic_config_dlg( 210 | "ArgonOne fan", panel, 211 | argonone_apply_configuration, widget, 212 | "Show label", &aone->show_label, CONF_TYPE_BOOL, 213 | "Include temperature", &aone->include_temperature, CONF_TYPE_BOOL, 214 | NULL 215 | ); 216 | } 217 | 218 | /*********************************************** 219 | * Plugin widget */ 220 | 221 | static gboolean argonone_button_press_event(GtkWidget *widget, GdkEventButton *event, LXPanel *panel) { 222 | ArgonOnePlugin *aone = lxpanel_plugin_get_data(widget); 223 | 224 | if (event->button == 1) { 225 | /* Left-click, toggle pause-off/resume fan control */ 226 | if (aone->is_fan_control_enabled) { 227 | _argonone_set_fan(aone, FALSE, 0); 228 | } else { 229 | _argonone_set_fan(aone, TRUE, -1); 230 | } 231 | } else if (event->button == 3) { 232 | /* Right-click, show popup menu */ 233 | if (aone->popup_menu == NULL) argonone_build_popup_menu(aone); 234 | gtk_menu_popup(GTK_MENU(aone->popup_menu), NULL, NULL, argonone_popup_menu_set_position, aone, event->button, event->time); 235 | return TRUE; 236 | } 237 | 238 | return FALSE; 239 | } 240 | 241 | #define STATUS_SIZE 32 /* Should never exceed 12 chars +1 '\0' -> 13 bytes */ 242 | 243 | /* Update all widgets, based on current plugin properties */ 244 | static void argonone_update_view (ArgonOnePlugin *aone, gboolean fan_changed, gboolean config_updated) { 245 | gchar status[STATUS_SIZE]; 246 | gint speed_len; 247 | 248 | /* Update icon */ 249 | if (fan_changed) { 250 | gchar *icon_name = NULL; 251 | if (!aone->is_fan_control_enabled) { 252 | icon_name = "argonone-fan-paused"; 253 | } else if (aone->fan_speed == 0) { 254 | icon_name = "argonone-fan"; 255 | } else if (aone->fan_speed <= 50) { 256 | icon_name = "argonone-fan-medium"; 257 | } else { 258 | icon_name = "argonone-fan-high"; 259 | } 260 | lxpanel_plugin_set_taskbar_icon( 261 | aone->panel, aone->tray_icon, 262 | icon_name); 263 | } 264 | 265 | if (config_updated) 266 | gtk_widget_set_visible(aone->tray_label, aone->show_label); 267 | 268 | if (config_updated || fan_changed || aone->include_temperature) { /* Assume temperature always changes */ 269 | /* Construct status string with fan speed and (optionally) temperature */ 270 | if (aone->fan_speed < 0) { 271 | speed_len = g_snprintf(status, STATUS_SIZE, " -- "); 272 | } else { 273 | speed_len = g_snprintf(status, STATUS_SIZE, "%3d%%", aone->fan_speed); 274 | } 275 | if (aone->include_temperature) { 276 | g_snprintf(status + speed_len, STATUS_SIZE - speed_len, " / %4.1fC", aone->temperature); 277 | } 278 | 279 | /* Update label and/or tooltip */ 280 | if (aone->show_label) { 281 | // gtk_label_set_width_chars(GTK_LABEL(aone->tray_label), aone->include_temperature ? 12 : 4); 282 | gtk_label_set_text(GTK_LABEL(aone->tray_label), status); 283 | if (config_updated) { 284 | gtk_widget_set_tooltip_text(aone->plugin, "ArgonOne fan"); 285 | } 286 | } else { 287 | gtk_widget_set_tooltip_text(aone->plugin, status); 288 | } 289 | } 290 | } 291 | 292 | /* Plugin destructor */ 293 | static void argonone_destructor(gpointer user_data) 294 | { 295 | ArgonOnePlugin *aone = (ArgonOnePlugin *)user_data; 296 | 297 | if (aone->popup_menu != NULL) gtk_widget_destroy(aone->popup_menu); 298 | 299 | g_object_unref(aone->proxy); 300 | 301 | /* TODO ? */ 302 | 303 | g_free(aone); 304 | } 305 | 306 | 307 | /* Plugin constructor */ 308 | static GtkWidget *argonone_constructor(LXPanel *panel, config_setting_t *settings) 309 | { 310 | /* Allocate and initialize plugin context */ 311 | ArgonOnePlugin *aone; 312 | GtkWidget *hbox; 313 | GError *error; 314 | 315 | aone = g_new0(ArgonOnePlugin, 1); 316 | 317 | aone->show_label = TRUE; 318 | aone->include_temperature = FALSE; 319 | 320 | /* Initial values for startup view, should be updated ASAP via D-Bus */ 321 | aone->is_fan_control_enabled = TRUE; 322 | aone->fan_speed = -1; 323 | 324 | aone->popup_menu = NULL; 325 | 326 | /* Allocate top level widget and set into Plugin widget pointer */ 327 | aone->panel = panel; 328 | aone->plugin = gtk_button_new(); 329 | gtk_button_set_relief(GTK_BUTTON(aone->plugin), GTK_RELIEF_NONE); 330 | g_signal_connect(aone->plugin, "button-press-event", G_CALLBACK(argonone_button_press_event), aone->panel); 331 | aone->settings = settings; 332 | lxpanel_plugin_set_data(aone->plugin, aone, argonone_destructor); 333 | gtk_widget_add_events(aone->plugin, GDK_BUTTON_PRESS_MASK); 334 | gtk_widget_set_tooltip_text(aone->plugin, "ArgonOne fan"); 335 | 336 | /* Allocate children of top-level */ 337 | hbox = gtk_hbox_new(FALSE, 2); 338 | aone->tray_icon = gtk_image_new(); 339 | gtk_box_pack_start(GTK_BOX(hbox), aone->tray_icon, TRUE, TRUE, 0); 340 | aone->tray_label = gtk_label_new(NULL); 341 | gtk_box_pack_start(GTK_BOX(hbox), aone->tray_label, TRUE, TRUE, 0); 342 | gtk_container_add (GTK_CONTAINER (aone->plugin), hbox); 343 | 344 | /* Update "model" from config settings */ 345 | argonone_update_from_settings(aone); 346 | 347 | /* Set up D-Bus connection, using object manager */ 348 | error = NULL; 349 | aone->proxy = g_dbus_proxy_new_for_bus_sync(G_BUS_TYPE_SYSTEM, 0, NULL, "net.clusterhack.ArgonOne", "/net/clusterhack/ArgonOne", "net.clusterhack.ArgonOne", NULL, &error); 350 | if (error) { 351 | g_error("Failed to get dbus proxy: %s", error->message); 352 | g_error_free(error); 353 | /* gtk_exit(-1); */ 354 | } 355 | g_signal_connect(aone->proxy, "g-signal", G_CALLBACK(argonone_dbus_signal), 356 | aone); 357 | 358 | /* Retrieve current fan control and speed values; blocking is ok on startup */ 359 | argonone_dbus_query_sync(aone->proxy, "GetFanSpeed", "(i)", &(aone->fan_speed)); 360 | argonone_dbus_query_sync(aone->proxy, "GetFanControlEnabled", "(b)", &(aone->is_fan_control_enabled)); 361 | argonone_dbus_query_sync(aone->proxy, "GetTemperature", "(d)", &(aone->temperature)); 362 | 363 | /* Update UI view */ 364 | 365 | /* Show widget and return */ 366 | gtk_widget_show_all(aone->plugin); 367 | argonone_update_view(aone, TRUE, TRUE); /* After _show_all(), since it updates label visibility */ 368 | return aone->plugin; 369 | } 370 | 371 | 372 | FM_DEFINE_MODULE(lxpanel_gtk, argonone) 373 | 374 | /* Plugin descriptor */ 375 | LXPanelPluginInit fm_module_init_lxpanel_gtk = { 376 | .name = "ArgonOne", 377 | .description = "ArgonOne case fan monitoring and control", 378 | .new_instance = argonone_constructor, 379 | .reconfigure = argonone_configuration_changed, 380 | .button_press_event = argonone_button_press_event, 381 | .config = argonone_configure_dialog 382 | }; 383 | -------------------------------------------------------------------------------- /lxpanel/icons/argonone-fan-high_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spapadim/argon1/a6167365e6eb2b0aeb5c9dec571cc579f6d3b61b/lxpanel/icons/argonone-fan-high_16.png -------------------------------------------------------------------------------- /lxpanel/icons/argonone-fan-high_24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spapadim/argon1/a6167365e6eb2b0aeb5c9dec571cc579f6d3b61b/lxpanel/icons/argonone-fan-high_24.png -------------------------------------------------------------------------------- /lxpanel/icons/argonone-fan-medium_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spapadim/argon1/a6167365e6eb2b0aeb5c9dec571cc579f6d3b61b/lxpanel/icons/argonone-fan-medium_16.png -------------------------------------------------------------------------------- /lxpanel/icons/argonone-fan-medium_24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spapadim/argon1/a6167365e6eb2b0aeb5c9dec571cc579f6d3b61b/lxpanel/icons/argonone-fan-medium_24.png -------------------------------------------------------------------------------- /lxpanel/icons/argonone-fan-paused_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spapadim/argon1/a6167365e6eb2b0aeb5c9dec571cc579f6d3b61b/lxpanel/icons/argonone-fan-paused_16.png -------------------------------------------------------------------------------- /lxpanel/icons/argonone-fan-paused_24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spapadim/argon1/a6167365e6eb2b0aeb5c9dec571cc579f6d3b61b/lxpanel/icons/argonone-fan-paused_24.png -------------------------------------------------------------------------------- /lxpanel/icons/argonone-fan_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spapadim/argon1/a6167365e6eb2b0aeb5c9dec571cc579f6d3b61b/lxpanel/icons/argonone-fan_16.png -------------------------------------------------------------------------------- /lxpanel/icons/argonone-fan_24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spapadim/argon1/a6167365e6eb2b0aeb5c9dec571cc579f6d3b61b/lxpanel/icons/argonone-fan_24.png -------------------------------------------------------------------------------- /man/argonctl.1: -------------------------------------------------------------------------------- 1 | .de URL 2 | \\$2 \(laURL: \\$1 \(ra\\$3 3 | .. 4 | .if \n[.g] .mso www.tmac 5 | .TH ARGONCTL 1 "June 2020" https://git.io/argon1 6 | .SH NAME 7 | argonctl \- modify files by randomly changing bits 8 | .SH SYNOPSIS 9 | .B argonctl 10 | \fIcommand\fR 11 | [\fIarg\fR ...] 12 | .SH DESCRIPTION 13 | .B argonctl 14 | is a command-line utility to query and control the \fBargononed\fR daemon. 15 | It simply translates it's arguments into D-Bus method calls. Therefore, 16 | D-Bus access rights apply: any user can issue query commands, but only 17 | users that belong to the argonone group can issue commands that modify 18 | the daemon's state or configuration (e.g., set speed, enable/disable control 19 | features, etc). 20 | .SH COMMANDS 21 | .TP 22 | .BR temp ", " temperature 23 | Print the current CPU temperature value. Note that this is updated based on regular polling, 24 | so it may lag actual temperature by the polling interval (dy default, 10 seconds). 25 | .TP 26 | .BR speed ", " fan_speed 27 | Print the current fan speed. The output is an integer between 0 (off) and 100 (maximum). 28 | .TP 29 | .BR set_speed " " \fIvalue\fR 30 | Set the fan speed. The argument is required and must be an integer between 0 and 100 (inclusive). 31 | This command does not change temperature-based control. Therefore, if control is enabled, the 32 | fan speed will be updated according to the lookup table when temperature is polled. 33 | .TP 34 | .BR pause ", " pause_fan 35 | Disables temperature-based fan control. Fan speed will remain at what it was when the 36 | command was issued. Therefore, if you wish to turn the fan off, you also need to issue a 37 | \fBset_speed\fR command. Although fan speed will no longer be set based on CPU temperature 38 | (and the daemon's look up table), the CPU temperature will still be polled and updated. 39 | If fan control was already disabled, this command has no effect. 40 | .TP 41 | .BR resume ", " resume_fan 42 | Enables temperature-based fan control. If fan control was enabled, this command has no effect 43 | .TP 44 | .BR fan_status 45 | Shows whether temperature-based fan control is enabled. Outputs 1 if enabled and 0 if disabled. 46 | .TP 47 | .BR lut ", " fan_lut 48 | Prints out the lookup table (LUT) for temperature-based fan speed control. 49 | .TP 50 | .BR pause_button " / " resume_button " / " button_status 51 | Similar to the pause/resume/status commands for temperature-based fan control, but for 52 | button-based shutdown/reboot control. When button-based power state control is disabled, 53 | button presses \fIare\fR monitored (and D-Bus signals are emitted), but the daemon itself 54 | does not issue system shutdown or reboot commands itself. This is similar to 55 | disabling temperature-based fan speed control: temperature is monitored (and D-Bus signals 56 | emmitted), but the daemon itself does not issue fan speed commands. 57 | .IP 58 | .BI NOTE: 59 | Even if power button control is disabled/paused, upon shutdown the daemon will 60 | issue a system command \fIanyway\fR. This is a workaround for ArgonOne's hardware "protocol" 61 | (see README.md for more information). 62 | .TP 63 | .BR shutdown 64 | This is a special command, reserved \fIonly\fR for root and argonone system user. It should 65 | \fInot\fR be used directly. Users should shut down the daemon using \fBsystemctl\fR instead. 66 | .SH COPYRIGHT 67 | Copyright \(co 2020 Spiros Papadimitriou 68 | .PP 69 | This software is released under the 70 | .URL "https://opensource.org/licenses/MIT" "MIT License" 71 | and it is distributed on an "AS IS" basis, 72 | WITHOUT WARRANTY OF ANY KIND, either express or implied. 73 | .SH SEE ALSO 74 | .URL "https://github.com/spapadim/argon1" "Argon1 homepage" 75 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | python_version = 3.7 3 | platform = linux 4 | warn_return_any = True 5 | warn_unused_configs = True 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # (c) 2020- Spiros Papadimitriou 2 | # 3 | # This file is released under the MIT License: 4 | # https://opensource.org/licenses/MIT 5 | # This software is distributed on an "AS IS" basis, 6 | # WITHOUT WARRANTY OF ANY KIND, either express or implied. 7 | 8 | from setuptools import setup 9 | 10 | setup( 11 | name="argon1", 12 | version="0.1", 13 | packages=['argonone'], 14 | 15 | entry_points={ 16 | "console_scripts": [ 17 | "argonctl = argonone.cmdline:argonctl_main", 18 | "argononed = argonone.cmdline:argondaemon_main", 19 | "argonone-shutdown = argonone.cmdline:argonshutdown_main", 20 | ], 21 | }, 22 | 23 | install_requires=[ 24 | 'PyYAML', 25 | 'RPi.GPIO', 26 | # TODO - python3-smbus deb source is i2c-tools, and package does not show up in pip... 27 | # TODO - python3-dbus ?? 28 | ], 29 | 30 | # Informational metadata 31 | author="Spiros Papadimitriou", 32 | author_email="spapadim@gmail.com", 33 | description="Alternative implementation of ArgonOne case fan and power control.", 34 | keywords="argonone raspberrypi", 35 | url="https://github.com/spapadim/argon1/", # project home page, if any 36 | project_urls={ 37 | # "Documentation": "https://docs.example.com/HelloWorld/", 38 | "Source Code": "https://github.com/spapadim/argon1/", 39 | }, 40 | classifiers=[ 41 | "Development Status :: 3 - Alpha", 42 | "Programming Language :: Python :: 3 :: Only", 43 | "License :: OSI Approved :: MIT License", 44 | "Topic :: System :: Hardware", 45 | ] 46 | ) 47 | --------------------------------------------------------------------------------