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 / utec.php

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

1,682 lines 65.4 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 U-Tec.
16 *
17 * @since 1.18.7 (J) - 1.8.7 (WP)
18 *
19 * @link https://doc.api.u-tec.com/
20 */
21 final class VBODooraccessProviderUtec extends VBODooraccessIntegrationAware
22 {
23 /**
24 * @var array
25 */
26 private array $httpHeaders = [];
27
28 /**
29 * @var string
30 */
31 private string $authScopes = 'openapi';
32
33 /**
34 * @inheritDoc
35 */
36 public function getAlias()
37 {
38 return basename(__FILE__, '.php');
39 }
40
41 /**
42 * @inheritDoc
43 */
44 public function getName()
45 {
46 return 'U-tec Smart Home';
47 }
48
49 /**
50 * @inheritDoc
51 */
52 public function getShortName()
53 {
54 return 'U-tec';
55 }
56
57 /**
58 * @inheritDoc
59 */
60 public function getIcon()
61 {
62 return VBO_ADMIN_URI . 'resources/u-tec-vikbooking-integration-logo.png';
63 }
64
65 /**
66 * @inheritDoc
67 */
68 public function getParams()
69 {
70 // load current settings
71 $settings = $this->getSettings();
72
73 // build the OAuth redirect URL
74 $dac_oauth_spawn_url = $this->buildOAuthURL();
75
76 // check if the application was authorised through OAuth
77 $oauth_authorised = !empty($settings['_oauth']['access_token']);
78
79 // build OAuth2 authorization link
80 if (empty($settings['client_id']) || empty($settings['client_secret'])) {
81 // settings must be saved first
82 $oauth2_auth_link = '<span class="label label-error">Save settings first. OAuth2 API Key and API Secret cannot be empty.</span>';
83 // convert the redirect URI to a text
84 $dac_oauth_spawn_url = 'Save settings first.';
85 } else {
86 // build button-link for the authorization code URL
87 $utec_auth_data = [
88 'response_type' => 'code',
89 'client_id' => $settings['client_id'],
90 'client_secret' => $settings['client_secret'],
91 'scope' => $this->authScopes,
92 'redirect_uri' => $dac_oauth_spawn_url,
93 'state' => $this->getOAuthCode(),
94 ];
95
96 // build final authorization URL to be clicked
97 $utec_auth_link = 'https://oauth.u-tec.com/authorize?' . http_build_query($utec_auth_data);
98
99 // set button content
100 $oauth2_auth_link = '<a class="btn btn-warning" href="' . $utec_auth_link . '">' . ($oauth_authorised ? '(Re-)' : '') . 'Authorize Application</a>';
101 }
102
103 // build HTML instructions for obtaining the OAuth2 information
104 $oauth_instructions_html = <<<HTML
105 <div>By using the official U-tec App, visit the section <strong>OpenAPI</strong> to obtain your <strong>Client ID</strong> and <strong>Client Secret</strong>. Then enter the following <strong>Redirect URI</strong>:</div>
106 <ul>
107 <li><em>$dac_oauth_spawn_url</em></li>
108 </ul>
109 <div>Save your settings on the U-tec App.</div>
110 HTML;
111
112 if (!$oauth_authorised) {
113 // add to the instructions that the application must be authorised
114 $oauth_instructions_html .= <<<HTML
115 <div>Proceed by clicking the authorization link below after saving your account settings from this interface.</div>
116 HTML;
117 }
118
119 if (!$oauth_authorised) {
120 // never obtained an access token through OAuth before
121 $oauth2_auth_status = '<span class="badge badge-error">Not Authorized</span>';
122 } else {
123 // access token was once obtained through OAuth
124 $oauth2_auth_status = '<span class="badge badge-success">Authorized</span>';
125 }
126
127 // return the list of parameters
128 return [
129 'ai' => [
130 'type' => 'checkbox',
131 'label' => JText::translate('VBO_AI_SUPPORT'),
132 'help' => JText::translate('VBO_DAC_AI_SUPPORT_HELP'),
133 'default' => 1,
134 ],
135 'passquant' => [
136 'type' => 'select',
137 'label' => JText::translate('VBO_PASSCODES'),
138 'help' => JText::translate('VBO_PASSCODES_QUANT_HELP'),
139 'options' => [
140 1 => JText::translate('VBO_ONE_PER_DEVICE'),
141 2 => JText::translate('VBO_ONE_PER_BOOKING'),
142 ],
143 'default' => 1,
144 ],
145 '_oauth2_auth_help' => [
146 'type' => 'custom',
147 'html' => $oauth_instructions_html,
148 ],
149 '_oauth2_auth_status' => [
150 'type' => 'custom',
151 'label' => 'Authorization Status',
152 'html' => $oauth2_auth_status,
153 ],
154 '_oauth2_auth_link' => [
155 'type' => 'custom',
156 'label' => 'Authorization Link',
157 'html' => $oauth2_auth_link,
158 ],
159 'client_id' => [
160 'type' => 'text',
161 'label' => 'Client ID',
162 'help' => (empty($settings['client_id']) ? 'Save settings to see the authorization link.' : ''),
163 ],
164 'client_secret' => [
165 'type' => 'password',
166 'label' => 'Client Secret',
167 'help' => (empty($settings['client_id']) ? 'Save settings to see the authorization link. ' : ''),
168 ],
169 ];
170 }
171
172 /**
173 * @inheritDoc
174 */
175 public function spawnOAuthCallback(?array $data = null)
176 {
177 $app = JFactory::getApplication();
178
179 // gather request or data variables to obtain the authorization code for the application
180 $auth_code = ($data['code'] ?? '') ?: $app->input->getString('code', '');
181 $auth_state = ($data['state'] ?? '') ?: $app->input->getString('state', '');
182
183 if (!empty($auth_code) && empty($auth_state)) {
184 // missing CSRF proof token
185 throw new Exception(JText::translate('JINVALID_TOKEN'), 403);
186 }
187
188 // perform the OAuth token internal validation that relies on the database for CSRF prevention
189 if ($auth_state != $this->getOAuthCode()) {
190 throw new Exception(JText::translate('JINVALID_TOKEN'), 403);
191 }
192
193 // load current settings
194 $settings = $this->getSettings();
195
196 if (!empty($auth_code)) {
197 /**
198 * Application was authorized and an authorization code was received.
199 * Perform a request to exchange the auth code and obtain the access token.
200 */
201
202 // start transporter (by setting the necessary headers)
203 $transporter = $this->createHTTPTransporter([
204 'doing_oauth' => 1,
205 'headers' => [
206 'Content-Type' => 'application/x-www-form-urlencoded',
207 ],
208 ]);
209
210 // build request data, inclusive of redirect URI (must match the one for the initial authorization)
211 $requestData = [
212 'grant_type' => 'authorization_code',
213 'client_id' => ($settings['client_id'] ?? ''),
214 'client_secret' => ($settings['client_secret'] ?? ''),
215 'code' => $auth_code,
216 ];
217
218 // make the API request
219 $response = $transporter->get('https://oauth.u-tec.com/token?' . http_build_query($requestData), $this->httpHeaders, 60);
220
221 // obtain response data
222 $responseData = (array) json_decode((string) $response->body, true);
223
224 if (empty($response->code) || $response->code > 299) {
225 // an error occurred
226 throw new Exception($response->body ?: 'Error exchanging the auth code for the OAuth access token.', ($response->code ?: 500));
227 }
228
229 if (empty($responseData['access_token']) || empty($responseData['refresh_token'])) {
230 // unexpected response format
231 throw new Exception(sprintf('Unexpected response format: missing access token or refresh token. %s', (string) ($responseData['error_description'] ?? '')), 500);
232 }
233
234 // calculate and set the token expiration timestamp (3600 seconds)
235 $responseData['expiry_ts'] = strtotime(sprintf('+%d seconds', (int) ($responseData['expires_in'] ?? 0)));
236
237 // inject OAuth details within the current integration settings
238 $settings['_oauth'] = $responseData;
239
240 // update integration record settings
241 $this->setProfileRecordProp('settings', $settings);
242
243 // store integration record settings
244 VBODooraccessFactory::getInstance()->saveIntegrationRecord($this, ['settings' => $this->getSettings()]);
245
246 // application was successfully authorised, close the response
247
248 // redirect to VikBooking admin-widget
249 $redirectData = [
250 'option' => 'com_vikbooking',
251 'load_widget' => 'door_access_control',
252 'multitask_data' => [
253 'provider' => $this->getProfileProvider(),
254 'profile' => $this->getProfileID(),
255 'tab' => 'settings',
256 ],
257 ];
258 $redirectUri = VBOFactory::getPlatform()->getUri()->admin('index.php?' . http_build_query($redirectData));
259
260 $app->redirect($redirectUri);
261 $app->close();
262 }
263
264 // close the response with a status code 204 (No Content)
265 VBOHttpDocument::getInstance($app)->close(204, 'No data to authorise the application.');
266 }
267
268 /**
269 * @inheritDoc
270 *
271 * @todo Identify the payload received for the various Webhook events.
272 * Validate the payload signature through the registered token.
273 * Identify the type(s) of Webhook events to process, if any.
274 */
275 public function spawnWebhookCallback(?array $data = null)
276 {
277 $app = JFactory::getApplication();
278
279 /**
280 * @todo remove webhook payload debugging
281 */
282 $fp = fopen(dirname(__FILE__) . DIRECTORY_SEPARATOR . 'webhook_debug.txt', 'a+');
283 fwrite($fp, date('c') . "\n" . print_r(file_get_contents('php://input'), true) . "\n" . print_r($app->input->request->getArray(), true) . "\n" . print_r($app->input->server->getArray(), true) . "\n\n\n");
284 fclose($fp);
285
286 // close the response with a 200 status code
287 VBOHttpDocument::getInstance($app)->close(200, 'Webhook data successfully received.');
288 }
289
290 /**
291 * @inheritDoc
292 */
293 public function canUnlockDevices()
294 {
295 // this method is called when the integration has loaded its profile record
296 // we return true only if the apposite AI setting is enabled
297
298 $settings = $this->getSettings();
299
300 return !empty($settings['ai']);
301 }
302
303 /**
304 * Device capability implementation to unlock a device.
305 *
306 * @param VBODooraccessIntegrationDevice $device The device executing the capability.
307 * @param ?array $options Optional settings populated from capability parameters.
308 *
309 * @return VBODooraccessDeviceCapabilityResult
310 *
311 * @link https://doc.api.u-tec.com/ Lock User Management - Command: st.lock - unlock
312 */
313 public function unlockDevice(VBODooraccessIntegrationDevice $device, ?array $options = null)
314 {
315 // start transporter (by setting the Content-Type header)
316 $transporter = $this->createHTTPTransporter([
317 'headers' => [
318 'Content-Type' => 'application/json',
319 ],
320 ]);
321
322 // build request data
323 $data = [
324 'header' => [
325 'namespace' => 'Uhome.Device',
326 'name' => 'Command',
327 'messageId' => VBOPerformanceIndicator::uuid(),
328 'payloadVersion' => '1',
329 ],
330 'payload' => [
331 'devices' => [
332 [
333 'id' => $device->getID(),
334 'command' => [
335 'capability' => 'st.lock',
336 'name' => 'unlock',
337 ],
338 ],
339 ],
340 ],
341 ];
342
343 // make the API request
344 $response = $transporter->post('https://api.u-tec.com/action', json_encode($data), $this->httpHeaders, 60);
345
346 // obtain the response data
347 $responseData = (array) json_decode((string) $response->body, true);
348
349 if (empty($response->code) || $response->code > 299) {
350 // an error occurred
351 throw new Exception($response->body ?: 'Error unlocking the device.', ($response->code ?: 500));
352 }
353
354 return (new VBODooraccessDeviceCapabilityResult)->setText(sprintf('The device "%s" was unlocked!', $device->getName()));
355 }
356
357 /**
358 * Device capability implementation to lock a device.
359 *
360 * @param VBODooraccessIntegrationDevice $device The device executing the capability.
361 * @param ?array $options Optional settings populated from capability parameters.
362 *
363 * @return VBODooraccessDeviceCapabilityResult
364 *
365 * @link https://doc.api.u-tec.com/ Lock User Management - Command: st.lock - lock
366 */
367 public function lockDevice(VBODooraccessIntegrationDevice $device, ?array $options = null)
368 {
369 // start transporter (by setting the Content-Type header)
370 $transporter = $this->createHTTPTransporter([
371 'headers' => [
372 'Content-Type' => 'application/json',
373 ],
374 ]);
375
376 // build request data
377 $data = [
378 'header' => [
379 'namespace' => 'Uhome.Device',
380 'name' => 'Command',
381 'messageId' => VBOPerformanceIndicator::uuid(),
382 'payloadVersion' => '1',
383 ],
384 'payload' => [
385 'devices' => [
386 [
387 'id' => $device->getID(),
388 'command' => [
389 'capability' => 'st.lock',
390 'name' => 'lock',
391 ],
392 ],
393 ],
394 ],
395 ];
396
397 // make the API request
398 $response = $transporter->post('https://api.u-tec.com/action', json_encode($data), $this->httpHeaders, 60);
399
400 // obtain the response data
401 $responseData = (array) json_decode((string) $response->body, true);
402
403 if (empty($response->code) || $response->code > 299) {
404 // an error occurred
405 throw new Exception($response->body ?: 'Error locking the device.', ($response->code ?: 500));
406 }
407
408 return (new VBODooraccessDeviceCapabilityResult)->setText(sprintf('The device "%s" was locked!', $device->getName()));
409 }
410
411 /**
412 * Device capability implementation to list a device authorization codes.
413 *
414 * @param VBODooraccessIntegrationDevice $device The device executing the capability.
415 * @param ?array $options Optional settings populated from capability parameters.
416 *
417 * @return VBODooraccessDeviceCapabilityResult
418 *
419 * @throws Exception
420 *
421 * @link https://doc.api.u-tec.com/ Lock User Management - Command: st.lockUser - list/get(id)
422 */
423 public function listPasscodes(VBODooraccessIntegrationDevice $device, ?array $options = null)
424 {
425 // start transporter (by setting the Content-Type header)
426 $transporter = $this->createHTTPTransporter([
427 'headers' => [
428 'Content-Type' => 'application/json',
429 ],
430 ]);
431
432 // build request data
433 $data = [
434 'header' => [
435 'namespace' => 'Uhome.Device',
436 'name' => 'Command',
437 'messageId' => VBOPerformanceIndicator::uuid(),
438 'payloadVersion' => '1',
439 ],
440 'payload' => [
441 'devices' => [
442 [
443 'id' => $device->getID(),
444 'command' => [
445 'capability' => 'st.lockUser',
446 'name' => 'list',
447 ],
448 ],
449 ],
450 ],
451 ];
452
453 // make the API request
454 $response = $transporter->post('https://api.u-tec.com/action', json_encode($data), $this->httpHeaders, 60);
455
456 // obtain the response data
457 $responseData = (array) json_decode((string) $response->body, true);
458
459 if (empty($response->code) || $response->code > 299) {
460 // an error occurred
461 throw new Exception($response->body ?: 'Error fetching device passcodes (lock users).', ($response->code ?: 500));
462 }
463
464 if (empty($responseData['payload']['devices'][0]['users'])) {
465 throw new Exception('No passcodes (lock users) found for the device.', 500);
466 }
467
468 // list of lock users returned
469 $lockUsersList = (array) $responseData['payload']['devices'][0]['users'];
470
471 // check if a specific passcode name should be matched from the API response (filter not supported)
472 $searchPasscode = $options['search'] ?? null;
473
474 // check if the list of lock users needs to be filtered by an exact search term (passcode name)
475 if ($searchPasscode && $lockUsersList) {
476 // filter lock users (passcodes) found by the given name
477 $lockUsersList = array_filter($lockUsersList, function($lockUser) use ($searchPasscode) {
478 // lock user (passcode) name must match the given one as we are doing an exact search
479 return ($lockUser['name'] ?? '') === $searchPasscode;
480 });
481 }
482
483 if (!$lockUsersList) {
484 throw new Exception('No matching passcodes (lock users) found for the device.', 500);
485 }
486
487 // reset array keys
488 $lockUsersList = array_values($lockUsersList);
489
490 // parse the lock users list and fetch their details (password)
491 foreach ($lockUsersList as &$lockUser) {
492 // build request data to obtain all lock-user (password) details
493 $data = [
494 'header' => [
495 'namespace' => 'Uhome.Device',
496 'name' => 'Command',
497 'messageId' => VBOPerformanceIndicator::uuid(),
498 'payloadVersion' => '1',
499 ],
500 'payload' => [
501 'devices' => [
502 [
503 'id' => $device->getID(),
504 'command' => [
505 'capability' => 'st.lockUser',
506 'name' => 'get',
507 'arguments' => [
508 'id' => $lockUser['id'] ?? null,
509 ],
510 ],
511 ],
512 ],
513 ],
514 ];
515
516 // make the API request
517 $response = $transporter->post('https://api.u-tec.com/action', json_encode($data), $this->httpHeaders, 60);
518
519 // obtain the response data
520 $responseData = (array) json_decode((string) $response->body, true);
521
522 // check if we got a password for the lock-user
523 if ($responseData['payload']['devices'][0]['user']['password'] ?? null) {
524 // lock user details were read successfully, merge user-lock information
525 $lockUser = array_merge($lockUser, $responseData['payload']['devices'][0]['user']);
526 }
527 }
528
529 // unset last reference
530 unset($lockUser);
531
532 // associative list of lock user IDs and related details
533 $lockUsersAssoc = [];
534
535 // build HTML output
536 $output = '';
537
538 // lang defs
539 $lang_passcode = JText::translate('VBO_PASSCODE');
540 $lang_startdate = JText::translate('VBNEWPKGDFROM');
541 $lang_enddate = JText::translate('VBNEWPKGDTO');
542
543 // table head
544 $output .= <<<HTML
545 <div class="vbo-dac-table-wrap">
546 <table class="vbo-dac-table">
547 <thead>
548 <tr>
549 <td>Password ID</td>
550 <td>Name</td>
551 <td>{$lang_passcode}</td>
552 <td>{$lang_startdate}</td>
553 <td>{$lang_enddate}</td>
554 <td>Type</td>
555 <td>Status</td>
556 <td>Sync Status</td>
557 </tr>
558 </thead>
559 <tbody>
560 HTML;
561
562 // scan all passcodes obtained
563 foreach ($lockUsersList as $lockUser) {
564 // set passcode properties
565 $lockUserId = $lockUser['id'] ?? '';
566 $lockUserName = $lockUser['name'] ?? '';
567 $lockUserPwd = $lockUser['password'] ?? '';
568 $lockUserPwdFrom = $lockUser['daterange'][0] ?? '';
569 $lockUserPwdTo = $lockUser['daterange'][1] ?? '';
570 $lockUserType = $this->getUserTypes((int) ($lockUser['type'] ?? 0), true);
571 $lockUserStatus = $lockUser['status'] ?? '';
572 $lockUserSyncStatus = $lockUser['sync_status'] ?? '';
573
574 // bind passcode id values
575 $lockUsersAssoc[$lockUserId] = [
576 'name' => $lockUserName,
577 'value' => $lockUserPwd,
578 ];
579
580 // build passcode HTML code
581 $output .= <<<HTML
582 <tr>
583 <td><span class="vbo-dac-table-passcode-id">{$lockUserId}</span></td>
584 <td><span class="vbo-dac-table-passcode-name">{$lockUserName}</span></td>
585 <td><span class="vbo-dac-table-passcode-code">{$lockUserPwd}</span></td>
586 <td>{$lockUserPwdFrom}</td>
587 <td>{$lockUserPwdTo}</td>
588 <td>{$lockUserType}</td>
589 <td>{$lockUserStatus}</td>
590 <td>{$lockUserSyncStatus}</td>
591 </tr>
592 HTML;
593 }
594
595 // close table
596 $output .= <<<HTML
597 </tbody>
598 </table>
599 </div>
600 HTML;
601
602 // return the capability result object by setting the output value
603 return (new VBODooraccessDeviceCapabilityResult($lockUsersAssoc))
604 ->setOutput($output);
605 }
606
607 /**
608 * Device capability implementation to create a custom passcode for a device.
609 *
610 * @param VBODooraccessIntegrationDevice $device The device executing the capability.
611 * @param ?array $options Optional settings populated from capability parameters.
612 *
613 * @return VBODooraccessDeviceCapabilityResult
614 *
615 * @link https://doc.api.u-tec.com/ Lock User Management - Command: st.lockUser - add(user)
616 */
617 public function createCustomPasscode(VBODooraccessIntegrationDevice $device, ?array $options = null)
618 {
619 // start transporter (by setting the Content-Type header)
620 $transporter = $this->createHTTPTransporter([
621 'headers' => [
622 'Content-Type' => 'application/json',
623 ],
624 ]);
625
626 if (empty($options['pwdvalue'])) {
627 // passcode value cannot be empty
628 $options['pwdvalue'] = $this->generateRandomPasscode();
629 }
630
631 // build request data
632 $data = [
633 'header' => [
634 'namespace' => 'Uhome.Device',
635 'name' => 'Command',
636 'messageId' => VBOPerformanceIndicator::uuid(),
637 'payloadVersion' => '1',
638 ],
639 'payload' => [
640 'devices' => [
641 [
642 'id' => $device->getID(),
643 'command' => [
644 'capability' => 'st.lockUser',
645 'name' => 'add',
646 'arguments' => [
647 'name' => $options['pwdname'] ?? null,
648 // type = 2 means "temporary user"
649 'type' => 2,
650 'password' => (int) $options['pwdvalue'],
651 'daterange' => [
652 date('Y-m-d H:i', strtotime($options['startdate'] ?? date('Y-m-d H:i:s'))),
653 date('Y-m-d H:i', strtotime($options['enddate'] ?? date('Y-m-d H:i:s'))),
654 ],
655 'weeks' => [0, 1, 2, 4, 5, 6],
656 'timerange' => ['00:00', '23:59'],
657 ],
658 ],
659 ],
660 ],
661 ],
662 ];
663
664 if (empty($options['startdate']) || empty($options['enddate'])) {
665 // unset the validity date-range and related properties when no dates provided
666 unset(
667 $data['payload']['devices'][0]['command']['arguments']['daterange'],
668 $data['payload']['devices'][0]['command']['arguments']['weeks'],
669 $data['payload']['devices'][0]['command']['arguments']['timerange']
670 );
671 }
672
673 // make the API request
674 $response = $transporter->post('https://api.u-tec.com/action', json_encode($data), $this->httpHeaders, 60);
675
676 // obtain the response data
677 $responseData = (array) json_decode((string) $response->body, true);
678
679 if (empty($response->code) || $response->code > 299) {
680 // an error occurred, build DAC Exception with retry-data
681 $dacError = (new VBODooraccessException($response->body ?: 'Error adding a custom passcode to the device.', ($response->code ?: 500)))
682 ->setDevice($device)
683 ->setRetryCallback('createCustomPasscode')
684 ->setRetryData($options);
685
686 // throw error
687 throw $dacError;
688 }
689
690 if ($responseData['payload']['devices'][0]['error']['message'] ?? null) {
691 // erroneous response
692 // an error occurred, build DAC Exception with retry-data
693 $dacError = (new VBODooraccessException(sprintf('(%s) %s', ($responseData['payload']['devices'][0]['error']['code'] ?? '0'), $responseData['payload']['devices'][0]['error']['message']), 500))
694 ->setDevice($device)
695 ->setRetryCallback('createCustomPasscode')
696 ->setRetryData($options);
697
698 // throw error
699 throw $dacError;
700 }
701
702 // build result properties to bind
703 $resultProps = [
704 'code' => (string) $options['pwdvalue'],
705 'name' => (string) ($options['pwdname'] ?? ''),
706 'listingId' => (int) ($options['listing_id'] ?? 0),
707 ];
708
709 // get the listing name, if available
710 $listingName = '';
711 if (!empty($resultProps['listingId'])) {
712 $listingData = VikBooking::getRoomInfo($resultProps['listingId'], ['name'], true);
713 $listingName = sprintf('%s: ', $listingData['name'] ?? '');
714 }
715
716 // wrap and return the device capability result object
717 return (new VBODooraccessDeviceCapabilityResult($resultProps))
718 ->setPasscode($resultProps['code'])
719 ->setText($listingName . JText::sprintf('VBO_PASSCODE_GEN_OK_DEVICE', $resultProps['code'], $device->getName()));
720 }
721
722 /**
723 * Device capability implementation to delete a passcode from a device.
724 *
725 * @param VBODooraccessIntegrationDevice $device The device executing the capability.
726 * @param ?array $options Optional settings populated from capability parameters.
727 *
728 * @return VBODooraccessDeviceCapabilityResult
729 *
730 * @link https://doc.api.u-tec.com/ Lock User Management - Command: st.lockUser - delete(id)
731 */
732 public function deletePasscode(VBODooraccessIntegrationDevice $device, ?array $options = null)
733 {
734 if (empty($options['pwdid'])) {
735 throw new Exception($response->body ?: 'Missing lock user (passcode) ID to delete.', 400);
736 }
737
738 // start transporter (by setting the Content-Type header)
739 $transporter = $this->createHTTPTransporter([
740 'headers' => [
741 'Content-Type' => 'application/json',
742 ],
743 ]);
744
745 // build request data
746 $data = [
747 'header' => [
748 'namespace' => 'Uhome.Device',
749 'name' => 'Command',
750 'messageId' => VBOPerformanceIndicator::uuid(),
751 'payloadVersion' => '1',
752 ],
753 'payload' => [
754 'devices' => [
755 [
756 'id' => $device->getID(),
757 'command' => [
758 'capability' => 'st.lockUser',
759 'name' => 'delete',
760 'arguments' => [
761 'id' => $options['pwdid'],
762 ],
763 ],
764 ],
765 ],
766 ],
767 ];
768
769 // make the API request
770 $response = $transporter->post('https://api.u-tec.com/action', json_encode($data), $this->httpHeaders, 60);
771
772 // obtain the response data
773 $responseData = (array) json_decode((string) $response->body, true);
774
775 if (empty($response->code) || $response->code > 299) {
776 // an error occurred
777 throw new Exception($response->body ?: 'Error deleting lock user (passcode) from device.', ($response->code ?: 500));
778 }
779
780 if ($responseData['payload']['devices'][0]['error']['message'] ?? null) {
781 // erroneous response
782 throw new Exception(sprintf('(%s) %s', ($responseData['payload']['devices'][0]['error']['code'] ?? '0'), $responseData['payload']['devices'][0]['error']['message']), 500);
783 }
784
785 return (new VBODooraccessDeviceCapabilityResult)
786 ->setText(JText::sprintf('VBO_PASSCODE_DEL_OK_DEVICE', $device->getName()));
787 }
788
789 /**
790 * Device capability implementation to update a passcode from a device.
791 *
792 * @param VBODooraccessIntegrationDevice $device The device executing the capability.
793 * @param ?array $options Optional settings populated from capability parameters.
794 *
795 * @return VBODooraccessDeviceCapabilityResult
796 *
797 * @link https://doc.api.u-tec.com/ Lock User Management - Command: st.lockUser - update(user)
798 */
799 public function updatePasscode(VBODooraccessIntegrationDevice $device, ?array $options = null)
800 {
801 if (empty($options['pwdid'])) {
802 throw new Exception($response->body ?: 'Missing lock user (passcode) ID to update.', 400);
803 }
804
805 // start transporter (by setting the Content-Type header)
806 $transporter = $this->createHTTPTransporter([
807 'headers' => [
808 'Content-Type' => 'application/json',
809 ],
810 ]);
811
812 // build request data
813 $data = [
814 'header' => [
815 'namespace' => 'Uhome.Device',
816 'name' => 'Command',
817 'messageId' => VBOPerformanceIndicator::uuid(),
818 'payloadVersion' => '1',
819 ],
820 'payload' => [
821 'devices' => [
822 [
823 'id' => $device->getID(),
824 'command' => [
825 'capability' => 'st.lockUser',
826 'name' => 'update',
827 'arguments' => [
828 'id' => $options['pwdid'],
829 'password' => $options['pwdvalue'] ?? null,
830 'daterange' => [
831 date('Y-m-d H:i', strtotime($options['startdate'] ?? date('Y-m-d H:i:s'))),
832 date('Y-m-d H:i', strtotime($options['enddate'] ?? date('Y-m-d H:i:s'))),
833 ],
834 'weeks' => [0, 1, 2, 4, 5, 6],
835 'timerange' => ['00:00', '23:59'],
836 ],
837 ],
838 ],
839 ],
840 ],
841 ];
842
843 if (empty($options['pwdvalue'])) {
844 // unset the password property if the same passcode should be kept
845 unset($data['payload']['devices'][0]['command']['arguments']['password']);
846 }
847
848 if (empty($options['startdate']) || empty($options['enddate'])) {
849 // unset the validity date-range and related properties to keep the existing value
850 unset(
851 $data['payload']['devices'][0]['command']['arguments']['daterange'],
852 $data['payload']['devices'][0]['command']['arguments']['weeks'],
853 $data['payload']['devices'][0]['command']['arguments']['timerange']
854 );
855 }
856
857 // make the API request
858 $response = $transporter->post('https://api.u-tec.com/action', json_encode($data), $this->httpHeaders, 60);
859
860 // obtain the response data
861 $responseData = (array) json_decode((string) $response->body, true);
862
863 if (empty($response->code) || $response->code > 299) {
864 // an error occurred
865 throw new Exception($response->body ?: 'Error updating lock user (passcode) on device.', ($response->code ?: 500));
866 }
867
868 if ($responseData['payload']['devices'][0]['error']['message'] ?? null) {
869 // erroneous response
870 throw new Exception(sprintf('(%s) %s', ($responseData['payload']['devices'][0]['error']['code'] ?? '0'), $responseData['payload']['devices'][0]['error']['message']), 500);
871 }
872
873 return (new VBODooraccessDeviceCapabilityResult)
874 ->setText(JText::sprintf('VBO_PASSCODE_UPD_OK_DEVICE', $device->getName()));
875 }
876
877 /**
878 * Device capability implementation to check the current device status, and eventually update the battery level.
879 *
880 * @param VBODooraccessIntegrationDevice $device The device executing the capability.
881 * @param ?array $options Optional settings populated from capability parameters.
882 *
883 * @return VBODooraccessDeviceCapabilityResult
884 *
885 * @throws Exception
886 *
887 * @link https://doc.api.u-tec.com/ Query Device Status
888 */
889 public function checkStatus(VBODooraccessIntegrationDevice $device, ?array $options = null)
890 {
891 // start transporter (by setting the Content-Type header)
892 $transporter = $this->createHTTPTransporter([
893 'headers' => [
894 'Content-Type' => 'application/json',
895 ],
896 ]);
897
898 // build request data
899 $data = [
900 'header' => [
901 'namespace' => 'Uhome.Device',
902 'name' => 'Query',
903 'messageId' => VBOPerformanceIndicator::uuid(),
904 'payloadVersion' => '1',
905 ],
906 'payload' => [
907 'devices' => [
908 [
909 'id' => $device->getID(),
910 ],
911 ],
912 ],
913 ];
914
915 // make the API request
916 $response = $transporter->post('https://api.u-tec.com/action', json_encode($data), $this->httpHeaders, 60);
917
918 // obtain the response data
919 $responseData = (array) json_decode((string) $response->body, true);
920
921 if (empty($response->code) || $response->code > 299) {
922 // an error occurred
923 throw new Exception($response->body ?: 'Error checking device status.', ($response->code ?: 500));
924 }
925
926 if (empty($responseData['payload']['devices'][0]['states'])) {
927 throw new Exception('No device status information obtained. The device may not be reachable, or may not be connected to the Internet.', 500);
928 }
929
930 // list of lock states
931 $lockStatesList = (array) $responseData['payload']['devices'][0]['states'];
932
933 // access the current device battery-level information, if any
934 $batteryInfo = null;
935 $devicePayload = $device->getPayload();
936 if (($devicePayload['attributes']['batteryLevelRange']['max'] ?? null)) {
937 // assign the device battery-level information
938 $batteryInfo = $devicePayload['attributes']['batteryLevelRange'];
939 }
940
941 // iterate all lock state properties (statuses) to check if we have the battery level
942 foreach ($lockStatesList as $lockStatus) {
943 if ($batteryInfo && ($lockStatus['capability'] ?? '') === 'st.batteryLevel' && isset($lockStatus['value'])) {
944 // calculate the device batter level
945 $currentLevelPcent = round(100 * (int) $lockStatus['value'] / (((int) $batteryInfo['max']) ?: 1), 0);
946
947 // update device battery level
948 $device->setBatteryLevel((float) $currentLevelPcent);
949
950 // update current device
951 try {
952 VBODooraccessFactory::getInstance()->saveIntegrationRecord($this, ['devices' => $this->getDevices()]);
953 } catch (Exception $e) {
954 // silently catch the error and do nothing
955 }
956
957 // do not proceed any further
958 break;
959 }
960 }
961
962 // build HTML output
963 $output = '';
964
965 // lang defs
966 $lang_passcode = JText::translate('VBO_PASSCODE');
967 $lang_startdate = JText::translate('VBNEWPKGDFROM');
968 $lang_enddate = JText::translate('VBNEWPKGDTO');
969
970 // table head
971 $output .= <<<HTML
972 <div class="vbo-dac-table-wrap">
973 <table class="vbo-dac-table">
974 <thead>
975 <tr>
976 <td>Name</td>
977 <td>Value</td>
978 <td>Capability</td>
979 </tr>
980 </thead>
981 <tbody>
982 HTML;
983
984 // scan all statuses obtained
985 foreach ($lockStatesList as $lockStatus) {
986 // set passcode properties
987 $lockStatusName = $lockStatus['name'] ?? '';
988 $lockStatusValue = $lockStatus['value'] ?? '';
989 $lockStatusCap = $lockStatus['capability'] ?? '';
990
991 // build passcode HTML code
992 $output .= <<<HTML
993 <tr>
994 <td><span class="vbo-dac-table-passcode-name">{$lockStatusName}</span></td>
995 <td><span class="vbo-dac-table-passcode-code">{$lockStatusValue}</span></td>
996 <td><span class="vbo-dac-table-passcode-id">{$lockStatusCap}</span></td>
997 </tr>
998 HTML;
999 }
1000
1001 // close table
1002 $output .= <<<HTML
1003 </tbody>
1004 </table>
1005 </div>
1006 HTML;
1007
1008 // return the capability result object by setting the output value
1009 return (new VBODooraccessDeviceCapabilityResult($lockStatesList))
1010 ->setOutput($output);
1011 }
1012
1013 /**
1014 * @inheritDoc
1015 */
1016 public function createBookingDoorAccess(VBODooraccessIntegrationDevice $device, int $listingId, VBOBookingRegistry $registry)
1017 {
1018 // access the integration settings
1019 $settings = $this->getSettings();
1020
1021 // build booking-listing signature
1022 $signature = sprintf('%d-%d', $registry->getID(), $listingId);
1023
1024 // access booking registry DAC data for passcodes generated
1025 $passcodesBuffer = $registry->getDACProperty($this->getAlias(), 'passcodes', []);
1026
1027 // determine the passcode value to use, either a new one or a previous one for the same booking
1028 if (($settings['passquant'] ?? 0) == 2 && ($passcodesBuffer[$signature] ?? null)) {
1029 // use the previously generated passcode for this booking and listing also on this device
1030 $passcodeValue = $passcodesBuffer[$signature];
1031 } else {
1032 // generate custom, yet random, passcode value of 6 digits for this device
1033 $passcodeValue = $this->generateRandomPasscode();
1034 }
1035
1036 // prepare the options for creating a custom passcode (randomly generated by us)
1037 $options = [
1038 // use a password name that can be used later to find it under this booking and listing ID
1039 'pwdname' => sprintf('bid:%d-%d', $registry->getID(), $listingId),
1040 // set the passcode validity start date and time
1041 'startdate' => date('Y-m-d H:i:00', $registry->getProperty('checkin', 0)),
1042 // set the passcode validity end date and time
1043 'enddate' => date('Y-m-d H:i:00', $registry->getProperty('checkout', 0)),
1044 // custom passcode value to create on the device
1045 'pwdvalue' => $passcodeValue,
1046 // inject the listing ID for completion of data
1047 'listing_id' => $listingId,
1048 ];
1049
1050 // create custom passcode on the current device
1051 $result = $this->createCustomPasscode($device, $options);
1052
1053 // update booking registry DAC data for passcodes generated
1054 $passcodesBuffer[$signature] = $result->getPasscode();
1055 $registry->setDACProperty($this->getAlias(), 'passcodes', $passcodesBuffer);
1056
1057 return $result;
1058 }
1059
1060 /**
1061 * @inheritDoc
1062 */
1063 public function modifyBookingDoorAccess(VBODooraccessIntegrationDevice $device, int $listingId, VBOBookingRegistry $registry)
1064 {
1065 // searching, deleting and re-creating passcodes is always safer in case of
1066 // booking modification for possibly different listing IDs involved
1067
1068 // find the passcode data that were previously created for this booking
1069 $previousDevicePasscodes = VikBooking::getBookingHistoryInstance($registry->getID())
1070 ->getEventsWithData(['ND', 'MD'], function($data) use ($device) {
1071 $data = (array) $data;
1072 // ensure the passcode was generated for this provider, profile and device
1073 return ($data['provider'] ?? '') == $this->getProfileProvider() &&
1074 ($data['profile'] ?? '') == $this->getProfileID() &&
1075 ($data['device'] ?? '') == $device->getID() &&
1076 (!empty($data['passcode']) || !empty($data['props']));
1077 });
1078
1079 if (!$previousDevicePasscodes) {
1080 // no passcodes were previously created for this booking
1081 // process the modification as a new door access creation
1082 return $this->createBookingDoorAccess($device, $listingId, $registry);
1083 }
1084
1085 // scan all previously created passcodes in DESC order on this device and delete them
1086 $previousPasscodeNamings = [];
1087 foreach (array_reverse($previousDevicePasscodes) as $previousData) {
1088 // ensure we only have array values
1089 $previousData = (array) json_decode(json_encode($previousData), true);
1090
1091 /**
1092 * The generation of booking passcodes is asynchronous, hence we don't immediately get and store the
1093 * lock user ID to be used for deleting it immediately. We need to "search" the lock user by name.
1094 */
1095
1096 // get the previous passcode name
1097 $previousPasscodeName = ($previousData['props']['name'] ?? '');
1098
1099 if (empty($previousPasscodeName) || in_array($previousPasscodeName, $previousPasscodeNamings)) {
1100 // no lock-user name to search and delete, or already deleted
1101 continue;
1102 }
1103
1104 // push processed passcode name
1105 $previousPasscodeNamings[] = $previousPasscodeName;
1106
1107 try {
1108 // attempt to find the passcode on this device by name, which includes booking and listing IDs
1109 $findResult = $this->listPasscodes($device, [
1110 // inject search property to match this exact lock-user (passcode) name
1111 'search' => sprintf('bid:%d-%d', $registry->getID(), $listingId),
1112 ]);
1113
1114 if (!$findResult->getProperties()) {
1115 // lock-user not found by name
1116 throw new Exception('Previous lock-user not found by name.', 404);
1117 }
1118
1119 // iterate the list of passcodes found, even if only one is expected
1120 foreach ($findResult->getProperties() as $pwdId => $pwdData) {
1121 // delete the first passcode found, previously created for this booking
1122 $this->deletePasscode($device, [
1123 'pwdid' => $pwdId,
1124 ]);
1125
1126 // abort loop at first run
1127 break;
1128 }
1129 } catch (Exception $e) {
1130 // do nothing on error with cancellations or previous passcodes not found
1131 }
1132 }
1133
1134 // process the modification as a new door access creation, always with custom passcodes
1135 return $this->createBookingDoorAccess($device, $listingId, $registry);
1136 }
1137
1138 /**
1139 * @inheritDoc
1140 */
1141 public function cancelBookingDoorAccess(VBODooraccessIntegrationDevice $device, int $listingId, VBOBookingRegistry $registry)
1142 {
1143 try {
1144 // find the previously created passcode for this booking and listing
1145 $findResult = $this->listPasscodes($device, [
1146 // inject search property to match this exact lock-user (passcode) name
1147 'search' => sprintf('bid:%d-%d', $registry->getID(), $listingId),
1148 ]);
1149
1150 if (!$findResult->getProperties()) {
1151 // lock-user (passcode) not found
1152 throw new Exception('Previous lock-user (passcode) not found.', 404);
1153 }
1154 } catch (Exception $e) {
1155 // nothing to cancel, but prevent unwanted errors not related to the real cancellation
1156 return null;
1157 }
1158
1159 // iterate the list of passcodes found, even if only one is expected
1160 foreach ($findResult->getProperties() as $pwdId => $pwdData) {
1161 // delete the first passcode found
1162 return $this->deletePasscode($device, [
1163 'pwdid' => $pwdId,
1164 ]);
1165 }
1166 }
1167
1168 /**
1169 * @inheritDoc
1170 */
1171 public function handleUnlockDevice(VBODooraccessIntegrationDevice $device)
1172 {
1173 // unlock the requested device
1174 return $this->unlockDevice($device);
1175 }
1176
1177 /**
1178 * @inheritDoc
1179 */
1180 public function getPasscodeFromHistoryResult(array $resultProperties)
1181 {
1182 // creating a passcode should bind its value within the device capability result object
1183 return $resultProperties['code'] ?? null;
1184 }
1185
1186 /**
1187 * @inheritDoc
1188 *
1189 * @link https://doc.api.u-tec.com/ Retrieve Device List
1190 */
1191 protected function fetchRemoteDevices()
1192 {
1193 // start transporter (by setting the Content-Type header)
1194 $transporter = $this->createHTTPTransporter([
1195 'headers' => [
1196 'Content-Type' => 'application/json',
1197 ],
1198 ]);
1199
1200 // build request data
1201 $data = [
1202 'header' => [
1203 'namespace' => 'Uhome.Device',
1204 'name' => 'Discovery',
1205 'messageId' => VBOPerformanceIndicator::uuid(),
1206 'payloadVersion' => '1',
1207 ],
1208 'payload' => new stdClass,
1209 ];
1210
1211 // make the API request
1212 $response = $transporter->post('https://api.u-tec.com/action', json_encode($data), $this->httpHeaders, 60);
1213
1214 // obtain the response data
1215 $responseData = (array) json_decode((string) $response->body, true);
1216
1217 if (empty($response->code) || $response->code > 299) {
1218 // an error occurred
1219 throw new Exception($response->body ?: 'Error fetching the remove devices.', ($response->code ?: 500));
1220 }
1221
1222 if (empty($responseData['payload']['devices'])) {
1223 throw new Exception('No devices found under the current account.', 500);
1224 }
1225
1226 /**
1227 * We should register the Notification URL at this point to allow
1228 * Webhook Notifications to be delivered by U-tec for any device.
1229 */
1230 $this->registerNotificationURL();
1231
1232 // return the list of devices fetched
1233 return (array) $responseData['payload']['devices'];
1234 }
1235
1236 /**
1237 * @inheritDoc
1238 */
1239 protected function decorateDeviceProperties(VBODooraccessIntegrationDevice $decorator, array $device)
1240 {
1241 // set device ID (device MAC address)
1242 $decorator->setID(($device['id'] ?? ''), $raw = true);
1243
1244 // set device name
1245 $decorator->setName($device['name'] ?? '');
1246
1247 // set device description
1248 $decorator->setDescription(implode(' - ', array_filter([
1249 $device['category'] ?? '',
1250 $device['handleType'] ?? '',
1251 ])));
1252
1253 // set device icon
1254 if (!strcasecmp(($device['category'] ?? ''), 'LIGHT')) {
1255 // not a smartlock, but a light
1256 $decorator->setIcon('<i class="' . VikBookingIcons::i('lightbulb') . '"></i>');
1257 } elseif (stripos(($device['category'] ?? ''), 'plug') !== false) {
1258 // not a smartlock, but a smart-plug
1259 $decorator->setIcon('<i class="' . VikBookingIcons::i('plug') . '"></i>');
1260 } else {
1261 // default to smartlock
1262 $decorator->setIcon('<i class="' . VikBookingIcons::i('fingerprint') . '"></i>');
1263 }
1264
1265 // set device model
1266 $decorator->setModel($device['deviceInfo']['model'] ?? '');
1267 $decorator->setModel(implode(' - ', array_filter([
1268 $device['deviceInfo']['manufacturer'] ?? '',
1269 $device['deviceInfo']['model'] ?? '',
1270 ])));
1271
1272 // set device capabilities
1273 $decorator->setCapabilities([
1274 // unlock device
1275 $this->createDeviceCapability([
1276 'id' => 'unlock_device',
1277 'title' => JText::translate('VBDASHUNLOCK'),
1278 'description' => JText::translate('VBO_UNLOCK_DEVICE_HELP'),
1279 'icon' => '<i class="' . VikBookingIcons::i('unlock') . '"></i>',
1280 'callback' => 'unlockDevice',
1281 ]),
1282 // lock device
1283 $this->createDeviceCapability([
1284 'id' => 'lock_device',
1285 'title' => JText::translate('VBO_LOCK'),
1286 'description' => JText::translate('VBO_LOCK_DEVICE_HELP'),
1287 'icon' => '<i class="' . VikBookingIcons::i('lock') . '"></i>',
1288 'callback' => 'lockDevice',
1289 ]),
1290 // read passcodes
1291 $this->createDeviceCapability([
1292 'id' => 'list_passcodes',
1293 'title' => JText::translate('VBO_LIST_PASSCODES'),
1294 'description' => JText::translate('VBO_LIST_PASSCODES_HELP'),
1295 'icon' => '<i class="' . VikBookingIcons::i('key') . '"></i>',
1296 'callback' => 'listPasscodes',
1297 ]),
1298 // create (custom) passcode
1299 $this->createDeviceCapability([
1300 'id' => 'create_custom_passcode',
1301 'title' => JText::translate('VBO_CREATE_PASSCODECUST'),
1302 'description' => JText::translate('VBO_CREATE_PASSCODECUST_HELP'),
1303 'icon' => '<i class="' . VikBookingIcons::i('user-plus') . '"></i>',
1304 'callback' => 'createCustomPasscode',
1305 'params' => [
1306 'pwdvalue' => [
1307 'type' => 'text',
1308 'label' => JText::translate('VBO_PASSCODE'),
1309 'help' => JText::translate('VBO_PASSCODE_EMPTY_HELP') . ' 4-8 digits (1-9, 6 digits by default), should not start with 12 and should not contain 0.',
1310 'attributes' => [
1311 'pattern' => '^(?!12)[1-9]{6}$',
1312 ],
1313 ],
1314 'pwdname' => [
1315 'type' => 'text',
1316 'label' => JText::translate('VBO_PASSCODE_NAME'),
1317 'help' => JText::translate('VBO_OPT_PASSCODE_NAME'),
1318 ],
1319 'startdate' => [
1320 'type' => 'datetime',
1321 'label' => JText::translate('VBNEWPKGDFROM'),
1322 'help' => JText::translate('VBO_PASSCODE_VALID_START'),
1323 ],
1324 'enddate' => [
1325 'type' => 'datetime',
1326 'label' => JText::translate('VBNEWPKGDTO'),
1327 'help' => JText::translate('VBO_PASSCODE_VALID_END'),
1328 ],
1329 ],
1330 ]),
1331 // delete passcode
1332 $this->createDeviceCapability([
1333 'id' => 'delete_passcode',
1334 'title' => JText::translate('VBO_DELETE_PASSCODE'),
1335 'description' => JText::translate('VBO_DELETE_PASSCODE_HELP'),
1336 'icon' => '<i class="' . VikBookingIcons::i('trash') . '"></i>',
1337 'callback' => 'deletePasscode',
1338 'params' => [
1339 'pwdid' => [
1340 'type' => 'text',
1341 'label' => 'Passcode ID',
1342 'help' => 'The password ID to delete. List all passcodes to find it.',
1343 ],
1344 ],
1345 ]),
1346 // update passcode
1347 $this->createDeviceCapability([
1348 'id' => 'update_passcode',
1349 'title' => JText::translate('VBO_UPDATE_PASSCODE'),
1350 'description' => JText::translate('VBO_UPDATE_PASSCODE_HELP'),
1351 'icon' => '<i class="' . VikBookingIcons::i('user-plus') . '"></i>',
1352 'callback' => 'updatePasscode',
1353 'params' => [
1354 'pwdid' => [
1355 'type' => 'text',
1356 'label' => 'Passcode ID',
1357 'help' => 'The password ID to update. List all passcodes to find it.',
1358 ],
1359 'pwdvalue' => [
1360 'type' => 'text',
1361 'label' => JText::translate('VBO_PASSCODE'),
1362 'help' => 'Leave empty to keep the existing password.',
1363 'attributes' => [
1364 'pattern' => '^(?!12)[1-9]{6}$',
1365 ],
1366 ],
1367 'startdate' => [
1368 'type' => 'datetime',
1369 'label' => JText::translate('VBNEWPKGDFROM'),
1370 'help' => JText::translate('VBO_PASSCODE_VALID_START'),
1371 ],
1372 'enddate' => [
1373 'type' => 'datetime',
1374 'label' => JText::translate('VBNEWPKGDTO'),
1375 'help' => JText::translate('VBO_PASSCODE_VALID_END'),
1376 ],
1377 ],
1378 ]),
1379 // check (query) device status
1380 $this->createDeviceCapability([
1381 'id' => 'device_status',
1382 'title' => JText::translate('VBSTATUS'),
1383 'description' => 'Check the current device status.',
1384 'icon' => '<i class="' . VikBookingIcons::i('signal') . '"></i>',
1385 'callback' => 'checkStatus',
1386 ]),
1387 ]);
1388
1389 // set device payload
1390 $decorator->setPayload($device);
1391 }
1392
1393 /**
1394 * Registers the Notification URL to receive Webhook notifications from U-tec.
1395 *
1396 * @param bool $updateToken True to generate a new verification token.
1397 *
1398 * @return void
1399 *
1400 * @throws Exception
1401 *
1402 * @todo they need an "access_token": at the moment we are generating a random token
1403 * internally to pass it to U-tec for the registration. They suggest to change
1404 * it periodically, so we are guessing they don't want the OAuth2 access token
1405 * that would be obtained through $this->getOauthToken(). However, their response
1406 * payload property was empty in both cases.
1407 */
1408 protected function registerNotificationURL(bool $updateToken = false)
1409 {
1410 // access the integration settings
1411 $settings = $this->getSettings();
1412
1413 // obtain current webhook token
1414 $webhookToken = $settings['_webhook']['token'] ?? null;
1415
1416 if ($webhookToken && !$updateToken) {
1417 // the notification URL was already registered with success
1418 return;
1419 }
1420
1421 if (!$webhookToken || $updateToken) {
1422 // generate a new token
1423 $webhookToken = VikBooking::getCPinInstance()->generateSerialCode(32, [
1424 'abcdefghijklmnopqrstuvwxyz',
1425 '0123456789',
1426 ]);
1427 }
1428
1429 // start transporter (by setting the Content-Type header)
1430 $transporter = $this->createHTTPTransporter([
1431 'headers' => [
1432 'Content-Type' => 'application/json',
1433 ],
1434 ]);
1435
1436 // build request data
1437 $data = [
1438 'header' => [
1439 'namespace' => 'Uhome.Configure',
1440 'name' => 'Set',
1441 'messageId' => VBOPerformanceIndicator::uuid(),
1442 'payloadVersion' => '1',
1443 ],
1444 'payload' => [
1445 'configure' => [
1446 'notification' => [
1447 // this is a value that will be used to sign the Webhook requests
1448 'access_token' => $webhookToken,
1449 // webhook endpoint URL for the current integration profile
1450 'url' => $this->buildWebhookURL(),
1451 ],
1452 ],
1453 ],
1454 ];
1455
1456 // make the API request
1457 $response = $transporter->post('https://api.u-tec.com/action', json_encode($data), $this->httpHeaders, 60);
1458
1459 // obtain the response data
1460 $responseData = (array) json_decode((string) $response->body, true);
1461
1462 if (empty($response->code) || $response->code > 299) {
1463 // an error occurred
1464 throw new Exception($response->body ?: 'Error registering the notification URL.', ($response->code ?: 500));
1465 }
1466
1467 // update settings to identify the webhook activation
1468 $webhookDetails = [
1469 'token' => $webhookToken,
1470 'creation_ts' => time(),
1471 ];
1472
1473 // merge webhook details with any possible value obtained within the response
1474 $webhookDetails = array_merge((array) ($responseData['payload'] ?? []), $webhookDetails);
1475
1476 // inject webhook details within the current integration settings
1477 $settings['_webhook'] = $webhookDetails;
1478
1479 // update integration record settings
1480 $this->setProfileRecordProp('settings', $settings);
1481
1482 // store integration record settings
1483 VBODooraccessFactory::getInstance()->saveIntegrationRecord($this, ['settings' => $this->getSettings()]);
1484 }
1485
1486 /**
1487 * Maps the supported user type identifiers with name.
1488 *
1489 * @param ?int $type Optional user type identifier to fetch.
1490 * @param bool $name True to get only the user type name.
1491 *
1492 * @return array|string Full list, user type array or type string name.
1493 */
1494 private function getUserTypes(?int $type = null, bool $name = false)
1495 {
1496 $list = [
1497 0 => [
1498 'name' => 'Normal User',
1499 ],
1500 1 => [
1501 'name' => 'User',
1502 ],
1503 2 => [
1504 'name' => 'Temporary User',
1505 ],
1506 3 => [
1507 'name' => 'Admin',
1508 ],
1509 ];
1510
1511 if (is_null($type)) {
1512 return $list;
1513 }
1514
1515 if (!$name) {
1516 return $list[$type] ?? [];
1517 }
1518
1519 return $list[$type]['name'] ?? '';
1520 }
1521
1522 /**
1523 * Generates a random serial code made of only digits with a given length.
1524 * The sequence obtained will never contain zeros for integer requirements
1525 * and it will not start with "12" for better randomness.
1526 *
1527 * @param int $length The passcode length.
1528 *
1529 * @return string
1530 */
1531 private function generateRandomPasscode(int $length = 6)
1532 {
1533 do {
1534 $passcode = VikBooking::getCPinInstance()->generateSerialCode($length, ['123456789']);
1535 } while (substr($passcode, 0, 2) == '12');
1536
1537 return $passcode;
1538 }
1539
1540 /**
1541 * Creates the HTTP Transporter to establish API connections with U-tec.
1542 * An integration profile record is supposed to be set before making an HTTP request.
1543 *
1544 * @param ?array $options Optional transporter options.
1545 *
1546 * @return object The prepared HTTP transporter object with bearer token.
1547 *
1548 * @throws Exception
1549 */
1550 private function createHTTPTransporter(?array $options = null)
1551 {
1552 // access current profile settings
1553 $settings = $this->getSettings();
1554
1555 if (empty($settings['client_id']) || empty($settings['client_secret'])) {
1556 // settings must be configured
1557 throw new Exception('Missing OAuth2 API Key (Client ID) and OAuth2 API Secret (Client Secret). Please go through settings.', 500);
1558 }
1559
1560 // access bearer token
1561 $bearerToken = null;
1562
1563 // ensure we are not actually using the transporter for authorising the application
1564 if (empty($options['doing_oauth'])) {
1565 if (empty($settings['_oauth']['access_token'])) {
1566 // application must be authorised
1567 throw new Exception('Missing OAuth2 authorisation data for the application. Please go through settings.', 500);
1568 }
1569
1570 // get or refresh the access (bearer) token
1571 $bearerToken = $this->getOauthToken();
1572 }
1573
1574 // set HTTP headers
1575 $this->httpHeaders = [
1576 'Authorization' => "Bearer {$bearerToken}",
1577 'Accept' => 'application/json',
1578 ];
1579
1580 if (!$bearerToken) {
1581 // the request should not define a default Authorization header
1582 unset($this->httpHeaders['Authorization']);
1583 }
1584
1585 if (is_array($options['headers'] ?? null)) {
1586 // merge default headers with the given ones (associative list expected)
1587 $this->httpHeaders = $this->httpHeaders + $options['headers'];
1588 }
1589
1590 return new JHttp;
1591 }
1592
1593 /**
1594 * Obtains an active OAuth (Bearer) token to establish API connections with U-tec.
1595 * In this case, the authentication method configured should be "OAuth".
1596 *
1597 * @return string An active OAuth (Bearer) token.
1598 *
1599 * @throws Exception
1600 */
1601 private function getOauthToken()
1602 {
1603 // access current profile settings
1604 $settings = $this->getSettings();
1605
1606 if (empty($settings['_oauth']['access_token'])) {
1607 // application must be authorised
1608 throw new Exception('Missing OAuth2 authorisation data for the application. Please go through settings.', 500);
1609 }
1610
1611 if (($settings['_oauth']['expiry_ts'] ?? 0) < time()) {
1612 // the token should be renewed because it's expired
1613 return $this->renewOauthToken();
1614 }
1615
1616 // return the supposingly active token
1617 return (string) $settings['_oauth']['access_token'];
1618 }
1619
1620 /**
1621 * Makes an API request with U-tec to refresh and save the OAuth token for any HTTP request.
1622 *
1623 * @return string An active OAuth (Bearer) token ready to be used.
1624 *
1625 * @throws Exception
1626 */
1627 private function renewOauthToken()
1628 {
1629 // access current profile settings
1630 $settings = $this->getSettings();
1631
1632 if (empty($settings['client_id']) || empty($settings['client_secret'])) {
1633 // settings must be configured
1634 throw new Exception('Missing OAuth2 API Key (Client ID) and OAuth2 API Secret (Client Secret). Please go through settings.', 500);
1635 }
1636
1637 if (empty($settings['_oauth']['refresh_token'])) {
1638 // application must be authorised
1639 throw new Exception('Missing OAuth2 authorisation data (refresh token) for the application. Please go through authorisation.', 500);
1640 }
1641
1642 // build request data
1643 $data = [
1644 'grant_type' => 'refresh_token',
1645 'client_id' => $settings['client_id'],
1646 'client_secret' => $settings['client_secret'],
1647 'refresh_token' => $settings['_oauth']['refresh_token'],
1648 ];
1649
1650 // exchange the settings to obtain the OAuth token details
1651 $response = (new JHttp)->post('https://oauth.u-tec.com/token', http_build_query($data), ['Content-Type' => 'application/x-www-form-urlencoded'], 10);
1652
1653 // obtain the response data
1654 $responseData = (array) json_decode((string) $response->body, true);
1655
1656 if (empty($response->code) || $response->code > 299) {
1657 // an error occurred
1658 throw new Exception($response->body ?: 'OAuth token error.', $response->code);
1659 }
1660
1661 if (empty($responseData['access_token'])) {
1662 // invalid response
1663 throw new Exception($response->body ?: 'OAuth token refresh response missing access token.', 500);
1664 }
1665
1666 // calculate and set the token expiration timestamp
1667 $responseData['expiry_ts'] = strtotime(sprintf('+%d seconds', (int) ($responseData['expires_in'] ?? 0)));
1668
1669 // inject OAuth details within the current integration settings
1670 $settings['_oauth'] = $responseData;
1671
1672 // update integration record settings
1673 $this->setProfileRecordProp('settings', $settings);
1674
1675 // store integration record settings
1676 VBODooraccessFactory::getInstance()->saveIntegrationRecord($this, ['settings' => $this->getSettings()]);
1677
1678 // return the current access token
1679 return (string) $responseData['access_token'];
1680 }
1681 }
1682