File indexing completed on 2024-04-14 05:19:07

0001 # Copyright 2021 Aditya Mehra <aix.m@outlook.com>
0002 # Copyright 2017 Mycroft AI Inc.
0003 #
0004 # Licensed under the Apache License, Version 2.0 (the "License");
0005 # you may not use this file except in compliance with the License.
0006 # You may obtain a copy of the License at
0007 #
0008 #    http://www.apache.org/licenses/LICENSE-2.0
0009 #
0010 # Unless required by applicable law or agreed to in writing, software
0011 # distributed under the License is distributed on an "AS IS" BASIS,
0012 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
0013 # See the License for the specific language governing permissions and
0014 # limitations under the License.
0015 #
0016 
0017 import time
0018 from requests import HTTPError
0019 from threading import Timer, Lock
0020 from uuid import uuid4
0021 
0022 from adapt.intent import IntentBuilder
0023 
0024 import mycroft.audio
0025 from mycroft.api import DeviceApi, is_paired, check_remote_pairing
0026 from mycroft.identity import IdentityManager
0027 from mycroft.messagebus.message import Message
0028 from mycroft.skills.core import MycroftSkill, intent_handler
0029 
0030 
0031 ACTION_BUTTON_PLATFORMS = ('mycroft_mark_1', 'mycroft_mark_2', 'bigscreen')
0032 MAX_PAIRING_CODE_RETRIES = 30
0033 ACTIVATION_POLL_FREQUENCY = 10  # secs between checking server for activation
0034 
0035 
0036 def _stop_speaking():
0037     """Stop speaking the pairing code if it is still being spoken."""
0038     if mycroft.audio.is_speaking():
0039         mycroft.audio.stop_speaking()
0040 
0041 
0042 class BigscreenSetupSkill(MycroftSkill):
0043     """Device pairing logic."""
0044     def __init__(self):
0045         super(BigscreenSetupSkill, self).__init__("BigscreenSetupSkill")
0046         self.api = DeviceApi()
0047         self.pairing_token = None
0048         self.pairing_code = None
0049         self.pairing_code_expiration = None
0050         self.state = str(uuid4())
0051         self.platform = None
0052         self.nato_alphabet = None
0053         self.mycroft_ready = False
0054         self.pairing_code_retry_cnt = 0
0055         self.account_creation_requested = False
0056 
0057         # These attributes track the status of the device activation
0058         self.device_activation_lock = Lock()
0059         self.device_activation_checker = None
0060         self.device_activation_cancelled = False
0061         self.activation_attempt_count = 0
0062 
0063         # These attributes are used when tracking the ready state to control
0064         # when the paired dialog is spoken.
0065         self.paired_dialog_lock = Lock()
0066         self.paired_dialog = None
0067         self.pairing_performed = False
0068 
0069         # These attributes are used when determining if pairing has started.
0070         self.pairing_status_lock = Lock()
0071         self.pairing_in_progress = False
0072 
0073     def initialize(self):
0074         """Register event handlers, setup language and platform dependent info."""
0075         self.add_event("mycroft.not.paired", self.not_paired)
0076         self.gui.register_handler("mycroft.device.set.first.config",
0077                                   self.handle_config_selected_event)
0078         self.gui.register_handler("mycroft.device.speak.first.config",
0079                                   self.speak_setup_menu)
0080         self.nato_alphabet = self.translate_namedvalues('codes')
0081         # TODO replace self.platform logic with call to enclosure capabilities
0082         self.platform = self.config_core['enclosure'].get(
0083             'platform', 'unknown'
0084         )
0085         self._select_paired_dialog()
0086 
0087         # If the device isn't paired catch mycroft.ready to report
0088         # that the device is ready for use.
0089         # This assumes that the pairing skill is loaded as a priority skill
0090         # before the rest of the skills are loaded.
0091         if not is_paired():
0092             self.add_event("mycroft.ready", self.handle_mycroft_ready)
0093 
0094     def _select_paired_dialog(self):
0095         """Select the correct dialog file to communicate pairing complete."""
0096         if self.platform in ACTION_BUTTON_PLATFORMS:
0097             self.paired_dialog = 'pairing.paired'
0098         else:
0099             self.paired_dialog = 'pairing.paired.no.button'
0100 
0101     def handle_mycroft_ready(self, _):
0102         """Catch info that skills are loaded and ready."""
0103         with self.paired_dialog_lock:
0104             if is_paired() and self.pairing_performed:
0105                 self.speak_dialog(self.paired_dialog)
0106             else:
0107                 self.mycroft_ready = True
0108 
0109     def not_paired(self, message):
0110         """When not paired, tell the user so and start pairing."""
0111         if not message.data.get('quiet', True):
0112             self.speak_dialog("pairing.not.paired")
0113         self.handle_pairing()
0114 
0115     @intent_handler(IntentBuilder("PairingIntent")
0116                     .require("PairingKeyword").require("DeviceKeyword"))
0117     def handle_pairing(self, message=None):
0118         """Attempt to pair the device to the Selene database."""
0119         already_paired = check_remote_pairing(ignore_errors=True)
0120         if already_paired:
0121             self.speak_dialog("already.paired")
0122             self.log.info(
0123                 "Pairing skill invoked but device is paired, exiting"
0124             )
0125         elif self.pairing_code is None:
0126             start_pairing = self._check_pairing_in_progress()
0127             if start_pairing:
0128                 self.reload_skill = False  # Prevent restart during pairing
0129                 self.enclosure.deactivate_mouth_events()
0130                 self.handle_setup_menu()
0131                 #self._communicate_create_account_url()
0132                 #self._execute_pairing_sequence()
0133 
0134     # Pairing GUI events
0135     # Backend selection menu
0136     def handle_setup_menu(self):
0137         self.gui['firstConfigDialog'] = 1
0138         self.handle_display_manager("ConfigIntro")
0139 
0140     def speak_setup_menu(self, message):
0141         self.speak_dialog("select_mycroft_config")
0142 
0143     def handle_config_selected_event(self, message):
0144         self.bus.emit(Message("mycroft.audio.speech.stop"))
0145         time.sleep(2)
0146         self.handle_config_confirmation(message.data["config"])
0147 
0148     def handle_display_manager(self, state):
0149         self.gui["state"] = state
0150         self.gui.show_page(
0151             "ProcessLoader.qml",
0152             override_idle=True,
0153             override_animations=True)
0154 
0155     # Config confirmation
0156     def handle_config_confirmation(self, selection):
0157         if selection == "pttwakeword":
0158             self.gui["configurePtt"] = True
0159 
0160         elif selection == "wakeword":
0161             self.gui["configurePtt"] = False
0162 
0163         self._execute_pairing_sequence()
0164 
0165     def _check_pairing_in_progress(self):
0166         """Determine if skill was invoked while pairing is in progress."""
0167         with self.pairing_status_lock:
0168             if self.pairing_in_progress:
0169                 self.log.debug(
0170                     "Pairing in progress; ignoring call to handle_pairing"
0171                 )
0172                 start_pairing = False
0173             else:
0174                 self.pairing_in_progress = True
0175                 start_pairing = True
0176 
0177         return start_pairing
0178 
0179     def _communicate_create_account_url(self):
0180         """Tell the user the URL for creating an account and display it.
0181         This should only happen once per pairing sequence.  If pairing is
0182         restarted due to an error, this will be skipped.
0183         """
0184         if not self.account_creation_requested:
0185             self.log.info("Communicating account URL to user")
0186             self.account_creation_requested = True
0187             if self.gui.connected:
0188                 self.gui.show_page("create_account.qml", override_idle=True)
0189             else:
0190                 self.enclosure.mouth_text("account.mycroft.ai      ")
0191             self.speak_dialog("create.account")
0192             mycroft.audio.wait_while_speaking()
0193             time.sleep(30)
0194 
0195     def _execute_pairing_sequence(self):
0196         """Interact with the user to pair the device."""
0197         self.log.info("Initiating device pairing sequence...")
0198         self._get_pairing_data()
0199         if self.pairing_code is not None:
0200             self._communicate_pairing_url()
0201             self._display_pairing_code()
0202             self._speak_pairing_code()
0203             self._attempt_activation()
0204 
0205     def _get_pairing_data(self):
0206         """Obtain a pairing code and access token from the Selene API
0207         A pairing code is good for 24 hours so set an expiration time in case
0208         pairing does not complete.  If the call to the API fails, retry for
0209         five minutes.  If the API call does not succeed after five minutes
0210         abort the pairing process.
0211         """
0212         self.log.info('Retrieving pairing code from device API...')
0213         try:
0214             pairing_data = self.api.get_code(self.state)
0215             self.pairing_code = pairing_data['code']
0216             self.pairing_token = pairing_data['token']
0217             self.pairing_code_expiration = (
0218                     time.monotonic()
0219                     + pairing_data['expiration']
0220             )
0221         except Exception:
0222             self.log.exception("API call to retrieve pairing data failed")
0223             self._handle_pairing_data_retrieval_error()
0224         else:
0225             self.log.info('Pairing code obtained: ' + self.pairing_code)
0226             self.pairing_code_retry_cnt = 0  # Reset counter on success
0227 
0228     def _handle_pairing_data_retrieval_error(self):
0229         """Retry retrieving pairing code for five minutes, then abort."""
0230         if self.pairing_code_retry_cnt < MAX_PAIRING_CODE_RETRIES:
0231             time.sleep(10)
0232             self.pairing_code_retry_cnt += 1
0233             self._restart_pairing(quiet=True)
0234         else:
0235             self._end_pairing('connection.error')
0236             self.pairing_code_retry_cnt = 0
0237 
0238     def _communicate_pairing_url(self):
0239         """Tell the user the URL for pairing and display it, if possible"""
0240         self.log.info("Communicating pairing URL to user")
0241         if self.gui.connected:
0242             self.gui.show_page("pairing_start.qml", override_idle=True)
0243         else:
0244             self.enclosure.mouth_text("mycroft.ai/pair      ")
0245         self.speak_dialog("pairing.intro")
0246         mycroft.audio.wait_while_speaking()
0247         time.sleep(5)
0248 
0249     def _display_pairing_code(self):
0250         """Show the pairing code on the display, if one is available"""
0251         if self.gui.connected:
0252             self.gui['code'] = self.pairing_code
0253             self.gui.show_page("pairing.qml", override_idle=True)
0254         else:
0255             self.enclosure.mouth_text(self.pairing_code)
0256 
0257     def _attempt_activation(self):
0258         """Speak the pairing code if two """
0259         with self.device_activation_lock:
0260             if not self.device_activation_cancelled:
0261                 self._check_speak_code_interval()
0262                 self._start_device_activation_checker()
0263 
0264     def _check_speak_code_interval(self):
0265         """Only speak pairing code every two minutes."""
0266         self.activation_attempt_count += 1
0267         if not self.activation_attempt_count % 12:
0268             self._speak_pairing_code()
0269 
0270     def _speak_pairing_code(self):
0271         """Speak pairing code."""
0272         self.log.debug("Speaking pairing code")
0273         pairing_code_utterance = map(self.nato_alphabet.get, self.pairing_code)
0274         speak_data = dict(code='. '.join(pairing_code_utterance) + '.')
0275         self.speak_dialog("pairing.code", speak_data)
0276 
0277     def _start_device_activation_checker(self):
0278         """Set a timer to check the activation status in ten seconds."""
0279         self.device_activation_checker = Timer(
0280             ACTIVATION_POLL_FREQUENCY, self.check_for_device_activation
0281         )
0282         self.device_activation_checker.daemon = True
0283         self.device_activation_checker.start()
0284 
0285     def check_for_device_activation(self):
0286         """Call the device API to determine if user completed activation.
0287         Called every 10 seconds by a Timer. Checks if user has activated the
0288         device on account.mycroft.ai.  Activation is considered successful when
0289         the API call returns without error. When the API call throws an
0290         HTTPError, the assumption is that the uer has not yet completed
0291         activation.
0292         """
0293         self.log.debug('Checking for device activation')
0294         try:
0295             login = self.api.activate(self.state, self.pairing_token)
0296         except HTTPError:
0297             self._handle_not_yet_activated()
0298         except Exception:
0299             self.log.exception("An unexpected error occurred.")
0300             self._restart_pairing()
0301         else:
0302             self._handle_activation(login)
0303 
0304     def _handle_not_yet_activated(self):
0305         """Activation has not been completed, determine what to do next.
0306         The pairing code expires after 24 hours. Restart pairing if expired.
0307         If the pairing code is still valid, speak the pairing code if the
0308         appropriate amount of time has elapsed since last spoken and restart
0309         the device activation checking timer.
0310         """
0311         if time.monotonic() > self.pairing_code_expiration:
0312             self._reset_pairing_attributes()
0313             self.handle_pairing()
0314         else:
0315             self._attempt_activation()
0316 
0317     def _handle_activation(self, login):
0318         """Steps to take after successful device activation."""
0319         self._save_identity(login)
0320         _stop_speaking()
0321         self._display_pairing_success()
0322         self.bus.emit(Message("mycroft.paired", login))
0323         self.pairing_performed = True
0324         self._speak_pairing_success()
0325         self.bus.emit(Message("configuration.updated"))
0326         self.reload_skill = True
0327 
0328     def _save_identity(self, login):
0329         """Save this device's identifying information to disk.
0330         The user has successfully paired the device on account.mycroft.ai.
0331         The UUID and access token of the device can now be saved to the
0332         local identity file.  If saving the identity file fails twice,
0333         something went very wrong and the pairing process will restart.
0334         """
0335         save_attempts = 1
0336         while save_attempts < 2:
0337             try:
0338                 IdentityManager.save(login)
0339             except Exception:
0340                 if save_attempts == 1:
0341                     save_attempts += 1
0342                     log_msg = "First attempt to save identity file failed."
0343                     self.log.exception(log_msg)
0344                     time.sleep(2)
0345                 else:
0346                     log_msg = (
0347                         "Second attempt to save identity file failed. "
0348                         "Restarting the pairing sequence..."
0349                     )
0350                     self.log.exception(log_msg)
0351                     self._restart_pairing()
0352             else:
0353                 self.log.info('Identity file saved.')
0354                 break
0355 
0356     def _display_pairing_success(self):
0357         """Display a pairing complete screen on GUI or clear Arduino"""
0358         if self.gui.connected:
0359             self.gui.show_page("pairing_done.qml", override_idle=False)
0360         else:
0361             self.enclosure.activate_mouth_events()  # clears the display
0362 
0363     def _speak_pairing_success(self):
0364         """Tell the user the device is paired.
0365         If the device is not ready for use, also tell the user to wait until
0366         the device is ready.
0367         """
0368         with self.paired_dialog_lock:
0369             if self.mycroft_ready:
0370                 self.speak_dialog(self.paired_dialog)
0371                 mycroft.audio.wait_while_speaking()
0372             else:
0373                 self.speak_dialog("wait.for.startup")
0374                 mycroft.audio.wait_while_speaking()
0375 
0376     def _end_pairing(self, error_dialog):
0377         """Resets the pairing and don't restart it.
0378         Arguments:
0379             error_dialog: Reason for the ending of the pairing process.
0380         """
0381         self.speak_dialog(error_dialog)
0382         self.bus.emit(Message("mycroft.mic.unmute", None))
0383         self._reset_pairing_attributes()
0384 
0385     def _restart_pairing(self, quiet=False):
0386         """Resets the pairing and don't restart it.
0387         Arguments:
0388             quiet: indicates if an error message should be spoken to the user
0389         """
0390         self.log.info("Aborting pairing process and restarting...")
0391         self.enclosure.activate_mouth_events()
0392         if not quiet:
0393             self.speak_dialog("unexpected.error.restarting")
0394         self._reset_pairing_attributes()
0395         self.bus.emit(Message("mycroft.not.paired", data=dict(quiet=quiet)))
0396 
0397     def _reset_pairing_attributes(self):
0398         """Reset attributes that need to be in a certain state for pairing."""
0399         with self.pairing_status_lock:
0400             self.pairing_in_progress = False
0401         with self.device_activation_lock:
0402             self.activation_attempt_count = 0
0403         self.device_activation_checker = None
0404         self.pairing_code = None
0405         self.pairing_token = None
0406 
0407     def shutdown(self):
0408         """Skill process termination steps."""
0409         with self.device_activation_lock:
0410             self.device_activation_cancelled = True
0411             if self.device_activation_checker:
0412                 self.device_activation_checker.cancel()
0413         if self.device_activation_checker:
0414             self.device_activation_checker.join()
0415 
0416 
0417 def create_skill():
0418     """Entrypoint for skill process to load the skill."""
0419     return BigscreenSetupSkill()