Skip to content

Commit 0958b9d

Browse files
sebgodclaude
andcommitted
Unify filter wheel config: profile URI as source of truth, equipment tab editing
All filter wheel drivers now read name/offset from URI query params first, with driver-specific fallbacks per slot (COM for ASCOM, REST API for Alpaca, presets for Fake, InstalledFilter for Manual). Slot count always comes from hardware/preset, not URI scanning. - ASCOM: URI params override COM Names[]/FocusOffsets[] per slot - Alpaca: URI params override API names/focusoffsets; add GetStringArrayAsync/ GetIntArrayAsync to AlpacaClient; ReadFilterConfigAsync populates on connect - Fake: per-device-ID presets (LRGB/Narrowband/Simple), max 8 slots - Manual: reads name+offset from URI (single slot) - EquipmentActions: add GetFilterConfig, SetFilterConfig, UpdateOTA helpers - VkEquipmentTab: filter table (expand/collapse, name cycle, offset stepper, add/remove capped at 8) and OTA property editors (name, focal length, aperture, optical design cycle) - PlannerActions.BuildSchedule: pass availableFilters and opticalDesign from active profile to ObservationScheduler Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b7b1c6b commit 0958b9d

File tree

12 files changed

+633
-31
lines changed

12 files changed

+633
-31
lines changed

TODO.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
## Next Up
44

5-
- [ ] Fake filter wheels should have pre-installed filters (realistic filter sets per device ID)
5+
- [x] Fake filter wheels should have pre-installed filters (realistic filter sets per device ID)
66
- [ ] Pinned items in planner should persist to disk (`<CommonDataRoot>/Sessions/Uncommitted.json`) so they survive app restarts while not yet committed to a planned session
7+
- [ ] Seed focuser `MaxStep` from hardware during ZWO EAF discovery (same `seedQueryParams` pattern as EFW slot count)
8+
- [ ] Remember last focus position in profile URI after auto-focus (save after every auto-focus attempt, whether successful or not) so the focuser can start near the last known good position on next session
79

810
## Observation Scheduler (PLAN-SessionTests.md)
911

@@ -95,7 +97,7 @@
9597

9698
- [ ] Query tracking rates from Alpaca when endpoint supports enumeration (`AlpacaTelescopeDriver.cs:46`)
9799
- [ ] Parse axis rates from Alpaca response (`AlpacaTelescopeDriver.cs:315`)
98-
- [ ] Implement string[] and int[] typed getters for filter names and focus offsets (`AlpacaFilterWheelDriver.cs:30`)
100+
- [x] Implement string[] and int[] typed getters for filter names and focus offsets (`AlpacaClient.cs`)
99101
- [ ] Parse string[] from Alpaca for `Offsets` (`AlpacaCameraDriver.cs:238`)
100102
- [ ] Parse string[] from Alpaca for `Gains` (`AlpacaCameraDriver.cs:248`)
101103
- [ ] Alpaca `imagearray` endpoint requires special binary handling (`AlpacaCameraDriver.cs:258`)
@@ -154,7 +156,7 @@ Learnings from PixInsight Statistical Stretch (SetiAstro, v2.3).
154156
- [x] Annotation overlay (object names from catalogs when plate-solved)
155157
- [x] Star detection overlay: `FitsDocument.DetectStarsAsync()` runs as background task,
156158
draws HFD-sized green circles, shows count/HFR/FWHM in status bar (S key toggle)
157-
- [ ] Clip star overlay circles to image viewport (currently drawn over toolbar, file list, and info panel)
159+
- [x] Clip star overlay circles to image viewport + fix centroid alignment (+0.5px offset)
158160
- [ ] Remember last opened folder and recent images across sessions
159161
- [ ] Continuous image advance when holding arrow keys (advance every ~1 second while pressed)
160162
- [ ] Display original bit depth before normalization (e.g. "16-bit" in status bar) when available from FITS header

src/TianWen.Lib/Devices/Alpaca/AlpacaClient.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,30 @@ public async Task<double> GetDoubleAsync(string baseUrl, string deviceType, int
113113
return result.Value;
114114
}
115115

