diff --git a/README.md b/README.md index 2b50b58..6d9daa4 100644 --- a/README.md +++ b/README.md @@ -21,110 +21,193 @@ zig build # Release build (arm64) zig build -Doptimize=ReleaseFast -# Create Lambda deployment package -zig build -Doptimize=ReleaseFast package - # Build for native target (e.g., for local testing) zig build -Dtarget=native + +# Run tests +zig build test -Dtarget=native ``` ## Dependencies -- [lambda-zig](../../lambda-zig) - AWS Lambda runtime for Zig -- [controlr](../../controlr) - Rinnai API client (provides `rinnai` module) +- [lambda-zig](https://git.lerch.org/lobo/lambda-zig) - AWS Lambda runtime for Zig +- [controlr](https://git.lerch.org/lobo/controlr) - Rinnai API client (provides `rinnai` module) + +## Deployment Setup + +Before deploying, you need to configure both AWS credentials and ASK CLI authentication. + +### 1. AWS Credentials + +AWS credentials are required for Lambda deployment. You can configure them in several ways: + +#### Option A: AWS CLI Configuration (Recommended) + +```bash +# Configure default profile +aws configure + +# Or configure a named profile +aws configure --profile personal +``` + +This creates `~/.aws/credentials` and `~/.aws/config` files. + +#### Option B: Environment Variables + +```bash +export AWS_ACCESS_KEY_ID=AKIA... +export AWS_SECRET_ACCESS_KEY=... +export AWS_DEFAULT_REGION=us-west-2 +``` + +#### Using a Non-Default AWS Profile + +If your default AWS profile is your work account but you want to deploy to your personal account: + +```bash +# Set the profile for the current shell session +export AWS_PROFILE=personal + +# Or specify it per-command +zig build awslambda_deploy -Dprofile=personal +zig build deploy -Dprofile=personal +``` + +You can also set the region: + +```bash +zig build awslambda_deploy -Dprofile=personal -Dregion=us-west-2 +``` + +### 2. ASK CLI Authentication + +The ASK CLI requires authentication with your Amazon Developer account to deploy Alexa skills. + +#### First-Time Setup + +```bash +# Install dependencies (if not already installed) +bun install + +# Configure ASK CLI (opens browser for Amazon login). Note this takes an ungodly +# amount of time to do anything and it will look like everything is hung +bun x ask configure +``` + +This will: +1. Open your browser to sign in with your Amazon Developer account +2. Store credentials in `~/.ask/cli_config` + +#### Verify Authentication + +```bash +bun x ask smapi list-skills-for-vendor +``` + +### 3. Rinnai Credentials + +The Lambda function needs your Rinnai account credentials to authenticate with the water heater API. + +Create a `.env` file in the project root (this file is gitignored): + +```bash +# .env +COGNITO_USERNAME=your@email.com +COGNITO_PASSWORD=your_password +``` + +These credentials will be automatically deployed to Lambda when you use the `-Denv-file=.env` option. + +## Build Steps + +| Step | Description | +|------|-------------| +| `zig build` | Build the bootstrap executable | +| `zig build test` | Run unit tests | +| `zig build awslambda_package` | Package Lambda function into zip | +| `zig build awslambda_iam` | Create/verify IAM role | +| `zig build awslambda_deploy` | Deploy Lambda function to AWS | +| `zig build awslambda_run` | Invoke the deployed Lambda function | +| `zig build ask_deploy` | Deploy Alexa skill metadata | +| `zig build deploy` | Deploy both Lambda and Alexa skill | + +### Build Options + +| Option | Description | Default | +|--------|-------------|---------| +| `-Doptimize=ReleaseFast` | Build with optimizations | Debug | +| `-Dtarget=native` | Build for local machine | aarch64-linux | +| `-Dfunction-name=NAME` | Lambda function name | zig-fn | +| `-Dprofile=PROFILE` | AWS profile to use | default | +| `-Dregion=REGION` | AWS region | from profile | +| `-Drole-name=ROLE` | IAM role name | lambda_basic_execution | +| `-Dpayload=JSON` | Payload for `awslambda_run` | {} | +| `-Denv-file=PATH` | Environment variables file | none | +| `-Dallow-principal=PRINCIPAL` | AWS service principal to grant invoke permission | none | ## Deployment ### Prerequisites -- AWS CLI configured with appropriate credentials -- mise (for zig and bun version management) +Before deploying, ensure you have: -### 1. Build the Package +1. **AWS Account** with credentials configured (see [Deployment Setup](#deployment-setup)) +2. **Amazon Developer Account** with ASK CLI authenticated (`bun x ask configure`) +3. **Rinnai credentials** in `.env` file (see [Rinnai Credentials](#3-rinnai-credentials)) + +### Full Deployment (Lambda + Alexa Skill) ```bash -mise exec -- zig build -Doptimize=ReleaseFast package +zig build deploy -Doptimize=ReleaseFast \ + -Dfunction-name=water-recirculation \ + -Dprofile=personal \ + -Dregion=us-west-2 \ + -Denv-file=.env \ + -Dallow-principal=alexa-appkit.amazon.com ``` -This creates `function.zip` containing the arm64 bootstrap executable. +This command will: +1. Build the Lambda function for arm64 +2. Package it into a zip file +3. Create/update the Lambda function in AWS +4. Set environment variables from `.env` +5. Grant Alexa Skills Kit permission to invoke the function +6. Deploy the Alexa skill metadata via ASK CLI -### 2. Create Lambda Function (first time only) +### Lambda Only ```bash -aws lambda create-function \ - --function-name water-recirculation \ - --runtime provided.al2023 \ - --handler bootstrap \ - --architectures arm64 \ - --role arn:aws:iam::ACCOUNT_ID:role/lambda_basic_execution \ - --zip-file fileb://function.zip \ - --timeout 30 \ - --memory-size 128 +zig build awslambda_deploy -Doptimize=ReleaseFast \ + -Dfunction-name=water-recirculation \ + -Dprofile=personal \ + -Dregion=us-west-2 \ + -Denv-file=.env \ + -Dallow-principal=alexa-appkit.amazon.com ``` -### 3. Set Environment Variables +### Alexa Skill Only ```bash -aws lambda update-function-configuration \ - --function-name water-recirculation \ - --environment "Variables={COGNITO_USERNAME=your@email.com,COGNITO_PASSWORD=your_password}" -``` - -### 4. Update Function Code (subsequent deploys) - -```bash -mise exec -- zig build -Doptimize=ReleaseFast package - -aws lambda update-function-code \ - --function-name water-recirculation \ - --zip-file fileb://function.zip -``` - -### 5. Deploy Alexa Skill - -First time setup - configure ASK CLI (opens browser for Amazon login): - -```bash -mise exec -- bunx ask-cli configure -``` - -Deploy the skill: - -```bash -mise exec -- bunx ask-cli deploy -``` - -This will: -- Create the Alexa skill in your developer account -- Upload the interaction model -- Link to the Lambda endpoint - -After deployment, add the Alexa Skills Kit trigger permission to Lambda: - -```bash -aws lambda add-permission \ - --function-name water-recirculation \ - --statement-id alexa-skill \ - --action lambda:InvokeFunction \ - --principal alexa-appkit.amazon.com \ - --event-source-token amzn1.ask.skill.YOUR_SKILL_ID +zig build ask_deploy ``` ## Project Structure ``` water_recirculation/ -├── build.zig # Build configuration (defaults to arm64-linux) +├── build.zig # Build configuration ├── build.zig.zon # Dependencies (lambda-zig, controlr) +├── .env # Rinnai credentials (gitignored, create locally) ├── ask-resources.json # ASK CLI deployment config +├── package.json # Node.js deps for ASK CLI ├── src/ -│ └── main.zig # Alexa request handler +│ └── main.zig # Alexa request handler + tests ├── skill-package/ │ ├── skill.json # Alexa skill manifest │ └── interactionModels/ │ └── custom/ │ └── en-US.json # Interaction model -└── function.zip # Lambda deployment package (after build) ``` ## Sample Utterances diff --git a/build.zig b/build.zig index ee23dcf..ce340c6 100644 --- a/build.zig +++ b/build.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const lambda_zig = @import("lambda_zig"); pub fn build(b: *std.Build) !void { // Default to aarch64-linux for Lambda Graviton deployment @@ -42,18 +43,24 @@ pub fn build(b: *std.Build) !void { b.installArtifact(exe); - // Create a step to package for Lambda - const package_step = b.step("package", "Package for AWS Lambda (arm64) deployment"); + // Configure Lambda build steps (awslambda_package, awslambda_deploy, etc.) + try lambda_zig.configureBuild(b, lambda_zig_dep, exe); - // After installing, create a zip - const install_step = b.getInstallStep(); - - // Add a system command to create zip (requires zip to be installed) - const zip_cmd = b.addSystemCommand(&.{ - "zip", "-j", "function.zip", "zig-out/bin/bootstrap", + // ASK CLI deploy step for Alexa skill metadata + const ask_deploy_cmd = b.addSystemCommand(&.{ + "bun", "x", "ask", "deploy", "--target", "skill-metadata", }); - zip_cmd.step.dependOn(install_step); - package_step.dependOn(&zip_cmd.step); + const ask_deploy_step = b.step("ask_deploy", "Deploy Alexa skill metadata via ASK CLI"); + ask_deploy_step.dependOn(&ask_deploy_cmd.step); + + // Full deploy step - deploys both Lambda function and Alexa skill + const full_deploy_step = b.step("deploy", "Deploy Lambda function and Alexa skill"); + // Lambda deploy (awslambda_deploy) is added by lambda_zig.configureBuild + // We need to get a reference to it - it's registered as "awslambda_deploy" + if (b.top_level_steps.get("awslambda_deploy")) |lambda_deploy| { + full_deploy_step.dependOn(&lambda_deploy.step); + } + full_deploy_step.dependOn(&ask_deploy_cmd.step); // Test step - reuses the same target query, tests run via emulation or on native arm64 const test_module = b.createModule(.{ diff --git a/build.zig.zon b/build.zig.zon index 87baade..669c2b0 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -9,8 +9,8 @@ .hash = "controlr-0.1.0-upFm0LtgAAAo85RiRDa0WmSrORIhAa5bCF8UdT9rDyUk", }, .lambda_zig = .{ - .url = "git+https://git.lerch.org/lobo/lambda-zig#183d2d912c41ca721c8d18e5c258e4472d38db70", - .hash = "lambda_zig-0.1.0-_G43_6YQAQD-ahqtf3DQpJroP__spvt4U_uI5TtMZ4Xv", + .url = "git+https://git.lerch.org/lobo/lambda-zig#b420abb0a145e8c9bb151606c124ed380cb744e9", + .hash = "lambda_zig-0.1.0-_G43_zRAAQA7RdBlHSxhmfSRrlvOOk3DJhfDJfimhneA", }, }, .paths = .{ diff --git a/src/main.zig b/src/main.zig index 5b0ef9b..77527f3 100644 --- a/src/main.zig +++ b/src/main.zig @@ -2,6 +2,7 @@ const std = @import("std"); const json = std.json; const lambda = @import("lambda_runtime"); const rinnai = @import("rinnai"); +const builtin = @import("builtin"); const log = std.log.scoped(.alexa); @@ -19,24 +20,24 @@ fn handler(allocator: std.mem.Allocator, event_data: []const u8) anyerror![]cons // Parse the Alexa request const parsed = json.parseFromSlice(json.Value, allocator, event_data, .{}) catch |err| { - log.err("Failed to parse Alexa request: {}", .{err}); + if (!builtin.is_test) log.err("Failed to parse Alexa request: {}", .{err}); return buildAlexaResponse(allocator, "I couldn't understand that request.", true); }; defer parsed.deinit(); // Get request type const request_obj = parsed.value.object.get("request") orelse { - log.err("No 'request' field in Alexa event", .{}); + if (!builtin.is_test) log.err("No 'request' field in Alexa event", .{}); return buildAlexaResponse(allocator, "Invalid request format.", true); }; const request_type = request_obj.object.get("type") orelse { - log.err("No 'type' field in request", .{}); + if (!builtin.is_test) log.err("No 'type' field in request", .{}); return buildAlexaResponse(allocator, "Invalid request format.", true); }; const request_type_str = if (request_type == .string) request_type.string else { - log.err("Request type is not a string", .{}); + if (!builtin.is_test) log.err("Request type is not a string", .{}); return buildAlexaResponse(allocator, "Invalid request format.", true); }; @@ -57,17 +58,17 @@ fn handler(allocator: std.mem.Allocator, event_data: []const u8) anyerror![]cons /// Handle Alexa intent requests fn handleIntentRequest(allocator: std.mem.Allocator, request_obj: json.Value) ![]const u8 { const intent_obj = request_obj.object.get("intent") orelse { - log.err("No 'intent' field in IntentRequest", .{}); + if (!builtin.is_test) log.err("No 'intent' field in IntentRequest", .{}); return buildAlexaResponse(allocator, "I couldn't understand your intent.", true); }; const intent_name_val = intent_obj.object.get("name") orelse { - log.err("No 'name' field in intent", .{}); + if (!builtin.is_test) log.err("No 'name' field in intent", .{}); return buildAlexaResponse(allocator, "I couldn't understand your intent.", true); }; const intent_name = if (intent_name_val == .string) intent_name_val.string else { - log.err("Intent name is not a string", .{}); + if (!builtin.is_test) log.err("Intent name is not a string", .{}); return buildAlexaResponse(allocator, "I couldn't understand your intent.", true); }; @@ -163,3 +164,196 @@ fn buildAlexaResponse(allocator: std.mem.Allocator, speech: []const u8, end_sess \\{{"version":"1.0","response":{{"outputSpeech":{{"type":"PlainText","text":"{s}"}},"shouldEndSession":{s}}}}} , .{ escaped_speech.items, end_session_str }); } + +// ============================================================================= +// Tests +// ============================================================================= + +test "buildAlexaResponse with speech and end session" { + const allocator = std.testing.allocator; + const response = try buildAlexaResponse(allocator, "Hello world", true); + defer allocator.free(response); + + const parsed = try json.parseFromSlice(json.Value, allocator, response, .{}); + defer parsed.deinit(); + + try std.testing.expectEqualStrings("1.0", parsed.value.object.get("version").?.string); + const resp = parsed.value.object.get("response").?.object; + try std.testing.expect(resp.get("shouldEndSession").?.bool == true); + try std.testing.expectEqualStrings("PlainText", resp.get("outputSpeech").?.object.get("type").?.string); + try std.testing.expectEqualStrings("Hello world", resp.get("outputSpeech").?.object.get("text").?.string); +} + +test "buildAlexaResponse with speech and keep session open" { + const allocator = std.testing.allocator; + const response = try buildAlexaResponse(allocator, "What can I help with?", false); + defer allocator.free(response); + + const parsed = try json.parseFromSlice(json.Value, allocator, response, .{}); + defer parsed.deinit(); + + const resp = parsed.value.object.get("response").?.object; + try std.testing.expect(resp.get("shouldEndSession").?.bool == false); +} + +test "buildAlexaResponse empty speech (SessionEndedRequest)" { + const allocator = std.testing.allocator; + const response = try buildAlexaResponse(allocator, "", true); + defer allocator.free(response); + + const parsed = try json.parseFromSlice(json.Value, allocator, response, .{}); + defer parsed.deinit(); + + const resp = parsed.value.object.get("response").?.object; + try std.testing.expect(resp.get("shouldEndSession").?.bool == true); + try std.testing.expect(resp.get("outputSpeech") == null); +} + +test "buildAlexaResponse escapes special characters" { + const allocator = std.testing.allocator; + const response = try buildAlexaResponse(allocator, "Say \"hello\"\nNew line", true); + defer allocator.free(response); + + // Should parse as valid JSON (escaping worked) + const parsed = try json.parseFromSlice(json.Value, allocator, response, .{}); + defer parsed.deinit(); + + const text = parsed.value.object.get("response").?.object.get("outputSpeech").?.object.get("text").?.string; + try std.testing.expectEqualStrings("Say \"hello\"\nNew line", text); +} + +test "handler returns error response for invalid JSON" { + const allocator = std.testing.allocator; + const response = try handler(allocator, "not valid json"); + defer allocator.free(response); + + const parsed = try json.parseFromSlice(json.Value, allocator, response, .{}); + defer parsed.deinit(); + + const text = parsed.value.object.get("response").?.object.get("outputSpeech").?.object.get("text").?.string; + try std.testing.expectEqualStrings("I couldn't understand that request.", text); +} + +test "handler returns error for missing request field" { + const allocator = std.testing.allocator; + const response = try handler(allocator, "{}"); + defer allocator.free(response); + + const parsed = try json.parseFromSlice(json.Value, allocator, response, .{}); + defer parsed.deinit(); + + const text = parsed.value.object.get("response").?.object.get("outputSpeech").?.object.get("text").?.string; + try std.testing.expectEqualStrings("Invalid request format.", text); +} + +test "handler handles LaunchRequest" { + const allocator = std.testing.allocator; + const launch_request = + \\{"request":{"type":"LaunchRequest"}} + ; + const response = try handler(allocator, launch_request); + defer allocator.free(response); + + const parsed = try json.parseFromSlice(json.Value, allocator, response, .{}); + defer parsed.deinit(); + + const resp = parsed.value.object.get("response").?.object; + try std.testing.expect(resp.get("shouldEndSession").?.bool == false); + const text = resp.get("outputSpeech").?.object.get("text").?.string; + try std.testing.expect(std.mem.indexOf(u8, text, "hot water") != null); +} + +test "handler handles SessionEndedRequest" { + const allocator = std.testing.allocator; + const session_ended = + \\{"request":{"type":"SessionEndedRequest"}} + ; + const response = try handler(allocator, session_ended); + defer allocator.free(response); + + const parsed = try json.parseFromSlice(json.Value, allocator, response, .{}); + defer parsed.deinit(); + + const resp = parsed.value.object.get("response").?.object; + try std.testing.expect(resp.get("shouldEndSession").?.bool == true); + try std.testing.expect(resp.get("outputSpeech") == null); +} + +test "handler handles AMAZON.HelpIntent" { + const allocator = std.testing.allocator; + const help_request = + \\{"request":{"type":"IntentRequest","intent":{"name":"AMAZON.HelpIntent"}}} + ; + const response = try handler(allocator, help_request); + defer allocator.free(response); + + const parsed = try json.parseFromSlice(json.Value, allocator, response, .{}); + defer parsed.deinit(); + + const resp = parsed.value.object.get("response").?.object; + try std.testing.expect(resp.get("shouldEndSession").?.bool == false); + const text = resp.get("outputSpeech").?.object.get("text").?.string; + try std.testing.expect(std.mem.indexOf(u8, text, "recirculation") != null); +} + +test "handler handles AMAZON.StopIntent" { + const allocator = std.testing.allocator; + const stop_request = + \\{"request":{"type":"IntentRequest","intent":{"name":"AMAZON.StopIntent"}}} + ; + const response = try handler(allocator, stop_request); + defer allocator.free(response); + + const parsed = try json.parseFromSlice(json.Value, allocator, response, .{}); + defer parsed.deinit(); + + const resp = parsed.value.object.get("response").?.object; + try std.testing.expect(resp.get("shouldEndSession").?.bool == true); + const text = resp.get("outputSpeech").?.object.get("text").?.string; + try std.testing.expectEqualStrings("Okay, goodbye.", text); +} + +test "handler handles AMAZON.CancelIntent" { + const allocator = std.testing.allocator; + const cancel_request = + \\{"request":{"type":"IntentRequest","intent":{"name":"AMAZON.CancelIntent"}}} + ; + const response = try handler(allocator, cancel_request); + defer allocator.free(response); + + const parsed = try json.parseFromSlice(json.Value, allocator, response, .{}); + defer parsed.deinit(); + + const resp = parsed.value.object.get("response").?.object; + try std.testing.expect(resp.get("shouldEndSession").?.bool == true); +} + +test "handler handles unknown intent" { + const allocator = std.testing.allocator; + const unknown_intent = + \\{"request":{"type":"IntentRequest","intent":{"name":"SomeRandomIntent"}}} + ; + const response = try handler(allocator, unknown_intent); + defer allocator.free(response); + + const parsed = try json.parseFromSlice(json.Value, allocator, response, .{}); + defer parsed.deinit(); + + const text = parsed.value.object.get("response").?.object.get("outputSpeech").?.object.get("text").?.string; + try std.testing.expectEqualStrings("I don't know how to do that.", text); +} + +test "handler handles unknown request type" { + const allocator = std.testing.allocator; + const unknown_request = + \\{"request":{"type":"SomeOtherRequest"}} + ; + const response = try handler(allocator, unknown_request); + defer allocator.free(response); + + const parsed = try json.parseFromSlice(json.Value, allocator, response, .{}); + defer parsed.deinit(); + + const text = parsed.value.object.get("response").?.object.get("outputSpeech").?.object.get("text").?.string; + try std.testing.expectEqualStrings("I didn't understand that.", text); +}