diff --git a/TouchPortalAPI/sdk_spec.py b/TouchPortalAPI/sdk_spec.py
new file mode 100644
index 0000000..08a969c
--- /dev/null
+++ b/TouchPortalAPI/sdk_spec.py
@@ -0,0 +1,131 @@
+"""
+Touch Portal API spec tables
+
+Each lookup table corresponds to a major "collection" in TP SDK,
+which are all listed in the TP API Reference page (https://www.touch-portal.com/api/index.php?section=reference)
+The tables can be used for generating and/or validating entry.tp files.
+Some tables, like for Actions, may contain nested data structures (like Action Data).
+
+Table attributes:
+ `v`: minimum TP SDK version
+ `r`: required true/false
+ `t`: value type (default is str)
+ `d`: default value, if any
+ `c`: optional list of valid value(s) (choices)
+ `l`: lookup table for child data structures, if any
+
+TODO: List valid attribute values per SDK version?
+"""
+
+__copyright__ = """
+This file is part of the TouchPortal-API project.
+Copyright TouchPortal-API Developers
+Copyright (c) 2021 Maxim Paperno
+All rights reserved.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+"""
+
+TPSDK_DEFAULT_VERSION = 4
+
+TPSDK_ATTRIBS_SETTINGS = {
+# key name sdk V required [type(s)] [default value] [valid value list]
+ 'name': { 'v': 3, 'r': True, 't': str },
+ 'type': { 'v': 3, 'r': True, 't': str, 'd': "text", 'c': ["text","number"] },
+ 'default': { 'v': 3, 'r': False, 't': str },
+ 'maxLength': { 'v': 3, 'r': False, 't': int },
+ 'isPassword': { 'v': 3, 'r': False, 't': bool },
+ 'minValue': { 'v': 3, 'r': False, 't': int },
+ 'maxValue': { 'v': 3, 'r': False, 't': int },
+ 'readOnly': { 'v': 3, 'r': False, 't': bool, 'd': False },
+}
+
+TPSDK_ATTRIBS_STATE = {
+# key name sdk V required [type(s)] [default value] [valid value list]
+ 'id': { 'v': 1, 'r': True, 't': str },
+ 'type': { 'v': 1, 'r': True, 't': str, 'd': "text", 'c': ["text","choice"]},
+ 'desc': { 'v': 1, 'r': True, 't': str },
+ 'default': { 'v': 1, 'r': True, 't': str, 'd': "" },
+ 'valueChoices': { 'v': 1, 'r': False, 't': list },
+}
+
+TPSDK_ATTRIBS_EVENT = {
+# key name sdk V required [type(s)] [default value] [valid value list]
+ 'id': { 'v': 1, 'r': True, 't': str },
+ 'name': { 'v': 1, 'r': True, 't': str },
+ 'format': { 'v': 1, 'r': True, 't': str },
+ 'type': { 'v': 1, 'r': True, 't': str, 'd': "communicate", 'c': ["communicate"] },
+ 'valueChoices': { 'v': 1, 'r': True, 't': list, 'd': [] },
+ 'valueType': { 'v': 1, 'r': True, 't': str, 'd': "choice", 'c': ["choice"] },
+ 'valueStateId': { 'v': 1, 'r': True, 't': str },
+}
+
+TPSDK_ATTRIBS_ACT_DATA = {
+# key name sdk V required [type(s)] [default value] [valid value list]
+ 'id': { 'v': 1, 'r': True, 't': str },
+ 'type': { 'v': 1, 'r': True, 't': str, 'd': "text", 'c': ["text","number","switch","choice","file","folder","color"] },
+ 'label': { 'v': 1, 'r': True, 't': str },
+ 'default': { 'v': 1, 'r': True, 't': (str,int,float,bool), 'd': "" },
+ 'valueChoices': { 'v': 1, 'r': False, 't': list },
+ 'extensions': { 'v': 2, 'r': False, 't': list },
+ 'allowDecimals': { 'v': 2, 'r': False, 't': bool },
+ 'minValue': { 'v': 3, 'r': False, 't': int },
+ 'maxValue': { 'v': 3, 'r': False, 't': int }
+}
+
+TPSDK_ATTRIBS_ACTION = {
+# key name sdk V required [type(s)] [default value] [valid value list] [lookup table]
+ 'id': { 'v': 1, 'r': True, 't': str },
+ 'name': { 'v': 1, 'r': True, 't': str },
+ 'prefix': { 'v': 1, 'r': True, 't': str }, # dynamic default? based on category name?
+ 'type': { 'v': 1, 'r': True, 't': str, 'd': "communicate", 'c': ["communicate","execute"] },
+ 'description': { 'v': 1, 'r': False, 't': str },
+ 'format': { 'v': 1, 'r': False, 't': str },
+ 'executionType': { 'v': 1, 'r': False, 't': str },
+ 'execution_cmd': { 'v': 1, 'r': False, 't': str },
+ 'tryInline': { 'v': 1, 'r': False, 't': bool },
+ 'hasHoldFunctionality': { 'v': 3, 'r': False, 't': bool },
+ 'data': { 'v': 1, 'r': False, 't': list, 'l': TPSDK_ATTRIBS_ACT_DATA },
+}
+
+TPSDK_ATTRIBS_CONNECTOR = {
+# key name sdk V required [type(s)] [default value] [valid value list] [lookup table]
+ 'id': { 'v': 4, 'r': True, 't': str },
+ 'name': { 'v': 4, 'r': True, 't': str },
+ 'format': { 'v': 4, 'r': False, 't': str },
+ 'data': { 'v': 4, 'r': False, 't': list, 'l': TPSDK_ATTRIBS_ACT_DATA }, # same data as Actions? TP API docs are still vague
+}
+
+TPSDK_ATTRIBS_CATEGORY = {
+# key name sdk V required [type(s)] [lookup table]
+ 'id': { 'v': 1, 'r': True, 't': str }, # dynamic default id based on plugin id?
+ 'name': { 'v': 1, 'r': True, 't': str }, # dynamic default based on plugin name?
+ 'imagepath': { 'v': 1, 'r': False, 't': str },
+ 'actions': { 'v': 1, 'r': False, 't': list, 'l': TPSDK_ATTRIBS_ACTION },
+ 'connectors': { 'v': 4, 'r': False, 't': list, 'l': TPSDK_ATTRIBS_CONNECTOR },
+ 'states': { 'v': 1, 'r': False, 't': list, 'l': TPSDK_ATTRIBS_STATE },
+ 'events': { 'v': 1, 'r': False, 't': list, 'l': TPSDK_ATTRIBS_EVENT },
+}
+
+TPSDK_ATTRIBS_ROOT = {
+# key name sdk V required [type(s)] [default value] [valid value list] [lookup table]
+ 'sdk': { 'v': 1, 'r': True, 't': int, 'd': TPSDK_DEFAULT_VERSION, 'c': [1,2,3,4] },
+ 'version': { 'v': 1, 'r': True, 't': int, 'd': 1 },
+ 'name': { 'v': 1, 'r': True, 't': str },
+ 'id': { 'v': 1, 'r': True, 't': str },
+ 'configuration': { 'v': 1, 'r': False, 't': dict },
+ 'plugin_start_cmd': { 'v': 1, 'r': False, 't': str },
+ 'categories': { 'v': 1, 'r': True, 't': list, 'd': [], 'l': TPSDK_ATTRIBS_CATEGORY },
+ 'settings': { 'v': 3, 'r': False, 't': list, 'd': [], 'l': TPSDK_ATTRIBS_SETTINGS },
+}
diff --git a/TouchPortalAPI/sdk_tools.py b/TouchPortalAPI/sdk_tools.py
new file mode 100644
index 0000000..b0faf44
--- /dev/null
+++ b/TouchPortalAPI/sdk_tools.py
@@ -0,0 +1,520 @@
+#!/usr/bin/env python3
+"""
+Touch Portal Python SDK Tools
+
+Functions:
+ * Generates an entry.tp definition file for a Touch Portal plugin based
+ on variables specified in the plugin source code (`generateDefinitionFromScript()`),
+ from a loaded module (`generateDefinitionFromModule()`) or specified by value (`generateDefinitionFromDeclaration()`).
+ * Validate an entire plugin definition (entry.tp) file (`validateDefinitionFile()`),
+ string (`validateDefinitionString()`), or object (`validateDefinitionObject()`).
+ * Validate an entry.tp attribute value against the minimum
+ SDK version, value type, value content, etc. (`validateAttribValue()`).
+ * ... ?
+
+Command-line Usage:
+ sdk_tools.py [-h] [-g] [-v] [-o ] [-s] [-i ] [target]
+
+ positional arguments:
+ target Either a plugin script for `generate` or an entry.tp file for `validate`. Paths are relative to current working directory. Defaults to './main.py' and
+ './entry.tp' respectively. Use 'stdin' (or '-') to read from input stream instead.
+
+ optional arguments:
+ -h, --help show this help message and exit
+ -g, --generate Generate a definition file from plugin script data. This is the default action.
+ -v, --validate Validate a definition JSON file (entry.tp). If given with `generate` then will validate the generated JSON output.
+
+ Generator arguments:
+ -o Output file for `generate` action. Default will be a file named 'entry.tp' in the same folder as the input script. Paths are relative to current working
+ directory. Use 'stdout' (or '-') to print the output to the console/stream instead.
+ -s, --skip-invalid Skip attributes with invalid values (they will not be included in generated output). Default behavior is to only warn about them.
+ -i , --indent Indent level (spaces) for generated JSON. Use 0 for only newlines, or -1 for the most compact representation. Default is 2 spaces.
+
+ This script exits with status code -1 (error) if generation or validation produces warning messages about malformed data.
+ All progress and warning messages are printed to stderr stream.
+
+TODO/Ideas:
+
+* Dynamic default values, eg. for action prefix or category id/name (see notes in sdk_spec tables).
+* Dynamic ID generation and write-back to plugin at runtime.
+* Allow plugin author to set their own defaults.
+"""
+
+__copyright__ = """
+This file is part of the TouchPortal-API project.
+Copyright TouchPortal-API Developers
+Copyright (c) 2021 Maxim Paperno
+All rights reserved.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+"""
+
+import sys
+import os.path
+import importlib.util
+import json
+from types import ModuleType
+from typing import (Union, TextIO)
+from re import compile as re_compile
+
+sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
+from sdk_spec import *
+
+## globals
+g_messages = [] # validation reporting
+g_seen_ids = {} # for validating unique IDs
+
+
+## Utils
+
+def getMessages():
+ """ Gets a list of messages which may have been generated during generation/validation.
+ """
+ return g_messages
+
+def clearMessages():
+ """ Clears the list of validation warning messages.
+ Do this before invoking `validateAttribValue()` directly (outside of the other functions provided here).
+ """
+ global g_messages
+ g_messages.clear()
+
+def _printMessages(messages:list):
+ for msg in messages:
+ _printToErr(msg)
+
+def _addMessage(msg):
+ global g_messages
+ g_messages.append(msg)
+
+
+def _seenIds():
+ global g_seen_ids
+ return g_seen_ids.keys()
+
+def _addSeenId(id, path):
+ global g_seen_ids
+ g_seen_ids[id] = path
+
+def _clearSeenIds():
+ global g_seen_ids
+ g_seen_ids.clear()
+
+
+def _printToErr(msg):
+ sys.stderr.write(msg + "\n")
+
+def _normPath(path):
+ if not isinstance(path, str):
+ return path
+ return os.path.normpath(os.path.join(os.getcwd(), path))
+
+def _keyPath(path, key):
+ return ":".join(filter(None, [path, key]))
+
+## Generator functions
+
+def _dictFromItem(item:dict, table:dict, sdk_v:int, path:str="", skip_invalid:bool=False):
+ ret = {}
+ if not isinstance(item, dict):
+ return ret
+ for k, data in table.items():
+ # try get explicit value from item
+ if (v := item.get(k)) is None:
+ # try get default value
+ v = data.get('d')
+ # check if there is nested data, eg. in an Action
+ if isinstance(v, dict) and data.get('t') is list:
+ v = _arrayFromDict(v, data.get('l', {}), sdk_v, path=_keyPath(path, k), skip_invalid=skip_invalid)
+ # check that the value is valid and add it to the dict if it is
+ if validateAttribValue(k, v, data, sdk_v, path) or (not skip_invalid and v != None):
+ ret[k] = v
+ return ret
+
+
+def _arrayFromDict(d:dict, table:dict, sdk_v:int, category:str=None, path:str="", skip_invalid:bool=False):
+ ret = []
+ if not isinstance(d, dict):
+ return ret
+ for key, item in d.items():
+ if not category or not (cat := item.get('category')) or cat == category:
+ ret.append(_dictFromItem(item, table, sdk_v, f"{path}[{key}]", skip_invalid))
+ if path in ["actions","connectors"]:
+ _replaceFormatTokens(ret)
+ return ret
+
+
+def _replaceFormatTokens(items:list):
+ for d in items:
+ if not isinstance(d, dict) or not 'format' in d.keys() or not 'data' in d.keys():
+ continue
+ data_ids = {}
+ for data in d.get('data'):
+ if (did := data.get('id')):
+ data_ids[did.rsplit(".", 1)[-1]] = did
+ if not data_ids:
+ continue
+ fmt = d.get('format')
+ rx = re_compile(r'\$\[(\w+)\]')
+ begin = 0
+ while (m := rx.search(fmt, begin)):
+ idx = m.group(1)
+ if idx in data_ids.keys():
+ val = data_ids.get(idx)
+ elif idx.isdigit() and (i := int(idx) - 1) >= 0 and i < len(data_ids):
+ val = list(data_ids.values())[i]
+ else:
+ begin = m.end()
+ _addMessage(f"WARNING: Could not find replacement for token '{idx}' in 'format' attribute for element `{d.get('id')}`. The data arry does not contain this name/index.")
+ continue
+ # print(m.span(), val)
+ fmt = fmt[:m.start()] + "{$" + val + "$}" + fmt[m.end():]
+ begin = m.start() + len(val) + 4
+ d['format'] = fmt
+
+
+def generateDefinitionFromScript(script:Union[str, TextIO], skip_invalid:bool=False):
+ """
+ Returns an "entry.tp" Python `dict` which is suitable for direct conversion to JSON format.
+
+ `script` should be a valid python script, either a file path (ending in .py), string, or open file handle (like stdin).
+ The script should contain "SDK declaration variables" like `TP_PLUGIN_INFO`, `TP_PLUGIN_SETTINGS`, etc.
+
+ Setting `skip_invalid` to `True` will skip attributes with invalid values (they will not be included in generated output).
+ Default behavior is to only warn about them.
+
+ Note that the script is interpreted (executed), so any actual "business" logic (like connecting to TP) should be in "__main__".
+ Also note that when using input from a file handle or string, the script's "__file__" attribute is set to the current working
+ directory and the file name "tp_plugin.py".
+
+ May raise an `ImportError` if the plugin script could not be loaded or is missing required variables.
+ Use `getMessages()` to check for any warnings/etc which may be generated (eg. from attribute validation).
+ """
+ script_str = ""
+ if hasattr(script, "read"):
+ script_str = script.read()
+ elif not script.endswith(".py"):
+ script_str = script
+
+ try:
+ if script_str:
+ # load from a string
+ spec = importlib.util.spec_from_loader("plugin", loader=None)
+ plugin = importlib.util.module_from_spec(spec)
+ setattr(plugin, "__file__", os.path.join(os.getcwd(), "tp_plugin.py"))
+ exec(script_str, plugin.__dict__)
+ else:
+ # load directly from a file path
+ spec = importlib.util.spec_from_file_location("plugin", script)
+ plugin = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(plugin)
+ # print(plugin.TP_PLUGIN_INFO)
+ except Exception as e:
+ input_name = "input stream" if script_str else script
+ raise ImportError(f"ERROR while trying to import plugin code from '{input_name}': {repr(e)}")
+ return generateDefinitionFromModule(plugin, skip_invalid)
+
+
+def generateDefinitionFromModule(plugin:ModuleType, skip_invalid:bool=False):
+ """
+ Returns an "entry.tp" Python `dict`, which is suitable for direct conversion to JSON format.
+ `plugin` should be a loaded Python "module" which contains "SDK declaration variables" like
+ `TP_PLUGIN_INFO`, `TP_PLUGIN_SETTINGS`, etc. From within a plugin script this could be called like:
+ `generateDefinitionFromModule(sys.modules[__name__])`.
+
+ Setting `skip_invalid` to `True` will skip attributes with invalid values (they will not be included in generated output).
+ Default behavior is to only warn about them.
+
+ May raise an `ImportError` if the plugin script is missing required variables TP_PLUGIN_INFO and TP_PLUGIN_CATEGORIES.
+ Use `getMessages()` to check for any warnings/etc which may be generated (eg. from attribute validation).
+ """
+ # Load the "standard SDK declaration variables" from plugin script into local scope
+ # INFO and CATEGORY are required, rest are optional.
+ if not (info := getattr(plugin, "TP_PLUGIN_INFO", {})):
+ raise ImportError(f"ERROR: Could not import required TP_PLUGIN_INFO variable from plugin source.")
+ if not (cats := getattr(plugin, "TP_PLUGIN_CATEGORIES", {})):
+ raise ImportError(f"ERROR: Could not import required TP_PLUGIN_CATEGORIES variable from plugin source.")
+ return generateDefinitionFromDeclaration(
+ info, cats,
+ settings = getattr(plugin, "TP_PLUGIN_SETTINGS", {}),
+ actions = getattr(plugin, "TP_PLUGIN_ACTIONS", {}),
+ states = getattr(plugin, "TP_PLUGIN_STATES", {}),
+ events = getattr(plugin, "TP_PLUGIN_EVENTS", {}),
+ connectors = getattr(plugin, "TP_PLUGIN_CONNECTORS", {}),
+ skip_invalid = skip_invalid
+ )
+
+
+def generateDefinitionFromDeclaration(info:dict, categories:dict, skip_invalid:bool=False, **kwargs):
+ """
+ Returns an "entry.tp" Python `dict` which is suitable for direct conversion to JSON format.
+ Arguments should contain SDK declaration dict values, for example as specified for `TP_PLUGIN_INFO`, etc.
+
+ The `info` and `category` values are required, the rest are optional.
+
+ Setting `skip_invalid` to `True` will skip attributes with invalid values (they will not be included in generated output).
+ Default behavior is to only warn about them.
+
+ `kwargs` can be one or more of:
+ settings:dict={},
+ actions:dict={},
+ states:dict={},
+ events:dict={},
+ connectors:dict={}
+
+ Use `getMessages()` to check for any warnings/etc which may be generated (eg. from attribute validation).
+ """
+ _clearSeenIds()
+ clearMessages()
+ settings = kwargs.get('settings', {})
+ actions = kwargs.get('actions', {})
+ states = kwargs.get('states', {})
+ events = kwargs.get('events', {})
+ connectors = kwargs.get('connectors', {})
+ # print(info, categories, settings, actions, states, events, connectors)
+
+ # Start the root entry.tp object using basic plugin metadata
+ # This will also create an empty `categories` array in the root of the entry.
+ entry = _dictFromItem(info, TPSDK_ATTRIBS_ROOT, TPSDK_DEFAULT_VERSION, "info")
+
+ # Get the target SDK version (was either specified in plugin or is TPSDK_DEFAULT_VERSION)
+ tgt_sdk_v = entry['sdk']
+
+ # Loop over each plugin category and set up actions, states, events, and connectors.
+ for cat, data in categories.items():
+ path = f"category[{cat}]"
+ category = _dictFromItem(data, TPSDK_ATTRIBS_CATEGORY, tgt_sdk_v, path, skip_invalid)
+ category['actions'] = _arrayFromDict(actions, TPSDK_ATTRIBS_ACTION, tgt_sdk_v, cat, "actions", skip_invalid)
+ category['states'] = _arrayFromDict(states, TPSDK_ATTRIBS_STATE, tgt_sdk_v, cat, "states", skip_invalid)
+ category['events'] = _arrayFromDict(events, TPSDK_ATTRIBS_EVENT, tgt_sdk_v, cat, "events", skip_invalid)
+ if tgt_sdk_v >= 4:
+ category['connectors'] = _arrayFromDict(connectors, TPSDK_ATTRIBS_CONNECTOR, tgt_sdk_v, cat, "connectors", skip_invalid)
+ # add the category to entry's categories array
+ entry['categories'].append(category)
+
+ # Add Settings to root
+ if tgt_sdk_v >= 3:
+ entry['settings'].extend(_arrayFromDict(settings, TPSDK_ATTRIBS_SETTINGS, tgt_sdk_v, path = "settings", skip_invalid = skip_invalid))
+
+ return entry
+
+
+## Validation functions
+
+def validateAttribValue(key:str, value, attrib_data:dict, sdk_v:int, path:str=""):
+ """
+ Validates one attribute's value based on provided lookup table and target SDK version.
+ Returns `False` if any validation fails or `value` is `None`, `True` otherwise.
+ Error description message(s) can be retrieved with `getMessages()` and cleared with `clearMessages()`.
+
+ Args:
+ `key` is the attribute name;
+ `value` is what to validate;
+ `attrib_data` is the lookup table data for the given key (eg. `TPSDK_ATTRIBS_INFO[key]` );
+ `sdk_v` is the TP SDK version being used (for validation).
+ `path` is just extra information to print before the key name in warning messages (to show where attribute is in the tree).
+ """
+ global g_seen_ids
+ keypath = _keyPath(path, key)
+ if value is None:
+ if attrib_data.get('r'):
+ _addMessage(f"WARNING: Missing required attribute '{keypath}'.")
+ return False
+ if not isinstance(value, (exp_typ := attrib_data.get('t', str))):
+ _addMessage(f"WARNING: Wrong data type for attribute '{keypath}'. Expected {exp_typ} but got {type(value)}")
+ return False
+ if sdk_v < (min_sdk := attrib_data.get('v', sdk_v)):
+ _addMessage(f"WARNING: Wrong SDK version for attribute '{keypath}'. Minimum is v{min_sdk} but using v{sdk_v}")
+ return False
+ if (choices := attrib_data.get('c')) and value not in choices:
+ _addMessage(f"WARNING: Value error for attribute '{keypath}'. Got '{value}' but expected one of {choices}")
+ return False
+ if key == "id":
+ if not value in _seenIds():
+ _addSeenId(value, keypath)
+ else:
+ _addMessage(f"WARNING: The ID '{value}' in '{keypath}' is not unique. It was previously seen in '{g_seen_ids.get(value)}'")
+ return False
+ return True
+
+def _validateDefinitionDict(d:dict, table:dict, sdk_v:int, path:str=""):
+ # iterate over existing attributes to validate them
+ for k, v in d.items():
+ adata = table.get(k)
+ keypath = _keyPath(path, k)
+ if not adata:
+ _addMessage(f"WARNING: Attribute '{keypath}' is unknown.")
+ continue
+ if not validateAttribValue(k, v, adata, sdk_v, path):
+ continue
+ # print(k, v, type(v))
+ if isinstance(v, list) and (ltable := adata.get('l')):
+ _validateDefinitionArray(v, ltable, sdk_v, keypath)
+ # iterate over table entries to check if all required attribs are present
+ for k, data in table.items():
+ if data.get('r') and k not in d.keys():
+ _addMessage(f"WARNING: Missing required attribute '{_keyPath(path, k)}'.")
+
+def _validateDefinitionArray(a:list, table:dict, sdk_v:int, path:str=""):
+ i = 0
+ for item in a:
+ if isinstance(item, dict):
+ _validateDefinitionDict(item, table, sdk_v, f"{path}[{i:d}]")
+ else:
+ _addMessage(f"WARNING: Unable to handle array member '{item}' in '{path}'.")
+ i += 1
+
+
+def validateDefinitionObject(data:dict):
+ """
+ Validates a TP plugin definition structure from a Python `dict` object.
+ `data` is a de-serialzed entry.tp JSON object (eg. json.load('entry.tp'))
+ Returns `True` if no problems were found, `False` otherwise.
+ Use `getMessages()` to check for any validation warnings which may be generated.
+ """
+ _clearSeenIds()
+ clearMessages()
+ sdk_v = data.get('sdk', TPSDK_DEFAULT_VERSION)
+ _validateDefinitionDict(data, TPSDK_ATTRIBS_ROOT, sdk_v)
+ return len(g_messages) == 0
+
+def validateDefinitionString(data:str):
+ """
+ Validates a TP plugin definition structure from JSON string.
+ `data` is an entry.tp JSON string
+ Returns `True` if no problems were found, `False` otherwise.
+ Use `getMessages()` to check for any validation warnings which may be generated.
+ """
+ return validateDefinitionObject(json.loads(data))
+
+def validateDefinitionFile(file:Union[str, TextIO]):
+ """
+ Validates a TP plugin definition structure from JSON file.
+ `file` is a valid system path to an entry.tp JSON file _or_ an already-opened file handle (eg. sys.stdin).
+ Returns `True` if no problems were found, `False` otherwise.
+ Use `getMessages()` to check for any validation warnings which may be generated.
+ """
+ fh = file
+ if isinstance(fh, str):
+ fh = open(file, 'r')
+ ret = validateDefinitionObject(json.load(fh))
+ if fh != file:
+ fh.close()
+ return ret
+
+
+## CLI handlers
+
+def _generateDefinition(script, output_path, indent, skip_invalid:bool=False):
+ input_name = "input stream"
+ if isinstance(script, str):
+ if len(script.split(".")) < 2:
+ script = script + ".py"
+ input_name = script
+ indent = None if indent is None or int(indent) < 0 else indent
+
+ _printToErr(f"Generating plugin definition JSON from '{input_name}'...\n")
+ entry = generateDefinitionFromScript(script, skip_invalid)
+ entry_str = json.dumps(entry, indent=indent) + "\n"
+ valid = True
+ if (messages := getMessages()):
+ valid = False
+ _printMessages(messages)
+ _printToErr("")
+ # output
+ if output_path:
+ # write it to a file
+ with open(output_path, "w") as entry_file:
+ entry_file.write(entry_str)
+ _printToErr(f"Saved generated JSON to '{output_path}'\n")
+ else:
+ # send to stdout
+ print(entry_str)
+ _printToErr(f"Finished generating plugin definition JSON from '{input_name}'.\n")
+ return entry_str, valid
+
+
+def _validateDefinition(entry, as_str=False):
+ name = entry if isinstance(entry, str) and not as_str else "input stream"
+ _printToErr(f"Validating '{name}', any errors or warnings will be printed below...\n")
+ if as_str:
+ res = validateDefinitionString(entry)
+ else:
+ res = validateDefinitionFile(entry)
+ if res:
+ _printToErr("No problems found!")
+ else:
+ _printMessages(getMessages())
+ _printToErr(f"\nFinished validating '{name}'.\n")
+ return res
+
+
+def main():
+ from argparse import ArgumentParser
+
+ parser = ArgumentParser(epilog="This script exits with status code -1 (error) if generation or validation produces warning messages about malformed data. "
+ "All progress and warning messages are printed to stderr stream.")
+ parser.add_argument("-g", "--generate", action='store_true',
+ help="Generate a definition file from plugin script data. This is the default action.")
+ parser.add_argument("-v", "--validate", action='store_true',
+ help="Validate a definition JSON file (entry.tp). If given with `generate` then will validate the generated JSON output.")
+ parser.add_argument("target", metavar="target", nargs="?", default="",
+ help="Either a plugin script for `generate` or an entry.tp file for `validate`. Paths are relative to current working directory. "
+ "Defaults to './main.py' and './entry.tp' respectively. Use 'stdin' (or '-') to read from input stream instead. ")
+ gen_grp = parser.add_argument_group("Generator arguments")
+ gen_grp.add_argument("-o", metavar="",
+ help="Output file for `generate` action. Default will be a file named 'entry.tp' in the same folder as the input script. "
+ "Paths are relative to current working directory. Use 'stdout' (or '-') to print the output to the console/stream instead.")
+ gen_grp.add_argument("-s", "--skip-invalid", action='store_true', dest="skip_invalid", default=False,
+ help="Skip attributes with invalid values (they will not be included in generated output). Default behavior is to only warn about them.")
+ gen_grp.add_argument("-i", "--indent", metavar="", type=int, default=2,
+ help="Indent level (spaces) for generated JSON. Use 0 for only newlines, or -1 for the most compact representation. Default is %(default)s spaces.")
+ opts = parser.parse_args()
+ del parser
+
+ # default action
+ opts.generate = opts.generate or not opts.validate
+
+ _printToErr("")
+
+ if opts.target in ("-","stdin"):
+ opts.target = sys.stdin
+
+ valid = True
+ entry_str = ""
+ if opts.generate:
+ opts.target = _normPath(opts.target or "main.py")
+ output_path = None
+ if opts.o:
+ if opts.o not in ("-","stdout"):
+ output_path = opts.o
+ else:
+ out_dir = os.getcwd() if hasattr(opts.target, "read") else os.path.dirname(opts.target)
+ output_path = os.path.join(out_dir, "entry.tp")
+ entry_str, valid = _generateDefinition(opts.target, output_path, opts.indent, opts.skip_invalid)
+ if opts.validate and output_path:
+ opts.target = output_path
+
+ if opts.validate:
+ if entry_str:
+ valid = _validateDefinition(entry_str, True)
+ else:
+ opts.target = _normPath(opts.target or "entry.tp")
+ valid = _validateDefinition(opts.target)
+
+ return 0 if valid else -1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/examples/plugin-example.py b/examples/plugin-example.py
new file mode 100644
index 0000000..991e1ae
--- /dev/null
+++ b/examples/plugin-example.py
@@ -0,0 +1,288 @@
+#!/usr/bin/env python3
+'''
+Touch Portal Plugin Example
+'''
+
+import sys
+
+# Load the TP Python API. Note that TouchPortalAPI must be installed (eg. with pip)
+# _or_ be in a folder directly below this plugin file.
+import TouchPortalAPI as TP
+
+# imports below are optional, to provide argument parsing and logging functionality
+from argparse import ArgumentParser
+from logging import (getLogger, Formatter, NullHandler, FileHandler, StreamHandler, DEBUG, INFO, WARNING)
+
+
+# Version string of this plugin (in Python style).
+__version__ = "1.0"
+
+# The unique plugin ID string is used in multiple places.
+# It also forms the base for all other ID strings (for states, actions, etc).
+PLUGIN_ID = "tp.plugin.example.python"
+
+## Start Python SDK declarations
+# These will be used to generate the entry.tp file,
+# and of course can also be used within this plugin's code.
+# These could also live in a separate .py file which is then imported
+# into your plugin's code, and be used directly to generate the entry.tp JSON.
+#
+# Some entries have default values (like "type" for a Setting),
+# which are commented below and could technically be excluded from this code.
+#
+# Note that you may add any arbitrary keys/data to these dictionaries
+# w/out breaking the generation routine. Only known TP SDK attributes
+# (targeting the specified SDK version) will be used in the final entry.tp JSON.
+##
+
+# Basic plugin metadata
+TP_PLUGIN_INFO = {
+ 'sdk': 3,
+ 'version': int(float(__version__) * 100), # TP only recognizes integer version numbers
+ 'name': "Touch Portal Plugin Example",
+ 'id': PLUGIN_ID,
+ 'configuration': {
+ 'colorDark': "#25274c",
+ 'colorLight': "#707ab5"
+ }
+}
+
+# Setting(s) for this plugin. These could be either for users to
+# set, or to persist data between plugin runs (as read-only settings).
+TP_PLUGIN_SETTINGS = {
+ 'example': {
+ 'name': "Example Setting",
+ # "text" is the default type and could be omitted here
+ 'type': "text",
+ 'default': "Example value",
+ 'readOnly': False, # this is also the default
+ 'value': None # we can optionally use the settings struct to hold the current value
+ },
+}
+
+# This example only uses one Category for actions/etc., but multiple categories are supported also.
+TP_PLUGIN_CATEGORIES = {
+ "main": {
+ 'id': PLUGIN_ID + ".main",
+ 'name' : "Python Examples",
+ 'imagepath' : "icon-24.png"
+ }
+}
+
+# Action(s) which this plugin supports.
+TP_PLUGIN_ACTIONS = {
+ 'example': {
+ # 'category' is optional, if omitted then this action will be added to all, or the only, category(ies)
+ 'category': "main",
+ 'id': PLUGIN_ID + ".act.example",
+ 'name': "Set Example State",
+ 'prefix': TP_PLUGIN_CATEGORIES['main']['name'],
+ 'type': "communicate",
+ # 'format' tokens like $[1] will be replaced in the generated JSON with the corresponding data id wrapped with "{$...$}".
+ # Numeric token values correspond to the order in which the data items are listed here, while text tokens correspond
+ # to the last part of a dotted data ID (the part after the last period; letters, numbers, and underscore allowed).
+ 'format': "Set Example State Text to $[text] and Color to $[2]",
+ 'data': {
+ 'text': {
+ 'id': PLUGIN_ID + ".act.example.data.text",
+ # "text" is the default type and could be omitted here
+ 'type': "text",
+ 'label': "Text",
+ 'default': "Hello World!"
+ },
+ 'color': {
+ 'id': PLUGIN_ID + ".act.example.data.color",
+ 'type': "color",
+ 'label': "Color",
+ 'default': "#818181FF"
+ },
+ }
+ },
+}
+
+# Plugin static state(s). These are listed in the entry.tp file,
+# vs. dynamic states which would be created/removed at runtime.
+TP_PLUGIN_STATES = {
+ 'text': {
+ # 'category' is optional, if omitted then this state will be added to all, or the only, category(ies)
+ 'category': "main",
+ 'id': PLUGIN_ID + ".state.text",
+ # "text" is the default type and could be omitted here
+ 'type': "text",
+ 'desc': "Example State Text",
+ # we can conveniently use a value here which we already defined above
+ 'default': TP_PLUGIN_ACTIONS['example']['data']['text']['default']
+ },
+ 'color': {
+ 'id': PLUGIN_ID + ".state.color",
+ 'desc': "Example State Color",
+ 'default': TP_PLUGIN_ACTIONS['example']['data']['color']['default']
+ },
+}
+
+# Plugin Event(s).
+TP_PLUGIN_EVENTS = {}
+
+##
+## End Python SDK declarations
+
+
+# Create the Touch Portal API client.
+try:
+ TPClient = TP.Client(
+ pluginId = PLUGIN_ID, # required ID of this plugin
+ sleepPeriod = 0.05, # allow more time than default for other processes
+ autoClose = True, # automatically disconnect when TP sends "closePlugin" message
+ checkPluginId = True, # validate destination of messages sent to this plugin
+ maxWorkers = 4, # run up to 4 event handler threads
+ updateStatesOnBroadcast = False, # do not spam TP with state updates on every page change
+ )
+except Exception as e:
+ sys.exit(f"Could not create TP Client, exiting. Error was:\n{repr(e)}")
+
+# Crate the (optional) global logger
+g_log = getLogger()
+
+# Settings will be sent by TP upon initial connection to the plugin,
+# as well as whenever they change at runtime. This example uses a
+# shared function to handle both cases. See also onConnect() and onSettingUpdate()
+def handleSettings(settings, on_connect=False):
+ # the settings array from TP can just be flattened to a single dict,
+ # from:
+ # [ {"Setting 1" : "value"}, {"Setting 2" : "value"} ]
+ # to:
+ # { "Setting 1" : "value", "Setting 2" : "value" }
+ settings = { list(settings[i])[0] : list(settings[i].values())[0] for i in range(len(settings)) }
+ # now we can just get settings, and their values, by name
+ if (value := settings.get(TP_PLUGIN_SETTINGS['example']['name'])) is not None:
+ # this example doesn't do anything useful with the setting, just saves it
+ TP_PLUGIN_SETTINGS['example']['value'] = value
+
+
+## TP Client event handler callbacks
+
+# Initial connection handler
+@TPClient.on(TP.TYPES.onConnect)
+def onConnect(data):
+ g_log.info(f"Connected to TP v{data.get('tpVersionString', '?')}, plugin v{data.get('pluginVersion', '?')}.")
+ g_log.debug(f"Connection: {data}")
+ if settings := data.get('settings'):
+ handleSettings(settings, True)
+
+# Settings handler
+@TPClient.on(TP.TYPES.onSettingUpdate)
+def onSettingUpdate(data):
+ g_log.debug(f"Settings: {data}")
+ if (settings := data.get('values')):
+ handleSettings(settings, False)
+
+# Action handler
+@TPClient.on(TP.TYPES.onAction)
+def onAction(data):
+ g_log.debug(f"Action: {data}")
+ # check that `data` and `actionId` members exist and save them for later use
+ if not (action_data := data.get('data')) or not (aid := data.get('actionId')):
+ return
+ if aid == TP_PLUGIN_ACTIONS['example']['id']:
+ # set our example State text and color values with the data from this action
+ text = TP.getActionDataValue(action_data, TP_PLUGIN_ACTIONS['example']['data']['text'])
+ color = TP.getActionDataValue(action_data, TP_PLUGIN_ACTIONS['example']['data']['color'])
+ TPClient.stateUpdate(TP_PLUGIN_STATES['text']['id'], text)
+ TPClient.stateUpdate(TP_PLUGIN_STATES['color']['id'], color)
+ else:
+ g_log.warning("Got unknown action ID: " + aid)
+
+# Shutdown handler
+@TPClient.on(TP.TYPES.onShutdown)
+def onShutdown(data):
+ g_log.info('Received shutdown event from TP Client.')
+ # We do not need to disconnect manually because we used `autoClose = True`
+ # when constructing TPClient()
+ # TPClient.disconnect()
+
+# Error handler
+@TPClient.on(TP.TYPES.onError)
+def onError(exc):
+ g_log.error(f'Error in TP Client event handler: {repr(exc)}')
+ # ... do something ?
+
+## main
+
+def main():
+ global TPClient, g_log
+ ret = 0 # sys.exit() value
+
+ # handle CLI arguments
+ parser = ArgumentParser()
+ parser.add_argument("-d", action='store_true',
+ help="Use debug logging.")
+ parser.add_argument("-w", action='store_true',
+ help="Only log warnings and errors.")
+ parser.add_argument("-q", action='store_true',
+ help="Disable all logging (quiet).")
+ parser.add_argument("-l", metavar="",
+ help="Log to this file (default is stdout).")
+ parser.add_argument("-s", action='store_true',
+ help="If logging to file, also output to stdout.")
+
+ opts = parser.parse_args()
+ del parser
+
+ # set up logging
+ if opts.q:
+ # no logging at all
+ g_log.addHandler(NullHandler())
+ else:
+ # set up pretty log formatting (similar to TP format)
+ fmt = Formatter(
+ fmt="{asctime:s}.{msecs:03.0f} [{levelname:.1s}] [{filename:s}:{lineno:d}] {message:s}",
+ datefmt="%H:%M:%S", style="{"
+ )
+ # set the logging level
+ if opts.d: g_log.setLevel(DEBUG)
+ elif opts.w: g_log.setLevel(WARNING)
+ else: g_log.setLevel(INFO)
+ # set up log destination (file/stdout)
+ if opts.l:
+ try:
+ # note that this will keep appending to any existing log file
+ fh = FileHandler(str(opts.l))
+ fh.setFormatter(fmt)
+ g_log.addHandler(fh)
+ except Exception as e:
+ opts.s = True
+ print(f"Error while creating file logger, falling back to stdout. {repr(e)}")
+ if not opts.l or opts.s:
+ sh = StreamHandler(sys.stdout)
+ sh.setFormatter(fmt)
+ g_log.addHandler(sh)
+
+ # ready to go
+ g_log.info(f"Starting {TP_PLUGIN_INFO['name']} v{__version__} on {sys.platform}.")
+
+ try:
+ # Connect to Touch Portal desktop application.
+ # If connection succeeds, this method will not return (blocks) until the client is disconnected.
+ TPClient.connect()
+ g_log.info('TP Client closed.')
+ except KeyboardInterrupt:
+ g_log.warning("Caught keyboard interrupt, exiting.")
+ except Exception:
+ # This will catch and report any critical exceptions in the base TPClient code,
+ # _not_ exceptions in this plugin's event handlers (use onError(), above, for that).
+ from traceback import format_exc
+ g_log.error(f"Exception in TP Client:\n{format_exc()}")
+ ret = -1
+ finally:
+ # Make sure TP Client is stopped, this will do nothing if it is already disconnected.
+ TPClient.disconnect()
+
+ # TP disconnected, clean up.
+ del TPClient
+
+ g_log.info(f"{TP_PLUGIN_INFO['name']} stopped.")
+ return ret
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/setup.py b/setup.py
index 1ae974e..d11e907 100644
--- a/setup.py
+++ b/setup.py
@@ -13,7 +13,7 @@
setup(
name='TouchPortal API',
- version='1.4',
+ version='1.5',
description='Touch Portal API for Python',
long_description=open('README.md').read(),
long_description_content_type="text/markdown",
@@ -22,11 +22,14 @@
author_email='DamienWeiFen@gmail.com',
license='MIT',
classifiers=classifiers,
- keywords='TouchPortal, API, Plugin',
+ keywords='TouchPortal, API, Plugin, SDK',
packages=['TouchPortalAPI'],
python_requires='>=3.8',
install_requires=[
'pyee',
'requests'
- ]
+ ],
+ entry_points = {
+ 'console_scripts': ['tppsdk=TouchPortalAPI.sdk_tools:main']
+ }
)