116+
/// <summary>
117+
/// GET a string array property (e.g. filterwheel/filternames).
118+
/// </summary>
119+
public async Task<string[]?> GetStringArrayAsync(string baseUrl, string deviceType, int deviceNumber, string endpoint, CancellationToken cancellationToken = default)
120+
{
121+
var url = BuildGetUrl(baseUrl, deviceType, deviceNumber, endpoint);
122+
using var response = await httpClient.GetAsync(url, cancellationToken);
123+
var result = await DeserializeResponseAsync(response, AlpacaJsonSerializerContext.Default.AlpacaResponseStringArray, cancellationToken);
124+
ThrowOnError(result.ErrorNumber, result.ErrorMessage);
125+
return result.Value;
126+
}
127+
128+
/// <summary>
129+
/// GET an integer array property (e.g. filterwheel/focusoffsets).
130+
/// </summary>
131+
public async Task<int[]?> GetIntArrayAsync(string baseUrl, string deviceType, int deviceNumber, string endpoint, CancellationToken cancellationToken = default)
132+
{
133+
var url = BuildGetUrl(baseUrl, deviceType, deviceNumber, endpoint);
134+
using var response = await httpClient.GetAsync(url, cancellationToken);
135+
var result = await DeserializeResponseAsync(response, AlpacaJsonSerializerContext.Default.AlpacaResponseInt32Array, cancellationToken);
136+
ThrowOnError(result.ErrorNumber, result.ErrorMessage);
137+
return result.Value;
138+
}
139+
116140
/// <summary>
117141
/// PUT (invoke a method) on an Alpaca device endpoint with form-encoded parameters.
118142
/// </summary>

src/TianWen.Lib/Devices/Alpaca/AlpacaFilterWheelDriver.cs

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,44 @@ public async Task BeginMoveAsync(int position, CancellationToken cancellationTok
2525
}
2626
}
2727

28+
private string[]? _names;
29+
private int[]? _focusOffsets;
30+
2831
public IReadOnlyList<InstalledFilter> Filters
2932
{
30-
get => []; // TODO: implement string[] and int[] typed getters for filter names and focus offsets
33+
get
34+
{
35+
// Slot count is authoritative from the Alpaca API (populated on connect)
36+
if (_names is not { Length: > 0 } names)
37+
{
38+
return [];
39+
}
40+
41+
var query = _device.Query;
42+
var offsets = _focusOffsets;
43+
var filters = new List<InstalledFilter>(names.Length);
44+
45+
for (var i = 0; i < names.Length; i++)
46+
{
47+
// URI query params override API values per slot (profile is source of truth)
48+
var uriName = query[DeviceQueryKeyExtensions.FilterKey(i + 1)];
49+
var name = uriName ?? names[i];
50+
var offset = int.TryParse(query[DeviceQueryKeyExtensions.FilterOffsetKey(i + 1)], out var o)
51+
? o
52+
: (offsets is not null && i < offsets.Length ? offsets[i] : 0);
53+
filters.Add(new InstalledFilter(name, offset));
54+
}
55+
56+
return filters;
57+
}
58+
}
59+
60+
/// <summary>
61+
/// Reads filter names and focus offsets from the Alpaca API after connecting.
62+
/// </summary>
63+
internal async Task ReadFilterConfigAsync(CancellationToken cancellationToken)
64+
{
65+
_names = await Client.GetStringArrayAsync(BaseUrl, AlpacaDeviceType, AlpacaDeviceNumber, "names", cancellationToken);
66+
_focusOffsets = await Client.GetIntArrayAsync(BaseUrl, AlpacaDeviceType, AlpacaDeviceNumber, "focusoffsets", cancellationToken);
3167
}
3268
}

