TouchPortalAPI.client
1__copyright__ = """ 2 This file is part of the TouchPortal-API project. 3 Copyright (c) TouchPortal-API Developers/Contributors 4 Copyright (C) 2021 DamienS 5 All rights reserved. 6 7 This program is free software: you can redistribute it and/or modify 8 it under the terms of the GNU General Public License as published by 9 the Free Software Foundation, either version 3 of the License, or 10 (at your option) any later version. 11 12 This program is distributed in the hope that it will be useful, 13 but WITHOUT ANY WARRANTY; without even the implied warranty of 14 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 GNU General Public License for more details. 16 17 You should have received a copy of the GNU General Public License 18 along with this program. If not, see <https://www.gnu.org/licenses/>. 19""" 20 21import socket 22import selectors 23import json 24from pyee import ExecutorEventEmitter 25from concurrent.futures import Executor, ThreadPoolExecutor 26from threading import Event, Lock 27from .logger import Logger 28from .tools import Tools 29from typing import TextIO 30import sys 31 32__all__ = ['Client', 'TYPES'] 33 34class TYPES: 35 """ 36 Event type enumerations. These correspond to the message types Touch Portal may send. 37 Event handler callbacks receive one parameter, which is the JSON data structure sent 38 from TP and then converted to a corresponding Python object using `json.loads()`. 39 Usage example: 40 ```py 41 import TouchPortalAPI as TP 42 43 client = TP.Client("myplugin") 44 45 @client.on(TP.TYPES.onConnect) 46 # equivalent to: @client.on('info') 47 def onConnect(data): 48 print(data) 49 50 # alternatively, w/out decorators: 51 def onAction(data): 52 print(data) 53 54 client.on(TP.TYPES.onAction, onAction) 55 ``` 56 """ 57 onHold_up = 'up' 58 """ When the action is used in a hold functionality and the user releases the Touch Portal button (Api 3.0+) """ 59 onHold_down = 'down' 60 """ When the action is used in a hold functionality and the user presses the Touch Portal button down (Api 3.0+) """ 61 onConnect = 'info' 62 """ Info message returned after successful pairing. """ 63 onAction = 'action' 64 """ When actions are triggered by the user (Api 1.0+) """ 65 onListChange = 'listChange' 66 """ When a user makes a change in one of your lists (Api 1.0+) """ 67 onConnectorChange = 'connectorChange' 68 """ When a connector is used in a slider functionality (connector) (Api 4.0+) """ 69 onShutdown = 'closePlugin' 70 """ When Touch Portal tries to close your plug-in (Api 2.0+) """ 71 onBroadcast = 'broadcast' 72 """ When Touch Portal broadcasts a message (Api 3.0+) """ 73 onSettingUpdate = 'settings' 74 """ When the plugin's Settings have been updated (by user or from the plugin itself) (Api 3.0+) """ 75 onNotificationOptionClicked = "notificationOptionClicked" 76 """ When a user clicks on a notification action (Api 4.0+) """ 77 shortConnectorIdNotification = 'shortConnectorIdNotification' 78 """ When creating new connector for the first time It will generate shortid and It will send as an event (Api 5.0+) """ 79 allMessage = 'any' 80 """ Special event handler which will receive **all** messages from TouchPortal. """ 81 onError = 'error' 82 """ Special event emitted when any other event callback raises an exception. 83 For this particular event, the parameter passed to the callback handler will be an `Exception` object. 84 See also `pyee.ExecutorEventEmitter.error` event. 85 """ 86 87class Client(ExecutorEventEmitter): 88 """ 89 A TCP/IP client for [Touch Portal API](https://www.touch-portal.com/api) plugin integration. 90 Implements a [pyee.ExecutorEventEmitter](https://pyee.readthedocs.io/en/latest/#pyee.ExecutorEventEmitter). 91 92 After an initial connection to a Touch Portal desktop application "server," the client 93 implements a send/receive event loop while maintaining the open sockets. Messages between 94 TP and the plugin are exchanged asynchronously, with all sending methods possibly returning 95 before the actual data is sent. 96 97 Messages from Touch Portal are delivered to the plugin script via event handler callbacks, 98 which can be either individual callbacks per message type, and/or a single handler for all 99 types of messages. The callbacks are executed in separate Thread(s), using a pool of up to 100 any number of concurrent threads as needed (if using more than one thread, the plugin 101 code itself is responsible for thread safety of its internal data). Please check the 102 `pyee.EventEmitter` documentation for general information on how events are handled 103 (which can be either via function decorators or via the inherited `Client.on()` method). 104 105 Generated event names correspond to Touch Portal API message types, one for each type 106 as defined in the TP API documentation (eg. "action" or "listChange") as well as one event 107 which is generated for all message types ("any"). Alternately, for a more formal approach, 108 the corresponding members of the `TYPES` class could be used instead of string names 109 (eg. `TYPES.onAction` or `TYPES.onListChange`). 110 111 Any errors raised within event listeners/handlers are trapped and then reported in the 112 inherited "error" event (from pyee.ExecutorEventEmitter), aka `TYPES.onError`. 113 """ 114 TPHOST = '127.0.0.1' 115 """ TP plugin server host IPv4 address. """ 116 TPPORT = 12136 117 """ TP plugin server host IPv4 port number. """ 118 RCV_BUFFER_SZ = 4096 119 """ [B] incoming data buffer size. """ 120 SND_BUFFER_SZ = 32**4 121 """ [B] maximum size of send data buffer (1MB). """ 122 SOCK_EVENT_TO = 1.0 123 """ [s] maximum wait time for socket events (blocking timeout for selector.select()). """ 124 125 def __init__(self, pluginId:str, 126 sleepPeriod:float = 0.01, 127 autoClose:bool = False, 128 checkPluginId:bool = True, 129 updateStatesOnBroadcast:bool = False, 130 maxWorkers:int = None, 131 executor:Executor = None, 132 useNamespaceCallbacks:bool = False, 133 loggerName:str = None, 134 logLevel:str = "INFO", 135 logStream:TextIO = sys.stderr, 136 logFileName:str = None): 137 """ 138 Creates an instance of the client. 139 140 Args: 141 `pluginId`: ID string of the TouchPortal plugin using this client. **Required.** 142 `sleepPeriod`: Seconds to sleep the event loop between socket read events. Default: 0.01 143 `autoClose`: If `True` then this client will automatically disconnect when a `closePlugin` message is received from TP. 144 Default is `False`. 145 `checkPluginId`: Validate that `pluginId` matches ours in any messages from TP which contain one (such as actions). 146 Default is `True`. 147 `updateStatesOnBroadcast`: Re-send all cached State values whenever user switches TP page. 148 Default is `True`. 149 `maxWorkers`: Maximum worker threads to run concurrently for event handlers. 150 Default of `None` creates a default-constructed `ThreadPoolExecutor`. 151 `executor`: Passed to `pyee.ExecutorEventEmitter`. By default this is a default-constructed 152 [ThreadPoolExecutor](https://docs.python.org/3/library/concurrent.futures.html#concurrent.futures.ThreadPoolExecutor), 153 optionally using `maxWorkers` concurrent threads. 154 `useNamespaceCallbacks`: use NamespaceCallback as message handler 155 Default is `False` meaning It will send normal json 156 `True` meaning It will automatically convert json to namespace to make easier access value 157 eg json: data['actionId']['value'] and namespace would be data.actionId.value 158 `loggerName`: Optional name for the Logger to be used by the Client. 159 Default of `None` creates (or uses, if it already exists) the "root" (default) logger. 160 `logLevel`: Desired minimum logging level, one of: 161 "CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG" (or equivalent Py `logging` module Level constants), 162 or `None` to disable all logging. The level can also be set at runtime via `setLogLevel()` method. 163 Default is "INFO". 164 `logStream`: Set a stream to write log messages to, or `None` to disable stream (console) logging. 165 The stream logger can also be modified at runtime via `setLogStream()` method. 166 Default is `sys.stderr`. 167 `logFileName`: A file name (with optional path) for log messages. Paths are relative to current working directory. 168 Pass `None` or empty string to disable file logging. The log file is rotated once per day and the last 7 169 logs are preserved (older ones are deleted). The file logger can also be modified at runtime via `setLogFile()` method. 170 Default is `None` (file logging is disabled). 171 """ 172 if not executor and maxWorkers: 173 executor = ThreadPoolExecutor(max_workers=maxWorkers) 174 super(Client, self).__init__(executor=executor) 175 self.pluginId = pluginId 176 self.sleepPeriod = sleepPeriod 177 self.autoClose = autoClose 178 self.checkPluginId = checkPluginId 179 self.updateStatesOnBroadcast = updateStatesOnBroadcast 180 self.useNamespaceCallbacks = useNamespaceCallbacks 181 self.log = Logger(name=loggerName, level=logLevel, filename=logFileName, stream=logStream) 182 self.client = None 183 self.selector = None 184 self.currentStates = {} 185 self.currentSettings = {} 186 self.choiceUpdateList = {} 187 self.shortIdTracker = {} 188 self.__heldActions = {} 189 self.__stopEvent = Event() # main loop inerrupt 190 self.__stopEvent.set() # not running yet 191 self.__dataReadyEvent = Event() # set when __sendBuffer has data 192 self.__writeLock = Lock() # mutex for __sendBuffer 193 self.__sendBuffer = bytearray() 194 self.__recvBuffer = bytearray() 195 # explicitly disable logging if logLevel `None` was passed (Logger() c'tor ignores `None` log level) 196 if not logLevel: 197 self.log.setLogLevel(None) 198 199 def __buffered_readLine(self): 200 try: 201 # Should be ready to read 202 data = self.client.recv(self.RCV_BUFFER_SZ) 203 except BlockingIOError: 204 pass # Resource temporarily unavailable (errno EWOULDBLOCK) 205 except OSError: 206 raise # No connection 207 else: 208 if data: 209 lines = [] 210 self.__recvBuffer += data 211 while (i := self.__recvBuffer.find(b'\n')) > -1: 212 lines.append(self.__recvBuffer[:i]) 213 del self.__recvBuffer[:i+1] 214 return lines 215 else: 216 # No connection 217 self.__raiseException("Peer closed the connection.", RuntimeError) 218 return [] 219 220 def __write(self): 221 if self.client and self.__sendBuffer and self.__getWriteLock(): 222 try: 223 # Should be ready to write 224 sent = self.client.send(self.__sendBuffer) 225 except BlockingIOError: 226 pass # Resource temporarily unavailable (errno EWOULDBLOCK) 227 except OSError: 228 raise # No connection 229 else: 230 del self.__sendBuffer[:sent] 231 finally: 232 if not self.__sendBuffer: 233 self.__dataReadyEvent.clear() 234 self.__writeLock.release() 235 236 def __run(self): 237 try: 238 while not self.__stopEvent.is_set(): 239 events = self.selector.select(timeout=self.SOCK_EVENT_TO) 240 if self.__stopEvent.is_set(): # may be set while waiting for selector events (unlikely) 241 break 242 for _, mask in events: 243 if (mask & selectors.EVENT_READ): 244 for line in self.__buffered_readLine(): 245 self.__processMessage(line) 246 if (mask & selectors.EVENT_WRITE): 247 self.__write() 248 # Sleep for period or until there is data in the write buffer. 249 # In theory if data is constantly avaiable, this could block, 250 # in which case it may be better to self.__stopEvent.wait() 251 if self.__dataReadyEvent.wait(self.sleepPeriod): 252 continue 253 continue 254 except Exception as e: 255 self.__die(f"Exception in client event loop: {repr(e)}", e) 256 257 def __processMessage(self, message: bytes): 258 data = json.loads(message.decode()) 259 if data and (act_type := data.get('type')): 260 if self.checkPluginId and (pid := data.get('pluginId')) and pid != self.pluginId: 261 return 262 if act_type == TYPES.onShutdown: 263 if self.autoClose: self.__close() 264 elif act_type == TYPES.onHold_down and (aid := data.get('actionId')): 265 self.__heldActions[aid] = True 266 elif act_type == TYPES.onHold_up and (aid := data.get('actionId')): 267 del self.__heldActions[aid] 268 elif act_type == TYPES.onBroadcast and self.updateStatesOnBroadcast: 269 for key, value in self.currentStates.items(): 270 self.__stateUpdate(key, value, True) 271 elif act_type == TYPES.shortConnectorIdNotification: 272 self.shortIdTracker[data["connectorId"]] = data['shortId'] 273 self.__emitEvent(act_type, data) 274 275 def __emitEvent(self, ev, data): 276 if not self.useNamespaceCallbacks: 277 self.emit(ev, data) 278 self.emit(TYPES.allMessage, data) 279 else: 280 convertedData = Tools.nested_conversion(data) # No need to call this twice 281 self.emit(ev, convertedData) 282 self.emit(TYPES.allMessage, convertedData) 283 284 def __open(self): 285 try: 286 self.client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 287 self.selector = selectors.DefaultSelector() 288 self.client.connect((self.TPHOST, self.TPPORT)) 289 except Exception: 290 self.selector = self.client = None 291 raise 292 self.client.setblocking(False) 293 self.selector.register(self.client, (selectors.EVENT_READ | selectors.EVENT_WRITE)) 294 self.__stopEvent.clear() 295 296 def __close(self): 297 self.log.info(f"{self.pluginId} Disconnected from TouchPortal") 298 self.__stopEvent.set() 299 if self.__writeLock.locked(): 300 self.__writeLock.release() 301 self.__sendBuffer.clear() 302 if not self.selector: 303 return 304 if self.selector.get_map(): 305 try: 306 self.selector.unregister(self.client) 307 except Exception as e: 308 self.log.warning(f"Error in selector.unregister(): {repr(e)}") 309 try: 310 self.client.shutdown(socket.SHUT_RDWR) 311 self.client.close() 312 except OSError as e: 313 self.log.warning(f"Error in socket.close(): {repr(e)}") 314 finally: 315 # Delete reference to socket object for garbage collection, socket cannot be reused anyway. 316 self.client = None 317 self.selector.close() 318 self.selector = None 319 # print("TP Client stopped.") 320 321 def __die(self, msg=None, exc=None): 322 if msg: self.log.info(msg) 323 self.__emitEvent(TYPES.onShutdown, {"type": TYPES.onShutdown}) 324 self.__close() 325 if exc: 326 self.log.critical(repr(exc)) 327 raise exc 328 329 def __getWriteLock(self): 330 if self.__writeLock.acquire(timeout=15): 331 if self.__stopEvent.is_set(): 332 if self.__writeLock.locked(): self.__writeLock.release() 333 return False 334 return True 335 self.__die(exc=RuntimeError("Send buffer mutex deadlock, cannot continue.")) 336 return False 337 338 def __raiseException(self, message, exc = TypeError): 339 self.log.error(message) 340 raise exc(message) 341 342 def isConnected(self): 343 """ 344 Returns `True` if the Client is connected to Touch Portal, `False` otherwise. 345 """ 346 return not self.__stopEvent.is_set() 347 348 def setLogLevel(self, level): 349 """ Sets the minimum logging level. `level` can be one of one of: 350 "CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG" (or equivalent Py `logging` module Level constants), 351 or `None` to disable all logging. 352 """ 353 self.log.setLogLevel(level) 354 355 def setLogStream(self, stream): 356 """ Set a destination for the StreamHandler logger. `stream` should be a file stream type (eg. os.stderr) or `None` to disable. """ 357 self.log.setStreamDestination(stream) 358 359 def setLogFile(self, fileName): 360 """ Set a destination for the File logger. `filename` should be a file name (with or w/out a path) or `None` to disable the file logger. """ 361 self.log.setFileDestination(fileName) 362 363 def isActionBeingHeld(self, actionId:str): 364 """ 365 For an Action with ID of `actionId` that can be held (`hasHoldFunctionality` is true), 366 this method returns `True` while it is being held and `False` otherwise. 367 """ 368 return actionId in self.__heldActions 369 370 def createState(self, stateId:str, description:str, value:str, parentGroup:str = None): 371 """ 372 This will create a TP State at runtime. `stateId`, `description`, and `value` (`value` becomes the State's default value) are all required except `parentGroup` which allow you to create states in a `folder`. 373 """ 374 if stateId and description and value != None: 375 if stateId not in self.currentStates: 376 self.send({"type": "createState", "id": stateId, "desc": description, "defaultValue": value, "parentGroup": parentGroup}) 377 self.currentStates[stateId] = value 378 else: 379 self.stateUpdate(stateId, value) 380 381 def createStateMany(self, states:list): 382 """ 383 Convenience function to create several States at once. `states` should be an iteratable of `dict` types 384 in the form of `{'id': "StateId", 'desc': "Description", 'value': "Default Value"}` and optionally `'parentGroup': "Value"` which will make state `folder`. 385 """ 386 try: 387 for state in states: 388 if isinstance(state, dict): 389 self.createState(state.get('id', ""), state.get('desc', ""), state.get('value', ""), state.get("parentGroup", "")) 390 else: 391 self.__raiseException(f'createStateMany() requires a list of dicts, got {type(state)} instead.') 392 except: 393 self.__raiseException(f"createStateMany() requires an iteratable, got {type(states)} instead.") 394 395 def removeState(self, stateId:str, validateExists = True): 396 """ 397 This removes a State that has been created at runtime. `stateId` is the ID to remove. 398 If `validateExists` is True (default) this method will raise an Exception if the `stateId` 399 does not exist in the current state cache. 400 """ 401 if stateId and stateId in self.currentStates: 402 self.send({"type": "removeState", "id": stateId}) 403 self.currentStates.pop(stateId) 404 elif validateExists: 405 self.__raiseException(f"{stateId} Does not exist.", Exception) 406 407 def removeStateMany(self, states:list): 408 """ 409 Convenience function to remove several States at once. `states` should be an iteratable of state ID strings. 410 This method does not validate if the states exist in the current state cache. See also: `removeState`. 411 """ 412 try: 413 for state in states: 414 self.removeState(state, False) 415 except TypeError: 416 self.__raiseException(f'removeStateMany() requires an iteratable, got {type(states)} instead.') 417 418 def choiceUpdate(self, choiceId:str, values:list): 419 """ 420 This updates the list of choices in a previously-declared TP State with id `stateId`. 421 See TP API reference for details on updating list values. 422 """ 423 if choiceId: 424 if isinstance(values, list): 425 self.send({"type": "choiceUpdate", "id": choiceId, "value": values}) 426 self.choiceUpdateList[choiceId] = values 427 else: 428 self.__raiseException(f'choiceUpdate() values argument needs to be a list not a {type(values)}') 429 430 def choiceUpdateSpecific(self, stateId:str, values:list, instanceId:str): 431 """ 432 This updates a list of choices in a specific TP Item Instance, specified in `instanceId`. 433 See TP API reference for details on updating specific instances. 434 """ 435 if stateId and instanceId: 436 if isinstance(values, list): 437 self.send({"type": "choiceUpdate", "id": stateId, "instanceId": instanceId, "value": values}) 438 else: 439 self.__raiseException(f'choiceUpdateSpecific() values argument needs to be a list not a {type(values)}') 440 441 def settingUpdate(self, settingName:str, settingValue): 442 """ 443 This updates a value named `settingName` in your plugin's Settings to `settingValue`. 444 """ 445 if settingName and settingName not in self.currentSettings or self.currentSettings[settingName] != settingValue: 446 self.send({"type": "settingUpdate", "name": settingName, "value": settingValue}) 447 self.currentSettings[settingName] = settingValue 448 449 def stateUpdate(self, stateId:str, stateValue:str): 450 """ 451 This updates a value in ether a pre-defined static State or a dynamic State created in runtime. 452 """ 453 self.__stateUpdate(stateId, stateValue, False) 454 455 def __stateUpdate(self, stateId:str, stateValue:str, forced:bool): 456 if stateId: 457 if forced or stateId not in self.currentStates or self.currentStates[stateId] != stateValue: 458 self.send({"type": "stateUpdate", "id": stateId, "value": stateValue}) 459 self.currentStates[stateId] = stateValue 460 461 def stateUpdateMany(self, states:list): 462 """ 463 Convenience function to update several states at once. 464 `states` should be an iteratable of `dict` types in the form of `{'id': "StateId", 'value': "The New Value"}`. 465 """ 466 try: 467 for state in states: 468 if isinstance(state, dict): 469 self.stateUpdate(state.get('id', ""), state.get('value', "")) 470 else: 471 self.__raiseException(f'StateUpdateMany() requires a list of dicts, got {type(state)} instead.') 472 except TypeError: 473 self.__raiseException(f"createStateMany() requires an iteratable, got {type(states)} instead.") 474 475 def showNotification(self, notificationId:str, title:str, msg:str, options:list): 476 """ 477 This method allows your plugin to send a notification to Touch Portal with custom title, message body and available user action(s). 478 Requires TP SDK v4.0 or higher. 479 480 Args: 481 `notificationId`: Unique ID of this notification. 482 `title`: The notification title. 483 `msg`: The message body text that is shown in the notifcation. 484 `options`: List of options (actions) for the notification. Each option should be a `dict` type with `id` and `title` keys. 485 """ 486 if notificationId and title and msg and options and isinstance(options, list): 487 for option in options: 488 if 'id' not in option.keys() or 'title' not in option.keys(): 489 self.__raiseException("all options require id and title keys") 490 self.send({ 491 "type": "showNotification", 492 "notificationId": str(notificationId), 493 "title": str(title), 494 "msg": str(msg), 495 "options": options 496 }) 497 498 def __findShortId(self, connectorId:str): 499 """ 500 This method is used internally to find the short ID of a connector. 501 502 UNUSED code. 503 """ 504 for cid in list(self.shortIdTracker.keys()): 505 if (splitCId := connectorId.split("|")) and splitCId[0] == cid.split("|")[0]: 506 if all(x in splitCId for x in splitCId[1:] if x in cid.split("|")[1:]): 507 return self.shortIdTracker[connectorId] 508 return None 509 510 def shortIdUpdate(self, shortId:str, connectorValue:int): 511 """ 512 This allows you to update slider position value using shortId which TouchPortal will broadcast. 513 514 Args: 515 `shortId`: a shortId is a id that is mapped to connectorId by TouchPortal 516 `connectorValue`: A integer between 0-100 517 518 """ 519 if 0 <= connectorValue <= 100: 520 self.send({ 521 "type": "connectorUpdate", 522 "shortId": shortId, 523 "value": str(connectorValue) 524 }) 525 526 def connectorUpdate(self, connectorId:str, connectorValue:int): 527 """ 528 This allows you to update slider position value. 529 530 Args: 531 `connectorId`: Cannot be longer then 200 characters. 532 connectorId have syntax that you need to follow https://www.touch-portal.com/api/index.php?section=connectors 533 Also according to that site It requires you to have prefix "pc_yourPluginId_" + connectorid however This already provide 534 you the prefix and the pluginId so you just need you take care the rest eg connectorid|setting1=aValue 535 `connectorValue`: Must be an integer between 0-100. 536 537 Note: This method will automatically looking for shortId using gaven connectorId however if It cannot find shortId it will just send connectorId 538 """ 539 if not isinstance(connectorId, str): 540 self.__raiseException(f"connectorId needs to be a str not a {type(connectorId)}") 541 if not isinstance(connectorValue, int): 542 self.__raiseException(f"connectorValue requires a int not {type(connectorValue)}") 543 if 0 <= connectorValue <= 100: 544 if f"pc_{self.pluginId}_{connectorId}" in self.shortIdTracker: 545 self.shortIdUpdate(self.shortIdTracker[connectorId], connectorValue) 546 else: 547 self.send({ 548 "type": "connectorUpdate", 549 "connectorId": f"pc_{self.pluginId}_{connectorId}", 550 "value": str(connectorValue) 551 }) 552 else: 553 self.__raiseException(f"connectorValue needs to be between 0-100 not {connectorValue}") 554 555 def updateActionData(self, instanceId:str, stateId:str, minValue, maxValue): 556 """ 557 This allows you to update Action Data in one of your Action. Currently TouchPortal only supports changing the minimum and maximum values in numeric data types. 558 """ 559 self.send({"type": "updateActionData", "instanceId": instanceId, "data": {"minValue": minValue, "maxValue": maxValue, "id": stateId, "type": "number"}}) 560 561 def getChoiceUpdatelist(self): 562 """ 563 This will return a dict that `choiceUpdate` registered. 564 example return value `{"choiceUpdateid1": ["item1", "item2", "item3"], "exampleChoiceId": ["Option1", "Option2", "Option3"]}` 565 566 You should use this to verify before Updating the choice list 567 **Note** This is the same as TPClient.choiceUpdateList variable *DO NOT MODIFY* TPClient.choiceUpdateList unless you know what your doing 568 """ 569 return self.choiceUpdateList 570 571 def getStatelist(self): 572 """ 573 This will return a dict that have key pair of states that you last updated. 574 Example retun value `{"stateId1": "value1", "stateId2": "value2", "stateId3": "value3"}` 575 This is used to keep track of all states. It will be automatically updated when you update states 576 **Note** This is the same as TPClient.currentState variable *DO NOT MODIFY* TPClient.currentState unless you know what your doing 577 """ 578 return self.currentStates 579 580 def getSettinghistory(self): 581 """ 582 This will return a dict that have key pair of setting value that you updated previously. 583 584 This is used to track settings value that you have updated previously 585 **Note** This is the same as TPClient.currentSettings variable *DO NOT MODIFY* TPClient.currentSettings unless you know what your doing 586 """ 587 return self.currentSettings 588 589 def send(self, data): 590 """ 591 This will try to send any arbitrary Python object in `data` (presumably something `dict`-like) to Touch Portal 592 after serializing it as JSON and adding a `\n`. Normally there is no need to use this method directly, but if the 593 Python API doesn't cover something from the TP API, this could be used instead. 594 """ 595 if not self.isConnected(): 596 self.__raiseException("TP Client not connected to Touch Portal, cannot send commands.", Exception) 597 if self.__getWriteLock(): 598 if len(self.__sendBuffer) + len(data) > self.SND_BUFFER_SZ: 599 self.__writeLock.release() 600 self.__raiseException("TP Client send buffer is full!", ResourceWarning) 601 self.__sendBuffer += (json.dumps(data)+'\n').encode() 602 self.__writeLock.release() 603 self.__dataReadyEvent.set() 604 605 def connect(self): 606 """ 607 Initiate connection to TP Server. 608 If successful, it starts the main processing loop of this client. 609 Does nothing if client is already connected. 610 611 **Note** that `connect()` blocks further execution of your script until one of the following occurs: 612 - `disconnect()` is called in an event handler, 613 - TP sends `closePlugin` message and `autoClose` is `True` 614 - or an internal error occurs (for example Touch Portal disconnects unexpectedly) 615 """ 616 if not self.isConnected(): 617 self.__open() 618 self.send({"type":"pair", "id": self.pluginId}) 619 self.__run() # start the event loop 620 621 def disconnect(self): 622 """ 623 This closes the connection to TP and terminates the client processing loop. 624 Does nothing if client is already disconnected. 625 """ 626 if self.isConnected(): 627 self.__close() 628 629 @staticmethod 630 def getActionDataValue(data:list, valueId:str=None): 631 """ 632 Utility for processing action messages from TP. For example: 633 {"type": "action", "data": [{ "id": "data object id", "value": "user specified value" }, ...]} 634 635 Returns the `value` with specific `id` from a list of action data, 636 or `None` if the `id` wasn't found. If a null id is passed in `valueId` 637 then the first entry which has a `value` key, if any, will be returned. 638 639 Args: 640 `data`: the "data" array from a TP "action", "on", or "off" message 641 `valueId`: the "id" to look for in `data`. `None` or blank to return the first value found. 642 """ 643 if not data: return None 644 if valueId: 645 return next((x.get('value') for x in data if x.get('id', '') == valueId), None) 646 return next((x.get('value') for x in data if x.get('value') != None), None)
88class Client(ExecutorEventEmitter): 89 """ 90 A TCP/IP client for [Touch Portal API](https://www.touch-portal.com/api) plugin integration. 91 Implements a [pyee.ExecutorEventEmitter](https://pyee.readthedocs.io/en/latest/#pyee.ExecutorEventEmitter). 92 93 After an initial connection to a Touch Portal desktop application "server," the client 94 implements a send/receive event loop while maintaining the open sockets. Messages between 95 TP and the plugin are exchanged asynchronously, with all sending methods possibly returning 96 before the actual data is sent. 97 98 Messages from Touch Portal are delivered to the plugin script via event handler callbacks, 99 which can be either individual callbacks per message type, and/or a single handler for all 100 types of messages. The callbacks are executed in separate Thread(s), using a pool of up to 101 any number of concurrent threads as needed (if using more than one thread, the plugin 102 code itself is responsible for thread safety of its internal data). Please check the 103 `pyee.EventEmitter` documentation for general information on how events are handled 104 (which can be either via function decorators or via the inherited `Client.on()` method). 105 106 Generated event names correspond to Touch Portal API message types, one for each type 107 as defined in the TP API documentation (eg. "action" or "listChange") as well as one event 108 which is generated for all message types ("any"). Alternately, for a more formal approach, 109 the corresponding members of the `TYPES` class could be used instead of string names 110 (eg. `TYPES.onAction` or `TYPES.onListChange`). 111 112 Any errors raised within event listeners/handlers are trapped and then reported in the 113 inherited "error" event (from pyee.ExecutorEventEmitter), aka `TYPES.onError`. 114 """ 115 TPHOST = '127.0.0.1' 116 """ TP plugin server host IPv4 address. """ 117 TPPORT = 12136 118 """ TP plugin server host IPv4 port number. """ 119 RCV_BUFFER_SZ = 4096 120 """ [B] incoming data buffer size. """ 121 SND_BUFFER_SZ = 32**4 122 """ [B] maximum size of send data buffer (1MB). """ 123 SOCK_EVENT_TO = 1.0 124 """ [s] maximum wait time for socket events (blocking timeout for selector.select()). """ 125 126 def __init__(self, pluginId:str, 127 sleepPeriod:float = 0.01, 128 autoClose:bool = False, 129 checkPluginId:bool = True, 130 updateStatesOnBroadcast:bool = False, 131 maxWorkers:int = None, 132 executor:Executor = None, 133 useNamespaceCallbacks:bool = False, 134 loggerName:str = None, 135 logLevel:str = "INFO", 136 logStream:TextIO = sys.stderr, 137 logFileName:str = None): 138 """ 139 Creates an instance of the client. 140 141 Args: 142 `pluginId`: ID string of the TouchPortal plugin using this client. **Required.** 143 `sleepPeriod`: Seconds to sleep the event loop between socket read events. Default: 0.01 144 `autoClose`: If `True` then this client will automatically disconnect when a `closePlugin` message is received from TP. 145 Default is `False`. 146 `checkPluginId`: Validate that `pluginId` matches ours in any messages from TP which contain one (such as actions). 147 Default is `True`. 148 `updateStatesOnBroadcast`: Re-send all cached State values whenever user switches TP page. 149 Default is `True`. 150 `maxWorkers`: Maximum worker threads to run concurrently for event handlers. 151 Default of `None` creates a default-constructed `ThreadPoolExecutor`. 152 `executor`: Passed to `pyee.ExecutorEventEmitter`. By default this is a default-constructed 153 [ThreadPoolExecutor](https://docs.python.org/3/library/concurrent.futures.html#concurrent.futures.ThreadPoolExecutor), 154 optionally using `maxWorkers` concurrent threads. 155 `useNamespaceCallbacks`: use NamespaceCallback as message handler 156 Default is `False` meaning It will send normal json 157 `True` meaning It will automatically convert json to namespace to make easier access value 158 eg json: data['actionId']['value'] and namespace would be data.actionId.value 159 `loggerName`: Optional name for the Logger to be used by the Client. 160 Default of `None` creates (or uses, if it already exists) the "root" (default) logger. 161 `logLevel`: Desired minimum logging level, one of: 162 "CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG" (or equivalent Py `logging` module Level constants), 163 or `None` to disable all logging. The level can also be set at runtime via `setLogLevel()` method. 164 Default is "INFO". 165 `logStream`: Set a stream to write log messages to, or `None` to disable stream (console) logging. 166 The stream logger can also be modified at runtime via `setLogStream()` method. 167 Default is `sys.stderr`. 168 `logFileName`: A file name (with optional path) for log messages. Paths are relative to current working directory. 169 Pass `None` or empty string to disable file logging. The log file is rotated once per day and the last 7 170 logs are preserved (older ones are deleted). The file logger can also be modified at runtime via `setLogFile()` method. 171 Default is `None` (file logging is disabled). 172 """ 173 if not executor and maxWorkers: 174 executor = ThreadPoolExecutor(max_workers=maxWorkers) 175 super(Client, self).__init__(executor=executor) 176 self.pluginId = pluginId 177 self.sleepPeriod = sleepPeriod 178 self.autoClose = autoClose 179 self.checkPluginId = checkPluginId 180 self.updateStatesOnBroadcast = updateStatesOnBroadcast 181 self.useNamespaceCallbacks = useNamespaceCallbacks 182 self.log = Logger(name=loggerName, level=logLevel, filename=logFileName, stream=logStream) 183 self.client = None 184 self.selector = None 185 self.currentStates = {} 186 self.currentSettings = {} 187 self.choiceUpdateList = {} 188 self.shortIdTracker = {} 189 self.__heldActions = {} 190 self.__stopEvent = Event() # main loop inerrupt 191 self.__stopEvent.set() # not running yet 192 self.__dataReadyEvent = Event() # set when __sendBuffer has data 193 self.__writeLock = Lock() # mutex for __sendBuffer 194 self.__sendBuffer = bytearray() 195 self.__recvBuffer = bytearray() 196 # explicitly disable logging if logLevel `None` was passed (Logger() c'tor ignores `None` log level) 197 if not logLevel: 198 self.log.setLogLevel(None) 199 200 def __buffered_readLine(self): 201 try: 202 # Should be ready to read 203 data = self.client.recv(self.RCV_BUFFER_SZ) 204 except BlockingIOError: 205 pass # Resource temporarily unavailable (errno EWOULDBLOCK) 206 except OSError: 207 raise # No connection 208 else: 209 if data: 210 lines = [] 211 self.__recvBuffer += data 212 while (i := self.__recvBuffer.find(b'\n')) > -1: 213 lines.append(self.__recvBuffer[:i]) 214 del self.__recvBuffer[:i+1] 215 return lines 216 else: 217 # No connection 218 self.__raiseException("Peer closed the connection.", RuntimeError) 219 return [] 220 221 def __write(self): 222 if self.client and self.__sendBuffer and self.__getWriteLock(): 223 try: 224 # Should be ready to write 225 sent = self.client.send(self.__sendBuffer) 226 except BlockingIOError: 227 pass # Resource temporarily unavailable (errno EWOULDBLOCK) 228 except OSError: 229 raise # No connection 230 else: 231 del self.__sendBuffer[:sent] 232 finally: 233 if not self.__sendBuffer: 234 self.__dataReadyEvent.clear() 235 self.__writeLock.release() 236 237 def __run(self): 238 try: 239 while not self.__stopEvent.is_set(): 240 events = self.selector.select(timeout=self.SOCK_EVENT_TO) 241 if self.__stopEvent.is_set(): # may be set while waiting for selector events (unlikely) 242 break 243 for _, mask in events: 244 if (mask & selectors.EVENT_READ): 245 for line in self.__buffered_readLine(): 246 self.__processMessage(line) 247 if (mask & selectors.EVENT_WRITE): 248 self.__write() 249 # Sleep for period or until there is data in the write buffer. 250 # In theory if data is constantly avaiable, this could block, 251 # in which case it may be better to self.__stopEvent.wait() 252 if self.__dataReadyEvent.wait(self.sleepPeriod): 253 continue 254 continue 255 except Exception as e: 256 self.__die(f"Exception in client event loop: {repr(e)}", e) 257 258 def __processMessage(self, message: bytes): 259 data = json.loads(message.decode()) 260 if data and (act_type := data.get('type')): 261 if self.checkPluginId and (pid := data.get('pluginId')) and pid != self.pluginId: 262 return 263 if act_type == TYPES.onShutdown: 264 if self.autoClose: self.__close() 265 elif act_type == TYPES.onHold_down and (aid := data.get('actionId')): 266 self.__heldActions[aid] = True 267 elif act_type == TYPES.onHold_up and (aid := data.get('actionId')): 268 del self.__heldActions[aid] 269 elif act_type == TYPES.onBroadcast and self.updateStatesOnBroadcast: 270 for key, value in self.currentStates.items(): 271 self.__stateUpdate(key, value, True) 272 elif act_type == TYPES.shortConnectorIdNotification: 273 self.shortIdTracker[data["connectorId"]] = data['shortId'] 274 self.__emitEvent(act_type, data) 275 276 def __emitEvent(self, ev, data): 277 if not self.useNamespaceCallbacks: 278 self.emit(ev, data) 279 self.emit(TYPES.allMessage, data) 280 else: 281 convertedData = Tools.nested_conversion(data) # No need to call this twice 282 self.emit(ev, convertedData) 283 self.emit(TYPES.allMessage, convertedData) 284 285 def __open(self): 286 try: 287 self.client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 288 self.selector = selectors.DefaultSelector() 289 self.client.connect((self.TPHOST, self.TPPORT)) 290 except Exception: 291 self.selector = self.client = None 292 raise 293 self.client.setblocking(False) 294 self.selector.register(self.client, (selectors.EVENT_READ | selectors.EVENT_WRITE)) 295 self.__stopEvent.clear() 296 297 def __close(self): 298 self.log.info(f"{self.pluginId} Disconnected from TouchPortal") 299 self.__stopEvent.set() 300 if self.__writeLock.locked(): 301 self.__writeLock.release() 302 self.__sendBuffer.clear() 303 if not self.selector: 304 return 305 if self.selector.get_map(): 306 try: 307 self.selector.unregister(self.client) 308 except Exception as e: 309 self.log.warning(f"Error in selector.unregister(): {repr(e)}") 310 try: 311 self.client.shutdown(socket.SHUT_RDWR) 312 self.client.close() 313 except OSError as e: 314 self.log.warning(f"Error in socket.close(): {repr(e)}") 315 finally: 316 # Delete reference to socket object for garbage collection, socket cannot be reused anyway. 317 self.client = None 318 self.selector.close() 319 self.selector = None 320 # print("TP Client stopped.") 321 322 def __die(self, msg=None, exc=None): 323 if msg: self.log.info(msg) 324 self.__emitEvent(TYPES.onShutdown, {"type": TYPES.onShutdown}) 325 self.__close() 326 if exc: 327 self.log.critical(repr(exc)) 328 raise exc 329 330 def __getWriteLock(self): 331 if self.__writeLock.acquire(timeout=15): 332 if self.__stopEvent.is_set(): 333 if self.__writeLock.locked(): self.__writeLock.release() 334 return False 335 return True 336 self.__die(exc=RuntimeError("Send buffer mutex deadlock, cannot continue.")) 337 return False 338 339 def __raiseException(self, message, exc = TypeError): 340 self.log.error(message) 341 raise exc(message) 342 343 def isConnected(self): 344 """ 345 Returns `True` if the Client is connected to Touch Portal, `False` otherwise. 346 """ 347 return not self.__stopEvent.is_set() 348 349 def setLogLevel(self, level): 350 """ Sets the minimum logging level. `level` can be one of one of: 351 "CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG" (or equivalent Py `logging` module Level constants), 352 or `None` to disable all logging. 353 """ 354 self.log.setLogLevel(level) 355 356 def setLogStream(self, stream): 357 """ Set a destination for the StreamHandler logger. `stream` should be a file stream type (eg. os.stderr) or `None` to disable. """ 358 self.log.setStreamDestination(stream) 359 360 def setLogFile(self, fileName): 361 """ Set a destination for the File logger. `filename` should be a file name (with or w/out a path) or `None` to disable the file logger. """ 362 self.log.setFileDestination(fileName) 363 364 def isActionBeingHeld(self, actionId:str): 365 """ 366 For an Action with ID of `actionId` that can be held (`hasHoldFunctionality` is true), 367 this method returns `True` while it is being held and `False` otherwise. 368 """ 369 return actionId in self.__heldActions 370 371 def createState(self, stateId:str, description:str, value:str, parentGroup:str = None): 372 """ 373 This will create a TP State at runtime. `stateId`, `description`, and `value` (`value` becomes the State's default value) are all required except `parentGroup` which allow you to create states in a `folder`. 374 """ 375 if stateId and description and value != None: 376 if stateId not in self.currentStates: 377 self.send({"type": "createState", "id": stateId, "desc": description, "defaultValue": value, "parentGroup": parentGroup}) 378 self.currentStates[stateId] = value 379 else: 380 self.stateUpdate(stateId, value) 381 382 def createStateMany(self, states:list): 383 """ 384 Convenience function to create several States at once. `states` should be an iteratable of `dict` types 385 in the form of `{'id': "StateId", 'desc': "Description", 'value': "Default Value"}` and optionally `'parentGroup': "Value"` which will make state `folder`. 386 """ 387 try: 388 for state in states: 389 if isinstance(state, dict): 390 self.createState(state.get('id', ""), state.get('desc', ""), state.get('value', ""), state.get("parentGroup", "")) 391 else: 392 self.__raiseException(f'createStateMany() requires a list of dicts, got {type(state)} instead.') 393 except: 394 self.__raiseException(f"createStateMany() requires an iteratable, got {type(states)} instead.") 395 396 def removeState(self, stateId:str, validateExists = True): 397 """ 398 This removes a State that has been created at runtime. `stateId` is the ID to remove. 399 If `validateExists` is True (default) this method will raise an Exception if the `stateId` 400 does not exist in the current state cache. 401 """ 402 if stateId and stateId in self.currentStates: 403 self.send({"type": "removeState", "id": stateId}) 404 self.currentStates.pop(stateId) 405 elif validateExists: 406 self.__raiseException(f"{stateId} Does not exist.", Exception) 407 408 def removeStateMany(self, states:list): 409 """ 410 Convenience function to remove several States at once. `states` should be an iteratable of state ID strings. 411 This method does not validate if the states exist in the current state cache. See also: `removeState`. 412 """ 413 try: 414 for state in states: 415 self.removeState(state, False) 416 except TypeError: 417 self.__raiseException(f'removeStateMany() requires an iteratable, got {type(states)} instead.') 418 419 def choiceUpdate(self, choiceId:str, values:list): 420 """ 421 This updates the list of choices in a previously-declared TP State with id `stateId`. 422 See TP API reference for details on updating list values. 423 """ 424 if choiceId: 425 if isinstance(values, list): 426 self.send({"type": "choiceUpdate", "id": choiceId, "value": values}) 427 self.choiceUpdateList[choiceId] = values 428 else: 429 self.__raiseException(f'choiceUpdate() values argument needs to be a list not a {type(values)}') 430 431 def choiceUpdateSpecific(self, stateId:str, values:list, instanceId:str): 432 """ 433 This updates a list of choices in a specific TP Item Instance, specified in `instanceId`. 434 See TP API reference for details on updating specific instances. 435 """ 436 if stateId and instanceId: 437 if isinstance(values, list): 438 self.send({"type": "choiceUpdate", "id": stateId, "instanceId": instanceId, "value": values}) 439 else: 440 self.__raiseException(f'choiceUpdateSpecific() values argument needs to be a list not a {type(values)}') 441 442 def settingUpdate(self, settingName:str, settingValue): 443 """ 444 This updates a value named `settingName` in your plugin's Settings to `settingValue`. 445 """ 446 if settingName and settingName not in self.currentSettings or self.currentSettings[settingName] != settingValue: 447 self.send({"type": "settingUpdate", "name": settingName, "value": settingValue}) 448 self.currentSettings[settingName] = settingValue 449 450 def stateUpdate(self, stateId:str, stateValue:str): 451 """ 452 This updates a value in ether a pre-defined static State or a dynamic State created in runtime. 453 """ 454 self.__stateUpdate(stateId, stateValue, False) 455 456 def __stateUpdate(self, stateId:str, stateValue:str, forced:bool): 457 if stateId: 458 if forced or stateId not in self.currentStates or self.currentStates[stateId] != stateValue: 459 self.send({"type": "stateUpdate", "id": stateId, "value": stateValue}) 460 self.currentStates[stateId] = stateValue 461 462 def stateUpdateMany(self, states:list): 463 """ 464 Convenience function to update several states at once. 465 `states` should be an iteratable of `dict` types in the form of `{'id': "StateId", 'value': "The New Value"}`. 466 """ 467 try: 468 for state in states: 469 if isinstance(state, dict): 470 self.stateUpdate(state.get('id', ""), state.get('value', "")) 471 else: 472 self.__raiseException(f'StateUpdateMany() requires a list of dicts, got {type(state)} instead.') 473 except TypeError: 474 self.__raiseException(f"createStateMany() requires an iteratable, got {type(states)} instead.") 475 476 def showNotification(self, notificationId:str, title:str, msg:str, options:list): 477 """ 478 This method allows your plugin to send a notification to Touch Portal with custom title, message body and available user action(s). 479 Requires TP SDK v4.0 or higher. 480 481 Args: 482 `notificationId`: Unique ID of this notification. 483 `title`: The notification title. 484 `msg`: The message body text that is shown in the notifcation. 485 `options`: List of options (actions) for the notification. Each option should be a `dict` type with `id` and `title` keys. 486 """ 487 if notificationId and title and msg and options and isinstance(options, list): 488 for option in options: 489 if 'id' not in option.keys() or 'title' not in option.keys(): 490 self.__raiseException("all options require id and title keys") 491 self.send({ 492 "type": "showNotification", 493 "notificationId": str(notificationId), 494 "title": str(title), 495 "msg": str(msg), 496 "options": options 497 }) 498 499 def __findShortId(self, connectorId:str): 500 """ 501 This method is used internally to find the short ID of a connector. 502 503 UNUSED code. 504 """ 505 for cid in list(self.shortIdTracker.keys()): 506 if (splitCId := connectorId.split("|")) and splitCId[0] == cid.split("|")[0]: 507 if all(x in splitCId for x in splitCId[1:] if x in cid.split("|")[1:]): 508 return self.shortIdTracker[connectorId] 509 return None 510 511 def shortIdUpdate(self, shortId:str, connectorValue:int): 512 """ 513 This allows you to update slider position value using shortId which TouchPortal will broadcast. 514 515 Args: 516 `shortId`: a shortId is a id that is mapped to connectorId by TouchPortal 517 `connectorValue`: A integer between 0-100 518 519 """ 520 if 0 <= connectorValue <= 100: 521 self.send({ 522 "type": "connectorUpdate", 523 "shortId": shortId, 524 "value": str(connectorValue) 525 }) 526 527 def connectorUpdate(self, connectorId:str, connectorValue:int): 528 """ 529 This allows you to update slider position value. 530 531 Args: 532 `connectorId`: Cannot be longer then 200 characters. 533 connectorId have syntax that you need to follow https://www.touch-portal.com/api/index.php?section=connectors 534 Also according to that site It requires you to have prefix "pc_yourPluginId_" + connectorid however This already provide 535 you the prefix and the pluginId so you just need you take care the rest eg connectorid|setting1=aValue 536 `connectorValue`: Must be an integer between 0-100. 537 538 Note: This method will automatically looking for shortId using gaven connectorId however if It cannot find shortId it will just send connectorId 539 """ 540 if not isinstance(connectorId, str): 541 self.__raiseException(f"connectorId needs to be a str not a {type(connectorId)}") 542 if not isinstance(connectorValue, int): 543 self.__raiseException(f"connectorValue requires a int not {type(connectorValue)}") 544 if 0 <= connectorValue <= 100: 545 if f"pc_{self.pluginId}_{connectorId}" in self.shortIdTracker: 546 self.shortIdUpdate(self.shortIdTracker[connectorId], connectorValue) 547 else: 548 self.send({ 549 "type": "connectorUpdate", 550 "connectorId": f"pc_{self.pluginId}_{connectorId}", 551 "value": str(connectorValue) 552 }) 553 else: 554 self.__raiseException(f"connectorValue needs to be between 0-100 not {connectorValue}") 555 556 def updateActionData(self, instanceId:str, stateId:str, minValue, maxValue): 557 """ 558 This allows you to update Action Data in one of your Action. Currently TouchPortal only supports changing the minimum and maximum values in numeric data types. 559 """ 560 self.send({"type": "updateActionData", "instanceId": instanceId, "data": {"minValue": minValue, "maxValue": maxValue, "id": stateId, "type": "number"}}) 561 562 def getChoiceUpdatelist(self): 563 """ 564 This will return a dict that `choiceUpdate` registered. 565 example return value `{"choiceUpdateid1": ["item1", "item2", "item3"], "exampleChoiceId": ["Option1", "Option2", "Option3"]}` 566 567 You should use this to verify before Updating the choice list 568 **Note** This is the same as TPClient.choiceUpdateList variable *DO NOT MODIFY* TPClient.choiceUpdateList unless you know what your doing 569 """ 570 return self.choiceUpdateList 571 572 def getStatelist(self): 573 """ 574 This will return a dict that have key pair of states that you last updated. 575 Example retun value `{"stateId1": "value1", "stateId2": "value2", "stateId3": "value3"}` 576 This is used to keep track of all states. It will be automatically updated when you update states 577 **Note** This is the same as TPClient.currentState variable *DO NOT MODIFY* TPClient.currentState unless you know what your doing 578 """ 579 return self.currentStates 580 581 def getSettinghistory(self): 582 """ 583 This will return a dict that have key pair of setting value that you updated previously. 584 585 This is used to track settings value that you have updated previously 586 **Note** This is the same as TPClient.currentSettings variable *DO NOT MODIFY* TPClient.currentSettings unless you know what your doing 587 """ 588 return self.currentSettings 589 590 def send(self, data): 591 """ 592 This will try to send any arbitrary Python object in `data` (presumably something `dict`-like) to Touch Portal 593 after serializing it as JSON and adding a `\n`. Normally there is no need to use this method directly, but if the 594 Python API doesn't cover something from the TP API, this could be used instead. 595 """ 596 if not self.isConnected(): 597 self.__raiseException("TP Client not connected to Touch Portal, cannot send commands.", Exception) 598 if self.__getWriteLock(): 599 if len(self.__sendBuffer) + len(data) > self.SND_BUFFER_SZ: 600 self.__writeLock.release() 601 self.__raiseException("TP Client send buffer is full!", ResourceWarning) 602 self.__sendBuffer += (json.dumps(data)+'\n').encode() 603 self.__writeLock.release() 604 self.__dataReadyEvent.set() 605 606 def connect(self): 607 """ 608 Initiate connection to TP Server. 609 If successful, it starts the main processing loop of this client. 610 Does nothing if client is already connected. 611 612 **Note** that `connect()` blocks further execution of your script until one of the following occurs: 613 - `disconnect()` is called in an event handler, 614 - TP sends `closePlugin` message and `autoClose` is `True` 615 - or an internal error occurs (for example Touch Portal disconnects unexpectedly) 616 """ 617 if not self.isConnected(): 618 self.__open() 619 self.send({"type":"pair", "id": self.pluginId}) 620 self.__run() # start the event loop 621 622 def disconnect(self): 623 """ 624 This closes the connection to TP and terminates the client processing loop. 625 Does nothing if client is already disconnected. 626 """ 627 if self.isConnected(): 628 self.__close() 629 630 @staticmethod 631 def getActionDataValue(data:list, valueId:str=None): 632 """ 633 Utility for processing action messages from TP. For example: 634 {"type": "action", "data": [{ "id": "data object id", "value": "user specified value" }, ...]} 635 636 Returns the `value` with specific `id` from a list of action data, 637 or `None` if the `id` wasn't found. If a null id is passed in `valueId` 638 then the first entry which has a `value` key, if any, will be returned. 639 640 Args: 641 `data`: the "data" array from a TP "action", "on", or "off" message 642 `valueId`: the "id" to look for in `data`. `None` or blank to return the first value found. 643 """ 644 if not data: return None 645 if valueId: 646 return next((x.get('value') for x in data if x.get('id', '') == valueId), None) 647 return next((x.get('value') for x in data if x.get('value') != None), None)
A TCP/IP client for Touch Portal API plugin integration. Implements a pyee.ExecutorEventEmitter.
After an initial connection to a Touch Portal desktop application "server," the client implements a send/receive event loop while maintaining the open sockets. Messages between TP and the plugin are exchanged asynchronously, with all sending methods possibly returning before the actual data is sent.
Messages from Touch Portal are delivered to the plugin script via event handler callbacks,
which can be either individual callbacks per message type, and/or a single handler for all
types of messages. The callbacks are executed in separate Thread(s), using a pool of up to
any number of concurrent threads as needed (if using more than one thread, the plugin
code itself is responsible for thread safety of its internal data). Please check the
pyee.EventEmitter
documentation for general information on how events are handled
(which can be either via function decorators or via the inherited Client.on()
method).
Generated event names correspond to Touch Portal API message types, one for each type
as defined in the TP API documentation (eg. "action" or "listChange") as well as one event
which is generated for all message types ("any"). Alternately, for a more formal approach,
the corresponding members of the TYPES
class could be used instead of string names
(eg. TYPES.onAction
or TYPES.onListChange
).
Any errors raised within event listeners/handlers are trapped and then reported in the
inherited "error" event (from pyee.ExecutorEventEmitter), aka TYPES.onError
.
126 def __init__(self, pluginId:str, 127 sleepPeriod:float = 0.01, 128 autoClose:bool = False, 129 checkPluginId:bool = True, 130 updateStatesOnBroadcast:bool = False, 131 maxWorkers:int = None, 132 executor:Executor = None, 133 useNamespaceCallbacks:bool = False, 134 loggerName:str = None, 135 logLevel:str = "INFO", 136 logStream:TextIO = sys.stderr, 137 logFileName:str = None): 138 """ 139 Creates an instance of the client. 140 141 Args: 142 `pluginId`: ID string of the TouchPortal plugin using this client. **Required.** 143 `sleepPeriod`: Seconds to sleep the event loop between socket read events. Default: 0.01 144 `autoClose`: If `True` then this client will automatically disconnect when a `closePlugin` message is received from TP. 145 Default is `False`. 146 `checkPluginId`: Validate that `pluginId` matches ours in any messages from TP which contain one (such as actions). 147 Default is `True`. 148 `updateStatesOnBroadcast`: Re-send all cached State values whenever user switches TP page. 149 Default is `True`. 150 `maxWorkers`: Maximum worker threads to run concurrently for event handlers. 151 Default of `None` creates a default-constructed `ThreadPoolExecutor`. 152 `executor`: Passed to `pyee.ExecutorEventEmitter`. By default this is a default-constructed 153 [ThreadPoolExecutor](https://docs.python.org/3/library/concurrent.futures.html#concurrent.futures.ThreadPoolExecutor), 154 optionally using `maxWorkers` concurrent threads. 155 `useNamespaceCallbacks`: use NamespaceCallback as message handler 156 Default is `False` meaning It will send normal json 157 `True` meaning It will automatically convert json to namespace to make easier access value 158 eg json: data['actionId']['value'] and namespace would be data.actionId.value 159 `loggerName`: Optional name for the Logger to be used by the Client. 160 Default of `None` creates (or uses, if it already exists) the "root" (default) logger. 161 `logLevel`: Desired minimum logging level, one of: 162 "CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG" (or equivalent Py `logging` module Level constants), 163 or `None` to disable all logging. The level can also be set at runtime via `setLogLevel()` method. 164 Default is "INFO". 165 `logStream`: Set a stream to write log messages to, or `None` to disable stream (console) logging. 166 The stream logger can also be modified at runtime via `setLogStream()` method. 167 Default is `sys.stderr`. 168 `logFileName`: A file name (with optional path) for log messages. Paths are relative to current working directory. 169 Pass `None` or empty string to disable file logging. The log file is rotated once per day and the last 7 170 logs are preserved (older ones are deleted). The file logger can also be modified at runtime via `setLogFile()` method. 171 Default is `None` (file logging is disabled). 172 """ 173 if not executor and maxWorkers: 174 executor = ThreadPoolExecutor(max_workers=maxWorkers) 175 super(Client, self).__init__(executor=executor) 176 self.pluginId = pluginId 177 self.sleepPeriod = sleepPeriod 178 self.autoClose = autoClose 179 self.checkPluginId = checkPluginId 180 self.updateStatesOnBroadcast = updateStatesOnBroadcast 181 self.useNamespaceCallbacks = useNamespaceCallbacks 182 self.log = Logger(name=loggerName, level=logLevel, filename=logFileName, stream=logStream) 183 self.client = None 184 self.selector = None 185 self.currentStates = {} 186 self.currentSettings = {} 187 self.choiceUpdateList = {} 188 self.shortIdTracker = {} 189 self.__heldActions = {} 190 self.__stopEvent = Event() # main loop inerrupt 191 self.__stopEvent.set() # not running yet 192 self.__dataReadyEvent = Event() # set when __sendBuffer has data 193 self.__writeLock = Lock() # mutex for __sendBuffer 194 self.__sendBuffer = bytearray() 195 self.__recvBuffer = bytearray() 196 # explicitly disable logging if logLevel `None` was passed (Logger() c'tor ignores `None` log level) 197 if not logLevel: 198 self.log.setLogLevel(None)
Creates an instance of the client.
Args
pluginId
: ID string of the TouchPortal plugin using this client. Required.sleepPeriod
: Seconds to sleep the event loop between socket read events. Default: 0.01autoClose
: IfTrue
then this client will automatically disconnect when aclosePlugin
message is received from TP. Default isFalse
.checkPluginId
: Validate thatpluginId
matches ours in any messages from TP which contain one (such as actions). Default isTrue
.updateStatesOnBroadcast
: Re-send all cached State values whenever user switches TP page. Default isTrue
.maxWorkers
: Maximum worker threads to run concurrently for event handlers. Default ofNone
creates a default-constructedThreadPoolExecutor
.executor
: Passed topyee.ExecutorEventEmitter
. By default this is a default-constructed ThreadPoolExecutor, optionally usingmaxWorkers
concurrent threads.useNamespaceCallbacks
: use NamespaceCallback as message handler Default isFalse
meaning It will send normal jsonTrue
meaning It will automatically convert json to namespace to make easier access value eg json: data['actionId']['value'] and namespace would be data.actionId.valueloggerName
: Optional name for the Logger to be used by the Client. Default ofNone
creates (or uses, if it already exists) the "root" (default) logger.logLevel
: Desired minimum logging level, one of: "CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG" (or equivalent Pylogging
module Level constants), orNone
to disable all logging. The level can also be set at runtime viasetLogLevel()
method. Default is "INFO".logStream
: Set a stream to write log messages to, orNone
to disable stream (console) logging. The stream logger can also be modified at runtime viasetLogStream()
method. Default issys.stderr
.logFileName
: A file name (with optional path) for log messages. Paths are relative to current working directory. PassNone
or empty string to disable file logging. The log file is rotated once per day and the last 7 logs are preserved (older ones are deleted). The file logger can also be modified at runtime viasetLogFile()
method. Default isNone
(file logging is disabled).
[s] maximum wait time for socket events (blocking timeout for selector.select()).
343 def isConnected(self): 344 """ 345 Returns `True` if the Client is connected to Touch Portal, `False` otherwise. 346 """ 347 return not self.__stopEvent.is_set()
Returns True
if the Client is connected to Touch Portal, False
otherwise.
349 def setLogLevel(self, level): 350 """ Sets the minimum logging level. `level` can be one of one of: 351 "CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG" (or equivalent Py `logging` module Level constants), 352 or `None` to disable all logging. 353 """ 354 self.log.setLogLevel(level)
Sets the minimum logging level. level
can be one of one of:
"CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG" (or equivalent Py logging
module Level constants),
or None
to disable all logging.
356 def setLogStream(self, stream): 357 """ Set a destination for the StreamHandler logger. `stream` should be a file stream type (eg. os.stderr) or `None` to disable. """ 358 self.log.setStreamDestination(stream)
Set a destination for the StreamHandler logger. stream
should be a file stream type (eg. os.stderr) or None
to disable.
360 def setLogFile(self, fileName): 361 """ Set a destination for the File logger. `filename` should be a file name (with or w/out a path) or `None` to disable the file logger. """ 362 self.log.setFileDestination(fileName)
Set a destination for the File logger. filename
should be a file name (with or w/out a path) or None
to disable the file logger.
364 def isActionBeingHeld(self, actionId:str): 365 """ 366 For an Action with ID of `actionId` that can be held (`hasHoldFunctionality` is true), 367 this method returns `True` while it is being held and `False` otherwise. 368 """ 369 return actionId in self.__heldActions
For an Action with ID of actionId
that can be held (hasHoldFunctionality
is true),
this method returns True
while it is being held and False
otherwise.
371 def createState(self, stateId:str, description:str, value:str, parentGroup:str = None): 372 """ 373 This will create a TP State at runtime. `stateId`, `description`, and `value` (`value` becomes the State's default value) are all required except `parentGroup` which allow you to create states in a `folder`. 374 """ 375 if stateId and description and value != None: 376 if stateId not in self.currentStates: 377 self.send({"type": "createState", "id": stateId, "desc": description, "defaultValue": value, "parentGroup": parentGroup}) 378 self.currentStates[stateId] = value 379 else: 380 self.stateUpdate(stateId, value)
This will create a TP State at runtime. stateId
, description
, and value
(value
becomes the State's default value) are all required except parentGroup
which allow you to create states in a folder
.
382 def createStateMany(self, states:list): 383 """ 384 Convenience function to create several States at once. `states` should be an iteratable of `dict` types 385 in the form of `{'id': "StateId", 'desc': "Description", 'value': "Default Value"}` and optionally `'parentGroup': "Value"` which will make state `folder`. 386 """ 387 try: 388 for state in states: 389 if isinstance(state, dict): 390 self.createState(state.get('id', ""), state.get('desc', ""), state.get('value', ""), state.get("parentGroup", "")) 391 else: 392 self.__raiseException(f'createStateMany() requires a list of dicts, got {type(state)} instead.') 393 except: 394 self.__raiseException(f"createStateMany() requires an iteratable, got {type(states)} instead.")
Convenience function to create several States at once. states
should be an iteratable of dict
types
in the form of {'id': "StateId", 'desc': "Description", 'value': "Default Value"}
and optionally 'parentGroup': "Value"
which will make state folder
.
396 def removeState(self, stateId:str, validateExists = True): 397 """ 398 This removes a State that has been created at runtime. `stateId` is the ID to remove. 399 If `validateExists` is True (default) this method will raise an Exception if the `stateId` 400 does not exist in the current state cache. 401 """ 402 if stateId and stateId in self.currentStates: 403 self.send({"type": "removeState", "id": stateId}) 404 self.currentStates.pop(stateId) 405 elif validateExists: 406 self.__raiseException(f"{stateId} Does not exist.", Exception)
This removes a State that has been created at runtime. stateId
is the ID to remove.
If validateExists
is True (default) this method will raise an Exception if the stateId
does not exist in the current state cache.
408 def removeStateMany(self, states:list): 409 """ 410 Convenience function to remove several States at once. `states` should be an iteratable of state ID strings. 411 This method does not validate if the states exist in the current state cache. See also: `removeState`. 412 """ 413 try: 414 for state in states: 415 self.removeState(state, False) 416 except TypeError: 417 self.__raiseException(f'removeStateMany() requires an iteratable, got {type(states)} instead.')
Convenience function to remove several States at once. states
should be an iteratable of state ID strings.
This method does not validate if the states exist in the current state cache. See also: removeState
.
419 def choiceUpdate(self, choiceId:str, values:list): 420 """ 421 This updates the list of choices in a previously-declared TP State with id `stateId`. 422 See TP API reference for details on updating list values. 423 """ 424 if choiceId: 425 if isinstance(values, list): 426 self.send({"type": "choiceUpdate", "id": choiceId, "value": values}) 427 self.choiceUpdateList[choiceId] = values 428 else: 429 self.__raiseException(f'choiceUpdate() values argument needs to be a list not a {type(values)}')
This updates the list of choices in a previously-declared TP State with id stateId
.
See TP API reference for details on updating list values.
431 def choiceUpdateSpecific(self, stateId:str, values:list, instanceId:str): 432 """ 433 This updates a list of choices in a specific TP Item Instance, specified in `instanceId`. 434 See TP API reference for details on updating specific instances. 435 """ 436 if stateId and instanceId: 437 if isinstance(values, list): 438 self.send({"type": "choiceUpdate", "id": stateId, "instanceId": instanceId, "value": values}) 439 else: 440 self.__raiseException(f'choiceUpdateSpecific() values argument needs to be a list not a {type(values)}')
This updates a list of choices in a specific TP Item Instance, specified in instanceId
.
See TP API reference for details on updating specific instances.
442 def settingUpdate(self, settingName:str, settingValue): 443 """ 444 This updates a value named `settingName` in your plugin's Settings to `settingValue`. 445 """ 446 if settingName and settingName not in self.currentSettings or self.currentSettings[settingName] != settingValue: 447 self.send({"type": "settingUpdate", "name": settingName, "value": settingValue}) 448 self.currentSettings[settingName] = settingValue
This updates a value named settingName
in your plugin's Settings to settingValue
.
450 def stateUpdate(self, stateId:str, stateValue:str): 451 """ 452 This updates a value in ether a pre-defined static State or a dynamic State created in runtime. 453 """ 454 self.__stateUpdate(stateId, stateValue, False)
This updates a value in ether a pre-defined static State or a dynamic State created in runtime.
462 def stateUpdateMany(self, states:list): 463 """ 464 Convenience function to update several states at once. 465 `states` should be an iteratable of `dict` types in the form of `{'id': "StateId", 'value': "The New Value"}`. 466 """ 467 try: 468 for state in states: 469 if isinstance(state, dict): 470 self.stateUpdate(state.get('id', ""), state.get('value', "")) 471 else: 472 self.__raiseException(f'StateUpdateMany() requires a list of dicts, got {type(state)} instead.') 473 except TypeError: 474 self.__raiseException(f"createStateMany() requires an iteratable, got {type(states)} instead.")
Convenience function to update several states at once.
states
should be an iteratable of dict
types in the form of {'id': "StateId", 'value': "The New Value"}
.
476 def showNotification(self, notificationId:str, title:str, msg:str, options:list): 477 """ 478 This method allows your plugin to send a notification to Touch Portal with custom title, message body and available user action(s). 479 Requires TP SDK v4.0 or higher. 480 481 Args: 482 `notificationId`: Unique ID of this notification. 483 `title`: The notification title. 484 `msg`: The message body text that is shown in the notifcation. 485 `options`: List of options (actions) for the notification. Each option should be a `dict` type with `id` and `title` keys. 486 """ 487 if notificationId and title and msg and options and isinstance(options, list): 488 for option in options: 489 if 'id' not in option.keys() or 'title' not in option.keys(): 490 self.__raiseException("all options require id and title keys") 491 self.send({ 492 "type": "showNotification", 493 "notificationId": str(notificationId), 494 "title": str(title), 495 "msg": str(msg), 496 "options": options 497 })
This method allows your plugin to send a notification to Touch Portal with custom title, message body and available user action(s). Requires TP SDK v4.0 or higher.
Args
notificationId
: Unique ID of this notification.title
: The notification title.msg
: The message body text that is shown in the notifcation.options
: List of options (actions) for the notification. Each option should be adict
type withid
andtitle
keys.
511 def shortIdUpdate(self, shortId:str, connectorValue:int): 512 """ 513 This allows you to update slider position value using shortId which TouchPortal will broadcast. 514 515 Args: 516 `shortId`: a shortId is a id that is mapped to connectorId by TouchPortal 517 `connectorValue`: A integer between 0-100 518 519 """ 520 if 0 <= connectorValue <= 100: 521 self.send({ 522 "type": "connectorUpdate", 523 "shortId": shortId, 524 "value": str(connectorValue) 525 })
This allows you to update slider position value using shortId which TouchPortal will broadcast.
Args
shortId
: a shortId is a id that is mapped to connectorId by TouchPortalconnectorValue
: A integer between 0-100
527 def connectorUpdate(self, connectorId:str, connectorValue:int): 528 """ 529 This allows you to update slider position value. 530 531 Args: 532 `connectorId`: Cannot be longer then 200 characters. 533 connectorId have syntax that you need to follow https://www.touch-portal.com/api/index.php?section=connectors 534 Also according to that site It requires you to have prefix "pc_yourPluginId_" + connectorid however This already provide 535 you the prefix and the pluginId so you just need you take care the rest eg connectorid|setting1=aValue 536 `connectorValue`: Must be an integer between 0-100. 537 538 Note: This method will automatically looking for shortId using gaven connectorId however if It cannot find shortId it will just send connectorId 539 """ 540 if not isinstance(connectorId, str): 541 self.__raiseException(f"connectorId needs to be a str not a {type(connectorId)}") 542 if not isinstance(connectorValue, int): 543 self.__raiseException(f"connectorValue requires a int not {type(connectorValue)}") 544 if 0 <= connectorValue <= 100: 545 if f"pc_{self.pluginId}_{connectorId}" in self.shortIdTracker: 546 self.shortIdUpdate(self.shortIdTracker[connectorId], connectorValue) 547 else: 548 self.send({ 549 "type": "connectorUpdate", 550 "connectorId": f"pc_{self.pluginId}_{connectorId}", 551 "value": str(connectorValue) 552 }) 553 else: 554 self.__raiseException(f"connectorValue needs to be between 0-100 not {connectorValue}")
This allows you to update slider position value.
Args
connectorId
: Cannot be longer then 200 characters. connectorId have syntax that you need to follow https://www.touch-portal.com/api/index.php?section=connectors Also according to that site It requires you to have prefix "pc_yourPluginId_" + connectorid however This already provide you the prefix and the pluginId so you just need you take care the rest eg connectorid|setting1=aValueconnectorValue
: Must be an integer between 0-100.
Note: This method will automatically looking for shortId using gaven connectorId however if It cannot find shortId it will just send connectorId
556 def updateActionData(self, instanceId:str, stateId:str, minValue, maxValue): 557 """ 558 This allows you to update Action Data in one of your Action. Currently TouchPortal only supports changing the minimum and maximum values in numeric data types. 559 """ 560 self.send({"type": "updateActionData", "instanceId": instanceId, "data": {"minValue": minValue, "maxValue": maxValue, "id": stateId, "type": "number"}})
This allows you to update Action Data in one of your Action. Currently TouchPortal only supports changing the minimum and maximum values in numeric data types.
562 def getChoiceUpdatelist(self): 563 """ 564 This will return a dict that `choiceUpdate` registered. 565 example return value `{"choiceUpdateid1": ["item1", "item2", "item3"], "exampleChoiceId": ["Option1", "Option2", "Option3"]}` 566 567 You should use this to verify before Updating the choice list 568 **Note** This is the same as TPClient.choiceUpdateList variable *DO NOT MODIFY* TPClient.choiceUpdateList unless you know what your doing 569 """ 570 return self.choiceUpdateList
This will return a dict that choiceUpdate
registered.
example return value {"choiceUpdateid1": ["item1", "item2", "item3"], "exampleChoiceId": ["Option1", "Option2", "Option3"]}
You should use this to verify before Updating the choice list Note This is the same as TPClient.choiceUpdateList variable DO NOT MODIFY TPClient.choiceUpdateList unless you know what your doing
572 def getStatelist(self): 573 """ 574 This will return a dict that have key pair of states that you last updated. 575 Example retun value `{"stateId1": "value1", "stateId2": "value2", "stateId3": "value3"}` 576 This is used to keep track of all states. It will be automatically updated when you update states 577 **Note** This is the same as TPClient.currentState variable *DO NOT MODIFY* TPClient.currentState unless you know what your doing 578 """ 579 return self.currentStates
This will return a dict that have key pair of states that you last updated.
Example retun value {"stateId1": "value1", "stateId2": "value2", "stateId3": "value3"}
This is used to keep track of all states. It will be automatically updated when you update states
Note This is the same as TPClient.currentState variable DO NOT MODIFY TPClient.currentState unless you know what your doing
581 def getSettinghistory(self): 582 """ 583 This will return a dict that have key pair of setting value that you updated previously. 584 585 This is used to track settings value that you have updated previously 586 **Note** This is the same as TPClient.currentSettings variable *DO NOT MODIFY* TPClient.currentSettings unless you know what your doing 587 """ 588 return self.currentSettings
This will return a dict that have key pair of setting value that you updated previously.
This is used to track settings value that you have updated previously Note This is the same as TPClient.currentSettings variable DO NOT MODIFY TPClient.currentSettings unless you know what your doing
590 def send(self, data): 591 """ 592 This will try to send any arbitrary Python object in `data` (presumably something `dict`-like) to Touch Portal 593 after serializing it as JSON and adding a `\n`. Normally there is no need to use this method directly, but if the 594 Python API doesn't cover something from the TP API, this could be used instead. 595 """ 596 if not self.isConnected(): 597 self.__raiseException("TP Client not connected to Touch Portal, cannot send commands.", Exception) 598 if self.__getWriteLock(): 599 if len(self.__sendBuffer) + len(data) > self.SND_BUFFER_SZ: 600 self.__writeLock.release() 601 self.__raiseException("TP Client send buffer is full!", ResourceWarning) 602 self.__sendBuffer += (json.dumps(data)+'\n').encode() 603 self.__writeLock.release() 604 self.__dataReadyEvent.set()
This will try to send any arbitrary Python object in data
(presumably something dict
-like) to Touch Portal
after serializing it as JSON and adding a
. Normally there is no need to use this method directly, but if the
Python API doesn't cover something from the TP API, this could be used instead.
606 def connect(self): 607 """ 608 Initiate connection to TP Server. 609 If successful, it starts the main processing loop of this client. 610 Does nothing if client is already connected. 611 612 **Note** that `connect()` blocks further execution of your script until one of the following occurs: 613 - `disconnect()` is called in an event handler, 614 - TP sends `closePlugin` message and `autoClose` is `True` 615 - or an internal error occurs (for example Touch Portal disconnects unexpectedly) 616 """ 617 if not self.isConnected(): 618 self.__open() 619 self.send({"type":"pair", "id": self.pluginId}) 620 self.__run() # start the event loop
Initiate connection to TP Server. If successful, it starts the main processing loop of this client. Does nothing if client is already connected.
Note that connect()
blocks further execution of your script until one of the following occurs:
disconnect()
is called in an event handler,- TP sends
closePlugin
message andautoClose
isTrue
- or an internal error occurs (for example Touch Portal disconnects unexpectedly)
622 def disconnect(self): 623 """ 624 This closes the connection to TP and terminates the client processing loop. 625 Does nothing if client is already disconnected. 626 """ 627 if self.isConnected(): 628 self.__close()
This closes the connection to TP and terminates the client processing loop. Does nothing if client is already disconnected.
630 @staticmethod 631 def getActionDataValue(data:list, valueId:str=None): 632 """ 633 Utility for processing action messages from TP. For example: 634 {"type": "action", "data": [{ "id": "data object id", "value": "user specified value" }, ...]} 635 636 Returns the `value` with specific `id` from a list of action data, 637 or `None` if the `id` wasn't found. If a null id is passed in `valueId` 638 then the first entry which has a `value` key, if any, will be returned. 639 640 Args: 641 `data`: the "data" array from a TP "action", "on", or "off" message 642 `valueId`: the "id" to look for in `data`. `None` or blank to return the first value found. 643 """ 644 if not data: return None 645 if valueId: 646 return next((x.get('value') for x in data if x.get('id', '') == valueId), None) 647 return next((x.get('value') for x in data if x.get('value') != None), None)
Utility for processing action messages from TP. For example: {"type": "action", "data": [{ "id": "data object id", "value": "user specified value" }, ...]}
Returns the value
with specific id
from a list of action data,
or None
if the id
wasn't found. If a null id is passed in valueId
then the first entry which has a value
key, if any, will be returned.
Args
data
: the "data" array from a TP "action", "on", or "off" messagevalueId
: the "id" to look for indata
.None
or blank to return the first value found.
Inherited Members
- pyee.executor.ExecutorEventEmitter
- shutdown
- pyee.base.EventEmitter
- on
- listens_to
- add_listener
- event_names
- emit
- once
- remove_listener
- remove_all_listeners
- listeners
35class TYPES: 36 """ 37 Event type enumerations. These correspond to the message types Touch Portal may send. 38 Event handler callbacks receive one parameter, which is the JSON data structure sent 39 from TP and then converted to a corresponding Python object using `json.loads()`. 40 Usage example: 41 ```py 42 import TouchPortalAPI as TP 43 44 client = TP.Client("myplugin") 45 46 @client.on(TP.TYPES.onConnect) 47 # equivalent to: @client.on('info') 48 def onConnect(data): 49 print(data) 50 51 # alternatively, w/out decorators: 52 def onAction(data): 53 print(data) 54 55 client.on(TP.TYPES.onAction, onAction) 56 ``` 57 """ 58 onHold_up = 'up' 59 """ When the action is used in a hold functionality and the user releases the Touch Portal button (Api 3.0+) """ 60 onHold_down = 'down' 61 """ When the action is used in a hold functionality and the user presses the Touch Portal button down (Api 3.0+) """ 62 onConnect = 'info' 63 """ Info message returned after successful pairing. """ 64 onAction = 'action' 65 """ When actions are triggered by the user (Api 1.0+) """ 66 onListChange = 'listChange' 67 """ When a user makes a change in one of your lists (Api 1.0+) """ 68 onConnectorChange = 'connectorChange' 69 """ When a connector is used in a slider functionality (connector) (Api 4.0+) """ 70 onShutdown = 'closePlugin' 71 """ When Touch Portal tries to close your plug-in (Api 2.0+) """ 72 onBroadcast = 'broadcast' 73 """ When Touch Portal broadcasts a message (Api 3.0+) """ 74 onSettingUpdate = 'settings' 75 """ When the plugin's Settings have been updated (by user or from the plugin itself) (Api 3.0+) """ 76 onNotificationOptionClicked = "notificationOptionClicked" 77 """ When a user clicks on a notification action (Api 4.0+) """ 78 shortConnectorIdNotification = 'shortConnectorIdNotification' 79 """ When creating new connector for the first time It will generate shortid and It will send as an event (Api 5.0+) """ 80 allMessage = 'any' 81 """ Special event handler which will receive **all** messages from TouchPortal. """ 82 onError = 'error' 83 """ Special event emitted when any other event callback raises an exception. 84 For this particular event, the parameter passed to the callback handler will be an `Exception` object. 85 See also `pyee.ExecutorEventEmitter.error` event. 86 """
Event type enumerations. These correspond to the message types Touch Portal may send.
Event handler callbacks receive one parameter, which is the JSON data structure sent
from TP and then converted to a corresponding Python object using json.loads()
.
Usage example
import TouchPortalAPI as TP client = TP.Client("myplugin") @client.on(TP.TYPES.onConnect) # equivalent to: @client.on('info') def onConnect(data): print(data) # alternatively, w/out decorators: def onAction(data): print(data) client.on(TP.TYPES.onAction, onAction)
When the action is used in a hold functionality and the user releases the Touch Portal button (Api 3.0+)
When the action is used in a hold functionality and the user presses the Touch Portal button down (Api 3.0+)
When a connector is used in a slider functionality (connector) (Api 4.0+)
When the plugin's Settings have been updated (by user or from the plugin itself) (Api 3.0+)
When a user clicks on a notification action (Api 4.0+)