controlr/src/main.zig
2025-12-09 12:47:13 -08:00

374 lines
16 KiB
Zig

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,
};
/// Credentials loaded from file with managed memory
const CognitoCredentials = struct {
allocator: std.mem.Allocator,
buffer: []const u8,
username: []const u8,
password: []const u8,
pub fn deinit(self: *CognitoCredentials) void {
self.allocator.free(self.buffer);
self.buffer = undefined;
self.username = undefined;
self.password = undefined;
}
};
/// Reads username and password from .credentials file (one per line)
fn readCredentials(allocator: std.mem.Allocator) !CognitoCredentials {
// TODO: something different
const file = try std.fs.cwd().openFile(".credentials", .{});
defer file.close();
const content = try file.readToEndAlloc(allocator, 1024);
var it = std.mem.splitScalar(u8, std.mem.trim(
u8,
content,
&std.ascii.whitespace,
), '\n');
const username = it.next() orelse return error.InvalidCredentials;
const password = it.next() orelse return error.InvalidCredentials;
return .{
.username = username,
.password = password,
.buffer = content,
.allocator = allocator,
};
}
/// 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 };
}
/// Fetches device list from AppSync GraphQL API for the given user
fn getDevices(allocator: std.mem.Allocator, id_token: []const u8, username: []const u8) !json.Parsed(json.Value) {
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];
return try json.parseFromSlice(json.Value, allocator, response_body, .{});
}
/// 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;
}
/// 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
\\ }
\\}
;
var query_clean_buf: [query.len]u8 = undefined;
// Remove newlines for JSON payload
const query_replacements = std.mem.replace(
u8,
query,
"\n",
" ",
query_clean_buf[0..],
);
const query_clean = query_clean_buf[0 .. query.len - ("\n".len * query_replacements)];
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 {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var stdout_buffer: [1024]u8 = undefined;
var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
const stdout = &stdout_writer.interface;
var stderr_buffer: [1024]u8 = undefined;
var stderr_writer = std.fs.File.stderr().writer(&stderr_buffer);
const stderr = &stderr_writer.interface;
var creds = try readCredentials(allocator);
defer creds.deinit();
try stdout.print("🔐 Authenticating...\n", .{});
try stdout.flush();
const auth = try authenticate(allocator, creds.username, creds.password);
defer allocator.free(auth.id_token);
defer allocator.free(auth.user_uuid);
try stdout.print("✓ User UUID: {s}\n\n", .{auth.user_uuid});
try stdout.print("📱 Fetching devices...\n", .{});
try stdout.flush();
const result = try getDevices(allocator, auth.id_token, creds.username);
defer result.deinit();
if (result.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| {
try stdout.print("\n✓ Found {d} device(s):\n\n", .{devices.array.items.len});
for (devices.array.items) |device| {
const device_name = if (device.object.get("device_name")) |n| if (n == .string) n.string else "Unnamed" else "Unnamed";
const thing_name = if (device.object.get("thing_name")) |n| if (n == .string) n.string else "N/A" else "N/A";
const dsn = if (device.object.get("dsn")) |n| if (n == .string) n.string else "N/A" else "N/A";
const model = if (device.object.get("model")) |m| if (m == .string) m.string else "N/A" else "N/A";
const serial_id = if (device.object.get("info")) |info| blk: {
if (info.object.get("serial_id")) |sid| {
if (sid == .string) break :blk sid.string else break :blk "N/A";
} else break :blk "N/A";
} else "N/A";
try stdout.print(" • {s}\n", .{device_name});
try stdout.print(" Thing Name: {s}\n", .{thing_name});
try stdout.print(" DSN: {s}\n", .{dsn});
try stdout.print(" Serial ID: {s}\n", .{serial_id});
try stdout.print(" Model: {s}\n\n", .{model});
}
try stdout.flush();
if (devices.array.items.len > 0) {
const device = devices.array.items[0];
const serial_id = if (device.object.get("info")) |info| blk: {
if (info.object.get("serial_id")) |sid| {
if (sid == .string) break :blk sid.string else break :blk null;
} else break :blk null;
} else null;
if (serial_id) |sid| {
const device_name = if (device.object.get("device_name")) |n| if (n == .string) n.string else "Unnamed" else "Unnamed";
try stdout.print("🔍 Checking recirculation status for {s}...\n", .{device_name});
try stdout.flush();
const status = try getRecirculationStatus(allocator, auth.id_token, sid);
defer status.deinit();
if (status.value.object.get("data")) |status_data| {
if (status_data.object.get("getDeviceShadow")) |shadow| {
try stdout.print("\nCurrent Shadow State:\n", .{});
if (shadow.object.get("heater_serial_number")) |v| {
if (v == .string) try stdout.print(" heater_serial_number: {s}\n", .{v.string});
}
if (shadow.object.get("set_recirculation_enabled")) |v| {
if (v == .bool) try stdout.print(" set_recirculation_enabled: {}\n", .{v.bool});
}
if (shadow.object.get("recirculation_enabled")) |v| {
if (v == .bool) try stdout.print(" recirculation_enabled: {}\n", .{v.bool});
}
if (shadow.object.get("recirculation_duration")) |v| {
if (v == .integer) try stdout.print(" recirculation_duration: {}\n", .{v.integer});
}
if (shadow.object.get("set_domestic_temperature")) |v| {
if (v == .integer) try stdout.print(" set_domestic_temperature: {}\n", .{v.integer});
}
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;
if (recirc_enabled) {
try stdout.print("\n✓ Recirculation is already active\n", .{});
// Recirculation code commented out as requested
} else {
// Recirculation code would go here but not called during testing
try stdout.print("\n(Recirculation start function available but not called during testing)\n", .{});
}
try stdout.flush();
}
}
} else {
try stderr.print("❌ No serial_id found for device\n", .{});
try stderr.flush();
return error.NoSerialId;
}
}
}
}
}
}
}
} else {
try stderr.writeAll("❌ Error fetching devices. Response: ");
try std.json.Stringify.value(result.value, .{}, stderr);
try stderr.flush();
}
}