Rewrite hydrophonitor as a NixOS RPi SD image (#19)

- Initially tested on Raspberry Pi 4B & Raspberry Pi 3B
- Create user + password, enable SSH, connect to local network
- Implemented modules: audio-recorder, gps-recorder, real-time-clock,
shutdown-button
- Deployment option with deploy-rs

Signed-off-by: Satu Koskinen <satu.a.koskinen@gmail.com>
This commit is contained in:
Satu Koskinen 2023-09-12 12:40:22 +02:00 committed by GitHub
parent 56d6d5ecbd
commit b5e34360a7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 888 additions and 2796 deletions

9
.gitignore vendored
View File

@ -1,2 +1,7 @@
target **/target
*.wav **/*.wav
package
result
.wifi-name
.wifi-password
*.pem

View File

@ -1,8 +0,0 @@
install:
cd scripts && setup-raspberry-pi.sh
build-audio:
cd audio-logger && cargo build --release
start-all:
cd scripts && start-all.sh

109
README.md
View File

@ -1,35 +1,106 @@
# Hydrophonitor # Hydrophonitor
A software package to record audio and related metadata from a configuration A NixOS flake based operating system for Raspberry Pi 3 (tested on v1.2) and Raspberry Pi 4 (tested on 4B), configured for recording audio through hydrophones via a USB sound card, logging GPS data through a USB GPS dongle, and recording depth through a pressure sensor and an analog-to-digital converter (depth recording not implemented yet).
of hydrophones.
For setup instructions, see `docs/setup.md`.
## Overview ## Overview
Module | Description Module | Description
-----------------|- -----------------|-
audio-logger | Receive an audio signal from the DAC and write it on disk in `.wav` format. audio-recorder | Receive an audio signal from the DAC and write it on disk in `.wav` format.
gps-logger | Record position and time of the device in `.csv` format. gps-recorder | Record position and time of the device in `.json` format.
depth-logger | Record depth of the device and save it in `.csv` format. *depth-recorder | Record depth of the device and save it in `.csv` format.
*lcd-display | Provide information on the device's LCD screen real-time-clock | Configure and use an RTC to update system time from hardware clock when not connected to the internet.
shutdown-button | Gracefully shut down the system using a button attached to the GPIO pin.
*device-controls | Provide device control using physical buttons. *device-controls | Provide device control using physical buttons.
*) todo, not implemented yet *) todo, not implemented yet
## Data Formats ### Data Formats
Type | Output file format | Output file name | Output structure | Content Type | Output file format | Output file name | Output structure
------------|--------------------|--------------------------------------|------------------|- ------------|--------------------|--------------------------------------|------------------
Audio Data | .wav | <start-time-of-recording>_audio.wav | Each recorded chunk will be written to its own file in `audio` folder | Wav audio data, configuration defined in XXX Audio Data | .wav | `<start-time-of-recording>.wav` | Each recorded batch will be written to its own file in `/output/audio` folder
GPS Data | .csv | <start-time-of-recording>_gps.wav | All data written to a single file | Csv data with following fields: GPS time UTC, latitude, longitude, speed, satellites in view GPS Data | .json | `<start-time-of-recording>.json` | All data written to a single file in `/output/gps`
Depth data | .csv | <start-time-of-recording>_depth.wav | All data written to a single file | Csv data with following fields: date and time, voltage of depth sensor (V), depth (m)
Log data | .txt | <start-time-of-recording>_log.txt | All data written to a single file | Text file where each entry contains the following: date and time, process that writes the entry, logged information
## Output Locations ### Notes on Modules and Configuration Options
The location for the output directories is defined by a configurable value OUTPUT_PATH in `hydrophonitor-config.txt`. If directories along this path do not exist, they will be created. If an error occurs or the location is not writable, output will be written to the default location (DEFAULT_OUTPUT_PATH in hydrophonitor-config.txt) instead. - `audio-recorder` module
- `audio-recorder` systemd service connects to a USB soundcard and starts recording by default in batches to a specified output directory
- configuration options and default values:
- `output-folder` (/output/audio)
- `sample-rate` (192000)
- `sample-format` (S32_LE)
- `channels` (4)
- `max-file-time-secs`(300)
- `gps-recorder` module: fetched from hydrophonitor-gps repository
- enables gpsd
- `gps-recorder` systemd service starts `gps-recorder` program that listens for and logs GPS data to a json file in a specified directory
- configuration options and default values:
- `output-folder` (/output/gps)
- `interval-secs` (10)
- `real-time-clock` module
- `i2c-rtc-start` systemd service configures I2C bus to connect to DS3231 RTC attached to GPIO pins of the RPi and updates system time to hardware clock time
- configuration options and default values:
- `i2c-bus` (1)
- `shutdown-button` module (currently only for RPi 4)
- `shutdown-button` service runs `shutdown-button` program that listens for a button press (by default from GPIO pin 21) and once button press is detected, runs a graceful shutdown
- configuration options yet to be implemented in the program:
- `gpio-pin` (21)
- `shutdown-press-secs` (1)
SSD card mounting is not yet configured in the setup.
A recording session starts when the Raspberry Pi is turned on or booted, and ends on shutdown. Each session will have its output written in its own directory that will be named <start-time-of-recording>_recordings. ## Other Configurations
- user `kaskelotti` is created with sudo privileges
- ssh enabled with password authentication
- `i2c-dev`, `i2c_bcm2708`, and `rtc_1307` kernel modules enabled for i2c-rtc, `i2c-tools` package installed
- `deploy-rs` used for deployments (after initial bootstrapping and connecting the pi over wifi or ethernet)
### Enabling WiFi
Add the following to targets/<raspberry-pi-model>/default.nix where "SSID name" is replaced with the network name and "SSID password" is the network password:
```nix
networking = {
hostName = "kaskelotti";
wireless = {
enable = true;
networks = {
"SSID name" = {
psk = "SSID password";
};
};
interfaces = ["wlan0"];
};
};
```
## Building the Image on Mac M1
Image has been built with UTM (virtualization for silicon Macs, now running on 2020 M1 Air) on Ubuntu 22.04 VM (with nix installed) with the following command:
```
nix build .#systems.raspberry-pi-4.config.system.build.sdImage --extra-experimental-features "nix-command flakes"
```
The result image has to be copied from the VM nix store path to the directory that was shared between the client and the host.
```
cp -rL result/sd-image/nixos-sd-image-23.11.20230908.db9208a-aarch64-linux.img .
```
Initially, image is flashed to the SD card with the following command (on the host)
```
diskutil list # check SD card, here /dev/disk4
diskutil unmountdisk /dev/disk4
sudo dd if=../UTM/hydrophonitor/nixos-sd-image-23.11.20230702.0fbe93c-aarch64-linux.img of=/dev/disk4 status=progress
diskutil eject /dev/disk4
```
After bootstrapping and connecting the Pi to the local network, deploy-rs can be used for deployments from UTM (update correct IP address to the deploy configuration):
```
nix run github:serokell/deploy-rs .#raspberry-pi-4
```
## Notes
- On Raspberry Pi 4, we use the [Argon One m.2](https://argon40.com/products/argon-one-m-2-case-for-raspberry-pi-4) case with [Intenso M.2 TOP SATA III 1TB SSD](https://www.intenso.de/en/products/solid-state-drives/M.2-SSD-Top) and boot from SSD. We lacked a USB extension cable that would have allowed us to flash the SD card image directly to the SSD from the computer, so we ended up booting the Pi from a USB stick, copying the SD card image to the system on the USB stick, and from there flashing the image with `dd` to the SSD (which showed up as /dev/sda). After removing the USB stick, the Raspberry Pi successfully booted from the SSD. The setup also works using an ordinary SD card without an SSD. It would also be possible to run the operating system from the SD card, add an additional SSD, partition and format it and configure the outputs to be written to the SSD.
- On Raspberry Pi 3, we only tested with an SD card without the SSD, and witnessed some buffer overruns with `arecord` with the default settings, i.e. some audio output is lost. Also, running the `shutdown-button` program on Raspberry Pi 3 failed with permission denied on /dev/mem, even though the service is run as root.

View File

@ -1,3 +0,0 @@
target
*.wav
recordings

1430
audio-logger/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,20 +0,0 @@
[package]
name = "audio"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { url = "https://github.com/clap-rs/clap", features = ["derive"] }
cpal= { version = "0.13.5", features = ["jack"] }
anyhow = "1.0.61"
hound = "3.4.0"
chrono = "0.4.22"
ctrlc = "3.2.3"
rodio = "0.16.0"
[target.'cfg(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd"))'.dependencies]
alsa = "0.6"
nix = "0.23"
libc = "0.2.65"
parking_lot = "0.12"
jack = { version = "0.9", optional = true }

View File

@ -1,16 +0,0 @@
all:
cargo build --release
test: all
mkdir -p recordings
bash run_test.sh
clean:
cargo clean
fclean: clean
rm -rf recordings
re: fclean all
.PHONY: all clean fclean re test

View File

@ -1,29 +0,0 @@
# Audio Logger
CLI-tool for recording audio in a Linux environment.
## Usage
```bash
USAGE:
audio-logger [OPTIONS] --name <NAME> <HOST>
ARGS:
<HOST> Host API to use [possible values: alsa, jack]
OPTIONS:
-b, --batch-recording <SECONDS> (optional) Will record in [SECONDS] batches
--buffer-size <FRAMES> Buffer size in frames
--channels <CHANNELS> Channels to record
-h, --help Print help information
-n, --name <NAME> Filename will be `[NAME]-yyyy-mm-dd-H:M:S.wav`
-o, --output <PATH> Path to save the file(s)
--print-configs Output the available devices and their configurations
--sample-rate <SAMPLE_RATE> Sample rate in Hz (default = 44,000Hz)
```
## Testing
Use the Makefile commands to build the project and run a simple test.

View File

@ -1,10 +0,0 @@
#!/bin/bash
target/release/audio rec \
--name test \
--output recordings/ \
--batch-recording 3 \
--sample-rate 44100 \
--channels 2 \
--buffer-size 1024 \
alsa \

View File

@ -1,100 +0,0 @@
use clap::{Parser, ValueEnum, Subcommand};
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
pub enum Hosts {
Alsa,
Jack,
CoreAudio,
Asio,
}
#[derive(Subcommand)]
#[clap(about = "A tool to record audio on Linux using the command line.")]
pub enum Commands {
/// Record audio either continously or in batches (in case a possible
/// interruption is an issue)
Rec(Rec),
/// Play audio from a .wav file
Play(Play),
/// Merge audio resulting from a batch recording into a single file
Merge(Merge),
/// Get reports of system's audio resources
Info(Info),
}
#[derive(Parser, Debug)]
#[clap(about = "Merge audio resulting from a batch recording into a single
file.
")]
pub struct Merge {
/// The directory to look for files to merge.
#[clap(short, long, default_value = "./", required = true)]
pub input: std::path::PathBuf,
/// The directory to save the merged file to.
#[clap(short, long, default_value = "./")]
pub output: std::path::PathBuf,
}
#[derive(Parser, Debug)]
#[clap(about = "Record audio either continously or in batches (in case a
possible interruption is an issue).
")]
pub struct Rec {
/// Filename will be `[NAME]-yyyy-mm-dd-H:M:S.wav`
#[clap(required = true, short, long)]
pub name: String,
/// Path to save the file(s)
#[clap(long, short, value_parser, value_name = "PATH", value_hint = clap::ValueHint::DirPath)]
pub output: Option<std::path::PathBuf>,
/// (optional) Will record in [SECONDS] batches
#[clap(short, long, value_name = "SECONDS")]
pub batch_recording: Option<u64>,
/// (optional) Will record for a total of [SECONDS]
#[clap(short, long, value_name = "MAX_SECONDS")]
pub max_seconds: Option<u64>,
/// Host API to use
#[clap(value_enum)]
pub host: Hosts,
/// Sample rate in Hz (default = 44,000Hz)
#[clap(long)]
pub sample_rate: Option<u32>,
/// Channels to record
#[clap(long, value_name = "CHANNELS")]
pub channels: Option<u16>,
/// Buffer size in frames
#[clap(long, value_name = "FRAMES")]
pub buffer_size: Option<u32>,
}
#[derive(Parser, Debug)]
#[clap(about = "Play audio from a .wav file.
")]
pub struct Play {
/// Path to the file to play
#[clap(value_parser, value_name = "PATH", value_hint = clap::ValueHint::FilePath)]
pub input: std::path::PathBuf,
}
#[derive(Parser, Debug)]
#[clap(about = "Get reports of system's audio resources.")]
pub struct Info {
/// Output the available devices and their configurations
#[clap(long)]
pub print_configs: bool,
}
#[derive(Parser)]
#[clap(author, version, about, long_about = None)]
#[clap(propagate_version = true)]
pub struct Cli {
#[clap(subcommand)]
pub command: Commands,
}

View File

@ -1,7 +0,0 @@
pub const DEFAULT_SAMPLE_RATE: u32 = 44100;
pub const DEFAULT_CHANNEL_COUNT: u16 = 1;
pub const DEFAULT_BUFFER_SIZE: u32 = 1024;
pub const ALLOWED_SAMPLE_RATES: [u32; 6] = [44100, 48000, 88200, 96000, 176400, 192000];
pub const MAX_CHANNEL_COUNT: u16 = 2;
pub const MIN_BUFFER_SIZE: usize = 64;
pub const MAX_BUFFER_SIZE: usize = 8192;

View File

@ -1,113 +0,0 @@
use super::*;
use cpal::{
StreamConfig,
SupportedStreamConfig,
Device,
HostId,
Host,
SampleRate,
BufferSize,
traits::{DeviceTrait, HostTrait},
};
use hound::WavSpec;
use std::path::PathBuf;
use chrono::*;
use anyhow::{Error, Result, anyhow};
/// # Get Host
///
/// Returns the host with the given id if it's available.
pub fn get_host(host: HostId) -> Result<Host, Error> {
Ok(cpal::host_from_id(cpal::available_hosts()
.into_iter()
.find(|id| *id == host)
.ok_or(anyhow!("Requested host device not found"))?
)?)
}
/// # Get Device
///
/// Returns the default input device for the host if it's available.
pub fn get_device(host: Host) -> Result<Device, Error> {
Ok(host.default_input_device()
.ok_or(anyhow!("No input device available. Try running `jackd -R -d alsa -d hw:0`",
))?)
}
/// # Get Default Config
///
/// Get the default config for the given device.
pub fn get_default_config(device: &Device) -> Result<SupportedStreamConfig, Error> {
Ok(device.default_input_config()?)
}
/// # Get User Config
///
/// Overrides certain fields of the default stream config with the user's config.
///
/// sample_rate: The user's sample rate if it is supported by the device, otherwise the default sample rate.
/// channels: The user's number of channels if it is supported by the device, otherwise the default number of channels.
/// buffer_size: The user's buffer size if it is supported by the device, otherwise the default buffer size.
pub fn get_user_config(sample_rate: u32, channels: u16, buffer_size: u32) -> Result<StreamConfig, Error> {
if !ALLOWED_SAMPLE_RATES.contains(&sample_rate) {
return Err(anyhow!(
"Sample rate {} is not supported. Allowed sample rates: {:?}",
sample_rate,
ALLOWED_SAMPLE_RATES
));
}
if !(channels >= 1 && channels <= MAX_CHANNEL_COUNT) {
return Err(anyhow!(
"Channel count {} is not supported. Allowed channel counts: 1-{}",
channels,
MAX_CHANNEL_COUNT
));
}
if !(buffer_size >= MIN_BUFFER_SIZE as u32 && buffer_size <= MAX_BUFFER_SIZE as u32) {
return Err(anyhow!(
"Buffer size {} is not supported. Allowed buffer sizes: {}-{}",
buffer_size,
MIN_BUFFER_SIZE,
MAX_BUFFER_SIZE
));
}
Ok(StreamConfig {
channels,
sample_rate: SampleRate(sample_rate),
buffer_size: BufferSize::Fixed(buffer_size),
})
}
/// # Get WAV Spec
///
/// Get the WAV spec for the given stream config.
pub fn get_wav_spec(default_config: &SupportedStreamConfig, user_config: &StreamConfig) -> Result<WavSpec, Error> {
Ok(WavSpec {
channels: user_config.channels,
sample_rate: user_config.sample_rate.0,
bits_per_sample: (default_config.sample_format().sample_size() * 8) as u16,
sample_format: match default_config.sample_format() {
cpal::SampleFormat::U16 => hound::SampleFormat::Int,
cpal::SampleFormat::I16 => hound::SampleFormat::Int,
cpal::SampleFormat::F32 => hound::SampleFormat::Float,
},
})
}
pub fn get_date_time_string() -> String {
let now: DateTime<Local> = Local::now();
format!(
"{:02}-{:02}-{:02}_{:02}:{:02}:{:02}",
now.year(), now.month(), now.day(),
now.hour(), now.minute(), now.second(),
)
}
/// # Get Filename
///
/// Get the filename for the current recording according to the given format,
/// the current date and time, and the name prefix.
pub fn get_filename(name: &str, path: &PathBuf) -> String {
let mut filename = path.clone();
filename.push(format!("{}_{}.wav", get_date_time_string(), name));
filename.to_str().unwrap().to_string()
}

View File

@ -1,70 +0,0 @@
use super::*;
use std::sync::{
Arc,
Mutex,
Condvar,
atomic::{AtomicBool, Ordering}
};
type StreamInterrupt = Arc<(Mutex<bool>, Condvar)>;
type BatchInterrupt= Arc<AtomicBool>;
/// # Interrupts Handling
///
/// The `Recorder` struct has two interrupt mechanisms:
///
/// 1. `stream_interrupt` is used to interrupt the stream when the user presses `ctrl+c`.
/// 2. `batch_interrupt` is used to interrupt the batch recording when the user presses `ctrl+c`.
#[derive(Clone)]
pub struct InterruptHandles {
batch_interrupt: BatchInterrupt,
stream_interrupt: StreamInterrupt,
max_seconds: Option<u64>,
}
impl InterruptHandles {
pub fn new(max_seconds: Option<u64>) -> Result<Self, anyhow::Error> {
let stream_interrupt = Arc::new((Mutex::new(false), Condvar::new()));
let stream_interrupt_cloned = stream_interrupt.clone();
let batch_interrupt = Arc::new(AtomicBool::new(false));
let batch_interrupt_cloned = batch_interrupt.clone();
ctrlc::set_handler(move || {
// Set batch interrupt to true
batch_interrupt_cloned.store(true, Ordering::SeqCst);
// Release the stream
let &(ref lock, ref cvar) = &*stream_interrupt_cloned;
let mut started = lock.lock().unwrap();
*started = true;
cvar.notify_one();
})?;
Ok(Self {
batch_interrupt,
stream_interrupt,
max_seconds,
})
}
pub fn stream_wait(&self) {
let &(ref lock, ref cvar) = &*self.stream_interrupt;
let mut started = lock.lock().unwrap();
let now = std::time::Instant::now();
while !*started {
match self.max_seconds {
Some(secs) => {
if now.elapsed().as_secs() >= secs {
break;
}
}
None => (),
}
started = cvar.wait(started).unwrap();
}
}
pub fn batch_is_running(&self) -> bool {
!self.batch_interrupt.load(Ordering::SeqCst)
}
}

View File

@ -1,43 +0,0 @@
//! # Main
mod cli;
mod recorder;
mod print_configs;
mod getters;
mod input_handling;
mod merge;
mod constants;
mod play;
use print_configs::*;
use cli::*;
use merge::*;
use recorder::*;
use constants::*;
use getters::*;
use input_handling::*;
use play::*;
use anyhow::{Error, Result, anyhow};
use clap::Parser;
fn main() -> Result<(), Error> {
let cli = Cli::parse();
match &cli.command {
Commands::Rec(args) => {
record(&args)?;
},
Commands::Play(args) => {
play(&args.input)?;
},
Commands::Info(args) => {
if args.print_configs {
print_configs()?;
}
},
Commands::Merge(args) => {
merge_wavs(&args.input, &args.output)?;
}
}
Ok(())
}

View File

@ -1,43 +0,0 @@
use hound::{WavReader, WavWriter};
use anyhow::{Result, Error};
pub fn merge_wavs(input: &std::path::PathBuf, output: &std::path::PathBuf) -> Result<(), Error> {
// Read files from input directory
let mut files = std::fs::read_dir(input)?
.filter_map(|entry| entry.ok())
.filter(|entry| entry.file_type().ok().map(|t| t.is_file()).unwrap_or(false))
.filter(|entry| entry.path().extension().unwrap_or_default() == "wav")
.collect::<Vec<_>>();
// Sort files by name
files.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
// Use the name of the first file as the name of the output file
let output_name = files.first().unwrap().file_name();
let output_name = output_name.to_str().unwrap();
// Get wav spec from file
let spec = WavReader::open(files.first().unwrap().path())?.spec();
let mut writer = WavWriter::create(output.join(output_name), spec)?;
match spec.sample_format {
hound::SampleFormat::Float => {
for file in files {
let mut reader = WavReader::open(file.path())?;
for sample in reader.samples::<f32>() {
writer.write_sample(sample?)?;
}
}
},
hound::SampleFormat::Int => {
for file in files {
let mut reader = WavReader::open(file.path())?;
for sample in reader.samples::<i32>() {
writer.write_sample(sample?)?;
}
}
},
}
writer.finalize()?;
Ok(())
}

View File

@ -1,14 +0,0 @@
//! Play audio from a .wav file.
use anyhow::{Error, Result};
use rodio::{Decoder, OutputStream, Sink};
pub fn play(path: &std::path::PathBuf) -> Result<(), Error> {
let file = std::fs::File::open(path)?;
let source = Decoder::new(file).unwrap();
let (_stream, stream_handle) = OutputStream::try_default()?;
let sink = Sink::try_new(&stream_handle)?;
sink.append(source);
sink.sleep_until_end();
Ok(())
}

View File

@ -1,85 +0,0 @@
use cpal::traits::{DeviceTrait, HostTrait};
// pub fn print_available_hosts() -> Result<(), anyhow::Error> {
// let hosts = cpal::available_hosts();
// println!("Available hosts:");
// for host in hosts {
// println!(" {}", host.name());
// }
// Ok(())
// }
// pub fn print_supported_hosts() {
// println!("Supported hosts:");
// println!("{:?}", cpal::ALL_HOSTS);
// }
pub fn print_configs() -> Result<(), anyhow::Error> {
println!("Supported hosts:\n {:?}", cpal::ALL_HOSTS);
let available_hosts = cpal::available_hosts();
println!("Available hosts:\n {:?}", available_hosts);
for host_id in available_hosts {
println!("{}", host_id.name());
let host = cpal::host_from_id(host_id)?;
let default_in = host.default_input_device().map(|e| e.name().unwrap());
let default_out = host.default_output_device().map(|e| e.name().unwrap());
println!(" Default Input Device:\n {:?}", default_in);
println!(" Default Output Device:\n {:?}", default_out);
let devices = host.devices()?;
println!(" Devices: ");
for (device_index, device) in devices.enumerate() {
println!(" {}. \"{}\"", device_index + 1, device.name()?);
// Input configs
if let Ok(conf) = device.default_input_config() {
println!(" Default input stream config:\n {:?}", conf);
}
let input_configs = match device.supported_input_configs() {
Ok(f) => f.collect(),
Err(e) => {
println!(" Error getting supported input configs: {:?}", e);
Vec::new()
}
};
if !input_configs.is_empty() {
println!(" All supported input stream configs:");
for (config_index, config) in input_configs.into_iter().enumerate() {
println!(
" {}.{}. {:?}",
device_index + 1,
config_index + 1,
config
);
}
}
// Output configs
if let Ok(conf) = device.default_output_config() {
println!(" Default output stream config:\n {:?}", conf);
}
let output_configs = match device.supported_output_configs() {
Ok(f) => f.collect(),
Err(e) => {
println!(" Error getting supported output configs: {:?}", e);
Vec::new()
}
};
if !output_configs.is_empty() {
println!(" All supported output stream configs:");
for (config_index, config) in output_configs.into_iter().enumerate() {
println!(
" {}.{}. {:?}",
device_index + 1,
config_index + 1,
config
);
}
}
}
}
Ok(())
}

View File

@ -1,246 +0,0 @@
use super::*;
use cpal::{
StreamConfig,
SupportedStreamConfig,
Device,
HostId,
Stream,
traits::{DeviceTrait, StreamTrait},
};
use std::{
fs::File,
io::BufWriter,
path::{PathBuf, Path},
sync::{Arc, Mutex},
};
use anyhow::Error;
type WriteHandle = Arc<Mutex<Option<hound::WavWriter<BufWriter<File>>>>>;
pub struct Recorder {
writer: WriteHandle,
interrupt_handles: InterruptHandles,
default_config: SupportedStreamConfig,
user_config: StreamConfig,
device: Device,
spec: hound::WavSpec,
name: String,
path: PathBuf,
current_file: String,
max_seconds: Option<u64>,
}
/// # Recorder
///
/// The `Recorder` struct is used to record audio.
///
/// Use `init()` to initialize the recorder, `record()` to start a continuous recording,
/// and `rec_secs()` to record for a given number of seconds. The Recorder does not
/// need to be reinitialized after a recording is stopped. Calling `record()` or
/// `rec_secs()` again will start a new recording with a new filename according to
/// the time and date.
impl Recorder {
/// # Init
///
/// Initializes the recorder with the given host, sample rate, channel count, and buffer size.
pub fn init(
name: String,
path: PathBuf,
host: HostId,
sample_rate: u32,
channels: u16,
buffer_size: u32,
max_seconds: Option<u64>,
) -> Result<Self, Error> {
// Create interrupt handles to be used by the stream or batch loop.
let interrupt_handles = InterruptHandles::new(max_seconds)?;
// Select requested host.
let host = get_host(host)?;
// Set up the input device and stream with the default input config.
let device = get_device(host)?;
// Get default config for the device.
let default_config = get_default_config(&device)?;
// Override certain fields of the default stream config with the user's config.
let user_config = get_user_config(sample_rate, channels, buffer_size)?;
// Get the hound WAV spec for the user's config.
let spec = get_wav_spec(&default_config, &user_config)?;
Ok(Self {
writer: Arc::new(Mutex::new(None)),
interrupt_handles,
default_config,
user_config,
device,
spec,
name,
path,
current_file: "".to_string(),
max_seconds,
})
}
fn init_writer(&mut self) -> Result<(), Error> {
let filename = get_filename(&self.name, &self.path);
self.current_file = filename.clone();
self.writer = Arc::new(Mutex::new(Some(hound::WavWriter::create(filename, self.spec)?)));
Ok(())
}
fn create_stream(&self) -> Result<Stream, Error> {
let writer = self.writer.clone();
let config = self.user_config.clone();
let err_fn = |err| { eprintln!("{}: An error occurred on stream: {}", get_date_time_string(), err); };
let stream = match self.default_config.sample_format() {
cpal::SampleFormat::F32 => self.device.build_input_stream(
&config.into(),
move |data, _: &_| write_input_data::<f32, f32>(data, &writer),
err_fn,
)?,
cpal::SampleFormat::I16 => self.device.build_input_stream(
&config.into(),
move |data, _: &_| write_input_data::<i16, i16>(data, &writer),
err_fn,
)?,
cpal::SampleFormat::U16 => self.device.build_input_stream(
&config.into(),
move |data, _: &_| write_input_data::<u16, i16>(data, &writer),
err_fn,
)?,
};
Ok(stream)
}
/// # Record
///
/// Start a continuous recording. The recording will be stopped when the
/// user presses `Ctrl+C`.
pub fn record(&mut self) -> Result<(), Error> {
self.init_writer()?;
let stream = self.create_stream()?;
stream.play()?;
println!("REC: {}", self.current_file);
self.interrupt_handles.stream_wait();
drop(stream);
self.writer.lock().unwrap().take().unwrap().finalize()?;
println!("STOP: {}", self.current_file);
Ok(())
}
/// # Record Seconds
///
/// Record for a given number of seconds or until the user presses `Ctrl+C`.
/// Current batch is finished before stopping.
pub fn record_secs(&mut self, secs: u64) -> Result<(), Error> {
self.init_writer()?;
let stream = self.create_stream()?;
stream.play()?;
let date_time = get_date_time_string();
println!("REC[{}]: {}", date_time, self.current_file);
let now = std::time::Instant::now();
while self.interrupt_handles.batch_is_running() {
std::thread::sleep(std::time::Duration::from_millis(1));
if now.elapsed().as_secs() >= secs {
break;
}
}
drop(stream);
let writer = self.writer.clone();
let current_file = self.current_file.clone();
std::thread::spawn(move || {
writer.lock().unwrap().take().unwrap().finalize().unwrap();
// Print STOP and the time and date
let date_time = get_date_time_string();
println!("STOP[{}]: {}", date_time, current_file);
});
Ok(())
}
}
fn write_input_data<T, U>(input: &[T], writer: &WriteHandle)
where
T: cpal::Sample,
U: cpal::Sample + hound::Sample,
{
if let Ok(mut guard) = writer.try_lock() {
if let Some(writer) = guard.as_mut() {
for &sample in input.iter() {
let sample: U = cpal::Sample::from(&sample);
writer.write_sample(sample).ok();
}
}
}
}
fn batch_recording(rec: &mut Recorder, secs: u64) -> Result<(), Error> {
let now = std::time::Instant::now();
while rec.interrupt_handles.batch_is_running() {
match rec.max_seconds {
Some(max_secs) => {
if now.elapsed().as_secs() >= max_secs {
break;
}
}
None => {}
}
rec.record_secs(secs)?;
}
Ok(())
}
fn continuous_recording(rec: &mut Recorder) -> Result<(), Error> {
rec.record()?;
Ok(())
}
#[cfg(target_os = "linux")]
fn match_host_platform(host: Hosts) -> Result<cpal::HostId, Error> {
match host {
Hosts::Alsa => Ok(cpal::HostId::Alsa),
Hosts::Jack => Ok(cpal::HostId::Jack),
_ => Err(anyhow!("Host not supported on Linux.")),
}
}
#[cfg(target_os = "macos")]
fn match_host_platform(host: Hosts) -> Result<cpal::HostId, Error> {
match host {
Hosts::CoreAudio => Ok(cpal::HostId::CoreAudio),
_ => Err(anyhow!("Host not supported on macOS.")),
}
}
#[cfg(target_os = "windows")]
fn match_host_platform(host: Hosts) -> Result<cpal::HostId, Error> {
match host {
Hosts::Asio => Ok(cpal::HostId::Asio),
_ => Err(anyhow!("Host not supported on Windows.")),
}
}
pub fn record(args: &Rec) -> Result<(), Error> {
let mut recorder = Recorder::init(
args.name.clone(),
match args.output.clone() {
Some(path) => path,
None => Path::new("./").to_path_buf(),
},
match_host_platform(args.host)?,
args.sample_rate.unwrap_or(DEFAULT_SAMPLE_RATE),
args.channels.unwrap_or(DEFAULT_CHANNEL_COUNT),
args.buffer_size.unwrap_or(DEFAULT_BUFFER_SIZE),
args.max_seconds,
)?;
match args.batch_recording {
Some(seconds) => batch_recording(&mut recorder, seconds),
None => continuous_recording(&mut recorder),
}
}

View File

@ -1,67 +0,0 @@
#!/usr/bin/python3
import argparse
import time
import board
import busio
import adafruit_ads1x15.ads1015 as ADS
from adafruit_extended_bus import ExtendedI2C as I2C
from adafruit_ads1x15.analog_in import AnalogIn
parser = argparse.ArgumentParser(description='GPS Logger')
parser.add_argument('-o', '--output', help='Output directory', required=True)
parser.add_argument('-i', '--interval', help='Interval in seconds', required=False)
parser.add_argument('-b', '--bus', help='Custom i2c bus to use', required=False)
args = parser.parse_args()
# Create the I2C bus
if args.bus:
i2c_bus = args.bus
i2c = I2C(i2c_bus)
else:
i2c = busio.I2C(board.SCL, board.SDA)
# Create the ADC object using the I2C bus
ads = ADS.ADS1015(i2c)
# Create a single-ended input on channel 1
depthS = AnalogIn(ads, ADS.P1)
# Create single-ended input on channel 0
# tmp36 = AnalogIn(ads, ADS.P0)
# Subtract the offset from the sensor voltage
# and convert chan.voltage * (1 degree C / 0.01V) = Degrees Celcius
# temperatureC = (tmp36.voltage - 0.5) / 0.01
# File to write down the results
filename = args.output + "/" + time.strftime("%Y-%m-%dT%H-%M-%S") + "_depth_data.csv"
interval = int(args.interval) if args.interval else 5
#Attempting to round the figure to a more intelligible figure
#rounddepth = round(depthM, ndigits)
#psi = depthS.voltage * 104.1666667 - 75
#bar = psi * 14.503773800722
with open(filename, "w", 1) as f:
print(f"Writing pressure/depth output to {filename}, interval {interval} seconds", flush=True)
f.write("time and date, Voltage of depth sensor (V), Depth (m)\n")
try:
while True:
voltage = depthS.voltage
depthM = ((voltage * 31.848) - 22.93)
rounddepth = round(depthM, 2)
roundvolts = round(voltage, 3)
# roundtemp = round(temperatureC, 2)
print((str(voltage) + " V ") + (str(depthM) + " m ") + (str(roundvolts) + " V ") + (str(rounddepth) + " m"), flush=True)
f.write(time.strftime("%Y-%m-%dT%H:%M:%S") + "," + str(roundvolts) + "," + str(rounddepth) + "\n")
time.sleep(interval)
except (KeyboardInterrupt, SystemExit): # when you press ctrl+c
print("Exiting depth recording.", flush=True)

View File

@ -1,3 +0,0 @@
Adafruit-Blinka==8.5.0
adafruit-circuitpython-ads1x15==2.2.21
adafruit-extended-bus==1.0.2

215
flake.lock Normal file
View File

@ -0,0 +1,215 @@
{
"nodes": {
"deploy-rs": {
"inputs": {
"flake-compat": "flake-compat",
"nixpkgs": "nixpkgs",
"utils": "utils"
},
"locked": {
"lastModified": 1694158470,
"narHash": "sha256-yWx9eBDHt6WR3gr65+J85KreHdMypty/P6yM35tIYYM=",
"owner": "serokell",
"repo": "deploy-rs",
"rev": "d0cfc042eba92eb206611c9e8784d41a2c053bab",
"type": "github"
},
"original": {
"owner": "serokell",
"repo": "deploy-rs",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1668681692,
"narHash": "sha256-Ht91NGdewz8IQLtWZ9LCeNXMSXHUss+9COoqu6JLmXU=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "009399224d5e398d03b22badca40a37ac85412a1",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1692799911,
"narHash": "sha256-3eihraek4qL744EvQXsK1Ha6C3CR7nnT8X2qWap4RNk=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "f9e7cf818399d17d347f847525c5a5a8032e4e44",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1692799911,
"narHash": "sha256-3eihraek4qL744EvQXsK1Ha6C3CR7nnT8X2qWap4RNk=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "f9e7cf818399d17d347f847525c5a5a8032e4e44",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"hydrophonitor-gps": {
"inputs": {
"flake-utils": "flake-utils_2",
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1694371631,
"narHash": "sha256-o2lx75F0VnFnBKajbVbEUuQRcbcSHdCxjUVjdaAoAn8=",
"owner": "nordic-dev-net",
"repo": "hydrophonitor-gps",
"rev": "d13bb78ce2d16b842f0be802f9d669ba32a35c3d",
"type": "github"
},
"original": {
"owner": "nordic-dev-net",
"repo": "hydrophonitor-gps",
"type": "github"
}
},
"nixos-hardware": {
"locked": {
"lastModified": 1693718952,
"narHash": "sha256-+nGdJlgTk0MPN7NygopipmyylVuAVi7OItIwTlwtGnw=",
"owner": "NixOS",
"repo": "nixos-hardware",
"rev": "793de77d9f83418b428e8ba70d1e42c6507d0d35",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "master",
"repo": "nixos-hardware",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1671417167,
"narHash": "sha256-JkHam6WQOwZN1t2C2sbp1TqMv3TVRjzrdoejqfefwrM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "bb31220cca6d044baa6dc2715b07497a2a7c4bc7",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1694183432,
"narHash": "sha256-YyPGNapgZNNj51ylQMw9lAgvxtM2ai1HZVUu3GS8Fng=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "db9208ab987cdeeedf78ad9b4cf3c55f5ebd269b",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1694183432,
"narHash": "sha256-YyPGNapgZNNj51ylQMw9lAgvxtM2ai1HZVUu3GS8Fng=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "db9208ab987cdeeedf78ad9b4cf3c55f5ebd269b",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"deploy-rs": "deploy-rs",
"flake-utils": "flake-utils",
"hydrophonitor-gps": "hydrophonitor-gps",
"nixos-hardware": "nixos-hardware",
"nixpkgs": "nixpkgs_3"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"utils": {
"locked": {
"lastModified": 1667395993,
"narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "5aed5285a952e0b949eb3ba02c12fa4fcfef535f",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

94
flake.nix Normal file
View File

@ -0,0 +1,94 @@
{
description = "NixOS Raspberry Pi configuration flake";
inputs = {
nixpkgs = {
url = "github:NixOS/nixpkgs/nixos-unstable";
};
nixos-hardware.url = "github:NixOS/nixos-hardware/master";
flake-utils.url = "github:numtide/flake-utils";
deploy-rs.url = "github:serokell/deploy-rs";
hydrophonitor-gps.url = "github:nordic-dev-net/hydrophonitor-gps";
};
outputs = {
self,
nixpkgs,
nixos-hardware,
flake-utils,
deploy-rs,
hydrophonitor-gps,
...
}: let
forEachSystem = nixpkgs.lib.genAttrs ["x86_64-linux" "aarch64-linux"];
forEachPkgs = f: forEachSystem (sys: f (nixpkgs.legacyPackages.${sys}));
system = "aarch64-linux";
pkgs = import nixpkgs {
inherit system;
config = {allowUnfree = true;};
overlays = [
(final: super: {
makeModulesClosure = x:
super.makeModulesClosure (x // {allowMissing = true;});
})
];
};
in {
systems = {
raspberry-pi-4 = nixpkgs.lib.nixosSystem {
system = "aarch64-linux";
specialArgs = {inherit pkgs;};
modules = [
./targets/raspberry-pi-4
./modules/audio-recorder.nix
./modules/real-time-clock/i2c-rtc.nix
./modules/shutdown-button/service.nix
nixos-hardware.nixosModules.raspberry-pi-4
hydrophonitor-gps.nixosModules.hydrophonitor-gps
"${nixpkgs}/nixos/modules/installer/sd-card/sd-image-aarch64.nix"
];
};
raspberry-pi-3 = nixpkgs.lib.nixosSystem {
system = "aarch64-linux";
specialArgs = {inherit pkgs;};
modules = [
./targets/raspberry-pi-3
./modules/audio-recorder.nix
./modules/real-time-clock/i2c-rtc.nix
hydrophonitor-gps.nixosModules.hydrophonitor-gps
"${nixpkgs}/nixos/modules/installer/sd-card/sd-image-aarch64.nix"
];
};
};
deploy.nodes = {
raspberry-pi-4 = {
hostname = "192.168.1.76";
profiles.system = {
sshUser = "kaskelotti";
sshOpts = ["-t"];
magicRollback = false;
path =
deploy-rs.lib.aarch64-linux.activate.nixos
self.systems.raspberry-pi-4;
user = "root";
};
};
raspberry-pi-3 = {
hostname = "192.168.1.117";
profiles.system = {
sshUser = "kaskelotti";
sshOpts = ["-t"];
magicRollback = false;
path =
deploy-rs.lib.aarch64-linux.activate.nixos
self.systems.raspberry-pi-3;
user = "root";
};
};
};
formatter = forEachPkgs (pkgs: pkgs.alejandra);
};
}

8
flash.sh Normal file
View File

@ -0,0 +1,8 @@
# Flash image to sd-card
#
# Check sd-card device name with lsblk
SDCARD=$1
IMAGE=$2
sudo dd if=${IMAGE} of=/dev/${SDCARD} status=progress

View File

@ -1,39 +0,0 @@
#!/usr/bin/python3
import gpsd
import time
import argparse
parser = argparse.ArgumentParser(description='GPS Logger')
parser.add_argument('-o', '--output', help='Output directory', required=True)
parser.add_argument('-i', '--interval', help='Interval in seconds', required=False)
args = parser.parse_args()
filename = args.output + "/" + time.strftime("%Y-%m-%dT%H-%M-%S") + "_GPS_data.csv"
interval = int(args.interval) if args.interval else 5
with open(filename, "w", 1) as f:
gpsd.connect()
print(f"Writing GPS output to {filename}, interval {interval} seconds", flush=True)
f.write("system_time,gps_time_utc,latitude,longitude,speed,sats_in_view\n")
while True:
try:
packet = gpsd.get_current()
gps_time_utc = str(packet.time) if packet.mode >= 2 else "-"
lat = str(packet.lat) if packet.mode >= 2 else "0.0"
lon = str(packet.lon) if packet.mode >= 2 else "0.0"
speed = str(packet.hspeed) if packet.mode >= 2 else "0.0"
sats = str(packet.sats)
system_time = time.strftime("%Y-%m-%dT%H-%M-%S")
f.write(f"{system_time},{gps_time_utc},{lat},{lon},{speed},{sats}\n")
except (KeyboardInterrupt, SystemExit): # when you press ctrl+c
print("Exiting GPS recording.", flush=True)
break
except Exception as e:
print(f"GPS error: {e}, trying again...", flush=True)
time.sleep(interval)

View File

@ -1 +0,0 @@
gpsd-py3==0.3.0

View File

@ -1,27 +0,0 @@
# Audio
SAMPLE_RATE=44100
CHANNELS=2
BITRATE=192k
# in seconds
BATCH_RECORD_LENGTH=60
# GPS
# in seconds
GPS_INTERVAL=5
# Depth
# in seconds
DEPTH_INTERVAL=5
# Output location
# TODO
#TRY_MOUNT_SSD=true
OUTPUT_PATH=/home/pi/data
HOME_PATH=/home/pi
DEFAULT_OUTPUT_PATH=/home/pi/recordings

View File

@ -0,0 +1,70 @@
{
config,
pkgs,
lib,
...
}: {
options = with lib; {
services.audio-recorder = {
enable = mkEnableOption "Whether to enable the audio recording service.";
output-folder = mkOption {
type = types.str;
default = "/output/audio";
description = "The folder to save recordings to.";
};
sample-rate = mkOption {
type = types.int;
default = 44100;
description = "The sample rate to use for recording.";
};
sample-format = mkOption {
type = types.str;
default = "S32_LE";
description = "The sample format to use for recording.";
};
channels = mkOption {
type = types.int;
default = 2;
description = "The amount of channels to use.";
};
max-file-time-secs = mkOption {
type = types.int;
default = 600;
description = "The maximum length of a recording in seconds.";
};
};
};
config = lib.mkIf config.services.audio-recorder.enable {
systemd.services.audio-recorder = {
description = "Audio Recording Service";
wantedBy = ["multi-user.target"];
script = ''
#!/usr/bin/env bash
set -x
${pkgs.coreutils}/bin/mkdir -p ${config.services.audio-recorder.output-folder}
${pkgs.alsaUtils}/bin/arecord -l
CARD_ID=$(${pkgs.alsaUtils}/bin/arecord -l | grep "USB Audio" | grep -oP '(?<=card )\d')
DEVICE_ID=$(${pkgs.alsaUtils}/bin/arecord -l | grep "USB Audio" | grep -oP '(?<=device )\d')
${pkgs.alsaUtils}/bin/arecord \
--device hw:$CARD_ID,$DEVICE_ID \
--format ${config.services.audio-recorder.sample-format} \
--max-file-time ${toString config.services.audio-recorder.max-file-time-secs} \
--rate ${toString config.services.audio-recorder.sample-rate} \
--channels ${toString config.services.audio-recorder.channels} \
--use-strftime \
${config.services.audio-recorder.output-folder}/%Y-%m-%d_%H-%M-%S.wav
'';
serviceConfig = {
User = "root"; # Replace with appropriate user
Restart = "always";
};
startLimitIntervalSec = 0;
};
};
}

View File

@ -0,0 +1,49 @@
{
config,
pkgs,
lib,
...
}: {
options = with lib; {
services.i2c-rtc-start = {
enable = mkEnableOption "Whether to enable the audio recording service.";
i2c-bus = mkOption {
type = types.int;
default = 1;
description = "The I2C bus to use.";
};
};
};
config = lib.mkIf config.services.i2c-rtc-start.enable {
systemd.services.i2c-rtc-start = {
description = "RTC start service; reads the RTC and sets the system time from it";
wantedBy = ["multi-user.target"];
requires = ["systemd-modules-load.service"];
after = ["systemd-modules-load.service"];
script = ''
#!/usr/bin/env bash
set -x
# Remove the i2c device if it already exists
if [ -e /sys/class/i2c-adapter/i2c-${toString config.services.i2c-rtc-start.i2c-bus}/new_device ]; then
echo "Try deleting existing i2c device"
echo 0x68 | tee /sys/class/i2c-adapter/i2c-${toString config.services.i2c-rtc-start.i2c-bus}/delete_device || true
fi
echo "Adding i2c device"
echo ds1307 0x68 | tee /sys/class/i2c-adapter/i2c-${toString config.services.i2c-rtc-start.i2c-bus}/new_device
echo "Current hwclock time:"
/run/current-system/sw/bin/hwclock -r
echo "Current system time:"
date
echo "Setting system time from hwclock"
/run/current-system/sw/bin/hwclock -s
'';
serviceConfig = {
User = "root";
Type = "oneshot";
};
startLimitIntervalSec = 0;
};
};
}

41
modules/shutdown-button/Cargo.lock generated Normal file
View File

@ -0,0 +1,41 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "lazy_static"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
[[package]]
name = "libc"
version = "0.2.147"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3"
[[package]]
name = "rppal"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb26758c881b4837b2f4aef569e4251f75388e36b37204e1804ef429c220121c"
dependencies = [
"lazy_static",
"libc",
]
[[package]]
name = "rust_gpiozero"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef0e15226913de082030649dc55bb1db057ffc1c7c2b418454912a85b40523f3"
dependencies = [
"rppal",
]
[[package]]
name = "shutdown-button"
version = "0.1.0"
dependencies = [
"rust_gpiozero",
]

View File

@ -0,0 +1,9 @@
[package]
name = "shutdown-button"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
rust_gpiozero = "^0.2"

View File

@ -0,0 +1,16 @@
{
rustPlatform,
pkg-config,
pkgs,
}:
rustPlatform.buildRustPackage {
pname = "shutdown-button";
version = "0.1.0";
src = ./.;
cargoLock = {
lockFile = ./Cargo.lock;
allowBuiltinFetchGit = true;
};
}

View File

@ -0,0 +1,43 @@
{
config,
pkgs,
lib,
...
}: let
shutdownButton = pkgs.callPackage ./default.nix {};
in {
options = {
services.shutdown-button = {
enable = lib.mkEnableOption "Whether to enable the shutdown-button service.";
gpio-pin = lib.mkOption {
type = lib.types.int;
default = 21;
description = "GPIO pin number to which button is connected";
};
shutdown-press-secs = lib.mkOption {
type = lib.types.int;
default = 1;
description = "How many seconds button must be pushed down to trigger shutdown signal";
};
};
};
config = lib.mkIf config.services.shutdown-button.enable {
systemd.services.shutdown-button = {
description = "Shutdown button service";
wantedBy = ["multi-user.target"];
script = ''
#!/usr/bin/env bash
set -x
${shutdownButton}/bin/shutdown-button --gpio-pin ${toString config.services.shutdown-button.gpio-pin} --shutdown-press-secs ${toString config.services.shutdown-button.shutdown-press-secs}
'';
serviceConfig = {
User = "root"; # Replace with appropriate user
Restart = "always";
};
startLimitIntervalSec = 0;
};
};
}

View File

@ -0,0 +1,25 @@
//! Display message in console when a Button is pressed
// use rust_gpiozero::{Button, Debounce};
use rust_gpiozero::{Button};
// use std::time::Duration;
use std::process::Command;
fn main() {
// Create a button which is attached to Pin 21
let mut button = Button::new(21);
// Add debouncing so that subsequent presses within 100ms don't trigger a press
// .debounce(Duration::from_millis(100));
// Add an async interrupt to trigger whenever the button is pressed
// button
// .when_pressed(|_| {
// println!("button pressed");
// })
// .unwrap();
println!("Waiting for shutdown button press");
button.wait_for_press(None);
println!("Shutdown button pressed");
Command::new("shutdown")
.output()
.expect("failed to execute shutdown command");
}

View File

View File

@ -1 +0,0 @@
<username>:<encrypted password>

View File

@ -1,13 +0,0 @@
ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev
update_config=1
country=<Insert 2 letter ISO 3166-1 country code here>
network={
scan_ssid=1
ssid="<Name of your wireless LAN>"
psk="<Password for your wireless LAN>"
proto=RSN
key_mgmt=WPA-PSK
pairwise=CCMP
auth_alg=OPEN
}

View File

@ -1,28 +0,0 @@
#!/usr/bin/env sh
# Install utils for cpu freq
sudo apt-get install cpufrequtils
sudo cpufreq-set -r -g performance
sudo echo "ENABLE="true"
GOVERNOR="performance"
MAX_SPEED="0"
MIN_SPEED="0" " | sudo tee -a /etc/default/cpufrequtils
# Set CPU governor
sudo sed -i 's/exit 0/sudo cpufreq-set -r -g performance/g' /etc/rc.local
sudo echo "exit 0" | sudo tee -a /etc/rc.local
# Set realtime priority and memlock
sudo echo "
@audio nice -15
@audio - rtprio 90 # maximum realtime priority
@audio - memlock unlimited # maximum locked-in-memory address space (KB)
" | sudo tee -a /etc/security/limits.conf
# Set swappiness
# This setting changes the so-called swappiness of your system,
# or in other words, the moment when your system starts to use its swap partition.
sudo echo "
vm.swappiness = 10
fs.inotify.max_user_watches = 524288
" | sudo tee /etc/sysctl.conf

View File

@ -1,15 +0,0 @@
#!/bin/bash
if [ $# -eq 1 ]; then
CONFIG_FILE=$1
else
CONFIG_FILE=/boot/hydrophonitor/hydrophonitor-config.txt
fi
# Select non-comment lines in CONFIG_FILE, clean horizontal whitespace
args=$(grep -v '^#' $CONFIG_FILE | tr -d '[:blank:]' | tr '\n' ' ')
for arg in $args; do
echo "export $arg"
export "${arg?}"
done

View File

@ -1,12 +0,0 @@
#!/bin/bash
for sysdevpath in $(find /sys/bus/usb/devices/usb*/ -name dev); do
(
syspath="${sysdevpath%/dev}"
devname="$(udevadm info -q name -p $syspath)"
[[ "$devname" == "bus/"* ]] && exit
eval "$(udevadm info -q property --export -p $syspath)"
[[ -z "$ID_SERIAL" ]] && exit
echo "/dev/$devname - $ID_SERIAL"
)
done

