Skip to content

react-viewer: firmware update proposal + one-click update#15391

Open
sareluzi wants to merge 2 commits into
realsenseai:developmentfrom
sareluzi:react-viewer-fw-update-proposal
Open

react-viewer: firmware update proposal + one-click update#15391
sareluzi wants to merge 2 commits into
realsenseai:developmentfrom
sareluzi:react-viewer-fw-update-proposal

Conversation

@sareluzi

@sareluzi sareluzi commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Overview: The react-viewer now proposes a firmware update when a newer version is recommended, and offers one-click download-and-install — restoring update notifications after the SDK stopped bundling firmware.

Tracked on [RSDEV-12698]

Why

When the SDK dropped the bundled firmware, RS2_CAMERA_INFO_RECOMMENDED_FIRMWARE_VERSION is no longer reported by devices, so the viewer could never propose an update. Recommendations must instead come from the online versions DB (mirroring the C++ viewer).

Summary

  • Backend: get_firmware_status fetches the online rs_versions_db.json (cached), matches the device (prefix-agnostic name + * wildcard, most-specific wins, mirrors the C++ versions_db_manager), and returns the recommended version, status (outdated/up_to_date/unknown), and download link.
  • One-click Update: new update_from_recommended endpoint downloads the recommended .bin (cached under the OS temp dir, size-capped) and reuses the existing DFU flash flow.
  • Download progress: the flash flow now emits a downloading phase with progress before installing, shown in the progress modal.
  • Auto-recovery: after a flash the device list force-refreshes with retry (and devices_changed now forces a refresh) so the camera reappears without a manual Refresh.
  • Proposal UI: shown only when outdated; hidden when up_to_date. Dismiss via X (permanent until a newer FW) or Remind me later (session). An explicit "Check for Firmware Updates" revives a dismissed proposal.

Test plan

  • Backend pytest: version compare + DB matcher incl. "Intel "-prefix regression and non-FIRMWARE/non-RECOMMENDED exclusion (12 tests).
  • Frontend Vitest: proposal render, dismiss (X + Remind me later), explicit-check revival, up-to-date hidden, unknown fallback (122 tests). tsc + eslint clean on changed files.
  • Live: /status returns recommended/outdated for a D455; download fetched a real 5.17.3.10 image with monotonic progress to a temp cache.
  • Manual: activate a device below the recommended FW, click Update, confirm download+install progress and auto-return.

🤖 Generated by AI

Copilot AI review requested due to automatic review settings July 9, 2026 13:21

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds firmware-update recommendations and a one-click “download + install” flow to the React viewer by pulling recommended versions from the online versions DB (mirroring the C++ viewer), surfacing the proposal UI, and extending progress reporting to include a download phase.

Changes:

  • Backend: fetch & cache rs_versions_db.json, compute firmware status + download link, and add an update_from_recommended endpoint that downloads (cached) and reuses the existing DFU flashing path.
  • Frontend: show an “outdated” firmware proposal with dismiss/remind behaviors, add one-click Update, and display a “downloading” phase in the progress modal.
  • Tests: add Python unit tests for version comparison / DB matching and extend React unit tests for proposal + dismissal behavior.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
wrappers/rest-api/tools/react-viewer/tests/unit/store/store.test.ts Adds unit tests for firmware-dismissal revival behavior in the store.
wrappers/rest-api/tools/react-viewer/tests/unit/components/DevicePanel.test.tsx Adds UI tests for firmware update proposal visibility and dismissal flows.
wrappers/rest-api/tools/react-viewer/src/store/index.ts Adds shared firmware-update driver, recommended-update action, and explicit firmware-check semantics.
wrappers/rest-api/tools/react-viewer/src/components/FirmwareProgressModal.tsx Extends progress modal to distinguish downloading vs installing and only mark done after install.
wrappers/rest-api/tools/react-viewer/src/components/DevicePanel.tsx Implements proposal banner UI, dismissal persistence, and one-click recommended update trigger.
wrappers/rest-api/tools/react-viewer/src/api/types.ts Extends firmware state with link and phase.
wrappers/rest-api/tools/react-viewer/src/api/socket.ts Forces a full device re-enumeration on devices_changed events.
wrappers/rest-api/tools/react-viewer/src/api/client.ts Extends firmware progress callback with phase, adds firmware-status link, and adds recommended-update endpoint call.
wrappers/rest-api/tests/test_firmware_status.py Adds backend tests for version comparison and versions DB matching rules.
wrappers/rest-api/app/services/rs_manager.py Implements versions DB fetch/cache, recommendation matching, firmware download/cache, and recommended-update flashing path.
wrappers/rest-api/app/api/endpoints/firmware.py Adds REST endpoint for “update from recommended”.

