"Succeed" is not a state change: the config write a cellular camera ignores
The camera returned HTTP 200 and a body that said succeed, and it had changed nothing. That is not an error path — it is the normal response to a write the firmware decided not to apply.
On this page
- Four writes that came back succeed and changed nothing
- Why doesn't reading it back settle the question?
- The enum nobody could re-read
- So what do you actually send?
- How does a careful engineer type the wrong constant?
- What does read-after-write cost, and where does it break?
- FAQ
- The three things worth taking away
TL;DR: A camera config write ignored by the device still comes back HTTP 200 with a body that says
succeed. That is not an error path being swallowed — it is the ordinary response to a write the firmware decided not to apply. We have one dated probe where it happened and three places in our own client where we read the value back rather than trust the ACK. The rule that came out of it: persist what the device reports after the write, never what you asked for. And know that even that only proves the write landed, not that the value meant what you thought it meant.
The write returns 200. The body says succeed. Nothing changed.
This is a worse failure than an error, because an error has somewhere to go. A 4xx lands in a log, a
retry, an alert, a ticket. A succeed that did nothing lands in your database as truth, and every
system downstream of it now believes a thing about a device that the device does not believe about
itself. On a camera at the end of a cellular uplink — where you cannot walk over and look — that
belief can survive for months.
Four writes that came back succeed and changed nothing
Only the first of these did I watch happen. The rest are in our device client as defensive code, and I am listing them with their provenance rather than pooling them, because the difference is the whole argument of the last section:
| What was written | What came back | What the device held | Why the ACK lies | How we know |
|---|---|---|---|---|
enable: 1 on a stream slot the model does not have |
HTTP 200, {"setState":"succeed"} |
enable: 0, unchanged |
field present, hardware absent — one API shape across a product range | probed, 2026-05-25 |
| A video codec on a slot that does exist | succeed |
the previous codec | firmware rollback | guarded in code against documented firmware behaviour |
| A bitrate on that same slot | succeed |
the previous bitrate | firmware rollback | guarded by analogy with the codec case |
| A resolution | succeed |
a different resolution | capability clamping — the device settles somewhere it can reach | pinned by a test, and the mechanism differs from the two above |
Three conditions make a vendor prone to all of this, and you can test your own router, encoder or PLC against them without knowing anything about cameras: one API shape shared across a product range, a reference document issued per build rather than published, and hardware that clamps out-of-range values instead of rejecting them. The first and last rows above are one of those three apiece; the middle two are the firmware behaviour our client guards against, which is the third condition seen from inside rather than a separate cause.
The first is worth walking through, because it explains why a careful engineer walks into it. Probed
2026-05-25: the camera advertises streamCount: 3, and its bitrate listing stops at the third slot —
no fourth, no fifth. But the general video config still returns fourthStream and fifthStream
objects, each with enable: 0, because the API shape is shared with larger models in the range.
So the field is present. A UI generated from the response renders five toggles. Four of them are
real. The fifth accepts your click, posts your payload, and receives succeed.
A capability check fixes one row and leaves three
Why doesn't reading it back settle the question?
It does, but only if you get two things right, and the second one is not obvious.
The ordering. The firmware serialises its CGI requests — that is our own conclusion, written into the client after concurrent calls misbehaved, not a documented guarantee — and a concurrent GET and POST against the same camera can return a 503 or — much worse — a stale read-after-write. So a read-back fired off in parallel with the write is not a verification, it is a race that usually passes. The read has to follow the write, sequentially, on purpose. Any advice that stops at "just read it back" has skipped the part that makes it work.
What you compare it against. Here is the trap that took longest to name: reading back the value you wrote proves the write landed. It does not prove the value meant what you thought. Those are different claims, and for a numeric configuration field they can come apart completely — which is what the next section is about.
The enum nobody could re-read
The cellular network-type field takes an integer. What integer?
The cameras here are Milesight, and I am naming them on purpose: an enum claim is only worth publishing if a reader can check it against the vendor's own documentation. So check it. Milesight's public user manual documents the option list, and it is unambiguous about the options:
"Select the network type of cellular network. There are five options including Auto, 5G, 4G, 3G and 2G." — with Auto defined as "connect to the network with the strongest signal automatically."
Five named options. Zero numbers. The public document that tells you what the field means does not tell you what to send.
The numbers are in the API reference that ships with the build, and when you find the line, it is not one mapping. It is two:
networkType: General 0-AUTO 1-5G_NSA 2-5G_SA 3-TYPE_4G
[Outdoor] 0-AUTO 3-TYPE_4G 4-TYPE_3GSame field, same vendor, same document — and the integers mean different radios depending on the
product class. On the general class the values climb with the generation and there is no 4 at
all. On the outdoor class 4 exists, and it is 3G: the oldest radio the unit will speak.
Now here is what our client types today, comment and all. The note is not attached to this enum in particular — it heads the whole block, and two other enums stand between it and the one that matters, which is the point rather than a quibble:
// NOTE: numeric labels below are inferred from the API examples
// (networkType=3 ⇒ "4G Connected") and standard Milesight conventions —
// the PDF's value→label column is obscured by a watermark. Confirm against
// firmware before relying on any specific non-zero value.
export enum SimCardType { /* … */ }
// … CellularAuthType … then, still under that one note:
export enum CellularNetworkType {
AUTO = 0,
GSM_3G = 1,
LTE_4G = 3,
NR_5G = 4,
}NR_5G = 4 is wrong under both mappings, and that is the part that needs no further argument.
Under the general one, 4 is not a value at all. Under the outdoor one, 4 is 3G — a constant
named for the newest radio requesting the oldest, on the link you were trying to improve. GSM_3G = 1
is wrong twice over: on the general class 1 is 5G non-standalone, on the outdoor class it is not
defined, and GSM is 2G in any case.
Which of those two is our wrongness, I cannot tell you — and that is not a gap in the research, it is the finding. Nothing we have establishes which class these units are.
We tried three times, and all three attempts are worth showing, because each one is a way of mistaking a document for a device.
The first was an inference from a returned field. A working note assigns the units to the outdoor
class because they return dataUsage. Checked against the reference, that distinguishes nothing —
though not for the reason I first wrote down. The document scopes the field inconsistently. The
configuration read marks dataUsage, and its daily and yearly siblings, [Outdoor]. The status-post
section documented for the general class carries the same field with no qualifier at all. So a
device returning dataUsage is consistent with either class, and the field settles nothing.
(An earlier version of this paragraph said the field carried no class qualifier anywhere. That was read off one page of the several that document it — which is the error the article beside this one is about, made inside the article about that error. It is left visible rather than quietly patched.)
The second was an inference from our own source code — that our client accepts redialInterval
and the traffic-alarm fields, which the reference marks "General unsupport, [Outdoor] support". The
marking is real; the inference is not. Those fields being in our TypeScript says that whoever wrote
the interface read the vendor's document, and nothing whatever about the hardware. The test fixture
that looks like corroboration is the vendor's own example response, copied: same IMEI, same IMSI,
same ICCID, same /30 subnet, down to a DNS server on a Chinese carrier — with four fields edited to
look like our fleet. A fixture is a record of what we imagined, and this one is not even that.
The third was an actual behavioural test, and it points the other way. A live probe the following day, on a camera of a different form factor, downloaded a file without the parameters the reference says outdoor devices require — which says general. But it was a different form factor, so it does not settle the cellular units either.
So: a value table exists, two of them exist, and which one applies to the device in front of you is a question the paperwork does not answer. The only thing that would answer it is a request to one of these cameras, which is a five-minute job for whoever can reach one.
So what do you actually send?
The paperwork does not answer it. The device does, and that turns out to be the whole trick.
Stop trying to establish the class. Both attempts above are proxies — a status field that one class returns, a request parameter the other class requires — and a proxy tells you which table to read, which is one inference away from what you wanted. What you wanted is to know what the integer did. So write a candidate, then read the field that names the radio in plain text, and keep the value that produced the generation you asked for. The class question stops mattering the moment the device answers it for you, and it answers in one request.
That is not a workaround for bad documentation. It is the same rule as the last section, applied to
the case where reading back your own value would have looked fine: 4 comes back as 4 under either
table, and only the connection status distinguishes a 5G attach from a 3G one.
When no observed-state field exists, the value is unverifiable and you should say so rather than
pick. Send the vendor's auto option: 0 means automatic in both tables, and 3 is 4G in both, so
those two are the only values you can send without first answering a question nobody has answered — and record in the code that the non-zero values were never confirmed. A constant with
a comment saying this is a guess costs nothing and stops the next person spending an afternoon on
it. A constant that merely looks decided costs whatever the wrong radio costs.
How does a careful engineer type the wrong constant?
The comment says it, and it is the most useful line in the whole file:
| Source | What it gives you | What it costs to get |
|---|---|---|
| The public manual | five option names, no integers | one click |
| The shipped API reference | both mappings, exactly | finding the line under a diagonal watermark |
| The device's own status string | ground truth for the value you sent | one request — and nobody made it |
The reference is watermarked diagonally across every page, and the value column is where the stamp lands. I read it as unreadable and typed the conventional guess. Five weeks later I read the same column without trouble, wrote both mappings into a file I never committed, and left the code alone. Same page, same reader, different answers — and nothing in the workflow was built to notice.
And a read across the estate found every camera sitting on 0, Auto — so the wrong constant has never
actually been sent. This is not causing an outage. It is waiting for the first person who decides
to force LTE, which is exactly the shape of defect that never gets prioritised: harmless until it
is used, and used on the day someone is already troubleshooting.
That is the trap, and it is not really about an enum. The thing that would have caught it is the
read-back — write the value, then read the status string and see which radio the device actually
attached on. Nothing in the cellular path does that: setCellularConfig posts a body and returns
nothing at all.
Nor is it alone, which is the more uncomfortable version. Every write service in that module returns nothing at all — audio, system, storage, SIP and video. The event settings do return the response body, which is not the same as checking it: handing the caller an ACK it never compares against anything is a read-back in the shape of one. Exactly one code path in the whole module reads state back: the orchestrator that configures video streams, on onboarding and on every later edit, which persists the codec and bitrate the camera reported rather than the ones it was asked for. One verified write out of a dozen-odd, and the one that got the treatment is the one where somebody had already been bitten.
You can check this on your own device in one request. Read the cellular config, note
networkType, write the value you believe means the radio you want, read it back — and then read the
connection status string, which names the generation in plain text. If those two disagree, your
constant is named after your intention rather than the vendor's table, and you have just found this
article's bug in your own code.
A read-back fired alongside the write is a race that passes
What does read-after-write cost, and where does it break?
What we changed is small: the client no longer returns what was requested. It returns what the camera reported afterwards, in a type whose fields are named for it — the applied codec, the applied bitrate. Callers persist those, and the requested values are thrown away once the request is made.
The standing cost is a second round trip on every write, sequential, against a device that serialises requests, over the least reliable link in the system. Onboarding got slower. That is the easy half to state. The harder half is that the rule has failure modes of its own, and they are worth knowing before you roll it out:
The write succeeds and the read times out. You are now worse off than before you started: the device may hold the new value or the old one, and you have no basis for either. The record needs a third state — unverified — because falling back to the requested value is exactly the confident wrong answer the read-back existed to prevent.
The write moves the transport you would verify over. The cellular network type is precisely such
a field: change the radio and you re-attach the link you were going to read across. So the read has
to wait for re-attach, and a device that never comes back is indistinguishable from a write that
never landed. We have not run this — every unit sits on Auto — so I am naming it as the rule's
known hole rather than telling you how the radio behaves. It is also why 0/Auto is the only value
you can set without answering this first.
Some fields apply on reboot. Those return the old value immediately and read as a silent rollback — a false positive the naive check manufactures, and one that will teach a team to distrust a guard that is working correctly. Know which of your fields are deferred before you gate a deployment on the comparison.
None of that argues against the read-back. It argues that "read it back" is a design with edges, like everything else, and the cheap version — comparing the response body to the request — has no edges at all because it never tested anything: it passes on all four rows of the table above.
- Write the valuethe ACK proves nothing
- Read after, never alongsidethe firmware serialises CGI
- Read observed statewhat it is doing, not what it stored
- Persist what it reportedthe value you asked for is discarded
FAQ
Is this a bug in the camera?
Not obviously. succeed plausibly means "the request was accepted and processed", and a device that
clamps a value to what its hardware supports has processed it. The failure is at the seam: an API
that reports transport-level success in the field a caller reads as state-level success, with no
third value for "accepted, then ignored".
Would validating against the device's capabilities have prevented it? The first row, yes — the camera advertises its real stream count, so a write targeting a slot beyond it can be rejected before it is sent. We have not built that — our own integration note says the backend must do it, which is a task written down rather than a guard shipped. And it does nothing for the other three, where the target is real and the device simply settles somewhere else.
Does this generalise past cameras? It is the general shape of any config API over a link you do not control: routers, encoders, set-top boxes, industrial sensors. Test yours against the three conditions in the first section — one API shape across a range, a per-build reference, hardware that clamps rather than rejects.
How do I find the rest of them in a system I already run? Two audits, and they are not the same job. The code audit walks every write in your device client and asks three questions of each: is there a read for this field, is there an observed-state field distinct from stored config, and does the device advertise a capability that would let you reject the write before sending it. The data audit is the one people skip — values already persisted from unverified writes do not repair themselves when you add the read-back, so a one-off reconcile pass over the estate is part of the fix, not a follow-up.
What if the numbers are documented, but the document is unreadable or you cannot reach it? That is the common case, and it is why the read-back matters more than the datasheet. Find the field that reports observed state rather than stored configuration — here, the connection status string that names the generation — and let the device tell you what your integer meant. If no such field exists, the constant is a hypothesis, and it belongs in a comment that says so rather than in a name that looks decided.
The three things worth taking away
- A success ACK is not a state change. It says the request was accepted. Whether anything moved is a separate question with a separate answer, and only a subsequent read has it.
- Read back observed state, not the value you wrote. Your own value coming back proves storage. It is the field describing what the device is actually doing that proves meaning.
- An enum transcribed from a table you could not read is a guess wearing a type. The compiler will bless it, every call site will look decided, and the guess becomes invisible the moment the comment is dropped. Leave the comment at the definition.
What else goes wrong between a remote camera and the platform that believes it configured it is on always-on video that survives a bad uplink.
If you are configuring devices you cannot walk up to and taking the response body's word for it, that is the kind of thing we come in for.