feat(iot-client): add device-initiated reset (iot_client_reset) - #27
Merged
Conversation
heshaoqiong-tuya
force-pushed
the
device_reset
branch
from
August 26, 2026 12:55
841b98f to
03d83c3
Compare
Every reset path in the SDK ran cloud -> device: the protocol-11 notice behind
iot_reset_callback_t, fired when a user removes the device from the app. A
device had no way to start an unbind itself.
int iot_client_reset(iot_client_t *client,
char *error_code, size_t error_code_len);
Success and failure decide who owns the client, and they differ:
OPRT_OK -- the cloud accepted the reset and the client has already been
destroyed (everything iot_client_deinit() frees), so the pointer
must not be used, freed or disconnected again.
anything else -- nothing was torn down, the client is still fully usable, and
the caller can retry.
The return code answers "does the cloud know", not "is the client alive".
Destroying on failure was rejected deliberately: a device that is locally
unbound while the cloud still has it bound is worse than a retry, and there
would be no handle left to retry through.
The error_code out-param is not a convenience. OPRT_ATOP_BUSINESS_ERROR alone
cannot separate REMOTE_API_RUN_UNKNOW_FAILED (server busy -- retry) from a
terminal GATEWAY_NOT_EXISTS (the binding is already gone -- retrying never
succeeds; wipe credentials and re-enter pairing). Treating the second as
retryable strands the device permanently: it never wipes, never re-pairs. The
reference doc gives the branch, and the demo shows it.
Built on the existing generic entry (iot_atop_call) rather than a dedicated
named wrapper. That entry already owns signing, AES-GCM body encryption, host
resolution, envelope parsing and the pre-activation credential check -- and,
unlike a result-less wrapper, it surfaces the cloud's errorCode, which is
precisely what the out-param needs. A first version duplicated all of that in an
atop_device_reset() plus its own request struct; removing it dropped ~80 lines
and one of two parallel request-assembly paths. This commit is net-negative in
source despite adding the out-param.
The interface version is "3.0", not the "1.0" most tuya.device.* interfaces use
-- easy to "correct" by mistake, and a wrong version fails only at the cloud, as
a rejection that reads like a network fault. The mock verifies v=3.0 and answers
anything else with UNKNOWN_API_VERSION, so a slip fails in iot_reset_test
instead of on a real device (checked: setting 1.0 drops it to 2/4).
Two non-responsibilities are documented at the declaration: it does not erase
persisted credentials/DP state/schema (only the app knows where those live), and
it does not wait for a protocol-11 notice -- that push is what a *remote*
removal looks like, while a device-initiated reset is acknowledged by the return
code.
Two related fixes came out of building it:
- iot_client_deinit() now wipes the client before freeing. devid, secret_key and
local_key are plaintext arrays, so on an embedded allocator the next
comparable malloc handed the block -- keys included -- to unrelated code.
Written through a volatile pointer, since a plain memset() immediately before
free() is a dead store a compiler may elide. Matters most here, where the
device is being decommissioned or handed to a new owner.
- unbind-demo --reset no longer requires a working MQTT connection. Reset
travels over ATOP HTTPS and needs no broker session, as the demo's own comment
said, but the code gated it behind a successful iot_client_connect() --
disabling the flag in the one case it exists for, since a device the cloud has
already unbound has its CONNECT refused. The reset path now runs before any
connect, and branches on the returned errorCode.
iot_reset_test uses a heap client because the success path frees the struct --
the stack client in iot_atop_call_test.c would hand free() a stack address. The
failure path is reached by presenting a devId containing "busy", which the mock
answers with the interface doc's own REMOTE_API_RUN_UNKNOW_FAILED, so both "a
rejected reset stays retryable" and "the errorCode reaches the caller" are
actually exercised rather than only the local guards.
Verified: full build clean; ctest --timeout 180 14/14 (1 new suite);
iot_reset_test 4/4; run_leaks_check.sh reports 0 leaks; unbind_demo builds
against the new signature.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
heshaoqiong-tuya
force-pushed
the
device_reset
branch
from
August 27, 2026 01:41
03d83c3 to
d958852
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Every reset path in the SDK ran cloud -> device: the protocol-11 notice behind iot_reset_callback_t, fired when a user removes the device from the app. A device had no way to start an unbind itself.
Add iot_client_reset() over a new atop_device_reset() wrapper for the cloud's tuya.device.reset (v3.0).
Success and failure decide who owns the client, and they differ:
OPRT_OK -- the cloud accepted the reset and the client has already
been destroyed (everything iot_client_deinit() frees), so
the pointer must not be used, freed or disconnected again.
anything else -- nothing was torn down, the client is still fully
usable, and the caller can retry.
The return code answers "does the cloud know", not "is the client alive". Destroying on failure was rejected deliberately: a device that is locally unbound while the cloud still has it bound is worse than a retry, and there would be no handle left to retry through.
Two non-responsibilities are documented at the declaration: it does not erase persisted credentials/DP state/schema (only the app knows where those live), and it does not wait for a protocol-11 notice -- that push is what a remote removal looks like, while a device-initiated reset is acknowledged by the return code.
atop_device_reset() takes no response struct, matching atop_version_update() / atop_upgrade_status_update(): the interface answers with an empty result object ("result":{}), so the return code carries the whole outcome. The wrapper deliberately does not require a non-empty result -- insisting on one would turn every success into an error.
The interface version is "3.0", not the "1.0" most tuya.device.* interfaces here use. That is easy to "correct" by mistake, and a wrong version fails only at the cloud, as a rejection that reads like a network fault. The mock therefore verifies v=3.0 and answers anything else with UNKNOWN_API_VERSION, so a slip fails in iot_reset_test rather than on a real device (checked by temporarily reverting to 1.0: test 3 fails, as intended).
iot_reset_test.c uses a heap client because the success path frees the struct -- the stack client in iot_atop_call_test.c would hand free() a stack address. The failure path is reached by presenting a devId containing "busy", which the mock answers with the interface doc's own REMOTE_API_RUN_UNKNOW_FAILED, so "a rejected reset stays retryable" is actually exercised rather than only the local guards. Whether the success path releases everything is not observable in-process; verified with run_leaks_check.sh (0 leaks).
examples/posix/pair/unbind-demo/ gains a --reset flag and now demonstrates both directions, contrasting the two teardown obligations.