PluginProbe
VikBooking Hotel Booking Engine & PMS / trunk
VikBooking Hotel Booking Engine & PMS vtrunk
1.8.15 1.8.14 1.8.13 1.8.12 1.8.11 1.8.10 1.8.9 1.8.6 1.8.7 1.8.8 trunk 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.7 1.6.8 1.6.9 1.7.0 1.7.1 1.7.2 1.7.3 All 36 releases
vikbooking / admin / helpers / src / dooraccess / provider / nuki.php

nuki.php in VikBooking Hotel Booking Engine & PMS trunk, at admin/helpers/src/dooraccess/provider/nuki.php

1,766 lines 68.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * @package VikBooking
4 * @subpackage core
5 * @author E4J s.r.l.
6 * @copyright Copyright (C) 2026 E4J s.r.l. All Rights Reserved.
7 * @license http://www.gnu.org/licenses/gpl-2.0.html GNU/GPL
8 * @link https://vikwp.com
9 */
10
11 // No direct access
12 defined('ABSPATH') or die('No script kiddies please!');
13
14 /**
15 * Door Access integration provider for Nuki.
16 *
17 * @since 1.18.6 (J) - 1.8.6 (WP)
18 *
19 * @link https://api.nuki.io/
20 * @link https://developer.nuki.io/
21 */
22 final class VBODooraccessProviderNuki extends VBODooraccessIntegrationAware
23 {
24 /**
25 * @var array
26 */
27 private array $httpHeaders = [];
28
29 /**
30 * @var string
31 */
32 private string $authScopes = 'smartlock smartlock.action smartlock.auth smartlock.log webhook.central';
33
34 /**
35 * @inheritDoc
36 */
37 public function getAlias()
38 {
39 return basename(__FILE__, '.php');
40 }
41
42 /**
43 * @inheritDoc
44 */
45 public function getName()
46 {
47 return 'Nuki - Smart Locks';
48 }
49
50 /**
51 * @inheritDoc
52 */
53 public function getShortName()
54 {
55 return 'Nuki';
56 }
57
58 /**
59 * @inheritDoc
60 */
61 public function getIcon()
62 {
63 return VBO_ADMIN_URI . 'resources/nuki-vikbooking-integration-logo.png';
64 }
65
66 /**
67 * @inheritDoc
68 */
69 public function getParams()
70 {
71 // load current settings
72 $settings = $this->getSettings();
73
74 // always build the OAuth redirect URL
75 $dac_oauth_spawn_url = $this->buildOAuthURL();
76
77 // check if the application was authorised through OAuth
78 $oauth_authorised = !empty($settings['_oauth']['access_token']);
79
80 // build OAuth2 authorization link
81 if (empty($settings['oauth2_api_key']) || empty($settings['oauth2_api_secret'])) {
82 // settings must be saved first
83 $oauth2_auth_link = '<span class="label label-error">Save settings first. OAuth2 API Key and API Secret cannot be empty.</span>';
84 // convert the redirect URI to a text
85 $dac_oauth_spawn_url = 'Save settings first.';
86 } else {
87 // build button-link for the authorization code URL
88 $nuki_auth_data = [
89 'response_type' => 'code',
90 'client_id' => $settings['oauth2_api_key'],
91 'scope' => $this->authScopes,
92 'state' => $this->getOAuthCode(),
93 'redirect_uri' => $dac_oauth_spawn_url,
94 ];
95
96 // build final Nuki authorization URL to be clicked
97 $nuki_auth_link = 'https://api.nuki.io/oauth/authorize?' . http_build_query($nuki_auth_data);
98
99 // set button content
100 $oauth2_auth_link = '<a class="btn btn-warning" href="' . $nuki_auth_link . '">' . ($oauth_authorised ? '(Re-)' : '') . 'Authorize Application</a>';
101 }
102
103 // build HTML instructions for obtaining the OAuth2 information
104 $help_url_webhook = $this->buildWebhookURL();
105 $oauth_instructions_html = <<<HTML
106 <div>Visit your <a href="https://web.nuki.io/" target="_blank">Nuki Web</a> account (section <em>API</em> - <em>OAuth2</em>) to enable and obtain the <strong>OAuth2 API Key</strong> and <strong>OAuth2 API Secret</strong>. Then enter the following <strong>OAuth2 redirect URL</strong>:</div>
107 <ul>
108 <li><em>$dac_oauth_spawn_url</em></li>
109 </ul>
110 <div>From the <em>Nuki Advanced API Integration section</em>, enther the following <strong>Webhook URL</strong>:</div>
111 <ul>
112 <li><em>$help_url_webhook</em></li>
113 </ul>
114 HTML;
115
116 if (!$oauth_authorised) {
117 // add to the instructions that the application must be authorised
118 $oauth_instructions_html .= <<<HTML
119 <div>Proceed by clicking the authorization link below after saving your OAuth settings.</div>
120 HTML;
121 }
122
123 if (!$oauth_authorised) {
124 // never obtained an access token through OAuth before
125 $oauth2_auth_status = '<span class="badge badge-error">Not Authorized</span>';
126 } else {
127 // access token was once obtained through OAuth
128 $oauth2_auth_status = '<span class="badge badge-success">Authorized</span>';
129 }
130
131 // return the list of parameters
132 return [
133 'ai' => [
134 'type' => 'checkbox',
135 'label' => JText::translate('VBO_AI_SUPPORT'),
136 'help' => JText::translate('VBO_DAC_AI_SUPPORT_HELP'),
137 'default' => 1,
138 ],
139 'passquant' => [
140 'type' => 'select',
141 'label' => JText::translate('VBO_PASSCODES'),
142 'help' => JText::translate('VBO_PASSCODES_QUANT_HELP'),
143 'options' => [
144 1 => JText::translate('VBO_ONE_PER_DEVICE'),
145 2 => JText::translate('VBO_ONE_PER_BOOKING'),
146 ],
147 'default' => 1,
148 ],
149 'authmeth' => [
150 'type' => 'select',
151 'label' => 'Authentication Method',
152 'help' => 'Choose the authentication type configured in your Nuki Web account.',
153 'options' => [
154 'oauth' => 'OAuth2',
155 'api_token' => 'API Token',
156 ],
157 'default' => 'oauth',
158 ],
159 'api_token' => [
160 'type' => 'password',
161 'label' => 'API Token',
162 'conditional' => 'authmeth:api_token',
163 ],
164 '_oauth2_auth_help' => [
165 'type' => 'custom',
166 // 'label' => 'Instructions',
167 'html' => $oauth_instructions_html,
168 'conditional' => 'authmeth:oauth',
169 ],
170 '_oauth2_auth_status' => [
171 'type' => 'custom',
172 'label' => 'Authorization Status',
173 'html' => $oauth2_auth_status,
174 'conditional' => 'authmeth:oauth',
175 ],
176 '_oauth2_auth_link' => [
177 'type' => 'custom',
178 'label' => 'Authorization Link',
179 'html' => $oauth2_auth_link,
180 'conditional' => 'authmeth:oauth',
181 ],
182 'oauth2_api_key' => [
183 'type' => 'text',
184 'label' => 'OAuth2 API Key (Client ID)',
185 'help' => (empty($settings['oauth2_api_key']) ? 'Save settings to see the authorization link. ' : '') . 'OAuth2 API Key from Nuki Web > Menu > API.',
186 'conditional' => 'authmeth:oauth',
187 ],
188 'oauth2_api_secret' => [
189 'type' => 'password',
190 'label' => 'OAuth2 API Secret (Client Secret)',
191 'help' => (empty($settings['oauth2_api_key']) ? 'Save settings to see the authorization link. ' : '') . 'OAuth2 API Secret from Nuki Web > Menu > API.',
192 'conditional' => 'authmeth:oauth',
193 ],
194 ];
195 }
196
197 /**
198 * @inheritDoc
199 */
200 public function spawnOAuthCallback(?array $data = null)
201 {
202 $app = JFactory::getApplication();
203
204 // gather request or data variables to obtain the authorization code for the application
205 $auth_code = ($data['code'] ?? '') ?: $app->input->getString('code', '');
206 $auth_state = ($data['state'] ?? '') ?: $app->input->getString('state', '');
207
208 if (!empty($auth_code) && empty($auth_state)) {
209 // missing CSRF proof token from Nuki
210 throw new Exception(JText::translate('JINVALID_TOKEN'), 403);
211 }
212
213 // perform the OAuth token internal validation that relies on the database for CSRF prevention
214 if ($auth_state != $this->getOAuthCode()) {
215 throw new Exception(JText::translate('JINVALID_TOKEN'), 403);
216 }
217
218 // load current settings
219 $settings = $this->getSettings();
220
221 if (!empty($auth_code)) {
222 /**
223 * Application was authorized and an authorization code was received.
224 * Perform a request to exchange the auth code and obtain the access token.
225 */
226
227 // start transporter (by setting the necessary headers)
228 $transporter = $this->createHTTPTransporter([
229 'doing_oauth' => 1,
230 'headers' => [
231 'Content-Type' => 'application/x-www-form-urlencoded',
232 ],
233 ]);
234
235 // build request data, inclusive of redirect URI (must match the one for the initial authorization)
236 $requestData = [
237 'client_id' => ($settings['oauth2_api_key'] ?? ''),
238 'client_secret' => ($settings['oauth2_api_secret'] ?? ''),
239 'grant_type' => 'authorization_code',
240 'code' => $auth_code,
241 'redirect_uri' => $this->buildOAuthURL(),
242 ];
243
244 // make the API request (POST with query string values in endpoint URL)
245 $response = $transporter->post('https://api.nuki.io/oauth/token', http_build_query($requestData), $this->httpHeaders, 60);
246
247 // obtain the response data (response should be empty in case of success)
248 $responseData = (array) json_decode((string) $response->body, true);
249
250 if (empty($response->code) || $response->code > 299) {
251 // an error occurred
252 throw new Exception($response->body ?: 'Error exchanging the auth code for the OAuth access token.', ($response->code ?: 500));
253 }
254
255 if (empty($responseData['access_token']) || empty($responseData['refresh_token'])) {
256 // unexpected response format
257 throw new Exception('Unexpected response format: missing access token or refresh token.', 500);
258 }
259
260 // calculate and set the token expiration timestamp (3600 seconds)
261 $responseData['expiry_ts'] = strtotime(sprintf('+%d seconds', (int) ($responseData['expires_in'] ?? 0)));
262
263 // inject OAuth details within the current integration settings
264 $settings['_oauth'] = $responseData;
265
266 // update integration record settings
267 $this->setProfileRecordProp('settings', $settings);
268
269 // store integration record settings
270 VBODooraccessFactory::getInstance()->saveIntegrationRecord($this, ['settings' => $this->getSettings()]);
271
272 // application was successfully authorised, close the response
273
274 // redirect to VikBooking admin-widget
275 $redirectData = [
276 'option' => 'com_vikbooking',
277 'load_widget' => 'door_access_control',
278 'multitask_data' => [
279 'provider' => $this->getProfileProvider(),
280 'profile' => $this->getProfileID(),
281 'tab' => 'settings',
282 ],
283 ];
284 $redirectUri = VBOFactory::getPlatform()->getUri()->admin('index.php?' . http_build_query($redirectData));
285
286 $app->redirect($redirectUri);
287 $app->close();
288 }
289
290 // close the response with a status code 204 (No Content)
291 VBOHttpDocument::getInstance($app)->close(204, 'No data to authorise the application.');
292 }
293
294 /**
295 * @inheritDoc
296 */
297 public function spawnWebhookCallback(?array $data = null)
298 {
299 $app = JFactory::getApplication();
300
301 // load current settings
302 $settings = $this->getSettings();
303
304 // access signing key (client secret)
305 $signingKey = $settings['oauth2_api_secret'] ?? '';
306
307 // access raw body payload
308 $rawPayload = file_get_contents('php://input');
309
310 if (empty($rawPayload)) {
311 // raise an error
312 VBOHttpDocument::getInstance($app)->close(400, 'Missing webhook payload.');
313 }
314
315 // access the signature value from headers
316 $signatureHeader = $app->input->server->get('HTTP_X_NUKI_SIGNATURE_SHA256', '');
317
318 if (empty($signatureHeader)) {
319 // raise an error
320 VBOHttpDocument::getInstance($app)->close(400, 'Missing webhook signature header.');
321 }
322
323 // generate the HMAC SHA256 signature
324 $computedSignature = hash_hmac('sha256', $rawPayload, $signingKey);
325
326 // validate signature
327 if ($computedSignature != $signatureHeader && !hash_equals($computedSignature, $signatureHeader)) {
328 // raise an error
329 VBOHttpDocument::getInstance($app)->close(401, 'Invalid webhook signature value.');
330 }
331
332 // access webhook data
333 $webhookData = $app->input->json->getArray();
334
335 // determine the webhook notification type
336 $webhookType = strtoupper((string) ($webhookData['feature'] ?? ''));
337
338 // process the request, if known and needed
339 try {
340 switch ($webhookType) {
341 case 'DEVICE_STATUS':
342 // handle device status update webhook notification
343 $this->webhookHandleDeviceStatus($webhookData);
344 break;
345
346 case 'DEVICE_LOGS':
347 // handle device log webhook notification
348 $this->webhookHandleDeviceLogs($webhookData);
349 break;
350
351 default:
352 // do nothing
353 break;
354 }
355 } catch (Exception $e) {
356 // propagate the "error" (could also be 200) by closing the response
357 VBOHttpDocument::getInstance($app)->close($e->getCode() ?: 500, $e->getMessage());
358 }
359
360 // close the response with a 200 status code
361 VBOHttpDocument::getInstance($app)->close(200, 'Webhook data successfully received.');
362 }
363
364 /**
365 * @inheritDoc
366 */
367 public function canUnlockDevices()
368 {
369 // this method is called when the integration has loaded its profile record
370 // we return true only if the apposite AI setting is enabled
371
372 $settings = $this->getSettings();
373
374 return !empty($settings['ai']);
375 }
376
377 /**
378 * Device capability implementation to unlock a device.
379 *
380 * @param VBODooraccessIntegrationDevice $device The device executing the capability.
381 * @param ?array $options Optional settings populated from capability parameters.
382 *
383 * @return VBODooraccessDeviceCapabilityResult
384 *
385 * @link https://api.nuki.io/#/Smartlock/SmartlockUnlockActionResource_postUnlock_post
386 */
387 public function unlockDevice(VBODooraccessIntegrationDevice $device, ?array $options = null)
388 {
389 // start transporter
390 $transporter = $this->createHTTPTransporter();
391
392 // make the API request
393 $response = $transporter->post('https://api.nuki.io/smartlock/' . $this->getDecimalDeviceID($device) . '/action/unlock', [], $this->httpHeaders, 60);
394
395 // obtain the response data (response should be empty in case of success)
396 $responseData = (array) json_decode((string) $response->body, true);
397
398 if (empty($response->code) || $response->code > 299) {
399 // an error occurred
400 throw new Exception($response->body ?: 'Error unlocking the device.', ($response->code ?: 500));
401 }
402
403 return (new VBODooraccessDeviceCapabilityResult)->setText(sprintf('The device "%s" was unlocked!', $device->getName()));
404 }
405
406 /**
407 * Device capability implementation to lock a device.
408 *
409 * @param VBODooraccessIntegrationDevice $device The device executing the capability.
410 * @param ?array $options Optional settings populated from capability parameters.
411 *
412 * @return VBODooraccessDeviceCapabilityResult
413 *
414 * @link https://api.nuki.io/#/Smartlock/SmartlockLockActionResource_postLock_post
415 */
416 public function lockDevice(VBODooraccessIntegrationDevice $device, ?array $options = null)
417 {
418 // start transporter
419 $transporter = $this->createHTTPTransporter();
420
421 // make the API request
422 $response = $transporter->post('https://api.nuki.io/smartlock/' . $this->getDecimalDeviceID($device) . '/action/lock', [], $this->httpHeaders, 60);
423
424 // obtain the response data (response should be empty in case of success)
425 $responseData = (array) json_decode((string) $response->body, true);
426
427 if (empty($response->code) || $response->code > 299) {
428 // an error occurred
429 throw new Exception($response->body ?: 'Error locking the device.', ($response->code ?: 500));
430 }
431
432 return (new VBODooraccessDeviceCapabilityResult)->setText(sprintf('The device "%s" was locked!', $device->getName()));
433 }
434
435 /**
436 * Device capability implementation to list a device authorization codes.
437 *
438 * @param VBODooraccessIntegrationDevice $device The device executing the capability.
439 * @param ?array $options Optional settings populated from capability parameters.
440 *
441 * @return VBODooraccessDeviceCapabilityResult
442 *
443 * @throws Exception
444 *
445 * @link https://api.nuki.io/#/SmartlockAuth/SmartlocksAuthsResource_get_get
446 */
447 public function listPasscodes(VBODooraccessIntegrationDevice $device, ?array $options = null)
448 {
449 // start transporter
450 $transporter = $this->createHTTPTransporter();
451
452 // check if a specific passcode name should be matched from the API response (filter not supported)
453 $searchPasscode = $options['search'] ?? null;
454
455 // build request data
456 $data = [
457 // type option could be "any", but we only accept integers, to be casted to string
458 // as the query-string filter accept a list of comma-separated values (i.e. "0,2,13")
459 'types' => is_numeric($options['type'] ?? null) ? (string) $options['type'] : null,
460 ];
461
462 // make a request to obtain all created passcodes of a lock
463 $response = $transporter->get('https://api.nuki.io/smartlock/' . $this->getDecimalDeviceID($device) . '/auth?' . http_build_query(array_filter($data, function($value) {
464 // allow to filter by type "0" which equals to "App"
465 return !empty($value) || $value === '0';
466 })), $this->httpHeaders, 20);
467
468 // obtain the response data
469 $responseData = (array) json_decode((string) $response->body, true);
470
471 if (empty($response->code) || $response->code > 299) {
472 // an error occurred
473 throw new Exception($response->body ?: 'Error fetching device passcodes.', ($response->code ?: 500));
474 }
475
476 // returned passcodes list expected
477 $passcodes = $responseData;
478
479 if ($searchPasscode && is_array($passcodes)) {
480 // filter passcodes found by the given name
481 $passcodes = array_filter($passcodes, function($authPasscode) use ($searchPasscode) {
482 // passcode name must match the given one as we are doing an exact search
483 return ($authPasscode['name'] ?? '') === $searchPasscode;
484 });
485 }
486
487 if (!$passcodes) {
488 throw new Exception('No authorization codes found for the device.', 404);
489 }
490
491 // list of passcode IDs and values obtained
492 $passcodesAssoc = [];
493
494 // build HTML output
495 $output = '';
496
497 // lang defs
498 $lang_passcode = JText::translate('VBO_PASSCODE');
499 $lang_startdate = JText::translate('VBNEWPKGDFROM');
500 $lang_enddate = JText::translate('VBNEWPKGDTO');
501 $lang_createdon = JText::translate('VBOINVCREATIONDATE');
502 $lang_createdby = JText::translate('VBCSVCREATEDBY');
503
504 // table head
505 $output .= <<<HTML
506 <div class="vbo-dac-table-wrap">
507 <table class="vbo-dac-table">
508 <thead>
509 <tr>
510 <td>Passcode ID</td>
511 <td>Passcode Name</td>
512 <td>{$lang_passcode}</td>
513 <td>Passcode Type</td>
514 <td>{$lang_startdate}</td>
515 <td>{$lang_enddate}</td>
516 <td>{$lang_createdon}</td>
517 <td>{$lang_createdby}</td>
518 </tr>
519 </thead>
520 <tbody>
521 HTML;
522
523 // scan all passcodes obtained
524 foreach ($passcodes as $passcode) {
525 // set passcode properties
526 $passcodeId = $passcode['id'] ?? '';
527 $passcodeValue = $passcode['code'] ?? '';
528 $passcodeName = $passcode['name'] ?? '';
529 $passcodeType = $this->getPasscodeTypes((int) ($passcode['type'] ?? 0), true);
530 $startDate = ($passcode['allowedFromDate'] ?? '') ? JHtml::fetch('date', $passcode['allowedFromDate'], 'Y-m-d H:i:s') : '---';
531 $endDate = ($passcode['allowedUntilDate'] ?? '') ? JHtml::fetch('date', $passcode['allowedUntilDate'], 'Y-m-d H:i:s') : '---';
532 $sendDate = ($passcode['creationDate'] ?? '') ? JHtml::fetch('date', $passcode['creationDate'], 'Y-m-d H:i:s') : '---';
533 $senderUsername = $passcode['accountUserId'] ?? '';
534
535 // bind passcode id values
536 $passcodesAssoc[$passcodeId] = [
537 'name' => $passcodeName,
538 'value' => $passcodeValue,
539 ];
540
541 // build passcode HTML code
542 $output .= <<<HTML
543 <tr>
544 <td><span class="vbo-dac-table-passcode-id">{$passcodeId}</span></td>
545 <td><span class="vbo-dac-table-passcode-name">{$passcodeName}</span></td>
546 <td><span class="vbo-dac-table-passcode-code">{$passcodeValue}</span></td>
547 <td>{$passcodeType}</td>
548 <td>{$startDate}</td>
549 <td>{$endDate}</td>
550 <td>{$sendDate}</td>
551 <td>{$senderUsername}</td>
552 </tr>
553 HTML;
554 }
555
556 // close table
557 $output .= <<<HTML
558 </tbody>
559 </table>
560 </div>
561 HTML;
562
563 // return the capability result object by setting the output value
564 return (new VBODooraccessDeviceCapabilityResult($passcodesAssoc))
565 ->setOutput($output);
566 }
567
568 /**
569 * Device capability implementation to create a custom passcode for a device.
570 *
571 * @param VBODooraccessIntegrationDevice $device The device executing the capability.
572 * @param ?array $options Optional settings populated from capability parameters.
573 *
574 * @return VBODooraccessDeviceCapabilityResult
575 *
576 * @link https://api.nuki.io/#/SmartlockAuth/SmartlockAuthsResource_put_put
577 */
578 public function createCustomPasscode(VBODooraccessIntegrationDevice $device, ?array $options = null)
579 {
580 // start transporter (by setting the Content-Type header)
581 $transporter = $this->createHTTPTransporter([
582 'headers' => [
583 'Content-Type' => 'application/json',
584 ],
585 ]);
586
587 if (empty($options['pwdvalue'])) {
588 // passcode value cannot be empty
589 $options['pwdvalue'] = $this->generateRandomPasscode();
590 }
591
592 // build request data
593 $data = [
594 'name' => $options['pwdname'] ?? null,
595 'allowedFromDate' => (($options['startdate'] ?? '') ? $this->getDateRFC3339($options['startdate']) : null),
596 'allowedUntilDate' => (($options['enddate'] ?? '') ? $this->getDateRFC3339($options['enddate']) : null),
597 'allowedWeekDays' => 127,
598 'allowedFromTime' => 0,
599 'allowedUntilTime' => 0,
600 'remoteAllowed' => true,
601 'type' => 13,
602 'code' => (int) $options['pwdvalue'],
603 ];
604
605 // make the API request
606 $response = $transporter->put('https://api.nuki.io/smartlock/' . $this->getDecimalDeviceID($device) . '/auth', json_encode($data), $this->httpHeaders, 60);
607
608 // obtain the response data (should be empty in case of success)
609 $responseData = (array) json_decode((string) $response->body, true);
610
611 if (empty($response->code) || $response->code > 299) {
612 // an error occurred, build DAC Exception with retry-data
613 $dacError = (new VBODooraccessException($response->body ?: 'Error adding a custom passcode to the device.', ($response->code ?: 500)))
614 ->setDevice($device)
615 ->setRetryCallback('createCustomPasscode')
616 ->setRetryData($options);
617
618 // throw error
619 throw $dacError;
620 }
621
622 // build result properties to bind (operation is asynchronous, so we don't immediately get an authorization ID for the new access code)
623 $resultProps = [
624 'code' => (string) $options['pwdvalue'],
625 'name' => (string) ($options['pwdname'] ?? ''),
626 'listingId' => (int) ($options['listing_id'] ?? 0),
627 ];
628
629 // get the listing name, if available
630 $listingName = '';
631 if (!empty($resultProps['listingId'])) {
632 $listingData = VikBooking::getRoomInfo($resultProps['listingId'], ['name'], true);
633 $listingName = sprintf('%s: ', $listingData['name'] ?? '');
634 }
635
636 // wrap and return the device capability result object
637 return (new VBODooraccessDeviceCapabilityResult($resultProps))
638 ->setPasscode($resultProps['code'])
639 ->setText($listingName . JText::sprintf('VBO_PASSCODE_GEN_OK_DEVICE', $resultProps['code'], $device->getName()));
640 }
641
642 /**
643 * Device capability implementation to delete a passcode from a device.
644 *
645 * @param VBODooraccessIntegrationDevice $device The device executing the capability.
646 * @param ?array $options Optional settings populated from capability parameters.
647 *
648 * @return VBODooraccessDeviceCapabilityResult
649 *
650 * @link https://api.nuki.io/#/SmartlockAuth/SmartlockAuthResource_delete_delete
651 */
652 public function deletePasscode(VBODooraccessIntegrationDevice $device, ?array $options = null)
653 {
654 if (empty($options['pwdid'])) {
655 throw new Exception($response->body ?: 'Missing authorization (access code) ID to delete.', 400);
656 }
657
658 // start transporter
659 $transporter = $this->createHTTPTransporter();
660
661 // make the API request
662 $response = $transporter->delete('https://api.nuki.io/smartlock/' . $this->getDecimalDeviceID($device) . '/auth/' . $options['pwdid'], [], $this->httpHeaders, 60);
663
664 // obtain the response data
665 $responseData = (array) json_decode((string) $response->body, true);
666
667 if (empty($response->code) || $response->code > 299) {
668 // an error occurred
669 throw new Exception($response->body ?: 'Error deleting passcode from device.', ($response->code ?: 500));
670 }
671
672 return (new VBODooraccessDeviceCapabilityResult)
673 ->setText(JText::sprintf('VBO_PASSCODE_DEL_OK_DEVICE', $device->getName()));
674 }
675
676 /**
677 * Device capability implementation to update a passcode from a device.
678 *
679 * @param VBODooraccessIntegrationDevice $device The device executing the capability.
680 * @param ?array $options Optional settings populated from capability parameters.
681 *
682 * @return VBODooraccessDeviceCapabilityResult
683 *
684 * @link https://api.nuki.io/#/SmartlockAuth/SmartlockAuthResource_post_post
685 */
686 public function updatePasscode(VBODooraccessIntegrationDevice $device, ?array $options = null)
687 {
688 if (empty($options['pwdid'])) {
689 throw new Exception($response->body ?: 'Missing authorization (access code) ID to update.', 400);
690 }
691
692 // start transporter (by setting the Content-Type header)
693 $transporter = $this->createHTTPTransporter([
694 'headers' => [
695 'Content-Type' => 'application/json',
696 ],
697 ]);
698
699 // build request data
700 $data = [
701 'allowedFromDate' => (($options['startdate'] ?? '') ? $this->getDateRFC3339($options['startdate']) : null),
702 'allowedUntilDate' => (($options['enddate'] ?? '') ? $this->getDateRFC3339($options['enddate']) : null),
703 'allowedWeekDays' => 127,
704 'allowedFromTime' => 0,
705 'allowedUntilTime' => 0,
706 'remoteAllowed' => true,
707 ];
708
709 // make the API request
710 $response = $transporter->post('https://api.nuki.io/smartlock/' . $this->getDecimalDeviceID($device) . '/auth/' . $options['pwdid'], json_encode($data), $this->httpHeaders, 60);
711
712 // obtain the response data
713 $responseData = (array) json_decode((string) $response->body, true);
714
715 if (empty($response->code) || $response->code > 299) {
716 // an error occurred
717 throw new Exception($response->body ?: 'Error updating passcode on device.', ($response->code ?: 500));
718 }
719
720 return (new VBODooraccessDeviceCapabilityResult)
721 ->setText(JText::sprintf('VBO_PASSCODE_UPD_OK_DEVICE', $device->getName()));
722 }
723
724 /**
725 * Device capability implementation to show the list of activity logs of a device.
726 *
727 * @param VBODooraccessIntegrationDevice $device The device executing the capability.
728 * @param ?array $options Optional settings populated from capability parameters.
729 *
730 * @return VBODooraccessDeviceCapabilityResult
731 *
732 * @throws Exception
733 *
734 * @link https://api.nuki.io/#/SmartlockLog/SmartlockLogsResource_get_get
735 */
736 public function showActivityLogs(VBODooraccessIntegrationDevice $device, ?array $options = null)
737 {
738 // start transporter
739 $transporter = $this->createHTTPTransporter();
740
741 // build query string data
742 $data = [
743 'fromDate' => (!empty($options['startdate']) ? $this->getDateRFC3339($options['startdate']) : null),
744 'toDate' => (!empty($options['enddate']) ? $this->getDateRFC3339($options['enddate']) : null),
745 'limit' => 50,
746 ];
747
748 // make a request to obtain the logs of a lock
749 $response = $transporter->get('https://api.nuki.io/smartlock/' . $this->getDecimalDeviceID($device) . '/log?' . http_build_query(array_filter($data)), $this->httpHeaders, 20);
750
751 // obtain the response data
752 $responseData = (array) json_decode((string) $response->body, true);
753
754 if (empty($response->code) || $response->code > 299) {
755 // an error occurred
756 throw new Exception($response->body ?: 'Error fetching device logs.', ($response->code ?: 500));
757 }
758
759 $activities = $responseData;
760
761 if (!is_array($activities) || !$activities) {
762 throw new Exception(sprintf("No log records found for the device.\n%s", print_r($activities, true)), 404);
763 }
764
765 // build HTML output
766 $output = '';
767
768 // lang defs
769 $lang_createdon = JText::translate('VBOINVCREATIONDATE');
770
771 // table head
772 $output .= <<<HTML
773 <div class="vbo-dac-table-wrap">
774 <table class="vbo-dac-table">
775 <thead>
776 <tr>
777 <td>Log ID</td>
778 <td>Name</td>
779 <td>Passcode ID</td>
780 <td>Action</td>
781 <td>Trigger</td>
782 <td>State</td>
783 <td>Source</td>
784 <td>{$lang_createdon}</td>
785 </tr>
786 </thead>
787 <tbody>
788 HTML;
789
790 // scan all activities obtained
791 foreach ($activities as $activity) {
792 // set activity properties
793 $activityId = $activity['id'] ?? '';
794 $activityName = $activity['name'] ?? '';
795 $activityAuthId = $activity['authId'] ?? '';
796 $activityAction = $this->getActivityAction((int) ($activity['action'] ?? 0));
797 $activityTrigger = $this->getActivityTrigger((int) ($activity['trigger'] ?? 0));
798 $activityState = $this->getActivityState((int) ($activity['state'] ?? 0));
799 $activitySource = $this->getActivitySource((int) ($activity['source'] ?? 0));
800 $activityDate = ($activity['date'] ?? '') ? JHtml::fetch('date', $activity['date'], 'Y-m-d H:i:s') : '---';
801
802 // build passcode HTML code
803 $output .= <<<HTML
804 <tr>
805 <td><span class="vbo-dac-table-passcode-id">{$activityId}</span></td>
806 <td><span class="vbo-dac-table-passcode-name">{$activityName}</span></td>
807 <td><span class="vbo-dac-table-passcode-id">{$activityAuthId}</span></td>
808 <td>{$activityAction}</td>
809 <td>{$activityTrigger}</td>
810 <td>{$activityState}</td>
811 <td>{$activitySource}</td>
812 <td>{$activityDate}</td>
813 </tr>
814 HTML;
815 }
816
817 // close table
818 $output .= <<<HTML
819 </tbody>
820 </table>
821 </div>
822 HTML;
823
824 // return the capability result object by setting the output value
825 return (new VBODooraccessDeviceCapabilityResult($activities))
826 ->setOutput($output);
827 }
828
829 /**
830 * @inheritDoc
831 */
832 public function createBookingDoorAccess(VBODooraccessIntegrationDevice $device, int $listingId, VBOBookingRegistry $registry)
833 {
834 // access the integration settings
835 $settings = $this->getSettings();
836
837 // build booking-listing signature
838 $signature = sprintf('%d-%d', $registry->getID(), $listingId);
839
840 // access booking registry DAC data for passcodes generated
841 $passcodesBuffer = $registry->getDACProperty($this->getAlias(), 'passcodes', []);
842
843 // determine the passcode value to use, either a new one or a previous one for the same booking
844 if (($settings['passquant'] ?? 0) == 2 && ($passcodesBuffer[$signature] ?? null)) {
845 // use the previously generated passcode for this booking and listing also on this device
846 $passcodeValue = $passcodesBuffer[$signature];
847 } else {
848 // generate custom, yet random, passcode value of 6 digits for this device
849 $passcodeValue = $this->generateRandomPasscode();
850 }
851
852 // prepare the options for creating a custom passcode (randomly generated by us)
853 $options = [
854 // use a password name that can be used later to find it under this booking and listing ID
855 'pwdname' => sprintf('bid:%d-%d', $registry->getID(), $listingId),
856 // set the passcode validity start date and time
857 'startdate' => date('Y-m-d H:i:00', $registry->getProperty('checkin', 0)),
858 // set the passcode validity end date and time
859 'enddate' => date('Y-m-d H:i:00', $registry->getProperty('checkout', 0)),
860 // custom passcode value to create on the device
861 'pwdvalue' => $passcodeValue,
862 // inject the listing ID for completion of data
863 'listing_id' => $listingId,
864 ];
865
866 // create custom passcode on the current device
867 $result = $this->createCustomPasscode($device, $options);
868
869 // update booking registry DAC data for passcodes generated
870 $passcodesBuffer[$signature] = $result->getPasscode();
871 $registry->setDACProperty($this->getAlias(), 'passcodes', $passcodesBuffer);
872
873 return $result;
874 }
875
876 /**
877 * @inheritDoc
878 */
879 public function modifyBookingDoorAccess(VBODooraccessIntegrationDevice $device, int $listingId, VBOBookingRegistry $registry)
880 {
881 // searching, deleting and re-creating passcodes is always safer in case of
882 // booking modification for possibly different listing IDs involved
883
884 // find the passcode data that were previously created for this booking
885 $previousDevicePasscodes = VikBooking::getBookingHistoryInstance($registry->getID())
886 ->getEventsWithData(['ND', 'MD'], function($data) use ($device) {
887 $data = (array) $data;
888 // ensure the passcode was generated for this provider, profile and device
889 return ($data['provider'] ?? '') == $this->getProfileProvider() &&
890 ($data['profile'] ?? '') == $this->getProfileID() &&
891 ($data['device'] ?? '') == $device->getID() &&
892 (!empty($data['passcode']) || !empty($data['props']));
893 });
894
895 if (!$previousDevicePasscodes) {
896 // no passcodes were previously created for this booking
897 // process the modification as a new door access creation
898 return $this->createBookingDoorAccess($device, $listingId, $registry);
899 }
900
901 // scan all previously created passcodes in DESC order on this device and delete them
902 $previousPasscodeNamings = [];
903 foreach (array_reverse($previousDevicePasscodes) as $previousData) {
904 // ensure we only have array values
905 $previousData = (array) json_decode(json_encode($previousData), true);
906
907 /**
908 * The generation of booking passcodes is asynchronous, hence we don't immediately get and store the
909 * passcode ID to be used for deleting it immediately. We need to "search" the passcode by name.
910 */
911
912 // get the previous passcode name
913 $previousPasscodeName = ($previousData['props']['name'] ?? '');
914
915 if (empty($previousPasscodeName) || in_array($previousPasscodeName, $previousPasscodeNamings)) {
916 // no passcode name to search and delete, or already deleted
917 continue;
918 }
919
920 // push processed passcode name
921 $previousPasscodeNamings[] = $previousPasscodeName;
922
923 try {
924 // attempt to find the passcode on this device by name, which includes booking and listing IDs
925 $findResult = $this->listPasscodes($device, [
926 // type "Keypad"
927 'type' => 13,
928 // inject search property to match this exact passcode name
929 'search' => sprintf('bid:%d-%d', $registry->getID(), $listingId),
930 ]);
931
932 if (!$findResult->getProperties()) {
933 // passcode not found by name
934 throw new Exception('Previous passcode not found by name.', 404);
935 }
936
937 // iterate the list of passcodes found, even if only one is expected
938 foreach ($findResult->getProperties() as $pwdId => $pwdData) {
939 // delete the first passcode found, previously created for this booking
940 $this->deletePasscode($device, [
941 'pwdid' => $pwdId,
942 ]);
943
944 // abort loop at first run
945 break;
946 }
947 } catch (Exception $e) {
948 // do nothing on error with cancellations or previous passcodes not found
949 }
950 }
951
952 // process the modification as a new door access creation, always with custom passcodes
953 return $this->createBookingDoorAccess($device, $listingId, $registry);
954 }
955
956 /**
957 * @inheritDoc
958 */
959 public function cancelBookingDoorAccess(VBODooraccessIntegrationDevice $device, int $listingId, VBOBookingRegistry $registry)
960 {
961 try {
962 // find the previously created passcode for this booking and listing
963 $findResult = $this->listPasscodes($device, [
964 // type "Keypad"
965 'type' => 13,
966 // inject search property to match this exact passcode name
967 'search' => sprintf('bid:%d-%d', $registry->getID(), $listingId),
968 ]);
969
970 if (!$findResult->getProperties()) {
971 // passcode not found
972 throw new Exception('Previous passcode not found.', 404);
973 }
974 } catch (Exception $e) {
975 // nothing to cancel, but prevent unwanted errors not related to the real cancellation
976 return null;
977 }
978
979 // iterate the list of passcodes found, even if only one is expected
980 foreach ($findResult->getProperties() as $pwdId => $pwdData) {
981 // delete the first passcode found
982 return $this->deletePasscode($device, [
983 'pwdid' => $pwdId,
984 ]);
985 }
986 }
987
988 /**
989 * @inheritDoc
990 */
991 public function handleUnlockDevice(VBODooraccessIntegrationDevice $device)
992 {
993 // unlock the requested device
994 return $this->unlockDevice($device);
995 }
996
997 /**
998 * @inheritDoc
999 */
1000 public function getPasscodeFromHistoryResult(array $resultProperties)
1001 {
1002 // creating a passcode should bind its value within the device capability result object
1003 return $resultProperties['code'] ?? null;
1004 }
1005
1006 /**
1007 * @inheritDoc
1008 *
1009 * @link https://api.nuki.io/#/Smartlock/SmartlocksResource_get_get
1010 */
1011 protected function fetchRemoteDevices()
1012 {
1013 // start transporter
1014 $transporter = $this->createHTTPTransporter();
1015
1016 // make a request to obtain the lock list of an account
1017 $response = $transporter->get('https://api.nuki.io/smartlock', $this->httpHeaders, 20);
1018
1019 // obtain the response data
1020 $responseData = (array) json_decode((string) $response->body, true);
1021
1022 if (empty($response->code) || $response->code > 299) {
1023 // an error occurred
1024 throw new Exception($response->body ?: 'Error fetching the remove devices.', ($response->code ?: 500));
1025 }
1026
1027 // get the list of devices returned
1028 $devices = $responseData;
1029
1030 if (empty($devices[0]['smartlockId'])) {
1031 throw new Exception('No devices found under the current account.', 500);
1032 }
1033
1034 return $devices;
1035 }
1036
1037 /**
1038 * @inheritDoc
1039 */
1040 protected function decorateDeviceProperties(VBODooraccessIntegrationDevice $decorator, array $device)
1041 {
1042 // set device ID
1043 $decorator->setID($device['smartlockId'] ?? '');
1044
1045 // set device name
1046 $decorator->setName($device['name'] ?? '');
1047
1048 // set device description
1049 $decorator->setDescription($device['accountId'] ?? '');
1050
1051 // set device icon
1052 $decorator->setIcon('<i class="' . VikBookingIcons::i('fingerprint') . '"></i>');
1053
1054 // map device model (type)
1055 $modelName = ($device['type'] ?? '') ? $this->getDeviceTypes((int) $device['type'], true) : '';
1056
1057 // set device model
1058 $decorator->setModel($modelName);
1059
1060 if ($device['state']['batteryCharge'] ?? null) {
1061 // set device battery level
1062 $decorator->setBatteryLevel((float) $device['state']['batteryCharge']);
1063 }
1064
1065 // set device capabilities
1066 $decorator->setCapabilities([
1067 // unlock device
1068 $this->createDeviceCapability([
1069 'id' => 'unlock_device',
1070 'title' => JText::translate('VBDASHUNLOCK'),
1071 'description' => JText::translate('VBO_UNLOCK_DEVICE_HELP'),
1072 'icon' => '<i class="' . VikBookingIcons::i('unlock') . '"></i>',
1073 'callback' => 'unlockDevice',
1074 ]),
1075 // lock device
1076 $this->createDeviceCapability([
1077 'id' => 'lock_device',
1078 'title' => JText::translate('VBO_LOCK'),
1079 'description' => JText::translate('VBO_LOCK_DEVICE_HELP'),
1080 'icon' => '<i class="' . VikBookingIcons::i('lock') . '"></i>',
1081 'callback' => 'lockDevice',
1082 ]),
1083 // read passcodes
1084 $this->createDeviceCapability([
1085 'id' => 'list_passcodes',
1086 'title' => JText::translate('VBO_LIST_PASSCODES'),
1087 'description' => JText::translate('VBO_LIST_PASSCODES_HELP'),
1088 'icon' => '<i class="' . VikBookingIcons::i('key') . '"></i>',
1089 'callback' => 'listPasscodes',
1090 'params' => [
1091 'type' => [
1092 'type' => 'select',
1093 'label' => JText::translate('VBPSHOWSEASONSTHREE'),
1094 'options' => [
1095 'any' => sprintf('- %s', JText::translate('VBANYTHING')),
1096 13 => 'Keypad',
1097 0 => 'App',
1098 2 => 'Fob',
1099 ],
1100 ],
1101 ],
1102 ]),
1103 // create (custom) passcode
1104 $this->createDeviceCapability([
1105 'id' => 'create_custom_passcode',
1106 'title' => JText::translate('VBO_CREATE_PASSCODECUST'),
1107 'description' => JText::translate('VBO_CREATE_PASSCODECUST_HELP'),
1108 'icon' => '<i class="' . VikBookingIcons::i('user-plus') . '"></i>',
1109 'callback' => 'createCustomPasscode',
1110 'params' => [
1111 'pwdvalue' => [
1112 'type' => 'text',
1113 'label' => JText::translate('VBO_PASSCODE'),
1114 'help' => JText::translate('VBO_PASSCODE_EMPTY_HELP') . ' 6 digits (1-9), should not start with 12 and should not contain 0.',
1115 'attributes' => [
1116 'pattern' => '^(?!12)[1-9]{6}$',
1117 ],
1118 ],
1119 'pwdname' => [
1120 'type' => 'text',
1121 'label' => JText::translate('VBO_PASSCODE_NAME'),
1122 'help' => JText::translate('VBO_OPT_PASSCODE_NAME'),
1123 ],
1124 'startdate' => [
1125 'type' => 'datetime',
1126 'label' => JText::translate('VBNEWPKGDFROM'),
1127 'help' => JText::translate('VBO_PASSCODE_VALID_START'),
1128 ],
1129 'enddate' => [
1130 'type' => 'datetime',
1131 'label' => JText::translate('VBNEWPKGDTO'),
1132 'help' => JText::translate('VBO_PASSCODE_VALID_END'),
1133 ],
1134 ],
1135 ]),
1136 // delete passcode
1137 $this->createDeviceCapability([
1138 'id' => 'delete_passcode',
1139 'title' => JText::translate('VBO_DELETE_PASSCODE'),
1140 'description' => JText::translate('VBO_DELETE_PASSCODE_HELP'),
1141 'icon' => '<i class="' . VikBookingIcons::i('trash') . '"></i>',
1142 'callback' => 'deletePasscode',
1143 'params' => [
1144 'pwdid' => [
1145 'type' => 'text',
1146 'label' => 'Passcode ID',
1147 ],
1148 ],
1149 ]),
1150 // update passcode
1151 $this->createDeviceCapability([
1152 'id' => 'update_passcode',
1153 'title' => JText::translate('VBO_UPDATE_PASSCODE'),
1154 'description' => JText::translate('VBO_UPDATE_PASSCODE_HELP'),
1155 'icon' => '<i class="' . VikBookingIcons::i('user-plus') . '"></i>',
1156 'callback' => 'updatePasscode',
1157 'params' => [
1158 'pwdid' => [
1159 'type' => 'text',
1160 'label' => 'Passcode ID',
1161 ],
1162 'startdate' => [
1163 'type' => 'datetime',
1164 'label' => JText::translate('VBNEWPKGDFROM'),
1165 'help' => JText::translate('VBO_PASSCODE_VALID_START'),
1166 ],
1167 'enddate' => [
1168 'type' => 'datetime',
1169 'label' => JText::translate('VBNEWPKGDTO'),
1170 'help' => JText::translate('VBO_PASSCODE_VALID_END'),
1171 ],
1172 ],
1173 ]),
1174 // show activity logs
1175 $this->createDeviceCapability([
1176 'id' => 'activity_logs',
1177 'title' => JText::translate('VBO_ACTIVITY_LOGS'),
1178 'description' => JText::translate('VBO_ACTIVITY_LOGS_HELP'),
1179 'icon' => '<i class="' . VikBookingIcons::i('search') . '"></i>',
1180 'callback' => 'showActivityLogs',
1181 'params' => [
1182 'startdate' => [
1183 'type' => 'datetime',
1184 'label' => JText::translate('VBOREPORTSDATEFROM'),
1185 ],
1186 'enddate' => [
1187 'type' => 'datetime',
1188 'label' => JText::translate('VBOREPORTSDATETO'),
1189 ],
1190 ],
1191 ]),
1192 ]);
1193
1194 // set device payload by unsetting the unwanted properties
1195 unset(
1196 $device['advancedConfig'],
1197 $device['openerAdvancedConfig'],
1198 $device['smartdoorAdvancedConfig'],
1199 $device['webConfig'],
1200 $device['previousSubscriptions'],
1201 $device['currentSubscription']
1202 );
1203 $decorator->setPayload($device);
1204 }
1205
1206 /**
1207 * Handles a Nuki Webhook notification of type "DEVICE_STATUS".
1208 *
1209 * @param ?array $data The notification payload array.
1210 *
1211 * @return void
1212 *
1213 * @throws Exception
1214 */
1215 protected function webhookHandleDeviceStatus(?array $data = null)
1216 {
1217 if (empty($data['smartlockId'])) {
1218 // useless to proceed without knowing the device ID
1219 // abort with no error status codes
1220 throw new Exception('Missing smartlock ID.', 200);
1221 }
1222
1223 // access the current device by ID
1224 try {
1225 $device = $this->getDeviceById((string) $data['smartlockId']);
1226 } catch (Exception $e) {
1227 // abort with no error status codes
1228 throw new Exception($e->getMessage(), 200);
1229 }
1230
1231 if (is_numeric($data['state']['batteryCharge'] ?? null)) {
1232 // update the device battery level
1233 $device->setBatteryLevel((float) $data['state']['batteryCharge']);
1234 }
1235
1236 // update current device
1237 try {
1238 VBODooraccessFactory::getInstance()->saveIntegrationRecord($this, ['devices' => $this->getDevices()]);
1239 } catch (Exception $e) {
1240 // abort with no error status codes
1241 throw new Exception($e->getMessage(), 200);
1242 }
1243 }
1244
1245 /**
1246 * Handles a Nuki Webhook notification of type "DEVICE_LOGS".
1247 *
1248 * @param ?array $data The notification payload array.
1249 *
1250 * @return void
1251 *
1252 * @throws Exception
1253 */
1254 protected function webhookHandleDeviceLogs(?array $data = null)
1255 {
1256 if (empty($data['smartlockLog']['smartlockId'])) {
1257 // useless to proceed without knowing the device ID
1258 // abort with no error status codes
1259 throw new Exception('Missing smartlock ID.', 200);
1260 }
1261
1262 // access the current device by ID
1263 try {
1264 $device = $this->getDeviceById((string) $data['smartlockLog']['smartlockId']);
1265 } catch (Exception $e) {
1266 // abort with no error status codes
1267 throw new Exception($e->getMessage(), 200);
1268 }
1269
1270 // identify log action, trigger, state and source
1271 $logAction = $data['smartlockLog']['action'] ?? null;
1272 $logTrigger = $data['smartlockLog']['trigger'] ?? null;
1273 $logState = $data['smartlockLog']['state'] ?? null;
1274 $logSource = $data['smartlockLog']['source'] ?? null;
1275
1276 /**
1277 * Check if the smartlock log refers to a successful authentication code through keypad.
1278 *
1279 * Action: should be 3, 1 or 5 (unlatch, unlock, lock-n-go with unlatch).
1280 * Trigger: should be 255 (keypad).
1281 * State: should be 0 (successful).
1282 * Source: should be 1 or 2 (keypad or fingerprint).
1283 */
1284 if (($logAction == 1 || $logAction == 3) && $logTrigger == 255 && $logState == 0 && ($logSource == 1 || $logSource == 2)) {
1285 // the device webhook log refers to a keypad/fingerprint successfuly authentication
1286
1287 // attempt to match a previously generated passcode name and authentication ID
1288 if (empty($data['smartlockLog']['authId']) || !preg_match('/^bid\:([0-9]+)\-([0-9]+)$/', (string) ($data['smartlockLog']['name'] ?? ''), $matches)) {
1289 // silently abort with a success code in order to not break the webhook response
1290 throw new Exception('Smartlock log verified with no needed actions.', 200);
1291 }
1292
1293 // identify booking ID and booking room ID from authentication code name
1294 $bookingId = (int) $matches[1];
1295 $bookingRoomId = (int) $matches[2];
1296
1297 // obtain the booking details
1298 $booking = VikBooking::getBookingInfoFromID($bookingId);
1299
1300 if (!$booking) {
1301 // silently abort with a success code in order to not break the webhook response
1302 throw new Exception('Smartlock log verified with no booking details.', 200);
1303 }
1304
1305 // wrap the booking information into a registry
1306 $registry = VBOBookingRegistry::getInstance($booking);
1307
1308 // obtain the authentication ID
1309 $authenticationId = (string) $data['smartlockLog']['authId'];
1310
1311 // make sure a first access notification for this booking was not already processed
1312 $history = VikBooking::getBookingHistoryInstance($booking['id']);
1313
1314 if ($history->hasEvent('FA')) {
1315 // silently abort with a success code in order to not break the webhook response
1316 throw new Exception('Smartlock log already processed.', 200);
1317 }
1318
1319 // we trust the webhook notification authentication ID to be the one previously generated
1320 // without performing any API request to match the authentication code saved in the history
1321
1322 // store booking history record
1323 VikBooking::getBookingHistoryInstance($registry->getID())
1324 ->setBookingData($registry->getData(), $registry->getRooms())
1325 ->setExtraData([
1326 'provider' => $this->getProfileProvider(),
1327 'profile' => $this->getProfileID(),
1328 'device' => $device->getID(),
1329 ])
1330 ->store('FA', sprintf('%s - %s: %s', (string) $this->getProfileName(), (string) $device->getName(), $authenticationId));
1331
1332 // store an entry within the notifications center for the successful operation
1333 VBOFactory::getNotificationCenter()
1334 ->store([
1335 [
1336 'sender' => 'dac',
1337 'type' => 'dac.FA.ok',
1338 'title' => sprintf('%s - %s', (string) $this->getProfileName(), (string) $device->getName()),
1339 'summary' => sprintf('%s: %s', JText::translate('VBOBOOKHISTORYTFA'), $authenticationId),
1340 'idorder' => $registry->getID(),
1341 'avatar' => preg_match('/^http/', (string) $this->getIcon()) ? $this->getIcon() : null,
1342 ],
1343 ]);
1344
1345 // terminate and go no further
1346 return;
1347 }
1348 }
1349
1350 /**
1351 * Given a date-time string in military format, returns the ISO 8601 / RFC 3339 date.
1352 *
1353 * @param string $dateTime The datetime in military format, eventually with time.
1354 *
1355 * @return string The formatted date in UTC timezone.
1356 */
1357 private function getDateRFC3339(string $dateTime)
1358 {
1359 // construct datetime object
1360 $dt = new DateTime($dateTime, new DateTimeZone(date_default_timezone_get()));
1361
1362 // force the timezone to be UTC
1363 $dt->setTimezone(new DateTimeZone('UTC'));
1364
1365 // return the formatted date
1366 return $dt->format('Y-m-d\TH:i:s.v\Z');
1367 }
1368
1369 /**
1370 * HTTP requests towards Nuki require the smartlock ID to be an integer, but
1371 * for some locks, the value fetched is in hexadecimal format. In order to
1372 * convert it into decimal format, hence integer, we also need to prefix a
1373 * number matching the device type to obtain a valid decimal ID for the lock.
1374 *
1375 * @param VBODooraccessIntegrationDevice $device The device to parse.
1376 *
1377 * @return int The decimal smartlock ID.
1378 */
1379 private function getDecimalDeviceID(VBODooraccessIntegrationDevice $device)
1380 {
1381 // obtain the current device ID
1382 $deviceId = (string) $device->getID();
1383
1384 if (!preg_match('/(?=.*[a-fA-F])[0-9a-fA-F]+/', $deviceId)) {
1385 // no hexadecimal values found within the device ID, hence it's supposingly a decimal value
1386 return (int) $deviceId;
1387 }
1388
1389 // attempt to access the device type
1390 $devicePayload = $device->getPayload();
1391 $deviceTypeInfo = $this->getDeviceTypes($devicePayload['type'] ?? -1);
1392 $deviceTypeId = $deviceTypeInfo['type'] ?? 0;
1393
1394 // prefix the hexadecimal string with exact device type prefix
1395 $prefixedHex = $deviceTypeId . $deviceId;
1396
1397 // return the converted decimal ID for the device
1398 return (int) hexdec($prefixedHex);
1399 }
1400
1401 /**
1402 * Fetches the activity action name from the given code.
1403 *
1404 * @param int $code The activity action code.
1405 *
1406 * @return string
1407 */
1408 private function getActivityAction(int $code)
1409 {
1410 $list = [
1411 1 => 'unlock',
1412 2 => 'lock',
1413 3 => 'unlatch',
1414 4 => 'lock\'n\'go',
1415 5 => 'lock\'n\'go with unlatch',
1416 6 => 'activate cm',
1417 7 => 'deactivate cm',
1418 208 => 'door warning ajar',
1419 209 => 'door warning status mismatch',
1420 224 => 'doorbell recognition (only Opener)',
1421 240 => 'door opened',
1422 241 => 'door closed',
1423 242 => 'door sensor jammed',
1424 243 => 'firmware update',
1425 250 => 'door log enabled',
1426 251 => 'door log disabled',
1427 252 => 'initialization',
1428 253 => 'calibration',
1429 254 => '(activity) log enabled',
1430 255 => '(activity) log disabled',
1431 ];
1432
1433 return $list[$code] ?? '';
1434 }
1435
1436 /**
1437 * Fetches the activity trigger name from the given code.
1438 *
1439 * @param int $code The activity trigger code.
1440 *
1441 * @return string
1442 */
1443 private function getActivityTrigger(int $code)
1444 {
1445 $list = [
1446 0 => 'system (bluetooth)',
1447 1 => 'manual',
1448 2 => 'button',
1449 3 => 'automatic',
1450 4 => 'web',
1451 5 => 'app',
1452 6 => 'auto lock',
1453 7 => 'external accessory',
1454 255 => 'keypad',
1455 ];
1456
1457 return $list[$code] ?? '';
1458 }
1459
1460 /**
1461 * Fetches the activity state name from the given code.
1462 *
1463 * @param int $code The activity state code.
1464 *
1465 * @return string
1466 */
1467 private function getActivityState(int $code)
1468 {
1469 $list = [
1470 0 => 'Success',
1471 1 => 'Motor blocked',
1472 2 => 'Cancelled',
1473 3 => 'Too recent',
1474 4 => 'Busy',
1475 5 => 'Low motor voltage',
1476 6 => 'Clutch failure',
1477 7 => 'Motor power failure',
1478 8 => 'Incomplete',
1479 9 => 'Rejected',
1480 10 => 'Rejected night mode',
1481 254 => 'Other errors',
1482 255 => 'Unknown error',
1483 ];
1484
1485 return $list[$code] ?? '';
1486 }
1487
1488 /**
1489 * Fetches the activity source name from the given code.
1490 *
1491 * @param int $code The activity source code.
1492 *
1493 * @return string
1494 */
1495 private function getActivitySource(int $code)
1496 {
1497 $list = [
1498 0 => 'Default',
1499 1 => 'Keypad code',
1500 2 => 'Fingerprint',
1501 ];
1502
1503 return $list[$code] ?? '';
1504 }
1505
1506 /**
1507 * Maps the supported device type identifiers with name and prefix.
1508 *
1509 * @param ?int $type Optional device type identifier to fetch.
1510 * @param bool $name True to get only the device type name.
1511 *
1512 * @return array|string Full list, device type array or type string name.
1513 */
1514 private function getDeviceTypes(?int $type = null, bool $name = false)
1515 {
1516 // list of device types, whose key "type" is equal to the hexadecimal prefix
1517 $list = [
1518 0 => [
1519 'name' => 'Nuki Smartlock 1 or 2',
1520 'type' => '0',
1521 ],
1522 1 => [
1523 'name' => 'Nuki Box',
1524 'type' => '1',
1525 ],
1526 2 => [
1527 'name' => 'Nuki Opener',
1528 'type' => '2',
1529 ],
1530 3 => [
1531 'name' => 'Nuki Smartdoor',
1532 'type' => '3',
1533 ],
1534 4 => [
1535 'name' => 'Nuki Smartlock 3rd/4th Gen (Basic & Pro)',
1536 'type' => '4',
1537 ],
1538 5 => [
1539 'name' => 'Nuki Smartlock Ultra',
1540 'type' => '5',
1541 ],
1542 ];
1543
1544 if (is_null($type)) {
1545 return $list;
1546 }
1547
1548 if (!$name) {
1549 return $list[$type] ?? [];
1550 }
1551
1552 return $list[$type]['name'] ?? '';
1553 }
1554
1555 /**
1556 * Maps the supported passcode type identifiers with name and description.
1557 *
1558 * @param ?int $type Optional passcode type identifier to fetch.
1559 * @param bool $name True to get only the passcode name.
1560 *
1561 * @return array|string Full list, passcode type array or passcode string name.
1562 */
1563 private function getPasscodeTypes(?int $type = null, bool $name = false)
1564 {
1565 $list = [
1566 0 => [
1567 'name' => 'App',
1568 'descr' => 'Authentication code for the App.',
1569 ],
1570 2 => [
1571 'name' => 'Fob',
1572 'descr' => 'Authentication code for Fob.',
1573 ],
1574 13 => [
1575 'name' => 'Keypad',
1576 'descr' => 'Authentication code for the Keypad.',
1577 ],
1578 ];
1579
1580 if (is_null($type)) {
1581 return $list;
1582 }
1583
1584 if (!$name) {
1585 return $list[$type] ?? [];
1586 }
1587
1588 return $list[$type]['name'] ?? '';
1589 }
1590
1591 /**
1592 * Generates a random serial code made of only digits with a given length.
1593 * The sequence obtained will never contain zeros to support the Nuki Keypad
1594 * and it will not start with "12".
1595 *
1596 * @param int $length The passcode length.
1597 *
1598 * @return string
1599 */
1600 private function generateRandomPasscode(int $length = 6)
1601 {
1602 do {
1603 $passcode = VikBooking::getCPinInstance()->generateSerialCode($length, ['123456789']);
1604 } while (substr($passcode, 0, 2) == '12');
1605
1606 return $passcode;
1607 }
1608
1609 /**
1610 * Creates the HTTP Transporter to establish API connections with Nuki.
1611 * An integration profile record is supposed to be set before making an HTTP request.
1612 *
1613 * @param ?array $options Optional transporter options.
1614 *
1615 * @return object The prepared HTTP transporter object with bearer token.
1616 *
1617 * @throws Exception
1618 */
1619 private function createHTTPTransporter(?array $options = null)
1620 {
1621 // access current profile settings
1622 $settings = $this->getSettings();
1623
1624 // determine the authentication method: OAuth2 or API Token
1625 $authMethod = ($settings['authmeth'] ?? '') == 'oauth' ? 'oauth' : 'api_token';
1626
1627 // access the bearer token depending on the authentication method configured
1628 $bearerToken = null;
1629
1630 if ($authMethod === 'api_token') {
1631 if (empty($settings['api_token'])) {
1632 throw new Exception('Missing API Token for Web API (settings).', 500);
1633 }
1634
1635 // use static token from configuration settings
1636 $bearerToken = $settings['api_token'];
1637 } else {
1638 // authentication method is OAuth2
1639 if (empty($settings['oauth2_api_key']) || empty($settings['oauth2_api_secret'])) {
1640 // settings must be configured
1641 throw new Exception('Missing OAuth2 API Key (Client ID) and OAuth2 API Secret (Client Secret). Please go through settings.', 500);
1642 }
1643
1644 // ensure we are not actually using the transporter for authorising the application
1645 if (empty($options['doing_oauth'])) {
1646 if (empty($settings['_oauth']['access_token'])) {
1647 // application must be authorised
1648 throw new Exception('Missing OAuth2 authorisation data for the application. Please go through settings.', 500);
1649 }
1650
1651 // get or refresh the access (bearer) token
1652 $bearerToken = $this->getOauthToken();
1653 }
1654 }
1655
1656 // set HTTP headers
1657 $this->httpHeaders = [
1658 'Authorization' => "Bearer {$bearerToken}",
1659 'Accept' => 'application/json',
1660 ];
1661
1662 if (!$bearerToken) {
1663 // the request should not define a default Authorization header
1664 unset($this->httpHeaders['Authorization']);
1665 }
1666
1667 if (is_array($options['headers'] ?? null)) {
1668 // merge default headers with the given ones (associative list expected)
1669 $this->httpHeaders = $this->httpHeaders + $options['headers'];
1670 }
1671
1672 return new JHttp;
1673 }
1674
1675 /**
1676 * Obtains an active OAuth (Bearer) token to establish API connections with Nuki.
1677 * In this case, the authentication method configured should be "OAuth".
1678 *
1679 * @return string An active OAuth (Bearer) token.
1680 *
1681 * @throws Exception
1682 */
1683 private function getOauthToken()
1684 {
1685 // access current profile settings
1686 $settings = $this->getSettings();
1687
1688 if (empty($settings['_oauth']['access_token'])) {
1689 // application must be authorised
1690 throw new Exception('Missing OAuth2 authorisation data for the application. Please go through settings.', 500);
1691 }
1692
1693 if (($settings['_oauth']['expiry_ts'] ?? 0) < time()) {
1694 // the token should be renewed because it's expired
1695 return $this->renewOauthToken();
1696 }
1697
1698 // return the supposingly active token
1699 return (string) $settings['_oauth']['access_token'];
1700 }
1701
1702 /**
1703 * Makes an API request with Nuki to refresh and save the OAuth token for any HTTP request.
1704 *
1705 * @return string An active OAuth (Bearer) token ready to be used.
1706 *
1707 * @throws Exception
1708 *
1709 * @link https://developer.nuki.io/
1710 */
1711 private function renewOauthToken()
1712 {
1713 // access current profile settings
1714 $settings = $this->getSettings();
1715
1716 if (empty($settings['oauth2_api_key']) || empty($settings['oauth2_api_secret'])) {
1717 // settings must be configured
1718 throw new Exception('Missing OAuth2 API Key (Client ID) and OAuth2 API Secret (Client Secret). Please go through settings.', 500);
1719 }
1720
1721 if (empty($settings['_oauth']['refresh_token'])) {
1722 // application must be authorised
1723 throw new Exception('Missing OAuth2 authorisation data (refresh token) for the application. Please go through authorisation.', 500);
1724 }
1725
1726 // build request data
1727 $data = [
1728 'grant_type' => 'refresh_token',
1729 'client_id' => $settings['oauth2_api_key'],
1730 'client_secret' => $settings['oauth2_api_secret'],
1731 'refresh_token' => $settings['_oauth']['refresh_token'],
1732 ];
1733
1734 // exchange the settings to obtain the OAuth token details
1735 $response = (new JHttp)->post('https://api.nuki.io/oauth/token', http_build_query($data), ['Content-Type' => 'application/x-www-form-urlencoded'], 10);
1736
1737 // obtain the response data
1738 $responseData = (array) json_decode((string) $response->body, true);
1739
1740 if (empty($response->code) || $response->code > 299) {
1741 // an error occurred
1742 throw new Exception($response->body ?: 'OAuth token error.', $response->code);
1743 }
1744
1745 if (empty($responseData['access_token'])) {
1746 // invalid response
1747 throw new Exception($response->body ?: 'OAuth token refresh response missing access token.', 500);
1748 }
1749
1750 // calculate and set the token expiration timestamp
1751 $responseData['expiry_ts'] = strtotime(sprintf('+%d seconds', (int) ($responseData['expires_in'] ?? 0)));
1752
1753 // inject OAuth details within the current integration settings
1754 $settings['_oauth'] = $responseData;
1755
1756 // update integration record settings
1757 $this->setProfileRecordProp('settings', $settings);
1758
1759 // store integration record settings
1760 VBODooraccessFactory::getInstance()->saveIntegrationRecord($this, ['settings' => $this->getSettings()]);
1761
1762 // return the current access token
1763 return (string) $responseData['access_token'];
1764 }
1765 }
1766