const builtin = @import("builtin"); const std = @import("std"); const pos = @import("pos"); fn loadReplacements(allocator: std.mem.Allocator) !std.StringHashMap([]const u8) { var replacements = std.StringHashMap([]const u8).init(allocator); // Try current directory first if (std.fs.cwd().openFile("replacements.json", .{})) |file| { defer file.close(); const content = try file.readToEndAlloc(allocator, 1024 * 1024); defer allocator.free(content); const parsed = try std.json.parseFromSlice(std.json.Value, allocator, content, .{}); defer parsed.deinit(); const obj = parsed.value.object; var it = obj.iterator(); while (it.next()) |entry| { try replacements.put(try allocator.dupe(u8, entry.key_ptr.*), try allocator.dupe(u8, entry.value_ptr.*.string)); } return replacements; } else |_| {} // Try XDG_CONFIG_HOME or HOME/.config const config_dir = std.posix.getenv("XDG_CONFIG_HOME") orelse blk: { const home = std.posix.getenv("HOME") orelse { std.log.warn("No replacement configuration file found", .{}); return replacements; }; break :blk try std.fs.path.join(allocator, &.{ home, ".config" }); }; const config_path = if (std.posix.getenv("XDG_CONFIG_HOME") != null) try std.fs.path.join(allocator, &.{ config_dir, "pos", "replacements.json" }) else blk: { defer allocator.free(config_dir); break :blk try std.fs.path.join(allocator, &.{ config_dir, "pos", "replacements.json" }); }; defer allocator.free(config_path); if (std.fs.openFileAbsolute(config_path, .{})) |file| { defer file.close(); const content = try file.readToEndAlloc(allocator, 1024 * 1024); defer allocator.free(content); const parsed = try std.json.parseFromSlice(std.json.Value, allocator, content, .{}); defer parsed.deinit(); const obj = parsed.value.object; var it = obj.iterator(); while (it.next()) |entry| { try replacements.put(try allocator.dupe(u8, entry.key_ptr.*), try allocator.dupe(u8, entry.value_ptr.*.string)); } return replacements; } else |_| {} std.log.warn("No replacement configuration file found", .{}); return replacements; } const DeviceAction = enum { on, off, toggle, }; fn sendWemoCommand(allocator: std.mem.Allocator, device_entry: std.hash_map.StringHashMap([]const u8).Entry, action: DeviceAction) !void { const state = switch (action) { .on => "1", .off => "0", .toggle => return error.ToggleNotImplemented, }; // port 49152 or 49153 // curl -0 -A '' -X POST -H 'Accept: ' -H 'Content-type: text/xml; charset="utf-8"' -H "SOAPACTION: \"urn:Belkin:service:basicevent:1#SetBinaryState\"" --data '1' -s http://$IP:$PORT/upnp/control/basicevent1 | const soap_body = try std.fmt.allocPrint(allocator, \\ \\ \\ \\ \\{s} \\ \\ \\ , .{state}); defer allocator.free(soap_body); const url = try std.fmt.allocPrint(allocator, "{s}/upnp/control/basicevent1", .{device_entry.value_ptr.*}); defer allocator.free(url); var client = std.http.Client{ .allocator = allocator }; defer client.deinit(); std.log.debug("sending WeMo {s} command to '{s}' at {s}\n", .{ @tagName(action), device_entry.key_ptr.*, device_entry.value_ptr.*, }); const res = try client.fetch(.{ .method = .POST, .payload = soap_body, .location = .{ .url = url }, .headers = .{ .content_type = .{ .override = "text/html; charset=\"utf-8\"" } }, .extra_headers = &.{ .{ .name = "SOAPACTION", .value = "urn:Belkin:service:basicevent:1#SetBinaryState" }, }, }); if (res.status == .ok) { var stdout_writer = std.fs.File.stdout().writer(&.{}); const stdout = &stdout_writer.interface; try stdout.print("WeMo {s} command sent to '{s}' at {s}\n", .{ @tagName(action), device_entry.key_ptr.*, device_entry.value_ptr.*, }); } else { var stderr_writer = std.fs.File.stderr().writer(&.{}); const stderr = &stderr_writer.interface; try stderr.print("ERROR: Could not send WeMo {s} command to '{s}' at {s}\n", .{ @tagName(action), device_entry.key_ptr.*, device_entry.value_ptr.*, }); } } fn loadDeviceConfig(allocator: std.mem.Allocator, bin_dir: []const u8) !std.StringHashMap([]const u8) { // Try current directory first if (loadConfigFromPath(allocator, "controlData.json")) |config| { return config; } else |_| {} // Try controlData.json in bin directory const json_path = try std.fs.path.join(allocator, &[_][]const u8{ bin_dir, "controlData.json" }); defer allocator.free(json_path); if (loadConfigFromPath(allocator, json_path)) |config| { return config; } else |_| {} // Try ../share/pos relative to bin directory const share_path = try std.fs.path.join(allocator, &[_][]const u8{ bin_dir, "../share/pos/controlData.json" }); defer allocator.free(share_path); if (loadConfigFromPath(allocator, share_path)) |config| { return config; } else |_| {} return loadDevicesFromTxt(allocator, bin_dir); } fn loadConfigFromPath(allocator: std.mem.Allocator, path: []const u8) !std.StringHashMap([]const u8) { var devices = std.StringHashMap([]const u8).init(allocator); const file = if (std.fs.path.isAbsolute(path)) std.fs.openFileAbsolute(path, .{}) catch return error.FileNotFound else std.fs.cwd().openFile(path, .{}) catch return error.FileNotFound; defer file.close(); const content = try file.readToEndAlloc(allocator, 1024 * 1024); defer allocator.free(content); var stderr_writer = std.fs.File.stderr().writer(&.{}); const stderr = &stderr_writer.interface; const parsed = std.json.parseFromSlice(std.json.Value, allocator, content, .{}) catch |err| { try stderr.print( "Failed to parse controlData.json: {}. Ignoring controlData.json, looking for devices.txt", .{err}, ); return error.ParseError; }; defer parsed.deinit(); const root = parsed.value.object; const device_array = root.get("devices").?.array; for (device_array.items) |device| { const device_obj = device.object; const name = device_obj.get("name").?.string; const url = device_obj.get("url").?.string; if (name.len > 0) { const name_copy = try allocator.alloc(u8, name.len); _ = std.ascii.lowerString(name_copy, name); try devices.put(name_copy, try allocator.dupe(u8, url)); std.log.debug("Loaded device: '{s}' -> {s}", .{ name, url }); } } return devices; } fn loadDevicesFromTxt(allocator: std.mem.Allocator, bin_dir: []const u8) !std.StringHashMap([]const u8) { var devices = std.StringHashMap([]const u8).init(allocator); const txt_path = try std.fs.path.join(allocator, &[_][]const u8{ bin_dir, "devices.txt" }); defer allocator.free(txt_path); const file = std.fs.openFileAbsolute(txt_path, .{}) catch |err| switch (err) { error.FileNotFound => { var stderr_writer = std.fs.File.stderr().writer(&.{}); const stderr = &stderr_writer.interface; try stderr.print( \\Error: could not load configuration. Please make sure controlData.json or \\devices.txt is available in the directory with this program , .{}); return error.ConfigurationNotAvailable; }, else => return err, }; defer file.close(); const content = try file.readToEndAlloc(allocator, 1024 * 1024); defer allocator.free(content); var lines = std.mem.splitScalar(u8, content, '\n'); while (lines.next()) |line| { const trimmed = std.mem.trim(u8, line, " \t\r\n"); if (trimmed.len == 0 or trimmed[0] == '#') continue; if (std.mem.indexOf(u8, trimmed, "=")) |eq_pos| { const name = std.mem.trim(u8, trimmed[0..eq_pos], " \t"); const url_base = std.mem.trim(u8, trimmed[eq_pos + 1 ..], " \t"); if (name.len > 0 and url_base.len > 0) { const name_copy = try allocator.alloc(u8, name.len); _ = std.ascii.lowerString(name_copy, name); try devices.put(name, try allocator.dupe(u8, url_base)); } } } return devices; } fn parseAction(action_words: [][]const u8) ?DeviceAction { if (action_words.len >= 2) { if (std.mem.eql(u8, action_words[0], "turn") and std.mem.eql(u8, action_words[1], "on")) { return .on; } else if (std.mem.eql(u8, action_words[0], "turn") and std.mem.eql(u8, action_words[1], "off")) { return .off; } } return null; } fn extractDevice(allocator: std.mem.Allocator, object_words: [][]const u8, devices: *std.StringHashMap([]const u8), replacements: *std.StringHashMap([]const u8)) !?std.hash_map.StringHashMap([]const u8).Entry { if (object_words.len == 0) return null; var total_size: usize = 0; for (object_words) |word| total_size += word.len + 1; var allocating_writer = try std.Io.Writer.Allocating.initCapacity(allocator, total_size); defer allocating_writer.deinit(); const writer = &allocating_writer.writer; // Try original words first for (0..object_words.len) |removed_words| { defer allocating_writer.clearRetainingCapacity(); const total_words = object_words.len - removed_words; for (object_words, 1..) |word, i| { if (i >= total_words + 1) break; var buf: [256]u8 = undefined; const lower_string = std.ascii.lowerString(&buf, word); try writer.print("{s}{s}", .{ lower_string, if (i < object_words.len - removed_words) " " else "", }); } std.log.debug("Attempting device match '{s}'", .{allocating_writer.written()}); if (devices.getEntry(allocating_writer.written())) |entry| return entry; } // Try with word replacements for (0..object_words.len) |removed_words| { defer allocating_writer.clearRetainingCapacity(); const total_words = object_words.len - removed_words; for (object_words, 1..) |word, i| { if (i >= total_words + 1) break; var buf: [256]u8 = undefined; const lower_string = std.ascii.lowerString(&buf, word); const final_word = replacements.get(lower_string) orelse lower_string; try writer.print("{s}{s}", .{ final_word, if (i < object_words.len - removed_words) " " else "", }); } std.log.debug("Attempting device match with replacements '{s}'", .{allocating_writer.written()}); if (devices.getEntry(allocating_writer.written())) |entry| return entry; } return null; } pub fn main() !u8 { 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); // Check for --sentence-parse-only option var sentence_parse_only: enum { sentence, command, none } = .none; var sentence_arg: ?[]const u8 = null; for (args[1..]) |arg| { if (std.mem.eql(u8, arg, "--sentence-parse-only")) sentence_parse_only = .sentence else if (std.mem.eql(u8, arg, "--command-parse-only")) sentence_parse_only = .command else if (sentence_arg == null) sentence_arg = arg; } var stdout_writer = std.fs.File.stdout().writer(&.{}); const stdout = &stdout_writer.interface; var stderr_writer = std.fs.File.stderr().writer(&.{}); const stderr = &stderr_writer.interface; if (sentence_arg == null) { try stderr.print("Usage: {s} [--sentence-parse-only] [--command-parse-only] \n", .{args[0]}); return 1; } if (sentence_parse_only == .none and std.mem.count(u8, sentence_arg.?, " ") == 0) { // a command cannot be made from a single word try stdout.print("Ignoring attempt at single word command\n", .{}); return 0; } const bin_dir = std.fs.selfExeDirPathAlloc(allocator) catch |err| { stderr.print("Failed to get binary path: {}\n", .{err}) catch {}; return 1; }; defer allocator.free(bin_dir); std.log.debug("initializing parser", .{}); var parser = pos.Parser.initWithDataDir(allocator, bin_dir) catch |err| { std.debug.print("Failed to initialize parser: {}\n", .{err}); return 1; }; defer parser.deinit(); if (sentence_parse_only != .none) { const sentence_z = try allocator.dupeZ(u8, sentence_arg.?); defer allocator.free(sentence_z); var tree = if (sentence_parse_only == .command) blk: { var replacements = try loadReplacements(allocator); defer { var iterator = replacements.iterator(); while (iterator.next()) |entry| { allocator.free(entry.key_ptr.*); allocator.free(entry.value_ptr.*); } replacements.deinit(); } break :blk parser.adaptiveCommandParse(sentence_z, &replacements) catch { std.log.err("Failed to parse sentence: {s}", .{sentence_z}); return 1; }; } else parser.parse(sentence_z) catch { std.log.err("Failed to parse sentence: {s}", .{sentence_z}); return 1; }; defer tree.deinit(); try stdout.print("{f}", .{tree}); } else { std.log.debug("loading device config", .{}); var devices = loadDeviceConfig(allocator, bin_dir) catch |err| { stderr.print("Failed to load device configuration: {}\n", .{err}) catch {}; return 1; }; defer { var iterator = devices.iterator(); while (iterator.next()) |entry| { allocator.free(entry.key_ptr.*); allocator.free(entry.value_ptr.*); } devices.deinit(); } var replacements = try loadReplacements(allocator); defer { var iterator = replacements.iterator(); while (iterator.next()) |entry| { allocator.free(entry.key_ptr.*); allocator.free(entry.value_ptr.*); } replacements.deinit(); } processCommand(allocator, args[1], &parser, &devices, &replacements) catch |err| { switch (err) { error.UnrecognizedSentence => { try stderr.print("Unrecognized sentence: {s}\n", .{args[1]}); return 1; }, else => return err, } }; } return 0; } fn processCommand(allocator: std.mem.Allocator, sentence: [:0]const u8, parser: *pos.Parser, devices: *std.StringHashMap([]const u8), replacements: *std.StringHashMap([]const u8)) !void { var tree = parser.adaptiveCommandParse(sentence, replacements) catch |err| { std.log.err("Failed to parse sentence: {}\n", .{err}); return error.UnrecognizedSentence; }; defer tree.deinit(); const action_words = try tree.sentenceAction(); defer allocator.free(action_words); const object_words = try tree.sentenceObject(); defer allocator.free(object_words); std.log.debug("{f}", .{tree}); var aw = std.Io.Writer.Allocating.init(allocator); defer aw.deinit(); const aw_writer = &aw.writer; try aw_writer.writeAll("Object words: ["); var first = true; for (object_words) |word| { try aw_writer.print("{s}\"{s}\"", .{ if (!first) ", " else "", word }); first = false; } std.log.debug("{s}]", .{aw.written()}); aw.clearRetainingCapacity(); try aw_writer.writeAll("Action words: ["); first = true; for (action_words) |word| { try aw_writer.print("{s}\"{s}\"", .{ if (!first) ", " else "", word }); first = false; } std.log.debug("{s}]", .{aw.written()}); if (parseAction(action_words)) |action| { if (try extractDevice(allocator, object_words, devices, replacements)) |entry| { if (builtin.is_test) testSendCommand(entry, action) else try sendWemoCommand(allocator, entry, action); return; // Success } } return error.UnrecognizedSentence; } // SAFETY: only used for testing, and in each case this is set before checking results var test_device_entry: std.hash_map.StringHashMap([]const u8).Entry = undefined; // SAFETY: only used for testing, and in each case this is set before checking results var test_action: DeviceAction = undefined; fn testSendCommand(device_entry: std.hash_map.StringHashMap([]const u8).Entry, action: DeviceAction) void { test_device_entry = device_entry; test_action = action; } test "extractDevice single word exact match" { var devices = std.StringHashMap([]const u8).init(std.testing.allocator); defer devices.deinit(); var replacements = std.StringHashMap([]const u8).init(std.testing.allocator); defer replacements.deinit(); try devices.put("kitchen", "192.168.1.100"); try devices.put("bedroom", "192.168.1.101"); var object_words = [_][]const u8{"kitchen"}; const result = try extractDevice(std.testing.allocator, object_words[0..], &devices, &replacements); try std.testing.expectEqualStrings("192.168.1.100", result.?.value_ptr.*); } test "extractDevice single word case insensitive" { var devices = std.StringHashMap([]const u8).init(std.testing.allocator); defer devices.deinit(); var replacements = std.StringHashMap([]const u8).init(std.testing.allocator); defer replacements.deinit(); try devices.put("kitchen", "192.168.1.100"); var object_words = [_][]const u8{"Kitchen"}; const result = try extractDevice(std.testing.allocator, object_words[0..], &devices, &replacements); try std.testing.expectEqualStrings("192.168.1.100", result.?.value_ptr.*); } test "extractDevice two words exact match" { var devices = std.StringHashMap([]const u8).init(std.testing.allocator); defer devices.deinit(); var replacements = std.StringHashMap([]const u8).init(std.testing.allocator); defer replacements.deinit(); try devices.put("bedroom light", "192.168.1.100"); var object_words = [_][]const u8{ "bedroom", "light" }; const result = try extractDevice(std.testing.allocator, object_words[0..], &devices, &replacements); try std.testing.expectEqualStrings("192.168.1.100", result.?.value_ptr.*); } test "extractDevice two words case insensitive" { var devices = std.StringHashMap([]const u8).init(std.testing.allocator); defer devices.deinit(); var replacements = std.StringHashMap([]const u8).init(std.testing.allocator); defer replacements.deinit(); try devices.put("bedroom light", "192.168.1.100"); var object_words = [_][]const u8{ "Bedroom", "Light" }; const result = try extractDevice(std.testing.allocator, object_words[0..], &devices, &replacements); try std.testing.expectEqualStrings("192.168.1.100", result.?.value_ptr.*); } test "extractDevice not found" { var devices = std.StringHashMap([]const u8).init(std.testing.allocator); defer devices.deinit(); var replacements = std.StringHashMap([]const u8).init(std.testing.allocator); defer replacements.deinit(); try devices.put("kitchen", "192.168.1.100"); var object_words = [_][]const u8{"bathroom"}; const result = try extractDevice(std.testing.allocator, object_words[0..], &devices, &replacements); try std.testing.expect(result == null); } test "extractDevice empty words" { var devices = std.StringHashMap([]const u8).init(std.testing.allocator); defer devices.deinit(); var replacements = std.StringHashMap([]const u8).init(std.testing.allocator); defer replacements.deinit(); var object_words = [_][]const u8{}; const result = try extractDevice(std.testing.allocator, object_words[0..], &devices, &replacements); try std.testing.expect(result == null); } test "extractDevice fallback to shorter match" { var devices = std.StringHashMap([]const u8).init(std.testing.allocator); defer devices.deinit(); var replacements = std.StringHashMap([]const u8).init(std.testing.allocator); defer replacements.deinit(); try devices.put("kitchen", "192.168.1.100"); var object_words = [_][]const u8{ "kitchen", "ceiling", "light" }; const result = try extractDevice(std.testing.allocator, object_words[0..], &devices, &replacements); try std.testing.expectEqualStrings("192.168.1.100", result.?.value_ptr.*); } test "extractDevice word replacement lake to light" { var devices = std.StringHashMap([]const u8).init(std.testing.allocator); defer devices.deinit(); var replacements = std.StringHashMap([]const u8).init(std.testing.allocator); defer replacements.deinit(); try replacements.put("lake", "light"); try devices.put("bedroom light", "192.168.1.100"); var object_words = [_][]const u8{ "bedroom", "lake" }; const result = try extractDevice(std.testing.allocator, object_words[0..], &devices, &replacements); try std.testing.expectEqualStrings("192.168.1.100", result.?.value_ptr.*); } test "extractDevice word replacement like to light" { var devices = std.StringHashMap([]const u8).init(std.testing.allocator); defer devices.deinit(); var replacements = std.StringHashMap([]const u8).init(std.testing.allocator); defer replacements.deinit(); try replacements.put("like", "light"); try devices.put("kitchen light", "192.168.1.101"); var object_words = [_][]const u8{ "kitchen", "like" }; const result = try extractDevice(std.testing.allocator, object_words[0..], &devices, &replacements); try std.testing.expectEqualStrings("192.168.1.101", result.?.value_ptr.*); } test "processCommand successful match" { var devices = std.StringHashMap([]const u8).init(std.testing.allocator); defer devices.deinit(); var replacements = std.StringHashMap([]const u8).init(std.testing.allocator); defer replacements.deinit(); try devices.put("kitchen", "192.168.1.100"); var parser = try pos.Parser.init(std.testing.allocator); defer parser.deinit(); const sentence = "turn on the kitchen"; const sentence_z = try std.testing.allocator.dupeZ(u8, sentence); defer std.testing.allocator.free(sentence_z); try processCommand(std.testing.allocator, sentence_z, &parser, &devices, &replacements); try std.testing.expectEqualStrings("192.168.1.100", test_device_entry.value_ptr.*); try std.testing.expectEqual(DeviceAction.on, test_action); } test "processCommand with word replacement lake to light" { var devices = std.StringHashMap([]const u8).init(std.testing.allocator); defer devices.deinit(); var replacements = std.StringHashMap([]const u8).init(std.testing.allocator); defer replacements.deinit(); try replacements.put("lake", "light"); try devices.put("kitchen light", "192.168.1.100"); var parser = try pos.Parser.init(std.testing.allocator); defer parser.deinit(); const sentence = "turn on the kitchen lake"; const sentence_z = try std.testing.allocator.dupeZ(u8, sentence); defer std.testing.allocator.free(sentence_z); try processCommand(std.testing.allocator, sentence_z, &parser, &devices, &replacements); try std.testing.expectEqualStrings("192.168.1.100", test_device_entry.value_ptr.*); try std.testing.expectEqual(DeviceAction.on, test_action); } test "processCommand with word replacement like to light" { var devices = std.StringHashMap([]const u8).init(std.testing.allocator); defer devices.deinit(); var replacements = std.StringHashMap([]const u8).init(std.testing.allocator); defer replacements.deinit(); try replacements.put("like", "light"); try devices.put("bedroom light", "192.168.1.101"); var parser = try pos.Parser.init(std.testing.allocator); defer parser.deinit(); const sentence = "turn off the bedroom like"; const sentence_z = try std.testing.allocator.dupeZ(u8, sentence); defer std.testing.allocator.free(sentence_z); try processCommand(std.testing.allocator, sentence_z, &parser, &devices, &replacements); try std.testing.expectEqualStrings("192.168.1.101", test_device_entry.value_ptr.*); try std.testing.expectEqual(DeviceAction.off, test_action); } test "processCommand with word replacement like to light - three words" { var devices = std.StringHashMap([]const u8).init(std.testing.allocator); defer devices.deinit(); var replacements = std.StringHashMap([]const u8).init(std.testing.allocator); defer replacements.deinit(); try replacements.put("like", "light"); try devices.put("jack bedroom light", "192.168.1.101"); var parser = try pos.Parser.init(std.testing.allocator); defer parser.deinit(); const sentence = "turn off jack bedroom like"; const sentence_z = try std.testing.allocator.dupeZ(u8, sentence); defer std.testing.allocator.free(sentence_z); try processCommand(std.testing.allocator, sentence_z, &parser, &devices, &replacements); try std.testing.expectEqualStrings("192.168.1.101", test_device_entry.value_ptr.*); try std.testing.expectEqual(DeviceAction.off, test_action); } test "processCommand no match found" { var devices = std.StringHashMap([]const u8).init(std.testing.allocator); defer devices.deinit(); var replacements = std.StringHashMap([]const u8).init(std.testing.allocator); defer replacements.deinit(); try devices.put("kitchen", "192.168.1.100"); var parser = try pos.Parser.init(std.testing.allocator); defer parser.deinit(); const sentence = "turn on the bathroom"; const sentence_z = try std.testing.allocator.dupeZ(u8, sentence); defer std.testing.allocator.free(sentence_z); try std.testing.expectError(error.UnrecognizedSentence, processCommand(std.testing.allocator, sentence_z, &parser, &devices, &replacements)); }