Initial commit: get everything rolling

This commit is contained in:
unfunny
2026-09-01 22:22:56 -04:00
parent 6e0b03580a
commit c80c561517
8 changed files with 441 additions and 1 deletions

View File

@@ -1,3 +1,13 @@
# SyncTool
The software that runs on my Pi USB Parking station project. Written in Node.
The software that runs on my Pi USB Parking station project. Written in Node.
## Features
- Highly configurable
- Doesn't rely on anything Node itself doesn't include
- Spins up and manges Copyparty, Rclone, Rsync, and Open Notebook upload.
- (probably) doesn't suck.
### code transparency
Most of the services/ modules were written by Gemini
index.js was written by me and most of usbkey-lifecycle.js
sysfs-functions was writen by gemini

63
config.json Normal file
View File

@@ -0,0 +1,63 @@
{
"system": {
"mountPoint": "/mnt/flashdrive",
"ledPath": "/sys/class/leds/ACT",
"cacheFile": "./data/sync_state.json",
"targetUuid": "7AEB-FF21",
"maxFailedMounts": 2
},
"copyparty": {
"enabled": true,
"binaryPath": "/opt/usb-syncer/copyparty-sfx.py",
"port": 3923,
"user": "pi",
"password": "changeme",
"volumeName": "flash",
"permissions": "rwmd",
"extraArgs": ["--no-reload", "--q"]
},
"rsync": {
"enabled": true,
"nasPath": "/mnt/nas/flash_backup",
"bidirectional": true,
"deleteRemote": false,
"bypassEmptyFolder": true,
"exclude": [
".Trash-*",
"System Volume Information",
".Spotlight-V100",
".fseventsd",
"*.tmp"
],
"flags": ["-avu", "--update", "--modify-window=1"]
},
"rclone": {
"enabled": true,
"remote": "gdrive:FlashBackup",
"bidirectional": true,
"bandwidthLimit": null,
"exclude": [
".*",
"*.tmp"
],
"flags": ["--update", "--transfers=2", "--checkers=4"]
},
"openNotebook": {
"enabled": true,
"endpoint": "http://192.168.1.50:8000/api/v1",
"apiToken": "env:NOTEBOOK_TOKEN",
"notebookId": "class-notes-2026",
"watchFolder": "classes",
"allowedExtensions": [".pdf", ".docx", ".md", ".txt"]
},
"notifications": {
"enabled": false,
"webhookUrl": "https://ntfy.sh/my-sync-appliance"
}
}

107
index.js Normal file
View File

@@ -0,0 +1,107 @@
// SyncTool
// Read or modify this code if you dare, it may be unilicense but you'll loose your sanity
import { execSync, spawn } from 'node:child_process';
import { existsSync, readFileSync, writeFileSync, readdirSync, statSync, openAsBlob } from 'node:fs';
import { isMounted,unmountDrive,mountDrive, driveExists } from './usbkey-lifecycle';
import { startCopyparty, stopCopyparty } from './services/copyparty'; //Realistically we should just use promise for this to keep things optimized. But, who cares about optimization!
import { start }
import data from './config.json' with { type: 'json' }
import path from 'node:path';
import { start } from 'node:repl';
import { runRclone, runRsync } from './services/syncjobs';
console.log('SyncTool init')
console.log("Don't blame me if stuff bursts into flames")
console.log("System info:")
// Print enabled servers
const serverConfigMap = {
'Open Notebook': 'openNotebook',
'Rclone': 'rclone',
'Rsync': 'rsync',
'Copyparty': 'copyparty'
}
Object.entries(serverConfigMap).forEach(([server, configKey]) => {
const isEnabled = data[configKey]?.enabled === true
console.log(`${isEnabled ? '✓' : '✗'} ${server} ${isEnabled ? '(enabled)' : '(disabled)'}`)
})
//Check if the Flash Drive is plugged in
if (isMounted(config.system.targetUuid, config.system.mountPoint) == true) {
console.log("Drive looks mounted.")
var USBKeyMounted = true
} else {
if (driveExists != null) {
var FDDevNode = driveExists()
var mountAttempts = 0
console.debug("Mount attempts: ", mountAttempts)
try {
mountDrive(FDDevNode, config.mountPoint)
} catch (error) {
mountAttempts++
console.debug("Mount attempts: ", mountAttempts)
if (mountAttempts >= config.maxFailedMounts) {
console.error("Failed to mount the drive... Did it disconnect?")
exit(1)
}
} // Otherwise the drive exists and we can move on
}
}
//If RSYNC is on, check if the NAS is mounted and empty:
if (config.rsync.enabled == true) {
const nasPath = config.rsync.nasPath
console.log(`Checking NAS at ${nasPath}:`)
try {
const files = readdirSync(nasPath)
if (files.length === 0) {
console.warn("NAS is empty!")
} else {
console.log(`Found ${files.length} items in NAS`)
}
} catch (error) {
console.error(`Error checking NAS: ${error.message}`)
}
}
// Now it's Loadin time
// The best part about index.js was when it said "it's loadin time" and loaded all over the place
var copypartyStarted = false
var OpenNotebookstarted = false
var rcloneStarted = false
var rcloneRuns = 0
var rSYNcStarted = false
var rSYNcRuns = 0
function loadintime() {
//WELCOME TO if HELL!!!!
if (config.copyparty.enabled == true) {
console.debug("Copyparty enabled, starting")
startCopyparty(config)
}
if (config.rclone.enabled == true) {
// Go ahead and run both once
rcloneRuns++
console.debug("Starting first run for Rclone...")
try {
await runRclone(config)
var rcloneStarted = true
} catch (error) {
console.warn("Rclone went up in flames: ", error.message)
var rcloneStarted = false
}
}
if (config.rsync.enabled == true) {
rSYNcRuns++
console.debug("Starting first run for Rsync...")
try {
await runRsync(config)
var rSYNcStarted = true
} catch (error) {
console.warn("Rsync went up in flames: ", error.message)
var rSYNcStarted = false
}
}
if (config.openNotebook.enabled == true) {
//do whatever is needed here
}
console.debug("loadin time complete")
}
//TODO: Actually do something? idiot

