def exec_shell_cmd(self, cmd): """Determine if cmd is valid shell cmd and execute if so. Command will execute as the local user unless the user prefixed the cmd with sudo -u Arguments: cmd {str} -- Input provided by user Returns: True|None -- Return True if cmd was determined to be a bash cmd """ s = subprocess c = cmd.replace("-u", "").replace("sudo", "").strip() p = "PATH=$PATH:/etc/ConsolePi/src/consolepi-commands && " # TODO if shutil.which(c.split()[0]): r = s.run(f"{p}which {c.split()[0]}", shell=True, stderr=s.PIPE, stdout=s.PIPE) if r.returncode == 0: try: if "sudo " not in cmd: cmd = f'sudo -u {config.loc_user} bash -c "{p}{cmd}"' elif "sudo -u " not in cmd: cmd = cmd.replace("sudo ", "") subprocess.run(cmd, shell=True) print("") input("Press Enter to Continue... ") except (KeyboardInterrupt, EOFError): log.show('Operation Aborted') print('') # prevents header and prompt on same line in debug return True
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
def get_outlets_from_file(self): '''Get outlets defined in config returns: dict: with following keys (all values are dicts) linked: linked outlets from config (linked to serial adapters- auto pwr-on) dli_power: dict any dlis in config have all ports represented here failures: failure to connect to any outlets will result in an entry here outlet_name: failure description ''' outlet_data = self.cfg_yml.get('POWER') if not outlet_data: # fallback to legacy json config outlet_data = self.get_json_file(self.static.get('POWER_FILE')) if not outlet_data: if self.power: log.show('Power Function Disabled - Configuration Not Found') self.power = False self.outlet_types = [] return outlet_data types = [] by_dev = {} for k in outlet_data: if outlet_data[k].get('linked_devs'): outlet_data[k]['linked_devs'] = utils.format_dev(outlet_data[k]['linked_devs'], hosts=self.hosts, with_path=True) self.linked_exists = True for dev in outlet_data[k]['linked_devs']: _type = outlet_data[k].get('type').lower() if _type == 'dli': _this = [f"{k}:{[int(p) for p in utils.listify(outlet_data[k]['linked_devs'][dev])]}"] elif _type == 'esphome': _linked = utils.listify(outlet_data[k]['linked_devs'][dev]) _this = [f'{k}:{[p for p in _linked]}'] else: _this = [k] by_dev[dev] = _this if dev not in by_dev else by_dev[dev] + _this else: outlet_data[k]['linked_devs'] = [] if outlet_data[k]["type"].lower() not in types: types.append(outlet_data[k]["type"].lower()) if outlet_data[k]['type'].upper() == 'GPIO' and isinstance(outlet_data[k].get('address'), str) \ and outlet_data[k]['address'].isdigit(): outlet_data[k]['address'] = int(outlet_data[k]['address']) self.outlet_types = types outlet_data = { 'defined': outlet_data, 'linked': by_dev, 'dli_power': {}, 'failures': {} } return outlet_data
def wait_for_boot(): while True: try: if utils.is_reachable(_h, _p, silent=True): break else: time.sleep(3) except KeyboardInterrupt: self.autopwr_wait = False log.show("Connection Aborted") break
def trigger_udev(self): '''reload/trigger udev (udevadm) Returns: No return unless there is an error. Returns {str} if an error occurs. ''' cmd = 'sudo udevadm control --reload && sudo udevadm trigger && systemctl is-active ser2net >/dev/null 2>&1'\ '&& sudo systemctl stop ser2net && sleep 1 && sudo systemctl start ser2net' error = utils.spinner( 'Triggering reload of udev & ser2net due to name change', utils.do_shell_cmd, cmd, shell=True) if not error: self.udev_pending = False else: log.show( 'Failed to reload udev rules, you may need to rectify manually for adapter names to display correctly' ) log.show( f'Check /var/log/syslog for errors, the rules file ({self.rules_file}) and reattempt {cmd} manually' ) log.show(error)
def auto_pwron_thread(self, pwr_key): """Ensure any outlets linked to device are powered on Called by consolepi_menu exec_menu function and remote_launcher (for sessions to remotes) when a connection initiated with adapter. Powers any linked outlets associated with the adapter on. params: menu_dev:str, The tty device user is connecting to. Returns: No Return - Updates class attributes """ if self.wait_for_threads("init"): return outlets = self.pwr.data if "linked" not in outlets: _msg = "Error linked key not found in outlet dict\nUnable to perform auto power on" log.show(_msg, show=True) return if not outlets["linked"].get(pwr_key): return # -- // Perform Auto Power On (if not already on) \\ -- for o in outlets["linked"][pwr_key]: outlet = outlets["defined"].get(o.split(":")[0]) ports = [] if ":" not in o else json.loads(o.split(":")[1]) # NoQA .replace('\'', '"')) No longer necessary single port defs are listified in config.py _addr = outlet["address"] # -- // DLI web power switch Auto Power On \\ -- if outlet["type"].lower() == "dli": for p in ports: log.debug( f"[Auto PwrOn] Power ON {pwr_key} Linked Outlet {outlet['type']}:{_addr} p{p}" ) # TODO have seen this, but unable to recreate. may be transient failure??? # NoQA This log occurs: [ERROR]: [DLI GET OUTLETS] dli @ labpower2.kabrew.com reachable, but failed to fetch statuslist (outlet_list) # is_on in pwr.data['labpower2']['defined'] is being flushed based on error above so empty dict resulting in key error # Exception in thread auto_pwr_on_r1-8320T-sw: # Traceback (most recent call last): # File "/usr/lib/python3.7/threading.py", line 917, in _bootstrap_inner # self.run() # File "/usr/lib/python3.7/threading.py", line 865, in run # self._target(*self._args, **self._kwargs) # File "/etc/ConsolePi/src/pypkg/consolepi/exec.py", line 88, in auto_pwron_thread # if not outlet["is_on"][p][ # KeyError: 2 if not outlet["is_on"][p]["state"]: # This is just checking what's in the dict not querying the DLI r = self.pwr.pwr_toggle( outlet["type"], _addr, desired_state=True, port=p ) if isinstance(r, bool): if r: threading.Thread( target=self.outlet_update, kwargs={"refresh": True, "upd_linked": True}, name="auto_pwr_refresh_dli", ).start() self.autopwr_wait = True else: log.warning( f"{pwr_key} Error operating linked outlet @ {o}", show=True, ) # -- // esphome Auto Power On \\ -- elif outlet["type"].lower() == "esphome": for p in ports: log.debug( f"[Auto PwrOn] Power ON {pwr_key} Linked Outlet {outlet['type']}:{_addr} p{p}" ) if not outlet["is_on"][p]["state"]: # This is just checking what's in the dict r = self.pwr.pwr_toggle( outlet["type"], _addr, desired_state=True, port=p ) if isinstance(r, bool): self.pwr.data['defined'][o.split(':')[0]]['is_on'][p]['state'] = r else: log.show(r) log.warning( f"{pwr_key} Error operating linked outlet @ {o}", show=True, ) # -- // GPIO & TASMOTA Auto Power On \\ -- else: log.debug( f"[Auto PwrOn] Power ON {pwr_key} Linked Outlet {outlet['type']}:{_addr}" ) r = self.pwr.pwr_toggle( outlet["type"], _addr, desired_state=True, noff=outlet.get("noff", True) if outlet["type"].upper() == "GPIO" else True, ) if isinstance(r, int) and r > 1: # return is an error r = False else: # return is bool which is what we expect if r: self.pwr.data["defined"][o]["state"] = r self.autopwr_wait = True # self.pwr.pwr_get_outlets(upd_linked=True) else: # self.config.log_and_show(f"Error operating linked outlet {o}:{outlet['address']}", log=log.warning) log.show( f"Error operating linked outlet {o}:{outlet['address']}", show=True, )
def menu_exec(self, choice, menu_actions, calling_menu="main_menu"): '''Execute Menu Selection. This method needs to be overhauled. The ConsolePiAction object defined but not used in __init__ is part of the plan for overhaul menu will build insance of the object for each selection. That will be used to determine what action to perform and what to do after etc. ''' pwr = self.pwr if not config.debug and calling_menu not in ["dli_menu", "power_menu"]: os.system("clear") if ( not choice.lower or choice.lower in menu_actions and menu_actions[choice.lower] is None ): ( self.menu.rows, self.menu.cols, ) = ( utils.get_tty_size() ) # re-calc tty size in case they've adjusted the window return else: ch = choice.lower try: # Invalid Selection if isinstance(menu_actions[ch], dict): if menu_actions[ch].get("cmd"): # TimeStamp for picocom session log file if defined menu_actions[ch]["cmd"] = menu_actions[ch]["cmd"].replace( "{{timestamp}}", time.strftime("%F_%H.%M") ) # -- // AUTO POWER ON LINKED OUTLETS \\ -- if ( config.power and "pwr_key" in menu_actions[ch] ): self.exec_auto_pwron(menu_actions[ch]["pwr_key"]) # -- // Print pre-connect messsge if provided \\ -- if menu_actions[ch].get("pre_msg"): print(menu_actions[ch]["pre_msg"]) # --// execute the command \\-- try: _error = None if "exec_kwargs" in menu_actions[ch]: c = menu_actions[ch]["cmd"] _error = utils.do_shell_cmd( c, **menu_actions[ch]["exec_kwargs"] ) if _error and self.autopwr_wait: # TODO simplify this after moving to action object _method = "ssh -t" if "ssh" in c else "telnet" if "ssh" in _method: _h = ( c.split(f"{_method} ")[1] .split(" -p")[0] .split("@")[1] ) _p = int(c.split("-p ")[1]) elif _method == "telnet": c_list = c.split() _h = c_list[-2] _p = int(c_list[-1]) def wait_for_boot(): while True: try: if utils.is_reachable(_h, _p, silent=True): break else: time.sleep(3) except KeyboardInterrupt: self.autopwr_wait = False log.show("Connection Aborted") break print( "\nInitial Attempt Failed, but host is linked to an outlet that was" ) print("off. Host may still be booting\n") utils.spinner( f"Waiting for {_h} to boot, CTRL-C to Abort", wait_for_boot, ) if self.autopwr_wait: _error = utils.do_shell_cmd(c, **menu_actions[ch]["exec_kwargs"]) self.autopwr_wait = False else: c = shlex.split(menu_actions[ch]["cmd"]) result = subprocess.run(c, stderr=subprocess.PIPE) _stderr = result.stderr.decode("UTF-8") if _stderr or result.returncode == 1: _error = utils.error_handler(c, _stderr) if _error: log.show(_error) # -- // resize the terminal to handle serial connections that jack the terminal size \\ -- c = " ".join([str(i) for i in c]) if "picocom" in c: os.system( "/etc/ConsolePi/src/consolepi-commands/resize >/dev/null" ) except KeyboardInterrupt: log.show("Aborted last command based on user input") elif "function" in menu_actions[ch]: args = ( menu_actions[ch]["args"] if "args" in menu_actions[ch] else [] ) kwargs = ( menu_actions[ch]["kwargs"] if "kwargs" in menu_actions[ch] else {} ) confirmed, spin_text, name = self.confirm_and_spin( menu_actions[ch], *args, **kwargs ) if confirmed: # update kwargs with name from confirm_and_spin method if menu_actions[ch]["function"].__name__ == "pwr_rename": kwargs["name"] = name # // -- CALL THE FUNCTION \\-- if ( spin_text ): # start spinner if spin_text set by confirm_and_spin with Halo(text=spin_text, spinner="dots2"): response = menu_actions[ch]["function"]( *args, **kwargs ) else: # no spinner response = menu_actions[ch]["function"](*args, **kwargs) # --// Power Menus \\-- if calling_menu in ["power_menu", "dli_menu"]: if menu_actions[ch]["function"].__name__ == "pwr_all": with Halo( text="Refreshing Outlet States", spinner="dots" ): self.outlet_update( refresh=True, upd_linked=True ) # TODO can I move this to Outlets Class else: _grp = menu_actions[ch]["key"] _type = menu_actions[ch]["args"][0] _addr = menu_actions[ch]["args"][1] # --// EVAL responses for dli outlets \\-- if _type == "dli": host_short = utils.get_host_short(_addr) _port = menu_actions[ch]["kwargs"]["port"] # --// Operations performed on ALL outlets \\-- if ( isinstance(response, bool) and _port is not None ): if ( menu_actions[ch]["function"].__name__ == "pwr_toggle" ): self.spin.start( "Request Sent, Refreshing Outlet States" ) # threading.Thread(target=self.get_dli_outlets, # kwargs={'upd_linked': True, 'refresh': True}, name='pwr_toggle_refresh').start() upd_linked = ( True if calling_menu == "power_menu" else False ) # else dli_menu threading.Thread( target=self.outlet_update, kwargs={ "upd_linked": upd_linked, "refresh": True, }, name="pwr_toggle_refresh", ).start() if _grp in pwr.data["defined"]: pwr.data["defined"][_grp]["is_on"][ _port ]["state"] = response elif _port != "all": pwr.data["dli_power"][_addr][_port][ "state" ] = response else: # dli toggle all for t in threading.enumerate(): if ( t.name == "pwr_toggle_refresh" ): t.join() # if refresh thread is running join ~ # wait for it to complete. # TODO Don't think this works or below # wouldn't have been necessary. # toggle all returns True (ON) or False (OFF) if command # successfully sent. In reality the ports # may not be in the state yet, but dli is working it. # Update menu items to reflect end state for p in pwr.data[ "dli_power" ][_addr]: pwr.data["dli_power"][ _addr ][p]["state"] = response break self.spin.stop() # Cycle operation returns False if outlet is off, only valid on powered outlets elif ( menu_actions[ch]["function"].__name__ == "pwr_cycle" and not response ): log.show( f"{host_short} Port {_port} is Off. Cycle is not valid" ) elif ( menu_actions[ch]["function"].__name__ == "pwr_rename" ): if response: _name = pwr._dli[_addr].name(_port) if _grp in pwr.data.get( "defined", {} ): pwr.data["defined"][_grp][ "is_on" ][_port]["name"] = _name else: threading.Thread( target=self.outlet_update, kwargs={ "upd_linked": True, "refresh": True, }, name="pwr_rename_refresh", ).start() pwr.data["dli_power"][_addr][_port][ "name" ] = _name # --// str responses are errors append to error_msgs \\-- # TODO refactor response to use new cpi.response(...) elif ( isinstance(response, str) and _port is not None ): log.show(response) # --// Can Remove After Refactoring all responses to bool or str \\-- elif isinstance(response, int): if ( menu_actions[ch]["function"].__name__ == "pwr_cycle" and _port == "all" ): if response != 200: log.show( "Error Response Returned {}".format( response ) ) # This is a catch as for the most part I've tried to refactor so the pwr library # returns port state on success (True/False) else: if response in [200, 204]: log.show( "DEV NOTE: check pwr library ret=200 or 204" ) else: _action = menu_actions[ch][ "function" ].__name__ log.show( f"Error returned from dli {host_short} when " f"attempting to {_action} port {_port}" ) # --// EVAL responses for espHome outlets \\-- elif _type == "esphome": host_short = utils.get_host_short(_addr) _port = menu_actions[ch]["kwargs"]["port"] # --// Operations performed on ALL outlets \\-- if (isinstance(response, bool) and _port is not None): pwr.data['defined'][_grp]['is_on'][_port]['state'] = response if ( menu_actions[ch]["function"].__name__ == "pwr_cycle" and not response ): _msg = f"{_grp}({host_short})" if _grp != host_short else f"{_grp}" if _msg != _port: _msg = f"{_msg} Port {_port} is Off. Cycle is not valid" else: _msg = f"{_msg} is Off. Cycle is not valid" log.show(_msg) elif (isinstance(response, str) and _port is not None): log.show(response) # --// EVAL responses for GPIO and tasmota outlets \\-- else: if ( menu_actions[ch]["function"].__name__ == "pwr_toggle" ): if _grp in pwr.data.get("defined", {}): if isinstance(response, bool): pwr.data["defined"][_grp][ "is_on" ] = response else: pwr.data["defined"][_grp][ "errors" ] = response elif ( menu_actions[ch]["function"].__name__ == "pwr_cycle" and not response ): log.show( "Cycle is not valid for Outlets in the off state" ) elif ( menu_actions[ch]["function"].__name__ == "pwr_rename" ): log.show( "rename not yet implemented for {} outlets".format( _type ) ) elif calling_menu in ["key_menu", "rename_menu"]: if response: log.show(response) else: # not confirmed log.show("Operation Aborted by User") elif menu_actions[ch].__name__ in ["power_menu", "dli_menu"]: menu_actions[ch](calling_menu=calling_menu) else: menu_actions[ch]() except KeyError as e: if len(choice.orig) <= 2 or not self.exec_shell_cmd(choice.orig): log.show(f"Invalid selection {e}, please try again.") return True
def auto_pwron_thread(self, pwr_key): """Ensure any outlets linked to device are powered on Called by consolepi_menu exec_menu function and remote_launcher (for sessions to remotes) when a connection initiated with adapter. Powers any linked outlets associated with the adapter on. params: menu_dev:str, The tty device user is connecting to. Returns: No Return - Updates class attributes """ if self.wait_for_threads("init"): return outlets = self.pwr.data if "linked" not in outlets: _msg = "Error linked key not found in outlet dict\nUnable to perform auto power on" log.show(_msg, show=True) return if not outlets["linked"].get(pwr_key): return # -- // Perform Auto Power On (if not already on) \\ -- for o in outlets["linked"][pwr_key]: outlet = outlets["defined"].get(o.split(":")[0]) ports = [] if ":" not in o else json.loads( o.replace("'", '"').split(":")[1]) _addr = outlet["address"] # -- // DLI web power switch Auto Power On \\ -- # # TODO combine all ports from same pwr_key and sent to pwr_toggle once # TODO Update outlet if return is OK, then run refresh in the background to validate # TODO Add class attribute to cpi_menu ~ cpi_menu.new_data = "power", "main", etc # Then in wait_for_input run loop to check for updates and re-display menu # TODO power_menu and dli_menu wait_for_threads auto power ... check cpiexec.autopwr_wait first # if outlet["type"].lower() == "dli": for p in ports: log.debug( f"[Auto PwrOn] Power ON {pwr_key} Linked Outlet {outlet['type']}:{_addr} p{p}" ) if not outlet["is_on"][p][ "state"]: # This is just checking what's in the dict not querying the DLI r = self.pwr.pwr_toggle(outlet["type"], _addr, desired_state=True, port=p) if isinstance(r, bool): if r: threading.Thread( target=self.outlet_update, kwargs={ "refresh": True, "upd_linked": True }, name="auto_pwr_refresh_dli", ).start() self.autopwr_wait = True else: log.warning( f"{pwr_key} Error operating linked outlet @ {o}", show=True, ) # -- // esphome Auto Power On \\ -- elif outlet["type"].lower() == "esphome": for p in ports: log.debug( f"[Auto PwrOn] Power ON {pwr_key} Linked Outlet {outlet['type']}:{_addr} p{p}" ) if not outlet["is_on"][p][ "state"]: # This is just checking what's in the dict r = self.pwr.pwr_toggle(outlet["type"], _addr, desired_state=True, port=p) if isinstance(r, bool): self.pwr.data['defined'][o.split( ':')[0]]['is_on'][p]['state'] = r else: log.show(r) log.warning( f"{pwr_key} Error operating linked outlet @ {o}", show=True, ) # -- // GPIO & TASMOTA Auto Power On \\ -- else: log.debug( f"[Auto PwrOn] Power ON {pwr_key} Linked Outlet {outlet['type']}:{_addr}" ) r = self.pwr.pwr_toggle( outlet["type"], _addr, desired_state=True, noff=outlet.get("noff", True) if outlet["type"].upper() == "GPIO" else True, ) if isinstance(r, int) and r > 1: # return is an error r = False else: # return is bool which is what we expect if r: self.pwr.data["defined"][o]["state"] = r self.autopwr_wait = True # self.pwr.pwr_get_outlets(upd_linked=True) else: # self.config.log_and_show(f"Error operating linked outlet {o}:{outlet['address']}", log=log.warning) log.show( f"Error operating linked outlet {o}:{outlet['address']}", show=True, )
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
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 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 add_to_udev(self, udev_line: str, section_marker: str, label: str = None): '''Add or edit udev rules file with new symlink after adapter rename. Arguments: udev_line {str} -- The properly formatted udev line being added to the file section_marker {str} -- Match text used to determine where to place the line Keyword Arguments: label {str} -- The rules file GOTO label used in some scenarios (i.e. multi-port 1 serial) (default: {None}) Returns: {str|None} -- Returns error string if an error occurs ''' found = ser_label_exists = get_next = update_file = False # init goto = line = cmd = '' # init rules_file = self.rules_file # if 'ttyAMA' not in udev_line else self.ttyama_rules_file Testing 1 rules file if utils.valid_file(rules_file): with open(rules_file) as x: for line in x: # temporary for those that have the original file if 'ID_SERIAL' in line and 'IMPORT' not in line: _old = 'ENV{ID_SERIAL}=="", GOTO="BYPATH-POINTERS"' _new = 'ENV{ID_SERIAL_SHORT}=="", IMPORT{builtin}="path_id", GOTO="BYPATH-POINTERS"' cmd = "sudo sed -i 's/{}/{}/' {}".format( _old, _new, rules_file) update_file = True # No longer including SUBSYSTEM in formatted udev line, redundant given logic @ top of rules file if line.replace('SUBSYSTEM=="tty", ', '').strip() == udev_line.strip(): return # Line is already in file Nothing to do. if get_next: goto = line get_next = False if section_marker.replace(' END', '') in line: get_next = True elif section_marker in line: found = True elif label and 'LABEL="{}"'.format(label) in line: ser_label_exists = True last_line = line if update_file: error = utils.do_shell_cmd(cmd) if error: log.show(error) goto = goto.split('GOTO=')[1].replace( '"', '').strip() if 'GOTO=' in goto else None if goto is None: goto = last_line.strip().replace('LABEL=', '').replace( '"', '') if 'LABEL=' in last_line else None else: error = utils.do_shell_cmd( f'sudo cp /etc/ConsolePi/src/{os.path.basename(rules_file)} /etc/udev/rules.d/' ) # TODO switch to pathlib.Path('path...').copy(src, dst) found = True goto = 'END' if goto and 'GOTO=' not in udev_line: udev_line = '{}, GOTO="{}"'.format(udev_line, goto) if label and not ser_label_exists: udev_line = 'LABEL="{}"\\n{}'.format(label, udev_line) # -- // UPDATE RULES FILE WITH FORMATTED LINE \\ -- if found: udev_line = '{}\\n{}'.format(udev_line, section_marker) cmd = "sudo sed -i 's/{}/{}/' {}".format(section_marker, udev_line, rules_file) error = utils.do_shell_cmd(cmd, handle_errors=False) if error: return error else: # Not Using new 10-ConsolePi.rules template just append to file if section_marker == '# END BYSERIAL-DEVS': return utils.append_to_file(rules_file, udev_line) else: # if not by serial device the new template is required return 'Unable to Add Line, please use the new 10.ConsolePi.rules found in src dir and\n' \ 'add you\'re current rules to the BYSERIAL-DEVS section.'
def do_rename_adapter(self, from_name): '''Rename USB to Serial Adapter Creates new or edits existing udev rules and ser2net conf for USB to serial adapters detected by the system. params: from_name(str): Devices current name passed in from rename_menu() returns: None type if no error, or Error (str) if Error occurred ''' from_name = from_name.replace('/dev/', '') local = self.cpi.local c = { 'green': '\033[1;32m', # Bold with normal ForeGround 'red': '\033[1;31m', 'norm': '\033[0m', # Reset to Normal } c_from_name = '{}{}{}'.format(c['red'], from_name, c['norm']) error = False use_def = True try: to_name = None while not to_name: print( " Press 'enter' to keep the same name and change baud/parity/..." ) to_name = input( f' [rename {c_from_name}]: Provide desired name: ') print("") to_name = to_name or from_name to_name = to_name.replace( '/dev/', '') # strip /dev/ if they thought they needed to include it # it's ok to essentialy rename with same name (to chg baud etc.), but not OK to rename to a name that is already # in use by another adapter # TODO collect not connected adapters as well to avoid dups if from_name != to_name and f"/dev/{to_name}" in local.adapters: return f"There is already an adapter using alias {to_name}" for _name in self.reserved_names: if to_name.startswith(_name): return f"You can't start the alias with {_name}. Matches system root device prefix" if ' ' in to_name or ':' in to_name or '(' in to_name or ')' in to_name: print( '\033[1;33m!!\033[0m Spaces, Colons and parentheses are not allowed by the associated config files.\n' '\033[1;33m!!\033[0m Swapping with valid characters\n') to_name = to_name.replace(' ', '_').replace('(', '_').replace( ')', '_') # not allowed in udev to_name = to_name.replace( ':', '-' ) # replace any colons with - as it's the field delim in ser2net except (KeyboardInterrupt, EOFError): return 'Rename Aborted based on User Input' c_to_name = f'{c["green"]}{to_name}{c["norm"]}' log_c_to_name = "".join(["{{green}}", to_name, "{{norm}}"]) go, con_only = True, False if from_name == to_name: log.show( f"Keeping {log_c_to_name}. Changing connection settings Only.") con_only = True use_def = False elif utils.user_input_bool(' Please Confirm Rename {} --> {}'.format( c_from_name, c_to_name)) is False: go = False if go: for i in local.adapters: if i == f'/dev/{from_name}': break _dev = local.adapters[i].get('config') # type: ignore # dict # -- these values are always safe, values set by config.py if not extracted from ser2net.conf baud = _dev['baud'] dbits = _dev['dbits'] flow = _dev['flow'] sbits = _dev['sbits'] parity = _dev['parity'] word = 'keep existing' for _name in self.reserved_names: if from_name.startswith(_name): word = 'Use default' # -- // Ask user if they want to update connection settings \\ -- if not con_only: use_def = utils.user_input_bool( ' {} connection values [{} {}{}1 Flow: {}]'.format( word, baud, dbits, parity.upper(), self.flow_pretty[flow])) if not use_def: self.con_menu(rename=True, con_dict={ 'baud': baud, 'data_bits': dbits, 'parity': parity, 'flow': flow, 'sbits': sbits }) baud = self.baud parity = self.parity dbits = self.data_bits parity = self.parity flow = self.flow sbits = self.sbits # restore defaults back to class attribute if we flipped them when we called con_menu # TODO believe this was an old hack, and can be removed if hasattr(self, 'con_dict') and self.con_dict: self.baud = self.con_dict['baud'] self.data_bits = self.con_dict['data_bits'] self.parity = self.con_dict['parity'] self.flow = self.con_dict['flow'] self.sbits = self.con_dict['sbits'] self.con_dict = None if word == 'Use default': # see above word is set if from_name matches a root_dev pfx devs = local.detect_adapters() if f'/dev/{from_name}' in devs: _tty = devs[f'/dev/{from_name}'] id_prod = _tty.get('id_model_id') id_model = _tty.get('id_model') # NoQA pylint: disable=unused-variable id_vendorid = _tty.get('id_vendor_id') id_vendor = _tty.get('id_vendor') # NoQA pylint: disable=unused-variable id_serial = _tty.get('id_serial_short') id_ifnum = _tty.get('id_ifnum') id_path = _tty.get('id_path') # NoQA lame_devpath = _tty.get('lame_devpath') root_dev = _tty.get('root_dev') else: return 'ERROR: Adapter no longer found' # -- // ADAPTERS WITH ALL ATTRIBUTES AND GPIO UART (TTYAMA) \\ -- if id_prod and id_serial and id_vendorid: if id_serial not in devs['_dup_ser']: udev_line = ( 'ATTRS{{idVendor}}=="{}", ATTRS{{idProduct}}=="{}", ' 'ATTRS{{serial}}=="{}", SYMLINK+="{}"'.format( id_vendorid, id_prod, id_serial, to_name)) error = None while not error: error = self.add_to_udev(udev_line, '# END BYSERIAL-DEVS') error = self.do_ser2net_line(from_name=from_name, to_name=to_name, baud=baud, dbits=dbits, parity=parity, flow=flow) break # -- // MULTI-PORT ADAPTERS WITH COMMON SERIAL (different ifnums) \\ -- else: # SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6011", ATTRS{serial}=="FT4XXXXP", GOTO="FTXXXXP" # NoQA udev_line = ( 'ATTRS{{idVendor}}=="{0}", ATTRS{{idProduct}}=="{1}", ' 'ATTRS{{serial}}=="{2}", GOTO="{2}"'.format( id_vendorid, id_prod, id_serial)) error = None while not error: error = self.add_to_udev(udev_line, '# END BYPORT-POINTERS') # ENV{ID_USB_INTERFACE_NUM}=="00", SYMLINK+="FT4232H_port1", GOTO="END" udev_line = ( 'ENV{{ID_USB_INTERFACE_NUM}}=="{}", SYMLINK+="{}"' .format(id_ifnum, to_name)) error = self.add_to_udev(udev_line, '# END BYPORT-DEVS', label=id_serial) error = self.do_ser2net_line(from_name=from_name, to_name=to_name, baud=baud, dbits=dbits, parity=parity, flow=flow) break else: if f'/dev/{from_name}' in devs: devname = devs[f'/dev/{from_name}'].get('devname', '') # -- // local ttyAMA adapters \\ -- if 'ttyAMA' in devname: udev_line = ('KERNEL=="{}", SYMLINK+="{}"'.format( devname.replace('/dev/', ''), to_name)) # Testing simplification not using separate file for ttyAMA error = None while not error: error = self.add_to_udev( udev_line, '# END TTYAMA-DEVS') error = self.do_ser2net_line( from_name=from_name, to_name=to_name, baud=baud, dbits=dbits, parity=parity, flow=flow) break else: # -- // LAME ADAPTERS NO SERIAL NUM (map usb port) \\ -- log.warning( '[ADD ADAPTER] Lame adapter missing key detail: idVendor={}, idProduct={}, serial#={}' .format( # NoQA id_vendorid, id_prod, id_serial)) print( '\n\n This Device Does not present a serial # (LAME!). So the adapter itself can\'t be ' 'uniquely identified.\n There are 2 options for naming this device:' ) mlines = [ '1. Map it to the USB port it\'s plugged in to' '\n\tAnytime a {} {} tty device is plugged into the port it\n\tis currently plugged into it will ' 'adopt the {} alias'.format( _tty['id_vendor_from_database'], _tty['id_model_from_database'], to_name), '2. Map it by vedor ({0}) and model ({1}) alone.' '\n\tThis will only work if this is the only {0} {1} adapter you plan to plug in' .format(_tty['id_vendor_from_database'], _tty['id_model_from_database']) # 'Temporary mapping' \ # '\n\tnaming will only persist during this menu session\n' ] print(self.menu.format_subhead(mlines)) print('\n b. back (abort rename)\n') valid_ch = {'1': 'by_path', '2': 'by_id'} valid = False ch = '' while not valid: print(' Please Select an option') ch = self.wait_for_input() if ch.lower == 'b': log.show( f'Rename {from_name} --> {to_name} Aborted' ) return elif ch.lower in valid_ch: valid = True else: print( 'invalid choice {} Try Again.'.format( ch.orig)) udev_line = None if valid_ch[ch.lower] == 'temp': error = True print( 'The Temporary rename feature is not yet implemented' ) elif valid_ch[ch.lower] == 'by_path': udev_line = ( 'ATTRS{{idVendor}}=="{0}", ATTRS{{idProduct}}=="{1}", GOTO="{0}_{1}"'.format( # NoQA id_vendorid, id_prod), 'ATTRS{{devpath}}=="{}", ENV{{ID_USB_INTERFACE_NUM}}=="{}", '\ 'SYMLINK+="{}"'.format(lame_devpath, id_ifnum, to_name), ) elif valid_ch[ch.lower] == 'by_id': udev_line = ( 'SUBSYSTEM=="tty", ATTRS{{idVendor}}=="{0}", ATTRS{{idProduct}}=="{1}", GOTO="{0}_{1}"' .format( # NoQA id_vendorid, id_prod), 'ENV{{ID_USB_INTERFACE_NUM}}=="{}", SYMLINK+="{}", GOTO="END"' .format(id_ifnum, to_name) # NoQA ) else: error = [ 'Unable to add udev rule adapter missing details', 'idVendor={}, idProduct={}, serial#={}'. format( # NoQA id_vendorid, id_prod, id_serial) ] while udev_line: error = self.add_to_udev( udev_line[0], '# END BYPATH-POINTERS') error = self.add_to_udev(udev_line[1], '# END BYPATH-DEVS', label='{}_{}'.format( id_vendorid, id_prod)) # NoQA error = self.do_ser2net_line( from_name=from_name, to_name=to_name, baud=baud, dbits=dbits, parity=parity, flow=flow) break else: log.error(f'Device {from_name} No Longer Found', show=True) # TODO simplify once ser2net existing verified else: # renaming previously named port. # -- // local ttyAMA adapters \\ -- devname = local.adapters[f'/dev/{from_name}']['udev'].get( 'devname', '') rules_file = self.rules_file if 'ttyAMA' not in devname else self.ttyama_rules_file cmd = 'sudo sed -i "s/{0}{3}/{1}{3}/g" {2} && grep -q "{1}{3}" {2} && [ $(grep -c "{0}{3}" {2}) -eq 0 ]'.format( from_name, to_name, rules_file, '') error = utils.do_shell_cmd(cmd, shell=True) if not error: error = self.do_ser2net_line(from_name=from_name, to_name=to_name, baud=baud, dbits=dbits, parity=parity, flow=flow) else: return [ error.split('\n'), 'Failed to change {} --> {} in {}'.format( from_name, to_name, self.ser2net_file) ] if not error: # Update adapter variables with new_name local.adapters[f'/dev/{to_name}'] = local.adapters[ f'/dev/{from_name}'] local.adapters[f'/dev/{to_name}']['config'][ 'port'] = config.ser2net_conf[f'/dev/{to_name}'].get( 'port', 0) local.adapters[f'/dev/{to_name}']['config'][ 'cmd'] = config.ser2net_conf[f'/dev/{to_name}'].get('cmd') local.adapters[f'/dev/{to_name}']['config'][ 'line'] = config.ser2net_conf[f'/dev/{to_name}'].get( 'line') local.adapters[f'/dev/{to_name}']['config'][ 'log'] = config.ser2net_conf[f'/dev/{to_name}'].get('log') local.adapters[f'/dev/{to_name}']['config'][ 'log_ptr'] = config.ser2net_conf[f'/dev/{to_name}'].get( 'log_ptr') _config_dict = local.adapters[f'/dev/{to_name}']['config'] if not use_def: # overwrite con settings if they were changed updates = { 'baud': baud, 'dbits': dbits, 'flow': flow, 'parity': parity, 'sbits': sbits, } local.adapters[f'/dev/{to_name}']['config'] = { **_config_dict, **updates } if from_name != to_name: # facilitates changing con settings without actually renaming del local.adapters[f'/dev/{from_name}'] self.udev_pending = True # toggle for exit function if they exit directly from rename memu # update first item in first section of menu_body menu uses it to determine if section is a continuation try: self.cur_menu.body_in[0][0] = self.cur_menu.body_in[0][ 0].replace(from_name, to_name) if self.menu.body_in is not None: # Can be none when called via rename directly self.menu.body_in[0][0] = self.menu.body_in[0][ 0].replace(from_name, to_name) except Exception as e: log.exception( f"[DEV NOTE menu_body update after rename caused exception.\n{e}", show=False) else: return 'Aborted based on user input'
def get_outlets_from_file(self): '''Get outlets defined in config returns: dict: with following keys (all values are dicts) linked: linked outlets from config (linked to serial adapters- auto pwr-on) dli_power: dict any dlis in config have all ports represented here failures: failure to connect to any outlets will result in an entry here outlet_name: failure description ''' outlet_data = self.cfg_yml.get('POWER') if not outlet_data: # fallback to legacy json config outlet_data = self.get_json_file(self.static.get('POWER_FILE')) if not outlet_data: if self.power: log.show('Power Function Disabled - Configuration Not Found') self.power = False self.outlet_types = [] return outlet_data types = [] by_dev: Dict[str, Any] = {} for k in outlet_data: _type = outlet_data[k].get('type').lower() relays = [] if _type != "esphome" else utils.listify( outlet_data[k].get('relays', k)) linked = outlet_data[k].get('linked_devs', {}) if linked: outlet_data[k]['linked_devs'] = utils.format_dev( outlet_data[k]['linked_devs'], hosts=self.hosts, with_path=True) self.linked_exists = True for dev in outlet_data[k]['linked_devs']: if _type == 'dli': self.do_dli_menu = True _this = [ f"{k}:{[int(p) for p in utils.listify(outlet_data[k]['linked_devs'][dev])]}" ] elif _type == 'esphome': _linked = utils.listify( outlet_data[k]['linked_devs'][dev]) _this = [f'{k}:{[p for p in _linked]}'] else: _this = [k] by_dev[dev] = _this if dev not in by_dev else by_dev[ dev] + _this else: outlet_data[k]['linked_devs'] = {} if outlet_data[k]["type"].lower() not in types: types.append(outlet_data[k]["type"].lower()) if outlet_data[k]['type'].upper() == 'GPIO' and isinstance(outlet_data[k].get('address'), str) \ and outlet_data[k]['address'].isdigit(): outlet_data[k]['address'] = int(outlet_data[k]['address']) # This block determines if we should show dli_menu / if any esphome outlets match criteria to show # in dli menu (anytime it has exactly 8 outlets, if it has > 1 relay and not all are linked) if not self.do_dli_menu and _type == "esphome" and len(relays) > 1: if len(relays) == 8 or not linked: self.do_dli_menu = True elif [r for r in relays if f"'{r}'" not in str(linked)]: self.do_dli_menu = True self.outlet_types = types outlet_data = { 'defined': outlet_data, 'linked': by_dev, 'dli_power': {}, 'esp_power': {}, 'failures': {} } return outlet_data