Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
7e879ab
plan: Media2 full support — initial plan
Apr 15, 2026
280cf98
plan: revise for full Media2 routing layer
Apr 15, 2026
84b652e
review: Media2 full support plan — APPROVED
Apr 15, 2026
5d19523
feat(1.1+1.2): expand IMedia2 WCF interface + add Media2EncoderOptions
Apr 15, 2026
17704a8
chore: update progress.json — tasks 1.1 and 1.2 completed
Apr 15, 2026
219ee8c
chore: progress.json — V1 blocked pending build approval
Apr 15, 2026
bfe9695
chore: progress.json — V1 build result confirmed PASS (Release x64, 0…
Apr 15, 2026
36f722e
review: Phase 1 code review — IMedia2 WCF interface expansion APPROVED
Apr 15, 2026
76b265e
feat(2.1): GetProfiles Media2 routing + Media2XmlParser foundation
Apr 15, 2026
1a62aa9
feat(2.2): GetStreamUri Media2 routing
Apr 15, 2026
4abd19d
feat(2.3): unit tests for GetProfiles + GetStreamUri XML parsing
Apr 15, 2026
ab5b3f9
chore: progress.json — V2 PASSED, Phase 2 complete
Apr 15, 2026
c105bba
review: Phase 2 code review — GetProfiles + GetStreamUri routing APPR…
Apr 15, 2026
25439e0
feat(3.1+3.2): GetVideoEncoderConfigurationOptions + SetVideoEncoderC…
Apr 15, 2026
b01445c
feat(3.3): unit tests 5-8 -- options parsing + SetVEC XML construction
Apr 15, 2026
a29e7c5
chore: progress.json -- V3 PASSED, Phase 3 complete
Apr 15, 2026
1333f60
chore: progress.json — V3 PASSED, Phase 3 complete
Apr 15, 2026
cc8fb56
review: Phase 3 code review — GetVideoEncoderConfigurationOptions + S…
Apr 15, 2026
dbc0c2d
feat(4.1-4.3): GetCompatible+GetVideoEncoder+GetVideoSource Media2 ro…
Apr 15, 2026
cb20148
chore: progress.json -- V4 PASSED, Phase 4 complete
Apr 15, 2026
20d9b30
feat(5.1-5.3): GetSnapshotUri Media2 routing + retire GetVideoEncoder…
Apr 15, 2026
4a403b1
chore: progress.json — Phase 5 tasks 5.1-5.3 completed, V5 pending MS…
Apr 15, 2026
0c628c3
chore: progress.json — VERIFY 5 completed, Release x64 build PASSED
Apr 15, 2026
4530894
review: Phases 4+5 code review — APPROVED
Apr 15, 2026
b17c25a
docs: add Media2 architecture and testing docs
Apr 15, 2026
5ffabdf
review: Sprint 4 documentation harvest — APPROVED
Apr 15, 2026
2087a45
cleanup: remove fleet control files
Apr 15, 2026
4ecace8
docs(sprint6): plan + requirements for Milesight camera compatibility
Apr 16, 2026
2da85c6
review: Sprint 6 plan review — CHANGES NEEDED
Apr 16, 2026
533ab38
plan: address Sprint 6 review CHANGES NEEDED — Fix A scope, exception…
Apr 16, 2026
35a9a47
review: Sprint 6 plan re-review — APPROVED
Apr 16, 2026
04c7a32
feat(sprint6/phase1): apply ServicePointManager trio before SOAP prob…
Apr 16, 2026
dd0defc
review: Sprint 6 Phase 1 — APPROVED
Apr 16, 2026
1b7bdec
feat(sprint6/phase2/task2.1): UpgradeScheme honours device HTTPS port…
Apr 16, 2026
b4208f5
feat(sprint6/phase2/task2.2): update FixUrlHttpsTests for new port-ma…
Apr 16, 2026
7f51a58
feat(sprint6/phase2/task2.3): remove hardcoded :443 from HttpsIntegra…
Apr 16, 2026
4a6afcd
chore: mark Phase 2 tasks done in progress.json (VERIFY 2 passed)
Apr 16, 2026
832f9a5
review: Sprint 6 Phase 2 — APPROVED
Apr 16, 2026
3b5e210
feat(sprint6/phase3/tasks3.1-3.2): connection-refused detector + HTTP…
Apr 16, 2026
92f3857
chore: mark Phase 3 tasks done in progress.json (VERIFY 3 passed)
Apr 16, 2026
b0d417c
review: Sprint 6 Phase 3 — APPROVED
Apr 16, 2026
c1d93be
feat(sprint6/phase4/task4.1-4.2): withMedia2HttpFallback + withMedia1…
Apr 16, 2026
a5bef61
test(sprint6/phase4/task4.3): MediaHttpFallbackTests — combinator beh…
Apr 16, 2026
47f6cc8
chore: mark Phase 4 tasks done in progress.json (VERIFY 4 passed)
Apr 16, 2026
0b64ae4
review: Sprint 6 Phase 4 — APPROVED
Apr 16, 2026
d00f85c
chore: Phase 5 VERIFY — integration test results (6/13)
Apr 16, 2026
fb458e9
chore: Phase 5 VERIFY — release build + integration test results
Apr 16, 2026
ac07122
diag: add Sprint 6 diagnostic logging for Axis auth regression (#axis…
Apr 16, 2026
e92a419
diag: broaden auth-fault logging to primary channel path and all Faul…
Apr 16, 2026
3fb918e
diag: broaden auth logging — primary channel URL + FaultException cod…
Apr 16, 2026
14bb7be
feat(#25/4.2-4.4): Milesight TLS probe — integration tests + IsHttps4…
Apr 17, 2026
eacc908
feat(#25/4.4-V4): StripActionMustUnderstand behavior + inconclusive G…
Apr 17, 2026
e60f747
feat(#26/tasks-1.1-1.3): port-matching guard in UpgradeScheme + tests
kumaakh Apr 16, 2026
3335805
chore(V1): VERIFY 1 results — Phase 1 (#26) all pass
kumaakh Apr 16, 2026
2ed6af5
feat(#30/2.1): RTSP regression integration test -- GetStreamUri_Camer…
kumaakh Apr 16, 2026
1296e03
feat(#30/2.3-2.4): fix GetMedia2Client GetServices-400 swallow + stre…
kumaakh Apr 16, 2026
fcb3314
feat(#29/3.1): add timing logs to CredentialStore.Load and auto-conne…
kumaakh Apr 16, 2026
aa1ea4d
feat(#29/3.2): add StartupRaceTests — gate auto-connect on Credential…
kumaakh Apr 16, 2026
9b3b66d
feat(#29/3.2-3.3): add IsLoaded gate to CredentialStore + StartupRace…
kumaakh Apr 16, 2026
fd4681c
fix(#25): remove StripActionMustUnderstand from global channel factor…
Apr 17, 2026
b674a7a
chore: update progress.json — V4 push confirmed, e856f12
Apr 17, 2026
f907e05
test: add CameraCompatibilitySweepTests multi-camera smoke sweep
Apr 17, 2026
862dd74
docs+test: architecture docs, CredentialStore+WS-Discovery sweep refa…
Apr 17, 2026
0584ee1
chore: remove session planning artifacts from repo
Apr 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ build_output.txt
# Claude Code / fleet
.claude/
permissions.json
CLAUDE.md

# Lib docs
libs/*/doc/
Expand Down
68 changes: 68 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,53 @@ The F# `NvtSessionFactory` accepts one `NetworkCredential` at construction time

---

## WS-Discovery (`onvif/onvif.discovery/`)

**File:** `onvif/onvif.discovery/NvtDiscovery.fs`

ONVIF WS-Discovery is implemented in F# using `System.ServiceModel.Discovery` (WCF). The
key types are:

| Type | Description |
|------|-------------|
| `NvtManager` | Concrete class; manages the discovery lifecycle |
| `INvtManager` | Interface — `Discover(TimeSpan)`, `Observe()` |
| `INvtNode` | Represents one discovered camera; has `identity: NvtIdentity` |
| `NvtIdentity` | `endpointReference`, `uris: Uri[]`, `scopes: Uri[]` |
| `WsDiscoveryObservable` | Low-level WCF `DiscoveryClient` wrapped as `IObservable<WsDiscoveredEndpoint>` |

### Usage pattern

```csharp
var manager = new NvtManager();
var nvtMgr = (INvtManager)manager;
using (nvtMgr.Observe().Subscribe(myObserver)) // subscribe first
using (nvtMgr.Discover(TimeSpan.FromSeconds(5))) // then probe
{
Thread.Sleep(6000); // wait for probe window + buffer
}
// myObserver.Nodes now contains all discovered INvtNode instances
```

### Probe types

`Discover()` fires two WCF probes in parallel — one for each contract type cameras
advertise:

- `NetworkVideoTransmitter` — `http://www.onvif.org/ver10/network/wsdl`
- `Device` — `http://www.onvif.org/ver10/device/wsdl`

Each probe uses `FindCriteria.Duration = TimeSpan.MaxValue` + a `Timeout()` operator to
honour the caller-specified duration. Responses are deduplicated by endpoint reference.

### Hello/Bye announcements

`NvtManager` also implements an announcement service host (`UdpAnnouncementEndpoint`) that
passively receives Hello and Bye multicast announcements from cameras as they come online
or go offline. This is the mechanism that drives the live device list updates in the UI.

---

## Test Architecture

### Unit/integration tests (`odm/odm.tests/`)
Expand All @@ -139,6 +186,27 @@ MSTest project targeting .NET 4.8. Tests are split by category:

The test project references `onvif.session.dll` and its dependencies from the Release output directory.

### Camera Compatibility Sweep (`CameraCompatibilitySweepTests`)

**File:** `odm/odm.tests/CameraCompatibilitySweepTests.cs`

Mirrors the real ODM UI workflow in a test:

1. Reads `CredentialStore.Instance.GetAll()` — DPAPI-decrypted credentials, same file ODM uses.
2. Runs `NvtManager` WS-Discovery for 5 seconds — finds cameras on the local network.
3. For each discovered camera, tries every stored credential (then anonymous) via
`NvtSessionFactory.CreateSession(identity.uris)` — exact same multi-URI probe ODM uses.
4. For authenticated cameras: `GetProfiles()` → `GetStreamUri()` to get the RTSP URL.
5. For each RTSP URL: TCP socket probe to the RTSP port (default 554) to verify reachability.

The test never asserts pass/fail — it is always green as long as at least one camera could
be authenticated. It writes a Markdown report to the test output directory (or
`ODM_SWEEP_REPORT_DIR`). The test is `Inconclusive` (orange in CI) when no cameras are
found or none authenticate.

To run: place the test DLL in an environment with ODM already configured (credentials.dat
present) and on the same network as the cameras.

### E2E UI tests (planned — `odm/odm.e2e-tests/`)

FlaUI-based (UIA3) end-to-end test suite that drives the full ODM UI against real cameras and uses Claude vision API for screenshot analysis. See `docs/e2e-smoke-test-poc.md` for architecture details.
129 changes: 129 additions & 0 deletions docs/features/media2-routing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# Media2 Transparent Routing

## Overview

ODM supports ONVIF Media2 (ver20/media/wsdl) cameras transparently. When a camera
advertises Media2, all video operations route through it automatically. Media1 remains
the fallback for cameras that do not support Media2 and as a per-operation safety net.
No changes were required to `INvtSession`, activity files, or the GUI.

Tracking issue: https://github.com/Apra-Labs/ONVIF-Device-Manager/issues/21

---

## Detection

`GetMedia2Client()` in `NvtSession.fs` is the single detection gate for the entire
routing layer. It inspects the `GetServices()` response for the `ver20/media/wsdl`
namespace and returns a non-null `IMedia2` WCF proxy if found.

The result is **memoized** (`Async.Memoize`) so detection happens exactly once per
session. Subsequent calls return the cached result immediately.

---

## Routing Pattern

Every video operation in `NvtSession.fs` follows the same pattern:

```fsharp
member this.SomeVideoOp(args) = async {
let! media2 = GetMedia2Client()
if media2 |> NotNull then
try return! someVideoOpViaMedia2(media2, args)
with _ -> return! someVideoOpViaMedia1(args)
else
return! someVideoOpViaMedia1(args)
}
```

Key properties of this design:

- **Media2-first** — if the camera supports Media2, the Media2 path is tried first.
- **Per-operation fallback** — each operation falls back to Media1 independently.
A failure in one operation does not affect others.
- **Transparent** — callers use the standard `INvtSession` interface; the routing
is invisible to activities and the GUI.
- **No shared channel state** — Media1 and Media2 use separate WCF channel factories
(`getMediaFactory` vs `getMedia2Factory`). Mixed calls cannot corrupt each other.

---

## Routed Operations

| Operation | Media2 request type | Notes |
|-----------|---------------------|-------|
| `GetProfiles` | `Media2GetProfilesRequest` | Returns all profiles; embedded encoder configs parsed inline |
| `GetStreamUri` | `Media2GetStreamUriRequest` | `Protocol = "RtspUnicast"` |
| `GetSnapshotUri` | `Media2GetSnapshotUriRequest` | Returns plain URI string; same parse path as GetStreamUri |
| `GetVideoEncoderConfigurationOptions` | `Media2GetVideoEncoderConfigurationOptionsRequest` | Per-encoding options block; fixes H265 slider ranges (issue #21) |
| `SetVideoEncoderConfiguration` | `Media2SetVideoEncoderConfigurationsRequest` | Raw XML body; `ForcePersistence` omitted (not supported by Media2) |
| `GetVideoEncoderConfigurations` | `Media2GetVideoEncoderConfigurationsRequest` | Full-field parser: encoding, resolution, rateControl, govLength, h264/h265 block |
| `GetCompatibleVideoEncoderConfigurations` | `Media2GetVideoEncoderConfigurationsRequest` | Reuses the same helper as GetVideoEncoderConfigurations with a `ProfileToken` filter |
| `GetVideoSourceConfigurations` | `Media2GetVideoSourceConfigurationsRequest` | Parses token, name, sourceToken, bounds |

---

## WCF Interface Design

All `IMedia2` operations use `System.ServiceModel.Channels.Message` as the return type
(Begin/End async pair). XML is parsed in `NvtSession.fs` using LINQ-to-XML with
namespace-qualified element names.

Two XML namespaces are used throughout:

| Prefix | Namespace URI | Usage |
|--------|---------------|-------|
| `tr2` | `http://www.onvif.org/ver20/media/wsdl` | Response wrapper elements (e.g., `tr2:Profiles`, `tr2:Options`) |
| `tt` | `http://www.onvif.org/ver10/schema` | Field elements inside each block (e.g., `tt:Encoding`, `tt:Resolution`) |

Request classes use `[MessageContract(WrapperNamespace = "http://www.onvif.org/ver20/media/wsdl")]`
and `[MessageBodyMember]` on each field. This pattern was chosen because the stock WCF
proxy generator does not handle Media2 responses correctly — the raw Message approach
gives full control over XML parsing and avoids serializer mismatches.

### XML parsing helper

`Media2XmlParser` (in `onvif.services`) is a static C# class that encapsulates all
LINQ-to-XML parsing logic. It is deliberately separate from the F# session code so it
can be unit-tested without a WCF channel. Parsers are defensive: missing optional
elements (e.g., `GovLengthRange`) are left null rather than default-initialised, to
preserve the distinction between "not provided" and "zero".

---

## Why INvtSession Was Not Changed

Activities and the GUI call `INvtSession` methods by name. Keeping the interface
unchanged means no activity, view model, or test mock required modification. The
routing is an internal implementation detail of `NvtSession.fs`.

The one temporary exception — `GetVideoEncoderConfigurationsMedia2` — was added as a
bridge during development and **retired in Phase 5**. Callers in
`VideoSettingsActivity.fs` and `ProfileManagementActivity.fs` were migrated to the
standard `GetVideoEncoderConfigurations()`, which now returns full H265 configurations
via Media2 routing.

---

## Why GetVideoEncoderConfigurationsMedia2 Was Retired

The original `GetVideoEncoderConfigurationsMedia2` member on `INvtSession` was a
temporary bridge that only parsed `token` and `encoding` from Media2 responses.
After Phase 4, `GetVideoEncoderConfigurations()` itself routes through Media2 and
returns fully-parsed configurations (all fields). Keeping the bridge method would have
left two ways to retrieve the same data with different field completeness. Retiring it
simplified the interface and removed the partial-parse path.

---

## Video Encoder Configuration Options — Issue #21 Root Cause

ONVIF Media1 returns `VideoEncoderConfigurationOptions` with a single
`h264`/`jpeg`/`mpeg4` sub-object. Cameras that encode H265 report options inside a
Media2-only `tr2:Options` block with `<tt:Encoding>H265</tt:Encoding>`. Media1 has no
`h265` sub-object, so H265 slider ranges were always empty.

Media2 returns one `tr2:Options` block per supported encoding. The parser maps each
block into the corresponding sub-object (`options.h265`, `options.h264`, `options.jpeg`)
of the existing `VideoEncoderConfigurationOptions` type. No type changes were needed.
133 changes: 133 additions & 0 deletions docs/features/media2-testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Media2 Testing

## Test Files

| File | Type | Count |
|------|------|-------|
| `odm/odm.tests/Media2XmlParserTests.cs` | Offline unit tests | 11 |
| `odm/odm.tests/Media2IntegrationTests.cs` | Integration tests (require camera) | 7 |

---

## Running Unit Tests (Offline)

Unit tests have no external dependencies. They exercise `Media2XmlParser` directly
with inline XML strings.

```bat
dotnet build odm\odm.tests\odm.tests.csproj -v quiet

"C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\IDE\Extensions\TestPlatform\vstest.console.exe" ^
odm\odm.tests\bin\Debug\net48\odm.tests.dll ^
--TestCaseFilter:"TestCategory!=Integration"
```

All 80 tests (including Media2 unit tests) should pass.

---

## Running Integration Tests (Live Camera)

Integration tests require a real ONVIF camera that supports Media2 (`ver20/media/wsdl`).

### Environment Variables

| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `ODM_TEST_HOST` | Yes | — | Camera IP or hostname (e.g., `192.168.1.100`) |
| `ODM_TEST_USER` | No | `""` | Username for authenticated access |
| `ODM_TEST_PASS` | No | `""` | Password for authenticated access |

If `ODM_TEST_HOST` is not set, all integration tests call `Assert.Inconclusive` and
skip gracefully. The offline CI run uses `TestCaseFilter:"TestCategory!=Integration"`
to exclude them entirely.

### Running Against a Camera

```bat
set ODM_TEST_HOST=192.168.1.100
set ODM_TEST_USER=admin
set ODM_TEST_PASS=password

"C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\IDE\Extensions\TestPlatform\vstest.console.exe" ^
odm\odm.tests\bin\Debug\net48\odm.tests.dll ^
--TestCaseFilter:"TestCategory=Integration"
```

---

## What Each Test Covers

### Unit Tests (Media2XmlParserTests.cs)

| Test | What it verifies |
|------|-----------------|
| 1. `ParseGetProfilesResponse_TwoProfiles_*` | Two-profile response (H264 + H265): tokens, names, embedded VSC and VEC fields, h264/h265 sub-blocks |
| 2. `ParseGetProfilesResponse_NoProfiles_*` | Empty response body → empty array, no exception |
| 3. `ParseGetStreamUriResponse_ValidUri_*` | Valid `tr2:Uri` element → URI string extracted correctly |
| 4. `ParseGetStreamUriResponse_EmptyUri_*` | Empty `tr2:Uri` element → returns null |
| 5. `ParseGetVideoEncoderConfigurationOptionsResponse_H265AndH264_*` | Per-encoding options blocks → `options.h265` and `options.h264` populated with correct resolution/range values; `options.jpeg` remains null |
| 6. `ParseGetVideoEncoderConfigurationOptionsResponse_MissingGovLengthRange_*` | Absent `GovLengthRange` element → field is null (not default-initialised), no exception |
| 7. `ParseGetVideoEncoderConfigurationOptionsResponse_EmptyBody_*` | No `tr2:Options` elements → all sub-objects null, no exception |
| 8. `BuildSetVideoEncoderConfigurationElement_H265Config_*` | H265 VEC → XML has correct token, Name, Encoding, Resolution, RateControl, H265 block; H264 block absent |
| 9. `ParseGetVideoEncoderConfigurationsResponse_FullFields_*` | Two configs (H265 + H264): all fields (token, name, encoding, resolution, rateControl, govLength, h265/h264 block); exclusive h265/h264 presence |
| 10. `ParseGetVideoSourceConfigurationsResponse_ValidResponse_*` | Two VSCs: token, name, sourceToken, bounds (x/y/width/height) |
| 11. `ParseGetVideoEncoder/SourceConfigurationsResponse_EmptyBody_*` | Both parsers return empty arrays for empty responses, no exception |

### Integration Tests (Media2IntegrationTests.cs)

| Test | What it verifies |
|------|-----------------|
| 1. `GetProfiles_Media2Camera_ReturnsNonEmptyProfiles` | Session returns at least one profile; all profiles have non-empty tokens |
| 2. `GetStreamUri_FirstProfile_ReturnsRtspUri` | Stream URI is non-empty and starts with `rtsp://` |
| 3. `GetVideoEncoderConfigurationOptions_FirstProfile_ReturnsOptions` | Options object is non-null for the first profile's encoder configuration |
| 4. `GetVideoEncoderConfigurations_Media2Camera_ReturnsConfigurations` | At least one configuration returned; all have non-empty tokens |
| 5. `GetVideoSourceConfigurations_Media2Camera_ReturnsConfigurations` | At least one source configuration; all have non-empty token and sourceToken |
| 6. `GetSnapshotUri_FirstProfile_ReturnsUriOrNull` | URI is HTTP(S) if returned; null or exception → marked Inconclusive (not a failure) |
| 7. `GetCompatibleVideoEncoderConfigurations_FirstProfile_ReturnsConfigurations` | No exception thrown; empty result is acceptable for some profiles |

---

## Adding New Integration Tests

1. Add a `[TestMethod]` inside `Media2IntegrationTests.cs`.
2. Tag it `[TestCategory("Integration")]`.
3. Call `SkipIfNoHost()` as the first statement.
4. Use `CreateSession()` to get an `INvtSession` against the live camera.
5. Use `Run(async)` to execute F# async workflows synchronously.

Example skeleton:

```csharp
[TestMethod]
[TestCategory("Integration")]
public void GetFoo_FirstProfile_ReturnsBar()
{
SkipIfNoHost();
var session = CreateSession();
var profiles = Run(session.GetProfiles());
if (profiles == null || profiles.Length == 0)
Assert.Inconclusive("No profiles — cannot test GetFoo");

var result = Run(session.GetFoo(profiles[0].token));
Assert.IsNotNull(result);
// ... specific assertions
}
```

---

## Adding New Unit Tests

All XML parser tests live in `Media2XmlParserTests.cs`. Each test:

1. Defines a literal XML string matching the WCF body format
(`response.GetReaderAtBodyContents().ReadOuterXml()`).
2. Calls the relevant static method on `Media2XmlParser`.
3. Asserts on the returned ODM type.

The XML must include the `tr2` and `tt` namespace declarations on the root element,
as the parser uses namespace-qualified `XName` lookups.

Tests should be tagged `[TestCategory("Unit")]` and must not depend on any external
resources or environment variables.
Loading
Loading