├── .eslintrc.json ├── .gitignore ├── README.md ├── build.sh ├── chromecast └── endpoints.txt ├── extension.js ├── icons ├── ceiling-light.svg ├── fan.svg ├── hass-blue.png ├── hass-symbolic.svg ├── media-player.svg ├── palette.svg ├── run.svg ├── script-text.svg ├── secondary.png └── toggle-switch-outline.svg ├── metadata.json ├── po ├── de.po ├── es.po ├── fr.mo ├── fr.po ├── hass-gshell.pot └── sk.po ├── prefs.js ├── schemas ├── gschemas.compiled └── org.gnome.shell.extensions.hass-data.gschema.xml ├── screenshots ├── general_settings.png ├── hass_events.png ├── hass_events_40.png ├── panel_icons.png ├── panel_icons_40.png ├── panel_menu.png ├── panel_menu_40.png ├── preferences_menu.png ├── preferences_menu_up.png └── togglable_settings.png ├── settings.js ├── stylesheet.css └── utils.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": "eslint:recommended", 7 | "parserOptions": { 8 | "ecmaVersion": "latest" 9 | }, 10 | "rules": { 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | data/ 2 | myextensions.code-workspace 3 | locale -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simple Gnome Shell Extension for Home Assistant 2 | 3 | This is a simple gnome shell extension which allows you to control your home assistant setup from your gnome desktop. 4 | 5 | Currently, the extension supports temperature (and humidity) sensors, toggling lights and switches and turning on scenes and scripts. 6 | In addition, you can also use this extension in order to send `start`, `stop` or `close` events to your Home Assistant instance. 7 | 8 | ## Contents 9 | 10 | - [Installation](#installation) 11 | - [Direct Install (Recommended)](#direct-install) 12 | - [Installing from Source](#installing-from-source) 13 | - [Installing from Gnome Extensions](#installing-from-gnome-extensions) 14 | - [How to Use](#how-to-use) 15 | - [Manage your Preferences](#manage-your-preferences) 16 | - [Authentication](#authentication) 17 | - [Appearance](#appearance) 18 | - [Panel Appearance](#panel-appearance) 19 | - [Opening the Menu](#opening-the-menu) 20 | - [Preferences (Settings)](#preferences-settings) 21 | - [Changing the Togglables](#changing-the-togglables) 22 | - [Security](#security) 23 | - [Updating](#updating) 24 | - [Removing the Extension](#removing-the-extension) 25 | - [Feature Requests](#feature-requests) 26 | - [Notes](#notes) 27 | - [Credits](#credits) 28 | 29 | 30 | ## Installation 31 | 32 | ### Direct Install 33 | 34 | The script `build.sh` aims at helping you download and install the extension. Its default behavior will simply download the latest release corresponding to your gnome version. If you want to have the latest changes directly from the `master` branch then you can provide the `--latest` argument. Example usage is shown below: 35 | 36 | ```bash 37 | # Download build.sh and give execution rights 38 | wget https://raw.githubusercontent.com/geoph9/hass-gshell-extension/master/build.sh && chmod +x build.sh 39 | 40 | # Download and install hass-gshell@geoph9-on-github from the releases 41 | ./build.sh 42 | 43 | # # Download and install the latest version directly from master. 44 | # # This makes it a bit more possible to encounter some bug. 45 | # # If you do so, please make an issue on github! 46 | # ./build.sh --latest # or simply -l 47 | 48 | # # Get help message 49 | # ./build.sh -h 50 | 51 | # Delete the build.sh script since you no longer need it. 52 | rm ./build.sh 53 | ``` 54 | 55 | After that, you will have to restart your session (e.g. `Alt+F2` -> `r+Enter` on Xorg or simply logout and re-login on Wayland) and then you will 56 | need to enable the extension. The enabling part can be done either from the terminal (`gnome-extensions enable hass-gshell@geoph9-on-github`) or 57 | from an app such as `Extensions` (available as a flatpak) or from the [`Gnome Extensions` website](https://extensions.gnome.org/). 58 | 59 | **NOTE:** The script simply downloads the extension's files either from github or fromt he realeases page. You can check that yourself. If you still don't trust running the script, then you follow the steps below. 60 | 61 | ### Installing from Source 62 | 63 | In order to install the extension you will have to clone this repository and move it under the directory where your other extensions are. The following commands should make it work: 64 | 65 | ```bash 66 | # Create the extensions directory in case it doesn't exist 67 | mkdir -p "$HOME"/.local/share/gnome-shell/extensions 68 | git clone https://github.com/geoph9/hass-gshell-extension.git "$HOME"/.local/share/gnome-shell/extensions/hass-gshell@geoph9-on-github 69 | ``` 70 | 71 | Then open Gnome Tweaks (or the Extensions app on Gnome >=40) and enable the extension. 72 | 73 | **Note:** Ubuntu 21.04 does not ship with `Gnome 40` and so you will still need to use the `Gnome 3.38` version. You can install that by using the `build.sh` script above, with the default settings. 74 | 75 | ### Installing from Gnome Extensions 76 | 77 | The extension is also available at the [Gnome Extensions (e.g.o.)](https://extensions.gnome.org/extension/4170/home-assistant-extension/) website 78 | with the name *Home Assistant Extension*. 79 | 80 | The versions there will not be updated very often and so you may miss some features if you choose to use the e.g.o. website. That is why, the recommended [installation method is from the release page](#installing-from-releases). 81 | 82 | ## How to Use 83 | 84 | ### Manage your Preferences 85 | 86 | After installing the extension, you can use the preferences widget in order to customize it. In order to do that, you can open the panel menu by pressing on the home assistant tray icon and then press `Preferences`. 87 | 88 | ### Authentication 89 | 90 | In order to communicate with `Home Assistant` you will need to provide a `Long Live Access Token` which you can get from your *dashboard → profile (on the bottom left) → Long Live Access Tokens (on the bottom of the page)→ Create Token*. 91 | 92 | After that, copy the token and add it in the in the text box below the `Access Token:` entry on the preferences menu. 93 | 94 | In addition, you need to provide the url of your hass instance on the `URL` box. 95 | 96 | ## Appearance 97 | 98 | ### Panel Appearance 99 | 100 | The panel will contain the following 2 entries (after configuring the temperature). *Note: The red line is there just to emphasize the icons.* 101 | 102 | ![Panel Appearance](screenshots/panel_icons_40.png?raw=true "How the panel icons appear.") 103 | 104 | By pressing the temperature buttons you can refresh the temperature. 105 | 106 | **Note: You can change the panel icon from the preferences menu. Currently only a blue and a white icon is supported. The white icon is the default.** 107 | 108 | ### Opening the Menu 109 | 110 | If you click the home assistant icon, you will get the following: 111 | 112 | ![Hass Opened](screenshots/panel_menu_40.png?raw=true "How the panel menu appears.") 113 | 114 | In this example, I have added 2 togglable entities that control my kitchen lights and my TV power. By pressing any of these buttons, its state will toggle. The names of these entries are taken from home assistant. 115 | 116 | **NOTE:** The menu can also be opened (toglled)by using the `Super+G` shortcut.This may make it easier for you to toggle something without using the mouse/touchpad. It is not possible (currently) to change this shortcut key (unless you change the schema file and re-compile it or use something like dconf). **UPDATE: This feature is removed now.** 117 | 118 | 119 | #### Home Assistant Events 120 | 121 | By pressing `Hass Events` a new sublist will appear: 122 | 123 | 124 | ![Hass Events](screenshots/hass_events_40.png?raw=true "How the hass events appear.") 125 | 126 | ### Preferences (Settings) 127 | 128 | **NOTE:** This is for Gnome >=40. Gnome 3.38 has a different menu but with similar functionality. 129 | 130 | By pressing the `Preferences` button you will get the following: 131 | 132 | 133 | ![Preferences](screenshots/general_settings.png?raw=true "How the preferences/settings appear.") 134 | 135 | Currently, there are four pages. Generic settings, togglables (lights/switches), runnables (scenes/scripts) and sensors. 136 | In the general settings, you are prompted to enter the URL and Long-Live Access Token of your Home Assistant instance. 137 | 138 | The rest of the options are self-describing. About the temperature and humidity id, they are only needed if the 139 | `Show Temperature/Humidity` and `Show Humidity` switches are on. Otherwise, you can still use the extension by 140 | using only the toggles or runnables. Theoretically, you can put any kind of sensor in these spots 141 | (but I haven't tested any other kind of sensor). 142 | 143 | **Note:** The `Hass White Icon` is the default Icon option and it is the classical home-assistant icon without any color. This integrates better with the rest of the icons in your panel. In the screenshots above I am using the `Hass Blue Icon`. 144 | 145 | **Note:** The Long Live Access Token can be obtained by going to your Home Assistant dashboard, then to your profile (on the bottom) and then go to the bottom of the page and create a new Long Live Access Token. More information about it [on the oficial Home Assistant website](https://developers.home-assistant.io/docs/auth_api/#long-lived-access-token). 146 | 147 | **Note:** The options to refresh the temperature/humidity statistics are currently not working. 148 | 149 | ### Changing the Togglables and Runnables 150 | 151 | If you click the `Togglables` page on the side you will get the following: 152 | 153 | ![Preferences](screenshots/togglable_settings.png?raw=true "How the togglable settings appear.") 154 | 155 | The extension will scan your home assistant instance in order to find all of the entities that are either switches or lights. These entities will be listed here. In my case, I only have 156 | two togglable entities and I use both of them. If I unchecked the switch.kitchen_lights entry then the option would also be removed from the extension's panel. 157 | 158 | By default, all togglables will appear. If you only want a subset of the switches, you can do that here. 159 | 160 | The same applies to the `Runnables` page with scene and script entities. 161 | 162 | ## Security 163 | 164 | I am using the `Secret` library in order to store the access token in the running secret service (like gnome-keyring or ksecretservice) [source](https://developer.gnome.org/libsecret/unstable/js-store-example.html). This makes it safer to use your access token since it is more difficult to have it stolen by a third party. So, your token is as safe as your current user (this means that if a third party knows your user password and has access to your machine then they can theoretically get the token, but if that is the case then you probably have more improtant things to worry about). 165 | 166 | In general, if you think that you have an exposed access token, then you should go to your profile and delete it. Pay attention to this especially if you are hosting your instance on the internet (and not locally). 167 | 168 | ## Updating 169 | 170 | If you installed the extension from the [release page](https://github.com/geoph9/hass-gshell-extension/releases), you should simply re-run the the `build.sh` script. 171 | 172 | If you installed from source, then you will have to pull the changes from the master branch as follows: 173 | 174 | ```bash 175 | cd $HOME/.local/share/gnome-shell/extensions/hass-gshell@geoph9-on-github && git pull origin master 176 | ``` 177 | 178 | If you installed from the [Gnome Extensions (e.g.o.)](https://extensions.gnome.org/extension/4170/home-assistant-extension/) website and there is an update available, then you will be prompted to update whenever you visit the website. 179 | 180 | ## Removing the Extension 181 | 182 | If you followed the installation instructions above then you can do the following: 183 | 184 | ```bash 185 | rm -rf $HOME/.local/share/gnome-shell/extensions/hass-gshell@geoph9-on-github 186 | ``` 187 | 188 | You will also have to restart your session in order to have the panel buttons dissapear. 189 | 190 | ## Feature Requests 191 | 192 | For feature requests please create a new issue and describe what you want. I will be happy to try and implement it (or you can implement it yourself in then make a PR). 193 | 194 | ## Notes: 195 | 196 | 1. Before starting check the preferences page by opening the widget (pressing the home assistant button on the panel) and pressing `Preferences`. There you can add as many (valid) entities as you want. 197 | 2. On Gnome 3.38, the entities **MUST** include a dot (`.`) and at least one underscore (`_`). For example, an entity id could be: `switch.kitchen_lights_relay`. 198 | 3. On Gnome 3.38, changing the preferences doesn't update togglable entities and you will need to restart your session for the changes to take effect. 199 | - On `Xorg` you can do that by pressing `Alt+F2` and then `r`. 200 | - On `Wayland` you will have to logout and re-login. 201 | 202 | If you are unsure about whether you have `Xorg` or `Wayland` then simply try the `Xorg` option and see if it works. 203 | 204 | 205 | ## Credits 206 | 207 | My implementation is based on the following: 208 | 209 | - **Codeproject Tutorial**: [How to Create A GNOME Extension](https://www.codeproject.com/Articles/5271677/How-to-Create-A-GNOME-Extension) 210 | - **Github Repo**: [TV Switch - Gnome Shell Extension](https://github.com/geoph9/tv-switch-gnome-shell-extension). 211 | - **Caffeine Extension**: [Caffeine](https://github.com/eonpatapon/gnome-shell-extension-caffeine) 212 | - **GameMode Extension**: [GameMode](https://github.com/gicmo/gamemode-extension) 213 | - **Custom Hot Corners Extended (forked extension)**: [Custom Hot Corners Extended](https://github.com/G-dH/custom-hot-corners-extended) 214 | 215 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Get gnome version 4 | vers=$(gnome-shell --version | cut -d ' ' -f 3- | cut -d. -f1) 5 | if [[ $vers == 40* ]] ; then # e.g. convert 40.0 to 40 6 | vers="40"; 7 | fi 8 | if [[ -z $vers ]] ; then 9 | # Are you not using gnome? 10 | echo "Could not identify gnome version. Aborting..." 11 | exit 1; 12 | fi 13 | 14 | function get_help() { 15 | echo "Usage: $0 [-h | --help] [-l | --latest] " 16 | echo 17 | echo "Arguments:" 18 | echo " -h | --help: Show this help message." 19 | echo " -l | --latest: Download the latest version directly from the 'master' branch on github." 20 | echo 21 | echo "The latter means that you will have the latest features but you may also encounter" 22 | echo "some bugs. Please make an issue on github if you encounter any bug!" 23 | echo 24 | echo "The version installed will depend on your gnome-shell version." 25 | echo "You can check the available versions from the releases page on github:" 26 | echo " https://github.com/geoph9/hass-gshell-extension/releases" 27 | echo 28 | } 29 | 30 | function check_releases() { 31 | echo "$0: Error: You either don't have 'wget' installed or the version $1 is invalid." 32 | echo "$0: Check the available versions from the releases page on github:" 33 | echo " https://github.com/geoph9/hass-gshell-extension/releases" 34 | exit 1; 35 | } 36 | 37 | function download_stable() { 38 | echo "$0: Downloading extension for version $vers..." 39 | 40 | # Download with wget (you can also download manually). 41 | wget -q https://github.com/geoph9/hass-gshell-extension/releases/download/"$vers"/hass-gshell@geoph9-on-github.shell-extension.zip || check_releases "$vers" 42 | # Unzip contents 43 | unzip hass-gshell@geoph9-on-github.shell-extension.zip -d hass-gshell@geoph9-on-github 44 | # Remove zip 45 | rm hass-gshell@geoph9-on-github.shell-extension.zip 46 | # Move directory to the extensions directory 47 | mkdir -p "$HOME"/.local/share/gnome-shell/extensions/ 48 | rm -rf "$HOME"/.local/share/gnome-shell/extensions/hass-gshell@geoph9-on-github 49 | mv hass-gshell@geoph9-on-github "$HOME"/.local/share/gnome-shell/extensions/hass-gshell@geoph9-on-github 50 | 51 | # Enable extension (the session needs to be restarted before that) 52 | # gnome-extensions enable hass-gshell@geoph9-on-github 53 | exit 0; 54 | } 55 | 56 | function download_latest() { 57 | echo "$0: Downloading latest version directly from github. This may result in unstable behavior." 58 | mkdir -p "$HOME"/.local/share/gnome-shell/extensions 59 | ext_dir="$HOME"/.local/share/gnome-shell/extensions/hass-gshell@geoph9-on-github 60 | git clone https://github.com/geoph9/hass-gshell-extension.git "$ext_dir" 61 | # Delete the screenshots/README since they take up a lot of space. 62 | rm -rf "$ext_dir"/screenshots "$ext_dir"/README.md "$ext_dir"/chromecast 63 | echo 64 | # echo "$0: Trying to enable the extension. This may result in an error if you don't have the 'gnome-extension' cli installed but it is okay." 65 | # echo "$0: You will still need to restart your session in order to make it work." 66 | # gnome-extensions enable hass-gshell-local@geoph9-on-github 67 | exit 0; 68 | } 69 | 70 | while [[ $# -gt 0 ]] ; do 71 | key="$1" 72 | 73 | case $key in 74 | -h|--help) 75 | get_help 76 | exit 0 77 | shift 78 | ;; 79 | -l|--latest) 80 | download_latest 81 | shift 82 | ;; 83 | *) # unknown option 84 | echo "$0: Unknown option $1." 85 | get_help 86 | read -p "$0: Do you want to continue with the default behavior? [y|N]: " -n 1 -r 87 | echo 88 | if [[ "$REPLY" =~ ^[Yy]$ ]] ; then 89 | break 90 | else 91 | echo "$0: Aborting..." 92 | exit 1; 93 | fi 94 | shift # past argument 95 | ;; 96 | esac 97 | done 98 | 99 | download_stable 100 | glib-compile-schemas "$HOME"/.local/share/gnome-shell/extensions/hass-gshell@geoph9-on-github 101 | 102 | exit 0; 103 | -------------------------------------------------------------------------------- /chromecast/endpoints.txt: -------------------------------------------------------------------------------- 1 | # Example: 2 | curl -X POST -d '{"entity_id": "media_player.tv_name"}' http://URL/api/services/media_player/media_play_pause 3 | 4 | media_play 5 | media_pause 6 | media_play_pause 7 | media_stop 8 | media_next_track 9 | media_previous_track 10 | 11 | media_seek (entity_id PLUS seek_position:int) 12 | 13 | select_sound_mode 14 | 15 | volume_up 16 | volume_down 17 | volume_set 18 | volume_mute 19 | 20 | turn_on 21 | turn_off 22 | toggle 23 | 24 | 25 | -------------------------------------------------------------------------------- /extension.js: -------------------------------------------------------------------------------- 1 | import Gio from 'gi://Gio'; 2 | import St from 'gi://St'; 3 | import Clutter from 'gi://Clutter'; 4 | import GObject from 'gi://GObject'; 5 | import GLib from 'gi://GLib'; 6 | 7 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 8 | import * as MessageTray from 'resource:///org/gnome/shell/ui/messageTray.js'; 9 | import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; 10 | import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; 11 | 12 | import * as Utils from './utils.js'; 13 | import * as Settings from './settings.js'; 14 | 15 | import {Extension, gettext as _} from 'resource:///org/gnome/shell/extensions/extension.js'; 16 | 17 | var HassPanelSensor = GObject.registerClass ({ 18 | GTypeName: "HassPanelSensor" 19 | }, class HassPanelSensor extends St.Bin { 20 | 21 | _init(entity, force_reload=false) { 22 | super._init({ 23 | style_class : "panel-button", 24 | reactive : true, 25 | can_focus : true, 26 | track_hover : true, 27 | height : 30, 28 | visible: true, 29 | }); 30 | this.entity = entity; 31 | 32 | this.label = new St.Label({ 33 | text : force_reload ? this.computePlaceholderText() : this.computeLabelText(), 34 | y_align: Clutter.ActorAlign.CENTER, 35 | style: "spacing: 2px", 36 | }); 37 | this.set_child(this.label); 38 | this.connect("button-press-event", () => this.refresh(true)); 39 | 40 | if (force_reload) 41 | this.refresh(true); 42 | 43 | this.build_tooltip(); 44 | 45 | // Import settings to have access to its constants 46 | // Note: we can't do that globally. 47 | this.Settings = Settings; 48 | this.connectedSettingIds = Utils.connectSettings([this.Settings.HASS_ENTITIES_CACHE], this.refresh.bind(this)); 49 | } 50 | 51 | build_tooltip() { 52 | this.tooltip = new St.Label({ 53 | text: this.entity.name, 54 | visible: false, 55 | style_class: "hass-sensor-tooltip", 56 | }); 57 | Main.layoutManager.uiGroup.add_child(this.tooltip); 58 | Main.layoutManager.uiGroup.set_child_above_sibling(this.tooltip, null); 59 | 60 | this.set_track_hover(true); 61 | this.connect('style-changed', (self) => { 62 | if(self.hover) { 63 | let [x, y] = this.get_transformed_position(); 64 | let w = this.tooltip.get_width(); 65 | let h = this.tooltip.get_height(); 66 | this.tooltip.set_position( 67 | x + Math.round(this.get_width() / 2) - Math.round(w / 2), 68 | y + Math.round(1.3 * h) 69 | ); 70 | this.tooltip.show(); 71 | this.tooltip.ease({ 72 | opacity: 200, 73 | duration: 300, 74 | mode: Clutter.AnimationMode.EASE_OUT_QUAD, 75 | }); 76 | } 77 | else { 78 | this.tooltip.remove_all_transitions(); 79 | this.tooltip.ease({ 80 | opacity: 0, 81 | duration: 100, 82 | mode: Clutter.AnimationMode.EASE_OUT_QUAD, 83 | onComplete: () => this.tooltip.hide(), 84 | }); 85 | } 86 | }); 87 | } 88 | 89 | refresh(force_reload=false, entity=null) { 90 | if (entity) { 91 | this.entity = entity; 92 | this.label.text = this.computeLabelText(); 93 | this.tooltip.text = this.entity.name; 94 | return; 95 | } 96 | Utils.getSensor( 97 | this.entity.entity_id, 98 | (entity) => this.refresh(false, entity), 99 | () => { 100 | Utils._log( 101 | 'Fail to load %s (%s) sensor state', 102 | [this.entity.name, this.entity.entity_id] 103 | ); 104 | this.label.text = this.computePlaceholderText(); 105 | }, 106 | force_reload 107 | ); 108 | } 109 | 110 | computeLabelText() { 111 | return `${this.entity.state} ${this.entity.unit}`; 112 | } 113 | 114 | computePlaceholderText() { 115 | return `- ${this.entity.unit}`; 116 | } 117 | 118 | destroy() { 119 | Utils.disconnectSettings(this.connectedSettingIds); 120 | this.label.destroy(); 121 | this.tooltip.destroy(); 122 | super.destroy(); 123 | } 124 | 125 | }); 126 | 127 | class TrayMenuItems { 128 | constructor(type, parentMenu) { 129 | if (type !== "togglable" && type !== "runnable") 130 | throw new Error(`Type ${type} is not supported in TrayMenuItems`) 131 | 132 | this.type = type; 133 | this.parentMenu = parentMenu; 134 | this.menuItems = []; 135 | this.separatorItem = null; 136 | } 137 | 138 | refresh(force_reload=false) { 139 | Utils._log(`refresh ${this.type} in tray menu...`); 140 | 141 | // Firstly delete previously created menu items 142 | this.delete(); 143 | 144 | // Now get the entities and continue in callback 145 | Utils.getEntitiesByType( 146 | this.type, 147 | function(results) { 148 | Utils._log(`get enabled ${this.type}s, continue refreshing tray menu`); 149 | this.menuItems = []; 150 | for (let [idx, entity] of results.entries()) { 151 | if (entity.entity_id === "" || !entity.entity_id.includes(".")) 152 | continue 153 | let pmItem = new PopupMenu.PopupImageMenuItem( 154 | _(this.type === "togglable" ? 'Toggle:': 'Run:') + ' ' + entity.name, 155 | Utils.getEntityIcon(entity.entity_id.split(".")[0]), 156 | ); 157 | pmItem.connect('activate', () => { 158 | if (this.type === "togglable") 159 | Utils.toggleEntity(entity) 160 | else if (this.type === "runnable") 161 | Utils.turnOnEntity(entity) 162 | }); 163 | this.menuItems.push({item: pmItem, entity: entity, index: idx}); 164 | 165 | // We insert here the menu items with their index as position to put 166 | // them at the top of the popup menu 167 | this.parentMenu.addMenuItem(pmItem, idx); 168 | } 169 | // If we have at least one item in menu, add the separator 170 | if (this.menuItems.length) { 171 | this.separatorItem = new PopupMenu.PopupSeparatorMenuItem(); 172 | this.parentMenu.addMenuItem( 173 | this.separatorItem, 174 | // use the count as position of the separator in the menu 175 | this.menuItems.length 176 | ); 177 | } 178 | Utils._log(`${this.type}s in tray menu refreshed`); 179 | }.bind(this), 180 | // On error callback 181 | () => Utils._log(`fail to load enabled ${this.type}s`, null, true), 182 | true, // we want only enabled entities 183 | force_reload 184 | ); 185 | } 186 | 187 | delete() { 188 | // Destroy the previously created togglable menu items 189 | for (let ptItem of this.menuItems) 190 | ptItem.item.destroy(); 191 | this.menuItems = []; 192 | if (this.separatorItem) { 193 | this.separatorItem.destroy(); 194 | this.separatorItem = null; 195 | } 196 | } 197 | } 198 | 199 | // Credits for organizing this class: https://github.com/vchlum/hue-lights/ 200 | var HassMenu = GObject.registerClass ({ 201 | GTypeName: "HassMenu" 202 | }, class HassMenu extends PanelMenu.Button { 203 | _init(metadata, settings, mainDir, openPref) { 204 | super._init(0.0, metadata.name, false); 205 | this.style_class = 'hass-menu'; 206 | this._settings = settings; 207 | this.Settings = null; 208 | // this.shortcutId = "hass-shortcut"; 209 | this._mainDir = mainDir; 210 | this._openPrefs = openPref; 211 | 212 | this.box = null; 213 | 214 | this.trayButton = null; 215 | this.trayIcon = null; 216 | 217 | this.togglablesMenuItems = new TrayMenuItems("togglable", this.menu); 218 | this.runnablesMenuItems = new TrayMenuItems("runnable", this.menu); 219 | 220 | this.panelSensorBox = null; 221 | this.panelSensors = []; 222 | this.refreshSensorsTimeout = null; 223 | 224 | this.connectedSettingIds = []; 225 | } 226 | 227 | enable() { 228 | Utils._log("enabling..."); 229 | 230 | // Import settings to have access to its constants 231 | // Note: we can't do that globally. 232 | this.Settings = Settings; 233 | 234 | // Disable click event on the PopupMenu to handle it in the components it contains 235 | this.connect('event', (actor, event) => { 236 | if ((event.type() == Clutter.EventType.TOUCH_BEGIN || 237 | event.type() == Clutter.EventType.BUTTON_PRESS)) 238 | return Clutter.EVENT_STOP; 239 | return Clutter.EVENT_PROPAGATE; 240 | }); 241 | 242 | // Create the main BoxLayout which will contain all compoments of the extension 243 | this.box = new St.BoxLayout({style: 'spacing: 2px'}); 244 | 245 | // Load settings and build all compoments 246 | this._loadSettings(); 247 | this._buildTrayMenu(); 248 | this._buildPanelSensors(); 249 | this._buildTrayIcon(); 250 | // this._enableShortcut(); 251 | 252 | // Add the main box as child of the PopupMenu 253 | this.add_child(this.box); 254 | 255 | // Connect the setting field that contain the HASS URL with the refresh() method with 256 | // force_reload argument equal to true 257 | this._connectSettings([this.Settings.HASS_URL], this.refresh, [true]); 258 | 259 | // Connect the setting field that contain the HASS entities state cache with the refresh() 260 | // method with force_reload argument equal to false (default) 261 | this._connectSettings([this.Settings.HASS_ENTITIES_CACHE], this.refresh); 262 | } 263 | 264 | disable() { 265 | Utils._log("disabling..."); 266 | Utils.disconnectSettings(this.connectedSettingIds); 267 | this._deletePanelSensors(); 268 | this._deleteTrayIcon(); 269 | // this._disableShortcut(); 270 | this._deleteMenuItems(); 271 | if (this.panelSensorBox) this.panelSensorBox.destroy(); 272 | if (this.box) this.box.destroy(); 273 | if (this.refreshSensorsTimeout) { 274 | GLib.Source.remove(this.refreshSensorsTimeout); 275 | this.refreshSensorsTimeout = null; 276 | } 277 | } 278 | 279 | refresh(force_reload=false) { 280 | if (force_reload) { 281 | Utils.getEntities( 282 | // We do not have to trigger a refresh() here, because on success, cache setting 283 | // will be updated and a refresh will be automatically triggered. So no on-success 284 | // callback here. 285 | null, 286 | function () { 287 | Utils._log("fail to refresh entities cache, invalidate it.", null, true); 288 | Utils.invalidateEntitiesCache(); 289 | }.bind(this), 290 | true // force refreshing cache 291 | ); 292 | } 293 | else { 294 | this.togglablesMenuItems.refresh(); 295 | this.runnablesMenuItems.refresh(); 296 | this._refreshPanelSensors(); 297 | } 298 | } 299 | 300 | /* 301 | ********************************************************************************************** 302 | * Shortcut 303 | ********************************************************************************************** 304 | */ 305 | // TODO: Add proper support for shortcuts (in a similar manner as with hass-url for example) 306 | // _enableShortcut() { 307 | // Main.wm.addKeybinding( 308 | // this.shortcutId, 309 | // this._settings.get_strv('org.gnome.shell.extensions.hass-shortcut'), 310 | // Meta.KeyBindingFlags.NONE, // key binding flag 311 | // Shell.ActionMode.ALL, // binding mode 312 | // () => this.menu.toggle() 313 | // ); 314 | // } 315 | // _disableShortcut() { 316 | // if (this.refreshSensorsTimeout) { 317 | // Mainloop.source_remove(this.refreshSensorsTimeout); 318 | // this.refreshSensorsTimeout = null; 319 | // } 320 | // Main.wm.removeKeybinding(this.shortcutId); 321 | // } 322 | 323 | /* 324 | ********************************************************************************************** 325 | * Manage settings 326 | ********************************************************************************************** 327 | */ 328 | 329 | _loadSettings() { 330 | Utils._log("load settings"); 331 | this.panelSensorIds = this._settings.get_strv(this.Settings.HASS_ENABLED_SENSOR_IDS); 332 | this.doRefresh = this._settings.get_boolean(this.Settings.DO_REFRESH); 333 | this.refreshSeconds = Number(this._settings.get_string(this.Settings.REFRESH_RATE)); 334 | } 335 | 336 | _connectSettings(settings, callback, args=[]) { 337 | this.connectedSettingIds.push.apply( 338 | this.connectedSettingIds, 339 | Utils.connectSettings( 340 | settings, 341 | function() { 342 | this._loadSettings(); 343 | callback.apply(this, args); 344 | }.bind(this) 345 | ) 346 | ); 347 | } 348 | 349 | /* 350 | ********************************************************************************************** 351 | * Tray icon 352 | ********************************************************************************************** 353 | */ 354 | 355 | _getTrayIconPath() { 356 | let icon_path = this._settings.get_string(this.Settings.PANEL_ICON_PATH); 357 | // Make sure the path is valid 358 | if (!icon_path.startsWith("/")) 359 | icon_path = "/" + icon_path; 360 | return this._mainDir.get_path() + icon_path; 361 | } 362 | 363 | _buildTrayIcon() { 364 | this.trayButton = new St.Bin({ 365 | style_class : "panel-button", 366 | reactive : true, 367 | can_focus : true, 368 | track_hover : true, 369 | height : 30, 370 | }); 371 | 372 | 373 | this.trayIcon = new St.Icon({ 374 | gicon : Gio.icon_new_for_string(this._getTrayIconPath()), 375 | style_class : 'system-status-icon', 376 | }); 377 | 378 | this.trayButton.set_child(this.trayIcon); 379 | this.trayButton.connect("button-press-event", () => this.menu.toggle()); 380 | 381 | this.box.add_child(this.trayButton); 382 | 383 | // Connect the setting field that contain the selected icon with the _updateTrayIcon() 384 | // method 385 | this._connectSettings([this.Settings.PANEL_ICON_PATH], this._updateTrayIcon); 386 | } 387 | 388 | _updateTrayIcon() { 389 | this.trayIcon.gicon = Gio.icon_new_for_string(this._getTrayIconPath()); 390 | } 391 | 392 | _deleteTrayIcon() { 393 | if (this.trayIcon) { 394 | this.trayIcon.destroy(); 395 | this.trayIcon = null; 396 | } 397 | if (this.trayButton) { 398 | this.trayButton.destroy(); 399 | this.trayButton = null; 400 | } 401 | } 402 | 403 | /* 404 | ********************************************************************************************** 405 | * Tray menu 406 | ********************************************************************************************** 407 | */ 408 | 409 | _buildTrayMenu() { 410 | Utils._log("build tray menu"); 411 | 412 | // Build the submenu containing the HASS events 413 | let subItem = new PopupMenu.PopupSubMenuMenuItem(_('HASS Events'), true); 414 | subItem.icon.gicon = Gio.icon_new_for_string(this._mainDir.get_path() + '/icons/hass-symbolic.svg'); 415 | this.menu.addMenuItem(subItem); 416 | 417 | let start_hass_item = new PopupMenu.PopupMenuItem(_('Start Home Assistant')); 418 | subItem.menu.addMenuItem(start_hass_item); 419 | start_hass_item.connect('activate', () => Utils.triggerHassEvent('start')); 420 | 421 | let stop_hass_item = new PopupMenu.PopupMenuItem(_('Stop Home Assistant')); 422 | subItem.menu.addMenuItem(stop_hass_item); 423 | stop_hass_item.connect('activate', () => Utils.triggerHassEvent('stop')); 424 | 425 | let close_hass_item = new PopupMenu.PopupMenuItem(_('Close Home Assistant')); 426 | subItem.menu.addMenuItem(close_hass_item, 2); 427 | close_hass_item.connect('activate', () => Utils.triggerHassEvent('close')); 428 | 429 | // Build the Refresh menu item 430 | let refreshMenuItem = new PopupMenu.PopupImageMenuItem(_("Refresh"), 'view-refresh'); 431 | refreshMenuItem.connect('activate', () => { 432 | // Firstly close the menu to avoid artifact when it will partially be rebuiled 433 | this.menu.close(); 434 | Utils._log("Refreshing..."); 435 | this.refresh(true); 436 | }); 437 | this.menu.addMenuItem(refreshMenuItem); 438 | 439 | // Build the Preferences menu item 440 | let prefsMenuItem = new PopupMenu.PopupImageMenuItem( 441 | _("Preferences"), 442 | 'security-high-symbolic', 443 | ); 444 | prefsMenuItem.connect('activate', () => { 445 | Utils._log("opening Preferences..."); 446 | this._openPrefs(); 447 | }); 448 | this.menu.addMenuItem(prefsMenuItem); 449 | 450 | // Connect the setting field that contain enabled togglable entities 451 | this._connectSettings([this.Settings.HASS_ENABLED_ENTITIES], this.togglablesMenuItems.refresh.bind(this.togglablesMenuItems)); 452 | 453 | // Refresh togglable items a first time 454 | this.togglablesMenuItems.refresh(); 455 | 456 | // Connect the setting field that contain enabled runnable entities 457 | this._connectSettings([this.Settings.HASS_ENABLED_RUNNABLES], this.runnablesMenuItems.refresh.bind(this.runnablesMenuItems)); 458 | 459 | // Refresh runnable items a first time 460 | this.runnablesMenuItems.refresh(); 461 | 462 | Utils._log("tray menu builded"); 463 | } 464 | 465 | _deleteMenuItems() { 466 | // Delete all the menu items 467 | this.togglablesMenuItems.delete(); 468 | this.runnablesMenuItems.delete(); 469 | this.menu.removeAll(); 470 | } 471 | 472 | /* 473 | ********************************************************************************************** 474 | * Panel sensors 475 | ********************************************************************************************** 476 | */ 477 | 478 | _buildPanelSensors() { 479 | Utils._log("build sensors panel..."); 480 | 481 | // Create a box for panel sensors and add it to main box 482 | this.panelSensorBox = new St.BoxLayout(); 483 | this.box.add_child(this.panelSensorBox); 484 | 485 | // Rebuild panel sensors item a first time (with force reload enabled) 486 | this._rebuildPanelSensors(true); 487 | 488 | // Connect the setting field that contain enabled sensors with the _rebuildPanelSensors() 489 | // method 490 | this._connectSettings([this.Settings.HASS_ENABLED_SENSOR_IDS], this._rebuildPanelSensors); 491 | 492 | // Configure the refreshing of sensors panel 493 | this._configPanelSensorsRefresh(); 494 | 495 | // Connect all setting fields that have impact on the refreshing of sensors panel with 496 | // the _configPanelSensorsRefresh() method 497 | this._connectSettings( 498 | [ 499 | this.Settings.HASS_ENABLED_SENSOR_IDS, 500 | this.Settings.DO_REFRESH, 501 | this.Settings.REFRESH_RATE, 502 | ], 503 | this._configPanelSensorsRefresh 504 | ); 505 | 506 | Utils._log("panel sensor builded..."); 507 | } 508 | 509 | _rebuildPanelSensors(force_reload=false) { 510 | Utils._log("Rebuild panel sensors..."); 511 | Utils.getSensors( 512 | function(panelSensors) { 513 | Utils._log("Get enabled sensors, rebuild panel..."); 514 | let panelSensorsIds = panelSensors.map((p) => p.entity_id); 515 | 516 | // Firstly, remove all panel sensors from their box and destroy removed sensors 517 | for (let [panelSensorId, panelSensor] of Object.entries(this.panelSensors)) { 518 | this.panelSensorBox.remove_child(panelSensor); 519 | if (!panelSensorsIds.includes(panelSensorId)) { 520 | Utils._log("Remove sensor %s (%s) from panel", [panelSensor.entity.name, panelSensor.entity.entity_id]); 521 | panelSensor.destroy(); 522 | delete this.panelSensors[panelSensorId]; 523 | } 524 | } 525 | 526 | // Now refresh existing sensors, create new ones and put them in their box 527 | for (let [idx, panelSensor] of panelSensors.entries()) { 528 | if (panelSensor.entity_id in this.panelSensors) { 529 | Utils._log("Refresh sensor %s (%s) in panel", [panelSensor.name, panelSensor.entity_id]); 530 | this.panelSensors[panelSensor.entity_id].refresh(force_reload, panelSensor); 531 | } 532 | else { 533 | Utils._log("Add sensor %s (%s) to panel", [panelSensor.name, panelSensor.entity_id]); 534 | this.panelSensors[panelSensor.entity_id] = new HassPanelSensor(panelSensor, force_reload); 535 | } 536 | this.panelSensorBox.add_child(this.panelSensors[panelSensor.entity_id]); 537 | } 538 | Utils._log("Panel sensors rebuilded"); 539 | }.bind(this), 540 | function() { 541 | Utils._log("fail to load enabled panel sensors, remove all", null, true); 542 | this._deletePanelSensors(); 543 | }.bind(this), 544 | true, // we want only enabled sensors 545 | ); 546 | } 547 | 548 | _configPanelSensorsRefresh() { 549 | // Firstly cancel previous configured timeout (if defined) 550 | if (this.refreshSensorsTimeout) { 551 | Utils._log("cancel previous sensors refresh timer..."); 552 | GLib.Source.remove(this.refreshSensorsTimeout); 553 | this.refreshSensorsTimeout = null; 554 | } 555 | 556 | // Only continue if refreshing is enabled, refreshing rate is configured and we have at 557 | // least one panel sensors configured 558 | if (!this.doRefresh || !this.refreshSeconds || !this.panelSensorIds.length) 559 | return; 560 | 561 | // Schedule sensors panel refreshing every X seconds 562 | Utils._log( 563 | 'schedule refreshing sensors panel every %s seconds', 564 | [this.refreshSeconds] 565 | ); 566 | this.refreshSensorsTimeout = GLib.timeout_add_seconds( 567 | GLib.PRIORITY_DEFAULT, 568 | this.refreshSeconds, () => { 569 | this._refreshPanelSensors(true); 570 | // We have to return true to keep the timer alive 571 | return true // return GLib.SOURCE_CONTINUE; 572 | } 573 | ); 574 | } 575 | 576 | _refreshPanelSensors(force_reload=false) { 577 | for (let panelSensor of Object.values(this.panelSensors)) 578 | panelSensor.refresh(force_reload); 579 | } 580 | 581 | _deletePanelSensors() { 582 | for (let [panelSensorId, panelSensor] of Object.entries(this.panelSensors)) { 583 | this.box.remove_child(panelSensor); 584 | panelSensor.destroy(); 585 | delete this.panelSensors[panelSensorId]; 586 | } 587 | } 588 | 589 | }) 590 | 591 | export default class HassExtension extends Extension { 592 | enable() { 593 | this._settings = this.getSettings(); 594 | Utils.init( 595 | this.metadata.uuid, 596 | this._settings, 597 | this.metadata, 598 | this.dir, 599 | _, 600 | MessageTray, 601 | Main 602 | ); 603 | Utils._log("enabling..."); 604 | 605 | this.popupMenu = new HassMenu( 606 | this.metadata, 607 | this._settings, 608 | this.dir, 609 | this.openPreferences.bind(this) 610 | ); 611 | this.popupMenu.enable() 612 | 613 | Main.panel.addToStatusArea('hass-extension', this.popupMenu); 614 | } 615 | 616 | disable() { 617 | Utils._log("disabling..."); 618 | 619 | this.popupMenu.disable(); 620 | this.popupMenu.destroy(); 621 | this.popupMenu = null; 622 | this._settings = null; 623 | Utils.disable(); 624 | } 625 | } 626 | 627 | -------------------------------------------------------------------------------- /icons/ceiling-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 34 |