Add enhanced block clock with 12 new features for Menu A

New clock/ package replaces the old design() polling loop with a
flicker-free ANSI cursor-positioned renderer. Features include:
countdown timer, epoch/halving progress bar, fee rate indicator,
hashrate sparkline, matrix mining animation, odometer digit transition,
heartbeat pulse, zen mode, UTC time display, fireworks on milestone
blocks, generative hash art, and configurable sound modes.

All features are toggleable via Settings → D (Clock Display Settings).

Also fixes hardcoded config paths in menuSelection(), menuSelectionLN(),
and TUI startup to use the cfg singleton instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
GaltRanch 2026-04-02 08:35:59 -03:00
parent 1fa1846790
commit 8e2737251d
10 changed files with 985 additions and 93 deletions

View file

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

View file

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

View file

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

200
pybitblock/clock/data.py Normal file
View file

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

View file

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

View file

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

23
pybitblock/clock/sound.py Normal file
View file

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

View file

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

View file

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

View file

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