GitHub Actions
fix: node identity, Gradio 6 chatbot format, peer discovery; add routing+special-node docs; screenshots
e6bca78
|
Raw
History Blame
18.3 kB

HearthNet β€” HOWTO Guide

This document answers the most common setup and usage questions.


Table of Contents

  1. Quick Start (single machine)
  2. Raspberry Pi Setup
  3. How Nodes Discover Each Other
  4. Connecting from a Second Device / Browser
  5. Adding Content to the RAG Knowledge Base
  6. Configuring LLM Backends
  7. Creating and Managing a Community
  8. Inviting Other Nodes
  9. How to Extend HearthNet (developer)
  10. Troubleshooting
  11. How Routing Works
  12. Creating a Special-Feature Node

1. Quick Start

# Install Python 3.11+
pip install -e ".[dev]"

# Start the Gradio UI (opens at http://127.0.0.1:7860)
python app.py

# Or via the CLI:
python -m hearthnet.cli run

The node starts with:

  • mDNS announcement (LAN discovery)
  • UDP multicast announcement (fallback)
  • A local-only Gradio UI at http://127.0.0.1:7860
  • Demo LLM (echo fallback until a real backend is configured)

2. Raspberry Pi Setup

HearthNet runs on a Raspberry Pi 4 (4 GB) or Pi 5.

Recommended model for Pi

MiniCPM3-4B via Ollama or llama.cpp β€” fits in 4 GB RAM.

# 1. Install on Pi (Raspberry Pi OS 64-bit bookworm)
sudo apt update && sudo apt install python3-pip git -y
git clone https://github.com/HearthNet/hearthnet
cd hearthnet
pip install -e .

# 2. Install Ollama (optional but recommended)
curl -fsSL https://ollama.com/install.sh | sh
ollama pull qwen2.5:3b          # ~2 GB, fast on Pi 5
# or
ollama pull minicpm3:4b         # if available

# 3. Create config
mkdir -p ~/.hearthnet
cat > ~/.hearthnet/config.toml << 'EOF'
[identity]
auto_generate = true

[transport]
host = "0.0.0.0"     # listen on all interfaces so LAN clients can connect
port = 7080

[discovery]
mdns_enabled = true
udp_enabled  = true

[ui]
host = "0.0.0.0"   # serve Gradio on all interfaces
port = 7860

[[llm.backends]]
name = "ollama"
url  = "http://localhost:11434"
EOF

# 4. Run
python -m hearthnet.cli run

Open http://<pi-ip>:7860 from any browser on the LAN.

Auto-start on boot (systemd)

# /etc/systemd/system/hearthnet.service
[Unit]
Description=HearthNet Community AI
After=network.target

[Service]
User=pi
WorkingDirectory=/home/pi/hearthnet
ExecStart=/home/pi/.local/bin/python -m hearthnet.cli run
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
sudo systemctl enable hearthnet
sudo systemctl start hearthnet

3. Discovery

HearthNet uses three discovery methods (in priority order):

mDNS (LAN β€” automatic)

Every node announces itself as _hearthnet._tcp.local. using Zeroconf.
No configuration needed. Works on any LAN where mDNS is not blocked.

Node A starts β†’ announces _hearthnet._tcp.local. via mDNS
Node B starts β†’ discovers Node A, sees its capabilities, registers them on its bus

UDP multicast (LAN β€” fallback)

Uses multicast group 239.255.42.42:42424.
Works when mDNS is blocked by a firewall or managed switch.

Relay tier (WAN β€” Phase 2)

For nodes behind NAT or across the internet, configure a relay URL:

[discovery]
relay_urls = ["https://your-relay.example.com"]

See docs/p2_p3/M15-relay-tier.md.

Checking connected peers

In the UI: Settings tab β†’ "Connected Peers & Capabilities" β†’ click Refresh.

Via CLI:

python -m hearthnet.cli status
python -m hearthnet.cli caps --remote-only

4. Multi-Device / Multi-Browser

Two browsers on the same LAN

  1. Start HearthNet on one machine with host = "0.0.0.0" in config.toml
  2. Open http://<machine-ip>:7860 in any browser on the LAN

Both browsers connect to the same node β€” they share the same bus, peer list, and capabilities.

Two separate nodes (two machines)

  1. Machine A: python -m hearthnet.cli run
  2. Machine B: python -m hearthnet.cli run
  3. Both must be on the same LAN (mDNS) or share a relay URL

Once discovered, Machine B's bus sees Machine A's capabilities (e.g. llm.chat@1.0).
Calls made from Machine B's UI automatically route to whichever node has the best-scoring provider.