View File

@ -1,32 +0,0 @@
#!/bin/bash
set -ex
echo "Setting up audio recording"
# Install packages
sudo apt-get update && sudo apt-get install -y libasound2-dev libjack-dev
# Get ID and number of the USB audio device
card_number=$(aplay -l | grep -i usb | grep -i audio | cut -d ' ' -f 2 | cut -d ':' -f 1)
# Change default audio device
sudo touch /etc/asound.conf
config="pcm.!default {
type plug
slave {
pcm \"hw:$card_number,0\"
}
}
ctl.!default {
type hw
card $card_number
}"
if ! grep -q "$config" /etc/asound.conf; then
echo "$config" | sudo tee -a /etc/asound.conf
fi
cd $HOME/hydrophonitor/audio-logger && cargo build --release

View File

@ -1,24 +0,0 @@
#!/bin/bash
set -ex
echo "Setting up GPS recording"
sudo apt-get update && sudo apt-get install -y gpsd gpsd-clients
sudo pip install -r $HOME/hydrophonitor/gps-logger/requirements.txt
sudo systemctl stop gpsd.socket
sudo systemctl disable gpsd.socket
device="/dev/ttyUSB0"
sudo gpsd ${device} -F /var/run/gpsd.sock
sudo sed -i "s|DEVICES=\"\"|DEVICES=\"${device}\"|g" /etc/default/gpsd
config="START_DAEMON=\"true\""
if ! grep -q "$config" /etc/default/gpsd; then
echo "$config" | sudo tee -a /etc/default/gpsd
fi

