diff --git a/pybitblock/PyBlock.py b/pybitblock/PyBlock.py index 4129852..9833422 100644 --- a/pybitblock/PyBlock.py +++ b/pybitblock/PyBlock.py @@ -882,70 +882,11 @@ def some_other_function(): def execute_visualizer(): block_visualizer.run_visualizer() -def artist(): # here we convert the result of the command 'getblockcount' on a random art design - while True: - try: - clear() - close() - design() - except KeyboardInterrupt: - break - except Exception as e: - logger.debug("Loop interrupted: %s", e) - break - -def design(): - if os.path.isfile('config/pyblocksettingsClock.conf') or os.path.isfile('config/pyblocksettingsClock.conf'): # Check if the file 'bclock.conf' is in the same folder - settingsv = json.load(open("config/pyblocksettingsClock.conf", "r")) # Load the file 'bclock.conf' - settingsClock = settingsv # Copy the variable pathv to 'path' - else: - settingsClock = {"gradient":"", "design":"block", "colorA":"green", "colorB":"yellow"} - with open("config/pyblocksettingsClock.conf", "w") as f: json.dump(settingsClock, f, indent=2) - bitcoinclient = f'{path["bitcoincli"]} getblockcount' - block = subprocess.run(str(bitcoinclient).split(), capture_output=True, text=True).stdout # 'getblockcount' convert to string - b = block - a = b - output = render(str(b), colors=[settingsClock['colorA'], settingsClock['colorB']], align='center') - print("\033[0;37;40m\x1b[?25l" + output) - while True: - x = a - bitcoinclient = f'{path["bitcoincli"]} getblockcount' - block = subprocess.run(str(bitcoinclient).split(), capture_output=True, text=True).stdout # 'getblockcount' convert to string - b = block - if b > a: - clear() - close() - output = render(str(b), colors=[settingsClock['colorA'], settingsClock['colorB']], align='center') - print("\a\x1b[?25l" + output) - bitcoinclient = f'{path["bitcoincli"]} getbestblockhash' - bb = subprocess.run(str(bitcoinclient).split(), capture_output=True, text=True).stdout - ll = bb - bitcoinclientgetblock = f'{path["bitcoincli"]} getblock {ll}' - qq = subprocess.run(bitcoinclientgetblock.split(), capture_output=True, text=True).stdout - yy = json.loads(qq) - mm = yy - outputsize = render(str(mm['size']) + " bytes", colors=[settingsClock['colorA'], settingsClock['colorB']], align='center', font='tiny') - print("\x1b[?25l" + outputsize) - outputtxs = render(str(mm['nTx']) + " txs", colors=[settingsClock['colorA'], settingsClock['colorB']], align='center', font='tiny') - print("\x1b[?25l" + outputtxs) - sh = int(mm['nTx']) / 4 - shq = int(sh) - ss = str(rectangle(shq)) - print(ss.replace("None","")) - t.sleep(10) - txs = str(mm['nTx']) - if txs == "1": - try: - p = subprocess.Popen(['curl', 'https://ascii.live/forrest']) - p.wait(5) - except subprocess.TimeoutExpired: - p.kill() - print("\033[0;37;40m\x1b[?25l") - clear() - close() - a = b - output = render(str(b), colors=[settingsClock['colorA'], settingsClock['colorB']], align='center') - print("\x1b[?25l" + output) +def artist(): + """Launch the enhanced block clock.""" + from clock import run_clock + mode = "remote" if not path.get("bitcoincli") else "local" + run_clock(mode, path, cfg.settings_clock) #--------------------------------- Hex Block Decoder Functions ------------------------------------- @@ -3926,6 +3867,7 @@ def settings4Local(): \u001b[38;5;27mA.\033[0;37;40m Change Logo Design \u001b[38;5;27mB.\033[0;37;40m Change Logo Colors \u001b[38;5;27mC.\033[0;37;40m Change Clock Colors + \u001b[38;5;27mD.\033[0;37;40m Clock Display Settings \u001b[33;1mEnter.\033[0;37;40m Return \n\n\x1b[?25h""".format(n, alias['alias'], d['blocks'], version, ())) menuSettingsLocal(input("\033[1;32;40mSelect option: \033[0;37;40m")) @@ -3950,6 +3892,7 @@ def settings4LocalOnchainONLY(): \u001b[38;5;27mA.\033[0;37;40m Change Logo Design \u001b[38;5;27mB.\033[0;37;40m Change Logo Colors \u001b[38;5;27mC.\033[0;37;40m Change Clock Colors + \u001b[38;5;27mD.\033[0;37;40m Clock Display Settings \u001b[33;1mEnter.\033[0;37;40m Return \n\n\x1b[?25h""".format(n, d['blocks'], version, ())) menuSettingsLocalOnchainONLY(input("\033[1;32;40mSelect option: \033[0;37;40m")) @@ -3980,6 +3923,7 @@ def settings4Remote(): \u001b[38;5;27mA.\033[0;37;40m Change Logo Design \u001b[38;5;27mB.\033[0;37;40m Change Logo Colors \u001b[38;5;27mC.\033[0;37;40m Change Clock Colors + \u001b[38;5;27mD.\033[0;37;40m Clock Display Settings \u001b[33;1mEnter.\033[0;37;40m Return \n\n\x1b[?25h""".format(a, alias['alias'], d['blocks'], version, ())) menuSettingsRemote(input("\033[1;32;40mSelect option: \033[0;37;40m")) @@ -5139,36 +5083,29 @@ def colorsSelectRainbowEndOnchainONLY(): def menuSelection(): chln = {"fullbtclnd":"","fullbtc":"","cropped":""} - if os.path.isfile('config/intro.conf'): - chain = json.load(open("config/intro.conf", "r")) - chln = chain + if cfg.has_config('intro.conf'): + chln = cfg.intro_mode print(chln + "\n") if chln == "B": - path = {"ip_port":"", "rpcuser":"", "rpcpass":"", "bitcoincli":""} - pathv = json.load(open("config/bclock.conf", "r")) # Load the file 'bclock.conf' - path = pathv # Copy the variable pathv to 'path' + path = cfg.path MainMenuLOCALChainONLY() elif chln == "A": - path = {"ip_port":"", "rpcuser":"", "rpcpass":"", "bitcoincli":""} - pathv = json.load(open("config/bclock.conf", "r")) # Load the file 'bclock.conf' - path = pathv # Copy the variable pathv to 'path' + path = cfg.path MainMenuLOCAL() elif chln == "C": from SPV.spvblock import MainMenuCROPPED as _lite_menu _lite_menu() else: - if os.path.isfile('config/blndconnect.conf'): + if cfg.has_config('blndconnect.conf'): chln['offchain'] = "offchain" else: chln['onchain'] = "onchain" - with open("config/selection.conf", "w") as f: json.dump(chln, f, indent=2) + cfg.save("selection.conf", chln) def menuSelectionLN(): - lndconnectload = {"ip_port":"", "tls":"", "macaroon":"", "lncli":""} - lndconnectData = json.load(open("config/blndconnect.conf", "r")) # Load the file 'bclock.conf' - lndconnectload = lndconnectData # Copy the variable pathv to 'path' + lndconnectload = cfg.lndconnectload if lndconnectload['ln']: menuLNDLOCAL() else: @@ -5452,6 +5389,8 @@ def menuSettingsLocal(menuSTT): clear() blogo() colorsC() + elif menuSTT in ["D", "d"]: + clockDisplaySettings() def menuSettingsLocalOnchainONLY(menuSTT): if menuSTT in ["A", "a"]: @@ -5466,6 +5405,8 @@ def menuSettingsLocalOnchainONLY(menuSTT): clear() blogo() colorsCOnchainONLY() + elif menuSTT in ["D", "d"]: + clockDisplaySettings() def menuSettingsRemote(menuSTT): if menuSTT in ["A", "a"]: @@ -5480,6 +5421,81 @@ def menuSettingsRemote(menuSTT): clear() blogo() colorsCRemote() + elif menuSTT in ["D", "d"]: + clockDisplaySettings() + +def clockDisplaySettings(): + """Interactive settings menu for clock display features.""" + while True: + try: + clear() + blogo() + s = cfg.settings_clock + + def _on_off(val): + return "\033[1;32;40mON\033[0;37;40m" if val else "\033[1;31;40mOFF\033[0;37;40m" + + print("""\t\t + \033[1;37;40mClock Display Settings\033[0;37;40m + + \u001b[38;5;27m1.\033[0;37;40m Countdown Timer {} + \u001b[38;5;27m2.\033[0;37;40m Epoch Progress Bar {} + \u001b[38;5;27m3.\033[0;37;40m Fee Rate Indicator {} + \u001b[38;5;27m4.\033[0;37;40m Hashrate Sparkline {} + \u001b[38;5;27m5.\033[0;37;40m UTC Time Display {} + \u001b[38;5;27m6.\033[0;37;40m Zen Mode {} + \u001b[38;5;27m7.\033[0;37;40m Heartbeat Pulse {} + \u001b[38;5;27m8.\033[0;37;40m Generative Art {} + \u001b[38;5;27m9.\033[0;37;40m Fireworks on Milestones {} + + \u001b[38;5;27mA.\033[0;37;40m Animation: \033[1;33;40m{}\033[0;37;40m + \u001b[38;5;27mS.\033[0;37;40m Sound: \033[1;33;40m{}\033[0;37;40m + \u001b[33;1mEnter.\033[0;37;40m Return + \n\x1b[?25h""".format( + _on_off(s.get('show_countdown', True)), + _on_off(s.get('show_epoch_bar', True)), + _on_off(s.get('show_fee_rates', True)), + _on_off(s.get('show_sparkline', False)), + _on_off(s.get('show_utc_time', False)), + _on_off(s.get('zen_mode', False)), + _on_off(s.get('heartbeat', True)), + _on_off(s.get('generative_art', False)), + _on_off(s.get('fireworks', True)), + s.get('animation', 'matrix'), + s.get('sound', 'bell'), + )) + + opt = input("\033[1;32;40mSelect option: \033[0;37;40m").strip() + + toggles = { + '1': 'show_countdown', '2': 'show_epoch_bar', + '3': 'show_fee_rates', '4': 'show_sparkline', + '5': 'show_utc_time', '6': 'zen_mode', + '7': 'heartbeat', '8': 'generative_art', + '9': 'fireworks', + } + + if opt in toggles: + key = toggles[opt] + s[key] = not s.get(key, False) + cfg.save("pyblocksettingsClock.conf", s) + elif opt in ['A', 'a']: + modes = ['matrix', 'odometer', 'none'] + current = s.get('animation', 'matrix') + idx = (modes.index(current) + 1) % len(modes) if current in modes else 0 + s['animation'] = modes[idx] + cfg.save("pyblocksettingsClock.conf", s) + elif opt in ['S', 's']: + modes = ['bell', 'pattern', 'silent'] + current = s.get('sound', 'bell') + idx = (modes.index(current) + 1) % len(modes) if current in modes else 0 + s['sound'] = modes[idx] + cfg.save("pyblocksettingsClock.conf", s) + else: + break + except KeyboardInterrupt: + break + def menuColors(menuCLS): if menuCLS in ["A", "a"]: @@ -6085,18 +6101,9 @@ def menuWeatherOnchainONLY(menuWD): def mainmenuControl(menuS, mode): #Unified execution of Main Menu options if menuS in ["A", "a"]: - if mode == "remote": - while True: - try: - clear() - close() - remotegetblock() - tmp() - except Exception as e: - logger.debug("Loop interrupted: %s", e) - break - else: - artist() + from clock import run_clock + clock_mode = "remote" if mode == "remote" else "local" + run_clock(clock_mode, path, cfg.settings_clock) elif menuS in ["B", "b"]: if mode == "remote": bitcoincoremenuREMOTE() @@ -7435,8 +7442,7 @@ if __name__ == "__main__": mode = "lite" cfg.load() if cfg.has_config('intro.conf'): - with open("config/intro.conf", "r") as f: - init_data = json.load(f) + init_data = cfg.intro_mode if isinstance(init_data, str): if init_data == "A": mode = "local" diff --git a/pybitblock/clock/__init__.py b/pybitblock/clock/__init__.py new file mode 100644 index 0000000..0f15623 --- /dev/null +++ b/pybitblock/clock/__init__.py @@ -0,0 +1,44 @@ +"""Enhanced block clock for PyBLOCK. + +Entry point: run_clock(mode, path, settings_clock) +""" + +import time +import sys + +from . import animations +from .data import ClockData +from .renderer import Layout + + +def run_clock(mode, path, settings_clock): + """Main clock loop with partial screen updates. + + mode: 'local', 'remote', or 'lite' + path: dict with bitcoincli, ip_port, rpcuser, rpcpass + settings_clock: dict from pyblocksettingsClock.conf + """ + data = ClockData(mode, path) + layout = Layout(settings_clock) + + try: + # Initial full fetch and render + data.refresh() + layout.render_full(data) + + while True: + time.sleep(2) + + changed = data.poll() + + if 'block_height' in changed: + layout.on_new_block(data, animations) + else: + # Update dynamic elements + layout.update_countdown(data) + layout.heartbeat(data) + + except KeyboardInterrupt: + pass + finally: + layout.cleanup() diff --git a/pybitblock/clock/animations.py b/pybitblock/clock/animations.py new file mode 100644 index 0000000..4f99ccb --- /dev/null +++ b/pybitblock/clock/animations.py @@ -0,0 +1,193 @@ +"""Visual animations for the block clock. + +Mining rain, odometer digit transition, fireworks on milestones. +""" + +import shutil +import subprocess +import sys +import time +from random import choice, randrange + +from cfonts import render + +# Halving blocks for milestone detection +HALVING_BLOCKS = {210_000 * i for i in range(1, 65)} + + +def is_milestone_block(height): + """Check if block is a milestone. Returns description or None.""" + if height in HALVING_BLOCKS: + return f"HALVING #{height // 210_000}" + if height % 100_000 == 0: + return f"BLOCK {height:,}" + if height % 10_000 == 0: + return f"BLOCK {height:,}" + return None + + +def mining_animation(duration=3.0): + """Matrix-style mining rain animation for new block discovery. + + Uses inline implementation to avoid import issues with terminal_matrix. + """ + cols, lines = shutil.get_terminal_size((80, 24)) + chars = [chr(i) for i in range(0x30, 0x80)] + green = "\033[32m" + bright_green = "\033[1;32m" + reset = "\033[0m" + + # Initialize cascades + cascades = {} + sys.stdout.write("\033[2J\033[H\x1b[?25l") + + end_time = time.time() + duration + while time.time() < end_time: + # Spawn new cascades + if len(cascades) < cols // 2: + col = randrange(1, cols + 1) + if col not in cascades: + speed = randrange(1, 4) + length = randrange(4, lines // 2) + cascades[col] = {'row': 1, 'speed': speed, 'length': length} + + buf = [] + to_remove = [] + for col, c in cascades.items(): + row = c['row'] + if row <= lines: + char = choice(chars) + buf.append(f"\033[{row};{col}H{bright_green}{char}") + # Dim the trail + trail_row = row - c['length'] + if 1 <= trail_row <= lines: + buf.append(f"\033[{trail_row};{col}H{reset} ") + c['row'] += c['speed'] + if c['row'] - c['length'] > lines: + to_remove.append(col) + + for col in to_remove: + del cascades[col] + + if buf: + sys.stdout.write(''.join(buf)) + sys.stdout.flush() + + time.sleep(0.03) + + sys.stdout.write(f"\033[2J\033[H{reset}\x1b[?25l") + sys.stdout.flush() + + +def odometer_transition(old_height_str, new_height_str, settings, start_row): + """Animate changing digits like a mechanical odometer. + + Renders intermediate digit values at the positions that changed. + """ + colors = [settings.get('colorA', 'green'), settings.get('colorB', 'yellow')] + font = settings.get('design', 'block') + + # Pad to same length + max_len = max(len(old_height_str), len(new_height_str)) + old = old_height_str.zfill(max_len) + new = new_height_str.zfill(max_len) + + # Find which digits changed + changed = [i for i in range(max_len) if old[i] != new[i]] + + if not changed: + return + + # Animate: show 3 intermediate frames + frames = 3 + for frame in range(frames): + intermediate = list(old) + for i in changed: + old_d = int(old[i]) + new_d = int(new[i]) + # Roll through digits + step = (old_d + (frame + 1) * (new_d - old_d + 10) // (frames + 1)) % 10 + if frame == frames - 1: + step = new_d + intermediate[i] = str(step) + + text = ''.join(intermediate) + output = render(text, colors=colors, align='center', font=font) + lines = output.rstrip('\n').split('\n') + + buf = [] + for j, line in enumerate(lines): + buf.append(f"\033[{start_row + j};1H\033[2K{line}") + sys.stdout.write(''.join(buf)) + sys.stdout.flush() + time.sleep(0.12) + + +def fireworks_animation(term_width, term_height, duration=5.0): + """ASCII fireworks celebration for milestone blocks.""" + colors = [ + "\033[1;31m", # red + "\033[1;33m", # yellow + "\033[1;32m", # green + "\033[1;36m", # cyan + "\033[1;35m", # magenta + "\033[1;37m", # white + ] + sparks = ['*', '.', '+', 'o', '\u2022', '\u2726', '\u2727', '\u2728'] + reset = "\033[0m" + + sys.stdout.write("\033[2J\033[H\x1b[?25l") + + end_time = time.time() + duration + explosions = [] + + while time.time() < end_time: + # Spawn new explosion + if randrange(5) == 0 or not explosions: + cx = randrange(5, term_width - 5) + cy = randrange(3, term_height - 3) + color = choice(colors) + explosions.append({ + 'cx': cx, 'cy': cy, 'color': color, + 'radius': 0, 'max_radius': randrange(3, 8), + 'age': 0 + }) + + buf = [] + alive = [] + for exp in explosions: + exp['age'] += 1 + exp['radius'] = min(exp['radius'] + 1, exp['max_radius']) + + if exp['age'] > exp['max_radius'] * 3: + # Fade: clear spark positions + for _ in range(8): + dx = randrange(-exp['max_radius'], exp['max_radius'] + 1) + dy = randrange(-exp['max_radius'] // 2, exp['max_radius'] // 2 + 1) + x = exp['cx'] + dx + y = exp['cy'] + dy + if 1 <= x <= term_width and 1 <= y <= term_height: + buf.append(f"\033[{y};{x}H ") + continue + + alive.append(exp) + r = exp['radius'] + for _ in range(r * 4): + dx = randrange(-r, r + 1) + dy = randrange(-r // 2, r // 2 + 1) + x = exp['cx'] + dx + y = exp['cy'] + dy + if 1 <= x <= term_width and 1 <= y <= term_height: + spark = choice(sparks) + buf.append(f"\033[{y};{x}H{exp['color']}{spark}") + + explosions = alive + + if buf: + sys.stdout.write(''.join(buf) + reset) + sys.stdout.flush() + + time.sleep(0.08) + + sys.stdout.write(f"\033[2J\033[H{reset}\x1b[?25l") + sys.stdout.flush() diff --git a/pybitblock/clock/data.py b/pybitblock/clock/data.py new file mode 100644 index 0000000..9aa5636 --- /dev/null +++ b/pybitblock/clock/data.py @@ -0,0 +1,200 @@ +"""Bitcoin data layer for the enhanced block clock. + +Fetches block height, block details, fees, hashrate, and epoch info +from either a local bitcoin-cli or JSON-RPC, plus mempool.space API +for fee rates and hashrate. +""" + +import json +import subprocess +import threading +import time + +import requests + +# Halving constants +BLOCKS_PER_HALVING = 210_000 +BLOCKS_PER_EPOCH = 2016 +HALVING_BLOCKS = [BLOCKS_PER_HALVING * i for i in range(1, 65)] + +# API endpoints +MEMPOOL_FEES_URL = "https://mempool.space/api/v1/fees/recommended" +MEMPOOL_HASHRATE_URL = "https://mempool.space/api/v1/mining/hashrate/3d" +MEMPOOL_HEIGHT_URL = "https://mempool.space/api/blocks/tip/height" +MEMPOOL_BLOCK_URL = "https://mempool.space/api/block/" + + +class ClockData: + """Fetches and caches Bitcoin data for the clock display.""" + + def __init__(self, mode, path): + """ + mode: 'local', 'remote', or 'lite' + path: dict with ip_port, rpcuser, rpcpass, bitcoincli + """ + self.mode = mode + self.path = path + + # Block data + self.block_height = 0 + self.block_hash = "" + self.block_time = 0 + self.block_size = 0 + self.block_tx_count = 0 + + # Epoch / halving + self.epoch_progress = 0.0 + self.epoch_block = 0 + self.blocks_to_halving = 0 + self.next_halving_block = 0 + + # Fee rates (sat/vB) + self.fee_fastest = 0 + self.fee_half_hour = 0 + self.fee_hour = 0 + + # Hashrate + self.hashrate_current = 0.0 + self.hashrate_history = [] + self.difficulty = 0.0 + + # Internal + self._lock = threading.Lock() + self._bg_thread = None + self._last_api_fetch = 0 + + # --- RPC / CLI abstraction --- + + def _cli(self, command): + """Run bitcoin-cli command, return stdout string.""" + cmd = f'{self.path["bitcoincli"]} {command}' + result = subprocess.run(cmd.split(), capture_output=True, text=True) + return result.stdout.strip() + + def _rpc(self, method, params=None): + """JSON-RPC call for remote mode.""" + payload = json.dumps({ + "jsonrpc": "2.0", "id": "clock", + "method": method, "params": params or [] + }) + resp = requests.post( + self.path['ip_port'], + auth=(self.path['rpcuser'], self.path['rpcpass']), + data=payload, timeout=10 + ) + return resp.json()['result'] + + def _get_block_count(self): + if self.mode == 'lite': + r = requests.get(MEMPOOL_HEIGHT_URL, timeout=10) + return int(r.text.strip()) + elif self.mode == 'remote': + return int(self._rpc('getblockcount')) + else: + return int(self._cli('getblockcount')) + + def _get_block_details(self): + """Fetch full block details for current tip.""" + if self.mode == 'lite': + r = requests.get(MEMPOOL_HEIGHT_URL, timeout=10) + tip_hash = requests.get( + "https://mempool.space/api/blocks/tip/hash", timeout=10 + ).text.strip() + r2 = requests.get(f"{MEMPOOL_BLOCK_URL}{tip_hash}", timeout=10) + block = r2.json() + self.block_hash = tip_hash + self.block_time = block.get('timestamp', int(time.time())) + self.block_size = block.get('size', 0) + self.block_tx_count = block.get('tx_count', 0) + else: + if self.mode == 'remote': + block_hash = self._rpc('getbestblockhash') + block = self._rpc('getblock', [block_hash]) + else: + block_hash = self._cli('getbestblockhash') + raw = self._cli(f'getblock {block_hash}') + block = json.loads(raw) + self.block_hash = block_hash + self.block_time = block.get('time', int(time.time())) + self.block_size = block.get('size', 0) + self.block_tx_count = block.get('nTx', 0) + + def _calc_epoch(self): + """Calculate epoch and halving progress from block height.""" + h = self.block_height + self.epoch_block = h % BLOCKS_PER_EPOCH + self.epoch_progress = self.epoch_block / BLOCKS_PER_EPOCH + + for hb in HALVING_BLOCKS: + if h < hb: + self.next_halving_block = hb + self.blocks_to_halving = hb - h + break + else: + self.blocks_to_halving = 0 + self.next_halving_block = 0 + + # --- API data (background thread) --- + + def _fetch_api_data(self): + """Fetch fee rates and hashrate from mempool.space (non-blocking).""" + try: + r = requests.get(MEMPOOL_FEES_URL, timeout=10) + fees = r.json() + with self._lock: + self.fee_fastest = fees.get('fastestFee', 0) + self.fee_half_hour = fees.get('halfHourFee', 0) + self.fee_hour = fees.get('hourFee', 0) + except Exception: + pass + + try: + r = requests.get(MEMPOOL_HASHRATE_URL, timeout=10) + data = r.json() + with self._lock: + self.hashrate_current = data.get('currentHashrate', 0) + self.difficulty = data.get('currentDifficulty', 0) + hashrates = data.get('hashrates', []) + self.hashrate_history = [ + h.get('avgHashrate', 0) for h in hashrates[-20:] + ] + except Exception: + pass + + def _start_bg_fetch(self): + """Fetch API data in background thread if enough time has passed.""" + now = time.time() + if now - self._last_api_fetch < 30: + return + self._last_api_fetch = now + t = threading.Thread(target=self._fetch_api_data, daemon=True) + t.start() + + # --- Public API --- + + def refresh(self): + """Full data fetch: block height, details, epoch, and trigger API fetch.""" + self.block_height = self._get_block_count() + self._get_block_details() + self._calc_epoch() + self._start_bg_fetch() + + def poll(self): + """Quick poll: just getblockcount. Returns set of changed field names.""" + changed = set() + new_height = self._get_block_count() + if new_height != self.block_height: + old_height = self.block_height + self.block_height = new_height + self._get_block_details() + self._calc_epoch() + self._start_bg_fetch() + changed.add('block_height') + return changed + + @property + def seconds_since_block(self): + """Seconds elapsed since the last block timestamp.""" + if self.block_time == 0: + return 0 + return int(time.time()) - self.block_time diff --git a/pybitblock/clock/generative.py b/pybitblock/clock/generative.py new file mode 100644 index 0000000..d41e0f3 --- /dev/null +++ b/pybitblock/clock/generative.py @@ -0,0 +1,52 @@ +"""Block hash generative ASCII art. + +Uses hash bytes as seeds to create a unique visual pattern per block. +""" + +# Characters ordered by visual density +GLYPHS = " \u2591\u2592\u2593\u2588\u2580\u2584\u258c\u2590\u256c\u2550\u2551" + +# 256-color ANSI foreground +def _color256(n): + return f"\033[38;5;{n}m" + +RESET = "\033[0m" + + +def hash_art(block_hash, width=40, height=6): + """Generate deterministic ASCII art from a block hash string. + + Each pair of hex digits maps to a glyph and color. + The pattern is mirrored horizontally for symmetry. + """ + # Convert hex hash to bytes + raw = block_hash.strip() + hex_pairs = [raw[i:i+2] for i in range(0, len(raw), 2)] + values = [int(h, 16) for h in hex_pairs if len(h) == 2] + + if not values: + return "" + + half_w = width // 2 + lines = [] + + for row in range(height): + left = [] + for col in range(half_w): + idx = (row * half_w + col) % len(values) + val = values[idx] + + # Glyph from lower nibble + glyph = GLYPHS[val % len(GLYPHS)] + # Color from upper nibble + row offset (for variety) + color_idx = 16 + ((val + row * 7) % 216) # 216-color cube + left.append(f"{_color256(color_idx)}{glyph}") + + # Mirror for symmetry + right = list(reversed(left)) + line = ''.join(left) + ''.join(right) + RESET + # Center it + pad = max(0, (80 - width) // 2) + lines.append(' ' * pad + line) + + return '\n'.join(lines) diff --git a/pybitblock/clock/renderer.py b/pybitblock/clock/renderer.py new file mode 100644 index 0000000..2ee3789 --- /dev/null +++ b/pybitblock/clock/renderer.py @@ -0,0 +1,240 @@ +"""Screen layout and rendering engine for the enhanced block clock. + +Uses ANSI cursor positioning for flicker-free partial screen updates. +Composes cfonts output with widget overlays. +""" + +import shutil +import sys +import time + +from cfonts import render + +from .widgets import ( + render_countdown, + render_epoch_bar, + render_fees, + render_utc_time, +) +from .sparkline import render_sparkline +from .generative import hash_art +from .sound import play_sound + + +# ANSI helpers +def _move(row, col=1): + return f"\033[{row};{col}H" + + +def _clear_line(): + return "\033[2K" + + +def _hide_cursor(): + return "\x1b[?25l" + + +def _show_cursor(): + return "\x1b[?25h" + + +def _bold(text): + return f"\033[1m{text}\033[0m" + + +def _dim(text): + return f"\033[2m{text}\033[0m" + + +def _clear_screen(): + sys.stdout.write("\033[2J\033[H") + sys.stdout.flush() + + +def _render_block_height(height, settings): + """Render block height using cfonts.""" + colors = [settings.get('colorA', 'green'), settings.get('colorB', 'yellow')] + gradient = settings.get('gradient', '') + font = settings.get('design', 'block') + + kwargs = {'align': 'center', 'font': font} + if gradient == 'grd': + kwargs['gradient'] = colors + else: + kwargs['colors'] = colors + + return render(str(height), **kwargs) + + +class Layout: + """Manages screen regions and partial updates.""" + + def __init__(self, settings): + self.settings = settings + self.term_width, self.term_height = shutil.get_terminal_size((80, 24)) + self._height_lines = 0 + self._heartbeat_step = 0 + self._last_rendered_height = None + + def _is_zen(self): + return self.settings.get('zen_mode', False) + + def _write(self, text): + sys.stdout.write(text) + sys.stdout.flush() + + def _render_cfonts_at(self, output, start_row): + """Render cfonts output at a specific row, line by line.""" + lines = output.rstrip('\n').split('\n') + self._height_lines = len(lines) + buf = [] + for i, line in enumerate(lines): + buf.append(_move(start_row + i) + _clear_line() + line) + self._write(''.join(buf)) + return start_row + len(lines) + + def render_full(self, data): + """Clear screen and render all regions.""" + _clear_screen() + self._write(_hide_cursor()) + self.term_width, self.term_height = shutil.get_terminal_size((80, 24)) + + output = _render_block_height(data.block_height, self.settings) + self._last_rendered_height = data.block_height + + if self._is_zen(): + # Center vertically + lines = output.rstrip('\n').split('\n') + start = max(1, (self.term_height - len(lines)) // 2) + self._render_cfonts_at(output, start) + return + + # Region 1: Block height (row 2) + next_row = self._render_cfonts_at(output, 2) + + # Region 2: Block info + next_row = self._render_info(data, next_row + 1) + + # Region 3+: Widgets + self._render_widgets(data, next_row + 1) + + def _render_info(self, data, row): + """Render block size and tx count line.""" + if data.block_size > 0: + size_mb = data.block_size / 1_000_000 + info = f" \033[0;37;40m{size_mb:.2f} MB · {data.block_tx_count} txs" + center_pad = max(0, (self.term_width - len(info) + 20) // 2) + self._write(_move(row) + _clear_line() + ' ' * center_pad + info) + return row + 1 + return row + + def _render_widgets(self, data, start_row): + """Render all enabled widget overlays.""" + row = start_row + s = self.settings + w = self.term_width + + if s.get('show_countdown', True): + text = render_countdown(data.seconds_since_block, w) + self._write(_move(row) + _clear_line() + text) + row += 2 + + if s.get('show_epoch_bar', True): + text = render_epoch_bar( + data.block_height, data.epoch_block, + data.blocks_to_halving, data.next_halving_block, w + ) + self._write(_move(row) + _clear_line() + text) + row += 2 + + if s.get('show_fee_rates', True): + text = render_fees( + data.fee_fastest, data.fee_half_hour, data.fee_hour, w + ) + self._write(_move(row) + _clear_line() + text) + row += 2 + + if s.get('show_sparkline', False) and data.hashrate_history: + text = render_sparkline( + data.hashrate_history, data.hashrate_current, w + ) + self._write(_move(row) + _clear_line() + text) + row += 2 + + if s.get('show_utc_time', False): + text = render_utc_time( + [s.get('colorA', 'green'), s.get('colorB', 'yellow')] + ) + lines = text.rstrip('\n').split('\n') + for i, line in enumerate(lines): + self._write(_move(row + i) + _clear_line() + line) + row += len(lines) + 1 + + if s.get('generative_art', False) and data.block_hash: + art = hash_art(data.block_hash, min(60, w - 4), 6) + lines = art.split('\n') + for i, line in enumerate(lines): + self._write(_move(row + i) + _clear_line() + line) + row += len(lines) + 1 + + return row + + def update_countdown(self, data): + """Update only the countdown timer (called every poll cycle).""" + if self._is_zen() or not self.settings.get('show_countdown', True): + return + # Countdown is rendered right after block height + info line + row = 2 + self._height_lines + 2 + text = render_countdown(data.seconds_since_block, self.term_width) + self._write(_move(row) + _clear_line() + text) + + def heartbeat(self, data): + """Toggle bold/dim on block height for breathing effect.""" + if self._is_zen() or not self.settings.get('heartbeat', True): + return + if self._last_rendered_height is None: + return + + self._heartbeat_step += 1 + output = _render_block_height(data.block_height, self.settings) + lines = output.rstrip('\n').split('\n') + + start_row = 2 + if self._is_zen(): + start_row = max(1, (self.term_height - len(lines)) // 2) + + # Apply dim on odd steps + wrapper = _dim if self._heartbeat_step % 2 == 1 else lambda x: x + buf = [] + for i, line in enumerate(lines): + buf.append(_move(start_row + i) + _clear_line() + wrapper(line)) + self._write(''.join(buf)) + + def on_new_block(self, data, animations_mod): + """Handle new block arrival: sound, animation, then full re-render.""" + play_sound(self.settings.get('sound', 'bell')) + + anim = self.settings.get('animation', 'matrix') + + # Check for milestone fireworks first + if self.settings.get('fireworks', True): + milestone = animations_mod.is_milestone_block(data.block_height) + if milestone: + animations_mod.fireworks_animation( + self.term_width, self.term_height, duration=5.0 + ) + + if anim == 'matrix': + animations_mod.mining_animation(duration=3.0) + elif anim == 'odometer' and self._last_rendered_height is not None: + animations_mod.odometer_transition( + str(self._last_rendered_height), + str(data.block_height), + self.settings, 2 + ) + + self.render_full(data) + + def cleanup(self): + """Restore terminal state.""" + self._write(_show_cursor() + "\033[0m") diff --git a/pybitblock/clock/sound.py b/pybitblock/clock/sound.py new file mode 100644 index 0000000..fcca1c1 --- /dev/null +++ b/pybitblock/clock/sound.py @@ -0,0 +1,23 @@ +"""Configurable sound notifications for new blocks.""" + +import sys +import time + + +def play_sound(mode): + """Play sound notification based on mode setting. + + mode: 'bell' (single beep), 'pattern' (rhythmic), 'silent' (nothing) + """ + if mode == 'silent': + return + elif mode == 'pattern': + # Three short beeps + for _ in range(3): + sys.stdout.write('\a') + sys.stdout.flush() + time.sleep(0.15) + else: + # Default: single bell + sys.stdout.write('\a') + sys.stdout.flush() diff --git a/pybitblock/clock/sparkline.py b/pybitblock/clock/sparkline.py new file mode 100644 index 0000000..9ce6b0b --- /dev/null +++ b/pybitblock/clock/sparkline.py @@ -0,0 +1,48 @@ +"""Hashrate sparkline using Unicode block characters.""" + +SPARK_CHARS = " \u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588" + + +def render_sparkline(values, current_hashrate, term_width): + """Render a mini sparkline graph for hashrate history. + + values: list of hashrate floats (last N data points) + current_hashrate: current hashrate in H/s + term_width: terminal width for centering + """ + if not values: + return "" + + mn = min(values) + mx = max(values) + rng = mx - mn if mx != mn else 1 + + spark = "" + for v in values: + idx = int((v - mn) / rng * (len(SPARK_CHARS) - 1)) + spark += SPARK_CHARS[idx] + + # Format hashrate in human-readable units + hr_str = _format_hashrate(current_hashrate) + + text = f" \033[1;33;40mHashrate\033[0;37;40m {spark} {hr_str}" + pad = max(0, (term_width - len(spark) - len(hr_str) - 12) // 2) + return ' ' * pad + text + + +def _format_hashrate(h): + """Format hashrate in appropriate unit.""" + if h <= 0: + return "-- H/s" + units = [ + (1e18, "EH/s"), + (1e15, "PH/s"), + (1e12, "TH/s"), + (1e9, "GH/s"), + (1e6, "MH/s"), + (1e3, "KH/s"), + ] + for threshold, unit in units: + if h >= threshold: + return f"{h / threshold:.1f} {unit}" + return f"{h:.0f} H/s" diff --git a/pybitblock/clock/widgets.py b/pybitblock/clock/widgets.py new file mode 100644 index 0000000..912ea6a --- /dev/null +++ b/pybitblock/clock/widgets.py @@ -0,0 +1,76 @@ +"""Clock widget components: countdown, epoch bar, fees, UTC time.""" + +from datetime import datetime, timezone + +from cfonts import render + + +def render_countdown(seconds_since_block, term_width): + """Render time since last block with color coding. + + Green: <600s (10min), Yellow: 600-1200s, Red: >1200s. + """ + mins = seconds_since_block // 60 + secs = seconds_since_block % 60 + + if seconds_since_block < 600: + color = "\033[1;32;40m" # green + elif seconds_since_block < 1200: + color = "\033[1;33;40m" # yellow + else: + color = "\033[1;31;40m" # red + + text = f"{color} \u23f1 {mins}m {secs:02d}s since last block\033[0;37;40m" + pad = max(0, (term_width - 35) // 2) + return ' ' * pad + text + + +def render_epoch_bar(block_height, epoch_block, blocks_to_halving, + next_halving_block, term_width): + """Render difficulty epoch progress bar + halving info.""" + # Difficulty adjustment progress + epoch_pct = (epoch_block / 2016) * 100 + bar_width = min(30, term_width - 40) + filled = int(bar_width * epoch_block / 2016) + empty = bar_width - filled + blocks_left = 2016 - epoch_block + + bar = f"\033[1;36;40m\u2593" * filled + f"\033[0;37;40m\u2591" * empty + epoch_line = ( + f" \033[1;36;40mEpoch\033[0;37;40m [{bar}\033[0;37;40m] " + f"{epoch_block}/2016 ({epoch_pct:.1f}%) " + f"· {blocks_left} blocks to retarget" + ) + + # Halving progress + if next_halving_block > 0: + halving_num = next_halving_block // 210_000 + halving_line = ( + f" \033[1;35;40mHalving #{halving_num}\033[0;37;40m " + f"in {blocks_to_halving:,} blocks " + f"(block {next_halving_block:,})" + ) + return epoch_line + "\n" + halving_line + + return epoch_line + + +def render_fees(fastest, half_hour, hour, term_width): + """Render compact fee rate display.""" + if fastest == 0 and half_hour == 0 and hour == 0: + return "" + + text = ( + f" \033[1;31;40m\u26a1 {fastest}\033[0;37;40m | " + f"\033[1;33;40m\u23f3 {half_hour}\033[0;37;40m | " + f"\033[1;32;40m\u2623 {hour}\033[0;37;40m sat/vB" + ) + pad = max(0, (term_width - 40) // 2) + return ' ' * pad + text + + +def render_utc_time(colors): + """Render current UTC time in tiny cfonts font.""" + now = datetime.now(timezone.utc).strftime("%H:%M") + output = render(now, colors=colors, align='center', font='tiny') + return output diff --git a/pybitblock/config.py b/pybitblock/config.py index 7491b89..90c182f 100644 --- a/pybitblock/config.py +++ b/pybitblock/config.py @@ -18,7 +18,17 @@ import os _DEFAULT_PATH = {"ip_port": "", "rpcuser": "", "rpcpass": "", "bitcoincli": ""} _DEFAULT_LND = {"ip_port": "", "tls": "", "macaroon": "", "ln": ""} _DEFAULT_SETTINGS = {"gradient": "", "design": "block", "colorA": "green", "colorB": "yellow"} -_DEFAULT_SETTINGS_CLOCK = {"gradient": "", "colorA": "green", "colorB": "yellow"} +_DEFAULT_SETTINGS_CLOCK = { + "gradient": "", "colorA": "green", "colorB": "yellow", + "show_countdown": True, "show_epoch_bar": True, + "show_fee_rates": True, "show_sparkline": False, + "show_utc_time": False, "zen_mode": False, + "animation": "matrix", + "fireworks": True, + "generative_art": False, + "sound": "bell", + "heartbeat": True, +} def _env_bitcoin_config():