Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18565d6eff |
Generated
-1686
File diff suppressed because it is too large
Load Diff
@@ -1,4 +0,0 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
|
||||
members = ["rust/*"]
|
||||
Generated
+15
-15
@@ -8,11 +8,11 @@
|
||||
},
|
||||
"locked": {
|
||||
"dir": "pkgs/firefox-addons",
|
||||
"lastModified": 1785729742,
|
||||
"narHash": "sha256-PBavY37OTsIM7VJMUYPv2Rz/gSpbtJGxAGl9iXCaMU4=",
|
||||
"lastModified": 1783828963,
|
||||
"narHash": "sha256-eTytzcUJCaDUZ3/9EF0+V3fvlikQMQBwiX1Sx4Gy+No=",
|
||||
"owner": "rycee",
|
||||
"repo": "nur-expressions",
|
||||
"rev": "1529ecae5978cd2ac18a8edbf27967350bf4b80e",
|
||||
"rev": "8d61e9afde605cd6c22dab68b83d7a71f0a6c5b2",
|
||||
"type": "gitlab"
|
||||
},
|
||||
"original": {
|
||||
@@ -29,11 +29,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1785531816,
|
||||
"narHash": "sha256-vkMnV0JIyw+g/NmcfoajlGaAO+9a0ezia+FZohQJrik=",
|
||||
"lastModified": 1783823409,
|
||||
"narHash": "sha256-OI4IkRjRXa1e7hYmCGJDPDq5H/kPwhsyoS80cNUF9fI=",
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"rev": "bf9ce9fec78f95f374e8dd3b503863a3ec128ebe",
|
||||
"rev": "7566825d4652a1b885bd4ce65bd9e8def432fec9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -47,11 +47,11 @@
|
||||
"nixpkgs": "nixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1785232496,
|
||||
"narHash": "sha256-65EQYIRRpTdpH8lUiB6Mvo5uBkG60aBIzAJuALfx+O0=",
|
||||
"lastModified": 1783792734,
|
||||
"narHash": "sha256-50rvY9GdFvpYDcMLcD/4cWSi0hVxArT5wsGlVsHy8eY=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixos-hardware",
|
||||
"rev": "2e790b0a6be8ec2b76174ac0931b8ff11919ec98",
|
||||
"rev": "8efb4337e857949f4cfac86d12ef1066f417f31f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -76,11 +76,11 @@
|
||||
},
|
||||
"nixpkgs-master": {
|
||||
"locked": {
|
||||
"lastModified": 1785777863,
|
||||
"narHash": "sha256-BPLjbZgQ7hud+zRbLDqyFAW4qTKD2MBLR+TRI7oOg/w=",
|
||||
"lastModified": 1783874024,
|
||||
"narHash": "sha256-Fd8rPvyBv6JjcO/nZxZiFQan6Fww/jAF4TYj0Th/Yfo=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "0adbd05f9f410c2b26d550d344ac019efa6b7224",
|
||||
"rev": "0b4f03c64b236e4ba4252414274e92796c300124",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -108,11 +108,11 @@
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1785692966,
|
||||
"narHash": "sha256-vUfIeBEfpbAfZ5zjgIkYk7eHBeVfCYVjLbWnMkseYnk=",
|
||||
"lastModified": 1783776592,
|
||||
"narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "643809054d65fdd466a63e3155b8c498cb483c04",
|
||||
"rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -65,7 +65,6 @@ lint.ignore = [
|
||||
"ISC001", # (TEMP) conflicts when used with the formatter
|
||||
"S603", # (PERM) This is known to cause a false positive
|
||||
"S607", # (PERM) This is becoming a consistent annoyance
|
||||
"CPY001", # (PERM) I don't include the license in every file
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
|
||||
+1
-20
@@ -1,30 +1,11 @@
|
||||
"""init."""
|
||||
|
||||
# run_command is deliberately not re-exported here. It is available from
|
||||
# python.zfs.command when something genuinely needs another binary, but the
|
||||
# wrappers are what callers should reach for by default.
|
||||
from python.zfs.command import CommandResult, run_zfs, run_zpool
|
||||
from python.zfs.dataset import (
|
||||
Dataset,
|
||||
Snapshot,
|
||||
create_dataset,
|
||||
get_datasets,
|
||||
get_properties,
|
||||
list_dataset_names,
|
||||
set_property,
|
||||
)
|
||||
from python.zfs.dataset import Dataset, Snapshot, get_datasets
|
||||
from python.zfs.zpool import Zpool
|
||||
|
||||
__all__ = [
|
||||
"CommandResult",
|
||||
"Dataset",
|
||||
"Snapshot",
|
||||
"Zpool",
|
||||
"create_dataset",
|
||||
"get_datasets",
|
||||
"get_properties",
|
||||
"list_dataset_names",
|
||||
"run_zfs",
|
||||
"run_zpool",
|
||||
"set_property",
|
||||
]
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
"""Running zfs and zpool commands.
|
||||
|
||||
One implementation shared by both, so the zpool side gets the same handling the
|
||||
zfs side does: arguments passed as a list, streams kept apart, and failures
|
||||
returned as data rather than guessed at by the caller.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommandResult:
|
||||
"""The outcome of a single zfs or zpool invocation."""
|
||||
|
||||
args: tuple[str, ...]
|
||||
stdout: str
|
||||
stderr: str
|
||||
return_code: int
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
"""Whether the command reported success."""
|
||||
return self.return_code == 0
|
||||
|
||||
@property
|
||||
def message(self) -> str:
|
||||
"""The most useful description of what went wrong."""
|
||||
return (self.stderr or self.stdout).strip()
|
||||
|
||||
|
||||
def run_command(*args: str) -> CommandResult:
|
||||
"""Run a command, passing arguments as a list rather than a shell string.
|
||||
|
||||
Two things this buys over bash_wrapper. Arguments are never split on
|
||||
whitespace, so a value containing a space arrives intact. And stdout stays
|
||||
separate from stderr, so a warning on a successful command is never
|
||||
mistaken for output, which bash_wrapper does whenever stderr is non-empty
|
||||
regardless of the return code.
|
||||
|
||||
The encoding is pinned rather than using text=True, which would decode with
|
||||
the locale encoding. These run from systemd units, where LANG is often
|
||||
unset.
|
||||
|
||||
Args:
|
||||
*args: The command and its arguments.
|
||||
|
||||
Returns:
|
||||
CommandResult: The streams and return code, never raising on failure.
|
||||
"""
|
||||
completed = subprocess.run(list(args), capture_output=True, encoding="utf-8", check=False)
|
||||
|
||||
if completed.returncode != 0:
|
||||
logger.debug(f"{' '.join(args)} exited {completed.returncode}: {completed.stderr.strip()}")
|
||||
|
||||
return CommandResult(
|
||||
args=tuple(args),
|
||||
stdout=completed.stdout,
|
||||
stderr=completed.stderr,
|
||||
return_code=completed.returncode,
|
||||
)
|
||||
|
||||
|
||||
def run_zfs(*args: str) -> CommandResult:
|
||||
"""Run a zfs command.
|
||||
|
||||
Args:
|
||||
*args: The arguments to pass to zfs.
|
||||
|
||||
Returns:
|
||||
CommandResult: The streams and return code.
|
||||
"""
|
||||
return run_command("zfs", *args)
|
||||
|
||||
|
||||
def run_zpool(*args: str) -> CommandResult:
|
||||
"""Run a zpool command.
|
||||
|
||||
Args:
|
||||
*args: The arguments to pass to zpool.
|
||||
|
||||
Returns:
|
||||
CommandResult: The streams and return code.
|
||||
"""
|
||||
return run_command("zpool", *args)
|
||||
+3
-87
@@ -8,7 +8,6 @@ from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from python.common import bash_wrapper
|
||||
from python.zfs.command import run_zfs
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -208,91 +207,8 @@ def get_datasets() -> list[Dataset]:
|
||||
"""
|
||||
logger.info("Getting zfs list")
|
||||
|
||||
return [Dataset(dataset_name) for dataset_name in list_dataset_names() if "/" in dataset_name]
|
||||
dataset_names, _ = bash_wrapper("zfs list -Hp -t filesystem -o name")
|
||||
|
||||
cleaned_datasets = dataset_names.strip().split("\n")
|
||||
|
||||
def get_properties(name: str) -> dict[str, tuple[str, str]]:
|
||||
"""Get every property of a dataset along with where its value came from.
|
||||
|
||||
The source is what distinguishes a property that was deliberately set on
|
||||
this dataset from one that is merely inherited or left at its default.
|
||||
|
||||
Args:
|
||||
name (str): The name of the dataset.
|
||||
|
||||
Returns:
|
||||
dict[str, tuple[str, str]]: A mapping of property name to (value, source).
|
||||
"""
|
||||
result = run_zfs("get", "-Hp", "-o", "property,value,source", "all", name)
|
||||
if not result.ok:
|
||||
error = f"Failed to get properties for {name}: {result.message}"
|
||||
raise RuntimeError(error)
|
||||
|
||||
properties = {}
|
||||
for line in result.stdout.strip().splitlines():
|
||||
if not line:
|
||||
continue
|
||||
prop, value, source = line.split("\t")
|
||||
properties[prop] = (value, source)
|
||||
|
||||
return properties
|
||||
|
||||
|
||||
def create_dataset(name: str, properties: dict[str, str]) -> str | None:
|
||||
"""Create a dataset with the given properties.
|
||||
|
||||
Args:
|
||||
name (str): The name of the dataset.
|
||||
properties (dict[str, str]): The properties to create the dataset with.
|
||||
|
||||
Returns:
|
||||
str | None: An error message on failure, None on success.
|
||||
"""
|
||||
options = [argument for key, value in sorted(properties.items()) for argument in ("-o", f"{key}={value}")]
|
||||
logger.debug(f"creating {name} with {properties}")
|
||||
|
||||
result = run_zfs("create", *options, name)
|
||||
if not result.ok:
|
||||
return f"Failed to create {name}: {result.message}"
|
||||
return None
|
||||
|
||||
|
||||
def set_property(name: str, key: str, value: str) -> str | None:
|
||||
"""Set a single property on a dataset.
|
||||
|
||||
Args:
|
||||
name (str): The name of the dataset.
|
||||
key (str): The property to set.
|
||||
value (str): The value to set the property to.
|
||||
|
||||
Returns:
|
||||
str | None: An error message on failure, None on success.
|
||||
"""
|
||||
logger.debug(f"setting {key}={value} on {name}")
|
||||
|
||||
result = run_zfs("set", f"{key}={value}", name)
|
||||
if not result.ok:
|
||||
return f"Failed to set {key}={value} on {name}: {result.message}"
|
||||
return None
|
||||
|
||||
|
||||
def list_dataset_names() -> list[str]:
|
||||
"""List every zfs filesystem name, including pool root filesystems.
|
||||
|
||||
Unlike get_datasets this does not build Dataset objects and does not filter
|
||||
out pool roots, which makes it usable for existence checks.
|
||||
|
||||
Returns:
|
||||
list[str]: The names of every zfs filesystem.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If zfs list fails. Never returns a partial or error
|
||||
derived list, since treating stderr as dataset names would make
|
||||
the reconciler think every dataset is missing.
|
||||
"""
|
||||
result = run_zfs("list", "-Hp", "-t", "filesystem", "-o", "name")
|
||||
if not result.ok:
|
||||
error = f"Failed to list ZFS datasets: {result.message}"
|
||||
raise RuntimeError(error)
|
||||
|
||||
return [name for name in result.stdout.strip().splitlines() if name]
|
||||
return [Dataset(dataset_name) for dataset_name in cleaned_datasets if "/" in dataset_name]
|
||||
|
||||
+11
-25
@@ -1,42 +1,28 @@
|
||||
"""zpool."""
|
||||
"""test."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from python.zfs.command import run_zpool
|
||||
from python.common import bash_wrapper
|
||||
|
||||
|
||||
def _zpool_list(*args: str) -> dict[str, Any]:
|
||||
"""Run a zpool list and check the output is a format we understand.
|
||||
def _zpool_list(zfs_list: str) -> dict[str, Any]:
|
||||
"""Check the version of zfs."""
|
||||
raw_zfs_list_data, _ = bash_wrapper(zfs_list)
|
||||
|
||||
Args:
|
||||
*args: The arguments to pass to zpool.
|
||||
zfs_list_data = json.loads(raw_zfs_list_data)
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: The decoded output.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If zpool fails, or reports a format this does not parse.
|
||||
Never decodes a partial or error derived payload.
|
||||
"""
|
||||
result = run_zpool(*args)
|
||||
if not result.ok:
|
||||
error = f"Failed to run zpool {' '.join(args)}: {result.message}"
|
||||
raise RuntimeError(error)
|
||||
|
||||
zpool_data = json.loads(result.stdout)
|
||||
|
||||
vers_major = zpool_data["output_version"]["vers_major"]
|
||||
vers_minor = zpool_data["output_version"]["vers_minor"]
|
||||
command = zpool_data["output_version"]["command"]
|
||||
vers_major = zfs_list_data["output_version"]["vers_major"]
|
||||
vers_minor = zfs_list_data["output_version"]["vers_minor"]
|
||||
command = zfs_list_data["output_version"]["command"]
|
||||
|
||||
if vers_major != 0 or vers_minor != 1 or command != "zpool list":
|
||||
error = f"Datasets are not in the correct format {vers_major=} {vers_minor=} {command=}"
|
||||
raise RuntimeError(error)
|
||||
|
||||
return zpool_data
|
||||
return zfs_list_data
|
||||
|
||||
|
||||
class Zpool:
|
||||
@@ -47,7 +33,7 @@ class Zpool:
|
||||
name: str,
|
||||
) -> None:
|
||||
"""__init__."""
|
||||
zpool_data = _zpool_list("list", name, "-pHj", "-o", "all")
|
||||
zpool_data = _zpool_list(f"zpool list {name} -pHj -o all")
|
||||
|
||||
properties = zpool_data["pools"][name]["properties"]
|
||||
|
||||
|
||||
Generated
-1685
File diff suppressed because it is too large
Load Diff
@@ -1,21 +0,0 @@
|
||||
[package]
|
||||
name = "van-weather"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "Fetch privacy-masked weather for a van and publish it to Home Assistant"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
chrono = "0.4"
|
||||
clap = { version = "4.5", features = ["derive", "env"] }
|
||||
env_logger = "0.11"
|
||||
log = "0.4"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
[lints.clippy]
|
||||
all = "deny"
|
||||
pedantic = "deny"
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
{ rustPlatform }:
|
||||
rustPlatform.buildRustPackage {
|
||||
pname = "van-weather";
|
||||
version = "0.1.0";
|
||||
src = ./.;
|
||||
|
||||
cargoLock.lockFile = ./Cargo.lock;
|
||||
|
||||
meta = {
|
||||
description = "Privacy-masked van weather publisher for Home Assistant";
|
||||
mainProgram = "van-weather";
|
||||
};
|
||||
}
|
||||
@@ -1,565 +0,0 @@
|
||||
use std::{thread, time::Duration};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use chrono::{DateTime, Utc};
|
||||
use clap::Parser;
|
||||
use log::{error, info};
|
||||
use reqwest::{
|
||||
StatusCode, Url,
|
||||
blocking::{Client, ClientBuilder},
|
||||
header::{AUTHORIZATION, HeaderMap, HeaderValue},
|
||||
retry,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const LAT_ENTITY: &str = "sensor.van_last_known_latitude";
|
||||
const LON_ENTITY: &str = "sensor.van_last_known_longitude";
|
||||
const PIRATE_WEATHER_HOST: &str = "api.pirateweather.net";
|
||||
const MASK_DECIMALS: u32 = 1;
|
||||
const MASK_FACTOR: f64 = decimal_factor(MASK_DECIMALS);
|
||||
const RETRIES_PER_REQUEST: u32 = 2;
|
||||
|
||||
const fn decimal_factor(decimals: u32) -> f64 {
|
||||
let mut factor = 1.0;
|
||||
let mut remaining = decimals;
|
||||
while remaining > 0 {
|
||||
factor *= 10.0;
|
||||
remaining -= 1;
|
||||
}
|
||||
factor
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(about, version)]
|
||||
struct Args {
|
||||
#[arg(long, env = "HA_URL")]
|
||||
ha_url: String,
|
||||
|
||||
#[arg(long, env = "HA_TOKEN", hide_env_values = true)]
|
||||
ha_token: String,
|
||||
|
||||
#[arg(long, env = "PIRATE_WEATHER_API_KEY", hide_env_values = true)]
|
||||
pirate_weather_api_key: String,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
default_value_t = 900,
|
||||
value_parser = clap::value_parser!(u64).range(1..)
|
||||
)]
|
||||
interval: u64,
|
||||
|
||||
#[arg(long, default_value = "info", env = "RUST_LOG")]
|
||||
log_level: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HaState {
|
||||
state: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct HttpClients {
|
||||
home_assistant: Client,
|
||||
pirate_weather: Client,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct ApiResponse {
|
||||
#[serde(default)]
|
||||
currently: CurrentWeather,
|
||||
#[serde(default)]
|
||||
daily: ForecastBlock<DailyApiForecast>,
|
||||
#[serde(default)]
|
||||
hourly: ForecastBlock<HourlyApiForecast>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CurrentWeather {
|
||||
temperature: Option<f64>,
|
||||
apparent_temperature: Option<f64>,
|
||||
humidity: Option<f64>,
|
||||
wind_speed: Option<f64>,
|
||||
wind_bearing: Option<f64>,
|
||||
icon: Option<String>,
|
||||
pressure: Option<f64>,
|
||||
visibility: Option<f64>,
|
||||
uv_index: Option<f64>,
|
||||
ozone: Option<f64>,
|
||||
nearest_storm_distance: Option<f64>,
|
||||
nearest_storm_bearing: Option<f64>,
|
||||
precip_probability: Option<f64>,
|
||||
cloud_cover: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ForecastBlock<T> {
|
||||
#[serde(default)]
|
||||
data: Vec<T>,
|
||||
}
|
||||
|
||||
impl<T> Default for ForecastBlock<T> {
|
||||
fn default() -> Self {
|
||||
Self { data: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DailyApiForecast {
|
||||
time: Option<i64>,
|
||||
icon: Option<String>,
|
||||
temperature_high: Option<f64>,
|
||||
temperature_low: Option<f64>,
|
||||
precip_probability: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct HourlyApiForecast {
|
||||
time: Option<i64>,
|
||||
icon: Option<String>,
|
||||
temperature: Option<f64>,
|
||||
precip_probability: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Weather {
|
||||
current: CurrentWeather,
|
||||
daily: Vec<DailyForecast>,
|
||||
hourly: Vec<HourlyForecast>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct DailyForecast {
|
||||
datetime: DateTime<Utc>,
|
||||
condition: &'static str,
|
||||
temperature: Option<f64>,
|
||||
templow: Option<f64>,
|
||||
precipitation_probability: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct HourlyForecast {
|
||||
datetime: DateTime<Utc>,
|
||||
condition: &'static str,
|
||||
temperature: Option<f64>,
|
||||
precipitation_probability: Option<f64>,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let Args {
|
||||
ha_url,
|
||||
ha_token,
|
||||
pirate_weather_api_key,
|
||||
interval,
|
||||
log_level,
|
||||
} = Args::parse();
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(log_level.as_str()))
|
||||
.init();
|
||||
|
||||
let ha_url = ha_url.trim_end_matches('/').to_owned();
|
||||
let ha_host = Url::parse(&ha_url)
|
||||
.context("HA_URL is not a valid URL")?
|
||||
.host_str()
|
||||
.context("HA_URL has no host")?
|
||||
.to_owned();
|
||||
let clients = HttpClients {
|
||||
home_assistant: build_client(&ha_host, Some(&ha_token))?,
|
||||
pirate_weather: build_client(PIRATE_WEATHER_HOST, None)?,
|
||||
};
|
||||
|
||||
info!("Starting van weather service, polling every {interval}s");
|
||||
loop {
|
||||
if let Err(err) = update_weather(&clients, &ha_url, &pirate_weather_api_key) {
|
||||
error!("Weather update failed: {err:#}");
|
||||
}
|
||||
thread::sleep(Duration::from_secs(interval));
|
||||
}
|
||||
}
|
||||
|
||||
fn build_client(host: &str, bearer_token: Option<&str>) -> Result<Client> {
|
||||
let policy = retry::for_host(host.to_owned())
|
||||
.max_retries_per_request(RETRIES_PER_REQUEST)
|
||||
.classify_fn(|request| {
|
||||
let retryable = request.error().is_some()
|
||||
|| request.status().is_some_and(|status| {
|
||||
status == StatusCode::REQUEST_TIMEOUT
|
||||
|| status == StatusCode::TOO_MANY_REQUESTS
|
||||
|| status.is_server_error()
|
||||
});
|
||||
if retryable {
|
||||
request.retryable()
|
||||
} else {
|
||||
request.success()
|
||||
}
|
||||
});
|
||||
|
||||
let mut builder = ClientBuilder::new()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.retry(policy);
|
||||
|
||||
if let Some(token) = bearer_token {
|
||||
let mut authorization = HeaderValue::from_str(&format!("Bearer {token}"))
|
||||
.context("HA_TOKEN contains invalid header characters")?;
|
||||
authorization.set_sensitive(true);
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(AUTHORIZATION, authorization);
|
||||
builder = builder.default_headers(headers);
|
||||
}
|
||||
|
||||
builder
|
||||
.build()
|
||||
.with_context(|| format!("failed to create HTTP client for {host}"))
|
||||
}
|
||||
|
||||
fn update_weather(clients: &HttpClients, ha_url: &str, api_key: &str) -> Result<()> {
|
||||
let lat = get_ha_state(&clients.home_assistant, ha_url, LAT_ENTITY)?;
|
||||
let lon = get_ha_state(&clients.home_assistant, ha_url, LON_ENTITY)?;
|
||||
let masked_lat = mask_coordinate(lat);
|
||||
let masked_lon = mask_coordinate(lon);
|
||||
info!("Masked location: {masked_lat}, {masked_lon}");
|
||||
|
||||
let weather = fetch_weather(&clients.pirate_weather, api_key, masked_lat, masked_lon)?;
|
||||
info!(
|
||||
"Weather: {}°F, {}",
|
||||
weather
|
||||
.current
|
||||
.temperature
|
||||
.map_or_else(|| "unknown".to_owned(), |value| value.to_string()),
|
||||
condition(weather.current.icon.as_deref())
|
||||
);
|
||||
post_to_ha(&clients.home_assistant, ha_url, &weather)?;
|
||||
info!("Posted weather to Home Assistant");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mask_coordinate(value: f64) -> f64 {
|
||||
(value * MASK_FACTOR).round() / MASK_FACTOR
|
||||
}
|
||||
|
||||
fn get_ha_state(client: &Client, ha_url: &str, entity_id: &str) -> Result<f64> {
|
||||
let HaState { state } = client
|
||||
.get(format!("{ha_url}/api/states/{entity_id}"))
|
||||
.send()
|
||||
.with_context(|| format!("request for {entity_id} failed"))?
|
||||
.error_for_status()
|
||||
.with_context(|| format!("Home Assistant rejected {entity_id} request"))?
|
||||
.json()
|
||||
.context("Home Assistant returned invalid JSON")?;
|
||||
|
||||
if matches!(state.as_str(), "unavailable" | "unknown") {
|
||||
bail!("{entity_id} is {state}");
|
||||
}
|
||||
|
||||
state
|
||||
.parse::<f64>()
|
||||
.with_context(|| format!("{entity_id} state is not numeric: {state}"))
|
||||
}
|
||||
|
||||
fn fetch_weather(client: &Client, api_key: &str, lat: f64, lon: f64) -> Result<Weather> {
|
||||
let response = client
|
||||
.get(format!(
|
||||
"https://{PIRATE_WEATHER_HOST}/forecast/{api_key}/{lat},{lon}"
|
||||
))
|
||||
.query(&[("units", "us")])
|
||||
.send()
|
||||
.context("Pirate Weather request failed")?
|
||||
.error_for_status()
|
||||
.context("Pirate Weather rejected request")?;
|
||||
let data = response
|
||||
.json::<ApiResponse>()
|
||||
.context("Pirate Weather returned invalid JSON")?;
|
||||
Ok(parse_weather(data))
|
||||
}
|
||||
|
||||
fn parse_weather(data: ApiResponse) -> Weather {
|
||||
let daily = data
|
||||
.daily
|
||||
.data
|
||||
.into_iter()
|
||||
.take(8)
|
||||
.filter_map(|day| {
|
||||
timestamp(day.time).map(|datetime| DailyForecast {
|
||||
datetime,
|
||||
condition: condition(day.icon.as_deref()),
|
||||
temperature: day.temperature_high,
|
||||
templow: day.temperature_low,
|
||||
precipitation_probability: day.precip_probability,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let hourly = data
|
||||
.hourly
|
||||
.data
|
||||
.into_iter()
|
||||
.take(48)
|
||||
.filter_map(|hour| {
|
||||
timestamp(hour.time).map(|datetime| HourlyForecast {
|
||||
datetime,
|
||||
condition: condition(hour.icon.as_deref()),
|
||||
temperature: hour.temperature,
|
||||
precipitation_probability: hour.precip_probability,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Weather {
|
||||
current: data.currently,
|
||||
daily,
|
||||
hourly,
|
||||
}
|
||||
}
|
||||
|
||||
fn timestamp(value: Option<i64>) -> Option<DateTime<Utc>> {
|
||||
value
|
||||
.filter(|value| *value != 0)
|
||||
.and_then(DateTime::from_timestamp_secs)
|
||||
}
|
||||
|
||||
fn condition(icon: Option<&str>) -> &'static str {
|
||||
match icon.unwrap_or_default() {
|
||||
"clear-day" => "sunny",
|
||||
"clear-night" => "clear-night",
|
||||
"rain" => "rainy",
|
||||
"snow" => "snowy",
|
||||
"sleet" => "snowy-rainy",
|
||||
"wind" => "windy",
|
||||
"fog" => "fog",
|
||||
"partly-cloudy-day" | "partly-cloudy-night" => "partlycloudy",
|
||||
_ => "cloudy",
|
||||
}
|
||||
}
|
||||
|
||||
fn post_to_ha(client: &Client, ha_url: &str, weather: &Weather) -> Result<()> {
|
||||
for (entity_id, payload) in weather_updates(weather) {
|
||||
let response = client
|
||||
.post(format!("{ha_url}/api/states/{entity_id}"))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.with_context(|| format!("failed to post {entity_id}"))?;
|
||||
ensure_success(response.status(), &entity_id)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_success(status: StatusCode, entity_id: &str) -> Result<()> {
|
||||
if status.is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
bail!("Home Assistant rejected {entity_id} update with {status}")
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn weather_updates(weather: &Weather) -> Vec<(String, Value)> {
|
||||
let current = &weather.current;
|
||||
let mut updates = vec![
|
||||
sensor(
|
||||
"sensor.van_weather_condition",
|
||||
Some(json!(condition(current.icon.as_deref()))),
|
||||
json!({"friendly_name": "Van Weather Condition"}),
|
||||
),
|
||||
sensor(
|
||||
"sensor.van_weather_temperature",
|
||||
current.temperature.map(|value| json!(value)),
|
||||
json!({"unit_of_measurement": "°F", "device_class": "temperature"}),
|
||||
),
|
||||
sensor(
|
||||
"sensor.van_weather_apparent_temperature",
|
||||
current.apparent_temperature.map(|value| json!(value)),
|
||||
json!({"unit_of_measurement": "°F", "device_class": "temperature"}),
|
||||
),
|
||||
sensor(
|
||||
"sensor.van_weather_humidity",
|
||||
Some(json!(percent(current.humidity))),
|
||||
json!({"unit_of_measurement": "%", "device_class": "humidity"}),
|
||||
),
|
||||
sensor(
|
||||
"sensor.van_weather_pressure",
|
||||
current.pressure.map(|value| json!(value)),
|
||||
json!({"unit_of_measurement": "mbar", "device_class": "pressure"}),
|
||||
),
|
||||
sensor(
|
||||
"sensor.van_weather_wind_speed",
|
||||
current.wind_speed.map(|value| json!(value)),
|
||||
json!({"unit_of_measurement": "mph", "device_class": "wind_speed"}),
|
||||
),
|
||||
sensor(
|
||||
"sensor.van_weather_wind_bearing",
|
||||
current.wind_bearing.map(|value| json!(value)),
|
||||
json!({"unit_of_measurement": "°"}),
|
||||
),
|
||||
sensor(
|
||||
"sensor.van_weather_visibility",
|
||||
current.visibility.map(|value| json!(value)),
|
||||
json!({"unit_of_measurement": "mi"}),
|
||||
),
|
||||
sensor(
|
||||
"sensor.van_weather_uv_index",
|
||||
current.uv_index.map(|value| json!(value)),
|
||||
json!({"friendly_name": "Van Weather UV Index", "icon": "mdi:sun-wireless"}),
|
||||
),
|
||||
sensor(
|
||||
"sensor.van_weather_ozone",
|
||||
current.ozone.map(|value| json!(value)),
|
||||
json!({"unit_of_measurement": "DU", "icon": "mdi:earth"}),
|
||||
),
|
||||
sensor(
|
||||
"sensor.van_weather_nearest_storm_distance",
|
||||
current.nearest_storm_distance.map(|value| json!(value)),
|
||||
json!({"unit_of_measurement": "mi", "icon": "mdi:weather-lightning"}),
|
||||
),
|
||||
sensor(
|
||||
"sensor.van_weather_nearest_storm_bearing",
|
||||
current.nearest_storm_bearing.map(|value| json!(value)),
|
||||
json!({"unit_of_measurement": "°", "icon": "mdi:weather-lightning"}),
|
||||
),
|
||||
sensor(
|
||||
"sensor.van_weather_precip_probability",
|
||||
Some(json!(percent(current.precip_probability))),
|
||||
json!({"unit_of_measurement": "%", "icon": "mdi:weather-rainy"}),
|
||||
),
|
||||
sensor(
|
||||
"sensor.van_weather_cloud_cover",
|
||||
Some(json!(percent(current.cloud_cover))),
|
||||
json!({"unit_of_measurement": "%", "icon": "mdi:weather-cloudy"}),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let daily = weather
|
||||
.daily
|
||||
.iter()
|
||||
.map(|forecast| {
|
||||
json!({
|
||||
"datetime": forecast.datetime.to_rfc3339(),
|
||||
"condition": forecast.condition,
|
||||
"temperature": forecast.temperature,
|
||||
"templow": forecast.templow,
|
||||
"precipitation_probability": percent(forecast.precipitation_probability),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
updates.push((
|
||||
"sensor.van_weather_forecast_daily".to_owned(),
|
||||
json!({"state": daily.len(), "attributes": {"forecast": daily}}),
|
||||
));
|
||||
|
||||
let hourly = weather
|
||||
.hourly
|
||||
.iter()
|
||||
.map(|forecast| {
|
||||
json!({
|
||||
"datetime": forecast.datetime.to_rfc3339(),
|
||||
"condition": forecast.condition,
|
||||
"temperature": forecast.temperature,
|
||||
"precipitation_probability": percent(forecast.precipitation_probability),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
updates.push((
|
||||
"sensor.van_weather_forecast_hourly".to_owned(),
|
||||
json!({"state": hourly.len(), "attributes": {"forecast": hourly}}),
|
||||
));
|
||||
updates
|
||||
}
|
||||
|
||||
fn sensor(entity_id: &str, state: Option<Value>, attributes: Value) -> Option<(String, Value)> {
|
||||
state.map(|state| {
|
||||
let mut payload = serde_json::Map::new();
|
||||
payload.insert("state".to_owned(), state);
|
||||
payload.insert("attributes".to_owned(), attributes);
|
||||
(entity_id.to_owned(), Value::Object(payload))
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
fn percent(value: Option<f64>) -> i64 {
|
||||
// Preserve Python's int(probability * 100) behavior for Home Assistant.
|
||||
(value.unwrap_or_default() * 100.0) as i64
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn maps_conditions_for_home_assistant() {
|
||||
assert_eq!(condition(Some("clear-day")), "sunny");
|
||||
assert_eq!(condition(Some("sleet")), "snowy-rainy");
|
||||
assert_eq!(condition(Some("partly-cloudy-night")), "partlycloudy");
|
||||
assert_eq!(condition(Some("unexpected")), "cloudy");
|
||||
assert_eq!(condition(None), "cloudy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_api_response_and_builds_compatible_payloads() {
|
||||
let response: ApiResponse = serde_json::from_value(json!({
|
||||
"currently": {
|
||||
"temperature": 72.5,
|
||||
"humidity": 0.67,
|
||||
"icon": "clear-day",
|
||||
"precipProbability": 0.129,
|
||||
"cloudCover": 0.4,
|
||||
"summary": "Fine"
|
||||
},
|
||||
"daily": {"data": [{
|
||||
"time": 1_700_000_000,
|
||||
"icon": "rain",
|
||||
"temperatureHigh": 75.0,
|
||||
"temperatureLow": 52.0,
|
||||
"precipProbability": 0.8
|
||||
}]},
|
||||
"hourly": {"data": [{
|
||||
"time": 1_700_000_000,
|
||||
"icon": "fog",
|
||||
"temperature": 61.0,
|
||||
"precipProbability": 0.05
|
||||
}]}
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let weather = parse_weather(response);
|
||||
let updates = weather_updates(&weather);
|
||||
let find = |id: &str| updates.iter().find(|(entity, _)| entity == id).unwrap();
|
||||
|
||||
assert_eq!(find("sensor.van_weather_condition").1["state"], "sunny");
|
||||
assert_eq!(find("sensor.van_weather_humidity").1["state"], 67);
|
||||
assert_eq!(find("sensor.van_weather_precip_probability").1["state"], 12);
|
||||
assert_eq!(find("sensor.van_weather_forecast_daily").1["state"], 1);
|
||||
assert_eq!(
|
||||
find("sensor.van_weather_forecast_daily").1["attributes"]["forecast"][0]["condition"],
|
||||
"rainy"
|
||||
);
|
||||
assert_eq!(find("sensor.van_weather_forecast_hourly").1["state"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omits_missing_optional_current_sensors_but_keeps_percentage_sensors() {
|
||||
let weather = parse_weather(ApiResponse::default());
|
||||
let updates = weather_updates(&weather);
|
||||
|
||||
assert!(
|
||||
!updates
|
||||
.iter()
|
||||
.any(|(id, _)| id == "sensor.van_weather_temperature")
|
||||
);
|
||||
assert_eq!(
|
||||
updates
|
||||
.iter()
|
||||
.find(|(id, _)| id == "sensor.van_weather_humidity")
|
||||
.unwrap()
|
||||
.1["state"],
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_coordinates_to_about_eleven_kilometres() {
|
||||
assert!((mask_coordinate(37.7749) - 37.8).abs() < f64::EPSILON);
|
||||
assert!((mask_coordinate(-122.4194) - (-122.4)).abs() < f64::EPSILON);
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@
|
||||
"translategemma:27b"
|
||||
"translategemma:4b"
|
||||
];
|
||||
modelsDir = "/zfs/storage/models";
|
||||
models = "/zfs/storage/models";
|
||||
openFirewall = true;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
{
|
||||
pkgs,
|
||||
inputs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
van-weather = pkgs.callPackage ../../../rust/van_weather/package.nix { };
|
||||
in
|
||||
{
|
||||
systemd.services.van-weather = {
|
||||
description = "Van Weather Service";
|
||||
@@ -15,9 +13,13 @@ in
|
||||
requires = [ "home-assistant.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
environment = {
|
||||
PYTHONPATH = "${inputs.self}/";
|
||||
};
|
||||
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
ExecStart = "${van-weather}/bin/van-weather";
|
||||
ExecStart = "${pkgs.my_python}/bin/python -m python.van_weather.main";
|
||||
EnvironmentFile = "/etc/van_weather.env";
|
||||
Restart = "on-failure";
|
||||
RestartSec = "5s";
|
||||
@@ -27,6 +29,7 @@ in
|
||||
ProtectSystem = "strict";
|
||||
ProtectHome = "read-only";
|
||||
PrivateTmp = true;
|
||||
ReadOnlyPaths = [ "${inputs.self}" ];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ in
|
||||
"qwen3:14b"
|
||||
"qwen3.5:35b"
|
||||
];
|
||||
modelsDir = vars.ollama;
|
||||
models = vars.ollama;
|
||||
openFirewall = true;
|
||||
};
|
||||
systemd.services = {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
ANONYMIZED_TELEMETRY = "False";
|
||||
DO_NOT_TRACK = "True";
|
||||
SCARF_NO_ANALYTICS = "True";
|
||||
OLLAMA_API_BASE_URL = "https://ollama.com";
|
||||
OLLAMA_API_BASE_URL = "http://127.0.0.1:11434";
|
||||
WEBUI_AUTH = "False";
|
||||
};
|
||||
};
|
||||
|
||||
+10
-384
@@ -2,35 +2,15 @@
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from subprocess import CompletedProcess
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import call
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from python.zfs import (
|
||||
CommandResult,
|
||||
Dataset,
|
||||
Snapshot,
|
||||
Zpool,
|
||||
create_dataset,
|
||||
get_datasets,
|
||||
get_properties,
|
||||
list_dataset_names,
|
||||
run_zfs,
|
||||
run_zpool,
|
||||
set_property,
|
||||
)
|
||||
|
||||
# Not re-exported from python.zfs on purpose: the wrappers are the default.
|
||||
from python.zfs.command import run_command
|
||||
from python.zfs import Dataset, Snapshot, Zpool, get_datasets
|
||||
from python.zfs.dataset import _zfs_list
|
||||
from python.zfs.zpool import _zpool_list
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
COMMAND = "python.zfs.command"
|
||||
DATASET = "python.zfs.dataset"
|
||||
ZPOOL = "python.zfs.zpool"
|
||||
SAMPLE_SNAPSHOT_DATA = {
|
||||
@@ -227,20 +207,12 @@ def test_zfs_list_version_check(mocker: MockerFixture) -> None:
|
||||
|
||||
def test_get_datasets(mocker: MockerFixture) -> None:
|
||||
"""Test get_datasets."""
|
||||
mock_run = mocker.patch(
|
||||
f"{DATASET}.run_zfs",
|
||||
return_value=CommandResult(
|
||||
args=(),
|
||||
stdout="pool/dataset\npool/other\ninvalid",
|
||||
stderr="",
|
||||
return_code=0,
|
||||
),
|
||||
)
|
||||
mock_bash = mocker.patch(f"{DATASET}.bash_wrapper", return_value=("pool/dataset\npool/other\ninvalid", 0))
|
||||
mock_dataset = mocker.patch(f"{DATASET}.Dataset")
|
||||
|
||||
get_datasets()
|
||||
|
||||
mock_run.assert_called_once_with("list", "-Hp", "-t", "filesystem", "-o", "name")
|
||||
mock_bash.assert_called_once_with("zfs list -Hp -t filesystem -o name")
|
||||
|
||||
calls = [call("pool/dataset"), call("pool/other")]
|
||||
|
||||
@@ -315,16 +287,11 @@ def test_zpool_repr(mocker: MockerFixture) -> None:
|
||||
def test_zpool_list(mocker: MockerFixture) -> None:
|
||||
"""Test version validation in _zpool_list."""
|
||||
mocker.patch(
|
||||
f"{ZPOOL}.run_zpool",
|
||||
return_value=CommandResult(
|
||||
args=(),
|
||||
stdout=json.dumps({"output_version": {"vers_major": 0, "vers_minor": 1, "command": "zpool list"}}),
|
||||
stderr="",
|
||||
return_code=0,
|
||||
),
|
||||
f"{ZPOOL}.bash_wrapper",
|
||||
return_value=(json.dumps({"output_version": {"vers_major": 0, "vers_minor": 1, "command": "zpool list"}}), 0),
|
||||
)
|
||||
|
||||
result = _zpool_list("list", "invalid", "-pHj", "-o", "all")
|
||||
result = _zpool_list("zpool list invalid -pHj -o all")
|
||||
|
||||
assert result == {"output_version": {"command": "zpool list", "vers_major": 0, "vers_minor": 1}}
|
||||
|
||||
@@ -332,352 +299,11 @@ def test_zpool_list(mocker: MockerFixture) -> None:
|
||||
def test_zpool_list_version_check(mocker: MockerFixture) -> None:
|
||||
"""Test version validation in _zpool_list."""
|
||||
mocker.patch(
|
||||
f"{ZPOOL}.run_zpool",
|
||||
return_value=CommandResult(
|
||||
args=(),
|
||||
stdout=json.dumps({"output_version": {"vers_major": 1, "vers_minor": 0, "command": "zpool list"}}),
|
||||
stderr="",
|
||||
return_code=0,
|
||||
),
|
||||
f"{ZPOOL}.bash_wrapper",
|
||||
return_value=(json.dumps({"output_version": {"vers_major": 1, "vers_minor": 0, "command": "zpool list"}}), 0),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
_zpool_list("list", "invalid", "-pHj", "-o", "all")
|
||||
_zpool_list("zpool list invalid -pHj -o all")
|
||||
|
||||
assert "Datasets are not in the correct format" in str(excinfo.value)
|
||||
|
||||
|
||||
# -- run_zfs, the subprocess boundary -----------------------------------------
|
||||
|
||||
|
||||
def completed(returncode: int = 0, stdout: str = "", stderr: str = "") -> CompletedProcess:
|
||||
"""Build a CompletedProcess the way subprocess.run would return one."""
|
||||
return CompletedProcess(args=["zfs"], returncode=returncode, stdout=stdout, stderr=stderr)
|
||||
|
||||
|
||||
def test_run_zfs_passes_arguments_as_a_list(mocker: MockerFixture) -> None:
|
||||
"""Arguments must never be joined into a string and re-split."""
|
||||
mock_run = mocker.patch(f"{COMMAND}.subprocess.run", return_value=completed(stdout="ok\n"))
|
||||
|
||||
run_zfs("get", "-Hp", "all", "media/temp")
|
||||
|
||||
mock_run.assert_called_once_with(
|
||||
["zfs", "get", "-Hp", "all", "media/temp"],
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def test_run_zfs_keeps_values_containing_spaces_intact(mocker: MockerFixture) -> None:
|
||||
"""A property value with a space must reach zfs as one argument.
|
||||
|
||||
bash_wrapper split on whitespace, so this silently became several
|
||||
arguments and zfs was handed something it could not parse.
|
||||
"""
|
||||
mock_run = mocker.patch(f"{COMMAND}.subprocess.run", return_value=completed())
|
||||
|
||||
run_zfs("set", "mountpoint=/zfs/two words", "media/temp")
|
||||
|
||||
assert mock_run.call_args.args[0] == ["zfs", "set", "mountpoint=/zfs/two words", "media/temp"]
|
||||
|
||||
|
||||
def test_run_zfs_reports_success(mocker: MockerFixture) -> None:
|
||||
mocker.patch(f"{COMMAND}.subprocess.run", return_value=completed(stdout="output\n", stderr=""))
|
||||
|
||||
result = run_zfs("list")
|
||||
|
||||
assert result.ok
|
||||
assert result.stdout == "output\n"
|
||||
assert result.return_code == 0
|
||||
assert result.args == ("zfs", "list")
|
||||
|
||||
|
||||
def test_run_zfs_returns_failures_rather_than_raising(mocker: MockerFixture) -> None:
|
||||
"""Callers decide what a failure means, so run_zfs never raises."""
|
||||
mocker.patch(f"{COMMAND}.subprocess.run", return_value=completed(returncode=1, stderr="no such pool\n"))
|
||||
|
||||
result = run_zfs("list")
|
||||
|
||||
assert not result.ok
|
||||
assert result.stderr == "no such pool\n"
|
||||
assert result.message == "no such pool"
|
||||
|
||||
|
||||
def test_run_zfs_keeps_streams_separate(mocker: MockerFixture) -> None:
|
||||
"""A warning on stderr must not contaminate stdout.
|
||||
|
||||
bash_wrapper returned stderr in place of stdout whenever stderr was
|
||||
non-empty, even on success, which is how a warning could be parsed as a
|
||||
list of dataset names.
|
||||
"""
|
||||
mocker.patch(
|
||||
f"{COMMAND}.subprocess.run",
|
||||
return_value=completed(stdout="pool/one\n", stderr="warning: something\n"),
|
||||
)
|
||||
|
||||
result = run_zfs("list")
|
||||
|
||||
assert result.ok
|
||||
assert result.stdout == "pool/one\n"
|
||||
assert result.stderr == "warning: something\n"
|
||||
|
||||
|
||||
def test_command_result_message_falls_back_to_stdout() -> None:
|
||||
"""Some zfs errors land on stdout, so the message must not be empty."""
|
||||
result = CommandResult(args=("list",), stdout=" something went wrong \n", stderr="", return_code=1)
|
||||
|
||||
assert result.message == "something went wrong"
|
||||
|
||||
|
||||
# -- list_dataset_names -------------------------------------------------------
|
||||
|
||||
|
||||
def patch_run(mocker: MockerFixture, **kwargs) -> object:
|
||||
"""Patch run_zfs with a single canned result."""
|
||||
return mocker.patch(f"{DATASET}.run_zfs", return_value=CommandResult(args=(), **kwargs))
|
||||
|
||||
|
||||
def test_list_dataset_names_builds_the_right_command(mocker: MockerFixture) -> None:
|
||||
mock_run = patch_run(mocker, stdout="pool\n", stderr="", return_code=0)
|
||||
|
||||
list_dataset_names()
|
||||
|
||||
mock_run.assert_called_once_with("list", "-Hp", "-t", "filesystem", "-o", "name")
|
||||
|
||||
|
||||
def test_list_dataset_names_includes_pool_roots(mocker: MockerFixture) -> None:
|
||||
"""Unlike get_datasets, nothing is filtered out, so existence checks work."""
|
||||
patch_run(mocker, stdout="media\nmedia/temp\nmedia/secure/docker\n", stderr="", return_code=0)
|
||||
|
||||
assert list_dataset_names() == ["media", "media/temp", "media/secure/docker"]
|
||||
|
||||
|
||||
def test_list_dataset_names_drops_blank_lines(mocker: MockerFixture) -> None:
|
||||
patch_run(mocker, stdout="media\n\nmedia/temp\n\n", stderr="", return_code=0)
|
||||
|
||||
assert list_dataset_names() == ["media", "media/temp"]
|
||||
|
||||
|
||||
def test_list_dataset_names_raises_on_failure(mocker: MockerFixture) -> None:
|
||||
"""Never return a partial list: the reconciler would create everything."""
|
||||
patch_run(mocker, stdout="", stderr="cannot open 'media': no such pool\n", return_code=1)
|
||||
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
list_dataset_names()
|
||||
|
||||
assert "Failed to list ZFS datasets" in str(excinfo.value)
|
||||
assert "no such pool" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_list_dataset_names_never_parses_stderr_as_names(mocker: MockerFixture) -> None:
|
||||
"""The regression this guards: error text read as a dataset list."""
|
||||
patch_run(mocker, stdout="", stderr="permission denied\n", return_code=1)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
list_dataset_names()
|
||||
|
||||
|
||||
# -- get_properties -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_properties_builds_the_right_command(mocker: MockerFixture) -> None:
|
||||
mock_run = patch_run(mocker, stdout="", stderr="", return_code=0)
|
||||
|
||||
get_properties("media/temp")
|
||||
|
||||
mock_run.assert_called_once_with("get", "-Hp", "-o", "property,value,source", "all", "media/temp")
|
||||
|
||||
|
||||
def test_get_properties_parses_value_and_source(mocker: MockerFixture) -> None:
|
||||
patch_run(
|
||||
mocker,
|
||||
stdout="compression\tzstd-9\tlocal\natime\toff\tinherited from media\nexec\ton\tdefault\n",
|
||||
stderr="",
|
||||
return_code=0,
|
||||
)
|
||||
|
||||
assert get_properties("media/temp") == {
|
||||
"compression": ("zstd-9", "local"),
|
||||
"atime": ("off", "inherited from media"),
|
||||
"exec": ("on", "default"),
|
||||
}
|
||||
|
||||
|
||||
def test_get_properties_skips_blank_lines(mocker: MockerFixture) -> None:
|
||||
"""A stray blank line in the middle must not raise on unpacking."""
|
||||
patch_run(mocker, stdout="compression\tzstd\tlocal\n\natime\toff\tlocal\n", stderr="", return_code=0)
|
||||
|
||||
assert get_properties("media/temp") == {
|
||||
"compression": ("zstd", "local"),
|
||||
"atime": ("off", "local"),
|
||||
}
|
||||
|
||||
|
||||
def test_get_properties_raises_on_failure(mocker: MockerFixture) -> None:
|
||||
patch_run(mocker, stdout="", stderr="dataset does not exist\n", return_code=1)
|
||||
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
get_properties("media/gone")
|
||||
|
||||
assert "Failed to get properties for media/gone" in str(excinfo.value)
|
||||
|
||||
|
||||
# -- create_dataset -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_dataset_builds_sorted_option_flags(mocker: MockerFixture) -> None:
|
||||
mock_run = patch_run(mocker, stdout="", stderr="", return_code=0)
|
||||
|
||||
assert create_dataset("media/temp", {"sync": "disabled", "compression": "zstd-9"}) is None
|
||||
|
||||
mock_run.assert_called_once_with(
|
||||
"create",
|
||||
"-o",
|
||||
"compression=zstd-9",
|
||||
"-o",
|
||||
"sync=disabled",
|
||||
"media/temp",
|
||||
)
|
||||
|
||||
|
||||
def test_create_dataset_with_no_properties(mocker: MockerFixture) -> None:
|
||||
mock_run = patch_run(mocker, stdout="", stderr="", return_code=0)
|
||||
|
||||
create_dataset("media/temp", {})
|
||||
|
||||
mock_run.assert_called_once_with("create", "media/temp")
|
||||
|
||||
|
||||
def test_create_dataset_keeps_a_value_with_spaces_together(mocker: MockerFixture) -> None:
|
||||
mock_run = patch_run(mocker, stdout="", stderr="", return_code=0)
|
||||
|
||||
create_dataset("media/temp", {"mountpoint": "/zfs/two words"})
|
||||
|
||||
assert mock_run.call_args.args == ("create", "-o", "mountpoint=/zfs/two words", "media/temp")
|
||||
|
||||
|
||||
def test_create_dataset_returns_the_error_on_failure(mocker: MockerFixture) -> None:
|
||||
patch_run(mocker, stdout="", stderr="cannot create 'media/temp': out of space\n", return_code=1)
|
||||
|
||||
error = create_dataset("media/temp", {})
|
||||
|
||||
assert error is not None
|
||||
assert "Failed to create media/temp" in error
|
||||
assert "out of space" in error
|
||||
|
||||
|
||||
# -- set_property -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_set_property_builds_the_right_command(mocker: MockerFixture) -> None:
|
||||
mock_run = patch_run(mocker, stdout="", stderr="", return_code=0)
|
||||
|
||||
assert set_property("media/temp", "compression", "zstd-9") is None
|
||||
|
||||
mock_run.assert_called_once_with("set", "compression=zstd-9", "media/temp")
|
||||
|
||||
|
||||
def test_set_property_keeps_a_value_with_spaces_together(mocker: MockerFixture) -> None:
|
||||
mock_run = patch_run(mocker, stdout="", stderr="", return_code=0)
|
||||
|
||||
set_property("media/temp", "mountpoint", "/zfs/two words")
|
||||
|
||||
assert mock_run.call_args.args == ("set", "mountpoint=/zfs/two words", "media/temp")
|
||||
|
||||
|
||||
def test_set_property_returns_the_error_on_failure(mocker: MockerFixture) -> None:
|
||||
patch_run(mocker, stdout="", stderr="permission denied\n", return_code=1)
|
||||
|
||||
error = set_property("media/temp", "compression", "zstd-9")
|
||||
|
||||
assert error is not None
|
||||
assert "Failed to set compression=zstd-9 on media/temp" in error
|
||||
assert "permission denied" in error
|
||||
|
||||
|
||||
# -- run_zpool, the same wrapper the zfs side uses ----------------------------
|
||||
|
||||
|
||||
def test_run_zpool_prefixes_the_binary(mocker: MockerFixture) -> None:
|
||||
mock_run = mocker.patch(f"{COMMAND}.subprocess.run", return_value=completed(stdout="{}"))
|
||||
|
||||
run_zpool("list", "media", "-pHj", "-o", "all")
|
||||
|
||||
mock_run.assert_called_once_with(
|
||||
["zpool", "list", "media", "-pHj", "-o", "all"],
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def test_run_zpool_reports_failures(mocker: MockerFixture) -> None:
|
||||
mocker.patch(f"{COMMAND}.subprocess.run", return_value=completed(returncode=1, stderr="no such pool\n"))
|
||||
|
||||
result = run_zpool("list", "gone")
|
||||
|
||||
assert not result.ok
|
||||
assert result.message == "no such pool"
|
||||
assert result.args == ("zpool", "list", "gone")
|
||||
|
||||
|
||||
def test_run_zpool_keeps_streams_separate(mocker: MockerFixture) -> None:
|
||||
"""The same contamination bug the zfs side had must not exist here."""
|
||||
mocker.patch(
|
||||
f"{COMMAND}.subprocess.run",
|
||||
return_value=completed(stdout="{}", stderr="warning: pool is degraded\n"),
|
||||
)
|
||||
|
||||
result = run_zpool("list")
|
||||
|
||||
assert result.ok
|
||||
assert result.stdout == "{}"
|
||||
|
||||
|
||||
def test_run_zpool_keeps_values_containing_spaces_intact(mocker: MockerFixture) -> None:
|
||||
mock_run = mocker.patch(f"{COMMAND}.subprocess.run", return_value=completed())
|
||||
|
||||
run_zpool("set", "comment=two words", "media")
|
||||
|
||||
assert mock_run.call_args.args[0] == ["zpool", "set", "comment=two words", "media"]
|
||||
|
||||
|
||||
def test_run_command_runs_what_it_is_given(mocker: MockerFixture) -> None:
|
||||
"""zfs and zpool are the same function with a different first argument."""
|
||||
mock_run = mocker.patch(f"{COMMAND}.subprocess.run", return_value=completed())
|
||||
|
||||
run_command("zpool", "status")
|
||||
|
||||
assert mock_run.call_args.args[0] == ["zpool", "status"]
|
||||
|
||||
|
||||
def test_zpool_list_raises_when_the_command_fails(mocker: MockerFixture) -> None:
|
||||
"""A failed zpool must never have its error text decoded as json."""
|
||||
mocker.patch(
|
||||
f"{ZPOOL}.run_zpool",
|
||||
return_value=CommandResult(args=(), stdout="", stderr="no such pool: media\n", return_code=1),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
_zpool_list("list", "media", "-pHj", "-o", "all")
|
||||
|
||||
assert "Failed to run zpool" in str(excinfo.value)
|
||||
assert "no such pool" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_zpool_builds_the_right_command(mocker: MockerFixture) -> None:
|
||||
"""Zpool passes argv through rather than a formatted string."""
|
||||
mock_run = mocker.patch(
|
||||
f"{ZPOOL}.run_zpool",
|
||||
return_value=CommandResult(
|
||||
args=(),
|
||||
stdout=json.dumps(SAMPLE_ZPOOL_DATA),
|
||||
stderr="",
|
||||
return_code=0,
|
||||
),
|
||||
)
|
||||
|
||||
Zpool("testpool")
|
||||
|
||||
mock_run.assert_called_once_with("list", "testpool", "-pHj", "-o", "all")
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
nmap
|
||||
wget
|
||||
# python
|
||||
poetry
|
||||
ruff
|
||||
uv
|
||||
# nodejs
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
nmap
|
||||
wget
|
||||
# python
|
||||
poetry
|
||||
ruff
|
||||
uv
|
||||
# nodejs
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
home.sessionPath = [
|
||||
"/home/richie/app_images/"
|
||||
];
|
||||
}
|
||||
@@ -7,7 +7,6 @@
|
||||
./firefox
|
||||
./kitty.nix
|
||||
./llm_tools.nix
|
||||
./t3_code
|
||||
./vscode
|
||||
];
|
||||
|
||||
@@ -27,8 +26,5 @@
|
||||
gparted
|
||||
jetbrains.datagrip
|
||||
proxychains
|
||||
# hardware tools
|
||||
kicad-unstable
|
||||
openscad
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
t3-code = pkgs.writeShellApplication {
|
||||
name = "t3-code";
|
||||
runtimeInputs = with pkgs; [
|
||||
coreutils
|
||||
kdePackages.kdialog
|
||||
];
|
||||
text = builtins.readFile ./launch.sh;
|
||||
};
|
||||
in
|
||||
{
|
||||
home = {
|
||||
# AppImages are runnable from a shell as well
|
||||
sessionPath = [ "${config.home.homeDirectory}/app_images" ];
|
||||
packages = [ t3-code ];
|
||||
};
|
||||
|
||||
# KDE builds its menu from desktop entries, not from PATH
|
||||
xdg.desktopEntries.t3-code = {
|
||||
name = "T3 Code";
|
||||
genericName = "Code Editor";
|
||||
comment = "Newest T3 Code AppImage in ~/app_images";
|
||||
exec = "${lib.getExe t3-code} %U";
|
||||
icon = "${./icon.png}";
|
||||
terminal = false;
|
||||
categories = [ "Development" ];
|
||||
startupNotify = true;
|
||||
settings.StartupWMClass = "t3code";
|
||||
};
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 161 KiB |
@@ -1,25 +0,0 @@
|
||||
# Launch the newest T3 Code AppImage in the app image directory.
|
||||
#
|
||||
# The nightly builds carry the version in the file name, so the file to run is
|
||||
# resolved at launch time rather than baked into the desktop entry.
|
||||
# No shebang: this is wrapped by writeShellApplication, which supplies one.
|
||||
|
||||
dir="${T3_CODE_DIR:-$HOME/app_images}"
|
||||
|
||||
shopt -s nullglob
|
||||
images=("$dir"/T3-Code-*.AppImage)
|
||||
|
||||
if [ "${#images[@]}" -eq 0 ]; then
|
||||
msg="No T3 Code AppImage found in $dir"
|
||||
echo "$msg" >&2
|
||||
# launched from KDE there is no terminal to read, so say it on screen too
|
||||
kdialog --error "$msg" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
img="$(printf '%s\n' "${images[@]}" | sort -V | tail -n1)"
|
||||
[ -x "$img" ] || chmod +x "$img"
|
||||
|
||||
# --no-sandbox matches the AppImage's own desktop entry; binfmt hands the
|
||||
# AppImage off to appimage-run.
|
||||
exec "$img" --no-sandbox "$@"
|
||||
@@ -39,24 +39,18 @@
|
||||
nmap
|
||||
wget
|
||||
# python
|
||||
poetry
|
||||
ruff
|
||||
uv
|
||||
# nodejs
|
||||
nodejs
|
||||
# Rust packages
|
||||
bacon
|
||||
cargo
|
||||
cargo-audit
|
||||
cargo-generate
|
||||
cargo-machete
|
||||
cargo-update
|
||||
cargo-watch
|
||||
clippy
|
||||
rust-analyzer
|
||||
rustc
|
||||
rustfmt
|
||||
trunk
|
||||
wasm-pack
|
||||
cargo-watch
|
||||
cargo-generate
|
||||
cargo-audit
|
||||
cargo-update
|
||||
# cpp
|
||||
clang-tools
|
||||
clang_20
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
imports = [
|
||||
../home/app_image_path.nix
|
||||
../home/global.nix
|
||||
../home/gui
|
||||
];
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
nmap
|
||||
wget
|
||||
# python
|
||||
poetry
|
||||
ruff
|
||||
uv
|
||||
# nodejs
|
||||
|
||||
Reference in New Issue
Block a user