Compare commits

..
3 Commits
Author SHA1 Message Date
ryanandClaude Opus 4.5 08bf13cc32 fix: pass src from flake to package.nix
Relative paths in package.nix don't resolve correctly when
the flake is used as an input in another flake. Pass src
explicitly from the flake instead.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 20:30:06 -08:00
ryanandClaude Opus 4.5 af4606c40b feat: add NixOS module and proper Nix packaging
- nix/package.nix: two-phase build with fixed-output derivation for deps
- nix/nixos-module.nix: systemd service with systant.enable and systant.configFile
- flake.nix: expose nixosModules.default and overlays.default

Usage in NixOS config:
  systant.enable = true;
  systant.configFile = ./systant.toml;

When deps change, update hash: nix build .#systant 2>&1 | grep 'got:'

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 20:19:27 -08:00
ryanandClaude Opus 4.5 ae778ebdab 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>
2026-01-19 19:52:47 -08:00
35 changed files with 1419 additions and 466 deletions
+40
View File
@@ -0,0 +1,40 @@
{
"$schema": "https://claude.ai/claude-code/settings.schema.json",
"permissions": {
"allow": [
"Bash(bun test*)",
"Bash(bun run*)",
"Bash(bun build*)",
"Bash(bun install*)",
"Bash(bunx tsc --noEmit*)",
"Bash(git status*)",
"Bash(git diff*)",
"Bash(git log*)",
"Bash(git add*)",
"Bash(git commit*)",
"Bash(ls*)",
"Bash(cat /proc/*)",
"Bash(cat /sys/*)"
],
"deny": [
"Bash(rm -rf /)*",
"Bash(sudo *)",
"Bash(*--force*)"
]
},
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bunx tsc --noEmit --pretty 2>&1 | head -20 || true",
"description": "Type check after file changes",
"timeout": 10000
}
]
}
]
}
}
+28
View File
@@ -0,0 +1,28 @@
# /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
+26
View File
@@ -0,0 +1,26 @@
# /plan
Enter planning mode to design an implementation approach.
## Instructions
1. Enter plan mode using EnterPlanMode tool
2. Explore the codebase to understand current state
3. Identify affected files and components
4. Design the implementation approach
5. Present the plan for user approval before coding
## When to Use
- New features
- Architectural changes
- Complex bug fixes
- Refactoring tasks
## Output
A clear plan including:
- Files to create/modify
- Key implementation steps
- Potential risks or considerations
- Testing approach
+28
View File
@@ -0,0 +1,28 @@
# /release
Prepare a release of systant.
## Instructions
1. Ensure working directory is clean (`git status`)
2. Run tests: `bun test`
3. Type check: `bunx tsc --noEmit`
4. Build binary: `bun build index.ts --compile --outfile dist/systant`
5. Ask user for version bump type (patch/minor/major)
6. Update version in package.json
7. Create git commit with message: "release: v{version}"
8. Create git tag: `v{version}`
9. Report next steps (push, publish, etc.)
## Prerequisites
- All tests must pass
- No TypeScript errors
- Clean git working directory (or user confirms to proceed)
## Success Criteria
- Binary built successfully
- Version bumped in package.json
- Git commit and tag created
- Clear instructions for next steps
+22
View File
@@ -0,0 +1,22 @@
# /test
Run the test suite.
## Instructions
1. Run `bun test` to execute all tests
2. If tests fail:
- Analyze the failure messages
- Identify the root cause
- Suggest specific fixes
3. If tests pass, report the summary
## Options
- `/test <pattern>` - Run tests matching a pattern (e.g., `/test metrics`)
- `/test --watch` - Run in watch mode
## Success Criteria
- All tests pass
- Clear reporting of any failures with actionable suggestions
+16
View File
@@ -0,0 +1,16 @@
# /typecheck
Run TypeScript type checking.
## Instructions
1. Run `bunx tsc --noEmit --pretty`
2. If errors found:
- List each error with file, line, and message
- Provide suggested fixes for each
3. If no errors, confirm success
## Success Criteria
- Report all type errors clearly
- Suggest actionable fixes
-4
View File
@@ -1,4 +0,0 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
+37 -21
View File
@@ -1,30 +1,46 @@
# The directory Mix will write compiled artifacts to.
/_build/
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# If you run "mix test --cover", coverage assets end up here.
/cover/
# Dependencies
node_modules
.pnp
.pnp.js
# The directory Mix downloads your dependencies sources to.
/deps/
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Where third-party dependencies like ExDoc output generated docs.
/doc/
# Testing
coverage
# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump
# Turbo
.turbo
# Also ignore archive artifacts (built via "mix archive.build").
*.ez
# Vercel
.vercel
# Ignore package tarball (built via "mix hex.build").
system_stats_daemon-*.tar
# Build Outputs
.next/
out/
build
dist
# Temporary files, for example, from tests.
/tmp/
# Nix direnv cache and generated files
.direnv/
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Nix result symlinks
result
result-*
# Misc
.DS_Store
*.pem
auth_failures.log
# Nix
.direnv
.direnv/*
# Local config (use systant.toml.example as template)
systant.toml
+3
View File
@@ -0,0 +1,3 @@
{
"terminal.integrated.fontFamily": "Fira Code"
}
+103
View File
@@ -0,0 +1,103 @@
# Agents
Specialized sub-agents for systant development tasks.
## test-runner
Use this agent after writing or modifying code to run the test suite and verify changes.
**Responsibilities:**
- Run `bun test` and report results
- Identify failing tests and their root causes
- Suggest fixes for test failures
- Run specific test files when targeted testing is needed
**Trigger:** After implementing features, fixing bugs, or modifying existing code.
## code-reviewer
Use this agent to review code changes before committing.
**Responsibilities:**
- Check for Bun best practices (no Node.js patterns)
- Verify type safety and explicit return types
- Look for potential bugs or edge cases
- Ensure code follows project conventions
- Flag any security concerns (especially in command execution)
**Trigger:** Before creating commits or PRs.
## metrics-specialist
Use this agent when working on system metric collection.
**Responsibilities:**
- Understand Linux /proc and /sys interfaces
- Know cross-platform metric collection strategies
- Ensure metrics are properly typed and documented
- Validate metric units and normalization
**Context:** Systant collects CPU, memory, disk, and network metrics. Metrics should be normalized (percentages 0-100, bytes for sizes) and include metadata for Home Assistant discovery.
## mqtt-specialist
Use this agent when working on MQTT publishing or Home Assistant integration.
**Responsibilities:**
- Understand MQTT topic conventions
- Know Home Assistant discovery protocol
- Ensure proper QoS and retain flag usage
- Handle connection lifecycle (connect, reconnect, disconnect)
- Design topic hierarchies for commands and events
**Context:** Systant publishes to MQTT with Home Assistant auto-discovery. Topics follow the pattern `systant/{hostname}/{metric_type}`. Command topics use `systant/{hostname}/command/{action}`.
## events-specialist
Use this agent when working on the event/command system.
**Responsibilities:**
- Design secure command execution with allowlists
- Implement event handlers and action dispatching
- Ensure proper input validation and sanitization
- Handle timeouts and error reporting
- Consider security implications of remote command execution
**Context:** Systant listens for MQTT commands and executes configured actions. Security is paramount - all commands must be validated against an allowlist, inputs sanitized, and execution sandboxed where possible.
## debug-investigator
Use this agent when troubleshooting issues or unexpected behavior.
**Responsibilities:**
- Add strategic logging to trace execution
- Isolate the problem to specific components
- Form and test hypotheses
- Propose minimal fixes
**Trigger:** When something isn't working as expected.
## architect
Use this agent for design decisions and architectural questions.
**Responsibilities:**
- Evaluate trade-offs between approaches
- Consider future extensibility
- Maintain consistency with existing patterns
- Document decisions in code comments or CLAUDE.md
**Trigger:** When facing design choices or planning new features.
## security-auditor
Use this agent when reviewing security-sensitive code.
**Responsibilities:**
- Review command execution paths for injection vulnerabilities
- Validate input sanitization
- Check allowlist/denylist implementations
- Ensure proper authentication for MQTT commands
- Review file system access patterns
**Context:** Systant executes commands based on MQTT messages. This is a critical attack surface that requires careful security review.
+101 -60
View File
@@ -1,79 +1,120 @@
# CLAUDE.md
# Systant
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
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.
## Common Commands
## Project Overview
### Development
```bash
# Install dependencies
mix deps.get
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
# Compile the project
mix compile
### Architecture
# Run in development (non-halt mode)
mix run --no-halt
# Run tests
mix test
# Run specific test
mix test test/systant_test.exs
# Enter development shell (via Nix)
nix develop
```
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
```
### Production
```bash
# Build production release
MIX_ENV=prod mix release
### Event/Command System
# Run production release
_build/prod/rel/systant/bin/systant start
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 ... }
```
## Architecture Overview
Actions are sandboxed and configurable via allowlists in the config file. Security is critical - never execute arbitrary commands without validation.
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.
### Key Design Decisions
### 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
- **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
### Key Libraries
- **Tortoise**: MQTT client library for pub/sub functionality
- **Jason**: JSON encoding/decoding for message payloads
## Tech Stack
### 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
- **Runtime**: Bun (not Node.js)
- **CLI**: yargs
- **Config**: TOML
- **MQTT**: mqtt.js or Bun-native when available
- **Package**: Nix flake for reproducible builds
### 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
## Bun Conventions
### NixOS Deployment
This project includes a complete Nix packaging and NixOS module:
Default to using Bun instead of Node.js.
- **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
- 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.
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
### Bun APIs
### Future Plans
- Integration with Home Assistant via custom MQTT integration
- Expandable command handling for host-specific automation
- Multi-host deployment for comprehensive system monitoring
- `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
+6 -51
View File
@@ -1,60 +1,15 @@
# Systant
# systant
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
## Configuration
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
```
## Building
To install dependencies:
```bash
mix deps.get
mix compile
bun install
```
## Running
To run:
```bash
# Development
mix run --no-halt
# Production release
MIX_ENV=prod mix release
_build/prod/rel/systant/bin/systant start
bun run index.ts
```
## 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
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
View File
@@ -0,0 +1,152 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "systant",
"dependencies": {
"mqtt": "^5.14.1",
"smol-toml": "^1.6.0",
"yargs": "^18.0.0",
},
"devDependencies": {
"@types/bun": "latest",
"@types/yargs": "^17.0.35",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
"@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="],
"@types/readable-stream": ["@types/readable-stream@4.0.23", "", { "dependencies": { "@types/node": "*" } }, "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="],
"@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="],
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"bl": ["bl@6.1.6", "", { "dependencies": { "@types/readable-stream": "^4.0.0", "buffer": "^6.0.3", "inherits": "^2.0.4", "readable-stream": "^4.2.0" } }, "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg=="],
"broker-factory": ["broker-factory@3.1.13", "", { "dependencies": { "@babel/runtime": "^7.28.6", "fast-unique-numbers": "^9.0.26", "tslib": "^2.8.1", "worker-factory": "^7.0.48" } }, "sha512-H2VALe31mEtO/SRcNp4cUU5BAm1biwhc/JaF77AigUuni/1YT0FLCJfbUxwIEs9y6Kssjk2fmXgf+Y9ALvmKlw=="],
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
"cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
"commist": ["commist@3.2.0", "", {}, "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw=="],
"concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
"fast-unique-numbers": ["fast-unique-numbers@9.0.26", "", { "dependencies": { "@babel/runtime": "^7.28.6", "tslib": "^2.8.1" } }, "sha512-3Mtq8p1zQinjGyWfKeuBunbuFoixG72AUkk4VvzbX4ykCW9Q4FzRaNyIlfQhUjnKw2ARVP+/CKnoyr6wfHftig=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
"help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
"js-sdsl": ["js-sdsl@4.3.0", "", {}, "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ=="],
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"mqtt": ["mqtt@5.14.1", "", { "dependencies": { "@types/readable-stream": "^4.0.21", "@types/ws": "^8.18.1", "commist": "^3.2.0", "concat-stream": "^2.0.0", "debug": "^4.4.1", "help-me": "^5.0.0", "lru-cache": "^10.4.3", "minimist": "^1.2.8", "mqtt-packet": "^9.0.2", "number-allocator": "^1.0.14", "readable-stream": "^4.7.0", "rfdc": "^1.4.1", "socks": "^2.8.6", "split2": "^4.2.0", "worker-timers": "^8.0.23", "ws": "^8.18.3" }, "bin": { "mqtt_pub": "build/bin/pub.js", "mqtt_sub": "build/bin/sub.js", "mqtt": "build/bin/mqtt.js" } }, "sha512-NxkPxE70Uq3Ph7goefQa7ggSsVzHrayCD0OyxlJgITN/EbzlZN+JEPmaAZdxP1LsIT5FamDyILoQTF72W7Nnbw=="],
"mqtt-packet": ["mqtt-packet@9.0.2", "", { "dependencies": { "bl": "^6.0.8", "debug": "^4.3.4", "process-nextick-args": "^2.0.1" } }, "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"number-allocator": ["number-allocator@1.0.14", "", { "dependencies": { "debug": "^4.3.1", "js-sdsl": "4.3.0" } }, "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA=="],
"process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="],
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
"readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
"smol-toml": ["smol-toml@1.6.0", "", {}, "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw=="],
"socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="],
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"worker-factory": ["worker-factory@7.0.48", "", { "dependencies": { "@babel/runtime": "^7.28.6", "fast-unique-numbers": "^9.0.26", "tslib": "^2.8.1" } }, "sha512-CGmBy3tJvpBPjUvb0t4PrpKubUsfkI1Ohg0/GGFU2RvA9j/tiVYwKU8O7yu7gH06YtzbeJLzdUR29lmZKn5pag=="],
"worker-timers": ["worker-timers@8.0.29", "", { "dependencies": { "@babel/runtime": "^7.28.6", "tslib": "^2.8.1", "worker-timers-broker": "^8.0.15", "worker-timers-worker": "^9.0.13" } }, "sha512-9jk0MWHhWAZ2xlJPXr45oe5UF/opdpfZrY0HtyPizWuJ+ce1M3IYk/4IIdGct3kn9Ncfs+tkZt3w1tU6KW2Fsg=="],
"worker-timers-broker": ["worker-timers-broker@8.0.15", "", { "dependencies": { "@babel/runtime": "^7.28.6", "broker-factory": "^3.1.13", "fast-unique-numbers": "^9.0.26", "tslib": "^2.8.1", "worker-timers-worker": "^9.0.13" } }, "sha512-Te+EiVUMzG5TtHdmaBZvBrZSFNauym6ImDaCAnzQUxvjnw+oGjMT2idmAOgDy30vOZMLejd0bcsc90Axu6XPWA=="],
"worker-timers-worker": ["worker-timers-worker@9.0.13", "", { "dependencies": { "@babel/runtime": "^7.28.6", "tslib": "^2.8.1", "worker-factory": "^7.0.48" } }, "sha512-qjn18szGb1kjcmh2traAdki1eiIS5ikFo+L90nfMOvSRpuDw1hAcR1nzkP2+Hkdqz5thIRnfuWx7QSpsEUsA6Q=="],
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="],
"yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
"concat-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
}
}
-5
View File
@@ -1,5 +0,0 @@
import Config
config :logger, :console,
format: "$time $metadata[$level] $message\n",
metadata: [:request_id]
-18
View File
@@ -1,18 +0,0 @@
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
View File
@@ -20,16 +20,16 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1753939845,
"narHash": "sha256-K2ViRJfdVGE8tpJejs8Qpvvejks1+A4GQej/lBk5y7I=",
"lastModified": 1768395095,
"narHash": "sha256-ZhuYJbwbZT32QA95tSkXd9zXHcdZj90EzHpEXBMabaw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "94def634a20494ee057c76998843c015909d6311",
"rev": "13868c071cc73a5e9f610c47d7bb08e5da64fdd5",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
+32 -19
View File
@@ -1,44 +1,57 @@
{
description = "Elixir system monitor daemon";
description = "Systant - System monitoring agent with MQTT and Home Assistant integration";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
inputs.flake-utils.url = "github:numtide/flake-utils";
outputs =
{
self,
nixpkgs,
flake-utils,
...
}:
{
# NixOS module (system-independent)
nixosModules.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 (
system:
let
pkgs = import nixpkgs {
system = system;
inherit system;
config.allowUnfree = true;
overlays = [ self.overlays.default ];
};
in
{
packages = {
systant = pkgs.systant;
default = pkgs.systant;
};
apps.default = {
type = "app";
program = "${pkgs.systant}/bin/systant";
};
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
elixir
erlang
claude-code
aider-chat
nodejs_20
yarn
deno
python311
python311Packages.pip
python311Packages.ipython
bashInteractive
glibcLocales
git
bun
inotify-tools
];
shellHook = ''
echo "Elixir development environment loaded"
elixir --version
export PROJECT_ROOT=$PWD
'';
};
}
+130
View File
@@ -0,0 +1,130 @@
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import { loadConfig } from "./src/config";
import { connect } from "./src/mqtt";
import { createEntityManager } from "./src/entities";
const DEFAULT_CONFIG_PATH = "./systant.toml";
yargs(hideBin(process.argv))
.scriptName("systant")
.usage("$0 <cmd> [args]")
.command(
"run",
"Start the systant daemon",
(yargs) => {
return yargs.option("config", {
alias: "c",
type: "string",
default: DEFAULT_CONFIG_PATH,
describe: "Path to config file",
});
},
async (argv) => {
await run(argv.config);
}
)
.command(
"check",
"Check config and connectivity",
(yargs) => {
return yargs.option("config", {
alias: "c",
type: "string",
default: DEFAULT_CONFIG_PATH,
describe: "Path to config file",
});
},
async (argv) => {
await check(argv.config);
}
)
.command(
"once",
"Poll all entity states once, then exit",
(yargs) => {
return yargs.option("config", {
alias: "c",
type: "string",
default: DEFAULT_CONFIG_PATH,
describe: "Path to config file",
});
},
async (argv) => {
await once(argv.config);
}
)
.demandCommand(1, "\nError: You need to specify a command!")
.help()
.parse();
async function run(configPath: string): Promise<void> {
const config = await loadConfig(configPath);
console.log(`Starting systant on ${config.systant.hostname}`);
const mqtt = await connect(config, config.systant.hostname);
const entities = createEntityManager(config, mqtt);
await entities.start();
// Handle shutdown
const shutdown = async () => {
console.log("\nShutting down...");
entities.stop();
await mqtt.disconnect();
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
console.log("Systant running. Press Ctrl+C to stop.");
}
async function check(configPath: string): Promise<void> {
console.log(`Checking config: ${configPath}`);
try {
const config = await loadConfig(configPath);
const entityCount = Object.keys(config.entities).length;
console.log("Config loaded successfully");
console.log(` MQTT broker: ${config.mqtt.broker}`);
console.log(` Entities: ${entityCount} configured (default interval: ${config.systant.defaultInterval}s)`);
console.log(` HA discovery: ${config.homeassistant.discovery}`);
console.log("\nTesting MQTT connection...");
const hostname = config.systant.hostname;
const mqtt = await connect(config, hostname);
console.log("MQTT connection successful");
await mqtt.disconnect();
console.log("\nAll checks passed!");
} catch (err) {
console.error("Check failed:", err instanceof Error ? err.message : err);
process.exit(1);
}
}
async function once(configPath: string): Promise<void> {
const config = await loadConfig(configPath);
const hostname = config.systant.hostname;
const mqtt = await connect(config, hostname);
const entities = createEntityManager(config, mqtt);
// Start will do initial poll of all entities
await entities.start();
// Wait a moment for the initial polls to complete
await new Promise((resolve) => setTimeout(resolve, 1000));
entities.stop();
await mqtt.disconnect();
console.log("Entity states published successfully");
}
-18
View File
@@ -1,18 +0,0 @@
defmodule Systant do
@moduledoc """
Documentation for `Systant`.
"""
@doc """
Hello world.
## Examples
iex> Systant.hello()
:world
"""
def hello do
:world
end
end
-19
View File
@@ -1,19 +0,0 @@
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
-92
View File
@@ -1,92 +0,0 @@
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
-41
View File
@@ -1,41 +0,0 @@
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
-10
View File
@@ -1,10 +0,0 @@
%{
"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"},
}
+95 -71
View File
@@ -1,98 +1,122 @@
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.services.systant;
cfg = config.systant;
settingsFormat = pkgs.formats.toml { };
in
{
options.services.systant = {
enable = mkEnableOption "Systant MQTT Daemon";
options.systant = {
enable = lib.mkEnableOption "systant system monitoring agent";
package = mkOption {
type = types.package;
description = "The systant package to use";
package = lib.mkOption {
type = lib.types.package;
default = pkgs.systant;
defaultText = lib.literalExpression "pkgs.systant";
description = "The systant package to use.";
};
mqttHost = mkOption {
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;
configFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "MQTT username (null for no auth)";
description = ''
Path to the systant configuration file (TOML).
If set, this takes precedence over the settings option.
'';
};
mqttPassword = mkOption {
type = types.nullOr types.str;
default = null;
description = "MQTT password (null for no auth)";
settings = lib.mkOption {
type = settingsFormat.type;
default = { };
description = ''
Configuration for systant in Nix attribute set form.
Will be converted to TOML. Ignored if configFile is set.
'';
example = lib.literalExpression ''
{
mqtt = {
broker = "mqtt://localhost:1883";
topicPrefix = "systant";
};
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";
};
};
homeassistant = {
discovery = true;
discoveryPrefix = "homeassistant";
};
}
'';
};
statsTopic = mkOption {
type = types.str;
default = "systant/${config.networking.hostName}/stats";
description = "MQTT topic for publishing stats";
user = lib.mkOption {
type = lib.types.str;
default = "systant";
description = "User account under which systant runs.";
};
commandTopic = mkOption {
type = types.str;
default = "systant/${config.networking.hostName}/commands";
description = "MQTT topic for receiving commands";
};
publishInterval = mkOption {
type = types.int;
default = 30000;
description = "Interval between stats publications (milliseconds)";
group = lib.mkOption {
type = lib.types.str;
default = "systant";
description = "Group under which systant runs.";
};
};
config = mkIf cfg.enable {
systemd.services.systant = {
description = "Systant MQTT Daemon";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
config = lib.mkIf cfg.enable {
# Create systant user/group if using defaults
users.users.${cfg.user} = lib.mkIf (cfg.user == "systant") {
isSystemUser = true;
group = cfg.group;
description = "Systant service user";
};
environment = {
SYSTANT_MQTT_HOST = cfg.mqttHost;
SYSTANT_MQTT_PORT = toString cfg.mqttPort;
SYSTANT_MQTT_USERNAME = mkIf (cfg.mqttUsername != null) cfg.mqttUsername;
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";
};
users.groups.${cfg.group} = lib.mkIf (cfg.group == "systant") { };
# Generate config file from settings if configFile not provided
environment.etc."systant/config.toml" = lib.mkIf (cfg.configFile == null && cfg.settings != { }) {
source = settingsFormat.generate "systant-config.toml" cfg.settings;
};
systemd.services.systant = {
description = "Systant system monitoring agent";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
serviceConfig = {
Type = "exec";
User = "root";
Group = "root";
ExecStart = "${cfg.package}/bin/systant start";
ExecStop = "${cfg.package}/bin/systant stop";
Restart = "always";
RestartSec = 5;
StandardOutput = "journal";
StandardError = "journal";
SyslogIdentifier = "systant";
WorkingDirectory = "${cfg.package}";
Type = "simple";
User = cfg.user;
Group = cfg.group;
ExecStart =
let
configPath =
if cfg.configFile != null
then cfg.configFile
else "/etc/systant/config.toml";
in
"${cfg.package}/bin/systant run --config ${configPath}";
Restart = "on-failure";
RestartSec = "5s";
# Security settings
# Hardening
NoNewPrivileges = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
ProtectSystem = false; # Need access to system stats
PrivateTmp = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
# Allow reading system metrics
ReadOnlyPaths = [
"/proc"
"/sys"
];
};
};
};
+50 -16
View File
@@ -1,32 +1,66 @@
{
lib,
beamPackages,
fetchgit,
stdenvNoCC,
stdenv,
bun,
cacert,
src, # passed from flake.nix
}:
beamPackages.mixRelease rec {
let
# 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";
version = "0.1.0";
src = fetchgit {
url = "https://git.ryanpandya.com/ryan/systant.git";
rev = "54f8d23945bcea17127631a81f8ac318bf7047a6";
sha256 = "sha256-1cRfSoH+JdO4a7q4hRZSkoDMk2wMCYRIyCIN56FSUgg=";
};
inherit src;
# 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=";
};
nativeBuildInputs = [ bun ];
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; {
description = "Systant - System stats MQTT daemon for monitoring system metrics";
description = "System monitoring agent with MQTT and Home Assistant integration";
homepage = "https://git.ryanpandya.com/ryan/systant";
license = licenses.mit;
maintainers = [ ];
platforms = platforms.linux;
};
}
+23
View File
@@ -0,0 +1,23 @@
{
"name": "systant",
"version": "0.1.0",
"module": "index.ts",
"devDependencies": {
"@types/bun": "latest",
"@types/yargs": "^17.0.35"
},
"peerDependencies": {
"typescript": "^5"
},
"private": true,
"scripts": {
"start": "bun run index.ts",
"dist": "bun build index.ts --compile --outfile dist/systant"
},
"type": "module",
"dependencies": {
"mqtt": "^5.14.1",
"smol-toml": "^1.6.0",
"yargs": "^18.0.0"
}
}
-6
View File
@@ -1,6 +0,0 @@
#!/bin/sh
# Configure environment for release
export MIX_ENV=prod
export RELEASE_DISTRIBUTION=none
export RELEASE_NODE=nonode@nohost
+130
View File
@@ -0,0 +1,130 @@
import { parse } from "smol-toml";
export interface SystantConfig {
hostname: string;
defaultInterval: number;
}
export interface MqttConfig {
broker: string;
username?: string;
password?: string;
clientId?: string;
topicPrefix: string;
}
export type EntityType = "sensor" | "binary_sensor" | "light" | "switch" | "button";
export interface EntityConfig {
type: EntityType;
state_command?: string; // for stateful entities (not button)
on_command?: string; // for light/switch
off_command?: string; // for light/switch
press_command?: string; // for button
interval?: number; // override default interval
unit?: string; // for sensor
device_class?: string; // for sensor (timestamp, etc.) or binary_sensor
icon?: string;
name?: string;
availability?: boolean; // default true; set false to keep last value when offline
}
export interface HomeAssistantConfig {
discovery: boolean;
discoveryPrefix: string;
}
export interface Config {
systant: SystantConfig;
mqtt: MqttConfig;
entities: Record<string, EntityConfig>;
homeassistant: HomeAssistantConfig;
}
const defaults = {
systant: {
defaultInterval: 30,
hostname: process.env.HOSTNAME || require("os").hostname(),
},
mqtt: {
broker: "mqtt://localhost:1883",
topicPrefix: "systant",
},
homeassistant: {
discovery: true,
discoveryPrefix: "homeassistant",
},
};
export async function loadConfig(path: string): Promise<Config> {
const file = Bun.file(path);
if (!(await file.exists())) {
throw new Error(`Config file not found: ${path}`);
}
const content = await file.text();
const parsed = parse(content) as Record<string, unknown>;
return buildConfig(parsed);
}
function buildConfig(parsed: Record<string, unknown>): Config {
const config: Config = {
systant: { ...defaults.systant },
mqtt: { ...defaults.mqtt },
entities: {},
homeassistant: { ...defaults.homeassistant },
};
// MQTT settings
if (parsed.mqtt && typeof parsed.mqtt === "object") {
Object.assign(config.mqtt, parsed.mqtt);
}
// Systant settings
if (parsed.systant && typeof parsed.systant === "object") {
Object.assign(config.systant, parsed.systant);
}
// Entities
if (parsed.entities && typeof parsed.entities === "object") {
const entities = parsed.entities as Record<string, unknown>;
for (const [key, value] of Object.entries(entities)) {
if (key === "interval") continue; // skip the default interval
if (value && typeof value === "object" && "type" in value) {
const e = value as Record<string, unknown>;
const entityType = String(e.type) as EntityType;
// Buttons need press_command, others need state_command
const hasRequiredCommand = entityType === "button"
? "press_command" in e
: "state_command" in e;
if (!hasRequiredCommand) continue;
config.entities[key] = {
type: entityType,
state_command: typeof e.state_command === "string" ? e.state_command : undefined,
on_command: typeof e.on_command === "string" ? e.on_command : undefined,
off_command: typeof e.off_command === "string" ? e.off_command : undefined,
press_command: typeof e.press_command === "string" ? e.press_command : undefined,
interval: typeof e.interval === "number" ? e.interval : undefined,
unit: typeof e.unit === "string" ? e.unit : undefined,
device_class: typeof e.device_class === "string" ? e.device_class : undefined,
icon: typeof e.icon === "string" ? e.icon : undefined,
name: typeof e.name === "string" ? e.name : undefined,
availability: typeof e.availability === "boolean" ? e.availability : true,
};
}
}
}
// Home Assistant
if (parsed.homeassistant && typeof parsed.homeassistant === "object") {
Object.assign(config.homeassistant, parsed.homeassistant);
}
return config;
}
+110
View File
@@ -0,0 +1,110 @@
import type { Config, EntityConfig } from "./config";
import type { MqttConnection } from "./mqtt";
export interface EntityManager {
start(): Promise<void>;
stop(): void;
}
export function createEntityManager(config: Config, mqtt: MqttConnection): EntityManager {
const timers: Timer[] = [];
async function setupStatefulEntity(id: string, entity: EntityConfig): Promise<void> {
const interval = entity.interval ?? config.systant.defaultInterval;
const isControllable = entity.type === "light" || entity.type === "switch";
if (!entity.state_command) return;
// State polling
const pollState = async () => {
try {
const output = await Bun.$`sh -c ${entity.state_command}`.text();
const value = output.trim();
console.debug(`[${id}] state: ${value}`);
await mqtt.publish(`${id}/state`, value, true); // retain state
} catch (err) {
console.error(`[${id}] state poll failed:`, err instanceof Error ? err.message : err);
}
};
// Initial state poll
await pollState();
// Schedule periodic polling
const timer = setInterval(pollState, interval * 1000);
timers.push(timer);
// Command subscription for controllable entities
if (isControllable && (entity.on_command || entity.off_command)) {
await mqtt.subscribe(`${id}/set`, async (_topic, payload) => {
const command = payload.toString().toUpperCase();
console.log(`[${id}] received command: ${command}`);
try {
if (command === "ON" && entity.on_command) {
await Bun.$`sh -c ${entity.on_command}`;
console.log(`[${id}] executed on_command`);
} else if (command === "OFF" && entity.off_command) {
await Bun.$`sh -c ${entity.off_command}`;
console.log(`[${id}] executed off_command`);
} else {
console.warn(`[${id}] unknown command or no handler: ${command}`);
return;
}
// Re-poll state after command execution
await new Promise((r) => setTimeout(r, 500)); // brief delay for state to settle
await pollState();
} catch (err) {
console.error(`[${id}] command failed:`, err instanceof Error ? err.message : err);
}
});
}
const typeLabel = isControllable ? `${entity.type} (controllable)` : entity.type;
console.log(` ${id}: ${typeLabel}, poll every ${interval}s`);
}
async function setupButton(id: string, entity: EntityConfig): Promise<void> {
if (!entity.press_command) return;
await mqtt.subscribe(`${id}/press`, async (_topic, _payload) => {
console.log(`[${id}] button pressed`);
try {
await Bun.$`sh -c ${entity.press_command}`;
console.log(`[${id}] executed press_command`);
} catch (err) {
console.error(`[${id}] press_command failed:`, err instanceof Error ? err.message : err);
}
});
console.log(` ${id}: button`);
}
return {
async start(): Promise<void> {
const entityCount = Object.keys(config.entities).length;
if (entityCount === 0) {
console.log("No entities configured");
return;
}
console.log(`Starting ${entityCount} entity manager(s):`);
for (const [id, entity] of Object.entries(config.entities)) {
if (entity.type === "button") {
await setupButton(id, entity);
} else {
await setupStatefulEntity(id, entity);
}
}
},
stop(): void {
for (const timer of timers) {
clearInterval(timer);
}
timers.length = 0;
},
};
}
+177
View File
@@ -0,0 +1,177 @@
import mqtt, { type MqttClient, type IClientOptions } from "mqtt";
import type { Config, EntityConfig } from "./config";
export interface MqttConnection {
client: MqttClient;
publish(topic: string, payload: string | object, retain?: boolean): Promise<void>;
subscribe(topic: string, handler: (topic: string, payload: Buffer) => void): Promise<void>;
disconnect(): Promise<void>;
}
export async function connect(config: Config, hostname: string): Promise<MqttConnection> {
const options: IClientOptions = {
clientId: config.mqtt.clientId || `systant-${hostname}`,
username: config.mqtt.username,
password: config.mqtt.password,
will: {
topic: `${config.mqtt.topicPrefix}/${hostname}/status`,
payload: Buffer.from("offline"),
qos: 1,
retain: true,
},
};
const client = mqtt.connect(config.mqtt.broker, options);
const handlers = new Map<string, (topic: string, payload: Buffer) => void>();
await new Promise<void>((resolve, reject) => {
client.on("connect", () => {
console.log(`Connected to MQTT broker: ${config.mqtt.broker}`);
resolve();
});
client.on("error", reject);
});
client.on("message", (topic, payload) => {
for (const [pattern, handler] of handlers) {
if (topicMatches(pattern, topic)) {
handler(topic, payload);
}
}
});
// Publish online status
await publishAsync(client, `${config.mqtt.topicPrefix}/${hostname}/status`, "online", true);
// Publish HA discovery if enabled
if (config.homeassistant.discovery) {
await publishDiscovery(client, config, hostname);
}
return {
client,
async publish(topic: string, payload: string | object, retain = false): Promise<void> {
const fullTopic = `${config.mqtt.topicPrefix}/${hostname}/${topic}`;
const data = typeof payload === "object" ? JSON.stringify(payload) : payload;
await publishAsync(client, fullTopic, data, retain);
},
async subscribe(topic: string, handler: (topic: string, payload: Buffer) => void): Promise<void> {
const fullTopic = `${config.mqtt.topicPrefix}/${hostname}/${topic}`;
handlers.set(fullTopic, handler);
await new Promise<void>((resolve, reject) => {
client.subscribe(fullTopic, { qos: 1 }, (err) => {
if (err) reject(err);
else resolve();
});
});
console.log(`Subscribed to: ${fullTopic}`);
},
async disconnect(): Promise<void> {
await publishAsync(client, `${config.mqtt.topicPrefix}/${hostname}/status`, "offline", true);
await new Promise<void>((resolve) => client.end(false, {}, () => resolve()));
},
};
}
function publishAsync(client: MqttClient, topic: string, payload: string, retain: boolean): Promise<void> {
return new Promise((resolve, reject) => {
client.publish(topic, payload, { qos: 1, retain }, (err) => {
if (err) reject(err);
else resolve();
});
});
}
function topicMatches(pattern: string, topic: string): boolean {
if (pattern === topic) return true;
if (pattern.endsWith("#")) {
return topic.startsWith(pattern.slice(0, -1));
}
return false;
}
async function publishDiscovery(client: MqttClient, config: Config, hostname: string): Promise<void> {
const prefix = config.homeassistant.discoveryPrefix;
const topicPrefix = config.mqtt.topicPrefix;
const entityCount = Object.keys(config.entities).length;
if (entityCount === 0) {
console.log("No entities configured, skipping HA discovery");
return;
}
for (const [id, entity] of Object.entries(config.entities)) {
const payload = buildDiscoveryPayload(id, entity, hostname, topicPrefix);
const discoveryTopic = `${prefix}/${entity.type}/${hostname}_${id}/config`;
await publishAsync(client, discoveryTopic, JSON.stringify(payload), true);
}
console.log(`Published Home Assistant discovery for ${entityCount} entity/entities`);
}
function buildDiscoveryPayload(
id: string,
entity: EntityConfig,
hostname: string,
topicPrefix: string
): Record<string, unknown> {
const displayName = entity.name || id.replace(/_/g, " ");
const payload: Record<string, unknown> = {
name: displayName,
unique_id: `systant_${hostname}_${id}`,
device: {
identifiers: [`systant_${hostname}`],
name: `${hostname}`,
manufacturer: "Systant",
},
};
// Stateful entities have a state_topic (buttons don't)
if (entity.type !== "button") {
payload.state_topic = `${topicPrefix}/${hostname}/${id}/state`;
}
// Add availability tracking unless explicitly disabled
if (entity.availability !== false) {
payload.availability_topic = `${topicPrefix}/${hostname}/status`;
payload.payload_available = "online";
payload.payload_not_available = "offline";
}
// Common optional fields
if (entity.icon) payload.icon = entity.icon;
// Type-specific fields
switch (entity.type) {
case "sensor":
if (entity.unit) payload.unit_of_measurement = entity.unit;
if (entity.device_class) payload.device_class = entity.device_class;
break;
case "binary_sensor":
payload.payload_on = "ON";
payload.payload_off = "OFF";
if (entity.device_class) payload.device_class = entity.device_class;
break;
case "light":
case "switch":
payload.command_topic = `${topicPrefix}/${hostname}/${id}/set`;
payload.payload_on = "ON";
payload.payload_off = "OFF";
payload.state_on = "ON";
payload.state_off = "OFF";
break;
case "button":
payload.command_topic = `${topicPrefix}/${hostname}/${id}/press`;
payload.payload_press = "PRESS";
break;
}
return payload;
}
+75
View File
@@ -0,0 +1,75 @@
# Systant Configuration
# Copy this to systant.toml and customize for your system
[systant]
# hostname = "myhost" # defaults to system hostname
[mqtt]
broker = "mqtt://localhost:1883"
# username = "user"
# password = "secret"
# clientId = "systant-myhost" # defaults to systant-{hostname}
topicPrefix = "systant"
[entities]
interval = 30 # default interval in seconds
# Sensor examples
[entities.cpu_usage]
type = "sensor"
state_command = "awk '/^cpu / {u=$2+$4; t=$2+$4+$5; print int(u*100/t)}' /proc/stat"
unit = "%"
icon = "mdi:cpu-64-bit"
name = "CPU Usage"
[entities.memory]
type = "sensor"
state_command = "awk '/MemTotal/{t=$2} /MemAvailable/{a=$2} END {print int((t-a)/t*100)}' /proc/meminfo"
unit = "%"
icon = "mdi:memory"
name = "Memory Usage"
[entities.last_seen]
type = "sensor"
state_command = "date -Iseconds"
device_class = "timestamp"
icon = "mdi:clock-check"
name = "Last Seen"
availability = false # keeps last value when offline
# Binary sensor example (read-only on/off)
# [entities.service_running]
# type = "binary_sensor"
# state_command = "systemctl is-active myservice >/dev/null && echo ON || echo OFF"
# device_class = "running"
# icon = "mdi:cog"
# name = "My Service"
# Light example (controllable, for things like monitors)
# [entities.screen]
# type = "light"
# state_command = "xrandr | grep -q 'connected primary' && echo ON || echo OFF"
# on_command = "xrandr --output DP-1 --auto"
# off_command = "xrandr --output DP-1 --off"
# icon = "mdi:monitor"
# name = "Screen"
# Switch example (controllable on/off)
# [entities.vpn]
# type = "switch"
# state_command = "systemctl is-active openvpn >/dev/null && echo ON || echo OFF"
# on_command = "systemctl start openvpn"
# off_command = "systemctl stop openvpn"
# icon = "mdi:vpn"
# name = "VPN"
# Button example (just executes a command)
# [entities.sync_time]
# type = "button"
# press_command = "ntpdate pool.ntp.org"
# icon = "mdi:clock-sync"
# name = "Sync Time"
[homeassistant]
discovery = true
discoveryPrefix = "homeassistant"
-8
View File
@@ -1,8 +0,0 @@
defmodule SystantTest do
use ExUnit.Case
doctest Systant
test "greets the world" do
assert Systant.hello() == :world
end
end
-1
View File
@@ -1 +0,0 @@
ExUnit.start()
+29
View File
@@ -0,0 +1,29 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}