fix: fall back to per-index object-list scan when ReadProperty(OBJECT_LIST, ALL) fails - #54
fix: fall back to per-index object-list scan when ReadProperty(OBJECT_LIST, ALL) fails#54ravz wants to merge 2 commits into
Conversation
…y loop Devices that answer ReadProperty(OBJECT_NAME) but silently drop ReadProperty(OBJECT_LIST, ALL) were never discovered by the scheduled poll. The per-index scan that works on them already existed but was only reachable from the read node's right-click "Update points" menu, so the poll retried the same doomed read every cycle and the point list stayed at just the device object. Add BacnetClient.discoverPointList(device): try the single round-trip ALL read, and fall back to the per-index scan when it fails. A successful fallback latches manualDiscoveryMode so later polls skip the doomed ALL read instead of burning an apduTimeout every cycle. Success is judged by what the scan returned on this pass rather than by growth of the device's cumulative point list, so a healthy latched device keeps reporting success in steady state instead of logging a spurious error every poll. Both the scheduled loop and updatePointsForDevice now go through this one strategy. scanDevice no longer resets manualDiscoveryMode as a side effect, which would otherwise clobber the latch on the next poll. Also rewrite the queryDevices device iteration from recursion to a for loop. A missing return in the getProtocolSupported error path made each failing device traverse the remaining list twice, so m failing devices produced 2^m traversals, each costing an apduTimeout per unresponsive device. pollInProgress now resets in a finally, so an exception escaping the loop can no longer disable device polling until Node-RED restarts. Error logs use getDeviceAddress() instead of interpolating the address object, which rendered as [object Object] for MSTP devices. Fixes #53 Fixes #52 Refs #49 Claude-Session: https://claude.ai/code/session_015Yf3sg2PeMKH29s3owfyxM
There was a problem hiding this comment.
Pull request overview
This PR fixes device point discovery when ReadProperty(OBJECT_LIST, ALL) times out by introducing a shared discovery strategy that falls back to a per-index object-list scan, and it also refactors the scheduled polling loop to eliminate exponential traversal and ensure pollInProgress always resets.
Changes:
- Add
discoverPointList(device)to tryOBJECT_LIST, ALLfirst and fall back to per-index scanning, latchingmanualDiscoveryModeafter a successful fallback. - Refactor
queryDevicesfrom recursive traversal to aforloop withtry/finallyto reliably clearpollInProgressand avoid repeated traversals on error. - Update
updatePointsForDeviceto use the shared discovery helper instead of duplicating fallback logic.
Suppressed comments (2)
bacnet_client.js:1216
- Because
getDevicePointListis now only used bydiscoverPointList, having it also log failures causes duplicate/noisy logs. It also logs the address viadevice.getAddress().toString(), which renders as[object Object]for MSTP devices. Prefer lettingdiscoverPointListbe the single place that logs point-list discovery failures, and havegetDevicePointListjust reject.
try {
let result = await that.scanDevice(device);
device.setPointsList(result);
device.setLastSeen(Date.now());
bacnet_client.js:645
- This log message is now misleading: the code no longer calls
getDevicePointListhere; it callsdiscoverPointList, which may use either the ALL read or the per-index scan. Renaming the log string makes failures easier to understand when triaging logs.
const discoverySucceeded = await that.discoverPointList(device);
if (!discoverySucceeded) {
that.logOut(`getDevicePointList error: ${that.getDeviceAddress(device)} - ${device.getDeviceId()}`);
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const discoverySucceeded = await this.discoverPointList(device); | ||
| if (!discoverySucceeded) { | ||
| this.logOut(`Update points list error: ${this.getDeviceAddress(device)} - ${device.getDeviceId()}`); | ||
| } |
Addresses both points from the Copilot review on PR #54. scanDevice and getDevicePointList each logged the same failure on the way out, so one timeout produced three lines — and getDevicePointList interpolated device.getAddress().toString(), which renders as [object Object] for MSTP devices whose address is an object. Both now just reject; discoverPointList is the only place a point-list discovery failure is reported, and it is the only frame that knows whether a fallback follows. Rename the queryDevices verdict log from "getDevicePointList error" to "Point list discovery failed (both object-list strategies)". The old string named a function the call site no longer invokes, and it could not distinguish which strategy failed. Also reject instead of swallowing in scanDevice's malformed-acknowledgement branch. It logged and left the promise unsettled, which would hang the caller rather than surfacing the bad response. Claude-Session: https://claude.ai/code/session_015Yf3sg2PeMKH29s3owfyxM
|
Both Copilot points addressed in 49a4879. Duplicate logging / Misleading log string — While in Context worth flagging for whoever merges this: the reporter on #49 has since confirmed this does not fix their problem. It reproduces on a two-point network and the per-index fallback times out too, so the object-list read is failing for a reason unrelated to response size. This PR still fixes #53 and #52 on their own merits — the scheduled poll genuinely cannot reach the fallback, and the recursion bug is real — but it should not be expected to close #49. Two further defects found while re-examining their new evidence, both filed separately with verification: #55 (per-property reads encode the raw max-APDU octet count into a 4-bit enum field, so a 480-octet device is told we accept 50, and a 206-octet device gets a reserved value) and #56 ( |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.
Suppressed comments (1)
bacnet_client.js:637
- In
queryDevices, agetProtocolSupportedfailure currentlycontinues the loop, which skipsupdateDeviceNameanddiscoverPointListfor that device. Point-list discovery usesReadProperty(OBJECT_NAME/OBJECT_LIST)and does not depend on protocol-services-supported, so devices that error onPROTOCOL_SERVICES_SUPPORTEDcould be permanently skipped by the scheduled poll.
} catch (error) {
that.logOut("getProtocolSupported error: ", error);
continue;
}
Fixes #53. Fixes #52. Addresses the report in #49.
The bug (#53)
A device that answers
ReadProperty(OBJECT_NAME)but silently dropsReadProperty(OBJECT_LIST, ALL)is never discovered by the scheduled poll. Its point list stays atjust the device object and the console fills with
ERR_TIMEOUT, forever.Devices behave this way when the full object-list response won't fit an unsegmented APDU — they drop
the request rather than returning an Error or Abort, so there is no error PDU, just a timeout. YABE
reads the same devices fine because it walks the object list by array index.
edge-bacnet already had that per-index scan (
scanDeviceManually), but the scheduled loop couldnever reach it: the fallback lived only in
updatePointsForDevice, which fires solely from the readnode's right-click Update points menu. The only automatic route in was
getSegmentation() == 3, soa device that advertises segmentation support but can't actually deliver the list never qualified.
Confirmed the request itself is emitted correctly — this was never a send-side problem. Instrumenting
the bundled stack at the transport and dgram boundaries:
Changes
discoverPointList(device)— one strategy, shared by the scheduled loop andupdatePointsForDevice. Tries the single round-tripALLread; on failure falls back to theper-index scan. A successful fallback latches
manualDiscoveryMode, so later polls skip the doomedALLread instead of burning anapduTimeoutevery cycle.Success is judged by what the scan returned on this pass, not by growth of the device's cumulative
point list. That distinction matters: a latched device re-scanned in steady state finds the same
objects,
setPointsListdedups them, and a count-delta test would report failure on every poll —reinstating the console spam this PR is meant to remove.
scanDeviceno longer callssetManualDiscoveryMode(false)as a side effect, which would otherwiseclobber the latch on the next poll.
queryDevicesrecursion →forloop (#52). A missingreturnin thegetProtocolSupportederror path made each failing device traverse the remaining list twice — m failing devices produced
2^m traversals, each costing an
apduTimeoutper unresponsive device.pollInProgressnow resets ina
finally; previously any exception escaping the loop left it stucktrue, and since the scheduledtask is gated on
!pollInProgress, that permanently disabled polling until a Node-RED restart.Error logs now use
getDeviceAddress(device)instead of interpolating the address object, whichrendered as
[object Object]for MSTP devices — visible in the #49 report.Verification
Driven through a stubbed
client(every BACnet call in this file goes throughclient.readProperty/client.readPropertyMultiple, so no network is needed):Covering: fallback recovers the point list and latches; subsequent polls issue no
ALLread and logno error; a device answering
ALLnormally still takes the one-shot path and never latches; totalfailure returns false without latching and is logged with the device named.
Known limitations
scanDeviceManuallyresolves — rather than rejects — when a per-index read errors, treating any erroras end-of-array. So when the index scan yields nothing,
discoverPointListhas no underlying error tolog; it returns false and both call sites log the failure with the device named. That resolve-on-error
behaviour also means a transient timeout mid-scan silently truncates the point list. Tracked
separately in #50 and deliberately out of scope here.
#51 (transport silently drops sends to ports outside the configured Port Range while still arming the
APDU timeout) is also untouched — real, but unrelated to #49.
Testing notes
This repo has no test harness, so the verification above is a throwaway script rather than a committed
test. Worth standing one up; the stubbed-client approach works without a network and would cover this
area cheaply.
https://claude.ai/code/session_015Yf3sg2PeMKH29s3owfyxM