// Innerscene fixture client (C#) - set color temperature & brightness over Wi-Fi. // // A single-file program (top-level statements, .NET 6+). Run with: // // dotnet run 192.168.1.42 4500 0.5 # 4500 K at 50% // dotnet run 192.168.1.42 4500 0.5 mypass # with a custom password // // or drop the FixtureClient class into a Crestron/Savant/BMS driver project. // // --------------------------------------------------------------------------- // Wi-Fi HTTP authentication // --------------------------------------------------------------------------- // Fixture firmware 2.0.253 added a per-request authentication gate on the Wi-Fi // (LAN) HTTP interface, and it is ON BY DEFAULT. On 2.0.253+ every control/status // request must be signed with an HMAC derived from the fixture password (an // installer can turn it off via "Require Password over Wi-Fi" on the fixture's // developer screen). Firmware older than 2.0.253 has no gate at all. // // This client is backward compatible and needs no configuration: // * Firmware before 2.0.253 (no gate) -> never challenged -> plain HTTP. // * 2.0.253+ with the gate ON (the default) -> the fixture answers 401 // {"auth_required":true}; we transparently run the handshake, derive the // signing key, and retry the request signed. // * 2.0.253+ with the gate OFF (installer disabled it) -> plain HTTP. // // Default password is "innerscene" (the fixture's Wi-Fi setup AP password). // --------------------------------------------------------------------------- using System; using System.IO; using System.Net.Http; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Threading.Tasks; var ip = args.Length > 0 ? args[0] : throw new ArgumentException("Usage: [password]"); var cct = int.Parse(args[1]); var intensity = double.Parse(args[2]); var password = args.Length > 3 ? args[3] : "innerscene"; var fx = new FixtureClient(ip, password); var before = await fx.GetStatusAsync(); Console.WriteLine($"Before : {before.GetProperty("cct")} K {before.GetProperty("lux")} lux"); await fx.SetCctAsync(cct, intensity); Console.WriteLine($"Set : {cct} K {intensity * 100:0}%"); var after = await fx.GetStatusAsync(); Console.WriteLine($"After : {after.GetProperty("cct")} K {after.GetProperty("lux")} lux"); public sealed class FixtureClient { // Context string baked into the firmware key-derivation. Must match exactly. private const string SigContext = "a2-sig-v1"; private readonly HttpClient _http = new(); private readonly string _base; private readonly byte[] _password; private string? _sid; // hex session id, once authenticated private byte[]? _ksig; // 32-byte per-session signing key private int _ctr; // monotonic per-request counter public FixtureClient(string ip, string password = "innerscene") { _base = $"http://{ip}"; _password = Encoding.UTF8.GetBytes(password); } // One HTTP GET, signing only when we hold a session. private async Task RawGetAsync(string path) { var req = new HttpRequestMessage(HttpMethod.Get, _base + path); if (_ksig is not null) { _ctr++; // canonical = METHOD "\n" URI "\n" CTR "\n" CONTENT_LENGTH (0 for GET). // URI is the exact request target, including the query string. var canonical = $"GET\n{path}\n{_ctr}\n0"; var sig = ToHex(HmacSha256(_ksig, Encoding.UTF8.GetBytes(canonical))); req.Headers.Add("X-Sig-Sid", _sid); req.Headers.Add("X-Sig-Ctr", _ctr.ToString()); req.Headers.Add("X-Sig", sig); } return await _http.SendAsync(req); } // The handshake: only runs when the fixture demands authentication. private async Task AuthenticateAsync() { // 1. Ask the fixture for a challenge. var ch = JsonDocument.Parse(await _http.GetStringAsync(_base + "/auth/request")).RootElement; var nonce = FromHex(ch.GetProperty("nonce").GetString()!); var salt = FromHex(ch.GetProperty("salt").GetString()!); var sid = ch.GetProperty("sid").GetString()!; // 2. Prove we know the password: HMAC(password, nonce || salt). var response = ToHex(HmacSha256(_password, Concat(nonce, salt))); var verify = JsonDocument.Parse( await _http.GetStringAsync($"{_base}/auth/verify2?sid={sid}&response={response}")).RootElement; if (!verify.TryGetProperty("a", out var a) || a.GetInt32() != 1) throw new UnauthorizedAccessException( "fixture rejected the password (default is 'innerscene')"); // 3. Derive the per-request signing key both sides now share: // K_sig = HMAC(password, nonce || salt || "a2-sig-v1"). _ksig = HmacSha256(_password, Concat(nonce, salt, Encoding.UTF8.GetBytes(SigContext))); _sid = sid; _ctr = 0; } // GET a path, authenticating on demand and retrying once. public async Task GetAsync(string path) { var r = await RawGetAsync(path); if (r.StatusCode == System.Net.HttpStatusCode.Unauthorized) { _ksig = null; // gate is on (or session expired) await AuthenticateAsync(); r = await RawGetAsync(path); } r.EnsureSuccessStatusCode(); var text = await r.Content.ReadAsStringAsync(); try { return JsonDocument.Parse(text).RootElement.Clone(); } catch (JsonException) { return JsonSerializer.SerializeToElement(text); } } // Set color temperature (Kelvin) and brightness (0-1). Exits schedule. public Task SetCctAsync(int cct, double intensity) => GetAsync($"/setCCT?cct={cct}&i={intensity}"); public Task GetStatusAsync() => GetAsync("/getStatus"); public Task IdentifyAsync() => GetAsync("/identify"); // -- helpers -------------------------------------------------------------- private static byte[] HmacSha256(byte[] key, byte[] data) { using var h = new HMACSHA256(key); return h.ComputeHash(data); } private static string ToHex(byte[] b) => Convert.ToHexString(b).ToLowerInvariant(); private static byte[] FromHex(string s) => Convert.FromHexString(s); private static byte[] Concat(params byte[][] parts) { var ms = new MemoryStream(); foreach (var p in parts) ms.Write(p); return ms.ToArray(); } }