View File

@ -1,24 +0,0 @@
#!/bin/bash
set -ex
SDA_GPIO_PIN=10
SCL_GPIO_PIN=11
I2C_BUS=3
echo "Setting up depth recording"
# Enable i2c
sudo raspi-config nonint do_i2c 0
# Enable i2c on bus 3 and GPIO pins sda=23 and scl=24
config="dtoverlay=i2c-gpio,bus=$I2C_BUS,i2c_gpio_sda=$SDA_GPIO_PIN,i2c_gpio_scl=$SCL_GPIO_PIN"
if ! grep -q "$config" /boot/config.txt; then
echo "$config" | sudo tee -a /boot/config.txt
fi
# Install packages
sudo apt-get update && sudo apt-get install -y i2c-tools python3-pip
sudo pip install -r $HOME/hydrophonitor/depth-logger/requirements.txt

View File

@ -1,101 +0,0 @@
#!/bin/bash
set -ex
BOOT_DIR_PATH=/boot/hydrophonitor
LOG_FILE=$BOOT_DIR_PATH/$(date +"%Y-%m-%dT%H-%M-%S")-setup-log.txt
# Output from commands within the curly braces is written
# to $LOG_FILE
{
DIR_PATH=$HOME
echo
echo "Starting setup, writing logs to $LOG_FILE"
echo
echo
echo "### Update file paths in config and start script files"
echo
# Update hydrophonitor-config.txt HOME_PATH to the current user's home
sudo sed -i "s|^HOME_PATH=.*$|HOME_PATH=$HOME|" $BOOT_DIR_PATH/hydrophonitor-config.txt
# Update script path in start scripts to the current user's home
sudo sed -i "s|^SCRIPT_PATH=.*$|SCRIPT_PATH=$HOME/hydrophonitor/scripts|" $BOOT_DIR_PATH/scripts/start-all.sh
sudo sed -i "s|^SCRIPT_PATH=.*$|SCRIPT_PATH=$HOME/hydrophonitor/scripts|" $BOOT_DIR_PATH/scripts/start-audio.sh
sudo sed -i "s|^SCRIPT_PATH=.*$|SCRIPT_PATH=$HOME/hydrophonitor/scripts|" $BOOT_DIR_PATH/scripts/start-gps.sh
sudo sed -i "s|^SCRIPT_PATH=.*$|SCRIPT_PATH=$HOME/hydrophonitor/scripts|" $BOOT_DIR_PATH/scripts/start-pressure-depth.sh
# Copy the files to DIR_PATH
echo
echo "### Copy files to $DIR_PATH"
echo
mkdir -p "$DIR_PATH"
cd "$DIR_PATH"
rm -rf hydrophonitor
cp -R $BOOT_DIR_PATH/ .
# Install some development tools
echo
echo "### Install some development tools"
echo
sudo apt-get update && sudo apt-get install -y build-essential python3-pip
# Install the Rust programming language toolchain
echo
echo "### Install the Rust toolchain"
echo
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source "$HOME/.cargo/env"
# Setup audio
echo
echo "### Setup audio"
echo
cd "$DIR_PATH" && ./hydrophonitor/scripts/setup-audio.sh
# Setup GPS
echo
echo "### Setup GPS"
echo
cd "$DIR_PATH" && ./hydrophonitor/scripts/setup-gps.sh
# Setup depth sensor
echo
echo "### Setup depth recording"
echo
cd "$DIR_PATH" && ./hydrophonitor/scripts/setup-pressure-depth.sh
# Setup shutdown button
echo
echo "### Setup shutdown button"
echo
cd "$DIR_PATH" && ./hydrophonitor/scripts/setup-shutdown-button.sh
# Set up cron job to start the recordings at boot
echo
echo "### Set up a cron job to start the recordings at boot"
echo
CRON_FILE=/etc/crontab
CRON_LOG_FILE="$BOOT_DIR_PATH/cron-log.txt"
CRON_COMMAND="@reboot root $DIR_PATH/hydrophonitor/scripts/start-all.sh 2>&1 >> $CRON_LOG_FILE"
# Append command to cron file only if it's not there yet
if ! grep -q "$CRON_COMMAND" "$CRON_FILE"; then
echo "$CRON_COMMAND" | sudo tee -a "$CRON_FILE"
fi
# Reboot
echo
echo "### Setup ready, run 'sudo reboot' to apply all changes"
echo
} 2>&1 | sudo tee $LOG_FILE

