Example #1
0
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)
Example #2
0
    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'
            )
Example #3
0
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)
Example #4
0
    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)
Example #5
0
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)
Example #6
0
    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)
Example #7
0
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
        }
Example #8
0
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