refactor for use as a module

This commit is contained in:
Emil Lerch 2026-01-29 11:36:55 -08:00
parent e30cf103e9
commit 4f5bd5b060
Signed by: lobo
GPG key ID: A7B62D657EF764F8
4 changed files with 413 additions and 359 deletions

View file

@ -4,6 +4,13 @@ pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{}); const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{}); 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(.{ const exe = b.addExecutable(.{
.name = "list-devices", .name = "list-devices",
.root_module = b.createModule(.{ .root_module = b.createModule(.{

12
build.zig.zon Normal file
View file

@ -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",
},
}

View file

@ -1,18 +1,5 @@
const std = @import("std"); const std = @import("std");
const http = std.http; const rinnai = @import("rinnai.zig");
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,
};
/// Credentials loaded from file with managed memory /// Credentials loaded from file with managed memory
const CognitoCredentials = struct { 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 { pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){}; var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit(); defer _ = gpa.deinit();
@ -407,13 +59,12 @@ pub fn main() !void {
try stdout.print("🔐 Authenticating...\n", .{}); try stdout.print("🔐 Authenticating...\n", .{});
try stdout.flush(); try stdout.flush();
const auth = try authenticate( var auth = try rinnai.authenticate(
allocator, allocator,
creds.username, creds.username,
creds.password, creds.password,
); );
defer allocator.free(auth.id_token); defer auth.deinit();
defer allocator.free(auth.user_uuid);
try stdout.print("✓ User UUID: {s}\n\n", .{auth.user_uuid}); try stdout.print("✓ User UUID: {s}\n\n", .{auth.user_uuid});
if (debug_mode) { if (debug_mode) {
@ -423,7 +74,7 @@ pub fn main() !void {
try stdout.print("📱 Fetching devices...\n", .{}); try stdout.print("📱 Fetching devices...\n", .{});
try stdout.flush(); try stdout.flush();
var result = try getDevices( var result = try rinnai.getDevices(
allocator, allocator,
auth.id_token, auth.id_token,
creds.username, creds.username,
@ -464,7 +115,7 @@ pub fn main() !void {
try stdout.print("🔍 Checking recirculation status for {?s}...\n", .{device.device_name}); try stdout.print("🔍 Checking recirculation status for {?s}...\n", .{device.device_name});
try stdout.flush(); 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(); defer status.deinit();
try stdout.print("\n{f}", .{status}); try stdout.print("\n{f}", .{status});
@ -472,13 +123,13 @@ pub fn main() !void {
if (debug_mode) { if (debug_mode) {
try stdout.print("\n=== CURL COMMANDS ===\n", .{}); try stdout.print("\n=== CURL COMMANDS ===\n", .{});
try stdout.print("Reset recirculation:\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 'Authorization: Bearer {s}' \\\n", .{auth.id_token});
try stdout.print(" -H 'Content-Type: application/json' \\\n", .{}); try stdout.print(" -H 'Content-Type: application/json' \\\n", .{});
try stdout.print(" -d '{{\"set_recirculation_enabled\":false}}'\n\n", .{}); try stdout.print(" -d '{{\"set_recirculation_enabled\":false}}'\n\n", .{});
try stdout.print("Start recirculation:\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 'Authorization: Bearer {s}' \\\n", .{auth.id_token});
try stdout.print(" -H 'Content-Type: application/json' \\\n", .{}); try stdout.print(" -H 'Content-Type: application/json' \\\n", .{});
try stdout.print(" -d '{{\"recirculation_duration\":15,\"set_recirculation_enabled\":true}}'\n\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 // The shadow state doesn't seem to update. The react native
// application doesn't wait, it just yolo's here, so // application doesn't wait, it just yolo's here, so
// we'll do the same // we'll do the same
try setRecirculation( try rinnai.setRecirculation(
allocator, allocator,
auth.id_token, auth.id_token,
sid, sid,
@ -514,7 +165,7 @@ pub fn main() !void {
std.Thread.sleep(3 * std.time.ns_per_s); std.Thread.sleep(3 * std.time.ns_per_s);
} }
} }
setRecirculation( rinnai.setRecirculation(
allocator, allocator,
auth.id_token, auth.id_token,
tn, tn,
@ -533,7 +184,7 @@ pub fn main() !void {
for (0..10) |i| { for (0..10) |i| {
std.Thread.sleep((20 / max) * std.time.ns_per_s); std.Thread.sleep((20 / max) * std.time.ns_per_s);
var post_command_state = try getRecirculationStatus( var post_command_state = try rinnai.getRecirculationStatus(
allocator, allocator,
auth.id_token, auth.id_token,
sid, sid,

384
src/rinnai.zig Normal file
View file

@ -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;
}
}