[
'type' => 'checkbox',
'label' => JText::translate('VBO_AI_SUPPORT'),
'help' => JText::translate('VBO_DAC_AI_SUPPORT_HELP'),
'default' => 1,
],
'firstaccess_notif' => [
'type' => 'checkbox',
'label' => JText::translate('VBO_NOTIFY_FIRST_ACCESS'),
'help' => JText::translate('VBO_NOTIFY_FIRST_ACCESS_HELP'),
'default' => 0,
],
'passquant' => [
'type' => 'select',
'label' => JText::translate('VBO_PASSCODES'),
'help' => JText::translate('VBO_PASSCODES_QUANT_HELP'),
'options' => [
1 => JText::translate('VBO_ONE_PER_DEVICE'),
2 => JText::translate('VBO_ONE_PER_BOOKING'),
],
'default' => 1,
],
'client_id' => [
'type' => 'text',
'label' => 'Client ID',
],
'client_secret' => [
'type' => 'password',
'label' => 'Client Secret',
],
'username' => [
'type' => 'text',
'label' => 'Username',
],
'password' => [
'type' => 'password',
'label' => 'Password',
],
];
}
/**
* @inheritDoc
*/
public function canUnlockDevices()
{
// this method is called when the integration has loaded its profile record
// we return true only if the apposite AI setting is enabled
$settings = $this->getSettings();
return !empty($settings['ai']);
}
/**
* @inheritDoc
*/
public function canWatchFirstAccess()
{
// this method is called when the integration has loaded its profile record
// we return true only if the apposite setting is enabled
$settings = $this->getSettings();
return !empty($settings['firstaccess_notif']);
}
/**
* Device capability implementation to unlock a device.
*
* @param VBODooraccessIntegrationDevice $device The device executing the capability.
* @param ?array $options Optional settings populated from capability parameters.
*
* @return VBODooraccessDeviceCapabilityResult
*
* @link https://euopen.ttlock.com/document/doc?urlName=cloud%2Fgateway%2FunlockEn.html
*/
public function unlockDevice(VBODooraccessIntegrationDevice $device, ?array $options = null)
{
// start transporter
$transporter = $this->createHTTPTransporter();
// obtain integration settings after initializing the transporter
$settings = $this->getSettings();
// build request data
$data = [
'clientId' => $settings['client_id'],
'accessToken' => $settings['_oauth']['access_token'],
'lockId' => $device->getID(),
'date' => time() . '000',
];
// make the API request
$response = $transporter->post('https://euapi.ttlock.com/v3/lock/unlock', $data, [], 60);
// obtain the response data
$responseData = (array) json_decode((string) $response->body, true);
if ($response->code != 200 || !empty($responseData['errcode'])) {
// an error occurred
throw new Exception($responseData['errmsg'] ?? $response->body ?: 'Error unlocking the device.', ($response->code != 200 ? $response->code : 500));
}
return (new VBODooraccessDeviceCapabilityResult)->setText(sprintf('The device "%s" was unlocked!', $device->getName()));
}
/**
* Device capability implementation to lock a device.
*
* @param VBODooraccessIntegrationDevice $device The device executing the capability.
* @param ?array $options Optional settings populated from capability parameters.
*
* @return VBODooraccessDeviceCapabilityResult
*
* @link https://euopen.ttlock.com/document/doc?urlName=cloud%2Fgateway%2FunlockEn.html
*/
public function lockDevice(VBODooraccessIntegrationDevice $device, ?array $options = null)
{
// start transporter
$transporter = $this->createHTTPTransporter();
// obtain integration settings after initializing the transporter
$settings = $this->getSettings();
// build request data
$data = [
'clientId' => $settings['client_id'],
'accessToken' => $settings['_oauth']['access_token'],
'lockId' => $device->getID(),
'date' => time() . '000',
];
// make the API request
$response = $transporter->post('https://euapi.ttlock.com/v3/lock/lock', $data, [], 60);
// obtain the response data
$responseData = (array) json_decode((string) $response->body, true);
if ($response->code != 200 || !empty($responseData['errcode'])) {
// an error occurred
throw new Exception($responseData['errmsg'] ?? $response->body ?: 'Error locking the device.', ($response->code != 200 ? $response->code : 500));
}
return (new VBODooraccessDeviceCapabilityResult)->setText(sprintf('The device "%s" was locked!', $device->getName()));
}
/**
* Device capability implementation to list a device passcodes.
*
* @param VBODooraccessIntegrationDevice $device The device executing the capability.
* @param ?array $options Optional settings populated from capability parameters.
*
* @return VBODooraccessDeviceCapabilityResult
*
* @throws Exception
*
* @link https://euopen.ttlock.com/document/doc?urlName=cloud%2Fpasscode%2FlistEn.html
*/
public function listPasscodes(VBODooraccessIntegrationDevice $device, ?array $options = null)
{
$passcodes = [];
// start transporter
$transporter = $this->createHTTPTransporter();
// obtain integration settings after initializing the transporter
$settings = $this->getSettings();
// request page settings
$pageNo = 1;
$pageSize = 100;
$reqCount = 0;
$reqMax = 5;
// start a loop to support pagination
while (true) {
if ($reqCount >= $reqMax) {
// too many requests
break;
}
// build query string data
$data = [
'clientId' => $settings['client_id'],
'accessToken' => $settings['_oauth']['access_token'],
'lockId' => $device->getID(),
'searchStr' => $options['search'] ?? null,
'pageNo' => $pageNo,
'pageSize' => $pageSize,
'orderBy' => 1,
'date' => time() . '000',
];
// make a request to obtain all created passcodes of a lock
$response = $transporter->get('https://euapi.ttlock.com/v3/lock/listKeyboardPwd?' . http_build_query($data), [], 20);
// obtain the response data
$responseData = (array) json_decode((string) $response->body, true);
if ($response->code != 200 || !empty($responseData['errcode'])) {
// an error occurred
throw new Exception($responseData['errmsg'] ?? $response->body ?: 'Error fetching device passcodes.', ($response->code != 200 ? $response->code : 500));
}
// increase request counter
$reqCount++;
if (!empty($responseData['list'])) {
$passcodes = array_merge($passcodes, $responseData['list']);
}
if (($responseData['pages'] ?? 0) > $pageNo) {
// go to the next loop
$pageNo++;
continue;
}
// all passcodes were read
break;
}
if (!$passcodes) {
throw new Exception('No passcodes found for the device.', 404);
}
// list of passcode IDs and values obtained
$passcodesAssoc = [];
// build HTML output
$output = '';
// lang defs
$lang_passcode = JText::translate('VBO_PASSCODE');
$lang_startdate = JText::translate('VBNEWPKGDFROM');
$lang_enddate = JText::translate('VBNEWPKGDTO');
$lang_createdon = JText::translate('VBOINVCREATIONDATE');
$lang_createdby = JText::translate('VBCSVCREATEDBY');
$lang_custom = JText::translate('VBO_CUSTOM');
// table head
$output .= <<
| Passcode ID |
Passcode Name |
{$lang_passcode} |
Passcode Type |
{$lang_startdate} |
{$lang_enddate} |
{$lang_createdon} |
{$lang_createdby} |
{$lang_custom} |
HTML;
// scan all passcodes obtained
foreach ($passcodes as $passcode) {
// set passcode properties
$passcodeId = $passcode['keyboardPwdId'] ?? '';
$passcodeValue = $passcode['keyboardPwd'] ?? '';
$passcodeName = $passcode['keyboardPwdName'] ?? '';
$passcodeType = $this->getPasscodeTypes((int) ($passcode['keyboardPwdType'] ?? 0), true);
$startDate = !empty($passcode['startDate']) ? date('Y-m-d H:i:s', ($passcode['startDate'] ?: 1000) / 1000) : '---';
$endDate = !empty($passcode['endDate']) ? date('Y-m-d H:i:s', ($passcode['endDate'] ?: 1000) / 1000) : '---';
$sendDate = !empty($passcode['sendDate']) ? date('Y-m-d H:i:s', ($passcode['sendDate'] ?: 1000) / 1000) : '---';
$senderUsername = $passcode['senderUsername'] ?? '';
$isCustom = ($passcode['isCustom'] ?? 0) == 1 ? JText::translate('VBYES') : JText::translate('VBNO');
// bind passcode id values
$passcodesAssoc[$passcodeId] = [
'name' => $passcodeName,
'value' => $passcodeValue,
];
// build passcode HTML code
$output .= <<
{$passcodeId} |
{$passcodeName} |
{$passcodeValue} |
{$passcodeType} |
{$startDate} |
{$endDate} |
{$sendDate} |
{$senderUsername} |
{$isCustom} |
HTML;
}
// close table
$output .= <<
HTML;
// return the capability result object by setting the output value
return (new VBODooraccessDeviceCapabilityResult($passcodesAssoc))
->setOutput($output);
}
/**
* Device capability implementation to create a random passcode for a device (generated by TTLock).
* Notice that the random passcode generation of TTLock is based on the password type and the validity dates.
* This means that deleting a passcode for a device that was valid on certain dates, will trigger an error
* if another random passcode is generated for the same device and validity dates. It is, therefore, unsafe
* to rely on this passcode generation method when passcodes are generated at the time of booking, because
* modifications or cancellations may occur and a new re-generation of passcode may be requested for new bookings.
*
* @param VBODooraccessIntegrationDevice $device The device executing the capability.
* @param ?array $options Optional settings populated from capability parameters.
*
* @return VBODooraccessDeviceCapabilityResult
*
* @link https://euopen.ttlock.com/document/doc?urlName=cloud%2Fpasscode%2FgetEn.html
*/
public function createRandomPasscode(VBODooraccessIntegrationDevice $device, ?array $options = null)
{
// start transporter
$transporter = $this->createHTTPTransporter();
// obtain integration settings after initializing the transporter
$settings = $this->getSettings();
// build request data
$data = [
'clientId' => $settings['client_id'],
'accessToken' => $settings['_oauth']['access_token'],
'lockId' => $device->getID(),
'keyboardPwdType' => (int) ($options['pwdtype'] ?? 3),
'keyboardPwdName' => $options['pwdname'] ?? null,
'startDate' => strtotime($options['startdate'] ?? date('Y-m-d H:i:s')) . '000',
'endDate' => strtotime($options['enddate'] ?? date('Y-m-d H:i:s')) . '000',
'date' => time() . '000',
];
// make the API request
$response = $transporter->post('https://euapi.ttlock.com/v3/keyboardPwd/get', $data, [], 60);
// obtain the response data
$responseData = (array) json_decode((string) $response->body, true);
if ($response->code != 200 || !empty($responseData['errcode'])) {
// an error occurred
throw new Exception($responseData['errmsg'] ?? $response->body ?: 'Error getting a random passcode for the device.', ($response->code != 200 ? $response->code : 500));
}
// build result properties to bind
$resultProps = [
'keyboardPwd' => (string) ($responseData['keyboardPwd'] ?? ''),
'keyboardPwdId' => (string) ($responseData['keyboardPwdId'] ?? ''),
'listingId' => (int) ($options['listing_id'] ?? 0),
];
// get the listing name, if available
$listingName = '';
if (!empty($resultProps['listingId'])) {
$listingData = VikBooking::getRoomInfo($resultProps['listingId'], ['name'], true);
$listingName = sprintf('%s: ', $listingData['name'] ?? '');
}
// wrap and return the device capability result object
return (new VBODooraccessDeviceCapabilityResult($resultProps))
->setPasscode($resultProps['keyboardPwd'])
->setText($listingName . JText::sprintf('VBO_PASSCODE_GEN_OK_DEVICE', $resultProps['keyboardPwd'], $device->getName()));
}
/**
* Device capability implementation to create a custom passcode for a device.
*
* @param VBODooraccessIntegrationDevice $device The device executing the capability.
* @param ?array $options Optional settings populated from capability parameters.
*
* @return VBODooraccessDeviceCapabilityResult
*
* @link https://euopen.ttlock.com/document/doc?urlName=cloud%2Fpasscode%2FaddEn.html
*
* @since 1.18.6 (J) - 1.8.6 (WP) API errors will throw a Door Access Exception with retry-data.
*/
public function createCustomPasscode(VBODooraccessIntegrationDevice $device, ?array $options = null)
{
// start transporter
$transporter = $this->createHTTPTransporter();
// obtain integration settings after initializing the transporter
$settings = $this->getSettings();
if (empty($options['pwdvalue'])) {
// passcode value cannot be empty
$options['pwdvalue'] = $this->generateRandomPasscode();
}
// build request data
$data = [
'clientId' => $settings['client_id'],
'accessToken' => $settings['_oauth']['access_token'],
'lockId' => $device->getID(),
'keyboardPwd' => (int) $options['pwdvalue'],
'keyboardPwdName' => $options['pwdname'] ?? null,
'keyboardPwdType' => (int) ($options['pwdtype'] ?? 3),
'startDate' => strtotime($options['startdate'] ?? date('Y-m-d H:i:s')) . '000',
'endDate' => strtotime($options['enddate'] ?? date('Y-m-d H:i:s')) . '000',
'addType' => 2,
'date' => time() . '000',
];
// make the API request
$response = $transporter->post('https://euapi.ttlock.com/v3/keyboardPwd/add', $data, [], 60);
// obtain the response data
$responseData = (array) json_decode((string) $response->body, true);
if ($response->code != 200 || !empty($responseData['errcode'])) {
// an error occurred, build DAC Exception with retry-data
$dacError = (new VBODooraccessException($responseData['errmsg'] ?? $response->body ?: 'Error adding a custom passcode to the device.', ($response->code != 200 ? $response->code : 500)))
->setDevice($device)
->setRetryCallback('createCustomPasscode')
->setRetryData($options);
// throw error
throw $dacError;
}
// build result properties to bind
$resultProps = [
'keyboardPwd' => (string) $options['pwdvalue'],
'keyboardPwdId' => (string) ($responseData['keyboardPwdId'] ?? ''),
'listingId' => (int) ($options['listing_id'] ?? 0),
];
// get the listing name, if available
$listingName = '';
if (!empty($resultProps['listingId'])) {
$listingData = VikBooking::getRoomInfo($resultProps['listingId'], ['name'], true);
$listingName = sprintf('%s: ', $listingData['name'] ?? '');
}
// wrap and return the device capability result object
return (new VBODooraccessDeviceCapabilityResult($resultProps))
->setPasscode($resultProps['keyboardPwd'])
->setText($listingName . JText::sprintf('VBO_PASSCODE_GEN_OK_DEVICE', $resultProps['keyboardPwd'], $device->getName()));
}
/**
* Device capability implementation to delete a passcode from a device.
*
* @param VBODooraccessIntegrationDevice $device The device executing the capability.
* @param ?array $options Optional settings populated from capability parameters.
*
* @return VBODooraccessDeviceCapabilityResult
*
* @link https://euopen.ttlock.com/document/doc?urlName=cloud%2Fpasscode%2FdeleteEn.html
*/
public function deletePasscode(VBODooraccessIntegrationDevice $device, ?array $options = null)
{
// start transporter
$transporter = $this->createHTTPTransporter();
// obtain integration settings after initializing the transporter
$settings = $this->getSettings();
// build request data
$data = [
'clientId' => $settings['client_id'],
'accessToken' => $settings['_oauth']['access_token'],
'lockId' => $device->getID(),
'keyboardPwdId' => $options['pwdid'] ?? null,
'deleteType' => 2,
'date' => time() . '000',
];
// make the API request
$response = $transporter->post('https://euapi.ttlock.com/v3/keyboardPwd/delete', $data, [], 60);
// obtain the response data
$responseData = (array) json_decode((string) $response->body, true);
if ($response->code != 200 || !empty($responseData['errcode'])) {
// an error occurred
throw new Exception($responseData['errmsg'] ?? $response->body ?: 'Error deleting passcode from device.', ($response->code != 200 ? $response->code : 500));
}
return (new VBODooraccessDeviceCapabilityResult)
->setText(JText::sprintf('VBO_PASSCODE_DEL_OK_DEVICE', $device->getName()));
}
/**
* Device capability implementation to show the list of activity logs (unlock records) of a device.
*
* @param VBODooraccessIntegrationDevice $device The device executing the capability.
* @param ?array $options Optional settings populated from capability parameters.
*
* @return VBODooraccessDeviceCapabilityResult
*
* @throws Exception
*
* @link https://euopen.ttlock.com/document/doc?urlName=cloud%2FlockRecord%2FlistEn.html
*
* @since 1.18.6 (J) - 1.8.6 (WP)
*/
public function showActivityLogs(VBODooraccessIntegrationDevice $device, ?array $options = null)
{
$activities = [];
// start transporter
$transporter = $this->createHTTPTransporter();
// obtain integration settings after initializing the transporter
$settings = $this->getSettings();
// request page settings
$pageNo = 1;
$pageSize = 150;
$reqCount = 0;
$reqMax = 5;
// start a loop to support pagination
while (true) {
if ($reqCount >= $reqMax) {
// too many requests
break;
}
// build query string data
$data = [
'clientId' => $settings['client_id'],
'accessToken' => $settings['_oauth']['access_token'],
'lockId' => $device->getID(),
'startDate' => (!empty($options['startdate']) ? strtotime($options['startdate']) . '000' : null),
'endDate' => (!empty($options['enddate']) ? strtotime($options['enddate']) . '000' : null),
'pageNo' => $pageNo,
'pageSize' => $pageSize,
'recordType' => (!empty($options['recordtype']) ? (int) $options['recordtype'] : null),
'searchStr' => $options['search'] ?? null,
'date' => time() . '000',
];
// make a request to obtain the unlock records of a lock
$response = $transporter->get('https://euapi.ttlock.com/v3/lockRecord/list?' . http_build_query($data), [], 20);
// obtain the response data
$responseData = (array) json_decode((string) $response->body, true);
if ($response->code != 200 || !empty($responseData['errcode'])) {
// an error occurred
throw new Exception($responseData['errmsg'] ?? $response->body ?: 'Error fetching device unlock records.', ($response->code != 200 ? $response->code : 500));
}
// increase request counter
$reqCount++;
if (!empty($responseData['list'])) {
$activities = array_merge($activities, $responseData['list']);
}
if (($responseData['pages'] ?? 0) > $pageNo) {
// go to the next loop
$pageNo++;
continue;
}
// all records were read
break;
}
if (!$activities) {
throw new Exception('No unlock records found for the device.', 404);
}
// build HTML output
$output = '';
// lang defs
$lang_passcode = JText::translate('VBO_PASSCODE');
$lang_type = JText::translate('VBPSHOWSEASONSTHREE');
$lang_status = JText::translate('VBSTATUS');
$lang_createdon = JText::translate('VBOINVCREATIONDATE');
$lang_createdby = JText::translate('VBCSVCREATEDBY');
// table head
$output .= <<
| ID |
{$lang_passcode} |
{$lang_type} |
{$lang_status} |
{$lang_createdon} |
{$lang_createdby} |
HTML;
// scan all activites obtained
foreach ($activities as $activity) {
// set activity properties
$activityId = $activity['recordId'] ?? '';
$passcodeValue = $activity['keyboardPwd'] ?? '---';
$activityType = $this->getActivityRecordTypes((int) ($activity['recordType'] ?? $activity['recordTypeFromLock'] ?? 0));
$isSuccess = ($activity['success'] ?? 0) == 1 ? JText::translate('VBYES') : JText::translate('VBNO');
$createdOn = !empty($activity['lockDate']) ? date('Y-m-d H:i:s', ($activity['lockDate'] / 1000)) : '';
$createdOn = empty($createdOn) && !empty($activity['serverDate']) ? date('Y-m-d H:i:s', ($activity['serverDate'] / 1000)) : $createdOn;
$createdOn = $createdOn ?: '---';
$createdBy = $activity['username'] ?? '---';
// build passcode HTML code
$output .= <<
{$activityId} |
{$passcodeValue} |
{$activityType} |
{$isSuccess} |
{$createdOn} |
{$createdBy} |
HTML;
}
// close table
$output .= <<
HTML;
// return the capability result object by setting the output value
return (new VBODooraccessDeviceCapabilityResult($activities))
->setOutput($output);
}
/**
* @inheritDoc
*/
public function createBookingDoorAccess(VBODooraccessIntegrationDevice $device, int $listingId, VBOBookingRegistry $registry)
{
// access the integration settings
$settings = $this->getSettings();
// build booking-listing signature
$signature = sprintf('%d-%d', $registry->getID(), $listingId);
// access booking registry DAC data for passcodes generated
$passcodesBuffer = $registry->getDACProperty($this->getAlias(), 'passcodes', []);
// determine the passcode value to use, either a new one or a previous one for the same booking
if (($settings['passquant'] ?? 0) == 2 && ($passcodesBuffer[$signature] ?? null)) {
// use the previously generated passcode for this booking and listing also on this device
$passcodeValue = $passcodesBuffer[$signature];
} else {
// generate custom, yet random, passcode value of 8 digits for this device
$passcodeValue = $this->generateRandomPasscode();
}
// prepare the options for creating a custom passcode (randomly generated by us)
$options = [
// use a password name that can be used later to find it under this booking and listing ID
'pwdname' => sprintf('bid:%d-%d', $registry->getID(), $listingId),
// set the passcode validity start date and time
'startdate' => date('Y-m-d H:i:00', $registry->getProperty('checkin', 0)),
// set the passcode validity end date and time
'enddate' => date('Y-m-d H:i:00', $registry->getProperty('checkout', 0)),
// custom passcode value to create on the device
'pwdvalue' => $passcodeValue,
// inject the listing ID for completion of data
'listing_id' => $listingId,
];
// create custom passcode on the current device
$result = $this->createCustomPasscode($device, $options);
// update booking registry DAC data for passcodes generated
$passcodesBuffer[$signature] = $result->getPasscode();
$registry->setDACProperty($this->getAlias(), 'passcodes', $passcodesBuffer);
return $result;
}
/**
* @inheritDoc
*/
public function modifyBookingDoorAccess(VBODooraccessIntegrationDevice $device, int $listingId, VBOBookingRegistry $registry)
{
// searching, deleting and re-creating passcodes is always safer in case of
// booking modification for possibly different listing IDs involved
// find the passcode data that were previously created for this booking
$previousDevicePasscodes = VikBooking::getBookingHistoryInstance($registry->getID())
->getEventsWithData(['ND', 'MD'], function($data) use ($device) {
$data = (array) $data;
// ensure the passcode was generated for this provider, profile and device
return ($data['provider'] ?? '') == $this->getProfileProvider() &&
($data['profile'] ?? '') == $this->getProfileID() &&
($data['device'] ?? '') == $device->getID() &&
(!empty($data['passcode']) || !empty($data['props']));
});
if (!$previousDevicePasscodes) {
// no passcodes were previously created for this booking
// process the modification as a new door access creation (with TTLock random passcode)
return $this->createBookingDoorAccess($device, $listingId, $registry);
}
// scan all previously created passcodes in DESC order on this device and delete them
$previousPasscodeIds = [];
foreach (array_reverse($previousDevicePasscodes) as $previousData) {
// ensure we only have array values
$previousData = (array) json_decode(json_encode($previousData), true);
// get the previous passcode
$previousPasscode = ($previousData['passcode'] ?? '') ?: ($previousData['props']['keyboardPwdId'] ?? '');
if (empty($previousPasscode) || in_array($previousPasscode, $previousPasscodeIds)) {
// no passcode ID to delete, or already deleted
continue;
}
// push processed passcode ID
$previousPasscodeIds[] = $previousPasscode;
try {
// delete previous passcode for this booking
$this->deletePasscode($device, [
'pwdid' => $previousPasscode,
]);
} catch (Exception $e) {
// do nothing on error
}
}
// process the modification as a new door access creation, always with custom passcodes
return $this->createBookingDoorAccess($device, $listingId, $registry);
}
/**
* @inheritDoc
*/
public function cancelBookingDoorAccess(VBODooraccessIntegrationDevice $device, int $listingId, VBOBookingRegistry $registry)
{
try {
// find the previously created passcode for this booking and listing
$findResult = $this->listPasscodes($device, [
'search' => sprintf('bid:%d-%d', $registry->getID(), $listingId),
]);
if (!$findResult->getProperties()) {
// passcode not found
throw new Exception('Previous passcode not found.', 404);
}
} catch (Exception $e) {
// nothing to cancel, but prevent unwanted errors not related to the real cancellation
return null;
}
// iterate the list of passcodes found, even if only one is expected
foreach ($findResult->getProperties() as $pwdId => $pwdData) {
// delete the first passcode found
return $this->deletePasscode($device, [
'pwdid' => $pwdId,
]);
}
}
/**
* @inheritDoc
*/
public function handleUnlockDevice(VBODooraccessIntegrationDevice $device)
{
// unlock the requested device
return $this->unlockDevice($device);
}
/**
* @inheritDoc
*/
public function getPasscodeFromHistoryResult(array $resultProperties)
{
// creating a passcode should bind its value within the device capability result object
return $resultProperties['keyboardPwd'] ?? null;
}
/**
* @inheritDoc
*
* @since 1.18.6 (J) - 1.8.6 (WP)
*/
public function detectFirstAccess(VBODooraccessIntegrationDevice $device, int $listingId, VBOBookingRegistry $registry)
{
// the expected passcode name to match in the activities "username"
$matchPwdName = sprintf('bid:%d-%d', $registry->getID(), $listingId);
try {
// access the activity logs for the booking stay dates in the current device
$findResult = $this->showActivityLogs($device, [
'startdate' => date('Y-m-d 00:00:00', $registry->getProperty('checkin', 0)),
'enddate' => date('Y-m-d 23:59:59', $registry->getProperty('checkout', 0)),
]);
if (!$findResult->getProperties()) {
// no activities found
throw new Exception('No activities found.', 404);
}
} catch (Exception $e) {
// nothing useful was detected, but prevent unwanted errors to be thrown
return null;
}
// iterate all activities found
foreach ($findResult->getProperties() as $activity) {
if (($activity['username'] ?? '') == $matchPwdName) {
// booking access found within the device activity logs for matching passcode name
$createdOn = !empty($activity['lockDate']) ? date('Y-m-d H:i:s', ($activity['lockDate'] / 1000)) : '';
$createdOn = empty($createdOn) && !empty($activity['serverDate']) ? date('Y-m-d H:i:s', ($activity['serverDate'] / 1000)) : $createdOn;
// return the capability result with the matching activity
return (new VBODooraccessDeviceCapabilityResult($activity))
->setPasscode($activity['keyboardPwd'] ?? '')
->setText(sprintf('%s (%s)', ($activity['keyboardPwd'] ?? ''), $createdOn));
}
}
// if nothing is found, scan booking registry DAC passcodes data as fallback
foreach ($registry->getDACProperty($this->getAlias(), 'passcodes_data', []) as $passcodeData) {
// get the passcode value
$passcodeValue = is_array($passcodeData) ? $this->getPasscodeFromHistoryResult($passcodeData) : $passcodeData;
if (!is_string($passcodeValue) || !$passcodeValue) {
// no passcode to look for
continue;
}
foreach ($findResult->getProperties() as $activity) {
if (($activity['keyboardPwd'] ?? '') == $passcodeValue) {
// booking access found within the device activity logs for matching passcode value
$createdOn = !empty($activity['lockDate']) ? date('Y-m-d H:i:s', ($activity['lockDate'] / 1000)) : '';
$createdOn = empty($createdOn) && !empty($activity['serverDate']) ? date('Y-m-d H:i:s', ($activity['serverDate'] / 1000)) : $createdOn;
// return the capability result with the matched property
return (new VBODooraccessDeviceCapabilityResult($activity))
->setPasscode($activity['keyboardPwd'] ?? '')
->setText(sprintf('%s (%s)', ($activity['keyboardPwd'] ?? ''), $createdOn));
}
}
}
return null;
}
/**
* @inheritDoc
*
* @link https://euopen.ttlock.com/document/doc?urlName=cloud%2Flock%2FlistEn.html
*/
protected function fetchRemoteDevices()
{
$devices = [];
// start transporter
$transporter = $this->createHTTPTransporter();
// obtain settings after initializing the transporter
$settings = $this->getSettings();
// request page settings
$pageNo = 1;
$pageSize = 20;
$reqCount = 0;
$reqMax = 20;
// start a loop to support pagination
while (true) {
if ($reqCount >= $reqMax) {
// too many requests
break;
}
// build query string data
$data = [
'clientId' => $settings['client_id'],
'accessToken' => $settings['_oauth']['access_token'],
'pageNo' => $pageNo,
'pageSize' => $pageSize,
'date' => time() . '000',
];
// make a request to obtain the lock list of an account
$response = $transporter->get('https://euapi.ttlock.com/v3/lock/list?' . http_build_query($data), [], 20);
// obtain the response data
$responseData = (array) json_decode((string) $response->body, true);
if ($response->code != 200 || !empty($responseData['errcode'])) {
// an error occurred
throw new Exception($responseData['errmsg'] ?? $response->body ?: 'Error fetching the remove devices.', ($response->code != 200 ? $response->code : 500));
}
// increase request counter
$reqCount++;
if (!empty($responseData['list'])) {
$devices = array_merge($devices, $responseData['list']);
}
if (($responseData['pages'] ?? 0) > $pageNo) {
// go to the next loop
$pageNo++;
continue;
}
// all devices were read
break;
}
if (!$devices) {
throw new Exception('No devices found under the current account.', 500);
}
return $devices;
}
/**
* @inheritDoc
*/
protected function decorateDeviceProperties(VBODooraccessIntegrationDevice $decorator, array $device)
{
// set device ID
$decorator->setID($device['lockId'] ?? '');
// set device name
$decorator->setName($device['lockAlias'] ?? $device['lockName'] ?? '');
// set device description
$decorator->setDescription($device['groupName'] ?? '');
// set device icon
$decorator->setIcon('');
// set device model
$decorator->setModel($device['lockName'] ?? '');
if ($device['electricQuantity'] ?? null) {
// set device battery level
$decorator->setBatteryLevel((float) $device['electricQuantity']);
}
// set device capabilities
$decorator->setCapabilities([
// unlock device
$this->createDeviceCapability([
'id' => 'unlock_device',
'title' => JText::translate('VBDASHUNLOCK'),
'description' => JText::translate('VBO_UNLOCK_DEVICE_HELP'),
'icon' => '',
'callback' => 'unlockDevice',
]),
// lock device
$this->createDeviceCapability([
'id' => 'lock_device',
'title' => JText::translate('VBO_LOCK'),
'description' => JText::translate('VBO_LOCK_DEVICE_HELP'),
'icon' => '',
'callback' => 'lockDevice',
]),
// read passcodes
$this->createDeviceCapability([
'id' => 'list_passcodes',
'title' => JText::translate('VBO_LIST_PASSCODES'),
'description' => JText::translate('VBO_LIST_PASSCODES_HELP'),
'icon' => '',
'callback' => 'listPasscodes',
'params' => [
'search' => [
'type' => 'text',
'label' => JText::translate('VBO_SEARCH_PASSCODE'),
'help' => JText::translate('VBO_OPT_SEARCH_KEYWORD'),
],
],
]),
// create (random) passcode
$this->createDeviceCapability([
'id' => 'create_passcode',
'title' => JText::translate('VBO_CREATE_PASSCODE'),
'description' => JText::translate('VBO_CREATE_PASSCODE_HELP'),
'icon' => '',
'callback' => 'createRandomPasscode',
'params' => [
'pwdname' => [
'type' => 'text',
'label' => JText::translate('VBO_PASSCODE_NAME'),
'help' => JText::translate('VBO_OPT_PASSCODE_NAME'),
],
'pwdtype' => [
'type' => 'select',
'label' => JText::translate('VBPSHOWSEASONSTHREE'),
'options' => array_combine(array_keys($this->getPasscodeTypes()), array_column($this->getPasscodeTypes(), 'name')),
'default' => 3,
],
'startdate' => [
'type' => 'datetime',
'label' => JText::translate('VBNEWPKGDFROM'),
'help' => JText::translate('VBO_PASSCODE_VALID_START'),
],
'enddate' => [
'type' => 'datetime',
'label' => JText::translate('VBNEWPKGDTO'),
'help' => JText::translate('VBO_PASSCODE_VALID_END'),
],
],
]),
// create (custom) passcode
$this->createDeviceCapability([
'id' => 'create_custom_passcode',
'title' => JText::translate('VBO_CREATE_PASSCODECUST'),
'description' => JText::translate('VBO_CREATE_PASSCODECUST_HELP'),
'icon' => '',
'callback' => 'createCustomPasscode',
'params' => [
'pwdvalue' => [
'type' => 'text',
'label' => JText::translate('VBO_PASSCODE'),
'help' => JText::translate('VBO_PASSCODE_EMPTY_HELP') . ' 4-9 digits, first digit should not be 0.',
'attributes' => [
'pattern' => '[1-9][0-9]{3,8}',
],
],
'pwdname' => [
'type' => 'text',
'label' => JText::translate('VBO_PASSCODE_NAME'),
'help' => JText::translate('VBO_OPT_PASSCODE_NAME'),
],
'pwdtype' => [
'type' => 'select',
'label' => JText::translate('VBPSHOWSEASONSTHREE'),
'options' => array_combine(array_keys($this->getPasscodeTypes()), array_column($this->getPasscodeTypes(), 'name')),
'default' => 3,
],
'startdate' => [
'type' => 'datetime',
'label' => JText::translate('VBNEWPKGDFROM'),
'help' => JText::translate('VBO_PASSCODE_VALID_START'),
],
'enddate' => [
'type' => 'datetime',
'label' => JText::translate('VBNEWPKGDTO'),
'help' => JText::translate('VBO_PASSCODE_VALID_END'),
],
],
]),
// delete passcode
$this->createDeviceCapability([
'id' => 'delete_passcode',
'title' => JText::translate('VBO_DELETE_PASSCODE'),
'description' => JText::translate('VBO_DELETE_PASSCODE_HELP'),
'icon' => '',
'callback' => 'deletePasscode',
'params' => [
'pwdid' => [
'type' => 'text',
'label' => 'Passcode ID',
],
],
]),
// show activity logs
$this->createDeviceCapability([
'id' => 'activity_logs',
'title' => JText::translate('VBO_ACTIVITY_LOGS'),
'description' => JText::translate('VBO_ACTIVITY_LOGS_HELP'),
'icon' => '',
'callback' => 'showActivityLogs',
'params' => [
'startdate' => [
'type' => 'datetime',
'label' => JText::translate('VBOREPORTSDATEFROM'),
],
'enddate' => [
'type' => 'datetime',
'label' => JText::translate('VBOREPORTSDATETO'),
],
'recordtype' => [
'type' => 'select',
'label' => JText::translate('VBPSHOWSEASONSTHREE'),
'options' => ([JText::translate('VBANYTHING')] + $this->getActivityRecordTypes()),
],
'search' => [
'type' => 'text',
'label' => JText::translate('VBO_SEARCH_PASSCODE'),
'help' => JText::translate('VBO_OPT_SEARCH_KEYWORD'),
],
],
]),
]);
// set device payload by unsetting the unwanted properties
unset($device['lockData']);
$decorator->setPayload($device);
}
/**
* Generates a random serial code made of only digits with a given length.
* The sequence obtained will never start with 0 to allow integer casting.
*
* @param int $length The passcode length.
*
* @return string
*/
private function generateRandomPasscode(int $length = 8)
{
return rand(1, 9) . VikBooking::getCPinInstance()->generateSerialCode($length - 1, ['0123456789']);
}
/**
* Maps the supported passcode type identifiers with name and description.
*
* @param ?int $type Optional passcode type identifier to fetch.
* @param bool $name True to get only the passcode name.
*
* @return array|string Full list, passcode type array or passcode string name.
*/
private function getPasscodeTypes(?int $type = null, bool $name = false)
{
$list = [
1 => [
'name' => 'One-time',
'descr' => 'Valid only once within 6 hours from the Start Time.',
],
2 => [
'name' => 'Permanent',
'descr' => 'Code must be used at least once within 24 Hours after the Start Time, or it will be invalidated.',
],
3 => [
'name' => 'Period',
'descr' => 'Code must be used at least once within 24 Hours after the Start Time, or it will be invalidated.',
],
4 => [
'name' => 'Delete',
'descr' => 'The code will delete all other codes.',
],
5 => [
'name' => 'Weekend Cyclic',
'descr' => 'The code is valid during the time period at the weekend.',
],
6 => [
'name' => 'Daily Cyclic',
'descr' => 'The code is valid during the time period everyday.',
],
7 => [
'name' => 'Workday Cyclic',
'descr' => 'The code is valid during the time period on workdays.',
],
8 => [
'name' => 'Monday Cyclic',
'descr' => 'The code is valid during the time period on Mondays.',
],
9 => [
'name' => 'Tuesday Cyclic',
'descr' => 'The code is valid during the time period on Tuesdays.',
],
10 => [
'name' => 'Wednesday Cyclic',
'descr' => 'The code is valid during the time period on Wednesdays.',
],
11 => [
'name' => 'Thursday Cyclic',
'descr' => 'The code is valid during the time period on Thursdays.',
],
12 => [
'name' => 'Friday Cyclic',
'descr' => 'The code is valid during the time period on Fridays.',
],
13 => [
'name' => 'Saturday Cyclic',
'descr' => 'The code is valid during the time period on Saturdays.',
],
14 => [
'name' => 'Sunday Cyclic',
'descr' => 'The code is valid during the time period on Sundays.',
],
];
if (is_null($type)) {
return $list;
}
if (!$name) {
return $list[$type] ?? [];
}
return $list[$type]['name'] ?? '';
}
/**
* Maps the known record types for the unlock records (activity logs).
*
* @param ?int $type Optional passcode type identifier to fetch.
*
* @return array|string Full list, or record type (event) name.
*
* @since 1.18.6 (J) - 1.8.6 (WP)
*/
private function getActivityRecordTypes(?int $type = null)
{
$list = [
4 => 'unlock by passcode',
1 => 'unlock by app',
7 => 'unlock by IC card',
8 => 'unlock by fingerprint',
9 => 'unlock by wrist strap',
10 => 'unlock by Mechanical key',
12 => 'unlock by gateway',
46 => 'unlock by unlock key',
49 => 'unlock by hotel card',
50 => 'Unlocked due to the high temperature',
57 => 'Unlock with QR code success',
58 => 'Unlock with QR code failed, it\'s expired',
51 => 'Try to unlock with a deleted card',
5 => 'Rise the lock (for parking lock)',
6 => 'Lower the lock (for parking lock)',
11 => 'lock by app',
29 => 'apply some force on the Lock',
30 => 'Door sensor closed',
31 => 'Door sensor open',
32 => 'open from inside',
33 => 'lock by fingerprint',
34 => 'lock by passcode',
35 => 'lock by IC card',
36 => 'lock by Mechanical key',
37 => 'Use APP button to control the lock (rise, fall, stop, lock), mostly used for roller shutter door',
42 => 'received new local mail',
43 => 'received new other cities mail',
44 => 'Tamper alert',
45 => 'Auto Lock',
47 => 'lock by lock key',
48 => 'System locked ( Caused by, for example: Using INVALID Passcode/Fingerprint/Card several times)',
52 => 'Dead lock with APP',
53 => 'Dead lock with passcode',
54 => 'The car left (for parking lock)',
55 => 'Use remote control lock or unlock lock',
59 => 'Double locked',
60 => 'Cancel double lock',
61 => 'Lock with QR code success',
62 => 'Lock with QR code failed, the lock is double locked',
63 => 'Auto unlock at passage mode',
64 => 'Door unclosed alarm',
65 => 'Failed to unlock',
66 => 'Failed to lock',
67 => 'Face unlock success',
68 => 'Face unlock failed (door locked from inside)',
69 => 'Lock with face',
71 => 'Face unlock failed (expired or ineffective)',
75 => 'Unlocked by App granting',
76 => 'Unlocked by remote granting',
77 => 'Dual authentication Bluetooth unlock verification success, waiting for second user',
78 => 'Dual authentication password unlock verification success, waiting for second user',
79 => 'Dual authentication fingerprint unlock verification success, waiting for second user',
80 => 'Dual authentication IC card unlock verification success, waiting for second user',
81 => 'Dual authentication face card unlock verification success, waiting for second user',
82 => 'Dual authentication wireless key unlock verification success, waiting for second user',
83 => 'Dual authentication palm vein unlock verification success, waiting for second user',
84 => 'Palm vein unlock success',
85 => 'Palm vein unlock success',
86 => 'Lock with palm vein',
88 => 'Palm vein unlock failed (expired or ineffective)',
92 => 'Administrator password to unlock ',
];
if (is_null($type)) {
return $list;
}
return $list[$type] ?? '';
}
/**
* Creates the HTTP Transporter to establish API connections with TTLock.
* An integration profile record is supposed to be set before making an HTTP request.
*
* @param ?array $options Optional transporter options.
*
* @return object The prepared HTTP transporter object with bearer token.
*
* @throws Exception
*/
private function createHTTPTransporter(?array $options = null)
{
// access current profile settings
$settings = $this->getSettings();
if (empty($settings['client_id']) || empty($settings['client_secret']) || empty($settings['username']) || empty($settings['password'])) {
throw new Exception('Missing integration profile credentials (settings).', 500);
}
// obtain a valid OAuth token
if ($options['renew_token'] ?? null) {
// force the token renewal
$this->oauthToken = $this->renewOauthToken();
} else {
// get the possibly valid token
$this->oauthToken = $this->getOauthToken();
}
// set HTTP headers
$this->httpHeaders = [
'Authorization' => "Bearer {$this->oauthToken}",
'ContentType' => 'application/x-www-form-urlencoded',
];
return new JHttp;
}
/**
* Obtains an active OAuth (Bearer) token to establish API connections with TTLock.
*
* @return string An active OAuth (Bearer) token.
*
* @throws Exception
*/
private function getOauthToken()
{
// access current profile settings
$settings = $this->getSettings();
if (empty($settings['_oauth']['access_token']) || empty($settings['_oauth']['expiry_ts'])) {
// the token should be obtained from scratch
return $this->renewOauthToken();
}
if ($settings['_oauth']['expiry_ts'] < time()) {
// the token should be renewed
return $this->renewOauthToken($refresh = true);
}
// return the supposingly active token
return (string) $settings['_oauth']['access_token'];
}
/**
* Makes an API request with TTLock to get and save the OAuth token for any HTTP request.
* It can either get a new access token, or it can refresh an existing access token.
*
* @param bool $refresh True to refresh an existing token, false to obtain a new one.
*
* @return string An active OAuth (Bearer) token.
*
* @throws Exception
*
* @link https://euopen.ttlock.com/document/doc?urlName=cloud%2Foauth2%2FgetAccessTokenEn.html
* @link https://euopen.ttlock.com/document/doc?urlName=cloud%2Foauth2%2FrefreshAccessTokenEn.html
*/
private function renewOauthToken(bool $refresh = false)
{
// access current profile settings
$settings = $this->getSettings();
if (empty($settings['client_id']) || empty($settings['client_secret']) || empty($settings['username']) || empty($settings['password'])) {
throw new Exception('Missing integration profile credentials (settings).', 500);
}
// build request data
if ($refresh === true && !empty($settings['_oauth']['refresh_token'])) {
// refresh an existing token
$data = [
'client_id' => $settings['client_id'],
'client_secret' => $settings['client_secret'],
'grant_type' => 'refresh_token',
'refresh_token' => $settings['_oauth']['refresh_token'],
];
} else {
// get a new token
$data = [
'client_id' => $settings['client_id'],
'client_secret' => $settings['client_secret'],
'username' => $settings['username'],
'password' => md5($settings['password']),
];
}
// exchange the settings to obtain the OAuth token details
$response = (new JHttp)->post('https://euapi.ttlock.com/oauth2/token', http_build_query($data), ['ContentType' => 'application/x-www-form-urlencoded'], 10);
// obtain the response data
$responseData = (array) json_decode((string) $response->body, true);
if ($response->code != 200) {
// an error occurred
throw new Exception($response->body ?: 'OAuth token error.', $response->code);
}
if (empty($responseData['access_token'])) {
// invalid response
throw new Exception($responseData['errmsg'] ?? $response->body ?: 'Generic response error.', 500);
}
// calculate and set the token expiration timestamp
$responseData['expiry_ts'] = strtotime(sprintf('+%d seconds', (int) ($responseData['expires_in'] ?? 0)));
// inject OAuth details within the current integration settings
$settings['_oauth'] = $responseData;
// update integration record settings
$this->setProfileRecordProp('settings', $settings);
// store integration record settings
VBODooraccessFactory::getInstance()->saveIntegrationRecord($this, ['settings' => $this->getSettings()]);
// return the current access token
return $responseData['access_token'];
}
}