Compare commits
9
Commits
main
..
46e585ec92
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46e585ec92 | ||
|
|
e217c7b180 | ||
|
|
d1e497cdc2 | ||
|
|
248f3f88e6 | ||
|
|
54f8d23945 | ||
|
|
f8173a1afb | ||
|
|
9d8ad1890b | ||
|
|
92fc90e3b4 | ||
|
|
9d8306a64b |
@@ -1,40 +0,0 @@
|
|||||||
{
|
|
||||||
"$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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# /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
|
|
||||||
|
|
||||||
## Nix Build
|
|
||||||
|
|
||||||
For NixOS deployment, the binary is built by Nix using:
|
|
||||||
```bash
|
|
||||||
nix build .#systant
|
|
||||||
```
|
|
||||||
|
|
||||||
If you update dependencies (bun.lock), update the hash in `nix/package.nix`:
|
|
||||||
```bash
|
|
||||||
nix build .#systant 2>&1 | grep 'got:'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
- No TypeScript errors
|
|
||||||
- Binary created at `dist/systant`
|
|
||||||
- Binary is executable
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# /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
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# /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
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# /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
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# /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
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Used by "mix format"
|
||||||
|
[
|
||||||
|
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
|
||||||
|
]
|
||||||
+21
-37
@@ -1,46 +1,30 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# The directory Mix will write compiled artifacts to.
|
||||||
|
/_build/
|
||||||
|
|
||||||
# Dependencies
|
# If you run "mix test --cover", coverage assets end up here.
|
||||||
node_modules
|
/cover/
|
||||||
.pnp
|
|
||||||
.pnp.js
|
|
||||||
|
|
||||||
# Local env files
|
# The directory Mix downloads your dependencies sources to.
|
||||||
.env
|
/deps/
|
||||||
.env.local
|
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
|
|
||||||
# Testing
|
# Where third-party dependencies like ExDoc output generated docs.
|
||||||
coverage
|
/doc/
|
||||||
|
|
||||||
# Turbo
|
# If the VM crashes, it generates a dump, let's ignore it too.
|
||||||
.turbo
|
erl_crash.dump
|
||||||
|
|
||||||
# Vercel
|
# Also ignore archive artifacts (built via "mix archive.build").
|
||||||
.vercel
|
*.ez
|
||||||
|
|
||||||
# Build Outputs
|
# Ignore package tarball (built via "mix hex.build").
|
||||||
.next/
|
system_stats_daemon-*.tar
|
||||||
out/
|
|
||||||
build
|
|
||||||
dist
|
|
||||||
|
|
||||||
|
# Temporary files, for example, from tests.
|
||||||
|
/tmp/
|
||||||
|
|
||||||
# Debug
|
# Nix direnv cache and generated files
|
||||||
npm-debug.log*
|
.direnv/
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
|
|
||||||
# Misc
|
# Nix result symlinks
|
||||||
.DS_Store
|
result
|
||||||
*.pem
|
result-*
|
||||||
auth_failures.log
|
|
||||||
|
|
||||||
# Nix
|
|
||||||
.direnv
|
|
||||||
.direnv/*
|
|
||||||
|
|
||||||
# Local config (use systant.toml.example as template)
|
|
||||||
systant.toml
|
|
||||||
|
|||||||
Vendored
-3
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"terminal.integrated.fontFamily": "Fira Code"
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
# 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.
|
|
||||||
|
|
||||||
## entity-specialist
|
|
||||||
|
|
||||||
Use this agent when working on entity configuration or the entity system.
|
|
||||||
|
|
||||||
**Responsibilities:**
|
|
||||||
- Understand entity types (sensor, binary_sensor, switch, light, button)
|
|
||||||
- Design shell commands for state polling and actions
|
|
||||||
- Ensure proper Home Assistant discovery payloads
|
|
||||||
- Validate entity configuration options
|
|
||||||
|
|
||||||
**Context:** Systant uses a unified "entity" system where all metrics and controls are defined as entities in TOML config. Each entity has a `state_command` and optionally `on_command`/`off_command`/`press_command` depending on type.
|
|
||||||
|
|
||||||
## 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}/{entity_id}/state` for state updates, `systant/{hostname}/{entity_id}/set` for switch/light commands, and `homeassistant/{type}/{hostname}_{entity_id}/config` for discovery.
|
|
||||||
|
|
||||||
## nix-specialist
|
|
||||||
|
|
||||||
Use this agent when working on Nix packaging or the home-manager module.
|
|
||||||
|
|
||||||
**Responsibilities:**
|
|
||||||
- Maintain the Nix flake and package definition
|
|
||||||
- Update the home-manager module options
|
|
||||||
- Handle fixed-output derivations for npm dependencies
|
|
||||||
- Ensure cross-system compatibility
|
|
||||||
|
|
||||||
**Context:** Systant is packaged as a Nix flake with a home-manager module. The package uses a two-phase build: FOD for `bun install`, then `bun build --compile` for the binary. The home-manager module creates a systemd user service.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
@@ -1,165 +1,79 @@
|
|||||||
# Systant
|
# CLAUDE.md
|
||||||
|
|
||||||
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.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
## Project Overview
|
## Common Commands
|
||||||
|
|
||||||
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
|
|
||||||
src/
|
|
||||||
config.ts # TOML configuration loading
|
|
||||||
mqtt.ts # MQTT client, publishing, and HA discovery
|
|
||||||
entities.ts # Entity management (state polling, command handling)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Entity System
|
|
||||||
|
|
||||||
Systant uses a unified "entity" concept that combines state monitoring and command handling. Entity types:
|
|
||||||
|
|
||||||
- **sensor**: Read-only numeric/string values (CPU usage, temperature, etc.)
|
|
||||||
- **binary_sensor**: Read-only on/off states (service running, etc.)
|
|
||||||
- **switch**: Controllable on/off with `on_command` and `off_command`
|
|
||||||
- **light**: Same as switch, for display/monitor control
|
|
||||||
- **button**: Press-only actions with `press_command`
|
|
||||||
|
|
||||||
Each entity is defined in TOML with shell commands:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[entities.cpu_usage]
|
|
||||||
type = "sensor"
|
|
||||||
state_command = "awk '/^cpu / {print int(($2+$4)*100/($2+$4+$5))}' /proc/stat"
|
|
||||||
unit = "%"
|
|
||||||
icon = "mdi:chip"
|
|
||||||
name = "CPU Usage"
|
|
||||||
|
|
||||||
[entities.headphones]
|
|
||||||
type = "switch"
|
|
||||||
state_command = "pactl get-default-sink | grep -q usb && echo ON || echo OFF"
|
|
||||||
on_command = "pactl set-default-sink alsa_output.usb-..."
|
|
||||||
off_command = "pactl set-default-sink alsa_output.pci-..."
|
|
||||||
```
|
|
||||||
|
|
||||||
### MQTT Topics
|
|
||||||
|
|
||||||
```
|
|
||||||
systant/{hostname}/{entity_id}/state # State updates
|
|
||||||
systant/{hostname}/{entity_id}/set # Commands (for switch/light)
|
|
||||||
systant/{hostname}/{entity_id}/press # Button presses
|
|
||||||
systant/{hostname}/availability # Online/offline status
|
|
||||||
homeassistant/{type}/{hostname}_{id}/config # HA auto-discovery
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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)
|
|
||||||
- **Config**: TOML (smol-toml)
|
|
||||||
- **MQTT**: mqtt.js
|
|
||||||
- **Packaging**: Nix flake with home-manager module
|
|
||||||
|
|
||||||
### NixOS/Home Manager Integration
|
|
||||||
|
|
||||||
```nix
|
|
||||||
# flake.nix inputs
|
|
||||||
inputs.systant.url = "git+ssh://...";
|
|
||||||
|
|
||||||
# Add overlay for pkgs.systant
|
|
||||||
nixpkgs.overlays = [ inputs.systant.overlays.default ];
|
|
||||||
|
|
||||||
# Import home-manager module
|
|
||||||
home-manager.sharedModules = [ inputs.systant.homeManagerModules.default ];
|
|
||||||
|
|
||||||
# In user config
|
|
||||||
services.systant = {
|
|
||||||
enable = true;
|
|
||||||
settings = { /* TOML as Nix attrset */ };
|
|
||||||
# or: configFile = ./systant.toml;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
|
### Development
|
||||||
```bash
|
```bash
|
||||||
# Development
|
# Install dependencies
|
||||||
bun run index.ts run --config systant.toml
|
mix deps.get
|
||||||
|
|
||||||
# Build standalone binary
|
# Compile the project
|
||||||
bun build index.ts --compile --outfile systant
|
mix compile
|
||||||
|
|
||||||
# Nix build
|
# Run in development (non-halt mode)
|
||||||
nix build .#systant
|
mix run --no-halt
|
||||||
|
|
||||||
# Run tests
|
# Run tests
|
||||||
bun test
|
mix test
|
||||||
|
|
||||||
|
# Run specific test
|
||||||
|
mix test test/systant_test.exs
|
||||||
|
|
||||||
|
# Enter development shell (via Nix)
|
||||||
|
nix develop
|
||||||
```
|
```
|
||||||
|
|
||||||
## Planning Protocol
|
### Production
|
||||||
|
```bash
|
||||||
|
# Build production release
|
||||||
|
MIX_ENV=prod mix release
|
||||||
|
|
||||||
When implementing features:
|
# Run production release
|
||||||
1. Discuss the approach before writing code
|
_build/prod/rel/systant/bin/systant start
|
||||||
2. Start with types/interfaces
|
```
|
||||||
3. Write tests alongside implementation
|
|
||||||
4. Keep PRs focused and small
|
## Architecture Overview
|
||||||
|
|
||||||
|
This is an Elixir OTP application that serves as a systemd daemon for MQTT-based system monitoring, designed for deployment across multiple NixOS hosts to integrate with Home Assistant.
|
||||||
|
|
||||||
|
### Core Components
|
||||||
|
- **Systant.Application** (`lib/systant/application.ex`): OTP application supervisor that starts the MQTT client
|
||||||
|
- **Systant.MqttClient** (`lib/systant/mqtt_client.ex`): GenServer that handles MQTT connection, publishes stats every 30 seconds, and listens for commands
|
||||||
|
- **Configuration**: MQTT settings configurable via environment variables or config files
|
||||||
|
|
||||||
|
### Key Libraries
|
||||||
|
- **Tortoise**: MQTT client library for pub/sub functionality
|
||||||
|
- **Jason**: JSON encoding/decoding for message payloads
|
||||||
|
|
||||||
|
### MQTT Behavior
|
||||||
|
- Publishes "Hello from systant" messages with timestamp and hostname to stats topic every 30 seconds
|
||||||
|
- Subscribes to commands topic for incoming events that can trigger user-customizable actions
|
||||||
|
- Uses randomized client ID to avoid conflicts across multiple hosts
|
||||||
|
- Sends immediate hello message on startup
|
||||||
|
|
||||||
|
### Default Configuration
|
||||||
|
- **MQTT Host**: `mqtt.home` (not localhost)
|
||||||
|
- **Stats Topic**: `systant/${hostname}/stats` (per-host topics)
|
||||||
|
- **Command Topic**: `systant/${hostname}/commands` (per-host topics)
|
||||||
|
- **Publish Interval**: 30 seconds
|
||||||
|
|
||||||
|
### NixOS Deployment
|
||||||
|
This project includes a complete Nix packaging and NixOS module:
|
||||||
|
|
||||||
|
- **Package**: `nix/package.nix` - Builds the Elixir release using beamPackages.mixRelease
|
||||||
|
- **Module**: `nix/nixos-module.nix` - Provides `services.systant` configuration options
|
||||||
|
- **Development**: Use `nix develop` for development shell with Elixir/Erlang
|
||||||
|
|
||||||
|
The NixOS module supports:
|
||||||
|
- Configurable MQTT connection settings
|
||||||
|
- Per-host topic naming using `${config.networking.hostName}`
|
||||||
|
- Environment variable configuration for runtime settings
|
||||||
|
- Systemd service with security hardening
|
||||||
|
- Auto-restart and logging to systemd journal
|
||||||
|
|
||||||
|
### Future Plans
|
||||||
|
- Integration with Home Assistant via custom MQTT integration
|
||||||
|
- Expandable command handling for host-specific automation
|
||||||
|
- Multi-host deployment for comprehensive system monitoring
|
||||||
@@ -1,15 +1,60 @@
|
|||||||
# systant
|
# Systant
|
||||||
|
|
||||||
To install dependencies:
|
An Elixir application that runs as a systemd daemon to:
|
||||||
|
1. Publish system stats to MQTT every 30 seconds
|
||||||
|
2. Listen for commands over MQTT and log them to syslog
|
||||||
|
|
||||||
```bash
|
## Configuration
|
||||||
bun install
|
|
||||||
|
Edit `config/config.exs` to configure MQTT connection:
|
||||||
|
|
||||||
|
```elixir
|
||||||
|
config :systant, Systant.MqttClient,
|
||||||
|
host: "localhost",
|
||||||
|
port: 1883,
|
||||||
|
client_id: "systant",
|
||||||
|
username: nil,
|
||||||
|
password: nil,
|
||||||
|
stats_topic: "system/stats",
|
||||||
|
command_topic: "system/commands",
|
||||||
|
publish_interval: 30_000
|
||||||
```
|
```
|
||||||
|
|
||||||
To run:
|
## Building
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run index.ts
|
mix deps.get
|
||||||
|
mix compile
|
||||||
```
|
```
|
||||||
|
|
||||||
This project was created using `bun init` in bun v1.3.6. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
|
## Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
mix run --no-halt
|
||||||
|
|
||||||
|
# Production release
|
||||||
|
MIX_ENV=prod mix release
|
||||||
|
_build/prod/rel/systant/bin/systant start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Systemd Installation
|
||||||
|
|
||||||
|
1. Build production release
|
||||||
|
2. Copy binary to `/usr/local/bin/`
|
||||||
|
3. Copy `systant.service` to `/etc/systemd/system/`
|
||||||
|
4. Enable and start:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl enable systant
|
||||||
|
sudo systemctl start systant
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Publishes "Hello from systant" stats every 30 seconds to `system/stats` topic
|
||||||
|
- Listens on `system/commands` topic and logs received messages
|
||||||
|
- Configurable MQTT connection settings
|
||||||
|
- Runs as systemd daemon with auto-restart
|
||||||
|
- Logs to system journal
|
||||||
|
|
||||||
|
|||||||
@@ -1,152 +0,0 @@
|
|||||||
{
|
|
||||||
"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=="],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import Config
|
||||||
|
|
||||||
|
config :logger, :console,
|
||||||
|
format: "$time $metadata[$level] $message\n",
|
||||||
|
metadata: [:request_id]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import Config
|
||||||
|
|
||||||
|
# Get hostname for topic construction
|
||||||
|
hostname = case :inet.gethostname() do
|
||||||
|
{:ok, hostname} -> List.to_string(hostname)
|
||||||
|
_ -> "unknown"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Runtime configuration that can use environment variables
|
||||||
|
config :systant, Systant.MqttClient,
|
||||||
|
host: System.get_env("SYSTANT_MQTT_HOST", "mqtt.home"),
|
||||||
|
port: String.to_integer(System.get_env("SYSTANT_MQTT_PORT", "1883")),
|
||||||
|
client_id: System.get_env("SYSTANT_CLIENT_ID", "systant"),
|
||||||
|
username: System.get_env("SYSTANT_MQTT_USERNAME"),
|
||||||
|
password: System.get_env("SYSTANT_MQTT_PASSWORD"),
|
||||||
|
stats_topic: System.get_env("SYSTANT_STATS_TOPIC", "systant/#{hostname}/stats"),
|
||||||
|
command_topic: System.get_env("SYSTANT_COMMAND_TOPIC", "systant/#{hostname}/commands"),
|
||||||
|
publish_interval: String.to_integer(System.get_env("SYSTANT_PUBLISH_INTERVAL", "30000"))
|
||||||
Generated
+4
-4
@@ -20,16 +20,16 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1768395095,
|
"lastModified": 1753939845,
|
||||||
"narHash": "sha256-ZhuYJbwbZT32QA95tSkXd9zXHcdZj90EzHpEXBMabaw=",
|
"narHash": "sha256-K2ViRJfdVGE8tpJejs8Qpvvejks1+A4GQej/lBk5y7I=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "13868c071cc73a5e9f610c47d7bb08e5da64fdd5",
|
"rev": "94def634a20494ee057c76998843c015909d6311",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"ref": "nixpkgs-unstable",
|
"ref": "nixos-unstable",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +1,44 @@
|
|||||||
{
|
{
|
||||||
description = "Systant - System monitoring agent with MQTT and Home Assistant integration";
|
description = "Elixir system monitor daemon";
|
||||||
|
|
||||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
inputs = {
|
||||||
inputs.flake-utils.url = "github:numtide/flake-utils";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
};
|
||||||
|
|
||||||
outputs =
|
outputs =
|
||||||
{
|
{
|
||||||
self,
|
self,
|
||||||
nixpkgs,
|
nixpkgs,
|
||||||
flake-utils,
|
flake-utils,
|
||||||
...
|
|
||||||
}:
|
}:
|
||||||
{
|
|
||||||
# Home Manager module (system-independent)
|
|
||||||
homeManagerModules.default = import ./nix/nixos-module.nix;
|
|
||||||
|
|
||||||
# Overlay to add systant to pkgs
|
|
||||||
overlays.default = final: prev: {
|
|
||||||
systant = final.callPackage ./nix/package.nix { src = self; };
|
|
||||||
};
|
|
||||||
}
|
|
||||||
//
|
|
||||||
flake-utils.lib.eachDefaultSystem (
|
flake-utils.lib.eachDefaultSystem (
|
||||||
system:
|
system:
|
||||||
let
|
let
|
||||||
pkgs = import nixpkgs {
|
pkgs = import nixpkgs {
|
||||||
inherit system;
|
system = system;
|
||||||
config.allowUnfree = true;
|
config.allowUnfree = true;
|
||||||
overlays = [ self.overlays.default ];
|
|
||||||
};
|
};
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
packages = {
|
|
||||||
systant = pkgs.systant;
|
|
||||||
default = pkgs.systant;
|
|
||||||
};
|
|
||||||
|
|
||||||
apps.default = {
|
|
||||||
type = "app";
|
|
||||||
program = "${pkgs.systant}/bin/systant";
|
|
||||||
};
|
|
||||||
|
|
||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
buildInputs = with pkgs; [
|
buildInputs = with pkgs; [
|
||||||
bashInteractive
|
elixir
|
||||||
glibcLocales
|
erlang
|
||||||
git
|
claude-code
|
||||||
bun
|
aider-chat
|
||||||
inotify-tools
|
nodejs_20
|
||||||
|
yarn
|
||||||
|
deno
|
||||||
|
python311
|
||||||
|
python311Packages.pip
|
||||||
|
python311Packages.ipython
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
export PROJECT_ROOT=$PWD
|
echo "Elixir development environment loaded"
|
||||||
|
elixir --version
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
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");
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
defmodule Systant do
|
||||||
|
@moduledoc """
|
||||||
|
Documentation for `Systant`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Hello world.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> Systant.hello()
|
||||||
|
:world
|
||||||
|
|
||||||
|
"""
|
||||||
|
def hello do
|
||||||
|
:world
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
defmodule Systant.Application do
|
||||||
|
# See https://hexdocs.pm/elixir/Application.html
|
||||||
|
# for more information on OTP Applications
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Application
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def start(_type, _args) do
|
||||||
|
children = [
|
||||||
|
{Systant.MqttClient, []}
|
||||||
|
]
|
||||||
|
|
||||||
|
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||||
|
# for other strategies and supported options
|
||||||
|
opts = [strategy: :one_for_one, name: Systant.Supervisor]
|
||||||
|
Supervisor.start_link(children, opts)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
defmodule Systant.MqttClient do
|
||||||
|
use GenServer
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
@moduledoc """
|
||||||
|
MQTT client for publishing system stats and handling commands
|
||||||
|
"""
|
||||||
|
|
||||||
|
def start_link(opts) do
|
||||||
|
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||||
|
end
|
||||||
|
|
||||||
|
def init(_opts) do
|
||||||
|
config = Application.get_env(:systant, __MODULE__)
|
||||||
|
Logger.info("Starting MQTT client with config: #{inspect(config)}")
|
||||||
|
|
||||||
|
# Use a unique client ID to avoid conflicts
|
||||||
|
client_id = "#{config[:client_id]}_#{:rand.uniform(1000)}"
|
||||||
|
|
||||||
|
connection_opts = [
|
||||||
|
client_id: client_id,
|
||||||
|
server: {Tortoise.Transport.Tcp, host: to_charlist(config[:host]), port: config[:port]},
|
||||||
|
handler: {Tortoise.Handler.Logger, []},
|
||||||
|
user_name: config[:username],
|
||||||
|
password: config[:password],
|
||||||
|
subscriptions: [{config[:command_topic], 0}]
|
||||||
|
]
|
||||||
|
|
||||||
|
case Tortoise.Connection.start_link(connection_opts) do
|
||||||
|
{:ok, _pid} ->
|
||||||
|
Logger.info("MQTT client connected successfully")
|
||||||
|
|
||||||
|
# Send immediate hello message
|
||||||
|
hello_msg = %{
|
||||||
|
message: "Hello - systant started",
|
||||||
|
timestamp: DateTime.utc_now() |> DateTime.to_iso8601(),
|
||||||
|
hostname: get_hostname()
|
||||||
|
}
|
||||||
|
Tortoise.publish(client_id, config[:stats_topic], Jason.encode!(hello_msg), qos: 0)
|
||||||
|
|
||||||
|
schedule_stats_publish(config[:publish_interval])
|
||||||
|
{:ok, %{config: config, client_id: client_id}}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error("Failed to connect to MQTT broker: #{inspect(reason)}")
|
||||||
|
{:stop, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info(:publish_stats, state) do
|
||||||
|
publish_stats(state.config, state.client_id)
|
||||||
|
schedule_stats_publish(state.config[:publish_interval])
|
||||||
|
{:noreply, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info(_msg, state) do
|
||||||
|
{:noreply, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
def terminate(reason, state) do
|
||||||
|
Logger.info("MQTT client terminating: #{inspect(reason)}")
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp publish_stats(config, client_id) do
|
||||||
|
stats = %{
|
||||||
|
message: "Hello from systant",
|
||||||
|
timestamp: DateTime.utc_now() |> DateTime.to_iso8601(),
|
||||||
|
hostname: get_hostname()
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = Jason.encode!(stats)
|
||||||
|
|
||||||
|
case Tortoise.publish(client_id, config[:stats_topic], payload, qos: 0) do
|
||||||
|
:ok ->
|
||||||
|
Logger.info("Published stats: #{payload}")
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error("Failed to publish stats: #{inspect(reason)}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp schedule_stats_publish(interval) do
|
||||||
|
Process.send_after(self(), :publish_stats, interval)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_hostname do
|
||||||
|
case :inet.gethostname() do
|
||||||
|
{:ok, hostname} -> List.to_string(hostname)
|
||||||
|
_ -> "unknown"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
defmodule SystemStatsDaemon.MixProject do
|
||||||
|
use Mix.Project
|
||||||
|
|
||||||
|
def project do
|
||||||
|
[
|
||||||
|
app: :systant,
|
||||||
|
version: "0.1.0",
|
||||||
|
elixir: "~> 1.18",
|
||||||
|
start_permanent: Mix.env() == :prod,
|
||||||
|
deps: deps(),
|
||||||
|
releases: releases()
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Run "mix help compile.app" to learn about applications.
|
||||||
|
def application do
|
||||||
|
[
|
||||||
|
extra_applications: [:logger],
|
||||||
|
mod: {Systant.Application, []}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Run "mix help deps" to learn about dependencies.
|
||||||
|
defp deps do
|
||||||
|
[
|
||||||
|
{:tortoise, "~> 0.9.5"},
|
||||||
|
{:jason, "~> 1.4"}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp releases do
|
||||||
|
[
|
||||||
|
systant: [
|
||||||
|
include_executables_for: [:unix],
|
||||||
|
applications: [runtime_tools: :permanent],
|
||||||
|
include_erts: true,
|
||||||
|
strip_beams: false
|
||||||
|
]
|
||||||
|
]
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
%{
|
||||||
|
"cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"},
|
||||||
|
"emqtt": {:hex, :emqtt, "1.14.4", "f34fd1e612e3138e61e9a2d27b0f9674e1da87cc794d30b7916d96f6ee7eef71", [:rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:getopt, "1.0.3", [hex: :getopt, repo: "hexpm", optional: false]}, {:gun, "2.1.0", [hex: :gun, repo: "hexpm", optional: false]}], "hexpm", "9065ba581ea899fde316b7eafd03f3c945044c151480bf3adabc6b62b0e60dad"},
|
||||||
|
"gen_state_machine": {:hex, :gen_state_machine, "3.0.0", "1e57f86a494e5c6b14137ebef26a7eb342b3b0070c7135f2d6768ed3f6b6cdff", [:mix], [], "hexpm", "0a59652574bebceb7309f6b749d2a41b45fdeda8dbb4da0791e355dd19f0ed15"},
|
||||||
|
"getopt": {:hex, :getopt, "1.0.3", "4f3320c1f6f26b2bec0f6c6446b943eb927a1e6428ea279a1c6c534906ee79f1", [:rebar3], [], "hexpm", "7e01de90ac540f21494ff72792b1e3162d399966ebbfc674b4ce52cb8f49324f"},
|
||||||
|
"gun": {:hex, :gun, "2.1.0", "b4e4cbbf3026d21981c447e9e7ca856766046eff693720ba43114d7f5de36e87", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "52fc7fc246bfc3b00e01aea1c2854c70a366348574ab50c57dfe796d24a0101d"},
|
||||||
|
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
|
||||||
|
"syslog": {:hex, :syslog, "1.1.0", "6419a232bea84f07b56dc575225007ffe34d9fdc91abe6f1b2f254fd71d8efc2", [:rebar3], [], "hexpm", "4c6a41373c7e20587be33ef841d3de6f3beba08519809329ecc4d27b15b659e1"},
|
||||||
|
"tortoise": {:hex, :tortoise, "0.9.9", "2e467570ef1d342d4de8fdc6ba3861f841054ab524080ec3d7052ee07c04501d", [:mix], [{:gen_state_machine, "~> 2.0 or ~> 3.0", [hex: :gen_state_machine, repo: "hexpm", optional: false]}], "hexpm", "4a316220b4b443c2497f42702f0c0616af3e4b2cbc6c150ebebb51657a773797"},
|
||||||
|
}
|
||||||
+75
-65
@@ -1,88 +1,98 @@
|
|||||||
{ config, lib, pkgs, ... }:
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
with lib;
|
||||||
|
|
||||||
let
|
let
|
||||||
cfg = config.services.systant;
|
cfg = config.services.systant;
|
||||||
settingsFormat = pkgs.formats.toml { };
|
|
||||||
configFile =
|
|
||||||
if cfg.configFile != null
|
|
||||||
then cfg.configFile
|
|
||||||
else if cfg.settings != { }
|
|
||||||
then settingsFormat.generate "systant-config.toml" cfg.settings
|
|
||||||
else null;
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
options.services.systant = {
|
options.services.systant = {
|
||||||
enable = lib.mkEnableOption "systant system monitoring agent";
|
enable = mkEnableOption "Systant MQTT Daemon";
|
||||||
|
|
||||||
package = lib.mkOption {
|
package = mkOption {
|
||||||
type = lib.types.package;
|
type = types.package;
|
||||||
default = pkgs.systant;
|
description = "The systant package to use";
|
||||||
defaultText = lib.literalExpression "pkgs.systant";
|
|
||||||
description = "The systant package to use.";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
configFile = lib.mkOption {
|
mqttHost = mkOption {
|
||||||
type = lib.types.nullOr lib.types.path;
|
type = types.str;
|
||||||
|
default = "localhost";
|
||||||
|
description = "MQTT broker hostname";
|
||||||
|
};
|
||||||
|
|
||||||
|
mqttPort = mkOption {
|
||||||
|
type = types.int;
|
||||||
|
default = 1883;
|
||||||
|
description = "MQTT broker port";
|
||||||
|
};
|
||||||
|
|
||||||
|
mqttUsername = mkOption {
|
||||||
|
type = types.nullOr types.str;
|
||||||
default = null;
|
default = null;
|
||||||
description = ''
|
description = "MQTT username (null for no auth)";
|
||||||
Path to the systant configuration file (TOML).
|
|
||||||
If set, this takes precedence over the settings option.
|
|
||||||
'';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
settings = lib.mkOption {
|
mqttPassword = mkOption {
|
||||||
type = settingsFormat.type;
|
type = types.nullOr types.str;
|
||||||
default = { };
|
default = null;
|
||||||
description = ''
|
description = "MQTT password (null for no auth)";
|
||||||
Configuration for systant in Nix attribute set form.
|
};
|
||||||
Will be converted to TOML. Ignored if configFile is set.
|
|
||||||
'';
|
statsTopic = mkOption {
|
||||||
example = lib.literalExpression ''
|
type = types.str;
|
||||||
{
|
default = "systant/${config.networking.hostName}/stats";
|
||||||
mqtt = {
|
description = "MQTT topic for publishing stats";
|
||||||
broker = "mqtt://localhost:1883";
|
};
|
||||||
topicPrefix = "systant";
|
|
||||||
};
|
commandTopic = mkOption {
|
||||||
entities = {
|
type = types.str;
|
||||||
cpu_usage = {
|
default = "systant/${config.networking.hostName}/commands";
|
||||||
type = "sensor";
|
description = "MQTT topic for receiving commands";
|
||||||
state_command = "awk '/^cpu / {u=$2+$4; t=$2+$4+$5; print int(u*100/t)}' /proc/stat";
|
};
|
||||||
unit = "%";
|
|
||||||
icon = "mdi:cpu-64-bit";
|
publishInterval = mkOption {
|
||||||
name = "CPU Usage";
|
type = types.int;
|
||||||
};
|
default = 30000;
|
||||||
};
|
description = "Interval between stats publications (milliseconds)";
|
||||||
homeassistant = {
|
|
||||||
discovery = true;
|
|
||||||
discoveryPrefix = "homeassistant";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
'';
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
config = lib.mkIf cfg.enable {
|
config = mkIf cfg.enable {
|
||||||
home.packages = [ cfg.package ];
|
systemd.services.systant = {
|
||||||
|
description = "Systant MQTT Daemon";
|
||||||
|
after = [ "network.target" ];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
|
||||||
systemd.user.services.systant = {
|
environment = {
|
||||||
Unit = {
|
SYSTANT_MQTT_HOST = cfg.mqttHost;
|
||||||
Description = "Systant system monitoring agent";
|
SYSTANT_MQTT_PORT = toString cfg.mqttPort;
|
||||||
After = [ "network-online.target" ];
|
SYSTANT_MQTT_USERNAME = mkIf (cfg.mqttUsername != null) cfg.mqttUsername;
|
||||||
Wants = [ "network-online.target" ];
|
SYSTANT_MQTT_PASSWORD = mkIf (cfg.mqttPassword != null) cfg.mqttPassword;
|
||||||
|
SYSTANT_STATS_TOPIC = cfg.statsTopic;
|
||||||
|
SYSTANT_COMMAND_TOPIC = cfg.commandTopic;
|
||||||
|
SYSTANT_PUBLISH_INTERVAL = toString cfg.publishInterval;
|
||||||
|
# Override RELEASE_COOKIE to bypass file reading
|
||||||
|
RELEASE_COOKIE = "systant-bypass-cookie";
|
||||||
};
|
};
|
||||||
|
|
||||||
Service = {
|
serviceConfig = {
|
||||||
Type = "simple";
|
Type = "exec";
|
||||||
ExecStart =
|
User = "root";
|
||||||
if configFile != null
|
Group = "root";
|
||||||
then "${cfg.package}/bin/systant run --config ${configFile}"
|
ExecStart = "${cfg.package}/bin/systant start";
|
||||||
else "${cfg.package}/bin/systant run";
|
ExecStop = "${cfg.package}/bin/systant stop";
|
||||||
Restart = "on-failure";
|
Restart = "always";
|
||||||
RestartSec = "5s";
|
RestartSec = 5;
|
||||||
};
|
StandardOutput = "journal";
|
||||||
|
StandardError = "journal";
|
||||||
|
SyslogIdentifier = "systant";
|
||||||
|
WorkingDirectory = "${cfg.package}";
|
||||||
|
|
||||||
Install = {
|
# Security settings
|
||||||
WantedBy = [ "default.target" ];
|
NoNewPrivileges = true;
|
||||||
|
PrivateTmp = true;
|
||||||
|
ProtectHome = true;
|
||||||
|
ProtectSystem = false; # Need access to system stats
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+16
-50
@@ -1,66 +1,32 @@
|
|||||||
{
|
{
|
||||||
lib,
|
lib,
|
||||||
stdenvNoCC,
|
beamPackages,
|
||||||
stdenv,
|
fetchgit,
|
||||||
bun,
|
|
||||||
cacert,
|
|
||||||
src, # passed from flake.nix
|
|
||||||
}:
|
}:
|
||||||
|
|
||||||
let
|
beamPackages.mixRelease rec {
|
||||||
# Fixed-output derivation to fetch npm dependencies
|
|
||||||
# Update the hash when bun.lock changes by running:
|
|
||||||
# nix build .#systant 2>&1 | grep 'got:'
|
|
||||||
deps = stdenvNoCC.mkDerivation {
|
|
||||||
pname = "systant-deps";
|
|
||||||
version = "0.1.0";
|
|
||||||
|
|
||||||
inherit src;
|
|
||||||
|
|
||||||
buildPhase = ''
|
|
||||||
export HOME=$TMPDIR
|
|
||||||
bun install --frozen-lockfile
|
|
||||||
'';
|
|
||||||
|
|
||||||
installPhase = ''
|
|
||||||
cp -r node_modules $out
|
|
||||||
'';
|
|
||||||
|
|
||||||
nativeBuildInputs = [ bun cacert ];
|
|
||||||
|
|
||||||
outputHashMode = "recursive";
|
|
||||||
outputHashAlgo = "sha256";
|
|
||||||
# To update: nix build .#systant 2>&1 | grep 'got:'
|
|
||||||
outputHash = "sha256-hQ1ZzOFOHHeaAtyfCXxX6jpqB7poFLwavgMW8yMwaHw=";
|
|
||||||
};
|
|
||||||
in
|
|
||||||
stdenv.mkDerivation {
|
|
||||||
pname = "systant";
|
pname = "systant";
|
||||||
version = "0.1.0";
|
version = "0.1.0";
|
||||||
|
|
||||||
inherit src;
|
src = fetchgit {
|
||||||
|
url = "https://git.ryanpandya.com/ryan/systant.git";
|
||||||
|
rev = "54f8d23945bcea17127631a81f8ac318bf7047a6";
|
||||||
|
sha256 = "sha256-1cRfSoH+JdO4a7q4hRZSkoDMk2wMCYRIyCIN56FSUgg=";
|
||||||
|
};
|
||||||
|
|
||||||
nativeBuildInputs = [ bun ];
|
# Mix dependencies will be automatically fetched and cached by Nix
|
||||||
|
mixFodDeps = beamPackages.fetchMixDeps {
|
||||||
|
pname = "systant-mix-deps";
|
||||||
|
inherit src version;
|
||||||
|
sha256 = "sha256-g8L/ZzCaXznrd+YLCMgvV94NVTKoFnK/Y/RXXPIMAjg=";
|
||||||
|
};
|
||||||
|
|
||||||
buildPhase = ''
|
|
||||||
export HOME=$TMPDIR
|
|
||||||
cp -r ${deps} node_modules
|
|
||||||
chmod -R u+w node_modules
|
|
||||||
bun build index.ts --compile --outfile systant
|
|
||||||
'';
|
|
||||||
|
|
||||||
installPhase = ''
|
|
||||||
mkdir -p $out/bin
|
|
||||||
cp systant $out/bin/systant
|
|
||||||
'';
|
|
||||||
|
|
||||||
# Bun's compiled binaries don't like being stripped
|
|
||||||
dontStrip = true;
|
|
||||||
|
|
||||||
meta = with lib; {
|
meta = with lib; {
|
||||||
description = "System monitoring agent with MQTT and Home Assistant integration";
|
description = "Systant - System stats MQTT daemon for monitoring system metrics";
|
||||||
homepage = "https://git.ryanpandya.com/ryan/systant";
|
homepage = "https://git.ryanpandya.com/ryan/systant";
|
||||||
license = licenses.mit;
|
license = licenses.mit;
|
||||||
|
maintainers = [ ];
|
||||||
platforms = platforms.linux;
|
platforms = platforms.linux;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# Configure environment for release
|
||||||
|
export MIX_ENV=prod
|
||||||
|
export RELEASE_DISTRIBUTION=none
|
||||||
|
export RELEASE_NODE=nonode@nohost
|
||||||
-130
@@ -1,130 +0,0 @@
|
|||||||
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
@@ -1,110 +0,0 @@
|
|||||||
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
@@ -1,177 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
# 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"
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
defmodule SystantTest do
|
||||||
|
use ExUnit.Case
|
||||||
|
doctest Systant
|
||||||
|
|
||||||
|
test "greets the world" do
|
||||||
|
assert Systant.hello() == :world
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ExUnit.start()
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user