tuya
2026 — A Go library for controlling Tuya smart home devices through Tuya's cloud
01 Why it exists
A Tuya cloud project can read and control the devices linked to it through the Tuya Cloud OpenAPI. Every request has to be signed with HMAC-SHA256 and carry an access token, which the project fetches first and Tuya can expire at any time.
This library does that part. You hand New the Access ID, the Access Secret and the URL of the data center your project lives in, then call the endpoint you want. Most data centers are on a tuya domain, like openapi.tuyaeu.com for Central Europe, but Singapore's is openapi-sg.iotbing.com.
client, err := tuya.New(accessID, accessSecret, "https://openapi-sg.iotbing.com")
devices, err := client.UserDevices(ctx, tuyaUID)
status, err := client.DeviceStatus(ctx, deviceID)
err = client.SendCommands(ctx, deviceID, []tuya.DataPoint{{Code: "switch_1", Value: true}})02 Signing
The string to sign is the method, the SHA-256 of the body, an empty line, and the path with its query. In front of it go the Access ID, the access token, the time in milliseconds and a random nonce, and the HMAC-SHA256 of all of it, keyed with the Access Secret, goes out in upper-case hex as the sign header.
The token request is signed the same way with an empty token. Tuya checks the signature against the query it decoded, so wherever a query joins values with commas, the client keeps them bare instead of %2C, which Tuya would refuse as 1004 sign invalid.
func hmacSign(accessID, accessSecret, accessToken, method, path string, body []byte) (*signature, error) {
timestamp := strconv.FormatInt(time.Now().UnixNano()/int64(time.Millisecond), 10)
hash := sha256.New()
hash.Write(body)
contentSha256 := hex.EncodeToString(hash.Sum(nil))
stringToSign := method + "\n" + contentSha256 + "\n\n" + path
nonceBytes := make([]byte, 16)
if _, err := rand.Read(nonceBytes); err != nil {
return nil, fmt.Errorf("generate nonce: %w", err)
}
nonce := hex.EncodeToString(nonceBytes)
tuyaStr := accessID + accessToken + timestamp + nonce + stringToSign
mac := hmac.New(sha256.New, []byte(accessSecret))
mac.Write([]byte(tuyaStr))
sign := strings.ToUpper(hex.EncodeToString(mac.Sum(nil)))
return &signature{
Sign: sign,
Timestamp: timestamp,
Nonce: nonce,
SignMethod: "HMAC-SHA256",
AccessToken: accessToken,
}, nil
}03 Tokens
New fetches the first token straight away, so a wrong Access ID or Secret fails at startup instead of on the first device call. From then on the token is cached and every call reuses it.
The client never reads expire_time. When Tuya answers code 1010, it takes the lock, fetches a new token, and sends the same request again with a fresh signature. If that one is refused too, the error comes back instead of another try.
const tokenExpiredTuyaErrorCode = 1010
if tuyaResp.Code == tokenExpiredTuyaErrorCode && attempt == 0 {
if err := c.refreshToken(ctx); err != nil {
return nil, fmt.Errorf("refresh token after error %d: %w", tuyaResp.Code, err)
}
continue
}
return nil, fmt.Errorf("request to %s: %w", fullURL, &APIError{Code: tuyaResp.Code, Msg: tuyaResp.Msg})04 Devices
UserDevices lists the devices of one Tuya account, DeviceStatus and DeviceProperties read one device, and SendCommands sets its data points. A data point is one thing a device reports or is told, a code and a value like switch_1 and true.
A switch with several gangs has one data point per gang, and the names people gave each gang sit behind a separate endpoint. WithChannelNames fetches them, only for devices whose category can have more than one channel, all at the same time, and a device that fails is reported next to the others instead of stopping them.
An endpoint that is not wrapped yet goes through Do, the same signed request one level down, which hands back the result as raw JSON.
devices, err := client.UserDevices(ctx, tuyaUID, tuya.WithChannelNames())
properties, err := client.DeviceProperties(ctx, deviceID, nil)
raw, err := client.Do(ctx, http.MethodGet, "/v1.0/devices/"+deviceID, nil)05 Spaces
Spaces are a tree of places in the cloud project, each with a parent and a root, for products where devices belong to a place, like a hotel room, rather than to a person.
The client creates, reads, renames and deletes spaces, lists the children and resources of one, asks whether one space contains another, and pages through the devices in a set of spaces.
Tuya confirms a rename or a delete with true, so a false or missing result comes back as ErrNotApplied instead of being taken as done. SpaceHasDevice reads resources 200 at a time and gives up with an error after 50 pages instead of paging forever.
hotel, err := client.CreateSpace(ctx, "Hotel", 0, "")
room, err := client.CreateSpace(ctx, "Room 1", hotel, "twin")
inside, err := client.SpaceHasDevice(ctx, room, deviceID)06 Your users
If your app has its own users and each of them connects their Tuya account, you are holding one Tuya UID per user. The appaccount package stores your user, the owner, against that UID, looks it up again on every call, and hands the call to the client, so you talk to Tuya in your own IDs.
An owner with no link, or a link with an empty UID, comes back as ErrNotLinked before anything is sent to Tuya.
Calls on one device need no owner, because a device ID already means something to Tuya. HasDevice says whether a device is in the owner's own list and blocks nothing, since a device someone shared with them is missing from that list even though they may use it.
app := appaccount.NewService(client, store)
acc, err := app.Link(ctx, owner, tuyaUID)
devices, err := app.Devices(ctx, owner)
if errors.Is(err, appaccount.ErrNotLinked) {
return err
}
owned, err := app.HasDevice(ctx, owner, deviceID)
err = app.SendCommands(ctx, deviceID, []tuya.DataPoint{{Code: "switch_1", Value: true}})07 Stores
The postgres store takes anything that can Exec and Query, so a pgx pool or a single connection both fit. WithAutoMigrate creates the tuya_app_accounts table from migrations embedded in the binary, and without it the constructor only checks that the table is there and fails at startup if it is not.
The firestore store keeps one document per owner in a tuya_app_accounts collection, or in one named with WithCollection.
In both, Link replaces the UID of an owner already on file and Unlink really deletes the record. Writing your own store means three methods, Get, Link and Unlink, and answering ErrNotLinked for an owner with no link.
store, err := postgres.NewAppAccountStore(ctx, pool, postgres.WithAutoMigrate())08 What it doesn't do
It doesn't run Tuya's account authorization flow, so Link assumes you already got the Tuya UID from somewhere.
It wraps the devices and spaces a project needs most, not all of Tuya, and Do reaches the rest. It never decides who may use a device, and it caches no answer.
09 With ADK
Giving a Google ADK agent this library is one call in agentkit. tuyaadk.Tools takes your appaccount.Service and returns four tools, get_account, list_devices, device_status and send_commands, and each one uses the ADK user ID as the owner.
list_devices asks for channel names, so the model can match the name a person gave a gang to its switch code, and an owner with no link reaches the model as a sentence telling it the human has to link their Tuya account first.
import tuyaadk "go.trueardian.com/agentkit/tuya/adk"
tools, err := tuyaadk.Tools(app)tuya
Every call reaches Tuya signed, and when Tuya rejects an expired token, a new one is fetched and the call retried before the caller ever sees it