Skip to content

Commit 933bdd1

Browse files
sebgodclaude
andcommitted
Add Equipment tab with profile creation, device discovery, and assignment
Full Equipment/Profile tab for tianwen-gui: - No profile → E tab forced active, sidebar locks P/V/S tabs (dimmed) - "Create Profile" button → text input for name → creates profile - SDL3 text input lifecycle (StartTextInput/StopTextInput/TextInput events) - Profile summary panel (360px left): mount, site, guider, OTA details - Clickable slots → assignment mode → click device in list to assign - Device list (fill right): type badge, display name, assigned checkmark - [Discover] button triggers background device discovery - [+ Add OTA] adds a new telescope to the profile - All assignments auto-save via EquipmentActions (shared with CLI) Also adds EquipmentActions to Abstractions for shared profile manipulation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 09bcf77 commit 933bdd1

File tree

5 files changed

+1252
-6
lines changed

5 files changed

+1252
-6
lines changed
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
using System;
2+
using System.Collections.Immutable;
3+
using System.Globalization;
4+
using System.Threading;
5+
using System.Threading.Tasks;
6+
using System.Web;
7+
using TianWen.Lib.Devices;
8+
using TianWen.Lib.Sequencing;
9+
10+
namespace TianWen.UI.Abstractions;
11+
12+
/// <summary>
13+
/// Pure functions for profile/equipment manipulation. Shared between CLI and GUI.
14+
/// All methods return new ProfileData (immutable record with-expressions).
15+
/// </summary>
16+
public static class EquipmentActions
17+
{
18+
public static async Task<Profile> CreateProfileAsync(string name, IExternal external, CancellationToken ct)
19+
{
20+
var profile = new Profile(Guid.NewGuid(), name, ProfileData.Empty);
21+
await profile.SaveAsync(external, ct);
22+
return profile;
23+
}
24+
25+
public static ProfileData AssignMount(ProfileData data, Uri mountUri)
26+
=> data with { Mount = mountUri };
27+
28+
public static ProfileData AssignGuider(ProfileData data, Uri guiderUri)
29+
=> data with { Guider = guiderUri };
30+
31+
public static ProfileData AssignGuiderCamera(ProfileData data, Uri cameraUri)
32+
=> data with { GuiderCamera = cameraUri };
33+
34+
public static ProfileData AssignGuiderFocuser(ProfileData data, Uri focuserUri)
35+
=> data with { GuiderFocuser = focuserUri };
36+
37+
public static ProfileData SetOagOtaIndex(ProfileData data, int otaIndex)
38+
=> data with { OAG_OTA_Index = otaIndex };
39+
40+
public static ProfileData SetSite(ProfileData data, double lat, double lon, double? elevation = null)
41+
{
42+
var query = HttpUtility.ParseQueryString(data.Mount.Query);
43+
query[DeviceQueryKey.Latitude.Key] = lat.ToString(CultureInfo.InvariantCulture);
44+
query[DeviceQueryKey.Longitude.Key] = lon.ToString(CultureInfo.InvariantCulture);
45+
if (elevation.HasValue)
46+
{
47+
query[DeviceQueryKey.Elevation.Key] = elevation.Value.ToString(CultureInfo.InvariantCulture);
48+
}
49+
var builder = new UriBuilder(data.Mount) { Query = query.ToString() };
50+
return data with { Mount = builder.Uri };
51+
}
52+
53+
public static ProfileData AddOTA(ProfileData data, OTAData ota)
54+
=> data with { OTAs = data.OTAs.Add(ota) };
55+
56+
public static ProfileData RemoveOTA(ProfileData data, int index)
57+
=> index >= 0 && index < data.OTAs.Length
58+
? data with { OTAs = data.OTAs.RemoveAt(index) }
59+
: data;
60+
61+
public static ProfileData AssignDeviceToOTA(ProfileData data, int otaIndex, DeviceType deviceType, Uri deviceUri)
62+
{
63+
if (otaIndex < 0 || otaIndex >= data.OTAs.Length)
64+
{
65+
return data;
66+
}
67+
68+
var ota = data.OTAs[otaIndex];
69+
var updated = deviceType switch
70+
{
71+
DeviceType.Camera => ota with { Camera = deviceUri },
72+
DeviceType.Focuser => ota with { Focuser = deviceUri },
73+
DeviceType.FilterWheel => ota with { FilterWheel = deviceUri },
74+
DeviceType.CoverCalibrator => ota with { Cover = deviceUri },
75+
_ => ota
76+
};
77+
78+
return data with { OTAs = data.OTAs.SetItem(otaIndex, updated) };
79+
}
80+
81+
/// <summary>
82+
/// Checks if a device URI is assigned anywhere in the profile.
83+
/// </summary>
84+
public static bool IsDeviceAssigned(ProfileData data, Uri deviceUri)
85+
{
86+
if (data.Mount == deviceUri || data.Guider == deviceUri)
87+
{
88+
return true;
89+
}
90+
if (data.GuiderCamera == deviceUri || data.GuiderFocuser == deviceUri)
91+
{
92+
return true;
93+
}
94+
95+
foreach (var ota in data.OTAs)
96+
{
97+
if (ota.Camera == deviceUri || ota.Focuser == deviceUri ||
98+
ota.FilterWheel == deviceUri || ota.Cover == deviceUri)
99+
{
100+
return true;
101+
}
102+
}
103+
104+
return false;
105+
}
106+
107+
/// <summary>
108+
/// Returns a human-readable label for a device URI, using the registry if available.
109+
/// </summary>
110+
public static string DeviceLabel(Uri? uri, IDeviceUriRegistry? registry)
111+
{
112+
if (uri is null || uri == NoneDevice.Instance.DeviceUri)
113+
{
114+
return "(none)";
115+
}
116+
117+
if (registry is not null && registry.TryGetDeviceFromUri(uri, out var device))
118+
{
119+
return device.DisplayName;
120+
}
121+
122+
// Fallback: extract from URI path
123+
var path = uri.AbsolutePath.TrimStart('/');
124+
return path.Length > 0 ? path : uri.ToString();
125+
}
126+
127+
/// <summary>
128+
/// Extracts site coordinates from a mount URI, if present.
129+
/// </summary>
130+
public static (double Lat, double Lon, double? Elev)? GetSiteFromMount(Uri mountUri)
131+
{
132+
if (mountUri == NoneDevice.Instance.DeviceUri)
133+
{
134+
return null;
135+
}
136+
137+
var query = HttpUtility.ParseQueryString(mountUri.Query);
138+
var latStr = query[DeviceQueryKey.Latitude.Key];
139+
var lonStr = query[DeviceQueryKey.Longitude.Key];
140+
var elevStr = query[DeviceQueryKey.Elevation.Key];
141+
142+
if (latStr is not null && lonStr is not null &&
143+
double.TryParse(latStr, CultureInfo.InvariantCulture, out var lat) &&
144+
double.TryParse(lonStr, CultureInfo.InvariantCulture, out var lon))
145+
{
146+
double? elev = elevStr is not null && double.TryParse(elevStr, CultureInfo.InvariantCulture, out var e) ? e : null;
147+
return (lat, lon, elev);
148+
}
149+
150+
return null;
151+
}
152+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
using System.Collections.Generic;
2+
using TianWen.Lib.Devices;
3+
using TianWen.UI.Abstractions;
4+
5+
namespace TianWen.UI.Gui;
6+
7+
/// <summary>
8+
/// State for the Equipment/Profile tab.
9+
/// </summary>
10+
public class EquipmentTabState
11+
{
12+
// Profile creation
13+
public bool IsCreatingProfile { get; set; }
14+
public TextInputState ProfileNameInput { get; } = new() { Placeholder = "Enter profile name..." };
15+
16+
// Device discovery
17+
public IReadOnlyList<DeviceBase> DiscoveredDevices { get; set; } = [];
18+
public bool IsDiscovering { get; set; }
19+
20+
// Assignment mode: when non-null, clicking a device in the list assigns it to this slot
21+
public AssignTarget? ActiveAssignment { get; set; }
22+
23+
// Scrolling
24+
public int DeviceScrollOffset { get; set; }
25+
public int ProfileScrollOffset { get; set; }
26+
27+
// Profile list (for multi-profile picker)
28+
public IReadOnlyList<Profile> AllProfiles { get; set; } = [];
29+
}
30+
31+
/// <summary>
32+
/// Identifies a slot in a profile that a device can be assigned to.
33+
/// </summary>
34+
public abstract record AssignTarget
35+
{
36+
/// <summary>A profile-level slot (Mount, Guider, GuiderCamera, GuiderFocuser).</summary>
37+
public sealed record ProfileLevel(string Field) : AssignTarget;
38+
39+
/// <summary>A per-OTA slot (Camera, Focuser, FilterWheel, Cover).</summary>
40+
public sealed record OTALevel(int OtaIndex, string Field) : AssignTarget;
41+
42+
/// <summary>Returns the DeviceType expected for this slot.</summary>
43+
public DeviceType ExpectedDeviceType => this switch
44+
{
45+
ProfileLevel { Field: "Mount" } => DeviceType.Mount,
46+
ProfileLevel { Field: "Guider" } => DeviceType.Guider,
47+
ProfileLevel { Field: "GuiderCamera" } => DeviceType.Camera,
48+
ProfileLevel { Field: "GuiderFocuser" } => DeviceType.Focuser,
49+
OTALevel { Field: "Camera" } => DeviceType.Camera,
50+
OTALevel { Field: "Focuser" } => DeviceType.Focuser,
51+
OTALevel { Field: "FilterWheel" } => DeviceType.FilterWheel,
52+
OTALevel { Field: "Cover" } => DeviceType.CoverCalibrator,
53+
_ => DeviceType.Unknown
54+
};
55+
}

0 commit comments

Comments
 (0)