From 4f5bd5b0607f73c9975ca41246fbfd5836cdfb98 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Thu, 29 Jan 2026 11:36:55 -0800 Subject: [PATCH] refactor for use as a module --- build.zig | 7 + build.zig.zon | 12 ++ src/main.zig | 369 ++--------------------------------------------- src/rinnai.zig | 384 +++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 413 insertions(+), 359 deletions(-) create mode 100644 build.zig.zon create mode 100644 src/rinnai.zig diff --git a/build.zig b/build.zig index e3f3ef6..789b123 100644 --- a/build.zig +++ b/build.zig @@ -4,6 +4,13 @@ pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + // Expose rinnai module for external dependencies + _ = b.addModule("rinnai", .{ + .root_source_file = b.path("src/rinnai.zig"), + .target = target, + .optimize = optimize, + }); + const exe = b.addExecutable(.{ .name = "list-devices", .root_module = b.createModule(.{ diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..71d7893 --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,12 @@ +.{ + .name = .controlr, + .version = "0.1.0", + .fingerprint = 0x8deabca9d06691ba, + .minimum_zig_version = "0.15.0", + .dependencies = .{}, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/src/main.zig b/src/main.zig index ff89976..27faf04 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,18 +1,5 @@ const std = @import("std"); -const http = std.http; -const json = std.json; - -const client_id = "5ghq3i6k4p9s7dfu34ckmec91"; -const cognito_url = "https://cognito-idp.us-east-1.amazonaws.com/"; -const appsync_url = "https://s34ox7kri5dsvdr43bfgp6qh6i.appsync-api.us-east-1.amazonaws.com/graphql"; -const api_key = "da2-dm2g4rqvjbaoxcpo4eccs3k5he"; -const shadow_api_url = "https://698suy4zs3.execute-api.us-east-1.amazonaws.com/Prod/thing"; - -/// Authentication result containing ID token and user UUID -const AuthResult = struct { - id_token: []const u8, - user_uuid: []const u8, -}; +const rinnai = @import("rinnai.zig"); /// Credentials loaded from file with managed memory const CognitoCredentials = struct { @@ -50,341 +37,6 @@ fn readCredentials(allocator: std.mem.Allocator) !CognitoCredentials { }; } -/// Authenticates with AWS Cognito and returns ID token and user UUID -fn authenticate(allocator: std.mem.Allocator, username: []const u8, password: []const u8) !AuthResult { - var client = http.Client{ .allocator = allocator }; - defer client.deinit(); - - const body = try std.fmt.allocPrint(allocator, - \\{{"AuthFlow":"USER_PASSWORD_AUTH","ClientId":"{s}","AuthParameters":{{"USERNAME":"{s}","PASSWORD":"{s}"}}}} - , .{ client_id, username, password }); - defer allocator.free(body); - - const uri = try std.Uri.parse(cognito_url); - - var response_buf: [1024 * 1024]u8 = undefined; - var writer = std.Io.Writer.fixed(&response_buf); - const result = try client.fetch(.{ - .location = .{ .uri = uri }, - .method = .POST, - .payload = body, - .response_writer = &writer, - .extra_headers = &.{ - .{ .name = "X-Amz-Target", .value = "AWSCognitoIdentityProviderService.InitiateAuth" }, - .{ .name = "Content-Type", .value = "application/x-amz-json-1.1" }, - }, - }); - - if (result.status != .ok) return error.AuthFailed; - - const response_body = response_buf[0..writer.end]; - - const parsed = try json.parseFromSlice(json.Value, allocator, response_body, .{}); - defer parsed.deinit(); - - const id_token = parsed.value.object.get("AuthenticationResult").?.object.get("IdToken").?.string; - const id_token_copy = try allocator.dupe(u8, id_token); - - var token_it = std.mem.splitScalar(u8, id_token, '.'); - _ = token_it.next(); - const payload_b64 = token_it.next() orelse return error.InvalidToken; - - const decoder = std.base64.url_safe_no_pad.Decoder; - const payload_size = try decoder.calcSizeForSlice(payload_b64); - const payload_buf = try allocator.alloc(u8, payload_size); - defer allocator.free(payload_buf); - try decoder.decode(payload_buf, payload_b64); - - const payload_parsed = try json.parseFromSlice(json.Value, allocator, payload_buf, .{}); - defer payload_parsed.deinit(); - - const user_uuid = payload_parsed.value.object.get("sub").?.string; - const user_uuid_copy = try allocator.dupe(u8, user_uuid); - - return .{ .id_token = id_token_copy, .user_uuid = user_uuid_copy }; -} - -const Device = struct { - id: ?[]const u8, - thing_name: ?[]const u8, - device_name: ?[]const u8, - dsn: ?[]const u8, - model: ?[]const u8, - serial_id: ?[]const u8, -}; -const DeviceList = struct { - json_data: json.Parsed(json.Value), - devices: []Device, - - pub fn deinit(self: *DeviceList) void { - self.json_data.deinit(); - } -}; - -fn stringFromJson(obj: std.json.Value, field: []const u8) ?[]const u8 { - if (obj != .object) return null; - if (obj.object.get(field)) |v| { - if (v != .string) return null; - return v.string; - } - return null; -} -fn parseDeviceJson(parsed: json.Parsed(json.Value)) !DeviceList { - if (parsed.value.object.get("data")) |data| { - if (data.object.get("getUserByEmail")) |user_data| { - if (user_data.object.get("items")) |items| { - if (items.array.items.len > 0) { - if (items.array.items[0].object.get("devices")) |devices_obj| { - if (devices_obj.object.get("items")) |devices_json| { - const device_array = try parsed.arena.allocator().alloc(Device, devices_json.array.items.len); - - for (devices_json.array.items, 0..) |device_json, i| { - const id = stringFromJson(device_json, "id"); - const thing_name = stringFromJson(device_json, "thing_name"); - const device_name = stringFromJson(device_json, "device_name"); - const dsn = stringFromJson(device_json, "dsn"); - const model = stringFromJson(device_json, "model"); - const serial_id = if (device_json.object.get("info")) |info| stringFromJson(info, "serial_id") else null; - - device_array[i] = .{ - .id = id, - .thing_name = thing_name, - .device_name = device_name, - .dsn = dsn, - .model = model, - .serial_id = serial_id, - }; - } - - return .{ - .json_data = parsed, - .devices = device_array, - }; - } - } - } - } - } - } - return error.NoDevicesFound; -} - -/// Fetches device list from AppSync GraphQL API for the given user -fn getDevices(allocator: std.mem.Allocator, id_token: []const u8, username: []const u8) !DeviceList { - var client = http.Client{ .allocator = allocator }; - defer client.deinit(); - - const query = - \\query GetUserByEmail($email: String!) { - \\ getUserByEmail(email: $email) { - \\ items { - \\ id - \\ email - \\ devices { - \\ items { - \\ id - \\ thing_name - \\ device_name - \\ dsn - \\ model - \\ info { - \\ serial_id - \\ } - \\ } - \\ } - \\ } - \\ } - \\} - ; - - // Remove newlines for JSON payload - const query_clean = try std.mem.replaceOwned(u8, allocator, query, "\n", " "); - defer allocator.free(query_clean); - - const body = try std.fmt.allocPrint(allocator, - \\{{"query":"{s}","variables":{{"email":"{s}"}}}} - , .{ query_clean, username }); - defer allocator.free(body); - - const uri = try std.Uri.parse(appsync_url); - - var response_buf: [1024 * 1024]u8 = undefined; - var writer = std.Io.Writer.fixed(&response_buf); - _ = try client.fetch(.{ - .location = .{ .uri = uri }, - .method = .POST, - .payload = body, - .response_writer = &writer, - .extra_headers = &.{ - .{ .name = "Authorization", .value = id_token }, - .{ .name = "x-api-key", .value = api_key }, - .{ .name = "Content-Type", .value = "application/json" }, - }, - }); - - const response_body = response_buf[0..writer.end]; - const parsed = try json.parseFromSlice(json.Value, allocator, response_body, .{}); - return try parseDeviceJson(parsed); -} - -/// Starts recirculation for the specified device with given duration -fn setRecirculation(allocator: std.mem.Allocator, id_token: []const u8, thing_name: []const u8, duration_minutes: ?u32) !void { - var client = http.Client{ .allocator = allocator }; - defer client.deinit(); - - const url = try std.fmt.allocPrint(allocator, - \\{s}/{s}/shadow - , .{ shadow_api_url, thing_name }); - defer allocator.free(url); - - const body = if (duration_minutes) |min| - try std.fmt.allocPrint(allocator, - \\{{"recirculation_duration":{d},"set_recirculation_enabled":true}} - , .{min}) - else - try allocator.dupe(u8, - \\{"set_recirculation_enabled":false} - ); - defer allocator.free(body); - - // std.debug.print("DEBUG: PATCH URL: {s}\n", .{url}); - // std.debug.print("DEBUG: PATCH Body: {s}\n", .{body}); - - const uri = try std.Uri.parse(url); - const auth_header = try std.fmt.allocPrint(allocator, - \\Bearer {s} - , .{id_token}); - defer allocator.free(auth_header); - - var response_buf: [4096]u8 = undefined; - var writer = std.Io.Writer.fixed(&response_buf); - const result = try client.fetch(.{ - .location = .{ .uri = uri }, - .method = .PATCH, - .payload = body, - .response_writer = &writer, - .extra_headers = &.{ - .{ .name = "Authorization", .value = auth_header }, - .{ .name = "Content-Type", .value = "application/json" }, - }, - }); - - const response_body = response_buf[0..writer.end]; - // std.debug.print("DEBUG: PATCH Response Status: {}\n", .{result.status}); - // std.debug.print("DEBUG: PATCH Response Body: {s}\n", .{response_body}); - - if (result.status != .ok) { - std.debug.print("PATCH failed - Status: {}, Body: {s}\n", .{ result.status, response_body }); - return error.StartRecirculationFailed; - } -} - -const DeviceShadow = struct { - json_data: json.Parsed(json.Value), - heater_serial_number: ?[]const u8, - set_recirculation_enabled: ?bool, - recirculation_enabled: ?bool, - recirculation_duration: ?i64, - set_domestic_temperature: ?i64, - operation_enabled: ?bool, - - pub fn deinit(self: *DeviceShadow) void { - self.json_data.deinit(); - } - - pub fn format(self: DeviceShadow, writer: *std.Io.Writer) std.Io.Writer.Error!void { - try writer.writeAll("Current Shadow State:\n"); - try writer.print(" heater_serial_number: {?s}\n", .{self.heater_serial_number}); - try writer.print(" set_recirculation_enabled: {?}\n", .{self.set_recirculation_enabled}); - try writer.print(" recirculation_enabled: {?}\n", .{self.recirculation_enabled}); - try writer.print(" recirculation_duration: {?}\n", .{self.recirculation_duration}); - try writer.print(" set_domestic_temperature: {?}\n", .{self.set_domestic_temperature}); - try writer.print(" operation_enabled: {?}\n", .{self.operation_enabled}); - } -}; - -fn parseDeviceShadow(parsed: json.Parsed(json.Value)) !DeviceShadow { - if (parsed.value.object.get("data")) |data| { - if (data.object.get("getDeviceShadow")) |shadow| { - const heater_serial_number = stringFromJson(shadow, "heater_serial_number"); - const set_recirculation_enabled = if (shadow.object.get("set_recirculation_enabled")) |v| if (v == .bool) v.bool else null else null; - const recirculation_enabled = if (shadow.object.get("recirculation_enabled")) |v| if (v == .bool) v.bool else null else null; - const recirculation_duration = if (shadow.object.get("recirculation_duration")) |v| if (v == .integer) v.integer else null else null; - const set_domestic_temperature = if (shadow.object.get("set_domestic_temperature")) |v| if (v == .integer) v.integer else null else null; - const operation_enabled = if (shadow.object.get("operation_enabled")) |v| if (v == .bool) v.bool else null else null; - - return .{ - .json_data = parsed, - .heater_serial_number = heater_serial_number, - .set_recirculation_enabled = set_recirculation_enabled, - .recirculation_enabled = recirculation_enabled, - .recirculation_duration = recirculation_duration, - .set_domestic_temperature = set_domestic_temperature, - .operation_enabled = operation_enabled, - }; - } - } - return error.NoShadowFound; -} - -fn removeNewlines(comptime input: []const u8) []const u8 { - comptime { - var result: [input.len]u8 = undefined; - var i: usize = 0; - for (input) |c| { - result[i] = if (c == '\n') ' ' else c; - i += 1; - } - const final = result; - return &final; - } -} - -/// Queries the device shadow to get current recirculation status -fn getRecirculationStatus(allocator: std.mem.Allocator, id_token: []const u8, serial_number: []const u8) !DeviceShadow { - var client = http.Client{ .allocator = allocator }; - defer client.deinit(); - - const query = - \\query GetDeviceShadow($heater_serial_number: ID!) { - \\ getDeviceShadow(heater_serial_number: $heater_serial_number) { - \\ heater_serial_number - \\ set_recirculation_enabled - \\ recirculation_enabled - \\ recirculation_duration - \\ set_domestic_temperature - \\ operation_enabled - \\ } - \\} - ; - - const query_clean = comptime removeNewlines(query); - const body = try std.fmt.allocPrint(allocator, - \\{{"query":"{s}","variables":{{"heater_serial_number":"{s}"}}}} - , .{ query_clean, serial_number }); - defer allocator.free(body); - - const uri = try std.Uri.parse(appsync_url); - - var response_buf: [1024 * 1024]u8 = undefined; - var writer = std.Io.Writer.fixed(&response_buf); - _ = try client.fetch(.{ - .location = .{ .uri = uri }, - .method = .POST, - .payload = body, - .response_writer = &writer, - .extra_headers = &.{ - .{ .name = "Authorization", .value = id_token }, - .{ .name = "x-api-key", .value = api_key }, - .{ .name = "Content-Type", .value = "application/json" }, - }, - }); - - const response_body = response_buf[0..writer.end]; - const parsed = try json.parseFromSlice(json.Value, allocator, response_body, .{}); - return try parseDeviceShadow(parsed); -} - pub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; defer _ = gpa.deinit(); @@ -407,13 +59,12 @@ pub fn main() !void { try stdout.print("🔐 Authenticating...\n", .{}); try stdout.flush(); - const auth = try authenticate( + var auth = try rinnai.authenticate( allocator, creds.username, creds.password, ); - defer allocator.free(auth.id_token); - defer allocator.free(auth.user_uuid); + defer auth.deinit(); try stdout.print("✓ User UUID: {s}\n\n", .{auth.user_uuid}); if (debug_mode) { @@ -423,7 +74,7 @@ pub fn main() !void { try stdout.print("📱 Fetching devices...\n", .{}); try stdout.flush(); - var result = try getDevices( + var result = try rinnai.getDevices( allocator, auth.id_token, creds.username, @@ -464,7 +115,7 @@ pub fn main() !void { try stdout.print("🔍 Checking recirculation status for {?s}...\n", .{device.device_name}); try stdout.flush(); - var status = try getRecirculationStatus(allocator, auth.id_token, sid); + var status = try rinnai.getRecirculationStatus(allocator, auth.id_token, sid); defer status.deinit(); try stdout.print("\n{f}", .{status}); @@ -472,13 +123,13 @@ pub fn main() !void { if (debug_mode) { try stdout.print("\n=== CURL COMMANDS ===\n", .{}); try stdout.print("Reset recirculation:\n", .{}); - try stdout.print("curl -X PATCH '{s}/{s}/shadow' \\\n", .{ shadow_api_url, tn }); + try stdout.print("curl -X PATCH '{s}/{s}/shadow' \\\n", .{ rinnai.shadow_api_url, tn }); try stdout.print(" -H 'Authorization: Bearer {s}' \\\n", .{auth.id_token}); try stdout.print(" -H 'Content-Type: application/json' \\\n", .{}); try stdout.print(" -d '{{\"set_recirculation_enabled\":false}}'\n\n", .{}); try stdout.print("Start recirculation:\n", .{}); - try stdout.print("curl -X PATCH '{s}/{s}/shadow' \\\n", .{ shadow_api_url, tn }); + try stdout.print("curl -X PATCH '{s}/{s}/shadow' \\\n", .{ rinnai.shadow_api_url, tn }); try stdout.print(" -H 'Authorization: Bearer {s}' \\\n", .{auth.id_token}); try stdout.print(" -H 'Content-Type: application/json' \\\n", .{}); try stdout.print(" -d '{{\"recirculation_duration\":15,\"set_recirculation_enabled\":true}}'\n\n", .{}); @@ -502,7 +153,7 @@ pub fn main() !void { // The shadow state doesn't seem to update. The react native // application doesn't wait, it just yolo's here, so // we'll do the same - try setRecirculation( + try rinnai.setRecirculation( allocator, auth.id_token, sid, @@ -514,7 +165,7 @@ pub fn main() !void { std.Thread.sleep(3 * std.time.ns_per_s); } } - setRecirculation( + rinnai.setRecirculation( allocator, auth.id_token, tn, @@ -533,7 +184,7 @@ pub fn main() !void { for (0..10) |i| { std.Thread.sleep((20 / max) * std.time.ns_per_s); - var post_command_state = try getRecirculationStatus( + var post_command_state = try rinnai.getRecirculationStatus( allocator, auth.id_token, sid, diff --git a/src/rinnai.zig b/src/rinnai.zig new file mode 100644 index 0000000..cb790af --- /dev/null +++ b/src/rinnai.zig @@ -0,0 +1,384 @@ +//! Rinnai API client for controlling water heaters via AWS Cognito, AppSync, and Shadow APIs. + +const std = @import("std"); +const http = std.http; +const json = std.json; + +// Rinnai API Configuration +const client_id = "5ghq3i6k4p9s7dfu34ckmec91"; +const cognito_url = "https://cognito-idp.us-east-1.amazonaws.com/"; +const appsync_url = "https://s34ox7kri5dsvdr43bfgp6qh6i.appsync-api.us-east-1.amazonaws.com/graphql"; +const api_key = "da2-dm2g4rqvjbaoxcpo4eccs3k5he"; +pub const shadow_api_url = "https://698suy4zs3.execute-api.us-east-1.amazonaws.com/Prod/thing"; + +const log = std.log.scoped(.rinnai); + +// ============================================================================ +// Types +// ============================================================================ + +/// Authentication result containing ID token and user UUID +pub const AuthResult = struct { + allocator: std.mem.Allocator, + id_token: []const u8, + user_uuid: []const u8, + + pub fn deinit(self: *AuthResult) void { + self.allocator.free(self.id_token); + self.allocator.free(self.user_uuid); + } +}; + +/// Device information from the Rinnai API +pub const Device = struct { + id: ?[]const u8, + thing_name: ?[]const u8, + device_name: ?[]const u8, + dsn: ?[]const u8, + model: ?[]const u8, + serial_id: ?[]const u8, +}; + +/// List of devices with managed JSON memory +pub const DeviceList = struct { + json_data: json.Parsed(json.Value), + devices: []Device, + + pub fn deinit(self: *DeviceList) void { + self.json_data.deinit(); + } +}; + +/// Device shadow state from the Rinnai API +pub const DeviceShadow = struct { + json_data: json.Parsed(json.Value), + heater_serial_number: ?[]const u8, + set_recirculation_enabled: ?bool, + recirculation_enabled: ?bool, + recirculation_duration: ?i64, + set_domestic_temperature: ?i64, + operation_enabled: ?bool, + + pub fn deinit(self: *DeviceShadow) void { + self.json_data.deinit(); + } + + pub fn format(self: DeviceShadow, writer: *std.Io.Writer) std.Io.Writer.Error!void { + try writer.writeAll("Current Shadow State:\n"); + try writer.print(" heater_serial_number: {?s}\n", .{self.heater_serial_number}); + try writer.print(" set_recirculation_enabled: {?}\n", .{self.set_recirculation_enabled}); + try writer.print(" recirculation_enabled: {?}\n", .{self.recirculation_enabled}); + try writer.print(" recirculation_duration: {?}\n", .{self.recirculation_duration}); + try writer.print(" set_domestic_temperature: {?}\n", .{self.set_domestic_temperature}); + try writer.print(" operation_enabled: {?}\n", .{self.operation_enabled}); + } +}; + +// ============================================================================ +// Public API +// ============================================================================ + +/// Authenticates with AWS Cognito and returns ID token and user UUID. +/// Caller must call `deinit()` on the returned AuthResult when done. +pub fn authenticate(allocator: std.mem.Allocator, username: []const u8, password: []const u8) !AuthResult { + var client = http.Client{ .allocator = allocator }; + defer client.deinit(); + + const body = try std.fmt.allocPrint(allocator, + \\{{"AuthFlow":"USER_PASSWORD_AUTH","ClientId":"{s}","AuthParameters":{{"USERNAME":"{s}","PASSWORD":"{s}"}}}} + , .{ client_id, username, password }); + defer allocator.free(body); + + const uri = try std.Uri.parse(cognito_url); + + var response_buf: [1024 * 1024]u8 = undefined; + var writer = std.Io.Writer.fixed(&response_buf); + const result = try client.fetch(.{ + .location = .{ .uri = uri }, + .method = .POST, + .payload = body, + .response_writer = &writer, + .extra_headers = &.{ + .{ .name = "X-Amz-Target", .value = "AWSCognitoIdentityProviderService.InitiateAuth" }, + .{ .name = "Content-Type", .value = "application/x-amz-json-1.1" }, + }, + }); + + if (result.status != .ok) { + log.err("Cognito auth failed with status: {}", .{result.status}); + return error.AuthFailed; + } + + const response_body = response_buf[0..writer.end]; + + const parsed = try json.parseFromSlice(json.Value, allocator, response_body, .{}); + defer parsed.deinit(); + + const auth_result = parsed.value.object.get("AuthenticationResult") orelse return error.AuthFailed; + const id_token = auth_result.object.get("IdToken") orelse return error.AuthFailed; + const id_token_str = if (id_token == .string) id_token.string else return error.AuthFailed; + const id_token_copy = try allocator.dupe(u8, id_token_str); + errdefer allocator.free(id_token_copy); + + // Parse JWT to get user UUID + var token_it = std.mem.splitScalar(u8, id_token_str, '.'); + _ = token_it.next(); + const payload_b64 = token_it.next() orelse return error.InvalidToken; + + const decoder = std.base64.url_safe_no_pad.Decoder; + const payload_size = try decoder.calcSizeForSlice(payload_b64); + const payload_buf = try allocator.alloc(u8, payload_size); + defer allocator.free(payload_buf); + try decoder.decode(payload_buf, payload_b64); + + const payload_parsed = try json.parseFromSlice(json.Value, allocator, payload_buf, .{}); + defer payload_parsed.deinit(); + + const sub = payload_parsed.value.object.get("sub") orelse return error.InvalidToken; + const user_uuid = if (sub == .string) sub.string else return error.InvalidToken; + const user_uuid_copy = try allocator.dupe(u8, user_uuid); + + return .{ + .allocator = allocator, + .id_token = id_token_copy, + .user_uuid = user_uuid_copy, + }; +} + +/// Fetches device list from AppSync GraphQL API for the given user. +/// Caller must call `deinit()` on the returned DeviceList when done. +pub fn getDevices(allocator: std.mem.Allocator, id_token: []const u8, username: []const u8) !DeviceList { + var client = http.Client{ .allocator = allocator }; + defer client.deinit(); + + const query = + \\query GetUserByEmail($email: String!) { + \\ getUserByEmail(email: $email) { + \\ items { + \\ id + \\ email + \\ devices { + \\ items { + \\ id + \\ thing_name + \\ device_name + \\ dsn + \\ model + \\ info { + \\ serial_id + \\ } + \\ } + \\ } + \\ } + \\ } + \\} + ; + + // Remove newlines for JSON payload + const query_clean = try std.mem.replaceOwned(u8, allocator, query, "\n", " "); + defer allocator.free(query_clean); + + const body = try std.fmt.allocPrint(allocator, + \\{{"query":"{s}","variables":{{"email":"{s}"}}}} + , .{ query_clean, username }); + defer allocator.free(body); + + const uri = try std.Uri.parse(appsync_url); + + var response_buf: [1024 * 1024]u8 = undefined; + var writer = std.Io.Writer.fixed(&response_buf); + _ = try client.fetch(.{ + .location = .{ .uri = uri }, + .method = .POST, + .payload = body, + .response_writer = &writer, + .extra_headers = &.{ + .{ .name = "Authorization", .value = id_token }, + .{ .name = "x-api-key", .value = api_key }, + .{ .name = "Content-Type", .value = "application/json" }, + }, + }); + + const response_body = response_buf[0..writer.end]; + const parsed = try json.parseFromSlice(json.Value, allocator, response_body, .{}); + return try parseDeviceJson(parsed); +} + +/// Starts or stops recirculation for the specified device. +/// Pass duration_minutes to start, or null to stop. +pub fn setRecirculation(allocator: std.mem.Allocator, id_token: []const u8, thing_name: []const u8, duration_minutes: ?u32) !void { + var client = http.Client{ .allocator = allocator }; + defer client.deinit(); + + const url = try std.fmt.allocPrint(allocator, "{s}/{s}/shadow", .{ shadow_api_url, thing_name }); + defer allocator.free(url); + + const body = if (duration_minutes) |min| + try std.fmt.allocPrint(allocator, + \\{{"recirculation_duration":{d},"set_recirculation_enabled":true}} + , .{min}) + else + try allocator.dupe(u8, + \\{"set_recirculation_enabled":false} + ); + defer allocator.free(body); + + const uri = try std.Uri.parse(url); + const auth_header = try std.fmt.allocPrint(allocator, "Bearer {s}", .{id_token}); + defer allocator.free(auth_header); + + var response_buf: [4096]u8 = undefined; + var writer = std.Io.Writer.fixed(&response_buf); + const result = try client.fetch(.{ + .location = .{ .uri = uri }, + .method = .PATCH, + .payload = body, + .response_writer = &writer, + .extra_headers = &.{ + .{ .name = "Authorization", .value = auth_header }, + .{ .name = "Content-Type", .value = "application/json" }, + }, + }); + + if (result.status != .ok) { + const response_body = response_buf[0..writer.end]; + log.err("PATCH failed - Status: {}, Body: {s}", .{ result.status, response_body }); + return error.RecirculationFailed; + } +} + +/// Queries the device shadow to get current recirculation status. +/// Caller must call `deinit()` on the returned DeviceShadow when done. +pub fn getRecirculationStatus(allocator: std.mem.Allocator, id_token: []const u8, serial_number: []const u8) !DeviceShadow { + var client = http.Client{ .allocator = allocator }; + defer client.deinit(); + + const query = + \\query GetDeviceShadow($heater_serial_number: ID!) { + \\ getDeviceShadow(heater_serial_number: $heater_serial_number) { + \\ heater_serial_number + \\ set_recirculation_enabled + \\ recirculation_enabled + \\ recirculation_duration + \\ set_domestic_temperature + \\ operation_enabled + \\ } + \\} + ; + + const query_clean = comptime removeNewlines(query); + const body = try std.fmt.allocPrint(allocator, + \\{{"query":"{s}","variables":{{"heater_serial_number":"{s}"}}}} + , .{ query_clean, serial_number }); + defer allocator.free(body); + + const uri = try std.Uri.parse(appsync_url); + + var response_buf: [1024 * 1024]u8 = undefined; + var writer = std.Io.Writer.fixed(&response_buf); + _ = try client.fetch(.{ + .location = .{ .uri = uri }, + .method = .POST, + .payload = body, + .response_writer = &writer, + .extra_headers = &.{ + .{ .name = "Authorization", .value = id_token }, + .{ .name = "x-api-key", .value = api_key }, + .{ .name = "Content-Type", .value = "application/json" }, + }, + }); + + const response_body = response_buf[0..writer.end]; + const parsed = try json.parseFromSlice(json.Value, allocator, response_body, .{}); + return try parseDeviceShadow(parsed); +} + +// ============================================================================ +// Internal helpers +// ============================================================================ + +fn stringFromJson(obj: std.json.Value, field: []const u8) ?[]const u8 { + if (obj != .object) return null; + if (obj.object.get(field)) |v| { + if (v != .string) return null; + return v.string; + } + return null; +} + +fn parseDeviceJson(parsed: json.Parsed(json.Value)) !DeviceList { + if (parsed.value.object.get("data")) |data| { + if (data.object.get("getUserByEmail")) |user_data| { + if (user_data.object.get("items")) |items| { + if (items.array.items.len > 0) { + if (items.array.items[0].object.get("devices")) |devices_obj| { + if (devices_obj.object.get("items")) |devices_json| { + const device_array = try parsed.arena.allocator().alloc(Device, devices_json.array.items.len); + + for (devices_json.array.items, 0..) |device_json, i| { + const id = stringFromJson(device_json, "id"); + const thing_name = stringFromJson(device_json, "thing_name"); + const device_name = stringFromJson(device_json, "device_name"); + const dsn = stringFromJson(device_json, "dsn"); + const model = stringFromJson(device_json, "model"); + const serial_id = if (device_json.object.get("info")) |info| stringFromJson(info, "serial_id") else null; + + device_array[i] = .{ + .id = id, + .thing_name = thing_name, + .device_name = device_name, + .dsn = dsn, + .model = model, + .serial_id = serial_id, + }; + } + + return .{ + .json_data = parsed, + .devices = device_array, + }; + } + } + } + } + } + } + return error.NoDevicesFound; +} + +fn parseDeviceShadow(parsed: json.Parsed(json.Value)) !DeviceShadow { + if (parsed.value.object.get("data")) |data| { + if (data.object.get("getDeviceShadow")) |shadow| { + const heater_serial_number = stringFromJson(shadow, "heater_serial_number"); + const set_recirculation_enabled = if (shadow.object.get("set_recirculation_enabled")) |v| if (v == .bool) v.bool else null else null; + const recirculation_enabled = if (shadow.object.get("recirculation_enabled")) |v| if (v == .bool) v.bool else null else null; + const recirculation_duration = if (shadow.object.get("recirculation_duration")) |v| if (v == .integer) v.integer else null else null; + const set_domestic_temperature = if (shadow.object.get("set_domestic_temperature")) |v| if (v == .integer) v.integer else null else null; + const operation_enabled = if (shadow.object.get("operation_enabled")) |v| if (v == .bool) v.bool else null else null; + + return .{ + .json_data = parsed, + .heater_serial_number = heater_serial_number, + .set_recirculation_enabled = set_recirculation_enabled, + .recirculation_enabled = recirculation_enabled, + .recirculation_duration = recirculation_duration, + .set_domestic_temperature = set_domestic_temperature, + .operation_enabled = operation_enabled, + }; + } + } + return error.NoShadowFound; +} + +fn removeNewlines(comptime input: []const u8) []const u8 { + comptime { + var result: [input.len]u8 = undefined; + var i: usize = 0; + for (input) |c| { + result[i] = if (c == '\n') ' ' else c; + i += 1; + } + const final = result; + return &final; + } +}