16
package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "synctool",
"version": "0.1.0",
"description": "The software that runs on my Pi USB Parking station project. Written in Node.",
"repository": {
"type": "git",
"url": "https://warehouse.randomhack.com/unfunny/SyncTool.git"
},
"license": "Unlicense",
"author": "",
"type": "module",
"main": "index.js",
"scripts": {
"test": "notify-send \"I AM NOT A MORON!\" \"COULD A MORON, [forget to set up the test command]? HUH? COULD A MORON DO THAT? uh oh\""
}
}

45
services/copyparty.js Normal file
View File

@@ -0,0 +1,45 @@
// services/copyparty.js
import { spawn } from 'node:child_process';
let processHandle = null;
export function startCopyparty(config) {
const cp = config.copyparty;
if (!cp?.enabled || processHandle) return;
const mountPoint = config.system.mountPoint;
// volflag :c,hist=/tmp keeps sqlite write hits off the flash drive and sd card
const volSpec = `${mountPoint}:${cp.volumeName}:${cp.permissions}:c,hist=/tmp/copyparty`;
const args = [
cp.binaryPath,
'-p', String(cp.port),
'-a', `${cp.user}:${cp.password}`,
'-v', volSpec,
...(cp.extraArgs || [])
];
console.log(`[COPYPARTY] Starting server on port ${cp.port}...`);
processHandle = spawn('python3', args, {
stdio: 'ignore', // swap to 'inherit' if debugging startup flags
detached: false
});
processHandle.on('error', (err) => {
console.error('[COPYPARTY] Failed to spawn:', err.message);
processHandle = null;
});
processHandle.on('exit', (code) => {
console.log(`[COPYPARTY] Process exited (code ${code})`);
processHandle = null;
});
}
export function stopCopyparty() {
if (!processHandle) return;
console.log('[COPYPARTY] Stopping server...');
processHandle.kill('SIGTERM');
processHandle = null;
}

58
services/syncjobs.js Normal file
View File

@@ -0,0 +1,58 @@
// services/syncJobs.js
// This file handles both
import { spawn } from 'node:child_process';
function runCmd(bin, args) {
return new Promise((resolve, reject) => {
console.log(`[EXEC] ${bin} ${args.join(' ')}`);
const proc = spawn(bin, args, { stdio: 'inherit' });
proc.on('error', reject);
proc.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`${bin} failed with exit code ${code}`));
});
});
}
export async function runRsync(config) {
const cfg = config.rsync;
if (!cfg?.enabled) return;
const mountPoint = config.system.mountPoint;
const excludes = (cfg.exclude || []).flatMap((pattern) => ['--exclude', pattern]);
console.log('[RSYNC] Syncing with NAS...');
await runCmd('rsync', [...cfg.flags, ...excludes, `${mountPoint}/`, `${cfg.nasPath}/`]);
if (cfg.bidirectional) {
await runCmd('rsync', [
...cfg.flags,
'--ignore-existing',
...excludes,
`${cfg.nasPath}/`,
`${mountPoint}/`
]);
}
}
export async function runRclone(config) {
const cfg = config.rclone;
if (!cfg?.enabled) return;
const mountPoint = config.system.mountPoint;
const excludes = (cfg.exclude || []).flatMap((pattern) => ['--exclude', pattern]);
console.log('[RCLONE] Syncing with cloud...');
await runCmd('rclone', ['copy', ...cfg.flags, ...excludes, mountPoint, cfg.remote]);
if (cfg.bidirectional) {
await runCmd('rclone', [
'copy',
'--ignore-existing',
...cfg.flags,
...excludes,
cfg.remote,
mountPoint
]);
}
}

