getSettings(); // always build the OAuth redirect URL $dac_oauth_spawn_url = $this->buildOAuthURL(); // check if the application was authorised through OAuth $oauth_authorised = !empty($settings['_oauth']['access_token']); // build OAuth2 authorization link if (empty($settings['oauth2_api_key']) || empty($settings['oauth2_api_secret'])) { // settings must be saved first $oauth2_auth_link = 'Save settings first. OAuth2 API Key and API Secret cannot be empty.'; // convert the redirect URI to a text $dac_oauth_spawn_url = 'Save settings first.'; } else { // build button-link for the authorization code URL $nuki_auth_data = [ 'response_type' => 'code', 'client_id' => $settings['oauth2_api_key'], 'scope' => $this->authScopes, 'state' => $this->getOAuthCode(), 'redirect_uri' => $dac_oauth_spawn_url, ]; // build final Nuki authorization URL to be clicked $nuki_auth_link = 'https://api.nuki.io/oauth/authorize?' . http_build_query($nuki_auth_data); // set button content $oauth2_auth_link = '' . ($oauth_authorised ? '(Re-)' : '') . 'Authorize Application'; } // build HTML instructions for obtaining the OAuth2 information $help_url_webhook = $this->buildWebhookURL(); $oauth_instructions_html = <<Visit your Nuki Web account (section API - OAuth2) to enable and obtain the OAuth2 API Key and OAuth2 API Secret. Then enter the following OAuth2 redirect URL:
From the Nuki Advanced API Integration section, enther the following Webhook URL:
HTML; if (!$oauth_authorised) { // add to the instructions that the application must be authorised $oauth_instructions_html .= <<Proceed by clicking the authorization link below after saving your OAuth settings. HTML; } if (!$oauth_authorised) { // never obtained an access token through OAuth before $oauth2_auth_status = 'Not Authorized'; } else { // access token was once obtained through OAuth $oauth2_auth_status = 'Authorized'; } // return the list of parameters return [ 'ai' => [ 'type' => 'checkbox', 'label' => JText::translate('VBO_AI_SUPPORT'), 'help' => JText::translate('VBO_DAC_AI_SUPPORT_HELP'), 'default' => 1, ], 'passquant' => [ 'type' => 'select', 'label' => JText::translate('VBO_PASSCODES'), 'help' => JText::translate('VBO_PASSCODES_QUANT_HELP'), 'options' => [ 1 => JText::translate('VBO_ONE_PER_DEVICE'), 2 => JText::translate('VBO_ONE_PER_BOOKING'), ], 'default' => 1, ], 'authmeth' => [ 'type' => 'select', 'label' => 'Authentication Method', 'help' => 'Choose the authentication type configured in your Nuki Web account.', 'options' => [ 'oauth' => 'OAuth2', 'api_token' => 'API Token', ], 'default' => 'oauth', ], 'api_token' => [ 'type' => 'password', 'label' => 'API Token', 'conditional' => 'authmeth:api_token', ], '_oauth2_auth_help' => [ 'type' => 'custom', // 'label' => 'Instructions', 'html' => $oauth_instructions_html, 'conditional' => 'authmeth:oauth', ], '_oauth2_auth_status' => [ 'type' => 'custom', 'label' => 'Authorization Status', 'html' => $oauth2_auth_status, 'conditional' => 'authmeth:oauth', ], '_oauth2_auth_link' => [ 'type' => 'custom', 'label' => 'Authorization Link', 'html' => $oauth2_auth_link, 'conditional' => 'authmeth:oauth', ], 'oauth2_api_key' => [ 'type' => 'text', 'label' => 'OAuth2 API Key (Client ID)', 'help' => (empty($settings['oauth2_api_key']) ? 'Save settings to see the authorization link. ' : '') . 'OAuth2 API Key from Nuki Web > Menu > API.', 'conditional' => 'authmeth:oauth', ], 'oauth2_api_secret' => [ 'type' => 'password', 'label' => 'OAuth2 API Secret (Client Secret)', 'help' => (empty($settings['oauth2_api_key']) ? 'Save settings to see the authorization link. ' : '') . 'OAuth2 API Secret from Nuki Web > Menu > API.', 'conditional' => 'authmeth:oauth', ], ]; } /** * @inheritDoc */ public function spawnOAuthCallback(?array $data = null) { $app = JFactory::getApplication(); // gather request or data variables to obtain the authorization code for the application $auth_code = ($data['code'] ?? '') ?: $app->input->getString('code', ''); $auth_state = ($data['state'] ?? '') ?: $app->input->getString('state', ''); if (!empty($auth_code) && empty($auth_state)) { // missing CSRF proof token from Nuki throw new Exception(JText::translate('JINVALID_TOKEN'), 403); } // perform the OAuth token internal validation that relies on the database for CSRF prevention if ($auth_state != $this->getOAuthCode()) { throw new Exception(JText::translate('JINVALID_TOKEN'), 403); } // load current settings $settings = $this->getSettings(); if (!empty($auth_code)) { /** * Application was authorized and an authorization code was received. * Perform a request to exchange the auth code and obtain the access token. */ // start transporter (by setting the necessary headers) $transporter = $this->createHTTPTransporter([ 'doing_oauth' => 1, 'headers' => [ 'Content-Type' => 'application/x-www-form-urlencoded', ], ]); // build request data, inclusive of redirect URI (must match the one for the initial authorization) $requestData = [ 'client_id' => ($settings['oauth2_api_key'] ?? ''), 'client_secret' => ($settings['oauth2_api_secret'] ?? ''), 'grant_type' => 'authorization_code', 'code' => $auth_code, 'redirect_uri' => $this->buildOAuthURL(), ]; // make the API request (POST with query string values in endpoint URL) $response = $transporter->post('https://api.nuki.io/oauth/token', http_build_query($requestData), $this->httpHeaders, 60); // obtain the response data (response should be empty in case of success) $responseData = (array) json_decode((string) $response->body, true); if (empty($response->code) || $response->code > 299) { // an error occurred throw new Exception($response->body ?: 'Error exchanging the auth code for the OAuth access token.', ($response->code ?: 500)); } if (empty($responseData['access_token']) || empty($responseData['refresh_token'])) { // unexpected response format throw new Exception('Unexpected response format: missing access token or refresh token.', 500); } // calculate and set the token expiration timestamp (3600 seconds) $responseData['expiry_ts'] = strtotime(sprintf('+%d seconds', (int) ($responseData['expires_in'] ?? 0))); // inject OAuth details within the current integration settings $settings['_oauth'] = $responseData; // update integration record settings $this->setProfileRecordProp('settings', $settings); // store integration record settings VBODooraccessFactory::getInstance()->saveIntegrationRecord($this, ['settings' => $this->getSettings()]); // application was successfully authorised, close the response // redirect to VikBooking admin-widget $redirectData = [ 'option' => 'com_vikbooking', 'load_widget' => 'door_access_control', 'multitask_data' => [ 'provider' => $this->getProfileProvider(), 'profile' => $this->getProfileID(), 'tab' => 'settings', ], ]; $redirectUri = VBOFactory::getPlatform()->getUri()->admin('index.php?' . http_build_query($redirectData)); $app->redirect($redirectUri); $app->close(); } // close the response with a status code 204 (No Content) VBOHttpDocument::getInstance($app)->close(204, 'No data to authorise the application.'); } /** * @inheritDoc */ public function spawnWebhookCallback(?array $data = null) { $app = JFactory::getApplication(); // load current settings $settings = $this->getSettings(); // access signing key (client secret) $signingKey = $settings['oauth2_api_secret'] ?? ''; // access raw body payload $rawPayload = file_get_contents('php://input'); if (empty($rawPayload)) { // raise an error VBOHttpDocument::getInstance($app)->close(400, 'Missing webhook payload.'); } // access the signature value from headers $signatureHeader = $app->input->server->get('HTTP_X_NUKI_SIGNATURE_SHA256', ''); if (empty($signatureHeader)) { // raise an error VBOHttpDocument::getInstance($app)->close(400, 'Missing webhook signature header.'); } // generate the HMAC SHA256 signature $computedSignature = hash_hmac('sha256', $rawPayload, $signingKey); // validate signature if ($computedSignature != $signatureHeader && !hash_equals($computedSignature, $signatureHeader)) { // raise an error VBOHttpDocument::getInstance($app)->close(401, 'Invalid webhook signature value.'); } // access webhook data $webhookData = $app->input->json->getArray(); // determine the webhook notification type $webhookType = strtoupper((string) ($webhookData['feature'] ?? '')); // process the request, if known and needed try { switch ($webhookType) { case 'DEVICE_STATUS': // handle device status update webhook notification $this->webhookHandleDeviceStatus($webhookData); break; case 'DEVICE_LOGS': // handle device log webhook notification $this->webhookHandleDeviceLogs($webhookData); break; default: // do nothing break; } } catch (Exception $e) { // propagate the "error" (could also be 200) by closing the response VBOHttpDocument::getInstance($app)->close($e->getCode() ?: 500, $e->getMessage()); } // close the response with a 200 status code VBOHttpDocument::getInstance($app)->close(200, 'Webhook data successfully received.'); } /** * @inheritDoc */ public function canUnlockDevices() { // this method is called when the integration has loaded its profile record // we return true only if the apposite AI setting is enabled $settings = $this->getSettings(); return !empty($settings['ai']); } /** * Device capability implementation to unlock a device. * * @param VBODooraccessIntegrationDevice $device The device executing the capability. * @param ?array $options Optional settings populated from capability parameters. * * @return VBODooraccessDeviceCapabilityResult * * @link https://api.nuki.io/#/Smartlock/SmartlockUnlockActionResource_postUnlock_post */ public function unlockDevice(VBODooraccessIntegrationDevice $device, ?array $options = null) { // start transporter $transporter = $this->createHTTPTransporter(); // make the API request $response = $transporter->post('https://api.nuki.io/smartlock/' . $this->getDecimalDeviceID($device) . '/action/unlock', [], $this->httpHeaders, 60); // obtain the response data (response should be empty in case of success) $responseData = (array) json_decode((string) $response->body, true); if (empty($response->code) || $response->code > 299) { // an error occurred throw new Exception($response->body ?: 'Error unlocking the device.', ($response->code ?: 500)); } return (new VBODooraccessDeviceCapabilityResult)->setText(sprintf('The device "%s" was unlocked!', $device->getName())); } /** * Device capability implementation to lock a device. * * @param VBODooraccessIntegrationDevice $device The device executing the capability. * @param ?array $options Optional settings populated from capability parameters. * * @return VBODooraccessDeviceCapabilityResult * * @link https://api.nuki.io/#/Smartlock/SmartlockLockActionResource_postLock_post */ public function lockDevice(VBODooraccessIntegrationDevice $device, ?array $options = null) { // start transporter $transporter = $this->createHTTPTransporter(); // make the API request $response = $transporter->post('https://api.nuki.io/smartlock/' . $this->getDecimalDeviceID($device) . '/action/lock', [], $this->httpHeaders, 60); // obtain the response data (response should be empty in case of success) $responseData = (array) json_decode((string) $response->body, true); if (empty($response->code) || $response->code > 299) { // an error occurred throw new Exception($response->body ?: 'Error locking the device.', ($response->code ?: 500)); } return (new VBODooraccessDeviceCapabilityResult)->setText(sprintf('The device "%s" was locked!', $device->getName())); } /** * Device capability implementation to list a device authorization codes. * * @param VBODooraccessIntegrationDevice $device The device executing the capability. * @param ?array $options Optional settings populated from capability parameters. * * @return VBODooraccessDeviceCapabilityResult * * @throws Exception * * @link https://api.nuki.io/#/SmartlockAuth/SmartlocksAuthsResource_get_get */ public function listPasscodes(VBODooraccessIntegrationDevice $device, ?array $options = null) { // start transporter $transporter = $this->createHTTPTransporter(); // check if a specific passcode name should be matched from the API response (filter not supported) $searchPasscode = $options['search'] ?? null; // build request data $data = [ // type option could be "any", but we only accept integers, to be casted to string // as the query-string filter accept a list of comma-separated values (i.e. "0,2,13") 'types' => is_numeric($options['type'] ?? null) ? (string) $options['type'] : null, ]; // make a request to obtain all created passcodes of a lock $response = $transporter->get('https://api.nuki.io/smartlock/' . $this->getDecimalDeviceID($device) . '/auth?' . http_build_query(array_filter($data, function($value) { // allow to filter by type "0" which equals to "App" return !empty($value) || $value === '0'; })), $this->httpHeaders, 20); // obtain the response data $responseData = (array) json_decode((string) $response->body, true); if (empty($response->code) || $response->code > 299) { // an error occurred throw new Exception($response->body ?: 'Error fetching device passcodes.', ($response->code ?: 500)); } // returned passcodes list expected $passcodes = $responseData; if ($searchPasscode && is_array($passcodes)) { // filter passcodes found by the given name $passcodes = array_filter($passcodes, function($authPasscode) use ($searchPasscode) { // passcode name must match the given one as we are doing an exact search return ($authPasscode['name'] ?? '') === $searchPasscode; }); } if (!$passcodes) { throw new Exception('No authorization codes found for the device.', 404); } // list of passcode IDs and values obtained $passcodesAssoc = []; // build HTML output $output = ''; // lang defs $lang_passcode = JText::translate('VBO_PASSCODE'); $lang_startdate = JText::translate('VBNEWPKGDFROM'); $lang_enddate = JText::translate('VBNEWPKGDTO'); $lang_createdon = JText::translate('VBOINVCREATIONDATE'); $lang_createdby = JText::translate('VBCSVCREATEDBY'); // table head $output .= << HTML; // scan all passcodes obtained foreach ($passcodes as $passcode) { // set passcode properties $passcodeId = $passcode['id'] ?? ''; $passcodeValue = $passcode['code'] ?? ''; $passcodeName = $passcode['name'] ?? ''; $passcodeType = $this->getPasscodeTypes((int) ($passcode['type'] ?? 0), true); $startDate = ($passcode['allowedFromDate'] ?? '') ? JHtml::fetch('date', $passcode['allowedFromDate'], 'Y-m-d H:i:s') : '---'; $endDate = ($passcode['allowedUntilDate'] ?? '') ? JHtml::fetch('date', $passcode['allowedUntilDate'], 'Y-m-d H:i:s') : '---'; $sendDate = ($passcode['creationDate'] ?? '') ? JHtml::fetch('date', $passcode['creationDate'], 'Y-m-d H:i:s') : '---'; $senderUsername = $passcode['accountUserId'] ?? ''; // bind passcode id values $passcodesAssoc[$passcodeId] = [ 'name' => $passcodeName, 'value' => $passcodeValue, ]; // build passcode HTML code $output .= << HTML; } // close table $output .= <<
Passcode ID Passcode Name {$lang_passcode} Passcode Type {$lang_startdate} {$lang_enddate} {$lang_createdon} {$lang_createdby}
{$passcodeId} {$passcodeName} {$passcodeValue} {$passcodeType} {$startDate} {$endDate} {$sendDate} {$senderUsername}
HTML; // return the capability result object by setting the output value return (new VBODooraccessDeviceCapabilityResult($passcodesAssoc)) ->setOutput($output); } /** * Device capability implementation to create a custom passcode for a device. * * @param VBODooraccessIntegrationDevice $device The device executing the capability. * @param ?array $options Optional settings populated from capability parameters. * * @return VBODooraccessDeviceCapabilityResult * * @link https://api.nuki.io/#/SmartlockAuth/SmartlockAuthsResource_put_put */ public function createCustomPasscode(VBODooraccessIntegrationDevice $device, ?array $options = null) { // start transporter (by setting the Content-Type header) $transporter = $this->createHTTPTransporter([ 'headers' => [ 'Content-Type' => 'application/json', ], ]); if (empty($options['pwdvalue'])) { // passcode value cannot be empty $options['pwdvalue'] = $this->generateRandomPasscode(); } // build request data $data = [ 'name' => $options['pwdname'] ?? null, 'allowedFromDate' => (($options['startdate'] ?? '') ? $this->getDateRFC3339($options['startdate']) : null), 'allowedUntilDate' => (($options['enddate'] ?? '') ? $this->getDateRFC3339($options['enddate']) : null), 'allowedWeekDays' => 127, 'allowedFromTime' => 0, 'allowedUntilTime' => 0, 'remoteAllowed' => true, 'type' => 13, 'code' => (int) $options['pwdvalue'], ]; // make the API request $response = $transporter->put('https://api.nuki.io/smartlock/' . $this->getDecimalDeviceID($device) . '/auth', json_encode($data), $this->httpHeaders, 60); // obtain the response data (should be empty in case of success) $responseData = (array) json_decode((string) $response->body, true); if (empty($response->code) || $response->code > 299) { // an error occurred, build DAC Exception with retry-data $dacError = (new VBODooraccessException($response->body ?: 'Error adding a custom passcode to the device.', ($response->code ?: 500))) ->setDevice($device) ->setRetryCallback('createCustomPasscode') ->setRetryData($options); // throw error throw $dacError; } // build result properties to bind (operation is asynchronous, so we don't immediately get an authorization ID for the new access code) $resultProps = [ 'code' => (string) $options['pwdvalue'], 'name' => (string) ($options['pwdname'] ?? ''), 'listingId' => (int) ($options['listing_id'] ?? 0), ]; // get the listing name, if available $listingName = ''; if (!empty($resultProps['listingId'])) { $listingData = VikBooking::getRoomInfo($resultProps['listingId'], ['name'], true); $listingName = sprintf('%s: ', $listingData['name'] ?? ''); } // wrap and return the device capability result object return (new VBODooraccessDeviceCapabilityResult($resultProps)) ->setPasscode($resultProps['code']) ->setText($listingName . JText::sprintf('VBO_PASSCODE_GEN_OK_DEVICE', $resultProps['code'], $device->getName())); } /** * Device capability implementation to delete a passcode from a device. * * @param VBODooraccessIntegrationDevice $device The device executing the capability. * @param ?array $options Optional settings populated from capability parameters. * * @return VBODooraccessDeviceCapabilityResult * * @link https://api.nuki.io/#/SmartlockAuth/SmartlockAuthResource_delete_delete */ public function deletePasscode(VBODooraccessIntegrationDevice $device, ?array $options = null) { if (empty($options['pwdid'])) { throw new Exception($response->body ?: 'Missing authorization (access code) ID to delete.', 400); } // start transporter $transporter = $this->createHTTPTransporter(); // make the API request $response = $transporter->delete('https://api.nuki.io/smartlock/' . $this->getDecimalDeviceID($device) . '/auth/' . $options['pwdid'], [], $this->httpHeaders, 60); // obtain the response data $responseData = (array) json_decode((string) $response->body, true); if (empty($response->code) || $response->code > 299) { // an error occurred throw new Exception($response->body ?: 'Error deleting passcode from device.', ($response->code ?: 500)); } return (new VBODooraccessDeviceCapabilityResult) ->setText(JText::sprintf('VBO_PASSCODE_DEL_OK_DEVICE', $device->getName())); } /** * Device capability implementation to update a passcode from a device. * * @param VBODooraccessIntegrationDevice $device The device executing the capability. * @param ?array $options Optional settings populated from capability parameters. * * @return VBODooraccessDeviceCapabilityResult * * @link https://api.nuki.io/#/SmartlockAuth/SmartlockAuthResource_post_post */ public function updatePasscode(VBODooraccessIntegrationDevice $device, ?array $options = null) { if (empty($options['pwdid'])) { throw new Exception($response->body ?: 'Missing authorization (access code) ID to update.', 400); } // start transporter (by setting the Content-Type header) $transporter = $this->createHTTPTransporter([ 'headers' => [ 'Content-Type' => 'application/json', ], ]); // build request data $data = [ 'allowedFromDate' => (($options['startdate'] ?? '') ? $this->getDateRFC3339($options['startdate']) : null), 'allowedUntilDate' => (($options['enddate'] ?? '') ? $this->getDateRFC3339($options['enddate']) : null), 'allowedWeekDays' => 127, 'allowedFromTime' => 0, 'allowedUntilTime' => 0, 'remoteAllowed' => true, ]; // make the API request $response = $transporter->post('https://api.nuki.io/smartlock/' . $this->getDecimalDeviceID($device) . '/auth/' . $options['pwdid'], json_encode($data), $this->httpHeaders, 60); // obtain the response data $responseData = (array) json_decode((string) $response->body, true); if (empty($response->code) || $response->code > 299) { // an error occurred throw new Exception($response->body ?: 'Error updating passcode on device.', ($response->code ?: 500)); } return (new VBODooraccessDeviceCapabilityResult) ->setText(JText::sprintf('VBO_PASSCODE_UPD_OK_DEVICE', $device->getName())); } /** * Device capability implementation to show the list of activity logs of a device. * * @param VBODooraccessIntegrationDevice $device The device executing the capability. * @param ?array $options Optional settings populated from capability parameters. * * @return VBODooraccessDeviceCapabilityResult * * @throws Exception * * @link https://api.nuki.io/#/SmartlockLog/SmartlockLogsResource_get_get */ public function showActivityLogs(VBODooraccessIntegrationDevice $device, ?array $options = null) { // start transporter $transporter = $this->createHTTPTransporter(); // build query string data $data = [ 'fromDate' => (!empty($options['startdate']) ? $this->getDateRFC3339($options['startdate']) : null), 'toDate' => (!empty($options['enddate']) ? $this->getDateRFC3339($options['enddate']) : null), 'limit' => 50, ]; // make a request to obtain the logs of a lock $response = $transporter->get('https://api.nuki.io/smartlock/' . $this->getDecimalDeviceID($device) . '/log?' . http_build_query(array_filter($data)), $this->httpHeaders, 20); // obtain the response data $responseData = (array) json_decode((string) $response->body, true); if (empty($response->code) || $response->code > 299) { // an error occurred throw new Exception($response->body ?: 'Error fetching device logs.', ($response->code ?: 500)); } $activities = $responseData; if (!is_array($activities) || !$activities) { throw new Exception(sprintf("No log records found for the device.\n%s", print_r($activities, true)), 404); } // build HTML output $output = ''; // lang defs $lang_createdon = JText::translate('VBOINVCREATIONDATE'); // table head $output .= << HTML; // scan all activities obtained foreach ($activities as $activity) { // set activity properties $activityId = $activity['id'] ?? ''; $activityName = $activity['name'] ?? ''; $activityAuthId = $activity['authId'] ?? ''; $activityAction = $this->getActivityAction((int) ($activity['action'] ?? 0)); $activityTrigger = $this->getActivityTrigger((int) ($activity['trigger'] ?? 0)); $activityState = $this->getActivityState((int) ($activity['state'] ?? 0)); $activitySource = $this->getActivitySource((int) ($activity['source'] ?? 0)); $activityDate = ($activity['date'] ?? '') ? JHtml::fetch('date', $activity['date'], 'Y-m-d H:i:s') : '---'; // build passcode HTML code $output .= << HTML; } // close table $output .= <<
Log ID Name Passcode ID Action Trigger State Source {$lang_createdon}
{$activityId} {$activityName} {$activityAuthId} {$activityAction} {$activityTrigger} {$activityState} {$activitySource} {$activityDate}
HTML; // return the capability result object by setting the output value return (new VBODooraccessDeviceCapabilityResult($activities)) ->setOutput($output); } /** * @inheritDoc */ public function createBookingDoorAccess(VBODooraccessIntegrationDevice $device, int $listingId, VBOBookingRegistry $registry) { // access the integration settings $settings = $this->getSettings(); // build booking-listing signature $signature = sprintf('%d-%d', $registry->getID(), $listingId); // access booking registry DAC data for passcodes generated $passcodesBuffer = $registry->getDACProperty($this->getAlias(), 'passcodes', []); // determine the passcode value to use, either a new one or a previous one for the same booking if (($settings['passquant'] ?? 0) == 2 && ($passcodesBuffer[$signature] ?? null)) { // use the previously generated passcode for this booking and listing also on this device $passcodeValue = $passcodesBuffer[$signature]; } else { // generate custom, yet random, passcode value of 6 digits for this device $passcodeValue = $this->generateRandomPasscode(); } // prepare the options for creating a custom passcode (randomly generated by us) $options = [ // use a password name that can be used later to find it under this booking and listing ID 'pwdname' => sprintf('bid:%d-%d', $registry->getID(), $listingId), // set the passcode validity start date and time 'startdate' => date('Y-m-d H:i:00', $registry->getProperty('checkin', 0)), // set the passcode validity end date and time 'enddate' => date('Y-m-d H:i:00', $registry->getProperty('checkout', 0)), // custom passcode value to create on the device 'pwdvalue' => $passcodeValue, // inject the listing ID for completion of data 'listing_id' => $listingId, ]; // create custom passcode on the current device $result = $this->createCustomPasscode($device, $options); // update booking registry DAC data for passcodes generated $passcodesBuffer[$signature] = $result->getPasscode(); $registry->setDACProperty($this->getAlias(), 'passcodes', $passcodesBuffer); return $result; } /** * @inheritDoc */ public function modifyBookingDoorAccess(VBODooraccessIntegrationDevice $device, int $listingId, VBOBookingRegistry $registry) { // searching, deleting and re-creating passcodes is always safer in case of // booking modification for possibly different listing IDs involved // find the passcode data that were previously created for this booking $previousDevicePasscodes = VikBooking::getBookingHistoryInstance($registry->getID()) ->getEventsWithData(['ND', 'MD'], function($data) use ($device) { $data = (array) $data; // ensure the passcode was generated for this provider, profile and device return ($data['provider'] ?? '') == $this->getProfileProvider() && ($data['profile'] ?? '') == $this->getProfileID() && ($data['device'] ?? '') == $device->getID() && (!empty($data['passcode']) || !empty($data['props'])); }); if (!$previousDevicePasscodes) { // no passcodes were previously created for this booking // process the modification as a new door access creation return $this->createBookingDoorAccess($device, $listingId, $registry); } // scan all previously created passcodes in DESC order on this device and delete them $previousPasscodeNamings = []; foreach (array_reverse($previousDevicePasscodes) as $previousData) { // ensure we only have array values $previousData = (array) json_decode(json_encode($previousData), true); /** * The generation of booking passcodes is asynchronous, hence we don't immediately get and store the * passcode ID to be used for deleting it immediately. We need to "search" the passcode by name. */ // get the previous passcode name $previousPasscodeName = ($previousData['props']['name'] ?? ''); if (empty($previousPasscodeName) || in_array($previousPasscodeName, $previousPasscodeNamings)) { // no passcode name to search and delete, or already deleted continue; } // push processed passcode name $previousPasscodeNamings[] = $previousPasscodeName; try { // attempt to find the passcode on this device by name, which includes booking and listing IDs $findResult = $this->listPasscodes($device, [ // type "Keypad" 'type' => 13, // inject search property to match this exact passcode name 'search' => sprintf('bid:%d-%d', $registry->getID(), $listingId), ]); if (!$findResult->getProperties()) { // passcode not found by name throw new Exception('Previous passcode not found by name.', 404); } // iterate the list of passcodes found, even if only one is expected foreach ($findResult->getProperties() as $pwdId => $pwdData) { // delete the first passcode found, previously created for this booking $this->deletePasscode($device, [ 'pwdid' => $pwdId, ]); // abort loop at first run break; } } catch (Exception $e) { // do nothing on error with cancellations or previous passcodes not found } } // process the modification as a new door access creation, always with custom passcodes return $this->createBookingDoorAccess($device, $listingId, $registry); } /** * @inheritDoc */ public function cancelBookingDoorAccess(VBODooraccessIntegrationDevice $device, int $listingId, VBOBookingRegistry $registry) { try { // find the previously created passcode for this booking and listing $findResult = $this->listPasscodes($device, [ // type "Keypad" 'type' => 13, // inject search property to match this exact passcode name 'search' => sprintf('bid:%d-%d', $registry->getID(), $listingId), ]); if (!$findResult->getProperties()) { // passcode not found throw new Exception('Previous passcode not found.', 404); } } catch (Exception $e) { // nothing to cancel, but prevent unwanted errors not related to the real cancellation return null; } // iterate the list of passcodes found, even if only one is expected foreach ($findResult->getProperties() as $pwdId => $pwdData) { // delete the first passcode found return $this->deletePasscode($device, [ 'pwdid' => $pwdId, ]); } } /** * @inheritDoc */ public function handleUnlockDevice(VBODooraccessIntegrationDevice $device) { // unlock the requested device return $this->unlockDevice($device); } /** * @inheritDoc */ public function getPasscodeFromHistoryResult(array $resultProperties) { // creating a passcode should bind its value within the device capability result object return $resultProperties['code'] ?? null; } /** * @inheritDoc * * @link https://api.nuki.io/#/Smartlock/SmartlocksResource_get_get */ protected function fetchRemoteDevices() { // start transporter $transporter = $this->createHTTPTransporter(); // make a request to obtain the lock list of an account $response = $transporter->get('https://api.nuki.io/smartlock', $this->httpHeaders, 20); // obtain the response data $responseData = (array) json_decode((string) $response->body, true); if (empty($response->code) || $response->code > 299) { // an error occurred throw new Exception($response->body ?: 'Error fetching the remove devices.', ($response->code ?: 500)); } // get the list of devices returned $devices = $responseData; if (empty($devices[0]['smartlockId'])) { throw new Exception('No devices found under the current account.', 500); } return $devices; } /** * @inheritDoc */ protected function decorateDeviceProperties(VBODooraccessIntegrationDevice $decorator, array $device) { // set device ID $decorator->setID($device['smartlockId'] ?? ''); // set device name $decorator->setName($device['name'] ?? ''); // set device description $decorator->setDescription($device['accountId'] ?? ''); // set device icon $decorator->setIcon(''); // map device model (type) $modelName = ($device['type'] ?? '') ? $this->getDeviceTypes((int) $device['type'], true) : ''; // set device model $decorator->setModel($modelName); if ($device['state']['batteryCharge'] ?? null) { // set device battery level $decorator->setBatteryLevel((float) $device['state']['batteryCharge']); } // set device capabilities $decorator->setCapabilities([ // unlock device $this->createDeviceCapability([ 'id' => 'unlock_device', 'title' => JText::translate('VBDASHUNLOCK'), 'description' => JText::translate('VBO_UNLOCK_DEVICE_HELP'), 'icon' => '', 'callback' => 'unlockDevice', ]), // lock device $this->createDeviceCapability([ 'id' => 'lock_device', 'title' => JText::translate('VBO_LOCK'), 'description' => JText::translate('VBO_LOCK_DEVICE_HELP'), 'icon' => '', 'callback' => 'lockDevice', ]), // read passcodes $this->createDeviceCapability([ 'id' => 'list_passcodes', 'title' => JText::translate('VBO_LIST_PASSCODES'), 'description' => JText::translate('VBO_LIST_PASSCODES_HELP'), 'icon' => '', 'callback' => 'listPasscodes', 'params' => [ 'type' => [ 'type' => 'select', 'label' => JText::translate('VBPSHOWSEASONSTHREE'), 'options' => [ 'any' => sprintf('- %s', JText::translate('VBANYTHING')), 13 => 'Keypad', 0 => 'App', 2 => 'Fob', ], ], ], ]), // create (custom) passcode $this->createDeviceCapability([ 'id' => 'create_custom_passcode', 'title' => JText::translate('VBO_CREATE_PASSCODECUST'), 'description' => JText::translate('VBO_CREATE_PASSCODECUST_HELP'), 'icon' => '', 'callback' => 'createCustomPasscode', 'params' => [ 'pwdvalue' => [ 'type' => 'text', 'label' => JText::translate('VBO_PASSCODE'), 'help' => JText::translate('VBO_PASSCODE_EMPTY_HELP') . ' 6 digits (1-9), should not start with 12 and should not contain 0.', 'attributes' => [ 'pattern' => '^(?!12)[1-9]{6}$', ], ], 'pwdname' => [ 'type' => 'text', 'label' => JText::translate('VBO_PASSCODE_NAME'), 'help' => JText::translate('VBO_OPT_PASSCODE_NAME'), ], 'startdate' => [ 'type' => 'datetime', 'label' => JText::translate('VBNEWPKGDFROM'), 'help' => JText::translate('VBO_PASSCODE_VALID_START'), ], 'enddate' => [ 'type' => 'datetime', 'label' => JText::translate('VBNEWPKGDTO'), 'help' => JText::translate('VBO_PASSCODE_VALID_END'), ], ], ]), // delete passcode $this->createDeviceCapability([ 'id' => 'delete_passcode', 'title' => JText::translate('VBO_DELETE_PASSCODE'), 'description' => JText::translate('VBO_DELETE_PASSCODE_HELP'), 'icon' => '', 'callback' => 'deletePasscode', 'params' => [ 'pwdid' => [ 'type' => 'text', 'label' => 'Passcode ID', ], ], ]), // update passcode $this->createDeviceCapability([ 'id' => 'update_passcode', 'title' => JText::translate('VBO_UPDATE_PASSCODE'), 'description' => JText::translate('VBO_UPDATE_PASSCODE_HELP'), 'icon' => '', 'callback' => 'updatePasscode', 'params' => [ 'pwdid' => [ 'type' => 'text', 'label' => 'Passcode ID', ], 'startdate' => [ 'type' => 'datetime', 'label' => JText::translate('VBNEWPKGDFROM'), 'help' => JText::translate('VBO_PASSCODE_VALID_START'), ], 'enddate' => [ 'type' => 'datetime', 'label' => JText::translate('VBNEWPKGDTO'), 'help' => JText::translate('VBO_PASSCODE_VALID_END'), ], ], ]), // show activity logs $this->createDeviceCapability([ 'id' => 'activity_logs', 'title' => JText::translate('VBO_ACTIVITY_LOGS'), 'description' => JText::translate('VBO_ACTIVITY_LOGS_HELP'), 'icon' => '', 'callback' => 'showActivityLogs', 'params' => [ 'startdate' => [ 'type' => 'datetime', 'label' => JText::translate('VBOREPORTSDATEFROM'), ], 'enddate' => [ 'type' => 'datetime', 'label' => JText::translate('VBOREPORTSDATETO'), ], ], ]), ]); // set device payload by unsetting the unwanted properties unset( $device['advancedConfig'], $device['openerAdvancedConfig'], $device['smartdoorAdvancedConfig'], $device['webConfig'], $device['previousSubscriptions'], $device['currentSubscription'] ); $decorator->setPayload($device); } /** * Handles a Nuki Webhook notification of type "DEVICE_STATUS". * * @param ?array $data The notification payload array. * * @return void * * @throws Exception */ protected function webhookHandleDeviceStatus(?array $data = null) { if (empty($data['smartlockId'])) { // useless to proceed without knowing the device ID // abort with no error status codes throw new Exception('Missing smartlock ID.', 200); } // access the current device by ID try { $device = $this->getDeviceById((string) $data['smartlockId']); } catch (Exception $e) { // abort with no error status codes throw new Exception($e->getMessage(), 200); } if (is_numeric($data['state']['batteryCharge'] ?? null)) { // update the device battery level $device->setBatteryLevel((float) $data['state']['batteryCharge']); } // update current device try { VBODooraccessFactory::getInstance()->saveIntegrationRecord($this, ['devices' => $this->getDevices()]); } catch (Exception $e) { // abort with no error status codes throw new Exception($e->getMessage(), 200); } } /** * Handles a Nuki Webhook notification of type "DEVICE_LOGS". * * @param ?array $data The notification payload array. * * @return void * * @throws Exception */ protected function webhookHandleDeviceLogs(?array $data = null) { if (empty($data['smartlockLog']['smartlockId'])) { // useless to proceed without knowing the device ID // abort with no error status codes throw new Exception('Missing smartlock ID.', 200); } // access the current device by ID try { $device = $this->getDeviceById((string) $data['smartlockLog']['smartlockId']); } catch (Exception $e) { // abort with no error status codes throw new Exception($e->getMessage(), 200); } // identify log action, trigger, state and source $logAction = $data['smartlockLog']['action'] ?? null; $logTrigger = $data['smartlockLog']['trigger'] ?? null; $logState = $data['smartlockLog']['state'] ?? null; $logSource = $data['smartlockLog']['source'] ?? null; /** * Check if the smartlock log refers to a successful authentication code through keypad. * * Action: should be 3, 1 or 5 (unlatch, unlock, lock-n-go with unlatch). * Trigger: should be 255 (keypad). * State: should be 0 (successful). * Source: should be 1 or 2 (keypad or fingerprint). */ if (($logAction == 1 || $logAction == 3) && $logTrigger == 255 && $logState == 0 && ($logSource == 1 || $logSource == 2)) { // the device webhook log refers to a keypad/fingerprint successfuly authentication // attempt to match a previously generated passcode name and authentication ID if (empty($data['smartlockLog']['authId']) || !preg_match('/^bid\:([0-9]+)\-([0-9]+)$/', (string) ($data['smartlockLog']['name'] ?? ''), $matches)) { // silently abort with a success code in order to not break the webhook response throw new Exception('Smartlock log verified with no needed actions.', 200); } // identify booking ID and booking room ID from authentication code name $bookingId = (int) $matches[1]; $bookingRoomId = (int) $matches[2]; // obtain the booking details $booking = VikBooking::getBookingInfoFromID($bookingId); if (!$booking) { // silently abort with a success code in order to not break the webhook response throw new Exception('Smartlock log verified with no booking details.', 200); } // wrap the booking information into a registry $registry = VBOBookingRegistry::getInstance($booking); // obtain the authentication ID $authenticationId = (string) $data['smartlockLog']['authId']; // make sure a first access notification for this booking was not already processed $history = VikBooking::getBookingHistoryInstance($booking['id']); if ($history->hasEvent('FA')) { // silently abort with a success code in order to not break the webhook response throw new Exception('Smartlock log already processed.', 200); } // we trust the webhook notification authentication ID to be the one previously generated // without performing any API request to match the authentication code saved in the history // store booking history record VikBooking::getBookingHistoryInstance($registry->getID()) ->setBookingData($registry->getData(), $registry->getRooms()) ->setExtraData([ 'provider' => $this->getProfileProvider(), 'profile' => $this->getProfileID(), 'device' => $device->getID(), ]) ->store('FA', sprintf('%s - %s: %s', (string) $this->getProfileName(), (string) $device->getName(), $authenticationId)); // store an entry within the notifications center for the successful operation VBOFactory::getNotificationCenter() ->store([ [ 'sender' => 'dac', 'type' => 'dac.FA.ok', 'title' => sprintf('%s - %s', (string) $this->getProfileName(), (string) $device->getName()), 'summary' => sprintf('%s: %s', JText::translate('VBOBOOKHISTORYTFA'), $authenticationId), 'idorder' => $registry->getID(), 'avatar' => preg_match('/^http/', (string) $this->getIcon()) ? $this->getIcon() : null, ], ]); // terminate and go no further return; } } /** * Given a date-time string in military format, returns the ISO 8601 / RFC 3339 date. * * @param string $dateTime The datetime in military format, eventually with time. * * @return string The formatted date in UTC timezone. */ private function getDateRFC3339(string $dateTime) { // construct datetime object $dt = new DateTime($dateTime, new DateTimeZone(date_default_timezone_get())); // force the timezone to be UTC $dt->setTimezone(new DateTimeZone('UTC')); // return the formatted date return $dt->format('Y-m-d\TH:i:s.v\Z'); } /** * HTTP requests towards Nuki require the smartlock ID to be an integer, but * for some locks, the value fetched is in hexadecimal format. In order to * convert it into decimal format, hence integer, we also need to prefix a * number matching the device type to obtain a valid decimal ID for the lock. * * @param VBODooraccessIntegrationDevice $device The device to parse. * * @return int The decimal smartlock ID. */ private function getDecimalDeviceID(VBODooraccessIntegrationDevice $device) { // obtain the current device ID $deviceId = (string) $device->getID(); if (!preg_match('/(?=.*[a-fA-F])[0-9a-fA-F]+/', $deviceId)) { // no hexadecimal values found within the device ID, hence it's supposingly a decimal value return (int) $deviceId; } // attempt to access the device type $devicePayload = $device->getPayload(); $deviceTypeInfo = $this->getDeviceTypes($devicePayload['type'] ?? -1); $deviceTypeId = $deviceTypeInfo['type'] ?? 0; // prefix the hexadecimal string with exact device type prefix $prefixedHex = $deviceTypeId . $deviceId; // return the converted decimal ID for the device return (int) hexdec($prefixedHex); } /** * Fetches the activity action name from the given code. * * @param int $code The activity action code. * * @return string */ private function getActivityAction(int $code) { $list = [ 1 => 'unlock', 2 => 'lock', 3 => 'unlatch', 4 => 'lock\'n\'go', 5 => 'lock\'n\'go with unlatch', 6 => 'activate cm', 7 => 'deactivate cm', 208 => 'door warning ajar', 209 => 'door warning status mismatch', 224 => 'doorbell recognition (only Opener)', 240 => 'door opened', 241 => 'door closed', 242 => 'door sensor jammed', 243 => 'firmware update', 250 => 'door log enabled', 251 => 'door log disabled', 252 => 'initialization', 253 => 'calibration', 254 => '(activity) log enabled', 255 => '(activity) log disabled', ]; return $list[$code] ?? ''; } /** * Fetches the activity trigger name from the given code. * * @param int $code The activity trigger code. * * @return string */ private function getActivityTrigger(int $code) { $list = [ 0 => 'system (bluetooth)', 1 => 'manual', 2 => 'button', 3 => 'automatic', 4 => 'web', 5 => 'app', 6 => 'auto lock', 7 => 'external accessory', 255 => 'keypad', ]; return $list[$code] ?? ''; } /** * Fetches the activity state name from the given code. * * @param int $code The activity state code. * * @return string */ private function getActivityState(int $code) { $list = [ 0 => 'Success', 1 => 'Motor blocked', 2 => 'Cancelled', 3 => 'Too recent', 4 => 'Busy', 5 => 'Low motor voltage', 6 => 'Clutch failure', 7 => 'Motor power failure', 8 => 'Incomplete', 9 => 'Rejected', 10 => 'Rejected night mode', 254 => 'Other errors', 255 => 'Unknown error', ]; return $list[$code] ?? ''; } /** * Fetches the activity source name from the given code. * * @param int $code The activity source code. * * @return string */ private function getActivitySource(int $code) { $list = [ 0 => 'Default', 1 => 'Keypad code', 2 => 'Fingerprint', ]; return $list[$code] ?? ''; } /** * Maps the supported device type identifiers with name and prefix. * * @param ?int $type Optional device type identifier to fetch. * @param bool $name True to get only the device type name. * * @return array|string Full list, device type array or type string name. */ private function getDeviceTypes(?int $type = null, bool $name = false) { // list of device types, whose key "type" is equal to the hexadecimal prefix $list = [ 0 => [ 'name' => 'Nuki Smartlock 1 or 2', 'type' => '0', ], 1 => [ 'name' => 'Nuki Box', 'type' => '1', ], 2 => [ 'name' => 'Nuki Opener', 'type' => '2', ], 3 => [ 'name' => 'Nuki Smartdoor', 'type' => '3', ], 4 => [ 'name' => 'Nuki Smartlock 3rd/4th Gen (Basic & Pro)', 'type' => '4', ], 5 => [ 'name' => 'Nuki Smartlock Ultra', 'type' => '5', ], ]; if (is_null($type)) { return $list; } if (!$name) { return $list[$type] ?? []; } return $list[$type]['name'] ?? ''; } /** * Maps the supported passcode type identifiers with name and description. * * @param ?int $type Optional passcode type identifier to fetch. * @param bool $name True to get only the passcode name. * * @return array|string Full list, passcode type array or passcode string name. */ private function getPasscodeTypes(?int $type = null, bool $name = false) { $list = [ 0 => [ 'name' => 'App', 'descr' => 'Authentication code for the App.', ], 2 => [ 'name' => 'Fob', 'descr' => 'Authentication code for Fob.', ], 13 => [ 'name' => 'Keypad', 'descr' => 'Authentication code for the Keypad.', ], ]; if (is_null($type)) { return $list; } if (!$name) { return $list[$type] ?? []; } return $list[$type]['name'] ?? ''; } /** * Generates a random serial code made of only digits with a given length. * The sequence obtained will never contain zeros to support the Nuki Keypad * and it will not start with "12". * * @param int $length The passcode length. * * @return string */ private function generateRandomPasscode(int $length = 6) { do { $passcode = VikBooking::getCPinInstance()->generateSerialCode($length, ['123456789']); } while (substr($passcode, 0, 2) == '12'); return $passcode; } /** * Creates the HTTP Transporter to establish API connections with Nuki. * An integration profile record is supposed to be set before making an HTTP request. * * @param ?array $options Optional transporter options. * * @return object The prepared HTTP transporter object with bearer token. * * @throws Exception */ private function createHTTPTransporter(?array $options = null) { // access current profile settings $settings = $this->getSettings(); // determine the authentication method: OAuth2 or API Token $authMethod = ($settings['authmeth'] ?? '') == 'oauth' ? 'oauth' : 'api_token'; // access the bearer token depending on the authentication method configured $bearerToken = null; if ($authMethod === 'api_token') { if (empty($settings['api_token'])) { throw new Exception('Missing API Token for Web API (settings).', 500); } // use static token from configuration settings $bearerToken = $settings['api_token']; } else { // authentication method is OAuth2 if (empty($settings['oauth2_api_key']) || empty($settings['oauth2_api_secret'])) { // settings must be configured throw new Exception('Missing OAuth2 API Key (Client ID) and OAuth2 API Secret (Client Secret). Please go through settings.', 500); } // ensure we are not actually using the transporter for authorising the application if (empty($options['doing_oauth'])) { if (empty($settings['_oauth']['access_token'])) { // application must be authorised throw new Exception('Missing OAuth2 authorisation data for the application. Please go through settings.', 500); } // get or refresh the access (bearer) token $bearerToken = $this->getOauthToken(); } } // set HTTP headers $this->httpHeaders = [ 'Authorization' => "Bearer {$bearerToken}", 'Accept' => 'application/json', ]; if (!$bearerToken) { // the request should not define a default Authorization header unset($this->httpHeaders['Authorization']); } if (is_array($options['headers'] ?? null)) { // merge default headers with the given ones (associative list expected) $this->httpHeaders = $this->httpHeaders + $options['headers']; } return new JHttp; } /** * Obtains an active OAuth (Bearer) token to establish API connections with Nuki. * In this case, the authentication method configured should be "OAuth". * * @return string An active OAuth (Bearer) token. * * @throws Exception */ private function getOauthToken() { // access current profile settings $settings = $this->getSettings(); if (empty($settings['_oauth']['access_token'])) { // application must be authorised throw new Exception('Missing OAuth2 authorisation data for the application. Please go through settings.', 500); } if (($settings['_oauth']['expiry_ts'] ?? 0) < time()) { // the token should be renewed because it's expired return $this->renewOauthToken(); } // return the supposingly active token return (string) $settings['_oauth']['access_token']; } /** * Makes an API request with Nuki to refresh and save the OAuth token for any HTTP request. * * @return string An active OAuth (Bearer) token ready to be used. * * @throws Exception * * @link https://developer.nuki.io/ */ private function renewOauthToken() { // access current profile settings $settings = $this->getSettings(); if (empty($settings['oauth2_api_key']) || empty($settings['oauth2_api_secret'])) { // settings must be configured throw new Exception('Missing OAuth2 API Key (Client ID) and OAuth2 API Secret (Client Secret). Please go through settings.', 500); } if (empty($settings['_oauth']['refresh_token'])) { // application must be authorised throw new Exception('Missing OAuth2 authorisation data (refresh token) for the application. Please go through authorisation.', 500); } // build request data $data = [ 'grant_type' => 'refresh_token', 'client_id' => $settings['oauth2_api_key'], 'client_secret' => $settings['oauth2_api_secret'], 'refresh_token' => $settings['_oauth']['refresh_token'], ]; // exchange the settings to obtain the OAuth token details $response = (new JHttp)->post('https://api.nuki.io/oauth/token', http_build_query($data), ['Content-Type' => 'application/x-www-form-urlencoded'], 10); // obtain the response data $responseData = (array) json_decode((string) $response->body, true); if (empty($response->code) || $response->code > 299) { // an error occurred throw new Exception($response->body ?: 'OAuth token error.', $response->code); } if (empty($responseData['access_token'])) { // invalid response throw new Exception($response->body ?: 'OAuth token refresh response missing access token.', 500); } // calculate and set the token expiration timestamp $responseData['expiry_ts'] = strtotime(sprintf('+%d seconds', (int) ($responseData['expires_in'] ?? 0))); // inject OAuth details within the current integration settings $settings['_oauth'] = $responseData; // update integration record settings $this->setProfileRecordProp('settings', $settings); // store integration record settings VBODooraccessFactory::getInstance()->saveIntegrationRecord($this, ['settings' => $this->getSettings()]); // return the current access token return (string) $responseData['access_token']; } }