v1.3.0.0
Logitech™ G HUB® Battery Monitor

A script to monitor battery level of Logitech™ G HUB® devices using WebSockets protocol.

See the published documentation for a properly formatted version of this README.

Description

Once initialized, this script runs in the background and essentially acts as its own "plugin." It connects to the "secret" G HUB WebSockets server and reports battery status for each device GH shows as having a battery.

The script will create several Touch Portal states for each discovered device, indicating charge level, whether the device is currently active and/or charging, remaining battery hours, and so forth. There is also a state to indicate overall G HUB connection status.

Device battery status can be shown on any page, using standard Touch Portal buttons which react to plugin state changes.

The script is also set up to send battery status icons based on the current charge level and charging status (to be used as a Touch Portal button background). Two example image sets are provided (horizontal and vertical battery orientation), custom sets could be substituted, or the image feature can be disabled altogether.

A Touch Portal page is provided for initial script setup and as a way to set some options (w/out having to modify the script directly). This could be considered the script's "control panel." It also provides a visual status indicator of the G HUB connection, the Dynamic Script Engine plugin itself, and examples of device battery status buttons.

Once initialized (using the INITIALIZE SCRIPT button on that control panel page), the script will load itself automatically each time the Dynamic Script Engine plugin starts, and attempt to open a G HUB connection. So as long as G HUB is running, further interaction with the "control panel" page shouldn't be necessary.

The script can be configured to automatically retry connecting to G HUB, for example if G HUB is not running when the script starts or is stopped/re-started later. This is described in more detail below.

Control Panel Page

Here is a brief rundown of what's on the page.

  • In the left column are actions to do something with the script.
    • INITIALIZE SCRIPT loads, or re-loads, the script and attempts to connect to G HUB. This button must be used at least once to set up the monitor script (see Download & Setup, below). If any changes are made to the script source code, this button can be used to load the new version.
    • CONNECT will initiate a connection attempt to G HUB (if not already connected).
    • DISCONNECT will close any active connection (and not try to automatically re-connect if that feature is enabled).
    • UPDATE DEVICE LIST will request a device list update from G HUB (it will try to connect first if needed). Generally there should be no need to request an update manually, but it may be useful in some situations.
    • DELETE SCRIPT completely removes the script and all saved settings from the plugin's environment. The script will no longer load automatically at plugin start either. To restore the script (with default settings), the INITIALIZE SCRIPT button must be used again.
  • To the right of that, at the top, is a button to reflect the current G HUB connection status. This starts out with a transparent background color and the text "CONNECTION STATUS" before the script is initialized. It will transition between "CONNECTING", "CONNECTED", and "DIS-CONNECTED" depending on the current status, with corresponding color backgrounds (blue, green, and red, respectively).
  • At the top right are two display-only buttons showing status of an actual G HUB battery device (a mouse). Note that unless you happen to also have a G903 mouse, the states used in both these buttons will need to be updated to use the ones created for your actual device(s).
    • The first is a simple "Dynamic Text Updater" Touch Portal event just showing all the available states for a device and their current values (screenshot below).
    • The other is an example of what someone may actually use as a status display. It has events which react to the battery level and status image states changing.
  • In the middle are the buttons for changing various options available as script settings. If this were a full plugin these options would be in the plugin's Settings page. As it is, their use is somewhat "unconventional."
    • All the "SET" buttons must be edited before using. The actual value which will be used to change the setting is part of the plugin action found in each button.
    • Each button has comments documenting what it does, the possible values, examples, any further instructions, etc. Edit a button to see the comments.
    • The full list of available options, with further comments, can be seen in the script's source code (reproduced below), towards the top in the "Script runtime settings" section.
  • At bottom right is a button showing status of the Dynamic Script Engine plugin itself (green for running, red if stopped or unknown). This can also be used to stop/start the plugin.
    And of course the obligatory "back to main page" arrow.

G HUB Connections

The script cannot connect to G HUB when GH isn't running, and it will be disconnected if G HUB stops running for whatever reason (an update, crash, manual restart, etc).

By default the monitor script is configured to re-try a failed or dropped connection up to 20 times, every 30 seconds. After that it will stop trying and a new connection needs to be initiated manually using the CONNECT button on the script's "control panel" page.

The time between tries can be changed using the SET CONNECT RETRY TIME button, after editing it (as described above). Setting the time to 0 (zero) will disable the automatic connection attempts entirely.

The maximum number of attempts can be changed using the SET CONNECT MAX. TRIES button, after editing it. If this is set to 0 (zero) then the script will attempt connections indefinitely.

The DISABLE CONNECTION RETRY button sets the connection retry time to zero, disabling the feature. To re-enable it, use the SET CONNECT RETRY TIME button (by default, w/out editing the button, this sets the retry time to 30 seconds).

If the auto-connect feature is disabled or exceeds the maximum number of retries, the CONNECT button can be used to attempt a connection manually once you know G HUB is running. If auto-connect is enabled, this also resets the current retry count.

Device Active Status

Some (all?) battery-powered devices will eventually go to sleep if unused for some period of time, and/or can be turned off with a switch on the device. G HUB treats these as "not active" devices, meaning they're not entirely disconnected, but their actual status is unknown. Similarly, if we get disconnected from G HUB, we don't know a device's status either. Presumably, we want to know when this happens so we're not seeing a happy green "all good" battery icon when in fact we don't know what the charge level is.