80
sysfs-functions.js Normal file
View File

@@ -0,0 +1,80 @@
// sysfs functions
const fs = require('node:fs');
const LED_PATH = fs.existsSync('/sys/class/leds/ACT')
? '/sys/class/leds/ACT'
: '/sys/class/leds/led0';
// detach kernel trigger once at startup
try {
fs.writeFileSync(`${LED_PATH}/trigger`, 'none');
} catch (err) {
console.error(`[LED] failed to claim sysfs led:`, err.message);
}
let blinkTimer = null;
function setBrightness(val) {
try {
fs.writeFileSync(`${LED_PATH}/brightness`, val.toString());
} catch (err) {
console.error(`[LED] error writing brightness:`, err.message);
}
}
function turnOff() {
stopBlink();
setBrightness(0);
}
function turnOn() {
stopBlink();
setBrightness(1);
}
function getState() {
try {
return parseInt(fs.readFileSync(`${LED_PATH}/brightness`, 'utf8').trim(), 10);
} catch {
return null;
}
}
function toggle() {
setBrightness(getState() === 1 ? 0 : 1);
}
function startBlink(interval = 500) {
stopBlink();
let state = 1;
setBrightness(state);
blinkTimer = setInterval(() => {
state = state === 1 ? 0 : 1;
setBrightness(state);
}, interval);
}
function stopBlink() {
if (blinkTimer) {
clearInterval(blinkTimer);
blinkTimer = null;
}
}
function restoreDefault() {
stopBlink();
try {
fs.writeFileSync(`${LED_PATH}/trigger`, 'mmc0');
} catch {}
}
module.exports = {
turnOff,
turnOn,
toggle,
getState,
startBlink,
stopBlink,
restoreDefault
};

61
usbkey-lifecycle.js Normal file
View File

@@ -0,0 +1,61 @@
// handles mounting/unmounting the USB drive
import { execSync } from 'node:child_process';
import { mkdirSync, rmdirSync, readFileSync, realpathSync, existsSync } from 'node:fs';
export function isMounted(mountPath) {
try {
return readFileSync('/proc/mounts', 'utf8').includes(` ${mountPath} `);
} catch {
return false;
}
}
export function mountDrive(devNode, mountPath) {
if (isMounted(mountPath)) {
console.log(`[MOUNT] ${mountPath} is already mounted.`);
return true;
}
try {
// ensure the directory exists first
mkdirSync(mountPath, { recursive: true });
// mount with open permissions so copyparty/rsync don't hit EACCES
execSync(`mount -o umask=000,uid=1000,gid=1000 ${devNode} ${mountPath}`);
console.log(`[MOUNT] success: ${devNode} -> ${mountPath}`);
return true;
} catch (err) {
console.error(`[MOUNT] failed:`, err.message);
return false;
}
}
export function unmountDrive(mountPath) {
if (!isMounted(mountPath)) {
// cleanup orphaned directory just in case
try { rmdirSync(mountPath); } catch {}
return;
}
try {
console.log(`[UNMOUNT] flushing buffers and unmounting ${mountPath}...`);
execSync('sync'); // force kernel to write any pending data to the usb
execSync(`umount -l ${mountPath}`); // lazy unmount in case a process is stuck reading
rmdirSync(mountPath);
console.log(`[UNMOUNT] cleanly unmounted and removed directory.`);
} catch (err) {
console.error(`[UNMOUNT] failed:`, err.message);
}
}
export function driveExists(targetuid) {
const linuidpath = '/dev/disk/by-uid/${targetuid}'
if (!existsSync(linuidpath)) {
return null;
}
try {
return realpathSync(linuidpath)
} catch {
return null;
}
}