Comment on lines +71 to +74
SERVER_VERSIONS_DB_URL = "https://librealsense.realsenseai.com/Releases/rs_versions_db.json"
_VERSIONS_DB_TTL = 3600.0 # seconds
_versions_db_cache: Dict[str, Any] = {"ts": 0.0, "entries": None}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the versions-DB cache is now guarded by a module-level threading.Lock in firmware.py _fetch_versions_db.

Comment on lines +121 to +135
def _fetch_versions_db():
"""Return the DB 'versions' list (cached ~1h), or None on any network/parse failure."""
now = time.monotonic()
cached = _versions_db_cache["entries"]
if cached is not None and now - _versions_db_cache["ts"] < _VERSIONS_DB_TTL:
return cached
try:
with urllib.request.urlopen(SERVER_VERSIONS_DB_URL, timeout=10) as resp:
data = json.loads(resp.read().decode("utf-8"))
entries = data.get("versions", [])
_versions_db_cache.update(ts=now, entries=entries)
return entries
except Exception as e: # network down, timeout, bad JSON — degrade gracefully
logging.warning("Could not fetch firmware versions DB: %s", e)
return None

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the same lock guards both read and populate, so no partially-updated state is observed. Also dropped the TTL (fetched once per process).

Comment on lines +155 to +166
os.makedirs(_FW_DOWNLOAD_DIR, exist_ok=True)
name = os.path.basename(urllib.parse.urlparse(url).path) or "firmware.bin"
path = os.path.join(_FW_DOWNLOAD_DIR, name)
if os.path.isfile(path) and os.path.getsize(path) > 0:
if on_progress:
on_progress(1.0)
with open(path, "rb") as f:
return f.read()
try:
buf = bytearray()
with urllib.request.urlopen(url, timeout=30) as resp:
total = int(resp.headers.get("Content-Length") or 0)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — download_firmware validates the scheme is http(s) with a netloc and rejects anything else (file://, relative).

resetStore()
})

