first commit

This commit is contained in:
2026-03-24 19:59:02 +09:00
commit ffd1635045
33 changed files with 3494 additions and 0 deletions
+27
View File
@@ -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}"
]
}
+6
View File
@@ -0,0 +1,6 @@
/target
/etc/digitemp-rrdgraph.json
/etc/digitemp.conf
/html/index.html
/html/images/graph-*.png
/db/*.rrd
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": true,
"tabWidth": 2,
"singleQuote": true,
"printWidth": 120,
"trailingComma": "es5"
}
+7
View File
@@ -0,0 +1,7 @@
{
"recommendations": [
"1yib.rust-bundle",
"streetsidesoftware.code-spell-checker",
"esbenp.prettier-vscode"
]
}
+18
View File
@@ -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
}
+66
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -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" }
+21
View File
@@ -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.
+153
View File
@@ -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
+11
View File
@@ -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"
+196
View File
@@ -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)
}
}
+74
View File
@@ -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)
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod config;
pub mod digitemp;
pub mod mail;
pub mod rrd;
pub mod template;
+89
View File
@@ -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(())
}
}
+279
View File
@@ -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());
}
}
+42
View File
@@ -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)
}
}
View File
+9
View File
@@ -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
+25
View File
@@ -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"
}
+9
View File
@@ -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
+27
View File
@@ -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; }
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View File
+3
View File
@@ -0,0 +1,3 @@
pub mod check_alert;
pub mod send_info;
pub mod update;
+52
View File
@@ -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(())
}
+44
View File
@@ -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(())
}
+52
View File
@@ -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
View File
@@ -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(),
&current_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
);
}
}
+72
View File
@@ -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>
+14
View File
@@ -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 }}
+9
View File
@@ -0,0 +1,9 @@
{{ subject }}
{% for sensor in sensors -%}
{% include "mail_sensor.inc.tera" %}
{% endfor -%}
--
{{ from.name }} <{{ from.email }}>
{{ url }}
+6
View File
@@ -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) }} ℃