Hi,
I've been running TTLock locks integrated with LogicMachine for a while. Here's a working setup with two scripts and some notes on remote unlock.
How it works
Each room has a block of LM group addresses. The trigger object (x/x/0) carries two tags — PassCode and Room_X — and its comment holds the LockId:
Offset | Purpose
+0 | Trigger / Generate passcode — tag: PassCode, comment: LockId:XXXXXXXX
+1 | Passcode value
+2 | Guest name (auto-generated)
+3 | Valid from — date
+4 | Valid from — time
+5 | Valid to — date
+6 | Valid to — time
+8 | Last PassCode ID (returned by API)
+9 | Last status message
The accessToken and refreshToken are stored as named LM objects ("TTLock Access Token", "TTLock Refresh Token") so all scripts read/write from one place.
Script 1 — Add Passcode (event script, tag: PassCode)
Triggered when x/x/0 is set to true. Reads the lockId from the object comment, builds the passcode name from room + dates, validates that arrival is in the future and departure is after arrival, then calls /v3/keyboardPwd/add for both the room lock and the main entrance. Includes automatic token refresh on errcode 10004.
When triggered with false, it just generates a random 5-digit code into x/x/1.
Script 2 — Remove Expired Passcodes (scheduled, runs daily)
Loops through all objects tagged PassCode, fetches the password list for each lock via /v3/lock/listKeyboardPwd, and deletes anything that expired more than 24 hours ago. Set DRY_RUN = true first to verify what would be deleted before enabling real deletion.
Unlock Door — how to extend this
The TTLock API has a /v3/lock/unlock endpoint. Here's a minimal example you can attach as an event script to a boolean GA:
Tag the GA with UnlockDoor and put LockId:XXXXXXXX in the object comment — same convention as the passcode objects, so the same pattern scales to multiple doors without duplicating code.
Hope this helps as a starting point.
I've been running TTLock locks integrated with LogicMachine for a while. Here's a working setup with two scripts and some notes on remote unlock.
How it works
Each room has a block of LM group addresses. The trigger object (x/x/0) carries two tags — PassCode and Room_X — and its comment holds the LockId:
Offset | Purpose
+0 | Trigger / Generate passcode — tag: PassCode, comment: LockId:XXXXXXXX
+1 | Passcode value
+2 | Guest name (auto-generated)
+3 | Valid from — date
+4 | Valid from — time
+5 | Valid to — date
+6 | Valid to — time
+8 | Last PassCode ID (returned by API)
+9 | Last status message
The accessToken and refreshToken are stored as named LM objects ("TTLock Access Token", "TTLock Refresh Token") so all scripts read/write from one place.
Script 1 — Add Passcode (event script, tag: PassCode)
Triggered when x/x/0 is set to true. Reads the lockId from the object comment, builds the passcode name from room + dates, validates that arrival is in the future and departure is after arrival, then calls /v3/keyboardPwd/add for both the room lock and the main entrance. Includes automatic token refresh on errcode 10004.
When triggered with false, it just generates a random 5-digit code into x/x/1.
Code:
local eventAction = event.getvalue()
local baseAddress = event.dstraw
local lastMessageObj = grp.find(baseAddress + 9)
local msgtxt = ""
local https = require("ssl.https")
local ltn12 = require("ltn12")
local json = require("json")
local API_BASE = "https://euapi.ttlock.com"
local clientId = "YOUR_CLIENT_ID"
local clientSecret = "YOUR_CLIENT_SECRET"
local ACCESS_TOKEN_OBJ_NAME = "TTLock Access Token"
local REFRESH_TOKEN_OBJ_NAME = "TTLock Refresh Token"
local MainDoorLockId = YOUR_MAIN_DOOR_LOCK_ID
local function normCode(code) return tonumber(code) or -1 end
local function urlencode(str)
if str == nil then return "" end
str = tostring(str)
str = str:gsub("\n", "\r\n")
str = str:gsub("([^%w _%%%-%.~])", function(c)
return string.format("%%%02X", string.byte(c))
end)
str = str:gsub(" ", "%%20")
return str
end
local function safeLog(label, text)
local t = tostring(text or "")
if clientSecret and clientSecret ~= "" then
t = t:gsub(clientSecret, "***")
end
log(label, t)
end
local function readTokens()
return tostring(grp.getvalue(ACCESS_TOKEN_OBJ_NAME) or ""),
tostring(grp.getvalue(REFRESH_TOKEN_OBJ_NAME) or "")
end
local function writeTokens(newAT, newRT)
if newAT and newAT ~= "" then grp.update(ACCESS_TOKEN_OBJ_NAME, newAT) end
if newRT and newRT ~= "" then grp.update(REFRESH_TOKEN_OBJ_NAME, newRT) end
end
local function toUTCTimestamp(dateTable, timeTable)
return os.time({
year = dateTable.year, month = dateTable.month, day = dateTable.day,
hour = timeTable.hour, min = timeTable.minute, sec = timeTable.second
}) * 1000
end
local function httpPostForm(url, formBody)
local response_body = {}
local _, code = https.request{
url = url, method = "POST",
headers = {
["Content-Type"] = "application/x-www-form-urlencoded",
["Content-Length"] = tostring(#formBody)
},
source = ltn12.source.string(formBody),
sink = ltn12.sink.table(response_body)
}
return code, table.concat(response_body or {})
end
local accessToken, refreshTokenValue = readTokens()
local function refreshToken()
accessToken, refreshTokenValue = readTokens()
if not refreshTokenValue or refreshTokenValue == "" then
log("TTLock: Refresh token missing.")
return false
end
local body =
"clientId=" .. urlencode(clientId) ..
"&clientSecret=" .. urlencode(clientSecret) ..
"&grant_type=refresh_token" ..
"&refresh_token=" .. urlencode(refreshTokenValue)
local code, resp = httpPostForm(API_BASE .. "/oauth2/token", body)
if normCode(code) == 200 then
local ok, data = pcall(json.decode, resp)
if ok and data and data.access_token then
accessToken = data.access_token
if data.refresh_token and data.refresh_token ~= "" then
refreshTokenValue = data.refresh_token
end
writeTokens(accessToken, refreshTokenValue)
log("TTLock: Token refreshed OK")
return true
end
end
log("TTLock: Token refresh failed: " .. tostring(code) .. " " .. (resp or ""))
return false
end
local function doPostWithRetry(formBody)
local addUrl = API_BASE .. "/v3/keyboardPwd/add"
local code, resp = httpPostForm(addUrl, formBody)
if normCode(code) ~= 200 then return nil, { httpcode = normCode(code), raw = resp } end
local ok, data = pcall(json.decode, resp)
if not ok or not data then return nil, { errcode = -1, errmsg = "Invalid JSON", raw = resp } end
if data.errcode == 10004 then
log("TTLock: Token expired (10004). Refreshing...")
if refreshToken() then
accessToken, refreshTokenValue = readTokens()
local newBody = formBody:gsub("accessToken=[^&]+", "accessToken=" .. urlencode(accessToken))
local code2, resp2 = httpPostForm(addUrl, newBody)
if normCode(code2) ~= 200 then return nil, { httpcode = normCode(code2), raw = resp2 } end
local ok2, data2 = pcall(json.decode, resp2)
if not ok2 or not data2 then return nil, { errcode = -1, errmsg = "Invalid JSON after refresh" } end
return data2, nil
else
return nil, { errcode = 10004, errmsg = "Token refresh failed" }
end
end
return data, nil
end
if eventAction then
accessToken, refreshTokenValue = readTokens()
if not accessToken or accessToken == "" then
refreshToken()
accessToken, refreshTokenValue = readTokens()
end
local lockObj = grp.find(baseAddress)
local lockId = lockObj and lockObj.comment and tonumber(lockObj.comment:match("LockId:(%d+)"))
if not lockId then
if lastMessageObj then lastMessageObj:update("Error: LockId not found in object comment") end
return
end
local roomName = (lockObj.tagcache or ""):match("Room_([%w%d][%w%d]?)") or "NA"
local keyboardPwd = grp.getvalue(baseAddress + 1)
local startDateTable = grp.getvalue(baseAddress + 3)
local startTimeTable = grp.getvalue(baseAddress + 4)
local endDateTable = grp.getvalue(baseAddress + 5)
local endTimeTable = grp.getvalue(baseAddress + 6)
local lastPassCodeIdObj = grp.find(baseAddress + 8)
local keyboardPwdName = string.format("%s_%02d%02d%02d%02d",
roomName, startDateTable.day, startDateTable.month, endDateTable.day, endDateTable.month)
local nameObj = grp.find(baseAddress + 2)
if nameObj then nameObj:update(keyboardPwdName) end
if lastMessageObj then lastMessageObj:update("Requesting PassCode creation. Please wait...") end
local startTs = toUTCTimestamp(startDateTable, startTimeTable)
local endTs = toUTCTimestamp(endDateTable, endTimeTable)
local nowTs = os.time() * 1000
if startTs < nowTs then
msgtxt = "Error: Arrival date/time is in the past."
if lastMessageObj then lastMessageObj:update(msgtxt) end
return
end
if endTs <= startTs then
msgtxt = "Error: Departure must be after arrival."
if lastMessageObj then lastMessageObj:update(msgtxt) end
return
end
local function buildBody(lid)
return "clientId=" .. urlencode(clientId) ..
"&accessToken=" .. urlencode(accessToken) ..
"&lockId=" .. urlencode(tostring(lid)) ..
"&keyboardPwd=" .. urlencode(tostring(keyboardPwd)) ..
"&keyboardPwdName=" .. urlencode(keyboardPwdName) ..
"&startDate=" .. urlencode(tostring(startTs)) ..
"&endDate=" .. urlencode(tostring(endTs)) ..
"&date=" .. urlencode(tostring(nowTs)) ..
"&addType=2"
end
accessToken, refreshTokenValue = readTokens()
local Rdata, Rerr = doPostWithRetry(buildBody(lockId))
if Rerr then
msgtxt = string.format("Room %s error: %s", roomName, Rerr.errmsg or Rerr.raw or "")
elseif Rdata.keyboardPwdId then
if lastPassCodeIdObj then lastPassCodeIdObj:update(Rdata.keyboardPwdId) end
msgtxt = string.format("Passcode %s added to Room %s (ID: %s)",
tostring(keyboardPwd), roomName, tostring(Rdata.keyboardPwdId))
else
msgtxt = string.format("Room %s error: %s - %s", roomName,
tostring(Rdata.errcode or "N/A"), tostring(Rdata.errmsg or "N/A"))
end
os.sleep(1)
accessToken, refreshTokenValue = readTokens()
local Mdata, Merr = doPostWithRetry(buildBody(MainDoorLockId))
if Merr then
msgtxt = msgtxt .. "\nMain Door error: " .. (Merr.errmsg or Merr.raw or "")
elseif Mdata.keyboardPwdId then
msgtxt = msgtxt .. string.format("\nPasscode %s added to Main Door (ID: %s)",
tostring(keyboardPwd), tostring(Mdata.keyboardPwdId))
else
msgtxt = msgtxt .. string.format("\nMain Door error: %s - %s",
tostring(Mdata.errcode or "N/A"), tostring(Mdata.errmsg or "N/A"))
end
alert(msgtxt)
else
math.randomseed(os.time() + os.clock() * 1000)
math.random()
grp.update(baseAddress + 1, string.format("%05d", math.random(10000, 99999)))
msgtxt = "New code generated"
end
if lastMessageObj then lastMessageObj:update(msgtxt) endScript 2 — Remove Expired Passcodes (scheduled, runs daily)
Loops through all objects tagged PassCode, fetches the password list for each lock via /v3/lock/listKeyboardPwd, and deletes anything that expired more than 24 hours ago. Set DRY_RUN = true first to verify what would be deleted before enabling real deletion.
Code:
local http = require("socket.http")
local ltn12 = require("ltn12")
local json = require("json")
local DRY_RUN = false
local accessToken = grp.getvalue("TTLock Access Token")
local clientId = "YOUR_CLIENT_ID"
local ExtraLockIds = {
[YOUR_MAIN_DOOR_LOCK_ID] = "MainDoor"
}
local lockIds = {}
local lockNameById = {}
for _, lock in ipairs(grp.tag("PassCode")) do
local lockId = tonumber(string.match(lock.comment or "", "LockId:(%d+)"))
local name = string.match(lock.tagcache or "", "Room_%S+") or "Unknown"
if lockId then
table.insert(lockIds, lockId)
lockNameById[lockId] = name
end
end
for id, name in pairs(ExtraLockIds) do
table.insert(lockIds, id)
lockNameById[id] = name
end
local function getKeyboardPwdsForLock(lockId)
local timestamp = tostring(os.time() * 1000):gsub("%.0*$", "")
local url = string.format(
"https://euapi.ttlock.com/v3/lock/listKeyboardPwd?" ..
"clientId=%s&accessToken=%s&lockId=%d&pageNo=1&pageSize=200&orderBy=1&date=%s",
clientId, accessToken, lockId, timestamp
)
local response = {}
local _, status = http.request{ url = url, method = "GET", sink = ltn12.sink.table(response) }
local body = table.concat(response)
if status == 200 then
local result = json.decode(body)
return result.list or {}
end
log("API error: " .. body)
return {}
end
local function deleteKeyboardPwd(lockId, keyboardPwdId)
local timestamp = tostring(os.time() * 1000):gsub("%.0*$", "")
local postData = string.format(
"clientId=%s&accessToken=%s&lockId=%d&keyboardPwdId=%d&deleteType=2&date=%s",
clientId, accessToken, lockId, keyboardPwdId, timestamp
)
local response = {}
local _, status = http.request{
url = "https://euapi.ttlock.com/v3/keyboardPwd/delete",
method = "POST",
headers = { ["Content-Type"] = "application/x-www-form-urlencoded", ["Content-Length"] = tostring(#postData) },
source = ltn12.source.string(postData),
sink = ltn12.sink.table(response)
}
local body = table.concat(response)
return status == 200 and body:find('"errcode"%s*:%s*0') ~= nil
end
local currentTimeMillis = os.time() * 1000
local expireThreshold = 86400000
for _, lockId in ipairs(lockIds) do
log(string.format("Processing LockId: %d (%s)", lockId, lockNameById[lockId] or "Unknown"))
for _, pwd in ipairs(getKeyboardPwdsForLock(lockId)) do
local endDate = pwd.endDate or 0
if endDate ~= 0 and endDate < (currentTimeMillis - expireThreshold) then
local readableDate = os.date("%Y-%m-%d %H:%M:%S", endDate / 1000)
if DRY_RUN then
log(string.format("DRY-RUN: %s | ID=%s | Name=%s | Expired: %s",
lockNameById[lockId], pwd.keyboardPwdId, pwd.keyboardPwdName, readableDate))
else
local ok = deleteKeyboardPwd(lockId, pwd.keyboardPwdId)
log(ok and ("Deleted: " .. pwd.keyboardPwdName) or ("Failed: " .. pwd.keyboardPwdName))
end
end
end
endUnlock Door — how to extend this
The TTLock API has a /v3/lock/unlock endpoint. Here's a minimal example you can attach as an event script to a boolean GA:
Code:
local https = require("ssl.https")
local ltn12 = require("ltn12")
local clientId = "YOUR_CLIENT_ID"
local accessToken = grp.getvalue("TTLock Access Token")
-- Read lockId from object comment (same pattern as Script 1)
local lockObj = grp.find(event.dst)
local lockId = lockObj and lockObj.comment and tonumber(lockObj.comment:match("LockId:(%d+)"))
if not lockId then
log("TTLock Unlock: LockId not found in object comment")
return
end
local timestamp = tostring(os.time() * 1000)
local body = "clientId=" .. clientId ..
"&accessToken=" .. accessToken ..
"&lockId=" .. tostring(lockId) ..
"&date=" .. timestamp
local response = {}
local _, code = https.request{
url = "https://euapi.ttlock.com/v3/lock/unlock",
method = "POST",
headers = {
["Content-Type"] = "application/x-www-form-urlencoded",
["Content-Length"] = tostring(#body)
},
source = ltn12.source.string(body),
sink = ltn12.sink.table(response)
}
log("Unlock response (" .. tostring(code) .. "): " .. table.concat(response))Tag the GA with UnlockDoor and put LockId:XXXXXXXX in the object comment — same convention as the passcode objects, so the same pattern scales to multiple doors without duplicating code.
Hope this helps as a starting point.