From 7f46d40027a6c2abd8812df328792907a826c29b Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Tue, 9 Dec 2025 11:39:12 -0800 Subject: [PATCH] initial vibe coded stuff that seems to work --- .gitignore | 2 + LICENSE | 21 ++++ README.md | 42 ++++++++ build.zig | 38 ++++++++ src/main.zig | 270 +++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 373 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 build.zig create mode 100644 src/main.zig diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2fc273e --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +zig-out +.zig-cache/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..506dadd --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Emil Lerch + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..91716e2 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# List Devices - Zig Port + +This is a direct port of `../list-devices.js` to Zig. + +## Features + +- Authenticates with AWS Cognito +- Fetches device list via GraphQL (AppSync) +- Displays device information +- Checks recirculation status for the first device +- Includes `startRecirculation` function (not called during testing as requested) + +## Building + +```bash +zig build +``` + +## Running + +```bash +./zig-out/bin/list-devices +``` + +## Testing + +```bash +zig build test +``` + +## Requirements + +- Zig 0.15.2 +- `.credentials` file in parent directory with username and password (one per line) + +## Implementation Notes + +- Uses Zig's standard library HTTP client +- JSON parsing with `std.json` +- Base64url decoding for JWT tokens +- Proper memory management with allocators +- Error handling with Zig's error unions diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..e3f3ef6 --- /dev/null +++ b/build.zig @@ -0,0 +1,38 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const exe = b.addExecutable(.{ + .name = "list-devices", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }), + }); + + b.installArtifact(exe); + + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| { + run_cmd.addArgs(args); + } + + const run_step = b.step("run", "Run the app"); + run_step.dependOn(&run_cmd.step); + + const exe_unit_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }), + }); + + const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests); + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_exe_unit_tests.step); +} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..8304fe4 --- /dev/null +++ b/src/main.zig @@ -0,0 +1,270 @@ +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"; + +const AuthResult = struct { + id_token: []const u8, + user_uuid: []const u8, +}; + +fn readCredentials(allocator: std.mem.Allocator) !struct { username: []const u8, password: []const u8, content: []const u8 } { + 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, .content = content }; +} + +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 }; +} + +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 } } } } } }"; + + const body = try std.fmt.allocPrint(allocator, "{{\"query\":\"{s}\",\"variables\":{{\"email\":\"{s}\"}}}}", .{ query, 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, .{}); +} + +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 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 body = try std.fmt.allocPrint(allocator, "{{\"query\":\"{s}\",\"variables\":{{\"heater_serial_number\":\"{s}\"}}}}", .{ query, 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(); + + const creds = try readCredentials(allocator); + defer allocator.free(creds.content); + + std.debug.print("šŸ” Authenticating...\n", .{}); + const auth = try authenticate(allocator, creds.username, creds.password); + defer allocator.free(auth.id_token); + defer allocator.free(auth.user_uuid); + std.debug.print("āœ“ User UUID: {s}\n\n", .{auth.user_uuid}); + + std.debug.print("šŸ“± Fetching devices...\n", .{}); + 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| { + std.debug.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"; + + std.debug.print(" • {s}\n", .{device_name}); + std.debug.print(" Thing Name: {s}\n", .{thing_name}); + std.debug.print(" DSN: {s}\n", .{dsn}); + std.debug.print(" Serial ID: {s}\n", .{serial_id}); + std.debug.print(" Model: {s}\n\n", .{model}); + } + + 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"; + std.debug.print("šŸ” Checking recirculation status for {s}...\n", .{device_name}); + + 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| { + std.debug.print("\nCurrent Shadow State:\n", .{}); + + if (shadow.object.get("heater_serial_number")) |v| { + if (v == .string) std.debug.print(" heater_serial_number: {s}\n", .{v.string}); + } + if (shadow.object.get("set_recirculation_enabled")) |v| { + if (v == .bool) std.debug.print(" set_recirculation_enabled: {}\n", .{v.bool}); + } + if (shadow.object.get("recirculation_enabled")) |v| { + if (v == .bool) std.debug.print(" recirculation_enabled: {}\n", .{v.bool}); + } + if (shadow.object.get("recirculation_duration")) |v| { + if (v == .integer) std.debug.print(" recirculation_duration: {}\n", .{v.integer}); + } + if (shadow.object.get("set_domestic_temperature")) |v| { + if (v == .integer) std.debug.print(" set_domestic_temperature: {}\n", .{v.integer}); + } + if (shadow.object.get("operation_enabled")) |v| { + if (v == .bool) std.debug.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) { + std.debug.print("\nāœ“ Recirculation is already active\n", .{}); + // Recirculation code commented out as requested + } else { + // Recirculation code would go here but not called during testing + std.debug.print("\n(Recirculation start function available but not called during testing)\n", .{}); + } + } + } + } else { + std.debug.print("āŒ No serial_id found for device\n", .{}); + return error.NoSerialId; + } + } + } + } + } + } + } + } else { + const err_str = try std.fmt.allocPrint(allocator, "{}", .{result.value}); + defer allocator.free(err_str); + std.debug.print("āŒ Error: {s}\n", .{err_str}); + } +}