Async-first Factorio server administration toolkit: live log telemetry, RCON control, Telegram/Slack bots, WebSocket streaming, and Matplotlib analytics in one Python service.
Note
This repository previously shipped without a README.md. This document was reverse-engineered from the source tree (main.py, config.py, settings.py, core/, parsers/, persistence/, bots/, web/, visuals/, storage/, tests/, pyproject.toml, config.yaml) so that every claim below is traceable to real code.
Important
The service talks to a live Factorio dedicated server over Source RCON (TCP) and tails its live log file. You need network reachability to the RCON port and filesystem access to factorio-current.log before anything else will work.
- Factorio Admin Devkit
- Table of Contents
- Features
- Tech Stack & Architecture
- Getting Started
- Testing
- Deployment
- Usage
- Configuration
- License
- Support the Project
Exhaustive capability inventory of the Devkit:
Log ingestion & parsing
- 🪵 Live async log tailer (
parsers/log_parser.py→LogTailer) that followsfactorio-current.logwithout busy-polling, usingasyncio.to_threadfor blocking I/O. - 🔄 Rotation/truncation aware — detects log shrinkage via
stat().st_sizevs. read offset and rewinds to byte0automatically. - 🧪 Pure-function parsers —
parse_line(line, *, now=None)is side-effect-free and fully unit-testable with zero I/O. - 📊 Three typed event models (frozen, slotted dataclasses):
UpsEvent(timestamp, value)— server ticks-per-second samples (60.0 UPS).PlayerEvent(timestamp, player_name, event_type)—[JOIN]/[LEAVE]presence.ProductionEvent(timestamp, item, count)— cumulativeitem-produced <item> <count>snapshots.
- 🧵 Partial-line buffering — incomplete trailing bytes are held in memory until the terminating
\narrives. - 🧷
FileNotFoundError-tolerant — if the log file does not exist yet (server not started / mid-rotation), the tailer sleeps and retries instead of crashing.
RCON control plane
- 🔌 Persistent async Source RCON client (
core/rcon_client.py→RconClient) over a single long-lived TCP connection. - 📦 Hand-rolled packet codec — little-endian
<iiiheader (size,packet_id,type) + UTF-8 body +\x00\x00terminator, with strict size (10..4096bytes) and terminator validation. - 🚦 Serialized command queue — all
execute()calls flow through oneasyncio.Queue, so concurrent callers can never interleave packets on the wire. - 🔁 Exponential-backoff reconnect —
1s → 2s → 4s …capped atrcon_max_backoff_seconds(default60s), resetting to1son success. - 🔐 Correct AUTH handshake — tolerates the
SERVERDATA_RESPONSE_VALUE-before-SERVERDATA_AUTH_RESPONSEordering quirk; treatsid == -1as terminalRconAuthError(no pointless retries on bad credentials). - ⏱️ Two independent timeouts —
command_timeout(per-command response budget) andconnect_timeout(TCP connect + auth budget). - 🧰 Injectable transport factory — pass
transport_factory: () -> (StreamReader, StreamWriter)to unit-test the full protocol without touching the network. - 🧹 Fail-fast shutdown —
close()cancels pending futures withRconErrorso no caller hangs forever.
Event distribution
- 📡 In-process generic pub/sub (
core/event_bus.py→EventBus[T]) decoupling producers (log tailer) from consumers (persistence, WebSocket). - 🧺 Bounded per-subscriber queues (default
256) with drop-oldest backpressure — slow consumers lose history instead of stalling the producer, preserving real-time semantics. - 🔌 Self-unregistering iterators —
async for event in bus.subscribe()cleans up onbreak, exception, or cancellation. - 🔒 Lock-guarded fan-out — subscriber-set snapshots are taken under
asyncio.Lockfor task-safe publish/subscribe.
Persistence & retention
- 💾 SQLite-backed telemetry store (
persistence/db.py→TelemetryStore) onaiosqlitewithPRAGMA journal_mode=WALfor concurrent read/write. - 🗄️ Three normalized tables —
ups_metrics(ts, value),player_events(ts, player, event),production_stats(ts, item, count)— each with a purpose-built timestamp index. - 🌊 Time-driven batched writes — events are buffered in a
dequeand flushed in a single transaction everydb_flush_interval_seconds(default5s), keeping write amplification near zero under log bursts. - 🔍 Time-windowed queries —
query_ups(since),query_players(since),query_production(since, items=[...])power every graph and bot command. - ⚡ In-memory ring buffer (
storage/metrics_store.py→MetricsStore) — bounded by both age (default24h) and cardinality (default50,000points/metric) for hot-path charting without SQLite round-trips. - 🧪 Test helper —
ingest_stream(events)enqueues and flushes immediately for deterministic integration tests.
Messenger bots
✈️ Telegram controller (bots/telegram_controller.py) onpython-telegram-bot v20+:/status,/players,/kick <player> [reason],/graph [ups|production] [hours].- 💬 Slack controller (
bots/slack_controller.py) onslack-boltSocket Mode with the identical slash-command surface (/status,/players,/kick,/graph) plusfiles_upload_v2chart uploads. - 🛡️ Allow-list auth (Telegram) —
telegram_allowed_user_idsrejects unauthorized users with anUnauthorized.reply. - 🚧 Shared sliding-window rate limiter —
SlidingWindowRateLimiter(max_events=5, window_seconds=30.0)per user ID,asyncio.Lock-guarded and reused by both bots. - 🖼️ In-chat chart delivery — Telegram sends PNGs via
send_document; Slack viafiles_upload_v2. - 🧯 Graceful RCON error mapping —
RconTimeoutError/RconAuthError/RconErrorbecome friendly chat messages, never tracebacks. - 🧭 Shared command router (
bots/command_router.py→CommandRouter) —shlex-based dispatch (status,players,kick,ban,say,graph) mapping text commands to RCON actions and telemetry queries.
Real-time streaming
- 🌐 Authenticated WebSocket broadcaster (
web/ws_server.py) fanning out everyEventBusevent as newline-delimited JSON ({"kind": ..., ...}with ISO-8601 timestamps). - 🔑 Pre-handshake shared-secret auth —
?token=query parameter enforced inprocess_request; mismatches get HTTP403 Forbiddenbefore the WebSocket upgrade completes. - 🧹 Dead-client reaping — send failures trigger
close()+ discard; server shutdown broadcasts close code1001.
Visualization
- 📈 UPS line charts with warning-region shading (
axhspanbelowups_warning_threshold),avgdashed line, and annotatedmin/maxscatter markers. - 📊 Production stacked-bar charts bucketed by configurable
bucket_seconds(default300s), one stack segment per item. - 🧵 Off-thread rendering — every chart runs in the default executor via
run_in_executor(MatplotlibAggbackend), so the event loop never blocks on CPU-bound drawing. - 🪶 Zero-copy-friendly PNG bytes — renderers return raw
bytesready for Telegram/Slack/HTTP upload.
Operations
- 🎛️ Single-binary orchestration (
main.py) — RCON, log ingest, DB flush loop, WebSocket server, and both bots composed under oneasyncio.gatherwithSIGINT/SIGTERMgraceful shutdown. - ⚙️ Dual configuration system —
pydantic-settings+.env(config.py, used bymain.py) and YAML + env-override dataclasses (settings.py+config.yaml). - 📝 Stdlib logging everywhere —
logging.getLogger(__name__)per module; service-task failures are logged withexc_infoinstead of being swallowed. - 📦 Console-script entry point —
factorio-admin = factorio_admin.main:maindeclared inpyproject.toml.
Tip
The fastest way to evaluate the Devkit is: start it against a running Factorio server's log file, connect a WebSocket client with your ?token=, and watch NDJSON telemetry arrive in real time — no bots required.
| Layer | Technology | Version constraint | Role |
|---|---|---|---|
| Language | Python | >=3.11 |
asyncio.timeout, TaskGroup-era idioms, X | None syntax |
| Validation | pydantic |
>=2.6 |
Strongly-typed Settings model |
| Settings | pydantic-settings |
>=2.2 |
.env + environment loading |
| Async SQLite | aiosqlite |
>=0.20 |
Non-blocking TelemetryStore |
| Telegram | python-telegram-bot |
>=20.7 |
Long-polling bot (ApplicationBuilder) |
| Slack | slack-bolt |
>=1.18 |
Socket Mode async app |
| WebSockets | websockets |
>=12.0 |
serve() + pre-handshake auth hook |
| Charts | matplotlib |
>=3.8 |
Agg backend PNG rendering |
| YAML (alt config) | PyYAML |
unpinned | settings.py (yaml.safe_load) |
| Tests | pytest |
>=8.0 |
Unit tests |
| Async tests | pytest-asyncio |
>=0.23 |
asyncio_mode = "auto" |
| Game protocol | Source RCON | hand-rolled | No third-party RCON dependency |
Warning
settings.py imports yaml (PyYAML), but pyproject.toml does not declare it as a dependency. If you use the YAML configuration path, install it explicitly: pip install pyyaml. The primary .env-based path (config.py) does not need it.
Caution
bots/command_router.py uses flat imports (from core.rcon_client import ..., from storage.metrics_store import ..., from visuals.graph_engine import ...) and references visuals.graph_engine.TimeSeries / render_timeseries, which do not exist in the current graph_engine.py (it exposes render_ups_chart / render_production_chart). Treat command_router.py as a legacy/WIP module until its imports and graph API are reconciled. All other modules use the factorio_admin.* package namespace.
📁 Full file tree (click to expand)
Factorio-admin-Devkit/ # Repository root (Python package: factorio_admin)
├── main.py # Entry point: service wiring, asyncio.gather, signal handlers
├── config.py # Primary config: pydantic-settings Settings + load_settings()
├── settings.py # Alt config: YAML dataclasses (Rcon/Telegram/Slack/Settings.from_yaml)
├── config.yaml # Sample YAML config (log_path, websocket_*, rcon/telegram/slack)
├── pyproject.toml # Build metadata, deps, pytest config, console script
├── __init__.py # Package marker: "Factorio administration bot and telemetry service."
├── LICENSE # Apache License 2.0
│
├── core/ # Transport & messaging primitives
│ ├── __init__.py
│ ├── event_bus.py # EventBus[T]: generic fan-out pub/sub, bounded queues
│ └── rcon_client.py # RconClient: Source RCON, queue-serialized, backoff reconnect
│
├── parsers/ # Log ingestion
│ ├── __init__.py
│ └── log_parser.py # parse_line(), Ups/Player/ProductionEvent, LogTailer
│
├── persistence/ # Durable storage
│ ├── __init__.py
│ └── db.py # TelemetryStore: aiosqlite, WAL, batched flush, windowed queries
│
├── storage/ # Ephemeral storage
│ ├── __init__.py
│ └── metrics_store.py # MetricsStore: in-memory TTL+cardinality bounded ring buffer
│
├── bots/ # Messenger control planes
│ ├── __init__.py
│ ├── command_router.py # CommandRouter: shlex dispatch → RCON/telemetry (legacy/WIP)
│ ├── telegram_controller.py # TelegramController + SlidingWindowRateLimiter
│ └── slack_controller.py # SlackController (Socket Mode, reuses rate limiter)
│
├── web/ # Streaming API
│ ├── __init__.py
│ └── ws_server.py # WebSocketBroadcaster: token-gated NDJSON fan-out
│
├── visuals/ # Chart rendering
│ ├── __init__.py
│ └── graph_engine.py # render_ups_chart(), render_production_chart() (Agg, off-thread)
│
└── tests/ # Test suite (pytest, asyncio_mode=auto)
├── __init__.py
├── test_log_parser.py # Pure-parser tests (UPS/join/leave/production/edge cases)
└── test_rcon_client.py # Protocol tests via injected FakeTransport (auth/cmd/timeout)
- Asyncio everywhere, threads only at the edges. The hot path (
LogTailer.stream→EventBus.publish→TelemetryStore.enqueue→WebSocketBroadcaster) never blocks the event loop. Blocking work (file reads, Matplotlib rendering) is pushed to threads viaasyncio.to_thread/run_in_executor. - Parse in pure functions, tail in a thin adapter.
parse_line()has no I/O, so regex behavior is pinned by cheap unit tests;LogTaileronly worries about file offsets, buffering, and rotation. - One RCON connection, one writer. The Source RCON protocol has no multiplexing story worth trusting here, so
RconClientserializes all commands through a single queue + single_dispatch. Concurrency lives in the callers, not on the socket. - Fail loudly on config, degrade gracefully at runtime. Missing secrets (
rcon_password,ws_secret_token,log_file_path) raise at startup via Pydantic validation; transient runtime faults (log file missing, RCON drop, slow WS client) are logged and retried/reaped. - Drop-oldest backpressure. Both
EventBus(per-subscriber256-slot queues) andMetricsStore(TTL + cardinality caps) prefer losing old data over OOMing or stalling producers — the correct trade-off for a live-ops dashboard. - Batch the database. SQLite writes are amortized into one transaction per flush interval, so a burst of 10k log lines costs ~1 commit, not 10k.
- Bots share infrastructure, not code paths. Telegram and Slack reuse the same
RconClientinstance (serialization for free) and the sameSlidingWindowRateLimiterclass, but register independent handlers so one platform's outage cannot wedge the other.
🧩 Deep-dive: service orchestration in main.py (click to expand)
main._run(settings) constructs the dependency graph in dependency order, then fans out:
Settings (load_settings)
├─ EventBus[ParsedEvent]
├─ TelemetryStore ──open()──► flush_loop() task ......... "db_flush"
├─ RconClient ─────────────── run() task ................. "rcon"
├─ LogTailer ─┐
│ └─ _ingest_logs(tailer, bus, store) task ... "log_ingest"
│ └─► for each event: store.enqueue + bus.publish
├─ WebSocketBroadcaster ───── serve_forever() task ....... "ws_server"
├─ TelegramController.start() (if telegram_enabled)
└─ SlackController.start() (if slack_enabled)
Shutdown (SIGINT/SIGTERM or first task completion):
tailer.stop() → ws.stop() → telegram.stop() → slack.stop()
→ cancel all tasks → gather(return_exceptions=True)
→ rcon.close() → store.close() (final flush!)
[!NOTE] Shutdown order is deliberate: producers stop first (
LogTailer), then edge servers (WebSocket, bots), then transports (RconClient), and the database closes last so the finalflush()persists every buffered event.
The core data flow — from raw log bytes to dashboards, databases, and chat:
flowchart LR
A["Factorio Server\nfactorio-current.log"] -->|append bytes| B["LogTailer\n(parsers/log_parser.py)"]
B -->|complete lines| C["parse_line()\nregex → dataclass"]
C -->|UpsEvent| D{"Fan-out\n_ingest_logs"}
C -->|PlayerEvent| D
C -->|ProductionEvent| D
D -->|enqueue| E["TelemetryStore\nSQLite WAL, 5s batch flush"]
D -->|publish| F["EventBus\nbounded fan-out"]
F -->|subscribe| G["WebSocketBroadcaster\nNDJSON + ?token= auth"]
E -->|query_ups / query_production| H["Graph Engine\nMatplotlib Agg PNG"]
H -->|send_document| I["Telegram Bot"]
H -->|files_upload_v2| J["Slack Bot"]
K["Operators"] -->|/status /players /kick /graph| I
K -->|/status /players /kick /graph| J
I -->|execute| L["RconClient\nserialized queue"]
J -->|execute| L
L -->|Source RCON TCP| M["Factorio Server\nconsole commands"]
🔬 Deep-dive: RCON client lifecycle (Mermaid sequence, click to expand)
sequenceDiagram
autonumber
participant Caller as Command caller
participant Q as asyncio.Queue
participant R as RconClient.run()
participant S as Factorio RCON server
Caller->>Q: execute("/players online")
R->>S: TCP connect (connect_timeout)
R->>S: SERVERDATA_AUTH(password)
alt tolerant ordering
S-->>R: SERVERDATA_RESPONSE_VALUE (ignored)
end
S-->>R: SERVERDATA_AUTH_RESPONSE (id != -1)
Note over R: connected.set(); backoff reset to 1s
R->>Q: get() next _Request
R->>S: SERVERDATA_EXECCOMMAND("/players online")
S-->>R: SERVERDATA_RESPONSE_VALUE("...players...")
R->>Caller: future.set_result(response)
alt transport lost
R->>R: log warning; _close_transport()
R->>R: loop → reconnect with backoff
else auth rejected (id == -1)
R->>Q: _fail_all_pending(RconAuthError)
Note over R: run() returns — operator must fix password
end
Packet anatomy (_Packet.encode, _read_packet):
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| size (int32 LE) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| packet id (int32 LE) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| type (int32 LE) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| UTF-8 body (variable) ...
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 0x00 | 0x00 |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
size = 8 + len(body_bytes) + 2 validated: 10 <= size <= 4096
🔬 Deep-dive: SQLite schema & indexes (click to expand)
Applied idempotently by TelemetryStore.open() (CREATE TABLE IF NOT EXISTS + PRAGMA journal_mode=WAL):
CREATE TABLE IF NOT EXISTS ups_metrics (
ts INTEGER NOT NULL, -- epoch seconds (UTC)
value REAL NOT NULL -- ticks per second
);
CREATE TABLE IF NOT EXISTS player_events (
ts INTEGER NOT NULL,
player TEXT NOT NULL,
event TEXT NOT NULL -- 'join' | 'leave'
);
CREATE TABLE IF NOT EXISTS production_stats (
ts INTEGER NOT NULL,
item TEXT NOT NULL, -- e.g. 'iron-plate'
count INTEGER NOT NULL -- cumulative counter snapshot
);
CREATE INDEX IF NOT EXISTS idx_ups_ts ON ups_metrics(ts);
CREATE INDEX IF NOT EXISTS idx_player_ts ON player_events(ts);
CREATE INDEX IF NOT EXISTS idx_prod_ts_item ON production_stats(ts, item);[!TIP] Timestamps are stored as epoch seconds (
int(ts.timestamp())), not ISO strings — range scans onts >= ?stay integer-compared and index-friendly. Conversion helpers_epoch()/_from_epoch()live inpersistence/db.py.
| Requirement | Minimum | Notes |
|---|---|---|
| Python | 3.11+ |
Uses asyncio.timeout, X | None, str.removeprefix-era stdlib |
pip |
23+ |
Or pipx / uv / poetry if you prefer |
| Factorio dedicated server | any with RCON | Enable RCON in server-settings.json ("rcon_port", "rcon_password") |
| Log file access | read | Path to factorio-current.log (local path or mounted volume) |
| SQLite | bundled | Ships with CPython; WAL mode needs a writable directory |
| (Optional) Telegram bot token | — | From @BotFather; only if telegram_enabled=true |
| (Optional) Slack app + bot tokens | — | Socket Mode app with xoxb-* + xapp-*; only if slack_enabled=true |
| (Optional) Docker / Docker Compose | 24+ / v2 |
Only for the containerized deployment path |
| (Optional) PyYAML | any | Only for the YAML config path (settings.py); pip install pyyaml |
Important
Factorio's RCON password is not optional: rcon_password has no default in config.py, and RconClient treats a wrong password as a terminal RconAuthError. Double-check server-settings.json before starting the Devkit.
# 1. Clone the repository
git clone https://github.com/FCTostin-team/Factorio-admin-Devkit.git
cd Factorio-admin-Devkit
# 2. Create and activate an isolated environment (recommended)
python3.11 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
# 3. Install the runtime dependencies
pip install --upgrade pip
pip install -e . # editable install (registers `factorio-admin`)
# 4. Install dev/test extras
pip install -e ".[dev]"
# 5. (YAML-path only) install the undeclared YAML dependency
pip install pyyaml
# 6. Configure secrets — copy the example and edit it
cp .env.example .env 2>/dev/null || true # see Configuration section for full schema
${EDITOR:-nano} .env
# 7. Run the service
python main.py
# — or, after install, via the console script —
factorio-adminNote
The console script is declared as factorio-admin = factorio_admin.main:main in pyproject.toml. If your checkout lays modules flat at the repo root (no factorio_admin/ package directory), prefer python main.py until the packaging layout is normalized — see troubleshooting below.
🛠️ Troubleshooting & alternative installs (click to expand)
A. ModuleNotFoundError: No module named 'factorio_admin'
The source files import each other as factorio_admin.* (e.g. from factorio_admin.core.rcon_client import RconClient), but some checkouts place the modules flat at the repo root. Fix with one of:
# Option 1 — run with the repo root on PYTHONPATH and a package shim:
mkdir -p factorio_admin
ln -s ../core ../parsers ../persistence ../bots ../web ../visuals ../storage factorio_admin/ 2>/dev/null || true
cp main.py config.py factorio_admin/
PYTHONPATH=. python -m factorio_admin.main
# Option 2 — install then run from the packaged location (once layout is normalized):
pip install -e .
python -c "import factorio_admin.main; factorio_admin.main.main()"B. ModuleNotFoundError: No module named 'yaml'
pip install pyyamlOnly required if you use settings.py / config.yaml. The default config.py path does not need it.
C. ModuleNotFoundError: No module named 'telegram' | 'slack_bolt' | 'websockets' | 'matplotlib' | 'aiosqlite'
You skipped the runtime install. Re-run:
pip install -e .
# — or explicitly —
pip install "pydantic>=2.6" "pydantic-settings>=2.2" "aiosqlite>=0.20" \
"python-telegram-bot>=20.7" "slack-bolt>=1.18" "websockets>=12.0" "matplotlib>=3.8"D. RCON authentication failed on first start
- Confirm
server-settings.jsonon the Factorio host has matchingrcon_port/rcon_password. - Confirm
RCON_PASSWORDin your.envmatches exactly (no trailing whitespace/quotes). - Confirm TCP reachability:
nc -vz 127.0.0.1 27015(or yourrcon_host/rcon_port).
E. Log file truncated; rewinding to start spam
Normal during server restarts/log rotation. If it loops forever, your log_file_path points at a file another process truncates continuously — verify the path.
F. Building from source (sdist/wheel)
pip install build
python -m build
ls dist/ # factorio_admin-0.1.0.tar.gz + .whl
pip install dist/factorio_admin-0.1.0-py3-none-any.whlG. Windows notes
- Use
pythoninstead ofpython3.11, and.venv\Scripts\activate. SIGINT/SIGTERMhandlers degrade gracefully (NotImplementedErroris caught); useCtrl+Cto stop.- Prefer forward slashes or escaped backslashes in
LOG_FILE_PATH, e.g.C:/factorio/factorio-current.log.
The suite uses pytest with pytest-asyncio in auto mode (asyncio_mode = "auto" in pyproject.toml), so async def test_... functions just work — no decorators required (though the RCON tests still carry explicit @pytest.mark.asyncio markers).
# Activate your venv first
source .venv/bin/activate
# 1. Full suite (unit + protocol tests)
pytest -v
# 2. Parser tests only (pure functions, no I/O — milliseconds)
pytest tests/test_log_parser.py -v
# 3. RCON protocol tests only (FakeTransport, no network)
pytest tests/test_rcon_client.py -v
# 4. With coverage (requires pytest-cov)
pip install pytest-cov
pytest --cov=. --cov-report=term-missing -vWhat is covered today:
| Test file | Cases | Technique |
|---|---|---|
tests/test_log_parser.py |
UPS line, [JOIN], [LEAVE], item-produced, unmatched line → None, empty/\r\n → None |
Fixed timestamp (now=_FIXED_TS) for determinism |
tests/test_rcon_client.py |
Auth + command round-trip (asserts on-the-wire ptype/body), auth failure → RconAuthError + run-loop exit, command timeout → RconTimeoutError |
Injected FakeTransport (StreamReader + recording _Writer); hand-encoded packets via struct.pack("<iii", …) |
🧪 Deep-dive: test authoring guide & linters (click to expand)
Writing a parser test — follow the existing table-driven style:
from datetime import datetime, timezone
from factorio_admin.parsers.log_parser import parse_line, UpsEvent
_FIXED_TS = datetime(2026, 5, 13, 12, 0, 0, tzinfo=timezone.utc)
def test_parse_ups_line() -> None:
event = parse_line("2026-05-13 12:00:00 60.0 UPS", now=_FIXED_TS)
assert isinstance(event, UpsEvent)
assert event.value == 60.0
assert event.timestamp == _FIXED_TSWriting an RCON test — feed canned server bytes, assert recorded client bytes:
# See tests/test_rcon_client.py::FakeTransport for the full harness.
transport.feed(_encode_packet(1, SERVERDATA_AUTH_RESPONSE, ""))
transport.feed(_encode_packet(2, SERVERDATA_RESPONSE_VALUE, "pong"))
run_task = asyncio.create_task(client.run())
try:
assert await client.execute("/ping") == "pong"
finally:
await client.close()
await asyncio.gather(run_task, return_exceptions=True)Recommended linters (not pinned in pyproject.toml — install as needed):
pip install ruff flake8 mypy
ruff check . # fast lint
ruff format --check . # formatting
flake8 --max-line-length=88 .
mypy --strict main.py core/ parsers/ persistence/ # static typing[!TIP]
TelemetryStore.ingest_stream()exists specifically for tests: enqueue an iterable of events and flush immediately without waiting for the 5-secondflush_looptick.
# Reproducible wheel build
pip install build
python -m build # → dist/factorio_admin-0.1.0-*
# Verify the artifact installs cleanly in a fresh env
python -m venv /tmp/smoke && /tmp/smoke/bin/pip install dist/*.whl
/tmp/smoke/bin/python -c "import factorio_admin.main; print('smoke OK')"Note
Version is single-sourced from pyproject.toml ([project] version = "0.1.0"). Bump it there before tagging a release.
systemd unit (/etc/systemd/system/factorio-admin.service):
[Unit]
Description=Factorio Admin Devkit
After=network.target
Wants=network-online.target
[Service]
Type=simple
User=factorio
WorkingDirectory=/opt/factorio-admin
EnvironmentFile=/opt/factorio-admin/.env
ExecStart=/opt/factorio-admin/.venv/bin/python /opt/factorio-admin/main.py
Restart=on-failure
RestartSec=5
# Graceful shutdown: SIGTERM → asyncio handlers → ordered teardown
KillSignal=SIGTERM
TimeoutStopSec=30
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now factorio-admin
sudo journalctl -u factorio-admin -fImportant
TimeoutStopSec=30 matters: shutdown flushes the SQLite buffer (store.close()) and closes RCON/WebSocket/bot connections in order. Killing the process with SIGKILL risks losing up to one flush interval (default 5s) of buffered telemetry.
Dockerfile:
FROM python:3.11-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
# System deps for matplotlib (Agg needs libfreetype) + healthcheck
RUN apt-get update && apt-get install -y --no-install-recommends \
libfreetype6 curl \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml ./
RUN pip install --no-cache-dir -e .
COPY . .
# Secrets come from env / mounted .env at runtime — never bake them in.
EXPOSE 8765
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD python -c "import socket; socket.create_connection(('127.0.0.1', 8765), 3)" || exit 1
CMD ["python", "main.py"]docker-compose.yml:
services:
factorio-admin:
build: .
container_name: factorio-admin
restart: unless-stopped
env_file: .env
environment:
LOG_FILE_PATH: /logs/factorio-current.log
DB_PATH: /data/factorio_metrics.sqlite3
ports:
- "8765:8765" # WebSocket broadcaster
volumes:
- /var/log/factorio:/logs:ro # Factorio log dir (read-only)
- factorio-data:/data # SQLite persistence
stop_grace_period: 30s # allow ordered asyncio shutdown
volumes:
factorio-data:docker compose up -d --build
docker compose logs -f factorio-adminCaution
Never commit a real .env into the image or the repo. Pass secrets via env_file, orchestrator secret stores, or runtime -e flags. The RCON password and WS_SECRET_TOKEN grant full server control and telemetry access respectively.
Example GitHub Actions workflow (.github/workflows/ci.yml):
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install
run: |
pip install --upgrade pip
pip install -e ".[dev]"
- name: Lint (ruff)
run: |
pip install ruff
ruff check .
- name: Test
run: pytest -vTip
The RCON and parser tests need no network, no Factorio server, and no credentials — they run on FakeTransport and pure strings. They are safe to execute on every push, in forks, and in sandboxed runners.
1. Start the full service (RCON + log ingest + SQLite + WebSocket + bots):
# main.py — this is the production entry point. Equivalent shell:
# $ python main.py
# $ factorio-admin
import asyncio
from config import load_settings # pydantic-settings: .env + environment
from main import _run # service orchestrator (private by convention)
settings = load_settings() # raises ValidationError if secrets are missing
asyncio.run(_run(settings)) # runs until SIGINT/SIGTERM2. Parse one log line (the heart of the "logging library" surface):
from datetime import datetime, timezone
from parsers.log_parser import parse_line, UpsEvent, PlayerEvent, ProductionEvent
# Each call is pure: same input → same output, no I/O, no globals.
ups = parse_line("2026-05-13 12:00:00 60.0 UPS") # → UpsEvent(value=60.0, ...)
join = parse_line("[JOIN] Alice joined the game") # → PlayerEvent('Alice', 'join')
leave = parse_line("[LEAVE] Bob left the game") # → PlayerEvent('Bob', 'leave')
prod = parse_line("item-produced iron-plate 12345") # → ProductionEvent('iron-plate', 12345)
miss = parse_line("nothing interesting here") # → None (no pattern matched)
# Deterministic timestamps for tests / replays:
fixed = datetime(2026, 5, 13, 12, 0, 0, tzinfo=timezone.utc)
event = parse_line("[JOIN] Alice joined the game", now=fixed)3. Tail a live log file:
import asyncio
from pathlib import Path
from parsers.log_parser import LogTailer
async def main() -> None:
tailer = LogTailer(
Path("/var/log/factorio/factorio-current.log"),
idle_sleep=0.5, # poll cadence when no new bytes
from_start=False, # False = seek to EOF (live only); True = replay history
)
async for event in tailer.stream(): # yields Ups/Player/ProductionEvent
print(event)
# ...stop from another task with tailer.stop()
asyncio.run(main())4. Execute an RCON command:
import asyncio
from core.rcon_client import RconClient
async def main() -> None:
rcon = RconClient("127.0.0.1", 27015, "s3cret", command_timeout=5.0)
task = asyncio.create_task(rcon.run()) # service loop (reconnects forever)
try:
print(await rcon.execute("/players online")) # serialized via queue
print(await rcon.execute("/players online count"))
finally:
await rcon.close() # unblocks pending callers
await task
asyncio.run(main())5. Stream telemetry over WebSocket (from any client):
# Node.js example — replace TOKEN with your WS_SECRET_TOKEN
node -e "
const ws = new (require('ws'))('ws://localhost:8765/?token=TOKEN');
ws.on('message', (m) => console.log(JSON.parse(m)));
"
# Each frame is NDJSON: {"kind":"UpsEvent","timestamp":"...","value":60.0}# Python client example (websockets)
import asyncio, json
import websockets
async def main() -> None:
async with websockets.connect("ws://localhost:8765/?token=TOKEN") as ws:
async for frame in ws:
print(json.loads(frame)) # {'kind': 'UpsEvent', ...}
asyncio.run(main())6. Chat-ops at a glance (Telegram /… or Slack /…):
/status → RCON: /players online count
/players → RCON: /players online
/kick Griefer123 spamming → RCON: /kick Griefer123 spamming
/graph ups 6 → PNG: UPS line chart, last 6 hours
/graph production 24 → PNG: stacked production bars, last 24 hours
🚀 Advanced Usage (click to expand)
A. Custom EventBus consumer — fan out to your own sink (Prometheus, MQTT, …):
import asyncio
from core.event_bus import EventBus
from parsers.log_parser import ParsedEvent, UpsEvent
async def prometheus_exporter(bus: EventBus[ParsedEvent]) -> None:
# Each subscriber gets an independent bounded queue (default 256).
# Slow consumers drop oldest events — the producer never blocks.
async for event in bus.subscribe():
if isinstance(event, UpsEvent):
# push event.value to your registry here
print(f"ups={event.value} @ {event.timestamp.isoformat()}")
async def main(bus: EventBus[ParsedEvent]) -> None:
await asyncio.gather(
prometheus_exporter(bus),
# ... other consumers
)B. Custom formatters for the WebSocket payload — _serialize is JSON; wrap it:
import json
from web.ws_server import _serialize # {"kind": ..., ...fields}
from parsers.log_parser import UpsEvent
# _serialize emits: {"kind":"UpsEvent","timestamp":"...","value":60.0}
# To add an envelope, post-process at your edge (or fork ws_server.py):
def to_logfmt(event: UpsEvent) -> str:
base = json.loads(_serialize(event))
return " ".join(f"{k}={v}" for k, v in base.items())
# kind=UpsEvent timestamp=2026-... value=60.0C. Direct TelemetryStore queries — build custom dashboards:
import asyncio
from datetime import datetime, timedelta, timezone
from pathlib import Path
from persistence.db import TelemetryStore
async def main() -> None:
store = TelemetryStore(Path("factorio_metrics.sqlite3"))
await store.open()
try:
since = datetime.now(timezone.utc) - timedelta(hours=24)
ups = await store.query_ups(since) # [(ts, value)]
players = await store.query_players(since) # [(ts, name, join|leave)]
iron = await store.query_production(since, items=["iron-plate", "copper-plate"])
print(f"{len(ups)} UPS samples, {len(players)} presence rows, {len(iron)} prod rows")
finally:
await store.close() # final flush + close
asyncio.run(main())D. In-memory hot metrics (MetricsStore) — no SQLite round-trip:
from datetime import timedelta
from storage.metrics_store import MetricsStore
ms = MetricsStore(max_age=timedelta(hours=24), max_points=50_000)
ms.record("ups", 59.7) # timestamp defaults to now(UTC)
last_hour = ms.window("ups", timedelta(hours=1)) # [(datetime, float)]
print(ms.metrics()) # iterable of known metric namesE. Render charts headlessly — PNG bytes for any transport:
import asyncio
from datetime import datetime, timedelta, timezone
from pathlib import Path
from persistence.db import TelemetryStore
from visuals.graph_engine import render_ups_chart, render_production_chart
async def main() -> None:
store = TelemetryStore(Path("factorio_metrics.sqlite3"))
await store.open()
try:
since = datetime.now(timezone.utc) - timedelta(hours=6)
ups_png = await render_ups_chart(
await store.query_ups(since),
warning_threshold=50.0, # red shading below 50 UPS
title="UPS (last 6h)",
)
prod_png = await render_production_chart(
await store.query_production(since),
bucket_seconds=300, # 5-minute stacks
title="Production (last 6h)",
)
Path("ups.png").write_bytes(ups_png)
Path("production.png").write_bytes(prod_png)
finally:
await store.close()
asyncio.run(main())F. Unit-testable RCON with a fake transport — no server needed:
import asyncio, struct
from core.rcon_client import RconClient, SERVERDATA_AUTH_RESPONSE, SERVERDATA_RESPONSE_VALUE
async def fake_factory():
reader = asyncio.StreamReader()
# ... feed canned packets, return (reader, writer) — see tests/test_rcon_client.py
raise NotImplementedError # full harness in tests/test_rcon_client.py
client = RconClient("h", 1, "pw", transport_factory=fake_factory)
# asyncio.create_task(client.run()); await client.execute("/ping"); await client.close()⚠️ Edge cases & failure modes (click to expand)
| # | Edge case | Where | Behavior |
|---|---|---|---|
| 1 | Log file missing at startup | LogTailer.stream |
FileNotFoundError → sleep idle_sleep, retry forever |
| 2 | Log rotated/truncated | LogTailer._read_chunk |
size < position → log Log file truncated; rewinding → seek 0 |
| 3 | Partial line at EOF | LogTailer.stream |
Bytes buffered until \n; no phantom events |
| 4 | Empty / \r\n line |
parse_line |
Returns None immediately |
| 5 | Unmatched line | parse_line |
Returns None (all four regexes missed) |
| 6 | Wrong RCON password | RconClient._authenticate |
RconAuthError, run() exits, pending callers fail — no retry loop |
| 7 | RCON TCP drop mid-session | RconClient.run |
Warning logged, transport closed, reconnect with backoff |
| 8 | Command exceeds budget | RconClient.execute |
RconTimeoutError after command_timeout; queue keeps serving |
| 9 | Malformed packet (bad size/terminator) | RconClient._read_packet |
RconProtocolError; serve loop re-raises → reconnect |
| 10 | Slow WebSocket consumer | EventBus.publish |
Oldest queued event evicted; producer never blocks |
| 11 | Dead WebSocket client | WebSocketBroadcaster._broadcast_loop |
Send exception → close() + discard from set |
| 12 | Wrong/missing ?token= |
WebSocketBroadcaster._process_request |
HTTP 403 Forbidden before handshake |
| 13 | SQLite write failure | TelemetryStore.flush_loop |
aiosqlite.Error logged; loop continues on next tick |
| 14 | Unauthorized Telegram user | TelegramController._guard |
Replies Unauthorized., command ignored |
| 15 | Rate limit hit | SlidingWindowRateLimiter.allow |
Replies Rate limit exceeded. Please slow down. |
| 16 | Unknown /graph metric |
both bot controllers | Replies Unknown metric '…'. Try 'ups' or 'production'. |
| 17 | Non-integer /graph hours |
both bot controllers | Replies Hours must be an integer. |
| 18 | Chart render crash | bot _cmd_graph |
logger.exception + Graph rendering failed; please check logs. |
| 19 | First service task exits | main._run |
asyncio.FIRST_COMPLETED → ordered teardown of everything else |
| 20 | SIGTERM under systemd/Docker |
main._run |
Signal → stop_event → producers→edge→transports→DB close order |
[!WARNING] Edge cases 6 and 19 are the two most operationally surprising: a bad RCON password stops the RCON task permanently (by design — retrying bad credentials is a ban magnet), and any service task completing triggers full shutdown. Monitor the
rcontask and alert onRCON authentication failed.
The Devkit ships two configuration systems. They are independent — pick one:
| System | Loader | File | Used by | Secrets |
|---|---|---|---|---|
| Primary (Pydantic) | config.load_settings() |
.env + environment |
main.py ✅ |
RCON_PASSWORD, WS_SECRET_TOKEN, LOG_FILE_PATH (required) |
| Alternative (YAML) | settings.Settings.from_yaml() |
config.yaml + environment |
Manual wiring only | FACTORIO_RCON_PASSWORD, TELEGRAM_BOT_TOKEN, SLACK_BOT_TOKEN, SLACK_APP_TOKEN overrides |
Important
main.py uses only the Pydantic path (from factorio_admin.config import Settings, load_settings). The YAML path (settings.py + config.yaml) is a standalone alternative for custom entry points — editing config.yaml alone will not affect python main.py.
Minimal .env to boot the service (bots + graphs optional):
# ── REQUIRED ──────────────────────────────────────────────
RCON_PASSWORD=s3cret-rcon-password
WS_SECRET_TOKEN=long-random-websocket-token
LOG_FILE_PATH=/var/log/factorio/factorio-current.log
# ── Common overrides (all optional — defaults shown) ──────
RCON_HOST=127.0.0.1
RCON_PORT=27015
DB_PATH=factorio_metrics.sqlite3
WS_HOST=0.0.0.0
WS_PORT=8765
TELEGRAM_ENABLED=false
SLACK_ENABLED=false
UPS_WARNING_THRESHOLD=50.0📋 Exhaustive environment variable reference (click to expand)
All variables are case-insensitive (case_sensitive=False) and loaded from the process environment with .env as fallback (env_file=".env", extra="ignore").
| Variable | Type | Default | Required | Description |
|---|---|---|---|---|
RCON_HOST |
str |
127.0.0.1 |
No | Factorio RCON hostname/IP |
RCON_PORT |
int |
27015 |
No | Factorio RCON TCP port |
RCON_PASSWORD |
str |
— | Yes | Shared RCON secret; must match server-settings.json. No default — startup fails loudly if absent |
RCON_COMMAND_TIMEOUT |
float |
5.0 |
No | Per-command response budget (seconds); exceeded → RconTimeoutError |
RCON_CONNECT_TIMEOUT |
float |
5.0 |
No | TCP connect + AUTH handshake budget (seconds) |
RCON_MAX_BACKOFF_SECONDS |
float |
60.0 |
No | Cap for exponential reconnect delay (1s→2s→4s…→cap) |
LOG_FILE_PATH |
Path |
— | Yes | Path to factorio-current.log (tailed live) |
DB_PATH |
Path |
factorio_metrics.sqlite3 |
No | SQLite file for TelemetryStore (WAL sidecars live alongside) |
DB_FLUSH_INTERVAL_SECONDS |
float |
5.0 |
No | Seconds between batched SQLite commits |
TELEGRAM_ENABLED |
bool |
false |
No | Set true to start polling Telegram |
TELEGRAM_BOT_TOKEN |
str |
"" |
If Telegram on | Bot token from @BotFather |
TELEGRAM_ALLOWED_USER_IDS |
list[int] |
[] |
No | Comma-separated allow-list, e.g. 123,456; empty = allow all |
SLACK_ENABLED |
bool |
false |
No | Set true to open the Socket Mode connection |
SLACK_BOT_TOKEN |
str |
"" |
If Slack on | xoxb-* token |
SLACK_APP_TOKEN |
str |
"" |
If Slack on | xapp-* token (Socket Mode) |
RATE_LIMIT_MAX_COMMANDS |
int |
5 |
No | Max bot commands per user per window |
RATE_LIMIT_WINDOW_SECONDS |
float |
30.0 |
No | Sliding-window width (seconds) |
UPS_WARNING_THRESHOLD |
float |
50.0 |
No | UPS chart red-shading boundary; below = degraded |
WS_HOST |
str |
0.0.0.0 |
No | WebSocket bind address |
WS_PORT |
int |
8765 |
No | WebSocket bind port |
WS_SECRET_TOKEN |
str |
— | Yes | Shared secret; clients pass ?token= or get 403 |
[!CAUTION] Leaving
TELEGRAM_ALLOWED_USER_IDSempty disables the allow-list — anyone who finds your bot can run/kick. Always set it in production.
Alternative declarative path via settings.Settings.from_yaml(path) — dataclasses with env-var overrides for secrets:
# config.yaml (shipped sample — edit for your server)
log_path: "C:/factorio/factorio-current.log"
websocket_host: "0.0.0.0"
websocket_port: 8765
rcon:
host: "127.0.0.1"
port: 27015
password: "" # prefer FACTORIO_RCON_PASSWORD env var
telegram:
enabled: true
token: "" # prefer TELEGRAM_BOT_TOKEN env var
allowed_user_ids: [] # e.g. [123456789]
slack:
enabled: false
bot_token: "" # prefer SLACK_BOT_TOKEN env var
app_token: "" # prefer SLACK_APP_TOKEN env var
allowed_user_ids: []📋 Full YAML schema & env-override map (click to expand)
# ── Complete schema with types, defaults, and overrides ──
log_path: string # REQUIRED — Path to factorio-current.log
websocket_host: string # default: "0.0.0.0"
websocket_port: integer # default: 8765
rcon: # → RconSettings(host, port, password)
host: string # REQUIRED
port: integer # REQUIRED
password: string # default: "" — overridden by $FACTORIO_RCON_PASSWORD if set
telegram: # → TelegramSettings(enabled, token, allowed_user_ids: set[int])
enabled: boolean # default: false
token: string # default: "" — overridden by $TELEGRAM_BOT_TOKEN if set
allowed_user_ids: [integer, ...] # default: []
slack: # → SlackSettings(enabled, bot_token, app_token, allowed_user_ids: set[str])
enabled: boolean # default: false
bot_token: string # default: "" — overridden by $SLACK_BOT_TOKEN if set
app_token: string # default: "" — overridden by $SLACK_APP_TOKEN if set
allowed_user_ids: [string, ...] # default: []Loading it in a custom entry point:
from pathlib import Path
from settings import Settings
settings = Settings.from_yaml(Path("config.yaml"))
print(settings.rcon.host, settings.rcon.port) # password already env-overridden
print(settings.telegram.enabled, settings.telegram.allowed_user_ids)Override precedence (highest wins): environment variable → YAML value → dataclass default.
| YAML key | Env override |
|---|---|
rcon.password |
FACTORIO_RCON_PASSWORD |
telegram.token |
TELEGRAM_BOT_TOKEN |
slack.bot_token |
SLACK_BOT_TOKEN |
slack.app_token |
SLACK_APP_TOKEN |
[!NOTE] The YAML
Settingsand the PydanticSettingsare different classes with different field names (log_pathvslog_file_path,websocket_portvsws_port). They are not interchangeable — check which one your entry point imports.
[!TIP] There are no CLI startup flags today — all tuning flows through
.env/ environment (primary) or YAML (alternative).logging.basicConfig(level=INFO)is hard-coded inmain(); redirect or wrap with your process supervisor for log-level control.
This project is licensed under the Apache License 2.0 — see the LICENSE file for the full text.
Copyright (c) Factorio-admin-Devkit contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Note
Apache-2.0 permits commercial use, modification, distribution, and private use, with the requirements to preserve copyright/license notices and state significant changes. It also grants an express patent license from contributors.
If you find this tool useful, consider leaving a star on GitHub or supporting the author directly.