Compare commits
10 commits
cba06ba30e
...
4f5bd5b060
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f5bd5b060 | |||
| e30cf103e9 | |||
| 707998df47 | |||
| 4661ce2354 | |||
| 4c54ef29e3 | |||
| 100d49b4c7 | |||
| 802b9e766b | |||
| afc81e3b95 | |||
| 82babf0ce6 | |||
| 750d931ffe |
5 changed files with 519 additions and 323 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
||||||
zig-out
|
zig-out
|
||||||
.zig-cache/
|
.zig-cache/
|
||||||
.credentials
|
.credentials
|
||||||
|
.mise.toml
|
||||||
|
|
|
||||||
|
|
@ -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
12
build.zig.zon
Normal 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",
|
||||||
|
},
|
||||||
|
}
|
||||||
430
src/main.zig
430
src/main.zig
|
|
@ -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,279 +37,15 @@ 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 startRecirculation(allocator: std.mem.Allocator, id_token: []const u8, serial_number: []const u8, duration_minutes: u32) !bool {
|
|
||||||
var client = http.Client{ .allocator = allocator };
|
|
||||||
defer client.deinit();
|
|
||||||
|
|
||||||
const url = try std.fmt.allocPrint(allocator,
|
|
||||||
\\{s}/{s}/shadow
|
|
||||||
, .{ shadow_api_url, serial_number });
|
|
||||||
defer allocator.free(url);
|
|
||||||
|
|
||||||
const body = try std.fmt.allocPrint(allocator,
|
|
||||||
\\{{"recirculation_duration":{d},"set_recirculation_enabled":true}}
|
|
||||||
, .{duration_minutes});
|
|
||||||
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);
|
|
||||||
|
|
||||||
const result = try client.fetch(.{
|
|
||||||
.location = .{ .uri = uri },
|
|
||||||
.method = .PATCH,
|
|
||||||
.payload = body,
|
|
||||||
.extra_headers = &.{
|
|
||||||
.{ .name = "Authorization", .value = auth_header },
|
|
||||||
.{ .name = "Content-Type", .value = "application/json" },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return result.status == .ok;
|
|
||||||
}
|
|
||||||
|
|
||||||
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) !json.Parsed(json.Value) {
|
|
||||||
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];
|
|
||||||
return try json.parseFromSlice(json.Value, allocator, response_body, .{});
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn main() !void {
|
pub fn main() !void {
|
||||||
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
|
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
|
||||||
defer _ = gpa.deinit();
|
defer _ = gpa.deinit();
|
||||||
const allocator = gpa.allocator();
|
const allocator = gpa.allocator();
|
||||||
|
|
||||||
|
const args = try std.process.argsAlloc(allocator);
|
||||||
|
defer std.process.argsFree(allocator, args);
|
||||||
|
const debug_mode = args.len > 1 and std.mem.eql(u8, args[1], "--debug");
|
||||||
|
|
||||||
var stdout_buffer: [1024]u8 = undefined;
|
var stdout_buffer: [1024]u8 = undefined;
|
||||||
var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
|
var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
|
||||||
const stdout = &stdout_writer.interface;
|
const stdout = &stdout_writer.interface;
|
||||||
|
|
@ -336,17 +59,22 @@ 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) {
|
||||||
|
try stdout.print("\n=== DEBUG MODE ===\n", .{});
|
||||||
|
try stdout.print("IdToken:\n{s}\n\n", .{auth.id_token});
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
|
@ -364,55 +92,119 @@ pub fn main() !void {
|
||||||
}
|
}
|
||||||
try stdout.flush();
|
try stdout.flush();
|
||||||
|
|
||||||
if (result.devices.len > 0) {
|
switch (result.devices.len) {
|
||||||
|
0 => {
|
||||||
|
try stderr.print("❌ No devices found for user\n", .{});
|
||||||
|
try stderr.flush();
|
||||||
|
return error.NoDevicesFound;
|
||||||
|
},
|
||||||
|
1 => {
|
||||||
const device = result.devices[0];
|
const device = result.devices[0];
|
||||||
|
|
||||||
if (device.serial_id) |sid| {
|
if (device.serial_id == null or
|
||||||
|
device.thing_name == null)
|
||||||
|
{
|
||||||
|
try stderr.print("❌ thing_name and serial_id both required\n", .{});
|
||||||
|
try stderr.flush();
|
||||||
|
return error.SerialIdAndThingNameRequired;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sid = device.serial_id.?;
|
||||||
|
const tn = device.thing_name.?;
|
||||||
|
|
||||||
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();
|
||||||
|
|
||||||
const status = try getRecirculationStatus(allocator, auth.id_token, sid);
|
var status = try rinnai.getRecirculationStatus(allocator, auth.id_token, sid);
|
||||||
defer status.deinit();
|
defer status.deinit();
|
||||||
|
|
||||||
if (status.value.object.get("data")) |status_data| {
|
try stdout.print("\n{f}", .{status});
|
||||||
if (status_data.object.get("getDeviceShadow")) |shadow| {
|
|
||||||
try stdout.print("\nCurrent Shadow State:\n", .{});
|
|
||||||
|
|
||||||
if (shadow.object.get("heater_serial_number")) |v| {
|
if (debug_mode) {
|
||||||
if (v == .string) try stdout.print(" heater_serial_number: {s}\n", .{v.string});
|
try stdout.print("\n=== CURL COMMANDS ===\n", .{});
|
||||||
}
|
try stdout.print("Reset recirculation:\n", .{});
|
||||||
if (shadow.object.get("set_recirculation_enabled")) |v| {
|
try stdout.print("curl -X PATCH '{s}/{s}/shadow' \\\n", .{ rinnai.shadow_api_url, tn });
|
||||||
if (v == .bool) try stdout.print(" set_recirculation_enabled: {}\n", .{v.bool});
|
try stdout.print(" -H 'Authorization: Bearer {s}' \\\n", .{auth.id_token});
|
||||||
}
|
try stdout.print(" -H 'Content-Type: application/json' \\\n", .{});
|
||||||
if (shadow.object.get("recirculation_enabled")) |v| {
|
try stdout.print(" -d '{{\"set_recirculation_enabled\":false}}'\n\n", .{});
|
||||||
if (v == .bool) try stdout.print(" recirculation_enabled: {}\n", .{v.bool});
|
|
||||||
}
|
try stdout.print("Start recirculation:\n", .{});
|
||||||
if (shadow.object.get("recirculation_duration")) |v| {
|
try stdout.print("curl -X PATCH '{s}/{s}/shadow' \\\n", .{ rinnai.shadow_api_url, tn });
|
||||||
if (v == .integer) try stdout.print(" recirculation_duration: {}\n", .{v.integer});
|
try stdout.print(" -H 'Authorization: Bearer {s}' \\\n", .{auth.id_token});
|
||||||
}
|
try stdout.print(" -H 'Content-Type: application/json' \\\n", .{});
|
||||||
if (shadow.object.get("set_domestic_temperature")) |v| {
|
try stdout.print(" -d '{{\"recirculation_duration\":15,\"set_recirculation_enabled\":true}}'\n\n", .{});
|
||||||
if (v == .integer) try stdout.print(" set_domestic_temperature: {}\n", .{v.integer});
|
try stdout.flush();
|
||||||
}
|
return;
|
||||||
if (shadow.object.get("operation_enabled")) |v| {
|
|
||||||
if (v == .bool) try stdout.print(" operation_enabled: {}\n", .{v.bool});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const recirc_enabled = if (shadow.object.get("recirculation_enabled")) |re| if (re == .bool) re.bool else false else false;
|
const recirc_enabled = status.recirculation_enabled orelse false;
|
||||||
|
|
||||||
if (recirc_enabled) {
|
if (recirc_enabled) {
|
||||||
try stdout.print("\n✓ Recirculation is already active\n", .{});
|
try stdout.print("\n✓ Recirculation is already active\n", .{});
|
||||||
// Recirculation code commented out as requested
|
|
||||||
} else {
|
} else {
|
||||||
// Recirculation code would go here but not called during testing
|
try stdout.print("\n🚿 Starting 15-minute recirculation...\n", .{});
|
||||||
try stdout.print("\n(Recirculation start function available but not called during testing)\n", .{});
|
try stdout.flush();
|
||||||
|
|
||||||
|
if (status.set_recirculation_enabled) |en| {
|
||||||
|
if (en) {
|
||||||
|
try stdout.print("⚠️ set_recirculation_enabled is already true, resetting first...\n", .{});
|
||||||
|
try stdout.flush();
|
||||||
|
|
||||||
|
// 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 rinnai.setRecirculation(
|
||||||
|
allocator,
|
||||||
|
auth.id_token,
|
||||||
|
sid,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
try stdout.print("⏳ Waiting 3 seconds...\n", .{});
|
||||||
|
try stdout.flush();
|
||||||
|
std.Thread.sleep(3 * std.time.ns_per_s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rinnai.setRecirculation(
|
||||||
|
allocator,
|
||||||
|
auth.id_token,
|
||||||
|
tn,
|
||||||
|
15,
|
||||||
|
) catch |e| {
|
||||||
|
try stderr.print("❌ Failed to start recirculation\n", .{});
|
||||||
|
try stderr.flush();
|
||||||
|
return e;
|
||||||
|
};
|
||||||
|
|
||||||
|
try stdout.print("✓ Recirculation command sent\n", .{});
|
||||||
|
try stdout.print("⏳ Waiting up to 20 seconds for device to respond...\n", .{});
|
||||||
|
try stdout.flush();
|
||||||
|
|
||||||
|
const max = 10;
|
||||||
|
for (0..10) |i| {
|
||||||
|
std.Thread.sleep((20 / max) * std.time.ns_per_s);
|
||||||
|
|
||||||
|
var post_command_state = try rinnai.getRecirculationStatus(
|
||||||
|
allocator,
|
||||||
|
auth.id_token,
|
||||||
|
sid,
|
||||||
|
);
|
||||||
|
defer post_command_state.deinit();
|
||||||
|
const enabled = post_command_state.recirculation_enabled != null and post_command_state.recirculation_enabled.?;
|
||||||
|
if (i == 10 or enabled) {
|
||||||
|
try stdout.print("\n{f}", .{post_command_state});
|
||||||
|
if (enabled)
|
||||||
|
try stdout.writeAll("\n👍 Recirculation enabled!\n");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
try stdout.flush();
|
try stdout.flush();
|
||||||
}
|
},
|
||||||
}
|
else => {
|
||||||
} else {
|
try stderr.print("❌ More than one device found for user, not currently equipped to handle this\n", .{});
|
||||||
try stderr.print("❌ No serial_id found for device\n", .{});
|
|
||||||
try stderr.flush();
|
try stderr.flush();
|
||||||
return error.NoSerialId;
|
return error.TooManyDevicesFound;
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
384
src/rinnai.zig
Normal file
384
src/rinnai.zig
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue