Initial systant implementation in Bun/TypeScript

A lightweight system monitoring agent that:
- Collects metrics via configurable shell commands
- Publishes to MQTT with Home Assistant auto-discovery
- Supports entity types: sensor, binary_sensor, light, switch, button
- Responds to commands over MQTT for controllable entities

Architecture:
- src/config.ts: TOML config loading and validation
- src/mqtt.ts: MQTT client with HA discovery
- src/entities.ts: Entity state polling and command handling
- index.ts: CLI entry point (run, check, once commands)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
ryan 2026-01-19 19:52:47 -08:00
commit ae778ebdab
22 changed files with 1381 additions and 0 deletions

40
.claude/settings.json Normal file
View File

@ -0,0 +1,40 @@
{
"$schema": "https://claude.ai/claude-code/settings.schema.json",
"permissions": {
"allow": [
"Bash(bun test*)",
"Bash(bun run*)",
"Bash(bun build*)",
"Bash(bun install*)",
"Bash(bunx tsc --noEmit*)",
"Bash(git status*)",
"Bash(git diff*)",
"Bash(git log*)",
"Bash(git add*)",
"Bash(git commit*)",
"Bash(ls*)",
"Bash(cat /proc/*)",
"Bash(cat /sys/*)"
],
"deny": [
"Bash(rm -rf /)*",
"Bash(sudo *)",
"Bash(*--force*)"
]
},
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bunx tsc --noEmit --pretty 2>&1 | head -20 || true",
"description": "Type check after file changes",
"timeout": 10000
}
]
}
]
}
}

16
.claude/skills/build.md Normal file
View File

@ -0,0 +1,16 @@
# /build
Build the systant CLI binary.
## Instructions
1. Run type checking first: `bunx tsc --noEmit`
2. If types pass, build the binary: `bun build index.ts --compile --outfile dist/systant`
3. Report the binary size and location
4. If there are errors, show them clearly and suggest fixes
## Success Criteria
- No TypeScript errors
- Binary created at `dist/systant`
- Binary is executable

26
.claude/skills/plan.md Normal file
View File

@ -0,0 +1,26 @@
# /plan
Enter planning mode to design an implementation approach.
## Instructions
1. Enter plan mode using EnterPlanMode tool
2. Explore the codebase to understand current state
3. Identify affected files and components
4. Design the implementation approach
5. Present the plan for user approval before coding
## When to Use
- New features
- Architectural changes
- Complex bug fixes
- Refactoring tasks
## Output
A clear plan including:
- Files to create/modify
- Key implementation steps
- Potential risks or considerations
- Testing approach

28
.claude/skills/release.md Normal file
View File

@ -0,0 +1,28 @@
# /release
Prepare a release of systant.
## Instructions
1. Ensure working directory is clean (`git status`)
2. Run tests: `bun test`
3. Type check: `bunx tsc --noEmit`
4. Build binary: `bun build index.ts --compile --outfile dist/systant`
5. Ask user for version bump type (patch/minor/major)
6. Update version in package.json
7. Create git commit with message: "release: v{version}"
8. Create git tag: `v{version}`
9. Report next steps (push, publish, etc.)
## Prerequisites
- All tests must pass
- No TypeScript errors
- Clean git working directory (or user confirms to proceed)
## Success Criteria
- Binary built successfully
- Version bumped in package.json
- Git commit and tag created
- Clear instructions for next steps

22
.claude/skills/test.md Normal file
View File

@ -0,0 +1,22 @@
# /test
Run the test suite.
## Instructions
1. Run `bun test` to execute all tests
2. If tests fail:
- Analyze the failure messages
- Identify the root cause
- Suggest specific fixes
3. If tests pass, report the summary
## Options
- `/test <pattern>` - Run tests matching a pattern (e.g., `/test metrics`)
- `/test --watch` - Run in watch mode
## Success Criteria
- All tests pass
- Clear reporting of any failures with actionable suggestions

View File

@ -0,0 +1,16 @@
# /typecheck
Run TypeScript type checking.
## Instructions
1. Run `bunx tsc --noEmit --pretty`
2. If errors found:
- List each error with file, line, and message
- Provide suggested fixes for each
3. If no errors, confirm success
## Success Criteria
- Report all type errors clearly
- Suggest actionable fixes

1
.envrc Normal file
View File

@ -0,0 +1 @@
use flake

46
.gitignore vendored Normal file
View File

