report system shutdown progress during poweroff #22

Manually merged
x1ddos merged 2 commits from poweroff-report into master 1 year ago

@ -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 {

@ -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;

@ -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;

@ -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});
}
}
}

@ -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
}

@ -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);
}

@ -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" {

@ -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());

@ -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});
}

@ -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();
}
};

@ -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;

@ -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);
}
}
};

@ -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";

@ -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, .{});
}

@ -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);