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