Testing two clients in one browser (different tabs / incognito)

Each browser tab that opens the Gradio UI is just a view onto the same node.
To simulate two truly independent clients, run two nodes on different ports:

# Terminal 1
HEARTHNET_TRANSPORT_PORT=7081 HEARTHNET_UI_PORT=7861 python -m hearthnet.cli run

# Terminal 2
HEARTHNET_TRANSPORT_PORT=7082 HEARTHNET_UI_PORT=7862 python -m hearthnet.cli run

Open http://127.0.0.1:7861 and http://127.0.0.1:7862 in two browser tabs.
Both nodes discover each other via mDNS within a few seconds.

Playwright E2E test for two nodes

# tests/test_e2e_playwright.py already includes:
# - TestUiLoads    β€” all 6 tabs present
# - TestAskTab     β€” real LLM/fallback response
# - TestResponsiveLayout β€” mobile viewport

Run:

python -m pytest tests/test_e2e_playwright.py -v

5. RAG β€” Adding to the Knowledge Base

Via the UI (Settings tab β†’ RAG β€” Ingest Documents)

  1. Open the Settings tab
  2. Expand "RAG β€” Ingest Documents"
  3. Enter a corpus name (default: community)
  4. Upload a .txt, .md, or .pdf file
  5. Click Ingest

The document is chunked (1000 tokens, 200-token overlap), embedded, and stored in ChromaDB.

Via CLI

python -m hearthnet.cli rag ingest ./docs/emergency-procedures.md --corpus community
python -m hearthnet.cli rag ingest ./manuals/first-aid.pdf --corpus medical

# List corpora
python -m hearthnet.cli rag list

Via the bus (programmatic)

result = await bus.call(
    "rag.ingest", (1, 0),
    {"input": {
        "corpus": "community",
        "doc_title": "Emergency procedures",
        "text": "... full document text ...",
    }}
)

Using RAG in the Ask tab

Select a corpus from the dropdown in the Ask tab. HearthNet retrieves
the top-k most relevant chunks and provides them as context to the LLM.


6. LLM Backends

HearthNet tries backends in this order:

Priority Backend When to use
1 Ollama Best UX. Zero-config. ollama serve + ollama pull <model>
2 llama.cpp HTTP Direct GPU control. Start with ./server -m model.gguf
3 OpenBMB / MiniCPM Small local models (4–8B). Pi-friendly
4 Nemotron NVIDIA cloud or NIM server
5 Generic OpenAI-compat LM Studio, vLLM, any OpenAI-compatible server
6 HF Transformers Last resort local inference

Cloud APIs (OpenAI, Nemotron cloud) are never the default β€” they require explicit config and are automatically deregistered when the node goes offline.

Ollama (recommended)

# Install: https://ollama.com
ollama pull llama3.2:3b      # 2 GB β€” works on 4 GB RAM
ollama pull qwen2.5:7b       # 5 GB β€” good quality
ollama pull minicpm3:4b      # 3 GB β€” Pi-friendly
[[llm.backends]]
name = "ollama"
url  = "http://localhost:11434"

llama.cpp HTTP server

./server -m models/qwen2.5-7b-q4_k_m.gguf --port 8080 -c 4096
[[llm.backends]]
name  = "llama_cpp"
url   = "http://localhost:8080"
model = "qwen2.5-7b"

OpenBMB MiniCPM (via vLLM)

vllm serve openbmb/MiniCPM4-8B --port 8000
[[llm.backends]]
name  = "openbmb"
url   = "http://localhost:8000"
model = "openbmb/MiniCPM4-8B"

Nemotron (cloud or NIM)

export NVIDIA_API_KEY=nvapi-xxx
[[llm.backends]]
name        = "nemotron"
url         = "https://integrate.api.nvidia.com/v1"
model       = "nvidia/nemotron-mini-4b-instruct"
api_key_env = "NVIDIA_API_KEY"

7. Creating and Managing a Community

A community is a signed group manifest with member trust levels.

Create a new community

python -m hearthnet.cli init --name "My Neighborhood" --profile anchor

This:

  1. Generates Ed25519 keys in ~/.hearthnet/keys/
  2. Creates a community manifest signed by the root key
  3. Writes ~/.hearthnet/config.toml

Join an existing community

python -m hearthnet.cli invite redeem "hnvite://v1/..."

Check community status

python -m hearthnet.cli status

8. Inviting Other Nodes

Generate an invite link (UI)

Settings tab β†’ "Invite a Node" β†’ enter trust level β†’ click Generate Invite Link.

Generate an invite link (CLI)

