Skip to content

feat(iot-client): add device-initiated reset (iot_client_reset) - #27

Merged
sedawwk merged 1 commit into
masterfrom
device_reset
Aug 27, 2026
Merged

feat(iot-client): add device-initiated reset (iot_client_reset)#27
sedawwk merged 1 commit into
masterfrom
device_reset

Conversation

@heshaoqiong-tuya

Copy link
Copy Markdown
Collaborator

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.

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>
@sedawwk
sedawwk merged commit 4a894e5 into master Aug 27, 2026
4 checks passed
@heshaoqiong-tuya
heshaoqiong-tuya deleted the device_reset branch August 27, 2026 01:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants