first commit
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"version": "0.2",
|
||||
"language": "en, en-US, en-GB",
|
||||
"ignorePaths": [],
|
||||
"dictionaryDefinitions": [],
|
||||
"dictionaries": [],
|
||||
"words": [
|
||||
"digitemp",
|
||||
"GPRINT",
|
||||
"imgformat",
|
||||
"lettre",
|
||||
"Neuroinformatics",
|
||||
"RIKEN",
|
||||
"rrdgraph",
|
||||
"rrdtemp",
|
||||
"rrdtool",
|
||||
"serde",
|
||||
"smtps",
|
||||
"starttls",
|
||||
"tera"
|
||||
],
|
||||
"ignoreWords": [],
|
||||
"import": [],
|
||||
"files": [
|
||||
"**/*.{rs,twig,conf,html,md,json}"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/target
|
||||
/etc/digitemp-rrdgraph.json
|
||||
/etc/digitemp.conf
|
||||
/html/index.html
|
||||
/html/images/graph-*.png
|
||||
/db/*.rrd
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": true,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": true,
|
||||
"printWidth": 120,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"1yib.rust-bundle",
|
||||
"streetsidesoftware.code-spell-checker",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": "explicit"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
// Extensions - Biome
|
||||
"biome.enabled": false
|
||||
// Extensions - Code Spell Checker
|
||||
// - see: .cspell.json
|
||||
// Extensions - Prettier
|
||||
// - see: .prettierrc.json
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Scope
|
||||
|
||||
This file applies to the Rust workspace at `digitemp-rrdgraph/`.
|
||||
|
||||
## Project Summary
|
||||
|
||||
- The binary provides three operational subcommands: `update`, `check-alert`, and `send-info`.
|
||||
- The repository is a Cargo workspace with a CLI crate at the repository root and an internal library crate at `crates/core/` named `digitemp_rrdgraph_core`.
|
||||
- The system depends on external commands and resources: `digitemp`, `rrdtool`, SMTP, JSON config files under `etc/`, Tera templates under `templates/`, generated graphs under `html/images/`, and RRD files under `db/`.
|
||||
|
||||
## Source Of Truth
|
||||
|
||||
- Treat this Rust repository as the sole implementation and documentation target.
|
||||
- Preserve the CLI contract exposed by `src/main.rs`, the command modules under `src/commands/`, and the public interfaces exposed by `crates/core/src/lib.rs` unless the user explicitly requests a behavior change.
|
||||
- Preserve the configuration schema in `etc/config.sample.json` unless the user explicitly asks for a breaking change.
|
||||
- Preserve template variable names expected by `templates/*.tera` unless the templates are updated in the same change.
|
||||
|
||||
## Documentation And Language
|
||||
|
||||
- Write repository documentation such as `AGENTS.md`, `README.md`, and other project documents in English by default.
|
||||
- When adding comments to source code, configuration files, templates, or scripts, write those comments in English.
|
||||
- Keep user-facing configuration keys, template names, and command names stable unless the task explicitly requires a change.
|
||||
|
||||
## Change Priorities
|
||||
|
||||
- Prefer fixes at the behavior or interface boundary, not cosmetic refactors.
|
||||
- Keep CLI behavior and config keys stable.
|
||||
- Keep changes small and local to the affected command or library module.
|
||||
- Avoid introducing new crates unless the standard library or existing dependencies are clearly insufficient.
|
||||
- Prefer explicit error propagation with `Result` over `unwrap` or `expect` in runtime paths.
|
||||
|
||||
## Safe Editing Rules
|
||||
|
||||
- Do not edit generated or runtime data under `target/`, `db/`, or `html/images/` unless the task explicitly requires it.
|
||||
- Do not overwrite `etc/digitemp-rrdgraph.conf` for convenience; use `etc/config.sample.json` as the editable reference.
|
||||
- Be careful with commands that can talk to real hardware, modify RRD files, or send email.
|
||||
- When validation would invoke external systems, prefer build-only or isolated checks unless the user explicitly wants end-to-end execution.
|
||||
|
||||
## Validation Expectations
|
||||
|
||||
- After Rust code changes, run `cargo build` in this repository at minimum. Prefer `cargo build --workspace` when changes affect `crates/core/` or workspace wiring.
|
||||
- If you add or refactor logic that can be unit tested without hardware or SMTP, add focused tests and run them.
|
||||
- Prefer tests around pure logic such as config parsing, digitemp config parsing, template rendering, and any command-argument construction helpers.
|
||||
- If a change depends on hardware access or local services, state clearly what could not be verified.
|
||||
|
||||
## Implementation Guidance
|
||||
|
||||
- Keep path handling relative to the repository root unless a config field explicitly stores an absolute path.
|
||||
- Encapsulate shell command construction so it can be inspected or tested without executing the command.
|
||||
- Match existing naming and module boundaries under `src/commands/` and `crates/core/src/`.
|
||||
- Keep CLI-only orchestration in the root crate and reusable logic in `digitemp_rrdgraph_core`.
|
||||
- Prefer direct, maintainable implementations over compatibility layers or unnecessary abstraction.
|
||||
|
||||
## Useful References
|
||||
|
||||
- Rust entrypoint: `src/main.rs`
|
||||
- Rust commands: `src/commands/`
|
||||
- Internal library crate manifest: `crates/core/Cargo.toml`
|
||||
- Rust support modules: `crates/core/src/`
|
||||
- Sample config: `etc/config.sample.json`
|
||||
- Email templates: `templates/`
|
||||
- RRD files: `db/`
|
||||
- Graph images: `html/images/`
|
||||
- External dependencies: `digitemp`, `rrdtool`, SMTP server
|
||||
Generated
+1984
File diff suppressed because it is too large
Load Diff
+12
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "digitemp-rrdgraph"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[workspace]
|
||||
members = ["crates/core"]
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
digitemp_rrdgraph_core = { path = "crates/core" }
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Neuroinformatics Unit, RIKEN Center for Brain Science
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,153 @@
|
||||
# digitemp-rrdgraph-rust
|
||||
|
||||
digitemp-rrdgraph-rust is a command-line application for temperature monitoring with 1-Wire sensors. It collects measurements through `digitemp`, stores them in RRD files, generates graph images, and sends email notifications based on configured schedules and thresholds.
|
||||
|
||||
The repository is organized as a Cargo workspace with two crates:
|
||||
|
||||
- `digitemp-rrdgraph`: the CLI binary crate
|
||||
- `digitemp_rrdgraph_core`: the internal library crate used by the CLI commands
|
||||
|
||||
## Features
|
||||
|
||||
- Read temperature data from 1-Wire sensors through `digitemp`
|
||||
- Store time-series data in RRD files
|
||||
- Generate graph images for hourly, daily, weekly, monthly, and yearly views
|
||||
- Send periodic information emails and threshold-based alert emails
|
||||
- Configure runtime behavior through JSON and template files
|
||||
|
||||
## Requirements
|
||||
|
||||
- Rust toolchain compatible with edition 2024
|
||||
- [digitemp](https://www.digitemp.com/)
|
||||
- [RRDTool](https://oss.oetiker.ch/rrdtool/)
|
||||
- Access to an SMTP server for email delivery
|
||||
|
||||
## Project Layout
|
||||
|
||||
- `Cargo.toml`: workspace root and CLI package definition
|
||||
- `src/`: CLI entrypoint and command modules
|
||||
- `crates/core/`: internal library crate shared by the CLI commands
|
||||
- `crates/core/src/`: shared modules for config loading, DigiTemp access, RRD operations, mail sending, and template rendering
|
||||
- `etc/`: configuration files and cron examples
|
||||
- `templates/`: Tera mail templates
|
||||
- `db/`: RRD database files
|
||||
- `html/images/`: generated graph images
|
||||
|
||||
## Setup
|
||||
|
||||
1. Build the release binary.
|
||||
|
||||
```sh
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
2. Choose where to place the application configuration file.
|
||||
|
||||
You can always override the default search order with `--config /path/to/digitemp-rrdgraph.json`.
|
||||
|
||||
Recommended locations are checked in this order:
|
||||
|
||||
- `$HOME/.config/digitemp-rrdgraph.json`
|
||||
- `/etc/digitemp-rrdgraph.json`
|
||||
- `<parent directory of the executable>/etc/digitemp-rrdgraph.json`
|
||||
- `$PWD/etc/digitemp-rrdgraph.json`
|
||||
|
||||
For an installed layout such as `/opt/digitemp-rrdgraph/bin/digitemp-rrdgraph`, place the file at `/opt/digitemp-rrdgraph/etc/digitemp-rrdgraph.json`.
|
||||
|
||||
For local development from the repository root, create `etc/digitemp-rrdgraph.json`.
|
||||
|
||||
```sh
|
||||
cp etc/digitemp-rrdgraph.sample.json etc/digitemp-rrdgraph.json
|
||||
```
|
||||
|
||||
3. Create a DigiTemp configuration file.
|
||||
|
||||
```sh
|
||||
digitemp_DS9097U -i -s /dev/ttyUSB0 -c ./etc/digitemp.conf
|
||||
```
|
||||
|
||||
4. Edit the selected `digitemp-rrdgraph.json` file for your environment.
|
||||
|
||||
Pass `--verbose` if you want the CLI to print the selected configuration file path to standard error.
|
||||
|
||||
Configure at least these values:
|
||||
|
||||
- `digitemp` and `digitemp_config`
|
||||
- `database_dir` and `images_dir`
|
||||
- `templates_dir`
|
||||
- `room_name`
|
||||
- SMTP server settings
|
||||
- mail recipients, subjects, and alert thresholds
|
||||
|
||||
`room_name` is required. It is used in mail templates, graph titles, and the generated top page.
|
||||
The `update` command also regenerates `html/index.html` from the template so the page title and heading stay aligned with `room_name`.
|
||||
|
||||
SMTP settings support these combinations:
|
||||
|
||||
- `mail_smtp_security`: `smtp`, `starttls`, or `smtps`
|
||||
- `mail_smtp_auth`: `true` to send SMTP AUTH credentials, `false` for unauthenticated servers
|
||||
- `mail_smtp_username` and `mail_smtp_password`: required only when `mail_smtp_auth` is `true`
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
This builds both workspace members, including the internal `digitemp_rrdgraph_core` crate.
|
||||
|
||||
The release binary is written to `target/release/digitemp-rrdgraph`.
|
||||
|
||||
To build only the CLI package explicitly:
|
||||
|
||||
```sh
|
||||
cargo build --release -p digitemp-rrdgraph
|
||||
```
|
||||
|
||||
To run tests across the workspace:
|
||||
|
||||
```sh
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
The application provides these subcommands:
|
||||
|
||||
- `update`: read sensors, update RRD files, and regenerate graph images
|
||||
- `check-alert`: evaluate current data against configured alert thresholds and send an alert email when required
|
||||
- `send-info`: send a periodic information email
|
||||
|
||||
Examples:
|
||||
|
||||
```sh
|
||||
./target/release/digitemp-rrdgraph update
|
||||
./target/release/digitemp-rrdgraph --verbose update
|
||||
./target/release/digitemp-rrdgraph --config /opt/digitemp-rrdgraph/etc/digitemp-rrdgraph.json update
|
||||
./target/release/digitemp-rrdgraph check-alert
|
||||
./target/release/digitemp-rrdgraph send-info
|
||||
```
|
||||
|
||||
## Scheduling
|
||||
|
||||
Use cron or another scheduler to run the subcommands at appropriate intervals.
|
||||
|
||||
- Run `update` on the data collection interval you want to keep in RRD.
|
||||
- Run `send-info` on the reporting interval you want for periodic summaries.
|
||||
- Run `check-alert` on the alert polling interval you want for threshold checks.
|
||||
|
||||
See `etc/crontab.sample` for a sample schedule.
|
||||
|
||||
## Templates
|
||||
|
||||
Mail templates are stored in `templates/` and rendered with [Tera](https://tera.netlify.app/).
|
||||
|
||||
## Development Notes
|
||||
|
||||
- CLI subcommands live under `src/commands/`.
|
||||
- Shared application logic lives under `crates/core/src/` and is exposed by the `digitemp_rrdgraph_core` crate.
|
||||
- The root crate depends on the internal core crate through a local path dependency.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "digitemp_rrdgraph_core"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
chrono = "0.4.44"
|
||||
lettre = { version = "0.11", features = ["smtp-transport", "builder"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
tera = "1.19"
|
||||
@@ -0,0 +1,196 @@
|
||||
use serde::Deserialize;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MailSmtpSecurity {
|
||||
Smtp,
|
||||
#[default]
|
||||
Starttls,
|
||||
Smtps,
|
||||
}
|
||||
|
||||
fn default_mail_smtp_auth() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Config {
|
||||
pub url: String,
|
||||
pub digitemp: String,
|
||||
pub digitemp_config: String,
|
||||
pub database_dir: String,
|
||||
pub images_dir: String,
|
||||
pub templates_dir: String,
|
||||
pub room_name: String,
|
||||
pub mail_alert_upper: f64,
|
||||
pub mail_alert_lower: f64,
|
||||
pub mail_from: String,
|
||||
pub mail_from_name: String,
|
||||
pub mail_info_to: String,
|
||||
pub mail_info_subject: String,
|
||||
pub mail_info_template: String,
|
||||
pub mail_alert_to: String,
|
||||
pub mail_alert_subject: String,
|
||||
pub mail_alert_template: String,
|
||||
pub mail_smtp_server: String,
|
||||
pub mail_smtp_port: u16,
|
||||
#[serde(default)]
|
||||
pub mail_smtp_security: MailSmtpSecurity,
|
||||
#[serde(default = "default_mail_smtp_auth")]
|
||||
pub mail_smtp_auth: bool,
|
||||
#[serde(default)]
|
||||
pub mail_smtp_username: String,
|
||||
#[serde(default)]
|
||||
pub mail_smtp_password: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Config, MailSmtpSecurity};
|
||||
|
||||
#[test]
|
||||
fn config_defaults_smtp_security_and_auth() {
|
||||
let json = r#"{
|
||||
"url": "https://example.com/temperature/",
|
||||
"digitemp": "/bin/digitemp_DS9097U",
|
||||
"digitemp_config": "etc/digitemp.conf",
|
||||
"database_dir": "db",
|
||||
"images_dir": "html/images",
|
||||
"templates_dir": "templates",
|
||||
"room_name": "S108",
|
||||
"mail_alert_upper": 30,
|
||||
"mail_alert_lower": 17,
|
||||
"mail_from": "from@example.com",
|
||||
"mail_from_name": "Temperature Monitor",
|
||||
"mail_info_to": "info@example.com",
|
||||
"mail_info_subject": "Info",
|
||||
"mail_info_template": "mail_info.tera",
|
||||
"mail_alert_to": "alert@example.com",
|
||||
"mail_alert_subject": "Alert",
|
||||
"mail_alert_template": "mail_alert.tera",
|
||||
"mail_smtp_server": "localhost",
|
||||
"mail_smtp_port": 587,
|
||||
"mail_smtp_username": "user@example.com",
|
||||
"mail_smtp_password": "password"
|
||||
}"#;
|
||||
|
||||
let config: Config = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert!(matches!(config.mail_smtp_security, MailSmtpSecurity::Starttls));
|
||||
assert!(config.mail_smtp_auth);
|
||||
assert_eq!(config.room_name().unwrap(), "S108");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_accepts_plain_smtp_without_auth() {
|
||||
let json = r#"{
|
||||
"url": "https://example.com/temperature/",
|
||||
"digitemp": "/bin/digitemp_DS9097U",
|
||||
"digitemp_config": "etc/digitemp.conf",
|
||||
"database_dir": "db",
|
||||
"images_dir": "html/images",
|
||||
"templates_dir": "templates",
|
||||
"room_name": "S108",
|
||||
"mail_alert_upper": 30,
|
||||
"mail_alert_lower": 17,
|
||||
"mail_from": "from@example.com",
|
||||
"mail_from_name": "Temperature Monitor",
|
||||
"mail_info_to": "info@example.com",
|
||||
"mail_info_subject": "Info",
|
||||
"mail_info_template": "mail_info.tera",
|
||||
"mail_alert_to": "alert@example.com",
|
||||
"mail_alert_subject": "Alert",
|
||||
"mail_alert_template": "mail_alert.tera",
|
||||
"mail_smtp_server": "localhost",
|
||||
"mail_smtp_port": 25,
|
||||
"mail_smtp_security": "smtp",
|
||||
"mail_smtp_auth": false
|
||||
}"#;
|
||||
|
||||
let config: Config = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert!(matches!(config.mail_smtp_security, MailSmtpSecurity::Smtp));
|
||||
assert!(!config.mail_smtp_auth);
|
||||
assert!(config.mail_smtp_username.is_empty());
|
||||
assert!(config.mail_smtp_password.is_empty());
|
||||
assert_eq!(config.room_name().unwrap(), "S108");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn room_name_rejects_blank_values() {
|
||||
let json = r#"{
|
||||
"url": "https://example.com/temperature/",
|
||||
"digitemp": "/bin/digitemp_DS9097U",
|
||||
"digitemp_config": "etc/digitemp.conf",
|
||||
"database_dir": "db",
|
||||
"images_dir": "html/images",
|
||||
"templates_dir": "templates",
|
||||
"room_name": " ",
|
||||
"mail_alert_upper": 30,
|
||||
"mail_alert_lower": 17,
|
||||
"mail_from": "from@example.com",
|
||||
"mail_from_name": "C407 温度監視プログラム",
|
||||
"mail_info_to": "info@example.com",
|
||||
"mail_info_subject": "C407 温度監視 定期報告",
|
||||
"mail_info_template": "mail_info.tera",
|
||||
"mail_alert_to": "alert@example.com",
|
||||
"mail_alert_subject": "C407 温度監視 警告",
|
||||
"mail_alert_template": "mail_alert.tera",
|
||||
"mail_smtp_server": "localhost",
|
||||
"mail_smtp_port": 25,
|
||||
"mail_smtp_security": "smtp",
|
||||
"mail_smtp_auth": false
|
||||
}"#;
|
||||
|
||||
let config: Config = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert!(config.room_name().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn room_name_is_required() {
|
||||
let json = r#"{
|
||||
"url": "https://example.com/temperature/",
|
||||
"digitemp": "/bin/digitemp_DS9097U",
|
||||
"digitemp_config": "etc/digitemp.conf",
|
||||
"database_dir": "db",
|
||||
"images_dir": "html/images",
|
||||
"templates_dir": "templates",
|
||||
"mail_alert_upper": 30,
|
||||
"mail_alert_lower": 17,
|
||||
"mail_from": "from@example.com",
|
||||
"mail_from_name": "S108 温度監視プログラム",
|
||||
"mail_info_to": "info@example.com",
|
||||
"mail_info_subject": "S108 温度監視 定期報告",
|
||||
"mail_info_template": "mail_info.tera",
|
||||
"mail_alert_to": "alert@example.com",
|
||||
"mail_alert_subject": "S108 温度監視 警告",
|
||||
"mail_alert_template": "mail_alert.tera",
|
||||
"mail_smtp_server": "localhost",
|
||||
"mail_smtp_port": 25,
|
||||
"mail_smtp_security": "smtp",
|
||||
"mail_smtp_auth": false
|
||||
}"#;
|
||||
|
||||
assert!(serde_json::from_str::<Config>(json).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn room_name(&self) -> Result<&str, Box<dyn std::error::Error>> {
|
||||
let room_name = self.room_name.trim();
|
||||
if room_name.is_empty() {
|
||||
return Err("room_name must not be empty".into());
|
||||
}
|
||||
Ok(room_name)
|
||||
}
|
||||
|
||||
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let data = fs::read_to_string(path)?;
|
||||
let config: Config = serde_json::from_str(&data)?;
|
||||
config.room_name()?;
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
use std::fs;
|
||||
use std::io::{self, BufRead};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DigiTemp {
|
||||
pub command_path: String,
|
||||
pub config_path: String,
|
||||
pub sensors: Vec<String>,
|
||||
}
|
||||
|
||||
impl DigiTemp {
|
||||
pub fn new(command_path: String, config_path: String) -> io::Result<Self> {
|
||||
let sensors = Self::parse_config(&config_path)?;
|
||||
Ok(DigiTemp {
|
||||
command_path,
|
||||
config_path,
|
||||
sensors,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_num_sensors(&self) -> usize {
|
||||
self.sensors.len()
|
||||
}
|
||||
|
||||
pub fn get_sensor_id(&self, num: usize) -> Option<&str> {
|
||||
self.sensors.get(num).map(|s| s.as_str())
|
||||
}
|
||||
|
||||
pub fn read_sensor(&self, num: usize) -> io::Result<f64> {
|
||||
let output = Command::new(&self.command_path)
|
||||
.arg("-c")
|
||||
.arg(&self.config_path)
|
||||
.arg("-q")
|
||||
.arg("-t")
|
||||
.arg(num.to_string())
|
||||
.arg("-o%.2C")
|
||||
.output()?;
|
||||
if !output.status.success() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
"digitemp command failed",
|
||||
));
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let value = stdout
|
||||
.trim()
|
||||
.parse::<f64>()
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn parse_config<P: AsRef<Path>>(path: P) -> io::Result<Vec<String>> {
|
||||
let file = fs::File::open(path)?;
|
||||
let reader = io::BufReader::new(file);
|
||||
let mut sensors = Vec::new();
|
||||
for line in reader.lines() {
|
||||
let line = line?;
|
||||
if line.starts_with("ROM ") {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() > 2 {
|
||||
let rom_id = parts[2..]
|
||||
.iter()
|
||||
.map(|s| s.trim_start_matches("0x"))
|
||||
.rev()
|
||||
.collect::<String>();
|
||||
sensors.push(rom_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(sensors)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod config;
|
||||
pub mod digitemp;
|
||||
pub mod mail;
|
||||
pub mod rrd;
|
||||
pub mod template;
|
||||
@@ -0,0 +1,89 @@
|
||||
use lettre::message::{Mailbox, Message, header};
|
||||
use lettre::{SmtpTransport, Transport, transport::smtp::authentication::Credentials};
|
||||
use std::error::Error;
|
||||
use std::io::{Error as IoError, ErrorKind};
|
||||
|
||||
use crate::config::MailSmtpSecurity;
|
||||
|
||||
pub struct MailSender {
|
||||
pub smtp_server: String,
|
||||
pub smtp_port: u16,
|
||||
pub smtp_security: MailSmtpSecurity,
|
||||
pub smtp_auth: bool,
|
||||
pub smtp_username: String,
|
||||
pub smtp_password: String,
|
||||
}
|
||||
|
||||
impl MailSender {
|
||||
pub fn new(
|
||||
smtp_server: String,
|
||||
smtp_port: u16,
|
||||
smtp_security: MailSmtpSecurity,
|
||||
smtp_auth: bool,
|
||||
smtp_username: String,
|
||||
smtp_password: String,
|
||||
) -> Self {
|
||||
MailSender {
|
||||
smtp_server,
|
||||
smtp_port,
|
||||
smtp_security,
|
||||
smtp_auth,
|
||||
smtp_username,
|
||||
smtp_password,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_mailer(&self) -> Result<SmtpTransport, Box<dyn Error>> {
|
||||
let builder = match self.smtp_security {
|
||||
MailSmtpSecurity::Smtp => {
|
||||
SmtpTransport::builder_dangerous(&self.smtp_server).port(self.smtp_port)
|
||||
}
|
||||
MailSmtpSecurity::Starttls => {
|
||||
SmtpTransport::starttls_relay(&self.smtp_server)?.port(self.smtp_port)
|
||||
}
|
||||
MailSmtpSecurity::Smtps => {
|
||||
SmtpTransport::relay(&self.smtp_server)?.port(self.smtp_port)
|
||||
}
|
||||
};
|
||||
|
||||
let builder = if self.smtp_auth {
|
||||
if self.smtp_username.is_empty() || self.smtp_password.is_empty() {
|
||||
return Err(Box::new(IoError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"mail_smtp_username and mail_smtp_password are required when mail_smtp_auth is true",
|
||||
)));
|
||||
}
|
||||
|
||||
builder.credentials(Credentials::new(
|
||||
self.smtp_username.clone(),
|
||||
self.smtp_password.clone(),
|
||||
))
|
||||
} else {
|
||||
builder
|
||||
};
|
||||
|
||||
Ok(builder.build())
|
||||
}
|
||||
|
||||
pub fn send_mail(
|
||||
&self,
|
||||
from_name: &str,
|
||||
from_email: &str,
|
||||
to_name: &str,
|
||||
to_email: &str,
|
||||
subject: &str,
|
||||
body: &str,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let from = Mailbox::new(Some(from_name.to_string()), from_email.parse()?);
|
||||
let to = Mailbox::new(Some(to_name.to_string()), to_email.parse()?);
|
||||
let email = Message::builder()
|
||||
.from(from)
|
||||
.to(to)
|
||||
.subject(subject)
|
||||
.header(header::ContentType::TEXT_PLAIN)
|
||||
.body(body.to_string())?;
|
||||
let mailer = self.build_mailer()?;
|
||||
mailer.send(&email)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use chrono::{Local, TimeZone};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub struct LastData {
|
||||
pub id: String,
|
||||
pub min: f64,
|
||||
pub max: f64,
|
||||
pub average: f64,
|
||||
pub latest: f64,
|
||||
pub timestamp: u64,
|
||||
pub date: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RrdTemperature {
|
||||
pub database_dir: PathBuf,
|
||||
pub sensor_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl RrdTemperature {
|
||||
pub fn new<P: AsRef<Path>>(database_dir: P) -> Self {
|
||||
RrdTemperature {
|
||||
database_dir: database_dir.as_ref().to_path_buf(),
|
||||
sensor_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_file_path(&self, id: &str) -> PathBuf {
|
||||
self.database_dir.join(format!("{}.rrd", id))
|
||||
}
|
||||
|
||||
pub fn add_sensor(&mut self, id: &str) -> io::Result<()> {
|
||||
if !self.sensor_ids.contains(&id.to_string()) {
|
||||
self.sensor_ids.push(id.to_string());
|
||||
}
|
||||
let fpath = self.get_file_path(id);
|
||||
if !fpath.exists() {
|
||||
self.create(id)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create(&self, id: &str) -> io::Result<()> {
|
||||
let fpath = self.get_file_path(id);
|
||||
let status = Command::new("rrdtool")
|
||||
.arg("create")
|
||||
.arg(fpath.to_str().unwrap())
|
||||
.arg("--step")
|
||||
.arg("60")
|
||||
.arg(format!("DS:{}:GAUGE:600:0:100", id)) // Mark missing data as NaN after 600 seconds and clamp values to 0-100.
|
||||
.arg("RRA:LAST:0.5:1:10080") // Store 1-minute samples for 7 days.
|
||||
.arg("RRA:LAST:0.5:30:1752000") // Store 30-minute samples for 10 years.
|
||||
.output()?;
|
||||
if !status.status.success() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
"rrdtool create failed",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update(&self, id: &str, time: u64, value: f64) -> io::Result<()> {
|
||||
let fpath = self.get_file_path(id);
|
||||
if !fpath.exists() {
|
||||
self.create(id)?;
|
||||
}
|
||||
let status = Command::new("rrdtool")
|
||||
.arg("update")
|
||||
.arg(fpath.to_str().unwrap())
|
||||
.arg(format!("{}:{}", time, value))
|
||||
.output()?;
|
||||
if !status.status.success() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
"rrdtool update failed",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Outputs a graph image for all sensors, matching the PHP implementation
|
||||
pub fn output_graph(&self, room_name: &str, typ: &str, num: i32, fpath: &str) -> io::Result<()> {
|
||||
// Validate period type
|
||||
let valid_types = ["hour", "day", "week", "month", "year"];
|
||||
if !valid_types.contains(&typ) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"Invalid graph type",
|
||||
));
|
||||
}
|
||||
let start = format!("-{}{}", num, typ);
|
||||
let colors = [
|
||||
"#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#00FFFF", "#FF00FF", "#FF9999", "#99FF99",
|
||||
"#9999FF", "#FFFF99", "#99FFFF", "#FF99FF",
|
||||
];
|
||||
// Find max sensor id length for formatting
|
||||
let id_len = self.sensor_ids.iter().map(|id| id.len()).max().unwrap_or(0);
|
||||
let mut args = vec![
|
||||
String::from("graph"),
|
||||
fpath.to_string(),
|
||||
String::from("--imgformat"),
|
||||
String::from("PNG"),
|
||||
String::from("--lower-limit"),
|
||||
String::from("10"),
|
||||
String::from("--upper-limit"),
|
||||
String::from("40"),
|
||||
String::from("--start"),
|
||||
start,
|
||||
String::from("--end"),
|
||||
String::from("now"),
|
||||
String::from("--width"),
|
||||
String::from("400"),
|
||||
String::from("--height"),
|
||||
String::from("200"),
|
||||
String::from("--units-exponent"),
|
||||
String::from("0"),
|
||||
String::from("--vertical-label"),
|
||||
String::from("Temperature [°C]"),
|
||||
String::from("--title"),
|
||||
format!("{} Temperature - by {}{}(s)\n", room_name, num, typ),
|
||||
];
|
||||
// DEFs for each sensor
|
||||
for (key, id) in self.sensor_ids.iter().enumerate() {
|
||||
let def = format!(
|
||||
"DEF:B{}={}:{}:LAST",
|
||||
key,
|
||||
self.get_file_path(id).to_str().unwrap(),
|
||||
id
|
||||
);
|
||||
args.push(def);
|
||||
}
|
||||
// Header for stats
|
||||
args.push(format!(
|
||||
"COMMENT: {} Cur\\: Min\\: Avg\\: Max\\:\\l",
|
||||
" ".repeat(id_len)
|
||||
));
|
||||
// LINEs and GPRINTs for each sensor
|
||||
for (key, id) in self.sensor_ids.iter().enumerate() {
|
||||
let color = colors[key % colors.len()];
|
||||
let line = format!(
|
||||
"LINE2:B{}{}:{}{}",
|
||||
key,
|
||||
color,
|
||||
id,
|
||||
" ".repeat(id_len - id.len())
|
||||
);
|
||||
args.push(line);
|
||||
args.push(format!("GPRINT:B{}:LAST: %-6.2lf", key));
|
||||
args.push(format!("GPRINT:B{}:MIN: %-6.2lf", key));
|
||||
args.push(format!("GPRINT:B{}:AVERAGE: %-6.2lf", key));
|
||||
args.push(format!("GPRINT:B{}:MAX: %-6.2lf\\l", key));
|
||||
}
|
||||
// Last update timestamp
|
||||
let now = Local::now();
|
||||
let last_update = now.format("%Y-%m-%d %H:%M:%S %Z").to_string();
|
||||
args.push(format!(
|
||||
"COMMENT:Last update\\: {}\\r",
|
||||
last_update.replace(':', "\\:")
|
||||
));
|
||||
// Run rrdtool command
|
||||
let status = Command::new("rrdtool").args(&args).output()?;
|
||||
if !status.status.success() {
|
||||
return Err(io::Error::new(io::ErrorKind::Other, "rrdtool graph failed"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read_last_data(&self, id: &str) -> io::Result<Option<LastData>> {
|
||||
let fpath = self.get_file_path(id);
|
||||
let output = Command::new("rrdtool")
|
||||
.arg("fetch")
|
||||
.arg(fpath.to_str().unwrap())
|
||||
.arg("LAST")
|
||||
.output()?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(io::Error::other(format!(
|
||||
"rrdtool fetch failed: {}",
|
||||
stderr.trim()
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(parse_fetch_output(&String::from_utf8_lossy(&output.stdout), id))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_fetch_output(output: &str, id: &str) -> Option<LastData> {
|
||||
let mut values = Vec::new();
|
||||
let mut latest_timestamp = 0;
|
||||
|
||||
for line in output.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some((timestamp, value)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let timestamp = match timestamp.trim().parse::<u64>() {
|
||||
Ok(timestamp) => timestamp,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let value = value.trim();
|
||||
if value.eq_ignore_ascii_case("nan") || value.eq_ignore_ascii_case("-nan") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let value = match value.parse::<f64>() {
|
||||
Ok(value) if value != 0.0 => value,
|
||||
Ok(_) | Err(_) => continue,
|
||||
};
|
||||
|
||||
values.push(value);
|
||||
latest_timestamp = timestamp;
|
||||
}
|
||||
|
||||
if values.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let latest = *values.last()?;
|
||||
let min = values.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
||||
let average = values.iter().sum::<f64>() / values.len() as f64;
|
||||
|
||||
Some(LastData {
|
||||
id: id.to_string(),
|
||||
min,
|
||||
max,
|
||||
average,
|
||||
latest,
|
||||
timestamp: latest_timestamp,
|
||||
date: format_timestamp(latest_timestamp),
|
||||
})
|
||||
}
|
||||
|
||||
fn format_timestamp(timestamp: u64) -> String {
|
||||
Local
|
||||
.timestamp_opt(timestamp as i64, 0)
|
||||
.single()
|
||||
.map(|datetime| datetime.format("%Y-%m-%d %H:%M:%S %Z").to_string())
|
||||
.unwrap_or_else(|| timestamp.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{format_timestamp, parse_fetch_output};
|
||||
|
||||
#[test]
|
||||
fn parse_fetch_output_returns_last_data() {
|
||||
let output = " 99000800B7732910\n\n1711260000: -nan\n1711260060: 0.0000000000e+00\n1711260120: 2.1500000000e+01\n1711260180: 2.3000000000e+01\n";
|
||||
|
||||
let data = parse_fetch_output(output, "99000800B7732910").expect("expected data");
|
||||
|
||||
assert_eq!(data.id, "99000800B7732910");
|
||||
assert_eq!(data.min, 21.5);
|
||||
assert_eq!(data.max, 23.0);
|
||||
assert_eq!(data.average, 22.25);
|
||||
assert_eq!(data.latest, 23.0);
|
||||
assert_eq!(data.timestamp, 1711260180);
|
||||
assert_eq!(data.date, format_timestamp(1711260180));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_fetch_output_returns_none_for_empty_series() {
|
||||
let output = " 99000800B7732910\n\n1711260000: -nan\n1711260060: 0.0000000000e+00\n";
|
||||
|
||||
assert!(parse_fetch_output(output, "99000800B7732910").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
use std::error::Error;
|
||||
use std::path::Path;
|
||||
use tera::{Context, Tera};
|
||||
|
||||
pub struct TemplateContext {
|
||||
inner: Context,
|
||||
}
|
||||
|
||||
impl TemplateContext {
|
||||
pub fn new() -> Self {
|
||||
TemplateContext {
|
||||
inner: Context::new(),
|
||||
}
|
||||
}
|
||||
pub fn insert<V: serde::Serialize>(&mut self, key: &str, value: &V) {
|
||||
self.inner.insert(key, value);
|
||||
}
|
||||
pub(crate) fn as_inner(&self) -> &Context {
|
||||
&self.inner
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TemplateEngine {
|
||||
tera: Tera,
|
||||
}
|
||||
|
||||
impl TemplateEngine {
|
||||
pub fn new<P: AsRef<Path>>(template_dir: P) -> Result<Self, Box<dyn Error>> {
|
||||
let pattern = format!("{}/**/*", template_dir.as_ref().display());
|
||||
let tera = Tera::new(&pattern)?;
|
||||
Ok(TemplateEngine { tera })
|
||||
}
|
||||
|
||||
pub fn render(
|
||||
&self,
|
||||
template: &str,
|
||||
context: &TemplateContext,
|
||||
) -> Result<String, Box<dyn Error>> {
|
||||
let rendered = self.tera.render(template, context.as_inner())?;
|
||||
Ok(rendered)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#
|
||||
# Server Room Temperature Monitoring System
|
||||
#
|
||||
# update minutely temperature sensor data
|
||||
* * * * * admin /opt/digitemp_rrdgraph/bin/digitemp_rrdgraph update
|
||||
# send daily temperature informations
|
||||
1 12 * * * admin php /opt/digitemp_rrdgraph/bin/digitemp_rrdgraph send-info
|
||||
# check alert thresholds on every 15 minutes
|
||||
0,15,30,45 * * * * admin /opt/digitemp_rrdgraph/bin/digitemp_rrdgraph check-alert
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"url": "https://www.ni.riken.jp/temperature/",
|
||||
"digitemp": "/bin/digitemp_DS9097U",
|
||||
"digitemp_config": "/opt/digitemp-rrdgraph/etc/digitemp.conf",
|
||||
"database_dir": "/opt/digitemp-rrdgraph/db",
|
||||
"images_dir": "/opt/digitemp-rrdgraph/html/images",
|
||||
"templates_dir": "/opt/digitemp-rrdgraph/templates",
|
||||
"room_name": "C407",
|
||||
"mail_alert_upper": 30,
|
||||
"mail_alert_lower": 17,
|
||||
"mail_from": "cbs-is@ml.riken.jp",
|
||||
"mail_from_name": "C407 温度監視プログラム",
|
||||
"mail_info_to": "niu-log@ml.riken.jp",
|
||||
"mail_info_subject": "C407 温度監視 定期報告",
|
||||
"mail_info_template": "mail_info.tera",
|
||||
"mail_alert_to": "cbs-is@ml.riken.jp",
|
||||
"mail_alert_subject": "C407 温度監視 警告",
|
||||
"mail_alert_template": "mail_alert.tera",
|
||||
"mail_smtp_server": "smtp.example.com",
|
||||
"mail_smtp_port": 587,
|
||||
"mail_smtp_security": "starttls",
|
||||
"mail_smtp_auth": true,
|
||||
"mail_smtp_username": "user@example.com",
|
||||
"mail_smtp_password": "password"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
TTY /dev/ttyS1
|
||||
READ_TIME 1000
|
||||
LOG_TYPE 1
|
||||
LOG_FORMAT "%b %d %H:%M:%S Sensor %s C: %.2C F: %.2F"
|
||||
CNT_FORMAT "%b %d %H:%M:%S Sensor %s #%n %C"
|
||||
HUM_FORMAT "%b %d %H:%M:%S Sensor %s C: %.2C F: %.2F H: %h%%"
|
||||
SENSORS 2
|
||||
ROM 0 0x10 0xF5 0x89 0xB7 0x00 0x08 0x00 0x3B
|
||||
ROM 1 0x10 0xA3 0x85 0xB7 0x00 0x08 0x00 0x86
|
||||
@@ -0,0 +1,27 @@
|
||||
body {
|
||||
margin: 2em 1em 0 3em;
|
||||
color: black;
|
||||
background: white;
|
||||
background-position: top left;
|
||||
background-attachment: fixed;
|
||||
background-repeat: no-repeat;
|
||||
background-image: url(logo.png);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h1 { text-align: center; }
|
||||
h2, h3, h4, h5, h6 { text-align: left; }
|
||||
h1 { color: black; font: 120% sans-serif; font-weight: bold; }
|
||||
h2 { color: #005A9C; font: 110%; font-weight: medium; }
|
||||
h3 { color: #001A4C; font: 105% sans-serif; font-weight: medium; }
|
||||
h4 { color: #007A9C; font: 90% sans-serif; font-weight: medium; }
|
||||
h5 { color: #005A9C; font: italic 80% sans-serif; }
|
||||
h6 { font: small-caps 70% sans-serif; }
|
||||
|
||||
img { width: auto; height: auto; max-width: 100%; max-height: 100%; }
|
||||
|
||||
main { display: flex; flex-wrap: wrap; }
|
||||
main div.graph { margin: 5px auto; }
|
||||
main div.range-select span { color: blue; text-decoration: underline; cursor: pointer; }
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,3 @@
|
||||
pub mod check_alert;
|
||||
pub mod send_info;
|
||||
pub mod update;
|
||||
@@ -0,0 +1,52 @@
|
||||
use digitemp_rrdgraph_core::config::Config;
|
||||
use digitemp_rrdgraph_core::digitemp::DigiTemp;
|
||||
use digitemp_rrdgraph_core::mail::MailSender;
|
||||
use digitemp_rrdgraph_core::rrd::RrdTemperature;
|
||||
use digitemp_rrdgraph_core::template::{TemplateContext, TemplateEngine};
|
||||
use serde_json::json;
|
||||
use std::error::Error;
|
||||
|
||||
pub fn run(config: &Config) -> Result<(), Box<dyn Error>> {
|
||||
let digitemp = DigiTemp::new(config.digitemp.clone(), config.digitemp_config.clone())?;
|
||||
let mut rrdtemp = RrdTemperature::new(&config.database_dir);
|
||||
let num = digitemp.get_num_sensors();
|
||||
let mut sensors = Vec::new();
|
||||
for i in 0..num {
|
||||
let id = digitemp.get_sensor_id(i).unwrap();
|
||||
rrdtemp.add_sensor(id)?;
|
||||
if let Some(sensor) = rrdtemp.read_last_data(id)? {
|
||||
sensors.push(sensor);
|
||||
}
|
||||
}
|
||||
let upper = config.mail_alert_upper;
|
||||
let lower = config.mail_alert_lower;
|
||||
let do_send = sensors
|
||||
.iter()
|
||||
.any(|sensor| sensor.latest < lower || sensor.latest > upper);
|
||||
if do_send {
|
||||
let from = &config.mail_from;
|
||||
let from_name = &config.mail_from_name;
|
||||
let to = &config.mail_alert_to;
|
||||
let subject = &config.mail_alert_subject;
|
||||
let room_name = config.room_name()?;
|
||||
let engine = TemplateEngine::new(&config.templates_dir)?;
|
||||
let mut ctx = TemplateContext::new();
|
||||
ctx.insert("range", &json!({"upper": upper, "lower": lower}));
|
||||
ctx.insert("sensors", &sensors);
|
||||
ctx.insert("from", &json!({"name": from_name, "email": from}));
|
||||
ctx.insert("room_name", &room_name);
|
||||
ctx.insert("subject", subject);
|
||||
ctx.insert("url", &config.url);
|
||||
let body = engine.render(&config.mail_alert_template, &ctx)?;
|
||||
let mailer = MailSender::new(
|
||||
config.mail_smtp_server.clone(),
|
||||
config.mail_smtp_port,
|
||||
config.mail_smtp_security.clone(),
|
||||
config.mail_smtp_auth,
|
||||
config.mail_smtp_username.clone(),
|
||||
config.mail_smtp_password.clone(),
|
||||
);
|
||||
mailer.send_mail(from_name, from, to, to, subject, &body)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use digitemp_rrdgraph_core::config::Config;
|
||||
use digitemp_rrdgraph_core::digitemp::DigiTemp;
|
||||
use digitemp_rrdgraph_core::mail::MailSender;
|
||||
use digitemp_rrdgraph_core::rrd::RrdTemperature;
|
||||
use digitemp_rrdgraph_core::template::{TemplateContext, TemplateEngine};
|
||||
use serde_json::json;
|
||||
use std::error::Error;
|
||||
|
||||
pub fn run(config: &Config) -> Result<(), Box<dyn Error>> {
|
||||
let digitemp = DigiTemp::new(config.digitemp.clone(), config.digitemp_config.clone())?;
|
||||
let mut rrdtemp = RrdTemperature::new(&config.database_dir);
|
||||
let num = digitemp.get_num_sensors();
|
||||
let mut sensors = Vec::new();
|
||||
for i in 0..num {
|
||||
let id = digitemp.get_sensor_id(i).unwrap();
|
||||
rrdtemp.add_sensor(id)?;
|
||||
if let Some(sensor) = rrdtemp.read_last_data(id)? {
|
||||
sensors.push(sensor);
|
||||
}
|
||||
}
|
||||
let from = &config.mail_from;
|
||||
let from_name = &config.mail_from_name;
|
||||
let to = &config.mail_info_to;
|
||||
let subject = &config.mail_info_subject;
|
||||
let room_name = config.room_name()?;
|
||||
let engine = TemplateEngine::new(&config.templates_dir)?;
|
||||
let mut ctx = TemplateContext::new();
|
||||
ctx.insert("from", &json!({"name": from_name, "email": from}));
|
||||
ctx.insert("room_name", &room_name);
|
||||
ctx.insert("sensors", &sensors);
|
||||
ctx.insert("subject", subject);
|
||||
ctx.insert("url", &config.url);
|
||||
let body = engine.render(&config.mail_info_template, &ctx)?;
|
||||
let mailer = MailSender::new(
|
||||
config.mail_smtp_server.clone(),
|
||||
config.mail_smtp_port,
|
||||
config.mail_smtp_security.clone(),
|
||||
config.mail_smtp_auth,
|
||||
config.mail_smtp_username.clone(),
|
||||
config.mail_smtp_password.clone(),
|
||||
);
|
||||
mailer.send_mail(from_name, from, to, to, subject, &body)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use digitemp_rrdgraph_core::config::Config;
|
||||
use digitemp_rrdgraph_core::digitemp::DigiTemp;
|
||||
use digitemp_rrdgraph_core::rrd::RrdTemperature;
|
||||
use digitemp_rrdgraph_core::template::{TemplateContext, TemplateEngine};
|
||||
use std::error::Error;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub fn run(config: &Config) -> Result<(), Box<dyn Error>> {
|
||||
let room_name = config.room_name()?;
|
||||
let digitemp = DigiTemp::new(config.digitemp.clone(), config.digitemp_config.clone())?;
|
||||
let mut rrdtemp = RrdTemperature::new(&config.database_dir);
|
||||
let num = digitemp.get_num_sensors();
|
||||
for i in 0..num {
|
||||
let id = digitemp.get_sensor_id(i).unwrap();
|
||||
let value = digitemp.read_sensor(i)?;
|
||||
rrdtemp.add_sensor(id)?;
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
|
||||
rrdtemp.update(id, now, value)?;
|
||||
}
|
||||
let types = vec![
|
||||
("hour", vec![1, 3, 6, 12]),
|
||||
("day", vec![1, 3]),
|
||||
("week", vec![1, 2]),
|
||||
("month", vec![1, 3, 6]),
|
||||
("year", vec![1, 3, 5, 10]),
|
||||
];
|
||||
for (typ, nums) in types {
|
||||
for num in nums {
|
||||
let num_str = if num == 1 {
|
||||
"".to_string()
|
||||
} else {
|
||||
num.to_string()
|
||||
};
|
||||
let fpath = format!("{}/graph-{}{}.png", config.images_dir, num_str, typ);
|
||||
rrdtemp.output_graph(room_name, typ, num, &fpath)?;
|
||||
}
|
||||
}
|
||||
|
||||
let engine = TemplateEngine::new(&config.templates_dir)?;
|
||||
let mut ctx = TemplateContext::new();
|
||||
ctx.insert("room_name", &room_name);
|
||||
let html = engine.render("index.tera", &ctx)?;
|
||||
let index_path = Path::new(&config.images_dir)
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("html"))
|
||||
.join("index.html");
|
||||
fs::write(index_path, html)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
mod commands;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use digitemp_rrdgraph_core::config::Config;
|
||||
use std::env;
|
||||
use std::error::Error;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(author, version, about)]
|
||||
struct Cli {
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
global = true,
|
||||
help = "Print the selected configuration file path to standard error"
|
||||
)]
|
||||
verbose: bool,
|
||||
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
global = true,
|
||||
value_name = "PATH",
|
||||
help = "Load configuration from PATH instead of using the default search order"
|
||||
)]
|
||||
config: Option<PathBuf>,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
Update,
|
||||
CheckAlert,
|
||||
SendInfo,
|
||||
}
|
||||
|
||||
const CONFIG_FILE_NAME: &str = "digitemp-rrdgraph.json";
|
||||
|
||||
fn config_candidates(
|
||||
home_dir: Option<&Path>,
|
||||
executable_root_dir: Option<&Path>,
|
||||
current_dir: &Path,
|
||||
) -> Vec<PathBuf> {
|
||||
let mut candidates = Vec::with_capacity(4);
|
||||
|
||||
if let Some(home_dir) = home_dir {
|
||||
candidates.push(home_dir.join(".config").join(CONFIG_FILE_NAME));
|
||||
}
|
||||
|
||||
candidates.push(PathBuf::from("/etc").join(CONFIG_FILE_NAME));
|
||||
|
||||
if let Some(executable_root_dir) = executable_root_dir {
|
||||
candidates.push(executable_root_dir.join("etc").join(CONFIG_FILE_NAME));
|
||||
}
|
||||
|
||||
candidates.push(current_dir.join("etc").join(CONFIG_FILE_NAME));
|
||||
|
||||
candidates
|
||||
}
|
||||
|
||||
fn resolve_config_path() -> Result<PathBuf, Box<dyn Error>> {
|
||||
let home_dir = env::var_os("HOME").map(PathBuf::from);
|
||||
let executable_root_dir = env::current_exe()?
|
||||
.parent()
|
||||
.and_then(Path::parent)
|
||||
.map(Path::to_path_buf);
|
||||
let current_dir = env::current_dir()?;
|
||||
let candidates = config_candidates(
|
||||
home_dir.as_deref(),
|
||||
executable_root_dir.as_deref(),
|
||||
¤t_dir,
|
||||
);
|
||||
|
||||
candidates
|
||||
.iter()
|
||||
.find(|path| path.is_file())
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
let searched = candidates
|
||||
.iter()
|
||||
.map(|path| path.display().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
format!("configuration file not found; searched: {searched}"),
|
||||
)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_config_path_from_cli(config_path: Option<PathBuf>) -> Result<PathBuf, Box<dyn Error>> {
|
||||
match config_path {
|
||||
Some(path) => Ok(path),
|
||||
None => resolve_config_path(),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
let cli = Cli::parse();
|
||||
let config_path = resolve_config_path_from_cli(cli.config.clone())?;
|
||||
if cli.verbose {
|
||||
eprintln!("Using configuration file: {}", config_path.display());
|
||||
}
|
||||
let config = Config::from_file(&config_path)?;
|
||||
match cli.command {
|
||||
Commands::Update => {
|
||||
commands::update::run(&config)?;
|
||||
}
|
||||
Commands::CheckAlert => {
|
||||
commands::check_alert::run(&config)?;
|
||||
}
|
||||
Commands::SendInfo => {
|
||||
commands::send_info::run(&config)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{CONFIG_FILE_NAME, config_candidates, resolve_config_path_from_cli};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[test]
|
||||
fn config_candidates_follow_expected_precedence() {
|
||||
let candidates = config_candidates(
|
||||
Some(Path::new("/home/testuser")),
|
||||
Some(Path::new("/opt/digitemp-rrdgraph")),
|
||||
Path::new("/work/app"),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
candidates,
|
||||
vec![
|
||||
Path::new("/home/testuser")
|
||||
.join(".config")
|
||||
.join(CONFIG_FILE_NAME),
|
||||
Path::new("/etc").join(CONFIG_FILE_NAME),
|
||||
Path::new("/opt/digitemp-rrdgraph")
|
||||
.join("etc")
|
||||
.join(CONFIG_FILE_NAME),
|
||||
Path::new("/work/app").join("etc").join(CONFIG_FILE_NAME),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_candidates_skip_optional_entries_when_paths_are_missing() {
|
||||
let candidates = config_candidates(None, None, Path::new("/work/app"));
|
||||
|
||||
assert_eq!(
|
||||
candidates,
|
||||
vec![
|
||||
Path::new("/etc").join(CONFIG_FILE_NAME),
|
||||
Path::new("/work/app").join("etc").join(CONFIG_FILE_NAME),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_config_path_takes_precedence_over_search_order() {
|
||||
let config_path = PathBuf::from("/tmp/custom-config.json");
|
||||
|
||||
assert_eq!(
|
||||
resolve_config_path_from_cli(Some(config_path.clone())).unwrap(),
|
||||
config_path
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Temperature Monitor - {{ room_name }}</title>
|
||||
<meta name="description" content="Temperature Monitor - {{ room_name }}">
|
||||
<meta name="author" content="Neuroinformatics Unit, RIKEN CBS">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta http-equiv="refresh" content="300">
|
||||
<link rel="stylesheet" href="css/default.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>Temperature Monitor - {{ room_name }}</h1>
|
||||
|
||||
<main>
|
||||
<div class="graph">
|
||||
<div class="range-select">
|
||||
Hour:
|
||||
<span onclick="changeGraph('g1', 'hour', 1)">1</span> ...
|
||||
<span onclick="changeGraph('g1', 'hour', 3)">3</span> ...
|
||||
<span onclick="changeGraph('g1', 'hour', 6)">6</span> ...
|
||||
<span onclick="changeGraph('g1', 'hour', 12)">12</span>
|
||||
</div>
|
||||
<div><img id="g1" src="./images/graph-hour.png" alt="Hourly Graph" /></div>
|
||||
</div>
|
||||
<div class="graph">
|
||||
<div class="range-select">
|
||||
Day:
|
||||
<span onclick="changeGraph('g2', 'day', 1)">1</span> ...
|
||||
<span onclick="changeGraph('g2', 'day', 3)">3</span>
|
||||
</div>
|
||||
<div><img id="g2" src="./images/graph-day.png" alt="Daily Graph" /></div>
|
||||
</div>
|
||||
<div class="graph">
|
||||
<div class="range-select">
|
||||
Week:
|
||||
<span onclick="changeGraph('g3', 'week', 1)">1</span> ...
|
||||
<span onclick="changeGraph('g3', 'week', 2)">2</span>
|
||||
</div>
|
||||
<div><img id="g3" src="./images/graph-week.png" alt="Weekly Graph" /></div>
|
||||
</div>
|
||||
<div class="graph">
|
||||
<div class="range-select">
|
||||
Month:
|
||||
<span onclick="changeGraph('g4', 'month', 1)">1</span> ...
|
||||
<span onclick="changeGraph('g4', 'month', 3)">3</span> ...
|
||||
<span onclick="changeGraph('g4', 'month', 6)">6</span>
|
||||
</div>
|
||||
<div><img id="g4" src="./images/graph-month.png" alt="Monthly Graph" /></div>
|
||||
</div>
|
||||
<div class="graph">
|
||||
<div class="range-select">
|
||||
Year:
|
||||
<span onclick="changeGraph('g5', 'year', 1)">1</span> ...
|
||||
<span onclick="changeGraph('g5', 'year', 3)">3</span> ...
|
||||
<span onclick="changeGraph('g5', 'year', 5)">5</span> ...
|
||||
<span onclick="changeGraph('g5', 'year', 10)">10</span>
|
||||
</div>
|
||||
<div><img id="g5" src="./images/graph-year.png" alt="Yearly Graph" /></div>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
function changeGraph(id, type, num) {
|
||||
const img = document.getElementById(id);
|
||||
if (img) {
|
||||
img.src = './images/graph-' + (num === 1 ? '' : num) + type + '.png';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,14 @@
|
||||
{{ subject }}
|
||||
|
||||
警告温度に到達しました。
|
||||
許容範囲({{ range.lower | round(precision=2) }} 〜 {{ range.upper | round(precision=2) }} ℃)外の温度です。
|
||||
|
||||
{{ room_name }} の環境を確認してください。
|
||||
|
||||
{% for sensor in sensors -%}
|
||||
{% include "mail_sensor.inc.tera" %}
|
||||
{% endfor -%}
|
||||
|
||||
--
|
||||
{{ from.name }} <{{ from.email }}>
|
||||
{{ url }}
|
||||
@@ -0,0 +1,9 @@
|
||||
{{ subject }}
|
||||
|
||||
{% for sensor in sensors -%}
|
||||
{% include "mail_sensor.inc.tera" %}
|
||||
{% endfor -%}
|
||||
|
||||
--
|
||||
{{ from.name }} <{{ from.email }}>
|
||||
{{ url }}
|
||||
@@ -0,0 +1,6 @@
|
||||
--- センサー #{{ sensor.id }} ---
|
||||
最新測定日時:{{ sensor.date }}
|
||||
最新温度: {{ sensor.latest | round(precision=2) }} ℃
|
||||
平均/日: {{ sensor.average | round(precision=2) }} ℃
|
||||
最高値/日: {{ sensor.max | round(precision=2) }} ℃
|
||||
最低値/日: {{ sensor.min | round(precision=2) }} ℃
|
||||
Reference in New Issue
Block a user