View File

@ -1,34 +0,0 @@
#!/bin/bash
set -x
echo "Setting up the real time clock module, part 1"
# Enable i2c
sudo raspi-config nonint do_i2c 0
# Enable i2c rtc on the default i2c pins
config="dtoverlay=i2c-rtc,ds3231"
if ! grep -q "$config" /boot/config.txt; then
echo "$config" | sudo tee -a /boot/config.txt
fi
# Disable fake-hwclock
sudo apt-get remove -y fake-hwclock
sudo update-rc.d -f fake-hwclock remove
sudo systemctl disable fake-hwclock
# Load needed modules at boot
config="i2c-bcm2708
i2c-dev
rtc-ds1307"
if ! grep -q "$config" /etc/modules; then
echo "$config" | sudo tee -a /etc/modules
fi
# Remove some lines from /lib/udev/hwclock-set
sudo sed -i '/^if \[ \-e \/run\/systemd\/system \] ; then$/,/^fi$/d' /lib/udev/hwclock-set
echo "Setup RTC part 1 done, reboot and run setup-rtc-2.sh"

View File

@ -1,38 +0,0 @@
#!/bin/bash
set -x
echo "Setting up the real time clock module, part 2"
I2C_BUS=1
echo ds1307 0x68 | sudo tee /sys/class/i2c-adapter/i2c-$I2C_BUS/new_device
# Load RTC clock at boot
config="echo ds1307 0x68 | sudo tee /sys/class/i2c-adapter/i2c-$I2C_BUS/new_device
sudo hwclock -s
exit 0"
if ! grep -q "$config" /etc/rc.local; then
sudo sed -i "s/^exit 0$//" /etc/rc.local
echo "$config" | sudo tee -a /etc/rc.local
fi
# Set system time to Internet time
echo "Restarting systmd-timesyncd to update system time"
sudo systemctl restart systemd-timesyncd
echo "System time now:"
date
sleep 5
# Write system time to the RTC module
echo "Hardware clock time now:"
sudo hwclock -r --verbose
echo "Writing system time to hardware clock"
sudo hwclock -w --verbose
echo "Hardware clock time now:"
sudo hwclock -r --verbose

View File

@ -1,11 +0,0 @@
#!/bin/bash
set -ex
echo "Setting up shutdown button"
config="dtoverlay=gpio-shutdown,gpio_pin=21,gpio_pull=up,active_low=1"
if ! grep -q "$config" /boot/config.txt; then
echo "$config" | sudo tee -a /boot/config.txt
fi

View File

@ -1,24 +0,0 @@
#!/bin/bash
# Print all commands to standard output
set -x
BOOT_DIR_PATH=/boot/hydrophonitor
LOG_FILE=$BOOT_DIR_PATH/$(date +"%Y-%m-%dT%H-%M-%S")-startup-log.txt
{
SCRIPT_PATH=/home/pi/hydrophonitor/scripts
# Export the configuration values
. $SCRIPT_PATH/export-config-values.sh
# Create output directory
OUTPUT_DIR=$OUTPUT_PATH/$(date +"%Y-%m-%d_%H-%M-%S_output")
echo "Create output directory $OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR"/audio
# Sleep for a little to wait for GPS and sound card to be ready
sleep 10
(export OUTPUT_DIR=$OUTPUT_DIR; $SCRIPT_PATH/start-audio.sh & $SCRIPT_PATH/start-gps.sh & $SCRIPT_PATH/start-pressure-depth.sh) 2>&1 | tee "$OUTPUT_DIR"/log.txt
} 2>&1 | tee $LOG_FILE

