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.