Each monitored device has an associated "Is Active" Touch Portal state with a value of 0 (zero) for "inactive" or 1 (one) for "active." When a device sleeps, for example, the state's value will change from one to zero, and vice versa when it wakes back up. Changes to this state's value can be monitored as an event and actions applied as needed. (An example of this is included in the "control panel" page's battery status button.)

If G HUB gets disconnected (for whatever reason) or the whole Dynamic Script Engine plugin is stopped, then all monitored devices are set to "inactive" state.

By default, when a device becomes inactive the script will also reset all of it's battery data to default "unknown" values (essentially like G HUB does). The charge level and remaining run time state values are set to -1 and the battery image (if used) changes to the "inactive" variant.

If you want to keep the last known battery details, the script has two other modes of operation which can be configured using the SET INACTIVITY BEHAVIOR button (edit it and enable/activate the action which sets one of the modes). The three modes are:

  • reset - the default behavior described above, resets all battery data and status image to "unknown"/"inactive" values.
  • keep - keeps the last known battery data as-is, w/out resetting it, including the last status image (if images are enabled).
  • resetImage - keeps the last known battery data but resets the battery status image to the "inactive" variant (if images are enabled).

Download & Setup

  1. Download the GHUB Monitor Control Panel Page and import it into Touch Portal.
    • The required script file is already included in the page's archive and will be copied into your Touch Portal data directory, into the "misc" subfolder.
    • Alternatively, the latest version of the script can also be downloaded separately: ghub-monitor.mjs
  2. Download and import the Battery Status Icon Pack. Optional but recommended to get started. By default the script reads these icon files and sends them as images to reflect device battery level & charging status.
  3. Create a new button somewhere (eg. on your (main) page) that opens the control panel page on your Touch Portal Android/iOS device.
  4. Navigate to the new page on your device, and then press the INITIALIZE SCRIPT button.
    • This only needs to be done once when you first import the page/script. After that everything should be re-created automatically next time you start Touch Portal (or restart this plugin).
  5. The G HUB status button should turn blue indicating a connection to G HUB is being attempted. If G HUB is running, this should turn green and indicate "CONNECTED." Otherwise it should turn red and indicate "DIS-CONNECTED."
  6. Once G HUB is connected, the script will request a list of devices. The list is processed and any devices which report a battery status are added and will be monitored for changes. Once devices are added, new plugin states should be available which reflect each device's status. Each device's set of states are grouped together, see below for example screenshot.

If you connect a new battery-powered G HUB device to your computer after the script has already started and connected, G HUB should report this to the script and the new device will be added (or updated) automatically. Removing a device from the system should act the same as if it went "inactive."

Device States

Here's a screenshot of what a set of states created for a monitored device looks like and where to find them. The device name will vary of course, and you may have multiple devices. This example is from the "control panel" page. (Click for larger version).

Troubleshooting & Log

Everything the script does is logged to the plugin's log files. If there are any errors or other unexpected issues, they should be noted in the logs. Normal activity like connection/disconnection or device discovery events are also recorded.

The logs are located in the plugin's installation folder, which is in Touch Portal's "data" directory:

  • Windows: C:\Users\<User_Name>\AppData\Roaming\TouchPortal\plugins\DSEP4TP\logs
  • Mac: ~/Documents/TouchPortal/plugins/DSEP4TP/logs

To continually monitor ("tail") the plugin's log file:

  • Windows: open a PowerShell window, paste (CTRL-V) the following line, then hit ENTER
    Get-Content -Tail 40 -Wait "$env:APPDATA\TouchPortal\plugins\DSEP4TP\logs\plugin.log"
  • MacOS: open a terminal window and paste the following:
    tail -F -n 10 ~/Documents/TouchPortal/plugins/DSEP4TP/logs/plugin.log
    To stop tailing the log, press CTRL-C or close the PowerShell/terminal window.

Further details on logging and plugin status can be found on the Status and Logging page.

G HUB Monitor Script

Note: This is reproduced here for reference. The latest version can be downloaded directly: ghub-monitor.mjs

1/* Dynamic Script Engine - G HUB Battery Monitor Script
2
3 A script to monitor battery level of Logitech™ G HUB® devices using WebSockets protocol.
4
5 This is in a lot of ways like a mini-plugin in itself, as it provides a lot of functionality one would otherwise need
6 an actual Touch Portal plugin for.
7
8Documentation and examples published at: https://dse.tpp.max.paperno.us/example_ghub_monitor.html
9
10Any function or variable marked as `export` can be used in a Touch Portal DSE action
11or imported into another script/module with `import` statement or `require()` function.
12
13---------
14Copyright Maxim Paperno; all rights reserved.
15
16This file may be used under the terms of the GNU
17General Public License as published by the Free Software Foundation,
18either version 3 of the License, or (at your option) any later version.
19
20This program is distributed in the hope that it will be useful,
21but WITHOUT ANY WARRANTY; without even the implied warranty of
22MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
23GNU General Public License for more details.
24
25A copy of the GNU General Public License is available at <http://www.gnu.org/licenses/>.
26*/
27
28/**
29 Script constant/default values.
30 They can be read via the module's import alias, but not set.
31 The module must be reloaded after editing any of these.
32*/
33export const
34 /** A friendly name for this "app." */
35 SCRIPT_NAME = "G HUB Monitor",
36 /** This default image path assumes they were imported into Touch Portal as an "icon pack" named "GHUB Monitor Icons".
37 Due to how `Dir.normalize()` works, the default image path will be empty if this icon pack doesn't exist, and status images are disabled. */
38 DEFAULT_IMAGES_PATH = Dir.normalize(DSE.TP_USER_DATA_PATH + "/iconpacks/GHUB Monitor Icons"),
39 /** GHUB WebSocket server address. GHUB only listens on localhost (127.0.0.1) address. */
40 GHUB_WS_URL = "ws://localhost:9010",
41 /** All TP states created by this script will have the following prefix (state IDs must be unique throughout TP). */
43 /** Prefix all messages to the plugin's log files with this text (for filtering/etc). */
44 LOG_PREFIX = SCRIPT_NAME + ":"
45;
46
47/**
48 Script runtime settings. Changes will take affect w/out restarting the script.
49 These can be modified directly using the module's import alias, or via the `setOption()` function (recommended).
50
51 For example, if the import is aliased as "M":
52 M.setOption("DeviceInactiveAction", "keep");
53
54 Or, directly:
55 M.Settings.DeviceInactiveAction = "keep";
56
57 After modifying Settings values directly, call `M.saveSettings()` to persist settings between script runs.
58 `setOption()` does this for you.
59*/
60export const Settings =
61{
62 /**
63 How often to re-try connecting to G HUB if a connection attempt times out or G HUB disconnects unexpectedly (eg. when it exits or restarts).
64 The value is specified in seconds. Set this to 0 (zero) to disable automatic re-connection attempts.
65 */
66 ConnectRetryTimeSec: 30,
67 /**
68 Maximum number of times to attempt an automatic re-connection to G HUB.
69 After this many tries, automatic re-connection is disabled until a new connection is attempted manually (eg. by calling `connect()`).
70 If set to zero, connections will be attempted indefinitely.
71 */
72 ConnectRetryMaxCount: 20,
73 /**
74 What to do when a monitored device becomes inactive (eg. going to sleep, turned off, GHUB connection is lost, etc).
75 The choices are:
76 * 'keep' - Keep all battery state data at their last known values, including the last sent image (if any).
77 Essentially does nothing besides update the "active" state of the device.
78 * 'reset' - Reset all battery state data to default "unknown" values (percentage = -1, charging = false, etc), and send default image (if images are enabled).
79 * 'resetImage' - Keeps all battery values at their last values (like 'keep') but sends the "default" image if images are enabled.
80 Because if using the images provided by this script, there's no other way to clear that image from the button.
81 Note that the "active" state of the device will always be updated, so this change can also be handled entirely on the TP side by setting deviceInactiveAction='keep'.
82 */
83 DeviceInactiveAction: 'reset',
84 /**
85 Absolute path to battery status image files.
86 Set this to an empty string to disable sending images altogether.
87 By default they are assumed to be next to the location of this script file in a sub-folder named "images".
88 */
89 ImagesPath: DEFAULT_IMAGES_PATH,
90 /**
91 Status image files begin with this string (then suffix is appended based on battery level).
92 See also `batteryLevelToImageName()` function below.
93 */
94 ImagePrefix: "h-battery-",
95 /** Status image file name suffix with image type extension. */
96 ImageSuffix: ".png",
97};
98
99//
100// The following functions may be useful to call from TP actions or other module consumers.
101// They're accessed using the module's "import alias" name as defined in the plugin's action which started this script.
102// For example if the import is named "M" then use syntax like `M.functionName()` to call these functions (eg. `M.connect()`).
103//
104
105/** Attempts to start a WebSocket connection to GHUB (if not already connected). */
106export function connect()
107{
108 _data.reconnectCount = 0;
109 open();
110}
111
112/** Disconnects from G HUB (if connected).
113 A new connection will not be attempted until the next time `connect()` is called.
114*/
115export function disconnect() {
116 close();
117}
118
119/**
120 Request a list of devices from GHUB. This will try to connect to GHUB if it isn't already connected.
121 The response will be scanned for new/changed devices.
122 An initial list request is automatically sent when a new connection to GHUB is opened.
123 After that it can be initiated on demand, if needed.
124 Note that this script also subscribes to the "/devices/state/changed" event when a new connection is established,
125 which should (in theory) keep the devices list (and each device's status) updated automatically.
126*/
127export function listDevices()
128{
129 if (isConnected())
130 send({
131 verb: "GET",
132 path: "/devices/list"
133 });
134 else
135 open();
136}
137
138/** Returns `true` if a connection to GHUB is currently open, `false` otherwise. */
139export function isConnected() {
140 return _data.ws?.readyState == WebSocket.OPEN;
141}
142
143/**
144 Change an option value in the Settings object.
145
146 `name` argument can be any of the properties listed in the `Settings` object (above).
147 `value` argument is the new value to assign to the property.
148 The value's type (string/number) must match the type of the corresponding `Settings` property.
149
150 For example, if the import is aliased as "M":
151 M.setOption("deviceInactiveAction", "keep");
152
153 Using this function will automatically save the updated settings to the script's persistend data (using `saveSettings()`).
154*/
155export function setOption(name, value)
156{
157 if (name in Settings && typeof Settings[name] == typeof value && Settings[name] !== value) {
158 Settings[name] = value;
159 console.info(LOG_PREFIX, `Set option '${name}' to '${value}'`);
160 callLater(saveSettings);
161 }
162}
163
164/**
165 Saves current `Settings` object properties to the script's persistent storage.
166 These values will be restored to the runtime `Settings` object the next time this script starts.
167*/
168export function saveSettings() {
169 _data.instance.dataStore.Settings = structuredClone(Settings);
170 console.info(LOG_PREFIX, "Saved settings.");
171}
172
173
174//
175// Internal use functions
176//
177
178/**
179 This function returns an image file suffix corresponding to the given battery percentage `level` and `charging` status.
180 For example if battery level is ~50% it may return "50" or if also charging then "50-charging".
181 If level is < 0 it returns "inactive".
182 This can be adjusted as desired based on how many images are being used and the percentage ranges at which each should be shown.
183 The "inactive" image is also used as the default image when a new device is detected, before we read any battery info from it.
184*/
185function batteryLevelToImageName(level, charging = false)
186{
187 if (level < 0)
188 return "inactive";
189 const chrgSfx = charging ? "-charging" : "";
190 if (level < 10)
191 return "0" + chrgSfx;
192 if (level < 35)
193 return "25" + chrgSfx;
194 if (level < 65)
195 return "50" + chrgSfx;
196 if (level < 90)
197 return "75" + chrgSfx;
198 return "100" + chrgSfx;
199}
200
201
202/**
203 Instances of the `GHUBDevice` class are used to track detected GHUB devices.
204 A new one is created for each device which reports a battery status.
205*/
206class GHUBDevice
207{
208 constructor(init = {})
209 {
210 this.id; // current GHUB ID (may change between GHUB runs)
211 this.name; // short device name
212 this.fullName; // long device name
213 this.type; // GHUB device type string
214 this.active = false; // device is marked as "ACTIVE" in GHUB
215 this.stateId; // base prefix string for all TP states for this device
216 this.lastImage = ""; // last battery image sent to TP (to avoid duplication)
217 this.battery = {
218 percentage: -1, // reported percentage, -1 if unknown
219 charging: false, // flag to indicate current charging status
220 mileage: null, // reported battery hours remaining; null means not supported, -1 means currently unknown
221 lastUpdate: 0, // timestamp of last received update
222 };
223 // Assign any property values passed in `init` argument;
224 // Object.assign() is not recursive so if `init.battery` object is present, assign those properties first and remove it
225 if (init?.battery != undefined) {
226 if (typeof init.battery == 'object')
227 Object.assign(this.battery, init.battery);
228 delete init.battery;
229 }
230 Object.assign(this, init);
231
232 this.updateImageState = this.updateImageState.bind(this);
233 }
234
235 /**
236 Sets the active state of this device. Updates TP states as needed.
237 If active, subscribes to GHUB battery updates and requests immediate status.
238 If inactive, also updates the battery icon for this device with the default image (if images are enabled).
239 */
240 setActive(active)
241 {
242 if (active == this.active)
243 return;
244 this.active = active;
245 // let TP know our new status
246 TP.stateUpdateById(`${this.stateId}.active`, active ? "1" : "0");
247 // sub/unsub to/from GHUB battery state change messages
248 subscribeDeviceBatteryState(this, active);
249 if (active) {
250 // if switching to active mode, request an immediate update from GHUB;
251 // battery values and TP states will be updated once the response is received
252 getDeviceBatteryState(this);
253 }
254 // if switching to being inactive, we have some options...
255 else if (Settings.DeviceInactiveAction == 'reset') {
256 // We could clear the current battery data and send state updates
257 this.resetBatteryData(true);
258 }
259 else if (Settings.DeviceInactiveAction == 'resetImage') {
260 // Or keep the last data intact and just send default "inactive" image as a visual indicator that device is inactive
261 this.updateBatteryStateImage(batteryLevelToImageName(-1));
262 }
263 // Or do nothing and have handlers on TP side react to the "active" state change to change visuals/etc.
264 }
265
266 /** Resets battery data to default "unknown" values and optionally sends TP state updates for this device. */
267 resetBatteryData(withUpdate = false)
268 {
269 this.battery.percentage = -1;
270 this.battery.charging = false;
271 if (this.battery.mileage != null)
272 this.battery.mileage = -1;
273 if (withUpdate)
274 this.updateBatteryStates();
275 }
276
277 /** Creates all TP States for this device. */
279 {
280 const stateId = this.stateId,
281 name = this.name,
282 parentGroup = `G HUB Device - ${name}`;
283
284 if (!stateId || !name)
285 return;
286
287 // All states for this device will appear grouped under this sub-category of "Dynamic Script Engine" plugin states.
288 TP.stateCreate(`${stateId}.level`, parentGroup, `${name} Battery Level`, "-1");
289 TP.stateCreate(`${stateId}.mileage`, parentGroup, `${name} Battery Hours Remaining`, "-1");
290 TP.stateCreate(`${stateId}.name`, parentGroup, `${name} Device Name`, name);
291 TP.stateCreate(`${stateId}.active`, parentGroup, `${name} Is Active`, "0");
292 TP.stateCreate(`${stateId}.charging`, parentGroup, `${name} Is Charging`, "0");
293 if (Settings.ImagesPath) {
294 // Create image state with empty default value
295 TP.stateCreate(`${stateId}.image`, parentGroup, `${name} Battery Image`, "");
296 // Send the initial "inactive" battery image asynchronously
297 this.updateBatteryStateImage(batteryLevelToImageName(-1));
298 }
299 }
300
301 /**
302 Sends TP battery state updates for this device.
303 The states should already have been created before this.
304 */
306 {
307 if (!this.stateId)
308 return;
309
310 const b = this.battery;
311 TP.stateUpdateById(`${this.stateId}.level`, b.percentage.toFixed(0));
312 TP.stateUpdateById(`${this.stateId}.charging`, b.charging ? "1" : "0");
313 if (b.mileage != null)
314 TP.stateUpdateById(`${this.stateId}.mileage`, b.mileage.toFixed(2));
315 // Update battery status image if it changes.
316 this.updateBatteryStateImage(batteryLevelToImageName(b.percentage, b.charging));
317 }
318
319 /**
320 Sends a TP state update for this device with image data read from `imgName`.
321 The image name is first compared to the last image sent for this device, and the update is skipped if they match.
322 Otherwise the device's last sent image name is set to the new one from `imgName` and an update is sent.
323 Image name is qualified into a full name & path using `ImagesPath`, `ImagePrefix` and `ImageSuffix` settings.
324 Reads and sends image data asynchronously by default, unless `synchronous` is to to `true`
325 or the `_data.aboutToQuit` flag is `true`.
326 */
327 updateBatteryStateImage(imgName, synchronous = _data.aboutToQuit)
328 {
329 // Do nothing if images are disabled or this particular image has already been sent for this device
330 if (!Settings.ImagesPath || this.lastImage == imgName)
331 return;
332
333 this.lastImage = imgName;
334 const imgPath = `${Settings.ImagesPath}/${Settings.ImagePrefix}${imgName}${Settings.ImageSuffix}`;
335 // console.debug(LOG_PREFIX, `Sending new image for "${this.stateId}.image" from ${this.battery.percentage}%/${this.battery.charging}:`, imgPath);
336
337 if (synchronous) {
338 try { this.updateImageState(File.read(imgPath, FS.O_BIN)); }
339 catch (ex) { console.exception(ex); }
340 return;
341 }
342
343 File.readAsync(imgPath, FS.O_BIN)
344 .then(this.updateImageState)
345 .catch(console.exception);
346 }
347
348 /** @param img {ArrayBuffer} */
349 updateImageState(img) {
350 if (img?.byteLength)
351 TP.stateUpdateById(`${this.stateId}.image`, img.toBase64());
352 }
353
354} // GHUBDevice class
355
356
357// GHUB device and battery state message parsing
358
359/** Creates a new instance of GHUBDevice from a GHUB "logi.protocol.devices.Device.Info" type message. */
360function newDevice(devInfo)
361{
362 let name = devInfo.displayName;
363 // Make sure device name is unique. GHUB won't do this for us!
364 let count = 0;
365 Array.from(_data.devices.values()).forEach((d) => { if (d.name.startsWith(name)) ++count; } )
366 if (count)
367 name += ` (${count})`;
368 const d = new GHUBDevice({
369 id: devInfo.id,
370 name: name,
371 fullName: devInfo.extendedDisplayName,
372 type: devInfo.deviceType,
373 stateId: `${STATES_BASE_ID}.${name}`,
374 });
375 console.info(LOG_PREFIX, `Added new device '${d.fullName}' of type ${d.type}`);
376 d.createStates();
377 return d;
378}
379
380/**
381 Processes a list of devices from GHUB and calls `parseDevice()` for each one.
382 `deviceInfos` should be an array of GHUB "logi.protocol.devices.Device.Info" type messages.
383*/
384function parseDeviceList(deviceInfos)
385{
386 if (Array.isArray(deviceInfos)) {
387 for (const dev of deviceInfos)
388 parseDevice(dev);
389 }
390}
391
392/**
393 Parse a device info structure from GHUB "logi.protocol.devices.Device.Info" type message.
394 If the device supports battery status monitoring then it is added to the list of monitored devices.
395 This also detects changes in existing device status, eg. going from ACTIVE to INACTIVE.
396*/
397function parseDevice(devInfo)
398{
399 if (!devInfo?.id) {
400 console.error(LOG_PREFIX, "Missing device data in device info message.");
401 return;
402 }
403 // We only care about devices which have batteries
404 if (!devInfo.capabilities?.hasBatteryStatus) {
405 console.info(LOG_PREFIX, `Skipping device '${devInfo.displayName}' because GHUB says it doesn't have a battery.`);
406 return;
407 }
408 // console.debugf("Processing device info message: %1O", devInfo);
409
410 let d = _data.devices.get(devInfo.id);
411 if (!d)
412 _data.devices.set(devInfo.id, (d = newDevice(devInfo)));
413
414 d.setActive(devInfo.state == "ACTIVE" || devInfo.state == "PRESENT");
415}
416
417/** Parses a GHUB battery status message and updates our monitored device with the new data.
418 `state` should be a "logi.protocol.wireless.Battery" type message object.
419*/
420function parseBatteryState(state)
421{
422 if (!state || !state.deviceId) {
423 console.error(LOG_PREFIX, "Missing device data in battery status message.");
424 return;
425 }
426 const d = _data.devices.get(state.deviceId);
427 if (!d) {
428 console.error(LOG_PREFIX, "Unknown Device ID in battery status message:", state.deviceId);
429 return;
430 }
431 // console.debugf("Processing device battery status message: %2O", payload);
432
433 // update device battery data
434 d.battery.percentage = state.percentage;
435 d.battery.charging = state.charging;
436
437 // Apparently not all devices support "mileage" estimates...
438 // if we already set the mileage in a previous update then it will be non-null,
439 // otherwise check the payload's `batteryMileageSupport` property value
440 if (d.battery.mileage != null || state.batteryMileageSupport == "MILEAGE_SUPPORTED")
441 d.battery.mileage = state.mileage;
442
443 // update timestamp
444 d.battery.lastUpdate = Date.now();
445 // send state updates
446 d.updateBatteryStates();
447}
448
449
450// General status update utilities
451
452/**
453 For all tracked devices, this updates the "Active" state value to "0" and
454 sends the default battery icon image (if images are enabled).
455 This function is called automatically when GHUB gets disconnected (for whatever reason).
456*/
457function deactivateAllDevices()
458{
459 for (const d of _data.devices.values())
460 d.setActive(false);
461}
462
463/** Updates the "GHUB Connection Status" state value. Called when the WebSocket to GHUB connects or disconnects.
464 `active` meaning is: 0 = disconnected; 1 = connecting; 2 = connected
465 If `active` is 0 then also calls `deactivateAllDevices()`. */
466function updateGHubStatus(active) {
467 TP.stateUpdateById(_data.ghubStatusStateId, active+"");
468 if (!active)
469 deactivateAllDevices();
470}
471
472
473// WebSocket open/close/reconnect functions
474
475/**
476 Attempts to open a WebSocket connection to GHUB.
477 @param cb Optional callback to run after a successful socket connection.
478 This is connected to the WebSocket's "opened" event.
479*/
480function open(cb = null)
481{
482 if (!_data.init)
483 init();
484 if (_data.ws && !isConnected() && _data.ws.readyState != WebSocket.CONNECTING) {
485 console.info(LOG_PREFIX, "Connecting to G HUB @", GHUB_WS_URL);
486 cancelConnectionAttempt();
487 updateGHubStatus(1);
488 _data.ws.open(cb);
489 }
490}
491
492/** Closes an open WebSocket connection to GHUB. */
493function close()
494{
495 if (_data.ws && _data.ws.readyState < WebSocket.CLOSING)
496 _data.ws.close();
497}
498
499/** Resets reconnection timer and attempts to open a connection. Called from timer. */
500function attempConnection() {
501 _data.reconnectTimerId = null;
502 open();
503}
504
505/** Cancels re-connection timer if one has been started. */
506function cancelConnectionAttempt() {
507 if (_data.reconnectTimerId) {
508 clearTimeout(_data.reconnectTimerId);
509 _data.reconnectTimerId = null;
510 }
511}
512
513/** Possibly schedules a re-connection attempt if auto-connection is enabled and retry count hasn't been exceeded.
514 Cancels any currently pending reconnection timer first. */
515function scheduleConnectionAttempt() {
516 cancelConnectionAttempt();
517 if (Settings.ConnectRetryTimeSec > 0 && (Settings.ConnectRetryMaxCount <= 0 || _data.reconnectCount <= Settings.ConnectRetryMaxCount)) {
518 ++_data.reconnectCount;
519 _data.reconnectTimerId = setTimeout(attempConnection, Settings.ConnectRetryTimeSec * 1000);
520 console.info(LOG_PREFIX, `Attempting to re-connect in ${Settings.ConnectRetryTimeSec} seconds...`);
521 }
522}
523
524/** Plugin shutdown event handler, runs when plugin sends an 'aboutToQuit' event. */
525function onAboutToQuit() {
526 _data.aboutToQuit = true;
527 updateGHubStatus(0);
528 saveSettings();
529}
530
531// GHUB WebSocket message senders
532
533/**
534 Send a message to GHUB WebSockets server.
535 `message` should be an object with, at minimum, `verb` and `path` properties.
536 If `connectIfClosed` is `true`, this will attempt to open a new connection
537 to the GHUB server if one isn't already opened, then send the message once connected.
538*/
539function send(message, connectIfClosed = true)
540{
541 if (!_data?.ws)
542 return;
543 if (!('verb' in message) || !('path' in message))
544 throw new TypeError("'verb' and 'path' message properties are required for send(message)");
545
546 if (!isConnected()) {
547 if (connectIfClosed)
548 open(() => send(message));
549 else
550 console.error(LOG_PREFIX, "WebSocket connection is closed.");
551 return;
552 }
553 if (!('msgId' in message))
554 message.msgId = "";
555 _data.ws.send(JSON.stringify(message));
556}
557
558/**
559 Subscribe to GHUB devices list change notifications.
560 This is done automatically when a new connection to GHUB is opened.
561*/
562function subscribeDevices() {
563 send({
564 verb: "SUBSCRIBE",
565 path: "/devices/state/changed"
566 });
567}
568
569/**
570 Requests an battery status update from GHUB for the given GHUBDevice.
571 @param d {GHUBDevice}
572*/
573function getDeviceBatteryState(d) {
574 if (!d?.id)
575 return;
576 send({
577 verb: "GET",
578 path: `/battery/${d.id}/state`
579 });
580}
581
582/**
583 Subscribes to GHUB battery status update events for the given GHUBDevice.
584 Fires only if already currently connected to GHUB.
585 @param d {GHUBDevice}
586*/
587function subscribeDeviceBatteryState(d, sub = true) {
588 if (!d?.id || !isConnected())
589 return;
590 send({
591 verb: sub ? "SUBSCRIBE" : "UNSUBSCRIBE",
592 path: `/battery/${d.id}/state/changed`
593 });
594}
595
596/** Subscribes to general GHUB battery status update events (not for any particular device).
597 Currently unused since per-device subscriptions seem to be enough. */
598function subscribeBatteryState(sub = true) {
599 send({
600 verb: sub ? "SUBSCRIBE" : "UNSUBSCRIBE",
601 path: `/battery/state/changed`
602 });
603}
604
605// WebSocket event handlers
606
607/** This handler runs when a WebSocket connection to GHUB is successfully opened. */
608function onWsOpened()
609{
610 console.info(LOG_PREFIX, 'connected to G HUB.');
611 _data.reconnectCount = 0;
612 updateGHubStatus(2);
613 listDevices();
614 subscribeDevices();
615 // subscribeBatteryState();
616}
617
618/** This handler runs when a WebSocket connection to GHUB is closed. */
619function onWsClosed(event) {
620 console.info(LOG_PREFIX, 'disconnected.');
621 updateGHubStatus(0);
622 // console.dir(event);
623}
624
625/** Handles WebSocket errors. */
626function onWsError(event) {
627 console.error(LOG_PREFIX, 'WebSocket Error:', event, "code:", event.code);
628 // Possibly retry connection on some error types
629 if (event.code == Socket.ConnectionRefusedError || event.code == Socket.RemoteHostClosedError)
630 scheduleConnectionAttempt();
631}
632
633/** Main handler of all incoming WebSocket messages from GHUB. */
634function onWsMessage(event)
635{
636 try {
637 const msg = JSON.parse(event.data);
638 if (!msg.verb) {
639 console.warn(LOG_PREFIX, "Message is missing 'verb' property:", event.data);
640 return;
641 }
642 if (msg.verb != 'BROADCAST' && msg.verb != 'GET') {
643 console.info(LOG_PREFIX, "Skipping un-requested message type:", msg.verb);
644 return;
645 }
646 if (msg.result && msg.result.code != "SUCCESS") {
647 console.warn(LOG_PREFIX, "Got error response from G HUB:", event.data);
648 return;
649 }
650 if (!msg.payload) {
651 console.warn(LOG_PREFIX, "G HUB message is missing payload:", event.data);
652 return;
653 }
654 // printf("%s Message received: %12o", LOG_PREFIX, msg);
655 if (msg.path == '/devices/list')
656 parseDeviceList(msg.payload.deviceInfos);
657 else if (msg.path == '/devices/state/changed')
658 parseDevice(msg.payload);
659 else if (msg.path.match(/battery\/.*?\/?state/))
660 parseBatteryState(msg.payload);
661 else
662 console.warn(`Received unknown message with path:`, msg.path);
663 }
664 catch (ex) {
665 console.errorf("%s Error decoding message data: %s\nMessage data was:\n%2O", LOG_PREFIX, ex, event.data);
666 }
667};
668
669//
670
671/**
672 Initialize required variables and objects, such as the WebSocket client used by this script.
673 Runs only once after module is first loaded.
674 Note that this does _not_ automatically open a connection to GHUB.
675 The `open()` function must be called to do that (which will run `init()` first if needed).
676*/
677function init()
678{
679 if (_data.init)
680 return;
681 console.info(LOG_PREFIX, "initializing...");
682
683 // Get the current script instance object (`DynamicScript` type);
684 _data.instance = DSE.currentInstance();
685 if (!_data.instance) {
686 console.error(LOG_PREFIX, "Something went wrong, could not find current DynamicScript instance!");
687 return;
688 }
689 _data.init = true;
690
691 // Get saved settings from the stored persistent data object, if any
692 const ds = _data.instance.dataStore;
693 if (ds.Settings) {
694 console.info(LOG_PREFIX, "Restoring saved settings");
695 Object.assign(Settings, ds.Settings);
696 }
697 else {
698 console.info(LOG_PREFIX, "No saved settings found, creating new data store");
699 ds.Settings = structuredClone(Settings);
700 }
701
702 // Create a state to reflect current overall GHUB connection status.
703 _data.ghubStatusStateId = `${STATES_BASE_ID}.ghub.active`;
704 TP.stateCreate(_data.ghubStatusStateId, SCRIPT_NAME, "G HUB Connection Status", "0");
705
706 // Connect to plugin quit event so we can mark all devices as inactive.
707 DSE.aboutToQuit.connect(onAboutToQuit);
708
709 // Create WebSocket instance for GHUB connection and connect our event listeners.
710 // The "json" subprotocol and the origin value are required.
711 _data.ws = new WebSocket(GHUB_WS_URL, "json", { origin: "file://" });
712 // Connect event listeners.
713 _data.ws.closed.connect(onWsClosed);
714 _data.ws.error.connect(onWsError);
715 _data.ws.message.connect(onWsMessage);
716 _data.ws.opened.connect(onWsOpened);
717
718 console.info(LOG_PREFIX, "initialization completed.");
719}
720
721// Runtime local data storage
722var _data =
723{
724 init: false, // initialization flag, set to true after first time `init()` is called.
725 instance: null, // the primary DynamicScript instance, set in init()
726 ws: null, // WebSocket instance
727 devices: new Map(), // tracked devices indexed by deviceId
728 reconnectTimerId: null, // timer used to schedule connection attempts
729 reconnectCount: 0, // how many re-connection attempts have been made
730 ghubStatusStateId: "", // state ID for GHUB active/inactive status
731 aboutToQuit: false, // flag indicating if plugin is about to exit, forces synchronous state updates
732};
733
734
735// Initialize on first load (or import, since this is a module).
736if (!_data.init)
737 init();
The DSE object contains constants and functions related to the plugin environment....
Definition: DSE.h:50
void aboutToQuit()
This event is emitted just before the plugin process exits, but allows one cycle for event handlers t...
DynamicScript * currentInstance()
Returns the current script Instance. This is equivalent to calling DSE.instance(DSE....
Definition: DSE.h:279
string currentInstanceName
The Instance Name of the script currently being evaluated, as specified in the corresponding Touch Po...
Definition: DSE.h:133
DynamicScript instance(String name)
Returns the script instance with given name, if any, otherwise returns null.
string VALUE_STATE_PREFIX
The prefix added by the plugin to an script instance's Name to form a unique State ID,...
Definition: DSE.h:73
string TP_USER_DATA_PATH
Contains the value of the current system user's Touch Portal settings directory path....
Definition: DSE.h:98
Date prototype extension methods
The Dir class provides static functions for directory operations, which are accessed in JS with the D...
Definition: Dir.h:43
string normalize(string &path)
Returns the canonical path, i.e. a path without symbolic links or redundant "." or "....
Definition: Dir.h:130
The FS namespace holds constants related to file system tasks. They are referenced in JavaScript as F...
Definition: FS.h:17
@ O_BIN
("b") Handle file in binary mode (returns results as byte arrays).
Definition: FS.h:40
The File class provides access to... files!
Definition: File.dox:412
< Promise|void > readAsync(string file,< Options|string|FS.OpenMode|Function|undefined > options,< Function|undefined > callback)
Read the contents of a file asynchronously using a callback or Promise to handle the results.
Instances of the GHUBDevice class are used to track detected GHUB devices.
Definition: ghub-monitor.mjs:207
updateBatteryStateImage(imgName, synchronous=_data.aboutToQuit)
Sends a TP state update for this device with image data read from imgName.
Definition: ghub-monitor.mjs:327
createStates()
Creates all TP States for this device.
Definition: ghub-monitor.mjs:278
resetBatteryData(withUpdate=false)
Resets battery data to default "unknown" values and optionally sends TP state updates for this device...
Definition: ghub-monitor.mjs:267
updateBatteryStates()
Sends TP battery state updates for this device.
Definition: ghub-monitor.mjs:305
setActive(active)
Sets the active state of this device.
Definition: ghub-monitor.mjs:240
updateImageState(img)
Definition: ghub-monitor.mjs:349
void callLater(Function function, any ... arguments)
Use this function to eliminate redundant calls to a function.
The global TP namespace is used as a container for all Touch Portal API methods, which are invoked wi...
Definition: touchportal.dox:6
void stateUpdateById(string id, string value)
Send a State update to Touch Portal for any arbitrary state/value matching the id,...
void stateCreate(string id, string parentGroup, string description, string defaultValue="", boolean forceUpdate=false, int delayMs=0)
Create a new Touch Portal dynamic State (if it doesn't already exist).
Implementation of the WebSocket Web API
Definition: WebSocket.h:128
@ CONNECTING
(0) Socket has been created but the connection is not yet open.
Definition: WebSocket.h:209
@ OPEN
(1) The connection is open and ready to communicate.
Definition: WebSocket.h:210
@ CLOSING
(2) The connection is in the process of closing.
Definition: WebSocket.h:211
The Socket namespace contains constants relating to network socket operations.
Definition: fetch.dox:575

Trademark Notice

Logitech, Logi, G HUB, and their logos are trademarks or registered trademarks of Logitech Europe S.A. and/or its affiliates in the United States and/or other countries.