View File

@ -1,20 +0,0 @@
#!/bin/bash
set -x
# Export the configuration values
SCRIPT_PATH=/home/pi/hydrophonitor/scripts
. $SCRIPT_PATH/export-config-values.sh
AUDIO_TARGET_EXECUTABLE="audio"
OPTIONS="rec \
--name audio_data \
--output $OUTPUT_DIR/audio \
--batch-recording $BATCH_RECORD_LENGTH \
--sample-rate $SAMPLE_RATE \
--channels $CHANNELS \
--buffer-size 1024 \
alsa"
cd $HOME_PATH/hydrophonitor/audio-logger/target/release && ./$AUDIO_TARGET_EXECUTABLE $OPTIONS

View File

@ -1,11 +0,0 @@
#!/bin/bash
set -x
# Export the configuration values
SCRIPT_PATH=/home/pi/hydrophonitor/scripts
. $SCRIPT_PATH/export-config-values.sh
OPTIONS="--output $OUTPUT_DIR --interval $GPS_INTERVAL"
cd $HOME_PATH/hydrophonitor/gps-logger && python record-gps.py $OPTIONS

View File

@ -1,13 +0,0 @@
#!/bin/bash
set -x
I2C_BUS=3
# Export the configuration values
SCRIPT_PATH=/home/pi/hydrophonitor/scripts
. $SCRIPT_PATH/export-config-values.sh
OPTIONS="--output $OUTPUT_DIR --interval $DEPTH_INTERVAL --bus $I2C_BUS"
cd $HOME_PATH/hydrophonitor/depth-logger && python record-depth.py $OPTIONS

