diff --git a/build.zig b/build.zig index 09c8c45..33aebc7 100644 --- a/build.zig +++ b/build.zig @@ -104,7 +104,7 @@ pub fn build(b: *std.build.Builder) void { nd_build_step.dependOn(&b.addInstallArtifact(nd).step); // default build - const build_all_step = b.step("all", "build everything"); + const build_all_step = b.step("all", "build nd and ngui"); build_all_step.dependOn(ngui_build_step); build_all_step.dependOn(nd_build_step); b.default_step.dependOn(build_all_step); @@ -114,12 +114,27 @@ pub fn build(b: *std.build.Builder) void { tests.setTarget(target); tests.setBuildMode(mode); tests.linkLibC(); + tests.addPackage(buildopts.getPackage("build_options")); + nifbuild.addPkg(b, tests, "lib/nif"); + const f = b.option([]const u8, "test-filter", "run tests matching the filter"); tests.setFilter(f); const test_step = b.step("test", "run tests"); test_step.dependOn(&tests.step); } + + { + const guiplay = b.addExecutable("guiplay", "src/test/guiplay.zig"); + guiplay.setTarget(target); + guiplay.setBuildMode(mode); + guiplay.step.dependOn(semver_step); + guiplay.addPackagePath("comm", "src/comm.zig"); + + const guiplay_build_step = b.step("guiplay", "build GUI playground"); + guiplay_build_step.dependOn(&b.addInstallArtifact(guiplay).step); + guiplay_build_step.dependOn(ngui_build_step); + } } const DriverTarget = enum { diff --git a/lib/nif/wpa.zig b/lib/nif/wpa.zig index 1a9da2b..05826b0 100644 --- a/lib/nif/wpa.zig +++ b/lib/nif/wpa.zig @@ -3,11 +3,11 @@ const mem = std.mem; const Thread = std.Thread; const WPACtrl = opaque {}; -const WPAReqCallback = *const fn ([*:0]const u8, usize) callconv(.C) void; +pub const ReqCallback = *const fn ([*:0]const u8, usize) callconv(.C) void; extern fn wpa_ctrl_open(ctrl_path: [*:0]const u8) ?*WPACtrl; extern fn wpa_ctrl_close(ctrl: *WPACtrl) void; -extern fn wpa_ctrl_request(ctrl: *WPACtrl, cmd: [*:0]const u8, clen: usize, reply: [*:0]u8, rlen: *usize, cb: ?WPAReqCallback) c_int; +extern fn wpa_ctrl_request(ctrl: *WPACtrl, cmd: [*:0]const u8, clen: usize, reply: [*:0]u8, rlen: *usize, cb: ?ReqCallback) c_int; extern fn wpa_ctrl_pending(ctrl: *WPACtrl) c_int; extern fn wpa_ctrl_recv(ctrl: *WPACtrl, reply: [*:0]u8, reply_len: *usize) c_int; @@ -130,7 +130,7 @@ pub const Control = struct { /// send a command to the control interface, returning a response owned by buf. /// callback receives a message from the same buf. - pub fn request(self: Self, cmd: [:0]const u8, buf: [:0]u8, callback: ?WPAReqCallback) Error![]const u8 { + pub fn request(self: Self, cmd: [:0]const u8, buf: [:0]u8, callback: ?ReqCallback) Error![]const u8 { //self.mu.lock(); //defer self.mu.unlock(); var n: usize = buf.len; diff --git a/src/comm.zig b/src/comm.zig index 1dcd33d..fe2a129 100644 --- a/src/comm.zig +++ b/src/comm.zig @@ -24,6 +24,7 @@ pub const Message = union(MessageTag) { wifi_connect: WifiConnect, network_report: NetworkReport, get_network_report: GetNetworkReport, + poweroff_progress: PoweroffProgress, pub const WifiConnect = struct { ssid: []const u8, @@ -39,6 +40,16 @@ pub const Message = union(MessageTag) { pub const GetNetworkReport = struct { scan: bool, // true starts a wifi scan and send NetworkReport only after completion }; + + pub const PoweroffProgress = struct { + services: []const Service, + + pub const Service = struct { + name: []const u8, + stopped: bool, + err: ?[]const u8, + }; + }; }; /// it is important to preserve ordinal values for future compatiblity, @@ -54,7 +65,9 @@ pub const MessageTag = enum(u16) { standby = 0x07, // ngui -> nd: resume screen due to user touch; no reply wakeup = 0x08, - // next: 0x09 + // nd -> ngui: reports poweroff progress + poweroff_progress = 0x09, + // next: 0x0a }; /// reads and parses a single message from the input stream reader. @@ -92,6 +105,9 @@ pub fn read(allocator: mem.Allocator, reader: anytype) !Message { .get_network_report => Message{ .get_network_report = try json.parse(Message.GetNetworkReport, &jstream, jopt), }, + .poweroff_progress => Message{ + .poweroff_progress = try json.parse(Message.PoweroffProgress, &jstream, jopt), + }, }; } @@ -106,6 +122,7 @@ pub fn write(allocator: mem.Allocator, writer: anytype, msg: Message) !void { .wifi_connect => try json.stringify(msg.wifi_connect, jopt, data.writer()), .network_report => try json.stringify(msg.network_report, jopt, data.writer()), .get_network_report => try json.stringify(msg.get_network_report, jopt, data.writer()), + .poweroff_progress => try json.stringify(msg.poweroff_progress, jopt, data.writer()), } if (data.items.len > std.math.maxInt(u64)) { return Error.CommWriteTooLarge; @@ -125,6 +142,22 @@ pub fn free(allocator: mem.Allocator, m: Message) void { } } +// TODO: use fifo +// +// var buf = std.fifo.LinearFifo(u8, .Dynamic).init(t.allocator); +// defer buf.deinit(); +// const w = buf.writer(); +// const r = buf.reader(); +// try w.writeAll("hello there"); +// +// var b: [100]u8 = undefined; +// var n = try r.readAll(&b); +// try t.expectEqualStrings("hello there", b[0..n]); +// +// try w.writeAll("once more"); +// n = try r.readAll(&b); +// try t.expectEqualStrings("once more2", b[0..n]); + test "read" { const t = std.testing; diff --git a/src/nd.zig b/src/nd.zig index fab09e9..1a67d17 100644 --- a/src/nd.zig +++ b/src/nd.zig @@ -111,13 +111,17 @@ fn parseArgs(gpa: std.mem.Allocator) !NdArgs { return flags; } -/// quit signals nd to exit. -/// TODO: thread-safety? -var quit = false; +/// sigquit signals nd main loop to exit. +/// since both the loop and sighandler are on the same thread, it must +/// not be guarded by a mutex which otherwise leads to a dealock. +var sigquit = false; fn sighandler(sig: c_int) callconv(.C) void { - logger.info("got signal {}; exiting...\n", .{sig}); - quit = true; + logger.info("received signal {}", .{sig}); + switch (sig) { + os.SIG.INT, os.SIG.TERM => sigquit = true, + else => {}, + } } pub fn main() !void { @@ -134,9 +138,7 @@ pub fn main() !void { // reset the screen backlight to normal power regardless // of its previous state. - screen.backlight(.on) catch |err| { - logger.err("backlight: {any}", .{err}); - }; + screen.backlight(.on) catch |err| logger.err("backlight: {any}", .{err}); // start ngui, unless -nogui mode var ngui = std.ChildProcess.init(&.{args.gui.?}, gpa); @@ -156,9 +158,8 @@ pub fn main() !void { //ngui.uid = uiuser.uid; //ngui.gid = uiuser.gid; // ngui.env_map = ... - ngui.spawn() catch |err| { - fatal("unable to start ngui: {any}", .{err}); - }; + ngui.spawn() catch |err| fatal("unable to start ngui: {any}", .{err}); + // TODO: thread-safety, esp. uiwriter const uireader = ngui.stdout.?.reader(); const uiwriter = ngui.stdin.?.writer(); @@ -175,24 +176,25 @@ pub fn main() !void { .flags = 0, }; try os.sigaction(os.SIG.INT, &sa, null); - //TODO: try os.sigaction(os.SIG.TERM, &sa, null); + try os.sigaction(os.SIG.TERM, &sa, null); - // start network monitor - var ctrl = try nif.wpa.Control.open(args.wpa.?); - defer ctrl.close() catch {}; - var nd: Daemon = .{ - .allocator = gpa, - .uiwriter = uiwriter, - .wpa_ctrl = ctrl, - }; + var nd = try Daemon.init(gpa, uiwriter, args.wpa.?); + defer nd.deinit(); try nd.start(); // send the UI network report right away, without scanning wifi nd.reportNetworkStatus(.{ .scan = false }); - // comm with ui loop; run until exit is requested var poweroff = false; - while (!quit) { + // ngui -> nd comm loop; run until exit is requested + // TODO: move this loop to Daemon.zig? but what about quit and keep ngui running + while (!sigquit) { time.sleep(100 * time.ns_per_ms); + if (poweroff) { + // GUI is not expected to send anything back at this point, + // so just loop until we're terminated by a SIGTERM (sigquit). + continue; + } + // note: uireader.read is blocking // TODO: handle error.EndOfStream - ngui exited const msg = comm.read(gpa, uireader) catch |err| { @@ -205,9 +207,11 @@ pub fn main() !void { logger.info("received pong from ngui", .{}); }, .poweroff => { - logger.info("poweroff requested; terminating", .{}); - quit = true; poweroff = true; + nd.beginPoweroff() catch |err| { + logger.err("beginPoweroff: {any}", .{err}); + poweroff = false; + }; }, .get_network_report => |req| { nd.reportNetworkStatus(.{ .scan = req.scan }); @@ -234,39 +238,10 @@ pub fn main() !void { comm.free(gpa, msg); } - // shutdown + // reached here due to sig TERM or INT; + // note: poweroff does not terminate the loop and instead initiates + // a system shutdown which in turn should terminate this process via a SIGTERM. + // so, there's no difference whether we're exiting due to poweroff of a SIGTERM here. _ = ngui.kill() catch |err| logger.err("ngui.kill: {any}", .{err}); nd.stop(); - if (poweroff) { - svShutdown(gpa); - var off = std.ChildProcess.init(&.{"poweroff"}, gpa); - _ = try off.spawnAndWait(); - } -} - -/// shut down important services manually. -/// TODO: make this OS-agnostic -fn svShutdown(allocator: std.mem.Allocator) void { - // sv waits 7sec by default but bitcoind and lnd need more - // http://smarden.org/runit/ - const Argv = []const []const u8; - const cmds: []const Argv = &.{ - &.{ "sv", "-w", "600", "stop", "lnd" }, - &.{ "sv", "-w", "600", "stop", "bitcoind" }, - }; - var procs: [cmds.len]?std.ChildProcess = undefined; - for (cmds) |argv, i| { - var p = std.ChildProcess.init(argv, allocator); - if (p.spawn()) { - procs[i] = p; - } else |err| { - logger.err("{s}: {any}", .{ argv, err }); - } - } - for (procs) |_, i| { - var p = procs[i]; - if (p != null) { - _ = p.?.wait() catch |err| logger.err("{any}", .{err}); - } - } } diff --git a/src/nd/Daemon.zig b/src/nd/Daemon.zig index 6bd6268..719fbda 100644 --- a/src/nd/Daemon.zig +++ b/src/nd/Daemon.zig @@ -1,5 +1,16 @@ -///! daemon watches network status and communicates updates to the gui -///! using uiwriter +//! daemon watches network status and communicates updates to the GUI using uiwriter. +//! public fields are allocator +//! usage example: +//! +//! var ctrl = try nif.wpa.Control.open("/run/wpa_supplicant/wlan0"); +//! defer ctrl.close() catch {}; +//! var nd: Daemon = .{ +//! .allocator = gpa, +//! .uiwriter = ngui_stdio_writer, +//! .wpa_ctrl = ctrl, +//! }; +//! try nd.start(); + const std = @import("std"); const mem = std.mem; const time = std.time; @@ -8,41 +19,137 @@ const nif = @import("nif"); const comm = @import("../comm.zig"); const screen = @import("../ui/screen.zig"); +const types = @import("../types.zig"); +const SysService = @import("SysService.zig"); const logger = std.log.scoped(.netmon); -// pub fields allocator: mem.Allocator, uiwriter: std.fs.File.Writer, // ngui stdin -wpa_ctrl: nif.wpa.Control, // guarded by mu once start'ed +wpa_ctrl: types.WpaControl, // guarded by mu once start'ed -// private fields +/// guards all the fields below to sync between pub fns and main/poweroff threads. mu: std.Thread.Mutex = .{}, -quit: bool = false, // tells daemon to quit -main_thread: ?std.Thread = null, // non-nill if started -want_report: bool = false, + +/// daemon state +state: enum { + stopped, + running, + poweroff, +}, + +main_thread: ?std.Thread = null, +poweroff_thread: ?std.Thread = null, + +want_stop: bool = false, // tells daemon main loop to quit +want_network_report: bool = false, want_wifi_scan: bool = false, wifi_scan_in_progress: bool = false, -report_ready: bool = true, // no need to scan for an immediate report +network_report_ready: bool = true, // no need to scan for an immediate report wpa_save_config_on_connected: bool = false, +/// system services actively managed by the daemon. +/// these are stop'ed during poweroff and their shutdown progress sent to ngui. +/// initialized in start and never modified again: ok to access without holding self.mu. +services: []SysService = &.{}, + const Daemon = @This(); +/// callers must deinit when done. +pub fn init(a: std.mem.Allocator, iogui: std.fs.File.Writer, wpa_path: [:0]const u8) !Daemon { + var svlist = std.ArrayList(SysService).init(a); + errdefer { + for (svlist.items) |*sv| sv.deinit(); + svlist.deinit(); + } + // the order is important. when powering off, the services are shut down + // in the same order appended here. + try svlist.append(SysService.init(a, "lnd", .{ .stop_wait_sec = 600 })); + try svlist.append(SysService.init(a, "bitcoind", .{ .stop_wait_sec = 600 })); + return .{ + .allocator = a, + .uiwriter = iogui, + .wpa_ctrl = try types.WpaControl.open(wpa_path), + .state = .stopped, + .services = svlist.toOwnedSlice(), + }; +} + +/// releases all associated resources. +/// if the daemon is not in a stopped or poweroff mode, deinit panics. +pub fn deinit(self: *Daemon) void { + self.mu.lock(); + defer self.mu.unlock(); + switch (self.state) { + .stopped, .poweroff => if (self.want_stop) { + @panic("deinit while stopping"); + }, + else => @panic("deinit while running"), + } + self.wpa_ctrl.close() catch |err| logger.err("deinit: wpa_ctrl.close: {any}", .{err}); + for (self.services) |*sv| { + sv.deinit(); + } + self.allocator.free(self.services); +} + +/// start launches a main thread and returns immediately. +/// once started, the daemon must be eventually stop'ed to clean up resources +/// even if a poweroff sequence is launched with beginPoweroff. however, in the latter +/// case the daemon cannot be start'ed again after stop. pub fn start(self: *Daemon) !void { - // TODO: return error if already started + self.mu.lock(); + defer self.mu.unlock(); + switch (self.state) { + .running => return error.AlreadyStarted, + .poweroff => return error.InPoweroffState, + .stopped => {}, // continue + } + + try self.wpa_ctrl.attach(); self.main_thread = try std.Thread.spawn(.{}, mainThreadLoop, .{self}); + self.state = .running; } +/// stop blocks until all daemon threads exit, including poweroff if any. +/// once stopped, the daemon can be start'ed again unless a poweroff was initiated. +/// +/// note: stop leaves system services like lnd and bitcoind running. pub fn stop(self: *Daemon) void { self.mu.lock(); - self.quit = true; - self.mu.unlock(); + if (self.want_stop or self.state == .stopped) { + self.mu.unlock(); + return; // already in progress or stopped + } + self.want_stop = true; + self.mu.unlock(); // avoid threads deadlock + if (self.main_thread) |th| { th.join(); + self.main_thread = null; + } + // must be the last one to join because it sends a final poweroff report. + if (self.poweroff_thread) |th| { + th.join(); + self.poweroff_thread = null; } + + self.mu.lock(); + defer self.mu.unlock(); + self.want_stop = false; + if (self.state != .poweroff) { // keep poweroff to prevent start'ing again + self.state = .stopped; + } + self.wpa_ctrl.detach() catch |err| logger.err("stop: wpa_ctrl.detach: {any}", .{err}); } -pub fn standby(_: *Daemon) !void { +pub fn standby(self: *Daemon) !void { + self.mu.lock(); + defer self.mu.unlock(); + switch (self.state) { + .poweroff => return error.InPoweroffState, + .running, .stopped => {}, // continue + } try screen.backlight(.off); } @@ -50,58 +157,119 @@ pub fn wakeup(_: *Daemon) !void { try screen.backlight(.on); } +/// initiates system poweroff sequence in a separate thread: shut down select +/// system services such as lnd and bitcoind, and issue "poweroff" command. +/// +/// in the poweroff mode, the daemon is still running as usual and must be stop'ed. +/// however, in poweroff mode regular functionalities are disabled, such as +/// wifi scan and standby. +pub fn beginPoweroff(self: *Daemon) !void { + self.mu.lock(); + defer self.mu.unlock(); + if (self.state == .poweroff) { + return; // already in poweroff state + } + + self.poweroff_thread = try std.Thread.spawn(.{}, poweroffThread, .{self}); + self.state = .poweroff; +} + +// stops all monitored services and issue poweroff command while reporting +// the progress to ngui. +fn poweroffThread(self: *Daemon) !void { + logger.info("begin powering off", .{}); + screen.backlight(.on) catch |err| { + logger.err("screen.backlight(.on) during poweroff: {any}", .{err}); + }; + self.wpa_ctrl.detach() catch {}; // don't care because powering off anyway + + // initiate shutdown of all services concurrently. + for (self.services) |*sv| { + sv.stop() catch |err| logger.err("sv stop '{s}': {any}", .{ sv.name, err }); + } + self.sendPoweroffReport() catch |err| logger.err("sendPoweroffReport: {any}", .{err}); + + // wait each service until stopped or error. + for (self.services) |*sv| { + _ = sv.stopWait() catch {}; + logger.info("{s} sv is now stopped; err={any}", .{ sv.name, sv.lastStopError() }); + self.sendPoweroffReport() catch |err| logger.err("sendPoweroffReport: {any}", .{err}); + } + + // finally, initiate system shutdown and power it off. + var off = types.ChildProcess.init(&.{"poweroff"}, self.allocator); + const res = off.spawnAndWait(); + logger.info("poweroff: {any}", .{res}); +} + /// main thread entry point. fn mainThreadLoop(self: *Daemon) !void { - try self.wpa_ctrl.attach(); - defer self.wpa_ctrl.detach() catch |err| logger.err("wpa_ctrl.detach failed on exit: {any}", .{err}); - - while (true) { + var quit = false; + while (!quit) { + self.mainThreadLoopCycle() catch |err| logger.err("main thread loop: {any}", .{err}); time.sleep(1 * time.ns_per_s); - self.mainThreadLoopCycle(); self.mu.lock(); - const do_quit = self.quit; + quit = self.want_stop; self.mu.unlock(); - if (do_quit) { - break; - } } } /// run one cycle of the main thread loop iteration. -/// holds self.mu for the whole duration. -fn mainThreadLoopCycle(self: *Daemon) void { - self.mu.lock(); - defer self.mu.unlock(); - self.readWPACtrlMsg() catch |err| logger.err("readWPACtrlMsg: {any}", .{err}); - if (self.want_wifi_scan) { - if (self.startWifiScan()) { - self.want_wifi_scan = false; - } else |err| { - logger.err("startWifiScan: {any}", .{err}); - } +/// unless in poweroff mode, the cycle holds self.mu for the whole duration. +fn mainThreadLoopCycle(self: *Daemon) !void { + switch (self.state) { + // poweroff mode: do nothing; handled by poweroffThread + .poweroff => {}, + // normal state: running or standby + else => { + self.mu.lock(); + defer self.mu.unlock(); + self.readWPACtrlMsg() catch |err| logger.err("readWPACtrlMsg: {any}", .{err}); + if (self.want_wifi_scan) { + if (self.startWifiScan()) { + self.want_wifi_scan = false; + } else |err| { + logger.err("startWifiScan: {any}", .{err}); + } + } + if (self.want_network_report and self.network_report_ready) { + if (self.sendNetworkReport()) { + self.want_network_report = false; + } else |err| { + logger.err("sendNetworkReport: {any}", .{err}); + } + } + }, } - if (self.want_report and self.report_ready) { - if (self.sendNetworkReport()) { - self.want_report = false; - } else |err| { - logger.err("sendNetworkReport: {any}", .{err}); - } +} + +fn sendPoweroffReport(self: *Daemon) !void { + var svstat = try self.allocator.alloc(comm.Message.PoweroffProgress.Service, self.services.len); + defer self.allocator.free(svstat); + for (self.services) |*sv, i| { + svstat[i] = .{ + .name = sv.name, + .stopped = sv.status() == .stopped, + .err = if (sv.lastStopError()) |err| @errorName(err) else null, + }; } + const report = comm.Message{ .poweroff_progress = .{ .services = svstat } }; + try comm.write(self.allocator, self.uiwriter, report); } /// caller must hold self.mu. fn startWifiScan(self: *Daemon) !void { try self.wpa_ctrl.scan(); self.wifi_scan_in_progress = true; - self.report_ready = false; + self.network_report_ready = false; } /// invoked when CTRL-EVENT-SCAN-RESULTS event is seen. /// caller must hold self.mu. fn wifiScanComplete(self: *Daemon) void { self.wifi_scan_in_progress = false; - self.report_ready = true; + self.network_report_ready = true; } /// invoked when CTRL-EVENT-CONNECTED event is seen. @@ -117,15 +285,15 @@ fn wifiConnected(self: *Daemon) void { } } // always send a network report when connected - self.want_report = true; + self.want_network_report = true; } /// invoked when CTRL-EVENT-SSID-TEMP-DISABLED event with authentication failures is seen. /// caller must hold self.mu. fn wifiInvalidKey(self: *Daemon) void { self.wpa_save_config_on_connected = false; - self.want_report = true; - self.report_ready = true; + self.want_network_report = true; + self.network_report_ready = true; } pub const ReportNetworkStatusOpt = struct { @@ -135,10 +303,10 @@ pub const ReportNetworkStatusOpt = struct { pub fn reportNetworkStatus(self: *Daemon, opt: ReportNetworkStatusOpt) void { self.mu.lock(); defer self.mu.unlock(); - self.want_report = true; + self.want_network_report = true; self.want_wifi_scan = opt.scan and !self.wifi_scan_in_progress; - if (self.want_wifi_scan and self.report_ready) { - self.report_ready = false; + if (self.want_wifi_scan and self.network_report_ready) { + self.network_report_ready = false; } } @@ -160,7 +328,7 @@ fn connectWifiThread(self: *Daemon, ssid: []const u8, password: []const u8) void // https://hostap.epitest.fi/wpa_supplicant/devel/ctrl_iface_page.html // https://wiki.archlinux.org/title/WPA_supplicant - // unfortunately, this prevents main thread from looping until released. + // this prevents main thread from looping until released, // but the following commands and expected to be pretty quick. self.mu.lock(); defer self.mu.unlock(); @@ -305,6 +473,7 @@ fn sendNetworkReport(self: *Daemon) !void { return comm.write(self.allocator, self.uiwriter, comm.Message{ .network_report = report }); } +/// caller must hold self.mu. fn queryWifiSSID(self: *Daemon) !?[]const u8 { var buf: [512:0]u8 = undefined; const resp = try self.wpa_ctrl.request("STATUS", &buf, null); @@ -320,7 +489,8 @@ fn queryWifiSSID(self: *Daemon) !?[]const u8 { return null; } -/// callers must free with StringList.deinit. +/// caller must hold self.mu. +/// the retuned value must free'd with StringList.deinit. fn queryWifiScanResults(self: *Daemon) !StringList { var buf: [8192:0]u8 = undefined; // TODO: what if isn't enough? // first line is banner: "bssid / frequency / signal level / flags / ssid" @@ -352,7 +522,8 @@ const WifiNetworksListFilter = struct { ssid: ?[]const u8, // ignore networks whose ssid doesn't match }; -/// caller must release results with allocator.free. +/// caller must hold self.mu. +/// the returned value must be free'd with self.allocator. fn queryWifiNetworksList(self: *Daemon, filter: WifiNetworksListFilter) ![]u32 { var buf: [8192:0]u8 = undefined; // TODO: is this enough? // first line is banner: "network id / ssid / bssid / flags" @@ -407,3 +578,83 @@ const StringList = struct { return self.l.items; } }; + +test "start-stop" { + const t = std.testing; + + const pipe = try types.IoPipe.create(); + defer pipe.close(); + var daemon = try Daemon.init(t.allocator, pipe.writer(), "/dev/null"); + + try t.expect(daemon.state == .stopped); + try daemon.start(); + try t.expect(daemon.state == .running); + try t.expect(daemon.wpa_ctrl.opened); + try t.expect(daemon.wpa_ctrl.attached); + + daemon.stop(); + try t.expect(daemon.state == .stopped); + try t.expect(!daemon.want_stop); + try t.expect(!daemon.wpa_ctrl.attached); + try t.expect(daemon.wpa_ctrl.opened); + try t.expect(daemon.main_thread == null); + try t.expect(daemon.poweroff_thread == null); + + try t.expect(daemon.services.len > 0); + for (daemon.services) |*sv| { + try t.expect(!sv.stop_proc.spawned); + try t.expectEqual(SysService.Status.initial, sv.status()); + } + + daemon.deinit(); + try t.expect(!daemon.wpa_ctrl.opened); +} + +test "start-poweroff-stop" { + const t = std.testing; + const tt = @import("../test.zig"); + + var arena_alloc = std.heap.ArenaAllocator.init(t.allocator); + defer arena_alloc.deinit(); + const arena = arena_alloc.allocator(); + + const pipe = try types.IoPipe.create(); + var daemon = try Daemon.init(arena, pipe.writer(), "/dev/null"); + defer { + daemon.deinit(); + pipe.close(); + } + + try daemon.start(); + try daemon.beginPoweroff(); + daemon.stop(); + try t.expect(daemon.state == .poweroff); + for (daemon.services) |*sv| { + try t.expect(sv.stop_proc.spawned); + try t.expect(sv.stop_proc.waited); + try t.expectEqual(SysService.Status.stopped, sv.status()); + } + + const pipe_reader = pipe.reader(); + const msg1 = try comm.read(arena, pipe_reader); + try tt.expectDeepEqual(comm.Message{ .poweroff_progress = .{ .services = &.{ + .{ .name = "lnd", .stopped = false, .err = null }, + .{ .name = "bitcoind", .stopped = false, .err = null }, + } } }, msg1); + + const msg2 = try comm.read(arena, pipe_reader); + try tt.expectDeepEqual(comm.Message{ .poweroff_progress = .{ .services = &.{ + .{ .name = "lnd", .stopped = true, .err = null }, + .{ .name = "bitcoind", .stopped = false, .err = null }, + } } }, msg2); + + const msg3 = try comm.read(arena, pipe_reader); + try tt.expectDeepEqual(comm.Message{ .poweroff_progress = .{ .services = &.{ + .{ .name = "lnd", .stopped = true, .err = null }, + .{ .name = "bitcoind", .stopped = true, .err = null }, + } } }, msg3); + + // TODO: ensure "poweroff" was executed once custom runner is in a zig release; + // need custom runner to set up a global registry for child processes. + // https://github.com/ziglang/zig/pull/13411 +} diff --git a/src/nd/SysService.zig b/src/nd/SysService.zig new file mode 100644 index 0000000..c902556 --- /dev/null +++ b/src/nd/SysService.zig @@ -0,0 +1,185 @@ +///! an interface to programmatically manage a system service. +///! safe for concurrent use. +const std = @import("std"); +const types = @import("../types.zig"); + +allocator: std.mem.Allocator, +name: []const u8, +stop_wait_sec: ?u32 = null, + +/// mutex guards all fields below. +mu: std.Thread.Mutex = .{}, +stat: State, +stop_proc: types.ChildProcess = undefined, +stop_err: ?anyerror = null, + +/// service current state. +/// the .initial value is a temporary solution until service watcher and start +/// are implemnted: at the moment, SysService can only stop services, nothing else. +pub const Status = enum(u8) { + initial, // TODO: add .running + stopping, + stopped, +}; + +const State = union(Status) { + initial: void, + stopping: void, + stopped: std.ChildProcess.Term, +}; + +const SysService = @This(); + +pub const InitOpts = struct { + /// how long to wait for the service to stop before SIGKILL. + /// if unspecified, default for sv is 7. + stop_wait_sec: ?u32 = null, +}; + +/// must deinit when done. +pub fn init(a: std.mem.Allocator, name: []const u8, opts: InitOpts) SysService { + return .{ + .allocator = a, + .name = name, + .stop_wait_sec = opts.stop_wait_sec, + .stat = .initial, + }; +} + +pub fn deinit(_: *SysService) void {} + +/// reports current state of the service. +/// note that it may incorrectly reflect the actual running service state +/// since SysService has no process watcher implementation yet. +pub fn status(self: *SysService) Status { + self.mu.lock(); + defer self.mu.unlock(); + return self.stat; +} + +/// returns an error during last stop call, if any. +pub fn lastStopError(self: *SysService) ?anyerror { + self.mu.lock(); + defer self.mu.unlock(); + return self.stop_err; +} + +/// launches a service stop procedure and returns immediately. +/// callers must invoke stopWait to release all resources used by the stop. +pub fn stop(self: *SysService) !void { + self.mu.lock(); + defer self.mu.unlock(); + + self.stop_err = null; + self.spawnStop() catch |err| { + self.stop_err = err; + return err; + }; +} + +/// blocks until the service stopping procedure terminates. +/// an error is returned also in the case where stopping a service failed. +pub fn stopWait(self: *SysService) !void { + self.mu.lock(); + defer self.mu.unlock(); + + self.stop_err = null; + self.spawnStop() catch |err| { + self.stop_err = err; + return err; + }; + + const term = self.stop_proc.wait() catch |err| { + self.stop_err = err; + return err; + }; + self.stat = .{ .stopped = term }; + switch (term) { + .Exited => |code| if (code != 0) { + self.stop_err = error.SysServiceBadStopCode; + }, + else => { + self.stop_err = error.SysServiceBadStopTerm; + }, + } + if (self.stop_err) |err| { + return err; + } +} + +/// actual internal body of SysService.stop: stopWait also uses this. +/// callers must hold self.mu. +fn spawnStop(self: *SysService) !void { + switch (self.stat) { + .stopping => return, // already in progress + // intentionally let .stopped state pass through: can't see any downsides. + .initial, .stopped => {}, + } + + // use arena to simplify stop proc args construction. + var arena_alloc = std.heap.ArenaAllocator.init(self.allocator); + defer arena_alloc.deinit(); + const arena = arena_alloc.allocator(); + + var argv = std.ArrayList([]const u8).init(arena); + try argv.append("sv"); + if (self.stop_wait_sec) |sec| { + const s = try std.fmt.allocPrint(arena, "{d}", .{sec}); + try argv.appendSlice(&.{ "-w", s }); + } + try argv.appendSlice(&.{ "stop", self.name }); + // can't use arena alloc since it's deinited upon return but proc needs alloc until wait'ed. + // child process dup's argv when spawned and auto-frees all resources when done (wait'ed). + self.stop_proc = types.ChildProcess.init(argv.items, self.allocator); + try self.stop_proc.spawn(); + self.stat = .stopping; +} + +test "stop then stopWait" { + const t = std.testing; + + var sv = SysService.init(t.allocator, "testsv1", .{ .stop_wait_sec = 13 }); + try t.expectEqual(Status.initial, sv.status()); + + try sv.stop(); + try t.expectEqual(Status.stopping, sv.status()); + defer sv.stop_proc.deinit(); // TestChildProcess + + try t.expect(sv.stop_proc.spawned); + try t.expect(!sv.stop_proc.waited); + try t.expect(!sv.stop_proc.killed); + const cmd = try std.mem.join(t.allocator, " ", sv.stop_proc.argv); + defer t.allocator.free(cmd); + try t.expectEqualStrings("sv -w 13 stop testsv1", cmd); + + try sv.stopWait(); + try t.expect(sv.stop_proc.waited); + try t.expect(!sv.stop_proc.killed); +} + +test "stopWait" { + const t = std.testing; + + var sv = SysService.init(t.allocator, "testsv2", .{ .stop_wait_sec = 14 }); + try sv.stopWait(); + defer sv.stop_proc.deinit(); // TestChildProcess + + try t.expect(sv.stop_proc.spawned); + try t.expect(sv.stop_proc.waited); + try t.expect(!sv.stop_proc.killed); + const cmd = try std.mem.join(t.allocator, " ", sv.stop_proc.argv); + defer t.allocator.free(cmd); + try t.expectEqualStrings("sv -w 14 stop testsv2", cmd); +} + +test "stop with default wait" { + const t = std.testing; + + var sv = SysService.init(t.allocator, "testsv3", .{}); + try sv.stopWait(); + defer sv.stop_proc.deinit(); // TestChildProcess + + const cmd = try std.mem.join(t.allocator, " ", sv.stop_proc.argv); + defer t.allocator.free(cmd); + try t.expectEqualStrings("sv stop testsv3", cmd); +} diff --git a/src/ngui.zig b/src/ngui.zig index b2c7799..c11f052 100644 --- a/src/ngui.zig +++ b/src/ngui.zig @@ -1,5 +1,6 @@ const buildopts = @import("build_options"); const std = @import("std"); +const os = std.os; const time = std.time; const comm = @import("comm.zig"); @@ -22,25 +23,26 @@ const logger = std.log.scoped(.ngui); extern "c" fn ui_update_network_status(text: [*:0]const u8, wifi_list: ?[*:0]const u8) void; -/// global heap allocator used throughout the gui program. +/// global heap allocator used throughout the GUI program. /// TODO: thread-safety? var gpa: std.mem.Allocator = undefined; /// the mutex must be held before any call reaching into lv_xxx functions. /// all nm_xxx functions assume it is the case since they are invoked from lvgl c code. var ui_mutex: std.Thread.Mutex = .{}; + /// the program runs until quit is true. -var quit: bool = false; +/// set from sighandler or on unrecoverable comm failure with the daemon. +var want_quit: bool = false; + var state: enum { active, // normal operational mode standby, // idling alert, // draw user attention; never go standby } = .active; -/// setting wakeup brings the screen back from sleep()'ing without waiting -/// for user action. -/// can be used by comms when an alert is received from the daemon, to draw -/// user attention. +/// by setting wakeup brings the screen back from sleep()'ing without waiting for user action. +/// can be used by comms when an alert is received from the daemon, to draw user attention. /// safe for concurrent use except wakeup.reset() is UB during another thread /// wakeup.wait()'ing or timedWait'ing. var wakeup = std.Thread.ResetEvent{}; @@ -68,12 +70,13 @@ export fn nm_check_idle_time(_: *lvgl.LvTimer) void { } } -/// initiate system shutdown leading to power off. +/// tells the daemon to initiate system shutdown leading to power off. +/// once all's done, the daemon will send a SIGTERM back to ngui. export fn nm_sys_shutdown() void { - logger.info("initiating system shutdown", .{}); const msg = comm.Message.poweroff; comm.write(gpa, stdout, msg) catch |err| logger.err("nm_sys_shutdown: {any}", .{err}); - quit = true; + state = .alert; // prevent screen sleep + wakeup.set(); // wake up from standby, if any } export fn nm_tab_settings_active() void { @@ -144,17 +147,16 @@ fn updateNetworkStatus(report: comm.Message.NetworkReport) !void { } } -/// reads messages from nd; loops indefinitely until program exit -fn commThread() void { +/// reads messages from nd. +/// loops indefinitely until program exit or comm returns EOS. +fn commThreadLoop() void { while (true) { commThreadLoopCycle() catch |err| logger.err("commThreadLoopCycle: {any}", .{err}); - ui_mutex.lock(); - const do_quit = quit; - ui_mutex.unlock(); - if (do_quit) { - return; - } + + std.atomic.spinLoopHint(); + time.sleep(1 * time.ns_per_ms); } + logger.info("exiting commThreadLoop", .{}); } fn commThreadLoopCycle() !void { @@ -162,8 +164,9 @@ fn commThreadLoopCycle() !void { if (err == error.EndOfStream) { // pointless to continue running if comms is broken. // a parent/supervisor is expected to restart ngui. + logger.err("comm.read: EOS", .{}); ui_mutex.lock(); - quit = true; + want_quit = true; ui_mutex.unlock(); } return err; @@ -175,6 +178,11 @@ fn commThreadLoopCycle() !void { .network_report => |report| { updateNetworkStatus(report) catch |err| logger.err("updateNetworkStatus: {any}", .{err}); }, + .poweroff_progress => |report| { + ui_mutex.lock(); + defer ui_mutex.unlock(); + ui.poweroff.updateStatus(report) catch |err| logger.err("poweroff.updateStatus: {any}", .{err}); + }, else => logger.warn("unhandled msg tag {s}", .{@tagName(msg)}), } } @@ -218,6 +226,20 @@ fn usage(prog: []const u8) !void { , .{prog}); } +/// handles sig TERM and INT: makes the program exit. +/// +/// note: must avoid locking ui_mutex within the handler since it may lead to +/// a race and a deadlock where the sighandler is invoked while the mutex is held +/// by the UI loop because a sighandler invocation interrupts main execution flow, +/// and so the mutex would then remain locked indefinitely. +fn sighandler(sig: c_int) callconv(.C) void { + logger.info("received signal {}", .{sig}); + switch (sig) { + os.SIG.INT, os.SIG.TERM => want_quit = true, + else => {}, + } +} + /// nakamochi UI program entry point. pub fn main() anyerror!void { // main heap allocator used through the lifetime of nd @@ -240,8 +262,16 @@ pub fn main() anyerror!void { }; // start comms with daemon in a seaparate thread. - const th = try std.Thread.spawn(.{}, commThread, .{}); - th.detach(); + _ = try std.Thread.spawn(.{}, commThreadLoop, .{}); + + // set up a sigterm handler for clean exit. + const sa = os.Sigaction{ + .handler = .{ .handler = sighandler }, + .mask = os.empty_sigset, + .flags = 0, + }; + try os.sigaction(os.SIG.INT, &sa, null); + try os.sigaction(os.SIG.TERM, &sa, null); // run idle timer indefinitely _ = lvgl.createTimer(nm_check_idle_time, 2000, null) catch |err| { @@ -249,16 +279,12 @@ pub fn main() anyerror!void { }; // main UI thread; must never block unless in idle/sleep mode - // TODO: handle sigterm - while (true) { + while (!want_quit) { ui_mutex.lock(); - var till_next_ms = lvgl.loopCycle(); - const do_quit = quit; + var till_next_ms = lvgl.loopCycle(); // UI loop const do_state = state; ui_mutex.unlock(); - if (do_quit) { - return; - } + if (do_state == .standby) { // go into a screen sleep mode due to no user activity wakeup.reset(); @@ -266,6 +292,7 @@ pub fn main() anyerror!void { logger.err("comm.write standby: {any}", .{err}); }; screen.sleep(&wakeup); + // wake up due to touch screen activity or wakeup event is set logger.info("waking up from sleep", .{}); ui_mutex.lock(); @@ -279,10 +306,14 @@ pub fn main() anyerror!void { ui_mutex.unlock(); continue; } + std.atomic.spinLoopHint(); - // sleep at least 1ms - time.sleep(@max(1, till_next_ms) * time.ns_per_ms); + time.sleep(@max(1, till_next_ms) * time.ns_per_ms); // sleep at least 1ms } + logger.info("main UI loop terminated", .{}); + + // not waiting for comm thread because it is terminated at program exit here + // anyway. } test "tick" { diff --git a/src/test.zig b/src/test.zig index 7ab39c5..d9e3823 100644 --- a/src/test.zig +++ b/src/test.zig @@ -1,4 +1,10 @@ const std = @import("std"); +const builtin = @import("builtin"); +const nif = @import("nif"); + +comptime { + if (!builtin.is_test) @compileError("test-only module"); +} export fn wifi_ssid_add_network(name: [*:0]const u8) void { _ = name; @@ -13,8 +19,222 @@ export fn lv_disp_get_inactive_time(disp: *opaque {}) u32 { return 0; } +/// TestTimer always reports the same fixed value. +pub const TestTimer = struct { + value: u64, + started: bool = false, // true if called start + resetted: bool = false, // true if called reset + + pub fn start() std.time.Timer.Error!TestTimer { + return .{ .value = 42 }; + } + + pub fn reset(self: *TestTimer) void { + self.resetted = true; + } + + pub fn read(self: *TestTimer) u64 { + return self.value; + } +}; + +/// args in init are dup'ed using an allocator. +/// the caller must deinit in the end. +pub const TestChildProcess = struct { + // test hooks + spawn_callback: ?*const fn (*TestChildProcess) std.ChildProcess.SpawnError!void = null, + wait_callback: ?*const fn (*TestChildProcess) anyerror!std.ChildProcess.Term = null, + kill_callback: ?*const fn (*TestChildProcess) anyerror!std.ChildProcess.Term = null, + spawned: bool = false, + waited: bool = false, + killed: bool = false, + + // original std ChildProcess init args + allocator: std.mem.Allocator, + argv: []const []const u8, + + pub fn init(argv: []const []const u8, allocator: std.mem.Allocator) TestChildProcess { + var adup = allocator.alloc([]u8, argv.len) catch unreachable; + for (argv) |v, i| { + adup[i] = allocator.dupe(u8, v) catch unreachable; + } + return .{ + .allocator = allocator, + .argv = adup, + }; + } + + pub fn deinit(self: *TestChildProcess) void { + for (self.argv) |v| self.allocator.free(v); + self.allocator.free(self.argv); + } + + pub fn spawn(self: *TestChildProcess) std.ChildProcess.SpawnError!void { + defer self.spawned = true; + if (self.spawn_callback) |cb| { + return cb(self); + } + } + + pub fn wait(self: *TestChildProcess) anyerror!std.ChildProcess.Term { + defer self.waited = true; + if (self.wait_callback) |cb| { + return cb(self); + } + return .{ .Exited = 0 }; + } + + pub fn spawnAndWait(self: *TestChildProcess) !std.ChildProcess.Term { + try self.spawn(); + return self.wait(); + } + + pub fn kill(self: *TestChildProcess) !std.ChildProcess.Term { + defer self.killed = true; + if (self.kill_callback) |cb| { + return cb(self); + } + return .{ .Exited = 0 }; + } +}; + +/// a nif.wpa.Control stub for tests. +pub const TestWpaControl = struct { + ctrl_path: []const u8, + opened: bool, + attached: bool = false, + scanned: bool = false, + saved: bool = false, + + const Self = @This(); + + pub fn open(path: [:0]const u8) !Self { + return .{ .ctrl_path = path, .opened = true }; + } + + pub fn close(self: *Self) !void { + self.opened = false; + } + + pub fn attach(self: *Self) !void { + self.attached = true; + } + + pub fn detach(self: *Self) !void { + self.attached = false; + } + + pub fn pending(_: Self) !bool { + return false; + } + + pub fn receive(_: Self, _: [:0]u8) ![]const u8 { + return &.{}; + } + + pub fn scan(self: *Self) !void { + self.scanned = true; + } + + pub fn saveConfig(self: *Self) !void { + self.saved = true; + } + + pub fn request(_: Self, _: [:0]const u8, _: [:0]u8, _: ?nif.wpa.ReqCallback) ![]const u8 { + return &.{}; + } +}; + +/// similar to std.testing.expectEqual but compares slices with expectEqualSlices +/// or expectEqualStrings where slice element is a u8. +pub fn expectDeepEqual(expected: anytype, actual: @TypeOf(expected)) !void { + const t = std.testing; + switch (@typeInfo(@TypeOf(actual))) { + .Pointer => |p| { + switch (p.size) { + .One => try expectDeepEqual(expected.*, actual.*), + .Slice => { + switch (@typeInfo(p.child)) { + .Pointer, .Struct, .Optional, .Union => { + var err: ?anyerror = blk: { + if (expected.len != actual.len) { + std.debug.print("expected.len = {d}, actual.len = {d}\n", .{ expected.len, actual.len }); + break :blk error.ExpectDeepEqual; + } + break :blk null; + }; + const n = std.math.min(expected.len, actual.len); + var i: usize = 0; + while (i < n) : (i += 1) { + expectDeepEqual(expected[i], actual[i]) catch |e| { + std.debug.print("unequal slice elements at index {d}\n", .{i}); + return e; + }; + } + if (err) |e| { + return e; + } + }, + else => { + if (p.child == u8) { + try t.expectEqualStrings(expected, actual); + } else { + try t.expectEqualSlices(p.child, expected, actual); + } + }, + } + }, + else => try t.expectEqual(expected, actual), + } + }, + .Struct => |st| { + inline for (st.fields) |f| { + expectDeepEqual(@field(expected, f.name), @field(actual, f.name)) catch |err| { + std.debug.print("unequal field '{s}' of struct {any}\n", .{ f.name, @TypeOf(actual) }); + return err; + }; + } + }, + .Optional => { + if (expected) |x| { + if (actual) |v| { + try expectDeepEqual(x, v); + } else { + std.debug.print("expected {any}, found null\n", .{x}); + return error.TestExpectDeepEqual; + } + } else { + if (actual) |v| { + std.debug.print("expected null, found {any}\n", .{v}); + return error.TestExpectDeepEqual; + } + } + }, + .Union => |u| { + if (u.tag_type == null) { + @compileError("unable to compare untagged union values"); + } + const Tag = std.meta.Tag(@TypeOf(expected)); + const atag = @as(Tag, actual); + try t.expectEqual(@as(Tag, expected), atag); + inline for (u.fields) |f| { + if (std.mem.eql(u8, f.name, @tagName(atag))) { + try expectDeepEqual(@field(expected, f.name), @field(actual, f.name)); + return; + } + } + unreachable; + }, + else => { + try t.expectEqual(expected, actual); + }, + } +} + test { - _ = @import("comm.zig"); + _ = @import("nd.zig"); + _ = @import("nd/Daemon.zig"); + _ = @import("nd/SysService.zig"); _ = @import("ngui.zig"); std.testing.refAllDecls(@This()); diff --git a/src/test/guiplay.zig b/src/test/guiplay.zig new file mode 100644 index 0000000..6082574 --- /dev/null +++ b/src/test/guiplay.zig @@ -0,0 +1,158 @@ +const std = @import("std"); +const time = std.time; +const os = std.os; + +const comm = @import("comm"); + +const logger = std.log.scoped(.play); +const stderr = std.io.getStdErr().writer(); + +var ngui_proc: std.ChildProcess = undefined; +var sigquit = false; + +fn sighandler(sig: c_int) callconv(.C) void { + logger.info("received signal {} (TERM={} INT={})", .{ sig, os.SIG.TERM, os.SIG.INT }); + switch (sig) { + os.SIG.INT, os.SIG.TERM => sigquit = true, + else => {}, + } +} + +fn fatal(comptime fmt: []const u8, args: anytype) noreturn { + stderr.print(fmt, args) catch {}; + if (fmt[fmt.len - 1] != '\n') { + stderr.writeByte('\n') catch {}; + } + std.process.exit(1); +} + +const Flags = struct { + ngui_path: ?[:0]const u8 = null, + + fn deinit(self: @This(), allocator: std.mem.Allocator) void { + if (self.ngui_path) |p| allocator.free(p); + } +}; + +fn parseArgs(gpa: std.mem.Allocator) !Flags { + var flags: Flags = .{}; + + var args = try std.process.ArgIterator.initWithAllocator(gpa); + defer args.deinit(); + const prog = args.next() orelse return error.NoProgName; + + var lastarg: enum { + none, + ngui_path, + } = .none; + while (args.next()) |a| { + switch (lastarg) { + .none => {}, + .ngui_path => { + flags.ngui_path = try gpa.dupeZ(u8, a); + lastarg = .none; + continue; + }, + } + if (std.mem.eql(u8, a, "-ngui")) { + lastarg = .ngui_path; + } else { + fatal("unknown arg name {s}", .{a}); + } + } + if (lastarg != .none) { + fatal("invalid arg: {s} requires a value", .{@tagName(lastarg)}); + } + + if (flags.ngui_path == null) { + const dir = std.fs.path.dirname(prog) orelse "/"; + flags.ngui_path = try std.fs.path.joinZ(gpa, &.{ dir, "ngui" }); + } + + return flags; +} + +pub fn main() !void { + var gpa_state = std.heap.GeneralPurposeAllocator(.{}){}; + defer if (gpa_state.deinit()) { + logger.err("memory leaks detected", .{}); + }; + const gpa = gpa_state.allocator(); + const flags = try parseArgs(gpa); + defer flags.deinit(gpa); + + ngui_proc = std.ChildProcess.init(&.{flags.ngui_path.?}, gpa); + ngui_proc.stdin_behavior = .Pipe; + ngui_proc.stdout_behavior = .Pipe; + ngui_proc.stderr_behavior = .Inherit; + ngui_proc.spawn() catch |err| { + fatal("unable to start ngui: {any}", .{err}); + }; + + const sa = os.Sigaction{ + .handler = .{ .handler = sighandler }, + .mask = os.empty_sigset, + .flags = 0, + }; + try os.sigaction(os.SIG.INT, &sa, null); + try os.sigaction(os.SIG.TERM, &sa, null); + + const uireader = ngui_proc.stdout.?.reader(); + const uiwriter = ngui_proc.stdin.?.writer(); + comm.write(gpa, uiwriter, .ping) catch |err| { + logger.err("comm.write ping: {any}", .{err}); + }; + + var poweroff = false; + while (!sigquit) { + std.atomic.spinLoopHint(); + time.sleep(100 * time.ns_per_ms); + if (poweroff) { + // GUI is not expected to send anything back at this point, + // so just loop until we're terminated by a SIGTERM (sigquit). + continue; + } + + const msg = comm.read(gpa, uireader) catch |err| { + logger.err("comm.read: {any}", .{err}); + continue; + }; + logger.debug("got ui msg tagged {s}", .{@tagName(msg)}); + switch (msg) { + .pong => { + logger.info("received pong from ngui", .{}); + }, + .poweroff => { + poweroff = true; + + logger.info("sending poweroff status1", .{}); + var s1: comm.Message.PoweroffProgress = .{ .services = &.{ + .{ .name = "lnd", .stopped = false, .err = null }, + .{ .name = "bitcoind", .stopped = false, .err = null }, + } }; + comm.write(gpa, uiwriter, .{ .poweroff_progress = s1 }) catch |err| logger.err("comm.write: {any}", .{err}); + + time.sleep(2 * time.ns_per_s); + logger.info("sending poweroff status2", .{}); + var s2: comm.Message.PoweroffProgress = .{ .services = &.{ + .{ .name = "lnd", .stopped = true, .err = null }, + .{ .name = "bitcoind", .stopped = false, .err = null }, + } }; + comm.write(gpa, uiwriter, .{ .poweroff_progress = s2 }) catch |err| logger.err("comm.write: {any}", .{err}); + + time.sleep(3 * time.ns_per_s); + logger.info("sending poweroff status3", .{}); + var s3: comm.Message.PoweroffProgress = .{ .services = &.{ + .{ .name = "lnd", .stopped = true, .err = null }, + .{ .name = "bitcoind", .stopped = true, .err = null }, + } }; + comm.write(gpa, uiwriter, .{ .poweroff_progress = s3 }) catch |err| logger.err("comm.write: {any}", .{err}); + }, + else => {}, + } + } + + logger.info("killing ngui", .{}); + const term = ngui_proc.kill(); + logger.info("ngui_proc.kill term: {any}", .{term}); +} diff --git a/src/types.zig b/src/types.zig index 3330a83..2d1fef3 100644 --- a/src/types.zig +++ b/src/types.zig @@ -1,17 +1,49 @@ -const std = @import("std"); const builtin = @import("builtin"); +const std = @import("std"); +const nif = @import("nif"); + +const tt = @import("test.zig"); + +pub usingnamespace if (builtin.is_test) struct { + // stubs, mocks, overrides for testing. + pub const Timer = tt.TestTimer; + pub const ChildProcess = tt.TestChildProcess; + pub const WpaControl = tt.TestWpaControl; +} else struct { + // regular types for production code. + pub const Timer = std.time.Timer; + pub const ChildProcess = std.ChildProcess; + pub const WpaControl = nif.wpa.Control; +}; /// prefer this type over the std.ArrayList(u8) just to ensure consistency /// and potential regressions. For example, comm module uses it for read/write. pub const ByteArrayList = std.ArrayList(u8); -pub const Timer = if (builtin.is_test) TestTimer else std.time.Timer; +/// an OS-based I/O pipe; see man(2) pipe. +pub const IoPipe = struct { + r: std.fs.File, + w: std.fs.File, -/// TestTimer always reports the same fixed value. -pub const TestTimer = if (!builtin.is_test) @compileError("TestTimer is for tests only") else struct { - value: u64, + /// a pipe must be close'ed when done. + pub fn create() std.os.PipeError!IoPipe { + const fds = try std.os.pipe(); + return .{ + .r = std.fs.File{ .handle = fds[0] }, + .w = std.fs.File{ .handle = fds[1] }, + }; + } + + pub fn close(self: IoPipe) void { + self.w.close(); + self.r.close(); + } + + pub fn reader(self: IoPipe) std.fs.File.Reader { + return self.r.reader(); + } - pub fn read(self: *Timer) u64 { - return self.value; + pub fn writer(self: IoPipe) std.fs.File.Writer { + return self.w.writer(); } }; diff --git a/src/ui/lvgl.zig b/src/ui/lvgl.zig index f61ff7e..77bada1 100644 --- a/src/ui/lvgl.zig +++ b/src/ui/lvgl.zig @@ -329,12 +329,17 @@ pub inline fn paletteDarken(p: Palette, l: PaletteModLevel) Color { /// represents lv_obj_t type in C. pub const LvObj = opaque { - /// deallocates all the resources used by the object, including its children. + /// deallocates all resources used by the object, including its children. /// user data pointers are untouched. pub fn destroy(self: *LvObj) void { lv_obj_del(self); } + /// deallocates all resources used by the object's children. + pub fn deleteChildren(self: *LvObj) void { + lv_obj_clean(self); + } + /// creates a new event handler where cb is called upon event with the filter code. /// to make cb called on any event, use EventCode.all filter. /// multiple event handlers are called in the same order as they were added. @@ -343,6 +348,11 @@ pub const LvObj = opaque { return lv_obj_add_event_cb(self, cb, filter, udata); } + /// sets label text to a new value. + pub fn setLabelText(self: *LvObj, text: [*:0]const u8) void { + lv_label_set_text(self, text); + } + /// sets or clears an object flag. pub fn setFlag(self: *LvObj, onoff: enum { on, off }, v: ObjFlag) void { switch (onoff) { @@ -388,11 +398,18 @@ pub const LvObj = opaque { } /// selects which side to pad in setPad func. - pub const PadSelector = enum { left, right, top, bottom, row, column }; + pub const PadSelector = enum { all, left, right, top, bottom, row, column }; /// adds a padding style to the object. pub fn setPad(self: *LvObj, v: Coord, p: PadSelector, sel: StyleSelector) void { switch (p) { + .all => { + const vsel = sel.value(); + lv_obj_set_style_pad_left(self, v, vsel); + lv_obj_set_style_pad_right(self, v, vsel); + lv_obj_set_style_pad_top(self, v, vsel); + lv_obj_set_style_pad_bottom(self, v, vsel); + }, .left => lv_obj_set_style_pad_left(self, v, sel.value()), .right => lv_obj_set_style_pad_right(self, v, sel.value()), .top => lv_obj_set_style_pad_top(self, v, sel.value()), @@ -453,6 +470,11 @@ pub const LvObj = opaque { pub fn setBackgroundColor(self: *LvObj, v: Color, sel: StyleSelector) void { lv_obj_set_style_bg_color(self, v, sel.value()); } + + /// sets the color of a text, typically a label object. + pub fn setTextColor(self: *LvObj, v: Color, sel: StyleSelector) void { + lv_obj_set_style_text_color(self, v, sel.value()); + } }; pub fn createObject(parent: *LvObj) !*LvObj { @@ -631,7 +653,7 @@ pub fn createWindow(parent: ?*LvObj, header_height: i16, title: [*:0]const u8) ! return .{ .winobj = winobj }; } -const Window = struct { +pub const Window = struct { winobj: *LvObj, pub fn content(self: Window) *LvObj { @@ -640,38 +662,58 @@ const Window = struct { }; pub const CreateLabelOpt = struct { - long_mode: enum(c.lv_label_long_mode_t) { + // LVGL defaults to .wrap + long_mode: ?enum(c.lv_label_long_mode_t) { wrap = c.LV_LABEL_LONG_WRAP, // keep the object width, wrap the too long lines and expand the object height dot = c.LV_LABEL_LONG_DOT, // keep the size and write dots at the end if the text is too long scroll = c.LV_LABEL_LONG_SCROLL, // keep the size and roll the text back and forth scroll_circular = c.LV_LABEL_LONG_SCROLL_CIRCULAR, // keep the size and roll the text circularly clip = c.LV_LABEL_LONG_CLIP, // keep the size and clip the text out of it - }, - pos: enum { - none, - centered, - }, + } = null, + pos: ?PosAlign = null, }; +/// creates a new label object. +/// the text is heap-duplicated for the lifetime of the object and free'ed automatically. pub fn createLabel(parent: *LvObj, text: [*:0]const u8, opt: CreateLabelOpt) !*LvObj { var lb = lv_label_create(parent) orelse return error.OutOfMemory; //lv_label_set_text_static(lb, text); // static doesn't work with .dot lv_label_set_text(lb, text); lv_label_set_recolor(lb, true); //lv_obj_set_height(lb, sizeContent); // default - lv_label_set_long_mode(lb, @enumToInt(opt.long_mode)); - if (opt.pos == .centered) { - lb.center(); + if (opt.long_mode) |m| { + lv_label_set_long_mode(lb, @enumToInt(m)); + } + if (opt.pos) |p| { + lb.posAlign(p, 0, 0); } return lb; } +/// formats label text using std.fmt.format and the provided buffer. +/// a label object is then created with the resulting text using createLabel. +/// the text is heap-dup'ed so no need to retain buf. see createLabel. +pub fn createLabelFmt(parent: *LvObj, buf: []u8, comptime format: []const u8, args: anytype, opt: CreateLabelOpt) !*LvObj { + const text = try std.fmt.bufPrintZ(buf, format, args); + return createLabel(parent, text, opt); +} + pub fn createButton(parent: *LvObj, label: [*:0]const u8) !*LvObj { const btn = lv_btn_create(parent) orelse return error.OutOfMemory; - _ = try createLabel(btn, label, .{ .long_mode = .dot, .pos = .centered }); + _ = try createLabel(btn, label, .{ .long_mode = .dot, .pos = .center }); return btn; } +/// creates a spinner object with hardcoded dimensions and animation speed +/// used througout the GUI. +pub fn createSpinner(parent: *LvObj) !*LvObj { + const spin = lv_spinner_create(parent, 1000, 60) orelse return error.OutOfMemory; + lv_obj_set_size(spin, 20, 20); + const ind: StyleSelector = .{ .part = .indicator }; + lv_obj_set_style_arc_width(spin, 4, ind.value()); + return spin; +} + // ========================================================================== // imports from nakamochi custom C code that extends LVGL // ========================================================================== @@ -748,12 +790,14 @@ extern fn lv_obj_add_style(obj: *LvObj, style: *LvStyle, sel: c.lv_style_selecto extern fn lv_obj_remove_style(obj: *LvObj, style: ?*LvStyle, sel: c.lv_style_selector_t) void; extern fn lv_obj_remove_style_all(obj: *LvObj) void; extern fn lv_obj_set_style_bg_color(obj: *LvObj, val: Color, sel: c.lv_style_selector_t) void; +extern fn lv_obj_set_style_text_color(obj: *LvObj, val: Color, sel: c.lv_style_selector_t) void; extern fn lv_obj_set_style_pad_left(obj: *LvObj, val: c.lv_coord_t, sel: c.lv_style_selector_t) void; extern fn lv_obj_set_style_pad_right(obj: *LvObj, val: c.lv_coord_t, sel: c.lv_style_selector_t) void; extern fn lv_obj_set_style_pad_top(obj: *LvObj, val: c.lv_coord_t, sel: c.lv_style_selector_t) void; extern fn lv_obj_set_style_pad_bottom(obj: *LvObj, val: c.lv_coord_t, sel: c.lv_style_selector_t) void; extern fn lv_obj_set_style_pad_row(obj: *LvObj, val: c.lv_coord_t, sel: c.lv_style_selector_t) void; extern fn lv_obj_set_style_pad_column(obj: *LvObj, val: c.lv_coord_t, sel: c.lv_style_selector_t) void; +extern fn lv_obj_set_style_arc_width(obj: *LvObj, val: c.lv_coord_t, sel: c.lv_style_selector_t) void; // TODO: port these to zig extern fn lv_palette_main(c.lv_palette_t) Color; @@ -766,6 +810,8 @@ extern fn lv_palette_darken(c.lv_palette_t, level: u8) Color; extern fn lv_obj_create(parent: ?*LvObj) ?*LvObj; /// deletes and deallocates an object and all its children from UI tree. extern fn lv_obj_del(obj: *LvObj) void; +/// deletes children of the obj. +extern fn lv_obj_clean(obj: *LvObj) void; extern fn lv_obj_add_flag(obj: *LvObj, v: c.lv_obj_flag_t) void; extern fn lv_obj_clear_flag(obj: *LvObj, v: c.lv_obj_flag_t) void; @@ -794,6 +840,8 @@ extern fn lv_label_set_text_static(label: *LvObj, text: [*:0]const u8) void; extern fn lv_label_set_long_mode(label: *LvObj, mode: c.lv_label_long_mode_t) void; extern fn lv_label_set_recolor(label: *LvObj, enable: bool) void; +extern fn lv_spinner_create(parent: *LvObj, speed_ms: u32, arc_deg: u32) ?*LvObj; + extern fn lv_win_create(parent: *LvObj, header_height: c.lv_coord_t) ?*LvObj; extern fn lv_win_add_title(win: *LvObj, title: [*:0]const u8) ?*LvObj; extern fn lv_win_get_content(win: *LvObj) *LvObj; diff --git a/src/ui/poweroff.zig b/src/ui/poweroff.zig new file mode 100644 index 0000000..2ff9c14 --- /dev/null +++ b/src/ui/poweroff.zig @@ -0,0 +1,136 @@ +//! poweroff workflow. +//! all functions assume ui_mutex is always locked. +const std = @import("std"); + +const comm = @import("../comm.zig"); +const lvgl = @import("lvgl.zig"); +const symbol = @import("symbol.zig"); +const widget = @import("widget.zig"); + +const logger = std.log.scoped(.ui); + +/// initiates system shutdown leading to power off. +/// defined in ngui.zig. +extern fn nm_sys_shutdown() void; + +/// called when "power off" button is pressed. +export fn nm_poweroff_btn_callback(_: *lvgl.LvEvent) void { + const proceed: [*:0]const u8 = "PROCEED"; + const abort: [*:0]const u8 = "CANCEL"; + const title = " " ++ symbol.Power ++ " SHUTDOWN"; + const text = + \\ARE YOU SURE? + \\ + \\once shut down, + \\payments cannot go through via bitcoin or lightning networks + \\until the node is powered back on. + ; + widget.modal(title, text, &.{ proceed, abort }, poweroffModalCallback) catch |err| { + logger.err("shutdown btn: modal: {any}", .{err}); + }; +} + +/// poweroff confirmation screen callback. +fn poweroffModalCallback(btn_idx: usize) void { + // proceed = 0, cancel = 1 + if (btn_idx != 0) { + return; + } + defer nm_sys_shutdown(); // initiate shutdown even if next lines fail + global_progress_win = ProgressWin.create() catch |err| { + logger.err("ProgressWin.create: {any}", .{err}); + return; + }; +} + +var global_progress_win: ?ProgressWin = null; + +/// updates the global poweroff process window with the status report. +/// the report is normally sent to GUI by the daemon. +pub fn updateStatus(report: comm.Message.PoweroffProgress) !void { + if (global_progress_win) |win| { + var all_stopped = true; + win.resetSvContainer(); + for (report.services) |sv| { + try win.addServiceStatus(sv.name, sv.stopped, sv.err); + all_stopped = all_stopped and sv.stopped; + } + if (all_stopped) { + win.status.setLabelText("powering off ..."); + } + } else { + return error.NoProgressWindow; + } +} + +/// represents a modal window in which the poweroff progress is reported until +/// the device turns off. +const ProgressWin = struct { + win: lvgl.Window, + status: *lvgl.LvObj, // text status label + svcont: *lvgl.LvObj, // services container + + /// symbol width next to the service name. this aligns all service names vertically. + /// has to be wide enough to accomodate the spinner, but not too wide + /// so that the service name is still close to the symbol. + const sym_width = 20; + + fn create() !ProgressWin { + const win = try lvgl.createWindow(null, 60, " " ++ symbol.Power ++ " SHUTDOWN"); + errdefer win.winobj.destroy(); // also deletes all children created below + const wincont = win.content(); + wincont.flexFlow(.column); + + // initial status message + const status = try lvgl.createLabel(wincont, "shutting down services. it may take up to a few minutes.", .{}); + status.setWidth(lvgl.sizePercent(100)); + // prepare a container for services status + const svcont = try lvgl.createObject(wincont); + svcont.removeBackgroundStyle(); + svcont.flexFlow(.column); + svcont.flexGrow(1); + svcont.padColumnDefault(); + svcont.setWidth(lvgl.sizePercent(100)); + + return .{ + .win = win, + .status = status, + .svcont = svcont, + }; + } + + fn resetSvContainer(self: ProgressWin) void { + self.svcont.deleteChildren(); + } + + fn addServiceStatus(self: ProgressWin, name: []const u8, stopped: bool, err: ?[]const u8) !void { + const row = try lvgl.createObject(self.svcont); + row.removeBackgroundStyle(); + row.flexFlow(.row); + row.flexAlign(.center, .center, .center); + row.padColumnDefault(); + row.setPad(10, .all, .{}); + row.setWidth(lvgl.sizePercent(100)); + row.setHeightToContent(); + + var buf: [100]u8 = undefined; + if (err) |e| { + const sym = try lvgl.createLabelFmt(row, &buf, symbol.Warning, .{}, .{ .long_mode = .clip }); + sym.setWidth(sym_width); + sym.setTextColor(lvgl.paletteMain(.red), .{}); + const lb = try lvgl.createLabelFmt(row, &buf, "{s}: {s}", .{ name, e }, .{ .long_mode = .dot }); + lb.setTextColor(lvgl.paletteMain(.red), .{}); + lb.flexGrow(1); + } else if (stopped) { + const sym = try lvgl.createLabelFmt(row, &buf, symbol.Ok, .{}, .{ .long_mode = .clip }); + sym.setWidth(sym_width); + const lb = try lvgl.createLabelFmt(row, &buf, "{s}", .{name}, .{ .long_mode = .dot }); + lb.flexGrow(1); + } else { + const spin = try lvgl.createSpinner(row); + spin.setWidth(sym_width); + const lb = try lvgl.createLabelFmt(row, &buf, "{s}", .{name}, .{ .long_mode = .dot }); + lb.flexGrow(1); + } + } +}; diff --git a/src/ui/screen.zig b/src/ui/screen.zig index c5bf992..b48a74c 100644 --- a/src/ui/screen.zig +++ b/src/ui/screen.zig @@ -1,4 +1,5 @@ ///! display and touch screen helper functions. +const builtin = @import("builtin"); const std = @import("std"); const Thread = std.Thread; @@ -36,7 +37,7 @@ pub fn sleep(wake: *const Thread.ResetEvent) void { /// turn on or off display backlight. pub fn backlight(onoff: enum { on, off }) !void { - const blpath = "/sys/class/backlight/rpi_backlight/bl_power"; + const blpath = if (builtin.is_test) "/dev/null" else "/sys/class/backlight/rpi_backlight/bl_power"; const f = try std.fs.openFileAbsolute(blpath, .{ .mode = .write_only }); defer f.close(); const v = if (onoff == .on) "0" else "1"; diff --git a/src/ui/ui.zig b/src/ui/ui.zig index 9321211..8e3e5d3 100644 --- a/src/ui/ui.zig +++ b/src/ui/ui.zig @@ -1,15 +1,16 @@ const buildopts = @import("build_options"); const std = @import("std"); +const comm = @import("../comm.zig"); const lvgl = @import("lvgl.zig"); const drv = @import("drv.zig"); const symbol = @import("symbol.zig"); const widget = @import("widget.zig"); +pub const poweroff = @import("poweroff.zig"); const logger = std.log.scoped(.ui); extern "c" fn nm_ui_init(disp: *lvgl.LvDisp) c_int; -extern fn nm_sys_shutdown() void; pub fn init() !void { lvgl.init(); @@ -25,33 +26,6 @@ pub fn init() !void { } } -/// called when "power off" button is pressed. -export fn nm_poweroff_btn_callback(e: *lvgl.LvEvent) void { - _ = e; - const proceed: [*:0]const u8 = "PROCEED"; - const abort: [*:0]const u8 = "CANCEL"; - const title = " " ++ symbol.Power ++ " SHUTDOWN"; - const text = - \\ARE YOU SURE? - \\ - \\once shut down, - \\payments cannot go through via bitcoin or lightning networks - \\until the node is powered back on. - ; - widget.modal(title, text, &.{ proceed, abort }, poweroffModalCallback) catch |err| { - logger.err("shutdown btn: modal: {any}", .{err}); - }; -} - -fn poweroffModalCallback(btn_idx: usize) void { - // proceed = 0, cancel = 1 - if (btn_idx != 0) { - return; - } - // proceed with shutdown - nm_sys_shutdown(); -} - export fn nm_create_info_panel(parent: *lvgl.LvObj) c_int { createInfoPanel(parent) catch |err| { logger.err("createInfoPanel: {any}", .{err}); @@ -66,5 +40,5 @@ fn createInfoPanel(parent: *lvgl.LvObj) !void { var buf: [100]u8 = undefined; const sver = try std.fmt.bufPrintZ(&buf, "GUI version: {any}", .{buildopts.semver}); - _ = try lvgl.createLabel(parent, sver, .{ .long_mode = .wrap, .pos = .none }); + _ = try lvgl.createLabel(parent, sver, .{}); } diff --git a/src/ui/widget.zig b/src/ui/widget.zig index 1338d04..4729347 100644 --- a/src/ui/widget.zig +++ b/src/ui/widget.zig @@ -58,7 +58,7 @@ pub fn modal(title: [*:0]const u8, text: [*:0]const u8, btns: []const [*:0]const const wincont = win.content(); wincont.flexFlow(.column); wincont.flexAlign(.start, .center, .center); - const msg = try lvgl.createLabel(wincont, text, .{ .long_mode = .wrap, .pos = .centered }); + const msg = try lvgl.createLabel(wincont, text, .{ .pos = .center }); msg.setWidth(lvgl.displayHoriz() - 100); msg.flexGrow(1);