PluginProbe
VikBooking Hotel Booking Engine & PMS / 1.8.16
VikBooking Hotel Booking Engine & PMS v1.8.16
1.8.16 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 All 37 releases
vikbooking / admin / helpers / src / dooraccess / provider / nuki.php

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

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