View File

@ -0,0 +1,74 @@
{
config,
pkgs,
...
}: let
system = "aarch64-linux";
in {
imports = [
./i2c.nix
];
system.stateVersion = "23.05";
sdImage.compressImage = false;
boot = {
# disable internal sound card and vc4 gpu
blacklistedKernelModules = ["snd_bcm2835" "vc4"];
# enable i2c and rtc modules
kernelModules = ["i2c-dev" "i2c_bcm2708" "rtc_ds1307"];
kernelPackages = pkgs.linuxKernel.packages.linux_rpi3;
initrd.availableKernelModules = [
"usbhid"
"usb_storage"
"pcie_brcmstb" # required for the pcie bus to work
"reset-raspberrypi" # required for vl805 firmware to load
];
loader = {
grub.enable = false;
generic-extlinux-compatible.enable = true;
};
};
hardware = {
raspberry-pi."3".i2c1.enable = true;
deviceTree.filter = "bcm2711-rpi-*.dtb";
# Required for the wireless firmware
enableRedistributableFirmware = true;
};
environment.systemPackages = with pkgs; [i2c-tools libgpiod];
users.users.kaskelotti = {
isNormalUser = true;
initialHashedPassword = "$6$ySDQdXbGH/qDvjpe$Jp5icbEFRSBLsxB2XGxFz.dACxOS/.KYHENxVSUzFED0UYi9R64858JevedVB06sTsFvlKOPSlzBvbACbxNZr1";
extraGroups = ["wheel" "networkmanager"];
};
nix.settings.trusted-users = ["kaskelotti"];
sound.enable = true;
services.openssh = {
enable = true;
settings.PasswordAuthentication = true;
};
services.audio-recorder = {
enable = true;
output-folder = "/output/audio";
sample-rate = 192000;
sample-format = "S32_LE";
channels = 4;
max-file-time-secs = 300;
};
services.gps-recorder = {
enable = true;
output-folder = "/output/gps";
interval-secs = 10;
};
services.i2c-rtc-start = {
enable = true;
i2c-bus = 1;
};
}