python -m hearthnet.cli invite create --node-id ed25519:xxx --level member
# Prints: hnvite://v1/...

Redeem on the new node

python -m hearthnet.cli invite redeem "hnvite://v1/..."

Mobile (M22)

The mobile app (Flutter) can scan a QR code displayed by:

python -m hearthnet.cli invite create --qr

Or via the Settings tab β†’ Invite a Node β†’ the link can be pasted into the app's
"Join Community" screen.


9. Extending HearthNet

Adding a new capability (service)

  1. Create hearthnet/services/myservice/service.py
# Spec reference: docs/M03-bus.md Β§4 (Service Protocol)
from hearthnet.services.base import Service
from hearthnet.bus.capability import CapabilityDescriptor, RouteRequest

class MyService(Service):
    name = "myservice"
    version = "1.0"

    def capabilities(self):
        desc = CapabilityDescriptor(
            name="myservice.do@1.0",
            version=(1, 0),
            stability="beta",
            request_schema={},
            response_schema=None,
            stream_schema=None,
            params={},
            max_concurrent=4,
            trust_required="member",
            timeout_seconds=30,
            idempotent=True,
        )
        return [(desc, self.handle_do, None)]

    async def handle_do(self, req: RouteRequest) -> dict:
        inp = req.body.get("input", {})
        return {"output": {"result": f"processed: {inp}"}, "meta": {}}

    async def start(self): pass
    async def stop(self): pass
    def health(self): return {"status": "ok"}
  1. Register with the bus in hearthnet/node.py:
from hearthnet.services.myservice.service import MyService
bus.register_service(MyService())
  1. Add tests in tests/test_myservice.py.

Adding a new LLM backend

Implement LlmBackend (Protocol in hearthnet/services/llm/backends/base.py):

# Spec: docs/M04-llm.md Β§3.1
class MyLlmBackend:
    name = "myllm"
    models = [BackendModel(name="my-model", family="local", context_length=8192, requires_internet=False)]

    async def chat(self, messages, *, model, stream=False, temperature=0.7, max_tokens=1024, **kw):
        ...  # call your server, return ChatResult or AsyncIterator[Token]

    async def complete(self, prompt, *, model, **kw): ...
    async def warm(self): pass
    async def close(self): pass
    def health(self): return {"status": "ok"}

Then register it in LlmService.__init__ alongside the other backends.

Adding a new UI tab

  1. Create hearthnet/ui/tabs/mytab.py
# Spec: docs/M08-ui.md Β§5
def build_mytab(bus=None):
    import gradio as gr
    with gr.Column():
        gr.Markdown("### My Tab")
        ...
  1. Add it to hearthnet/ui/app.py inside the gr.Tabs() block:
with gr.Tab("MyTab"):
    from hearthnet.ui.tabs.mytab import build_mytab
    build_mytab(self._bus)

10. Troubleshooting

No LLM responses

  1. Check Ollama is running: ollama list
  2. Check python -m hearthnet.cli doctor
  3. Check python -m hearthnet.cli caps β€” does llm.chat@1.0 appear?

Peers not discovered

  1. Are both machines on the same LAN subnet?
  2. Is mDNS blocked? Try enabling UDP fallback in config
  3. python -m hearthnet.cli status β€” what does it show?

RAG returns no results

  1. Did you ingest documents? Settings tab β†’ RAG β€” Ingest Documents
  2. python -m hearthnet.cli rag list β€” are corpora listed?
  3. Embedding model must be loaded β€” check python -m hearthnet.cli doctor

Config file location

~/.hearthnet/config.toml   (Linux/macOS)
%USERPROFILE%\.hearthnet\config.toml   (Windows)

Log files

python -m hearthnet.cli log --follow
# Or look at:
~/.hearthnet/logs/hearthnet.log

Emergency mode stuck "offline"

# Force a connectivity check:
python -m hearthnet.cli call emergency.probe@1.0 '{}'
# Or in UI: Emergency tab β†’ Run Connectivity Probe

11. How Routing Works

Spec: docs/M03-bus.md Β§3.5 / Β§5.4
Implementation: hearthnet/bus/router.py, hearthnet/bus/__init__.py

The capability bus

Every node has a CapabilityBus. Services register their capabilities on startup.
When another node or the UI calls bus.call("llm.chat", (1, 0), body), the bus:

  1. Route selection β€” Router.route(req) scores all registered providers (local and remote) using the score formula below.
  2. Local-first β€” local handlers always get priority over remote ones (lower latency, no serialization).
  3. Call dispatch β€” if local: await entry.handler(req); if remote: await transport.call(node_id, req).
  4. Health update β€” HealthTracker.record(entry, success, latency_ms) updates rolling success-rate and latency EMA.