describe('Firmware dismissal', () => {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — added an afterEach in the dismissal describe that clears both localStorage and sessionStorage.

Comment on lines +466 to +467
// Check for a firmware-update proposal (online versions DB; best-effort, non-blocking)
get().checkFirmwareUpdates(device.device_id)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the on-activate check is now best-effort: it no longer raises the global error banner; only an explicit user-triggered check surfaces the error.

}

function FirmwareBanner({ device: _device }: FirmwareBannerProps) {
function FirmwareBanner({ deviceId, firmware, onUpdate }: FirmwareBannerProps) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Design wise, I think it'd be better to reuse the Toast popups we already have - their action would be 'update' or the built-in 'x' to dismiss - on load read /status per device and add toast if needed. Same pattern as the "Enable metadata" prompt.

Making this change will allow us to revert other changes like checkFirmwareUpdates, or alternatively make it re-show that toast.

When clicking the 'check for updates' button, indication of 'Up to date' in a Toast when relevant is also nice-to-have

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed this is a nicer pattern. It's a larger UX redesign though — it would touch/revert the banner and the checkFirmwareUpdates/retry logic — so I'd rather not fold it into this PR. Deferring to a separate follow-up PR to keep this one focused.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'd revert the banner, yes — but the toast is a smaller net diff than the banner + dual-storage dismissal + checkFirmwareUpdates it replaces (~−110 lines; it reuses the existing "Enable metadata" toast). Splitting it into a follow-up is more total work, not less: that PR has to revert code this one just added, then go through review again. Cheaper to fold it in here while the code's fresh.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@AviaAv - I implemented the banner as you suggested but I'm not sure it's good. If you have multiple cameras connected the banner pops at the global level making it unclear which camera should be updated. We can add the S/N to the toast pop-up, but it feels a bit cumbersome.
@Nir-Az - what do you think?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we add the SN, both flows are OK IMO, I am used to the C++ viewer pop up, but if it's hard to implement it's not a must.

Comment thread wrappers/rest-api/app/services/rs_manager.py Outdated
Comment thread wrappers/rest-api/app/services/rs_manager.py Outdated
Comment thread wrappers/rest-api/app/services/rs_manager.py Outdated
Comment thread wrappers/rest-api/app/services/rs_manager.py Outdated
// Shared driver for both firmware-update paths (user file + recommended download):
// flips is_updating, runs the API call (progress arrives via Socket.IO), refreshes
// device info, and records any failure on the device's firmware state.
async function performFirmwareUpdate(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great idea to extract logic into this function, but i don't think the 'attempt' logic is needed - previous version would await get().fetchDevices() - seems more suitable here too

This function will also be simplified if we use a Toast to prompt for updating

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simplified, but kept a short bounded retry: a single await fetchDevices(true) can be dropped by the isLoadingDevices guard when a devices_changed fetch races, leaving the UI stale. Also added post-flash auto-initialize of a lone camera (same as first-load) + a firmware re-check so the proposal reflects the new FW.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The toggle-active / FW re-check on the returned device is good. But the attempt loop's still there, and it worked fine without it before. Is there a specific case you think needs the retry? The backend doesn't return until the device is back, so a single fetchDevices(true) should always catch it — the UI shouldn't be left stale.

… validation, best-effort check) + auto-init single camera and re-check FW after flash
Copilot AI review requested due to automatic review settings July 15, 2026 10:43
raise RealSenseError(status_code=400, detail="Refusing to download firmware from a non-http(s) URL")
try:
buf = bytearray()
with urllib.request.urlopen(url, timeout=30) as resp:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential user input in HTTP request may allow SSRF attack - low severity
If an attacker can control the URL input leading into this HTTP request, the attack might be able to perform an SSRF attack. This kind of attack is even more dangerous if the application returns the response of the request to the user. It could allow them to retrieve information from higher privileged services within the network (such as the metadata service, which is commonly available in cloud services, and could allow them to retrieve credentials).

Show fix

Remediation: If possible, only allow requests to allowlisting domains. If not, consult the article linked above to learn about other mitigating techniques such as disabling redirects, blocking private IPs and making sure private services have internal authentication. If you return data coming from the request to the user, validate the data before returning it to make sure you don't return random data.

Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.

Comment on lines +120 to +122
def recommended_firmware(device_name):
"""Recommended FIRMWARE (version, link) for a device from the online DB, or (None, None)."""
return _pick_recommended_fw(_fetch_versions_db(), device_name, platform.system())
Comment on lines +6 to +10
Pure logic with no pyrealsense2 dependency, so tests can import it directly without
loading the SDK. The SDK no longer bundles firmware, so the recommended version is
looked up from the online versions DB (mirrors the C++ viewer's server_versions_db_url),
and the image is downloaded into memory and flashed from there (like the C++ viewer's
download_to_bytes_vector) — no temp files.
Comment on lines 4 to 7
import asyncio
import os
import platform
import struct
with urllib.request.urlopen(SERVER_VERSIONS_DB_URL, timeout=10) as resp:
data = json.loads(resp.read().decode("utf-8"))
entries = data.get("versions", [])
_versions_db_cache["entries"] = entries

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [rs-agentic] BUG — empty DB cached permanently

If the server returns a well-formed JSON with an empty versions array (e.g. CDN hiccup, wrong endpoint), entries = [] is stored here. Because the guard above checks is not None, an empty list passes it and will be returned forever — silently suppressing all firmware proposals for the process lifetime.

Consider only caching when entries is non-empty:

if entries:
    _versions_db_cache["entries"] = entries

Or distinguish between "not yet fetched" and "fetched empty" with an explicit sentinel.

if not data:
raise RealSenseError(status_code=502, detail="Downloaded firmware image is empty")
if on_progress:
on_progress(1.0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [rs-agentic] Minor — on_progress(1.0) called twice

When Content-Length is known and the last chunk fills the buffer exactly, on_progress(min(len(buf)/total, 1.0)) inside the loop already fires 1.0. The unconditional call here emits it again. Rate-limiting in _make_fw_progress_callback lets p == 1.0 through unconditionally, so two 100% events are emitted. Harmless but noisy on the socket.

@sareluzi

Copy link
Copy Markdown
Collaborator Author

Review round addressed (commit 7f7d8f8)

Replied inline on each thread. Summary:

Security / correctness

  • SSRF (aikido) + URL scheme (Copilot): download_firmware now validates the URL is http(s) with a netloc and rejects file:// / relative / other schemes.
  • Download wrapped in try/except that emits firmware_update_failed then re-raises (no hung modal).
  • On-activate FW check is best-effort — no longer raises the global error banner (only explicit checks do).

Refactor (AviaAv)

  • All firmware helpers extracted to app/services/firmware.py; test_firmware_status.py now imports pure logic (no SDK).
  • Download is in-memory (no temp file), matching the C++ viewer; dropped the temp cache + tempfile/json/urllib imports.
  • Dropped the 1-hour versions-DB TTL (fetched once per process); cache guarded by a lock.

Tests

  • afterEach clears local/sessionStorage in the dismissal tests.

Also fixed two behaviors found while testing: after a flash, a lone camera now auto-initializes (like first load) and the firmware proposal is re-checked.

Deferred: the Toast-based proposal UX (AviaAv) — larger redesign, tracked for a separate follow-up PR.

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.

5 participants