src/TianWen.Lib/Devices/Ascom/AscomFilterWheelDriver.cs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,23 @@ public IReadOnlyList<InstalledFilter> Filters
4141
{
4242
get
4343
{
44+
var query = _device.Query;
45+
46+
// Determine slot count: COM driver is authoritative, URI params as fallback
4447
var names = Names;
4548
var offsets = FocusOffsets;
46-
var filters = new List<InstalledFilter>(names.Length);
47-
for (var i = 0; i < names.Length; i++)
49+
var slotCount = names.Length;
50+
51+
var filters = new List<InstalledFilter>(slotCount);
52+
for (var i = 0; i < slotCount; i++)
4853
{
49-
filters.Add(new InstalledFilter(names[i], i < offsets.Length ? offsets[i] : 0));
54+
// URI query params override COM values per slot (profile is source of truth)
55+
var uriName = query[DeviceQueryKeyExtensions.FilterKey(i + 1)];
56+
var name = uriName ?? names[i];
57+
var offset = int.TryParse(query[DeviceQueryKeyExtensions.FilterOffsetKey(i + 1)], out var o)
58+
? o
59+
: (i < offsets.Length ? offsets[i] : 0);
60+
filters.Add(new InstalledFilter(name, offset));
5061
}
5162

5263
return filters;

src/TianWen.Lib/Devices/Fake/FakeFilterWheelDriver.cs

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System;
12
using System.Collections.Generic;
23
using System.Threading;
34
using System.Threading.Tasks;
@@ -6,24 +7,44 @@ namespace TianWen.Lib.Devices.Fake;
67

78
internal sealed class FakeFilterWheelDriver(FakeDevice fakeDevice, IExternal external) : FakePositionBasedDriver(fakeDevice, external), IFilterWheelDriver
89
{
9-
private static readonly IReadOnlyList<InstalledFilter> DefaultFilters =
10+
// Per-device-ID presets (1-based ID, mod 3)
11+
private static readonly IReadOnlyList<InstalledFilter> LrgbFilters =
1012
[
1113
new InstalledFilter("Luminance"),
12-
new InstalledFilter("H-Alpha + OIII", +11),
1314
new InstalledFilter("Red", +20),
1415
new InstalledFilter("Green"),
15-
new InstalledFilter("Blue", -15),
16-
new InstalledFilter("SII", +25),
16+
new InstalledFilter("Blue", -15)
17+
];
18+
19+
private static readonly IReadOnlyList<InstalledFilter> NarrowbandFilters =
20+
[
21+
new InstalledFilter("Luminance"),
1722
new InstalledFilter("H-Alpha", +21),
18-
new InstalledFilter("OIII", -3)
23+
new InstalledFilter("OIII", -3),
24+
new InstalledFilter("SII", +25),
25+
new InstalledFilter("Red", +20),
26+
new InstalledFilter("Green"),
27+
new InstalledFilter("Blue", -15)
1928
];
2029

30+
private static readonly IReadOnlyList<InstalledFilter> SimpleFilters =
31+
[
32+
new InstalledFilter("Luminance"),
33+
new InstalledFilter("Red", +20),
34+
new InstalledFilter("Green")
35+
];
36+
37+
private static readonly IReadOnlyList<IReadOnlyList<InstalledFilter>> Presets = [LrgbFilters, NarrowbandFilters, SimpleFilters];
38+
39+
internal const int MaxSlots = 8;
40+
41+
internal static IReadOnlyList<InstalledFilter> GetDefaultFiltersForId(int id) => Presets[(id - 1) % Presets.Count];
42+
2143
private IReadOnlyList<InstalledFilter>? _filters;
2244

2345
/// <summary>
24-
/// Reads filter names and focus offsets from the device URI query parameters
25-
/// (filter1, offset1, filter2, offset2, ...), same as the ZWO driver.
26-
/// Falls back to a default 8-slot wheel when no query params are present.
46+
/// Slot count is determined by the per-device-ID preset (max 8 slots).
47+
/// URI query params (filter1, offset1, ...) override names and offsets per slot.
2748
/// </summary>
2849
public IReadOnlyList<InstalledFilter> Filters
2950
{
@@ -35,21 +56,18 @@ public IReadOnlyList<InstalledFilter> Filters
3556
}
3657

3758
var query = _fakeDevice.Query;
38-
var filters = new List<InstalledFilter>();
59+
var defaults = GetDefaultFiltersForId(Math.Max(1, FakeCameraDriver.ExtractId(_fakeDevice.DeviceUri)));
60+
var slotCount = defaults.Count;
61+
var filters = new List<InstalledFilter>(slotCount);
3962

40-
for (var i = 1; ; i++)
63+
for (var i = 0; i < slotCount; i++)
4164
{
42-
var name = query[DeviceQueryKeyExtensions.FilterKey(i)];
43-
if (name is null)
44-
{
45-
break;
46-
}
47-
48-
var offset = int.TryParse(query[DeviceQueryKeyExtensions.FilterOffsetKey(i)], out var o) ? o : 0;
65+
var name = query[DeviceQueryKeyExtensions.FilterKey(i + 1)] ?? defaults[i].Filter.Name;
66+
var offset = int.TryParse(query[DeviceQueryKeyExtensions.FilterOffsetKey(i + 1)], out var o) ? o : defaults[i].Position;
4967
filters.Add(new InstalledFilter(name, offset));
5068
}
5169

52-
_filters = filters.Count > 0 ? filters : DefaultFilters;
70+
_filters = filters;
5371
return _filters;
5472
}
5573
}

src/TianWen.Lib/Devices/ManualFilterWheelDriver.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,17 @@ internal sealed class ManualFilterWheelDriver(ManualFilterWheelDevice device, IE
1515
{
1616
private bool _connected;
1717

18-
public IReadOnlyList<InstalledFilter> Filters => [new InstalledFilter(device.InstalledFilter)];
18+
public IReadOnlyList<InstalledFilter> Filters
19+
{
20+
get
21+
{
22+
// Single slot — name and offset from URI query params (profile is source of truth)
23+
var query = device.Query;
24+
var name = query[DeviceQueryKeyExtensions.FilterKey(1)] ?? device.InstalledFilter.Name;
25+
var offset = int.TryParse(query[DeviceQueryKeyExtensions.FilterOffsetKey(1)], out var o) ? o : 0;
26+
return [new InstalledFilter(name, offset)];
27+
}
28+
}
1929

2030
public ValueTask<int> GetPositionAsync(CancellationToken cancellationToken = default)
2131
=> ValueTask.FromResult(0);

src/TianWen.UI.Abstractions/EquipmentActions.cs

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Collections.Generic;
23
using System.Collections.Immutable;
34
using System.Globalization;
45
using System.Threading;
@@ -202,6 +203,124 @@ public static string DeviceLabel(Uri? uri, IDeviceUriRegistry? registry)
202203
return path.Length > 0 ? path : uri.ToString();
203204
}
204205

206+
/// <summary>
207+
/// Reads filter config from a filter wheel URI's query params.
208+
/// Returns the list of installed filters (may be empty if no filter{N} params present).
209+
/// </summary>
210+
public static IReadOnlyList<InstalledFilter> GetFilterConfig(ProfileData data, int otaIndex)
211+
{
212+
if (otaIndex < 0 || otaIndex >= data.OTAs.Length)
213+
{
214+
return [];
215+
}
216+
217+
var fwUri = data.OTAs[otaIndex].FilterWheel;
218+
if (fwUri is null || fwUri == NoneDevice.Instance.DeviceUri)
219+
{
220+
return [];
221+
}
222+
223+
var query = HttpUtility.ParseQueryString(fwUri.Query);
224+
var filters = new List<InstalledFilter>();
225+
226+
for (var i = 1; ; i++)
227+
{
228+
var name = query[DeviceQueryKeyExtensions.FilterKey(i)];
229+
if (name is null)
230+
{
231+
break;
232+
}
233+
234+
var offset = int.TryParse(query[DeviceQueryKeyExtensions.FilterOffsetKey(i)], out var o) ? o : 0;
235+
filters.Add(new InstalledFilter(name, offset));
236+
}
237+
238+
return filters;
239+
}
240+
241+
/// <summary>
242+
/// Returns new ProfileData with the filter wheel URI's query params updated to reflect the given filters.
243+
/// Preserves other query params on the URI.
244+
/// </summary>
245+
public static ProfileData SetFilterConfig(ProfileData data, int otaIndex, IReadOnlyList<InstalledFilter> filters)
246+
{
247+
if (otaIndex < 0 || otaIndex >= data.OTAs.Length)
248+
{
249+
return data;
250+
}
251+
252+
var ota = data.OTAs[otaIndex];
253+
var fwUri = ota.FilterWheel;
254+
if (fwUri is null || fwUri == NoneDevice.Instance.DeviceUri)
255+
{
256+
return data;
257+
}
258+
259+
var query = HttpUtility.ParseQueryString(fwUri.Query);
260+
261+
// Remove existing filter/offset params
262+
for (var i = 1; ; i++)
263+
{
264+
var key = DeviceQueryKeyExtensions.FilterKey(i);
265+
if (query[key] is null)
266+
{
267+
break;
268+
}
269+
query.Remove(key);
270+
query.Remove(DeviceQueryKeyExtensions.FilterOffsetKey(i));
271+
}
272+
273+
// Write new filter/offset params
274+
for (var i = 0; i < filters.Count; i++)
275+
{
276+
query[DeviceQueryKeyExtensions.FilterKey(i + 1)] = filters[i].Filter.Name;
277+
query[DeviceQueryKeyExtensions.FilterOffsetKey(i + 1)] = filters[i].Position.ToString(CultureInfo.InvariantCulture);
278+
}
279+
280+
var builder = new UriBuilder(fwUri) { Query = query.ToString() };
281+
var updatedOta = ota with { FilterWheel = builder.Uri };
282+
return data with { OTAs = data.OTAs.SetItem(otaIndex, updatedOta) };
283+
}
284+
285+
/// <summary>
286+
/// Returns new ProfileData with the OTA at the given index updated with the provided properties.
287+
/// Only non-null parameters are applied.
288+
/// </summary>
289+
public static ProfileData UpdateOTA(
290+
ProfileData data,
291+
int otaIndex,
292+
string? name = null,
293+
int? focalLength = null,
294+
int? aperture = null,
295+
OpticalDesign? opticalDesign = null)
296+
{
297+
if (otaIndex < 0 || otaIndex >= data.OTAs.Length)
298+
{
299+
return data;
300+
}
301+
302+
var ota = data.OTAs[otaIndex];
303+
304+
if (name is not null)
305+
{
306+
ota = ota with { Name = name };
307+
}
308+
if (focalLength is not null)
309+
{
310+
ota = ota with { FocalLength = focalLength.Value };
311+
}
312+
if (aperture is not null)
313+
{
314+
ota = ota with { Aperture = aperture.Value > 0 ? aperture.Value : null };
315+
}
316+
if (opticalDesign is not null)
317+
{
318+
ota = ota with { OpticalDesign = opticalDesign.Value };
319+
}
320+
321+
return data with { OTAs = data.OTAs.SetItem(otaIndex, ota) };
322+
}
323+
205324
/// <summary>
206325
/// Extracts site coordinates from a mount URI, if present.
207326
/// </summary>

src/TianWen.UI.Abstractions/PlannerActions.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -916,7 +916,9 @@ public static void BuildSchedule(
916916
int defaultGain,
917917
int defaultOffset,
918918
TimeSpan defaultSubExposure,
919-
TimeSpan defaultObservationTime)
919+
TimeSpan defaultObservationTime,
920+
IReadOnlyList<InstalledFilter>? availableFilters = null,
921+
OpticalDesign opticalDesign = OpticalDesign.Unknown)
920922
{
921923
if (state.Proposals.Count == 0)
922924
{
@@ -934,7 +936,9 @@ public static void BuildSchedule(
934936
defaultGain,
935937
defaultOffset,
936938
defaultSubExposure,
937-
defaultObservationTime);
939+
defaultObservationTime,
940+
availableFilters: availableFilters,
941+
opticalDesign: opticalDesign);
938942

939943
state.NeedsRedraw = true;
940944
}

0 commit comments

Comments
 (0)