From 91570d6721bb1fe024ee712a585ba90959d3f6c6 Mon Sep 17 00:00:00 2001
From: devAsmodeus
Date: Sat, 2 May 2026 12:33:43 +0300
Subject: [PATCH 1/9] docs(spec): three-PR roadmap push spec + handoff scaffold
---
docs/handoff/README.md | 9 +++++++++
1 file changed, 9 insertions(+)
create mode 100644 docs/handoff/README.md
diff --git a/docs/handoff/README.md b/docs/handoff/README.md
new file mode 100644
index 0000000..ff0b8c0
--- /dev/null
+++ b/docs/handoff/README.md
@@ -0,0 +1,9 @@
+# Subagent handoff docs
+
+Each PR in the 2026-05-02 three-PR push writes a `pr-{a,b,c}-summary.md` here:
+- file map of new/changed files
+- new CLI flags and env vars
+- gotchas the next agent must know
+- open questions
+
+Final consolidated review lands in `final-review.md`.
From 4dde245c4a50c24fbfdc482ece7b984c5ca9fd3a Mon Sep 17 00:00:00 2001
From: devAsmodeus
Date: Sat, 2 May 2026 12:34:51 +0300
Subject: [PATCH 2/9] docs(spec): add three-PR roadmap design doc
---
.../specs/2026-05-02-three-pr-roadmap.md | 153 ++++++++++++++++++
1 file changed, 153 insertions(+)
create mode 100644 docs/superpowers/specs/2026-05-02-three-pr-roadmap.md
diff --git a/docs/superpowers/specs/2026-05-02-three-pr-roadmap.md b/docs/superpowers/specs/2026-05-02-three-pr-roadmap.md
new file mode 100644
index 0000000..0a5dae9
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-02-three-pr-roadmap.md
@@ -0,0 +1,153 @@
+# 2026-05-02 — Three-PR ROADMAP push: ops, perf, web+docs
+
+## Context
+
+Hope-Hash is at v0.4.0 (benchmark mode). ROADMAP Levels 1.5/2/3 mostly open. Goal:
+ship the realistic, **stdlib-only** slice of Levels 1–2 plus web + final docs in three
+sequential, stacked PRs, executed by subagents, then reviewed by a separate panel.
+
+Hard constraint from `CLAUDE.md`: **no new runtime dependencies**. Anything that
+requires a third-party package (`rich`, `FastAPI`, `cffi`, `cupy`, `PyO3`) is out
+of scope. Where ROADMAP asked for `rich` we use `curses`; where it asked for
+FastAPI we use stdlib `http.server`; where it asked for `cffi` we use `ctypes`.
+
+## Scope: three stacked PRs
+
+### PR A — `feat/ops-and-ux` (off `main`)
+
+Closes Level 1 remainder + tail audit items.
+
+**Features**
+- `hope-hash --tui` — curses dashboard (hashrate EMA, uptime, shares accepted/rejected,
+ current job_id, pool diff, workers). Updates in place. Quit on `q` or Ctrl+C.
+- ASCII-art logo printed once at startup (gated by `--no-banner` for log-only mode).
+- Telegram inbound commands: `/stats` (returns last EMA + uptime + counters),
+ `/stop` (sets stop_event), `/restart` (re-runs supervisor). Long-poll via
+ `urllib`, single background thread, idempotent disable when env vars absent.
+- Healthcheck endpoint `/healthz` on the metrics HTTP server. Returns 200 with
+ JSON `{status, uptime_s, last_share_ts}` when stratum reader is alive AND
+ last hashrate sample is non-zero, 503 otherwise.
+- `deploy/grafana/hope-hash.json` — importable dashboard with hashrate/diff/shares
+ panels referencing the existing Prometheus metrics.
+
+**Quality**
+- Test for `notify_share_accepted` timing (must be triggered from pool-confirmed
+ callback path, not from submit path).
+- Type annotations completion in `miner.py` and `stratum.py` (return types and
+ parameter types where missing).
+
+**Docs**
+- README gets a one-paragraph "What's new in v0.5.0".
+- CHANGELOG entry for v0.5.0.
+- `docs/handoff/pr-a-summary.md` — what shipped, file map, follow-on hooks.
+
+**Tests must pass**: `py -3.11 -m unittest discover -s tests -v`.
+
+### PR B — `feat/perf-and-resilience` (off PR A's branch)
+
+Stdlib-friendly slice of Level 2.
+
+**Features**
+- Multi-pool failover. CLI: `--pool host:port` repeatable; if first pool fails to
+ connect or stays disconnected for >30s, supervisor switches to next. Round-robin
+ on cycle through all. New module `src/hope_hash/pools.py` (`PoolList`, rotation logic).
+- Solo mode via `getblocktemplate`. `hope-hash --solo --rpc-url http://... --rpc-cookie path`
+ fetches block template from a local bitcoind, builds the header, mines, on hit
+ calls `submitblock`. JSON-RPC over `urllib`. New module `src/hope_hash/solo.py`.
+ Authoritative reference: BIP-22/BIP-23. Coinbase build is the trickiest part:
+ needs witness commitment (segwit) for any non-trivial template.
+- Optional ctypes SHA-256 path. `src/hope_hash/sha_native.py` tries to load
+ `libcrypto`/`libssl` via `ctypes.CDLL` (Win: `libcrypto-3.dll` / `libcrypto-1_1.dll`;
+ Linux: `libcrypto.so.3`/`.1.1`; macOS: `/usr/lib/libcrypto.dylib`). Exposes
+ `sha256_double(data: bytes) -> bytes`. Falls back to `hashlib` if load fails.
+ CLI `--sha-backend {auto,hashlib,ctypes}` (auto = ctypes if available).
+- Bench gets `--backends` flag — runs each available backend back-to-back for
+ comparison.
+
+**Quality**
+- Tests for `pools.PoolList` rotation (skip-failed, wrap-around, single-pool no-op).
+- Tests for `sha_native` (correctness vs hashlib on known vectors; fallback path).
+- Tests for `solo.build_coinbase` (witness commitment, BIP141 path).
+- Solo mode integration test uses a `FakeRPC` (no real bitcoind needed).
+
+**Docs**
+- CHANGELOG entry for v0.6.0.
+- `docs/handoff/pr-b-summary.md`.
+
+### PR C — `feat/web-and-docs` (off PR B's branch)
+
+Web dashboard + Docker + full bilingual docs.
+
+**Features**
+- Web dashboard on stdlib `http.server` (extends `metrics.MetricsServer` or
+ separate port via `--web-port`):
+ - `GET /` — single-page HTML dashboard, vanilla JS, polls `/api/stats` every 2s.
+ - `GET /api/stats` — JSON snapshot (hashrate, diff, workers, uptime, shares,
+ current job_id, last share ts).
+ - `GET /api/events` — Server-Sent Events stream; emits a line per share
+ found/accepted/rejected and per job change.
+- Docker:
+ - `Dockerfile` (python:3.11-slim base, multi-stage if helpful).
+ - `docker-compose.yml` with the miner + Prometheus + Grafana, volumes for
+ SQLite and the dashboard JSON.
+ - `.dockerignore`.
+
+**Docs (the big one)**
+- `README.md` rewritten: top half English, bottom half Russian. Both halves cover:
+ what / install / run / advanced flags / demo / benchmark / architecture /
+ realistic-expectations / contributing pointer.
+- `docs/getting-started.en.md` and `docs/getting-started.ru.md` — step-by-step
+ for a fresh user (Python install → BTC address → first run → reading logs).
+- `docs/deploy.en.md` and `docs/deploy.ru.md` — Docker + Prometheus + Grafana +
+ Telegram + healthcheck setup.
+- `docs/architecture.en.md` and `docs/architecture.ru.md` — protocol, threading
+ model, file map, hot path, observers, performance notes.
+- CHANGELOG entry for v0.7.0.
+
+## Subagent communication protocol
+
+Each subagent:
+1. Reads `docs/handoff/pr-{prev}-summary.md` (if present) to learn the deltas.
+2. Branches off the previous PR's branch (or `main` for PR A).
+3. Implements its scope. **Pure stdlib.** No `pip install`.
+4. Runs full test suite; must pass.
+5. Commits with conventional-commit messages, pushes, opens a draft PR via `gh`.
+6. Writes `docs/handoff/pr-{this}-summary.md` with: file map, new flags, gotchas,
+ open questions for next agent.
+7. Returns a structured summary to the orchestrator (test count delta, files
+ added/modified, PR URL).
+
+## Final review panel (parallel)
+
+After PR C is opened, four review subagents run **in parallel** against the full
+diff (`origin/main..feat/web-and-docs`):
+
+1. **code-reviewer** (`superpowers:code-reviewer`) — architecture, naming,
+ adherence to `CLAUDE.md` invariants (endianness, hot path, error handling).
+2. **security** — input validation surface (web dashboard, RPC cookie handling,
+ Telegram command authz, ctypes loader path injection).
+3. **docs/UX** — README EN/RU parity, getting-started accuracy on a fresh box,
+ doc cross-links, dead links, copy quality.
+4. **test-coverage** — coverage of new modules, missing edge cases, flaky tests,
+ Windows/Linux/macOS specifics.
+
+Each returns a markdown report. Orchestrator consolidates into
+`docs/handoff/final-review.md` and surfaces top issues to the user.
+
+## Out of scope
+
+- Rust / PyO3 — needs toolchain.
+- GPU (PyOpenCL/cupy) — needs deps.
+- Stratum V2 — Noise protocol crypto in stdlib is a non-trivial sub-project.
+- TUI on `rich` — not stdlib.
+- FastAPI — not stdlib.
+- k8s/Helm — over-engineering for this project size.
+- Lottery visualization, shitcoin mining, pyinstaller bundling — fun ideas, not
+ worth this push.
+
+## Success criteria
+
+- Three PRs open against `main`, stacked in order, each with green tests.
+- README in EN+RU with copy-pasteable run instructions.
+- Final review report identifies any blockers before merge.
+- User can return, read the consolidated review, merge in order or request changes.
From 53a6f292d47da230e4024385f616453a7c581a44 Mon Sep 17 00:00:00 2001
From: devAsmodeus
Date: Sat, 2 May 2026 12:48:31 +0300
Subject: [PATCH 3/9] feat(banner): ASCII-art startup banner gated by
--no-banner
Adds src/hope_hash/banner.py (render_banner / print_banner) and 6
unit tests. ASCII-only on purpose so the banner survives consoles
without UTF-8 (legacy Win, syslog with C locale).
Co-Authored-By: Claude Opus 4.7 (1M context)
---
src/hope_hash/banner.py | 43 ++++++++++++++++++++++++++++++++++++
tests/test_banner.py | 49 +++++++++++++++++++++++++++++++++++++++++
2 files changed, 92 insertions(+)
create mode 100644 src/hope_hash/banner.py
create mode 100644 tests/test_banner.py
diff --git a/src/hope_hash/banner.py b/src/hope_hash/banner.py
new file mode 100644
index 0000000..a0d7822
--- /dev/null
+++ b/src/hope_hash/banner.py
@@ -0,0 +1,43 @@
+"""ASCII-art баннер для шапки CLI.
+
+Выводится один раз при старте через ``print_banner()``. Гасится флагом
+``--no-banner`` для log-only режима (cron/systemd/контейнер с
+агрегатором логов).
+
+Дизайн: компактный (≤10 строк), ASCII-only — не ломает терминалы без
+UTF-8/эмоджи. Печатается через ``sys.stdout`` напрямую, а не через
+logger: баннер — это UX, а не лог-событие.
+"""
+
+from __future__ import annotations
+
+import sys
+from typing import TextIO
+
+from . import __version__
+
+
+# 5-строчный логотип "HOPE HASH" собранный вручную через | / _ / -.
+# Не используем pyfiglet/figlet, чтобы не тащить зависимость.
+_BANNER_LINES: tuple[str, ...] = (
+ r" _ _ ___ ____ _____ _ _ _ ____ _ _ ",
+ r"| | | |/ _ \| _ \| ____| | | | | / \ / ___|| | | |",
+ r"| |_| | | | | |_) | _| | |_| | / _ \ \___ \| |_| |",
+ r"| _ | |_| | __/| |___ | _ |/ ___ \ ___) | _ |",
+ r"|_| |_|\___/|_| |_____| |_| |_/_/ \_\____/|_| |_|",
+)
+
+
+def render_banner(version: str = __version__) -> str:
+ """Возвращает баннер строкой. Удобно для тестов и для записи в файл-лог."""
+ # ASCII-only: dot-separator вместо middle-dot — иначе ломается на консолях
+ # без UTF-8 (старые Win-консоли, syslog-агрегаторы с локалью C).
+ subtitle = f" solo BTC miner on pure stdlib -- v{version}"
+ return "\n".join(_BANNER_LINES) + "\n" + subtitle + "\n"
+
+
+def print_banner(stream: TextIO | None = None) -> None:
+ """Печатает баннер в stdout (или в указанный stream)."""
+ out = stream if stream is not None else sys.stdout
+ out.write(render_banner())
+ out.flush()
diff --git a/tests/test_banner.py b/tests/test_banner.py
new file mode 100644
index 0000000..bfc073e
--- /dev/null
+++ b/tests/test_banner.py
@@ -0,0 +1,49 @@
+"""Smoke-тесты для banner.py.
+
+Проверяем что баннер не пустой, многострочный и содержит версию.
+Это компромисс между «вообще не тестируем строку ASCII-графики» и
+«фиксируем каждый пиксель» — последнее ломает любую корректировку.
+"""
+
+import io
+import unittest
+
+from hope_hash import __version__
+from hope_hash.banner import print_banner, render_banner
+
+
+class TestBanner(unittest.TestCase):
+ def test_render_returns_non_empty_string(self) -> None:
+ text = render_banner()
+ self.assertIsInstance(text, str)
+ self.assertGreater(len(text), 0)
+
+ def test_render_is_multiline(self) -> None:
+ # Лого + подпись = минимум 6 строк
+ text = render_banner()
+ self.assertGreaterEqual(text.count("\n"), 5)
+
+ def test_render_contains_version(self) -> None:
+ # В подписи должна быть текущая версия — иначе обновление забудет.
+ self.assertIn(__version__, render_banner())
+
+ def test_render_with_explicit_version(self) -> None:
+ text = render_banner(version="9.9.9-test")
+ self.assertIn("9.9.9-test", text)
+
+ def test_print_banner_writes_to_stream(self) -> None:
+ buf = io.StringIO()
+ print_banner(stream=buf)
+ out = buf.getvalue()
+ self.assertGreater(len(out), 0)
+ self.assertIn(__version__, out)
+
+ def test_render_is_ascii(self) -> None:
+ # Намеренно ASCII-only: баннер должен корректно показываться
+ # в терминалах без UTF-8 (старые Windows консоли, цпус под docker).
+ text = render_banner()
+ text.encode("ascii") # AssertionError если есть не-ASCII символы
+
+
+if __name__ == "__main__":
+ unittest.main()
From a3b1b7161d6bb4417dbb45dbc264dd656f4f3690 Mon Sep 17 00:00:00 2001
From: devAsmodeus
Date: Sat, 2 May 2026 12:48:39 +0300
Subject: [PATCH 4/9] feat(tui): curses dashboard + StatsProvider data bus
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Introduces a thread-safe StatsProvider that miner pushes to and TUI/
healthz/web consume from — single source of truth for all observers.
TUIApp wraps curses in a daemon thread, redraws every 1s, quits on
q/ESC. On Windows without windows-curses we degrade gracefully (warn,
keep mining).
13 tests cover provider thread safety, snapshot immutability, and
format helpers. curses-loop itself is not unit-tested (TTY-only).
Co-Authored-By: Claude Opus 4.7 (1M context)
---
src/hope_hash/tui.py | 288 +++++++++++++++++++++++++++++++++++++++++++
tests/test_tui.py | 152 +++++++++++++++++++++++
2 files changed, 440 insertions(+)
create mode 100644 src/hope_hash/tui.py
create mode 100644 tests/test_tui.py
diff --git a/src/hope_hash/tui.py b/src/hope_hash/tui.py
new file mode 100644
index 0000000..75b5c4a
--- /dev/null
+++ b/src/hope_hash/tui.py
@@ -0,0 +1,288 @@
+"""Curses-дашборд для майнера.
+
+Использует stdlib ``curses``. На Windows curses не входит в стандартную
+поставку CPython — нужен пакет ``windows-curses``. Мы НЕ добавляем его
+в зависимости (CLAUDE.md: pure stdlib), а graceful-fail с понятным
+сообщением, если импорт не удался.
+
+Архитектура:
+
+- ``StatsProvider`` — лёгкая шина данных. Майнер пушит сюда снапшоты,
+ TUI и /healthz/web-дашборд читают. Один источник правды для всех
+ внешних потребителей.
+- ``TUIApp`` — curses-цикл в фоновой нити, перерисовывает экран каждый
+ ``refresh_interval`` секунд. Quit на ``q`` или ``Ctrl+C``.
+
+Связка с ``mine()``: при старте CLI делает ``provider = StatsProvider()``,
+отдаёт его в ``mine()`` (через новый kwarg) и одновременно стартует
+``TUIApp(provider).start()``. mine() в hot-path вызывает
+``provider.update(...)`` — это thread-safe и дешёвое присваивание.
+"""
+
+from __future__ import annotations
+
+import threading
+import time
+from dataclasses import dataclass, field
+from typing import Optional
+
+
+# ─────────────────── Stats provider (без curses) ───────────────────
+
+
+@dataclass
+class StatsSnapshot:
+ """Иммутабельный снапшот статистики майнера для одного кадра дашборда."""
+
+ started_at: float = field(default_factory=time.time)
+ hashrate_ema: float = 0.0
+ hashrate_last: float = 0.0
+ workers: int = 0
+ pool_difficulty: float = 0.0
+ current_job_id: Optional[str] = None
+ shares_total: int = 0
+ shares_accepted: int = 0
+ shares_rejected: int = 0
+ last_share_ts: Optional[float] = None
+ pool_url: str = ""
+
+ @property
+ def uptime_s(self) -> float:
+ return max(0.0, time.time() - self.started_at)
+
+
+class StatsProvider:
+ """Потокобезопасный снимок состояния майнера.
+
+ Майнер пушит обновления через ``update_*`` методы; потребители
+ (TUI, /healthz, /api/stats в PR C) читают через ``snapshot()``.
+
+ Намеренно plain-data, никакой бизнес-логики — она в ``mine()``.
+ """
+
+ def __init__(self, pool_url: str = "") -> None:
+ self._lock = threading.Lock()
+ self._snap = StatsSnapshot(pool_url=pool_url)
+
+ def snapshot(self) -> StatsSnapshot:
+ """Атомарный снимок текущего состояния."""
+ with self._lock:
+ # dataclasses.replace копирует — потребитель не увидит мутаций.
+ return StatsSnapshot(
+ started_at=self._snap.started_at,
+ hashrate_ema=self._snap.hashrate_ema,
+ hashrate_last=self._snap.hashrate_last,
+ workers=self._snap.workers,
+ pool_difficulty=self._snap.pool_difficulty,
+ current_job_id=self._snap.current_job_id,
+ shares_total=self._snap.shares_total,
+ shares_accepted=self._snap.shares_accepted,
+ shares_rejected=self._snap.shares_rejected,
+ last_share_ts=self._snap.last_share_ts,
+ pool_url=self._snap.pool_url,
+ )
+
+ def update_hashrate(self, ema: float, last_sample: float, workers: int) -> None:
+ with self._lock:
+ self._snap.hashrate_ema = float(ema)
+ self._snap.hashrate_last = float(last_sample)
+ self._snap.workers = int(workers)
+
+ def update_job(self, job_id: Optional[str], pool_difficulty: float) -> None:
+ with self._lock:
+ self._snap.current_job_id = job_id
+ self._snap.pool_difficulty = float(pool_difficulty)
+
+ def record_share(self, accepted: Optional[bool] = None) -> None:
+ """Учёт шар. accepted=None → submitted (ещё ждём ответа пула).
+ accepted=True/False → ответ пришёл."""
+ with self._lock:
+ if accepted is None:
+ self._snap.shares_total += 1
+ self._snap.last_share_ts = time.time()
+ elif accepted:
+ self._snap.shares_accepted += 1
+ else:
+ self._snap.shares_rejected += 1
+
+
+def format_rate(rate: float) -> str:
+ """Человекочитаемый хешрейт. Копия из miner._format_rate (без импорта)."""
+ if rate < 1000:
+ return f"{rate:.0f} H/s"
+ if rate < 1_000_000:
+ return f"{rate / 1000:.2f} KH/s"
+ return f"{rate / 1_000_000:.2f} MH/s"
+
+
+def format_uptime(s: float) -> str:
+ """Аптайм в формате HH:MM:SS (с днями при >24ч)."""
+ s_int = int(s)
+ days, rem = divmod(s_int, 86400)
+ hours, rem = divmod(rem, 3600)
+ minutes, secs = divmod(rem, 60)
+ if days:
+ return f"{days}d {hours:02d}:{minutes:02d}:{secs:02d}"
+ return f"{hours:02d}:{minutes:02d}:{secs:02d}"
+
+
+# ─────────────────── Curses-приложение ───────────────────
+
+
+def is_curses_available() -> bool:
+ """True если ``curses`` импортируется в текущем окружении."""
+ try:
+ import curses # noqa: F401
+ return True
+ except ImportError:
+ return False
+
+
+class TUIApp:
+ """Фоновый curses-дашборд. Старт/стоп идемпотентны.
+
+ Если curses недоступен (Windows без windows-curses), ``start()``
+ логирует warning и возвращается без поднятия нити — майнер
+ продолжает работать. Это сознательный degrade, не падение.
+ """
+
+ def __init__(
+ self,
+ provider: StatsProvider,
+ stop_event: threading.Event,
+ refresh_interval: float = 1.0,
+ ) -> None:
+ self.provider = provider
+ self.stop_event = stop_event
+ self.refresh_interval = refresh_interval
+ self._thread: threading.Thread | None = None
+ self._lifecycle_lock = threading.Lock()
+
+ def start(self) -> bool:
+ """Запускает curses в фоне. Возвращает True если поднялся."""
+ # Импортируем здесь, чтобы модуль загружался даже без curses.
+ try:
+ import curses # noqa: F401
+ except ImportError:
+ import logging
+ logging.getLogger("hope_hash").warning(
+ "[tui] curses недоступен (на Windows нужен пакет windows-curses); "
+ "TUI выключен, майнер работает без дашборда"
+ )
+ return False
+
+ with self._lifecycle_lock:
+ if self._thread is not None:
+ return True
+ self._thread = threading.Thread(
+ target=self._run,
+ name="hope_hash-tui",
+ daemon=True,
+ )
+ self._thread.start()
+ return True
+
+ def stop(self, timeout: float = 2.0) -> None:
+ """Просит TUI остановиться. Идемпотентно."""
+ with self._lifecycle_lock:
+ t = self._thread
+ self._thread = None
+ # stop_event общий с майнером — устанавливать его TUI не должен,
+ # просто ждём пока сам выйдет (он смотрит на stop_event каждый кадр).
+ if t is not None and t.is_alive():
+ t.join(timeout=timeout)
+
+ # ─── внутренние методы ───
+
+ def _run(self) -> None:
+ import curses
+
+ try:
+ curses.wrapper(self._loop)
+ except Exception as exc:
+ # Не валим майнер из-за сбоя в дашборде — терминал странный,
+ # маленький экран, что угодно. Лучше degrade в логи.
+ import logging
+ logging.getLogger("hope_hash").warning(
+ "[tui] curses-цикл упал: %s — TUI отключён", exc
+ )
+
+ def _loop(self, stdscr) -> None: # noqa: ANN001 — curses screen object
+ import curses
+
+ curses.curs_set(0)
+ stdscr.nodelay(True) # getch не блокирует — без него Ctrl+C ловится плохо
+ stdscr.timeout(int(self.refresh_interval * 1000))
+
+ while not self.stop_event.is_set():
+ try:
+ self._draw(stdscr)
+ except curses.error:
+ # Резайз окна, обрезание строки за пределы экрана —
+ # не повод падать, перерисуем на следующем кадре.
+ pass
+
+ ch = stdscr.getch()
+ if ch in (ord("q"), ord("Q"), 27): # 27 == ESC
+ self.stop_event.set()
+ break
+
+ def _draw(self, stdscr) -> None: # noqa: ANN001
+ import curses
+
+ snap = self.provider.snapshot()
+ stdscr.erase()
+ h, w = stdscr.getmaxyx()
+
+ title = " Hope-Hash · solo BTC miner "
+ try:
+ stdscr.addstr(0, 0, title.center(w - 1, "─"), curses.A_BOLD)
+ except curses.error:
+ pass
+
+ rows: list[tuple[str, str]] = [
+ ("Pool", snap.pool_url or "(disconnected)"),
+ ("Uptime", format_uptime(snap.uptime_s)),
+ ("Hashrate (EMA)", format_rate(snap.hashrate_ema)),
+ ("Hashrate (last)", format_rate(snap.hashrate_last)),
+ ("Workers", str(snap.workers)),
+ ("Pool difficulty", f"{snap.pool_difficulty}"),
+ ("Job ID", _truncate(snap.current_job_id or "—", 24)),
+ ("Shares submitted", str(snap.shares_total)),
+ ("Shares accepted", str(snap.shares_accepted)),
+ ("Shares rejected", str(snap.shares_rejected)),
+ ("Last share", _format_ago(snap.last_share_ts)),
+ ]
+
+ for i, (label, value) in enumerate(rows, start=2):
+ if i >= h - 2:
+ break
+ line = f" {label:<18} {value}"
+ try:
+ stdscr.addstr(i, 0, line[: w - 1])
+ except curses.error:
+ pass
+
+ footer = " press q or ESC to quit · refresh 1s "
+ if h > 2:
+ try:
+ stdscr.addstr(h - 1, 0, footer.ljust(w - 1, " "), curses.A_DIM)
+ except curses.error:
+ pass
+
+ stdscr.refresh()
+
+
+def _truncate(s: str, n: int) -> str:
+ return s if len(s) <= n else s[: n - 1] + "…"
+
+
+def _format_ago(ts: Optional[float]) -> str:
+ if ts is None:
+ return "never"
+ delta = max(0.0, time.time() - ts)
+ if delta < 60:
+ return f"{delta:.0f}s ago"
+ if delta < 3600:
+ return f"{delta / 60:.1f}m ago"
+ return f"{delta / 3600:.1f}h ago"
diff --git a/tests/test_tui.py b/tests/test_tui.py
new file mode 100644
index 0000000..2a7ff6e
--- /dev/null
+++ b/tests/test_tui.py
@@ -0,0 +1,152 @@
+"""Тесты для StatsProvider — без реального curses.
+
+curses-цикл интегрировать в unittest на CI бессмысленно: на Linux
+работает только в TTY, на Windows вообще отсутствует. Поэтому тестируем
+только pure-Python кусок: thread-safe StatsProvider + хелперы форматирования.
+"""
+
+import threading
+import time
+import unittest
+
+from hope_hash.tui import (
+ StatsProvider,
+ StatsSnapshot,
+ format_rate,
+ format_uptime,
+ is_curses_available,
+)
+
+
+class TestStatsProvider(unittest.TestCase):
+ def test_default_snapshot_is_zeros(self) -> None:
+ p = StatsProvider(pool_url="example.com:1234")
+ snap = p.snapshot()
+ self.assertIsInstance(snap, StatsSnapshot)
+ self.assertEqual(snap.pool_url, "example.com:1234")
+ self.assertEqual(snap.shares_total, 0)
+ self.assertEqual(snap.shares_accepted, 0)
+ self.assertEqual(snap.shares_rejected, 0)
+ self.assertEqual(snap.hashrate_ema, 0.0)
+ self.assertIsNone(snap.current_job_id)
+ self.assertIsNone(snap.last_share_ts)
+
+ def test_update_hashrate(self) -> None:
+ p = StatsProvider()
+ p.update_hashrate(ema=12345.6, last_sample=11000.0, workers=8)
+ snap = p.snapshot()
+ self.assertAlmostEqual(snap.hashrate_ema, 12345.6)
+ self.assertAlmostEqual(snap.hashrate_last, 11000.0)
+ self.assertEqual(snap.workers, 8)
+
+ def test_update_job(self) -> None:
+ p = StatsProvider()
+ p.update_job(job_id="abc123", pool_difficulty=2.5)
+ snap = p.snapshot()
+ self.assertEqual(snap.current_job_id, "abc123")
+ self.assertAlmostEqual(snap.pool_difficulty, 2.5)
+
+ def test_record_share_submitted(self) -> None:
+ p = StatsProvider()
+ before = time.time()
+ p.record_share(accepted=None)
+ snap = p.snapshot()
+ self.assertEqual(snap.shares_total, 1)
+ self.assertEqual(snap.shares_accepted, 0)
+ self.assertEqual(snap.shares_rejected, 0)
+ self.assertIsNotNone(snap.last_share_ts)
+ self.assertGreaterEqual(snap.last_share_ts, before)
+
+ def test_record_share_accepted_does_not_double_count(self) -> None:
+ # accepted=True должен увеличить только accepted, не total —
+ # total отражает «отправлено», accepted/rejected — «подтверждено».
+ p = StatsProvider()
+ p.record_share(accepted=None) # отправили
+ p.record_share(accepted=True) # пул подтвердил
+ snap = p.snapshot()
+ self.assertEqual(snap.shares_total, 1)
+ self.assertEqual(snap.shares_accepted, 1)
+ self.assertEqual(snap.shares_rejected, 0)
+
+ def test_record_share_rejected(self) -> None:
+ p = StatsProvider()
+ p.record_share(accepted=None)
+ p.record_share(accepted=False)
+ snap = p.snapshot()
+ self.assertEqual(snap.shares_total, 1)
+ self.assertEqual(snap.shares_accepted, 0)
+ self.assertEqual(snap.shares_rejected, 1)
+
+ def test_snapshot_is_a_copy(self) -> None:
+ # Мутация снапшота не должна влиять на провайдера —
+ # иначе race условия между TUI и mine() сломают учёт.
+ p = StatsProvider()
+ p.update_hashrate(100.0, 90.0, 4)
+ snap1 = p.snapshot()
+ snap1.hashrate_ema = 999999.0
+ snap2 = p.snapshot()
+ self.assertAlmostEqual(snap2.hashrate_ema, 100.0)
+
+ def test_thread_safety_smoke(self) -> None:
+ # Несколько писателей и читателей — никаких эксепшнов.
+ p = StatsProvider()
+ stop = threading.Event()
+
+ def writer() -> None:
+ for i in range(200):
+ p.update_hashrate(float(i), float(i - 1), 4)
+ p.record_share(accepted=None)
+ if stop.is_set():
+ return
+
+ def reader() -> None:
+ for _ in range(500):
+ _ = p.snapshot()
+ if stop.is_set():
+ return
+
+ ts = [threading.Thread(target=writer) for _ in range(3)]
+ ts += [threading.Thread(target=reader) for _ in range(3)]
+ for t in ts:
+ t.start()
+ for t in ts:
+ t.join(timeout=10)
+ stop.set()
+
+ snap = p.snapshot()
+ # 3 writers × 200 итераций = 600 шар.
+ self.assertEqual(snap.shares_total, 600)
+
+
+class TestFormatHelpers(unittest.TestCase):
+ def test_format_rate_hps(self) -> None:
+ self.assertEqual(format_rate(0), "0 H/s")
+ self.assertEqual(format_rate(999), "999 H/s")
+
+ def test_format_rate_khps(self) -> None:
+ self.assertIn("KH/s", format_rate(1500))
+ self.assertIn("1.50", format_rate(1500))
+
+ def test_format_rate_mhps(self) -> None:
+ self.assertIn("MH/s", format_rate(2_500_000))
+ self.assertIn("2.50", format_rate(2_500_000))
+
+ def test_format_uptime_short(self) -> None:
+ self.assertEqual(format_uptime(0), "00:00:00")
+ self.assertEqual(format_uptime(65), "00:01:05")
+ self.assertEqual(format_uptime(3600), "01:00:00")
+
+ def test_format_uptime_with_days(self) -> None:
+ # 1 день и 2 часа.
+ self.assertIn("1d", format_uptime(86400 + 7200))
+
+
+class TestCursesAvailability(unittest.TestCase):
+ def test_is_curses_available_returns_bool(self) -> None:
+ # Не утверждаем True/False — на Windows без windows-curses будет False,
+ # на Linux/macOS будет True. Проверяем только что функция работает.
+ self.assertIsInstance(is_curses_available(), bool)
+
+
+if __name__ == "__main__":
+ unittest.main()
From 39a606a37c35f4f4b1973efe5032e2a8457fca00 Mon Sep 17 00:00:00 2001
From: devAsmodeus
Date: Sat, 2 May 2026 12:48:46 +0300
Subject: [PATCH 5/9] feat(health): /healthz endpoint + build_health_snapshot
Extends MetricsServer with a /healthz route returning JSON. Status
is computed by a pluggable callable registered via
set_health_provider(). Pure build_health_snapshot() encodes the
state machine: ok/degraded (200) vs down (503), with a 30s
hashrate-freshness window and configurable share-staleness.
11 tests cover both the snapshot matrix and the live HTTP layer.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
src/hope_hash/metrics.py | 156 +++++++++++++++++++++++++++++++---
tests/test_healthz.py | 176 +++++++++++++++++++++++++++++++++++++++
2 files changed, 322 insertions(+), 10 deletions(-)
create mode 100644 tests/test_healthz.py
diff --git a/src/hope_hash/metrics.py b/src/hope_hash/metrics.py
index 4dec04e..7e32e43 100644
--- a/src/hope_hash/metrics.py
+++ b/src/hope_hash/metrics.py
@@ -1,17 +1,36 @@
"""Prometheus-совместимые метрики через stdlib http.server. Без зависимостей.
Два класса: ``Metrics`` — потокобезопасный регистр counter/gauge,
-``MetricsServer`` — HTTP-сервер ``/metrics`` на фоновой нити. Логгер
-берём по имени пакета, чтобы не плодить циклических импортов.
+``MetricsServer`` — HTTP-сервер ``/metrics`` + ``/healthz`` на фоновой
+нити. Логгер берём по имени пакета, чтобы не плодить циклических импортов.
+
+``/healthz`` отдаёт JSON ``{status, uptime_s, last_share_ts, ...}``. Источник
+правды — callable, который владелец сервера ставит через
+``set_health_provider()``. Семантика статусов:
+
+- ``ok`` (HTTP 200) — всё штатно: reader_loop жив, EMA>0 за последние 30с,
+ последний шар не древнее ``stale_after_s`` секунд.
+- ``degraded`` (HTTP 200) — что-то одно подвыпало (нет шар давно, EMA=0).
+ Liveness-зонд считает узел живым.
+- ``down`` (HTTP 503) — reader не жив или провайдер не зарегистрирован —
+ readiness-зонд должен вырубить трафик.
"""
+from __future__ import annotations
+
+import json
import logging
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from typing import Callable, Optional
logger = logging.getLogger("hope_hash")
+# Тип health-провайдера: callable без аргументов, возвращает dict-снапшот.
+# Возвращаемое поле ``status`` обязательно (один из "ok"/"degraded"/"down").
+HealthProvider = Callable[[], dict]
+
# Допустимые символы для имени метрики по Prometheus naming convention:
# первая буква — [a-zA-Z_:], остальные — [a-zA-Z0-9_:]. Всё прочее
@@ -112,17 +131,57 @@ def render(self) -> bytes:
return ("\n".join(lines) + "\n").encode("utf-8")
-def _make_handler(metrics: Metrics) -> type[BaseHTTPRequestHandler]:
- """Фабрика handler-класса с ``metrics`` через замыкание — без глобалов."""
+def _make_handler(
+ metrics: Metrics,
+ health_provider_ref: list[Optional[HealthProvider]],
+) -> type[BaseHTTPRequestHandler]:
+ """Фабрика handler-класса с ``metrics`` через замыкание — без глобалов.
+
+ ``health_provider_ref`` — однослотовый список (mutable container),
+ чтобы ``MetricsServer.set_health_provider()`` мог поменять провайдер
+ после старта сервера, не пересоздавая handler-класс.
+ """
class _Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 — имя задано базовым классом
- if self.path != "/metrics":
- self.send_error(404)
+ if self.path == "/metrics":
+ body = metrics.render()
+ self.send_response(200)
+ self.send_header(
+ "Content-Type", "text/plain; version=0.0.4; charset=utf-8"
+ )
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+ return
+
+ if self.path == "/healthz":
+ self._serve_healthz(health_provider_ref[0])
return
- body = metrics.render()
- self.send_response(200)
- self.send_header("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
+
+ self.send_error(404)
+
+ def _serve_healthz(self, provider: Optional[HealthProvider]) -> None:
+ if provider is None:
+ # Сервер запущен, но никто не зарегистрировал источник
+ # health-данных → читать состояние нечем, считаем down.
+ payload = {"status": "down", "reason": "no health provider registered"}
+ http_status = 503
+ else:
+ try:
+ payload = provider() or {}
+ except Exception as exc: # provider — пользовательский код
+ payload = {"status": "down", "reason": f"provider error: {exc}"}
+ http_status = 503
+ else:
+ status = payload.get("status", "down")
+ # 503 только на полное «лежу»; degraded — это всё ещё 200
+ # (k8s liveness не должен убивать пере-перезагружающийся узел).
+ http_status = 503 if status == "down" else 200
+
+ body = json.dumps(payload).encode("utf-8")
+ self.send_response(http_status)
+ self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
@@ -147,6 +206,18 @@ def __init__(self, metrics: Metrics, host: str = "127.0.0.1", port: int = 9090)
self._thread: threading.Thread | None = None
# Лок защищает start/stop от гонки, если их зовут из разных нитей.
self._lifecycle_lock = threading.Lock()
+ # Однослотовый mutable container: handler читает [0] на каждом запросе,
+ # а set_health_provider() обновляет [0]. Так провайдер можно
+ # подменить и после start(), без пересоздания handler-класса.
+ self._health_ref: list[Optional[HealthProvider]] = [None]
+
+ def set_health_provider(self, provider: Optional[HealthProvider]) -> None:
+ """Регистрирует callable, отдающий dict для ``/healthz``.
+
+ Можно вызывать до или после ``start()``. ``None`` — сброс
+ (полезно в тестах, чтобы вернуть статус ``down``).
+ """
+ self._health_ref[0] = provider
def start(self) -> None:
"""Запускает сервер в фоновой нити. Идемпотентен."""
@@ -155,7 +226,7 @@ def start(self) -> None:
# Уже запущен — ничего не делаем, чтобы не порвать рабочий
# сокет повторным bind'ом.
return
- handler_cls = _make_handler(self.metrics)
+ handler_cls = _make_handler(self.metrics, self._health_ref)
self._server = ThreadingHTTPServer((self.host, self.port), handler_cls)
self._thread = threading.Thread(
target=self._server.serve_forever,
@@ -186,3 +257,68 @@ def stop(self, timeout: float = 2.0) -> None:
@property
def url(self) -> str:
return f"http://{self.host}:{self.port}/metrics"
+
+ @property
+ def health_url(self) -> str:
+ return f"http://{self.host}:{self.port}/healthz"
+
+
+def build_health_snapshot(
+ *,
+ reader_alive: bool,
+ hashrate_ema: float,
+ hashrate_ts: Optional[float],
+ last_share_ts: Optional[float],
+ started_at: float,
+ stale_after_s: float = 600.0,
+ hashrate_window_s: float = 30.0,
+ now: Optional[float] = None,
+) -> dict:
+ """Чистая функция: считает status из набора параметров.
+
+ Вынесена сюда (а не в miner.py), чтобы быть тестируемой без сети
+ и multiprocessing. ``now`` опционален — для детерминистичных тестов.
+ """
+ t = now if now is not None else time.time()
+
+ # 1. Reader жив? Без него мы пилим невалидный job — это down.
+ if not reader_alive:
+ return {
+ "status": "down",
+ "reason": "stratum reader thread is not alive",
+ "uptime_s": max(0.0, t - started_at),
+ "last_share_ts": last_share_ts,
+ }
+
+ # 2. EMA-сэмпл свежий и положительный?
+ fresh_hashrate = (
+ hashrate_ts is not None
+ and (t - hashrate_ts) <= hashrate_window_s
+ and hashrate_ema > 0
+ )
+
+ # 3. Был ли шар за окно stale_after_s?
+ fresh_share = (
+ last_share_ts is not None
+ and (t - last_share_ts) <= stale_after_s
+ )
+
+ if fresh_hashrate and (fresh_share or last_share_ts is None and t - started_at < stale_after_s):
+ # Только что стартанули и шар ещё не нашли — это нормально, не degraded.
+ status = "ok"
+ reason = None
+ elif fresh_hashrate:
+ status = "degraded"
+ reason = "no recent share"
+ else:
+ status = "degraded"
+ reason = "stale or zero hashrate"
+
+ return {
+ "status": status,
+ "reason": reason,
+ "uptime_s": max(0.0, t - started_at),
+ "hashrate_ema": float(hashrate_ema),
+ "hashrate_ts": hashrate_ts,
+ "last_share_ts": last_share_ts,
+ }
diff --git a/tests/test_healthz.py b/tests/test_healthz.py
new file mode 100644
index 0000000..102e0fb
--- /dev/null
+++ b/tests/test_healthz.py
@@ -0,0 +1,176 @@
+"""Тесты /healthz endpoint и build_health_snapshot.
+
+Стратегия:
+- Чистая функция build_health_snapshot тестируется на детерминистичных
+ параметрах (передаём now=...) — никакой реальной хронологии.
+- HTTP-слой /healthz прогоняем через настоящий MetricsServer на свободном
+ порту: зеркалит интеграцию с Prometheus-сервером.
+"""
+
+import json
+import socket
+import time
+import unittest
+import urllib.error
+import urllib.request
+
+from hope_hash.metrics import Metrics, MetricsServer, build_health_snapshot
+
+
+def _free_port() -> int:
+ s = socket.socket()
+ s.bind(("127.0.0.1", 0))
+ port = s.getsockname()[1]
+ s.close()
+ return port
+
+
+class TestBuildHealthSnapshot(unittest.TestCase):
+ def test_ok_when_everything_fresh(self) -> None:
+ now = 1000.0
+ snap = build_health_snapshot(
+ reader_alive=True,
+ hashrate_ema=100000.0,
+ hashrate_ts=now - 5, # 5с назад — свежее окна 30с
+ last_share_ts=now - 60, # минута назад — внутри 600с
+ started_at=now - 300,
+ stale_after_s=600,
+ now=now,
+ )
+ self.assertEqual(snap["status"], "ok")
+ self.assertGreater(snap["uptime_s"], 0)
+
+ def test_down_when_reader_dead(self) -> None:
+ now = 1000.0
+ snap = build_health_snapshot(
+ reader_alive=False,
+ hashrate_ema=100000.0,
+ hashrate_ts=now,
+ last_share_ts=now,
+ started_at=now - 100,
+ now=now,
+ )
+ self.assertEqual(snap["status"], "down")
+ self.assertIn("reader", snap["reason"])
+
+ def test_degraded_when_hashrate_stale(self) -> None:
+ now = 1000.0
+ snap = build_health_snapshot(
+ reader_alive=True,
+ hashrate_ema=100000.0,
+ hashrate_ts=now - 200, # старше 30с-окна → stale
+ last_share_ts=now - 60,
+ started_at=now - 1000,
+ now=now,
+ )
+ self.assertEqual(snap["status"], "degraded")
+
+ def test_degraded_when_no_recent_share(self) -> None:
+ now = 1000.0
+ snap = build_health_snapshot(
+ reader_alive=True,
+ hashrate_ema=100000.0,
+ hashrate_ts=now - 5,
+ last_share_ts=now - 1000, # старше 600с-окна
+ started_at=now - 5000,
+ stale_after_s=600,
+ now=now,
+ )
+ self.assertEqual(snap["status"], "degraded")
+
+ def test_ok_at_startup_no_share_yet(self) -> None:
+ # Только что запустились (uptime < stale_after_s), шар нет ещё —
+ # это должно считаться ok, иначе healthz будет flap'ать каждый старт.
+ now = 1000.0
+ snap = build_health_snapshot(
+ reader_alive=True,
+ hashrate_ema=50000.0,
+ hashrate_ts=now - 3,
+ last_share_ts=None,
+ started_at=now - 30,
+ stale_after_s=600,
+ now=now,
+ )
+ self.assertEqual(snap["status"], "ok")
+
+ def test_zero_hashrate_is_degraded(self) -> None:
+ now = 1000.0
+ snap = build_health_snapshot(
+ reader_alive=True,
+ hashrate_ema=0.0,
+ hashrate_ts=now,
+ last_share_ts=now - 60,
+ started_at=now - 1000,
+ now=now,
+ )
+ self.assertEqual(snap["status"], "degraded")
+
+
+class TestHealthzHTTP(unittest.TestCase):
+ def setUp(self) -> None:
+ self.metrics = Metrics()
+ self.port = _free_port()
+ self.server = MetricsServer(self.metrics, port=self.port)
+ self.server.start()
+ # Дать серверу подняться.
+ time.sleep(0.05)
+
+ def tearDown(self) -> None:
+ self.server.stop()
+
+ def _get(self, path: str) -> tuple[int, dict]:
+ url = f"http://127.0.0.1:{self.port}{path}"
+ try:
+ with urllib.request.urlopen(url, timeout=2) as resp:
+ return resp.status, json.loads(resp.read().decode("utf-8"))
+ except urllib.error.HTTPError as e:
+ body = e.read().decode("utf-8")
+ try:
+ return e.code, json.loads(body)
+ except json.JSONDecodeError:
+ return e.code, {"raw": body}
+
+ def test_healthz_503_when_no_provider(self) -> None:
+ status, body = self._get("/healthz")
+ self.assertEqual(status, 503)
+ self.assertEqual(body["status"], "down")
+
+ def test_healthz_200_when_ok(self) -> None:
+ self.server.set_health_provider(lambda: {"status": "ok", "uptime_s": 1.0})
+ status, body = self._get("/healthz")
+ self.assertEqual(status, 200)
+ self.assertEqual(body["status"], "ok")
+
+ def test_healthz_200_when_degraded(self) -> None:
+ self.server.set_health_provider(
+ lambda: {"status": "degraded", "reason": "no recent share"}
+ )
+ status, body = self._get("/healthz")
+ self.assertEqual(status, 200)
+ self.assertEqual(body["status"], "degraded")
+
+ def test_healthz_503_when_provider_returns_down(self) -> None:
+ self.server.set_health_provider(lambda: {"status": "down", "reason": "x"})
+ status, _ = self._get("/healthz")
+ self.assertEqual(status, 503)
+
+ def test_healthz_503_when_provider_raises(self) -> None:
+ def bad_provider() -> dict:
+ raise RuntimeError("kaboom")
+ self.server.set_health_provider(bad_provider)
+ status, body = self._get("/healthz")
+ self.assertEqual(status, 503)
+ self.assertEqual(body["status"], "down")
+ self.assertIn("kaboom", body["reason"])
+
+ def test_metrics_still_works(self) -> None:
+ # /metrics не должен сломаться от добавления /healthz.
+ self.metrics.counter_inc("smoke_total", 5)
+ url = f"http://127.0.0.1:{self.port}/metrics"
+ with urllib.request.urlopen(url, timeout=2) as resp:
+ body = resp.read().decode("utf-8")
+ self.assertIn("smoke_total 5", body)
+
+
+if __name__ == "__main__":
+ unittest.main()
From b4bafb17e956c7beb83b8a5862a1a680610e55ad Mon Sep 17 00:00:00 2001
From: devAsmodeus
Date: Sat, 2 May 2026 12:48:56 +0300
Subject: [PATCH 6/9] feat(tg): inbound /stats /stop /restart commands + timing
test
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds long-poll getUpdates thread (start_inbound/stop_inbound) and a
register_command registry. Authz strictly by HOPE_HASH_TELEGRAM_CHAT_ID;
foreign chat updates are dropped with a warning log. Opt-in via
HOPE_HASH_TELEGRAM_INBOUND=1 — we don't open a network thread without
explicit ack.
test_notifier_timing.py is a regression test for the class of bugs
where notify_share_accepted fires from the submit path before the pool
acks: we now drive it exclusively from on_share_result(accepted=True),
verified across submit-only, ack-true, ack-false, duplicate-ack, and
multi-share scenarios.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
src/hope_hash/notifier.py | 176 +++++++++++++++++++++++++++++++++-
tests/test_notifier.py | 106 ++++++++++++++++++++
tests/test_notifier_timing.py | 121 +++++++++++++++++++++++
3 files changed, 399 insertions(+), 4 deletions(-)
create mode 100644 tests/test_notifier_timing.py
diff --git a/src/hope_hash/notifier.py b/src/hope_hash/notifier.py
index a22da9b..5430072 100644
--- a/src/hope_hash/notifier.py
+++ b/src/hope_hash/notifier.py
@@ -10,24 +10,38 @@
Если token или chat_id не заданы — все методы становятся no-op (silent disable).
Это позволяет интегрировать notifier в miner без обязательной настройки.
-Архитектура: фоновая нить-воркер тащит сообщения из `queue.Queue` и шлёт
-на Telegram Bot API через urllib. Сетевые вызовы не блокируют hot-path
-майнера; при переполнении очереди сообщения отбрасываются с warning.
+Архитектура исходящих: фоновая нить-воркер тащит сообщения из ``queue.Queue``
+и шлёт на Telegram Bot API через urllib. Сетевые вызовы не блокируют
+hot-path майнера; при переполнении очереди сообщения отбрасываются с warning.
+
+Архитектура входящих (опционально, ``HOPE_HASH_TELEGRAM_INBOUND=1``):
+вторая фоновая нить long-polling-ом читает getUpdates и диспатчит
+``/stats``, ``/stop``, ``/restart``. Команды принимаются ТОЛЬКО от
+``chat_id``, на который настроен notifier — это закрывает основной
+authz-вектор (бот может быть добавлен в чужой чат).
"""
+from __future__ import annotations
+
+import json
import logging
import os
import queue
import threading
+import time
import urllib.error
import urllib.parse
import urllib.request
-from typing import Optional
+from typing import Callable, Optional
logger = logging.getLogger("hope_hash")
# Шаблон URL Telegram Bot API. sendMessage принимает form-encoded body.
TELEGRAM_API = "https://api.telegram.org/bot{token}/sendMessage"
+TELEGRAM_GET_UPDATES = "https://api.telegram.org/bot{token}/getUpdates"
+
+# Поддерживаемые inbound-команды.
+KNOWN_COMMANDS: tuple[str, ...] = ("/stats", "/stop", "/restart", "/help", "/start")
class TelegramNotifier:
@@ -54,6 +68,19 @@ def __init__(
self._queue: queue.Queue = queue.Queue(maxsize=queue_maxsize)
self._stop_event = threading.Event()
self._thread: Optional[threading.Thread] = None
+
+ # ─── inbound state (опционально) ───
+ # Поллер-нить и реестр обработчиков команд. Включается через
+ # start_inbound() — по умолчанию выключено, чтобы не открывать
+ # сетевую дыру без явного opt-in.
+ self._inbound_thread: Optional[threading.Thread] = None
+ self._inbound_stop = threading.Event()
+ self._command_handlers: dict[str, Callable[[], Optional[str]]] = {}
+ self._last_update_id: int = 0
+ # Lock защищает _last_update_id и _command_handlers — обновляются
+ # из inbound-нити, читаются из API-методов.
+ self._inbound_lock = threading.Lock()
+
if self.enabled:
# daemon=True — чтобы при жёстком SIGKILL процесс не висел из-за нити.
# Корректное завершение всё равно через shutdown().
@@ -75,6 +102,11 @@ def from_env(cls) -> "TelegramNotifier":
chat_id=os.environ.get("HOPE_HASH_TELEGRAM_CHAT_ID"),
)
+ @staticmethod
+ def inbound_enabled_in_env() -> bool:
+ """True если HOPE_HASH_TELEGRAM_INBOUND=1 в окружении."""
+ return os.environ.get("HOPE_HASH_TELEGRAM_INBOUND", "").strip() in ("1", "true", "yes", "on")
+
def notify(self, text: str) -> None:
"""Кладёт сообщение в очередь. No-op если disabled или очередь полна."""
if not self.enabled:
@@ -107,8 +139,144 @@ def notify_block_found(self, hash_hex: str, height: Optional[int] = None) -> Non
h = f" #{height}" if height else ""
self.notify(f"🎉 БЛОК НАЙДЕН{h}!\nhash: {hash_hex[:32]}…")
+ # ─────────────────── inbound long-poll ───────────────────
+
+ def register_command(self, name: str, handler: Callable[[], Optional[str]]) -> None:
+ """Регистрирует обработчик команды.
+
+ ``handler`` вызывается в inbound-нити (не в hot-path майнера).
+ Возвращает строку — она будет отправлена в чат как ack; ``None`` —
+ ничего не отвечаем (но факт обработки логгируется).
+ """
+ with self._inbound_lock:
+ self._command_handlers[name] = handler
+
+ def start_inbound(self, poll_interval: float = 25.0) -> bool:
+ """Поднимает нить-поллер getUpdates. Возвращает True если стартанула.
+
+ Idempotent: повторный вызов — no-op. ``poll_interval`` — long-poll
+ timeout, который мы передаём Telegram'у; реальный getUpdates висит
+ до этой длительности или до прихода события.
+ """
+ if not self.enabled:
+ logger.info("[telegram] inbound не запущен: notifier disabled")
+ return False
+ if self._inbound_thread is not None and self._inbound_thread.is_alive():
+ return True
+ self._inbound_stop.clear()
+ self._inbound_thread = threading.Thread(
+ target=self._inbound_loop,
+ args=(poll_interval,),
+ daemon=True,
+ name="telegram-inbound",
+ )
+ self._inbound_thread.start()
+ logger.info("[tg] inbound long-poll запущен (chat=%s)", self.chat_id)
+ return True
+
+ def stop_inbound(self, timeout: float = 5.0) -> None:
+ """Останавливает inbound-нить. Идемпотентно."""
+ self._inbound_stop.set()
+ t = self._inbound_thread
+ self._inbound_thread = None
+ if t is not None and t.is_alive():
+ t.join(timeout=timeout)
+
+ def _inbound_loop(self, poll_interval: float) -> None:
+ # Backoff на сетевые сбои, чтобы не спамить API при выключенном роутере.
+ backoff = 1.0
+ while not self._inbound_stop.is_set():
+ try:
+ updates = self._fetch_updates(poll_interval)
+ backoff = 1.0
+ for upd in updates:
+ self._handle_update(upd)
+ except (urllib.error.URLError, OSError, TimeoutError) as e:
+ logger.warning("[tg] long-poll сетевая ошибка: %s (retry %.1fs)", e, backoff)
+ self._inbound_stop.wait(timeout=backoff)
+ backoff = min(backoff * 2, 60.0)
+ except (ValueError, json.JSONDecodeError) as e:
+ # Битый JSON — уж точно не наше дело его парсить, скипаем.
+ logger.warning("[tg] битый ответ getUpdates: %s", e)
+ self._inbound_stop.wait(timeout=1.0)
+
+ def _fetch_updates(self, poll_interval: float) -> list[dict]:
+ """Один HTTP-вызов getUpdates с long-poll timeout."""
+ with self._inbound_lock:
+ offset = self._last_update_id + 1 if self._last_update_id else 0
+
+ params: dict[str, str | int] = {
+ "timeout": int(poll_interval),
+ "allowed_updates": json.dumps(["message"]),
+ }
+ if offset:
+ params["offset"] = offset
+ url = TELEGRAM_GET_UPDATES.format(token=self.token) + "?" + urllib.parse.urlencode(params)
+ # urlopen timeout = poll_interval + 5с (на накладные).
+ with urllib.request.urlopen(url, timeout=poll_interval + 5.0) as resp:
+ data = json.loads(resp.read().decode("utf-8"))
+ if not data.get("ok"):
+ logger.warning("[tg] getUpdates вернул ok=false: %s", data.get("description"))
+ return []
+ return list(data.get("result", []))
+
+ def _handle_update(self, upd: dict) -> None:
+ """Один update: фильтр по chat_id, парс команды, диспатч."""
+ update_id = upd.get("update_id")
+ if update_id is not None:
+ with self._inbound_lock:
+ if update_id > self._last_update_id:
+ self._last_update_id = update_id
+
+ msg = upd.get("message") or {}
+ chat = msg.get("chat") or {}
+ # Telegram возвращает chat_id как int; в env у нас строка — сравниваем строки.
+ incoming_chat_id = str(chat.get("id", ""))
+ if incoming_chat_id != str(self.chat_id):
+ logger.warning(
+ "[tg] отвергнут update от чужого chat_id=%r (ожидаем %r)",
+ incoming_chat_id, self.chat_id,
+ )
+ return
+
+ text = (msg.get("text") or "").strip()
+ if not text:
+ return
+
+ # Команда — первое слово; всё после — аргументы (мы их пока игнорируем).
+ cmd = text.split()[0].lower()
+ # Telegram-команды могут идти с username-суффиксом: "/stats@MyBot".
+ if "@" in cmd:
+ cmd = cmd.split("@", 1)[0]
+
+ if cmd not in KNOWN_COMMANDS:
+ return
+
+ with self._inbound_lock:
+ handler = self._command_handlers.get(cmd)
+
+ if handler is None:
+ self.notify(f"команда {cmd} не настроена в этом инстансе")
+ return
+
+ logger.info("[tg] выполнение команды %s от chat=%s", cmd, incoming_chat_id)
+ try:
+ reply = handler()
+ except Exception as exc:
+ logger.warning("[tg] handler %s упал: %s", cmd, exc)
+ self.notify(f"⚠️ ошибка в обработчике {cmd}: {exc}")
+ return
+ if reply:
+ self.notify(reply)
+
+ # ─────────────────── shutdown ───────────────────
+
def shutdown(self, timeout: float = 5.0) -> None:
"""Дренирует очередь и останавливает воркер. Идемпотентно."""
+ # Inbound стопаем первым: иначе он может попытаться положить ack
+ # в уже закрытую очередь.
+ self.stop_inbound(timeout=timeout)
+
if not self.enabled or self._thread is None:
return
# Сначала ждём, пока воркер обработает все ранее положенные сообщения.
diff --git a/tests/test_notifier.py b/tests/test_notifier.py
index a13c05b..73d4cad 100644
--- a/tests/test_notifier.py
+++ b/tests/test_notifier.py
@@ -262,5 +262,111 @@ def test_lifecycle_messages(self, mock_urlopen):
self.assertIn("worker-7", first_body)
+class TestNotifierInbound(unittest.TestCase):
+ """Inbound long-poll: dispatch команд и authz по chat_id."""
+
+ @patch("hope_hash.notifier.urllib.request.urlopen")
+ def test_inbound_disabled_when_notifier_disabled(self, mock_urlopen):
+ n = TelegramNotifier() # disabled
+ self.assertFalse(n.start_inbound())
+ n.shutdown()
+
+ @patch("hope_hash.notifier.urllib.request.urlopen")
+ def test_handle_update_dispatches_known_command(self, mock_urlopen):
+ # _handle_update — pure-функция в смысле unit-теста (одно сообщение).
+ # Проверяем что зарегистрированный handler вызывается, а ack уходит в очередь.
+ mock_urlopen.return_value = _make_mock_response()
+ n = TelegramNotifier(token="t", chat_id="42")
+ called = []
+
+ def stats_handler():
+ called.append(True)
+ return "stats reply"
+
+ n.register_command("/stats", stats_handler)
+
+ n._handle_update({
+ "update_id": 1,
+ "message": {
+ "chat": {"id": 42},
+ "text": "/stats",
+ },
+ })
+ n.shutdown()
+ self.assertEqual(len(called), 1)
+ # Ack отправлен через outbound queue → urlopen.
+ self.assertTrue(mock_urlopen.called)
+
+ @patch("hope_hash.notifier.urllib.request.urlopen")
+ def test_handle_update_rejects_foreign_chat(self, mock_urlopen):
+ mock_urlopen.return_value = _make_mock_response()
+ n = TelegramNotifier(token="t", chat_id="42")
+ called = []
+ n.register_command("/stats", lambda: called.append(True) or "x")
+
+ # Чужой chat_id — handler НЕ должен сработать.
+ with self.assertLogs("hope_hash", level="WARNING") as cm:
+ n._handle_update({
+ "update_id": 2,
+ "message": {
+ "chat": {"id": 9999},
+ "text": "/stats",
+ },
+ })
+ n.shutdown()
+
+ self.assertEqual(len(called), 0)
+ self.assertTrue(any("чужого chat_id" in line for line in cm.output))
+
+ @patch("hope_hash.notifier.urllib.request.urlopen")
+ def test_handle_update_ignores_unknown_command(self, mock_urlopen):
+ mock_urlopen.return_value = _make_mock_response()
+ n = TelegramNotifier(token="t", chat_id="42")
+ called = []
+ n.register_command("/stats", lambda: called.append(True) or None)
+
+ n._handle_update({
+ "update_id": 3,
+ "message": {"chat": {"id": 42}, "text": "/wat"},
+ })
+ n.shutdown()
+ self.assertEqual(len(called), 0)
+
+ @patch("hope_hash.notifier.urllib.request.urlopen")
+ def test_handle_update_strips_botname_suffix(self, mock_urlopen):
+ # /stats@MyBot должно трактоваться как /stats.
+ mock_urlopen.return_value = _make_mock_response()
+ n = TelegramNotifier(token="t", chat_id="42")
+ called = []
+ n.register_command("/stats", lambda: called.append(True) or None)
+
+ n._handle_update({
+ "update_id": 4,
+ "message": {"chat": {"id": 42}, "text": "/stats@HopeHashBot"},
+ })
+ n.shutdown()
+ self.assertEqual(len(called), 1)
+
+ @patch("hope_hash.notifier.urllib.request.urlopen")
+ def test_handle_update_advances_offset(self, mock_urlopen):
+ mock_urlopen.return_value = _make_mock_response()
+ n = TelegramNotifier(token="t", chat_id="42")
+ n._handle_update({"update_id": 100, "message": {"chat": {"id": 42}, "text": "ok"}})
+ # _last_update_id должен подняться.
+ self.assertEqual(n._last_update_id, 100)
+ # Меньший id не должен снизить:
+ n._handle_update({"update_id": 50, "message": {"chat": {"id": 42}, "text": "ok"}})
+ self.assertEqual(n._last_update_id, 100)
+ n.shutdown()
+
+ def test_inbound_enabled_in_env_flag(self):
+ with patch.dict(os.environ, {"HOPE_HASH_TELEGRAM_INBOUND": "1"}, clear=True):
+ self.assertTrue(TelegramNotifier.inbound_enabled_in_env())
+ with patch.dict(os.environ, {"HOPE_HASH_TELEGRAM_INBOUND": "no"}, clear=True):
+ self.assertFalse(TelegramNotifier.inbound_enabled_in_env())
+ with patch.dict(os.environ, {}, clear=True):
+ self.assertFalse(TelegramNotifier.inbound_enabled_in_env())
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_notifier_timing.py b/tests/test_notifier_timing.py
new file mode 100644
index 0000000..6de730b
--- /dev/null
+++ b/tests/test_notifier_timing.py
@@ -0,0 +1,121 @@
+"""Тесты таймингов notify_share_accepted: пинг летит ТОЛЬКО после ack пула,
+не из submit-пути.
+
+Это регресс-тест на класс ошибок «уведомили о принятом шаре, а пул его потом
+отверг». Архитектура mine() теперь ждёт callback on_share_result, и
+notify_share_accepted дёргается только при accepted=True от пула.
+
+Прямой интеграционный тест mine() — слишком тяжёлый (multiprocessing pool,
+сетевой клиент). Вместо этого тестируем через StratumClient:
+
+1. Ставим callback on_share_result, который дергает notifier.
+2. Симулируем submit() (запоминает req_id).
+3. Симулируем входящее ack-сообщение reader_loop'а через _handle_message.
+4. Убеждаемся что notifier дёрнулся ровно один раз.
+5. Симулируем reject — notifier НЕ должен быть дёрнут (только accepted).
+"""
+
+import unittest
+from unittest.mock import MagicMock
+
+from hope_hash.notifier import TelegramNotifier
+from hope_hash.stratum import StratumClient
+
+
+class _FakeSocket:
+ """Минимальный fake-socket: запоминает посланное, возвращает заданное."""
+
+ def __init__(self) -> None:
+ self.sent: list[bytes] = []
+ self._recv_chunks: list[bytes] = []
+
+ def sendall(self, data: bytes) -> None:
+ self.sent.append(data)
+
+ def recv(self, n: int) -> bytes:
+ if not self._recv_chunks:
+ return b""
+ return self._recv_chunks.pop(0)
+
+ def close(self) -> None:
+ pass
+
+
+class TestNotifyTiming(unittest.TestCase):
+ """Notify_share_accepted дёргается только из ack-callback."""
+
+ def _make_client_with_notifier(self) -> tuple[StratumClient, MagicMock]:
+ client = StratumClient("h", 1, "bc1qaddress", "w")
+ client.sock = _FakeSocket() # type: ignore[assignment]
+
+ # Mock notifier — мы тестируем именно тайминг вызова, не реальную сеть.
+ notifier = MagicMock(spec=TelegramNotifier)
+ notifier.enabled = True
+
+ # Воспроизводим логику из mine(): один callback регистрируется,
+ # он отвечает за все последствия ack-а.
+ def on_share_result(req_id: int, accepted: bool) -> None:
+ if accepted:
+ notifier.notify_share_accepted(job_id="job-x", difficulty=1.0)
+
+ client.on_share_result = on_share_result
+ return client, notifier
+
+ def test_no_notify_on_submit_alone(self) -> None:
+ # Сценарий: submit ушёл, ответ ещё не пришёл. notifier должен молчать.
+ client, notifier = self._make_client_with_notifier()
+
+ client.submit("job-x", "00000001", "5e000000", "deadbeef")
+
+ notifier.notify_share_accepted.assert_not_called()
+
+ def test_notify_only_after_pool_ack_accepted(self) -> None:
+ # Submit → ack(accepted=true) → ровно один notify.
+ client, notifier = self._make_client_with_notifier()
+
+ req_id = client.submit("job-x", "00000001", "5e000000", "deadbeef")
+
+ # Симулируем ответ пула: сообщение с тем же id и result=true.
+ client._handle_message({"id": req_id, "result": True})
+
+ notifier.notify_share_accepted.assert_called_once_with(
+ job_id="job-x", difficulty=1.0
+ )
+
+ def test_no_notify_on_pool_reject(self) -> None:
+ # Submit → ack(result=false) → notifier НЕ должен быть вызван.
+ client, notifier = self._make_client_with_notifier()
+
+ req_id = client.submit("job-x", "00000001", "5e000000", "deadbeef")
+ client._handle_message({"id": req_id, "result": False, "error": "Low difficulty share"})
+
+ notifier.notify_share_accepted.assert_not_called()
+
+ def test_notify_exactly_once_on_duplicate_ack(self) -> None:
+ # Если по какой-то причине пул присылает ack дважды (или callback
+ # перевызвался) — notifier должен быть вызван ровно один раз,
+ # т.к. submit_req_ids вычищает себя в _handle_message.
+ client, notifier = self._make_client_with_notifier()
+
+ req_id = client.submit("job-x", "00000001", "5e000000", "deadbeef")
+
+ client._handle_message({"id": req_id, "result": True})
+ client._handle_message({"id": req_id, "result": True}) # дубль
+
+ notifier.notify_share_accepted.assert_called_once()
+
+ def test_notify_for_each_distinct_share(self) -> None:
+ # Каждая принятая шара = отдельный вызов notifier.
+ client, notifier = self._make_client_with_notifier()
+
+ rid1 = client.submit("job-x", "00000001", "5e000000", "deadbeef")
+ rid2 = client.submit("job-x", "00000002", "5e000000", "deadbef0")
+
+ client._handle_message({"id": rid1, "result": True})
+ client._handle_message({"id": rid2, "result": True})
+
+ self.assertEqual(notifier.notify_share_accepted.call_count, 2)
+
+
+if __name__ == "__main__":
+ unittest.main()
From 2517d124129603c7c4bdcd24d583f2c8a7488bab Mon Sep 17 00:00:00 2001
From: devAsmodeus
Date: Sat, 2 May 2026 12:49:06 +0300
Subject: [PATCH 7/9] feat(cli): wire TUI/healthz/inbound + complete type
annotations
cli.main() now:
- prints the banner unless --no-banner / --tui
- creates a StatsProvider shared between mine() and consumers
- starts TUIApp when --tui (with curses-availability fallback)
- registers a health provider on the metrics server
- registers /stats /stop /restart Telegram handlers when opt-in env set
- routes log INFO to --log-file in TUI mode (stderr stays at WARNING+)
mine() accepts stats_provider; supervisor_loop() accepts restart_event
for the /restart command. stratum.py gets full attribute type hints
and dict[str, Any]/list[Any] params.
Bumps __version__ to 0.5.0 and re-exports new public symbols.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
src/hope_hash/__init__.py | 13 ++-
src/hope_hash/cli.py | 174 +++++++++++++++++++++++++++++++++++++-
src/hope_hash/miner.py | 36 +++++++-
src/hope_hash/stratum.py | 42 +++++----
4 files changed, 241 insertions(+), 24 deletions(-)
diff --git a/src/hope_hash/__init__.py b/src/hope_hash/__init__.py
index 88c493c..67fc014 100644
--- a/src/hope_hash/__init__.py
+++ b/src/hope_hash/__init__.py
@@ -1,15 +1,18 @@
"""Hope-Hash — учебный solo BTC miner на чистом stdlib."""
+__version__ = "0.5.0"
+
+from .banner import print_banner, render_banner
from .bench import BenchResult, run_benchmark
from .block import build_merkle_root, difficulty_to_target, double_sha256, swap_words
from .demo import run_demo
-from .metrics import Metrics, MetricsServer
+from .metrics import Metrics, MetricsServer, build_health_snapshot
from .miner import mine
from .notifier import TelegramNotifier
from .storage import ShareStore
from .stratum import StratumClient
+from .tui import StatsProvider, StatsSnapshot, TUIApp
-__version__ = "0.4.0"
__all__ = [
"double_sha256",
"swap_words",
@@ -23,6 +26,12 @@
"ShareStore",
"Metrics",
"MetricsServer",
+ "build_health_snapshot",
"TelegramNotifier",
+ "StatsProvider",
+ "StatsSnapshot",
+ "TUIApp",
+ "print_banner",
+ "render_banner",
"__version__",
]
diff --git a/src/hope_hash/cli.py b/src/hope_hash/cli.py
index d58df1f..2ccab2a 100644
--- a/src/hope_hash/cli.py
+++ b/src/hope_hash/cli.py
@@ -1,6 +1,9 @@
"""Точка входа CLI: argparse, запуск supervisor + mine() + observers."""
+from __future__ import annotations
+
import argparse
+import logging
import multiprocessing
import os
import sys
@@ -10,11 +13,13 @@
from ._logging import logger, setup_logging
from .address import validate_btc_address
-from .metrics import Metrics, MetricsServer
+from .banner import print_banner
+from .metrics import Metrics, MetricsServer, build_health_snapshot
from .miner import mine, supervisor_loop
from .notifier import TelegramNotifier
from .storage import ShareStore
from .stratum import StratumClient
+from .tui import StatsProvider, TUIApp, format_rate, format_uptime, is_curses_available
POOL_HOST = "solo.ckpool.org"
@@ -79,19 +84,103 @@ def _parse_args() -> argparse.Namespace:
metavar="SEC",
help="Длительность бенчмарка в секундах (по умолчанию: 10).",
)
+ parser.add_argument(
+ "--tui", action="store_true",
+ help="Включить curses-дашборд (на Windows нужен windows-curses; "
+ "при отсутствии — graceful skip с логом).",
+ )
+ parser.add_argument(
+ "--no-banner", action="store_true",
+ help="Не печатать ASCII-баннер при старте (для systemd/cron-режима).",
+ )
+ parser.add_argument(
+ "--log-file", type=str, default=None,
+ metavar="PATH",
+ help="Дублировать лог в файл. Полезно вместе с --tui, "
+ "когда stdout занят дашбордом.",
+ )
+ parser.add_argument(
+ "--healthz-stale-after", type=float, default=600.0,
+ metavar="SEC",
+ help="Сколько секунд без шар до того, как /healthz отдаёт degraded "
+ "(по умолчанию: 600).",
+ )
return parser.parse_args()
+def _setup_logging_for_tui(log_file: str | None, tui_active: bool) -> None:
+ """Логи + curses несовместимы: stdout-handler рвёт перерисовку. Если TUI
+ включён — поднимаем уровень до WARNING на консоли, а INFO направляем в
+ файл (если задан --log-file). Без TUI — поведение прежнее, basicConfig.
+ """
+ if not tui_active:
+ setup_logging()
+ if log_file:
+ fh = logging.FileHandler(log_file, encoding="utf-8")
+ fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
+ logging.getLogger().addHandler(fh)
+ return
+
+ # TUI режим: убираем default handlers basicConfig, добавляем тихий console
+ # на WARNING+ и файл (если задан) на INFO.
+ root = logging.getLogger()
+ for h in list(root.handlers):
+ root.removeHandler(h)
+ root.setLevel(logging.INFO)
+
+ console = logging.StreamHandler(sys.stderr)
+ console.setLevel(logging.WARNING)
+ console.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
+ root.addHandler(console)
+
+ if log_file:
+ fh = logging.FileHandler(log_file, encoding="utf-8")
+ fh.setLevel(logging.INFO)
+ fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
+ root.addHandler(fh)
+
+
+def _format_stats_message(snap) -> str:
+ """Сборка ответа на /stats для Telegram."""
+ return (
+ "📊 Hope-Hash stats\n"
+ f"uptime: {format_uptime(snap.uptime_s)}\n"
+ f"hashrate (EMA): {format_rate(snap.hashrate_ema)}\n"
+ f"workers: {snap.workers}\n"
+ f"pool diff: {snap.pool_difficulty}\n"
+ f"shares: {snap.shares_total} sent / "
+ f"{snap.shares_accepted} ok / {snap.shares_rejected} rej\n"
+ f"job: {snap.current_job_id or '—'}"
+ )
+
+
def main():
# Защитный вызов: на Windows multiprocessing требует freeze_support()
# при запуске через `python -m hope_hash`. Без него spawn-дети могут
# пытаться повторно стартовать main() и упасть.
multiprocessing.freeze_support()
- setup_logging()
args = _parse_args()
n_workers = max(1, args.workers)
+ # TUI работает только в реальном майнинге; для bench/demo просто игнорируем.
+ tui_requested = bool(getattr(args, "tui", False)) and not (args.benchmark or args.demo)
+ tui_active = tui_requested and is_curses_available()
+ if tui_requested and not tui_active:
+ # Лог через basicConfig (он ниже в setup), а пока просто print —
+ # пользователь должен это увидеть до тишины TUI-режима.
+ print(
+ "warning: --tui запрошен, но curses недоступен в этом Python "
+ "(на Windows нужен пакет windows-curses). Майнер продолжит без TUI.",
+ file=sys.stderr,
+ )
+
+ _setup_logging_for_tui(args.log_file, tui_active)
+
+ if not args.no_banner and not tui_active:
+ # В TUI-режиме баннер ломает curses-кадр; пропускаем.
+ print_banner()
+
if args.benchmark and args.demo:
print("error: --benchmark и --demo взаимоисключающи", file=sys.stderr)
sys.exit(2)
@@ -141,26 +230,101 @@ def main():
else:
session_id = None
+ # ─── stats provider, TUI и healthz ───
+ pool_url = f"{POOL_HOST}:{POOL_PORT}"
+ stats_provider = StatsProvider(pool_url=pool_url)
+
# ─── сетевая часть и mine() ───
stop = threading.Event()
+ restart_event = threading.Event()
client = StratumClient(POOL_HOST, POOL_PORT, args.btc_address, args.worker_name,
stop_event=stop, suggest_diff=args.suggest_diff)
# Сетевая часть живёт в отдельной нити-супервизоре: она держит коннект,
# переподключается при разрывах и сама поднимает reader_loop. main thread
# отдан под mine(), чтобы Ctrl+C ловился предсказуемо.
- supervisor = threading.Thread(target=supervisor_loop, args=(client,),
+ supervisor = threading.Thread(target=supervisor_loop, args=(client, restart_event),
name="stratum-supervisor", daemon=False)
supervisor.start()
+ # Healthz: знаем, что reader жив, если supervisor поднял текущий коннект.
+ # Свежий timestamp хешрейта храним сами через wrap-callable.
+ started_at = time.time()
+ last_hashrate_ts: dict[str, float | None] = {"ts": None}
+
+ def _bump_hashrate_ts() -> None:
+ last_hashrate_ts["ts"] = time.time()
+
+ if metrics_server is not None:
+ def _health_provider() -> dict:
+ snap = stats_provider.snapshot()
+ # reader_alive: считаем sock != None как «коннект жив» — это
+ # не идеально (между connect и subscribe он уже не None),
+ # но достаточно для liveness-зонда.
+ reader_alive = client.sock is not None and supervisor.is_alive()
+ return build_health_snapshot(
+ reader_alive=reader_alive,
+ hashrate_ema=snap.hashrate_ema,
+ hashrate_ts=last_hashrate_ts["ts"],
+ last_share_ts=snap.last_share_ts,
+ started_at=started_at,
+ stale_after_s=args.healthz_stale_after,
+ )
+ metrics_server.set_health_provider(_health_provider)
+
+ # TUI поднимаем сразу — он покажет «жду первый job».
+ tui_app: TUIApp | None = None
+ if tui_active:
+ tui_app = TUIApp(stats_provider, stop_event=stop)
+ tui_app.start()
+
+ # ─── Telegram inbound (опционально) ───
+ if notifier.enabled and TelegramNotifier.inbound_enabled_in_env():
+ def _on_stats() -> str:
+ return _format_stats_message(stats_provider.snapshot())
+
+ def _on_stop() -> str:
+ logger.info("[tg] /stop принят, выставляю stop_event")
+ stop.set()
+ client.close()
+ return "🛑 stop_event установлен, майнер останавливается"
+
+ def _on_restart() -> str:
+ logger.info("[tg] /restart принят, выставляю restart_event")
+ restart_event.set()
+ client.close()
+ return "♻️ restart-сигнал отправлен"
+
+ def _on_help() -> str:
+ return "Доступные команды: /stats /stop /restart /help"
+
+ notifier.register_command("/stats", _on_stats)
+ notifier.register_command("/stop", _on_stop)
+ notifier.register_command("/restart", _on_restart)
+ notifier.register_command("/help", _on_help)
+ notifier.register_command("/start", _on_help)
+ notifier.start_inbound()
+
logger.info(f"[main] жду первый job от пула... (воркеров: {n_workers})")
while client.current_job is None and not stop.is_set():
time.sleep(0.1)
+ # Оборачиваем mine() так, чтобы успевать обновлять last_hashrate_ts:
+ # внутри mine() уже идут update_hashrate-ы, но timestamp нужен снаружи
+ # (для healthz). Делаем это через monkey-патч update_hashrate.
+ _orig_update = stats_provider.update_hashrate
+
+ def _wrapped_update(ema: float, last_sample: float, workers: int) -> None:
+ _orig_update(ema, last_sample, workers)
+ _bump_hashrate_ts()
+
+ stats_provider.update_hashrate = _wrapped_update # type: ignore[method-assign]
+
try:
if not stop.is_set():
mine(client, stop, n_workers=n_workers,
- store=store, metrics=metrics, notifier=notifier)
+ store=store, metrics=metrics, notifier=notifier,
+ stats_provider=stats_provider)
except KeyboardInterrupt:
logger.info("[main] остановка по Ctrl+C")
finally:
@@ -168,6 +332,8 @@ def main():
# → join всех нитей. Никаких висячих daemon'ов.
stop.set()
client.close()
+ if tui_app is not None:
+ tui_app.stop()
supervisor.join(timeout=5)
if supervisor.is_alive():
logger.warning("[main] supervisor не остановился за 5с")
diff --git a/src/hope_hash/miner.py b/src/hope_hash/miner.py
index 280a42b..3440b70 100644
--- a/src/hope_hash/miner.py
+++ b/src/hope_hash/miner.py
@@ -1,5 +1,7 @@
"""Главный цикл хеширования mine() и сетевой супервизор переподключений."""
+from __future__ import annotations
+
import queue
import socket
import threading
@@ -13,6 +15,7 @@
from .parallel import start_pool, stop_pool
from .storage import ShareStore
from .stratum import StratumClient
+from .tui import StatsProvider
# ─────────────────────── сетевой супервизор ───────────────────────
@@ -37,11 +40,20 @@ def run_session(client: StratumClient) -> threading.Thread:
return t
-def supervisor_loop(client: StratumClient) -> None:
+def supervisor_loop(
+ client: StratumClient,
+ restart_event: Optional[threading.Event] = None,
+) -> None:
"""
Поднимает соединение и переподключается с экспоненциальным backoff
(1с → 2с → 4с → ... до 60с) пока stop_event не выставлен.
Запускается в отдельной нити, чтобы main thread мог крутить mine().
+
+ ``restart_event`` (опционально) — сигнал «дёрнуть текущий коннект и
+ переподключиться». Используется обработчиком ``/restart`` из Telegram.
+ Будучи установленным, закрывает сокет (это разбудит reader_loop, и
+ тот вернёт управление); supervisor увидит выход reader-а и пойдёт на
+ новый цикл connect/subscribe.
"""
backoff = 1
while not client.stop_event.is_set():
@@ -52,6 +64,11 @@ def supervisor_loop(client: StratumClient) -> None:
# Ждём, пока reader не выйдет (по ошибке сети или stop_event).
while reader_thread.is_alive() and not client.stop_event.is_set():
reader_thread.join(timeout=1.0)
+ if restart_event is not None and restart_event.is_set():
+ logger.info("[net] получен restart-сигнал, закрываем сессию")
+ client.close() # разбудит reader_loop через recv-ошибку
+ restart_event.clear()
+ break
except (ConnectionError, socket.error, OSError) as e:
logger.warning(f"[net] не удалось подключиться: {e}")
except Exception:
@@ -109,6 +126,7 @@ def mine(
store: Optional[ShareStore] = None,
metrics: Optional[Metrics] = None,
notifier: Optional[TelegramNotifier] = None,
+ stats_provider: Optional[StatsProvider] = None,
) -> None:
"""
Оркестратор пула воркеров.
@@ -150,6 +168,11 @@ def _on_share_result(req_id: int, accepted: bool) -> None:
metrics.counter_inc(label, 1,
help="Shares confirmed accepted by pool" if accepted
else "Shares rejected by pool")
+ if stats_provider is not None:
+ stats_provider.record_share(accepted=accepted)
+ # ВАЖНО: notify_share_accepted дёргается ТОЛЬКО из callback'а пула,
+ # НЕ из submit-пути. Это закрывает дыру, когда мы радуемся «принят»
+ # ещё до подтверждения и потом удивляемся reject-ам в счётчике.
if notifier is not None and accepted:
notifier.notify_share_accepted(job_id=job_id, difficulty=diff)
@@ -238,6 +261,10 @@ def _on_share_result(req_id: int, accepted: bool) -> None:
"hopehash_shares_total", 1,
help="Total shares submitted to pool (pending pool confirmation)",
)
+ if stats_provider is not None:
+ # accepted=None: пока не подтверждено пулом, просто
+ # инкрементируем «отправлено» и фиксируем ts.
+ stats_provider.record_share(accepted=None)
except queue.Empty:
pass
@@ -267,6 +294,13 @@ def _on_share_result(req_id: int, accepted: bool) -> None:
"hopehash_workers", float(len(processes)),
help="Number of active worker processes",
)
+ if stats_provider is not None:
+ stats_provider.update_hashrate(
+ ema=ema, last_sample=sample, workers=len(processes),
+ )
+ stats_provider.update_job(
+ job_id=current_job_id, pool_difficulty=current_diff,
+ )
prev_count = cur
last_report = now
diff --git a/src/hope_hash/stratum.py b/src/hope_hash/stratum.py
index ffb7c0a..995dffd 100644
--- a/src/hope_hash/stratum.py
+++ b/src/hope_hash/stratum.py
@@ -1,28 +1,36 @@
"""Stratum V1 клиент: TCP-сокет, JSON line-delimited, обработка mining.* сообщений."""
+from __future__ import annotations
+
import json
import socket
import threading
-from typing import Callable, Optional
+from typing import Any, Callable, Optional
from ._logging import logger
class StratumClient:
- def __init__(self, host: str, port: int, btc_address: str, worker_name: str = "py01",
- stop_event: Optional[threading.Event] = None,
- suggest_diff: Optional[float] = None) -> None:
- self.host = host
- self.port = port
- self.username = f"{btc_address}.{worker_name}"
- self.sock = None
- self.buf = b""
- self.req_id = 0
- self.extranonce1 = ""
- self.extranonce2_size = 0
- self.difficulty = 1.0
- self.current_job = None
- self.job_lock = threading.Lock()
+ def __init__(
+ self,
+ host: str,
+ port: int,
+ btc_address: str,
+ worker_name: str = "py01",
+ stop_event: Optional[threading.Event] = None,
+ suggest_diff: Optional[float] = None,
+ ) -> None:
+ self.host: str = host
+ self.port: int = port
+ self.username: str = f"{btc_address}.{worker_name}"
+ self.sock: Optional[socket.socket] = None
+ self.buf: bytes = b""
+ self.req_id: int = 0
+ self.extranonce1: str = ""
+ self.extranonce2_size: int = 0
+ self.difficulty: float = 1.0
+ self.current_job: Optional[dict[str, Any]] = None
+ self.job_lock: threading.Lock = threading.Lock()
# Общий флаг остановки: даёт reader_loop и mine() согласованно завершаться,
# чтобы при ошибке в одной нити вторая не «висла» молча.
self.stop_event = stop_event if stop_event is not None else threading.Event()
@@ -41,7 +49,7 @@ def connect(self) -> None:
self.sock = socket.create_connection((self.host, self.port), timeout=30)
logger.info(f"[net] подключён к {self.host}:{self.port}")
- def _send(self, method: str, params: list) -> int:
+ def _send(self, method: str, params: list[Any]) -> int:
if self.sock is None:
raise OSError("сокет не подключён")
self.req_id += 1
@@ -89,7 +97,7 @@ def subscribe_and_authorize(self) -> None:
if self.suggest_diff is not None:
self.suggest_difficulty(self.suggest_diff)
- def _handle_message(self, msg: dict) -> None:
+ def _handle_message(self, msg: dict[str, Any]) -> None:
method = msg.get("method")
params = msg.get("params", []) or []
From 2ddbbd0127195eb57d4f7d1e9aa3097892ff56ee Mon Sep 17 00:00:00 2001
From: devAsmodeus
Date: Sat, 2 May 2026 12:49:13 +0300
Subject: [PATCH 8/9] feat(grafana): importable v0.5.0 dashboard
Minimal Grafana 10.x dashboard with 5 panels: hashrate timeseries,
pool difficulty, shares accepted vs rejected (stacked bar), workers
gauge, uptime stat. Datasource templated as ${DS_PROMETHEUS} so it
imports clean against any Prometheus connection.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
deploy/grafana/hope-hash.json | 383 ++++++++++++++++++++++++++++++++++
1 file changed, 383 insertions(+)
create mode 100644 deploy/grafana/hope-hash.json
diff --git a/deploy/grafana/hope-hash.json b/deploy/grafana/hope-hash.json
new file mode 100644
index 0000000..320459f
--- /dev/null
+++ b/deploy/grafana/hope-hash.json
@@ -0,0 +1,383 @@
+{
+ "annotations": {
+ "list": [
+ {
+ "builtIn": 1,
+ "datasource": {
+ "type": "grafana",
+ "uid": "-- Grafana --"
+ },
+ "enable": true,
+ "hide": true,
+ "iconColor": "rgba(0, 211, 255, 1)",
+ "name": "Annotations & Alerts",
+ "type": "dashboard"
+ }
+ ]
+ },
+ "description": "Hope-Hash solo BTC miner — pure-stdlib Python miner. Minimal dashboard for hashrate, pool difficulty, shares, workers and uptime.",
+ "editable": true,
+ "fiscalYearStartMonth": 0,
+ "graphTooltip": 0,
+ "id": null,
+ "links": [],
+ "liveNow": false,
+ "panels": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisLabel": "H/s",
+ "drawStyle": "line",
+ "fillOpacity": 15,
+ "gradientMode": "opacity",
+ "lineInterpolation": "smooth",
+ "lineWidth": 2,
+ "showPoints": "never",
+ "spanNulls": true
+ },
+ "unit": "Hs"
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 9,
+ "w": 16,
+ "x": 0,
+ "y": 0
+ },
+ "id": 1,
+ "options": {
+ "legend": {
+ "calcs": ["lastNotNull", "mean", "max"],
+ "displayMode": "table",
+ "placement": "bottom"
+ },
+ "tooltip": {
+ "mode": "single"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr": "hopehash_hashrate_hps",
+ "legendFormat": "EMA hashrate",
+ "refId": "A"
+ }
+ ],
+ "title": "Hashrate (EMA, H/s)",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "thresholds"
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ }
+ ]
+ },
+ "unit": "s"
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 4,
+ "w": 8,
+ "x": 16,
+ "y": 0
+ },
+ "id": 2,
+ "options": {
+ "colorMode": "value",
+ "graphMode": "area",
+ "justifyMode": "auto",
+ "orientation": "auto",
+ "reduceOptions": {
+ "calcs": ["lastNotNull"],
+ "fields": "",
+ "values": false
+ },
+ "textMode": "auto"
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr": "hopehash_uptime_seconds",
+ "refId": "A"
+ }
+ ],
+ "title": "Uptime",
+ "type": "stat"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "thresholds"
+ },
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "yellow",
+ "value": null
+ },
+ {
+ "color": "green",
+ "value": 1
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 5,
+ "w": 8,
+ "x": 16,
+ "y": 4
+ },
+ "id": 3,
+ "options": {
+ "colorMode": "background",
+ "graphMode": "none",
+ "justifyMode": "auto",
+ "orientation": "auto",
+ "reduceOptions": {
+ "calcs": ["lastNotNull"],
+ "fields": "",
+ "values": false
+ },
+ "textMode": "value_and_name"
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr": "hopehash_workers",
+ "legendFormat": "workers",
+ "refId": "A"
+ }
+ ],
+ "title": "Workers",
+ "type": "stat"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisLabel": "diff",
+ "drawStyle": "line",
+ "fillOpacity": 0,
+ "lineInterpolation": "stepAfter",
+ "lineWidth": 2,
+ "showPoints": "never"
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 9
+ },
+ "id": 4,
+ "options": {
+ "legend": {
+ "calcs": ["lastNotNull"],
+ "displayMode": "list",
+ "placement": "bottom"
+ },
+ "tooltip": {
+ "mode": "single"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr": "hopehash_pool_difficulty",
+ "legendFormat": "pool diff",
+ "refId": "A"
+ }
+ ],
+ "title": "Pool difficulty",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisLabel": "shares/min",
+ "drawStyle": "bars",
+ "fillOpacity": 80,
+ "lineWidth": 1,
+ "showPoints": "never",
+ "stacking": {
+ "group": "A",
+ "mode": "normal"
+ }
+ }
+ },
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "rejected"
+ },
+ "properties": [
+ {
+ "id": "color",
+ "value": {
+ "fixedColor": "red",
+ "mode": "fixed"
+ }
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "accepted"
+ },
+ "properties": [
+ {
+ "id": "color",
+ "value": {
+ "fixedColor": "green",
+ "mode": "fixed"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 9
+ },
+ "id": 5,
+ "options": {
+ "legend": {
+ "calcs": ["sum"],
+ "displayMode": "table",
+ "placement": "bottom"
+ },
+ "tooltip": {
+ "mode": "multi"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr": "rate(hopehash_shares_accepted_total[5m]) * 60",
+ "legendFormat": "accepted",
+ "refId": "A"
+ },
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr": "rate(hopehash_shares_rejected_total[5m]) * 60",
+ "legendFormat": "rejected",
+ "refId": "B"
+ }
+ ],
+ "title": "Shares per minute (accepted vs rejected, stacked)",
+ "type": "timeseries"
+ }
+ ],
+ "refresh": "10s",
+ "schemaVersion": 38,
+ "style": "dark",
+ "tags": ["hope-hash", "bitcoin", "mining"],
+ "templating": {
+ "list": [
+ {
+ "current": {
+ "selected": false,
+ "text": "Prometheus",
+ "value": "Prometheus"
+ },
+ "hide": 0,
+ "includeAll": false,
+ "label": "Datasource",
+ "multi": false,
+ "name": "DS_PROMETHEUS",
+ "options": [],
+ "query": "prometheus",
+ "queryValue": "",
+ "refresh": 1,
+ "regex": "",
+ "skipUrlSync": false,
+ "type": "datasource"
+ }
+ ]
+ },
+ "time": {
+ "from": "now-1h",
+ "to": "now"
+ },
+ "timepicker": {
+ "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h"]
+ },
+ "timezone": "",
+ "title": "Hope-Hash · solo miner",
+ "uid": "hope-hash",
+ "version": 1,
+ "weekStart": ""
+}
From a367fb8513958b311ec184a5564eddf5395037ff Mon Sep 17 00:00:00 2001
From: devAsmodeus
Date: Sat, 2 May 2026 12:49:26 +0300
Subject: [PATCH 9/9] docs(v0.5.0): changelog, README blurb, ROADMAP ticks,
handoff
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- CHANGELOG.md: v0.5.0 section (TUI, banner, healthz, telegram inbound,
Grafana, type annotations, 145 tests).
- README.md: short "What's new in v0.5.0" paragraph above the status
table; bumped test count 101 → 145. Full README rewrite is PR C.
- ROADMAP.md: ticked TUI (curses), banner, telegram inbound, Grafana,
healthchecks endpoint.
- docs/handoff/pr-a-summary.md: file map, new flags/env vars, gotchas
and open questions for the PR B subagent.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
CHANGELOG.md | 42 +++++++++++
README.md | 13 +++-
ROADMAP.md | 12 +--
docs/handoff/pr-a-summary.md | 138 +++++++++++++++++++++++++++++++++++
4 files changed, 198 insertions(+), 7 deletions(-)
create mode 100644 docs/handoff/pr-a-summary.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6fa1327..1394b5b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,48 @@
## [Unreleased]
+## [0.5.0] — 2026-05-02
+
+### Добавлено
+- **Curses TUI-дашборд** (`tui.py`): `hope-hash --tui`. Постоянное окно с
+ EMA-хешрейтом, аптаймом, шарами (sent/ok/rej), pool diff, текущим job_id,
+ числом воркеров. Quit на `q`/`ESC`/`Ctrl+C`. На Windows без
+ `windows-curses` graceful fallback (warning + продолжаем без TUI),
+ чтобы не ломать `cli.main()` для тех, кто запустил с голым CPython.
+- **`StatsProvider`** (`tui.py`): thread-safe шина между `mine()` и
+ потребителями (TUI/healthz/web). Pure-Python, без curses-зависимостей.
+- **`--no-banner`** и ASCII-логотип (`banner.py`): при старте печатается
+ «HOPE HASH» в ASCII; для cron/systemd подавляется флагом.
+- **Healthcheck endpoint** (`metrics.py`): `GET /healthz` отдаёт JSON
+ `{status, uptime_s, last_share_ts, ...}`. `ok` (200) когда reader жив,
+ EMA свежее 30с, шар в пределах `--healthz-stale-after` секунд.
+ `degraded` (200) — что-то одно подвыпало. `down` (503) — reader умер.
+ Внутри: чистая `build_health_snapshot()` тестируется без сети.
+- **Telegram inbound-команды** (`notifier.py`, `tg_commands` встроены):
+ long-poll `getUpdates` в фоновой нити, диспатч `/stats`, `/stop`,
+ `/restart`, `/help`. Authz по `chat_id`. Включается через
+ `HOPE_HASH_TELEGRAM_INBOUND=1` (по умолчанию off — чтобы не открывать
+ поллер без явного opt-in).
+- **`--log-file PATH`**: дублирует логи в файл. Особенно полезно с `--tui`,
+ где stdout занят дашбордом.
+- **Grafana-дашборд** (`deploy/grafana/hope-hash.json`): минимальный JSON
+ для Grafana 10.x, datasource templated как `prometheus`. Панели:
+ hashrate over time, pool diff, shares accepted vs rejected (stacked
+ bar), workers gauge, uptime stat.
+- **Type annotations** в `miner.py` и `stratum.py` доведены до 100%.
+- **Тесты**: `test_tui.py` (StatsProvider, форматтеры), `test_banner.py`,
+ `test_healthz.py` (snapshot + HTTP), `test_notifier_timing.py`
+ (notify_share_accepted дёргается ТОЛЬКО из ack-callback, не из submit).
+ Расширен `test_notifier.py` (inbound dispatch + chat_id authz).
+ Всего **145 тестов** (было 101).
+
+### Изменено
+- `mine()` принимает опциональный `stats_provider: StatsProvider` —
+ пушит EMA/job/share-события в общую шину, чтобы TUI и healthz видели
+ одно и то же состояние.
+- `supervisor_loop()` принимает опциональный `restart_event` — для
+ обработчика `/restart` из Telegram.
+
## [0.4.0] — 2026-04-30
### Добавлено
diff --git a/README.md b/README.md
index 971b007..1e5e272 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,17 @@
---
+## Что нового в v0.5.0
+
+Ops & UX полировка: `--tui` — curses-дашборд (EMA-хешрейт, шары,
+job_id, аптайм; quit на `q`), ASCII-баннер при старте (`--no-banner`
+для cron), `/healthz` JSON-эндпоинт на `/metrics`-сервере (200/503
+для k8s liveness), Telegram inbound-команды `/stats`/`/stop`/`/restart`
+(opt-in через `HOPE_HASH_TELEGRAM_INBOUND=1`, authz по chat_id),
+готовый Grafana-дашборд в `deploy/grafana/hope-hash.json`. Полный
+API `mine()` теперь принимает `stats_provider: StatsProvider` —
+единая шина данных для TUI и web (web придёт в v0.7.0).
+
## Статус: что уже сделано
- [x] TCP-клиент к `solo.ckpool.org:3333` через стандартную `socket`
@@ -189,7 +200,7 @@ hope-hash
**Тесты:**
```bash
-python -m unittest discover -s tests -v # 101 тест
+python -m unittest discover -s tests -v # 145 тестов
```
**Prometheus-метрики, экспортируемые на `/metrics`:**
diff --git a/ROADMAP.md b/ROADMAP.md
index 7a49e3f..52836fb 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -38,20 +38,20 @@ TUI и команды Telegram — отложены.
### UI/UX
-- [ ] **TUI на `rich`.** Постоянный дашборд со столбиками: текущий хешрейт, средний за час, аптайм, найденные шары, текущий job_id, pool difficulty. Обновляется in-place.
-- [ ] **Альтернатива — `curses` дашборд** для терминалов без поддержки `rich`.
-- [ ] **ASCII-арт логотип** в шапке при старте (когда определишься с названием).
+- [ ] **TUI на `rich`.** Не делаем — `rich` не входит в stdlib. Заменено на `curses` ниже.
+- [x] **`curses` дашборд** (`--tui`, v0.5.0). На Windows degrade без падения.
+- [x] **ASCII-арт логотип** при старте (`banner.py`, v0.5.0). Гасится `--no-banner`.
### Telegram-бот
- [x] **Исходящие уведомления.** `notifier.py` через stdlib `urllib`, без `python-telegram-bot`. События: started / stopped / share_accepted / block_found / disconnected / reconnected. Конфиг через env (`HOPE_HASH_TELEGRAM_TOKEN`, `HOPE_HASH_TELEGRAM_CHAT_ID`). Если переменные не заданы — модуль молча disabled.
-- [ ] **Входящие команды `/stats`, `/restart`, `/stop`** через тот же бот (long polling). Отложено.
+- [x] **Входящие команды `/stats`, `/restart`, `/stop`** (long polling, v0.5.0). Authz по chat_id, opt-in через `HOPE_HASH_TELEGRAM_INBOUND=1`.
### Логи и метрики
- [x] **SQLite-журнал** (`storage.py`). Таблицы `shares` (ts, job_id, nonce_hex, hash_hex, difficulty, accepted, is_block) и `sessions`. WAL-режим, потокобезопасность через `threading.Lock`.
- [x] **Prometheus-экспортёр** (`metrics.py`). Эндпоинт `/metrics` на `http.server` (`ThreadingHTTPServer` в фоновой нити). Метрики: `hopehash_shares_total`, `hopehash_hashrate_hps`, `hopehash_pool_difficulty`, `hopehash_workers`, `hopehash_uptime_seconds`. CLI `--metrics-port` (0 — выключить).
-- [ ] **Grafana-дашборд** с готовым JSON для импорта. Отложено.
+- [x] **Grafana-дашборд** (`deploy/grafana/hope-hash.json`, v0.5.0). 5 панелей: hashrate / pool diff / shares stacked / workers / uptime.
---
@@ -95,7 +95,7 @@ TUI и команды Telegram — отложены.
### Мониторинг и SRE
-- [ ] **Healthchecks endpoint.** Для k8s/docker liveness + readiness.
+- [x] **Healthchecks endpoint.** `/healthz` JSON на metrics-сервере (v0.5.0). 200/503, флаг `--healthz-stale-after`.
- [ ] **Docker-образ.** `Dockerfile`, `docker-compose.yml` с volumes для логов и конфигов.
- [ ] **Helm chart**, если совсем хочется хардкора с k8s на одном Raspberry Pi.
diff --git a/docs/handoff/pr-a-summary.md b/docs/handoff/pr-a-summary.md
new file mode 100644
index 0000000..3efe70b
--- /dev/null
+++ b/docs/handoff/pr-a-summary.md
@@ -0,0 +1,138 @@
+# PR A summary — `feat/ops-and-ux` (v0.5.0)
+
+Closes Level 1 remainder + observability tail. Pure stdlib. No new pip
+dependencies. Tests: **101 → 145** (44 new).
+
+## File map
+
+### Added
+
+| Path | Purpose |
+| --- | --- |
+| `src/hope_hash/banner.py` | ASCII-art logo (`render_banner`, `print_banner`). Called from `cli.main()` unless `--no-banner`. |
+| `src/hope_hash/tui.py` | `StatsProvider` (thread-safe data bus), `StatsSnapshot`, `TUIApp` (curses), `format_rate`/`format_uptime`, `is_curses_available()`. |
+| `deploy/grafana/hope-hash.json` | Importable Grafana 10.x dashboard. Datasource templated as `${DS_PROMETHEUS}`. Panels: hashrate ts, pool diff, shares stacked bar, workers stat, uptime stat. |
+| `tests/test_banner.py` | 6 tests: render non-empty, multiline, contains version, ASCII-only. |
+| `tests/test_tui.py` | 13 tests: provider thread safety, snapshot immutability, share counters, format helpers. |
+| `tests/test_healthz.py` | 11 tests: `build_health_snapshot` matrix + live HTTP `/healthz` smoke. |
+| `tests/test_notifier_timing.py` | 5 tests: notify_share_accepted fires only from pool-ack path, never from submit. |
+| `docs/handoff/pr-a-summary.md` | This file. |
+
+### Modified
+
+| Path | Why |
+| --- | --- |
+| `src/hope_hash/cli.py` | Added flags `--tui`, `--no-banner`, `--log-file`, `--healthz-stale-after`. Wired stats provider, TUI, healthz, telegram inbound. New helpers `_setup_logging_for_tui`, `_format_stats_message`. |
+| `src/hope_hash/miner.py` | `mine()` accepts `stats_provider`, pushes hashrate/job/share events. `supervisor_loop()` accepts `restart_event` for `/restart` command. |
+| `src/hope_hash/metrics.py` | Added `/healthz` route, `MetricsServer.set_health_provider()`, pure `build_health_snapshot()`. Handler factory now takes a mutable container so provider can be swapped after `start()`. |
+| `src/hope_hash/notifier.py` | Inbound long-poll thread (`start_inbound`/`stop_inbound`), `register_command`, `_handle_update`, `_fetch_updates`. Authz by `chat_id`. Sentinel env var `HOPE_HASH_TELEGRAM_INBOUND`. |
+| `src/hope_hash/stratum.py` | `from __future__ import annotations`, attribute type hints, `params: list[Any]` in `_send`, `dict[str, Any]` in `_handle_message`. |
+| `src/hope_hash/__init__.py` | Bump version to `0.5.0`, re-export new symbols. |
+| `tests/test_notifier.py` | +7 tests for inbound dispatch & chat_id authz. |
+| `CHANGELOG.md` | v0.5.0 section. |
+| `README.md` | "Что нового в v0.5.0" paragraph; test count 101 → 145. |
+| `ROADMAP.md` | Ticked TUI, banner, telegram inbound, Grafana, healthchecks. |
+
+## New CLI flags
+
+| Flag | Default | Notes |
+| --- | --- | --- |
+| `--tui` | off | curses dashboard. Windows graceful skip if `windows-curses` not installed. |
+| `--no-banner` | off | suppress ASCII banner (cron/systemd). |
+| `--log-file PATH` | none | duplicate logs to file (essential with `--tui`). |
+| `--healthz-stale-after SEC` | 600 | window after which `/healthz` flips to `degraded`. |
+
+## New env vars
+
+| Name | Values | Purpose |
+| --- | --- | --- |
+| `HOPE_HASH_TELEGRAM_INBOUND` | `1`/`true`/`yes`/`on` | opt-in for the long-poll command thread. Default off so we don't spawn a network thread without explicit ack. |
+
+## New endpoints
+
+- `GET /healthz` on the existing `--metrics-port`. JSON. 200 for `ok`/`degraded`, 503 for `down`. Schema:
+ ```
+ {"status": "ok|degraded|down",
+ "reason": "...|null",
+ "uptime_s": float,
+ "hashrate_ema": float,
+ "hashrate_ts": float|null,
+ "last_share_ts": float|null}
+ ```
+
+## Architecture additions
+
+- `StatsProvider` is the canonical data bus. `mine()` pushes; consumers
+ read. Used today by TUI and `/healthz`. Will be used by `/api/stats`
+ in PR C (web). All access is `threading.Lock`-guarded.
+- `MetricsServer.set_health_provider(callable -> dict)` — register a
+ callable that returns a dict (must include `status`). Handler reads
+ this on every request via a one-slot mutable container, so the
+ provider can be swapped after `start()`.
+- Telegram inbound is a separate daemon thread inside `TelegramNotifier`.
+ It does NOT share queue with outbound — only acks land in outbound
+ queue. Authz happens before dispatch (chat_id mismatch → warning log,
+ drop the update).
+
+## Gotchas for PR B
+
+1. **Don't change `mine()` signature** without preserving keyword-only
+ compatibility — it's now public-API-shaped (positional + kwargs).
+ PR B's solo mode and multi-pool will likely want to wrap mine in a
+ different way; prefer a thin façade module over modifying `mine()`.
+2. **`StatsProvider` is the canonical data bus.** When you add multi-pool
+ failover, push the active pool URL via `provider._snap.pool_url`
+ under the lock (or add a `update_pool(url)` method). The TUI shows it.
+3. **Healthz reader_alive heuristic** in `cli.py` checks
+ `client.sock is not None and supervisor.is_alive()`. With multi-pool
+ failover the meaning of "current client" changes — you may need to
+ pass a callable that knows about the active connection.
+4. **Telegram `/stop` calls `client.close()` directly** to wake reader.
+ With multi-pool the close target is whatever connection is current;
+ keep the indirection through the supervisor.
+5. **Curses on Windows** silently degrades. Don't add try/except around
+ `import curses` elsewhere — use `tui.is_curses_available()` instead
+ to keep the branch test points consolidated.
+6. **`cli.main()` monkey-patches `stats_provider.update_hashrate`** to
+ capture the timestamp for healthz. This is intentional (avoids
+ threading another arg through `mine()`) but if you refactor it,
+ move the hashrate-ts bookkeeping into `StatsProvider` itself
+ (e.g. `last_hashrate_ts` field) and read it from healthz directly.
+7. **Notifier inbound thread is daemon=True.** On hard kill it dies
+ silently. `shutdown()` is the clean path. Don't add network calls
+ in handler functions — they execute in the inbound thread and a
+ long urlopen will block subsequent commands.
+8. **Test isolation:** `test_healthz.py` binds free ports and starts
+ real HTTP servers. On busy CI matrix this can flake if a port is
+ grabbed in the gap between `_free_port()` and `bind`. We accept
+ this — see the comment in `_free_port`.
+
+## Open questions for PR B
+
+- Should `/restart` also re-init the stats provider? Currently it only
+ bounces the TCP connection, hashrate EMA continues. Probably correct,
+ but worth a confirm.
+- Should `/healthz` know about multi-pool? E.g. degraded if all pools
+ down, ok if at least one is up. Spec says yes — design the provider
+ callable to take a list of clients in PR B.
+- Telegram inbound: should `/stats` include the active pool name when
+ multi-pool ships? Recommend yes — the user will care.
+- ctypes SHA-256 backend: when it lands, the bench mode comparison
+ matrix in `bench.py` should pull from `StatsProvider` so the TUI
+ can show "active backend" too.
+- Solo mode (`getblocktemplate`): the healthz `last_share_ts` field
+ loses meaning when there's no pool to send to. Add a separate
+ `last_block_template_ts` for the solo path.
+
+## Verification
+
+```
+py -3.11 -m unittest discover -s tests -v
+# Ran 145 tests in ~10s — OK
+py -3.11 -m hope_hash --help # CLI parses
+py -3.11 -m hope_hash --benchmark --bench-duration 1 --workers 1 # banner + bench
+```
+
+No `pip install` was performed. No third-party imports added under
+`src/hope_hash/`. All hot-path code in `parallel.py`/`block.py` left
+untouched.