Score formula

score = (1.0 if is_local else 0.5)
      + success_rate * 0.3
      - (in_flight / max_concurrent) * 0.2
      + (1.0 if not quarantined else -999)

A quarantined entry (repeated failures) scores -999 and is skipped until the cooldown expires.

Params-based routing

When a capability descriptor is registered with params (e.g. {"corpus": "medical", "requires_internet": False}), the router also checks whether the caller's body["params"] are compatible:

def _corpus_matches(offered: dict, requested: dict) -> bool:
    return requested.get("corpus", offered["corpus"]) == offered["corpus"]

This lets multiple nodes serve the same capability name with different parameters, and callers select the one that matches their requirements.

Sticky sessions (M10 Chat)

Pass session_id to bus.call(...) to pin subsequent calls to the same node:

result = await bus.call("chat.history", (1, 0), body, session_id="s:abc123")

Subsequent calls with the same session_id route to the same entry (sticky routing).

Routing a call to a specific node

Use the InMemoryTransport directly (in tests) or send an HTTP request to the transport server:

POST http://<node-host>:7080/call
Content-Type: application/json

{
  "capability": "llm.chat",
  "version_req": [1, 0],
  "body": {"input": {"messages": [{"role": "user", "content": "Hello"}]}}
}

Offline/Emergency routing

When Detector.apply_probe_results({"internet": False}) marks the node offline:

  • All capabilities with requires_internet=True are deregistered from the bus
  • Calls that were routed to internet-only providers now fail over to local providers
  • This is automatic β€” callers see no difference

12. Creating a Special-Feature Node

A special-feature node is any node where you register a non-default set of capabilities.

OCR-only node (medical document reading)

# hearthnet/examples/ocr_node.py
from hearthnet.node import HearthNode
from hearthnet.services.ocr.service import OcrService  # M17
from hearthnet.ui.app import build_ui

node = HearthNode("ocr-node-01", "OCR Specialist", "ed25519:your-community")
node.bus.register_service(OcrService(backend="tesseract"))
# Do NOT install LLM or RAG services if this node should only do OCR

ui = build_ui(bus=node.bus, display_name=node.display_name, node_id=node.node_id, community_id=node.community_id)
ui.build().launch(server_port=7865)

Any other node in the community can now call ocr.extract@1.0 and the bus
automatically routes it to this specialist node.

Medical-RAG node (EBKH evidence base)

from hearthnet.services.demo import RagService
from hearthnet.services.llm.service import LlmService

# Install LLM with a domain-specific system prompt
llm = LlmService(model="ollama:meditron-7b")
rag = RagService(corpus="medical-ebkh")

node.bus.register_service(llm)
node.bus.register_service(rag)

# Optionally seed the RAG corpus on startup
import asyncio
asyncio.run(node.rag.ingest("medical-ebkh", title="WHO First Aid", text="..."))

Anchor node (high-availability, no UI)

An anchor node (profile="anchor") is designed for always-on servers or Pis:

python -m hearthnet.cli run --profile anchor --no-ui
[identity]
profile = "anchor"

[transport]
host = "0.0.0.0"
port = 7080

[ui]
enabled = false   # anchor nodes typically don't serve a web UI

Anchor nodes act as relay points (M15) and capability hubs. Other nodes discover them and offload compute tasks.

Multilingual translation node (M18)

from hearthnet.services.translation.service import TranslationService

node.bus.register_service(
    TranslationService(backend="helsinki-nlp", languages=["de", "fr", "es", "ar"])
)

Callers use translation.translate@1.0 with {"input": {"text": "...", "source": "en", "target": "de"}}.

Civil Defense node (M31) β€” emergency broadcast

from hearthnet.services.civil_defense.service import CivilDefenseService

node.bus.register_service(
    CivilDefenseService(
        broadcast_endpoints=["239.255.42.42:42425"],  # UDP multicast
        priority_filter="critical",
    )
)

Combining capabilities (full-service node)

node.install_demo_services()  # LLM, RAG, Marketplace, Chat

# Then add specialist services on top:
node.bus.register_service(OcrService())
node.bus.register_service(TranslationService())
node.bus.register_service(SttTtsService())

The bus merges all capabilities. Peer nodes discover all of them via the manifest exchange.

Verified capabilities in the UI

The Settings tab β†’ "Connected Peers & Capabilities" β†’ Refresh shows the live list
of what each peer node offers. You can verify routing is correct before deploying.