View File

@ -0,0 +1,84 @@
{
config,
lib,
...
}: let
cfg = config.hardware.raspberry-pi."3";
optionalProperty = name: value: lib.optionalString (value != null) "${name} = <${builtins.toString value}>;";
simple-overlay = {
target,
status,
frequency,
}: {
name = "${target}-${status}-overlay";
dtsText = ''
/dts-v1/;
/plugin/;
/ {
compatible = "brcm,bcm2711";
fragment@0 {
target = <&${target}>;
__overlay__ {
status = "${status}";
${optionalProperty "clock-frequency" frequency}
};
};
};
'';
};
in {
options.hardware.raspberry-pi."3" = {
i2c0 = {
enable = lib.mkEnableOption ''
Turn on the VideoCore I2C bus (maps to /dev/i2c-22) and enable access from the i2c group.
After a reboot, i2c-tools (e.g. i2cdetect -F 22) should work for root or any user in i2c.
'';
frequency = lib.mkOption {
type = lib.types.nullOr lib.types.int;
default = null;
description = ''
interface clock-frequency
'';
};
};
i2c1 = {
enable = lib.mkEnableOption ''
Turn on the ARM I2C bus (/dev/i2c-1 on GPIO pins 3 and 5) and enable access from the i2c group.
After a reboot, i2c-tools (e.g. i2cdetect -F 1) should work for root or any user in i2c.
'';
frequency = lib.mkOption {
type = lib.types.nullOr lib.types.int;
default = null;
description = ''
interface clock-frequency
'';
};
};
};
config.hardware = lib.mkMerge [
(lib.mkIf cfg.i2c0.enable {
i2c.enable = lib.mkDefault true;
deviceTree = {
overlays = [
(simple-overlay {
target = "i2c0if";
status = "okay";
inherit (cfg.i2c0) frequency;
})
];
};
})
(lib.mkIf cfg.i2c1.enable {
i2c.enable = lib.mkDefault true;
deviceTree = {
overlays = [
(simple-overlay {
target = "i2c1";
status = "okay";
inherit (cfg.i2c1) frequency;
})
];
};
})
];
}

View File

@ -0,0 +1,63 @@
{
config,
pkgs,
...
}: let
system = "aarch64-linux";
in {
system.stateVersion = "23.05";
sdImage.compressImage = false;
# disable internal sound card and vc4 gpu
boot.blacklistedKernelModules = ["snd_bcm2835" "vc4"];
# enable i2c and rtc modules
boot.kernelModules = ["i2c-dev" "i2c_bcm2708" "rtc_ds1307"];
hardware = {
raspberry-pi."4".i2c1.enable = true;
raspberry-pi."4".apply-overlays-dtmerge.enable = true;
deviceTree.filter = "bcm2711-rpi-4*.dtb";
};
environment.systemPackages = with pkgs; [i2c-tools];
users.users.kaskelotti = {
isNormalUser = true;
initialHashedPassword = "$6$ySDQdXbGH/qDvjpe$Jp5icbEFRSBLsxB2XGxFz.dACxOS/.KYHENxVSUzFED0UYi9R64858JevedVB06sTsFvlKOPSlzBvbACbxNZr1";
extraGroups = ["wheel" "networkmanager"];
};
nix.settings.trusted-users = ["kaskelotti"];
sound.enable = true;
services.openssh = {
enable = true;
settings.PasswordAuthentication = true;
};
services.audio-recorder = {
enable = true;
output-folder = "/output/audio";
sample-rate = 192000;
sample-format = "S32_LE";
channels = 4;
max-file-time-secs = 300;
};
services.gps-recorder = {
enable = true;
output-folder = "/output/gps";
interval-secs = 10;
};
services.i2c-rtc-start = {
enable = true;
i2c-bus = 1;
};
services.shutdown-button = {
enable = true;
gpio-pin = 21; # option not implemented yet
shutdown-press-secs = 1; # option not implemented yet
};
}