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:
commit
ae778ebdab
40
.claude/settings.json
Normal file
40
.claude/settings.json
Normal 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
16
.claude/skills/build.md
Normal 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
26
.claude/skills/plan.md
Normal 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
28
.claude/skills/release.md
Normal 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
22
.claude/skills/test.md
Normal 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
|
||||
16
.claude/skills/typecheck.md
Normal file
16
.claude/skills/typecheck.md
Normal 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
|
||||
46
.gitignore
vendored
Normal file
46
.gitignore
vendored
Normal 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
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"terminal.integrated.fontFamily": "Fira Code"
|
||||
}
|
||||
103
AGENTS.md
Normal file
103
AGENTS.md
Normal 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
120
CLAUDE.md
Normal 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
15
README.md
Normal 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
152
bun.lock
Normal 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
61
flake.lock
generated
Normal 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
58
flake.nix
Normal 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
130
index.ts
Normal 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
23
package.json
Normal 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
130
src/config.ts
Normal 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
110
src/entities.ts
Normal 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
177
src/mqtt.ts
Normal 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
75
systant.toml.example
Normal 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
29
tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user