first working thingy
This commit is contained in:
parent
0c56542913
commit
363efa1cca
14 changed files with 10038 additions and 0 deletions
32
.forgejo/workflows/build.yaml
Normal file
32
.forgejo/workflows/build.yaml
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
name: Alexa skill build
|
||||||
|
run-name: ${{ github.actor }} building alexa skill
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- '*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out repository code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Zig
|
||||||
|
uses: https://codeberg.org/mlugg/setup-zig@v2.2.1
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: zig build --summary all
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: zig build test --summary all
|
||||||
|
|
||||||
|
- name: Notify
|
||||||
|
uses: https://git.lerch.org/lobo/action-notify-ntfy@v2
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.NTFY_HOST }}
|
||||||
|
topic: ${{ secrets.NTFY_TOPIC }}
|
||||||
|
user: ${{ secrets.NTFY_USER }}
|
||||||
|
password: ${{ secrets.NTFY_PASSWORD }}
|
||||||
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# Zig build artifacts
|
||||||
|
zig-out/
|
||||||
|
.zig-cache/
|
||||||
|
|
||||||
|
# Lambda deployment package
|
||||||
|
lambda.zip
|
||||||
|
function.zip
|
||||||
|
|
||||||
|
# Editor/IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Credentials (never commit)
|
||||||
|
.credentials
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# ASK CLI state (account-specific)
|
||||||
|
.ask/
|
||||||
6
.mise.toml
Normal file
6
.mise.toml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[tools]
|
||||||
|
zig = "0.15.2"
|
||||||
|
bun = "1.3.8"
|
||||||
|
pre-commit = "4.2.0"
|
||||||
|
"ubi:DonIsaac/zlint" = "0.7.6"
|
||||||
|
zls = "0.15.1"
|
||||||
36
.pre-commit-config.yaml
Normal file
36
.pre-commit-config.yaml
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
# See https://pre-commit.com for more information
|
||||||
|
# See https://pre-commit.com/hooks.html for more hooks
|
||||||
|
repos:
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v3.2.0
|
||||||
|
hooks:
|
||||||
|
- id: trailing-whitespace
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: check-yaml
|
||||||
|
- id: check-added-large-files
|
||||||
|
- repo: https://github.com/batmac/pre-commit-zig
|
||||||
|
rev: v0.3.0
|
||||||
|
hooks:
|
||||||
|
- id: zig-fmt
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: zlint
|
||||||
|
name: Run zlint
|
||||||
|
entry: zlint
|
||||||
|
args: ["--deny-warnings", "--fix"]
|
||||||
|
language: system
|
||||||
|
types: [zig]
|
||||||
|
- repo: https://github.com/batmac/pre-commit-zig
|
||||||
|
rev: v0.3.0
|
||||||
|
hooks:
|
||||||
|
- id: zig-build
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: test
|
||||||
|
name: Run zig build test
|
||||||
|
entry: zig
|
||||||
|
# args: ["build", "coverage", "-Dcoverage-threshold=80"]
|
||||||
|
args: ["build", "test"]
|
||||||
|
language: system
|
||||||
|
types: [file]
|
||||||
|
pass_filenames: false
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 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.
|
||||||
154
README.md
Normal file
154
README.md
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
# Water Recirculation Alexa Skill
|
||||||
|
|
||||||
|
An Alexa skill that triggers water recirculation on Rinnai tankless water heaters.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
> "Alexa, ask house to start the hot water"
|
||||||
|
|
||||||
|
This will authenticate with the Rinnai API and start a 15-minute recirculation cycle.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
Requires [Zig 0.15](https://ziglang.org/) and [mise](https://mise.jdx.dev/) for version management.
|
||||||
|
|
||||||
|
The build defaults to `aarch64-linux` for AWS Lambda Graviton (arm64) deployment.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Debug build (arm64)
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- [lambda-zig](../../lambda-zig) - AWS Lambda runtime for Zig
|
||||||
|
- [controlr](../../controlr) - Rinnai API client (provides `rinnai` module)
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- AWS CLI configured with appropriate credentials
|
||||||
|
- mise (for zig and bun version management)
|
||||||
|
|
||||||
|
### 1. Build the Package
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mise exec -- zig build -Doptimize=ReleaseFast package
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates `function.zip` containing the arm64 bootstrap executable.
|
||||||
|
|
||||||
|
### 2. Create Lambda Function (first time 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
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Set Environment Variables
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
water_recirculation/
|
||||||
|
├── build.zig # Build configuration (defaults to arm64-linux)
|
||||||
|
├── build.zig.zon # Dependencies (lambda-zig, controlr)
|
||||||
|
├── ask-resources.json # ASK CLI deployment config
|
||||||
|
├── src/
|
||||||
|
│ └── main.zig # Alexa request handler
|
||||||
|
├── skill-package/
|
||||||
|
│ ├── skill.json # Alexa skill manifest
|
||||||
|
│ └── interactionModels/
|
||||||
|
│ └── custom/
|
||||||
|
│ └── en-US.json # Interaction model
|
||||||
|
└── function.zip # Lambda deployment package (after build)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sample Utterances
|
||||||
|
|
||||||
|
- "start the hot water"
|
||||||
|
- "turn on the hot water"
|
||||||
|
- "heat the water"
|
||||||
|
- "preheat the water"
|
||||||
|
- "start recirculation"
|
||||||
|
- "warm up the water"
|
||||||
|
|
||||||
|
## Lambda Details
|
||||||
|
|
||||||
|
- **Function**: `water-recirculation`
|
||||||
|
- **Region**: us-west-2
|
||||||
|
- **Architecture**: arm64 (Graviton)
|
||||||
|
- **Runtime**: provided.al2023
|
||||||
|
- **ARN**: `arn:aws:lambda:us-west-2:932028523435:function:water-recirculation`
|
||||||
|
|
||||||
|
## Alexa Skill
|
||||||
|
|
||||||
|
- **Skill ID**: `amzn1.ask.skill.c373c562-d574-4f38-bd06-001e96426d12`
|
||||||
|
- **Invocation**: "Alexa, ask house to..."
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
10
ask-resources.json
Normal file
10
ask-resources.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"askcliResourcesVersion": "2020-03-31",
|
||||||
|
"profiles": {
|
||||||
|
"default": {
|
||||||
|
"skillMetadata": {
|
||||||
|
"src": "./skill-package"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
build.zig
Normal file
75
build.zig
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
const std = @import("std");
|
||||||
|
|
||||||
|
pub fn build(b: *std.Build) !void {
|
||||||
|
// Default to aarch64-linux for Lambda Graviton deployment
|
||||||
|
const target = b.standardTargetOptions(.{
|
||||||
|
.default_target = .{
|
||||||
|
.cpu_arch = .aarch64,
|
||||||
|
.os_tag = .linux,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const optimize = b.standardOptimizeOption(.{});
|
||||||
|
|
||||||
|
// Get lambda-zig dependency
|
||||||
|
const lambda_zig_dep = b.dependency("lambda_zig", .{
|
||||||
|
.target = target,
|
||||||
|
.optimize = optimize,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get controlr dependency for rinnai module
|
||||||
|
const controlr_dep = b.dependency("controlr", .{
|
||||||
|
.target = target,
|
||||||
|
.optimize = optimize,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create the main module
|
||||||
|
const main_module = b.createModule(.{
|
||||||
|
.root_source_file = b.path("src/main.zig"),
|
||||||
|
.target = target,
|
||||||
|
.optimize = optimize,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add lambda_runtime import
|
||||||
|
main_module.addImport("lambda_runtime", lambda_zig_dep.module("lambda_runtime"));
|
||||||
|
// Add rinnai import from controlr
|
||||||
|
main_module.addImport("rinnai", controlr_dep.module("rinnai"));
|
||||||
|
|
||||||
|
// Create executable
|
||||||
|
const exe = b.addExecutable(.{
|
||||||
|
.name = "bootstrap", // Lambda requires the executable to be named "bootstrap"
|
||||||
|
.root_module = main_module,
|
||||||
|
});
|
||||||
|
|
||||||
|
b.installArtifact(exe);
|
||||||
|
|
||||||
|
// Create a step to package for Lambda
|
||||||
|
const package_step = b.step("package", "Package for AWS Lambda (arm64) deployment");
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
});
|
||||||
|
zip_cmd.step.dependOn(install_step);
|
||||||
|
package_step.dependOn(&zip_cmd.step);
|
||||||
|
|
||||||
|
// Test step - reuses the same target query, tests run via emulation or on native arm64
|
||||||
|
const test_module = b.createModule(.{
|
||||||
|
.root_source_file = b.path("src/main.zig"),
|
||||||
|
.target = target,
|
||||||
|
.optimize = optimize,
|
||||||
|
});
|
||||||
|
test_module.addImport("lambda_runtime", lambda_zig_dep.module("lambda_runtime"));
|
||||||
|
test_module.addImport("rinnai", controlr_dep.module("rinnai"));
|
||||||
|
|
||||||
|
const main_tests = b.addTest(.{
|
||||||
|
.name = "test",
|
||||||
|
.root_module = test_module,
|
||||||
|
});
|
||||||
|
|
||||||
|
const run_main_tests = b.addRunArtifact(main_tests);
|
||||||
|
const test_step = b.step("test", "Run unit tests");
|
||||||
|
test_step.dependOn(&run_main_tests.step);
|
||||||
|
}
|
||||||
21
build.zig.zon
Normal file
21
build.zig.zon
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
.{
|
||||||
|
.name = .water_recirculation_alexa,
|
||||||
|
.version = "0.1.0",
|
||||||
|
.fingerprint = 0xaff1623a413ed497,
|
||||||
|
.minimum_zig_version = "0.15.2",
|
||||||
|
.dependencies = .{
|
||||||
|
.controlr = .{
|
||||||
|
.url = "git+https://git.lerch.org/lobo/controlr#4f5bd5b0607f73c9975ca41246fbfd5836cdfb98",
|
||||||
|
.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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
.paths = .{
|
||||||
|
"build.zig",
|
||||||
|
"build.zig.zon",
|
||||||
|
"src",
|
||||||
|
},
|
||||||
|
}
|
||||||
9379
package-lock.json
generated
Normal file
9379
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
5
package.json
Normal file
5
package.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"ask-cli": "^2.30.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
54
skill-package/interactionModels/custom/en-US.json
Normal file
54
skill-package/interactionModels/custom/en-US.json
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
{
|
||||||
|
"interactionModel": {
|
||||||
|
"languageModel": {
|
||||||
|
"invocationName": "house",
|
||||||
|
"intents": [
|
||||||
|
{
|
||||||
|
"name": "RecirculateWaterIntent",
|
||||||
|
"slots": [],
|
||||||
|
"samples": [
|
||||||
|
"start the hot water",
|
||||||
|
"start hot water",
|
||||||
|
"turn on the hot water",
|
||||||
|
"turn on hot water",
|
||||||
|
"heat the water",
|
||||||
|
"heat water",
|
||||||
|
"preheat the water",
|
||||||
|
"preheat water",
|
||||||
|
"start recirculation",
|
||||||
|
"start the recirculation",
|
||||||
|
"start water recirculation",
|
||||||
|
"run the hot water",
|
||||||
|
"run hot water",
|
||||||
|
"warm up the water",
|
||||||
|
"warm the water up",
|
||||||
|
"get hot water ready",
|
||||||
|
"make the water hot",
|
||||||
|
"I need hot water"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AMAZON.HelpIntent",
|
||||||
|
"samples": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AMAZON.StopIntent",
|
||||||
|
"samples": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AMAZON.CancelIntent",
|
||||||
|
"samples": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AMAZON.NavigateHomeIntent",
|
||||||
|
"samples": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AMAZON.FallbackIntent",
|
||||||
|
"samples": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"types": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
skill-package/skill.json
Normal file
53
skill-package/skill.json
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
{
|
||||||
|
"manifest": {
|
||||||
|
"apis": {
|
||||||
|
"custom": {
|
||||||
|
"endpoint": {
|
||||||
|
"uri": "arn:aws:lambda:us-west-2:932028523435:function:water-recirculation"
|
||||||
|
},
|
||||||
|
"interfaces": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"manifestVersion": "1.0",
|
||||||
|
"publishingInformation": {
|
||||||
|
"locales": {
|
||||||
|
"en-US": {
|
||||||
|
"name": "House Water Control",
|
||||||
|
"summary": "Control water recirculation on your Rinnai tankless water heater",
|
||||||
|
"description": "This skill allows you to start water recirculation on your Rinnai tankless water heater by voice command. Just say \"Alexa, ask house to start the hot water\" to begin a 15-minute recirculation cycle.",
|
||||||
|
"examplePhrases": [
|
||||||
|
"Alexa, ask house to start the hot water",
|
||||||
|
"Alexa, ask house to heat the water",
|
||||||
|
"Alexa, ask house to start recirculation"
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"water heater",
|
||||||
|
"rinnai",
|
||||||
|
"recirculation",
|
||||||
|
"hot water",
|
||||||
|
"tankless"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isAvailableWorldwide": false,
|
||||||
|
"testingInstructions": "Sample testing instructions.",
|
||||||
|
"category": "SMART_HOME",
|
||||||
|
"distributionCountries": [
|
||||||
|
"US"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"privacyAndCompliance": {
|
||||||
|
"allowsPurchases": false,
|
||||||
|
"usesPersonalInfo": false,
|
||||||
|
"isChildDirected": false,
|
||||||
|
"isExportCompliant": true,
|
||||||
|
"containsAds": false,
|
||||||
|
"locales": {
|
||||||
|
"en-US": {
|
||||||
|
"privacyPolicyUrl": "",
|
||||||
|
"termsOfUseUrl": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
165
src/main.zig
Normal file
165
src/main.zig
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
const std = @import("std");
|
||||||
|
const json = std.json;
|
||||||
|
const lambda = @import("lambda_runtime");
|
||||||
|
const rinnai = @import("rinnai");
|
||||||
|
|
||||||
|
const log = std.log.scoped(.alexa);
|
||||||
|
|
||||||
|
pub fn main() !u8 {
|
||||||
|
lambda.run(null, handler) catch |err| {
|
||||||
|
log.err("Lambda runtime error: {}", .{err});
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Main Alexa request handler
|
||||||
|
fn handler(allocator: std.mem.Allocator, event_data: []const u8) anyerror![]const u8 {
|
||||||
|
log.info("Received Alexa request: {d} bytes", .{event_data.len});
|
||||||
|
|
||||||
|
// Parse the Alexa request
|
||||||
|
const parsed = json.parseFromSlice(json.Value, allocator, event_data, .{}) catch |err| {
|
||||||
|
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", .{});
|
||||||
|
return buildAlexaResponse(allocator, "Invalid request format.", true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const request_type = request_obj.object.get("type") orelse {
|
||||||
|
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", .{});
|
||||||
|
return buildAlexaResponse(allocator, "Invalid request format.", true);
|
||||||
|
};
|
||||||
|
|
||||||
|
log.info("Request type: {s}", .{request_type_str});
|
||||||
|
|
||||||
|
// Handle different request types
|
||||||
|
if (std.mem.eql(u8, request_type_str, "LaunchRequest")) {
|
||||||
|
return buildAlexaResponse(allocator, "What would you like me to do? You can ask me to start the hot water.", false);
|
||||||
|
} else if (std.mem.eql(u8, request_type_str, "IntentRequest")) {
|
||||||
|
return handleIntentRequest(allocator, request_obj);
|
||||||
|
} else if (std.mem.eql(u8, request_type_str, "SessionEndedRequest")) {
|
||||||
|
return buildAlexaResponse(allocator, "", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildAlexaResponse(allocator, "I didn't understand that.", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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", .{});
|
||||||
|
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", .{});
|
||||||
|
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", .{});
|
||||||
|
return buildAlexaResponse(allocator, "I couldn't understand your intent.", true);
|
||||||
|
};
|
||||||
|
|
||||||
|
log.info("Intent: {s}", .{intent_name});
|
||||||
|
|
||||||
|
if (std.mem.eql(u8, intent_name, "RecirculateWaterIntent")) {
|
||||||
|
return handleRecirculateWater(allocator);
|
||||||
|
} else if (std.mem.eql(u8, intent_name, "AMAZON.HelpIntent")) {
|
||||||
|
return buildAlexaResponse(allocator, "You can ask me to start the hot water to begin recirculation. This will preheat your water for about 15 minutes.", false);
|
||||||
|
} else if (std.mem.eql(u8, intent_name, "AMAZON.StopIntent") or std.mem.eql(u8, intent_name, "AMAZON.CancelIntent")) {
|
||||||
|
return buildAlexaResponse(allocator, "Okay, goodbye.", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildAlexaResponse(allocator, "I don't know how to do that.", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle the main recirculate water intent
|
||||||
|
fn handleRecirculateWater(allocator: std.mem.Allocator) ![]const u8 {
|
||||||
|
// Get credentials from environment variables
|
||||||
|
const username = std.posix.getenv("COGNITO_USERNAME") orelse {
|
||||||
|
log.err("COGNITO_USERNAME environment variable not set", .{});
|
||||||
|
return buildAlexaResponse(allocator, "I'm not configured properly. Please set up my credentials.", true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const password = std.posix.getenv("COGNITO_PASSWORD") orelse {
|
||||||
|
log.err("COGNITO_PASSWORD environment variable not set", .{});
|
||||||
|
return buildAlexaResponse(allocator, "I'm not configured properly. Please set up my credentials.", true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Authenticate with Cognito
|
||||||
|
log.info("Authenticating with Cognito...", .{});
|
||||||
|
var auth = rinnai.authenticate(allocator, username, password) catch |err| {
|
||||||
|
log.err("Authentication failed: {}", .{err});
|
||||||
|
return buildAlexaResponse(allocator, "I couldn't log in to your water heater account.", true);
|
||||||
|
};
|
||||||
|
defer auth.deinit();
|
||||||
|
log.info("Authenticated successfully", .{});
|
||||||
|
|
||||||
|
// Get device list
|
||||||
|
log.info("Fetching device list...", .{});
|
||||||
|
var devices = rinnai.getDevices(allocator, auth.id_token, username) catch |err| {
|
||||||
|
log.err("Failed to get devices: {}", .{err});
|
||||||
|
return buildAlexaResponse(allocator, "I couldn't find your water heater.", true);
|
||||||
|
};
|
||||||
|
defer devices.deinit();
|
||||||
|
|
||||||
|
if (devices.devices.len == 0) {
|
||||||
|
return buildAlexaResponse(allocator, "I couldn't find any water heaters on your account.", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const device = devices.devices[0];
|
||||||
|
if (device.thing_name == null) {
|
||||||
|
return buildAlexaResponse(allocator, "Your water heater isn't properly configured.", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start recirculation
|
||||||
|
log.info("Starting recirculation for device: {?s}", .{device.device_name});
|
||||||
|
rinnai.setRecirculation(allocator, auth.id_token, device.thing_name.?, 15) catch |err| {
|
||||||
|
log.err("Failed to start recirculation: {}", .{err});
|
||||||
|
return buildAlexaResponse(allocator, "I couldn't start the water recirculation. Please try again.", true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return buildAlexaResponse(allocator, "Starting water recirculation. Hot water should be ready in about 2 minutes.", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build an Alexa skill response JSON
|
||||||
|
fn buildAlexaResponse(allocator: std.mem.Allocator, speech: []const u8, end_session: bool) ![]const u8 {
|
||||||
|
// Escape speech for JSON
|
||||||
|
var escaped_speech: std.ArrayList(u8) = .{};
|
||||||
|
defer escaped_speech.deinit(allocator);
|
||||||
|
|
||||||
|
for (speech) |c| {
|
||||||
|
switch (c) {
|
||||||
|
'"' => try escaped_speech.appendSlice(allocator, "\\\""),
|
||||||
|
'\\' => try escaped_speech.appendSlice(allocator, "\\\\"),
|
||||||
|
'\n' => try escaped_speech.appendSlice(allocator, "\\n"),
|
||||||
|
'\r' => try escaped_speech.appendSlice(allocator, "\\r"),
|
||||||
|
'\t' => try escaped_speech.appendSlice(allocator, "\\t"),
|
||||||
|
else => try escaped_speech.append(allocator, c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const end_session_str = if (end_session) "true" else "false";
|
||||||
|
|
||||||
|
if (speech.len == 0) {
|
||||||
|
// Empty response for SessionEndedRequest
|
||||||
|
return try std.fmt.allocPrint(allocator,
|
||||||
|
\\{{"version":"1.0","response":{{"shouldEndSession":{s}}}}}
|
||||||
|
, .{end_session_str});
|
||||||
|
}
|
||||||
|
|
||||||
|
return try std.fmt.allocPrint(allocator,
|
||||||
|
\\{{"version":"1.0","response":{{"outputSpeech":{{"type":"PlainText","text":"{s}"}},"shouldEndSession":{s}}}}}
|
||||||
|
, .{ escaped_speech.items, end_session_str });
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue