Compare commits
No commits in common. "37dcb8fd6cae6cb94084c4f43ae2a9055ce8d528" and "d77e59360789e986c9cc80357309c6994158f0f9" have entirely different histories.
37dcb8fd6c
...
d77e593607
12 changed files with 234 additions and 1179 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -25,6 +25,3 @@ node_modules/
|
||||||
|
|
||||||
# ASK CLI state (account-specific)
|
# ASK CLI state (account-specific)
|
||||||
.ask/
|
.ask/
|
||||||
|
|
||||||
# Generated skill manifest (contains account-specific Lambda ARN)
|
|
||||||
skill-package/skill.json
|
|
||||||
|
|
|
||||||
112
README.md
112
README.md
|
|
@ -1,7 +1,6 @@
|
||||||
# Home control Alexa Skill
|
# Water Recirculation Alexa Skill
|
||||||
|
|
||||||
An Alexa skill that triggers water recirculation on Rinnai tankless water heaters
|
An Alexa skill that triggers water recirculation on Rinnai tankless water heaters.
|
||||||
and supports homeassistant device control
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
|
@ -9,11 +8,6 @@ and supports homeassistant device control
|
||||||
|
|
||||||
This will authenticate with the Rinnai API and start a 15-minute recirculation cycle.
|
This will authenticate with the Rinnai API and start a 15-minute recirculation cycle.
|
||||||
|
|
||||||
> "Alexa, turn on the bedroom light"
|
|
||||||
|
|
||||||
This will use the homeassistant REST API to find the bedroom device and turn it on.
|
|
||||||
You can turn on, turn off, toggle, and query status
|
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
Requires [Zig 0.15](https://ziglang.org/) and [mise](https://mise.jdx.dev/) for version management.
|
Requires [Zig 0.15](https://ziglang.org/) and [mise](https://mise.jdx.dev/) for version management.
|
||||||
|
|
@ -27,13 +21,15 @@ zig build
|
||||||
# Release build (arm64)
|
# Release build (arm64)
|
||||||
zig build -Doptimize=ReleaseFast
|
zig build -Doptimize=ReleaseFast
|
||||||
|
|
||||||
# Run tests (uses host CPU/OS)
|
# Build for native target (e.g., for local testing)
|
||||||
zig build test
|
zig build -Dtarget=native
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
zig build test -Dtarget=native
|
||||||
```
|
```
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- [aws-sdk-for-zig](https://git.lerch.org/lobo/aws-sdk-for-zig) - AWS SDK for Zig
|
|
||||||
- [lambda-zig](https://git.lerch.org/lobo/lambda-zig) - AWS Lambda runtime for Zig
|
- [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)
|
- [controlr](https://git.lerch.org/lobo/controlr) - Rinnai API client (provides `rinnai` module)
|
||||||
|
|
||||||
|
|
@ -109,41 +105,18 @@ This will:
|
||||||
bun x ask smapi list-skills-for-vendor
|
bun x ask smapi list-skills-for-vendor
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Environment Variables
|
### 3. Rinnai Credentials
|
||||||
|
|
||||||
The Lambda function needs credentials for the services it interacts with.
|
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):
|
Create a `.env` file in the project root (this file is gitignored):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# .env
|
# .env
|
||||||
|
|
||||||
# Rinnai API credentials for water heater control
|
|
||||||
COGNITO_USERNAME=your@email.com
|
COGNITO_USERNAME=your@email.com
|
||||||
COGNITO_PASSWORD=your_password
|
COGNITO_PASSWORD=your_password
|
||||||
|
|
||||||
# Home Assistant configuration
|
|
||||||
HOME_ASSISTANT_URL=https://your-homeassistant.example.com
|
|
||||||
HOME_ASSISTANT_TOKEN=your_long_lived_access_token
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Rinnai Credentials
|
|
||||||
|
|
||||||
The `COGNITO_USERNAME` and `COGNITO_PASSWORD` are your Rinnai app login credentials.
|
|
||||||
These are used to authenticate with the Rinnai API for water recirculation control.
|
|
||||||
|
|
||||||
#### Home Assistant Token
|
|
||||||
|
|
||||||
To generate a long-lived access token in Home Assistant:
|
|
||||||
|
|
||||||
1. Go to your Home Assistant profile (click your username in the sidebar)
|
|
||||||
2. Scroll down to "Long-Lived Access Tokens"
|
|
||||||
3. Click "Create Token"
|
|
||||||
4. Give it a name (e.g., "Alexa Lambda")
|
|
||||||
5. Copy the token immediately (it won't be shown again)
|
|
||||||
|
|
||||||
The `HOME_ASSISTANT_URL` should be the external URL of your Home Assistant instance.
|
|
||||||
|
|
||||||
These credentials will be automatically deployed to Lambda when you use the `-Denv-file=.env` option.
|
These credentials will be automatically deployed to Lambda when you use the `-Denv-file=.env` option.
|
||||||
|
|
||||||
## Build Steps
|
## Build Steps
|
||||||
|
|
@ -165,12 +138,13 @@ These credentials will be automatically deployed to Lambda when you use the `-De
|
||||||
|--------|-------------|---------|
|
|--------|-------------|---------|
|
||||||
| `-Doptimize=ReleaseFast` | Build with optimizations | Debug |
|
| `-Doptimize=ReleaseFast` | Build with optimizations | Debug |
|
||||||
| `-Dtarget=native` | Build for local machine | aarch64-linux |
|
| `-Dtarget=native` | Build for local machine | aarch64-linux |
|
||||||
| `-Dfunction-name=NAME` | Lambda function name | exe name (house-control) |
|
| `-Dfunction-name=NAME` | Lambda function name | zig-fn |
|
||||||
| `-Dprofile=PROFILE` | AWS profile to use | default |
|
| `-Dprofile=PROFILE` | AWS profile to use | default |
|
||||||
| `-Dregion=REGION` | AWS region | from profile |
|
| `-Dregion=REGION` | AWS region | from profile |
|
||||||
| `-Drole-name=ROLE` | IAM role name | lambda_basic_execution |
|
| `-Drole-name=ROLE` | IAM role name | lambda_basic_execution |
|
||||||
| `-Dpayload=JSON` | Payload for `awslambda_run` | {} |
|
| `-Dpayload=JSON` | Payload for `awslambda_run` | {} |
|
||||||
| `-Denv-file=PATH` | Environment variables file | none |
|
| `-Denv-file=PATH` | Environment variables file | none |
|
||||||
|
| `-Dallow-principal=PRINCIPAL` | AWS service principal to grant invoke permission | none |
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
|
|
@ -180,44 +154,38 @@ Before deploying, ensure you have:
|
||||||
|
|
||||||
1. **AWS Account** with credentials configured (see [Deployment Setup](#deployment-setup))
|
1. **AWS Account** with credentials configured (see [Deployment Setup](#deployment-setup))
|
||||||
2. **Amazon Developer Account** with ASK CLI authenticated (`bun x ask configure`)
|
2. **Amazon Developer Account** with ASK CLI authenticated (`bun x ask configure`)
|
||||||
3. **Credentials** in `.env` file (see [Environment Variables](#3-environment-variables)):
|
3. **Rinnai credentials** in `.env` file (see [Rinnai Credentials](#3-rinnai-credentials))
|
||||||
- Rinnai account credentials (for water recirculation)
|
|
||||||
- Home Assistant URL and long-lived access token (for device control)
|
|
||||||
|
|
||||||
### Full Deployment (Lambda + Alexa Skill)
|
### Full Deployment (Lambda + Alexa Skill)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
zig build deploy -Doptimize=ReleaseFast \
|
zig build deploy -Doptimize=ReleaseFast \
|
||||||
|
-Dfunction-name=water-recirculation \
|
||||||
-Dprofile=personal \
|
-Dprofile=personal \
|
||||||
-Dregion=us-west-2 \
|
-Dregion=us-west-2 \
|
||||||
-Denv-file=.env
|
-Denv-file=.env \
|
||||||
|
-Dallow-principal=alexa-appkit.amazon.com
|
||||||
```
|
```
|
||||||
|
|
||||||
This command orchestrates a multi-step deployment pipeline:
|
This command will:
|
||||||
|
1. Build the Lambda function for arm64
|
||||||
1. **Build** - Compile Lambda function for arm64
|
2. Package it into a zip file
|
||||||
2. **Package** - Create deployment zip with bootstrap executable
|
3. Create/update the Lambda function in AWS
|
||||||
3. **Deploy Lambda** - Create/update function in AWS, set env vars from `.env`
|
4. Set environment variables from `.env`
|
||||||
4. **Generate skill.json** - Inject Lambda ARN into `skill.template.json`
|
5. Grant Alexa Skills Kit permission to invoke the function
|
||||||
5. **ASK Deploy** - Deploy Alexa skill metadata and interaction model
|
6. Deploy the Alexa skill metadata via ASK CLI
|
||||||
6. **Add Permission** - Grant Alexa permission to invoke Lambda with skill-specific token
|
|
||||||
|
|
||||||
The permission step uses the skill ID (from `.ask/ask-states.json`) as an
|
|
||||||
`event_source_token` condition, restricting invocation to only this specific
|
|
||||||
Alexa skill rather than allowing any Alexa skill to invoke the Lambda.
|
|
||||||
|
|
||||||
### Lambda Only
|
### Lambda Only
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
zig build awslambda_deploy -Doptimize=ReleaseFast \
|
zig build awslambda_deploy -Doptimize=ReleaseFast \
|
||||||
|
-Dfunction-name=water-recirculation \
|
||||||
-Dprofile=personal \
|
-Dprofile=personal \
|
||||||
-Dregion=us-west-2 \
|
-Dregion=us-west-2 \
|
||||||
-Denv-file=.env
|
-Denv-file=.env \
|
||||||
|
-Dallow-principal=alexa-appkit.amazon.com
|
||||||
```
|
```
|
||||||
|
|
||||||
Note: This only deploys the Lambda function. To invoke the function from Alexa,
|
|
||||||
you must also deploy the skill (`zig build deploy`) to set up the permission.
|
|
||||||
|
|
||||||
### Alexa Skill Only
|
### Alexa Skill Only
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -229,29 +197,21 @@ zig build ask_deploy
|
||||||
```
|
```
|
||||||
water_recirculation/
|
water_recirculation/
|
||||||
├── build.zig # Build configuration
|
├── build.zig # Build configuration
|
||||||
├── build.zig.zon # Dependencies
|
├── build.zig.zon # Dependencies (lambda-zig, controlr)
|
||||||
├── .env # Credentials (gitignored)
|
├── .env # Rinnai credentials (gitignored, create locally)
|
||||||
├── skill.template.json # Skill manifest template (Lambda ARN placeholder)
|
|
||||||
├── ask-resources.json # ASK CLI deployment config
|
├── ask-resources.json # ASK CLI deployment config
|
||||||
├── package.json # Node.js deps for ASK CLI
|
├── package.json # Node.js deps for ASK CLI
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── main.zig # Lambda entry point
|
│ └── main.zig # Alexa request handler + tests
|
||||||
│ └── homeassistant.zig # Home Assistant API client
|
|
||||||
├── tools/
|
|
||||||
│ ├── gen-skill-json.zig # Generates skill.json from template
|
|
||||||
│ └── add-alexa-permission.zig # Adds skill-specific Lambda permission
|
|
||||||
├── skill-package/
|
├── skill-package/
|
||||||
│ ├── skill.json # Generated (gitignored)
|
│ ├── skill.json # Alexa skill manifest
|
||||||
│ └── interactionModels/
|
│ └── interactionModels/
|
||||||
│ └── custom/
|
│ └── custom/
|
||||||
│ └── en-US.json # Interaction model with intents/slots
|
│ └── en-US.json # Interaction model
|
||||||
└── .ask/
|
|
||||||
└── ask-states.json # ASK CLI state (contains skill ID)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Sample Utterances
|
## Sample Utterances
|
||||||
|
|
||||||
### Water Recirculation
|
|
||||||
- "start the hot water"
|
- "start the hot water"
|
||||||
- "turn on the hot water"
|
- "turn on the hot water"
|
||||||
- "heat the water"
|
- "heat the water"
|
||||||
|
|
@ -259,23 +219,17 @@ water_recirculation/
|
||||||
- "start recirculation"
|
- "start recirculation"
|
||||||
- "warm up the water"
|
- "warm up the water"
|
||||||
|
|
||||||
### Home Assistant Device Control
|
|
||||||
- "turn on the bedroom light"
|
|
||||||
- "turn off the kitchen"
|
|
||||||
- "toggle the basement fireplace"
|
|
||||||
- "is the deck on"
|
|
||||||
- "check the family room"
|
|
||||||
|
|
||||||
## Lambda Details
|
## Lambda Details
|
||||||
|
|
||||||
- **Function**: `house-control`
|
- **Function**: `water-recirculation`
|
||||||
- **Region**: us-west-2
|
- **Region**: us-west-2
|
||||||
- **Architecture**: arm64 (Graviton)
|
- **Architecture**: arm64 (Graviton)
|
||||||
- **Runtime**: provided.al2023
|
- **Runtime**: provided.al2023
|
||||||
|
- **ARN**: `arn:aws:lambda:us-west-2:932028523435:function:water-recirculation`
|
||||||
|
|
||||||
## Alexa Skill
|
## Alexa Skill
|
||||||
|
|
||||||
- **Skill ID**: `amzn1.ask.skill.5cc9bf04-8be9-4229-936d-49a22fae6a3e`
|
- **Skill ID**: `amzn1.ask.skill.c373c562-d574-4f38-bd06-001e96426d12`
|
||||||
- **Invocation**: "Alexa, ask house to..."
|
- **Invocation**: "Alexa, ask house to..."
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
|
||||||
107
build.zig
107
build.zig
|
|
@ -11,9 +11,6 @@ pub fn build(b: *std.Build) !void {
|
||||||
});
|
});
|
||||||
const optimize = b.standardOptimizeOption(.{});
|
const optimize = b.standardOptimizeOption(.{});
|
||||||
|
|
||||||
// Native target for build tools
|
|
||||||
const native_target = b.resolveTargetQuery(.{});
|
|
||||||
|
|
||||||
// Get lambda-zig dependency
|
// Get lambda-zig dependency
|
||||||
const lambda_zig_dep = b.dependency("lambda_zig", .{
|
const lambda_zig_dep = b.dependency("lambda_zig", .{
|
||||||
.target = target,
|
.target = target,
|
||||||
|
|
@ -46,103 +43,30 @@ pub fn build(b: *std.Build) !void {
|
||||||
|
|
||||||
b.installArtifact(exe);
|
b.installArtifact(exe);
|
||||||
|
|
||||||
// Configure Lambda build steps and get deployment info
|
// Configure Lambda build steps (awslambda_package, awslambda_deploy, etc.)
|
||||||
// Function name defaults to exe.name ("house-control")
|
try lambda_zig.configureBuild(b, lambda_zig_dep, exe, .{
|
||||||
const lambda = try lambda_zig.configureBuild(b, lambda_zig_dep, exe, .{});
|
.default_function_name = "house-control",
|
||||||
|
|
||||||
// Get AWS profile option (already declared by lambda-zig)
|
|
||||||
const profile = b.user_input_options.get("profile");
|
|
||||||
const profile_str: ?[]const u8 = if (profile) |p| switch (p.value) {
|
|
||||||
.scalar => |s| s,
|
|
||||||
else => null,
|
|
||||||
} else null;
|
|
||||||
|
|
||||||
// Get AWS region option (already declared by lambda-zig)
|
|
||||||
const region = b.user_input_options.get("region");
|
|
||||||
const region_str: ?[]const u8 = if (region) |r| switch (r.value) {
|
|
||||||
.scalar => |s| s,
|
|
||||||
else => null,
|
|
||||||
} else null;
|
|
||||||
|
|
||||||
// Build the gen-skill-json tool (runs on host)
|
|
||||||
const gen_skill_json_module = b.createModule(.{
|
|
||||||
.root_source_file = b.path("tools/gen-skill-json.zig"),
|
|
||||||
.target = native_target,
|
|
||||||
.optimize = .ReleaseSafe,
|
|
||||||
});
|
});
|
||||||
const gen_skill_json_exe = b.addExecutable(.{
|
|
||||||
.name = "gen-skill-json",
|
|
||||||
.root_module = gen_skill_json_module,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Generate skill.json from template using Lambda ARN
|
|
||||||
const gen_skill_cmd = b.addRunArtifact(gen_skill_json_exe);
|
|
||||||
gen_skill_cmd.addFileArg(lambda.deploy_output);
|
|
||||||
gen_skill_cmd.addFileArg(b.path("skill.template.json"));
|
|
||||||
gen_skill_cmd.step.dependOn(lambda.deploy_step);
|
|
||||||
|
|
||||||
// Capture generated skill.json
|
|
||||||
const skill_json = gen_skill_cmd.captureStdOut();
|
|
||||||
|
|
||||||
// Write skill.json to skill-package directory (updates source files, necessary for the ask deploy)
|
|
||||||
const write_skill_json = b.addUpdateSourceFiles();
|
|
||||||
write_skill_json.addCopyFileToSource(skill_json, "skill-package/skill.json");
|
|
||||||
|
|
||||||
const gen_skill_step = b.step("gen_skill_json", "Generate skill.json from template (will deploy function)");
|
|
||||||
gen_skill_step.dependOn(&write_skill_json.step);
|
|
||||||
|
|
||||||
// ASK CLI deploy step for Alexa skill metadata
|
// ASK CLI deploy step for Alexa skill metadata
|
||||||
const ask_deploy_cmd = b.addSystemCommand(&.{
|
const ask_deploy_cmd = b.addSystemCommand(&.{
|
||||||
"bun", "x", "ask", "deploy", "--target", "skill-metadata",
|
"bun", "x", "ask", "deploy", "--target", "skill-metadata",
|
||||||
});
|
});
|
||||||
// ASK deploy depends on skill.json being generated
|
|
||||||
ask_deploy_cmd.step.dependOn(&write_skill_json.step);
|
|
||||||
|
|
||||||
// Add Alexa skill-specific Lambda permission
|
|
||||||
//
|
|
||||||
// Alexa requires a skill-specific Lambda permission with the skill ID as an
|
|
||||||
// event_source_token condition. This is more secure than a generic principal-based
|
|
||||||
// permission (--allow-principal) and restricts invocation to only our specific skill.
|
|
||||||
//
|
|
||||||
// We use our own tool instead of lambda-zig's built-in --allow-principal because:
|
|
||||||
// 1. The skill ID isn't known until after ASK deploy runs
|
|
||||||
// 2. event_source_token is Alexa-specific (not supported by lambda-zig)
|
|
||||||
//
|
|
||||||
// Pipeline: Lambda deploy -> gen-skill-json -> ASK deploy -> add-alexa-permission
|
|
||||||
const aws_dep = b.dependency("aws", .{
|
|
||||||
.target = native_target,
|
|
||||||
.optimize = .ReleaseSafe,
|
|
||||||
});
|
|
||||||
const add_alexa_perm_module = b.createModule(.{
|
|
||||||
.root_source_file = b.path("tools/add-alexa-permission.zig"),
|
|
||||||
.target = native_target,
|
|
||||||
.optimize = .ReleaseSafe,
|
|
||||||
});
|
|
||||||
add_alexa_perm_module.addImport("aws", aws_dep.module("aws"));
|
|
||||||
const add_alexa_perm_exe = b.addExecutable(.{
|
|
||||||
.name = "add-alexa-permission",
|
|
||||||
.root_module = add_alexa_perm_module,
|
|
||||||
});
|
|
||||||
const add_alexa_perm_cmd = b.addRunArtifact(add_alexa_perm_exe);
|
|
||||||
add_alexa_perm_cmd.addFileArg(lambda.deploy_output);
|
|
||||||
add_alexa_perm_cmd.addFileArg(b.path(".ask/ask-states.json"));
|
|
||||||
if (profile_str) |p| {
|
|
||||||
add_alexa_perm_cmd.addArgs(&.{ "--profile", p });
|
|
||||||
}
|
|
||||||
if (region_str) |r| {
|
|
||||||
add_alexa_perm_cmd.addArgs(&.{ "--region", r });
|
|
||||||
}
|
|
||||||
// Must run after ASK deploy (which creates/updates skill ID) and Lambda deploy
|
|
||||||
add_alexa_perm_cmd.step.dependOn(&ask_deploy_cmd.step);
|
|
||||||
|
|
||||||
const ask_deploy_step = b.step("ask_deploy", "Deploy Alexa skill metadata via ASK CLI");
|
const ask_deploy_step = b.step("ask_deploy", "Deploy Alexa skill metadata via ASK CLI");
|
||||||
ask_deploy_step.dependOn(&add_alexa_perm_cmd.step);
|
ask_deploy_step.dependOn(&ask_deploy_cmd.step);
|
||||||
|
|
||||||
// Full deploy step - deploys Lambda, generates skill.json, deploys Alexa skill, adds permission
|
// Full deploy step - deploys both Lambda function and Alexa skill
|
||||||
const full_deploy_step = b.step("deploy", "Deploy Lambda function and Alexa skill");
|
const full_deploy_step = b.step("deploy", "Deploy Lambda function and Alexa skill");
|
||||||
full_deploy_step.dependOn(&add_alexa_perm_cmd.step);
|
// 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 - use native target for tests (not cross-compiled Lambda target)
|
// Test step - use native target for tests (not cross-compiled Lambda target)
|
||||||
|
const native_target = b.resolveTargetQuery(.{});
|
||||||
|
|
||||||
const lambda_zig_dep_native = b.dependency("lambda_zig", .{
|
const lambda_zig_dep_native = b.dependency("lambda_zig", .{
|
||||||
.target = native_target,
|
.target = native_target,
|
||||||
.optimize = optimize,
|
.optimize = optimize,
|
||||||
|
|
@ -169,9 +93,6 @@ pub fn build(b: *std.Build) !void {
|
||||||
const run_main_tests = b.addRunArtifact(main_tests);
|
const run_main_tests = b.addRunArtifact(main_tests);
|
||||||
const test_step = b.step("test", "Run unit tests");
|
const test_step = b.step("test", "Run unit tests");
|
||||||
test_step.dependOn(&run_main_tests.step);
|
test_step.dependOn(&run_main_tests.step);
|
||||||
// Also verify tools compile
|
|
||||||
test_step.dependOn(&gen_skill_json_exe.step);
|
|
||||||
test_step.dependOn(&add_alexa_perm_exe.step);
|
|
||||||
|
|
||||||
// Run step for local testing (uses native target)
|
// Run step for local testing (uses native target)
|
||||||
const run_module = b.createModule(.{
|
const run_module = b.createModule(.{
|
||||||
|
|
@ -183,7 +104,7 @@ pub fn build(b: *std.Build) !void {
|
||||||
run_module.addImport("rinnai", controlr_dep_native.module("rinnai"));
|
run_module.addImport("rinnai", controlr_dep_native.module("rinnai"));
|
||||||
|
|
||||||
const run_exe = b.addExecutable(.{
|
const run_exe = b.addExecutable(.{
|
||||||
.name = exe.name,
|
.name = "bootstrap",
|
||||||
.root_module = run_module,
|
.root_module = run_module,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,8 @@
|
||||||
.hash = "controlr-0.1.0-upFm0LtgAAAo85RiRDa0WmSrORIhAa5bCF8UdT9rDyUk",
|
.hash = "controlr-0.1.0-upFm0LtgAAAo85RiRDa0WmSrORIhAa5bCF8UdT9rDyUk",
|
||||||
},
|
},
|
||||||
.lambda_zig = .{
|
.lambda_zig = .{
|
||||||
.url = "git+https://git.lerch.org/lobo/lambda-zig#2bdf23f375c1453cb471e8603b3cdbb6616aba28",
|
.url = "git+https://git.lerch.org/lobo/lambda-zig#ed9c7ced6c23426c062a46a77f9dead9eb708550",
|
||||||
.hash = "lambda_zig-0.1.0-_G43_x1fAQD4mNzDFE-Iz9lSrwQctI6V6510luXlJAkc",
|
.hash = "lambda_zig-0.1.0-_G43_2hEAQC6_qXjFpEt2kAjCK4cBYIqkF3E-yl9S_dA",
|
||||||
},
|
|
||||||
.aws = .{
|
|
||||||
.url = "git+https://git.lerch.org/lobo/aws-sdk-for-zig#5c7aed071f6251d53a1627080a21d604ff58f0a5",
|
|
||||||
.hash = "aws-0.0.1-SbsFcFE7CgDBilPa15i4gIB6Qr5ozBz328O63abDQDDk",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
.paths = .{
|
.paths = .{
|
||||||
|
|
|
||||||
|
|
@ -32,20 +32,20 @@
|
||||||
"slots": [
|
"slots": [
|
||||||
{
|
{
|
||||||
"name": "device",
|
"name": "device",
|
||||||
"type": "DEVICE_NAME"
|
"type": "AMAZON.SearchQuery"
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "action",
|
|
||||||
"type": "DEVICE_ACTION"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"samples": [
|
"samples": [
|
||||||
"{action} {device}",
|
"turn on {device}",
|
||||||
"{action} the {device}",
|
"turn on the {device}",
|
||||||
"{device}",
|
"turn {device} on",
|
||||||
"the {device}",
|
"switch on {device}",
|
||||||
"about {device}",
|
"switch on the {device}",
|
||||||
"about the {device}",
|
"turn off {device}",
|
||||||
|
"turn off the {device}",
|
||||||
|
"turn {device} off",
|
||||||
|
"switch off {device}",
|
||||||
|
"switch off the {device}",
|
||||||
"is {device} on",
|
"is {device} on",
|
||||||
"is the {device} on",
|
"is the {device} on",
|
||||||
"is {device} off",
|
"is {device} off",
|
||||||
|
|
@ -56,17 +56,12 @@
|
||||||
"what is the state of the {device}",
|
"what is the state of the {device}",
|
||||||
"what is {device} set to",
|
"what is {device} set to",
|
||||||
"what is the {device} set to",
|
"what is the {device} set to",
|
||||||
"what about {device}",
|
|
||||||
"what about the {device}",
|
|
||||||
"how is {device}",
|
|
||||||
"how is the {device}",
|
|
||||||
"status of {device}",
|
|
||||||
"status of the {device}",
|
|
||||||
"{device} status",
|
|
||||||
"check {device}",
|
"check {device}",
|
||||||
"check the {device}",
|
"check the {device}",
|
||||||
"check on {device}",
|
"check on {device}",
|
||||||
"check on the {device}"
|
"check on the {device}",
|
||||||
|
"toggle {device}",
|
||||||
|
"toggle the {device}"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -155,30 +150,26 @@
|
||||||
{
|
{
|
||||||
"name": "DEVICE_NAME",
|
"name": "DEVICE_NAME",
|
||||||
"values": [
|
"values": [
|
||||||
{"name": {"value": "bar"}},
|
|
||||||
{"name": {"value": "bar light"}},
|
|
||||||
{"name": {"value": "basement"}},
|
|
||||||
{"name": {"value": "basement light"}},
|
|
||||||
{"name": {"value": "basement fireplace"}},
|
|
||||||
{"name": {"value": "fireplace"}},
|
|
||||||
{"name": {"value": "deck"}},
|
|
||||||
{"name": {"value": "deck light"}},
|
|
||||||
{"name": {"value": "family room"}},
|
|
||||||
{"name": {"value": "family room light"}},
|
|
||||||
{"name": {"value": "jack bedroom light"}},
|
|
||||||
{"name": {"value": "jack bedroom"}},
|
|
||||||
{"name": {"value": "jack's bedroom"}},
|
|
||||||
{"name": {"value": "kitchen"}},
|
|
||||||
{"name": {"value": "kitchen light"}},
|
|
||||||
{"name": {"value": "kris bedroom"}},
|
|
||||||
{"name": {"value": "kris bedroom light"}},
|
|
||||||
{"name": {"value": "kris's bedroom"}},
|
|
||||||
{"name": {"value": "bedroom"}},
|
|
||||||
{"name": {"value": "bedroom light"}},
|
{"name": {"value": "bedroom light"}},
|
||||||
{"name": {"value": "master bedroom"}},
|
{"name": {"value": "kitchen light"}},
|
||||||
{"name": {"value": "master bedroom light"}},
|
{"name": {"value": "living room light"}},
|
||||||
{"name": {"value": "emil light"}},
|
{"name": {"value": "bathroom light"}},
|
||||||
{"name": {"value": "emil's light"}}
|
{"name": {"value": "hallway light"}},
|
||||||
|
{"name": {"value": "office light"}},
|
||||||
|
{"name": {"value": "garage light"}},
|
||||||
|
{"name": {"value": "porch light"}},
|
||||||
|
{"name": {"value": "front porch"}},
|
||||||
|
{"name": {"value": "back porch"}},
|
||||||
|
{"name": {"value": "bedroom lamp"}},
|
||||||
|
{"name": {"value": "desk lamp"}},
|
||||||
|
{"name": {"value": "floor lamp"}},
|
||||||
|
{"name": {"value": "thermostat"}},
|
||||||
|
{"name": {"value": "downstairs thermostat"}},
|
||||||
|
{"name": {"value": "upstairs thermostat"}},
|
||||||
|
{"name": {"value": "bedroom fan"}},
|
||||||
|
{"name": {"value": "ceiling fan"}},
|
||||||
|
{"name": {"value": "living room fan"}},
|
||||||
|
{"name": {"value": "kitchen fan"}}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
"apis": {
|
"apis": {
|
||||||
"custom": {
|
"custom": {
|
||||||
"endpoint": {
|
"endpoint": {
|
||||||
"uri": "{{LAMBDA_ARN}}"
|
"uri": "arn:aws:lambda:us-west-2:932028523435:function:water-recirculation"
|
||||||
},
|
},
|
||||||
"interfaces": []
|
"interfaces": []
|
||||||
}
|
}
|
||||||
178
src/alexa.zig
178
src/alexa.zig
|
|
@ -1,178 +0,0 @@
|
||||||
//! Alexa-specific utilities: context parsing and Settings API.
|
|
||||||
|
|
||||||
const std = @import("std");
|
|
||||||
const json = std.json;
|
|
||||||
const http = std.http;
|
|
||||||
const Allocator = std.mem.Allocator;
|
|
||||||
const homeassistant = @import("homeassistant.zig");
|
|
||||||
|
|
||||||
const log = std.log.scoped(.alexa);
|
|
||||||
|
|
||||||
/// Context extracted from Alexa request
|
|
||||||
pub const Context = struct {
|
|
||||||
api_endpoint: []const u8,
|
|
||||||
api_access_token: []const u8,
|
|
||||||
device_id: []const u8,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Parse Alexa context from request JSON.
|
|
||||||
/// Returns null if context is missing or malformed.
|
|
||||||
pub fn parseContext(json_value: json.Value) ?Context {
|
|
||||||
const obj = switch (json_value) {
|
|
||||||
.object => |o| o,
|
|
||||||
else => return null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const context_obj = switch (obj.get("context") orelse return null) {
|
|
||||||
.object => |o| o,
|
|
||||||
else => return null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const system_obj = switch (context_obj.get("System") orelse return null) {
|
|
||||||
.object => |o| o,
|
|
||||||
else => return null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const api_endpoint = switch (system_obj.get("apiEndpoint") orelse return null) {
|
|
||||||
.string => |s| s,
|
|
||||||
else => return null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const api_access_token = switch (system_obj.get("apiAccessToken") orelse return null) {
|
|
||||||
.string => |s| s,
|
|
||||||
else => return null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const device_obj = switch (system_obj.get("device") orelse return null) {
|
|
||||||
.object => |o| o,
|
|
||||||
else => return null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const device_id = switch (device_obj.get("deviceId") orelse return null) {
|
|
||||||
.string => |s| s,
|
|
||||||
else => return null,
|
|
||||||
};
|
|
||||||
|
|
||||||
return Context{
|
|
||||||
.api_endpoint = api_endpoint,
|
|
||||||
.api_access_token = api_access_token,
|
|
||||||
.device_id = device_id,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch user's timezone from Alexa Settings API.
|
|
||||||
/// GET {apiEndpoint}/v2/devices/{deviceId}/settings/System.timeZone
|
|
||||||
/// Returns timezone name like "America/Los_Angeles" or null on failure.
|
|
||||||
/// Caller owns the returned memory.
|
|
||||||
///
|
|
||||||
/// NOTE: This function is currently not used because the API call takes ~1 second
|
|
||||||
/// which is too slow for our 3 second Lambda timeout. See resolveTimezone() in main.zig.
|
|
||||||
pub fn fetchTimezone(
|
|
||||||
allocator: Allocator,
|
|
||||||
http_interface: homeassistant.HttpClientInterface,
|
|
||||||
context: Context,
|
|
||||||
) ?[]const u8 {
|
|
||||||
const url = std.fmt.allocPrint(
|
|
||||||
allocator,
|
|
||||||
"{s}/v2/devices/{s}/settings/System.timeZone",
|
|
||||||
.{ context.api_endpoint, context.device_id },
|
|
||||||
) catch {
|
|
||||||
log.warn("Failed to allocate Alexa Settings API URL", .{});
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
defer allocator.free(url);
|
|
||||||
|
|
||||||
const auth_header = std.fmt.allocPrint(
|
|
||||||
allocator,
|
|
||||||
"Bearer {s}",
|
|
||||||
.{context.api_access_token},
|
|
||||||
) catch {
|
|
||||||
log.warn("Failed to allocate auth header", .{});
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
defer allocator.free(auth_header);
|
|
||||||
|
|
||||||
const headers = [_]http.Header{
|
|
||||||
.{ .name = "Authorization", .value = auth_header },
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = http_interface.fetch(allocator, .{
|
|
||||||
.url = url,
|
|
||||||
.method = .GET,
|
|
||||||
.headers = &headers,
|
|
||||||
.body = null,
|
|
||||||
}) catch |err| {
|
|
||||||
log.warn("Alexa Settings API request failed: {}", .{err});
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
defer allocator.free(result.body);
|
|
||||||
|
|
||||||
if (result.status != .ok) {
|
|
||||||
log.warn("Alexa Settings API returned status: {}", .{result.status});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Response is a JSON string like "America/Los_Angeles" (with quotes)
|
|
||||||
const parsed = json.parseFromSlice(json.Value, allocator, result.body, .{}) catch {
|
|
||||||
log.warn("Failed to parse Alexa Settings API response", .{});
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
defer parsed.deinit();
|
|
||||||
|
|
||||||
const timezone = switch (parsed.value) {
|
|
||||||
.string => |s| s,
|
|
||||||
else => {
|
|
||||||
log.warn("Alexa Settings API response is not a string", .{});
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return allocator.dupe(u8, timezone) catch {
|
|
||||||
log.warn("Failed to allocate timezone string", .{});
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Tests
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
test "parseContext with valid context" {
|
|
||||||
const json_str =
|
|
||||||
\\{
|
|
||||||
\\ "context": {
|
|
||||||
\\ "System": {
|
|
||||||
\\ "apiEndpoint": "https://api.amazonalexa.com",
|
|
||||||
\\ "apiAccessToken": "test-token-123",
|
|
||||||
\\ "device": {
|
|
||||||
\\ "deviceId": "amzn1.ask.device.XXXX"
|
|
||||||
\\ }
|
|
||||||
\\ }
|
|
||||||
\\ },
|
|
||||||
\\ "request": {}
|
|
||||||
\\}
|
|
||||||
;
|
|
||||||
|
|
||||||
const parsed = try json.parseFromSlice(json.Value, std.testing.allocator, json_str, .{});
|
|
||||||
defer parsed.deinit();
|
|
||||||
|
|
||||||
const ctx = parseContext(parsed.value);
|
|
||||||
try std.testing.expect(ctx != null);
|
|
||||||
try std.testing.expectEqualStrings("https://api.amazonalexa.com", ctx.?.api_endpoint);
|
|
||||||
try std.testing.expectEqualStrings("test-token-123", ctx.?.api_access_token);
|
|
||||||
try std.testing.expectEqualStrings("amzn1.ask.device.XXXX", ctx.?.device_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "parseContext with missing context" {
|
|
||||||
const json_str =
|
|
||||||
\\{
|
|
||||||
\\ "request": {}
|
|
||||||
\\}
|
|
||||||
;
|
|
||||||
|
|
||||||
const parsed = try json.parseFromSlice(json.Value, std.testing.allocator, json_str, .{});
|
|
||||||
defer parsed.deinit();
|
|
||||||
|
|
||||||
const ctx = parseContext(parsed.value);
|
|
||||||
try std.testing.expect(ctx == null);
|
|
||||||
}
|
|
||||||
|
|
@ -251,7 +251,7 @@ pub const Client = struct {
|
||||||
base_url: []const u8,
|
base_url: []const u8,
|
||||||
token: []const u8,
|
token: []const u8,
|
||||||
http_interface: HttpClientInterface,
|
http_interface: HttpClientInterface,
|
||||||
client: ?*HttpClient = null,
|
client: ?HttpClient = null,
|
||||||
|
|
||||||
/// Normal initialization. If you are writing tests and need a mock
|
/// Normal initialization. If you are writing tests and need a mock
|
||||||
/// implemenation for unit tests, use MockHttpClient with the initInterface
|
/// implemenation for unit tests, use MockHttpClient with the initInterface
|
||||||
|
|
@ -261,8 +261,7 @@ pub const Client = struct {
|
||||||
base_url: []const u8,
|
base_url: []const u8,
|
||||||
token: []const u8,
|
token: []const u8,
|
||||||
) Client {
|
) Client {
|
||||||
const client = allocator.create(HttpClient) catch @panic("OOM");
|
var client = HttpClient.init(allocator);
|
||||||
client.* = HttpClient.init(allocator);
|
|
||||||
return .{
|
return .{
|
||||||
.allocator = allocator,
|
.allocator = allocator,
|
||||||
.base_url = base_url,
|
.base_url = base_url,
|
||||||
|
|
@ -289,10 +288,7 @@ pub const Client = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn deinit(self: Client) void {
|
pub fn deinit(self: Client) void {
|
||||||
if (self.client) |c| {
|
if (self.client) |c| c.deinit();
|
||||||
c.deinit();
|
|
||||||
self.allocator.destroy(c);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
/// Fetch all entity states from Home Assistant
|
/// Fetch all entity states from Home Assistant
|
||||||
pub fn getStates(self: *Client) !struct { entities: []Entity, parsed: json.Parsed(json.Value) } {
|
pub fn getStates(self: *Client) !struct { entities: []Entity, parsed: json.Parsed(json.Value) } {
|
||||||
|
|
@ -514,22 +510,8 @@ fn searchWordsInName(friendly_name: []const u8, search: []const u8) usize {
|
||||||
return important_matches;
|
return important_matches;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Count important (non-noise) words in a friendly name
|
|
||||||
fn countImportantWords(friendly_name: []const u8) usize {
|
|
||||||
var name_buf: [256]u8 = undefined;
|
|
||||||
const name_len = @min(friendly_name.len, name_buf.len);
|
|
||||||
const name_lower = std.ascii.lowerString(name_buf[0..name_len], friendly_name[0..name_len]);
|
|
||||||
|
|
||||||
var count: usize = 0;
|
|
||||||
var words = std.mem.tokenizeAny(u8, name_lower, " _-");
|
|
||||||
while (words.next()) |word| {
|
|
||||||
if (!isNoiseWord(word)) count += 1;
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find entities matching the given name
|
/// Find entities matching the given name
|
||||||
/// Priority: 1) Exact match, 2) All search words in friendly name (prefer higher match ratio), 3) Fuzzy match
|
/// Priority: 1) Exact match, 2) All search words in friendly name (prefer more matches, then shorter name), 3) Fuzzy match
|
||||||
pub fn findEntitiesByName(
|
pub fn findEntitiesByName(
|
||||||
allocator: Allocator,
|
allocator: Allocator,
|
||||||
entities: []const Entity,
|
entities: []const Entity,
|
||||||
|
|
@ -569,26 +551,27 @@ pub fn findEntitiesByName(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have ties, prefer entities with fewer important words (higher match ratio)
|
// If we have ties, prefer shorter friendly names (more specific/direct match)
|
||||||
// e.g., "bedroom" (1 word) over "Jack Bedroom Light" (2 important words) when searching "bedroom"
|
|
||||||
if (matches.items.len > 1) {
|
if (matches.items.len > 1) {
|
||||||
var best_idx: usize = matches.items[0];
|
var shortest_idx: usize = matches.items[0];
|
||||||
var fewest_words: usize = countImportantWords(entities[best_idx].friendly_name);
|
var shortest_len: usize = entities[shortest_idx].friendly_name.len;
|
||||||
var unique_best = true;
|
|
||||||
|
|
||||||
for (matches.items[1..]) |idx| {
|
for (matches.items[1..]) |idx| {
|
||||||
const word_count = countImportantWords(entities[idx].friendly_name);
|
if (entities[idx].friendly_name.len < shortest_len) {
|
||||||
if (word_count < fewest_words) {
|
shortest_idx = idx;
|
||||||
best_idx = idx;
|
shortest_len = entities[idx].friendly_name.len;
|
||||||
fewest_words = word_count;
|
|
||||||
unique_best = true;
|
|
||||||
} else if (word_count == fewest_words) {
|
|
||||||
unique_best = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (unique_best)
|
// Check if there's a unique shortest
|
||||||
return .{ .single = best_idx };
|
var count_at_shortest: usize = 0;
|
||||||
|
for (matches.items) |idx| {
|
||||||
|
if (entities[idx].friendly_name.len == shortest_len)
|
||||||
|
count_at_shortest += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count_at_shortest == 1)
|
||||||
|
return .{ .single = shortest_idx };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matches.items.len == 1)
|
if (matches.items.len == 1)
|
||||||
|
|
@ -614,7 +597,7 @@ pub fn findEntitiesByName(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format a detailed state response for an entity
|
/// Format a detailed state response for an entity
|
||||||
pub fn formatStateResponse(allocator: Allocator, entity: *const Entity, utc_offset: ?i32) ![]const u8 {
|
pub fn formatStateResponse(allocator: Allocator, entity: *const Entity) ![]const u8 {
|
||||||
var response: std.ArrayListUnmanaged(u8) = .empty;
|
var response: std.ArrayListUnmanaged(u8) = .empty;
|
||||||
errdefer response.deinit(allocator);
|
errdefer response.deinit(allocator);
|
||||||
|
|
||||||
|
|
@ -635,29 +618,13 @@ pub fn formatStateResponse(allocator: Allocator, entity: *const Entity, utc_offs
|
||||||
try writer.print(". Target temperature is {d:.0} degrees", .{target});
|
try writer.print(". Target temperature is {d:.0} degrees", .{target});
|
||||||
if (entity.current_temperature) |current|
|
if (entity.current_temperature) |current|
|
||||||
try writer.print(". Current temperature is {d:.0} degrees", .{current});
|
try writer.print(". Current temperature is {d:.0} degrees", .{current});
|
||||||
}
|
} else if (std.mem.eql(u8, entity.domain, "binary_sensor") or
|
||||||
|
|
||||||
// Add time since last change for lights, switches, binary sensors, and covers
|
|
||||||
if (std.mem.eql(u8, entity.domain, "light") or
|
|
||||||
std.mem.eql(u8, entity.domain, "switch") or
|
|
||||||
std.mem.eql(u8, entity.domain, "binary_sensor") or
|
|
||||||
std.mem.eql(u8, entity.domain, "cover"))
|
std.mem.eql(u8, entity.domain, "cover"))
|
||||||
{
|
{
|
||||||
if (entity.last_changed) |lc| {
|
// Add time since last change if available
|
||||||
if (parseTimestamp(lc)) |seconds| {
|
if (entity.last_changed) |lc|
|
||||||
const duration = formatDuration(allocator, seconds) catch null;
|
if (parseTimeAgo(lc)) |ago|
|
||||||
const time_str = formatTimeFromTimestamp(allocator, lc, utc_offset) catch null;
|
try writer.print(" and has been {s} for {s}", .{ entity.state, ago });
|
||||||
if (duration) |dur| {
|
|
||||||
defer allocator.free(dur);
|
|
||||||
if (time_str) |ts| {
|
|
||||||
defer allocator.free(ts);
|
|
||||||
try writer.print(", and has been {s} for {s}, since {s}", .{ entity.state, dur, ts });
|
|
||||||
} else {
|
|
||||||
try writer.print(", and has been {s} for {s}", .{ entity.state, dur });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try writer.writeAll(".");
|
try writer.writeAll(".");
|
||||||
|
|
@ -665,119 +632,15 @@ pub fn formatStateResponse(allocator: Allocator, entity: *const Entity, utc_offs
|
||||||
return response.toOwnedSlice(allocator);
|
return response.toOwnedSlice(allocator);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse ISO timestamp and return duration in seconds since that time
|
/// Parse ISO timestamp and return human-readable "time ago" string
|
||||||
fn parseTimestamp(iso_timestamp: []const u8) ?i64 {
|
fn parseTimeAgo(iso_timestamp: []const u8) ?[]const u8 {
|
||||||
// Expected format: 2024-01-15T10:30:45.123456+00:00 or similar
|
// Simple heuristic: check if it looks like an ISO timestamp
|
||||||
if (iso_timestamp.len < 19) return null;
|
// In production, you'd parse this properly
|
||||||
|
if (iso_timestamp.len < 10) return null;
|
||||||
|
|
||||||
// Parse date components
|
// For now, just return a generic message
|
||||||
const year = std.fmt.parseInt(u16, iso_timestamp[0..4], 10) catch return null;
|
// TODO: Implement proper time parsing
|
||||||
if (iso_timestamp[4] != '-') return null;
|
return "some time";
|
||||||
const month = std.fmt.parseInt(u4, iso_timestamp[5..7], 10) catch return null;
|
|
||||||
if (iso_timestamp[7] != '-') return null;
|
|
||||||
const day = std.fmt.parseInt(u5, iso_timestamp[8..10], 10) catch return null;
|
|
||||||
if (iso_timestamp[10] != 'T') return null;
|
|
||||||
const hour = std.fmt.parseInt(u5, iso_timestamp[11..13], 10) catch return null;
|
|
||||||
if (iso_timestamp[13] != ':') return null;
|
|
||||||
const minute = std.fmt.parseInt(u6, iso_timestamp[14..16], 10) catch return null;
|
|
||||||
if (iso_timestamp[16] != ':') return null;
|
|
||||||
const second = std.fmt.parseInt(u6, iso_timestamp[17..19], 10) catch return null;
|
|
||||||
|
|
||||||
// Calculate days from epoch (1970-01-01)
|
|
||||||
// Count days for complete years
|
|
||||||
var days: i64 = 0;
|
|
||||||
var y: u16 = 1970;
|
|
||||||
while (y < year) : (y += 1) {
|
|
||||||
days += if (std.time.epoch.isLeapYear(y)) 366 else 365;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add days for complete months in current year
|
|
||||||
const months = [_]u5{ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
|
|
||||||
var m: u4 = 1;
|
|
||||||
while (m < month) : (m += 1) {
|
|
||||||
days += months[m - 1];
|
|
||||||
if (m == 2 and std.time.epoch.isLeapYear(year)) days += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add days in current month
|
|
||||||
days += day - 1;
|
|
||||||
|
|
||||||
const timestamp_epoch = days * std.time.s_per_day +
|
|
||||||
@as(i64, hour) * 3600 + @as(i64, minute) * 60 + @as(i64, second);
|
|
||||||
|
|
||||||
// Get current time
|
|
||||||
const now = std.time.timestamp();
|
|
||||||
|
|
||||||
return now - timestamp_epoch;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Format duration as human-readable string
|
|
||||||
fn formatDuration(allocator: Allocator, seconds: i64) ![]const u8 {
|
|
||||||
if (seconds < 0) return try allocator.dupe(u8, "just now");
|
|
||||||
|
|
||||||
const minutes = @divFloor(seconds, 60);
|
|
||||||
const hours = @divFloor(minutes, 60);
|
|
||||||
const days = @divFloor(hours, 24);
|
|
||||||
|
|
||||||
if (days > 0) {
|
|
||||||
if (days == 1) {
|
|
||||||
const remaining_hours = @mod(hours, 24);
|
|
||||||
if (remaining_hours > 0) {
|
|
||||||
return try std.fmt.allocPrint(allocator, "1 day and {d} hour{s}", .{
|
|
||||||
remaining_hours,
|
|
||||||
if (remaining_hours == 1) "" else "s",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return try allocator.dupe(u8, "1 day");
|
|
||||||
}
|
|
||||||
return try std.fmt.allocPrint(allocator, "{d} days", .{days});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hours > 0) {
|
|
||||||
const remaining_minutes = @mod(minutes, 60);
|
|
||||||
if (remaining_minutes > 0) {
|
|
||||||
return try std.fmt.allocPrint(allocator, "{d} hour{s} and {d} minute{s}", .{
|
|
||||||
hours,
|
|
||||||
if (hours == 1) "" else "s",
|
|
||||||
remaining_minutes,
|
|
||||||
if (remaining_minutes == 1) "" else "s",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return try std.fmt.allocPrint(allocator, "{d} hour{s}", .{ hours, if (hours == 1) "" else "s" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (minutes > 0) {
|
|
||||||
return try std.fmt.allocPrint(allocator, "{d} minute{s}", .{ minutes, if (minutes == 1) "" else "s" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (seconds < 10) {
|
|
||||||
return try allocator.dupe(u8, "just now");
|
|
||||||
}
|
|
||||||
|
|
||||||
return try std.fmt.allocPrint(allocator, "{d} seconds", .{seconds});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Format time from ISO timestamp as "11:30 AM" style, adjusted for timezone.
|
|
||||||
/// If utc_offset is null, returns error (caller should omit "since" time).
|
|
||||||
fn formatTimeFromTimestamp(allocator: Allocator, iso_timestamp: []const u8, utc_offset: ?i32) ![]const u8 {
|
|
||||||
const offset = utc_offset orelse return error.NoTimezone;
|
|
||||||
|
|
||||||
if (iso_timestamp.len < 16) return error.InvalidTimestamp;
|
|
||||||
|
|
||||||
const hour_24_utc = std.fmt.parseInt(i32, iso_timestamp[11..13], 10) catch return error.InvalidTimestamp;
|
|
||||||
const minute = std.fmt.parseInt(u6, iso_timestamp[14..16], 10) catch return error.InvalidTimestamp;
|
|
||||||
|
|
||||||
// Apply timezone offset (offset is in seconds, convert to hours)
|
|
||||||
const offset_hours = @divTrunc(offset, 3600);
|
|
||||||
const hour_24_local = @mod(hour_24_utc + offset_hours + 24, 24);
|
|
||||||
|
|
||||||
const am_pm: []const u8 = if (hour_24_local < 12) "AM" else "PM";
|
|
||||||
const hour_12: i32 = if (hour_24_local == 0) 12 else if (hour_24_local > 12) hour_24_local - 12 else hour_24_local;
|
|
||||||
|
|
||||||
if (minute == 0) {
|
|
||||||
return try std.fmt.allocPrint(allocator, "{d} {s}", .{ hour_12, am_pm });
|
|
||||||
}
|
|
||||||
return try std.fmt.allocPrint(allocator, "{d}:{d:0>2} {s}", .{ hour_12, minute, am_pm });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format the "which one?" clarification prompt
|
/// Format the "which one?" clarification prompt
|
||||||
|
|
@ -816,7 +679,6 @@ pub fn handleDeviceAction(
|
||||||
action: Action,
|
action: Action,
|
||||||
device_name: []const u8,
|
device_name: []const u8,
|
||||||
value: ?f32,
|
value: ?f32,
|
||||||
utc_offset: ?i32,
|
|
||||||
) !ActionResult {
|
) !ActionResult {
|
||||||
// Fetch all entities
|
// Fetch all entities
|
||||||
const states_result = client.getStates() catch |err| {
|
const states_result = client.getStates() catch |err| {
|
||||||
|
|
@ -947,7 +809,7 @@ pub fn handleDeviceAction(
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
.query_state => {
|
.query_state => {
|
||||||
const response = try formatStateResponse(allocator, entity, utc_offset);
|
const response = try formatStateResponse(allocator, entity);
|
||||||
return ActionResult{
|
return ActionResult{
|
||||||
.speech = response,
|
.speech = response,
|
||||||
.end_session = true,
|
.end_session = true,
|
||||||
|
|
@ -1009,93 +871,104 @@ test "fuzzyMatchWord handles underscores and hyphens" {
|
||||||
try std.testing.expect(fuzzyMatchWord("living-room-lamp", "room"));
|
try std.testing.expect(fuzzyMatchWord("living-room-lamp", "room"));
|
||||||
}
|
}
|
||||||
|
|
||||||
test "findEntitiesByName - spoken phrase resolution" {
|
test "findEntitiesByName single match" {
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
|
|
||||||
// Test entities representing a typical Home Assistant setup
|
|
||||||
const entities = [_]Entity{
|
const entities = [_]Entity{
|
||||||
.{ .entity_id = "light.bedroom", .state = "off", .friendly_name = "Bedroom", .domain = "light", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
|
.{ .entity_id = "light.bedroom", .state = "on", .friendly_name = "Bedroom Light", .domain = "light", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
|
||||||
.{ .entity_id = "light.jack_bedroom", .state = "off", .friendly_name = "Jack Bedroom Light", .domain = "light", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
|
|
||||||
.{ .entity_id = "light.kitchen", .state = "off", .friendly_name = "Kitchen Light", .domain = "light", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
|
.{ .entity_id = "light.kitchen", .state = "off", .friendly_name = "Kitchen Light", .domain = "light", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
|
||||||
.{ .entity_id = "light.living_room", .state = "off", .friendly_name = "Living Room Light", .domain = "light", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
|
|
||||||
.{ .entity_id = "climate.thermostat", .state = "heat", .friendly_name = "Thermostat", .domain = "climate", .last_changed = null, .brightness = null, .temperature = 72, .current_temperature = 68 },
|
|
||||||
.{ .entity_id = "climate.upstairs", .state = "cool", .friendly_name = "Upstairs Thermostat", .domain = "climate", .last_changed = null, .brightness = null, .temperature = 70, .current_temperature = 72 },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Expected result type for test cases
|
const result = try findEntitiesByName(allocator, &entities, "bedroom");
|
||||||
const Expected = union(enum) {
|
try std.testing.expect(result == .single);
|
||||||
single: []const u8, // friendly_name of expected match
|
try std.testing.expectEqual(@as(usize, 0), result.single);
|
||||||
multiple: usize, // number of matches expected
|
}
|
||||||
none,
|
|
||||||
|
test "findEntitiesByName multiple matches" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const entities = [_]Entity{
|
||||||
|
.{ .entity_id = "light.bedroom", .state = "on", .friendly_name = "Bedroom Light", .domain = "light", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
|
||||||
|
.{ .entity_id = "light.kitchen", .state = "off", .friendly_name = "Kitchen Light", .domain = "light", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
|
||||||
};
|
};
|
||||||
|
|
||||||
const TestCase = struct {
|
const result = try findEntitiesByName(allocator, &entities, "light");
|
||||||
spoken: []const u8,
|
try std.testing.expect(result == .multiple);
|
||||||
expected: Expected,
|
defer allocator.free(result.multiple);
|
||||||
|
try std.testing.expectEqual(@as(usize, 2), result.multiple.len);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "findEntitiesByName no match" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const entities = [_]Entity{
|
||||||
|
.{ .entity_id = "light.bedroom", .state = "on", .friendly_name = "Bedroom Light", .domain = "light", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
|
||||||
};
|
};
|
||||||
|
|
||||||
const test_cases = [_]TestCase{
|
const result = try findEntitiesByName(allocator, &entities, "garage");
|
||||||
// Exact matches
|
try std.testing.expect(result == .none);
|
||||||
.{ .spoken = "bedroom", .expected = .{ .single = "Bedroom" } },
|
}
|
||||||
.{ .spoken = "Bedroom", .expected = .{ .single = "Bedroom" } },
|
|
||||||
.{ .spoken = "BEDROOM", .expected = .{ .single = "Bedroom" } },
|
|
||||||
.{ .spoken = "thermostat", .expected = .{ .single = "Thermostat" } },
|
|
||||||
.{ .spoken = "kitchen light", .expected = .{ .single = "Kitchen Light" } },
|
|
||||||
|
|
||||||
// Specific name with multiple words matches over generic
|
test "findEntitiesByName exact match takes priority" {
|
||||||
.{ .spoken = "jack bedroom", .expected = .{ .single = "Jack Bedroom Light" } },
|
const allocator = std.testing.allocator;
|
||||||
.{ .spoken = "jack bedroom light", .expected = .{ .single = "Jack Bedroom Light" } },
|
const entities = [_]Entity{
|
||||||
.{ .spoken = "jack's bedroom", .expected = .{ .single = "Jack Bedroom Light" } },
|
.{ .entity_id = "switch.master_bedroom", .state = "off", .friendly_name = "Bedroom", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
|
||||||
.{ .spoken = "jack's bedroom light", .expected = .{ .single = "Jack Bedroom Light" } },
|
.{ .entity_id = "switch.jack_bedroom", .state = "on", .friendly_name = "Jack Bedroom Light", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
|
||||||
.{ .spoken = "upstairs thermostat", .expected = .{ .single = "Upstairs Thermostat" } },
|
|
||||||
.{ .spoken = "living room", .expected = .{ .single = "Living Room Light" } },
|
|
||||||
.{ .spoken = "living room light", .expected = .{ .single = "Living Room Light" } },
|
|
||||||
|
|
||||||
// Ambiguous spoken phrase resolves to simpler entity name
|
|
||||||
.{ .spoken = "bedroom light", .expected = .{ .single = "Bedroom" } }, // "Bedroom" has 1 important word, "Jack Bedroom Light" has 2
|
|
||||||
.{ .spoken = "light", .expected = .{ .multiple = 3 } }, // fuzzy matches entities with "Light" in name (not "Bedroom" which has no "Light")
|
|
||||||
|
|
||||||
// No match
|
|
||||||
.{ .spoken = "garage", .expected = .none },
|
|
||||||
.{ .spoken = "garage door", .expected = .none },
|
|
||||||
.{ .spoken = "front porch", .expected = .none },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
for (test_cases) |tc| {
|
// "bedroom" exactly matches "Bedroom"
|
||||||
const result = try findEntitiesByName(allocator, &entities, tc.spoken);
|
const result = try findEntitiesByName(allocator, &entities, "bedroom");
|
||||||
|
try std.testing.expect(result == .single);
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), result.single);
|
||||||
|
}
|
||||||
|
|
||||||
switch (tc.expected) {
|
test "findEntitiesByName noise words dont override important matches" {
|
||||||
.single => |expected_name| {
|
const allocator = std.testing.allocator;
|
||||||
if (result != .single) {
|
const entities = [_]Entity{
|
||||||
std.debug.print("FAIL: '{s}' expected single match '{s}', got {}\n", .{ tc.spoken, expected_name, result });
|
.{ .entity_id = "switch.master_bedroom", .state = "off", .friendly_name = "Bedroom", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
|
||||||
return error.TestUnexpectedResult;
|
.{ .entity_id = "switch.jack_bedroom", .state = "on", .friendly_name = "Jack Bedroom Light", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
|
||||||
}
|
};
|
||||||
const actual_name = entities[result.single].friendly_name;
|
|
||||||
if (!std.mem.eql(u8, actual_name, expected_name)) {
|
// "bedroom light" should still match "Bedroom" - "light" is a noise word
|
||||||
std.debug.print("FAIL: '{s}' expected '{s}', got '{s}'\n", .{ tc.spoken, expected_name, actual_name });
|
const result = try findEntitiesByName(allocator, &entities, "bedroom light");
|
||||||
return error.TestUnexpectedResult;
|
try std.testing.expect(result == .single);
|
||||||
}
|
try std.testing.expectEqual(@as(usize, 0), result.single);
|
||||||
},
|
}
|
||||||
.multiple => |expected_count| {
|
|
||||||
if (result != .multiple) {
|
test "findEntitiesByName specific name matches over generic" {
|
||||||
std.debug.print("FAIL: '{s}' expected {d} matches, got {}\n", .{ tc.spoken, expected_count, result });
|
const allocator = std.testing.allocator;
|
||||||
return error.TestUnexpectedResult;
|
const entities = [_]Entity{
|
||||||
}
|
.{ .entity_id = "switch.master_bedroom", .state = "off", .friendly_name = "Bedroom", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
|
||||||
defer allocator.free(result.multiple);
|
.{ .entity_id = "switch.jack_bedroom", .state = "on", .friendly_name = "Jack Bedroom Light", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
|
||||||
if (result.multiple.len != expected_count) {
|
};
|
||||||
std.debug.print("FAIL: '{s}' expected {d} matches, got {d}\n", .{ tc.spoken, expected_count, result.multiple.len });
|
|
||||||
return error.TestUnexpectedResult;
|
// "jack bedroom" should match "Jack Bedroom Light" - has both important words
|
||||||
}
|
const result = try findEntitiesByName(allocator, &entities, "jack bedroom");
|
||||||
},
|
try std.testing.expect(result == .single);
|
||||||
.none => {
|
try std.testing.expectEqual(@as(usize, 1), result.single);
|
||||||
if (result != .none) {
|
}
|
||||||
std.debug.print("FAIL: '{s}' expected no match, got {}\n", .{ tc.spoken, result });
|
|
||||||
if (result == .multiple) allocator.free(result.multiple);
|
test "findEntitiesByName handles possessives" {
|
||||||
return error.TestUnexpectedResult;
|
const allocator = std.testing.allocator;
|
||||||
}
|
const entities = [_]Entity{
|
||||||
},
|
.{ .entity_id = "switch.master_bedroom", .state = "off", .friendly_name = "Bedroom", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
|
||||||
}
|
.{ .entity_id = "switch.jack_bedroom", .state = "on", .friendly_name = "Jack Bedroom Light", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
|
||||||
}
|
};
|
||||||
|
|
||||||
|
// "jack's bedroom" should match "Jack Bedroom Light"
|
||||||
|
const result = try findEntitiesByName(allocator, &entities, "jack's bedroom");
|
||||||
|
try std.testing.expect(result == .single);
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), result.single);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "findEntitiesByName full specific match" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const entities = [_]Entity{
|
||||||
|
.{ .entity_id = "switch.master_bedroom", .state = "off", .friendly_name = "Bedroom", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
|
||||||
|
.{ .entity_id = "switch.jack_bedroom", .state = "on", .friendly_name = "Jack Bedroom Light", .domain = "switch", .last_changed = null, .brightness = null, .temperature = null, .current_temperature = null },
|
||||||
|
};
|
||||||
|
|
||||||
|
// "jack bedroom light" should match "Jack Bedroom Light"
|
||||||
|
const result = try findEntitiesByName(allocator, &entities, "jack bedroom light");
|
||||||
|
try std.testing.expect(result == .single);
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), result.single);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "formatStateResponse light on with brightness" {
|
test "formatStateResponse light on with brightness" {
|
||||||
|
|
@ -1111,7 +984,7 @@ test "formatStateResponse light on with brightness" {
|
||||||
.current_temperature = null,
|
.current_temperature = null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = try formatStateResponse(allocator, &entity, null);
|
const response = try formatStateResponse(allocator, &entity);
|
||||||
defer allocator.free(response);
|
defer allocator.free(response);
|
||||||
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, response, "Bedroom Light") != null);
|
try std.testing.expect(std.mem.indexOf(u8, response, "Bedroom Light") != null);
|
||||||
|
|
@ -1132,7 +1005,7 @@ test "formatStateResponse light off" {
|
||||||
.current_temperature = null,
|
.current_temperature = null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = try formatStateResponse(allocator, &entity, null);
|
const response = try formatStateResponse(allocator, &entity);
|
||||||
defer allocator.free(response);
|
defer allocator.free(response);
|
||||||
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, response, "off") != null);
|
try std.testing.expect(std.mem.indexOf(u8, response, "off") != null);
|
||||||
|
|
@ -1152,7 +1025,7 @@ test "formatStateResponse climate with temperatures" {
|
||||||
.current_temperature = 68.0,
|
.current_temperature = 68.0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = try formatStateResponse(allocator, &entity, null);
|
const response = try formatStateResponse(allocator, &entity);
|
||||||
defer allocator.free(response);
|
defer allocator.free(response);
|
||||||
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, response, "Thermostat") != null);
|
try std.testing.expect(std.mem.indexOf(u8, response, "Thermostat") != null);
|
||||||
|
|
@ -1270,7 +1143,7 @@ test "handleDeviceAction turn on light success" {
|
||||||
mock.interface(),
|
mock.interface(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = try handleDeviceAction(allocator, &client, .turn_on, "bedroom", null, null);
|
const result = try handleDeviceAction(allocator, &client, .turn_on, "bedroom", null);
|
||||||
defer allocator.free(result.speech);
|
defer allocator.free(result.speech);
|
||||||
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, result.speech, "Turned on") != null);
|
try std.testing.expect(std.mem.indexOf(u8, result.speech, "Turned on") != null);
|
||||||
|
|
@ -1297,7 +1170,7 @@ test "handleDeviceAction device not found" {
|
||||||
mock.interface(),
|
mock.interface(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = try handleDeviceAction(allocator, &client, .turn_on, "garage", null, null);
|
const result = try handleDeviceAction(allocator, &client, .turn_on, "garage", null);
|
||||||
defer allocator.free(result.speech);
|
defer allocator.free(result.speech);
|
||||||
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, result.speech, "couldn't find") != null);
|
try std.testing.expect(std.mem.indexOf(u8, result.speech, "couldn't find") != null);
|
||||||
|
|
@ -1324,7 +1197,7 @@ test "handleDeviceAction multiple matches returns clarification" {
|
||||||
mock.interface(),
|
mock.interface(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = try handleDeviceAction(allocator, &client, .turn_on, "light", null, null);
|
const result = try handleDeviceAction(allocator, &client, .turn_on, "light", null);
|
||||||
defer allocator.free(result.speech);
|
defer allocator.free(result.speech);
|
||||||
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, result.speech, "Which one") != null);
|
try std.testing.expect(std.mem.indexOf(u8, result.speech, "Which one") != null);
|
||||||
|
|
@ -1352,7 +1225,7 @@ test "handleDeviceAction query state" {
|
||||||
mock.interface(),
|
mock.interface(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = try handleDeviceAction(allocator, &client, .query_state, "bedroom", null, null);
|
const result = try handleDeviceAction(allocator, &client, .query_state, "bedroom", null);
|
||||||
defer allocator.free(result.speech);
|
defer allocator.free(result.speech);
|
||||||
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, result.speech, "Bedroom Light") != null);
|
try std.testing.expect(std.mem.indexOf(u8, result.speech, "Bedroom Light") != null);
|
||||||
|
|
@ -1381,7 +1254,7 @@ test "handleDeviceAction set brightness" {
|
||||||
mock.interface(),
|
mock.interface(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = try handleDeviceAction(allocator, &client, .set_value, "bedroom", 50.0, null);
|
const result = try handleDeviceAction(allocator, &client, .set_value, "bedroom", 50.0);
|
||||||
defer allocator.free(result.speech);
|
defer allocator.free(result.speech);
|
||||||
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, result.speech, "Set") != null);
|
try std.testing.expect(std.mem.indexOf(u8, result.speech, "Set") != null);
|
||||||
|
|
|
||||||
91
src/main.zig
91
src/main.zig
|
|
@ -3,8 +3,6 @@ const json = std.json;
|
||||||
const lambda = @import("lambda_runtime");
|
const lambda = @import("lambda_runtime");
|
||||||
const rinnai = @import("rinnai");
|
const rinnai = @import("rinnai");
|
||||||
const homeassistant = @import("homeassistant.zig");
|
const homeassistant = @import("homeassistant.zig");
|
||||||
const alexa = @import("alexa.zig");
|
|
||||||
const timezone = @import("timezone.zig");
|
|
||||||
const Config = @import("Config.zig");
|
const Config = @import("Config.zig");
|
||||||
const builtin = @import("builtin");
|
const builtin = @import("builtin");
|
||||||
|
|
||||||
|
|
@ -21,7 +19,7 @@ pub fn main() !u8 {
|
||||||
// Check for --help first (no config needed)
|
// Check for --help first (no config needed)
|
||||||
for (args) |arg| {
|
for (args) |arg| {
|
||||||
if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
|
if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
|
||||||
printHelp(args) catch return 1;
|
printHelp() catch return 1;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -55,6 +53,7 @@ pub fn main() !u8 {
|
||||||
|
|
||||||
/// Main Alexa request handler
|
/// Main Alexa request handler
|
||||||
fn handler(allocator: std.mem.Allocator, event_data: []const u8, config: Config) anyerror![]const u8 {
|
fn handler(allocator: std.mem.Allocator, event_data: []const u8, config: Config) anyerror![]const u8 {
|
||||||
|
log.info("Received Alexa request: {d} bytes", .{event_data.len});
|
||||||
|
|
||||||
// Parse the Alexa request
|
// Parse the Alexa request
|
||||||
const parsed = json.parseFromSlice(json.Value, allocator, event_data, .{}) catch |err| {
|
const parsed = json.parseFromSlice(json.Value, allocator, event_data, .{}) catch |err| {
|
||||||
|
|
@ -85,7 +84,7 @@ fn handler(allocator: std.mem.Allocator, event_data: []const u8, config: Config)
|
||||||
if (std.mem.eql(u8, request_type_str, "LaunchRequest")) {
|
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, or control your smart home devices.", false);
|
return buildAlexaResponse(allocator, "What would you like me to do? You can ask me to start the hot water, or control your smart home devices.", false);
|
||||||
} else if (std.mem.eql(u8, request_type_str, "IntentRequest")) {
|
} else if (std.mem.eql(u8, request_type_str, "IntentRequest")) {
|
||||||
return handleIntentRequest(allocator, request_obj, parsed.value, config);
|
return handleIntentRequest(allocator, request_obj, config);
|
||||||
} else if (std.mem.eql(u8, request_type_str, "SessionEndedRequest")) {
|
} else if (std.mem.eql(u8, request_type_str, "SessionEndedRequest")) {
|
||||||
return buildAlexaResponse(allocator, "", true);
|
return buildAlexaResponse(allocator, "", true);
|
||||||
}
|
}
|
||||||
|
|
@ -94,7 +93,7 @@ fn handler(allocator: std.mem.Allocator, event_data: []const u8, config: Config)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle Alexa intent requests
|
/// Handle Alexa intent requests
|
||||||
fn handleIntentRequest(allocator: std.mem.Allocator, request_obj: json.Value, full_request: json.Value, config: Config) ![]const u8 {
|
fn handleIntentRequest(allocator: std.mem.Allocator, request_obj: json.Value, config: Config) ![]const u8 {
|
||||||
const intent_obj = request_obj.object.get("intent") orelse {
|
const intent_obj = request_obj.object.get("intent") orelse {
|
||||||
if (!builtin.is_test) 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);
|
return buildAlexaResponse(allocator, "I couldn't understand your intent.", true);
|
||||||
|
|
@ -117,7 +116,7 @@ fn handleIntentRequest(allocator: std.mem.Allocator, request_obj: json.Value, fu
|
||||||
} else if (std.mem.eql(u8, intent_name, "HomeAssistantIntent") or
|
} else if (std.mem.eql(u8, intent_name, "HomeAssistantIntent") or
|
||||||
std.mem.eql(u8, intent_name, "SetDeviceValueIntent"))
|
std.mem.eql(u8, intent_name, "SetDeviceValueIntent"))
|
||||||
{
|
{
|
||||||
return handleHomeAssistantIntent(allocator, intent_obj, full_request, config);
|
return handleHomeAssistantIntent(allocator, intent_obj, config);
|
||||||
} else if (std.mem.eql(u8, intent_name, "WeezTheJuiceIntent")) {
|
} else if (std.mem.eql(u8, intent_name, "WeezTheJuiceIntent")) {
|
||||||
return handleWeezTheJuice(allocator, config);
|
return handleWeezTheJuice(allocator, config);
|
||||||
} else if (std.mem.eql(u8, intent_name, "AMAZON.HelpIntent")) {
|
} else if (std.mem.eql(u8, intent_name, "AMAZON.HelpIntent")) {
|
||||||
|
|
@ -179,7 +178,7 @@ fn handleRecirculateWater(allocator: std.mem.Allocator, config: Config) ![]const
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle the Home Assistant device control intent
|
/// Handle the Home Assistant device control intent
|
||||||
fn handleHomeAssistantIntent(allocator: std.mem.Allocator, intent_obj: json.Value, full_request: json.Value, config: Config) ![]const u8 {
|
fn handleHomeAssistantIntent(allocator: std.mem.Allocator, intent_obj: json.Value, config: Config) ![]const u8 {
|
||||||
// Get Home Assistant credentials from config
|
// Get Home Assistant credentials from config
|
||||||
const ha_url = config.home_assistant_url orelse {
|
const ha_url = config.home_assistant_url orelse {
|
||||||
log.err("HOME_ASSISTANT_URL not configured", .{});
|
log.err("HOME_ASSISTANT_URL not configured", .{});
|
||||||
|
|
@ -209,12 +208,6 @@ fn handleHomeAssistantIntent(allocator: std.mem.Allocator, intent_obj: json.Valu
|
||||||
);
|
);
|
||||||
defer client.deinit();
|
defer client.deinit();
|
||||||
|
|
||||||
// Only resolve timezone for state queries (avoids unnecessary Alexa API calls)
|
|
||||||
const utc_offset: ?i32 = if (params.action == .query_state)
|
|
||||||
resolveTimezone(allocator, full_request, config)
|
|
||||||
else
|
|
||||||
null;
|
|
||||||
|
|
||||||
// Execute the action
|
// Execute the action
|
||||||
const result = homeassistant.handleDeviceAction(
|
const result = homeassistant.handleDeviceAction(
|
||||||
allocator,
|
allocator,
|
||||||
|
|
@ -222,7 +215,6 @@ fn handleHomeAssistantIntent(allocator: std.mem.Allocator, intent_obj: json.Valu
|
||||||
params.action,
|
params.action,
|
||||||
params.device_name,
|
params.device_name,
|
||||||
params.value,
|
params.value,
|
||||||
utc_offset,
|
|
||||||
) catch |err| {
|
) catch |err| {
|
||||||
log.err("Home Assistant error: {}", .{err});
|
log.err("Home Assistant error: {}", .{err});
|
||||||
return buildAlexaResponse(allocator, "I had trouble communicating with Home Assistant. Please try again.", true);
|
return buildAlexaResponse(allocator, "I had trouble communicating with Home Assistant. Please try again.", true);
|
||||||
|
|
@ -256,7 +248,6 @@ fn handleWeezTheJuice(allocator: std.mem.Allocator, config: Config) ![]const u8
|
||||||
.toggle,
|
.toggle,
|
||||||
"bedroom",
|
"bedroom",
|
||||||
null,
|
null,
|
||||||
null, // No timezone needed for toggle
|
|
||||||
) catch |err| {
|
) catch |err| {
|
||||||
log.err("Home Assistant error: {}", .{err});
|
log.err("Home Assistant error: {}", .{err});
|
||||||
return buildAlexaResponse(allocator, "I had trouble weezin' the juice.", true);
|
return buildAlexaResponse(allocator, "I had trouble weezin' the juice.", true);
|
||||||
|
|
@ -352,42 +343,6 @@ fn extractSlotNumber(slots: ?json.Value, slot_name: []const u8) ?f32 {
|
||||||
return std.fmt.parseFloat(f32, value.string) catch null;
|
return std.fmt.parseFloat(f32, value.string) catch null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the UTC offset for timezone conversion.
|
|
||||||
/// In Lambda mode: uses TZ env var (Alexa API lookup disabled - too slow ~1s).
|
|
||||||
/// In local mode: uses TZ env var or /etc/timezone.
|
|
||||||
fn resolveTimezone(allocator: std.mem.Allocator, parsed_value: json.Value, config: Config) ?i32 {
|
|
||||||
_ = parsed_value;
|
|
||||||
_ = config;
|
|
||||||
|
|
||||||
// NOTE: Alexa Settings API timezone lookup is disabled because it takes ~1 second
|
|
||||||
// which is too much of our 3 second Lambda timeout budget. Instead, we use the
|
|
||||||
// TZ environment variable set in Lambda configuration.
|
|
||||||
//
|
|
||||||
// To re-enable Alexa timezone lookup, uncomment the following block:
|
|
||||||
//
|
|
||||||
// if (alexa.parseContext(parsed_value)) |context| {
|
|
||||||
// const ha_url = config.home_assistant_url orelse return resolveLocalTimezone(allocator);
|
|
||||||
// const ha_token = config.home_assistant_token orelse return resolveLocalTimezone(allocator);
|
|
||||||
// var ha_client = homeassistant.Client.init(allocator, ha_url, ha_token);
|
|
||||||
// defer ha_client.deinit();
|
|
||||||
// if (alexa.fetchTimezone(allocator, ha_client.httpInterface(), context)) |tz_name| {
|
|
||||||
// defer allocator.free(tz_name);
|
|
||||||
// if (timezone.getUtcOffset(allocator, tz_name)) |offset| {
|
|
||||||
// return offset;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
return resolveLocalTimezone(allocator);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve timezone from local system (TZ env var or /etc/timezone)
|
|
||||||
fn resolveLocalTimezone(allocator: std.mem.Allocator) ?i32 {
|
|
||||||
const tz_name = timezone.getLocalTimezone(allocator) orelse return null;
|
|
||||||
defer allocator.free(tz_name);
|
|
||||||
return timezone.getUtcOffset(allocator, tz_name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build an Alexa skill response JSON
|
/// Build an Alexa skill response JSON
|
||||||
fn buildAlexaResponse(allocator: std.mem.Allocator, speech: []const u8, end_session: bool) ![]const u8 {
|
fn buildAlexaResponse(allocator: std.mem.Allocator, speech: []const u8, end_session: bool) ![]const u8 {
|
||||||
// Escape speech for JSON
|
// Escape speech for JSON
|
||||||
|
|
@ -423,13 +378,9 @@ fn buildAlexaResponse(allocator: std.mem.Allocator, speech: []const u8, end_sess
|
||||||
// Local Testing Mode
|
// Local Testing Mode
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
fn printHelp(args: []const []const u8) !void {
|
fn printHelp() !void {
|
||||||
const prog = std.fs.path.basename(args[0]);
|
const help =
|
||||||
var stdout_buffer: [4096]u8 = undefined;
|
\\Usage: bootstrap [OPTIONS]
|
||||||
var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
|
|
||||||
const stdout = &stdout_writer.interface;
|
|
||||||
try stdout.print(
|
|
||||||
\\Usage: {0s} [OPTIONS]
|
|
||||||
\\
|
\\
|
||||||
\\Alexa skill handler for water recirculation and Home Assistant control.
|
\\Alexa skill handler for water recirculation and Home Assistant control.
|
||||||
\\Automatically detects Lambda environment via AWS_LAMBDA_RUNTIME_API.
|
\\Automatically detects Lambda environment via AWS_LAMBDA_RUNTIME_API.
|
||||||
|
|
@ -454,16 +405,16 @@ fn printHelp(args: []const []const u8) !void {
|
||||||
\\ AMAZON.CancelIntent Cancel/goodbye
|
\\ AMAZON.CancelIntent Cancel/goodbye
|
||||||
\\
|
\\
|
||||||
\\Examples:
|
\\Examples:
|
||||||
\\ {0s} --type=launch
|
\\ bootstrap --type=launch
|
||||||
\\ {0s} --intent=RecirculateWaterIntent
|
\\ bootstrap --intent=RecirculateWaterIntent
|
||||||
\\ {0s} --intent=HomeAssistantIntent --device="bedroom light" --action="turn on"
|
\\ bootstrap --intent=HomeAssistantIntent --device="bedroom light" --action="turn on"
|
||||||
\\ {0s} --intent=HomeAssistantIntent --device="bedroom light" --action="turn off"
|
\\ bootstrap --intent=HomeAssistantIntent --device="bedroom light" --action="turn off"
|
||||||
\\ {0s} --intent=HomeAssistantIntent --device="bedroom light" --action="toggle"
|
\\ bootstrap --intent=HomeAssistantIntent --device="bedroom light" --action="toggle"
|
||||||
\\ {0s} --intent=HomeAssistantIntent --device="bedroom light"
|
\\ bootstrap --intent=HomeAssistantIntent --device="bedroom light"
|
||||||
\\ (no action = query state)
|
\\ (no action = query state)
|
||||||
\\ {0s} --intent=SetDeviceValueIntent --device="bedroom light" --value=50
|
\\ bootstrap --intent=SetDeviceValueIntent --device="bedroom light" --value=50
|
||||||
\\ {0s} --intent=SetDeviceValueIntent --device="thermostat" --value=72
|
\\ bootstrap --intent=SetDeviceValueIntent --device="thermostat" --value=72
|
||||||
\\ {0s} --intent=WeezTheJuiceIntent
|
\\ bootstrap --intent=WeezTheJuiceIntent
|
||||||
\\
|
\\
|
||||||
\\Environment variables (or .env file in current directory):
|
\\Environment variables (or .env file in current directory):
|
||||||
\\ COGNITO_USERNAME Rinnai account username
|
\\ COGNITO_USERNAME Rinnai account username
|
||||||
|
|
@ -471,7 +422,11 @@ fn printHelp(args: []const []const u8) !void {
|
||||||
\\ HOME_ASSISTANT_URL Home Assistant URL (e.g., https://ha.example.com)
|
\\ HOME_ASSISTANT_URL Home Assistant URL (e.g., https://ha.example.com)
|
||||||
\\ HOME_ASSISTANT_TOKEN Home Assistant long-lived access token
|
\\ HOME_ASSISTANT_TOKEN Home Assistant long-lived access token
|
||||||
\\
|
\\
|
||||||
, .{prog});
|
;
|
||||||
|
var stdout_buffer: [4096]u8 = undefined;
|
||||||
|
var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
|
||||||
|
const stdout = &stdout_writer.interface;
|
||||||
|
try stdout.print("{s}", .{help});
|
||||||
try stdout.flush();
|
try stdout.flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
238
src/timezone.zig
238
src/timezone.zig
|
|
@ -1,238 +0,0 @@
|
||||||
//! Timezone utilities: TZif parsing and local timezone resolution.
|
|
||||||
|
|
||||||
const std = @import("std");
|
|
||||||
const builtin = @import("builtin");
|
|
||||||
const Allocator = std.mem.Allocator;
|
|
||||||
|
|
||||||
const log = std.log.scoped(.timezone);
|
|
||||||
|
|
||||||
// Suppress warnings during tests
|
|
||||||
fn warn(comptime fmt: []const u8, args: anytype) void {
|
|
||||||
if (!builtin.is_test) {
|
|
||||||
log.warn(fmt, args);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get UTC offset in seconds for a timezone name.
|
|
||||||
/// Reads /usr/share/zoneinfo/{name} and parses TZif format.
|
|
||||||
/// Returns null on any error (file not found, parse error, etc.)
|
|
||||||
pub fn getUtcOffset(allocator: Allocator, timezone_name: []const u8) ?i32 {
|
|
||||||
const path = std.fmt.allocPrint(allocator, "/usr/share/zoneinfo/{s}", .{timezone_name}) catch {
|
|
||||||
warn("Failed to allocate timezone path", .{});
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
defer allocator.free(path);
|
|
||||||
|
|
||||||
const file = std.fs.openFileAbsolute(path, .{}) catch {
|
|
||||||
warn("Failed to open timezone file: {s}", .{path});
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
defer file.close();
|
|
||||||
|
|
||||||
const data = file.readToEndAlloc(allocator, 64 * 1024) catch {
|
|
||||||
warn("Failed to read timezone file: {s}", .{path});
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
defer allocator.free(data);
|
|
||||||
|
|
||||||
return parseTzif(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get local timezone name from environment.
|
|
||||||
/// Checks TZ env var first, then reads /etc/timezone.
|
|
||||||
/// Returns owned slice that caller must free, or null if not found.
|
|
||||||
pub fn getLocalTimezone(allocator: Allocator) ?[]const u8 {
|
|
||||||
// First check TZ environment variable
|
|
||||||
if (std.process.getEnvVarOwned(allocator, "TZ")) |tz| {
|
|
||||||
// TZ can be a path like ":/etc/localtime" or just a name like "America/Los_Angeles"
|
|
||||||
// Strip leading colon if present
|
|
||||||
if (tz.len > 0 and tz[0] == ':') {
|
|
||||||
const stripped = allocator.dupe(u8, tz[1..]) catch {
|
|
||||||
allocator.free(tz);
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
allocator.free(tz);
|
|
||||||
return stripped;
|
|
||||||
}
|
|
||||||
return tz;
|
|
||||||
} else |_| {}
|
|
||||||
|
|
||||||
// Fall back to /etc/timezone
|
|
||||||
const file = std.fs.openFileAbsolute("/etc/timezone", .{}) catch {
|
|
||||||
warn("No TZ env var and failed to open /etc/timezone", .{});
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
defer file.close();
|
|
||||||
|
|
||||||
const content = file.readToEndAlloc(allocator, 256) catch {
|
|
||||||
warn("Failed to read /etc/timezone", .{});
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Trim trailing newline
|
|
||||||
const trimmed = std.mem.trimRight(u8, content, "\n\r \t");
|
|
||||||
if (trimmed.len == content.len) {
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = allocator.dupe(u8, trimmed) catch {
|
|
||||||
allocator.free(content);
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
allocator.free(content);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse TZif binary file format.
|
|
||||||
/// Returns most recent UTC offset in seconds, or null on error.
|
|
||||||
fn parseTzif(data: []const u8) ?i32 {
|
|
||||||
// TZif header is 44 bytes minimum
|
|
||||||
if (data.len < 44) {
|
|
||||||
warn("TZif file too small: {d} bytes", .{data.len});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check magic number "TZif"
|
|
||||||
if (!std.mem.eql(u8, data[0..4], "TZif")) {
|
|
||||||
warn("Invalid TZif magic number", .{});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const version = data[4];
|
|
||||||
|
|
||||||
// Parse header counts (big-endian)
|
|
||||||
// Header layout:
|
|
||||||
// 0-3: magic "TZif"
|
|
||||||
// 4: version (0, '2', or '3')
|
|
||||||
// 5-19: reserved (15 bytes)
|
|
||||||
// 20-23: tzh_ttisutcnt
|
|
||||||
// 24-27: tzh_ttisstdcnt
|
|
||||||
// 28-31: tzh_leapcnt
|
|
||||||
// 32-35: tzh_timecnt
|
|
||||||
// 36-39: tzh_typecnt
|
|
||||||
// 40-43: tzh_charcnt
|
|
||||||
const tzh_ttisutcnt = std.mem.readInt(u32, data[20..24], .big);
|
|
||||||
const tzh_ttisstdcnt = std.mem.readInt(u32, data[24..28], .big);
|
|
||||||
const tzh_leapcnt = std.mem.readInt(u32, data[28..32], .big);
|
|
||||||
const tzh_timecnt = std.mem.readInt(u32, data[32..36], .big);
|
|
||||||
const tzh_typecnt = std.mem.readInt(u32, data[36..40], .big);
|
|
||||||
const tzh_charcnt = std.mem.readInt(u32, data[40..44], .big);
|
|
||||||
|
|
||||||
if (tzh_typecnt == 0) {
|
|
||||||
warn("TZif file has no time types", .{});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For v2/v3 files, skip v1 data and parse v2 header
|
|
||||||
// v1 uses 4-byte transition times, v2/v3 use 8-byte
|
|
||||||
if (version == '2' or version == '3') {
|
|
||||||
// Calculate size of v1 data block to skip
|
|
||||||
const v1_data_size = tzh_timecnt * 4 + // transition times (4 bytes each in v1)
|
|
||||||
tzh_timecnt + // transition types (1 byte each)
|
|
||||||
tzh_typecnt * 6 + // ttinfos (6 bytes each)
|
|
||||||
tzh_charcnt + // timezone abbreviations
|
|
||||||
tzh_leapcnt * 8 + // leap second records (4+4 in v1)
|
|
||||||
tzh_ttisstdcnt + // std/wall indicators
|
|
||||||
tzh_ttisutcnt; // ut/local indicators
|
|
||||||
|
|
||||||
const v2_header_start = 44 + v1_data_size;
|
|
||||||
|
|
||||||
if (data.len < v2_header_start + 44) {
|
|
||||||
warn("TZif v2 file truncated", .{});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify v2 header magic
|
|
||||||
if (!std.mem.eql(u8, data[v2_header_start..][0..4], "TZif")) {
|
|
||||||
warn("Invalid TZif v2 header magic", .{});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse v2 header
|
|
||||||
const v2_timecnt = std.mem.readInt(u32, data[v2_header_start + 32 ..][0..4], .big);
|
|
||||||
const v2_typecnt = std.mem.readInt(u32, data[v2_header_start + 36 ..][0..4], .big);
|
|
||||||
|
|
||||||
if (v2_typecnt == 0) {
|
|
||||||
warn("TZif v2 has no time types", .{});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate offset to ttinfo structures in v2 data
|
|
||||||
const v2_data_start = v2_header_start + 44;
|
|
||||||
const ttinfo_offset = v2_data_start +
|
|
||||||
v2_timecnt * 8 + // transition times (8 bytes each in v2)
|
|
||||||
v2_timecnt; // transition types (1 byte each)
|
|
||||||
|
|
||||||
return readLastTtinfoOffset(data, ttinfo_offset, v2_typecnt);
|
|
||||||
}
|
|
||||||
|
|
||||||
// v1 format: ttinfos start after transition times and types
|
|
||||||
const ttinfo_offset = 44 +
|
|
||||||
tzh_timecnt * 4 + // transition times
|
|
||||||
tzh_timecnt; // transition types
|
|
||||||
|
|
||||||
return readLastTtinfoOffset(data, ttinfo_offset, tzh_typecnt);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read the UTC offset from ttinfo structures.
|
|
||||||
/// Finds the first non-DST entry with a "normal" offset (multiple of 15 min), or falls back to first entry.
|
|
||||||
/// Each ttinfo is 6 bytes: i32 offset, u8 is_dst, u8 abbr_idx
|
|
||||||
fn readLastTtinfoOffset(data: []const u8, ttinfo_offset: usize, typecnt: u32) ?i32 {
|
|
||||||
if (data.len < ttinfo_offset + 6) {
|
|
||||||
warn("TZif file truncated at ttinfo", .{});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Look for a non-DST entry (standard time) with a "normal" offset
|
|
||||||
// Skip historical LMT entries which often have unusual offsets
|
|
||||||
var i: usize = 0;
|
|
||||||
while (i < typecnt) : (i += 1) {
|
|
||||||
const off = ttinfo_offset + i * 6;
|
|
||||||
if (data.len < off + 6) break;
|
|
||||||
|
|
||||||
const offset = std.mem.readInt(i32, data[off..][0..4], .big);
|
|
||||||
const is_dst = data[off + 4];
|
|
||||||
|
|
||||||
// Skip DST entries
|
|
||||||
if (is_dst != 0) continue;
|
|
||||||
|
|
||||||
// Skip unusual offsets (not multiples of 15 minutes = 900 seconds)
|
|
||||||
// This filters out historical LMT entries
|
|
||||||
if (@mod(offset, 900) != 0) continue;
|
|
||||||
|
|
||||||
// Sanity check: offset should be reasonable (-14 to +14 hours)
|
|
||||||
if (offset >= -14 * 3600 and offset <= 14 * 3600) {
|
|
||||||
return offset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to first entry if no suitable entry found
|
|
||||||
const offset = std.mem.readInt(i32, data[ttinfo_offset..][0..4], .big);
|
|
||||||
|
|
||||||
// Sanity check
|
|
||||||
if (offset < -14 * 3600 or offset > 14 * 3600) {
|
|
||||||
warn("TZif offset out of range: {d}", .{offset});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Tests
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
test "getLocalTimezone with TZ env var" {
|
|
||||||
// This test depends on environment, just verify it doesn't crash
|
|
||||||
const allocator = std.testing.allocator;
|
|
||||||
if (getLocalTimezone(allocator)) |tz| {
|
|
||||||
defer allocator.free(tz);
|
|
||||||
try std.testing.expect(tz.len > 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test "parseTzif with invalid data" {
|
|
||||||
try std.testing.expectEqual(null, parseTzif(""));
|
|
||||||
try std.testing.expectEqual(null, parseTzif("short"));
|
|
||||||
try std.testing.expectEqual(null, parseTzif("NotTZif" ++ "\x00" ** 37));
|
|
||||||
}
|
|
||||||
|
|
@ -1,153 +0,0 @@
|
||||||
//! Adds Alexa skill-specific Lambda permission.
|
|
||||||
//! Alexa requires the Lambda policy to include the skill ID as an event source token condition.
|
|
||||||
|
|
||||||
const std = @import("std");
|
|
||||||
const aws = @import("aws");
|
|
||||||
const json = std.json;
|
|
||||||
|
|
||||||
pub fn main() !u8 {
|
|
||||||
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init;
|
|
||||||
defer _ = gpa.deinit();
|
|
||||||
const allocator = gpa.allocator();
|
|
||||||
|
|
||||||
var stdout_buf: [4096]u8 = undefined;
|
|
||||||
var stderr_buf: [4096]u8 = undefined;
|
|
||||||
var stdout_writer = std.fs.File.stdout().writer(&stdout_buf);
|
|
||||||
var stderr_writer = std.fs.File.stderr().writer(&stderr_buf);
|
|
||||||
const stdout = &stdout_writer.interface;
|
|
||||||
const stderr = &stderr_writer.interface;
|
|
||||||
|
|
||||||
const args = try std.process.argsAlloc(allocator);
|
|
||||||
defer std.process.argsFree(allocator, args);
|
|
||||||
|
|
||||||
if (args.len < 3) {
|
|
||||||
try stderr.print("Usage: {s} <deploy-output.json> <ask-states.json> [--profile <profile>] [--region <region>]\n", .{args[0]});
|
|
||||||
try stderr.flush();
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse optional arguments
|
|
||||||
var profile: ?[]const u8 = null;
|
|
||||||
var region_override: ?[]const u8 = null;
|
|
||||||
var i: usize = 3;
|
|
||||||
while (i < args.len) : (i += 1) {
|
|
||||||
if (std.mem.eql(u8, args[i], "--profile") and i + 1 < args.len) {
|
|
||||||
profile = args[i + 1];
|
|
||||||
i += 1;
|
|
||||||
} else if (std.mem.eql(u8, args[i], "--region") and i + 1 < args.len) {
|
|
||||||
region_override = args[i + 1];
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read deploy output to get function name and region
|
|
||||||
const deploy_output = std.fs.cwd().readFileAlloc(allocator, args[1], 1024 * 1024) catch |err| {
|
|
||||||
try stderr.print("Failed to read deploy output '{s}': {}\n", .{ args[1], err });
|
|
||||||
try stderr.flush();
|
|
||||||
return 1;
|
|
||||||
};
|
|
||||||
defer allocator.free(deploy_output);
|
|
||||||
|
|
||||||
const deploy_parsed = json.parseFromSlice(json.Value, allocator, deploy_output, .{}) catch |err| {
|
|
||||||
try stderr.print("Failed to parse deploy output: {}\n", .{err});
|
|
||||||
try stderr.flush();
|
|
||||||
return 1;
|
|
||||||
};
|
|
||||||
defer deploy_parsed.deinit();
|
|
||||||
|
|
||||||
const function_name = deploy_parsed.value.object.get("function_name").?.string;
|
|
||||||
const region = region_override orelse deploy_parsed.value.object.get("region").?.string;
|
|
||||||
|
|
||||||
// Read ask-states.json to get skill ID
|
|
||||||
const ask_states = std.fs.cwd().readFileAlloc(allocator, args[2], 1024 * 1024) catch |err| {
|
|
||||||
try stderr.print("Failed to read ask-states.json '{s}': {}\n", .{ args[2], err });
|
|
||||||
try stderr.flush();
|
|
||||||
return 1;
|
|
||||||
};
|
|
||||||
defer allocator.free(ask_states);
|
|
||||||
|
|
||||||
const ask_parsed = json.parseFromSlice(json.Value, allocator, ask_states, .{}) catch |err| {
|
|
||||||
try stderr.print("Failed to parse ask-states.json: {}\n", .{err});
|
|
||||||
try stderr.flush();
|
|
||||||
return 1;
|
|
||||||
};
|
|
||||||
defer ask_parsed.deinit();
|
|
||||||
|
|
||||||
// Skill ID is always under "default" profile in ask-states.json
|
|
||||||
// (ASK CLI profile is separate from AWS profile)
|
|
||||||
const profiles_obj = ask_parsed.value.object.get("profiles") orelse {
|
|
||||||
try stderr.print("No 'profiles' field in ask-states.json\n", .{});
|
|
||||||
try stderr.flush();
|
|
||||||
return 1;
|
|
||||||
};
|
|
||||||
const profile_obj = profiles_obj.object.get("default") orelse {
|
|
||||||
try stderr.print("No 'default' profile in ask-states.json\n", .{});
|
|
||||||
try stderr.flush();
|
|
||||||
return 1;
|
|
||||||
};
|
|
||||||
const skill_id = profile_obj.object.get("skillId").?.string;
|
|
||||||
|
|
||||||
try stdout.print("Adding Alexa permission for skill {s} to function {s} in {s}", .{ skill_id, function_name, region });
|
|
||||||
if (profile) |p| try stdout.print(" (profile: {s})", .{p});
|
|
||||||
try stdout.print("\n", .{});
|
|
||||||
try stdout.flush();
|
|
||||||
|
|
||||||
// Build statement ID from skill ID (use last 12 chars to keep it short but unique)
|
|
||||||
var statement_id_buf: [64]u8 = undefined;
|
|
||||||
const statement_id = std.fmt.bufPrint(&statement_id_buf, "alexa-skill-{s}", .{skill_id[skill_id.len - 12 ..]}) catch {
|
|
||||||
try stderr.print("Failed to build statement ID\n", .{});
|
|
||||||
try stderr.flush();
|
|
||||||
return 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create AWS client and options
|
|
||||||
var client = aws.Client.init(allocator, .{});
|
|
||||||
defer client.deinit();
|
|
||||||
|
|
||||||
var diagnostics: aws.Diagnostics = .{
|
|
||||||
.response_status = undefined,
|
|
||||||
.response_body = undefined,
|
|
||||||
.allocator = allocator,
|
|
||||||
};
|
|
||||||
|
|
||||||
const opts = aws.Options{
|
|
||||||
.client = client,
|
|
||||||
.region = region,
|
|
||||||
.diagnostics = &diagnostics,
|
|
||||||
.credential_options = .{
|
|
||||||
.profile = .{
|
|
||||||
.profile_name = profile,
|
|
||||||
.prefer_profile_from_file = profile != null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add permission with skill ID as event source token
|
|
||||||
const services = aws.Services(.{.lambda}){};
|
|
||||||
|
|
||||||
const result = aws.Request(services.lambda.add_permission).call(.{
|
|
||||||
.function_name = function_name,
|
|
||||||
.statement_id = statement_id,
|
|
||||||
.action = "lambda:InvokeFunction",
|
|
||||||
.principal = "alexa-appkit.amazon.com",
|
|
||||||
.event_source_token = skill_id,
|
|
||||||
}, opts) catch |err| {
|
|
||||||
defer diagnostics.deinit();
|
|
||||||
|
|
||||||
// 409 Conflict means permission already exists - that's fine
|
|
||||||
if (diagnostics.response_status == .conflict) {
|
|
||||||
try stdout.print("Permission already exists for skill: {s}\n", .{skill_id});
|
|
||||||
try stdout.flush();
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
try stderr.print("AddPermission failed: {} (HTTP {})\n", .{ err, diagnostics.response_status });
|
|
||||||
try stderr.flush();
|
|
||||||
return 1;
|
|
||||||
};
|
|
||||||
result.deinit();
|
|
||||||
|
|
||||||
try stdout.print("Added Alexa permission for skill: {s}\n", .{skill_id});
|
|
||||||
try stdout.flush();
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
//! Generate skill.json from template by substituting Lambda ARN
|
|
||||||
//!
|
|
||||||
//! Usage: gen-skill-json <deploy-output.json> <skill.template.json>
|
|
||||||
//! Outputs the generated skill.json to stdout.
|
|
||||||
|
|
||||||
const std = @import("std");
|
|
||||||
|
|
||||||
pub fn main() !void {
|
|
||||||
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
|
|
||||||
defer _ = gpa.deinit();
|
|
||||||
const allocator = gpa.allocator();
|
|
||||||
|
|
||||||
const args = try std.process.argsAlloc(allocator);
|
|
||||||
defer std.process.argsFree(allocator, args);
|
|
||||||
|
|
||||||
if (args.len != 3) {
|
|
||||||
std.debug.print("Usage: {s} <deploy-output.json> <skill.template.json>\n", .{args[0]});
|
|
||||||
std.process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const deploy_output_path = args[1];
|
|
||||||
const template_path = args[2];
|
|
||||||
|
|
||||||
// Read deploy output JSON
|
|
||||||
const deploy_output = try readFile(allocator, deploy_output_path);
|
|
||||||
defer allocator.free(deploy_output);
|
|
||||||
|
|
||||||
// Parse to extract ARN
|
|
||||||
const parsed = try std.json.parseFromSlice(std.json.Value, allocator, deploy_output, .{});
|
|
||||||
defer parsed.deinit();
|
|
||||||
|
|
||||||
const arn = parsed.value.object.get("arn") orelse {
|
|
||||||
std.debug.print("Error: deploy output missing 'arn' field\n", .{});
|
|
||||||
std.process.exit(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const arn_str = switch (arn) {
|
|
||||||
.string => |s| s,
|
|
||||||
else => {
|
|
||||||
std.debug.print("Error: 'arn' field is not a string\n", .{});
|
|
||||||
std.process.exit(1);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Read template
|
|
||||||
const template = try readFile(allocator, template_path);
|
|
||||||
defer allocator.free(template);
|
|
||||||
|
|
||||||
// Replace placeholder with ARN
|
|
||||||
const result = try std.mem.replaceOwned(u8, allocator, template, "{{LAMBDA_ARN}}", arn_str);
|
|
||||||
defer allocator.free(result);
|
|
||||||
|
|
||||||
// Write to stdout
|
|
||||||
const stdout = std.fs.File.stdout();
|
|
||||||
try stdout.writeAll(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn readFile(allocator: std.mem.Allocator, path: []const u8) ![]u8 {
|
|
||||||
const file = try std.fs.cwd().openFile(path, .{});
|
|
||||||
defer file.close();
|
|
||||||
|
|
||||||
return try file.readToEndAlloc(allocator, 1024 * 1024);
|
|
||||||
}
|
|
||||||
Loading…
Add table
Reference in a new issue