diff --git a/build.zig b/build.zig index 33aebc7..f1eb2d2 100644 --- a/build.zig +++ b/build.zig @@ -135,6 +135,16 @@ pub fn build(b: *std.build.Builder) void { guiplay_build_step.dependOn(&b.addInstallArtifact(guiplay).step); guiplay_build_step.dependOn(ngui_build_step); } + + { + const btcrpc = b.addExecutable("btcrpc", "src/test/btcrpc.zig"); + btcrpc.setTarget(target); + btcrpc.setBuildMode(mode); + btcrpc.strip = strip; + btcrpc.addPackagePath("bitcoindrpc", "src/nd/bitcoindrpc.zig"); + const btcrpc_build_step = b.step("btcrpc", "bitcoind RPC client playground"); + btcrpc_build_step.dependOn(&b.addInstallArtifact(btcrpc).step); + } } const DriverTarget = enum { diff --git a/src/comm.zig b/src/comm.zig index ea90080..dd2db44 100644 --- a/src/comm.zig +++ b/src/comm.zig @@ -27,6 +27,7 @@ pub const Message = union(MessageTag) { network_report: NetworkReport, get_network_report: GetNetworkReport, poweroff_progress: PoweroffProgress, + bitcoind_report: BitcoindReport, pub const WifiConnect = struct { ssid: []const u8, @@ -52,6 +53,34 @@ pub const Message = union(MessageTag) { err: ?[]const u8, }; }; + + pub const BitcoindReport = struct { + blocks: u64, + headers: u64, + timestamp: u64, // unix epoch + hash: []const u8, // best block hash + ibd: bool, // initial block download + verifyprogress: u8, // 0-100% + diskusage: u64, // estimated size on disk, in bytes + version: []const u8, // bitcoin core version string + conn_in: u16, + conn_out: u16, + warnings: []const u8, + localaddr: []struct { + addr: []const u8, + port: u16, + score: i16, + }, + mempool: struct { + loaded: bool, + txcount: usize, + usage: u64, // in memory, bytes + max: u64, // bytes + totalfee: f32, // in BTC + minfee: f32, // BTC/kvB + fullrbf: bool, + }, + }; }; /// it is important to preserve ordinal values for future compatiblity, @@ -69,7 +98,9 @@ pub const MessageTag = enum(u16) { wakeup = 0x08, // nd -> ngui: reports poweroff progress poweroff_progress = 0x09, - // next: 0x0a + // nd -> ngui: bitcoin core daemon status report + bitcoind_report = 0x0a, + // next: 0x0b }; /// reads and parses a single message from the input stream reader. @@ -111,6 +142,9 @@ pub fn read(allocator: mem.Allocator, reader: anytype) !Message { .poweroff_progress => Message{ .poweroff_progress = try json.parse(Message.PoweroffProgress, &jstream, jopt), }, + .bitcoind_report => Message{ + .bitcoind_report = try json.parse(Message.BitcoindReport, &jstream, jopt), + }, }; } @@ -126,6 +160,7 @@ pub fn write(allocator: mem.Allocator, writer: anytype, msg: Message) !void { .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()), + .bitcoind_report => try json.stringify(msg.bitcoind_report, jopt, data.writer()), } if (data.items.len > std.math.maxInt(u64)) { return Error.CommWriteTooLarge; diff --git a/src/nd/Daemon.zig b/src/nd/Daemon.zig index b74c2dd..6abdfc1 100644 --- a/src/nd/Daemon.zig +++ b/src/nd/Daemon.zig @@ -20,6 +20,7 @@ const network = @import("network.zig"); const screen = @import("../ui/screen.zig"); const types = @import("../types.zig"); const SysService = @import("SysService.zig"); +const bitcoindrpc = @import("bitcoindrpc.zig"); const logger = std.log.scoped(.daemon); @@ -50,6 +51,10 @@ want_wifi_scan: bool, // initiate wifi scan at the next loop cycle network_report_ready: bool, // indicates whether the network status is ready to be sent wifi_scan_in_progress: bool = false, wpa_save_config_on_connected: bool = false, +// bitcoin flags +want_bitcoind_report: bool, +bitcoin_timer: time.Timer, +bitcoin_report_interval: u64 = 1 * time.ns_per_min, /// system services actively managed by the daemon. /// these are stop'ed during poweroff and their shutdown progress sent to ngui. @@ -82,6 +87,9 @@ pub fn init(a: std.mem.Allocator, r: std.fs.File.Reader, w: std.fs.File.Writer, .want_network_report = true, .want_wifi_scan = false, .network_report_ready = true, + // report bitcoind status immediately on start + .want_bitcoind_report = true, + .bitcoin_timer = try time.Timer.start(), }; } @@ -269,6 +277,14 @@ fn mainThreadLoopCycle(self: *Daemon) !void { logger.err("network.sendReport: {any}", .{err}); } } + if (self.want_bitcoind_report or self.bitcoin_timer.read() > self.bitcoin_report_interval) { + if (self.sendBitcoindReport()) { + self.bitcoin_timer.reset(); + self.want_bitcoind_report = false; + } else |err| { + logger.err("sendBitcoinReport: {any}", .{err}); + } + } } /// comm thread entry point: reads messages sent from ngui and acts accordinly. @@ -484,12 +500,54 @@ fn readWPACtrlMsg(self: *Daemon) !void { } } +fn sendBitcoindReport(self: *Daemon) !void { + var client = bitcoindrpc.Client{ + .allocator = self.allocator, + .cookiepath = "/ssd/bitcoind/mainnet/.cookie", + }; + const bcinfo = try client.call(.getblockchaininfo, {}); + defer bcinfo.deinit(); + const netinfo = try client.call(.getnetworkinfo, {}); + defer netinfo.deinit(); + const mempool = try client.call(.getmempoolinfo, {}); + defer mempool.deinit(); + + const btcrep: comm.Message.BitcoindReport = .{ + .blocks = bcinfo.value.blocks, + .headers = bcinfo.value.headers, + .timestamp = bcinfo.value.time, + .hash = bcinfo.value.bestblockhash, + .ibd = bcinfo.value.initialblockdownload, + .diskusage = bcinfo.value.size_on_disk, + .version = netinfo.value.subversion, + .conn_in = netinfo.value.connections_in, + .conn_out = netinfo.value.connections_out, + .warnings = bcinfo.value.warnings, // TODO: netinfo.result.warnings + .localaddr = &.{}, // TODO: populate + // something similar to this: + // @round(bcinfo.verificationprogress * 100) + .verifyprogress = 0, + .mempool = .{ + .loaded = mempool.value.loaded, + .txcount = mempool.value.size, + .usage = mempool.value.usage, + .max = mempool.value.maxmempool, + .totalfee = mempool.value.total_fee, + .minfee = mempool.value.mempoolminfee, + .fullrbf = mempool.value.fullrbf, + }, + }; + + try comm.write(self.allocator, self.uiwriter, .{ .bitcoind_report = btcrep }); +} + test "start-stop" { const t = std.testing; const pipe = try types.IoPipe.create(); var daemon = try Daemon.init(t.allocator, pipe.reader(), pipe.writer(), "/dev/null"); daemon.want_network_report = false; + daemon.want_bitcoind_report = false; try t.expect(daemon.state == .stopped); try daemon.start(); @@ -533,6 +591,7 @@ test "start-poweroff" { const gui_reader = gui_stdin.reader(); var daemon = try Daemon.init(arena, gui_stdout.reader(), gui_stdin.writer(), "/dev/null"); daemon.want_network_report = false; + daemon.want_bitcoind_report = false; defer { daemon.deinit(); gui_stdin.close(); diff --git a/src/nd/bitcoindrpc.zig b/src/nd/bitcoindrpc.zig new file mode 100644 index 0000000..7f0ce5f --- /dev/null +++ b/src/nd/bitcoindrpc.zig @@ -0,0 +1,296 @@ +//! a bitcoin core RPC client. + +const std = @import("std"); +const ArenaAllocator = std.heap.ArenaAllocator; +const Atomic = std.atomic.Atomic; +const base64enc = std.base64.standard.Encoder; + +pub const Client = struct { + allocator: std.mem.Allocator, + cookiepath: []const u8, + addr: []const u8 = "127.0.0.1", + port: u16 = 8332, + + // each request gets a new ID with a value of reqid.fetchAdd(1, .Monotonic) + reqid: Atomic(u64) = Atomic(u64).init(1), + + pub const Method = enum { + getblockchaininfo, + getblockhash, + getmempoolinfo, + getnetworkinfo, + }; + + pub const RpcError = error{ + // json-rpc 2.0 + RpcInvalidRequest, + RpcMethodNotFound, + RpcInvalidParams, + RpcInternalError, + RpcParseError, + // general purpose errors + RpcMiscError, + RpcTypeError, + RpcInvalidAddressOrKey, + RpcOutOfMemory, + RpcInvalidParameter, + RpcDatabaseError, + RpcDeserializationError, + RpcVerifyError, + RpcVerifyRejected, + RpcVerifyAlreadyInChain, + RpcInWarmup, + RpcMethodDeprecated, + // p2p client errors + RpcClientNotConnected, + RpcClientInInitialDownload, + RpcClientNodeAlreadyAdded, + RpcClientNodeNotAdded, + RpcClientNodeNotConnected, + RpcClientInvalidIpOrSubnet, + RpcClientP2pDisabled, + RpcClientNodeCapacityReached, + // chain errors + RpcClientMempoolDisabled, + }; + + pub fn Result(comptime m: Method) type { + return struct { + value: ResultValue(m), + arena: *ArenaAllocator, + + pub fn deinit(self: @This()) void { + const allocator = self.arena.child_allocator; + self.arena.deinit(); + allocator.destroy(self.arena); + } + }; + } + + pub fn ResultValue(comptime m: Method) type { + return switch (m) { + .getblockchaininfo => BlockchainInfo, + .getblockhash => []const u8, + .getmempoolinfo => MempoolInfo, + .getnetworkinfo => NetworkInfo, + }; + } + + pub fn MethodArgs(comptime m: Method) type { + return switch (m) { + .getblockchaininfo, .getmempoolinfo, .getnetworkinfo => void, + .getblockhash => struct { height: u64 }, + }; + } + + fn RpcRequest(comptime m: Method) type { + return struct { + jsonrpc: []const u8 = "1.0", + id: u64, + method: []const u8, + params: MethodArgs(m), + }; + } + + fn RpcResponse(comptime m: Method) type { + return struct { + id: u64, + result: ?ResultValue(m), + @"error": ?struct { + code: isize, + //message: ?[]const u8, // no use for it atm + }, + }; + } + + /// makes an RPC call to the addr:port endpoint. + /// the returned value must be deinit'ed when done. + pub fn call(self: *Client, comptime method: Method, args: MethodArgs(method)) !Result(method) { + const addrport = try std.net.Address.resolveIp(self.addr, self.port); + const reqbytes = try self.formatreq(method, args); + defer self.allocator.free(reqbytes); + + // connect and send the request + const stream = try std.net.tcpConnectToAddress(addrport); + defer stream.close(); + const reader = stream.reader(); + _ = try stream.writer().writeAll(reqbytes); + + // read and parse the response + try skipResponseHeaders(reader, 4096); + const body = try reader.readAllAlloc(self.allocator, 1 << 20); // 1Mb should be enough for all response types + defer self.allocator.free(body); + return self.parseResponse(method, body); + } + + /// reads all response headers, at most `limit` bytes, and returns the index + /// at which response body starts or error.EndOfStream. + /// single header length must be at most `limit` or 1024, whichever is smaller. + fn skipResponseHeaders(r: anytype, comptime limit: usize) !void { + var n: usize = 0; + var buf: [@min(1024, limit)]u8 = undefined; + while (true) { + const slice = try r.readUntilDelimiter(&buf, '\n'); + n += slice.len + 1; // delimiter is not included in the slice + if (n > limit) { + return error.StreamTooLong; + } + if (slice.len == 0 or (slice.len == 1 and slice[0] == '\r')) { + return; + } + } + } + + fn parseResponse(self: Client, comptime m: Method, b: []const u8) !Result(m) { + var result = Result(m){ + .value = undefined, + .arena = try self.allocator.create(ArenaAllocator), + }; + errdefer self.allocator.destroy(result.arena); + result.arena.* = ArenaAllocator.init(self.allocator); + + var jstream = std.json.TokenStream.init(b); + const jopt = std.json.ParseOptions{ .allocator = result.arena.allocator(), .ignore_unknown_fields = true }; + const resp = try std.json.parse(RpcResponse(m), &jstream, jopt); + + errdefer result.arena.deinit(); + if (resp.@"error") |errfield| { + return rpcErrorFromCode(errfield.code) orelse error.UnknownError; + } + if (resp.result == null) { + return error.NullResult; + } + + result.value = resp.result.?; + return result; + } + + fn formatreq(self: *Client, comptime m: Method, args: MethodArgs(m)) ![]const u8 { + const req = RpcRequest(m){ + .id = self.reqid.fetchAdd(1, .Monotonic), + .method = @tagName(m), + .params = args, + }; + var jreq = std.ArrayList(u8).init(self.allocator); + defer jreq.deinit(); + try std.json.stringify(req, .{}, jreq.writer()); + + const auth = try self.getAuthBase64(); + defer self.allocator.free(auth); + + var bytes = std.ArrayList(u8).init(self.allocator); + const w = bytes.writer(); + try w.writeAll("POST / HTTP/1.0\r\n"); + //try w.writeAll("Host: 127.0.0.1\n", .{}); + try w.writeAll("Connection: close\r\n"); + try w.print("Authorization: Basic {s}\r\n", .{auth}); + try w.writeAll("Accept: application/json-rpc\r\n"); + try w.writeAll("Content-Type: application/json-rpc\r\n"); + try w.print("Content-Length: {d}\r\n", .{jreq.items.len}); + try w.writeAll("\r\n"); + try w.writeAll(jreq.items); + return bytes.toOwnedSlice(); + } + + fn getAuthBase64(self: Client) ![]const u8 { + const file = try std.fs.openFileAbsolute(self.cookiepath, .{ .mode = .read_only }); + defer file.close(); + const cookie = try file.readToEndAlloc(self.allocator, 1024); + defer self.allocator.free(cookie); + var auth = try self.allocator.alloc(u8, base64enc.calcSize(cookie.len)); + return base64enc.encode(auth, cookie); + } + + // taken from bitcoind source code. + // see https://github.com/bitcoin/bitcoin/blob/64440bb73/src/rpc/protocol.h#L23 + fn rpcErrorFromCode(code: isize) ?RpcError { + return switch (code) { + // json-rpc 2.0 + -32600 => error.RpcInvalidRequest, + -32601 => error.RpcMethodNotFound, + -32602 => error.RpcInvalidParams, + -32603 => error.RpcInternalError, + -32700 => error.RpcParseError, + // general purpose errors + -1 => error.RpcMiscError, + -3 => error.RpcTypeError, + -5 => error.RpcInvalidAddressOrKey, + -7 => error.RpcOutOfMemory, + -8 => error.RpcInvalidParameter, + -20 => error.RpcDatabaseError, + -22 => error.RpcDeserializationError, + -25 => error.RpcVerifyError, + -26 => error.RpcVerifyRejected, + -27 => error.RpcVerifyAlreadyInChain, + -28 => error.RpcInWarmup, + -32 => error.RpcMethodDeprecated, + // p2p client errors + -9 => error.RpcClientNotConnected, + -10 => error.RpcClientInInitialDownload, + -23 => error.RpcClientNodeAlreadyAdded, + -24 => error.RpcClientNodeNotAdded, + -29 => error.RpcClientNodeNotConnected, + -30 => error.RpcClientInvalidIpOrSubnet, + -31 => error.RpcClientP2pDisabled, + -34 => error.RpcClientNodeCapacityReached, + // chain errors + -33 => error.RpcClientMempoolDisabled, + else => null, + }; + } +}; + +pub const BlockchainInfo = struct { + chain: []const u8, + blocks: u64, + headers: u64, + bestblockhash: []const u8, + difficulty: f64, + time: u64, // block time, unix epoch + mediantime: u64, // median block time, unix epoch + verificationprogress: f32, // estimate in [0..1] + initialblockdownload: bool, + size_on_disk: u64, + pruned: bool, + //pruneheight: ?u64, // present if pruning is enabled + //automatic_prunning: ?bool, // present if pruning is enabled + //prune_target_size: ?u64, // present if automatic is enabled + warnings: []const u8, +}; + +pub const MempoolInfo = struct { + loaded: bool, // whether the mempool is fully loaded + size: usize, // tx count + bytes: u64, // sum of all virtual transaction sizes as per BIP-141 (discounted witness data) + usage: u64, // total memory usage + total_fee: f32, // total fees in BTC ignoring modified fees through prioritisetransaction + maxmempool: u64, // memory usage cap, in bytes + mempoolminfee: f32, // min fee rate in BTC/kvB for tx to be accepted + minrelaytxfee: f32, // current min relay fee rate + incrementalrelayfee: f32, // min fee rate increment for replacement, in BTC/kvB + unbroadcastcount: u64, // number of transactions that haven't passed initial broadcast yet + fullrbf: bool, // whether the mempool accepts RBF without replaceability signaling inspection +}; + +pub const NetworkInfo = struct { + version: u32, + subversion: []const u8, + protocolversion: u32, + connections: u16, // in + out + connections_in: u16, + connections_out: u16, + networkactive: bool, + networks: []struct { + name: []const u8, // ipv4, ipv6, onion, i2p, cjdns + limited: bool, // whether this network is limited with -onlynet flag + reachable: bool, + }, + relayfee: f32, // min rate, in BTC/kvB + incrementalfee: f32, // min rate increment for RBF in BTC/vkB + localaddresses: []struct { + address: []const u8, + port: u16, + score: i16, + }, + warnings: []const u8, +}; diff --git a/src/ngui.zig b/src/ngui.zig index 0641a1f..6f8936d 100644 --- a/src/ngui.zig +++ b/src/ngui.zig @@ -67,8 +67,12 @@ export fn nm_get_curr_tick() u32 { export fn nm_check_idle_time(_: *lvgl.LvTimer) void { const standby_idle_ms = 60000; // 60sec const idle_ms = lvgl.idleTime(); - if (idle_ms > standby_idle_ms and state != .alert) { - state = .standby; + if (idle_ms < standby_idle_ms) { + return; + } + switch (state) { + .alert, .standby => {}, + .active => state = .standby, } } @@ -105,10 +109,8 @@ export fn nm_wifi_start_connect(ssid: [*:0]const u8, password: [*:0]const u8) vo }; } +/// callers must hold ui mutex for the whole duration. fn updateNetworkStatus(report: comm.Message.NetworkReport) !void { - ui_mutex.lock(); - defer ui_mutex.unlock(); - var wifi_list: ?[:0]const u8 = null; var wifi_list_ptr: ?[*:0]const u8 = null; if (report.wifi_scan_networks.len > 0) { @@ -171,21 +173,32 @@ fn commThreadLoop() void { /// runs one cycle of the commThreadLoop: read messages from stdin and update /// the UI accordingly. +/// holds ui mutex for most of the duration. fn commThreadLoopCycle() !void { const msg = try comm.read(gpa, stdin); defer comm.free(gpa, msg); logger.debug("got msg: {s}", .{@tagName(msg)}); - switch (msg) { - .ping => try comm.write(gpa, stdout, comm.Message.pong), - .network_report => |report| { - updateNetworkStatus(report) catch |err| logger.err("updateNetworkStatus: {any}", .{err}); + + ui_mutex.lock(); // guards state and all UI calls below + defer ui_mutex.unlock(); + switch (state) { + .standby => switch (msg) { + .ping => try comm.write(gpa, stdout, comm.Message.pong), + else => logger.debug("ignoring: in standby", .{}), }, - .poweroff_progress => |report| { - ui_mutex.lock(); - defer ui_mutex.unlock(); - ui.poweroff.updateStatus(report) catch |err| logger.err("poweroff.updateStatus: {any}", .{err}); + .active, .alert => switch (msg) { + .ping => try comm.write(gpa, stdout, comm.Message.pong), + .network_report => |report| { + updateNetworkStatus(report) catch |err| logger.err("updateNetworkStatus: {any}", .{err}); + }, + .poweroff_progress => |report| { + ui.poweroff.updateStatus(report) catch |err| logger.err("poweroff.updateStatus: {any}", .{err}); + }, + .bitcoind_report => |rep| { + ui.bitcoin.updateTabPanel(rep) catch |err| logger.err("bitcoin.updateTabPanel: {any}", .{err}); + }, + else => logger.warn("unhandled msg tag {s}", .{@tagName(msg)}), }, - else => logger.warn("unhandled msg tag {s}", .{@tagName(msg)}), } } @@ -207,7 +220,7 @@ fn uiThreadLoop() void { comm.write(gpa, stdout, comm.Message.standby) catch |err| { logger.err("comm.write standby: {any}", .{err}); }; - screen.sleep(&wakeup); // blocking + screen.sleep(&ui_mutex, &wakeup); // blocking // wake up due to touch screen activity or wakeup event is set logger.info("waking up from sleep", .{}); diff --git a/src/test/btcrpc.zig b/src/test/btcrpc.zig new file mode 100644 index 0000000..58c300f --- /dev/null +++ b/src/test/btcrpc.zig @@ -0,0 +1,20 @@ +const std = @import("std"); +const base64enc = std.base64.standard.Encoder; +const bitcoinrpc = @import("bitcoindrpc"); + +pub fn main() !void { + var gpa_state = std.heap.GeneralPurposeAllocator(.{}){}; + defer if (gpa_state.deinit()) { + std.debug.print("memory leaks detected!", .{}); + }; + const gpa = gpa_state.allocator(); + + var client = bitcoinrpc.Client{ + .allocator = gpa, + .cookiepath = "/ssd/bitcoind/mainnet/.cookie", + }; + + const res = try client.call(.getmempoolinfo, {}); + defer res.deinit(); + std.debug.print("{any}\n", .{res.value}); +} diff --git a/src/test/guiplay.zig b/src/test/guiplay.zig index 988e118..3063b97 100644 --- a/src/test/guiplay.zig +++ b/src/test/guiplay.zig @@ -72,7 +72,7 @@ fn parseArgs(gpa: std.mem.Allocator) !Flags { return flags; } -fn commThread(gpa: std.mem.Allocator, r: anytype, w: anytype) void { +fn commReadThread(gpa: std.mem.Allocator, r: anytype, w: anytype) void { comm.write(gpa, w, .ping) catch |err| logger.err("comm.write ping: {any}", .{err}); while (true) { @@ -125,6 +125,47 @@ fn commThread(gpa: std.mem.Allocator, r: anytype, w: anytype) void { sigquit.set(); } +fn commWriteThread(gpa: std.mem.Allocator, w: anytype) !void { + var sectimer = try time.Timer.start(); + var block_count: u64 = 801365; + + while (true) { + time.sleep(time.ns_per_s); + if (sectimer.read() < time.ns_per_s) { + continue; + } + + sectimer.reset(); + block_count += 1; + const now = time.timestamp(); + + const btcrep: comm.Message.BitcoindReport = .{ + .blocks = block_count, + .headers = block_count, + .timestamp = @intCast(u64, now), + .hash = "00000000000000000002bf8029f6be4e40b4a3e0e161b6a1044ddaf9eb126504", + .ibd = false, + .verifyprogress = 100, + .diskusage = 567119364054, + .version = "/Satoshi:24.0.1/", + .conn_in = 8, + .conn_out = 10, + .warnings = "", + .localaddr = &.{}, + .mempool = .{ + .loaded = true, + .txcount = 100000 + block_count, + .usage = std.math.min(200123456 + block_count * 10, 300000000), + .max = 300000000, + .totalfee = 2.23049932, + .minfee = 0.00004155, + .fullrbf = false, + }, + }; + comm.write(gpa, w, .{ .bitcoind_report = btcrep }) catch |err| logger.err("comm.write: {any}", .{err}); + } +} + pub fn main() !void { var gpa_state = std.heap.GeneralPurposeAllocator(.{}){}; defer if (gpa_state.deinit()) { @@ -145,7 +186,10 @@ pub fn main() !void { // ngui proc stdio is auto-closed as soon as its main process terminates. const uireader = ngui_proc.stdout.?.reader(); const uiwriter = ngui_proc.stdin.?.writer(); - _ = try std.Thread.spawn(.{}, commThread, .{ gpa, uireader, uiwriter }); + const th1 = try std.Thread.spawn(.{}, commReadThread, .{ gpa, uireader, uiwriter }); + th1.detach(); + const th2 = try std.Thread.spawn(.{}, commWriteThread, .{ gpa, uiwriter }); + th2.detach(); const sa = os.Sigaction{ .handler = .{ .handler = sighandler }, diff --git a/src/ui/bitcoin.zig b/src/ui/bitcoin.zig new file mode 100644 index 0000000..dab529e --- /dev/null +++ b/src/ui/bitcoin.zig @@ -0,0 +1,125 @@ +//! bitcoin main tab panel. +//! all functions assume LVGL is init'ed and ui mutex is locked on entry. + +const std = @import("std"); +const fmt = std.fmt; + +const lvgl = @import("lvgl.zig"); +const comm = @import("../comm.zig"); +const xfmt = @import("../xfmt.zig"); + +const logger = std.log.scoped(.ui); +/// label color mark start to make "label:" part of a "label: value" +/// in a different color. +const cmark = "#bbbbbb "; + +var tab: struct { + // blockchain section + currblock: lvgl.Label, + timestamp: lvgl.Label, + blockhash: lvgl.Label, + // usage section + diskusage: lvgl.Label, + conn_in: lvgl.Label, + conn_out: lvgl.Label, + // mempool section + mempool: struct { + txcount: lvgl.Label, + totalfee: lvgl.Label, + usage_bar: lvgl.Bar, + usage_lab: lvgl.Label, + }, +} = undefined; + +/// creates the tab content with all elements. +/// must be called only once at UI init. +pub fn initTabPanel(cont: lvgl.Container) !void { + const parent = cont.flex(.column, .{}); + + // blockchain section + { + const card = try lvgl.Card.new(parent, "BLOCKCHAIN"); + const row = try lvgl.FlexLayout.new(card, .row, .{}); + row.setWidth(lvgl.sizePercent(100)); + row.clearFlag(.scrollable); + const left = try lvgl.FlexLayout.new(row, .column, .{}); + left.setWidth(lvgl.sizePercent(50)); + left.setPad(10, .row, .{}); + tab.currblock = try lvgl.Label.new(left, "HEIGHT\n", .{ .recolor = true }); + tab.timestamp = try lvgl.Label.new(left, "TIMESTAMP\n", .{ .recolor = true }); + tab.blockhash = try lvgl.Label.new(row, "BLOCK HASH\n", .{ .recolor = true }); + tab.blockhash.flexGrow(1); + } + + // mempool section + { + const card = try lvgl.Card.new(parent, "MEMPOOL"); + const row = try lvgl.FlexLayout.new(card, .row, .{}); + row.setWidth(lvgl.sizePercent(100)); + row.clearFlag(.scrollable); + const left = try lvgl.FlexLayout.new(row, .column, .{}); + left.setWidth(lvgl.sizePercent(50)); + left.setPad(8, .top, .{}); + left.setPad(10, .row, .{}); + tab.mempool.usage_bar = try lvgl.Bar.new(left); + tab.mempool.usage_lab = try lvgl.Label.new(left, "0Mb out of 0Mb (0%)", .{ .recolor = true }); + const right = try lvgl.FlexLayout.new(row, .column, .{}); + right.setWidth(lvgl.sizePercent(50)); + right.setPad(10, .row, .{}); + tab.mempool.txcount = try lvgl.Label.new(right, "TRANSACTIONS COUNT\n", .{ .recolor = true }); + tab.mempool.totalfee = try lvgl.Label.new(right, "TOTAL FEES\n", .{ .recolor = true }); + } + + // usage section + { + const card = try lvgl.Card.new(parent, "USAGE"); + const row = try lvgl.FlexLayout.new(card, .row, .{}); + row.setWidth(lvgl.sizePercent(100)); + row.clearFlag(.scrollable); + const left = try lvgl.FlexLayout.new(row, .column, .{}); + left.setWidth(lvgl.sizePercent(50)); + left.setPad(10, .row, .{}); + tab.diskusage = try lvgl.Label.new(left, "DISK USAGE\n", .{ .recolor = true }); + const right = try lvgl.FlexLayout.new(row, .column, .{}); + right.setWidth(lvgl.sizePercent(50)); + right.setPad(10, .row, .{}); + tab.conn_in = try lvgl.Label.new(right, "CONNECTIONS IN\n", .{ .recolor = true }); + tab.conn_out = try lvgl.Label.new(right, "CONNECTIONS OUT\n", .{ .recolor = true }); + } +} + +/// updates the tab with new data from the report. +/// the tab must be inited first with initTabPanel. +pub fn updateTabPanel(rep: comm.Message.BitcoindReport) !void { + var buf: [512]u8 = undefined; + + // blockchain section + try tab.currblock.setTextFmt(&buf, cmark ++ "HEIGHT#\n{d}", .{rep.blocks}); + try tab.timestamp.setTextFmt(&buf, cmark ++ "TIMESTAMP#\n{}", .{xfmt.unix(rep.timestamp)}); + try tab.blockhash.setTextFmt(&buf, cmark ++ "BLOCK HASH#\n{s}\n{s}", .{ rep.hash[0..32], rep.hash[32..] }); + + // mempool section + const mempool_pct: f32 = pct: { + if (rep.mempool.usage > rep.mempool.max) { + break :pct 100; + } + if (rep.mempool.max == 0) { + break :pct 0; + } + const v = @intToFloat(f64, rep.mempool.usage) / @intToFloat(f64, rep.mempool.max); + break :pct @floatCast(f32, v * 100); + }; + tab.mempool.usage_bar.setValue(@floatToInt(i32, @round(mempool_pct))); + try tab.mempool.usage_lab.setTextFmt(&buf, "{:.1} " ++ cmark ++ "out of# {:.1} ({d:.1}%)", .{ + fmt.fmtIntSizeBin(rep.mempool.usage), + fmt.fmtIntSizeBin(rep.mempool.max), + mempool_pct, + }); + try tab.mempool.txcount.setTextFmt(&buf, cmark ++ "TRANSACTIONS COUNT#\n{d}", .{rep.mempool.txcount}); + try tab.mempool.totalfee.setTextFmt(&buf, cmark ++ "TOTAL FEES#\n{d:10} BTC", .{rep.mempool.totalfee}); + + // usage section + try tab.diskusage.setTextFmt(&buf, cmark ++ "DISK USAGE#\n{:.1}", .{fmt.fmtIntSizeBin(rep.diskusage)}); + try tab.conn_in.setTextFmt(&buf, cmark ++ "CONNECTIONS IN#\n{d}", .{rep.conn_in}); + try tab.conn_out.setTextFmt(&buf, cmark ++ "CONNECTIONS OUT#\n{d}", .{rep.conn_out}); +} diff --git a/src/ui/c/ui.c b/src/ui/c/ui.c index 66d6dff..bb12fb9 100644 --- a/src/ui/c/ui.c +++ b/src/ui/c/ui.c @@ -20,6 +20,11 @@ void nm_sys_shutdown(); */ int nm_create_info_panel(lv_obj_t *parent); +/** + * creates the bitcoin tab panel. + */ +int nm_create_bitcoin_panel(lv_obj_t *parent); + /** * invoken when the UI is switched to the network settings tab. */ @@ -103,15 +108,6 @@ static void textarea_event_cb(lv_event_t *e) } } -static void create_bitcoin_panel(lv_obj_t *parent) -{ - lv_obj_t *label = lv_label_create(parent); - lv_label_set_text_static(label, - "bitcoin tab isn't designed yet\n" - "follow https://nakamochi.io"); - lv_obj_center(label); -} - static void create_lnd_panel(lv_obj_t *parent) { lv_obj_t *label = lv_label_create(parent); @@ -338,7 +334,9 @@ extern int nm_ui_init(lv_disp_t *disp) if (tab_btc == NULL) { return -1; } - create_bitcoin_panel(tab_btc); + if (nm_create_bitcoin_panel(tab_btc) != 0) { + return -1; + } lv_obj_t *tab_lnd = lv_tabview_add_tab(tabview, NM_SYMBOL_BOLT " LIGHTNING"); if (tab_lnd == NULL) { diff --git a/src/ui/screen.zig b/src/ui/screen.zig index b48a74c..6f73f9d 100644 --- a/src/ui/screen.zig +++ b/src/ui/screen.zig @@ -13,10 +13,19 @@ const logger = std.log.scoped(.screen); /// a touch screen activity or wake event is triggered. /// sleep removes all input devices at enter and reinstates them at exit so that /// a touch event triggers no accidental action. -pub fn sleep(wake: *const Thread.ResetEvent) void { +/// +/// the UI mutex is held while calling LVGL UI functions, and released during +/// idling or waiting for wake event. +/// although sleep is safe for concurrent use, the input drivers init/deinit +/// implementation used on entry and exit might not be. +pub fn sleep(ui: *std.Thread.Mutex, wake: *const Thread.ResetEvent) void { + ui.lock(); drv.deinitInput(); widget.topdrop(.show); + ui.unlock(); defer { + ui.lock(); + defer ui.unlock(); drv.initInput() catch |err| logger.err("drv.initInput: {any}", .{err}); widget.topdrop(.remove); } diff --git a/src/ui/ui.zig b/src/ui/ui.zig index 9c50a47..e23f37b 100644 --- a/src/ui/ui.zig +++ b/src/ui/ui.zig @@ -7,6 +7,7 @@ const drv = @import("drv.zig"); const symbol = @import("symbol.zig"); const widget = @import("widget.zig"); pub const poweroff = @import("poweroff.zig"); +pub const bitcoin = @import("bitcoin.zig"); const logger = std.log.scoped(.ui); @@ -34,6 +35,14 @@ export fn nm_create_info_panel(parent: *lvgl.LvObj) c_int { return 0; } +export fn nm_create_bitcoin_panel(parent: *lvgl.LvObj) c_int { + bitcoin.initTabPanel(lvgl.Container{ .lvobj = parent }) catch |err| { + logger.err("createBitcoinPanel: {any}", .{err}); + return -1; + }; + return 0; +} + fn createInfoPanel(cont: lvgl.Container) !void { const flex = cont.flex(.column, .{}); var buf: [100]u8 = undefined; diff --git a/src/xfmt.zig b/src/xfmt.zig new file mode 100644 index 0000000..c55562f --- /dev/null +++ b/src/xfmt.zig @@ -0,0 +1,31 @@ +//! extra formatting utilities, missing from std.fmt. + +const std = @import("std"); + +/// formats a unix timestamp in YYYY-MM-DD HH:MM:SS UTC. +/// if the sec value greater than u47, outputs raw digits. +pub fn unix(sec: u64) std.fmt.Formatter(formatUnix) { + return .{ .data = sec }; +} + +fn formatUnix(sec: u64, comptime fmt: []const u8, opts: std.fmt.FormatOptions, w: anytype) !void { + _ = fmt; // unused + _ = opts; + if (sec > std.math.maxInt(u47)) { + // EpochSeconds.getEpochDay trucates to u47 which results in a "truncated bits" + // panic for too big numbers. so, just print raw digits. + return std.fmt.format(w, "{d}", .{sec}); + } + const epoch: std.time.epoch.EpochSeconds = .{ .secs = sec }; + const daysec = epoch.getDaySeconds(); + const yearday = epoch.getEpochDay().calculateYearDay(); + const monthday = yearday.calculateMonthDay(); + return std.fmt.format(w, "{d}-{d:0>2}-{d:0>2} {d:0>2}:{d:0>2}:{d:0>2} UTC", .{ + yearday.year, + monthday.month.numeric(), + monthday.day_index + 1, + daysec.getHoursIntoDay(), + daysec.getMinutesIntoHour(), + daysec.getSecondsIntoMinute(), + }); +}