@ -0,0 +1,46 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Dependencies
node_modules
.pnp
.pnp.js
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Testing
coverage
# Turbo
.turbo
# Vercel
.vercel
# Build Outputs
.next/
out/
build
dist
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Misc
.DS_Store
*.pem
auth_failures.log
# Nix
.direnv
.direnv/*
# Local config (use systant.toml.example as template)
systant.toml

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"terminal.integrated.fontFamily": "Fira Code"
}

103
AGENTS.md Normal file
View File

@ -0,0 +1,103 @@
# Agents
Specialized sub-agents for systant development tasks.
## test-runner
Use this agent after writing or modifying code to run the test suite and verify changes.
**Responsibilities:**
- Run `bun test` and report results
- Identify failing tests and their root causes
- Suggest fixes for test failures
- Run specific test files when targeted testing is needed
**Trigger:** After implementing features, fixing bugs, or modifying existing code.
## code-reviewer
Use this agent to review code changes before committing.
**Responsibilities:**
- Check for Bun best practices (no Node.js patterns)
- Verify type safety and explicit return types
- Look for potential bugs or edge cases
- Ensure code follows project conventions
- Flag any security concerns (especially in command execution)
**Trigger:** Before creating commits or PRs.
## metrics-specialist
Use this agent when working on system metric collection.
**Responsibilities:**
- Understand Linux /proc and /sys interfaces
- Know cross-platform metric collection strategies
- Ensure metrics are properly typed and documented
- Validate metric units and normalization
**Context:** Systant collects CPU, memory, disk, and network metrics. Metrics should be normalized (percentages 0-100, bytes for sizes) and include metadata for Home Assistant discovery.
## mqtt-specialist
Use this agent when working on MQTT publishing or Home Assistant integration.
**Responsibilities:**
- Understand MQTT topic conventions
- Know Home Assistant discovery protocol
- Ensure proper QoS and retain flag usage
- Handle connection lifecycle (connect, reconnect, disconnect)
- Design topic hierarchies for commands and events
**Context:** Systant publishes to MQTT with Home Assistant auto-discovery. Topics follow the pattern `systant/{hostname}/{metric_type}`. Command topics use `systant/{hostname}/command/{action}`.
## events-specialist
Use this agent when working on the event/command system.
**Responsibilities:**
- Design secure command execution with allowlists
- Implement event handlers and action dispatching
- Ensure proper input validation and sanitization
- Handle timeouts and error reporting
- Consider security implications of remote command execution
**Context:** Systant listens for MQTT commands and executes configured actions. Security is paramount - all commands must be validated against an allowlist, inputs sanitized, and execution sandboxed where possible.
## debug-investigator
Use this agent when troubleshooting issues or unexpected behavior.
**Responsibilities:**
- Add strategic logging to trace execution
- Isolate the problem to specific components
- Form and test hypotheses
- Propose minimal fixes
**Trigger:** When something isn't working as expected.
## architect
Use this agent for design decisions and architectural questions.
**Responsibilities:**
- Evaluate trade-offs between approaches
- Consider future extensibility
- Maintain consistency with existing patterns
- Document decisions in code comments or CLAUDE.md
**Trigger:** When facing design choices or planning new features.
## security-auditor
Use this agent when reviewing security-sensitive code.
**Responsibilities:**
- Review command execution paths for injection vulnerabilities
- Validate input sanitization
- Check allowlist/denylist implementations
- Ensure proper authentication for MQTT commands
- Review file system access patterns
**Context:** Systant executes commands based on MQTT messages. This is a critical attack surface that requires careful security review.

120
CLAUDE.md Normal file
View File

@ -0,0 +1,120 @@
# Systant
A system monitoring agent that collects metrics, monitors services, and reports to MQTT/Home Assistant; and responds to events over MQTT to trigger commands or other behavior.
## Project Overview
Systant is a lightweight CLI tool written in Bun/TypeScript that:
- Collects system metrics (CPU, memory, disk, network)
- Monitors service health
- Publishes data to MQTT brokers
- Supports Home Assistant auto-discovery
- **Listens for MQTT commands** to trigger actions (run scripts, restart services, etc.)
- **Responds to events** with configurable handlers
- Runs as a daemon or one-shot command
### Architecture
```
index.ts # CLI entry point (yargs)
src/
commands/ # CLI command handlers
metrics/ # System metric collectors
mqtt/ # MQTT client and publishing
events/ # MQTT event listeners and handlers
actions/ # Executable actions (shell, service, notify)
ha/ # Home Assistant discovery
config/ # Configuration loading
```
### Event/Command System
Systant subscribes to MQTT topics and executes configured actions:
```
Topic: systant/{hostname}/command/{action}
Payload: { "args": [...], "timeout": 30 }
Topic: systant/{hostname}/event/{event_name}
Payload: { ... event data ... }
```
Actions are sandboxed and configurable via allowlists in the config file. Security is critical - never execute arbitrary commands without validation.
### Key Design Decisions
- **Single binary**: Compiles to standalone executable via `bun build --compile`
- **No external services**: Uses Bun built-ins (sqlite, file, etc.)
- **Config-driven**: TOML configuration for flexibility
- **Typed throughout**: Full TypeScript with strict mode
## Tech Stack
- **Runtime**: Bun (not Node.js)
- **CLI**: yargs
- **Config**: TOML
- **MQTT**: mqtt.js or Bun-native when available
- **Package**: Nix flake for reproducible builds
## Bun Conventions
Default to using Bun instead of Node.js.
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>`
- Use `bunx <package> <command>` instead of `npx <package> <command>`
- Bun automatically loads .env, so don't use dotenv.
### Bun APIs
- `Bun.serve()` for HTTP/WebSocket servers
- `bun:sqlite` for SQLite (not better-sqlite3)
- `Bun.file()` for file I/O (not node:fs readFile/writeFile)
- `Bun.$\`cmd\`` for shell commands (not execa)
- Native `WebSocket` (not ws)
### Testing
```ts
import { test, expect, describe, beforeEach } from "bun:test";
describe("MetricCollector", () => {
test("collects CPU metrics", async () => {
const metrics = await collectCPU();
expect(metrics.usage).toBeGreaterThanOrEqual(0);
});
});
```
Run tests: `bun test`
Run specific: `bun test src/metrics`
Watch mode: `bun test --watch`
## Code Style
- Prefer `async/await` over callbacks
- Use explicit return types on public functions
- Prefer `interface` over `type` for object shapes
- Use `const` by default, `let` only when reassignment needed
- No classes unless state encapsulation is genuinely needed
- Prefer pure functions and composition
## Commands
```bash
bun run start # Run in development
bun run dist # Build standalone binary
bun test # Run tests
bun test --watch # Watch mode
```
## Planning Protocol
When implementing features:
1. Discuss the approach before writing code
2. Start with types/interfaces
3. Write tests alongside implementation
4. Keep PRs focused and small

15
README.md Normal file
View File

@ -0,0 +1,15 @@
# systant
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run index.ts
```
This project was created using `bun init` in bun v1.3.6. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.

152
bun.lock Normal file
View File

@ -0,0 +1,152 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "systant",
"dependencies": {
"mqtt": "^5.14.1",
"smol-toml": "^1.6.0",
"yargs": "^18.0.0",
},
"devDependencies": {
"@types/bun": "latest",
"@types/yargs": "^17.0.35",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
"@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="],
"@types/readable-stream": ["@types/readable-stream@4.0.23", "", { "dependencies": { "@types/node": "*" } }, "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="],
"@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="],
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"bl": ["bl@6.1.6", "", { "dependencies": { "@types/readable-stream": "^4.0.0", "buffer": "^6.0.3", "inherits": "^2.0.4", "readable-stream": "^4.2.0" } }, "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg=="],
"broker-factory": ["broker-factory@3.1.13", "", { "dependencies": { "@babel/runtime": "^7.28.6", "fast-unique-numbers": "^9.0.26", "tslib": "^2.8.1", "worker-factory": "^7.0.48" } }, "sha512-H2VALe31mEtO/SRcNp4cUU5BAm1biwhc/JaF77AigUuni/1YT0FLCJfbUxwIEs9y6Kssjk2fmXgf+Y9ALvmKlw=="],
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
"cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
"commist": ["commist@3.2.0", "", {}, "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw=="],
"concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
"fast-unique-numbers": ["fast-unique-numbers@9.0.26", "", { "dependencies": { "@babel/runtime": "^7.28.6", "tslib": "^2.8.1" } }, "sha512-3Mtq8p1zQinjGyWfKeuBunbuFoixG72AUkk4VvzbX4ykCW9Q4FzRaNyIlfQhUjnKw2ARVP+/CKnoyr6wfHftig=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
"help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
"js-sdsl": ["js-sdsl@4.3.0", "", {}, "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ=="],
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"mqtt": ["mqtt@5.14.1", "", { "dependencies": { "@types/readable-stream": "^4.0.21", "@types/ws": "^8.18.1", "commist": "^3.2.0", "concat-stream": "^2.0.0", "debug": "^4.4.1", "help-me": "^5.0.0", "lru-cache": "^10.4.3", "minimist": "^1.2.8", "mqtt-packet": "^9.0.2", "number-allocator": "^1.0.14", "readable-stream": "^4.7.0", "rfdc": "^1.4.1", "socks": "^2.8.6", "split2": "^4.2.0", "worker-timers": "^8.0.23", "ws": "^8.18.3" }, "bin": { "mqtt_pub": "build/bin/pub.js", "mqtt_sub": "build/bin/sub.js", "mqtt": "build/bin/mqtt.js" } }, "sha512-NxkPxE70Uq3Ph7goefQa7ggSsVzHrayCD0OyxlJgITN/EbzlZN+JEPmaAZdxP1LsIT5FamDyILoQTF72W7Nnbw=="],
"mqtt-packet": ["mqtt-packet@9.0.2", "", { "dependencies": { "bl": "^6.0.8", "debug": "^4.3.4", "process-nextick-args": "^2.0.1" } }, "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"number-allocator": ["number-allocator@1.0.14", "", { "dependencies": { "debug": "^4.3.1", "js-sdsl": "4.3.0" } }, "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA=="],
"process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="],
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
"readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
"smol-toml": ["smol-toml@1.6.0", "", {}, "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw=="],
"socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="],
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"worker-factory": ["worker-factory@7.0.48", "", { "dependencies": { "@babel/runtime": "^7.28.6", "fast-unique-numbers": "^9.0.26", "tslib": "^2.8.1" } }, "sha512-CGmBy3tJvpBPjUvb0t4PrpKubUsfkI1Ohg0/GGFU2RvA9j/tiVYwKU8O7yu7gH06YtzbeJLzdUR29lmZKn5pag=="],
"worker-timers": ["worker-timers@8.0.29", "", { "dependencies": { "@babel/runtime": "^7.28.6", "tslib": "^2.8.1", "worker-timers-broker": "^8.0.15", "worker-timers-worker": "^9.0.13" } }, "sha512-9jk0MWHhWAZ2xlJPXr45oe5UF/opdpfZrY0HtyPizWuJ+ce1M3IYk/4IIdGct3kn9Ncfs+tkZt3w1tU6KW2Fsg=="],
"worker-timers-broker": ["worker-timers-broker@8.0.15", "", { "dependencies": { "@babel/runtime": "^7.28.6", "broker-factory": "^3.1.13", "fast-unique-numbers": "^9.0.26", "tslib": "^2.8.1", "worker-timers-worker": "^9.0.13" } }, "sha512-Te+EiVUMzG5TtHdmaBZvBrZSFNauym6ImDaCAnzQUxvjnw+oGjMT2idmAOgDy30vOZMLejd0bcsc90Axu6XPWA=="],
"worker-timers-worker": ["worker-timers-worker@9.0.13", "", { "dependencies": { "@babel/runtime": "^7.28.6", "tslib": "^2.8.1", "worker-factory": "^7.0.48" } }, "sha512-qjn18szGb1kjcmh2traAdki1eiIS5ikFo+L90nfMOvSRpuDw1hAcR1nzkP2+Hkdqz5thIRnfuWx7QSpsEUsA6Q=="],
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="],
"yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
"concat-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
}
}

61
flake.lock generated Normal file
View File

@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1768395095,
"narHash": "sha256-ZhuYJbwbZT32QA95tSkXd9zXHcdZj90EzHpEXBMabaw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "13868c071cc73a5e9f610c47d7bb08e5da64fdd5",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

58
flake.nix Normal file
View File

@ -0,0 +1,58 @@
{
description = "Systant";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
inputs.flake-utils.url = "github:numtide/flake-utils";
outputs =
{
self,
nixpkgs,
flake-utils,
...
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = import nixpkgs {
inherit system;
config.allowUnfree = true;
};
systant-cli = pkgs.writeShellScriptBin "systant" ''
cd "$PROJECT_ROOT" && ./dist/systant "$@"
'';
in
with pkgs;
{
devShell = pkgs.mkShell {
buildInputs = [
bashInteractive
glibcLocales
git
bun
inotify-tools
claude-code
systant-cli
];
shellHook = ''
export PROJECT_ROOT=$PWD
'';
};
packages = {
systant-server = pkgs.callPackage ./nix/package.nix {
src = ./server;
};
systant-cli = pkgs.systant-cli;
default = systant-cli;
};
apps = {
default = {
type = "app";
program = "${self.packages.${system}.default}/bin/systant";
};
};
}
);
}

130
index.ts Normal file
View File

@ -0,0 +1,130 @@
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import { loadConfig } from "./src/config";
import { connect } from "./src/mqtt";
import { createEntityManager } from "./src/entities";
const DEFAULT_CONFIG_PATH = "./systant.toml";
yargs(hideBin(process.argv))
.scriptName("systant")
.usage("$0 <cmd> [args]")
.command(
"run",
"Start the systant daemon",
(yargs) => {
return yargs.option("config", {
alias: "c",
type: "string",
default: DEFAULT_CONFIG_PATH,
describe: "Path to config file",
});
},
async (argv) => {
await run(argv.config);
}
)
.command(
"check",
"Check config and connectivity",
(yargs) => {
return yargs.option("config", {
alias: "c",
type: "string",
default: DEFAULT_CONFIG_PATH,
describe: "Path to config file",
});
},
async (argv) => {
await check(argv.config);
}
)
.command(
"once",
"Poll all entity states once, then exit",
(yargs) => {
return yargs.option("config", {
alias: "c",
type: "string",
default: DEFAULT_CONFIG_PATH,
describe: "Path to config file",
});
},
async (argv) => {
await once(argv.config);
}
)
.demandCommand(1, "\nError: You need to specify a command!")
.help()
.parse();
async function run(configPath: string): Promise<void> {
const config = await loadConfig(configPath);
console.log(`Starting systant on ${config.systant.hostname}`);
const mqtt = await connect(config, config.systant.hostname);
const entities = createEntityManager(config, mqtt);
await entities.start();
// Handle shutdown
const shutdown = async () => {
console.log("\nShutting down...");
entities.stop();
await mqtt.disconnect();
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
console.log("Systant running. Press Ctrl+C to stop.");
}
async function check(configPath: string): Promise<void> {
console.log(`Checking config: ${configPath}`);
try {
const config = await loadConfig(configPath);
const entityCount = Object.keys(config.entities).length;
console.log("Config loaded successfully");
console.log(` MQTT broker: ${config.mqtt.broker}`);
console.log(` Entities: ${entityCount} configured (default interval: ${config.systant.defaultInterval}s)`);
console.log(` HA discovery: ${config.homeassistant.discovery}`);
console.log("\nTesting MQTT connection...");
const hostname = config.systant.hostname;
const mqtt = await connect(config, hostname);
console.log("MQTT connection successful");
await mqtt.disconnect();
console.log("\nAll checks passed!");
} catch (err) {
console.error("Check failed:", err instanceof Error ? err.message : err);
process.exit(1);
}
}
async function once(configPath: string): Promise<void> {
const config = await loadConfig(configPath);
const hostname = config.systant.hostname;
const mqtt = await connect(config, hostname);
const entities = createEntityManager(config, mqtt);
// Start will do initial poll of all entities
await entities.start();
// Wait a moment for the initial polls to complete
await new Promise((resolve) => setTimeout(resolve, 1000));
entities.stop();
await mqtt.disconnect();
console.log("Entity states published successfully");
}

23
package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "systant",
"version": "0.1.0",
"module": "index.ts",
"devDependencies": {
"@types/bun": "latest",
"@types/yargs": "^17.0.35"
},
"peerDependencies": {
"typescript": "^5"
},
"private": true,
"scripts": {
"start": "bun run index.ts",
"dist": "bun build index.ts --compile --outfile dist/systant"
},
"type": "module",
"dependencies": {
"mqtt": "^5.14.1",
"smol-toml": "^1.6.0",
"yargs": "^18.0.0"
}
}

130
src/config.ts Normal file
View File

@ -0,0 +1,130 @@
import { parse } from "smol-toml";
export interface SystantConfig {
hostname: string;
defaultInterval: number;
}
export interface MqttConfig {
broker: string;
username?: string;
password?: string;
clientId?: string;
topicPrefix: string;
}
export type EntityType = "sensor" | "binary_sensor" | "light" | "switch" | "button";
export interface EntityConfig {
type: EntityType;
state_command?: string; // for stateful entities (not button)
on_command?: string; // for light/switch
off_command?: string; // for light/switch
press_command?: string; // for button
interval?: number; // override default interval
unit?: string; // for sensor
device_class?: string; // for sensor (timestamp, etc.) or binary_sensor
icon?: string;
name?: string;
availability?: boolean; // default true; set false to keep last value when offline
}
export interface HomeAssistantConfig {
discovery: boolean;
discoveryPrefix: string;
}
export interface Config {
systant: SystantConfig;
mqtt: MqttConfig;
entities: Record<string, EntityConfig>;
homeassistant: HomeAssistantConfig;
}
const defaults = {
systant: {
defaultInterval: 30,
hostname: process.env.HOSTNAME || require("os").hostname(),
},
mqtt: {
broker: "mqtt://localhost:1883",
topicPrefix: "systant",
},
homeassistant: {
discovery: true,
discoveryPrefix: "homeassistant",
},
};
export async function loadConfig(path: string): Promise<Config> {
const file = Bun.file(path);
if (!(await file.exists())) {
throw new Error(`Config file not found: ${path}`);
}
const content = await file.text();
const parsed = parse(content) as Record<string, unknown>;
return buildConfig(parsed);
}
function buildConfig(parsed: Record<string, unknown>): Config {
const config: Config = {
systant: { ...defaults.systant },
mqtt: { ...defaults.mqtt },
entities: {},
homeassistant: { ...defaults.homeassistant },
};
// MQTT settings
if (parsed.mqtt && typeof parsed.mqtt === "object") {
Object.assign(config.mqtt, parsed.mqtt);
}
// Systant settings
if (parsed.systant && typeof parsed.systant === "object") {
Object.assign(config.systant, parsed.systant);
}
// Entities
if (parsed.entities && typeof parsed.entities === "object") {
const entities = parsed.entities as Record<string, unknown>;
for (const [key, value] of Object.entries(entities)) {
if (key === "interval") continue; // skip the default interval
if (value && typeof value === "object" && "type" in value) {
const e = value as Record<string, unknown>;
const entityType = String(e.type) as EntityType;
// Buttons need press_command, others need state_command
const hasRequiredCommand = entityType === "button"
? "press_command" in e
: "state_command" in e;
if (!hasRequiredCommand) continue;
config.entities[key] = {
type: entityType,
state_command: typeof e.state_command === "string" ? e.state_command : undefined,
on_command: typeof e.on_command === "string" ? e.on_command : undefined,
off_command: typeof e.off_command === "string" ? e.off_command : undefined,
press_command: typeof e.press_command === "string" ? e.press_command : undefined,
interval: typeof e.interval === "number" ? e.interval : undefined,
unit: typeof e.unit === "string" ? e.unit : undefined,
device_class: typeof e.device_class === "string" ? e.device_class : undefined,
icon: typeof e.icon === "string" ? e.icon : undefined,
name: typeof e.name === "string" ? e.name : undefined,
availability: typeof e.availability === "boolean" ? e.availability : true,
};
}
}
}
// Home Assistant
if (parsed.homeassistant && typeof parsed.homeassistant === "object") {
Object.assign(config.homeassistant, parsed.homeassistant);
}
return config;
}

110
src/entities.ts Normal file
View File

@ -0,0 +1,110 @@
import type { Config, EntityConfig } from "./config";
import type { MqttConnection } from "./mqtt";
export interface EntityManager {
start(): Promise<void>;
stop(): void;
}
export function createEntityManager(config: Config, mqtt: MqttConnection): EntityManager {
const timers: Timer[] = [];
async function setupStatefulEntity(id: string, entity: EntityConfig): Promise<void> {
const interval = entity.interval ?? config.systant.defaultInterval;
const isControllable = entity.type === "light" || entity.type === "switch";
if (!entity.state_command) return;
// State polling
const pollState = async () => {
try {
const output = await Bun.$`sh -c ${entity.state_command}`.text();
const value = output.trim();
console.debug(`[${id}] state: ${value}`);
await mqtt.publish(`${id}/state`, value, true); // retain state
} catch (err) {
console.error(`[${id}] state poll failed:`, err instanceof Error ? err.message : err);
}
};
// Initial state poll
await pollState();
// Schedule periodic polling
const timer = setInterval(pollState, interval * 1000);
timers.push(timer);
// Command subscription for controllable entities
if (isControllable && (entity.on_command || entity.off_command)) {
await mqtt.subscribe(`${id}/set`, async (_topic, payload) => {
const command = payload.toString().toUpperCase();
console.log(`[${id}] received command: ${command}`);
try {
if (command === "ON" && entity.on_command) {
await Bun.$`sh -c ${entity.on_command}`;
console.log(`[${id}] executed on_command`);
} else if (command === "OFF" && entity.off_command) {
await Bun.$`sh -c ${entity.off_command}`;
console.log(`[${id}] executed off_command`);
} else {
console.warn(`[${id}] unknown command or no handler: ${command}`);
return;
}
// Re-poll state after command execution
await new Promise((r) => setTimeout(r, 500)); // brief delay for state to settle
await pollState();
} catch (err) {
console.error(`[${id}] command failed:`, err instanceof Error ? err.message : err);
}
});
}
const typeLabel = isControllable ? `${entity.type} (controllable)` : entity.type;
console.log(` ${id}: ${typeLabel}, poll every ${interval}s`);
}
async function setupButton(id: string, entity: EntityConfig): Promise<void> {
if (!entity.press_command) return;
await mqtt.subscribe(`${id}/press`, async (_topic, _payload) => {
console.log(`[${id}] button pressed`);
try {
await Bun.$`sh -c ${entity.press_command}`;
console.log(`[${id}] executed press_command`);
} catch (err) {
console.error(`[${id}] press_command failed:`, err instanceof Error ? err.message : err);
}
});
console.log(` ${id}: button`);
}
return {
async start(): Promise<void> {
const entityCount = Object.keys(config.entities).length;
if (entityCount === 0) {
console.log("No entities configured");
return;
}
console.log(`Starting ${entityCount} entity manager(s):`);
for (const [id, entity] of Object.entries(config.entities)) {
if (entity.type === "button") {
await setupButton(id, entity);
} else {
await setupStatefulEntity(id, entity);
}
}
},
stop(): void {
for (const timer of timers) {
clearInterval(timer);
}
timers.length = 0;
},
};
}

177
src/mqtt.ts Normal file
View File

@ -0,0 +1,177 @@
import mqtt, { type MqttClient, type IClientOptions } from "mqtt";
import type { Config, EntityConfig } from "./config";
export interface MqttConnection {
client: MqttClient;
publish(topic: string, payload: string | object, retain?: boolean): Promise<void>;
subscribe(topic: string, handler: (topic: string, payload: Buffer) => void): Promise<void>;
disconnect(): Promise<void>;
}
export async function connect(config: Config, hostname: string): Promise<MqttConnection> {
const options: IClientOptions = {
clientId: config.mqtt.clientId || `systant-${hostname}`,
username: config.mqtt.username,
password: config.mqtt.password,
will: {
topic: `${config.mqtt.topicPrefix}/${hostname}/status`,
payload: Buffer.from("offline"),
qos: 1,
retain: true,
},
};
const client = mqtt.connect(config.mqtt.broker, options);
const handlers = new Map<string, (topic: string, payload: Buffer) => void>();
await new Promise<void>((resolve, reject) => {
client.on("connect", () => {
console.log(`Connected to MQTT broker: ${config.mqtt.broker}`);
resolve();
});
client.on("error", reject);
});
client.on("message", (topic, payload) => {
for (const [pattern, handler] of handlers) {
if (topicMatches(pattern, topic)) {
handler(topic, payload);
}
}
});
// Publish online status
await publishAsync(client, `${config.mqtt.topicPrefix}/${hostname}/status`, "online", true);
// Publish HA discovery if enabled
if (config.homeassistant.discovery) {
await publishDiscovery(client, config, hostname);
}
return {
client,
async publish(topic: string, payload: string | object, retain = false): Promise<void> {
const fullTopic = `${config.mqtt.topicPrefix}/${hostname}/${topic}`;
const data = typeof payload === "object" ? JSON.stringify(payload) : payload;
await publishAsync(client, fullTopic, data, retain);
},
async subscribe(topic: string, handler: (topic: string, payload: Buffer) => void): Promise<void> {
const fullTopic = `${config.mqtt.topicPrefix}/${hostname}/${topic}`;
handlers.set(fullTopic, handler);
await new Promise<void>((resolve, reject) => {
client.subscribe(fullTopic, { qos: 1 }, (err) => {
if (err) reject(err);
else resolve();
});
});
console.log(`Subscribed to: ${fullTopic}`);
},
async disconnect(): Promise<void> {
await publishAsync(client, `${config.mqtt.topicPrefix}/${hostname}/status`, "offline", true);
await new Promise<void>((resolve) => client.end(false, {}, () => resolve()));
},
};
}
function publishAsync(client: MqttClient, topic: string, payload: string, retain: boolean): Promise<void> {
return new Promise((resolve, reject) => {
client.publish(topic, payload, { qos: 1, retain }, (err) => {
if (err) reject(err);
else resolve();
});
});
}
function topicMatches(pattern: string, topic: string): boolean {
if (pattern === topic) return true;
if (pattern.endsWith("#")) {
return topic.startsWith(pattern.slice(0, -1));
}
return false;
}
async function publishDiscovery(client: MqttClient, config: Config, hostname: string): Promise<void> {
const prefix = config.homeassistant.discoveryPrefix;
const topicPrefix = config.mqtt.topicPrefix;
const entityCount = Object.keys(config.entities).length;
if (entityCount === 0) {
console.log("No entities configured, skipping HA discovery");
return;
}
for (const [id, entity] of Object.entries(config.entities)) {
const payload = buildDiscoveryPayload(id, entity, hostname, topicPrefix);
const discoveryTopic = `${prefix}/${entity.type}/${hostname}_${id}/config`;
await publishAsync(client, discoveryTopic, JSON.stringify(payload), true);
}
console.log(`Published Home Assistant discovery for ${entityCount} entity/entities`);
}
function buildDiscoveryPayload(
id: string,
entity: EntityConfig,
hostname: string,
topicPrefix: string
): Record<string, unknown> {
const displayName = entity.name || id.replace(/_/g, " ");
const payload: Record<string, unknown> = {
name: displayName,
unique_id: `systant_${hostname}_${id}`,
device: {
identifiers: [`systant_${hostname}`],
name: `${hostname}`,
manufacturer: "Systant",
},
};
// Stateful entities have a state_topic (buttons don't)
if (entity.type !== "button") {
payload.state_topic = `${topicPrefix}/${hostname}/${id}/state`;
}
// Add availability tracking unless explicitly disabled
if (entity.availability !== false) {
payload.availability_topic = `${topicPrefix}/${hostname}/status`;
payload.payload_available = "online";
payload.payload_not_available = "offline";
}
// Common optional fields
if (entity.icon) payload.icon = entity.icon;
// Type-specific fields
switch (entity.type) {
case "sensor":
if (entity.unit) payload.unit_of_measurement = entity.unit;
if (entity.device_class) payload.device_class = entity.device_class;
break;
case "binary_sensor":
payload.payload_on = "ON";
payload.payload_off = "OFF";
if (entity.device_class) payload.device_class = entity.device_class;
break;
case "light":
case "switch":
payload.command_topic = `${topicPrefix}/${hostname}/${id}/set`;
payload.payload_on = "ON";
payload.payload_off = "OFF";
payload.state_on = "ON";
payload.state_off = "OFF";
break;
case "button":
payload.command_topic = `${topicPrefix}/${hostname}/${id}/press`;
payload.payload_press = "PRESS";
break;
}
return payload;
}

75
systant.toml.example Normal file
View File

@ -0,0 +1,75 @@
# Systant Configuration
# Copy this to systant.toml and customize for your system
[systant]
# hostname = "myhost" # defaults to system hostname
[mqtt]
broker = "mqtt://localhost:1883"
# username = "user"
# password = "secret"
# clientId = "systant-myhost" # defaults to systant-{hostname}
topicPrefix = "systant"
[entities]
interval = 30 # default interval in seconds
# Sensor examples
[entities.cpu_usage]
type = "sensor"
state_command = "awk '/^cpu / {u=$2+$4; t=$2+$4+$5; print int(u*100/t)}' /proc/stat"
unit = "%"
icon = "mdi:cpu-64-bit"
name = "CPU Usage"
[entities.memory]
type = "sensor"
state_command = "awk '/MemTotal/{t=$2} /MemAvailable/{a=$2} END {print int((t-a)/t*100)}' /proc/meminfo"
unit = "%"
icon = "mdi:memory"
name = "Memory Usage"
[entities.last_seen]
type = "sensor"
state_command = "date -Iseconds"
device_class = "timestamp"
icon = "mdi:clock-check"
name = "Last Seen"
availability = false # keeps last value when offline
# Binary sensor example (read-only on/off)
# [entities.service_running]
# type = "binary_sensor"
# state_command = "systemctl is-active myservice >/dev/null && echo ON || echo OFF"
# device_class = "running"
# icon = "mdi:cog"
# name = "My Service"
# Light example (controllable, for things like monitors)
# [entities.screen]
# type = "light"
# state_command = "xrandr | grep -q 'connected primary' && echo ON || echo OFF"
# on_command = "xrandr --output DP-1 --auto"
# off_command = "xrandr --output DP-1 --off"
# icon = "mdi:monitor"
# name = "Screen"
# Switch example (controllable on/off)
# [entities.vpn]
# type = "switch"
# state_command = "systemctl is-active openvpn >/dev/null && echo ON || echo OFF"
# on_command = "systemctl start openvpn"
# off_command = "systemctl stop openvpn"
# icon = "mdi:vpn"
# name = "VPN"
# Button example (just executes a command)
# [entities.sync_time]
# type = "button"
# press_command = "ntpdate pool.ntp.org"
# icon = "mdi:clock-sync"
# name = "Sync Time"
[homeassistant]
discovery = true
discoveryPrefix = "homeassistant"

29
tsconfig.json Normal file
View File

@ -0,0 +1,29 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}