diff --git a/database/__init__.py b/database/__init__.py index 2f2fd9eee..11c604125 100755 --- a/database/__init__.py +++ b/database/__init__.py @@ -102,6 +102,7 @@ def __init__(self, sh, *args, **kwargs): if self._removeold_cycle == self._dump_cycle: self._removeold_cycle += 2 self._precision = self.get_parameter_value('precision') + self._time_precision = self.get_parameter_value('time_precision') self.count_logentries = self.get_parameter_value('count_logentries') self.max_delete_logentries = self.get_parameter_value('max_delete_logentries') self.max_reassign_logentries = self.get_parameter_value('max_reassign_logentries') @@ -1115,21 +1116,21 @@ def _series(self, func, start, end='now', count=100, ratio=1, update=False, step sid = item + '|' + func + '|' + str(start) + '|' + str(end) + '|' + str(count) func, expression = self._expression(func) queries = { - 'avg': 'MIN(time), ' + self._precision_query('AVG(val_num * duration) / AVG(duration)'), + 'avg': self._time_precision_query('MIN(time)') + ', ' + self._precision_query('AVG(val_num * duration) / AVG(duration)'), 'avg.order': 'ORDER BY time ASC', - 'integrate': 'MIN(time), SUM(val_num * duration)', - 'diff': 'MIN(time), (val_num - LAG(val_num,1) OVER (ORDER BY val_num))', - 'duration': 'MIN(time), duration', + 'integrate': self._time_precision_query('MIN(time)') + ', SUM(val_num * duration)', + 'diff': self._time_precision_query('MIN(time)') + ', (val_num - LAG(val_num,1) OVER (ORDER BY val_num))', + 'duration': self._time_precision_query('MIN(time)') + ', duration', # differentiate (d/dt) is scaled to match the conversion from d/dt (kWh) = kWh: time is in ms, val_num in kWh, therefore scale by 1000ms and 3600s/h to obtain the result in kW: - 'differentiate': 'MIN(time), (val_num - LAG(val_num,1) OVER (ORDER BY val_num)) / ( (time - LAG(time,1) OVER (ORDER BY val_num)) / (3600 * 1000) )', - 'count': 'MIN(time), SUM(CASE WHEN val_num{op}{value} THEN 1 ELSE 0 END)'.format(**expression['params']), - 'countall': 'MIN(time), COUNT(*)', - 'min': 'MIN(time), MIN(val_num)', - 'max': 'MIN(time), MAX(val_num)', - 'on': 'MIN(time), ' + self._precision_query('SUM(val_bool * duration) / SUM(duration)'), + 'differentiate': self._time_precision_query('MIN(time)') + ', (val_num - LAG(val_num,1) OVER (ORDER BY val_num)) / ( (time - LAG(time,1) OVER (ORDER BY val_num)) / (3600 * 1000) )', + 'count': self._time_precision_query('MIN(time)') + ', SUM(CASE WHEN val_num{op}{value} THEN 1 ELSE 0 END)'.format(**expression['params']), + 'countall': self._time_precision_query('MIN(time)') + ', COUNT(*)', + 'min': self._time_precision_query('MIN(time)') + ', MIN(val_num)', + 'max': self._time_precision_query('MIN(time)') + ', MAX(val_num)', + 'on': self._time_precision_query('MIN(time)') + ', ' + self._precision_query('SUM(val_bool * duration) / SUM(duration)'), 'on.order': 'ORDER BY time ASC', - 'sum': 'MIN(time), SUM(val_num)', - 'raw': 'time, val_num', + 'sum': self._time_precision_query('MIN(time)') + ', SUM(val_num)', + 'raw': self._time_precision_query('time') + ', val_num', 'raw.order': 'ORDER BY time ASC', 'raw.group': '' } @@ -1241,6 +1242,10 @@ def _precision_query(self, query): return 'ROUND({}, {})'.format(query, self._precision) return query + def _time_precision_query(self, query): + if self._time_precision < 3: + return 'ROUND({}, {})'.format(query, self._time_precision - 3) + return query def _fetch_log(self, item, columns, start, end, step=None, count=100, group='', order=''): _item = self.items.return_item(item) diff --git a/database/plugin.yaml b/database/plugin.yaml index 50f3e59fb..19c732339 100755 --- a/database/plugin.yaml +++ b/database/plugin.yaml @@ -57,6 +57,13 @@ parameters: de: 'Genauigkeit der aus der Datenbank ausgelesenen Werte (Nachkommastellen).' en: 'Precision of values read from database (digits after comma).' + time_precision: + type: int + default: 3 + description: + de: 'Genauigkeit der aus der Datenbank ausgelesenen Zeitwerte (Nachkommastellen (für Sekunden)).' + en: 'Precision of time values read from database (digits after comma (for seconds).' + count_logentries: type: bool default: False diff --git a/enocean/protocol/eep_parser.py b/enocean/protocol/eep_parser.py index d9d90def3..d54d4e2a1 100755 --- a/enocean/protocol/eep_parser.py +++ b/enocean/protocol/eep_parser.py @@ -177,7 +177,7 @@ def _parse_eep_A5_07_03(self, payload, status): self.logger.error(f"Occupancy sensor issued error code: {payload[0]}") else: result['SVC'] = payload[0] / 255.0 * 5.0 # supply voltage in volts - result['ILL'] = payload[1] << 2 + (payload[2] & 0xC0) >> 6 # 10 bit illumination in lux + result['ILL'] = (payload[1] << 2) + ((payload[2] & 0xC0) >> 6) # 10 bit illumination in lux result['PIR'] = (payload[3] & 0x80) == 0x80 # Movement flag, 1:motion detected self.logger.debug(f"Occupancy: PIR:{result['PIR']} illumination: {result['ILL']}lx, voltage: {result['SVC']}V") return result @@ -240,7 +240,7 @@ def _parse_eep_A5_12_01(self, payload, status): self.logger.debug("Processing A5_12_01: powermeter: Unit is Watts") else: self.logger.debug("Processing A5_12_01: powermeter: Unit is kWh") - value = (payload[0] << 16 + payload[1] << 8 + payload[2]) / divisor + value = ((payload[0] << 16) + (payload[1] << 8) + payload[2]) / divisor self.logger.debug(f"Processing A5_12_01: powermeter: {value} W") # It is confirmed by Eltako that with the use of multiple repeaters in an Eltako network, values can be corrupted in random cases. @@ -326,7 +326,7 @@ def _parse_eep_A5_38_08(self, payload, status): def _parse_eep_A5_3F_7F(self, payload, status): self.logger.debug("Processing A5_3F_7F") results = {'DI_3': (payload[3] & 1 << 3) == 1 << 3, 'DI_2': (payload[3] & 1 << 2) == 1 << 2, 'DI_1': (payload[3] & 1 << 1) == 1 << 1, 'DI_0': (payload[3] & 1 << 0) == 1 << 0} - results['AD_0'] = ((payload[1] & 0x03) << 8 + payload[2]) * 1.8 / pow(2, 10) + results['AD_0'] = (((payload[1] & 0x03) << 8) + payload[2]) * 1.8 / pow(2, 10) results['AD_1'] = (payload[1] >> 2) * 1.8 / pow(2, 6) results['AD_2'] = payload[0] * 1.8 / pow(2, 8) return results diff --git a/githubplugin/__init__.py b/githubplugin/__init__.py new file mode 100644 index 000000000..b1c6e7921 --- /dev/null +++ b/githubplugin/__init__.py @@ -0,0 +1,916 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2024- Sebastian Helms Morg @ knx-user-forum +######################################################################### +# This file is part of SmartHomeNG. +# https://www.smarthomeNG.de +# https://knx-user-forum.de/forum/supportforen/smarthome-py +# +# Sample plugin for new plugins to run with SmartHomeNG version 1.10 +# and up. +# +# SmartHomeNG 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. +# +# SmartHomeNG 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 SmartHomeNG. If not, see . +# +######################################################################### + +import os +from shutil import rmtree +from pathlib import Path + +from lib.model.smartplugin import SmartPlugin +from lib.shyaml import yaml_load +from .webif import WebInterface +from .gperror import GPError + +from github import Auth +from github import Github +from git import Repo + + +class GitHubHelper(object): + """ Helper class for handling the GitHub API """ + + def loggerr(self, msg): + """ log error message and raise GPError to signal WebIf """ + + # TODO: this need to be reworked if WebIf errors should be displayed in German or translated + self.logger.error(msg) + raise GPError(msg) + + def __init__(self, dt, logger, repo='plugins', apikey='', auth=None, **kwargs): + self.dt = dt + self.logger = logger + self.apikey = apikey + # allow auth only, if apikey is set + if auth is None: + self.auth = bool(self.apikey) + else: + self.auth = auth and bool(self.apikey) + + # name of the repo, at present always 'plugins' + self.repo = repo + + # github class instance + self._github = None + + # contains a list of all smarthomeNG/plugins forks after fetching from GitHub: + # + # self.forks = { + # 'owner': { + # 'repo': git.Repo(path), + # 'branches': { # (optional!) + # '': {'branch': git.Branch(name=""), 'repo': git.Repo(path)', 'owner': ''}, + # '': {...} + # } + # } + # } + # + # the 'branches' key and data is only inserted after branches have been fetched from github + # repo and owner info are identical to the forks' data and present for branches return data + # outside the self.forks dict + self.forks = {} + + # contains a list of all PRs of smarthomeNG/plugins after fetching from GitHub: + # + # self.pulls = { + # : {'title': '', 'pull': github.PullRequest(title, number), 'git_repo': git.Repo(path), 'owner': '<fork owner>', 'repo': 'plugins', 'branch': '<branch>'}, + # <PR2 number>: {...} + # } + # + # as this is the GitHub PR data, no information is present which plugin "really" is + # changed in the PR, need to identify this later + self.pulls = {} + + # keeps the git.Repo() for smarthomeNG/plugins + self.git_repo = None + + def login(self): + try: + if self.auth: + auth = Auth.Token(self.apikey) + else: + auth = None + + self._github = Github(auth=auth, retry=None) + + if auth: + self._github.get_user().login + + if not self._github: + raise GPError('setup/login failed') + except Exception as e: + self._github = None + self.loggerr(f'could not initialize Github object: {e}') + + def get_rate_limit(self): + """ return list of allowed and remaining requests and backoff seconds """ + try: + rl = self._github.get_rate_limit() + allow = rl.core.limit + remain = rl.core.remaining + backoff = (rl.core.reset - self.dt.now()).total_seconds() + if backoff < 0: + backoff = 0 + except Exception as e: + self.logger.warning(f'error while getting rate limits: {e}') + return [0, 0, 0] + + return [allow, remain, backoff] + + def get_repo(self, user, repo): + if not self._github: + self.login() + + try: + return self._github.get_repo(f'{user}/{repo}') + except Exception: + pass + + def set_repo(self) -> bool: + if not self._github: + self.login() + + self.git_repo = self.get_repo('smarthomeNG', self.repo) + return True + + def get_pulls(self, fetch=False) -> bool: + if not self._github: + self.login() + + if not self.git_repo: + self.set_repo() + + # succeed if cached pulls present and fetch not requested + if not fetch: + if self.pulls != {}: + self.logger.debug(f'using cached pulls: {self.pulls.keys()}') + return True + + self.logger.debug('fetching pulls from github') + self.pulls = {} + try: + for pull in self.git_repo.get_pulls(): + self.pulls[pull.number] = { + 'title': pull.title, + 'pull': pull, + 'git_repo': pull.head.repo, + 'owner': pull.head.repo.owner.login, + 'repo': pull.head.repo.name, + 'branch': pull.head.ref + } + except AttributeError: + self.logger.warning('github object not created. Check rate limit.') + return False + + return True + + def get_forks(self, fetch=False) -> bool: + if not self._github: + self.login() + + if not self.git_repo: + self.set_repo() + + # succeed if cached forks present and fetch not requested + if not fetch: + if self.forks != {}: + return True + + self.forks = {} + try: + for fork in self.git_repo.get_forks(): + self.forks[fork.full_name.split('/')[0]] = {'repo': fork} + except AttributeError: + self.logger.warning('github object not created. Check rate limit.') + return False + + return True + + def get_branches_from(self, fork=None, owner='', fetch=False) -> dict: + + if fork is None and owner: + try: + fork = self.forks[owner]['repo'] + except Exception: + pass + if not fork: + return {} + + # if not specifically told to fetch from github - + if not fetch: + # try to get cached branches + try: + b_list = self.forks[fork.owner.login]['branches'] + except KeyError: + pass + else: + self.logger.debug(f'returning cached {b_list}') + return b_list + + # fetch from github + self.logger.debug('refreshing branches from github') + branches = fork.get_branches() + b_list = {} + for branch in branches: + b_list[branch.name] = {'branch': branch, 'repo': fork, 'owner': fork.owner.login} + + self.forks[fork.owner.login]['branches'] = b_list + return b_list + + def get_plugins_from(self, fork=None, owner='', branch='', fetch=False) -> list: + + if not branch: + return [] + + if fork is None and owner: + try: + fork = self.forks[owner]['repo'] + except Exception: + pass + + if not fork: + return [] + + # if not specifically told to fetch from github, - + if not fetch: + # try to get cached plugin list + try: + plugins = self.forks[fork.owner.login]['branches'][branch]['plugins'] + except KeyError: + pass + else: + self.logger.debug(f'returning cached plugins {plugins}') + return plugins + + # plugins not yet cached, fetch from github + self.logger.debug('fetching plugins from github') + contents = fork.get_contents("", ref=branch) + plugins = [item.path for item in contents if item.type == 'dir' and not item.path.startswith('.')] + + try: + # add plugins to branch entry, if present + b = self.forks[fork.owner.login]['branches'][branch] + b['plugins'] = plugins + except KeyError: + pass + return sorted(plugins) + + +class GithubPlugin(SmartPlugin): + """ + This class supports testing foreign plugins by letting the user select a + shng plugins fork and branch, and then setting up a local repo containing + that fork. Additionally, the specified plugin will be soft-linked into the + "live" plugins repo worktree as a private plugin. + """ + PLUGIN_VERSION = '1.0.0' + REPO_DIR = 'priv_repos' + + def loggerr(self, msg): + """ log error message and raise GPError to signal WebIf """ + self.logger.error(msg) + raise GPError(msg) + + def __init__(self, sh): + super().__init__() + + # self.repos enthält die Liste der lokal eingebundenen Fremd-Repositories + # mit den jeweils zugehörigen Daten über das installierte Plugin, den + # jeweiligen Worktree/Branch und Pfadangaben. + # + # self.repos = { + # '<id1>': { + # 'plugin': '<plugin>', # Name des installierten Plugins + # 'owner': '<owner>', # Owner des GitHub-Forks + # 'branch': '<branch>'', # Branch im GitHub-Fork + # 'gh_repo': 'plugins', # fix, Repo smarthomeNG/plugins + # 'url': f'https://github.com/{owner}/plugins.git', # URL des Forks + # 'repo_path': repo_path, # absoluter Pfad zum Repo + # 'wt_path': wt_path, # absoluter Pfad zum Worktree + # 'disp_wt_path': 'plugins/priv_repos/...' # relativer Pfad zum Worktree vom shng-Basisverzeichnis aus + # 'rel_wt_path': os.path.join('..', '..', wt_path), # relativer Pfad zum Worktree vom Repo aus + # 'link': os.path.join('plugins', f'priv_{plugin}'), # absoluter Pfad-/Dateiname des Plugin-Symlinks + # 'rel_link_path': os.path.join(wt_path, plugin), # Ziel der Plugin-Symlinks: relativer Pfad des Ziel-Pluginordners "unterhalb" von plugins/ + # 'repo': repo, # git.Repo(path) + # 'clean': bool # repo is clean and synced? + # }, + # '<id2>': {...} + # } + self.repos = {} + + # to make sure we really hit the right path, don't use relative paths + # but use shngs BASE path attribute + self.plg_path = os.path.join(self._sh.get_basedir(), 'plugins') + self.repo_path = os.path.join(self.plg_path, self.REPO_DIR) + + # make plugins/priv_repos if not present + if not os.path.exists(self.repo_path): + self.logger.debug(f'creating repo dir {self.repo_path}') + os.mkdir(self.repo_path) + + self.gh_apikey = self.get_parameter_value('app_token') + self.gh = GitHubHelper(self._sh.shtime, apikey=self.gh_apikey, logger=self.logger) + + self.init_webinterface(WebInterface) + + # + # methods for handling local repos + # + + def read_repos_from_dir(self, exc=False): + # clear stored repos + self.repos = {} + + # plugins/priv_repos not present -> no previous plugin action + if not os.path.exists(self.repo_path): + return + + self.logger.debug('checking plugin links') + pathlist = Path(self.plg_path).glob('priv_*') + for item in pathlist: + if not item.is_symlink(): + self.logger.debug(f'ignoring {item}, is not symlink') + continue + target = os.path.join(self.plg_path, os.readlink(str(item))) + if not os.path.isdir(target): + self.logger.debug(f'ignoring link {item}, target {target} is not directory') + continue + try: + # plugins/priv_repos/foo_wt_bar/baz/ -> + # pr = 'priv_repos' + # wt = 'foo_wt_bar' + # plugin = 'baz' + pr, wt, plugin = self._get_last_3_path_parts(target) + if pr != 'priv_repos' or '_wt_' not in wt: + self.logger.debug(f'ignoring {target}, not in priv_repos/*_wt_*/plugin form ') + continue + except Exception: + continue + + try: + # owner_wt_branch + owner, branch = wt.split('_wt_') + except Exception: + self.logger.debug(f'ignoring {target}, not in priv_repos/*_wt_*/plugin form ') + continue + + # surely it is possible to deduce the different path names from previously uses paths + # but this seems more consistent... + wtpath, _ = os.path.split(target) + repo = Repo(wtpath) + repo_path = os.path.join(self.repo_path, owner) + wt_path = os.path.join(self.repo_path, f'{owner}_wt_{branch}') + + # use part of link name after ".../plugins/priv_" + name = str(item)[len(self.plg_path) + 6:] + + self.repos[name] = { + 'plugin': plugin, + 'owner': owner, + 'branch': branch, + 'gh_repo': 'plugins', + 'url': f'https://github.com/{owner}/plugins.git', + 'repo_path': repo_path, + 'wt_path': wt_path, + 'disp_wt_path': wt_path[len(self._sh.get_basedir()) + 1:], + 'rel_wt_path': os.path.join('..', f'{owner}_wt_{branch}'), + 'link': str(item), + 'rel_link_path': str(target), + 'repo': repo, + } + self.repos[name]['clean'] = self.is_repo_clean(name, exc) + + def check_for_repo_name(self, name) -> bool: + """ check if name exists in repos or link exists """ + if name in self.repos or os.path.exists(os.path.join(self.plg_path, 'priv_' + name)): + self.loggerr(f'name {name} already taken, delete old plugin first or choose a different name.') + return False + + return True + + def create_repo(self, name, owner, plugin, branch=None, rename=False) -> bool: + """ create repo from given parameters """ + + if not rename: + try: + self.check_for_repo_name(name) + except Exception as e: + self.loggerr(e) + return False + + if not owner or not plugin: + self.loggerr(f'Insufficient parameters, github user {owner} or plugin {plugin} empty, unable to fetch repo, aborting.') + return False + + # if branch is not given, assume that the branch is named like the plugin + if not branch: + branch = plugin + + repo = { + 'plugin': plugin, + 'owner': owner, + 'branch': branch, + 'plugin': plugin, + # default to plugins repo. No further repos are managed right now + 'gh_repo': 'plugins' + } + + # build GitHub url from parameters. Hope they don't change the syntax... + repo['url'] = f'https://github.com/{owner}/{repo["gh_repo"]}.git' + + # path to git repo dir, default to "plugins/priv_repos/{owner}" + repo['repo_path'] = os.path.join(self.repo_path, owner) + + # üath to git worktree dir, default to "plugins/priv_repos/{owner}_wt_{branch}" + repo['wt_path'] = os.path.join(self.repo_path, f'{owner}_wt_{branch}') + repo['disp_wt_path'] = repo['wt_path'][len(self._sh.get_basedir()) + 1:] + repo['rel_wt_path'] = os.path.join('..', f'{owner}_wt_{branch}') + + # set link location from plugin name + repo['link'] = os.path.join(self.plg_path, f'priv_{name}') + repo['rel_link_path'] = os.path.join(self.REPO_DIR, f'{owner}_wt_{branch}', plugin) + + # make plugins/priv_repos if not present + if not os.path.exists(self.repo_path): + self.logger.debug(f'creating dir {self.repo_path}') + os.mkdir(self.repo_path) + + self.logger.debug(f'check for repo at {repo["repo_path"]}...') + + if os.path.exists(repo['repo_path']) and os.path.isdir(repo['repo_path']): + # path exists, try to load existing repo + repo['repo'] = Repo(repo['repo_path']) + + self.logger.debug(f'found repo {repo["repo"]} at {repo["repo_path"]}') + + if "origin" not in repo['repo'].remotes: + self.loggerr(f'repo at {repo["repo_path"]} has no "origin" remote set') + return False + + origin = repo['repo'].remotes.origin + if origin.url != repo['url']: + self.loggerr(f'origin of existing repo is {origin.url}, expecting {repo["url"]}') + return False + + else: + self.logger.debug(f'cloning repo at {repo["repo_path"]} from {repo["url"]}...') + + # clone repo from url + try: + repo['repo'] = Repo.clone_from(repo['url'], repo['repo_path']) + except Exception as e: + self.loggerr(f'error while cloning: {e}') + + # fetch repo data + self.logger.debug('fetching from origin...') + repo['repo'].remotes.origin.fetch() + + wtr = '' + + # cleanup worktrees (just in case...) + try: + self.logger.debug('pruning worktree') + repo['repo'].git.worktree('prune') + except Exception: + pass + + # setup worktree + if not os.path.exists(repo['wt_path']): + self.logger.debug(f'creating worktree at {repo["wt_path"]}...') + wtr = repo['repo'].git.worktree('add', repo['rel_wt_path'], repo['branch']) + + # path exists, try to load existing worktree + repo['wt'] = Repo(repo['wt_path']) + self.logger.debug(f'found worktree {repo["wt"]} at {repo["wt_path"]}') + + # worktree not created from branch, checkout branch of existing worktree manually + if not wtr: + # get remote branch ref + rbranch = getattr(repo['repo'].remotes.origin.refs, repo['branch']) + if not rbranch: + self.loggerr(f'Ref {repo["branch"]} not found at origin {repo["url"]}') + return False + + # create local branch + self.logger.debug(f'creating local branch {repo["branch"]}') + try: + branchref = repo['wt'].create_head(repo['branch'], rbranch) + branchref.set_tracking_branch(rbranch) + branchref.checkout() + except Exception as e: + self.loggerr(f'setting up local branch {repo["branch"]} failed: {e}') + return False + + repo['clean'] = True + + if rename: + self.logger.debug(f'renaming old link priv_{name}') + if not self._move_old_link(name): + self.loggerr(f'unable to move old link priv_{name}, installation needs to be repaired manually') + + self.logger.debug(f'creating link {repo["link"]} to {repo["rel_link_path"]}...') + try: + os.symlink(repo['rel_link_path'], repo['link']) + except FileExistsError: + self.loggerr(f'plugin link {repo["link"]} was created by someone else while we were setting up repo. Not overwriting, check link file manually') + + self.repos[name] = repo + + return True + + def _move_old_link(self, name) -> bool: + """ rename old plugin link or folder and repo entry """ + link = os.path.join(self.plg_path, f'priv_{name}') + if not os.path.exists(link): + self.logger.debug(f'old link/folder not found: {link}') + return True + + self.logger.debug(f'found old link/folder {link}') + + # try plugin.yaml + plgyml = os.path.join(link, 'plugin.yaml') + if not os.path.exists(plgyml): + self.loggerr(f'plugin.yaml not found for {link}, aborting') + return False + + try: + yaml = yaml_load(plgyml, ignore_notfound=True) + ver = yaml['plugin']['version'] + self.logger.debug(f'found old version {ver}') + newlink = f'{link}_{ver}' + os.rename(link, newlink) + self.logger.debug(f'renamed {link} to {newlink}') + try: + # try to move repo entry to new name + # ignore if repo name is not existent + name_new = f'{name}_{ver}' + self.repos[name_new] = self.repos[name] + del self.repos[name] + # also change stored link + self.repos[name_new]['link'] += f'_{ver}' + except KeyError: + pass + return True + except Exception as e: + self.loggerr(f'error renaming old plugin: {e}') + return False + + def _rmtree(self, path): + """ remove path tree, also try to remove .DS_Store if present """ + try: + rmtree(path) + except Exception: + pass + + if os.path.exists(path): + # if directory wasn't deleted, just silently try again + try: + rmtree(path) + except Exception: + pass + + if os.path.exists(path): + # Try again, but finally give up if error persists + rmtree(path) + + def remove_plugin(self, name) -> bool: + """ remove plugin link, worktree and if not longer needed, local repo """ + if name not in self.repos: + self.loggerr(f'plugin entry {name} not found.') + return False + + (allow, remain, backoff) = self.gh.get_rate_limit() + if not remain: + self.loggerr(f'rate limit active, operation not possible. Wait {backoff} seconds...') + return False + + if not self.is_repo_clean(name): + self.loggerr(f'repo {name} is not synced or dirty, not removing.') + return False + + # get all data to remove + repo = self.repos[name] + link_path = repo['link'] + wt_path = repo['wt_path'] + repo_path = repo['repo_path'] + owner = repo['owner'] + branch = repo['branch'] + # check if repo is used by other plugins + last_repo = True + last_branch = True + for r in self.repos: + if r == name: + continue + if self.repos[r]["owner"] == owner: + last_repo = False + + if self.repos[r]["branch"] == branch: + last_branch = False + break + + break + + err = [] + try: + self.logger.debug(f'removing link {link_path}') + os.remove(link_path) + except Exception as e: + err.append(e) + + if last_branch: + try: + self.logger.debug(f'removing worktree {wt_path}') + self._rmtree(wt_path) + except Exception as e: + err.append(e) + try: + self.logger.debug('pruning worktree') + repo['repo'].git.worktree('prune') + except Exception as e: + err.append(e) + + if last_repo: + try: + self.logger.debug(f'repo {repo_path} is no longer used, removing') + self._rmtree(repo_path) + except Exception as e: + err.append(e) + else: + self.logger.info(f'keeping repo {repo_path} as it is still in use') + else: + self.logger.info(f'keeping worktree {wt_path} as it is still in use') + + # remove repo entry from plugin dict + del self.repos[name] + del repo + + if err: + msg = ", ".join([str(x) for x in err]) + self.loggerr(f'error(s) occurred while removing plugin: {msg}') + return False + + return True + + # + # github API methods + # + + def is_repo_clean(self, name: str, exc=False) -> bool: + """ checks if worktree is clean and local and remote branches are in sync """ + if name not in self.repos: + self.loggerr(f'repo {name} not found') + return False + + entry = self.repos[name] + local = entry['repo'] + + # abort if worktree isn't clean + if local.is_dirty() or local.untracked_files != []: + self.repos[name]['clean'] = False + return False + + # get remote and local branch heads + try: + remote = self.gh.get_repo(entry['owner'], entry['gh_repo']) + r_branch = remote.get_branch(branch=entry['branch']) + l_head = local.heads[entry['branch']].commit.hexsha + r_head = r_branch.commit.sha + except AttributeError: + if exc: + f = self.loggerr + else: + f = self.logger.warning + f(f'error while checking sync status for {name}. Rate limit active?') + return False + except Exception as e: + self.loggerr(f'error while checking sync status for {name}: {e}') + return False + + clean = l_head == r_head + self.repos[name]['clean'] = clean + return clean + + def pull_repo(self, name: str) -> bool: + """ pull repo if clean """ + if not name or name not in self.repos: + self.loggerr(f'repo {name} invalid or not found') + return False + + try: + res = self.is_repo_clean(name) + if not res: + raise GPError('worktree not clean') + except Exception as e: + self.loggerr(f'error checking repo {name}: {e}') + return False + + repo = self.repos[name]['repo'] + org = None + try: + org = repo.remotes.origin + except Exception: + if len(repo.remotes) > 0: + org = repo.remotes.get('origin') + + if org is None: + self.loggerr(f'remote "origin" not found in remotes {repo.remotes}') + return False + + try: + org.pull() + return True + except Exception as e: + self.loggerr(f'error while pulling: {e}') + + def setup_github(self) -> bool: + """ login to github and set repo """ + try: + self.gh.login() + except Exception as e: + self.loggerr(f'error while logging in to GitHub: {e}') + return False + + return self.gh.set_repo() + + def fetch_github_forks(self, fetch=False) -> bool: + """ fetch forks from github API """ + if self.gh: + return self.gh.get_forks(fetch=fetch) + else: + return False + + def fetch_github_pulls(self, fetch=False) -> bool: + """ fetch PRs from github API """ + if self.gh: + return self.gh.get_pulls(fetch=fetch) + else: + return False + + def fetch_github_branches_from(self, fork=None, owner='', fetch=False) -> dict: + """ + fetch branches for given fork from github API + + if fork is given as fork object, use this + if owner is given and present in self.forks, use their fork object + """ + return self.gh.get_branches_from(fork=fork, owner=owner, fetch=fetch) + + def fetch_github_plugins_from(self, fork=None, owner='', branch='', fetch=False) -> list: + """ fetch plugin names for selected fork/branch """ + return self.gh.get_plugins_from(fork=fork, owner=owner, branch=branch, fetch=fetch) + + def get_github_forks(self, owner=None) -> dict: + """ return forks or single fork for given owner """ + if owner: + return self.gh.forks.get(owner, {}) + else: + return self.gh.forks + + def get_github_forklist_sorted(self) -> list: + """ return list of forks, sorted alphabetically, used forks up front """ + + # case insensitive sorted forks + forks = sorted(self.get_github_forks().keys(), key=lambda x: x.casefold()) + sforkstop = [] + sforks = [] + + # existing owners in self.repos + owners = [v['owner'] for k, v in self.repos.items()] + + for f in forks: + if f in owners: + # put at top of list + sforkstop.append(f) + else: + # put in nowmal list below + sforks.append(f) + + return sforkstop + sforks + + def get_github_pulls(self, number=None) -> dict: + """ return pulls or single pull for given number """ + if number: + return self.gh.pulls.get(number, {}) + else: + return self.gh.pulls + + # + # methods to run git actions based on github data + # + + # unused right now, possibly remove? + def create_repo_from_gh(self, number=0, owner='', branch=None, plugin='') -> bool: + """ + call init/create methods to download new repo and create worktree + + if number is given, the corresponding PR is used for identifying the branch + if branch is given, it is used + + if plugin is given, it is used as plugin name. otherwise, we will try to + deduce it from the PR title or use the branch name + """ + r_owner = '' + r_branch = '' + r_plugin = plugin + + if number: + # get data from given PR + pr = self.get_github_pulls(number=number) + if pr: + r_owner = pr['owner'] + r_branch = pr['branch'] + # try to be smart about the plugin name + if not r_plugin: + if ':' in pr['title']: + r_plugin, _ = pr['title'].split(':', maxsplit=1) + elif ' ' in pr['name']: + r_plugin, _ = pr['title'].split(' ', maxsplit=1) + else: + r_plugin = pr['title'] + if r_plugin.lower().endswith(' plugin'): + r_plugin = r_plugin[:-7].strip() + + elif branch is not None and type(branch) is str and owner is not None: + # just take given data + r_owner = owner + r_branch = branch + if not r_plugin: + r_plugin = branch + + elif branch is not None: + # search for branch object in forks. + # Will not succeed if branches were not fetched for this fork earlier... + for user in self.gh.forks: + if 'branches' in self.gh.forks[user]: + for b in self.gh.forks[user]['branches']: + if self.gh.forks[user]['branches'][b]['branch'] is branch: + r_owner = user + r_branch = b + if not r_plugin: + r_plugin = b + + # do some sanity checks on given data + if not r_owner or not r_branch or not r_plugin: + self.loggerr(f'unable to identify repo from owner "{r_owner}", branch "{r_branch}" and plugin "{r_plugin}"') + return False + + if r_owner not in self.gh.forks: + self.loggerr(f'smarthomeNG/plugins fork by owner {r_owner} not found') + return False + + if 'branches' in self.gh.forks[r_owner] and r_branch not in self.gh.forks[r_owner]['branches']: + self.loggerr(f'branch {r_branch} not found in cached branches for owner {r_owner}. Maybe re-fetch branches?') + + # default id for plugin (actually not identifying the plugin but the branch...) + name = f'{r_owner}/{r_branch}' + + return self.create_repo(name, r_owner, r_plugin.lower(), r_branch) + + # + # general plugin methods + # + + def run(self): + self.logger.dbghigh(self.translate("Methode '{method}' aufgerufen", {'method': 'run()'})) + self.alive = True # if using asyncio, do not set self.alive here. Set it in the session coroutine + + try: + self.setup_github() + self.fetch_github_pulls() + self.fetch_github_forks() + self.read_repos_from_dir() + except GPError: + pass + + def stop(self): + """ + Stop method for the plugin + """ + self.logger.dbghigh(self.translate("Methode '{method}' aufgerufen", {'method': 'stop()'})) + self.alive = False # if using asyncio, do not set self.alive here. Set it in the session coroutine + + # + # helper methods + # + + def _get_last_3_path_parts(self, path): + """ return last 3 parts of a path """ + try: + head, l3 = os.path.split(path) + head, l2 = os.path.split(head) + _, l1 = os.path.split(head) + return (l1, l2, l3) + except Exception: + return ('', '', '') diff --git a/githubplugin/gperror.py b/githubplugin/gperror.py new file mode 100644 index 000000000..a1cd64379 --- /dev/null +++ b/githubplugin/gperror.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2024- Sebastian Helms Morg @ knx-user-forum +######################################################################### +# This file is part of SmartHomeNG. +# https://www.smarthomeNG.de +# https://knx-user-forum.de/forum/supportforen/smarthome-py +# +# Sample plugin for new plugins to run with SmartHomeNG version 1.10 +# and up. +# +# SmartHomeNG 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. +# +# SmartHomeNG 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 SmartHomeNG. If not, see <http://www.gnu.org/licenses/>. +# +######################################################################### + + +class GPError(Exception): + """ GithubPlugin-Exception used to signal errors to the webif """ + pass diff --git a/githubplugin/plugin.yaml b/githubplugin/plugin.yaml new file mode 100644 index 000000000..1b5cd573a --- /dev/null +++ b/githubplugin/plugin.yaml @@ -0,0 +1,47 @@ +# Metadata for the plugin +plugin: + # Global plugin attributes + type: system # plugin type (gateway, interface, protocol, system, web) + description: + de: 'Plugin zur Installation von Plugins aus fremden GitHub-Repositories' + en: 'Plugin to install plugins from foreign GitHub repositories' + maintainer: Morg42 +# tester: # Who tests this plugin? + state: develop # Initial 'develop'. change to 'ready' when done with development + keywords: git github plugin +# documentation: '' # An url to optional plugin doc - NOT the url to user_doc!!! +# support: https://knx-user-forum.de/forum/supportforen/smarthome-py + + version: 1.0.0 # Plugin version (must match the version specified in __init__.py) + + # these min/max-versions MUST be given in quotes, or e.g. 3.10 will be interpreted as 3.1 (3.1 < 3.9 < 3.10) + sh_minversion: '1.10' # minimum shNG version to use this plugin +# sh_maxversion: '1.11' # maximum shNG version to use this plugin (omit if latest) +# py_minversion: '3.10' # minimum Python version to use for this plugin +# py_maxversion: '4.25' # maximum Python version to use for this plugin (omit if latest) + + multi_instance: false # plugin supports multi instance + restartable: true # plugin supports stopping and starting again, must be implemented + #configuration_needed: False # False: The plugin will be enabled by the Admin GUI without configuration + classname: GithubPlugin # class containing the plugin + +parameters: + # Definition of parameters to be configured in etc/plugin.yaml (enter 'parameters: NONE', if section should be empty) + app_token: + type: str + default: '' + description: + de: 'App-Token zum Zugriff auf GitHub (optional)' + en: 'App token for accessing GitHub (optional)' + + +item_attributes: NONE + +item_structs: NONE + # Definition of item-structure templates for this plugin (enter 'item_structs: NONE', if section should be empty) + +plugin_functions: NONE + # Definition of plugin functions defined by this plugin (enter 'plugin_functions: NONE', if section should be empty) + +logic_parameters: NONE + # Definition of logic parameters defined by this plugin (enter 'logic_parameters: NONE', if section should be empty) diff --git a/githubplugin/requirements.txt b/githubplugin/requirements.txt new file mode 100644 index 000000000..b77965513 --- /dev/null +++ b/githubplugin/requirements.txt @@ -0,0 +1,2 @@ +GitPython +PyGithub \ No newline at end of file diff --git a/githubplugin/user_doc.rst b/githubplugin/user_doc.rst new file mode 100644 index 000000000..c1278fb32 --- /dev/null +++ b/githubplugin/user_doc.rst @@ -0,0 +1,55 @@ + +.. index:: Plugins; githubplugin +.. index:: githubplugin + + +============ +githubplugin +============ + +.. image:: webif/static/img/plugin_logo.png + :alt: plugin logo + :width: 300px + :height: 300px + :scale: 50 % + :align: left + + +Beschreibung +============ + +Wenn man das Plugin eines anderen Autors ausprobieren oder testen möchte, muss es aus einem fremden Repository von GitHub in die eigene Installation eingebunden werden. + +Dieses Plugin ermöglicht es komfortabel, fremde Plugins von GitHub zu installieren und wieder zu deinstallieren. + +Die Bedienung des Plugins und die Übersicht über installierte Plugins erfolgt über das Web-Interface, das über die Admin-UI von SmartHomeNG zugänglich ist. + +Dort können Plugins angezeigt, installiert und entfernt sowie durch einen pull aktualisiert werden. Das Aktualisieren oder Löschen von installierten Plugins ist nur möglich, wenn deren git-Verzeichnisse keine veränderten, gelöschten oder hinzugefügten Dateien enthalten und alle neuen mögliche Commits zu GitHub hochgeladen wurden (push). + +Das Plugin legt im Verzeichnis `plugins` das Unterverzeichnis `priv_repos` an, in dem die heruntergeladenen Daten abgelegt werden. Für jedes Plugin werden die folgenden Dateien bzw. Verzeichnisse benötigt: + +- ein Repository ("git clone") in `plugins/priv_repos/<Autor>/`; dieser Ordner kann durch mehrere Plugins genutzt werden, +- ein Ornder mit Worktree-Daten für den jeweiligen Branch in `plugins/priv_repos/<Autor>_wt_<Branch>/`, +- eine Verknüpfung/Symlink `plugins/priv_<Name>`, die auf den Plugin-Ordner im Worktree verlinkt. + +Dabei ist Name ein Bezeichner, der vom Nutzer selbst festgelegt werden kann. Dieser wird dann in der Form `priv_<Name>` auch in der `etc/plugin.yaml` als Bezeichner für das Plugin verwendet. + +Im Status-Bereich zeigt das Web-If an, wieviele Zugriffe in der aktuellen Konfiguration pro Stunde möglich und wieviele noch verfügbar sind. Wenn die Zugriffe verbraucht sind, wird die Zeit bis zum nächsten Rücksetzzeitpunkt angezeigt und alle 10 Sekunden aktualisiert. + +Anforderungen +============= + +Notwendige Software +------------------- + +Das Plugin benötigt die Python-Pakete GitPython und PyGithub. + +Konfiguration +============= + +Das Plugin benötigt keine Konfiguration. + +Ohne weiteres Zutun sind 60 Zugriffe pro Stunde möglich. Für normale Nutzer, die gelegentlich neue Plugins zum Testen oder Ausprobieren installieren, reicht das völlig aus. Wenn mehr Zugriffe benötigt werden, kann auf der Seite `GitHub App-Tokens <https://github.com/settings/tokens>` ein Token registriert und in der Plugin-Konfiguration hinterlegt werden. Damit sind bis zu 5000 Zugriffe pro Stunde möglich. + +Die Plugin-Parameter sind unter :doc:`/plugins_doc/config/githubplugin` beschrieben. + diff --git a/githubplugin/webif/__init__.py b/githubplugin/webif/__init__.py new file mode 100644 index 000000000..36e2ec14a --- /dev/null +++ b/githubplugin/webif/__init__.py @@ -0,0 +1,344 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2016- Oliver Hinckel github@ollisnet.de +# Based on ideas of sqlite plugin by Marcus Popp marcus@popp.mx +######################################################################### +# This file is part of SmartHomeNG. +# +# Sample Web Interface for new plugins to run with SmartHomeNG version 1.4 +# and upwards. +# +# SmartHomeNG 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. +# +# SmartHomeNG 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 SmartHomeNG. If not, see <http://www.gnu.org/licenses/>. +# +######################################################################### + +import cherrypy +import os +import json +from copy import deepcopy + +from lib.item import Items +from lib.model.smartplugin import SmartPluginWebIf +# from ..Exception import Exception + +# return for error signaling to webif -> display returned error message as alert +ERR_CODE = 500 + + +# ------------------------------------------ +# Webinterface of the plugin +# ------------------------------------------ + +class WebInterface(SmartPluginWebIf): + + def __init__(self, webif_dir, plugin): + """ + Initialization of instance of class WebInterface + + :param webif_dir: directory where the webinterface of the plugin resides + :param plugin: instance of the plugin + :type webif_dir: str + :type plugin: object + """ + self.logger = plugin.logger + self.webif_dir = webif_dir + self.plugin = plugin + self.items = Items.get_instance() + + self.tplenv = self.init_template_environment() + + def collect_repos(self): + # remove object items from repos dict + repos = deepcopy(self.plugin.repos) + for repo in repos: + if 'repo' in repos[repo]: + del repos[repo]['repo'] + + return repos + + @cherrypy.expose + def index(self): + """ + Build index.html for cherrypy + + Render the template and return the html file to be delivered to the browser + + :return: contents of the template after beeing rendered + """ + + # try to get the webif pagelength from the module.yaml configuration + pagelength = self.plugin.get_parameter_value('webif_pagelength') + + tmpl = self.tplenv.get_template('index.html') + + """ build current PR data structures for the webif """ + reposbyowner = {} + for repo in self.plugin.repos: + owner = self.plugin.repos[repo]['owner'] + if owner not in reposbyowner: + reposbyowner[owner] = [] + reposbyowner[owner].append(self.plugin.repos[repo]['branch']) + + # collect only PRs which (branches) are not already installed as a worktree + pulls = {} + for pr in self.plugin.gh.pulls: + skip = False + if self.plugin.gh.pulls[pr]['owner'] in reposbyowner: + if self.plugin.gh.pulls[pr]['branch'] in reposbyowner[self.plugin.gh.pulls[pr]['owner']]: + skip = True + + if not skip: + pulls[pr] = { + 'title': self.plugin.gh.pulls[pr]['title'], + 'owner': self.plugin.gh.pulls[pr]['owner'], + 'branch': self.plugin.gh.pulls[pr]['branch'] + } + + return tmpl.render(p=self.plugin, + webif_pagelength=pagelength, + repos=self.collect_repos(), + forklist=self.plugin.get_github_forklist_sorted(), + forks=self.plugin.gh.forks, + pulls=pulls, + auth=self.plugin.gh_apikey != '', + conn=self.plugin.gh._github is not None, + language=self.plugin.get_sh().get_defaultlanguage()) + + @cherrypy.expose + def get_data_html(self, dataSet=None): + """ + Return data to update the webpage + + For the standard update mechanism of the web interface, the dataSet to return the data for is None + + :param dataSet: Dataset for which the data should be returned (standard: None) + :return: dict with the data needed to update the web page. + """ + if dataSet == 'overview': + # get the new data + data = self.collect_repos() + + # return it as json the the web page + try: + return json.dumps(data) + except Exception as e: + self.logger.error(f"get_data_html exception: {e}, {data}") + + return {} + + @cherrypy.expose + @cherrypy.tools.json_out() + def rescanDirs(self): + try: + self.plugin.read_repos_from_dir(exc=True) + except Exception as e: + cherrypy.response.status = ERR_CODE + return {"error": str(e)} + + return {"operation": "request", "result": "success"} + + @cherrypy.expose + @cherrypy.tools.json_out() + def getRateLimit(self): + try: + rl = self.plugin.gh.get_rate_limit() + return {"operation": "request", "result": "success", "data": rl} + except Exception as e: + cherrypy.response.status = ERR_CODE + return {"error": str(e)} + + @cherrypy.expose + @cherrypy.tools.json_out() + def updateForks(self): + try: + if self.plugin.fetch_github_forks(fetch=True): + return {"operation": "request", "result": "success", "data": self.plugin.get_github_forklist_sorted()} + except Exception as e: + cherrypy.response.status = ERR_CODE + return {"error": str(e)} + + @cherrypy.expose + @cherrypy.tools.json_in() + @cherrypy.tools.json_out() + def isRepoClean(self): + try: + json = cherrypy.request.json + name = json.get('name') + + if not name or name not in self.plugin.repos: + raise Exception(f'Repo {name} ungültig oder nicht gefunden') + + allow, remain, backoff = self.plugin.gh.get_rate_limit() + if not remain: + raise Exception(f'Rate limit aktiv, Vorgang nicht möglich. Bitte {int(backoff)} Sekunden warten...') + clean = self.plugin.is_repo_clean(name) + return {"operation": "request", "result": "success", "data": clean} + except Exception as e: + cherrypy.response.status = ERR_CODE + return {"error": str(e)} + + @cherrypy.expose + @cherrypy.tools.json_in() + @cherrypy.tools.json_out() + def pullRepo(self): + try: + json = cherrypy.request.json + name = json.get('name') + + if not name or name not in self.plugin.repos: + raise Exception(f'Repo {name} ungültig oder nicht vorhanden') + + if self.plugin.pull_repo(name): + return {"operation": "request", "result": "success"} + else: + raise Exception(f'Pull von Repo {name} fehlgeschlagen') + except Exception as e: + cherrypy.response.status = ERR_CODE + return {"error": str(e)} + + @cherrypy.expose + @cherrypy.tools.json_in() + @cherrypy.tools.json_out() + def getNameSuggestion(self): + try: + json = cherrypy.request.json + plugin = json.get('plugin') + + count = '' + while os.path.exists(os.path.join(self.plugin.plg_path, f'priv_{plugin}{count}')) and int('0' + count) < 20: + count = str(int('0' + count) + 1) + return {"operation": "request", "result": "success", "name": plugin + count} + except Exception as e: + cherrypy.response.status = ERR_CODE + return {"error": str(e)} + + @cherrypy.expose + @cherrypy.tools.json_in() + @cherrypy.tools.json_out() + def getPull(self): + try: + json = cherrypy.request.json + try: + pr = int(json.get("pull", 0)) + except Exception: + raise Exception(f'Ungültiger Wert für "pr" in {json}') + if pr > 0: + pull = self.plugin.get_github_pulls(number=pr) + b = pull.get('branch') + o = pull.get('owner') + if b and o: + return {"operation": "request", "result": "success", "owner": o, "branch": b} + else: + raise Exception(f'Ungültige Daten beim Verarbeiten von PR {pr}') + except Exception as e: + cherrypy.response.status = ERR_CODE + return {"error": str(e)} + + @cherrypy.expose + @cherrypy.tools.json_out() + def updatePulls(self): + try: + if self.plugin.fetch_github_pulls(fetch=True): + prn = list(self.plugin.get_github_pulls().keys()) + prt = [v['title'] for pr, v in self.plugin.get_github_pulls().items()] + return {"operation": "request", "result": "success", "prn": prn, "prt": prt} + except Exception as e: + cherrypy.response.status = ERR_CODE + return {"error": str(e)} + + @cherrypy.expose + @cherrypy.tools.json_out() + @cherrypy.tools.json_in() + def updateBranches(self): + try: + json = cherrypy.request.json + owner = json.get("owner") + force = json.get("force", False) + if owner is not None and owner != '': + branches = self.plugin.fetch_github_branches_from(owner=owner, fetch=force) + if branches != {}: + return {"operation": "request", "result": "success", "data": list(branches.keys())} + except Exception as e: + cherrypy.response.status = ERR_CODE + return {"error": str(e)} + + @cherrypy.expose + @cherrypy.tools.json_out() + @cherrypy.tools.json_in() + def updatePlugins(self): + try: + json = cherrypy.request.json + force = json.get("force", False) + owner = json.get("owner") + branch = json.get("branch") + if owner is not None and owner != '' and branch is not None and branch != '': + plugins = self.plugin.fetch_github_plugins_from(owner=owner, branch=branch, fetch=force) + if plugins != {}: + return {"operation": "request", "result": "success", "data": plugins} + except Exception as e: + cherrypy.response.status = ERR_CODE + return {"error": str(e)} + + @cherrypy.expose + @cherrypy.tools.json_out() + @cherrypy.tools.json_in() + def removePlugin(self): + try: + json = cherrypy.request.json + name = json.get("name") + if name is None or name == '' or name not in self.plugin.repos: + raise Exception(f'Repo {name} nicht vorhanden.') + + if self.plugin.remove_plugin(name): + return {"operation": "request", "result": "success"} + except Exception as e: + cherrypy.response.status = ERR_CODE + return {"error": str(e)} + + @cherrypy.expose + @cherrypy.tools.json_out() + @cherrypy.tools.json_in() + def selectPlugin(self): + try: + json = cherrypy.request.json + owner = json.get("owner") + branch = json.get("branch") + plugin = json.get("plugin") + name = json.get("name") + confirm = json.get("confirm") + rename = json.get("rename") + + if (owner is None or owner == '' or + branch is None or branch == '' or + plugin is None or plugin == ''): + raise Exception(f'Fehlerhafte Daten für Repo {owner}/plugins, Branch {branch} oder Plugin {plugin} übergeben.') + + if confirm: + res = self.plugin.create_repo(name, owner, plugin, branch, rename=rename) + msg = f'Fehler beim Erstellen des Repos "{owner}/plugins", Branch {branch}, Plugin {plugin}' + else: + if not rename: + res = self.plugin.check_for_repo_name(name) + else: + res = True + msg = f'Repo {name} oder Plugin-Link "priv_{name}" schon vorhanden' + + if res: + return {"operation": "request", "result": "success"} + else: + raise Exception(msg) + except Exception as e: + cherrypy.response.status = ERR_CODE + return {"error": str(e)} diff --git a/githubplugin/webif/static/img/plugin_logo.png b/githubplugin/webif/static/img/plugin_logo.png new file mode 100644 index 000000000..b32bf2fc4 Binary files /dev/null and b/githubplugin/webif/static/img/plugin_logo.png differ diff --git a/githubplugin/webif/static/img/sort_asc.png b/githubplugin/webif/static/img/sort_asc.png new file mode 100755 index 000000000..e1ba61a80 Binary files /dev/null and b/githubplugin/webif/static/img/sort_asc.png differ diff --git a/githubplugin/webif/static/img/sort_asc_disabled.png b/githubplugin/webif/static/img/sort_asc_disabled.png new file mode 100755 index 000000000..fb11dfe24 Binary files /dev/null and b/githubplugin/webif/static/img/sort_asc_disabled.png differ diff --git a/githubplugin/webif/static/img/sort_both.png b/githubplugin/webif/static/img/sort_both.png new file mode 100755 index 000000000..af5bc7c5a Binary files /dev/null and b/githubplugin/webif/static/img/sort_both.png differ diff --git a/githubplugin/webif/static/img/sort_desc.png b/githubplugin/webif/static/img/sort_desc.png new file mode 100755 index 000000000..0e156deb5 Binary files /dev/null and b/githubplugin/webif/static/img/sort_desc.png differ diff --git a/githubplugin/webif/static/img/sort_desc_disabled.png b/githubplugin/webif/static/img/sort_desc_disabled.png new file mode 100755 index 000000000..c9fdd8a15 Binary files /dev/null and b/githubplugin/webif/static/img/sort_desc_disabled.png differ diff --git a/githubplugin/webif/static/style.css b/githubplugin/webif/static/style.css new file mode 100644 index 000000000..9ba0922dd --- /dev/null +++ b/githubplugin/webif/static/style.css @@ -0,0 +1,50 @@ +/* The Modal (background) */ +.or-modal { + display: none; /* Hidden by default */ + position: fixed; /* Stay in place */ + z-index: 999; /* Sit on top */ + left: 0; + top: 0; + width: 100%; /* Full width */ + height: 100%; /* Full height */ + overflow: auto; /* Enable scroll if needed */ + background-color: rgb(0,0,0); /* Fallback color */ + background-color: rgba(0,0,0,0.4); /* Black w/ opacity */ +} + +.in-modal { + display: none; /* Hidden by default */ + position: fixed; /* Stay in place */ + z-index: 900; /* Sit on top */ + left: 0; + top: 0; + width: 100%; /* Full width */ + height: 100%; /* Full height */ + overflow: auto; /* Enable scroll if needed */ + background-color: rgb(0,0,0); /* Fallback color */ + background-color: rgba(0,0,0,0.4); /* Black w/ opacity */ +} + +/* Modal Content/Box */ +.or-modal-content { + background-color: #fefefe; + margin: 15% auto; /* 15% from the top and centered */ + padding: 20px; + border: 1px solid #888; + width: 80%; /* Could be more or less, depending on screen size */ +} + +/* The Close Button */ +.or-close { + color: #aaa; + float: right; + font-size: 28px; + font-weight: bold; +} + +.or-close:hover, +.or-close:focus { + color: black; + text-decoration: none; + cursor: pointer; +} \ No newline at end of file diff --git a/githubplugin/webif/templates/index.html b/githubplugin/webif/templates/index.html new file mode 100644 index 000000000..0c0023368 --- /dev/null +++ b/githubplugin/webif/templates/index.html @@ -0,0 +1,678 @@ +{% extends "base_plugin.html" %} +{% set tabcount = 1 %} +{% set dataSet = 'overview' %} +{% set tab1title = _('Plugins in GitHub-Repos') %} +<!-- deactivated until proper table handling implemented +{% set xupdate_interval = 5000 %} +{% set xupdate_active = true %} +--> + +{% block pluginstyles %} +<link rel="stylesheet" href="static/style.css"> +{% endblock pluginstyles %} + +{% block buttons %} +<button id='installBtn' class="btn btn-shng btn-sm" onclick='javascript:document.getElementById("installModal").style.display = "block";' type="button">Neues Plugin installieren</button> +<button id='rescanBtn' class="btn btn-shng btn-sm" onclick='javascript:rescanDirs()' title='location.href="?action=rescan"' type="button">Plugin-Verzeichnis neu lesen</button><br /> +{% endblock buttons %} + +{% block headtable %} +<table class="table table-striped table-hover"> + <tbody> + <tr> + <td class="py-1"></td> + <td class="py-1"></td> + <td class="py-1"><strong>GitHub</strong></td> + <td class="py-1" align="right">verbunden:</td> + <td class="py-1">{% if conn %}Ja{% else %}Nein{% endif %}</td> + <td class="py-1" align="right">angemeldet:</td> + <td class="py-1">{% if auth %}Ja{% else %}Nein{% endif %}</td> + </tr> + <tr> + <td class="py-1"></td> + <td class="py-1"></td> + <td class="py-1"></td> + <td class="py-1" align="right">verbleibende Zugriffe:</td> + <td class="py-1"><span id="remain"></span>/<span id="allow"></span></td> + <td class="py-1" align="right">Reset in:</td> + <td class="py-1"><span id="backoff"></span></td> + </tr> + </tbody> +</table> + +<div id="alert" class="mb-2 alert alert-danger alert-dismissible show" role="alert"> + <strong>Fehler bei der Ausführung</strong><br/> + <span id="alertmsg"></span> + <button type="button" class="close" data-hide="alert" aria-label="Close"> + <span aria-hidden="true">×</span> + </button> +</div> + +<div id="success" class="mb-2 alert alert-info alert-dismissible show" role="alert"> + <strong>Ausführung erfolgreich</strong><br/> + <span id="successmsg"></span> + <button type="button" class="close" data-hide="alert" aria-label="Close"> + <span aria-hidden="true">×</span> + </button> +</div> + +<div id="install_ConfirmModal" class="or-modal"> + <div class="or-modal-content"> + <span class="or-close" onclick="document.getElementById('install_ConfirmModal').style.display = 'none';">×</span> + <div> + <strong>Installieren von Plugin <span id='install_plugin'></span> aus dem Repo <span id='install_owner'></span>/plugins, Branch <span id='install_branch'></span><span id='install_name' display='none' /></strong> + </div> + <div> + <span id="install_modalButtons" display="block"> + <span>Soll das Plugin wirklich installiert werden?</span><br /> + <button type="button" class="btn btn-danger btn-sm" onclick="installPlugin(this)">Installieren</button> + <button type="button" class="btn btn-shng btn-sm" onclick="document.getElementById('install_ConfirmModal').style.display = 'none';">Abbrechen</button> + </span> + <span id="install_modalSpinner" display="none">Installation wird ausgeführt. Bitte Geduld, dies kann etwas dauern... </span> + </div> + </div> +</div> + +<div id="remove_ConfirmModal" class="or-modal"> + <div class="or-modal-content"> + <span class="or-close" onclick="document.getElementById('remove_ConfirmModal').style.display = 'none';">×</span> + <div> + <strong>Entfernen von Plugin <span id='remove_plugin'></span> aus dem Repo <span id='remove_owner'></span>/plugins, Branch <span id='remove_branch'></span></strong> + <span id='remove_name' style='display: none;'></span> + </div> + <div> + <span id="remove_modalButtons" display="block"> + <span>Soll das Plugin wirklich gelöscht werden?</span><br /> + <button type="button" class="btn btn-danger btn-sm" onclick="doRemovePlugin(this)">Löschen</button> + <button type="button" class="btn btn-shng btn-sm" onclick="document.getElementById('remove_ConfirmModal').style.display = 'none';">Abbrechen</button> + </span> + <span id="remove_modalSpinner" display="none">Löschen wird ausgeführt. Bitte Geduld, dies kann etwas dauern... </i></span> + </div> + </div> +</div> + +<div id="installModal" class="in-modal"> + <div class="or-modal-content"> + <span class="or-close" onclick="document.getElementById('installModal').style.display = 'none';">×</span> + <div> + <strong>Neues Plugin installieren</strong> + </div> + <div> + <form id='install'> + <div class="container-fluid m-2"> + <div class="mb-2">Plugin installieren aus:</div> + <table width="100%"> + <tr> + <td>PR:</td> + <td colspan=3> + <select id='pr' {% if len(pulls) == 0 %}disabled {% endif %} onchange="javascript:PR_onchange(this);"> + <option value=''>(PR auswählen)</option> + {% for pr in pulls %} + <option value='{{ pr }}'>{{ pr }}: {{ pulls[pr].title }}</option> + {% endfor %} + </select> + <button id='btn-ref-pr' type="button" class="btn btn-shng btn-sm" onclick="javascript:refetch('pr');">↻</i></button> + </td> + <td> </td> + </tr> + <tr> + <td>Repo von:</td> + <td> + <select id='owner' onchange="javascript:updateBranches(this);"> + <option value=''>(Repo auswählen)</option> + {% for owner in forklist %} + <option>{{ owner }}</option> + {% endfor %} + </select> + <button id='btn-ref-fork' type="button" class="btn btn-shng btn-sm" onclick="javascript:refetch('owner');">↻</i></button> + </td> + <td style='padding-left: 15px;'>Branch:</td> + <td> + <select id='branch' disabled onchange="javascript:Branch_onchange(this);"> + <option value=''>(Branch auswählen)</option> + </select> + <button id='btn-ref-branch' type="button" class="btn btn-shng btn-sm" onclick="javascript:refetch('branch');">↻</i></button> + </td> + <td style='padding-left: 15px;'> + <button id='btn-branch' disabled type="button" class="btn btn-shng btn-sm" onclick="javascript:updatePlugins(this);">Auswählen</button> + </td> + </tr> + <tr> + <td>Plugin:</td> + <td> + <select id='plugin' disabled onchange="javascript:Plugin_onchange(this);"> + <option value=''>(Plugin auswählen)</option> + </select> + <button id='btn-ref-plugin' type="button" class="btn btn-shng btn-sm" onclick="javascript:refetch('plugin');">↻</i></button> + </td> + <td style='padding-left: 15px;'>Pluginname: priv_</td> + <td><input id='name' disabled /></td> + <td style='padding-left: 15px;'> + <button id='btn-plugin' disabled type="button" class="btn btn-shng btn-sm" onclick="javascript:selectPlugin(this);">Auswählen</button> + </td> + </tr> + <tr> + <td colspan=2>alte Version umbenennen:</td> + <td><input id="rename" type="checkbox" /></td> + </tr> + </table> + </div> + </form> + </div> + </div> +</div> + +{% endblock headtable %} + +{% block pluginscripts %} +{{ super() }} +<script type="text/javascript"> + + var rateInterval = null; + + function handleUpdatedData(response, dataSet='overview') { + if (dataSet === 'overview' || dataSet === null) { + objResponse = JSON.parse(response); + myProto = document.getElementById(dataSet); + for (var plugin in objResponse) { + if (!document.getElementById(plugin+'_name')) { + if ( $.fn.dataTable.isDataTable('#plugintable') ) { + table_to_update = $('#plugintable').DataTable(); + let newRow = table_to_update.row.add( [ plugin, '' ] ).draw(false).node(); + newRow.id = plugin+"_row"; + $('td:eq(1)', newRow).attr('id', plugin+'_name'); + $('td:eq(2)', newRow).attr('id', plugin+'_plugin'); + $('td:eq(3)', newRow).attr('id', plugin+'_owner'); + $('td:eq(4)', newRow).attr('id', plugin+'_branch'); + $('td:eq(5)', newRow).attr('id', plugin+'_wtpath'); + $('td:eq(6)', newRow).attr('id', plugin+'_action'); + } + } + shngInsertText(plugin+'_name', plugin, 'plugintable', 2); + shngInsertText(plugin+'_plugin', objResponse[plugin]['plugin'], 'plugintable', 2); + shngInsertText(plugin+'_owner', objResponse[plugin]['owner'], 'plugintable', 2); + shngInsertText(plugin+'_branch', objResponse[plugin]['branch'], 'plugintable', 2); + shngInsertText(plugin+'_wtpath', objResponse[plugin]['disp_wt_path'], 'plugintable', 2); + if (objResponse[plugin]['clean']) { + txt = + '<button type="button" class="btn btn-danger btn-sm" onclick="javascript:removePlugin(' + + objResponse[plugin]['owner'] + ', ' + objResponse[plugin]['branch'] + ', ' + objResponse[plugin]['plugin'] + ', ' + plugin + ');"><i class="fas fa-times"></i></button>' + + '<button type="button" class="btn btn-shng btn-sm" onclick="javascript:pullRepo(' + plugin + ');"><i class="fas fa-download"></i></button>'; + } else { + txt = 'Änderungen vorhanden'; + } + shngInsertText(plugin+'_action', txt, 'plugintable', 2); + } + } + } + + $(function(){ + $("[data-hide]").on("click", function(){ + $("." + $(this).attr("data-hide")).hide(); + }); + }); + + function alertMsg(msg) { + // show exception from python code + document.getElementById('alertmsg').textContent = msg; + $('#alert').show(); + } + + function successMsg(msg) { + // show message + document.getElementById('successmsg').textContent = msg; + $('#success').show(); + } + + function clearSelect(sel) { + // empty HTML select value list except for first "empty" entry + var i, L = sel.options.length - 1; + for (i = L; i > 0; i--) { + sel.remove(i); + } + } + + function addOption(sel, text, def, val) { + // add option to HTML select field, possibly set selected option + var option = document.createElement('option'); + option.text = text; + if (val != undefined) { + option.value = val; + } else { + option.value = text; + } + if (def) { + option.selected = true; + } + sel.add(option); + } + + function showModal(mode, owner, branch, plugin, name) { + // show given confirmation modal for install or remove + document.getElementById(mode + '_plugin').textContent = plugin; + document.getElementById(mode + '_owner').textContent = owner; + document.getElementById(mode + '_branch').textContent = branch; + document.getElementById(mode + '_name').textContent = name; + document.getElementById(mode + '_modalButtons').style.display = 'block'; + document.getElementById(mode + '_modalSpinner').style.display = 'none'; + document.getElementById(mode + '_ConfirmModal').style.display = 'block'; + } + + function hideModal(mode) { + // hide given confirmation modal and clear text areas + document.getElementById(mode + '_ConfirmModal').style.display = 'none'; + document.getElementById(mode + '_plugin').textContent = ''; + document.getElementById(mode + '_owner').textContent = ''; + document.getElementById(mode + '_branch').textContent = ''; + document.getElementById(mode + '_name').textContent = ''; + document.getElementById(mode + '_modalButtons').style.display = 'block'; + document.getElementById(mode + '_modalSpinner').style.display = 'none'; + } + + function spinModal(mode) { + // switch given confirmation modal to "please wait" mode + document.getElementById(mode + '_modalButtons').style.display = 'none'; + document.getElementById(mode + '_modalSpinner').style.display = 'block'; + } + + function sendData(url, data, errf, success) { + // send ajax data and execute proper given function + // error function always executes given function first, then alerts + $.ajax({ + type: "POST", + url: url, + data: JSON.stringify(data), + contentType: 'application/json', + dataType: 'json', + error: function(response) { + errf(response); + getRateLimit(); + alertMsg(response['responseJSON']['error']); + }, + success: success + }) + } + + function PR_onchange(selObj) { + // called if PR selection field is changed + var PR = document.getElementById('pr').value; + if (PR > 0) { + sendData('getPull', {'pull': PR}, + function(response) {}, + function(response) { + var branch = document.getElementById('branch'); + var powner = response['owner']; + var pbranch = response['branch']; + document.getElementById('owner').value = powner; + clearSelect(branch); + addOption(branch, pbranch); + branch.value = pbranch; + branch.disabled = false; + document.getElementById('btn-branch').disabled = false; + document.getElementById('btn-plugin').disabled = true; + clearPlugin(); + }) + } + } + + function clearForks() { + // empty forks (owner) list and following selections + var f = document.getElementById('owner'); + f.value = ''; + + clearBranches(); + } + + function clearBranches() { + // clear branches list and following selections + var b = document.getElementById('branch'); + clearSelect(b); + b.disabled = true; + + clearPlugin(); + } + + function clearPlugin() { + // clear plugins list, clear name input + var p = document.getElementById('plugin'); + clearSelect(p); + p.disabled = true; + + var n = document.getElementById('name'); + n.value = ''; + n.disabled = true; + } + + function rescanDirs() { + // trigger rescan of plugin dir + sendData('rescanDirs', {}, + function(response) {}, + function(response) { + location.reload(); + }); + } + + function updatePulls(selObj, force) { + // reload PR list, cleanup dialog + clearForks(); + + sendData('updatePulls', {}, + function(response) {}, + function(response) { + var item = document.getElementById('pr'); + + // clear options + clearSelect(pr); + + // add all pulls + prn = response['prn']; + prt = response['prt']; + for (var i = 0; i < prn.length; i++) { + addOption(item, prn[i] + ": " + prt[i], false, prn[i]); + } + } + ) + } + + function updateBranches(selObj, force) { + // reload branches list, cleanup dialog + var owner = document.getElementById('owner').value; + clearPlugin(); + + if (owner != '') { + data = {'owner': owner} + if (force == true) { + data['force'] = true; + } + sendData('updateBranches', data, + function(response) {}, + function(response) { + var item = document.getElementById('branch'); + + // enable branch options + item.disabled = false; + + // clear options + clearSelect(item); + + // add all branches except master and main + for (const branch of response['data']) { + if (branch == 'master' || branch == 'main') { + continue; + } + addOption(item, branch, false); + } + } + ) + } + } + + function Branch_onchange(selObj) { + // called if branch selection field is changed + if (document.getElementById('branch').value != '') { + document.getElementById('btn-branch').disabled = false; + } else { + document.getElementById('btn-branch').disabled = true; + document.getElementById('btn-plugin').disabled = true; + } + } + + function updatePlugins(selObj, force) { + // reload plugins list, cleanup dialog + var owner = document.getElementById('owner').value; + var branch = document.getElementById('branch').value; + + document.getElementById('pr').value = ''; + if (owner != '' && branch != '') { + data = {'owner': owner, 'branch': branch}; + if (force == true) { + data['force'] = true; + } + sendData("updatePlugins", data, + function(response) {}, + function(response) { + var item = document.getElementById('plugin'); + + // enable branch options + item.disabled = false; + + // clear options + clearSelect(item); + + // add all branches except master and main + for (const plugin of response['data']) { + addOption(item, plugin, plugin==branch); + } + Plugin_onchange(this); + } + ); + } + } + + function Plugin_onchange(selObj) { + // called if plugin selection field is changed, get suggested priv_ name + var owner = document.getElementById('owner').value; + var branch = document.getElementById('branch').value; + var plugin = document.getElementById('plugin').value; + + if (plugin != '') { + document.getElementById('name').value = plugin; + sendData("getNameSuggestion", {"plugin": plugin}, + function(response) {}, + function(response) { + if (response["name"] != undefined && response["name"] != "") { + document.getElementById('name').value = response["name"];; + } + } + ); + document.getElementById('name').disabled = false; + document.getElementById('btn-plugin').disabled = false; + } + } + + function selectPlugin(selObj) { + // plugin selected in install dialog, prepare installation, show confirmation + var owner = document.getElementById('owner').value; + var branch = document.getElementById('branch').value; + var plugin = document.getElementById('plugin').value; + var name = document.getElementById('name').value; + var rename = document.getElementById('rename').checked; + + if (owner != '' && branch != '' && plugin != '') { + sendData("selectPlugin", {'owner': owner, 'branch': branch, 'plugin': plugin, 'name': name, 'confirm': false, 'rename': rename}, + function(response) { + document.getElementById('installModal').style.display = 'none'; + }, + function(response) { + document.getElementById('installModal').style.display = 'none'; + showModal('install', owner, branch, plugin); + } + ) + } + } + + function installPlugin(selObj) { + // plugin installation confirmed, call function to execute installation + var owner = document.getElementById('owner').value; + var branch = document.getElementById('branch').value; + var plugin = document.getElementById('plugin').value; + var name = document.getElementById('name').value; + var rename = document.getElementById('rename').checked; + + // execute installation + showModal('install', owner, branch, plugin); + if (owner != '' && branch != '' && plugin != '') { + spinModal('install'); + sendData("selectPlugin", {'owner': owner, 'branch': branch, 'plugin': plugin, 'name': name, 'confirm': true, 'rename': rename}, + function(response) { + hideModal('install'); + }, + function(response) { + hideModal('install'); + setTimeout(window.location.reload(), 300); + } + ) + } + } + + function pullRepo(name) { + // try to pull from origin for given repo + if (name != '') { + sendData("pullRepo", {'name': name}, + function(response) {}, + function(response) { + successMsg('Plugin ' + name + ' erfolgreich aktualisiert'); + } + ); + } + } + function removePlugin(owner, branch, plugin, name) { + // check if plugin can be removed, show confirmation + if (name != '') { + sendData("isRepoClean", {'name': name}, + function(response) { + hideModal('remove'); + }, + function(response){ + var clean = response['data']; + if (clean) { + showModal('remove', owner, branch, plugin, name); + } else { + alertMsg('Plugin ' + name + ' kann nicht entfernt werden, Repo ist nicht sauber (lose Dateien im Arbeitsverzeichnis, Änderungen am Index, commits nicht gepushed).') + } + } + ); + } + } + + function doRemovePlugin(selObj) { + // execute plugin removal + var name = document.getElementById('remove_name').textContent; + if (name != '') { + spinModal('remove'); + sendData("removePlugin", {'name': name}, + function(response) { + hideModal('remove'); + }, + function(response) { + hideModal('remove'); + setTimeout(window.location.reload(), 300); + } + ); + } + } + + function refetch(what) { + // reload given HTML select field data + if (what == 'owner') { + + } + if (what == 'pr') { + updatePulls(null, true); + } + if (what == 'branch') { + updateBranches(null, true); + } + if (what == 'plugin') { + updatePlugins(null, true); + } + + } + + function getRateLimit() { + // update rate limit data + sendData('getRateLimit', '', + function(response) {}, + function(response) { + data = response['data']; + var allow = data[0]; + var remain = data[1]; + var backoff = data[2]; + + document.getElementById('allow').textContent = allow; + document.getElementById('remain').textContent = remain; + var bo = document.getElementById('backoff') + if (backoff > 0 && remain == 0) { + // backoff active, no remaining actions + var secs = backoff % 60; + var mins = ~~(backoff / 60); + var val = "" + mins + ":" + (secs < 10 ? "0" : "") + parseInt(secs) + "m"; + + bo.style.color = 'red'; + bo.textContent = val; + + if (rateInterval === null) { + rateInterval = setInterval(getRateLimit, 10000); + } + + document.getElementById('installBtn').disabled = true; + } else { + bo.style.color = 'black'; + bo.textContent = '---'; + + if (rateInterval != null) { + clearInterval(rateInterval); + rateInterval = null; + } + + document.getElementById('installBtn').disabled = false; + } + } + ); + } + + $(document).ready( function () { + + // hide alert popups + $('#alert').hide(); + $('#success').hide(); + + // enable datatable table + $(window).trigger('datatables_defaults'); + $('#plugintable').DataTable( { + "paging": false, + fixedHeader: true + } ); + + getRateLimit(); + }); +</script> + +{% endblock pluginscripts %} + + +{% block bodytab1 %} + +<div class="container-fluid m-2 table-responsive"> + <div>Die folgenden Plugins sind von extern installiert:</div> + <table id="plugintable"> + <thead> + <tr><th></th> + <th>Name</th> + <th>Plugin</th> + <th>Autor</th> + <th>Branch</th> + <th>Pfad (Worktree)</th> + <th>Aktion</th> + </tr> + </thead> + <tbody> + {% for plugin in repos %} + <tr> + <td></td> + <td id="{{ plugin }}_name">{{ plugin }}</td> + <td id="{{ plugin }}_plugin">{{ repos[plugin].plugin }}</td> + <td id="{{ plugin }}_owner">{{ repos[plugin].owner }}</td> + <td id="{{ plugin }}_branch">{{ repos[plugin].branch }}</td> + <td id="{{ plugin }}_wtpath">{{ repos[plugin].disp_wt_path }}</td> + <td id="{{ plugin }}_action">{% if not repos[plugin].clean %}Änderungen vorhanden{% else %} + <button type="button" class="btn btn-danger btn-sm" onclick="javascript:removePlugin('{{ repos[plugin].owner }}', '{{ repos[plugin].branch }}', '{{ repos[plugin].plugin }}', '{{ plugin }}');"><i class="fas fa-times"></i></button> + <button type="button" class="btn btn-shng btn-sm" onclick="javascript:pullRepo('{{ plugin }}');"><i class="fas fa-download"></i></button> + {% endif %}</td> + </tr> + {% endfor %} + </tbody> + </table> +</div> + +{% endblock bodytab1 %} diff --git a/sonos/__init__.py b/sonos/__init__.py old mode 100755 new mode 100644 index 918abc9ff..d6182febb --- a/sonos/__init__.py +++ b/sonos/__init__.py @@ -184,7 +184,7 @@ def renew_error_callback(exception): # events_twisted: failure # Redundant, as the exception will be logged by the events module self.logger.error(msg) - # ToDo possible improvement: Do not do periodic renew but do propper disposal on renew failure here instead. sub.renew(requested_timeout=10) + # ToDo possible improvement: Do not do periodic renew but do proper disposal on renew failure here instead. sub.renew(requested_timeout=10) class SubscriptionHandler(object): @@ -201,7 +201,7 @@ def __init__(self, endpoint, service, logger, threadName): def subscribe(self): self.logger.dbglow(f"start subscribe for endpoint {self._endpoint}") if 'eventAvTransport' in self._threadName: - self.logger.dbghigh(f"subscribe(): endpoint av envent detected. Enabling debugging logs") + self.logger.dbghigh(f"subscribe(): endpoint av event detected. Enabling debugging logs") debug = 1 else: debug = 0 @@ -254,7 +254,7 @@ def subscribe(self): def unsubscribe(self): self.logger.dbglow(f"unsubscribe(): start for endpoint {self._endpoint}") if 'eventAvTransport' in self._threadName: - self.logger.dbghigh(f"unsubscribe: endpoint av envent detected. Enabling debugging logs") + self.logger.dbghigh(f"unsubscribe: endpoint av event detected. Enabling debugging logs") debug = 1 else: debug = 0 @@ -283,11 +283,11 @@ def unsubscribe(self): self.logger.dbghigh(f"unsubscribe(): Thread joined for endpoint {self._endpoint}") if not self._thread.is_alive(): - self.logger.dbglow("Thread killed for enpoint {self._endpoint}") + self.logger.dbglow("Thread killed for endpoint {self._endpoint}") if debug: self.logger.dbghigh(f"Thread killed for endpoint {self._endpoint}") else: - self.logger.warning("unsubscibe(): Error, thread is still alive after termination (join timed-out)") + self.logger.warning("unsubscribe(): Error, thread is still alive after termination (join timed-out)") self._thread = None self.logger.info(f"Event {self._endpoint} thread terminated") @@ -297,7 +297,6 @@ def unsubscribe(self): if debug: self.logger.dbghigh(f"unsubscribe(): {self._endpoint}: lock released") - @property def eventSignalIsSet(self): if self._signal: @@ -514,7 +513,6 @@ def subscribe_base_events(self): # Important note: # av event is not subscribed here because it has special handling in function zone group event. pass - def refresh_static_properties(self) -> None: """ @@ -707,12 +705,12 @@ def _av_transport_event(self, sub_handler: SubscriptionHandler) -> None: self.logger.dbghigh(f"_av_transport_event: {self.uid}: av transport event handler active.") while not sub_handler.signal.wait(1): -# self.logger.dbglow(f"_av_transport_event: {self.uid}: start try") + # self.logger.dbglow(f"_av_transport_event: {self.uid}: start try") try: event = sub_handler.event.events.get(timeout=0.5) except Empty: - #self.logger.dbglow(f"av_transport_event: got empty exception, which is normal") + # self.logger.dbglow(f"av_transport_event: got empty exception, which is normal") pass except Exception as e: self.logger.error(f"_av_tranport_event: Exception during events.get(): {e}") @@ -1108,7 +1106,7 @@ def loudness(self) -> bool: @loudness.setter def loudness(self, loudness: bool) -> None: """ - Setter for loudnes (internal) + Setter for loudness (internal) :param loudness: True or False :rtype: None :return: None @@ -1259,7 +1257,7 @@ def volume(self, value: int) -> None: def _check_max_volume_exceeded(self, volume: int, max_volume: int) -> bool: """ Checks if the volume exceeds a maximum volume value. - :param volume: volme + :param volume: volume :param max_volume: maximum volume :return: 'True' if volume exceeds maximum volume, 'False# otherwise. """ @@ -1442,13 +1440,13 @@ def zone_group_members(self, value: list) -> None: pass else: # Register AV event for coordinator speakers: - #self.logger.dbglow(f"Un/Subscribe av event for uid '{self.uid}' in fct zone_group_members") + # self.logger.dbglow(f"Un/Subscribe av event for uid '{self.uid}' in fct zone_group_members") active = member.av_subscription.subscriptionThreadIsActive is_subscribed = member.av_subscription.is_subscribed self.logger.dbghigh(f"zone_group_members(): Subscribe av event for uid '{self.uid}': Status before measure: AV Thread is {active}, subscription is {is_subscribed}, Eventflag: {member.av_subscription.eventSignalIsSet}") - if active == False: + if active is False: self.logger.dbghigh(f"zone_group_members: Subscribe av event for uid '{self.uid}' because thread is not active") #member.av_subscription.unsubscribe() # @@ -1456,7 +1454,6 @@ def zone_group_members(self, value: list) -> None: # member.av_subscription.update_endpoint(endpoint=self._av_transport_event) member.av_subscription.subscribe() self.logger.dbghigh(f"zone_group_members: Subscribe av event for uid '{self.uid}': Status after measure: AV thread is {member.av_subscription.subscriptionThreadIsActive}, subscription {member.av_subscription.is_subscribed}, Eventflag: {member.av_subscription.eventSignalIsSet}") - @property def streamtype(self) -> str: @@ -1906,7 +1903,7 @@ def is_coordinator(self) -> bool: def is_coordinator(self, value: bool) -> None: """ is_coordinator setter - :param value: 'True' to indicate that the speker is the coordiantor of the group, otherwise 'False' + :param value: 'True' to indicate that the speaker is the coordinator of the group, otherwise 'False' """ self._is_coordinator = value for item in self.is_coordinator_items: @@ -2511,7 +2508,6 @@ def _play_radio(self, station_name: str, music_service: str = 'TuneIn', start: b self.soco.play_uri(uri=uri, meta=metadata, title=the_station.title, start=start, force_radio=True) return True, "" - def play_sharelink(self, url: str, start: bool = True) -> None: """ Plays a sharelink from a given url @@ -2998,7 +2994,7 @@ class Sonos(SmartPlugin): """ Main class of the Plugin. Does all plugin specific stuff """ - PLUGIN_VERSION = "1.8.7" + PLUGIN_VERSION = "1.8.8" def __init__(self, sh): """Initializes the plugin.""" @@ -3030,7 +3026,6 @@ def __init__(self, sh): self._uid_lookup_levels = 4 # iterations of return_parent() on lookup for item uid self._speaker_ips = [] # list of fixed speaker ips self.zones = {} # dict to hold zone information via soco objects - self.item_list = [] # list of all items, used by / linked to that plugin self.alive = False # plugin alive property self.webservice = None # webservice thread @@ -3089,8 +3084,10 @@ def parse_item(self, item: Items) -> object: :param item: item to parse :return: update function or None """ - uid = None + + item_config = dict() + # handling sonos_recv and sonos_send if self.has_iattr(item.conf, 'sonos_recv') or self.has_iattr(item.conf, 'sonos_send'): self.logger.debug(f"parse item: {item.property.path}") # get uid from parent item @@ -3098,34 +3095,45 @@ def parse_item(self, item: Items) -> object: if not uid: self.logger.error(f"No uid found for {item.property.path}.") return + + item_config.update({'uid': uid}) - if self.has_iattr(item.conf, 'sonos_recv'): - # create Speaker instance if not exists - _initialize_speaker(uid, self.logger, self.get_shortname()) + if self.has_iattr(item.conf, 'sonos_recv'): + # create Speaker instance if not exists + _initialize_speaker(uid, self.logger, self.get_shortname()) - # to make code smaller, map sonos_cmd value to the Speaker property by name - item_attribute = self.get_iattr_value(item.conf, 'sonos_recv') - list_name = f"{item_attribute}_items" - try: - attr = getattr(sonos_speaker[uid], list_name) - self.logger.debug(f"Adding item {item.property.path} to {uid}: list {list_name}") - attr.append(item) - if item not in self.item_list: - self.item_list.append(item) - except Exception: - self.logger.warning(f"No item list available for sonos_cmd '{item_attribute}'.") - - if self.has_iattr(item.conf, 'sonos_send'): - self.logger.debug(f"Item {item.property.path} registered to 'sonos_send' commands.") - if item not in self.item_list: - self.item_list.append(item) - return self.update_item - - # some special handling for dpt3 volume - if self.has_iattr(item.conf, 'sonos_attrib'): - if self.get_iattr_value(item.conf, 'sonos_attrib') != 'vol_dpt3': - if item not in self.item_list: - self.item_list.append(item) + # to make code smaller, map sonos_cmd value to the Speaker property by name + item_attribute = self.get_iattr_value(item.conf, 'sonos_recv') + list_name = f"{item_attribute}_items" + try: + attr = getattr(sonos_speaker[uid], list_name) + self.logger.debug(f"Adding item {item.property.path} to {uid}: list {list_name}") + attr.append(item) + item_config.update({'sonos_recv': item_attribute}) + self.logger.debug(f"Item {item.property.path} registered to 'sonos_send' commands with '{item_attribute}'.") + except Exception: + self.logger.warning(f"No item list available for sonos_cmd '{item_attribute}'.") + + if self.has_iattr(item.conf, 'sonos_send'): + item_attribute = self.get_iattr_value(item.conf, 'sonos_send') + item_config.update({'sonos_send': item_attribute}) + self.logger.debug(f"Item {item.property.path} registered to 'sonos_send' commands with '{item_attribute}'.") + + if 'sonos_recv' in item_config or 'sonos_send' in item_config: + self.add_item(item, config_data_dict=item_config, updating=True) + + if 'sonos_send' in item_config: + return self.update_item + + # handling sonos_attrib incl some special handling for dpt3 volume + elif self.has_iattr(item.conf, 'sonos_attrib'): + uid = self._resolve_uid(item) + item_config.update({'uid': uid}) + item_attribute = self.get_iattr_value(item.conf, 'sonos_attrib') + + if item_attribute != 'vol_dpt3': + item_config.update({'sonos_attrib': item_attribute}) + self.add_item(item, config_data_dict=item_config, updating=True) return # check, if a volume parent item exists @@ -3139,8 +3147,6 @@ def parse_item(self, item: Items) -> object: self.logger.warning("volume_dpt3 item has no volume parent item. Ignoring!") return - item.conf['volume_parent'] = parent_item - # make sure there is a child helper item child_helper = None for child in item.return_children(): @@ -3153,21 +3159,14 @@ def parse_item(self, item: Items) -> object: self.logger.warning("volume_dpt3 item has no helper item. Ignoring!") return - item.conf['helper'] = child_helper - - if not self.has_iattr(item.conf, 'sonos_dpt3_step'): - item.conf['sonos_dpt3_step'] = self._sonos_dpt3_step - self.logger.debug(f"No sonos_dpt3_step defined, using default value {self._sonos_dpt3_step}.") - - if not self.has_iattr(item.conf, 'sonos_dpt3_time'): - item.conf['sonos_dpt3_time'] = self._sonos_dpt3_time - self.logger.debug(f"No sonos_dpt3_time defined, using default value {self._sonos_dpt3_time}.") + dpt3_step = self.get_iattr_value(item.conf, 'sonos_dpt3_step') + dpt3_time = self.get_iattr_value(item.conf, 'sonos_dpt3_time') - if item not in self.item_list: - self.item_list.append(item) + item_config.update({'volume_item': parent_item, 'helper': child_helper, 'dpt3_step': dpt3_step, 'dpt3_time': dpt3_time}) + self.add_item(item, config_data_dict=item_config, updating=True) return self._handle_dpt3 - def play_alert_all_speakers(self, alert_uri, speaker_list = [], alert_volume=20, alert_duration=0, fade_back=False): + def play_alert_all_speakers(self, alert_uri, speaker_list=[], alert_volume=20, alert_duration=0, fade_back=False): """ Demo function using soco.snapshot across multiple Sonos players. @@ -3226,11 +3225,14 @@ def play_alert_all_speakers(self, alert_uri, speaker_list = [], alert_volume=20, self.logger.warning(f"Debug: restoring {zone.player_name}") zone.snap.restore(fade=fade_back) - def _handle_dpt3(self, item, caller=None, source=None, dest=None): if caller != self.get_shortname(): - volume_item = self.get_iattr_value(item.conf, 'volume_parent') - volume_helper = self.get_iattr_value(item.conf, 'helper') + + item_config = self.get_item_config(item) + volume_item = item_config['volume_item'] + volume_helper = item_config['helper'] + vol_step = item_config['dpt3_step'] + vol_time = item_config['dpt3_time'] vol_max = self._resolve_max_volume_command(item) if vol_max < 0: @@ -3243,8 +3245,6 @@ def _handle_dpt3(self, item, caller=None, source=None, dest=None): current_volume = 100 volume_helper(current_volume) - vol_step = int(item.conf['sonos_dpt3_step']) - vol_time = int(item.conf['sonos_dpt3_time']) if item()[1] == 1: if item()[0] == 1: @@ -3293,7 +3293,7 @@ def _check_local_webservice_path(self, local_webservice_path: str) -> bool: self.logger.warning(f"Mandatory path for local webserver for TTS not given in Plugin parameters. TTS disabled!") return False - # if path is given, check avilability, create and check access rights + # if path is given, check availability, create and check access rights try: os.makedirs(local_webservice_path, exist_ok=True) except OSError: @@ -3322,7 +3322,7 @@ def _check_local_webservice_path_snippet(self, local_webservice_path_snippet: st self._local_webservice_path_snippet = self._local_webservice_path return True - # if path is given, check avilability, create and check access rights + # if path is given, check availability, create and check access rights try: os.makedirs(local_webservice_path_snippet, exist_ok=True) except OSError: @@ -3407,16 +3407,14 @@ def _parse_speaker_ips(self, speaker_ips: list) -> list: # return unique items in list return utils.unique_list(self._speaker_ips) - def debug_speaker(self, uid): self.logger.warning(f"debug_speaker: Starting function for uid {uid}") - #sonos_speaker[uid].set_stop() + # sonos_speaker[uid].set_stop() self.logger.warning(f"debug_speaker: check sonos_speaker[uid].av.subscription: {sonos_speaker[uid].av_subscription}") # Event objekt is not callable: - #sonos_speaker[uid]._av_transport_event(sonos_speaker[uid].av_subscription) + # sonos_speaker[uid]._av_transport_event(sonos_speaker[uid].av_subscription) self.logger.warning(f"debug_speaker: av_subscription: thread active {sonos_speaker[uid].av_subscription.subscriptionThreadIsActive}, eventSignal: {sonos_speaker[uid].av_subscription.eventSignalIsSet}") - def get_soco_version(self) -> str: """ Get version of used Soco and return it @@ -3461,9 +3459,15 @@ def update_item(self, item: Items, caller: object, source: object, dest: object) """ if self.alive and caller != self.get_fullname(): - if self.has_iattr(item.conf, 'sonos_send'): - uid = self._resolve_uid(item) - command = self.get_iattr_value(item.conf, "sonos_send").lower() + + self.logger.debug(f"update_item called for {item.path()} with value {item()}") + item_config = self.get_item_config(item) + command = item_config.get('sonos_send', '').lower() + uid = item_config.get('uid') + + self.logger.debug(f"{uid=}, {command=}, ") + + if command and uid: if command == "play": sonos_speaker[uid].set_play() if item() else sonos_speaker[uid].set_pause() @@ -3634,9 +3638,11 @@ def _resolve_group_command(self, item: Items) -> bool: :return: 'True' or 'False' (whether the command should execute as a group command or not) """ + item_config = self.get_item_config(item) + # special handling for dpt_volume - if self.get_iattr_value(item.conf, 'sonos_attrib') == 'vol_dpt3': - group_item = self.get_iattr_value(item.conf, 'volume_parent') + if item_config.get('sonos_attrib', '') == 'vol_dpt3': + group_item = item_config['volume_item'] else: group_item = item @@ -3653,8 +3659,10 @@ def _resolve_max_volume_command(self, item: Items) -> int: :return: """ - if self.get_iattr_value(item.conf, 'sonos_attrib') == 'vol_dpt3': - volume_item = self.get_iattr_value(item.conf, 'volume_parent') + item_config = self.get_item_config(item) + + if item_config.get('sonos_attrib', '') == 'vol_dpt3': + volume_item = item_config['volume_item'] else: volume_item = item diff --git a/sonos/plugin.yaml b/sonos/plugin.yaml old mode 100755 new mode 100644 index 3b2ef4ac5..b9f41007c --- a/sonos/plugin.yaml +++ b/sonos/plugin.yaml @@ -12,7 +12,7 @@ plugin: documentation: https://github.com/smarthomeNG/plugins/blob/master/sonos/README.md support: https://knx-user-forum.de/forum/supportforen/smarthome-py/25151-sonos-anbindung - version: 1.8.7 # Plugin version + version: 1.8.8 # Plugin version sh_minversion: '1.5.1' # minimum shNG version to use this plugin py_minversion: '3.8' # minimum Python version to use for this plugin multi_instance: False # plugin supports multi instance @@ -227,6 +227,7 @@ item_attributes: sonos_dpt3_step: type: int + default: 2 description: de: 'Relatives dpt3 Inkrement' en: 'Relative dpt3 increment' @@ -234,6 +235,7 @@ item_attributes: sonos_dpt3_time: type: int + default: 1 description: de: 'Dpt3 Zeitinkrement' en: 'Dpt3 time increment' diff --git a/sonos/webif/__init__.py b/sonos/webif/__init__.py old mode 100755 new mode 100644 index d2e6b2c71..2a333209c --- a/sonos/webif/__init__.py +++ b/sonos/webif/__init__.py @@ -71,8 +71,8 @@ def index(self, reload=None): return tmpl.render(p=self.plugin, webif_pagelength=pagelength, - item_list=self.plugin.item_list, - item_count=len(self.plugin.item_list), + item_list=self.plugin.get_item_list(), + item_count=len(self.plugin.get_item_list()), plugin_shortname=self.plugin.get_shortname(), plugin_version=self.plugin.get_version(), plugin_info=self.plugin.get_info(), @@ -103,7 +103,7 @@ def get_data_html(self, dataSet=None): data = dict() data['items'] = {} - for item in self.plugin.item_list: + for item in self.plugin.get_item_list(): data['items'][item.property.path] = {} data['items'][item.property.path]['value'] = item() if item() is not None else '-' data['items'][item.property.path]['last_update'] = item.property.last_update.strftime('%d.%m.%Y %H:%M:%S') diff --git a/sonos/webif/templates/index.html b/sonos/webif/templates/index.html old mode 100755 new mode 100644 index 69b0036b6..fb36e6f52 --- a/sonos/webif/templates/index.html +++ b/sonos/webif/templates/index.html @@ -210,21 +210,12 @@ <td></td> <td class="py-1">{{ item._path }}</td> <td class="py-1">{{ item._type }}</td> - <td class="py-1"> - {% if 'sonos_uid' in item.conf %} - {{ p._get_zone_name_from_uid(item.conf['sonos_uid']) }} - {% elif 'sonos_recv' in item.conf %} - {{ p._get_zone_name_from_uid(p._resolve_uid(item)) }} - {% elif 'sonos_send' in item.conf %} - {{ p._get_zone_name_from_uid(p._resolve_uid(item)) }} - {% else %} - {{_('-')}} - {% endif %}</td> - <td class="py-1">{% if 'sonos_recv' in item.conf %} {{ item.conf['sonos_recv'] }} {% else %} {{_('-')}} {% endif %}</td> - <td class="py-1">{% if 'sonos_send' in item.conf %} {{ item.conf['sonos_send'] }} {% else %} {{_('-')}} {% endif %}</td> - <td class="py-1">{% if 'sonos_attrib' in item.conf %} {{ item.conf['sonos_attrib'] }} {% else %} {{_('-')}} {% endif %}</td> - <td class="py-1">{% if 'sonos_dpt3_step' in item.conf %} {{ item.conf['sonos_dpt3_step'] }} {% else %} {{_('-')}} {% endif %}</td> - <td class="py-1">{% if 'sonos_dpt3_time' in item.conf %} {{ item.conf['sonos_dpt3_time'] }} {% else %} {{_('-')}} {% endif %}</td> + <td class="py-1">{{ p._get_zone_name_from_uid(p.get_item_config(item).get('uid')) }}</td> + <td class="py-1">{{ p.get_item_config(item).get('sonos_recv', '-') }}</td> + <td class="py-1">{{ p.get_item_config(item).get('sonos_send', '-') }}</td> + <td class="py-1">{{ p.get_item_config(item).get('sonos_attrib', '-') }}</td> + <td class="py-1">{{ p.get_item_config(item).get('dpt3_step', '-') }}</td> + <td class="py-1">{{ p.get_item_config(item).get('dpt3_time', '-') }}</td> <td class="py-1" id="{{ item.id() }}_value">.{{ item._value }}</td> <!-- <td class="py-1">{{ item.conf }}</td> --> </tr>