update query state code (AI yolo mode)

This commit is contained in:
Emil Lerch 2026-02-04 13:49:10 -08:00
parent bfb066264d
commit ba17d65d77
Signed by: lobo
GPG key ID: A7B62D657EF764F8
4 changed files with 613 additions and 45 deletions

175
src/alexa.zig Normal file
View 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);
}

View file

@ -610,7 +610,7 @@ pub fn findEntitiesByName(
}
/// 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;
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});
if (entity.current_temperature) |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"))
{
// Add time since last change if available
if (entity.last_changed) |lc|
if (parseTimeAgo(lc)) |ago|
try writer.print(" and has been {s} for {s}", .{ entity.state, ago });
if (entity.last_changed) |lc| {
if (parseTimestamp(lc)) |seconds| {
const duration = formatDuration(allocator, seconds) catch null;
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(".");
@ -645,15 +661,119 @@ pub fn formatStateResponse(allocator: Allocator, entity: *const Entity) ![]const
return response.toOwnedSlice(allocator);
}
/// Parse ISO timestamp and return human-readable "time ago" string
fn parseTimeAgo(iso_timestamp: []const u8) ?[]const u8 {
// Simple heuristic: check if it looks like an ISO timestamp
// In production, you'd parse this properly
if (iso_timestamp.len < 10) return null;
/// Parse ISO timestamp and return duration in seconds since that time
fn parseTimestamp(iso_timestamp: []const u8) ?i64 {
// Expected format: 2024-01-15T10:30:45.123456+00:00 or similar
if (iso_timestamp.len < 19) return null;
// For now, just return a generic message
// TODO: Implement proper time parsing
return "some time";
// Parse date components
const year = std.fmt.parseInt(u16, iso_timestamp[0..4], 10) catch return null;
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
@ -692,6 +812,7 @@ pub fn handleDeviceAction(
action: Action,
device_name: []const u8,
value: ?f32,
utc_offset: ?i32,
) !ActionResult {
// Fetch all entities
const states_result = client.getStates() catch |err| {
@ -822,7 +943,7 @@ pub fn handleDeviceAction(
};
},
.query_state => {
const response = try formatStateResponse(allocator, entity);
const response = try formatStateResponse(allocator, entity, utc_offset);
return ActionResult{
.speech = response,
.end_session = true,
@ -986,7 +1107,7 @@ test "formatStateResponse light on with brightness" {
.current_temperature = null,
};
const response = try formatStateResponse(allocator, &entity);
const response = try formatStateResponse(allocator, &entity, null);
defer allocator.free(response);
try std.testing.expect(std.mem.indexOf(u8, response, "Bedroom Light") != null);
@ -1007,7 +1128,7 @@ test "formatStateResponse light off" {
.current_temperature = null,
};
const response = try formatStateResponse(allocator, &entity);
const response = try formatStateResponse(allocator, &entity, null);
defer allocator.free(response);
try std.testing.expect(std.mem.indexOf(u8, response, "off") != null);
@ -1027,7 +1148,7 @@ test "formatStateResponse climate with temperatures" {
.current_temperature = 68.0,
};
const response = try formatStateResponse(allocator, &entity);
const response = try formatStateResponse(allocator, &entity, null);
defer allocator.free(response);
try std.testing.expect(std.mem.indexOf(u8, response, "Thermostat") != null);
@ -1145,7 +1266,7 @@ test "handleDeviceAction turn on light success" {
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);
try std.testing.expect(std.mem.indexOf(u8, result.speech, "Turned on") != null);
@ -1172,7 +1293,7 @@ test "handleDeviceAction device not found" {
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);
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(),
);
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);
try std.testing.expect(std.mem.indexOf(u8, result.speech, "Which one") != null);
@ -1227,7 +1348,7 @@ test "handleDeviceAction query state" {
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);
try std.testing.expect(std.mem.indexOf(u8, result.speech, "Bedroom Light") != null);
@ -1256,7 +1377,7 @@ test "handleDeviceAction set brightness" {
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);
try std.testing.expect(std.mem.indexOf(u8, result.speech, "Set") != null);

View file

@ -3,6 +3,8 @@ const json = std.json;
const lambda = @import("lambda_runtime");
const rinnai = @import("rinnai");
const homeassistant = @import("homeassistant.zig");
const alexa = @import("alexa.zig");
const timezone = @import("timezone.zig");
const Config = @import("Config.zig");
const builtin = @import("builtin");
@ -19,7 +21,7 @@ pub fn main() !u8 {
// Check for --help first (no config needed)
for (args) |arg| {
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;
}
}
@ -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")) {
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")) {
return handleIntentRequest(allocator, request_obj, config);
return handleIntentRequest(allocator, request_obj, parsed.value, config);
} else if (std.mem.eql(u8, request_type_str, "SessionEndedRequest")) {
return buildAlexaResponse(allocator, "", true);
}
@ -93,7 +95,7 @@ fn handler(allocator: std.mem.Allocator, event_data: []const u8, config: Config)
}
/// 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 {
if (!builtin.is_test) log.err("No 'intent' field in IntentRequest", .{});
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
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")) {
return handleWeezTheJuice(allocator, config);
} 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
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
const ha_url = config.home_assistant_url orelse {
log.err("HOME_ASSISTANT_URL not configured", .{});
@ -208,6 +210,12 @@ fn handleHomeAssistantIntent(allocator: std.mem.Allocator, intent_obj: json.Valu
);
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
const result = homeassistant.handleDeviceAction(
allocator,
@ -215,6 +223,7 @@ fn handleHomeAssistantIntent(allocator: std.mem.Allocator, intent_obj: json.Valu
params.action,
params.device_name,
params.value,
utc_offset,
) catch |err| {
log.err("Home Assistant error: {}", .{err});
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,
"bedroom",
null,
null, // No timezone needed for toggle
) catch |err| {
log.err("Home Assistant error: {}", .{err});
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;
}
/// 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
fn buildAlexaResponse(allocator: std.mem.Allocator, speech: []const u8, end_session: bool) ![]const u8 {
// Escape speech for JSON
@ -378,9 +420,13 @@ fn buildAlexaResponse(allocator: std.mem.Allocator, speech: []const u8, end_sess
// Local Testing Mode
// =============================================================================
fn printHelp() !void {
const help =
\\Usage: bootstrap [OPTIONS]
fn printHelp(args: []const []const u8) !void {
const prog = std.fs.path.basename(args[0]);
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.
\\Automatically detects Lambda environment via AWS_LAMBDA_RUNTIME_API.
@ -405,16 +451,16 @@ fn printHelp() !void {
\\ AMAZON.CancelIntent Cancel/goodbye
\\
\\Examples:
\\ bootstrap --type=launch
\\ bootstrap --intent=RecirculateWaterIntent
\\ bootstrap --intent=HomeAssistantIntent --device="bedroom light" --action="turn on"
\\ bootstrap --intent=HomeAssistantIntent --device="bedroom light" --action="turn off"
\\ bootstrap --intent=HomeAssistantIntent --device="bedroom light" --action="toggle"
\\ bootstrap --intent=HomeAssistantIntent --device="bedroom light"
\\ {0s} --type=launch
\\ {0s} --intent=RecirculateWaterIntent
\\ {0s} --intent=HomeAssistantIntent --device="bedroom light" --action="turn on"
\\ {0s} --intent=HomeAssistantIntent --device="bedroom light" --action="turn off"
\\ {0s} --intent=HomeAssistantIntent --device="bedroom light" --action="toggle"
\\ {0s} --intent=HomeAssistantIntent --device="bedroom light"
\\ (no action = query state)
\\ bootstrap --intent=SetDeviceValueIntent --device="bedroom light" --value=50
\\ bootstrap --intent=SetDeviceValueIntent --device="thermostat" --value=72
\\ bootstrap --intent=WeezTheJuiceIntent
\\ {0s} --intent=SetDeviceValueIntent --device="bedroom light" --value=50
\\ {0s} --intent=SetDeviceValueIntent --device="thermostat" --value=72
\\ {0s} --intent=WeezTheJuiceIntent
\\
\\Environment variables (or .env file in current directory):
\\ 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_TOKEN Home Assistant long-lived access token
\\
;
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});
, .{prog});
try stdout.flush();
}

230
src/timezone.zig Normal file
View 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));
}