├── .editorconfig ├── .gitignore ├── .pylintrc ├── LICENSE ├── README.md ├── docs └── example-bar.png ├── examples ├── automatic_suspend_on_idle.py ├── battery_warning.py ├── configure_monitor_xrandr.py ├── polybar_icons.py ├── power_menu.py ├── set_energy_profile.py ├── setup_input_devices.py └── show_keyboard_layout.py ├── poetry.lock ├── pyproject.toml ├── tests ├── __init__.py └── test_wmcompanion.py └── wmcompanion ├── __init__.py ├── app.py ├── cli.py ├── decorators.py ├── errors.py ├── event_listening.py ├── events ├── __init__.py ├── audio.py ├── bluetooth.py ├── keyboard.py ├── libexec │ ├── README.md │ ├── wireplumber-volume-watcher.lua │ └── x11_device_watcher.py ├── network.py ├── notifications.py ├── power.py └── x11.py ├── modules ├── __init__.py ├── notifications.py └── polybar.py ├── object_container.py └── utils ├── __init__.py ├── dbus_client.py ├── inotify_simple.py └── process.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | # Unix-style newlines with a newline ending every file 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 2 11 | max_line_length = 100 12 | trim_trailing_whitespace = true 13 | 14 | [*.py] 15 | indent_size = 4 16 | 17 | [*.md] 18 | trim_trailing_whitespace = false 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | # If you find yourself ignoring temporary files generated by your text editor 3 | # or operating system, you probably want to add a global ignore instead: 4 | # git config --global core.excludesfile ~/.gitignore_global 5 | 6 | # OS Specifics 7 | *~ 8 | *.bak 9 | Thumbs.db 10 | desktop.ini 11 | .DS_Store 12 | 13 | # Python-specifics 14 | __pycache__ 15 | .mypy_cache 16 | /dist 17 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | disable=missing-module-docstring 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wmcompanion 2 | 3 | ### Build your own desktop environment using Python 4 | 5 | You use a minimalist tiling window manager, yet you want to be able to tinker with your desktop more 6 | easily and implement features like the ones available in full blown desktop environments? 7 | 8 | More specifically, you want to react to system events (such as returning from sleep, or wifi signal 9 | change) and easily automate your workflow or power your desktop user experience using a consistent 10 | and centralized configuration so it is _actually_ easy to maintain? 11 | 12 | ## Show me 13 | 14 | See below small examples of the broad idea that is `wmcompanion` and what you can achieve with small 15 | amounts of code. 16 | 17 | - Send a desktop notification and updates a given module on Polybar whenever a certain connection 18 | managed by NetworkManager changes statuses: 19 | ```python 20 | from wmcompanion import use, on 21 | from wmcompanion.modules.polybar import Polybar 22 | from wmcompanion.modules.notifications import Notify 23 | from wmcompanion.events.network import NetworkConnectionStatus 24 | 25 | @on(NetworkConnectionStatus, connection_name="Wired-Network") 26 | @use(Polybar) 27 | @use(Notify) 28 | async def network_status(status: dict, polybar: Polybar, notify: Notify): 29 | color = "blue" if status["connected"] else "gray" 30 | await polybar("eth", polybar.fmt("eth", color=color)) 31 | 32 | msg = "connected" if status["connected"] else "disconnected" 33 | await notify(f"Hey, wired network is {msg}") 34 | ``` 35 | 36 | - Add a microphone volume level to Polybar: 37 | ```python 38 | from wmcompanion import use, on 39 | from wmcompanion.modules.polybar import Polybar 40 | from wmcompanion.events.audio import MainVolumeLevel 41 | 42 | @on(MainVolumeLevel) 43 | @use(Polybar) 44 | async def volume_level(volume: dict, polybar: Polybar): 45 | if not volume["input"]["available"]: 46 | return await polybar("mic", "") 47 | 48 | if not volume["muted"]: 49 | level = int(volume['level'] * 100) 50 | text = f"[mic: {level}]" 51 | color = "blue" 52 | else: 53 | text = "[mic: muted]" 54 | color = "gray" 55 | 56 | await polybar("mic", polybar.fmt(text, color=color)) 57 | ``` 58 | 59 | - Set your monitor screen arrangement on plug/unplug events: 60 | ```python 61 | from wmcompanion import use, on 62 | from wmcompanion.modules.notifications import Notify 63 | from wmcompanion.events.x11 import DeviceState 64 | 65 | @on(DeviceState) 66 | @use(Notify) 67 | async def configure_screens(status: dict, notify: Notify): 68 | if status["event"] == DeviceState.ChangeEvent.SCREEN_CHANGE: 69 | await cmd("autorandr") 70 | await notify("Screen layout adjusted!") 71 | ``` 72 | 73 | - A [more complex example][polybar-example] of Polybar widgets powered by wmcompanion, in less than 74 | 80 lines of code: 75 | 76 | ![image](docs/example-bar.png) 77 | 78 | ## Who is this for? 79 | 80 | It is initially built for people using tiling window managers that don't have the many of the 81 | features that a full `DE` provides, but still want some convenience and automation here and there 82 | without having to rely on lots of unorganized shell scripts running without supervision. Things like 83 | bluetooth status notifications, keyboard layout visualizer, volume, network manager status and so 84 | on. 85 | 86 | If you already have a desktop environment such as GNOME or KDE, this tool is probably not for you, 87 | as most of its features are already built-in on those. However, there's absolutely nothing stopping 88 | you from using it, as it is so flexible you may find it useful for other purposes (such as 89 | notifications, for instance). 90 | 91 | ### Design rationale 92 | 93 | You might want to ask: isn't most of that feature set already available on a status bar such as 94 | Polybar, for instance? And some of them aren't just a matter of writing a simple shell script? 95 | 96 | Generally, yes, but then you will be limited by the features of that status bar and how they are 97 | implemented internally, and have a small room for customization. Ever wanted to have microphone 98 | volume on Polybar? Or a `kbdd` widget? Or a built-in `dunst` pause toggle? You may be well served 99 | with the default option your status bar provides, but you also might want more out of it and they 100 | can not be as easily customizable or integrate well with, let's say, notifications, for instance. 101 | 102 | Moreover, `wmcompanion` isn't designed to power status bars or simply serve as a notification 103 | daemon. Instead it is modeled around listening to events and reacting to them. One of these 104 | reactions might be to update a status bar, of course. But it can also be to send a notification, or 105 | perhaps change a layout, update your monitor setup, etc. The important part is that it is meant to 106 | be integrated and easily scriptable in a single service, and you won't have to maintain and manually 107 | orchestrate several scripts to make your desktop experience more pleasant. 108 | 109 | ## Usage 110 | 111 | ### 1. Install 112 | 113 | Currently it's available as an OS package for [Arch Linux on AUR][aur]. On other platforms, you can 114 | pull this repository, install `poetry` and run `poetry run wmcompanion`. 115 | 116 | ### 2. Configure 117 | 118 | First, you need to add a config file on `~/.config/wmcompanion/config.py`. For starters, you can use 119 | the one below: 120 | 121 | ```python 122 | from wmcompanion import use, on 123 | from wmcompanion.modules.notifications import Notify 124 | from wmcompanion.events.audio import MainVolumeLevel 125 | 126 | @on(MainVolumeLevel) 127 | @use(Notify) 128 | async def volume_level(volume: dict, notify: Notify): 129 | await notify(f"Your volume levels: {volume=}") 130 | ``` 131 | 132 | Take a look at [examples][examples] if you want to get inspired, and you can get really creative by 133 | reading the source files under `events` folder. 134 | 135 | ### 3. Run 136 | 137 | You can simply run `wmcompanion` as it's an executable installed on your system, or use `poetry run 138 | wmcompanion` in case you downloaded the codebase using git. 139 | 140 | Most people already have many user daemons running as part of their `.xinit` file, and that's a 141 | fine place for you to run it automatically on user login. 142 | 143 | A recommendation is to keep it under a `systemd` user unit, so it's separate from your window 144 | manager and you can manage logs and failures a bit better. 145 | 146 | ## Available event listeners 147 | 148 | By default, `wmcompanion` is _accompanied_ by many _`EventListeners`_ already. An `EventListener` is 149 | the heart of the application. Yet, they are simple Python classes that can listen to system events 150 | asynchronously and notify the user configured callbacks whenever there's a change in the state. 151 | 152 | Currently there are the following event listeners available: 153 | 154 | * Main audio input/output volume level with WirePlumber (`events.audio.MainVolumeLevel`) 155 | * Bluetooth status (`events.bluetooth.BluetoothRadioStatus`) 156 | * [Kbdd][kbdd] currently selected layout (`events.keyboard.KbddChangeLayout`) 157 | * NetworkManager connection status (`events.network.NetworkConnectionStatus`) 158 | * NetworkManager Wi-Fi status/strength (`events.network.WifiStatus`) 159 | * Dunst notification pause status (`events.notifications.DunstPausedStatus`) 160 | * Power actions (`events.power.PowerActions`) 161 | * Logind Idle status (`events.power.LogindIdleStatus`) 162 | * X11 monitor and input device changes (`events.x11.DeviceState`) **[requires python-xcffib]** 163 | 164 | The architecture allows for developing event listeners very easily and make them reusable by others, 165 | even if they are not integrated in this codebase -- they just need to be classes extending 166 | `wmcompanion.event_listening.EventListener` and you can even include them in your dotfiles. 167 | 168 | ## Built-in modules 169 | 170 | Modules are built-in integrations with the most common desktop tooling so that you don't need to 171 | reimplement them for your configurations. All you need is to inject them at runtime and they will be 172 | available to you automatically, keeping your user configuration clean. 173 | 174 | For instance, instead of playing with `notify-send` manually, there's a builtin module that you can 175 | invoke from within Python script and it will work as you would expect. 176 | 177 | * Polybar IPC _(replaces `polybar-msg action`)_ (`modules.polybar.Polybar`) 178 | * Notifications _(replaces `notify-send`)_ (`modules.notifications.Notify`) 179 | 180 | ### Polybar IPC integration 181 | 182 | In order to use Polybar integration, you need to create a module on Polybar using `custom/ipc` as 183 | the type and then add an initial hook to it so it reads from wmcompanion's module upon 184 | initialization. Here's an example below: 185 | 186 | ```ini 187 | [module/kbdd] 188 | type = custom/ipc 189 | hook-0 = cat $XDG_RUNTIME_DIR/polybar/kbdd 2> /dev/null 190 | initial = 1 191 | ``` 192 | 193 | Mind you that, for that example, `kbdd` must be the first string argument that you pass when 194 | calling `polybar()` on a wmcompanion callback: 195 | 196 | ```python 197 | @use(Polybar) 198 | async def my_callback(status: dict, polybar: Polybar): 199 | await polybar("kbdd", "any string that will show up on polybar") 200 | ``` 201 | 202 | ### Desktop notifications 203 | 204 | We have a full implementation of the [desktop notifications spec][desktop-notifications], and it's 205 | super easy to use: 206 | 207 | ```python 208 | @use(Notify) 209 | async def my_callback(status: dict, notify: Notify): 210 | await notify("Summary", "Body") 211 | ``` 212 | 213 | It also provides native support for Dunst-specific behaviors, such as progress bar and colors: 214 | 215 | ```python 216 | await notify("Volume level", dunst_progress_bar: 20) 217 | ``` 218 | 219 | 220 | As always, refer to the [source code][notifications.py] if you want more details. 221 | 222 | ## Development 223 | 224 | In order to run the daemon in development mode, just run: 225 | 226 | ```sh 227 | $ poetry run wmcompanion 228 | ``` 229 | 230 | ## Acknowledgements 231 | 232 | * Main design is inspired by Vincent Bernat's [great i3-companion][i3-companion] script. 233 | * The `DBusClient` util was partially extracted from [qtile utils][qtile-utils]. 234 | * The `INotify` util was partially extracted from Chris Billington's 235 | [inotify_simple][inotify-simple]. 236 | 237 | [i3-companion]: https://github.com/vincentbernat/i3wm-configuration/blob/master/bin/i3-companion 238 | [qtile-utils]: https://github.com/qtile/qtile/blob/master/libqtile/utils.py 239 | [inotify-simple]: https://github.com/chrisjbillington/inotify_simple/blob/master/inotify_simple.py 240 | [kbdd]: https://github.com/qnikst/kbdd 241 | [aur]: https://aur.archlinux.org/packages/wmcompanion 242 | [desktop-notifications]: https://specifications.freedesktop.org/notification-spec/notification-spec-latest.html 243 | [notifications.py]: ./wmcompanion/modules/notifications.py 244 | [examples]: ./examples 245 | [polybar-example]: ./examples/polybar_icons.py 246 | 247 | ## License 248 | 249 | Apache V2. 250 | -------------------------------------------------------------------------------- /docs/example-bar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kriansa/wmcompanion/051f6b90dc367a94cb860c85590030d535ede1db/docs/example-bar.png -------------------------------------------------------------------------------- /examples/automatic_suspend_on_idle.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 Daniel Pereira 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | # pylint: disable=missing-module-docstring 6 | from asyncio import get_running_loop, sleep 7 | from wmcompanion import on 8 | from wmcompanion.utils.process import cmd 9 | from wmcompanion.events.power import LogindIdleStatus 10 | 11 | 12 | IDLE_TIMER: "async.Task" = None 13 | 14 | @on(LogindIdleStatus) 15 | async def suspend_when_idle(status: dict): 16 | """ 17 | Automatically suspends the system when we idle for over 20 minutes. 18 | 19 | This is only possible when using `xss-lock`, a tool that intercepts X11 ScreenSaver and helps 20 | with locking the session, as well as telling `logind` that the system is idle. With that 21 | information you can either configure systemd-logind `logind.conf` so that it automatically 22 | perform an action after a certain time, or you can use this hook to do it programatically and 23 | leveraging other system variables such as power source, for instance. 24 | 25 | Dependencies: xss-lock 26 | """ 27 | global IDLE_TIMER # pylint: disable=global-statement 28 | if IDLE_TIMER: 29 | IDLE_TIMER.cancel() 30 | IDLE_TIMER = None 31 | 32 | if status["idle"]: 33 | async def sleep_then_suspend(): 34 | await sleep(20 * 60) 35 | await cmd("systemctl", "suspend") 36 | IDLE_TIMER = get_running_loop().create_task(sleep_then_suspend) 37 | -------------------------------------------------------------------------------- /examples/battery_warning.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 Daniel Pereira 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | # pylint: disable=missing-module-docstring 6 | from asyncio import sleep 7 | from wmcompanion import use, on 8 | from wmcompanion.utils.process import cmd 9 | from wmcompanion.modules.notifications import Notify, Urgency 10 | from wmcompanion.events.power import PowerActions 11 | 12 | 13 | @on(PowerActions) 14 | @use(Notify) 15 | async def battery_level_warning(status: dict, notify: Notify): 16 | """ 17 | Notify when the battery is below 10% and automatically hibernates whenever it reaches 5% 18 | 19 | Depedencies: systemd 20 | """ 21 | ignore_battery_statuses = [ 22 | PowerActions.BatteryStatus.CHARGING, 23 | PowerActions.BatteryStatus.FULL, 24 | ] 25 | 26 | if ( 27 | status["event"] != PowerActions.Events.BATTERY_LEVEL_CHANGE 28 | or status["battery-status"] in ignore_battery_statuses 29 | ): 30 | return 31 | 32 | level = status["battery-level"] 33 | if level > 10: 34 | return 35 | 36 | if level > 5: 37 | await notify( 38 | f"Battery is low ({level}%)", 39 | "System will hibernate automatically at 5%", 40 | urgency=Urgency.NORMAL, 41 | dunst_stack_tag="low-battery-warn", 42 | icon="battery-level-10-symbolic", 43 | ) 44 | else: 45 | await notify( 46 | "Hibernating in 5 seconds...", 47 | urgency=Urgency.CRITICAL, 48 | dunst_stack_tag="low-battery-warn", 49 | icon="battery-level-0-symbolic", 50 | ) 51 | await sleep(5) 52 | await cmd("systemctl", "hibernate") 53 | -------------------------------------------------------------------------------- /examples/configure_monitor_xrandr.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 Daniel Pereira 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | # pylint: disable=missing-module-docstring 6 | from pathlib import Path 7 | from wmcompanion import use, on 8 | from wmcompanion.utils.process import cmd 9 | from wmcompanion.modules.notifications import Notify, Urgency 10 | from wmcompanion.events.x11 import DeviceState 11 | 12 | 13 | @on(DeviceState) 14 | @use(Notify) 15 | async def configure_screens(status: dict, notify: Notify): 16 | """ 17 | Configure our screen layouts using Xrandr 18 | 19 | Dependencies: xrandr and feh 20 | """ 21 | 22 | if status["event"] != DeviceState.ChangeEvent.SCREEN_CHANGE: 23 | return 24 | 25 | match status["screens"]: 26 | # Single monitor 27 | # If it's a single monitor (most cases) then layouts don't matter, it will always be 28 | # assigned as the primary 29 | case [{"output": output, "edid_hash": _}]: 30 | await cmd("xrandr", "--output", output, "--preferred", "--primary") 31 | 32 | # Configured layouts 33 | # Notice that we also match the monitor EDID so that we have unique configurations per 34 | # monitor, in case we have a laptop and we connect to different monitors like office or home 35 | case [ 36 | {"output": "eDP-1", "edid_hash": "7B59785F"}, 37 | {"output": "HDMI-1", "edid_hash": "E65018AA"}, 38 | ]: 39 | await cmd( 40 | "xrandr", 41 | "--output", "HDMI-1", "--preferred", "--primary", "--pos", "0x0", 42 | "--output", "eDP-1", "--preferred", "--pos", "3440x740", 43 | ) 44 | 45 | # No monitors - we turn everything off 46 | case []: 47 | return await cmd("xrandr", "--output", "eDP-1", "--off", "--output", "HDMI-1", "--off") 48 | 49 | # Layout not configured 50 | # Then we just notify the user to do a manual configuration 51 | case _: 52 | await notify( 53 | "Monitor combination not configured", 54 | "Run 'Arandr' to configure it manually.", 55 | urgency=Urgency.CRITICAL, 56 | ) 57 | 58 | # Start/reload polybar to switch the monitor/size if needed 59 | # await cmd("systemctl", "reload-or-restart", "--user", "polybar") 60 | 61 | # Apply background 62 | await cmd( 63 | "feh", 64 | "--bg-fill", 65 | "--no-fehbg", 66 | Path.home().joinpath("Wallpapers/mountains.png"), 67 | ) 68 | -------------------------------------------------------------------------------- /examples/polybar_icons.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 Daniel Pereira 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | # pylint: disable=missing-module-docstring 6 | from types import SimpleNamespace 7 | from wmcompanion import use, on 8 | from wmcompanion.modules.polybar import Polybar 9 | from wmcompanion.events.bluetooth import BluetoothRadioStatus 10 | from wmcompanion.events.notifications import DunstPausedStatus 11 | from wmcompanion.events.keyboard import KbddChangeLayout 12 | from wmcompanion.events.audio import MainVolumeLevel 13 | from wmcompanion.events.network import WifiStatus, NetworkConnectionStatus 14 | 15 | # Setup few colors that I like to use on my setup 16 | colors = SimpleNamespace(BAR_FG="#F2F5EA", BAR_DISABLED="#2E5460") 17 | 18 | @on(BluetoothRadioStatus) 19 | @use(Polybar) 20 | async def bluetooth_status(status: dict, polybar: Polybar): 21 | """ 22 | Show the bluetooth status icon on Polybar 23 | 24 | This requires you to setup a polybar module using `custom/ipc` as the type 25 | """ 26 | 27 | icon_color = colors.BAR_FG if status["enabled"] else colors.BAR_DISABLED 28 | await polybar("bluetooth", polybar.fmt("", color=icon_color)) 29 | 30 | @on(DunstPausedStatus) 31 | @use(Polybar) 32 | async def dunst_status(status: dict, polybar: Polybar): 33 | """ 34 | Show the dunst status icon on Polybar 35 | 36 | This requires you to setup a polybar module using `custom/ipc` as the type 37 | 38 | Dependencies: dunst 39 | """ 40 | 41 | if status["paused"]: 42 | content = polybar.fmt("", color=colors.BAR_DISABLED) 43 | else: 44 | content = polybar.fmt("", color=colors.BAR_FG) 45 | 46 | await polybar("dunst", content) 47 | 48 | @on(KbddChangeLayout) 49 | @use(Polybar) 50 | async def kbdd_layout(layout: dict, polybar: Polybar): 51 | """ 52 | Show an icon of the current selected keyboard layout on your Polybar 53 | 54 | This requires you to setup a polybar module using `custom/ipc` as the type 55 | 56 | Dependencies: kbdd 57 | """ 58 | 59 | layout_mappings = ["U.S.", "INT."] 60 | layout_id = layout["id"] 61 | 62 | if len(layout_mappings) >= layout_id + 1: 63 | output = layout_mappings[layout_id] 64 | else: 65 | output = f"Unknown: {layout}" 66 | 67 | await polybar("kbdd", polybar.fmt("", color=colors.BAR_FG), output) 68 | 69 | @on(WifiStatus) 70 | @use(Polybar) 71 | async def wifi_status(status: dict, polybar: Polybar): 72 | """ 73 | Show the wifi signal and status icon on Polybar 74 | """ 75 | 76 | if status["connected"]: 77 | color = colors.BAR_FG 78 | label = f"{status['strength']}%" 79 | elif not status["enabled"]: 80 | color = colors.BAR_DISABLED 81 | label = "" 82 | else: 83 | color = colors.BAR_FG 84 | label = "" 85 | 86 | await polybar("wlan", polybar.fmt("", color=color), label) 87 | 88 | @on(NetworkConnectionStatus, connection_name="Wired-Network") 89 | @use(Polybar) 90 | async def network_status(status: dict, polybar: Polybar): 91 | """ 92 | Show the Wired-Network connection status icon on Polybar 93 | 94 | Dependencies: NetworkManager 95 | """ 96 | color = colors.BAR_FG if status["connected"] else colors.BAR_DISABLED 97 | await polybar("eth", polybar.fmt("", color=color)) 98 | 99 | @on(MainVolumeLevel) 100 | @use(Polybar) 101 | async def volume_level(volume: dict, polybar: Polybar): 102 | """ 103 | Show both speaker and mic volume level and status icon on Polybar 104 | 105 | Dependencies: PipeWire, WirePlumber 106 | """ 107 | async def render(polybar_module, icon_on, icon_muted, volume): 108 | if not volume["available"]: 109 | return await polybar(polybar_module, "") 110 | 111 | if not volume["muted"]: 112 | icon = icon_on 113 | color = colors.BAR_FG 114 | else: 115 | icon = icon_muted 116 | color = colors.BAR_DISABLED 117 | 118 | level = int(volume['level'] * 100) 119 | await polybar(polybar_module, polybar.fmt(f"{icon} {level}%", color=color)) 120 | 121 | await render("mic", "", "", volume["input"]) 122 | await render("speaker", "", "", volume["output"]) 123 | -------------------------------------------------------------------------------- /examples/power_menu.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 Daniel Pereira 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | # pylint: disable=missing-module-docstring 6 | from wmcompanion import on 7 | from wmcompanion.utils.process import cmd 8 | from wmcompanion.events.power import PowerActions 9 | 10 | 11 | @on(PowerActions) 12 | async def power_menu(status: dict): 13 | """ 14 | Opens up a different power menu by pressing the power button 15 | """ 16 | if status["event"] == PowerActions.Events.POWER_BUTTON_PRESS: 17 | await cmd("my-rofi-power-menu") 18 | -------------------------------------------------------------------------------- /examples/set_energy_profile.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 Daniel Pereira 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | # pylint: disable=missing-module-docstring 6 | from wmcompanion import use, on 7 | from wmcompanion.utils.process import cmd 8 | from wmcompanion.events.power import PowerActions 9 | 10 | 11 | @on(PowerActions) 12 | async def set_energy_profile(status: dict): 13 | """ 14 | Automatically set the energy profile based on the power source. Very useful for laptops. 15 | 16 | Dependencies: cpupower, xset and xbacklight 17 | """ 18 | if status["event"] not in [ 19 | PowerActions.Events.INITIAL_STATE, 20 | PowerActions.Events.POWER_SOURCE_SWITCH, 21 | PowerActions.Events.RETURN_FROM_SLEEP, 22 | ]: 23 | return 24 | 25 | if status["power-source"] == PowerActions.PowerSource.AC: 26 | cpu_governor = "performance" 27 | screen_saver = "300" 28 | backlight = "70" 29 | elif status["power-source"] == PowerActions.PowerSource.BATTERY: 30 | cpu_governor = "powersave" 31 | screen_saver = "60" 32 | backlight = "30" 33 | 34 | await cmd("sudo", "cpupower", "frequency-set", "-g", cpu_governor) 35 | 36 | # xset s 37 | # The meaning of these values are that timeout is how much time after idling it will trigger the 38 | # ScreenSaver ON, while the cycle is, after screen saver being on, how often it will trigger its 39 | # cycle event, originally meant for changing background patterns to avoid burn-in, but nowadays 40 | # it's used to flag `xss-lock` that the locker can be executed -- otherwise, `xss-lock` will 41 | # only execute the `notify` application. See more on xss-lock(1). 42 | # 43 | # Recommendation: Keep the second parameter the amount of time that the dimmer (notify app for 44 | # `xss-lock`) needs to fade out completely before showing the locker - usually 5 seconds. 45 | await cmd("xset", "s", screen_saver, "5") 46 | await cmd("xbacklight", "-ctrl", "intel_backlight", "-set", backlight) 47 | -------------------------------------------------------------------------------- /examples/setup_input_devices.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 Daniel Pereira 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | # pylint: disable=missing-module-docstring 6 | from wmcompanion import use, on 7 | from wmcompanion.utils.process import cmd 8 | from wmcompanion.events.x11 import DeviceState 9 | 10 | 11 | @on(DeviceState) 12 | async def configure_inputs(status: dict): 13 | """ 14 | Configure my input devices, such as mice and keyboards 15 | 16 | Dependencies: xset and xinput 17 | """ 18 | 19 | if status["event"] != DeviceState.ChangeEvent.INPUT_CHANGE or "added" not in status["inputs"]: 20 | return 21 | 22 | for device in status["inputs"]["added"]: 23 | dev_id = str(device["id"]) 24 | 25 | # Setup all my keyboards with two layouts, with CAPS LOCK as the layout toggle shortcut 26 | # Also set a lower key repeat rate 27 | if device["type"] == DeviceState.InputType.SLAVE_KEYBOARD: 28 | await cmd( 29 | "setxkbmap", 30 | "-model", "pc104", 31 | "-layout", "us,us", 32 | "-variant", ",alt-intl", 33 | "-option", "", "-option", "grp:caps_toggle", 34 | ) 35 | await cmd("xset", "r", "rate", "300", "30") 36 | 37 | elif device["type"] == DeviceState.InputType.SLAVE_POINTER: 38 | # Configure my mouse 39 | if "Razer DeathAdder" in device["name"]: 40 | await cmd("xinput", "set-prop", dev_id, "libinput Accel Speed", "-0.800000") 41 | 42 | # And my trackpad 43 | elif "Touchpad" in device["name"]: 44 | await cmd("xinput", "set-prop", dev_id, "libinput Tapping Enabled", "1") 45 | await cmd("xinput", "set-prop", dev_id, "libinput Natural Scrolling Enabled", "1") 46 | await cmd("xinput", "set-prop", dev_id, "libinput Tapping Drag Lock Enabled", "1") 47 | -------------------------------------------------------------------------------- /examples/show_keyboard_layout.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 Daniel Pereira 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | # pylint: disable=missing-module-docstring 6 | from wmcompanion import use, on 7 | from wmcompanion.modules.polybar import Polybar 8 | from wmcompanion.events.keyboard import KbddChangeLayout 9 | 10 | @on(KbddChangeLayout) 11 | @use(Polybar) 12 | async def kbdd_layout(layout: dict, polybar: Polybar): 13 | """ 14 | Show an icon of the current selected keyboard layout on your Polybar 15 | 16 | This requires you to setup a polybar module using `custom/ipc` as the type 17 | 18 | Dependencies: kbdd 19 | """ 20 | 21 | layout_mappings = ["U.S.", "INT."] 22 | layout_id = layout["id"] 23 | 24 | if len(layout_mappings) >= layout_id + 1: 25 | output = layout_mappings[layout_id] 26 | else: 27 | output = f"Unknown: {layout}" 28 | 29 | await polybar("kbdd", polybar.fmt("", color="#F2F5EA"), output) 30 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "atomicwrites" 5 | version = "1.4.1" 6 | description = "Atomic file writes." 7 | category = "dev" 8 | optional = false 9 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 10 | files = [ 11 | {file = "atomicwrites-1.4.1.tar.gz", hash = "sha256:81b2c9071a49367a7f770170e5eec8cb66567cfbbc8c73d20ce5ca4a8d71cf11"}, 12 | ] 13 | 14 | [[package]] 15 | name = "attrs" 16 | version = "23.1.0" 17 | description = "Classes Without Boilerplate" 18 | category = "dev" 19 | optional = false 20 | python-versions = ">=3.7" 21 | files = [ 22 | {file = "attrs-23.1.0-py3-none-any.whl", hash = "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04"}, 23 | {file = "attrs-23.1.0.tar.gz", hash = "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015"}, 24 | ] 25 | 26 | [package.extras] 27 | cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] 28 | dev = ["attrs[docs,tests]", "pre-commit"] 29 | docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] 30 | tests = ["attrs[tests-no-zope]", "zope-interface"] 31 | tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] 32 | 33 | [[package]] 34 | name = "cffi" 35 | version = "1.15.1" 36 | description = "Foreign Function Interface for Python calling C code." 37 | category = "main" 38 | optional = false 39 | python-versions = "*" 40 | files = [ 41 | {file = "cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"}, 42 | {file = "cffi-1.15.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2"}, 43 | {file = "cffi-1.15.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914"}, 44 | {file = "cffi-1.15.1-cp27-cp27m-win32.whl", hash = "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3"}, 45 | {file = "cffi-1.15.1-cp27-cp27m-win_amd64.whl", hash = "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e"}, 46 | {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162"}, 47 | {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b"}, 48 | {file = "cffi-1.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21"}, 49 | {file = "cffi-1.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185"}, 50 | {file = "cffi-1.15.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd"}, 51 | {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc"}, 52 | {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f"}, 53 | {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e"}, 54 | {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4"}, 55 | {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01"}, 56 | {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e"}, 57 | {file = "cffi-1.15.1-cp310-cp310-win32.whl", hash = "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2"}, 58 | {file = "cffi-1.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d"}, 59 | {file = "cffi-1.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac"}, 60 | {file = "cffi-1.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83"}, 61 | {file = "cffi-1.15.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9"}, 62 | {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c"}, 63 | {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325"}, 64 | {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c"}, 65 | {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef"}, 66 | {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8"}, 67 | {file = "cffi-1.15.1-cp311-cp311-win32.whl", hash = "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d"}, 68 | {file = "cffi-1.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104"}, 69 | {file = "cffi-1.15.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7"}, 70 | {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6"}, 71 | {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d"}, 72 | {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a"}, 73 | {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405"}, 74 | {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e"}, 75 | {file = "cffi-1.15.1-cp36-cp36m-win32.whl", hash = "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf"}, 76 | {file = "cffi-1.15.1-cp36-cp36m-win_amd64.whl", hash = "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497"}, 77 | {file = "cffi-1.15.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375"}, 78 | {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e"}, 79 | {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82"}, 80 | {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b"}, 81 | {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c"}, 82 | {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426"}, 83 | {file = "cffi-1.15.1-cp37-cp37m-win32.whl", hash = "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9"}, 84 | {file = "cffi-1.15.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045"}, 85 | {file = "cffi-1.15.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3"}, 86 | {file = "cffi-1.15.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a"}, 87 | {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5"}, 88 | {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca"}, 89 | {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02"}, 90 | {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192"}, 91 | {file = "cffi-1.15.1-cp38-cp38-win32.whl", hash = "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314"}, 92 | {file = "cffi-1.15.1-cp38-cp38-win_amd64.whl", hash = "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5"}, 93 | {file = "cffi-1.15.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585"}, 94 | {file = "cffi-1.15.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0"}, 95 | {file = "cffi-1.15.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415"}, 96 | {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d"}, 97 | {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984"}, 98 | {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35"}, 99 | {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27"}, 100 | {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76"}, 101 | {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3"}, 102 | {file = "cffi-1.15.1-cp39-cp39-win32.whl", hash = "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee"}, 103 | {file = "cffi-1.15.1-cp39-cp39-win_amd64.whl", hash = "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c"}, 104 | {file = "cffi-1.15.1.tar.gz", hash = "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9"}, 105 | ] 106 | 107 | [package.dependencies] 108 | pycparser = "*" 109 | 110 | [[package]] 111 | name = "colorama" 112 | version = "0.4.6" 113 | description = "Cross-platform colored terminal text." 114 | category = "dev" 115 | optional = false 116 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 117 | files = [ 118 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 119 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 120 | ] 121 | 122 | [[package]] 123 | name = "dbus-next" 124 | version = "0.2.3" 125 | description = "A zero-dependency DBus library for Python with asyncio support" 126 | category = "main" 127 | optional = false 128 | python-versions = ">=3.6.0" 129 | files = [ 130 | {file = "dbus_next-0.2.3-py3-none-any.whl", hash = "sha256:58948f9aff9db08316734c0be2a120f6dc502124d9642f55e90ac82ffb16a18b"}, 131 | {file = "dbus_next-0.2.3.tar.gz", hash = "sha256:f4eae26909332ada528c0a3549dda8d4f088f9b365153952a408e28023a626a5"}, 132 | ] 133 | 134 | [[package]] 135 | name = "more-itertools" 136 | version = "9.1.0" 137 | description = "More routines for operating on iterables, beyond itertools" 138 | category = "dev" 139 | optional = false 140 | python-versions = ">=3.7" 141 | files = [ 142 | {file = "more-itertools-9.1.0.tar.gz", hash = "sha256:cabaa341ad0389ea83c17a94566a53ae4c9d07349861ecb14dc6d0345cf9ac5d"}, 143 | {file = "more_itertools-9.1.0-py3-none-any.whl", hash = "sha256:d2bc7f02446e86a68911e58ded76d6561eea00cddfb2a91e7019bbb586c799f3"}, 144 | ] 145 | 146 | [[package]] 147 | name = "packaging" 148 | version = "23.1" 149 | description = "Core utilities for Python packages" 150 | category = "dev" 151 | optional = false 152 | python-versions = ">=3.7" 153 | files = [ 154 | {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, 155 | {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, 156 | ] 157 | 158 | [[package]] 159 | name = "pluggy" 160 | version = "0.13.1" 161 | description = "plugin and hook calling mechanisms for python" 162 | category = "dev" 163 | optional = false 164 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 165 | files = [ 166 | {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, 167 | {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, 168 | ] 169 | 170 | [package.extras] 171 | dev = ["pre-commit", "tox"] 172 | 173 | [[package]] 174 | name = "py" 175 | version = "1.11.0" 176 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 177 | category = "dev" 178 | optional = false 179 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 180 | files = [ 181 | {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, 182 | {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, 183 | ] 184 | 185 | [[package]] 186 | name = "pycparser" 187 | version = "2.21" 188 | description = "C parser in Python" 189 | category = "main" 190 | optional = false 191 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 192 | files = [ 193 | {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, 194 | {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, 195 | ] 196 | 197 | [[package]] 198 | name = "pytest" 199 | version = "5.4.3" 200 | description = "pytest: simple powerful testing with Python" 201 | category = "dev" 202 | optional = false 203 | python-versions = ">=3.5" 204 | files = [ 205 | {file = "pytest-5.4.3-py3-none-any.whl", hash = "sha256:5c0db86b698e8f170ba4582a492248919255fcd4c79b1ee64ace34301fb589a1"}, 206 | {file = "pytest-5.4.3.tar.gz", hash = "sha256:7979331bfcba207414f5e1263b5a0f8f521d0f457318836a7355531ed1a4c7d8"}, 207 | ] 208 | 209 | [package.dependencies] 210 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 211 | attrs = ">=17.4.0" 212 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 213 | more-itertools = ">=4.0.0" 214 | packaging = "*" 215 | pluggy = ">=0.12,<1.0" 216 | py = ">=1.5.0" 217 | wcwidth = "*" 218 | 219 | [package.extras] 220 | checkqa-mypy = ["mypy (==v0.761)"] 221 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] 222 | 223 | [[package]] 224 | name = "six" 225 | version = "1.16.0" 226 | description = "Python 2 and 3 compatibility utilities" 227 | category = "main" 228 | optional = false 229 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 230 | files = [ 231 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 232 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 233 | ] 234 | 235 | [[package]] 236 | name = "wcwidth" 237 | version = "0.2.6" 238 | description = "Measures the displayed width of unicode strings in a terminal" 239 | category = "dev" 240 | optional = false 241 | python-versions = "*" 242 | files = [ 243 | {file = "wcwidth-0.2.6-py2.py3-none-any.whl", hash = "sha256:795b138f6875577cd91bba52baf9e445cd5118fd32723b460e30a0af30ea230e"}, 244 | {file = "wcwidth-0.2.6.tar.gz", hash = "sha256:a5220780a404dbe3353789870978e472cfe477761f06ee55077256e509b156d0"}, 245 | ] 246 | 247 | [[package]] 248 | name = "xcffib" 249 | version = "0.11.1" 250 | description = "A drop in replacement for xpyb, an XCB python binding" 251 | category = "main" 252 | optional = false 253 | python-versions = "*" 254 | files = [ 255 | {file = "xcffib-0.11.1.tar.gz", hash = "sha256:12949cfe2e68c806efd57596bb9bf3c151f399d4b53e15d1101b2e9baaa66f5a"}, 256 | ] 257 | 258 | [package.dependencies] 259 | cffi = ">=1.1.0" 260 | six = "*" 261 | 262 | [metadata] 263 | lock-version = "2.0" 264 | python-versions = "^3.10" 265 | content-hash = "7d1c593533c5227f311a7ccffe709ea97c870ed2fdad8adb8ae23857224bdc13" 266 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "wmcompanion" 3 | version = "0.6.4" 4 | license = "Apache-2.0" 5 | description = "wmcompanion is a utility for connecting system events to user actions" 6 | authors = ["Daniel Pereira "] 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.10" 10 | dbus-next = "^0.2" 11 | xcffib = "^0.11.1" 12 | 13 | [tool.poetry.dev-dependencies] 14 | pytest = "^5.2" 15 | 16 | [tool.poetry.scripts] 17 | wmcompanion = "wmcompanion.cli:main" 18 | 19 | [build-system] 20 | requires = ["poetry-core>=1.0.0"] 21 | build-backend = "poetry.core.masonry.api" 22 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kriansa/wmcompanion/051f6b90dc367a94cb860c85590030d535ede1db/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_wmcompanion.py: -------------------------------------------------------------------------------- 1 | from wmcompanion import __version__ 2 | 3 | 4 | def test_version(): 5 | assert __version__ == '0.1.0' 6 | -------------------------------------------------------------------------------- /wmcompanion/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 Daniel Pereira 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | # This is the `index` of wmcompanion module, and this file is supposed to hold the most used names 6 | # that the user config may need. 7 | # 8 | # Due to the architecture of wmcompanion, it was chosen not to have any initialized value here, and 9 | # instead this would be done dynamically, after the application start. 10 | # 11 | # In short, this means that names on this module will be dynamically set by 12 | # `app.App#setup_index_module_exports`, so if you need to find out what they are, just go and read 13 | # that method. 14 | 15 | __version__ = "0.6.4" 16 | 17 | # Below are the names that this module exports. They are here for static reference so linters can 18 | # find out what names this module exports. 19 | # They are actually defined on `app.py` - remember to keep the back ref when changing that file. 20 | # 21 | # pylint: disable=invalid-name, missing-function-docstring 22 | def use(*_, **__): 23 | pass 24 | 25 | 26 | def on(*_, **__): 27 | pass 28 | -------------------------------------------------------------------------------- /wmcompanion/app.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 Daniel Pereira 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | import os 6 | import sys 7 | import logging 8 | from .object_container import ObjectContainer 9 | from .event_listening import EventWatcher 10 | from .decorators import UseDecorator, OnDecorator 11 | 12 | 13 | class App: 14 | """ 15 | Orchestrate the application functionality into a single unit. 16 | 17 | Instantiate then hit `start()` to have it running. 18 | """ 19 | 20 | def __init__(self, config_file: str, verbose: bool = False): 21 | self.config_file = config_file or self._default_config_file_path() 22 | self.event_watcher = None 23 | self.object_container = None 24 | self.verbose = verbose 25 | 26 | def _default_config_file_path(self) -> str: 27 | config_home = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")) 28 | return f"{config_home}/wmcompanion/config.py" 29 | 30 | def setup_logging(self): 31 | """ 32 | Setup the global application logging 33 | """ 34 | log_level = "DEBUG" if self.verbose else "INFO" 35 | log_format = ( 36 | "[%(levelname)s] [%(filename)s:%(funcName)s():L%(lineno)d] %(message)s" 37 | ) 38 | logging.basicConfig(level=log_level, format=log_format) 39 | 40 | def setup_index_module_exports(self): 41 | """ 42 | Dynamically assigns export values for the `wmcompanion` module, so that the user config file 43 | can pick up only the values already instantiated by this application. 44 | """ 45 | decorators = self.create_decorators() 46 | for name, decorator in decorators.items(): 47 | setattr(sys.modules["wmcompanion"], name, decorator) 48 | 49 | def create_decorators(self): 50 | """ 51 | Instantiate all decorators that will be useful for the user config file 52 | 53 | Whenever this gets changed, please also add a static reference to them on __init__.py to 54 | help linters finding out what names this module exports. 55 | """ 56 | return { 57 | "use": UseDecorator(self.object_container), 58 | "on": OnDecorator(self.event_watcher), 59 | } 60 | 61 | def start(self): 62 | """ 63 | Instantiate all required application classes then run it 64 | 65 | It will stop gracefully when receiving a SIGINT or SIGTERM 66 | """ 67 | self.object_container = ObjectContainer() 68 | self.event_watcher = EventWatcher(self.config_file) 69 | self.setup_logging() 70 | self.setup_index_module_exports() 71 | self.event_watcher.run() 72 | -------------------------------------------------------------------------------- /wmcompanion/cli.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 Daniel Pereira 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | from argparse import ArgumentParser 6 | from . import __version__ 7 | from .app import App 8 | 9 | 10 | def main(): 11 | """ 12 | wmcompanion is an event listener focused on desktop activities and user customization. 13 | 14 | It leverages the power of Python to create useful hooks to several system events, such as 15 | NetworkManager connection, Bluetooth activation and many others. 16 | 17 | The focus is being easily customizable and highly flexible, being able to 18 | power status bars such as Polybar or i3bar, as well as to manage monitor 19 | arrangements using xrandr. 20 | """ 21 | 22 | parser = ArgumentParser(description=main.__doc__) 23 | parser.add_argument("-c", "--config-file", help="user config file path") 24 | parser.add_argument("--version", action="version", version=__version__) 25 | parser.add_argument( 26 | "--verbose", action="store_true", help="increase the log verbosity" 27 | ) 28 | 29 | args = parser.parse_args() 30 | App(config_file=args.config_file, verbose=args.verbose).start() 31 | -------------------------------------------------------------------------------- /wmcompanion/decorators.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 Daniel Pereira 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | import functools 6 | import types 7 | from collections import namedtuple 8 | from .object_container import ObjectContainer 9 | from .event_listening import EventWatcher 10 | 11 | 12 | class SoftDecorator: 13 | """ 14 | Traditionally, a decorator intent in Python is to change a function at runtime and its name will 15 | be automatically referenced to the new changed one, if changed by a decorator. Moreover, a 16 | decorator runs from inside out, meaning that the last declared decorator will run first, in a 17 | stack-based fashion, similarly as you would expect when you have a nested function call. 18 | 19 | While this behavior is very convenient and makes writing behavior-changing decorators very 20 | easily, it is not always wanted in case we want to stack up multiple decorators that may be 21 | dependent and require some coordination such as order of execution. As a parallel, in languages 22 | such as Java for instance, there's no such thing as "behavior-changing metadata", and instead we 23 | have annotations which syntactically similar to decorators, but they don't automatically wrap 24 | and modify functions as a decorator in Python would, instead that is up to the application to 25 | read that metadata and act upon it. 26 | 27 | This class is the simplest implementation I could think of to create the idea of `annotation` 28 | you have on Java, using Python decorators. The naming embodies the idea of being `soft` as a way 29 | to say that this does not automatically change the function, instead it only creates a new 30 | function with the expected behavior applied when you call `with_decorators()` on the function. 31 | """ 32 | 33 | FUNCTION_ATTR_KEY_NAME = "_annotation_decorators" 34 | 35 | DecoratorEnvelope = namedtuple("DecoratorEnvelope", ["object", "args", "kwargs"]) 36 | 37 | def __call__(self, *args, **kwargs): 38 | """ 39 | Makes this object callable. It is supposed to be called when applied to a function as a 40 | decorator. 41 | When called, it saves this decorator object and the arguments (args and kwargs) passed to 42 | the decorator as internal properties to the function metadata. 43 | """ 44 | 45 | def decorator(function: callable): 46 | self.add_self_reference(function, args, kwargs) 47 | self.after_declared(function, args, kwargs) 48 | return function 49 | 50 | return decorator 51 | 52 | def after_declared(self, function: callable, args: list, kwargs: dict): 53 | """ 54 | Hook called after a decorator has been added to the function. It is convenient so that we 55 | can i.e. add the function to a callback list, but it can't change the function behavior, for 56 | that use `apply` instead. 57 | 58 | This is useful so we don't end up overriding `__call__` 59 | """ 60 | 61 | def add_self_reference(self, function: callable, args: list, kwargs: dict): 62 | """ 63 | Links the function to this decorator and its arguments. Adds a new method (with_decorators) 64 | to the function object so that when called, it will return a new function with all 65 | decorators applied. 66 | """ 67 | if not hasattr(function, self.FUNCTION_ATTR_KEY_NAME): 68 | setattr(function, self.FUNCTION_ATTR_KEY_NAME, []) 69 | 70 | function_attr_key_name = self.FUNCTION_ATTR_KEY_NAME 71 | 72 | def with_decorators(self) -> callable: 73 | """ 74 | Returns a new function, with all soft decorators applied in the order they have been 75 | declared. 76 | """ 77 | function = self 78 | for decorator_envelope in reversed( 79 | getattr(self, function_attr_key_name) 80 | ): 81 | function = decorator_envelope.object.apply( 82 | function, decorator_envelope.args, decorator_envelope.kwargs 83 | ) 84 | return function 85 | 86 | # All function stages while applying the decorators will have a reference to the 87 | # original one, and a new method to apply all decorators 88 | function.original_function = function 89 | function.with_decorators = types.MethodType(with_decorators, function) 90 | 91 | # Add a doubly linked reference between the function and the decorator object 92 | decorator_envelope = self.DecoratorEnvelope(self, args, kwargs) 93 | getattr(function, self.FUNCTION_ATTR_KEY_NAME).append(decorator_envelope) 94 | 95 | def apply(self, function: callable, args: list, kwargs: dict): 96 | """ 97 | This is the decorator logic that should be applied to the function. It is only applied after 98 | calling `with_decorators()` method on the function object. 99 | 100 | args - Is the list of arguments passed to the decorator (@dec(arg1, arg2)) 101 | kwargs - Is a dict with the keyword arguments passed to the decorator (@dec(arg="val")) 102 | """ 103 | 104 | 105 | class UseDecorator(SoftDecorator): 106 | """ 107 | Inject dependencies on the function at runtime by means of an ObjectContainer. 108 | """ 109 | 110 | def __init__(self, object_container: ObjectContainer): 111 | self.object_container = object_container 112 | 113 | def apply(self, function: callable, args: list, kwargs: dict): 114 | curried_function = function 115 | for dep in args: 116 | obj = self.object_container.get(dep) 117 | # Set the object to the leftmost parameter of the function 118 | curried_function = functools.partial(curried_function, obj) 119 | # Wraps it and make it look like the original function 120 | curried_function = functools.update_wrapper( 121 | curried_function, function.original_function 122 | ) 123 | return curried_function 124 | 125 | 126 | class OnDecorator(SoftDecorator): 127 | """ 128 | Adds the function as a callback to a given event type. If that event is not yet started, then 129 | also starts it. 130 | The event callback param will be injected as the first parameter of the function, regardless of 131 | its value (i.e. if no callback param is passed, None will be passed as the first parameter to 132 | the function). 133 | When the EventListener have specific attributes, they can be passed as keyword arguments, after 134 | the name of the listener, for instance: 135 | ``` 136 | @on(NetworkChange, ifname="eth0") 137 | ``` 138 | 139 | And you can also pass multiple events in the same decorator, given that it's either without 140 | parameters like so: 141 | ``` 142 | @on(NetworkChange, PowerActions) 143 | ``` 144 | 145 | Or the parameters are wrapped in a list: 146 | ``` 147 | @on([NetworkChange, dict(ifname="eth0")], [NetworkChange, { "ifname": "enp1s0" }]) 148 | ``` 149 | """ 150 | 151 | event_object: any = None 152 | 153 | def __init__(self, event_watcher: EventWatcher): 154 | self.event_watcher = event_watcher 155 | 156 | def after_declared(self, function: callable, args: list, kwargs: dict): 157 | if len(args) == 1: 158 | args = [[args[0], kwargs]] 159 | 160 | for event in args: 161 | if isinstance(event, list): 162 | event_klass = event[0] 163 | attributes = event[1] if len(event) == 2 else {} 164 | else: 165 | event_klass = event 166 | attributes = {} 167 | 168 | self.event_watcher.add_callback([event_klass, attributes], function) 169 | 170 | def apply(self, function: callable, _args: list, _kwargs: dict): 171 | # Set the event object to the leftmost parameter of the function. The `event_object` is an 172 | # attribute set to the function by the EventListener when it triggers the function. After 173 | # used, clean it up so we don't end up with a property we don't want to keep around. 174 | # 175 | # Mind you that this is not thread safe. Because all the work is done asynchronously on a 176 | # single thread, it is fine. However, having multiple `on` callbacks on a function that 177 | # triggers using different threads will ocasionally make this run into a race condition. 178 | event_object = ( 179 | function.event_object if hasattr(function, "event_object") else None 180 | ) 181 | curried_function = functools.partial(function, event_object) 182 | if event_object: 183 | del function.event_object 184 | # Now wrap it and make it look like the original function 185 | return functools.update_wrapper(curried_function, function.original_function) 186 | -------------------------------------------------------------------------------- /wmcompanion/errors.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 Daniel Pereira 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | 6 | class WMCompanionError(Exception): 7 | """ 8 | Base exception used for all module-based errors. When run inside the loop it will be logged but 9 | the application will still be running. 10 | """ 11 | 12 | 13 | class WMCompanionFatalError(WMCompanionError): 14 | """ 15 | This exception is, as the name implies, fatal, therefore will stop the application when raised. 16 | """ 17 | -------------------------------------------------------------------------------- /wmcompanion/event_listening.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 Daniel Pereira 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | import os 6 | import sys 7 | import asyncio 8 | import logging 9 | import signal 10 | import traceback 11 | import gc 12 | from typing import Coroutine 13 | from concurrent.futures import ThreadPoolExecutor 14 | from importlib.util import spec_from_loader, module_from_spec 15 | from importlib.machinery import SourceFileLoader 16 | from .errors import WMCompanionFatalError 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | class EventListener: 22 | """ 23 | This is the base class for every class that is supposed to listen for a specific kind of 24 | systematic action and then reacts to it. 25 | 26 | For instance, we could have a VolumeControlListener that listen for volume changes and then 27 | reacts to it by invoking the callback with the current volume. 28 | 29 | An EventListener is used on user configuration under the `@on` decorator, and it is 30 | automatically instantiated by EventWatcher whenever there's at least one `@on` decorator using 31 | it. 32 | """ 33 | 34 | def __init__(self, event_watcher: "EventWatcher"): 35 | self.event_watcher = event_watcher 36 | self.previous_trigger_argument = None 37 | self.callbacks = [] 38 | 39 | def name(self) -> str: 40 | """ 41 | Full name of this class to help identifying it on logs 42 | """ 43 | return ".".join([self.__class__.__module__, self.__class__.__name__]) 44 | 45 | def add_callback(self, callback: callable): 46 | """ 47 | Append the function as a callback to this listener, and it will be called whenever 48 | `trigger()` is called. 49 | """ 50 | self.callbacks.append(callback) 51 | 52 | def run_coro(self, coro: Coroutine): 53 | """ 54 | Adds a coroutine to the main event loop. It has a similar behavior than what you would 55 | expect from `asyncio.run()` - but it instead uses the same event loop the application is on. 56 | """ 57 | self.event_watcher.run_coro(coro) 58 | 59 | async def run_blocking_io(self, callback: callable) -> any: 60 | """ 61 | Python asyncio does not natively support regular files, so in order to avoid blocking 62 | functions in the loop, use this to spawn a separate thread to run blocking operations. 63 | """ 64 | with ThreadPoolExecutor(max_workers=1) as executor: 65 | return await asyncio.get_running_loop().run_in_executor(executor, callback) 66 | 67 | async def trigger(self, value: dict = None, allow_duplicate_events: bool = False): 68 | """ 69 | Executes all callbacks registered for that listener. Callbacks will receive the events in 70 | the order they have been registered. 71 | 72 | Optionally, an arbitrary `value` can be passed and it will be forwarded to the callback 73 | function as the first argument. If passed, a value will be compared to its previous 74 | triggered value and will not continue if it is the same, so that we don't bother callbacks 75 | with repetitive triggers and avoid unecessary re-renders or stacked notifications. This 76 | behavior can be turned off if you pass True to the parameter `allow_duplicate_events`. 77 | """ 78 | if ( 79 | not allow_duplicate_events 80 | and value 81 | and value == self.previous_trigger_argument 82 | ): 83 | return 84 | self.previous_trigger_argument = value 85 | 86 | for callback in self.callbacks: 87 | # Adds this class name as the `event-type` attribute on the value object callback 88 | event = value.copy() 89 | event["event-class"] = self.name() 90 | 91 | # Sets the event value object as a property of the callback, so that the @on decorator 92 | # can pick it up and pass it as the first argument to the function call. 93 | # See `decorators.OnDecorator#apply`. 94 | callback.event_object = event 95 | await (callback.with_decorators())() 96 | 97 | async def start(self): 98 | """ 99 | Executes the primary action for this EventListener. It is executed right after it is first 100 | required and instantiated. Usually, it is meant to start some sort of system listener and 101 | register the `trigger()` as a callback to it. 102 | """ 103 | 104 | 105 | class EventWatcher: 106 | """ 107 | This is the main application object, it is responsible for loading the user configuration, 108 | dynamically registering EventListeners, adding callbacks to them and finally running an infinite 109 | event loop so that async functions can be executed on. 110 | """ 111 | 112 | def __init__(self, config_file: str): 113 | self.config_file = config_file 114 | self.listeners = {} 115 | self.loop = None 116 | self.tasks = set() 117 | self.stopping = False 118 | 119 | def get_listener(self, listener: list[type, dict]) -> EventListener: 120 | """ 121 | Get a registered EventListener if already instantiated, or register a new one and returns it 122 | """ 123 | klass, attributes = listener 124 | lookup = ".".join([klass.__module__, klass.__name__]) 125 | if attributes: 126 | lookup += f"[{str(attributes)}]" 127 | 128 | if lookup not in self.listeners: 129 | self.listeners[lookup] = klass(self) 130 | for attribute, value in attributes.items(): 131 | setattr(self.listeners[lookup], attribute, value) 132 | 133 | return self.listeners[lookup] 134 | 135 | def add_callback(self, event: list[type, dict], callback: Coroutine): 136 | """ 137 | Adds a function as a callback to an event listener. If that event listener is not yet 138 | registered, then it will be instantiated and registered accordingly before callback is set. 139 | """ 140 | self.get_listener(event).add_callback(callback) 141 | 142 | def stop(self): 143 | """ 144 | Gracefully stops the event loop 145 | """ 146 | self.stopping = True 147 | 148 | for task in self.tasks: 149 | task.cancel() 150 | 151 | self.loop.stop() 152 | 153 | # pylint: disable-next=unused-argument 154 | def exception_handler(self, loop: asyncio.AbstractEventLoop, context: dict): 155 | """ 156 | Default exception handler for every EventListener event loop. 157 | """ 158 | if "exception" not in context: 159 | return 160 | 161 | traceback.print_exception(context["exception"]) 162 | sys.stdout.flush() 163 | 164 | if isinstance(context["exception"], WMCompanionFatalError): 165 | if not self.stopping: 166 | self.stop() 167 | os._exit(1) # pylint: disable=protected-access 168 | 169 | def load_user_config(self): 170 | """ 171 | Loads the user config file and expects that at the final of this step, we will have several 172 | listeners activated, each with at least one callback. 173 | """ 174 | try: 175 | loader = SourceFileLoader("config", self.config_file) 176 | mod = module_from_spec(spec_from_loader(loader.name, loader)) 177 | loader.exec_module(mod) 178 | except FileNotFoundError as err: 179 | raise WMCompanionFatalError( 180 | f"Config file not found at '{self.config_file}'" 181 | ) from err 182 | 183 | def run_coro(self, coro: Coroutine) -> asyncio.Task: 184 | """ 185 | As recommended by Python docs, add the coroutine to a set before adding it to the loop. This 186 | creates a strong reference and prevents it being garbage-collected before it is done. 187 | 188 | See: https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task 189 | See: https://stackoverflow.com/a/62520369 190 | See: https://bugs.python.org/issue21163 191 | """ 192 | task = self.loop.create_task(coro) 193 | 194 | # Add it to the set, creating a strong reference 195 | self.tasks.add(task) 196 | # But then ensure we clear its reference after it's finished 197 | task.add_done_callback(self.tasks.discard) 198 | 199 | return task 200 | 201 | async def start_listener(self, listener: EventListener): 202 | """ 203 | Encapsulate the initialization of the listener so it breaks if any exception is raised. 204 | """ 205 | try: 206 | await listener.start() 207 | except Exception as exc: 208 | raise WMCompanionFatalError( 209 | f"Failure while initializing listener {listener.name()}" 210 | ) from exc 211 | 212 | def run(self): 213 | """ 214 | Loads the user config, adds all required event listeners to the event loop and start it 215 | """ 216 | self.loop = asyncio.new_event_loop() 217 | self.loop.set_exception_handler(self.exception_handler) 218 | 219 | # Load provided config with all definitions 220 | self.load_user_config() 221 | 222 | if len(self.listeners) == 0: 223 | logger.warning("No event listeners enabled. Exiting...") 224 | sys.exit() 225 | 226 | # Add signal handlers 227 | for sig in [signal.SIGINT, signal.SIGTERM]: 228 | self.loop.add_signal_handler(sig, self.stop) 229 | 230 | # Run all listeners in the event loop 231 | for name, listener in self.listeners.items(): 232 | self.run_coro(self.start_listener(listener)) 233 | logger.info("Listener %s started", name) 234 | 235 | # Run GC just to cleanup objects before starting 236 | gc.collect() 237 | 238 | # Then make sure we run until we hit `stop()` 239 | self.loop.run_forever() 240 | -------------------------------------------------------------------------------- /wmcompanion/events/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kriansa/wmcompanion/051f6b90dc367a94cb860c85590030d535ede1db/wmcompanion/events/__init__.py -------------------------------------------------------------------------------- /wmcompanion/events/audio.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 Daniel Pereira 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | import asyncio 6 | from typing import Coroutine 7 | from pathlib import Path 8 | from decimal import Decimal 9 | from ..utils.inotify_simple import INotify, Flags as INotifyFlags 10 | from ..utils.process import ProcessWatcher 11 | from ..event_listening import EventListener 12 | from ..errors import WMCompanionFatalError 13 | 14 | 15 | class MainVolumeLevel(EventListener): 16 | """ 17 | Reacts to the main volume source/sink level changes. 18 | Uses wireplumber in order to do so, and be able to react to default sink/source changes. 19 | """ 20 | 21 | wp_state_file: Path = Path("~/.local/state/wireplumber/default-nodes").expanduser() 22 | volume_output: Decimal = None 23 | volume_input: Decimal = None 24 | restart_watcher: callable = None 25 | inotify: INotify = None 26 | 27 | AUDIO_DIRECTION_INPUT = "@DEFAULT_SOURCE@" 28 | AUDIO_DIRECTION_OUTPUT = "@DEFAULT_SINK@" 29 | 30 | async def set_volume(self, direction, level, muted, available): 31 | """ 32 | Triggers a volume change event 33 | """ 34 | volume = {"level": level, "muted": muted, "available": available} 35 | if direction == self.AUDIO_DIRECTION_OUTPUT: 36 | self.volume_output = volume 37 | else: 38 | self.volume_input = volume 39 | 40 | await self.trigger({"input": self.volume_input, "output": self.volume_output}) 41 | 42 | def wp_statefile_changed(self): 43 | """ 44 | This is a callback that gets called every time a change on WirePlumber's state file is 45 | detected, meaning we need to restart the Lua volume watcher daemon. 46 | """ 47 | for event in self.inotify.read(): 48 | if "default-nodes" in event.name: 49 | # Restarting the watcher will re-read all volumes 50 | self.run_coro(self.restart_watcher()) 51 | return 52 | 53 | async def run_volume_watcher(self): 54 | """ 55 | Run a separate daemon that listen for wireplumber volume change events so we can pick them 56 | up and trigger wmcompanion events. 57 | """ 58 | cmd = [ 59 | "wpexec", 60 | Path(__file__).parent.joinpath("libexec/wireplumber-volume-watcher.lua"), 61 | ] 62 | watcher = ProcessWatcher(cmd, restart_every=3600) 63 | self.restart_watcher = watcher.restart 64 | 65 | async def read_events(proc: Coroutine): 66 | while line := await proc.stdout.readline(): 67 | direction, level, muted, available = ( 68 | line.decode("ascii").strip().split(":") 69 | ) 70 | direction = ( 71 | self.AUDIO_DIRECTION_OUTPUT 72 | if "output" == direction 73 | else self.AUDIO_DIRECTION_INPUT 74 | ) 75 | level = Decimal(level) 76 | muted = muted == "true" 77 | available = available == "true" 78 | 79 | await self.set_volume(direction, level, muted, available) 80 | 81 | async def on_fail(): 82 | raise WMCompanionFatalError( 83 | "wireplumber-volume-watcher.lua initialization failed" 84 | ) 85 | 86 | watcher.on_start(read_events) 87 | watcher.on_failure(on_fail) 88 | await watcher.start() 89 | 90 | async def start(self): 91 | # Initial values for volume 92 | self.volume_input = self.volume_output = { 93 | "level": 0, 94 | "muted": False, 95 | "available": False, 96 | } 97 | # Add a watcher for wireplumber state file so we can get to know when the default 98 | # input/output devices have changed and act upon it 99 | self.inotify = INotify() 100 | self.inotify.add_watch(self.wp_state_file.parent, INotifyFlags.CREATE) 101 | # Then we add the IO file to the event loop 102 | asyncio.get_running_loop().add_reader(self.inotify, self.wp_statefile_changed) 103 | 104 | # Then we start the volume watcher subprocess using wireplumber's wpexec engine 105 | await self.run_volume_watcher() 106 | -------------------------------------------------------------------------------- /wmcompanion/events/bluetooth.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 Daniel Pereira 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | import logging 6 | from ..event_listening import EventListener 7 | from ..utils.dbus_client import SystemDBusClient 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | class BluetoothRadioStatus(EventListener): 12 | """ 13 | Reacts to bluetooth radio status changes 14 | """ 15 | 16 | async def start(self): 17 | client = SystemDBusClient() 18 | 19 | # Get the initial state 20 | state = await client.call_method( 21 | destination="org.bluez", 22 | interface="org.freedesktop.DBus.ObjectManager", 23 | path="/", 24 | member="GetManagedObjects", 25 | signature="", 26 | body=[], 27 | ) 28 | 29 | for _, props in state.items(): 30 | if "org.bluez.Adapter1" in props.keys(): 31 | await self.trigger( 32 | {"enabled": props["org.bluez.Adapter1"]["Powered"].value} 33 | ) 34 | 35 | def property_changed(adapter, values, _, dbus_message): 36 | if "/org/bluez/hci" not in dbus_message.path: 37 | return 38 | 39 | if adapter == "org.bluez.Adapter1" and "Powered" in values: 40 | self.run_coro(self.trigger({"enabled": values["Powered"].value})) 41 | 42 | # Then subscribe for changes 43 | subscribed = await client.add_signal_receiver( 44 | callback=property_changed, 45 | signal_name="PropertiesChanged", 46 | dbus_interface="org.freedesktop.DBus.Properties", 47 | ) 48 | 49 | if not subscribed: 50 | logger.warning("Could not subscribe to bluetooth status signal.") 51 | raise RuntimeError("Fail to setup bluez DBus signal receiver") 52 | -------------------------------------------------------------------------------- /wmcompanion/events/keyboard.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 Daniel Pereira 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | import logging 6 | from ..utils.dbus_client import SessionDBusClient 7 | from ..event_listening import EventListener 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | class KbddChangeLayout(EventListener): 12 | """ 13 | Reacts to kbdd layout changes 14 | """ 15 | async def start(self): 16 | client = SessionDBusClient() 17 | 18 | # This is equivalent to running the following in the terminal: 19 | # dbus-send --dest=ru.gentoo.KbddService /ru/gentoo/KbddService \ 20 | # ru.gentoo.kbdd.getCurrentLayout 21 | state = await client.call_method( 22 | destination = "ru.gentoo.KbddService", 23 | interface = "ru.gentoo.kbdd", 24 | path = "/ru/gentoo/KbddService", 25 | member = "getCurrentLayout", 26 | signature = "", 27 | body = [], 28 | ) 29 | 30 | await self.trigger({ "id": state }) 31 | 32 | def layout_changed(layout_id, **_): 33 | self.run_coro(self.trigger({ "id": layout_id })) 34 | 35 | subscribed = await client.add_signal_receiver( 36 | callback=layout_changed, 37 | signal_name="layoutChanged", 38 | dbus_interface="ru.gentoo.kbdd", 39 | ) 40 | 41 | if not subscribed: 42 | logger.warning("Could not subscribe to kbdd signal.") 43 | raise RuntimeError("Fail to setup kbdd DBus signal receiver") 44 | -------------------------------------------------------------------------------- /wmcompanion/events/libexec/README.md: -------------------------------------------------------------------------------- 1 | # What is this folder? 2 | 3 | Files placed here are not supposed to serve as library dependencies, meaning you can't call them in 4 | your config code nor should you depend on it as they are internal implementation details and can 5 | change in the future. 6 | 7 | These files are usually executable files that serves the purpose of their EventListener only. 8 | -------------------------------------------------------------------------------- /wmcompanion/events/libexec/wireplumber-volume-watcher.lua: -------------------------------------------------------------------------------- 1 | -- Copyright (c) 2022 Daniel Pereira 2 | -- 3 | -- SPDX-License-Identifier: Apache-2.0 4 | 5 | -- This is an always-on volume watcher that prints out every volume change to stdout, and it's meant 6 | -- to work with wmcompanion's MainVolumeLevel class. 7 | -- 8 | -- To test it, simply run: 9 | -- wpexec wireplumber-volume-watcher.lua 10 | 11 | Core.require_api("default-nodes", "mixer", function(default_nodes, mixer) 12 | -- Use the right scale to display the volume, same used by pulseaudio 13 | mixer["scale"] = "cubic" 14 | 15 | -- Constants 16 | VolumeSource = { INPUT = "input", OUTPUT = "output" } 17 | NodeByInput = { [VolumeSource.INPUT] = "Audio/Source", [VolumeSource.OUTPUT] = "Audio/Sink" } 18 | 19 | -- This is the state of each of the sources 20 | ENABLED_SOURCES = { [VolumeSource.INPUT] = nil, [VolumeSource.OUTPUT] = nil } 21 | 22 | -- Prints the volume in the following format: 23 | -- SOURCE(input or output):LEVEL(as decimal):MUTED(true or false):AVAILABILITY(true or false) 24 | -- 25 | -- * SOURCE is whether that volume is for speakers (output) or microphone (input) 26 | -- * LEVEL is a decimal betwen 0.00 and 1.00 27 | -- * MUTED is whether the user has muted the main input/output 28 | -- * AVAILABILITY is whether the system has or not at least one of that kind of input/output 29 | function print_volume(source, node_id) 30 | if ENABLED_SOURCES[source] == false then 31 | print(string.format("%s:0:false:false", source)) 32 | else 33 | local volume = mixer:call("get-volume", node_id) 34 | print(string.format("%s:%.2f:%s:true", source, volume["volume"], volume["mute"])) 35 | end 36 | end 37 | 38 | -- 39 | -- Add a watcher for volume level changes 40 | -- 41 | mixer:connect("changed", function(_, obj_id) 42 | local default_sink = default_nodes:call("get-default-node", NodeByInput[VolumeSource.OUTPUT]) 43 | if obj_id == default_sink then 44 | print_volume(VolumeSource.OUTPUT, default_sink) 45 | return 46 | end 47 | 48 | local default_source = default_nodes:call("get-default-node", NodeByInput[VolumeSource.INPUT]) 49 | if obj_id == default_source then 50 | print_volume(VolumeSource.INPUT, default_source) 51 | end 52 | end) 53 | 54 | -- 55 | -- Add watcher for sources (input/output) disconnection detection 56 | -- 57 | 58 | -- This function will either turn on or turn off a given device (output or input) 59 | function set_device_state(source, state) 60 | if state == true then 61 | default_node = default_nodes:call("get-default-node", NodeByInput[source]) 62 | 63 | -- Workaround: If by some reason the result of this function is INT_MAX, it failed somehow to 64 | -- fetch the default node, so let's rerun this call after wp_core_sync 65 | if default_node == 4294967295 then 66 | Core.sync(function() set_device_state(source, state) end) 67 | return 68 | end 69 | else 70 | default_node = 0 71 | end 72 | 73 | ENABLED_SOURCES[source] = state 74 | print_volume(source, default_node) 75 | end 76 | 77 | function enable_devices(devices) 78 | set_device_state(VolumeSource.INPUT, devices[VolumeSource.INPUT]) 79 | set_device_state(VolumeSource.OUTPUT, devices[VolumeSource.OUTPUT]) 80 | end 81 | 82 | function size(table) 83 | local count = 0 84 | for _ in pairs(table) do 85 | count = count + 1 86 | end 87 | return count 88 | end 89 | 90 | function resync_devices(om) 91 | devices = { [NodeByInput[VolumeSource.INPUT]] = {}, [NodeByInput[VolumeSource.OUTPUT]] = {} } 92 | 93 | for dev in om:iterate() do 94 | devices[dev.properties["media.class"]][dev.properties["object.id"]] = true 95 | end 96 | 97 | enable_devices({ 98 | input = size(devices[NodeByInput[VolumeSource.INPUT]]) >= 1, 99 | output = size(devices[NodeByInput[VolumeSource.OUTPUT]]) >= 1, 100 | }) 101 | end 102 | 103 | om = ObjectManager({ 104 | Interest({ 105 | type = "node", 106 | Constraint({ "media.class", "matches", NodeByInput[VolumeSource.OUTPUT], type = "pw" }), 107 | }), 108 | Interest({ 109 | type = "node", 110 | Constraint({ "media.class", "matches", NodeByInput[VolumeSource.INPUT], type = "pw" }), 111 | }), 112 | }) 113 | 114 | -- Workaround: Due to some some synchronization issue, wp seems not to pick up the correct 115 | -- default_node right after some object-added or object-removed has happened. To ensure that we 116 | -- always get it correctly, let's simply reschedule the callback to run after 100ms after an 117 | -- event has happened -- either a node is added or removed. 118 | delayed_resync_devices = function(om) 119 | resync_devices(om) 120 | Core.timeout_add(100, function() resync_devices(om); return false end) 121 | end 122 | 123 | om:connect("object-added", delayed_resync_devices) 124 | om:connect("object-removed", delayed_resync_devices) 125 | om:activate() 126 | end) 127 | -------------------------------------------------------------------------------- /wmcompanion/events/libexec/x11_device_watcher.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 Daniel Pereira 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | """ 6 | Watch for screen, keyboard and mice plug/unplug events and reports them through STDOUT. 7 | 8 | The reason why this is a separate application/process is because it depends on xcffib, which is a 9 | FFI extension and relies on C code, which might not have a good integration with python, especially 10 | when it comes to event handling as the C codebase doesn't have cooperative scheduling with Python. 11 | This forces us to use some hacks to get the job done, namely running the C event loop on a separate 12 | thread and enforcing this gets force killed if necessary, in case it doesn't respond in time. 13 | Because this is a global behavior and the entire Python VM would be affected by it, it was decided 14 | to keep this hack piece apart so that if it fails we can easily get rid of the process without 15 | interfering in the operation on the main process. As an advantage to this, one can easily port this 16 | code to any codebase or even run is as a separate application and it will just work. 17 | 18 | Dependencies: 19 | - Required: xcffib (Arch: python-xcffib) 20 | - Optional: acpi-daemon (Arch: acpid) 21 | 22 | Useful constant locations: 23 | * /usr/include/X11/X.h 24 | * /usr/include/X11/extensions/Xrandr.h 25 | * /usr/include/X11/extensions/randr.h 26 | * /usr/include/X11/extensions/XInput2.h 27 | """ 28 | 29 | import os 30 | import traceback 31 | import shutil 32 | import signal 33 | import sys 34 | import threading 35 | import socket 36 | import json 37 | import zlib 38 | import re 39 | import glob 40 | import concurrent.futures 41 | from enum import Enum 42 | 43 | 44 | class RPCPrinter: 45 | """ 46 | Communicate with the main process through stdout messages 47 | """ 48 | 49 | @staticmethod 50 | def event(evtype: "EventType", event: any): 51 | """Communicates an event message""" 52 | print(json.dumps({"action": evtype.value, "state": event}), flush=True) 53 | 54 | @staticmethod 55 | def exception(exc): 56 | """Communicates an exception message""" 57 | RPCPrinter.error(traceback.format_exception(exc)) 58 | 59 | @staticmethod 60 | def error(exc): 61 | """Communicates an error message""" 62 | print(json.dumps({"action": "error", "error": exc}), flush=True) 63 | 64 | 65 | try: 66 | import xcffib 67 | import xcffib.randr 68 | import xcffib.xinput 69 | from xcffib.xproto import GeGenericEvent, Atom 70 | from xcffib.randr import Connection, NotifyMask, ScreenChangeNotifyEvent 71 | from xcffib.xinput import ( 72 | Device, 73 | DeviceType, 74 | EventMask, 75 | XIEventMask, 76 | HierarchyEvent, 77 | ) 78 | except ModuleNotFoundError as e: 79 | RPCPrinter.error("Python xcffib module is not installed!") 80 | sys.exit() 81 | 82 | 83 | class EventType(Enum): 84 | """The kind of event that gets triggered""" 85 | 86 | SCREEN_CHANGE = "screen-change" 87 | INPUT_CHANGE = "input-change" 88 | 89 | 90 | class X11Client: 91 | """ 92 | The interface with X11 through xcffib 93 | """ 94 | 95 | def __init__(self): 96 | self.conn = xcffib.connect() 97 | self.randr = self.conn(xcffib.randr.key) 98 | self.xinput = self.conn(xcffib.xinput.key) 99 | self.main_window_id = self.conn.get_setup().roots[self.conn.pref_screen].root 100 | 101 | def get_monitor_unique_id(self, output: int) -> str | None: 102 | """ 103 | Returns a CRC32 of a monitor's EDID 104 | """ 105 | edid = self.get_output_edid(output) 106 | if edid is None: 107 | return None 108 | 109 | return hex(zlib.crc32(edid.raw))[2:].upper().rjust(8, "0") 110 | 111 | def get_output_edid(self, output: int): 112 | """ 113 | Returns the EDID data of a given output 114 | """ 115 | atoms = self.randr.ListOutputProperties(output).reply().atoms.list 116 | for atom in atoms: 117 | name = self.conn.core.GetAtomName(atom).reply().name.raw.decode("ascii") 118 | if name == "EDID": 119 | type_int = Atom.INTEGER 120 | reply = self.randr.GetOutputProperty( 121 | output, atom, type_int, 0, 2048, False, False 122 | ).reply() 123 | return reply.data 124 | 125 | return None 126 | 127 | def get_connected_outputs(self) -> list[dict]: 128 | """ 129 | Get the currently connected outputs 130 | """ 131 | res = self.randr.GetScreenResources(self.main_window_id).reply() 132 | 133 | monitors = [] 134 | for output in res.outputs: 135 | info = self.randr.GetOutputInfo(output, xcffib.CurrentTime).reply() 136 | if info.connection != Connection.Connected: 137 | continue 138 | 139 | monitors.append( 140 | { 141 | "output": info.name.raw.decode("ascii"), 142 | "edid_hash": self.get_monitor_unique_id(output), 143 | } 144 | ) 145 | 146 | return monitors 147 | 148 | X11_INPUT_TYPES = [ 149 | DeviceType.MasterPointer, 150 | DeviceType.MasterKeyboard, 151 | DeviceType.SlavePointer, 152 | DeviceType.SlaveKeyboard, 153 | DeviceType.FloatingSlave, 154 | ] 155 | 156 | X11_INPUT_TYPES_STR = { 157 | DeviceType.MasterPointer: "master-pointer", 158 | DeviceType.MasterKeyboard: "master-keyboard", 159 | DeviceType.SlavePointer: "slave-pointer", 160 | DeviceType.SlaveKeyboard: "slave-keyboard", 161 | DeviceType.FloatingSlave: "floating-slave", 162 | } 163 | 164 | def get_connected_inputs(self) -> list[dict]: 165 | """ 166 | Get the currently connected input devices 167 | """ 168 | inputs = [] 169 | for info in self.xinput.XIQueryDevice(Device.All).reply().infos: 170 | if info.type in self.X11_INPUT_TYPES: 171 | inputs.append( 172 | { 173 | "id": info.deviceid, 174 | "type": self.X11_INPUT_TYPES_STR[info.type], 175 | "name": info.name.raw.decode("utf-8"), 176 | } 177 | ) 178 | 179 | return inputs 180 | 181 | def listen_device_connection_events(self, callback: callable): 182 | """ 183 | Actively watch for an input device or screen connection change and notifies callback. 184 | """ 185 | # Watch for both randr screen change 186 | self.randr.SelectInput(self.main_window_id, NotifyMask.ScreenChange) 187 | # And XI2 device tree change 188 | mask = EventMask.synthetic(Device.All, 1, [XIEventMask.Hierarchy]) 189 | self.xinput.XISelectEvents(self.main_window_id, 1, [mask]) 190 | 191 | self.conn.flush() 192 | stop = threading.Event() 193 | 194 | # We use xcffib lib, which uses Python's CFFI library under the hood in order to provide a 195 | # thin layer on top of XCB C lib. 196 | # As in any FFI library, whenever we switch control to the C code, Python's VM loses control 197 | # over that program until the routine C yields, which is not the case for a blocking 198 | # function such as `wait_for_event`. 199 | # In order to increase the responsiveness of this application and make sure we are able to 200 | # stop it quickly if needed, we'll run it within a separate thread, leaving the main one 201 | # free for user interactivity. 202 | def wait_for_x11_event(stop, event): 203 | try: 204 | event["value"] = self.conn.wait_for_event() 205 | except Exception as err: # pylint: disable=broad-except 206 | event["value"] = err 207 | finally: 208 | stop.set() 209 | 210 | while True: 211 | event = {} 212 | stop.clear() 213 | threading.Thread( 214 | target=wait_for_x11_event, 215 | args=( 216 | stop, 217 | event, 218 | ), 219 | # Daemonize this thread so Python can exit even with it still running, which will 220 | # likely be the case because it will be blocked by the C function underneath. 221 | daemon=True, 222 | ).start() 223 | 224 | # Wait for the blocking operation 225 | stop.wait() 226 | 227 | if isinstance(event["value"], Exception): 228 | raise event["value"] 229 | 230 | # GeGenericEvent is for compatibility with xcffib < 1.2.0 231 | if isinstance(event["value"], (GeGenericEvent, HierarchyEvent)): 232 | callback(EventType.INPUT_CHANGE) 233 | 234 | if isinstance(event["value"], ScreenChangeNotifyEvent): 235 | callback(EventType.SCREEN_CHANGE) 236 | 237 | 238 | class MonitorLid: 239 | """ 240 | Handles the system lid and reads its state 241 | """ 242 | 243 | lid_state_file: str = "" 244 | is_present: bool = False 245 | 246 | # Singleton 247 | _instance: "MonitorLid" = None 248 | 249 | @classmethod 250 | def instance(cls): 251 | """Singleton instance""" 252 | if cls._instance is None: 253 | cls._instance = cls() 254 | return cls._instance 255 | 256 | def __init__(self): 257 | lids = glob.glob("/proc/acpi/button/lid/*/state") 258 | self.lid_state_file = lids[0] if len(lids) == 1 else None 259 | self.is_present = ( 260 | shutil.which("acpi_listen") is not None and self.lid_state_file is not None 261 | ) 262 | 263 | def is_open(self, output_name=None): 264 | """ 265 | Checks whether the lid is open. If none is present, it considers the lid as open. 266 | """ 267 | # If we don't have ACPI, then the lid is always open 268 | if not self.is_present: 269 | return True 270 | 271 | # If this is not a "laptop monitor", then the "lid" is open 272 | # Stolen from autorandr 273 | if output_name is not None and not re.match( 274 | r"(eDP(-?[0-9]\+)*|LVDS(-?[0-9]\+)*)", output_name 275 | ): 276 | return True 277 | 278 | with open(self.lid_state_file, encoding="ascii") as file: 279 | return "open" in file.read() 280 | 281 | 282 | class DeviceStatusReader: 283 | """ 284 | Main class responsible for listening and reporting device status changes 285 | """ 286 | 287 | def __init__(self): 288 | self.x11_client = X11Client() 289 | self.consider_lid = MonitorLid.instance().is_present 290 | self.device_state = [] 291 | self.screen_state = [] 292 | 293 | def listen_changes(self): 294 | """ 295 | Start a device/monitor change listener and blocks until an error or a sigint 296 | """ 297 | 298 | signal.signal(signal.SIGINT, self._exit_handler) 299 | 300 | # Execute the two blocking operations in a ThreadPool 301 | with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: 302 | futures = [] 303 | 304 | # Print the initial state on startup 305 | futures.append(executor.submit(self.dispatch_display_state)) 306 | futures.append(executor.submit(self.dispatch_device_state)) 307 | 308 | # Start the XCB listener 309 | futures.append(executor.submit(self._x11_listener)) 310 | 311 | # And if available, start the ACPI listener 312 | if self.consider_lid: 313 | futures.append(executor.submit(self._acpi_listener)) 314 | 315 | # Handle errors 316 | for future in concurrent.futures.as_completed(futures): 317 | try: 318 | future.result() 319 | except Exception as exc: # pylint: disable=broad-except 320 | RPCPrinter.exception(exc) 321 | os._exit(0) # pylint: disable=protected-access 322 | 323 | def get_active_screens(self, state): 324 | """ 325 | Filter screens that are really considered active based on their lid state, if applicable 326 | """ 327 | 328 | def monitor_is_on(mon): 329 | return not self.consider_lid or MonitorLid.instance().is_open(mon["output"]) 330 | 331 | return [mon for mon in state if monitor_is_on(mon)] 332 | 333 | def dispatch_device_state(self): 334 | """ 335 | Communicates the current device tree state change (i.e. what has been added/removed since 336 | last dispatch) 337 | """ 338 | 339 | previous_state = self.device_state 340 | self.device_state = self.x11_client.get_connected_inputs() 341 | 342 | # Avoid dispatching events if the state hasn't really changed 343 | if previous_state == self.device_state: 344 | return 345 | 346 | event = {"active": self.device_state} 347 | 348 | # Check what's changed specifically 349 | added = [x for x in self.device_state if x not in previous_state] 350 | removed = [x for x in previous_state if x not in self.device_state] 351 | if added: 352 | event["added"] = added 353 | if removed: 354 | event["removed"] = removed 355 | 356 | RPCPrinter.event(EventType.INPUT_CHANGE, event) 357 | 358 | def dispatch_display_state(self): 359 | """ 360 | Communicates the currently connected displays 361 | """ 362 | 363 | previous_state = self.screen_state 364 | self.screen_state = self.get_active_screens( 365 | self.x11_client.get_connected_outputs() 366 | ) 367 | 368 | # Avoid dispatching events if the state hasn't really changed 369 | if previous_state == self.screen_state: 370 | return 371 | 372 | event = {"active": self.screen_state} 373 | RPCPrinter.event(EventType.SCREEN_CHANGE, event) 374 | 375 | def _handle_x11_callback(self, event: EventType): 376 | match event: 377 | case EventType.SCREEN_CHANGE: 378 | self.dispatch_display_state() 379 | case EventType.INPUT_CHANGE: 380 | self.dispatch_device_state() 381 | case _: 382 | raise RuntimeError( 383 | f"Unable to understand X11Client callback response: {event}" 384 | ) 385 | 386 | def _acpi_listener(self): 387 | last_state = "open" if MonitorLid.instance().is_open() else "closed" 388 | current_state = last_state 389 | 390 | sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 391 | sock.connect("/var/run/acpid.socket") 392 | while True: 393 | line = sock.recv(128).decode("utf-8") 394 | if "button/lid" in line: 395 | current_state = "open" if "open" in line else "closed" 396 | if current_state == last_state: 397 | continue 398 | 399 | last_state = current_state 400 | self.dispatch_display_state() 401 | 402 | def _x11_listener(self): 403 | self.x11_client.listen_device_connection_events(self._handle_x11_callback) 404 | 405 | def _exit_handler(self, *_): 406 | print("SIGINT received, exiting...", file=sys.stderr, flush=True) 407 | os._exit(0) # pylint: disable=protected-access 408 | 409 | 410 | if __name__ == "__main__": 411 | monitor_status_reader = DeviceStatusReader() 412 | monitor_status_reader.listen_changes() 413 | -------------------------------------------------------------------------------- /wmcompanion/events/network.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 Daniel Pereira 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | import logging 6 | from contextlib import suppress 7 | from ..utils.dbus_client import SystemDBusClient, DBusClientError 8 | from ..event_listening import EventListener 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class WifiStatus(EventListener): 14 | """ 15 | Reacts to wifi radio changes, connection activity and strength changes. 16 | """ 17 | 18 | # Sets which adapter we want to watch for updates. If blank, use the first available 19 | wifi_adapter: str = "" 20 | 21 | dbus: SystemDBusClient = None 22 | wifi_enabled: bool = False 23 | wifi_connected: bool = False 24 | wifi_strength: str = "" 25 | wifi_access_point_path: str = "" 26 | 27 | async def update_state(self): 28 | """ 29 | Triggers a state update with all Wifi details 30 | """ 31 | await self.fetch_wifi_access_point() 32 | await self.fetch_wifi_strength() 33 | await self.trigger( 34 | { 35 | "enabled": self.wifi_enabled, 36 | "connected": self.wifi_connected, 37 | "strength": self.wifi_strength, 38 | } 39 | ) 40 | 41 | async def start(self): 42 | self.dbus = SystemDBusClient() 43 | 44 | # Get initial state 45 | await self.update_state() 46 | 47 | # Then subscribe for state updates 48 | def property_changed(prop, value, _, dbus_message): 49 | # AccessPoint Strength update 50 | if prop == "org.freedesktop.NetworkManager.AccessPoint": 51 | if ( 52 | "Strength" in value 53 | and dbus_message.path == self.wifi_access_point_path 54 | ): 55 | self.wifi_strength = value["Strength"].value 56 | self.run_coro(self.update_state()) 57 | return 58 | 59 | # Connected/disconnected event 60 | if ( 61 | prop == "org.freedesktop.NetworkManager" 62 | and "ActiveConnections" in value 63 | ): 64 | self.run_coro(self.update_state()) 65 | 66 | subscribed = await self.dbus.add_signal_receiver( 67 | callback=property_changed, 68 | signal_name="PropertiesChanged", 69 | dbus_interface="org.freedesktop.DBus.Properties", 70 | ) 71 | 72 | if not subscribed: 73 | logger.warning("Could not subscribe to DBus PropertiesChanged signal.") 74 | raise RuntimeError("Fail to setup NetworkManager DBus signal receiver") 75 | 76 | async def is_connection_wifi(self, connection_path) -> bool: 77 | """ 78 | If there's a defined interface name for the wifi network, check if that's the one 79 | Otherwise, simply check if this connection is wireless 80 | """ 81 | if self.wifi_adapter: 82 | conn_devices = await self.dbus.call_method( 83 | destination="org.freedesktop.NetworkManager", 84 | path=connection_path, 85 | interface="org.freedesktop.DBus.Properties", 86 | member="Get", 87 | signature="ss", 88 | body=["org.freedesktop.NetworkManager.Connection.Active", "Devices"], 89 | ) 90 | 91 | for device_path in conn_devices.value: 92 | device = await self.dbus.call_method( 93 | destination="org.freedesktop.NetworkManager", 94 | path=device_path, 95 | interface="org.freedesktop.DBus.Properties", 96 | member="Get", 97 | signature="ss", 98 | body=["org.freedesktop.NetworkManager.Device", "Interface"], 99 | ) 100 | 101 | if device.value == self.wifi_adapter: 102 | return True 103 | 104 | return False 105 | 106 | conn_type = await self.dbus.call_method( 107 | destination="org.freedesktop.NetworkManager", 108 | path=connection_path, 109 | interface="org.freedesktop.DBus.Properties", 110 | member="Get", 111 | signature="ss", 112 | body=["org.freedesktop.NetworkManager.Connection.Active", "Type"], 113 | ) 114 | 115 | return conn_type.value == "802-11-wireless" 116 | 117 | async def fetch_wifi_access_point(self): 118 | """ 119 | Updates the currently connected access point path. 120 | """ 121 | # 0. Check if HW/SW access to wifi is enabled 122 | self.wifi_enabled = await self.wifi_is_enabled() 123 | if not self.wifi_enabled: 124 | self.wifi_connected = False 125 | self.wifi_access_point_path = "" 126 | return 127 | 128 | # 1. Collect all active connections 129 | active_connections = await self.dbus.call_method( 130 | destination="org.freedesktop.NetworkManager", 131 | path="/org/freedesktop/NetworkManager", 132 | interface="org.freedesktop.DBus.Properties", 133 | member="Get", 134 | signature="ss", 135 | body=["org.freedesktop.NetworkManager", "ActiveConnections"], 136 | ) 137 | 138 | # 2A. Next, if we know which interface name, we filter by it 139 | # 2B. Otherwise, we get the first connection that is wireless and we'll use it 140 | wifi_connection = None 141 | for connection_path in active_connections.value: 142 | if await self.is_connection_wifi(connection_path): 143 | wifi_connection = connection_path 144 | break 145 | 146 | # No wifi detected, just skip through 147 | if not wifi_connection: 148 | self.wifi_connected = False 149 | self.wifi_access_point_path = "" 150 | return 151 | 152 | # 3. Get AccessPoint from Wi-Fi connection 153 | access_point = await self.dbus.call_method( 154 | destination="org.freedesktop.NetworkManager", 155 | path=wifi_connection, 156 | interface="org.freedesktop.DBus.Properties", 157 | member="Get", 158 | signature="ss", 159 | body=["org.freedesktop.NetworkManager.Connection.Active", "SpecificObject"], 160 | ) 161 | 162 | self.wifi_connected = True 163 | self.wifi_access_point_path = access_point.value 164 | 165 | async def fetch_wifi_strength(self) -> None: 166 | """ 167 | Updates the signal strength of the current connected network, or sets to 0 in case we're not 168 | connected to any wireless network. 169 | """ 170 | if not self.wifi_connected: 171 | self.wifi_strength = 0 172 | return 173 | 174 | signal = await self.dbus.call_method( 175 | destination="org.freedesktop.NetworkManager", 176 | path=self.wifi_access_point_path, 177 | interface="org.freedesktop.DBus.Properties", 178 | member="Get", 179 | signature="ss", 180 | body=["org.freedesktop.NetworkManager.AccessPoint", "Strength"], 181 | ) 182 | 183 | self.wifi_strength = signal.value 184 | 185 | async def wifi_is_enabled(self): 186 | """ 187 | Test whether wifi is enabled in hardware and software. 188 | """ 189 | state = await self.dbus.call_method( 190 | destination="org.freedesktop.NetworkManager", 191 | path="/org/freedesktop/NetworkManager", 192 | interface="org.freedesktop.DBus.Properties", 193 | member="Get", 194 | signature="ss", 195 | body=["org.freedesktop.NetworkManager", "WirelessEnabled"], 196 | ) 197 | 198 | hw_state = await self.dbus.call_method( 199 | destination="org.freedesktop.NetworkManager", 200 | path="/org/freedesktop/NetworkManager", 201 | interface="org.freedesktop.DBus.Properties", 202 | member="Get", 203 | signature="ss", 204 | body=["org.freedesktop.NetworkManager", "WirelessHardwareEnabled"], 205 | ) 206 | 207 | return state.value and hw_state.value 208 | 209 | 210 | class NetworkConnectionStatus(EventListener): 211 | """ 212 | Reacts to NetworkManager connection status change 213 | """ 214 | 215 | # Defines which connection name on NetworkManager we want to monitor 216 | connection_name: str = "" 217 | 218 | dbus: SystemDBusClient = None 219 | 220 | def __str__(self): 221 | return f"{type(self).__name__}[{self.connection_name}]" 222 | 223 | async def start(self): 224 | self.dbus = SystemDBusClient() 225 | 226 | # Get initial state 227 | await self.update_state() 228 | 229 | # Then subscribe for state updates 230 | def property_changed(prop, value, *_, **__): 231 | # Connected/disconnected event 232 | if ( 233 | prop == "org.freedesktop.NetworkManager" 234 | and "ActiveConnections" in value 235 | ): 236 | self.run_coro(self.update_state()) 237 | 238 | subscribed = await self.dbus.add_signal_receiver( 239 | callback=property_changed, 240 | signal_name="PropertiesChanged", 241 | dbus_interface="org.freedesktop.DBus.Properties", 242 | ) 243 | 244 | if not subscribed: 245 | logger.warning("Could not subscribe to DBus PropertiesChanged signal.") 246 | raise RuntimeError("Fail to setup NetworkManager DBus signal receiver") 247 | 248 | async def update_state(self): 249 | """ 250 | Triggers a state update with the network status 251 | """ 252 | connected = await self.connection_is_active() 253 | await self.trigger({"connected": connected}) 254 | 255 | async def connection_is_active(self) -> bool: 256 | """ 257 | Check whether the connection name is active 258 | """ 259 | with suppress(DBusClientError): 260 | # 1. Collect all active connections 261 | active_connections = await self.dbus.call_method( 262 | destination="org.freedesktop.NetworkManager", 263 | path="/org/freedesktop/NetworkManager", 264 | interface="org.freedesktop.DBus.Properties", 265 | member="Get", 266 | signature="ss", 267 | body=["org.freedesktop.NetworkManager", "ActiveConnections"], 268 | ) 269 | 270 | # Check if the one we're looking for is here... 271 | for connection_path in active_connections.value: 272 | conn_name = await self.dbus.call_method( 273 | destination="org.freedesktop.NetworkManager", 274 | path=connection_path, 275 | interface="org.freedesktop.DBus.Properties", 276 | member="Get", 277 | signature="ss", 278 | body=["org.freedesktop.NetworkManager.Connection.Active", "Id"], 279 | ) 280 | 281 | if conn_name.value == self.connection_name: 282 | return True 283 | 284 | return False 285 | -------------------------------------------------------------------------------- /wmcompanion/events/notifications.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 Daniel Pereira 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | import logging 6 | from ..utils.dbus_client import SessionDBusClient 7 | from ..event_listening import EventListener 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class DunstPausedStatus(EventListener): 13 | """ 14 | Reacts to dunst pause status changes 15 | """ 16 | 17 | async def start(self): 18 | client = SessionDBusClient() 19 | 20 | state = await client.call_method( 21 | destination="org.freedesktop.Notifications", 22 | interface="org.freedesktop.DBus.Properties", 23 | path="/org/freedesktop/Notifications", 24 | member="Get", 25 | signature="ss", 26 | body=["org.dunstproject.cmd0", "paused"], 27 | ) 28 | 29 | if state is None: 30 | logger.warning("Unable to get initial keyboard state from DBus") 31 | raise RuntimeError("Fail to get initial kbdd DBus status") 32 | 33 | # Set the initial state 34 | await self.trigger({"paused": state.value}) 35 | 36 | def property_changed(prop, value, *_, **__): 37 | if prop == "org.dunstproject.cmd0" and "paused" in value: 38 | self.run_coro(self.trigger({"paused": value["paused"].value})) 39 | 40 | # Then subscribe for changes 41 | subscribed = await client.add_signal_receiver( 42 | callback=property_changed, 43 | signal_name="PropertiesChanged", 44 | dbus_interface="org.freedesktop.DBus.Properties", 45 | ) 46 | 47 | if not subscribed: 48 | logger.warning("Could not subscribe to kbdd signal.") 49 | raise RuntimeError("Fail to setup kbdd DBus signal receiver") 50 | -------------------------------------------------------------------------------- /wmcompanion/events/power.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 Daniel Pereira 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | import asyncio 6 | import logging 7 | from glob import glob 8 | from enum import Enum 9 | from pathlib import Path 10 | from datetime import datetime 11 | from ..utils.dbus_client import SystemDBusClient 12 | from ..event_listening import EventListener 13 | from ..errors import WMCompanionError 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class LogindIdleStatus(EventListener): 19 | """ 20 | Listen for systemd-logind IdleHint events, which is how the desktop environment let systemd know 21 | that it is idle so it can take actions such as automatically suspending. With this module, you 22 | are able to hook on those events and perform those actions yourself. 23 | 24 | See: https://www.freedesktop.org/wiki/Software/systemd/logind/ 25 | See: https://www.freedesktop.org/software/systemd/man/logind.conf.html 26 | """ 27 | 28 | async def start(self): 29 | def property_changed(prop, values, _, dbus_message): 30 | if "/org/freedesktop/login1" not in dbus_message.path: 31 | return 32 | 33 | if prop == "org.freedesktop.login1.Manager" and "IdleHint" in values: 34 | status = values["IdleHint"].value 35 | time = datetime.fromtimestamp(values["IdleSinceHint"].value / 1000000) 36 | self.run_coro(self.trigger({"idle": status, "idle-since": time})) 37 | 38 | subscribed = await SystemDBusClient().add_signal_receiver( 39 | callback=property_changed, 40 | signal_name="PropertiesChanged", 41 | dbus_interface="org.freedesktop.DBus.Properties", 42 | ) 43 | 44 | if not subscribed: 45 | logger.warning("Could not subscribe to DBus PropertiesChanged signal.") 46 | raise RuntimeError( 47 | "Fail to setup logind DBus signal receiver for PropertiesChanged" 48 | ) 49 | 50 | 51 | class PowerActions(EventListener): 52 | """ 53 | PowerActions will listen for all possible power related events, such as power source switch and 54 | battery level changes, then proceed with notifying the callbacks with the current system power 55 | state. 56 | """ 57 | 58 | battery_path: str|None = None 59 | previous_level: int = 0 60 | previous_status: "BatteryStatus" = None 61 | 62 | class Events(Enum): 63 | """The kind of event that's been triggered""" 64 | 65 | INITIAL_STATE = "initial-state" 66 | RETURN_FROM_SLEEP = "return-from-sleep" 67 | BATTERY_LEVEL_CHANGE = "battery-level-change" 68 | POWER_BUTTON_PRESS = "power-button-press" 69 | POWER_SOURCE_SWITCH = "power-source-switch" 70 | 71 | class PowerSource(Enum): 72 | """The current computer power source""" 73 | 74 | AC = "ac" 75 | BATTERY = "battery" 76 | 77 | class BatteryStatus(Enum): 78 | """The current status of the battery""" 79 | 80 | NOT_CHARGING = "Not charging" 81 | CHARGING = "Charging" 82 | DISCHARGING = "Discharging" 83 | UNKNOWN = "Unknown" 84 | FULL = "Full" 85 | 86 | async def start(self): 87 | await self.fetch_system_battery() 88 | await self.start_battery_poller() 89 | await self.start_acpi_listener() 90 | await self.start_wakeup_detector() 91 | await self.trigger_event(self.Events.INITIAL_STATE) 92 | 93 | async def trigger_event( 94 | self, 95 | event: Events, 96 | power_source: PowerSource = None, 97 | battery_status: BatteryStatus = None, 98 | battery_level: int = None, 99 | ): 100 | """ 101 | Triggers a power event with current power state 102 | """ 103 | if not power_source: 104 | power_source = await self.current_power_source() 105 | if not battery_level: 106 | battery_level = await self.current_battery_level() 107 | if not battery_status: 108 | battery_status = await self.current_battery_status() 109 | allow_duplicate_events = event in [ 110 | self.Events.POWER_BUTTON_PRESS, 111 | self.Events.RETURN_FROM_SLEEP, 112 | ] 113 | 114 | self.previous_level = battery_level 115 | self.previous_status = battery_status 116 | 117 | await self.trigger( 118 | { 119 | "event": event, 120 | "power-source": power_source, 121 | "battery-level": battery_level, 122 | "battery-status": battery_status, 123 | }, 124 | allow_duplicate_events=allow_duplicate_events, 125 | ) 126 | 127 | async def start_battery_poller(self): 128 | """ 129 | Starts an asynchronous battery level poller if the system has a battery 130 | """ 131 | if await self.system_has_battery(): 132 | self.run_coro(self.battery_poller()) 133 | 134 | async def battery_poller(self): 135 | """ 136 | Polls the battery for level changes and triggers an event upon a state change 137 | """ 138 | frequency = 60 139 | while await asyncio.sleep(frequency, True): 140 | battery_status = await self.current_battery_status() 141 | battery_level = await self.current_battery_level() 142 | 143 | # Nothing has changed, save one call 144 | if ( 145 | battery_status == self.previous_status 146 | and battery_level == self.previous_level 147 | ): 148 | continue 149 | 150 | await self.trigger_event( 151 | self.Events.BATTERY_LEVEL_CHANGE, 152 | battery_status=battery_status, 153 | battery_level=battery_level, 154 | ) 155 | 156 | # Inteligently adjust the polling frequency: 157 | # 158 | # - If the last measured status is unknown, it is very likely for it to change shortly 159 | # after that, so we just monitor for that change more tightly, giving a more real-time 160 | # sense for the poll. 161 | # - When battery is low, it usually drains quicker, so we need to check that more 162 | # frequently 163 | # - Otherwise we just keep the default polling freq. 164 | if self.previous_status == self.BatteryStatus.UNKNOWN: 165 | frequency = 5 166 | elif self.previous_level <= 10: 167 | frequency = 30 168 | else: 169 | frequency = 60 170 | 171 | async def current_power_source(self) -> PowerSource: 172 | """ 173 | Retrieves the current system power source 174 | """ 175 | 176 | def is_on_ac(): 177 | return ( 178 | not Path("/sys/class/power_supply/AC").is_dir() 179 | or Path("/sys/class/power_supply/AC/online").read_text("utf-8").strip() 180 | == "1" 181 | ) 182 | 183 | if await self.run_blocking_io(is_on_ac): 184 | return self.PowerSource.AC 185 | 186 | return self.PowerSource.BATTERY 187 | 188 | async def fetch_system_battery(self) -> bool: 189 | """ 190 | Checks whether the system has a battery, then save its sys path to `battery_path` 191 | """ 192 | 193 | def fetch_battery(): 194 | """Blocking I/O that get the first available battery on the system""" 195 | batteries = glob("/sys/class/power_supply/BAT*") 196 | if len(batteries) > 0 and Path(batteries[0]).is_dir(): 197 | self.battery_path = batteries[0] 198 | 199 | return await self.run_blocking_io(fetch_battery) 200 | 201 | async def system_has_battery(self) -> bool: 202 | """ 203 | Checks whether the system has a battery 204 | """ 205 | 206 | if self.battery_path is None: 207 | await self.fetch_system_battery() 208 | 209 | return bool(self.battery_path) 210 | 211 | async def current_battery_status(self) -> BatteryStatus|None: 212 | """ 213 | Get the current battery status 214 | """ 215 | 216 | def battery_status(): 217 | """ 218 | Blocking I/O that gets the battery status 219 | (Not Charging, Charging, Discharging, Unknown, Full) 220 | """ 221 | return ( 222 | Path(f"{self.battery_path}/status").read_text("utf-8").strip() 223 | ) 224 | 225 | if not await self.system_has_battery(): 226 | return None 227 | 228 | return self.BatteryStatus(await self.run_blocking_io(battery_status)) 229 | 230 | async def current_battery_level(self) -> int|None: 231 | """ 232 | Get the current battery level 233 | """ 234 | 235 | def battery_capacity(): 236 | """Blocking I/O that gets the battery capacity""" 237 | return Path(f"{self.battery_path}/capacity").read_text("utf-8") 238 | 239 | if not await self.system_has_battery(): 240 | return None 241 | 242 | return int(await self.run_blocking_io(battery_capacity)) 243 | 244 | async def system_has_acpi(self) -> bool: 245 | """ 246 | Checks whether the system has ACPI daemon installed 247 | """ 248 | 249 | def has_acpi(): 250 | """Blocking I/O that gets whether this system has acpid installed or not""" 251 | return Path("/etc/acpi").is_dir() 252 | 253 | return await self.run_blocking_io(has_acpi) 254 | 255 | async def start_acpi_listener(self): 256 | """ 257 | Starts the ACPI daemon listener that helps detecting power events such as power button or 258 | power source changes 259 | """ 260 | if not await self.system_has_acpi(): 261 | return 262 | 263 | try: 264 | reader, _writer = await asyncio.open_unix_connection( 265 | "/var/run/acpid.socket" 266 | ) 267 | self.run_coro(self.acpid_event(reader)) 268 | except FileNotFoundError as err: 269 | raise WMCompanionError( 270 | "ACPI socket not found. Listener can't be started." 271 | ) from err 272 | 273 | async def start_wakeup_detector(self): 274 | """Detects when the system has returned from sleep or hibernation""" 275 | 276 | def prepare_for_sleep(sleeping: bool, **_): 277 | if not sleeping: 278 | self.run_coro(self.trigger_event(self.Events.RETURN_FROM_SLEEP)) 279 | 280 | dbus = SystemDBusClient() 281 | subscribed = await dbus.add_signal_receiver( 282 | callback=prepare_for_sleep, 283 | signal_name="PrepareForSleep", 284 | dbus_interface="org.freedesktop.login1.Manager", 285 | ) 286 | 287 | if not subscribed: 288 | logger.warning("Could not subscribe to DBus PrepareForSleep signal.") 289 | raise RuntimeError( 290 | "Fail to setup logind DBus signal receiver for PrepareForSleep" 291 | ) 292 | 293 | async def acpid_event(self, reader: asyncio.StreamReader): 294 | """ 295 | Callback called when there's any ACPI daemon event triggered, then converts them to 296 | wmcompanion ones 297 | """ 298 | while line := (await reader.readline()).decode("utf-8").strip(): 299 | if "button/power" in line: 300 | await self.trigger_event(self.Events.POWER_BUTTON_PRESS) 301 | elif "ac_adapter" in line: 302 | if line.split(" ")[3] == "00000000": 303 | source = self.PowerSource.BATTERY 304 | else: 305 | source = self.PowerSource.AC 306 | 307 | await self.trigger_event( 308 | self.Events.POWER_SOURCE_SWITCH, power_source=source 309 | ) 310 | 311 | async def schedule_battery_report(): 312 | """ 313 | We schedule a new battery report to 5 seconds from now. This threshold is so 314 | that we can account for the kernel to process the battery state transition 315 | after a plug/unplug event 316 | """ 317 | await asyncio.sleep(5) 318 | await self.trigger_event(self.Events.BATTERY_LEVEL_CHANGE) 319 | 320 | self.run_coro(schedule_battery_report()) 321 | -------------------------------------------------------------------------------- /wmcompanion/events/x11.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 Daniel Pereira 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | import json 6 | import logging 7 | import zlib 8 | import pickle 9 | from typing import Coroutine 10 | from pathlib import Path 11 | from enum import Enum 12 | from ..event_listening import EventListener 13 | from ..utils.process import ProcessWatcher 14 | from ..errors import WMCompanionFatalError 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | class DeviceState(EventListener): 20 | """ 21 | Listen for X11 input device and screen changes, making it easy for configuring devices using 22 | xinput and screen resolution with xrandr. 23 | """ 24 | 25 | previous_trigger_checksum: dict = None 26 | 27 | class ChangeEvent(Enum): 28 | """ 29 | The kind of change a given event is related to 30 | """ 31 | 32 | SCREEN_CHANGE = "screen-change" 33 | INPUT_CHANGE = "input-change" 34 | 35 | class InputType(Enum): 36 | """ 37 | Input type of a INPUT_CHANGE event 38 | """ 39 | 40 | MASTER_POINTER = "master-pointer" 41 | MASTER_KEYBOARD = "master-keyboard" 42 | SLAVE_POINTER = "slave-pointer" 43 | SLAVE_KEYBOARD = "slave-keyboard" 44 | FLOATING_SLAVE = "floating-slave" 45 | 46 | async def start(self): 47 | self.previous_trigger_checksum = {} 48 | cmd = [ 49 | "python", 50 | Path(__file__).parent.joinpath("libexec/x11_device_watcher.py"), 51 | ] 52 | watcher = ProcessWatcher(cmd, restart_every=3600) 53 | watcher.on_start(self.read_events) 54 | watcher.on_failure(self.on_failure) 55 | await watcher.start() 56 | 57 | async def read_events(self, proc: Coroutine): 58 | """ 59 | Reads and processes any event coming from X11 Device Watcher daemon 60 | """ 61 | while line := await proc.stdout.readline(): 62 | event = json.loads(line.decode("utf-8")) 63 | 64 | # Detect and prevent duplicate events. 65 | # 66 | # Although the duplication detection already exists at EventListener, it only works for 67 | # events of the same kind, but because this class treats two kinds of changes (input OR 68 | # screens) as if they were only one, then it won't help and we need to do the work on 69 | # this class. 70 | # 71 | # We do the detection by checking the `state.active` key, where active input and screens 72 | # are stored and thus the key to determine whether this is a duplicate event based on 73 | # the last one triggered. 74 | # 75 | # This duplicate prevention is very important because by default the X11 helper process 76 | # gets restarted every hour, and each time it starts, the entire state is resent as an 77 | # event, but no necessarily a change would happen. 78 | trigger_checksum = zlib.adler32(pickle.dumps(event["state"]["active"])) 79 | if trigger_checksum == self.previous_trigger_checksum.get(event["action"]): 80 | continue 81 | 82 | self.previous_trigger_checksum[event["action"]] = trigger_checksum 83 | 84 | match event["action"]: 85 | case "screen-change": 86 | await self.trigger( 87 | { 88 | "event": self.ChangeEvent.SCREEN_CHANGE, 89 | "screens": event["state"]["active"], 90 | }, 91 | allow_duplicate_events=True, 92 | ) 93 | 94 | case "input-change": 95 | await self.trigger( 96 | { 97 | "event": self.ChangeEvent.INPUT_CHANGE, 98 | "inputs": event["state"], 99 | }, 100 | allow_duplicate_events=True, 101 | ) 102 | 103 | case "error": 104 | logger.error( 105 | "x11_device_watcher error: %s", "".join(event["error"]) 106 | ) 107 | 108 | async def on_failure(self): 109 | """ 110 | Callback for failures on X11 Device Watcher daemon 111 | """ 112 | raise WMCompanionFatalError( 113 | "x11_device_watcher run failed, please check the logs" 114 | ) 115 | -------------------------------------------------------------------------------- /wmcompanion/modules/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kriansa/wmcompanion/051f6b90dc367a94cb860c85590030d535ede1db/wmcompanion/modules/__init__.py -------------------------------------------------------------------------------- /wmcompanion/modules/notifications.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 Daniel Pereira 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | from enum import Enum 6 | from ..utils.dbus_client import SessionDBusClient, Variant 7 | 8 | 9 | class Urgency(Enum): 10 | """ 11 | Level of urgency, as defined by 12 | https://specifications.freedesktop.org/notification-spec/notification-spec-latest.html#urgency-levels 13 | """ 14 | 15 | LOW = 0 16 | NORMAL = 1 17 | CRITICAL = 2 18 | 19 | 20 | class Category(Enum): 21 | """ 22 | Notifications optional type indicator, as described by 23 | https://specifications.freedesktop.org/notification-spec/notification-spec-latest.html#categories 24 | """ 25 | 26 | DEVICE = "device" 27 | DEVICE_ADDED = "device.added" 28 | DEVICE_ERROR = "device.error" 29 | DEVICE_REMOVED = "device.removed" 30 | EMAIL = "email" 31 | EMAIL_ARRIVED = "email.arrived" 32 | EMAIL_BOUNCED = "email.bounced" 33 | IM = "im" 34 | IM_ERROR = "im.error" 35 | IM_RECEIVED = "im.received" 36 | NETWORK = "network" 37 | NETWORK_CONNECTED = "network.connected" 38 | NETWORK_DISCONNECTED = "network.disconnected" 39 | NETWORK_ERROR = "network.error" 40 | PRESENCE = "presence" 41 | PRESENCE_OFFLINE = "presence.offline" 42 | PRESENCE_ONLINE = "presence.online" 43 | TRANSFER = "transfer" 44 | TRANSFER_COMPLETE = "transfer.complete" 45 | TRANSFER_ERROR = "transfer.error" 46 | 47 | 48 | class Action: # pylint: disable=too-few-public-methods 49 | """ 50 | The actions send a request message back to the notification client when invoked. 51 | 52 | The default action (usually invoked my clicking the notification) should have a key named 53 | "default". The name can be anything, though implementations are free not to display it. 54 | """ 55 | 56 | def __init__(self, identifier: str, message: str): 57 | self.identifier = identifier 58 | self.message = message 59 | 60 | def to_value(self): 61 | """ 62 | Transform the hint object into a value to be transmitted to the notification server 63 | 64 | Actions are sent over as a list of pairs. 65 | Each even element in the list (starting at index 0) represents the identifier for the 66 | action. Each odd element in the list is the localized string that will be displayed to the 67 | user. 68 | """ 69 | return [self.identifier, self.message] 70 | 71 | 72 | class HintABC: # pylint: disable=too-few-public-methods 73 | """ 74 | Hints are a way to provide extra data to a notification server that the server may be able to 75 | make use of. 76 | 77 | Usage: 78 | hints = [Hint.ActionIcons(False), Hint.Urgency(Urgency.LOW)] 79 | """ 80 | 81 | name: str 82 | value_type: type 83 | signature: str 84 | 85 | def __init__(self, value: any): 86 | self.value = value 87 | 88 | def to_value(self): 89 | """ 90 | Transform the hint object into a value to be transmitted to the notification server 91 | """ 92 | raw = self.value_type(self.value) 93 | if hasattr(raw, "value"): 94 | raw = raw.value # Unwrap Enums 95 | return [self.name, Variant(self.signature, raw)] 96 | 97 | 98 | # pylint: disable=too-few-public-methods 99 | class Hint: 100 | """ 101 | Namespace for all available hints 102 | """ 103 | 104 | class ActionIcons(HintABC): 105 | """ 106 | When set, a server that has the "action-icons" capability will attempt to interpret any 107 | action identifier as a named icon. The localized display name will be used to annotate 108 | the icon for accessibility purposes. The icon name should be compliant with the 109 | Freedesktop.org Icon Naming Specification. 110 | """ 111 | 112 | name = "action-icons" 113 | value_type = bool 114 | signature = "b" 115 | 116 | class Category(HintABC): 117 | """ 118 | The type of notification this is. 119 | """ 120 | 121 | name = "category" 122 | value_type = Category 123 | signature = "s" 124 | 125 | class DesktopEntry(HintABC): 126 | """ 127 | This specifies the name of the desktop filename representing the calling program. This 128 | should be the same as the prefix used for the application's .desktop file. An example would 129 | be "rhythmbox" from "rhythmbox.desktop". This can be used by the daemon to retrieve the 130 | correct icon for the application, for logging purposes, etc. 131 | """ 132 | 133 | name = "desktop-entry" 134 | value_type = str 135 | signature = "s" 136 | 137 | class ImageData(HintABC): 138 | """ 139 | This is a raw data image format which describes the width, height, rowstride, has alpha, 140 | bits per sample, channels and image data respectively. 141 | 142 | Usage: ImageData([width, height, rowstride, alpha_bool, bits, channels, imgdata_bytes]) 143 | """ 144 | 145 | name = "image-data" 146 | value_type = list 147 | signature = "(iiibiiay)" 148 | 149 | class ImagePath(HintABC): 150 | """ 151 | Alternative way to define the notification image. 152 | 153 | It should be either an URI (file:// is the only URI schema supported right now) or a name in 154 | a freedesktop.org-compliant icon theme (not a GTK+ stock ID). 155 | 156 | See: 157 | https://specifications.freedesktop.org/notification-spec/notification-spec-latest.html#icons-and-images 158 | """ 159 | 160 | name = "image-path" 161 | value_type = str 162 | signature = "s" 163 | 164 | class Resident(HintABC): 165 | """ 166 | The server will not automatically remove the notification when an action has been 167 | invoked. The notification will remain resident in the server until it is explicitly removed 168 | by the user or by the sender. This hint is likely only useful when the server has the 169 | "persistence" capability. 170 | """ 171 | 172 | name = "resident" 173 | value_type = bool 174 | signature = "b" 175 | 176 | class SoundFile(HintABC): 177 | """ 178 | The path to a sound file to play when the notification pops up. 179 | """ 180 | 181 | name = "sound-file" 182 | value_type = str 183 | signature = "s" 184 | 185 | class SoundName(HintABC): 186 | """ 187 | A themeable named sound from the freedesktop.org sound naming specification to play when the 188 | notification pops up. Similar to icon-name, only for sounds. An example would be 189 | "message-new-instant". 190 | """ 191 | 192 | name = "sound-file" 193 | value_type = str 194 | signature = "s" 195 | 196 | class SuppressSound(HintABC): 197 | """ 198 | Causes the server to suppress playing any sounds, if it has that ability. This is usually 199 | set when the client itself is going to play its own sound. 200 | """ 201 | 202 | name = "suppress-sound" 203 | value_type = bool 204 | signature = "b" 205 | 206 | class Transient(HintABC): 207 | """ 208 | When set the server will treat the notification as transient and by-pass the server's 209 | persistence capability, if it should exist. 210 | """ 211 | 212 | name = "transient" 213 | value_type = bool 214 | signature = "b" 215 | 216 | class PositionX(HintABC): 217 | """ 218 | Specifies the X location on the screen that the notification should point to. The "y" hint 219 | must also be specified. 220 | """ 221 | 222 | name = "x" 223 | value_type = int 224 | signature = "i" 225 | 226 | class PositionY(HintABC): 227 | """ 228 | Specifies the Y location on the screen that the notification should point to. The "x" hint 229 | must also be specified. 230 | """ 231 | 232 | name = "y" 233 | value_type = int 234 | signature = "i" 235 | 236 | class Urgency(HintABC): 237 | """ 238 | The urgency level. 239 | 240 | Usage: Hints.Urgency(Urgency.LOW) 241 | """ 242 | 243 | name = "urgency" 244 | value_type = Urgency 245 | signature = "y" 246 | 247 | # Non-standard hints. All prependend with X and the vendor name. 248 | # For now, only Dunst ones, but in the future it might also accomodate hints for other servers. 249 | # 250 | # See: https://dunst-project.org/documentation 251 | 252 | class XDunstProgressBarValue(HintABC): 253 | """ 254 | Non-standard hint, used by Dunst. 255 | 256 | A progress bar will be drawn at the bottom of the notification. 257 | """ 258 | 259 | name = "value" 260 | value_type = int 261 | signature = "i" 262 | 263 | class XDunstFgColor(HintABC): 264 | """ 265 | Non-standard hint, used by Dunst. 266 | 267 | Foreground color in the format #RRGGBBAA. 268 | """ 269 | 270 | name = "fgcolor" 271 | value_type = str 272 | signature = "s" 273 | 274 | class XDunstBgColor(HintABC): 275 | """ 276 | Non-standard hint, used by Dunst. 277 | 278 | Background color in the format #RRGGBBAA. 279 | """ 280 | 281 | name = "bgcolor" 282 | value_type = str 283 | signature = "s" 284 | 285 | class XDunstFrColor(HintABC): 286 | """ 287 | Non-standard hint, used by Dunst. 288 | 289 | Frame color in the format #RRGGBBAA. 290 | """ 291 | 292 | name = "frcolor" 293 | value_type = str 294 | signature = "s" 295 | 296 | class XDunstHlColor(HintABC): 297 | """ 298 | Non-standard hint, used by Dunst. 299 | 300 | Highlight color (also sets the color of the progress bar) in the format #RRGGBBAA. 301 | """ 302 | 303 | name = "hlcolor" 304 | value_type = str 305 | signature = "s" 306 | 307 | class XDunstStackTag(HintABC): 308 | """ 309 | Non-standard hint, used by Dunst. 310 | 311 | Notifications with the same (non-empty) stack tag and the same appid will replace each-other 312 | so only the newest one is visible. This can be useful for example in volume or brightness 313 | notifications where you only want one of the same type visible. 314 | """ 315 | 316 | name = "x-dunst-stack-tag" 317 | value_type = str 318 | signature = "s" 319 | 320 | 321 | class Notify: 322 | """ 323 | Send desktop notifications according to the Freedesktop spec. 324 | See: https://specifications.freedesktop.org/notification-spec/notification-spec-latest.html 325 | """ 326 | 327 | def __init__(self): 328 | self.dbus_client = SessionDBusClient() 329 | 330 | async def notify( # pylint: disable=too-many-arguments,too-many-locals 331 | self, 332 | summary: str, 333 | body: str = "", 334 | urgency: Urgency = None, 335 | category: Category = None, 336 | transient: bool = False, 337 | dunst_progress_bar: int = -1, 338 | dunst_stack_tag: str = "", 339 | dunst_fg_color: str = "", 340 | dunst_bg_color: str = "", 341 | dunst_fr_color: str = "", 342 | dunst_hl_color: str = "", 343 | icon: str = "", 344 | expire_time_ms: int = -1, 345 | app_name: str = "", 346 | replaces_id: int = 0, 347 | hints: list[Hint] = None, 348 | actions: list[Action] = None, 349 | ) -> dict: 350 | """ 351 | Parse arguments and convert them to hints if applicable, then send the desktop notification 352 | using DBus. This is mostly a syntax suggar on top of `send()` 353 | """ 354 | if hints is None: 355 | hints = [] 356 | 357 | if urgency: 358 | hints.append(Hint.Urgency(urgency)) 359 | if category: 360 | hints.append(Hint.Category(category)) 361 | if transient: 362 | hints.append(Hint.Transient(transient)) 363 | if dunst_progress_bar >= 0: 364 | hints.append(Hint.XDunstProgressBarValue(dunst_progress_bar)) 365 | if dunst_stack_tag: 366 | hints.append(Hint.XDunstStackTag(dunst_stack_tag)) 367 | if dunst_fg_color: 368 | hints.append(Hint.XDunstFgColor(dunst_fg_color)) 369 | if dunst_bg_color: 370 | hints.append(Hint.XDunstBgColor(dunst_bg_color)) 371 | if dunst_fr_color: 372 | hints.append(Hint.XDunstFrColor(dunst_fr_color)) 373 | if dunst_hl_color: 374 | hints.append(Hint.XDunstHlColor(dunst_hl_color)) 375 | 376 | return await self.send( 377 | summary=summary, 378 | body=body, 379 | icon=icon, 380 | expire_time_ms=expire_time_ms, 381 | app_name=app_name, 382 | replaces_id=replaces_id, 383 | actions=actions, 384 | hints=hints, 385 | ) 386 | 387 | # Make this object callable by invoking notify 388 | __call__ = notify 389 | 390 | async def send( # pylint: disable=too-many-arguments 391 | self, 392 | summary: str, 393 | body: str = "", 394 | icon: str = "", 395 | expire_time_ms: int = -1, 396 | app_name: str = __name__, 397 | replaces_id: int = 0, 398 | actions: list[Action] = None, 399 | hints: list[Hint] = None, 400 | ) -> int: 401 | """ 402 | Send the notification to the Desktop Notifications Daemon via DBus 403 | """ 404 | if hints: 405 | hints = dict([hint.to_value() for hint in hints]) 406 | else: 407 | hints = {} 408 | 409 | if actions: 410 | actions = [action.to_value() for action in actions] 411 | else: 412 | actions = [] 413 | 414 | params = [ 415 | app_name, 416 | replaces_id, 417 | icon, 418 | summary, 419 | body, 420 | actions, 421 | hints, 422 | expire_time_ms, 423 | ] 424 | 425 | return await self.dbus_client.call_method( 426 | destination="org.freedesktop.Notifications", 427 | interface="org.freedesktop.Notifications", 428 | path="/org/freedesktop/Notifications", 429 | member="Notify", 430 | signature="susssasa{sv}i", 431 | body=params, 432 | ) 433 | -------------------------------------------------------------------------------- /wmcompanion/modules/polybar.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 Daniel Pereira 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | import asyncio 6 | import os 7 | import glob 8 | import struct 9 | import logging 10 | from contextlib import suppress 11 | from concurrent.futures import ThreadPoolExecutor 12 | from pathlib import Path 13 | from ..errors import WMCompanionError 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | class Polybar: 18 | """ 19 | Set of functionality to interact with polybar modules. Uses ipc functionality available on 20 | Polybar >= 3.5 to communicate with the daemon and change module values at runtime. 21 | 22 | To use this module properly, you can simply call `set_module_content()` and pass the module name 23 | and the content you want to override on Polybar. On Polybar config file, you must state that the 24 | given module is a `custom/ipc` as the example below: 25 | 26 | ```ini 27 | [module/eth] 28 | type = custom/ipc 29 | hook-0 = cat $XDG_RUNTIME_DIR/polybar/eth 2> /dev/null 30 | initial = 1 31 | ``` 32 | 33 | Observe how the hook path filename must match the module name to have better results and be able 34 | to properly restart Polybar while maintaining the value set by wmcompanion. 35 | """ 36 | def __init__(self): 37 | Path(f"{os.getenv('XDG_RUNTIME_DIR')}/polybar").mkdir(mode=0o700, exist_ok=True) 38 | 39 | def format(self, content: str, color: str = None) -> str: 40 | """ 41 | Formats a string with the proper tags. 42 | 43 | TODO: Add all format tags available for Polybar. 44 | See: https://github.com/polybar/polybar/wiki/Formatting#format-tags 45 | """ 46 | result = content 47 | if color: 48 | result = f"%{{F{color}}}{result}%{{F-}}" 49 | return result 50 | 51 | # Alias fmt as format 52 | fmt = format 53 | 54 | async def set_module_content(self, module: str, *content: list[str]): 55 | """ 56 | Set the value of a given Polybar module to the content provided. 57 | You can provide multiple strings as the content and they will be joined by spaces when 58 | rendered. 59 | """ 60 | content_str = " ".join(content) 61 | await self._write_module_content(module, content_str) 62 | await self._ipc_action(f"#{module}.send.{content_str}") 63 | 64 | # Make this object callable by invoking set_module_content 65 | __call__ = set_module_content 66 | 67 | async def _write_module_content(self, module: str, content: str): 68 | """ 69 | Set the value of the content statically so that when polybar restarts it can pick up the 70 | value previously set 71 | """ 72 | def sync_io(): 73 | module_path = f"{os.getenv('XDG_RUNTIME_DIR')}/polybar/{module}" 74 | with open(module_path, "w", encoding="utf-8") as out: 75 | out.write(content) 76 | 77 | # REFACTOR: This is a copy of the same method on `event_listening.EventListener` 78 | with ThreadPoolExecutor(max_workers=1) as executor: 79 | await asyncio.get_running_loop().run_in_executor(executor, sync_io) 80 | 81 | async def _ipc_action(self, cmd: str): 82 | """ 83 | Replicates the behavior of polybar-msg action 84 | """ 85 | payload = bytes(cmd, "utf-8") 86 | ipc_version = 0 87 | msg_type = 2 88 | data = ( 89 | b"polyipc" # magic 90 | + struct.pack("=BIB", ipc_version, len(payload), msg_type) # version, length, type 91 | + payload 92 | ) 93 | 94 | for name in glob.glob(f"{os.getenv('XDG_RUNTIME_DIR')}/polybar/*.sock"): 95 | try: 96 | with suppress(ConnectionError): 97 | reader, writer = await asyncio.open_unix_connection(name) 98 | 99 | # Write to the file 100 | writer.write(data) 101 | await writer.drain() 102 | logger.debug("polybar action sent to socket %s: %s", name, payload) 103 | 104 | # Then close it 105 | await reader.read() 106 | writer.close() 107 | await writer.wait_closed() 108 | except Exception as err: 109 | raise WMCompanionError(f"Failed to connect to unix socket {name}") from err 110 | -------------------------------------------------------------------------------- /wmcompanion/object_container.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 Daniel Pereira 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | from .errors import WMCompanionError 6 | 7 | 8 | class ObjectContainerError(WMCompanionError): 9 | """ 10 | Base exception class for all object_container module based errors 11 | """ 12 | 13 | 14 | class ObjectContainer: 15 | """ 16 | A minimalist implementation of an IoC container for managing dependencies at runtime. 17 | """ 18 | 19 | def __init__(self): 20 | self.objects = {} 21 | 22 | def register(self, value: any, name: str | type = None): 23 | """ 24 | Adds a new object to the container. If no name is given, then it tries to guess a name for 25 | that object, but if not possible then an exception is raised instead. 26 | """ 27 | if not name: 28 | # If it's a class, then register it directly 29 | if isinstance(value, type): 30 | name = value.__name__ 31 | value = value() # We always instantiate it without args 32 | else: 33 | type_name = type(value).__name__ 34 | raise ObjectContainerError( 35 | f"You must provide a name to register this object type ({type_name})." 36 | ) 37 | 38 | self.objects[name] = value 39 | 40 | def get(self, dependency: str | type): 41 | """ 42 | Fetch an object which name matches the specified argument. If there's no object available 43 | for that name, it registers them before usage. 44 | Raises an exception if such name is not found. 45 | """ 46 | if hasattr(dependency, "__name__"): 47 | lookup = dependency.__name__ 48 | else: 49 | lookup = dependency 50 | 51 | if lookup not in self.objects: 52 | try: 53 | self.register(dependency) 54 | except ObjectContainerError as err: 55 | raise ObjectContainerError( 56 | f"Object named {lookup} was not found in the container." 57 | ) from err 58 | 59 | return self.objects[lookup] 60 | -------------------------------------------------------------------------------- /wmcompanion/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kriansa/wmcompanion/051f6b90dc367a94cb860c85590030d535ede1db/wmcompanion/utils/__init__.py -------------------------------------------------------------------------------- /wmcompanion/utils/dbus_client.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 Daniel Pereira 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 AND MIT 4 | 5 | import logging 6 | 7 | # pylint: disable-next=unused-import 8 | from dbus_next import Message, Variant 9 | from dbus_next.aio import MessageBus 10 | from dbus_next.constants import BusType, MessageType 11 | 12 | logger = logging.getLogger(__package__) 13 | 14 | 15 | class DBusClientError(Exception): 16 | """ 17 | Base error class for DBus related exceptions 18 | """ 19 | 20 | 21 | class DBusClient: 22 | """ 23 | Base DBus client class 24 | """ 25 | 26 | def __init__(self, bus_type: BusType): 27 | self.bus_type = bus_type 28 | self.bus = None 29 | 30 | async def connect(self) -> None: 31 | """ 32 | Connects to DBus allowing this instance to call methods 33 | """ 34 | if self.bus: 35 | return 36 | 37 | try: 38 | self.bus = await MessageBus(bus_type=self.bus_type).connect() 39 | except Exception as err: 40 | raise DBusClientError("Unable to connect to dbus.") from err 41 | 42 | async def disconnect(self) -> None: 43 | """ 44 | Disconnects from an existing DBus connection. 45 | """ 46 | if self.bus: 47 | self.bus.disconnect() 48 | 49 | # pylint: disable-next=too-many-arguments 50 | async def add_signal_receiver( 51 | self, 52 | callback: callable, 53 | signal_name: str | None = None, 54 | dbus_interface: str | None = None, 55 | bus_name: str | None = None, 56 | path: str | None = None, 57 | ) -> bool: 58 | """ 59 | Helper function which aims to recreate python-dbus's add_signal_receiver 60 | method in dbus_next with asyncio calls. 61 | Returns True if subscription is successful. 62 | """ 63 | match_args = { 64 | "type": "signal", 65 | "sender": bus_name, 66 | "member": signal_name, 67 | "path": path, 68 | "interface": dbus_interface, 69 | } 70 | 71 | rule = ",".join(f"{k}='{v}'" for k, v in match_args.items() if v) 72 | 73 | try: 74 | await self.call_method( 75 | "org.freedesktop.DBus", 76 | "/org/freedesktop/DBus", 77 | "org.freedesktop.DBus", 78 | "AddMatch", 79 | "s", 80 | rule, 81 | ) 82 | except DBusClientError: 83 | # Check if message sent successfully 84 | logger.warning("Unable to add watch for DBus events (%s)", rule) 85 | return False 86 | 87 | def message_handler(message): 88 | if message.message_type != MessageType.SIGNAL: 89 | return 90 | 91 | callback(*message.body, dbus_message=message) 92 | 93 | self.bus.add_message_handler(message_handler) 94 | return True 95 | 96 | # pylint: disable-next=too-many-arguments 97 | async def call_method( 98 | self, 99 | destination: str, 100 | path: str, 101 | interface: str, 102 | member: str, 103 | signature: str, 104 | body: any, 105 | ) -> any: 106 | """ 107 | Calls any available DBus method and return its value 108 | """ 109 | msg = await self._send_dbus_message( 110 | MessageType.METHOD_CALL, 111 | destination, 112 | interface, 113 | path, 114 | member, 115 | signature, 116 | body, 117 | ) 118 | 119 | if msg is None or msg.message_type != MessageType.METHOD_RETURN: 120 | raise DBusClientError(f"Unable to call method on dbus: {msg.error_name}") 121 | 122 | match len(msg.body): 123 | case 0: 124 | return None 125 | case 1: 126 | return msg.body[0] 127 | case _: 128 | return msg.body 129 | 130 | # pylint: disable-next=too-many-arguments 131 | async def _send_dbus_message( 132 | self, 133 | message_type: MessageType, 134 | destination: str | None, 135 | interface: str | None, 136 | path: str | None, 137 | member: str | None, 138 | signature: str, 139 | body: any, 140 | ) -> Message | None: 141 | """ 142 | Private method to send messages to dbus via dbus_next. 143 | Returns a tuple of the bus object and message response. 144 | """ 145 | 146 | if isinstance(body, str): 147 | body = [body] 148 | 149 | await self.connect() 150 | 151 | # Ignore types here: dbus-next has default values of `None` for certain 152 | # parameters but the signature is `str` so passing `None` results in an 153 | # error in mypy. 154 | return await self.bus.call( 155 | Message( 156 | message_type=message_type, 157 | destination=destination, # type: ignore 158 | interface=interface, # type: ignore 159 | path=path, # type: ignore 160 | member=member, # type: ignore 161 | signature=signature, 162 | body=body, 163 | ) 164 | ) 165 | 166 | 167 | class SessionDBusClient(DBusClient): 168 | """ 169 | Session DBus client 170 | """ 171 | 172 | def __init__(self): 173 | super().__init__(BusType.SESSION) 174 | 175 | 176 | class SystemDBusClient(DBusClient): 177 | """ 178 | System DBus client 179 | """ 180 | 181 | def __init__(self): 182 | super().__init__(BusType.SYSTEM) 183 | -------------------------------------------------------------------------------- /wmcompanion/utils/inotify_simple.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 Daniel Pereira 2 | # Copyright (c) 2016 Chris Billington 3 | # 4 | # SPDX-License-Identifier: Apache-2.0 AND BSD-2-Clause 5 | 6 | # This small inotify library is based on the excellent `inotify_simple` available at 7 | # `chrisjbillington/inotify_simple` on Github, commit: 5573789 at Jun 20, 2022. 8 | # 9 | # In general, I removed support for past Python versions and removed support for an IO selector, 10 | # so that if you want such feature you need to do it externally. In short, this is even simpler than 11 | # what the original lib proposed. 12 | # 13 | # See: https://github.com/chrisjbillington/inotify_simple 14 | # pylint: disable=invalid-name 15 | 16 | from os import fsencode, fsdecode, read, strerror, O_CLOEXEC 17 | from enum import IntEnum 18 | from collections import namedtuple 19 | from struct import unpack_from, calcsize 20 | from ctypes import CDLL, get_errno, c_int 21 | from ctypes.util import find_library 22 | from errno import EINTR 23 | from termios import FIONREAD 24 | from fcntl import ioctl 25 | from io import FileIO 26 | 27 | _libc = None 28 | 29 | 30 | def _libc_call(function, *args): 31 | """Wrapper which raises errors and retries on EINTR.""" 32 | while True: 33 | return_code = function(*args) 34 | if return_code != -1: 35 | return return_code 36 | errno = get_errno() 37 | if errno != EINTR: 38 | raise OSError(errno, strerror(errno)) 39 | 40 | 41 | #: A ``namedtuple`` (wd, mask, cookie, name) for an inotify event. The 42 | #: :attr:`~inotify_simple.Event.name` field is a ``str`` decoded with 43 | #: ``os.fsdecode()`` 44 | Event = namedtuple("Event", ["wd", "mask", "cookie", "name"]) 45 | 46 | _EVENT_FMT = "iIII" 47 | _EVENT_SIZE = calcsize(_EVENT_FMT) 48 | 49 | 50 | class INotify(FileIO): 51 | """ 52 | This is a simple FileIO wrapper around OS native inotify system calls. 53 | 54 | Attempting to read from `INotify.fileno()` using `os.read()` will ocasionally raise a 55 | ``BlockingIOError`` if there's no data available to read from. 56 | """ 57 | 58 | #: The inotify file descriptor returned by ``inotify_init()``. You are 59 | #: free to use it directly with ``os.read`` if you'd prefer not to call 60 | #: :func:`~inotify_simple.INotify.read` for some reason. Also available as 61 | #: :func:`~inotify_simple.INotify.fileno` 62 | fd = property(FileIO.fileno) 63 | 64 | def __init__(self, inheritable=False): 65 | """File-like object wrapping ``inotify_init1()``. Raises ``OSError`` on failure. 66 | :func:`~inotify_simple.INotify.close` should be called when no longer needed. 67 | Can be used as a context manager to ensure it is closed, and can be used 68 | directly by functions expecting a file-like object, such as ``select``, or with 69 | functions expecting a file descriptor via 70 | :func:`~inotify_simple.INotify.fileno`. 71 | 72 | Args: 73 | inheritable (bool): whether the inotify file descriptor will be inherited by 74 | child processes. The default,``False``, corresponds to passing the 75 | ``IN_CLOEXEC`` flag to ``inotify_init1()``. Setting this flag when 76 | opening filedescriptors is the default behaviour of Python standard 77 | library functions since PEP 446. 78 | 79 | nonblocking (bool): whether to open the inotify file descriptor in 80 | nonblocking mode, corresponding to passing the ``IN_NONBLOCK`` flag to 81 | ``inotify_init1()``. This does not affect the normal behaviour of 82 | :func:`~inotify_simple.INotify.read`, which uses ``poll()`` to control 83 | blocking behaviour according to the given timeout, but will cause other 84 | reads of the file descriptor (for example if the application reads data 85 | manually with ``os.read(fd)``) to raise ``BlockingIOError`` if no data 86 | is available.""" 87 | try: 88 | libc_so = find_library("c") 89 | except RuntimeError: # Python on Synology NASs raises a RuntimeError 90 | libc_so = None 91 | global _libc # pylint: disable=global-statement 92 | _libc = _libc or CDLL(libc_so or "libc.so.6", use_errno=True) 93 | flags = (not inheritable) * O_CLOEXEC 94 | FileIO.__init__(self, _libc_call(_libc.inotify_init1, flags), mode="rb") 95 | 96 | def add_watch(self, path, mask): 97 | """Wrapper around ``inotify_add_watch()``. Returns the watch 98 | descriptor or raises an ``OSError`` on failure. 99 | 100 | Args: 101 | path (str, bytes, or PathLike): The path to watch. Will be encoded with 102 | ``os.fsencode()`` before being passed to ``inotify_add_watch()``. 103 | 104 | mask (int): The mask of events to watch for. Can be constructed by 105 | bitwise-ORing :class:`~inotify_simple.Flags` together. 106 | 107 | Returns: 108 | int: watch descriptor""" 109 | return _libc_call(_libc.inotify_add_watch, self.fileno(), fsencode(path), mask) 110 | 111 | def rm_watch(self, wd): 112 | """Wrapper around ``inotify_rm_watch()``. Raises ``OSError`` on failure. 113 | 114 | Args: 115 | wd (int): The watch descriptor to remove""" 116 | _libc_call(_libc.inotify_rm_watch, self.fileno(), wd) 117 | 118 | def read(self): 119 | """Read the inotify file descriptor and return the resulting 120 | :attr:`~inotify_simple.Event` namedtuples (wd, mask, cookie, name). 121 | 122 | It might return an empty list if nothing is available for reading at the time, as this is a 123 | non-blocking read. Use a `selector` if you need to know when there's data to read from. 124 | 125 | Returns: 126 | list: list of :attr:`~inotify_simple.Event` namedtuples""" 127 | data = self._readbytes() 128 | if not data: 129 | return [] 130 | return parse_events(data) 131 | 132 | def _readbytes(self): 133 | bytes_avail = c_int() 134 | ioctl(self.fileno(), FIONREAD, bytes_avail) 135 | if not bytes_avail.value: 136 | return b"" 137 | return read(self.fileno(), bytes_avail.value) 138 | 139 | 140 | def parse_events(data): 141 | """Unpack data read from an inotify file descriptor into 142 | :attr:`~inotify_simple.Event` namedtuples (wd, mask, cookie, name). This function 143 | can be used if the application has read raw data from the inotify file 144 | descriptor rather than calling :func:`~inotify_simple.INotify.read`. 145 | 146 | Args: 147 | data (bytes): A bytestring as read from an inotify file descriptor. 148 | 149 | Returns: 150 | list: list of :attr:`~inotify_simple.Event` namedtuples""" 151 | pos = 0 152 | events = [] 153 | while pos < len(data): 154 | wd, mask, cookie, namesize = unpack_from(_EVENT_FMT, data, pos) 155 | pos += _EVENT_SIZE + namesize 156 | name = data[pos - namesize : pos].split(b"\x00", 1)[0] 157 | events.append(Event(wd, mask, cookie, fsdecode(name))) 158 | return events 159 | 160 | 161 | class Flags(IntEnum): 162 | """Inotify flags as defined in ``inotify.h`` but with ``IN_`` prefix omitted. 163 | Includes a convenience method :func:`~inotify_simple.Flags.from_mask` for extracting 164 | flags from a mask.""" 165 | 166 | ACCESS = 0x00000001 #: File was accessed 167 | MODIFY = 0x00000002 #: File was modified 168 | ATTRIB = 0x00000004 #: Metadata changed 169 | CLOSE_WRITE = 0x00000008 #: Writable file was closed 170 | CLOSE_NOWRITE = 0x00000010 #: Unwritable file closed 171 | OPEN = 0x00000020 #: File was opened 172 | MOVED_FROM = 0x00000040 #: File was moved from X 173 | MOVED_TO = 0x00000080 #: File was moved to Y 174 | CREATE = 0x00000100 #: Subfile was created 175 | DELETE = 0x00000200 #: Subfile was deleted 176 | DELETE_SELF = 0x00000400 #: Self was deleted 177 | MOVE_SELF = 0x00000800 #: Self was moved 178 | 179 | UNMOUNT = 0x00002000 #: Backing fs was unmounted 180 | Q_OVERFLOW = 0x00004000 #: Event queue overflowed 181 | IGNORED = 0x00008000 #: File was ignored 182 | 183 | ONLYDIR = 0x01000000 #: only watch the path if it is a directory 184 | DONT_FOLLOW = 0x02000000 #: don't follow a sym link 185 | EXCL_UNLINK = 0x04000000 #: exclude events on unlinked objects 186 | MASK_ADD = 0x20000000 #: add to the mask of an already existing watch 187 | ISDIR = 0x40000000 #: event occurred against dir 188 | ONESHOT = 0x80000000 #: only send event once 189 | 190 | @classmethod 191 | def from_mask(cls, mask): 192 | """Convenience method that returns a list of every flag in a mask.""" 193 | return [flag for flag in cls.__members__.values() if flag & mask] 194 | 195 | 196 | class Masks(IntEnum): 197 | """Convenience masks as defined in ``inotify.h`` but with ``IN_`` prefix omitted.""" 198 | 199 | #: helper event mask equal to ``Flags.CLOSE_WRITE | Flags.CLOSE_NOWRITE`` 200 | CLOSE = Flags.CLOSE_WRITE | Flags.CLOSE_NOWRITE 201 | #: helper event mask equal to ``Flags.MOVED_FROM | Flags.MOVED_TO`` 202 | MOVE = Flags.MOVED_FROM | Flags.MOVED_TO 203 | 204 | #: bitwise-OR of all the events that can be passed to 205 | #: :func:`~inotify_simple.INotify.add_watch` 206 | ALL_EVENTS = ( 207 | Flags.ACCESS 208 | | Flags.MODIFY 209 | | Flags.ATTRIB 210 | | Flags.CLOSE_WRITE 211 | | Flags.CLOSE_NOWRITE 212 | | Flags.OPEN 213 | | Flags.MOVED_FROM 214 | | Flags.MOVED_TO 215 | | Flags.CREATE 216 | | Flags.DELETE 217 | | Flags.DELETE_SELF 218 | | Flags.MOVE_SELF 219 | ) 220 | -------------------------------------------------------------------------------- /wmcompanion/utils/process.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 Daniel Pereira 2 | # 3 | # SPDX-License-Identifier: Apache-2.0 4 | 5 | import asyncio 6 | import asyncio.subprocess 7 | from typing import Coroutine 8 | from datetime import datetime, timedelta 9 | from logging import getLogger 10 | 11 | logger = getLogger(__name__) 12 | 13 | 14 | class ProcessWatcher: # pylint: disable=too-many-instance-attributes 15 | """ 16 | Watches a process asynchronously, restarting it if it ends unexpectedly, and restarting it 17 | automatically if `restart_every` is set. 18 | """ 19 | 20 | def __init__( 21 | self, 22 | exec_args: list[str], 23 | restart_every: int | None = None, 24 | retries: int = 5, 25 | retry_threshold_seconds: int = 30, 26 | ): 27 | self.exec_args = exec_args 28 | self.restart_every = restart_every 29 | self.retries = retries 30 | self.retry_threshold_seconds = retry_threshold_seconds 31 | self.loop_tasks = set() 32 | self.stopped = False 33 | self.retry_attempts = 0 34 | self.last_restarted_at = 0 35 | self.start_callback = lambda: None 36 | self.failure_callback = lambda: None 37 | self.proc = None 38 | 39 | def on_start(self, callback: Coroutine): 40 | """ 41 | Sets a callback to be called when the process starts 42 | """ 43 | self.start_callback = callback 44 | 45 | def on_failure(self, callback: Coroutine): 46 | """ 47 | Sets a callback to be called when the process execution fails 48 | """ 49 | self.failure_callback = callback 50 | 51 | async def start(self): 52 | """ 53 | Starts the process, then automatically restarts it on the specified timeout or when there's 54 | a failure 55 | """ 56 | self.proc = await asyncio.create_subprocess_exec( 57 | "/usr/bin/env", 58 | *self.exec_args, 59 | stdout=asyncio.subprocess.PIPE, 60 | stderr=asyncio.subprocess.PIPE, 61 | ) 62 | 63 | logger.debug("Process %s started.", self.exec_args[0]) 64 | self.stopped = False 65 | 66 | self._add_to_loop(self.start_callback(self.proc)) 67 | self._add_to_loop(self._auto_restart()) 68 | self._add_to_loop(self._watch()) 69 | 70 | async def stop(self): 71 | """ 72 | Stops the process and the automatic restart watcher 73 | """ 74 | # `watch()` is holding `proc.wait()`, therefore as soon as the process is killed it will 75 | # understand as the process died if we don't let it know that we purposefully killed it and 76 | # it should not restart it. 77 | self.stopped = True 78 | 79 | self.proc.kill() 80 | await self.proc.wait() 81 | logger.debug("Process %s stopped.", self.exec_args[0]) 82 | 83 | async def restart(self): 84 | """ 85 | Restarts the process 86 | """ 87 | await self.stop() 88 | await self.start() 89 | 90 | async def _watch(self): 91 | await self.proc.wait() 92 | 93 | # Reset restart ticks if it's over the threshold 94 | restart_delta = datetime.now() - timedelta(seconds=self.retry_threshold_seconds) 95 | if self.last_restarted_at and self.last_restarted_at <= restart_delta: 96 | self.retry_attempts = 0 97 | 98 | # Checks if the process died because we wanted it to (by calling `stop()`) then don't 99 | # try to restart it. 100 | if self.stopped: 101 | return 102 | 103 | if self.retry_attempts < self.retries: 104 | self.retry_attempts += 1 105 | logger.warning( 106 | "Process %s died unexpectedly. Restarting... (%s/%s)", 107 | self.exec_args[0], 108 | self.retry_attempts, 109 | self.retries, 110 | ) 111 | await asyncio.sleep(2**self.retry_attempts) 112 | self.last_restarted_at = datetime.now() 113 | await self.start() 114 | else: 115 | logger.error( 116 | "Process %s has reached the restart threshold...", 117 | self.exec_args[0], 118 | ) 119 | await self.failure_callback() 120 | 121 | def _add_to_loop(self, coro: Coroutine): 122 | task = asyncio.get_running_loop().create_task(coro) 123 | # Add the coroutine to the set, creating a strong reference and preventing it from being 124 | # garbage-collected before it's finished 125 | self.loop_tasks.add(task) 126 | # But then ensure we clear its reference after it's done 127 | task.add_done_callback(self.loop_tasks.discard) 128 | 129 | async def _auto_restart(self): 130 | if not self.restart_every: 131 | return 132 | 133 | await asyncio.sleep(self.restart_every) 134 | logger.debug("Automatically restarting process %s...", self.exec_args[0]) 135 | await self.restart() 136 | 137 | 138 | async def cmd( 139 | command: str, *args: list[str], env: dict = None, output_encoding: str = "utf-8" 140 | ): 141 | """ 142 | Run a command in the existing thread event loop and return its return code and outputs. 143 | """ 144 | proc = await asyncio.create_subprocess_exec( 145 | command, 146 | *args, 147 | env=env, 148 | stdout=asyncio.subprocess.PIPE, 149 | stderr=asyncio.subprocess.PIPE, 150 | ) 151 | 152 | stdout, stderr = await proc.communicate() 153 | 154 | if proc.returncode != 0: 155 | logger.warning( 156 | "Process '%s' returned %i: %s", 157 | command, 158 | proc.returncode, 159 | stderr.decode(output_encoding).strip(), 160 | ) 161 | 162 | return dict( 163 | rc=proc.returncode, 164 | stderr=stderr.decode(output_encoding), 165 | stdout=stdout.decode(output_encoding), 166 | ) 167 | 168 | 169 | async def shell(command: str, env: dict = None, output_encoding: str = "utf-8"): 170 | """ 171 | Run a shell command in the existing thread event loop and return its return code and outputs. 172 | """ 173 | proc = await asyncio.create_subprocess_shell( 174 | command, 175 | env=env, 176 | stdout=asyncio.subprocess.PIPE, 177 | stderr=asyncio.subprocess.PIPE, 178 | ) 179 | 180 | stdout, stderr = await proc.communicate() 181 | 182 | if proc.returncode != 0: 183 | logger.warning( 184 | "Shell command '%s' returned %i: %s", 185 | command, 186 | proc.returncode, 187 | stderr.decode(output_encoding).strip(), 188 | ) 189 | 190 | return dict( 191 | rc=proc.returncode, 192 | stderr=stderr.decode(output_encoding), 193 | stdout=stdout.decode(output_encoding), 194 | ) 195 | --------------------------------------------------------------------------------