def trigger_cloud_update(): log.info( '[CLOUD TRIGGER (udev)] Cloud Update triggered by serial adapter add/remove - waiting 30 seconds for other changes' ) time.sleep( 30 ) # Wait 30 seconds and then update, to accomodate multiple add removes data = { config.hostname: { 'adapters': config.get_local(do_print=False), 'interfaces': config.get_if_ips(), 'user': '******' } } log.debug( '[CLOUD TRIGGER (udev)] Final Data set collected for {}: \n{}'.format( config.hostname, data)) if config.cloud_svc == 'gdrive': # pylint: disable=maybe-no-member cloud = GoogleDrive(log) remote_consoles = cloud.update_files(data) # Send remotes learned from cloud file to local cache if len(remote_consoles) > 0: config.update_local_cloud_file(remote_consoles)
def trigger_cloud_update(self): local = self.cpi.local remotes = self.cpi.remotes log.info( '[MDNS REG] Cloud Update triggered delaying {} seconds'.format( UPDATE_DELAY)) time.sleep( UPDATE_DELAY ) # Wait 30 seconds and then update, to accomodate multiple add removes data = local.build_local_dict(refresh=True) for a in local.data[local.hostname].get('adapters', {}): if 'udev' in local.data[local.hostname]['adapters'][a]: del local.data[local.hostname]['adapters'][a]['udev'] log.debug( f'[MDNS REG] Final Data set collected for {local.hostname}: \n{json.dumps(data)}' ) remote_consoles = {} if config.cloud_svc == 'gdrive': # pylint: disable=maybe-no-member cloud = GoogleDrive(local.hostname) remote_consoles = cloud.update_files(data) # Send remotes learned from cloud file to local cache if len(remote_consoles) > 0 and 'Gdrive-Error' not in remote_consoles: remotes.update_local_cloud_file(remote_consoles) log.info( '[MDNS REG] Cloud Update Completed, Found {} Remote ConsolePis' .format(len(remote_consoles))) else: log.warning( '[MDNS REG] Cloud Update Completed, No remotes found, or Error Occured' )
def main(): log = config.log data = config.local log.debug( '[CLOUD TRIGGER (IP)]: Final Data set collected for {}: \n{}'.format( config.hostname, data)) log.info('[CLOUD TRIGGER (IP)]: Cloud Update triggered by IP Update') if config.cloud_svc == 'gdrive': # pylint: disable=maybe-no-member cloud = GoogleDrive(log) remote_consoles = cloud.update_files(data) # Send remotes learned from cloud file to local cache if len(remote_consoles) > 0: config.update_local_cloud_file(remote_consoles)
def refresh(self, rem_update=False): # pylint: disable=maybe-no-member remote_consoles = None config = self.config # Update Local Adapters if not rem_update: print('Detecting Locally Attached Serial Adapters') self.data['local'] = { self.hostname: { 'adapters': config.get_local(), 'interfaces': config.get_if_ips(), 'user': '******' } } config.log.info('Final Data set collected for {}: {}'.format( self.hostname, self.data['local'])) # Get details from Google Drive - once populated will skip if self.do_cloud and not self.local_only: if config.cloud_svc == 'gdrive' and self.cloud is None: self.cloud = GoogleDrive(config.log, hostname=self.hostname) # Pass Local Data to update_sheet method get remotes found on sheet as return # update sheets function updates local_cloud_file config.plog('Updating to/from {}'.format(config.cloud_svc)) remote_consoles = self.cloud.update_files(self.data['local']) if len(remote_consoles) > 0: config.plog('Updating Local Cache with data from {}'.format( config.cloud_svc)) config.update_local_cloud_file(remote_consoles) else: config.plog('No Remote ConsolePis found on {}'.format( config.cloud_svc)) else: if self.do_cloud: print('Not Updating from {} due to connection failure'.format( config.cloud_svc)) print( 'Close and re-launch menu if network access has been restored' ) # Update Remote data with data from local_cloud cache self.data['remote'] = self.get_remote(data=remote_consoles, refresh=True)
def main(): cpi = ConsolePi() cloud_svc = config.cfg.get("cloud_svc", "error") local = cpi.local remotes = cpi.remotes cpiexec = cpi.cpiexec log.info('[CLOUD TRIGGER (IP)]: Cloud Update triggered by IP Update') CLOUD_CREDS_FILE = config.static.get( "CLOUD_CREDS_FILE", '/etc/ConsolePi/cloud/gdrive/.credentials/credentials.json') if not utils.is_reachable("www.googleapis.com", 443): log.error(f"Not Updating {cloud_svc} due to connection failure") sys.exit(1) if not utils.valid_file(CLOUD_CREDS_FILE): log.error('Credentials file not found or invalid') sys.exit(1) # -- // Get details from Google Drive - once populated will skip \\ -- if cloud_svc == "gdrive" and remotes.cloud is None: remotes.cloud = GoogleDrive(hostname=local.hostname) if cpiexec.wait_for_threads(thread_type="remotes") and ( config.power and cpiexec.wait_for_threads(name="_toggle_refresh")): log.error( 'IP Change Cloud Update Trigger: TimeOut Waiting for Threads to Complete' ) remote_consoles = remotes.cloud.update_files(local.data) if remote_consoles and "Gdrive-Error:" in remote_consoles: log.error(remote_consoles) else: for r in remote_consoles: # -- Convert Any Remotes with old API schema to new API schema -- if isinstance(remote_consoles[r].get("adapters", {}), list): remote_consoles[r]["adapters"] = remotes.convert_adapters( remote_consoles[r]["adapters"]) log.warning( f"Adapter data for {r} retrieved from cloud in old API format... Converted" ) if len(remote_consoles) > 0: remotes.update_local_cloud_file(remote_consoles)
def refresh(self, bypass_cloud=False): remote_consoles = None cpiexec = self.cpiexec local = self.local cloud_svc = config.cfg.get("cloud_svc", "error") # TODO refactor wait_for_threads to have an all key or accept a list with Halo(text="Waiting For threads to complete", spinner="dots1"): if cpiexec.wait_for_threads(thread_type="remotes") and ( config.power and cpiexec.wait_for_threads(name="_toggle_refresh")): log.show( "Timeout Waiting for init or toggle threads to complete try again later or" " investigate logs") return # -- // Update/Refresh Local Data (Adapters/Interfaces) \\ -- local.data = local.build_local_dict(refresh=True) log.debugv( f"Final Data set collected for {local.hostname}: {local.data}") # -- // Get details from Google Drive - once populated will skip \\ -- if not bypass_cloud and self.do_cloud and not self.local_only: if cloud_svc == "gdrive" and self.cloud is None: # burried import until I find out why this import takes so @#%$@#% long. Not imported until 1st refresh is called with Halo(text="Loading Google Drive Library", spinner="dots1"): from consolepi.gdrive import GoogleDrive self.cloud = GoogleDrive(hostname=local.hostname) log.info("[MENU REFRESH] Gdrive init") # Pass Local Data to update_sheet method get remotes found on sheet as return # update sheets function updates local_cloud_file _msg = "[MENU REFRESH] Updating to/from {}".format(cloud_svc) log.info(_msg) if stdin.isatty(): self.spin.start(_msg) # -- // SYNC DATA WITH GDRIVE \\ -- remote_consoles = self.cloud.update_files( local.data) # local data refreshed above if remote_consoles and "Gdrive-Error:" not in remote_consoles: if stdin.isatty(): self.spin.succeed(_msg + "\n\tFound {} Remotes via Gdrive Sync". format(len(remote_consoles))) for r in remote_consoles: # -- Convert Any Remotes with old API schema to new API schema -- if isinstance(remote_consoles[r].get("adapters", {}), list): remote_consoles[r][ "adapters"] = self.convert_adapters( remote_consoles[r]["adapters"]) log.warning( f"Adapter data for {r} retrieved from cloud in old API format... Converted" ) elif "Gdrive-Error:" in remote_consoles: if stdin.isatty(): self.spin.fail("{}\n\t{} {}".format( _msg, self.log_sym_error, remote_consoles)) log.show(remote_consoles ) # display error returned from gdrive module remote_consoles = [] else: if stdin.isatty(): self.spin.warn(_msg + "\n\tNo Remotes Found via Gdrive Sync") if len(remote_consoles) > 0: _msg = f"[MENU REFRESH] Updating Local Cache with data from {cloud_svc}" log.info(_msg) if stdin.isatty(): self.spin.start(_msg) self.update_local_cloud_file(remote_consoles) if stdin.isatty(): self.spin.succeed(_msg) # no real error correction here else: log.warning( f"[MENU REFRESH] No Remote ConsolePis found on {cloud_svc}", show=True, ) else: if self.do_cloud and not bypass_cloud: log.show( f"Not Updating from {cloud_svc} due to connection failure\n" "Close and re-launch menu if network access has been restored" ) # Update Remote data with data from local_cloud cache / cloud self.data = self.get_remote(data=remote_consoles)
class Remotes: """Remotes Object Contains attributes for discovered remote ConsolePis""" def __init__(self, local, cpiexec): self.cpiexec = cpiexec self.pop_list = [] self.old_api_log_sent = False self.log_sym_warn = log_sym.WARNING.value self.log_sym_error = log_sym.ERROR.value self.local = local self.connected = False self.cache_update_pending = False self.spin = Halo(spinner="dots") self.cloud = None # Set in refresh method if reachable self.do_cloud = config.cfg.get("cloud", False) CLOUD_CREDS_FILE = config.static.get("CLOUD_CREDS_FILE") if not CLOUD_CREDS_FILE: self.no_creds_error() if self.do_cloud and config.cloud_svc == "gdrive": if utils.is_reachable("www.googleapis.com", 443): self.local_only = False if not utils.valid_file(CLOUD_CREDS_FILE): self.no_creds_error() else: log.warning( f"failed to connect to {config.cloud_svc} - operating in local only mode", show=True, ) self.local_only = True self.data = self.get_remote(data=config.remote_update( )) # re-get cloud.json to capture any updates via mdns def no_creds_error(self): cloud_svc = config.cfg.get("cloud_svc", "UNDEFINED!") log.warning( f"Required {cloud_svc} credentials files are missing refer to GitHub for details" ) log.warning(f"Disabling {cloud_svc} updates") log.show("Cloud Function Disabled by script - No Credentials Found") self.do_cloud = config.cfg["do_cloud"] = False # get remote consoles from local cache refresh function will check/update cloud file and update local cache def get_remote(self, data=None, rename=False): spin = self.spin def verify_remote_thread(remotepi, data, rename): """sub to verify reachability and api data for remotes params: remotepi: The hostname currently being processed data: dict remote ConsolePi dict with hostname as key """ this = data[remotepi] res = self.api_reachable(remotepi, this, rename=rename) this = res.data if res.update: self.cache_update_pending = True if not res.reachable: log.warning( f"[GET REM] Found {remotepi} in Local Cloud Cache: UNREACHABLE" ) this["fail_cnt"] = (1 if not this.get("fail_cnt") else this["fail_cnt"] + 1) self.pop_list.append(remotepi) self.cache_update_pending = True else: self.connected = True if this.get("fail_cnt"): this["fail_cnt"] = 0 self.cache_update_pending = True if res.update: log.info( f"[GET REM] Updating Cache - Found {remotepi} in Local Cloud Cache, " f"reachable via {this['rem_ip']}") data[remotepi] = this if data is None or len(data) == 0: data = config.remotes # remotes from local cloud cache if not data: # print(self.log_sym_warn + " No Remotes in Local Cache") log.info("No Remotes found in Local Cache") data = {} # convert None type to empy dict else: # if self is in the remote-data remove and warn user (can occur in rare scenarios i.e. hostname changes) if socket.gethostname() in data: del data[socket.gethostname()] log.show( "Local cache included entry for self - do you have other ConsolePis using the same hostname?" ) # Verify Remote ConsolePi details and reachability if stdin.isatty(): spin.start( "Querying Remotes via API to verify reachability and adapter data" ) for remotepi in data: # -- // Launch Threads to verify all remotes in parallel \\ -- threading.Thread( target=verify_remote_thread, args=(remotepi, data, rename), name=f"vrfy_{remotepi}", ).start() # verify_remote_thread(remotepi, data) # Non-Threading DEBUG # -- wait for threads to complete -- if not self.cpiexec.wait_for_threads(name="vrfy_", thread_type="remote"): if config.remotes: if stdin.isatty(): spin.succeed( "[GET REM] Querying Remotes via API to verify reachability and adapter data\n\t" f"Found {len(config.remotes)} Remote ConsolePis") else: if stdin.isatty(): spin.warn( "[GET REM] Querying Remotes via API to verify reachability and adapter data\n\t" "No Reachable Remote ConsolePis Discovered") else: log.error( "[GET REM] Remote verify threads Still running / exceeded timeout" ) if stdin.isatty(): spin.stop() # update local cache if any ConsolePis found UnReachable if self.cache_update_pending: if self.pop_list: for remotepi in self.pop_list: if ( data[remotepi]["fail_cnt"] >= 3 ): # NoQA remove from local cache after 3 failures (cloud or mdns will repopulate if discovered) removed = data.pop(remotepi) log.warning( "[GET REM] {} has been removed from Local Cache after {} failed attempts" .format(remotepi, removed["fail_cnt"]), show=True, ) else: log.show("Cached Remote '{}' is unreachable".format( remotepi)) # update local cache file if rem_ip or adapter data changed data = self.update_local_cloud_file(data) self.pop_list = [] self.cache_update_pending = False return data # Update with Data from ConsolePi.csv on Gdrive and local cache populated by mdns. Update Gdrive with our data def refresh(self, bypass_cloud=False): remote_consoles = None cpiexec = self.cpiexec local = self.local cloud_svc = config.cfg.get("cloud_svc", "error") # TODO refactor wait_for_threads to have an all key or accept a list with Halo(text="Waiting For threads to complete", spinner="dots1"): if cpiexec.wait_for_threads(thread_type="remotes") and ( config.power and cpiexec.wait_for_threads(name="_toggle_refresh")): log.show( "Timeout Waiting for init or toggle threads to complete try again later or" " investigate logs") return # -- // Update/Refresh Local Data (Adapters/Interfaces) \\ -- local.data = local.build_local_dict(refresh=True) log.debugv( f"Final Data set collected for {local.hostname}: {local.data}") # -- // Get details from Google Drive - once populated will skip \\ -- if not bypass_cloud and self.do_cloud and not self.local_only: if cloud_svc == "gdrive" and self.cloud is None: # burried import until I find out why this import takes so @#%$@#% long. Not imported until 1st refresh is called with Halo(text="Loading Google Drive Library", spinner="dots1"): from consolepi.gdrive import GoogleDrive self.cloud = GoogleDrive(hostname=local.hostname) log.info("[MENU REFRESH] Gdrive init") # Pass Local Data to update_sheet method get remotes found on sheet as return # update sheets function updates local_cloud_file _msg = "[MENU REFRESH] Updating to/from {}".format(cloud_svc) log.info(_msg) if stdin.isatty(): self.spin.start(_msg) # -- // SYNC DATA WITH GDRIVE \\ -- remote_consoles = self.cloud.update_files( local.data) # local data refreshed above if remote_consoles and "Gdrive-Error:" not in remote_consoles: if stdin.isatty(): self.spin.succeed(_msg + "\n\tFound {} Remotes via Gdrive Sync". format(len(remote_consoles))) for r in remote_consoles: # -- Convert Any Remotes with old API schema to new API schema -- if isinstance(remote_consoles[r].get("adapters", {}), list): remote_consoles[r][ "adapters"] = self.convert_adapters( remote_consoles[r]["adapters"]) log.warning( f"Adapter data for {r} retrieved from cloud in old API format... Converted" ) elif "Gdrive-Error:" in remote_consoles: if stdin.isatty(): self.spin.fail("{}\n\t{} {}".format( _msg, self.log_sym_error, remote_consoles)) log.show(remote_consoles ) # display error returned from gdrive module remote_consoles = [] else: if stdin.isatty(): self.spin.warn(_msg + "\n\tNo Remotes Found via Gdrive Sync") if len(remote_consoles) > 0: _msg = f"[MENU REFRESH] Updating Local Cache with data from {cloud_svc}" log.info(_msg) if stdin.isatty(): self.spin.start(_msg) self.update_local_cloud_file(remote_consoles) if stdin.isatty(): self.spin.succeed(_msg) # no real error correction here else: log.warning( f"[MENU REFRESH] No Remote ConsolePis found on {cloud_svc}", show=True, ) else: if self.do_cloud and not bypass_cloud: log.show( f"Not Updating from {cloud_svc} due to connection failure\n" "Close and re-launch menu if network access has been restored" ) # Update Remote data with data from local_cloud cache / cloud self.data = self.get_remote(data=remote_consoles) def update_local_cloud_file(self, remote_consoles=None, current_remotes=None, local_cloud_file=None): """Update local cloud cache (cloud.json). Verifies the newly discovered data is more current than what we already know and updates the local cloud.json file if so The Menu uses cloud.json to populate remote menu items params: remote_consoles: The newly discovered data (from Gdrive or mdns) current_remotes: The current remote data fetched from the local cloud cache (cloud.json) - func will retrieve this if not provided local_cloud_file The path to the local cloud file (global var cloud.json) returns: dict: The resulting remote console dict representing the most recent data for each remote. """ local_cloud_file = (config.static.get("LOCAL_CLOUD_FILE") if local_cloud_file is None else local_cloud_file) if len(remote_consoles) > 0: if current_remotes is None: current_remotes = self.data = config.remote_update( ) # grabs the remote data from local cloud cache # update current_remotes dict with data passed to function if len(remote_consoles) > 0: if current_remotes is not None: for _ in current_remotes: if _ not in remote_consoles: if ("fail_cnt" not in current_remotes[_] or current_remotes[_]["fail_cnt"] < 2): remote_consoles[_] = current_remotes[_] elif (remote_consoles.get(_) and "fail_cnt" not in remote_consoles[_] and "fail_cnt" in current_remotes[_]): remote_consoles[_]["fail_cnt"] = current_remotes[ _]["fail_cnt"] else: # -- VERBOSE DEBUG -- log.debugv( "[CACHE UPD] \n--{}-- \n remote upd_time: {}\n remote rem_ip: {}\n remote source: {}\n cache rem upd_time: {}\n cache rem_ip: {}\n cache source: {}\n" .format( # NoQA _, time.strftime( "%a %x %I:%M:%S %p %Z", time.localtime( remote_consoles[_]["upd_time"]), ) if "upd_time" in remote_consoles[_] else None, # NoQA remote_consoles[_]["rem_ip"] if "rem_ip" in remote_consoles[_] else None, remote_consoles[_]["source"] if "source" in remote_consoles[_] else None, time.strftime( "%a %x %I:%M:%S %p %Z", time.localtime( current_remotes[_]["upd_time"]), ) if "upd_time" in current_remotes[_] else None, # NoQA current_remotes[_]["rem_ip"] if "rem_ip" in current_remotes[_] else None, current_remotes[_]["source"] if "source" in current_remotes[_] else None, )) # -- END VERBOSE DEBUG -- # No Change Detected (data passed to function matches cache) if "last_ip" in current_remotes[_]: del current_remotes[_]["last_ip"] if remote_consoles[_] == current_remotes[_]: log.debug( "[CACHE UPD] {} No Change in info detected". format(_)) # only factor in existing data if source is not mdns elif ("upd_time" in remote_consoles[_] or "upd_time" in current_remotes[_]): if ("upd_time" in remote_consoles[_] and "upd_time" in current_remotes[_]): if (current_remotes[_]["upd_time"] > remote_consoles[_]["upd_time"]): remote_consoles[_] = current_remotes[_] log.info( f"[CACHE UPD] {_} Keeping existing data from {current_remotes[_].get('source', '')} " "based on more current update time") elif (remote_consoles[_]["upd_time"] > current_remotes[_]["upd_time"]): log.info( "[CACHE UPD] {} Updating data from {} " "based on more current update time". format(_, remote_consoles[_]["source"])) else: # -- Update Times are equal -- if (current_remotes[_].get("adapters") and remote_consoles[_].get("adapters") and current_remotes[_]["adapters"]. keys() != remote_consoles[_] ["adapters"].keys() ) or remote_consoles[_].get( "interfaces", {}) != current_remotes[_].get( "interfaces", {}): log.warning( "[CACHE UPD] {} current cache update time and {} update time are equal" " but data appears to have changed. Updating" .format( _, remote_consoles[_]["source"])) elif "upd_time" in current_remotes[_]: remote_consoles[_] = current_remotes[_] log.info( "[CACHE UPD] {} Keeping existing data based *existence* of update time " "which is lacking in this update from {}". format(_, remote_consoles[_]["source"])) for _try in range(0, 2): try: with open(local_cloud_file, "w") as cloud_file: cloud_file.write( json.dumps(remote_consoles, indent=4, sort_keys=True)) utils.set_perm( local_cloud_file ) # a hack to deal with perms ~ consolepi-details del func break except PermissionError: utils.set_perm(local_cloud_file) else: log.warning( "[CACHE UPD] cache update called with no data passed, doing nothing" ) return remote_consoles # Currently not Used def do_api_request(self, ip: str, path: str, *args, **kwargs): """Send RestFul GET request to Remote ConsolePi to collect data params: ip(str): ip address or FQDN of remote ConsolePi path(str): path beyond /api/v1.0/ returns: response object """ url = f"http://{ip}:5000/api/v1.0/{path}" log.debug(f'[do_api_request] URL: {url}') headers = { "Accept": "*/*", "Cache-Control": "no-cache", "Host": f"{ip}:5000", "accept-encoding": "gzip, deflate", "Connection": "keep-alive", "cache-control": "no-cache", } try: response = requests.request("GET", url, headers=headers, timeout=config.remote_timeout) except (OSError, TimeoutError): log.warning( f"[API RQST OUT] Remote ConsolePi @ {ip} TimeOut when querying via API - Unreachable." ) return False if response.ok: log.info(f"[API RQST OUT] {url} Response: OK") log.debugv( f"[API RQST OUT] Response: \n{json.dumps(response.json(), indent=4, sort_keys=True)}" ) else: log.error(f"[API RQST OUT] API Request Failed {url}") return response def get_adapters_via_api(self, ip: str, rename: bool = False): """Send RestFul GET request to Remote ConsolePi to collect adapter info params: ip(str): ip address or FQDN of remote ConsolePi returns: adapter dict for remote if successful Falsey or response status_code if an error occured. """ # log = self.config.log if rename: url = f"http://{ip}:5000/api/v1.0/adapters?refresh=true" else: url = f"http://{ip}:5000/api/v1.0/adapters" log.info(url) # DEBUG headers = { "Accept": "*/*", "Cache-Control": "no-cache", "Host": f"{ip}:5000", "accept-encoding": "gzip, deflate", "Connection": "keep-alive", "cache-control": "no-cache", } try: response = requests.request("GET", url, headers=headers, timeout=config.remote_timeout) except (OSError, TimeoutError): log.warning( "[API RQST OUT] Remote ConsolePi @ {} TimeOut when querying via API - Unreachable." .format(ip)) return False if response.ok: ret = response.json() ret = ret["adapters"] if ret["adapters"] else response.status_code _msg = "Adapters Successfully retrieved via API for Remote ConsolePi @ {}".format( ip) log.info("[API RQST OUT] {}".format(_msg)) log.debugv("[API RQST OUT] Response: \n{}".format( json.dumps(ret, indent=4, sort_keys=True))) else: ret = response.status_code log.error( "[API RQST OUT] Failed to retrieve adapters via API for Remote ConsolePi @ {}\n{}:{}" .format(ip, ret, response.text)) return ret def api_reachable(self, remote_host: str, cache_data: dict, rename: bool = False): """Check Rechability & Fetch adapter data via API for remote ConsolePi params: remote_host:str, The hostname of the Remote ConsolePi cache_data:dict, The ConsolePi dictionary for the remote (from cache file) rename:bool, rename = True will do api call with refresh=True Query parameter which tells the api to first update connection data from ser2net as it likely changed as a result of remote rename operation. returns: tuple [0]: Bool, indicating if data is different than cache [1]: dict, Updated ConsolePi dictionary for the remote """ class ApiReachableResponse: def __init__(self, update, data, reachable): self.update = update self.data = data self.reachable = reachable update = False local = self.local _iface_dict = cache_data["interfaces"] rem_ip_list = [ _iface_dict[_iface].get("ip") for _iface in _iface_dict if not _iface.startswith("_") and _iface_dict[_iface].get("ip") not in local.ip_list ] # if inbound data includes rem_ip make sure to try that first for _ip in [cache_data.get("rem_ip"), cache_data.get("last_ip")]: if _ip: if _ip not in rem_ip_list or rem_ip_list.index(_ip) != 0: rem_ip_list.remove(_ip) rem_ip_list.insert(0, _ip) rem_ip = None for _ip in rem_ip_list: log.debug(f"[API_REACHABLE] verifying {remote_host}") _adapters = self.get_adapters_via_api(_ip, rename=rename) if _adapters: rem_ip = _ip # Remote is reachable if not isinstance( _adapters, int): # indicates an html error code was returned if isinstance( _adapters, list ): # indicates need for conversion from old api format _adapters = self.convert_adapters(_adapters) if not self.old_api_log_sent: log.warning( f"{remote_host} provided old api schema. Recommend Upgrading to current." ) self.old_api_log_sent = True # Only compare config dict for each adapter as udev dict will generally be different due to time_since_init if not cache_data.get("adapters") or { a: { "config": _adapters[a].get("config", {}) } for a in _adapters } != { a: { "config": cache_data["adapters"][a].get( "config", {}) } for a in cache_data["adapters"] }: cache_data["adapters"] = _adapters update = True # --> Update if adapter dict is different else: cached_udev = [ False for a in cache_data["adapters"] if 'udev' not in cache_data["adapters"][a] ] if False in cached_udev: cache_data["adapters"] = _adapters update = True # --> Update if udev key not in existing data (udev not sent to cloud) elif _adapters == 200: log.show( f"Remote {remote_host} is reachable via {_ip}," " but has no adapters attached\nit's still available in remote shell menu" ) # remote was reachable update last_ip, even if returned bad status_code still reachable if not cache_data.get("last_ip", "") == _ip: cache_data["last_ip"] = _ip update = True # --> Update if last_ip is different than currently reachable IP break if cache_data.get("rem_ip") != rem_ip: cache_data["rem_ip"] = rem_ip update = ( True # --> Update if rem_ip didn't match (was previously unreachable) ) if not _adapters: reachable = False if isinstance(cache_data.get("adapters"), list): _adapters = cache_data.get("adapters") _adapters = { _adapters[_adapters.index(d)]["dev"]: { "config": { k: _adapters[_adapters.index(d)][k] for k in _adapters[_adapters.index(d)] } } for d in _adapters } cache_data["adapters"] = _adapters _msg = ( f"{remote_host} Cached adapter data was in old format... Converted to new.\n" f"\t\t{remote_host} Should be upgraded to the current version of ConsolePi." ) log.warning(_msg, show=True) update = True # --> Convert to new and Update if cache data was in old format else: reachable = True return ApiReachableResponse(update, cache_data, reachable) def convert_adapters(self, adapters): return { adapters[adapters.index(d)]["dev"]: { "config": { k: adapters[adapters.index(d)][k] for k in adapters[adapters.index(d)] } } for d in adapters }
class ConsolePiMenu(Outlets): def __init__(self, bypass_remote=False, do_print=True): super().__init__() # pylint: disable=maybe-no-member config = ConsolePi_data() self.config = config self.error_msgs = [] self.remotes_connected = False # self.error = None self.cloud = None # Set in refresh method if reachable self.do_cloud = config.cloud if self.do_cloud and config.cloud_svc == 'gdrive': if check_reachable('www.googleapis.com', 443): self.local_only = False if not os.path.isfile( '/etc/ConsolePi/cloud/gdrive/.credentials/credentials.json' ): config.plog( 'Required {} credentials files are missing refer to GitHub for details' .format(config.cloud_svc), level='warning') config.plog('Disabling {} updates'.format( config.cloud_svc), level='warning') self.error_msgs.append( 'Cloud Function Disabled by script - No Credentials Found' ) self.do_cloud = False else: config.plog( 'failed to connect to {}, operating in local only mode'. format(config.cloud_svc), level='warning') self.error_msgs.append( 'failed to connect to {} - operating in local only mode'. format(config.cloud_svc)) self.local_only = True self.go = True self.baud = 9600 self.data_bits = 8 self.parity = 'n' self.flow = 'n' self.parity_pretty = {'o': 'Odd', 'e': 'Even', 'n': 'No'} self.flow_pretty = {'x': 'Xon/Xoff', 'h': 'RTS/CTS', 'n': 'No'} self.hostname = config.hostname self.if_ips = config.interfaces self.ip_list = [] for _iface in self.if_ips: self.ip_list.append(self.if_ips[_iface]['ip']) self.data = {'local': config.local} if not bypass_remote: self.data['remote'] = self.get_remote() if config.power: if not os.path.isfile(config.POWER_FILE): config.plog( 'Outlet Control Function enabled but no power.json configuration found - Disabling feature', level='warning') self.error_msgs.append( 'Outlet Control Disabled by Script - No power.json found') config.power = False self.outlet_data = self.get_outlets() if config.power else None self.DEBUG = config.debug self.menu_actions = { 'main_menu': self.main_menu, 'key_menu': self.key_menu, 'c': self.con_menu, 'p': self.power_menu, 'h': self.picocom_help, 'k': self.key_menu, 'r': self.refresh, 's': self.rshell_menu, 'x': self.exit } # get remote consoles from local cache refresh function will check/update cloud file and update local cache def get_remote(self, data=None, refresh=False): config = self.config log = config.log # plog = config.plog print( 'Fetching Remote ConsolePis with attached Serial Adapters from local cache' ) log.info('[GET REM] Starting fetch from local cache') if data is None: data = config.get_local_cloud_file() if config.hostname in data: data.pop(self.hostname) log.warning( '[GET REM] Local cache included entry for self - there is a logic error someplace' ) # Add remote commands to remote_consoles dict for each adapter update_cache = False for remotepi in data: this = data[remotepi] print(' {} Found... Checking reachability'.format(remotepi), end='') if 'rem_ip' in this and this[ 'rem_ip'] is not None and check_reachable( this['rem_ip'], 22): print(': Success', end='\n') log.info( '[GET REM] Found {0} in Local Cache, reachable via {1}'. format(remotepi, this['rem_ip'])) #this['adapters'] = build_adapter_commands(this) else: for _iface in this['interfaces']: _ip = this['interfaces'][_iface]['ip'] if _ip not in self.ip_list: if check_reachable(_ip, 22): this['rem_ip'] = _ip print(': Success', end='\n') log.info( '[GET REM] Found {0} in Local Cloud Cache, reachable via {1}' .format(remotepi, _ip)) #this['adapters'] = build_adapter_commands(this) break # Stop Looping through interfaces we found a reachable one else: this['rem_ip'] = None if this['rem_ip'] is None: log.warning( '[GET REM] Found {0} in Local Cloud Cache: UNREACHABLE'. format(remotepi)) update_cache = True print(': !!! UNREACHABLE !!!', end='\n') # update local cache if any ConsolePis found UnReachable if update_cache: data = config.update_local_cloud_file(data) return data # Update ConsolePi.csv on Google Drive and pull any data for other ConsolePis def refresh(self, rem_update=False): # pylint: disable=maybe-no-member remote_consoles = None config = self.config # Update Local Adapters if not rem_update: print('Detecting Locally Attached Serial Adapters') self.data['local'] = { self.hostname: { 'adapters': config.get_local(), 'interfaces': config.get_if_ips(), 'user': '******' } } config.log.info('Final Data set collected for {}: {}'.format( self.hostname, self.data['local'])) # Get details from Google Drive - once populated will skip if self.do_cloud and not self.local_only: if config.cloud_svc == 'gdrive' and self.cloud is None: self.cloud = GoogleDrive(config.log, hostname=self.hostname) # Pass Local Data to update_sheet method get remotes found on sheet as return # update sheets function updates local_cloud_file config.plog('Updating to/from {}'.format(config.cloud_svc)) remote_consoles = self.cloud.update_files(self.data['local']) if len(remote_consoles) > 0: config.plog('Updating Local Cache with data from {}'.format( config.cloud_svc)) config.update_local_cloud_file(remote_consoles) else: config.plog('No Remote ConsolePis found on {}'.format( config.cloud_svc)) else: if self.do_cloud: print('Not Updating from {} due to connection failure'.format( config.cloud_svc)) print( 'Close and re-launch menu if network access has been restored' ) # Update Remote data with data from local_cloud cache self.data['remote'] = self.get_remote(data=remote_consoles, refresh=True) # -- Deprecated function will return this ConsolePis data if consolepi-menu called with an argument (if arg is data of calling ConsolePi it's read in) def update_from_remote(self, rem_data): config = self.config log = config.log rem_data = ast.literal_eval(rem_data) if isinstance(rem_data, dict): log.info('Remote Update Received via ssh: {}'.format(rem_data)) cache_data = config.get_local_cloud_file() for host in rem_data: cache_data[host] = rem_data[host] log.info( 'Updating Local Cloud Cache with data recieved from {}'.format( host)) config.update_local_cloud_file(cache_data) self.refresh(rem_update=True) # ======================= # MENUS FUNCTIONS # ======================= def menu_formatting(self, section, text=None): if section == 'header': if not self.DEBUG: os.system('clear') print('=' * 74) a = 74 - len(text) b = int(a / 2 - 2) if text is not None: if isinstance(text, list): for t in text: print(' {0} {1} {0}'.format('-' * b, t)) else: print(' {0} {1} {0}'.format('-' * b, text)) print('=' * 74 + '\n') elif section == 'footer': print('') if text is not None: if isinstance(text, list): for t in text: print(t) else: print(text) print(' x. exit\n') print('=' * 74) # if not self.do_cloud: # if self.error is not None: # print('* Cloud Function Disabled by script - no credentials found *') # elif self.local_only: # print('* !!!LOCAL ONLY MODE!!! *') # else: # print('* Remote Adapters based on local cache only use refresh option to update *') # -- print any error messages -- # if len(self.error_msgs) > 0: for msg in self.error_msgs: x = ((74 - len(msg)) / 2) - 1 print('*{}{}{}*'.format( ' ' * int(x), msg, ' ' * x if x == int(x) else ' ' * (int(x) + 1))) print('=' * 74) def do_flow_pretty(self, flow): if flow == 'x': flow_pretty = 'xon/xoff' elif flow == 'h': flow_pretty = 'RTS/CTS' elif flow == 'n': flow_pretty = 'NONE' else: flow_pretty = 'VALUE ERROR' return flow_pretty def picocom_help(self): print( '##################### picocom Command Sequences ########################\n' ) print(' This program will launch serial session via picocom') print( ' This is a list of the most common command sequences in picocom') print( ' To use them press and hold ctrl then press and release each character\n' ) print(' ctrl+ a - x Exit session - reset the port') print(' ctrl+ a - q Exit session - without resetting the port') print(' ctrl+ a - u increase baud') print(' ctrl+ a - d decrease baud') print(' ctrl+ a - f cycle through flow control options') print(' ctrl+ a - y cycle through parity options') print(' ctrl+ a - b cycle through data bits') print(' ctrl+ a - v Show configured port options') print(' ctrl+ a - c toggle local echo') print( '\n########################################################################\n' ) input('Press Enter to Continue') def power_menu(self): choice = '' outlets = self.outlet_data while choice.lower() not in ['x', 'b']: item = 1 # if choice.lower() == 'r': outlets = self.get_outlets() # print('Refreshing Outlets') if not self.DEBUG: os.system('clear') self.menu_formatting('header', text=' Power Control Menu ') print(' Defined Power Outlets') print(' ' + '-' * 33) # Build menu items for each serial adapter found on remote ConsolePis for r in sorted(outlets): outlet = outlets[r] if outlet['type'].upper() == 'GPIO': if outlet['noff']: cur_state = 'on' if outlet['is_on'] else 'off' to_state = 'off' if outlet['is_on'] else 'on' else: cur_state = 'off' if outlet['is_on'] else 'on' to_state = 'on' if outlet['is_on'] else 'off' elif outlet['type'].lower() == 'tasmota': cur_state = 'on' if outlet['is_on'] else 'off' to_state = 'off' if outlet['is_on'] else 'on' if isinstance(outlet['is_on'], int) and outlet['is_on'] <= 1: print(' {0}. Turn {1} {2} [ Current State {3} ]'.format( item, r, to_state, cur_state)) self.menu_actions[str(item)] = { 'function': self.do_toggle, 'args': [outlet['type'], outlet['address']] } item += 1 else: print(' !! Skipping {} as it returned an error: {}'.format( r, outlet['is_on'])) self.menu_actions['b'] = self.main_menu text = [' b. Back', ' r. Refresh'] self.menu_formatting('footer', text=text) choice = input(" >> ") if not choice.lower() == 'r': self.exec_menu(choice, actions=self.menu_actions, calling_menu='power_menu') def key_menu(self): rem = self.data['remote'] choice = '' while choice.lower() not in ['x', 'b']: menu_actions = { 'b': self.main_menu, 'x': self.exit, 'key_menu': self.key_menu } if not self.DEBUG: os.system('clear') # self.menu_actions['b'] = self.main_menu self.menu_formatting('header', text=' Remote SSH Key Distribution Menu ') print(' Available Remote Hosts') print(' ' + '-' * 33) # Build menu items for each serial adapter found on remote ConsolePis item = 1 for host in rem: if rem[host]['rem_ip'] is not None: rem_ip = rem[host]['rem_ip'] print(' {0}. Send SSH key to {1} @ {2}'.format( item, host, rem_ip)) # self.menu_actions[str(item)] = {'function': gen_copy_key, 'args': rem[host]['rem_ip']} menu_actions[str(item)] = { 'function': gen_copy_key, 'args': rem[host]['rem_ip'] } item += 1 self.menu_formatting('footer', text=' b. Back') choice = input(" >> ") self.exec_menu(choice, actions=menu_actions, calling_menu='key_menu') def main_menu(self): loc = self.data['local'][self.hostname]['adapters'] rem = self.data['remote'] config = self.config # remotes_connected = False item = 1 if not self.DEBUG: os.system('clear') self.menu_formatting('header', text=' ConsolePi Serial Menu ') print(' [LOCAL] Connect to Local Adapters') print(' ' + '-' * 33) # TODO # >> Clean this up, make sub to do this on both local and remote # Build menu items for each locally connected serial adapter for _dev in sorted(loc, key=lambda i: i['port']): this_dev = _dev['dev'] try: def_indicator = '' baud = _dev['baud'] dbits = _dev['dbits'] flow = _dev['flow'] parity = _dev['parity'] except KeyError: def_indicator = '*' baud = self.baud flow = self.flow dbits = self.data_bits parity = self.parity # Generate Menu Line spacing = ' ' if item < 10 else ' ' menu_line = ' {0}.{1}Connect to {2} [{3}{4} {5}{6}1]'.format( item, spacing, this_dev.replace('/dev/', ''), def_indicator, baud, dbits, parity[0].upper()) flow_pretty = self.do_flow_pretty(flow) if flow_pretty != 'NONE': menu_line += ' {}'.format(flow_pretty) print(menu_line) # Generate Command executed for Menu Line _cmd = 'picocom {0} -b{1} -f{2} -d{3} -p{4}'.format( this_dev, baud, flow, dbits, parity) self.menu_actions[str(item)] = {'cmd': _cmd} item += 1 # Build menu items for each serial adapter found on remote ConsolePis for host in sorted(rem): if rem[host]['rem_ip'] is not None and len( rem[host]['adapters']) > 0: self.remotes_connected = True header = ' [Remote] {} @ {}'.format( host, rem[host]['rem_ip']) print('\n' + header + '\n ' + '-' * (len(header) - 5)) for _dev in sorted(rem[host]['adapters'], key=lambda i: i['port']): try: def_indicator = '' baud = _dev['baud'] dbits = _dev['dbits'] flow = _dev['flow'] parity = _dev['parity'] except KeyError: def_indicator = '*' baud = self.baud flow = self.flow dbits = self.data_bits parity = self.parity # Generate Menu Line spacing = ' ' if item < 10 else ' ' menu_line = ' {0}.{1}Connect to {2} [{3}{4} {5}{6}1]'.format( item, spacing, _dev['dev'].replace('/dev/', ''), def_indicator, baud, dbits, parity[0].upper()) flow_pretty = self.do_flow_pretty(flow) if flow_pretty != 'NONE': menu_line += ' {}'.format(flow_pretty) print(menu_line) # Generate Command executed for Menu Line # _cmd = 'ssh -t {0}@{1} "picocom {2} -b{3} -f{4} -d{5} -p{6}"'.format( # rem[host]['user'], rem[host]['rem_ip'], _dev['dev'], baud, flow, dbits, parity) # pylint: disable=maybe-no-member _cmd = 'ssh -t {0}@{1} "{2} picocom {3} -b{4} -f{5} -d{6} -p{7}"'.format( rem[host]['user'], rem[host]['rem_ip'], config.REM_LAUNCH, _dev['dev'], baud, flow, dbits, parity) self.menu_actions[str(item)] = {'cmd': _cmd} item += 1 # -- General Menu Command Options -- text = [ ' c. Change *default Serial Settings [{0} {1}{2}1 flow={3}] '. format(self.baud, self.data_bits, self.parity.upper(), self.flow_pretty[self.flow]), ' h. Display picocom help' ] if self.outlet_data is not None: text.append(' p. Power Control Menu') if self.remotes_connected: text.append(' k. Distribute SSH Key to Remote Hosts') text.append( ' s. Remote Shell Menu (Connect to Remote ConsolePi Shell)') text.append(' r. Refresh') self.menu_formatting('footer', text=text) choice = input(" >> ") self.exec_menu(choice) return def rshell_menu(self): if self.remotes_connected: choice = '' rem = self.data['remote'] while choice.lower() not in ['x', 'b']: if not self.DEBUG: os.system('clear') self.menu_actions['b'] = self.main_menu self.menu_formatting('header', text=' Remote Shell Menu ') # Build menu items for each serial adapter found on remote ConsolePis item = 1 for host in sorted(rem): if rem[host]['rem_ip'] is not None: self.remotes_connected = True print(' {0}. Connect to {1} @ {2}'.format( item, host, rem[host]['rem_ip'])) _cmd = 'ssh -t {0}@{1}'.format('pi', rem[host]['rem_ip']) self.menu_actions[str(item)] = {'cmd': _cmd} item += 1 text = ' b. Back' self.menu_formatting('footer', text=text) choice = input(" >> ") self.exec_menu(choice, actions=self.menu_actions, calling_menu='rshell_menu') else: print('No Reachable remote devices found') return # Execute menu def exec_menu(self, choice, actions=None, calling_menu='main_menu'): menu_actions = self.menu_actions if actions is None else actions # for k in menu_actions: # print('{}: {}'.format(k, menu_actions[k])) config = self.config log = config.log if not self.DEBUG: os.system('clear') ch = choice.lower() if ch == '': menu_actions[calling_menu]() else: try: if isinstance(menu_actions[ch], dict): if 'cmd' in menu_actions[ch]: c = shlex.split(menu_actions[ch]['cmd']) # c = list like (local): ['picocom', '/dev/White3_7003', '-b9600', '-fn', '-d8', '-pn'] # (remote): ['ssh', '-t', '[email protected]', 'picocom /dev/AP303P-BARN_7001 -b9600 -fn -d8 -pn'] # -- if Power Control function is enabled check if device is linked to an outlet and ensure outlet is pwrd on -- if config.power: # pylint: disable=maybe-no-member if '/dev/' in c[1] or (len(c) >= 4 and '/dev/' in c[3]): menu_dev = c[1] if c[0] != 'ssh' else c[ 3].split()[1] for dev in config.local[ self.hostname]['adapters']: if menu_dev == dev['dev']: outlet = dev['outlet'] if outlet is not None and isinstance( outlet['is_on'], int) and outlet['is_on'] <= 1: desired_state = 'on' if outlet[ 'noff'] else 'off' # TODO Move noff logic to power.py print('Ensuring ' + menu_dev + ' is Powered On') r = self.do_toggle( outlet['type'], outlet['address'], desired_state=desired_state) if isinstance(r, int) and r > 1: print( 'Error operating linked outlet @ {}' .format(outlet['address'])) log.warning( '{} Error operating linked outlet @ {}' .format( menu_dev, outlet['address'])) else: print( 'Linked Outlet @ {} returned an error during menu load. Skipping...' .format(outlet['address'])) subprocess.run(c) elif 'function' in menu_actions[ch]: args = menu_actions[ch]['args'] # this is a lame hack but for the sake of time... for now try: # hardcoded for the gen key function menu_actions[ch]['function']( args, rem_user=rem_user, hostname=self.hostname, copy=True) except TypeError as e: if 'toggle()' in str(e): menu_actions[ch]['function'](args[0], args[1]) # self.do_toggle('tasmota', '10.115.0.129') else: # print(e) raise TypeError(e) else: menu_actions[ch]() except KeyError as e: print('Invalid selection {}, please try again.\n'.format(e)) # menu_actions[calling_menu]() return # Connection SubMenu def con_menu(self): menu_actions = { 'main_menu': self.main_menu, 'con_menu': self.con_menu, '1': self.baud_menu, '2': self.data_bits_menu, '3': self.parity_menu, '4': self.flow_menu, 'b': self.main_menu, 'x': self.exit } self.menu_formatting('header', text=' Connection Settings Menu ') print(' 1. Baud [{}]'.format(self.baud)) print(' 2. Data Bits [{}]'.format(self.data_bits)) print(' 3. Parity [{}]'.format(self.parity_pretty[self.parity])) print(' 4. Flow [{}]'.format(self.flow_pretty[self.flow])) text = ' b. Back' self.menu_formatting('footer', text=text) choice = input(" >> ") self.exec_menu(choice, actions=menu_actions, calling_menu='con_menu') return # Baud Menu def baud_menu(self): menu_actions = od([('main_menu', self.main_menu), ('con_menu', self.con_menu), ('baud_menu', self.baud_menu), ('1', 300), ('2', 1200), ('3', 9600), ('4', 19200), ('5', 57600), ('6', 115200), ('c', 'custom'), ('b', self.con_menu), ('x', self.exit)]) self.menu_formatting('header', text=' Select Desired Baud Rate ') for key in menu_actions: if not callable(menu_actions[key]): print(' {0}. {1}'.format(key, menu_actions[key])) text = ' b. Back' self.menu_formatting('footer', text=text) choice = input(" Baud >> ") ch = choice.lower() try: if type(menu_actions[ch]) == int: self.baud = menu_actions[ch] menu_actions['con_menu']() elif ch == 'c': self.baud = input(' Enter Desired Baud Rate >> ') menu_actions['con_menu']() else: menu_actions[ch]() except KeyError: print("\n!!! Invalid selection, please try again.\n") menu_actions['baud_menu']() return # Data Bits Menu def data_bits_menu(self): valid = False while not valid: self.menu_formatting('header', text=' Enter Desired Data Bits ') print(' Default 8, Current {}, Valid range 5-8'.format( self.data_bits)) self.menu_formatting('footer', text=' b. Back') choice = input(' Data Bits >> ') try: if choice.lower() == 'x': # self.exit() sys.exit(0) elif choice.lower() == 'b': valid = True elif int(choice) >= 5 and int(choice) <= 8: self.data_bits = choice valid = True else: print("\n!!! Invalid selection, please try again.\n") except ValueError: print("\n!! Invalid selection, please try again.\n") self.con_menu() return def parity_menu(self): def print_menu(): self.menu_formatting('header', text=' Select Desired Parity ') print(' Default No Parity\n') print(' 1. None') print(' 2. Odd') print(' 3. Even') text = ' b. Back' self.menu_formatting('footer', text=text) valid = False while not valid: print_menu() valid = True choice = input(' Parity >> ') choice = choice.lower() if choice == '1': self.parity = 'n' elif choice == '2': self.parity = 'o' elif choice == '3': self.parity = 'e' elif choice == 'b': pass elif choice == 'x': # self.exit() sys.exit(0) else: valid = False print('\n!!! Invalid selection, please try again.\n') if valid: self.con_menu() return def flow_menu(self): def print_menu(): self.menu_formatting('header', text=' Select Desired Flow Control ') print(' Default No Flow\n') print(' 1. No Flow Control (default)') print(' 2. Xon/Xoff (software)') print(' 3. RTS/CTS (hardware)') text = ' b. Back' self.menu_formatting('footer', text=text) valid = False while not valid: print_menu() choice = input(' Flow >> ') choice = choice.lower() if choice in ['1', '2', '3', 'b', 'x']: valid = True try: if choice == '1': self.flow = 'n' elif choice == '2': self.flow = 'x' elif choice == '3': self.flow = 'h' elif choice.lower() == 'b': pass elif choice == 'x': # self.exit() sys.exit(0) else: print("\n!!! Invalid selection, please try again.\n") except Exception as e: print('\n[{}]\n!!! Invalid selection, please try again.\n'. format(e)) # if valid: # self.con_menu() return # Back to main menu def back(self): self.menu_actions['main_menu']() # Exit program def exit(self): self.go = False