update query state code (AI yolo mode)
This commit is contained in:
parent
bfb066264d
commit
ba17d65d77
4 changed files with 613 additions and 45 deletions
175
src/alexa.zig
Normal file
175
src/alexa.zig
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
//! 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.
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
@ -610,7 +610,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) ![]const u8 {
|
pub fn formatStateResponse(allocator: Allocator, entity: *const Entity, utc_offset: ?i32) ![]const u8 {
|
||||||
var response: std.ArrayListUnmanaged(u8) = .empty;
|
var response: std.ArrayListUnmanaged(u8) = .empty;
|
||||||
errdefer response.deinit(allocator);
|
errdefer response.deinit(allocator);
|
||||||
|
|
||||||
|
|
@ -631,13 +631,29 @@ pub fn formatStateResponse(allocator: Allocator, entity: *const Entity) ![]const
|
||||||
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"))
|
||||||
{
|
{
|
||||||
// Add time since last change if available
|
if (entity.last_changed) |lc| {
|
||||||
if (entity.last_changed) |lc|
|
if (parseTimestamp(lc)) |seconds| {
|
||||||
if (parseTimeAgo(lc)) |ago|
|
const duration = formatDuration(allocator, seconds) catch null;
|
||||||
try writer.print(" and has been {s} for {s}", .{ entity.state, ago });
|
const time_str = formatTimeFromTimestamp(allocator, lc, utc_offset) catch null;
|
||||||
|
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(".");
|
||||||
|
|
@ -645,15 +661,119 @@ pub fn formatStateResponse(allocator: Allocator, entity: *const Entity) ![]const
|
||||||
return response.toOwnedSlice(allocator);
|
return response.toOwnedSlice(allocator);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse ISO timestamp and return human-readable "time ago" string
|
/// Parse ISO timestamp and return duration in seconds since that time
|
||||||
fn parseTimeAgo(iso_timestamp: []const u8) ?[]const u8 {
|
fn parseTimestamp(iso_timestamp: []const u8) ?i64 {
|
||||||
// Simple heuristic: check if it looks like an ISO timestamp
|
// Expected format: 2024-01-15T10:30:45.123456+00:00 or similar
|
||||||
// In production, you'd parse this properly
|
if (iso_timestamp.len < 19) return null;
|
||||||
if (iso_timestamp.len < 10) return null;
|
|
||||||
|
|
||||||
// For now, just return a generic message
|
// Parse date components
|
||||||
// TODO: Implement proper time parsing
|
const year = std.fmt.parseInt(u16, iso_timestamp[0..4], 10) catch return null;
|
||||||
return "some time";
|
if (iso_timestamp[4] != '-') return null;
|
||||||
|
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
|
||||||
|
|
@ -692,6 +812,7 @@ 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| {
|
||||||
|
|
@ -822,7 +943,7 @@ pub fn handleDeviceAction(
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
.query_state => {
|
.query_state => {
|
||||||
const response = try formatStateResponse(allocator, entity);
|
const response = try formatStateResponse(allocator, entity, utc_offset);
|
||||||
return ActionResult{
|
return ActionResult{
|
||||||
.speech = response,
|
.speech = response,
|
||||||
.end_session = true,
|
.end_session = true,
|
||||||
|
|
@ -986,7 +1107,7 @@ test "formatStateResponse light on with brightness" {
|
||||||
.current_temperature = null,
|
.current_temperature = null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = try formatStateResponse(allocator, &entity);
|
const response = try formatStateResponse(allocator, &entity, null);
|
||||||
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);
|
||||||
|
|
@ -1007,7 +1128,7 @@ test "formatStateResponse light off" {
|
||||||
.current_temperature = null,
|
.current_temperature = null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = try formatStateResponse(allocator, &entity);
|
const response = try formatStateResponse(allocator, &entity, null);
|
||||||
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);
|
||||||
|
|
@ -1027,7 +1148,7 @@ test "formatStateResponse climate with temperatures" {
|
||||||
.current_temperature = 68.0,
|
.current_temperature = 68.0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = try formatStateResponse(allocator, &entity);
|
const response = try formatStateResponse(allocator, &entity, null);
|
||||||
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);
|
||||||
|
|
@ -1145,7 +1266,7 @@ test "handleDeviceAction turn on light success" {
|
||||||
mock.interface(),
|
mock.interface(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = try handleDeviceAction(allocator, &client, .turn_on, "bedroom", null);
|
const result = try handleDeviceAction(allocator, &client, .turn_on, "bedroom", null, 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);
|
||||||
|
|
@ -1172,7 +1293,7 @@ test "handleDeviceAction device not found" {
|
||||||
mock.interface(),
|
mock.interface(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = try handleDeviceAction(allocator, &client, .turn_on, "garage", null);
|
const result = try handleDeviceAction(allocator, &client, .turn_on, "garage", null, 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);
|
||||||
|
|
@ -1199,7 +1320,7 @@ test "handleDeviceAction multiple matches returns clarification" {
|
||||||
mock.interface(),
|
mock.interface(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = try handleDeviceAction(allocator, &client, .turn_on, "light", null);
|
const result = try handleDeviceAction(allocator, &client, .turn_on, "light", null, 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);
|
||||||
|
|
@ -1227,7 +1348,7 @@ test "handleDeviceAction query state" {
|
||||||
mock.interface(),
|
mock.interface(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = try handleDeviceAction(allocator, &client, .query_state, "bedroom", null);
|
const result = try handleDeviceAction(allocator, &client, .query_state, "bedroom", null, 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);
|
||||||
|
|
@ -1256,7 +1377,7 @@ test "handleDeviceAction set brightness" {
|
||||||
mock.interface(),
|
mock.interface(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = try handleDeviceAction(allocator, &client, .set_value, "bedroom", 50.0);
|
const result = try handleDeviceAction(allocator, &client, .set_value, "bedroom", 50.0, null);
|
||||||
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);
|
||||||
|
|
|
||||||
86
src/main.zig
86
src/main.zig
|
|
@ -3,6 +3,8 @@ 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");
|
||||||
|
|
||||||
|
|
@ -19,7 +21,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() catch return 1;
|
printHelp(args) catch return 1;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -84,7 +86,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, config);
|
return handleIntentRequest(allocator, request_obj, parsed.value, 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);
|
||||||
}
|
}
|
||||||
|
|
@ -93,7 +95,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, config: Config) ![]const u8 {
|
fn handleIntentRequest(allocator: std.mem.Allocator, request_obj: json.Value, full_request: 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);
|
||||||
|
|
@ -116,7 +118,7 @@ fn handleIntentRequest(allocator: std.mem.Allocator, request_obj: json.Value, co
|
||||||
} 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, config);
|
return handleHomeAssistantIntent(allocator, intent_obj, full_request, 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")) {
|
||||||
|
|
@ -178,7 +180,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, config: Config) ![]const u8 {
|
fn handleHomeAssistantIntent(allocator: std.mem.Allocator, intent_obj: json.Value, full_request: 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", .{});
|
||||||
|
|
@ -208,6 +210,12 @@ 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,
|
||||||
|
|
@ -215,6 +223,7 @@ 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);
|
||||||
|
|
@ -248,6 +257,7 @@ 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);
|
||||||
|
|
@ -343,6 +353,38 @@ 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: tries Alexa Settings API, then falls back to local timezone.
|
||||||
|
/// In local mode: uses TZ env var or /etc/timezone.
|
||||||
|
fn resolveTimezone(allocator: std.mem.Allocator, parsed_value: json.Value, config: Config) ?i32 {
|
||||||
|
// First try to get timezone from Alexa context (Lambda mode)
|
||||||
|
if (alexa.parseContext(parsed_value)) |context| {
|
||||||
|
// Need HTTP client for Alexa API
|
||||||
|
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.http_interface, context)) |tz_name| {
|
||||||
|
defer allocator.free(tz_name);
|
||||||
|
if (timezone.getUtcOffset(allocator, tz_name)) |offset| {
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to local timezone
|
||||||
|
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
|
||||||
|
|
@ -378,9 +420,13 @@ fn buildAlexaResponse(allocator: std.mem.Allocator, speech: []const u8, end_sess
|
||||||
// Local Testing Mode
|
// Local Testing Mode
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
fn printHelp() !void {
|
fn printHelp(args: []const []const u8) !void {
|
||||||
const help =
|
const prog = std.fs.path.basename(args[0]);
|
||||||
\\Usage: bootstrap [OPTIONS]
|
var stdout_buffer: [4096]u8 = undefined;
|
||||||
|
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.
|
||||||
|
|
@ -405,16 +451,16 @@ fn printHelp() !void {
|
||||||
\\ AMAZON.CancelIntent Cancel/goodbye
|
\\ AMAZON.CancelIntent Cancel/goodbye
|
||||||
\\
|
\\
|
||||||
\\Examples:
|
\\Examples:
|
||||||
\\ bootstrap --type=launch
|
\\ {0s} --type=launch
|
||||||
\\ bootstrap --intent=RecirculateWaterIntent
|
\\ {0s} --intent=RecirculateWaterIntent
|
||||||
\\ bootstrap --intent=HomeAssistantIntent --device="bedroom light" --action="turn on"
|
\\ {0s} --intent=HomeAssistantIntent --device="bedroom light" --action="turn on"
|
||||||
\\ bootstrap --intent=HomeAssistantIntent --device="bedroom light" --action="turn off"
|
\\ {0s} --intent=HomeAssistantIntent --device="bedroom light" --action="turn off"
|
||||||
\\ bootstrap --intent=HomeAssistantIntent --device="bedroom light" --action="toggle"
|
\\ {0s} --intent=HomeAssistantIntent --device="bedroom light" --action="toggle"
|
||||||
\\ bootstrap --intent=HomeAssistantIntent --device="bedroom light"
|
\\ {0s} --intent=HomeAssistantIntent --device="bedroom light"
|
||||||
\\ (no action = query state)
|
\\ (no action = query state)
|
||||||
\\ bootstrap --intent=SetDeviceValueIntent --device="bedroom light" --value=50
|
\\ {0s} --intent=SetDeviceValueIntent --device="bedroom light" --value=50
|
||||||
\\ bootstrap --intent=SetDeviceValueIntent --device="thermostat" --value=72
|
\\ {0s} --intent=SetDeviceValueIntent --device="thermostat" --value=72
|
||||||
\\ bootstrap --intent=WeezTheJuiceIntent
|
\\ {0s} --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
|
||||||
|
|
@ -422,11 +468,7 @@ fn printHelp() !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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
230
src/timezone.zig
Normal file
230
src/timezone.zig
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
//! Timezone utilities: TZif parsing and local timezone resolution.
|
||||||
|
|
||||||
|
const std = @import("std");
|
||||||
|
const Allocator = std.mem.Allocator;
|
||||||
|
|
||||||
|
const log = std.log.scoped(.timezone);
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
log.warn("Failed to allocate timezone path", .{});
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
defer allocator.free(path);
|
||||||
|
|
||||||
|
const file = std.fs.openFileAbsolute(path, .{}) catch {
|
||||||
|
log.warn("Failed to open timezone file: {s}", .{path});
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
defer file.close();
|
||||||
|
|
||||||
|
const data = file.readToEndAlloc(allocator, 64 * 1024) catch {
|
||||||
|
log.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 {
|
||||||
|
log.warn("No TZ env var and failed to open /etc/timezone", .{});
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
defer file.close();
|
||||||
|
|
||||||
|
const content = file.readToEndAlloc(allocator, 256) catch {
|
||||||
|
log.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) {
|
||||||
|
log.warn("TZif file too small: {d} bytes", .{data.len});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check magic number "TZif"
|
||||||
|
if (!std.mem.eql(u8, data[0..4], "TZif")) {
|
||||||
|
log.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) {
|
||||||
|
log.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) {
|
||||||
|
log.warn("TZif v2 file truncated", .{});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify v2 header magic
|
||||||
|
if (!std.mem.eql(u8, data[v2_header_start..][0..4], "TZif")) {
|
||||||
|
log.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) {
|
||||||
|
log.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) {
|
||||||
|
log.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) {
|
||||||
|
log.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));
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue