diff --git a/.actrc b/.actrc new file mode 100644 index 0000000..d270424 --- /dev/null +++ b/.actrc @@ -0,0 +1 @@ +-P ubuntu-latest=catthehacker/ubuntu:runner-latest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..06cae95 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,41 @@ +name: Run RaspberryPi-Gateway tests +on: + pull_request: + push: + workflow_dispatch: +jobs: + flake: + name: Run Nix flake checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Initialize Nix store paths + run: | + sudo mkdir -p /nix/store + sudo chmod -R 777 /nix + # https://github.com/cachix/install-nix-action/issues/56#issuecomment-1030697681 + - name: Configure Nix store cache + uses: actions/cache@v4 + with: + key: ${{ runner.os }}-${{ runner.arch }}-nix-store + # See https://github.com/actions/cache/pull/726 and + # https://github.com/actions/cache/issues/494 for caveats on negative + # patterns. + path: | + /nix/store/** + /nix/var/nix/*/* + /nix/var/nix/db/* + /nix/var/nix/db/*/** + !/nix/var/nix/daemon-socket/socket + !/nix/var/nix/userpool/* + !/nix/var/nix/gc.lock + !/nix/var/nix/db/big-lock + !/nix/var/nix/db/reserved + - name: Install Nix + uses: cachix/install-nix-action@v31 + with: + extra_nix_config: | + system-features = benchmark big-parallel kvm nixos-test uid-range + - name: Run Nix flake checks + run: | + nix flake check -L diff --git a/.gitignore b/.gitignore index 43607a7..6aff2e3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,14 @@ node_modules db/* *.log + +# VM disk images +*.qcow2 + +# Nix build results +result +result-* +repl-result-* + +# NixOS test driver REPL history +.nixos-test-history diff --git a/.setup/gateway b/.setup/gateway index 4a4fb15..bdb430c 100644 --- a/.setup/gateway +++ b/.setup/gateway @@ -63,33 +63,12 @@ server{ proxy_set_header X-Forwarded-User $remote_user; } - # enable and configure PHP - location ~* \.php$ { - fastcgi_split_path_info ^(.+\.php)(.*)$; - - # typically the fpm.sock is used, but check to make sure by using: - # sudo grep -ri "listen = " /etc/php - # should get something like: - # /etc/php/7.3/fpm/pool.d/www.conf:listen = /run/php/php7.3-fpm.sock - # then you know it's fpm.sock - fastcgi_pass unix:/run/php/PHPFPMSOCK; #uncomment if PHP runs on fpm.sock - #fastcgi_pass 127.0.0.1:9000; #uncomment if PHP is running on port 9000 - - fastcgi_index index.php; - fastcgi_param SCRIPT_FILENAME /home/pi/gateway/www$fastcgi_script_name; - fastcgi_param QUERY_STRING $query_string; - fastcgi_param REQUEST_METHOD $request_method; - fastcgi_param CONTENT_TYPE $content_type; - fastcgi_param CONTENT_LENGTH $content_length; - include fastcgi_params; - } - # redirect server error pages to the static page /50x.html error_page 500 502 503 504 /50x.html; location = /50x.html { root html; } - + ## Disable viewing .htaccess & .htpassword location ~ /\.ht { deny all; diff --git a/.setup/gatewaysetup.sh b/.setup/gatewaysetup.sh index 7c7e217..9f9dcc4 100644 --- a/.setup/gatewaysetup.sh +++ b/.setup/gatewaysetup.sh @@ -108,9 +108,6 @@ sudo apt-get -y install git apache2-utils echo -e "${CYAN}************* STEP: Install latest NGINX *************${NC}" sudo apt-get -y install nginx -echo -e "${CYAN}************* STEP: Install latest PHP *********************${NC}" -sudo apt-get -y install php-common php-cli php-fpm - echo -e "${CYAN}************* STEP: Install nodeJS & npm *************${NC}" #install latest NodeJS --- https://www.raspberrypi.org/forums/viewtopic.php?t=141770 sudo wget -O - https://raw.githubusercontent.com/audstanley/NodeJs-Raspberry-Pi/master/Install-Node.sh | sudo bash @@ -179,10 +176,6 @@ echo -e "${YLW}Done. You can add/change http_auth credentials using ${RED}htpass echo -e "${CYAN}************* STEP: Copy gateway site config to sites-available *************${NC}" cp -rf $APPSRVDIR/.setup/gateway /etc/nginx/sites-available/gateway -#determine php-fpm version and replace in gateway site config -phpfpmsock=$(grep -ri "listen = /" /etc/php) #search for file containing "listen =" in php path -phpfpmsock=${phpfpmsock##*/} #extract everything after last / -sudo sed -i "s/PHPFPMSOCK/${phpfpmsock}/g" /etc/nginx/sites-available/gateway #replace PHPFPMSOCK with it in site config file cd /etc/nginx/sites-enabled sudo rm /etc/nginx/sites-enabled/default sudo ln -s /etc/nginx/sites-available/gateway diff --git a/app.nix b/app.nix new file mode 100644 index 0000000..7ff6e33 --- /dev/null +++ b/app.nix @@ -0,0 +1,40 @@ +{ + lib, + config, + dream2nix, + pkgs, + ... +} @ args: let + packageJSON = lib.importJSON ./package.json; +in { + imports = [ + dream2nix.modules.dream2nix.nodejs-package-json-v3 + dream2nix.modules.dream2nix.nodejs-granular-v3 + ]; + + inherit (packageJSON) name version; + + deps = { + inherit (pkgs.nodejs-slim) npm; + }; + + # Take advantage of a shell injection vector in dream2nix in order to apply a + # workaround for an issue with `npm i` converting dependencies specified with + # `git+https://` or `github:` to `git+ssh://`. + # See: + # - https://github.com/npm/cli/issues/2610 + # - https://github.com/npm/cli/issues/2631 + nodejs-package-json.npmArgs = lib.mkAfter [ + "--package-lock-only' ; ${lib.getExe pkgs.gnused} -i -e 's|git+ssh://|git+https://|g' ./package-lock.json #" + ]; + + mkDerivation = { + src = lib.cleanSource ./.; + doCheck = false; + meta = { + # XXX broken somehow? `lib.licenses.gpl3` works... + #license = lib.licenses.cc-by-nc-40; + homepage = "https://github.com/LowPowerLab/RaspberryPi-Gateway"; + }; + }; +} diff --git a/config.js b/config.js new file mode 100644 index 0000000..bee8e12 --- /dev/null +++ b/config.js @@ -0,0 +1,86 @@ +// ********************************************************************************** +// Websocket server backend for the RaspberryPi-Gateway App +// http://lowpowerlab.com/gateway +// ********************************************************************************** +// Common application configuration settings. +// ********************************************************************************** + +const JSON5 = require('json5'); // https://github.com/aseemk/json5 +const nconf = require('nconf'); // https://github.com/indexzero/nconf +const path = require('path'); + +exports.load = function({defaultStateDir = __dirname, defaultContentDir = null} = {}) { + nconf.argv().env() + + const stateDir = path.normalize(nconf.get('MOTEINO_GATEWAY_STATE_DIRECTORY') || defaultStateDir); + + const resolvePath = (base) => (...dirs) => path.resolve(base, ...dirs); + + const resolveCoreStatePath = resolvePath(defaultStateDir); + const resolveUserStatePath = resolvePath(stateDir); + + const coreContentDir = resolveCoreStatePath('www'); + const contentDir = path.normalize(nconf.get('MOTEINO_GATEWAY_CONTENT_DIRECTORY') || defaultContentDir || coreContentDir); + + const resolveCoreContentPath = resolvePath(coreContentDir); + const resolveUserContentPath = resolvePath(contentDir); + + const coreImagesDir = resolveCoreContentPath('images'); + const userImagesDir = resolveUserContentPath('images'); + + const coreMetricsDir = resolveCoreStatePath('metrics'); + const userMetricsDir = resolveUserStatePath('metrics'); + + const dbDir = resolveUserStatePath('data/db'); + + if (stateDir == defaultStateDir) + { + nconf.file('mutable5', { + file: resolveCoreStatePath('settings.json5'), + format: JSON5, + }); + + nconf.file('mutable', { + file: resolveCoreStatePath('settings.json'), + }); + } + else + { + nconf.file('mutable5', { + file: resolveUserStatePath('settings.json5'), + format: JSON5, + }); + + nconf.file('mutable', { + file: resolveUserStatePath('settings.json'), + }); + + nconf.file('immutable5', { + file: resolveCoreStatePath('settings.json5'), + format: JSON5, + }); + + nconf.file('immutable', { + file: resolveCoreStatePath('settings.json'), + }); + } + + return { + 'nconf': nconf, + 'stateDir': stateDir, + 'contentDir': contentDir, + 'coreContentDir': coreContentDir, + 'coreImagesDir': coreImagesDir, + 'userImagesDir': userImagesDir, + 'coreMetricsDir': coreMetricsDir, + 'userMetricsDir': userMetricsDir, + 'dbDir': dbDir, + 'resolveCoreContentPath': resolveCoreContentPath, + 'resolveCoreStatePath': resolveCoreStatePath, + 'resolveUserContentPath': resolveUserContentPath, + 'resolveUserStatePath': resolveUserStatePath, + }; +}; + +// Load configuration and export it +Object.assign(exports, exports.load()); diff --git a/data/db/.gitignore b/data/db/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/data/db/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/doc/nixos-modules.md b/doc/nixos-modules.md new file mode 100644 index 0000000..e2db80c --- /dev/null +++ b/doc/nixos-modules.md @@ -0,0 +1,490 @@ +## profiles\.mightyhat\.enable + + + +Whether to enable the LowPowerLab Moteino Gateway Raspberry Pi profile\. + + + +*Type:* +boolean + + + +*Default:* +` false ` + + + +*Example:* +` true ` + +*Declared by:* + - [flake\.nix](/flake.nix) + + + +## profiles\.mightyhat\.apply-overlays-dtmerge\.enable + +Merge device tree overlays with ` dtmerge ` from +` libraspberrypi `\. + +Additionally, supports merging compiled overlays from +` device-tree_rpi `, even if they specify a ` compatible ` +stanza that is different than that nominally required by the target +device tree\. + + + +*Type:* +boolean + + + +*Default:* +` true ` + +*Declared by:* + - [flake\.nix](/flake.nix) + + + +## profiles\.mightyhat\.bluetooth\.disable + + + +Apply the ` disable-bt ` device tree overlay from +` raspberrypi/firmware `\. + + + +*Type:* +boolean + + + +*Default:* +` true ` + +*Declared by:* + - [flake\.nix](/flake.nix) + + + +## profiles\.mightyhat\.bluetooth\.miniuart + + + +Apply the ` miniuart-bt ` device tree overlay from +` raspberrypi/firmware `\. + + + +*Type:* +boolean + + + +*Default:* +` false ` + +*Declared by:* + - [flake\.nix](/flake.nix) + + + +## profiles\.mightyhat\.deviceTree + + + +The device-tree_rpi package to use\. + + + +*Type:* +package + + + +*Default:* +` pkgs.device-tree_rpi.device-tree_rpi ` + +*Declared by:* + - [flake\.nix](/flake.nix) + + + +## profiles\.mightyhat\.uboot\.disableConsole + + + +Apply an overlay that redefines the Raspberry-Pi-specific uboot +packages in order to disable the serial console\. + +**Important:** Enabling this option may prevent you from selecting a NixOS boot +generation at the uboot loader screen! + + + +*Type:* +boolean + + + +*Default:* +` false ` + +*Declared by:* + - [flake\.nix](/flake.nix) + + + +## services\.moteino-gateway\.enable + + + +Whether to enable the LowPowerLab Moteino Gateway\. + + + +*Type:* +boolean + + + +*Default:* +` false ` + + + +*Example:* +` true ` + +*Declared by:* + - [flake\.nix](/flake.nix) + + + +## services\.moteino-gateway\.package + + + +The package providing the LowPowerLab Moteino Gateway\. + + + +*Type:* +package + + + +*Default:* +` "perSystem.config.packages.default" ` + +*Declared by:* + - [flake\.nix](/flake.nix) + + + +## services\.moteino-gateway\.contentDir + + + +Directory for holding static content\. At present, used only for +uploaded node icon images\. + +**Note:** Because the gateway systemd service uses ` DynamicUser=yes `, this +directory has to be outside of ` StateDirectory `, ` CacheDirectory `, +and so on, so that the nginx webserver can read its contents\. + + + +*Type:* +absolute path + + + +*Default:* +` "/srv/www/moteino-gateway" ` + +*Declared by:* + - [flake\.nix](/flake.nix) + + + +## services\.moteino-gateway\.metrics + + + +JavaScript scripts for exporting node metrics data\. + +See https://github\.com/LowPowerLab/RaspberryPi-Gateway/blob/master/metrics/examples/_example\.js +for an example metrics script definition\. + + + +*Type:* +attribute set of (submodule) + + + +*Default:* +` { } ` + +*Declared by:* + - [flake\.nix](/flake.nix) + + + +## services\.moteino-gateway\.metrics\.\\.name + + + +Name of the metrics script\. + + + +*Type:* +string + + + +*Default:* +` "‹name›" ` + +*Declared by:* + - [flake\.nix](/flake.nix) + + + +## services\.moteino-gateway\.metrics\.\\.source + + + +Path of the source file containing the metrics script\. + + + +*Type:* +absolute path + +*Declared by:* + - [flake\.nix](/flake.nix) + + + +## services\.moteino-gateway\.metrics\.\\.text + + + +Text of the metrics script\. + + + +*Type:* +null or strings concatenated with “\\n” + + + +*Default:* +` null ` + +*Declared by:* + - [flake\.nix](/flake.nix) + + + +## services\.moteino-gateway\.nodejs + + + +The nodejs package to use\. + + + +*Type:* +package + + + +*Default:* +` pkgs.nodejs.nodejs ` + +*Declared by:* + - [flake\.nix](/flake.nix) + + + +## services\.moteino-gateway\.persistLogs + + + +Whether to persist gateway info and error logs to the systemd +service’s configured ` LogsDirectory ` (by default, +` /var/log/moteino-gateway `)\. + + + +*Type:* +boolean + + + +*Default:* +` false ` + +*Declared by:* + - [flake\.nix](/flake.nix) + + + +## services\.moteino-gateway\.settings + + + +Settings for the LowPowerLab Moteino Gateway\. Defaults are loaded from +` ${services.moteino-gateway.package}/lib/node_modules/RaspberryPi-Gateway/settings.json5 `\. + + + +*Type:* +JSON value + + + +*Default:* +` { } ` + +*Declared by:* + - [flake\.nix](/flake.nix) + + + +## services\.moteino-gateway\.settings\.general + + + +General settings\. + + + +*Type:* +JSON value + + + +*Default:* +` { } ` + +*Declared by:* + - [flake\.nix](/flake.nix) + + + +## services\.moteino-gateway\.settings\.general\.port + + + +Listening port for the web application of the +LowPowerLab Moteino Gateway\. + + + +*Type:* +16 bit unsigned integer; between 0 and 65535 (both inclusive) + + + +*Default:* +` 8081 ` + +*Declared by:* + - [flake\.nix](/flake.nix) + + + +## services\.moteino-gateway\.settings\.general\.socketPort + + + +Listening port for the websocket of the LowPowerLab Moteino Gateway\. + + + +*Type:* +16 bit unsigned integer; between 0 and 65535 (both inclusive) + + + +*Default:* +` 8080 ` + +*Declared by:* + - [flake\.nix](/flake.nix) + + + +## services\.moteino-gateway\.settings\.serial + + + +Serial port settings\. + + + +*Type:* +JSON value + + + +*Default:* +` { } ` + +*Declared by:* + - [flake\.nix](/flake.nix) + + + +## services\.moteino-gateway\.settings\.serial\.baud + + + +Needs to match the serial baud speed in the sketch +running on the Moteino or MightyHat, if any, that is +attached to this machine\. + + + +*Type:* +positive integer, meaning >0 + + + +*Default:* +` 115200 ` + +*Declared by:* + - [flake\.nix](/flake.nix) + + + +## services\.moteino-gateway\.settings\.serial\.port + + + +Serial port for the LowPowerLab Moteino Gateway\. + + + +*Type:* +absolute path + + + +*Default:* +` "/dev/ttyAMA0" ` + +*Declared by:* + - [flake\.nix](/flake.nix) + + diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..da2cebc --- /dev/null +++ b/flake.lock @@ -0,0 +1,225 @@ +{ + "nodes": { + "avrdude-rpi": { + "flake": false, + "locked": { + "lastModified": 1760726709, + "narHash": "sha256-M9VYhA8xCmprOcfPk+qq5dYS8YkddQDGos7W9ViMmfY=", + "owner": "LowPowerLab", + "repo": "avrdude-rpi", + "rev": "56034ea5a1f0be6e22d46d522a1f315054ccc5a9", + "type": "github" + }, + "original": { + "owner": "LowPowerLab", + "repo": "avrdude-rpi", + "type": "github" + } + }, + "devshell": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1768818222, + "narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=", + "owner": "numtide", + "repo": "devshell", + "rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "devshell", + "type": "github" + } + }, + "dream2nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "purescript-overlay": "purescript-overlay", + "pyproject-nix": "pyproject-nix" + }, + "locked": { + "lastModified": 1779999259, + "narHash": "sha256-aLAG+TG7stjp0AOI2uvn4agxrrws2Q+fXMDS8ZuHYmo=", + "owner": "nix-community", + "repo": "dream2nix", + "rev": "dd8a3ebbbfcfafc139dba1323a6c443ec034c9f5", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "dream2nix", + "type": "github" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1696426674, + "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1782949081, + "narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1784120854, + "narHash": "sha256-KesHgItiZPgGX740axSiQLcIQ8D24MDqNpkKYWIek8k=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "753cc8a3a87467296ddd1fa93f0cc3e81120ee46", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-lib": { + "locked": { + "lastModified": 1782614948, + "narHash": "sha256-ePjCwr1sNm9NYUqywL7QfK3JnlS015msC+eBu2zKlp8=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "db3f255737b94216eb71cce308e2912cf6bc2d7c", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, + "purescript-overlay": { + "inputs": { + "flake-compat": "flake-compat", + "nixpkgs": [ + "dream2nix", + "nixpkgs" + ], + "slimlock": "slimlock" + }, + "locked": { + "lastModified": 1728546539, + "narHash": "sha256-Sws7w0tlnjD+Bjck1nv29NjC5DbL6nH5auL9Ex9Iz2A=", + "owner": "thomashoneyman", + "repo": "purescript-overlay", + "rev": "4ad4c15d07bd899d7346b331f377606631eb0ee4", + "type": "github" + }, + "original": { + "owner": "thomashoneyman", + "repo": "purescript-overlay", + "type": "github" + } + }, + "pyproject-nix": { + "inputs": { + "nixpkgs": [ + "dream2nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1763017646, + "narHash": "sha256-Z+R2lveIp6Skn1VPH3taQIuMhABg1IizJd8oVdmdHsQ=", + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "rev": "47bd6f296502842643078d66128f7b5e5370790c", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "type": "github" + } + }, + "root": { + "inputs": { + "avrdude-rpi": "avrdude-rpi", + "devshell": "devshell", + "dream2nix": "dream2nix", + "flake-parts": "flake-parts", + "nixpkgs": "nixpkgs", + "treefmt-nix": "treefmt-nix" + } + }, + "slimlock": { + "inputs": { + "nixpkgs": [ + "dream2nix", + "purescript-overlay", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1688756706, + "narHash": "sha256-xzkkMv3neJJJ89zo3o2ojp7nFeaZc2G0fYwNXNJRFlo=", + "owner": "thomashoneyman", + "repo": "slimlock", + "rev": "cf72723f59e2340d24881fd7bf61cb113b4c407c", + "type": "github" + }, + "original": { + "owner": "thomashoneyman", + "repo": "slimlock", + "type": "github" + } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1780220602, + "narHash": "sha256-eynAfOmbmxJnkp7YewvCEbShNnnYJ9gLLqkzsYtBPeM=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "db947814a175b7ca6ded66e21383d938df01c227", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..7bfb934 --- /dev/null +++ b/flake.nix @@ -0,0 +1,100 @@ +{ + description = "Description for the project"; + + inputs = { + avrdude-rpi.url = "github:LowPowerLab/avrdude-rpi"; + avrdude-rpi.flake = false; + + dream2nix.url = "github:nix-community/dream2nix"; + dream2nix.inputs.nixpkgs.follows = "nixpkgs"; + + devshell.url = "github:numtide/devshell"; + devshell.inputs.nixpkgs.follows = "nixpkgs"; + + flake-parts.url = "github:hercules-ci/flake-parts"; + + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + treefmt-nix.url = "github:numtide/treefmt-nix"; + treefmt-nix.inputs.nixpkgs.follows = "nixpkgs"; + }; + + outputs = inputs: + inputs.flake-parts.lib.mkFlake {inherit inputs;} ({ + inputs, + self, + moduleWithSystem, + ... + } @ toplevel: { + systems = [ + "aarch64-darwin" + "aarch64-linux" + "x86_64-darwin" + "x86_64-linux" + ]; + + imports = [ + inputs.devshell.flakeModule + inputs.treefmt-nix.flakeModule + + ./nix/checks.nix + ./nix/devshells.nix + ./nix/nixos-modules.nix + ./nix/overlays.nix + ./nix/packages.nix + ]; + + perSystem = { + config, + lib, + ... + }: { + apps = lib.mapAttrs (lib.const (lib.getAttr "flakeApp")) config.devShells; + + treefmt = { + flakeFormatter = true; + projectRootFile = "flake.nix"; + + programs.alejandra.enable = true; + #programs.deno.enable = true; + #programs.prettier.enable = true; + #programs.shellcheck.enable = true; + #programs.shfmt.enable = true; + #programs.typos.enable = true; + + settings.global.excludes = [ + "blueprints/*" + "doc/nixos-modules.md" + "*.min.*" + "*.cdr" + "*.dxf" + "*.gif" + "*.ico" + "*.mp3" + "*.png" + "*.svg" + "*.wav" + ]; + }; + }; + + flake = { + nixosConfigurations = { + default = inputs.nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + self.nixosModules.mightyhat + self.nixosModules.moteino-gateway + ({pkgs, ...}: { + boot.isContainer = true; + services.moteino-gateway.enable = true; + system.stateVersion = "24.05"; + environment.systemPackages = with pkgs; [curl]; + fileSystems."/".fsType = "tmpfs"; + }) + ]; + }; + }; + }; + }); +} diff --git a/gateway.js b/gateway.js index 20150ea..43d1a8d 100644 --- a/gateway.js +++ b/gateway.js @@ -29,14 +29,27 @@ // ******************************************************************************************** // Note: In NodeJS modules are loaded synchronously and processed in the order they occur // ******************************************************************************************** -var nconf = require('nconf'); //https://github.com/indexzero/nconf -var JSON5 = require('json5'); //https://github.com/aseemk/json5 -var path = require('path'); -var dbDir = 'data/db'; + +const JSON5 = require('json5'); // https://github.com/aseemk/json5 +const fs = require('fs'); +const path = require('path'); + +const config = require('./config'); + +const nconf = config.nconf; +const coreImagesDir = config.coreImagesDir; +const userImagesDir = config.userImagesDir; +const coreMetricsDir = config.coreMetricsDir; +const userMetricsDir = config.userMetricsDir; +const dbDir = config.dbDir; + +fs.mkdirSync(dbDir, {'recursive': true}); +fs.mkdirSync(userImagesDir, {'recursive': true}); + +global.settings = nconf.get('settings'); + var packageJson = require('./package.json') var coreMetricsFilePath = './metrics/core.js'; -nconf.argv().file({ file: './settings.json5', format: JSON5 }); -global.settings = nconf.get('settings'); var dbLog = require('./logUtil.js'); io = require('socket.io')().listen(settings.general.socketPort.value) //usage in 2.3.0: io = require('socket.io').listen(settings.general.socketPort.value); const { SerialPort } = require('serialport') //https://github.com/node-serialport/node-serialport --> v13.0.0 syntax! @@ -45,9 +58,8 @@ var Datastore = require('nedb'); //https://github var nodemailer = require('nodemailer'); //https://github.com/andris9/Nodemailer var http = require('http'); var url = require('url'); -db = new Datastore({ filename: path.join(__dirname, dbDir, settings.database.name.value), autoload: true }); //used to keep all node/metric data +db = new Datastore({ filename: path.join(dbDir, settings.database.name.value), autoload: true }); //used to keep all node/metric data var dbCompacted = Date.now(); -var fs = require('fs'); var child_process = require('child_process'); var gatewayUptime=''; var gatewayFrequency=''; @@ -55,11 +67,11 @@ global.port=undefined; global.parser=undefined; var unmatchedDataDB = null; if (settings.database.nonMatchesName.value) - unmatchedDataDB = new Datastore({ filename: path.join(__dirname, dbDir, settings.database.nonMatchesName.value), autoload: true }); + unmatchedDataDB = new Datastore({ filename: path.join(dbDir, settings.database.nonMatchesName.value), autoload: true }); require("console-stamp")(console, settings.general.consoleLogDateFormat.value); //timestamp logs - https://github.com/starak/node-console-stamp //HTTP ENDPOINT - accept HTTP: data from the internet/LAN -http.createServer(httpEndPointHandler).listen(8081); +http.createServer(httpEndPointHandler).listen(settings.general.port.value); console.info('*********************************************************************'); console.info('************************* GATEWAY APP START *************************'); @@ -231,7 +243,7 @@ global.sendMessageToGateway = function(msg) { //console.log('sendMessageToGateway: ' + msg.replaceNewlines()); let serialEnabled = settings.serial.enabled && (settings.serial.enabled.value.toString().toLowerCase()==='true') if (!serialEnabled) { console.info('settings.serial.enabled FALSE or NOT FOUND!'); return; } - port.write(msg + '\n', function (err) { + port.write(msg + '\n', function (err) { if (err) return console.error('port.write error: ', err.message) port.drain(); }); @@ -260,7 +272,7 @@ global.handleNodeEvents = function(node) { global.getGraphData = function(nodeId, metricKey, start, end, exportMode) { var sts = Math.floor(start / 1000); //get timestamp in whole seconds var ets = Math.floor(end / 1000); //get timestamp in whole seconds - var logfile = path.join(__dirname, dbDir, dbLog.getLogName(nodeId,metricKey)); + var logfile = path.join(dbDir, dbLog.getLogName(nodeId,metricKey)); var graphData = dbLog.getData(logfile, sts, ets, exportMode ? 100000 : settings.general.graphMaxPoints.value); //100k points when exporting, more points is really pointless var graphOptions={}; for(var k in metricsDef.metrics) @@ -276,19 +288,42 @@ global.getGraphData = function(nodeId, metricKey, start, end, exportMode) { return { graphData:graphData, options : graphOptions }; } -global.getNodeIcons = function(dir, files_, steps){ - files_ = files_ || []; - dir = dir || __dirname + '/www/images'; - steps = steps || 0; - var files = fs.readdirSync(dir); - for (var i in files){ - var name = dir + '/' + files[i]; - if (fs.statSync(name).isDirectory() && steps==0) //recurse 1 level only - getNodeIcons(name, files_, steps+1); - else if (files[i].match(/^icon_.+\.(bmp|png|jpg|jpeg|ico)$/ig)) //images only - files_.push(name.replace(__dirname+'/www/images/','')); +global.getNodeIcons = function({ + dir = coreImagesDir, + root = null, + files = [], + steps = 0, + prefix = 'icon_', + extensions = ['.bmp', '.png', '.jpg', '.jpeg', '.ico'], +} = {}) { + dir = path.resolve(dir); + root = path.resolve(root || dir); + + console.log({'dir': dir, 'root': root, 'files': files, 'steps': steps, 'prefix': prefix, 'extensions': extensions}); + + var basenames = fs.readdirSync(dir); + for (var i in basenames) { + var basename = basenames[i]; + var file = path.join(dir, basename); + + if (fs.statSync(file).isDirectory() && steps==0) //recurse 1 level only + getNodeIcons({'dir': file, 'root': root, 'files': files, 'steps': steps+1, 'prefix': prefix, 'extensions': extensions}); + else if (basename.startsWith(prefix) && extensions.includes(path.extname(file))) + files.push(path.relative(root, file)); } - return files_; + + return files; +} + +global.allNodeIcons = function () { + var files; + + if (coreImagesDir == userImagesDir) + files = []; + else + files = getNodeIcons({'dir': userImagesDir, 'prefix': ''}); + + return getNodeIcons({'dir': coreImagesDir, 'files': files}); } //authorize handshake - make sure the request is proxied from localhost, not from the outside world @@ -333,7 +368,7 @@ io.sockets.on('connection', function (socket) { socket.emit('SETTINGSDEF', settings); broadcastServerInfo(socket); socket.emit('DBCOMPACTED', dbCompacted); - socket.emit('NODEICONS', getNodeIcons()); + socket.emit('NODEICONS', allNodeIcons()); let serialEnabled = settings.serial.enabled && (settings.serial.enabled.value.toString().toLowerCase()==='true'); socket.emit('SVREVENT',`SERIAL-${serialEnabled?(port?(port.isOpen?'CONNECTED':'DISCONNECTED'):'UNAVAILABLE'):'DISABLED'}`); @@ -353,7 +388,7 @@ io.sockets.on('connection', function (socket) { }); socket.on('REFRESHICONS', function () { - io.sockets.emit('NODEICONS', getNodeIcons()); + io.sockets.emit('NODEICONS', allNodeIcons()); }); socket.on('UPDATENODELISTORDER', function (newOrder) { @@ -447,7 +482,7 @@ io.sockets.on('connection', function (socket) { } }); }); - + socket.on('ADDNODEGRAPH', function (nodeId, selectedMetrics) { db.find({ _id : nodeId }, function (err, entries) { if (entries.length == 1) @@ -470,7 +505,7 @@ io.sockets.on('connection', function (socket) { } }); }); - + socket.on('DELETENODEGRAPH', function (nodeId) { db.find({ _id : nodeId }, function (err, entries) { if (entries.length == 1) @@ -493,7 +528,7 @@ io.sockets.on('connection', function (socket) { if (dbNode.metrics) { Object.keys(dbNode.metrics).forEach(function(mKey,index) { //syncronous/blocking call if (dbNode.metrics[mKey].graph == 1) - dbLog.removeMetricLog(path.join(__dirname, dbDir, dbLog.getLogName(dbNode._id, mKey))); + dbLog.removeMetricLog(path.join(dbDir, dbLog.getLogName(dbNode._id, mKey))); }); } } @@ -527,7 +562,7 @@ io.sockets.on('connection', function (socket) { delete(dbNode.metrics[metricKey]); db.update({ _id: dbNode._id }, { $set : dbNode}, {}, function (err, numReplaced) { console.info('DELETENODEMETRIC DB-Replaced:' + numReplaced); }); if (settings.general.keepMetricLogsOnDelete.value != 'true') - dbLog.removeMetricLog(path.join(__dirname, dbDir, dbLog.getLogName(dbNode._id, metricKey))); + dbLog.removeMetricLog(path.join(dbDir, dbLog.getLogName(dbNode._id, metricKey))); io.sockets.emit('UPDATENODE', dbNode); //post it back to all clients to confirm UI changes } }); @@ -547,7 +582,7 @@ io.sockets.on('connection', function (socket) { } }); }); - + socket.on('DELETEMETRICDATA', function (nodeId, metricKey, start, end) { var sts = Math.floor(start / 1000); //get timestamp in whole seconds var ets = Math.floor(end / 1000); //get timestamp in whole seconds @@ -555,7 +590,7 @@ io.sockets.on('connection', function (socket) { if (entries.length == 1) { var dbNode = entries[0]; - var logfile = path.join(__dirname, dbDir, dbLog.getLogName(dbNode._id, metricKey)); + var logfile = path.join(dbDir, dbLog.getLogName(dbNode._id, metricKey)); var count = dbLog.deleteData(logfile, sts, ets); console.info('DELETEMETRICDATA DB-Removed points:' + count); //if (settings.general.keepMetricLogsOnDelete.value != 'true') @@ -571,7 +606,7 @@ io.sockets.on('connection', function (socket) { if (entries.length == 1) { var dbNode = entries[0]; - var logfile = path.join(__dirname, dbDir, dbLog.getLogName(dbNode._id, metricKey)); + var logfile = path.join(dbDir, dbLog.getLogName(dbNode._id, metricKey)); var count = dbLog.editData(logfile, sts, ets, newValue); console.info(`EDITMETRICDATA DB-Updated points:${count} to:${newValue}`); socket.emit('EDITMETRICDATA_OK', count); //post it back to requesting client only @@ -598,7 +633,7 @@ io.sockets.on('connection', function (socket) { socket.on('NODEMESSAGE', function (msg) { sendMessageToNode(msg); }); - + socket.on('SIMULATEDMESSAGE', function (str) { if (str) processSerialData(str, true); }); @@ -702,7 +737,7 @@ io.sockets.on('connection', function (socket) { var dbNode = entries[0]; Object.keys(dbNode.metrics).forEach(function(mKey,index) { //syncronous/blocking call if (dbNode.metrics[mKey].graph == 1) { - var logfile = path.join(__dirname, dbDir, dbLog.getLogName(dbNode._id, mKey)); + var logfile = path.join(dbDir, dbLog.getLogName(dbNode._id, mKey)); var theData = dbLog.getData(logfile, sts, ets, howManyPoints /*settings.general.graphMaxPoints.value*/); theData.label = dbNode.metrics[mKey].label || mKey; sets.push(theData); //100k points when exporting, more points is really pointless @@ -712,7 +747,7 @@ io.sockets.on('connection', function (socket) { } }); }); - + socket.on('UPDATESETTINGSDEF', function (newSettings) { //console.info(`UPDATESETTINGSDEF requested, new settings: ${JSON.stringify(newSettings)}`); var settings = nconf.get('settings'); @@ -739,14 +774,17 @@ io.sockets.on('connection', function (socket) { } if (changed) { - global.settings = settings; + nconf.set('settings', settings) + nconf.save(function (err) { if (err !=null) socket.emit('LOG', 'UPDATESETTINGSDEF ERROR: '+err); else io.sockets.emit('SETTINGSDEF', settings); }); - + + global.settings = nconf.get('settings') + if (portChangedTo) { log = `PORT changed to ${portChangedTo} - reopening serial port @ ${settings.serial.baud.value} baud...`; openPort(true); @@ -777,6 +815,25 @@ io.sockets.on('connection', function (socket) { console.info('PI SHUTDOWN REQUESTED from ' + address); child_process.exec('sudo /sbin/shutdown now "GATEWAY SHUTDOWN REQUEST"', function (msg) { console.info(msg) }); }); + + // https://socket.io/how-to/upload-a-file + socket.on('UPLOADIMAGE', function (name, buffer, callback) { + if (!name) { + callback(null, "missing file name"); + return; + } + + if (!buffer) { + callback(null, "missing file data"); + return; + } + + target = path.join(userImagesDir, name); + + fs.writeFile(target, buffer, function(err) { + callback(name, err); + }); + }); }); //entries should contain the node list and also a node that contains the order (if this was ever added) @@ -916,7 +973,7 @@ global.processSerialData = function (data, simulated) { if (isNumeric(graphValue)) { var ts = Math.floor(Date.now() / 1000); //get timestamp in whole seconds - var logfile = path.join(__dirname, dbDir, dbLog.getLogName(id, matchingMetric.name)); + var logfile = path.join(dbDir, dbLog.getLogName(id, matchingMetric.name)); try { console.log('post: ' + logfile + '[' + ts + ','+graphValue + ']'); dbLog.postData(logfile, ts, graphValue, matchingMetric.duplicateInterval || null); @@ -971,14 +1028,14 @@ global.processSerialData = function (data, simulated) { if (dbEntry==null) { //INSERT if (settings.general.genNodeIfNoMatch.value == true || settings.general.genNodeIfNoMatch.value == 'true' || hasMatchedMetrics) { db.insert(entry, function(err, newDoc) { if (err) {console.error('db.insert fail: ' + err.toString() + ' --- ' + JSON.stringify(newDoc))}}); - console.dbinsert(`[${id}] attempted`, ); + console.info(`[${id}] attempted`, ); io.sockets.emit('UPDATENODE', entry); return; } } else { //UPDATE db.update({ _id: id }, { $set : entry }, {}, function (err, numReplaced) { - //console.dbupdate(`processSerialData('${data.replaceNewlines()}',${simulated?1:0}) ${JSON5.stringify(entry)}`); - console.dbupdate(`[${id}] updated`); + //console.info(`processSerialData('${data.replaceNewlines()}',${simulated?1:0}) ${JSON5.stringify(entry)}`); + console.info(`[${id}] updated`); }); io.sockets.emit('UPDATENODE', entry); } @@ -991,7 +1048,7 @@ global.processSerialData = function (data, simulated) { { somethingMatched=false; validTokenMatched=false; - + if (data.startsWith('DEBUG:')) { return; //it is already logged } @@ -1002,7 +1059,7 @@ global.processSerialData = function (data, simulated) { somethingMatched=true; var tokenMatch = regexpGeneralRequests.exec(match[0]); //format is REQUEST:VALUE:STATUS with VALUE and STATUS optional var partialMatch = false; - + //check "known" REQUESTs if (tokenMatch != null) { updateServerInfo=false; @@ -1020,7 +1077,7 @@ global.processSerialData = function (data, simulated) { db.find({ _id : { $exists: true }, requests : { $exists: true } }, function (err, entries) { var updates = []; var ts = Date.now(); - + for (var i = 0; i < entries.length; i++) { var nodeUpdated=false; var dbNode = entries[i]; @@ -1127,7 +1184,7 @@ function httpEndPointHandler(req, res) { if (isNumeric(graphValue)) { var ts = Math.floor(Date.now() / 1000); //get timestamp in whole seconds - var logfile = path.join(__dirname, dbDir, dbLog.getLogName(id, matchingMetric.name)); + var logfile = path.join(dbDir, dbLog.getLogName(id, matchingMetric.name)); try { console.log(`post: ${logfile} [${ts},${graphValue}]`); dbLog.postData(logfile, ts, graphValue, matchingMetric.duplicateInterval || null); @@ -1281,7 +1338,7 @@ setInterval(function(){ db.find({ _id : { $exists: true }, requests : { $exists: true } }, function (err, entries) { var updates = []; var ts = Date.now(); - + for (var i = 0; i < entries.length; i++) { var nodeUpdated=false; var dbNode = entries[i]; diff --git a/lock.json b/lock.json new file mode 100644 index 0000000..ea0bc4e --- /dev/null +++ b/lock.json @@ -0,0 +1,1374 @@ +{ + "package-lock": { + "name": "RaspberryPi-Gateway", + "version": "9.2.2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "RaspberryPi-Gateway", + "version": "9.2.2", + "license": "CC-BY-NC-SA-4.0", + "dependencies": { + "colors": "~1.4", + "console-stamp": "^3.0.2", + "json5": "~2.2.0", + "merge": "^2.1.1", + "nconf": "~0.11.2", + "nedb": "git+https://github.com/LowPowerLab/nedb.git", + "nodemailer": "^6.6.0", + "samba-client": "^4.0.0", + "serialport": "~13.0.0", + "socket.io": "~4.8.0", + "speedtest-net": "^1.6.2", + "suncalc": "~1.9" + } + }, + "node_modules/@serialport/binding-mock": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@serialport/binding-mock/-/binding-mock-10.2.2.tgz", + "integrity": "sha512-HAFzGhk9OuFMpuor7aT5G1ChPgn5qSsklTFOTUX72Rl6p0xwcSVsRtG/xaGp6bxpN7fI9D/S8THLBWbBgS6ldw==", + "license": "MIT", + "dependencies": { + "@serialport/bindings-interface": "^1.2.1", + "debug": "^4.3.3" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@serialport/bindings-cpp": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/bindings-cpp/-/bindings-cpp-13.0.0.tgz", + "integrity": "sha512-r25o4Bk/vaO1LyUfY/ulR6hCg/aWiN6Wo2ljVlb4Pj5bqWGcSRC4Vse4a9AcapuAu/FeBzHCbKMvRQeCuKjzIQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@serialport/bindings-interface": "1.2.2", + "@serialport/parser-readline": "12.0.0", + "debug": "4.4.0", + "node-addon-api": "8.3.0", + "node-gyp-build": "4.8.4" + }, + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/bindings-cpp/node_modules/@serialport/parser-delimiter": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-delimiter/-/parser-delimiter-12.0.0.tgz", + "integrity": "sha512-gu26tVt5lQoybhorLTPsH2j2LnX3AOP2x/34+DUSTNaUTzu2fBXw+isVjQJpUBFWu6aeQRZw5bJol5X9Gxjblw==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/bindings-cpp/node_modules/@serialport/parser-readline": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-readline/-/parser-readline-12.0.0.tgz", + "integrity": "sha512-O7cywCWC8PiOMvo/gglEBfAkLjp/SENEML46BXDykfKP5mTPM46XMaX1L0waWU6DXJpBgjaL7+yX6VriVPbN4w==", + "license": "MIT", + "dependencies": { + "@serialport/parser-delimiter": "12.0.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/bindings-interface": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@serialport/bindings-interface/-/bindings-interface-1.2.2.tgz", + "integrity": "sha512-CJaUd5bLvtM9c5dmO9rPBHPXTa9R2UwpkJ0wdh9JCYcbrPWsKz+ErvR0hBLeo7NPeiFdjFO4sonRljiw4d2XiA==", + "license": "MIT", + "engines": { + "node": "^12.22 || ^14.13 || >=16" + } + }, + "node_modules/@serialport/parser-byte-length": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-byte-length/-/parser-byte-length-13.0.0.tgz", + "integrity": "sha512-32yvqeTAqJzAEtX5zCrN1Mej56GJ5h/cVFsCDPbF9S1ZSC9FWjOqNAgtByseHfFTSTs/4ZBQZZcZBpolt8sUng==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-cctalk": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-cctalk/-/parser-cctalk-13.0.0.tgz", + "integrity": "sha512-RErAe57g9gvnlieVYGIn1xymb1bzNXb2QtUQd14FpmbQQYlcrmuRnJwKa1BgTCujoCkhtaTtgHlbBWOxm8U2uA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-delimiter": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-delimiter/-/parser-delimiter-13.0.0.tgz", + "integrity": "sha512-Qqyb0FX1avs3XabQqNaZSivyVbl/yl0jywImp7ePvfZKLwx7jBZjvL+Hawt9wIG6tfq6zbFM24vzCCK7REMUig==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-inter-byte-timeout": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-inter-byte-timeout/-/parser-inter-byte-timeout-13.0.0.tgz", + "integrity": "sha512-a0w0WecTW7bD2YHWrpTz1uyiWA2fDNym0kjmPeNSwZ2XCP+JbirZt31l43m2ey6qXItTYVuQBthm75sPVeHnGA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-packet-length": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-packet-length/-/parser-packet-length-13.0.0.tgz", + "integrity": "sha512-60ZDDIqYRi0Xs2SPZUo4Jr5LLIjtb+rvzPKMJCohrO6tAqSDponcNpcB1O4W21mKTxYjqInSz+eMrtk0LLfZIg==", + "license": "MIT", + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/@serialport/parser-readline": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-readline/-/parser-readline-13.0.0.tgz", + "integrity": "sha512-dov3zYoyf0dt1Sudd1q42VVYQ4WlliF0MYvAMA3MOyiU1IeG4hl0J6buBA2w4gl3DOCC05tGgLDN/3yIL81gsA==", + "license": "MIT", + "dependencies": { + "@serialport/parser-delimiter": "13.0.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-ready": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-ready/-/parser-ready-13.0.0.tgz", + "integrity": "sha512-JNUQA+y2Rfs4bU+cGYNqOPnNMAcayhhW+XJZihSLQXOHcZsFnOa2F9YtMg9VXRWIcnHldHYtisp62Etjlw24bw==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-regex": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-regex/-/parser-regex-13.0.0.tgz", + "integrity": "sha512-m7HpIf56G5XcuDdA3DB34Z0pJiwxNRakThEHjSa4mG05OnWYv0IG8l2oUyYfuGMowQWaVnQ+8r+brlPxGVH+eA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-slip-encoder": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-slip-encoder/-/parser-slip-encoder-13.0.0.tgz", + "integrity": "sha512-fUHZEExm6izJ7rg0A1yjXwu4sOzeBkPAjDZPfb+XQoqgtKAk+s+HfICiYn7N2QU9gyaeCO8VKgWwi+b/DowYOg==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/parser-spacepacket": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/parser-spacepacket/-/parser-spacepacket-13.0.0.tgz", + "integrity": "sha512-DoXJ3mFYmyD8X/8931agJvrBPxqTaYDsPoly9/cwQSeh/q4EjQND9ySXBxpWz5WcpyCU4jOuusqCSAPsbB30Eg==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/stream": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/stream/-/stream-13.0.0.tgz", + "integrity": "sha512-F7xLJKsjGo2WuEWMSEO1SimRcOA+WtWICsY13r0ahx8s2SecPQH06338g28OT7cW7uRXI7oEQAk62qh5gHJW3g==", + "license": "MIT", + "dependencies": { + "@serialport/bindings-interface": "1.2.2", + "debug": "4.4.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz", + "integrity": "sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==", + "license": "MIT", + "dependencies": { + "es6-promisify": "^5.0.0" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/async": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", + "integrity": "sha512-nSVgobk4rv61R9PUSDtYt7mPVB2olxNR5RWJcAsH676/ef11bUZwvu7+RGYrYauVdDPcO519v68wRhXQtxsV9w==", + "license": "MIT" + }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, + "node_modules/binary-search-tree": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/binary-search-tree/-/binary-search-tree-0.2.5.tgz", + "integrity": "sha512-CvNVKS6iXagL1uGwLagSXz1hzSMezxOuGnFi5FHGKqaTO3nPPWrAbyALUzK640j+xOTVm7lzD9YP8W1f/gvUdw==", + "dependencies": { + "underscore": "~1.4.4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/console-stamp": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/console-stamp/-/console-stamp-3.1.2.tgz", + "integrity": "sha512-ab66x3NxOTxPuq71dI6gXEiw2X6ql4Le5gZz0bm7FW3FSCB00eztra/oQUuCoCGlsyKOxtULnHwphzMrRtzMBg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "dateformat": "^4.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/draftlog": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/draftlog/-/draftlog-1.0.13.tgz", + "integrity": "sha512-GeMWOpXERBpfVDK6v7m0x1hPg8+g8ZsZWqJl2T17wHqrm4h8fnjiZmXcnCrmwogAc6R3YTxFXax15wezfuyCUw==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/engine.io": { + "version": "6.6.9", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz", + "integrity": "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", + "license": "MIT" + }, + "node_modules/es6-promisify": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", + "integrity": "sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ==", + "license": "MIT", + "dependencies": { + "es6-promise": "^4.0.3" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/http-proxy-agent": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-2.1.0.tgz", + "integrity": "sha512-qwHbBLV7WviBl0rQsOzH6o5lwyOIvwp/BdFnvVxXORldu5TmjFfjzBcWUWS5kWAZhmv+JtiDhSuQCp4sBfbIgg==", + "license": "MIT", + "dependencies": { + "agent-base": "4", + "debug": "3.1.0" + }, + "engines": { + "node": ">= 4.5.0" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-3.0.1.tgz", + "integrity": "sha512-+ML2Rbh6DAuee7d07tYGEKOEi2voWPUGan+ExdPbPW6Z3svq+JCqr0v8WmKPOkz1vOVykPCBSuobe7G8GJUtVg==", + "license": "MIT", + "dependencies": { + "agent-base": "^4.3.0", + "debug": "^3.1.0" + }, + "engines": { + "node": ">= 4.5.0" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/ini": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", + "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lie": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", + "integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/localforage": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz", + "integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==", + "license": "Apache-2.0", + "dependencies": { + "lie": "3.1.1" + } + }, + "node_modules/merge": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/merge/-/merge-2.1.1.tgz", + "integrity": "sha512-jz+Cfrg9GWOZbQAnDQ4hlVnQky+341Yk5ru8bZSe6sIDTCIg8n9i/u7hSQGSVOF3C7lH6mGtqjkiT9G4wFLL0w==", + "license": "MIT" + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nconf": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/nconf/-/nconf-0.11.4.tgz", + "integrity": "sha512-YaDR846q11JnG1vTrhJ0QIlhiGY6+W1bgWtReG9SS3vkTl3AoNwFvUItdhG6/ZjGCfWpUVuRTNEBTDAQ3nWhGw==", + "license": "MIT", + "dependencies": { + "async": "^1.4.0", + "ini": "^2.0.0", + "secure-keys": "^1.0.0", + "yargs": "^16.1.1" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/nedb": { + "version": "1.8.0", + "resolved": "git+https://git@github.com/LowPowerLab/nedb.git#beb22a683ab6745058fede3c3f86178b3c3c3fba", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "async": "0.2.10", + "binary-search-tree": "0.2.5", + "localforage": "^1.3.0", + "mkdirp": "~0.5.1", + "underscore": "~1.4.4" + } + }, + "node_modules/nedb/node_modules/async": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", + "integrity": "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-addon-api": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.3.0.tgz", + "integrity": "sha512-8VOpLHFrOQlAH+qA0ZzuGRlALRA6/LVh8QJldbrC4DY0hXoMP0l4Acq8TzFC018HztWiRqyCEj2aTWY2UvnJUg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/nodemailer": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/samba-client": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/samba-client/-/samba-client-4.0.0.tgz", + "integrity": "sha512-g9SBG9i1ys3aRWQqc8Gdd+IeGhIJky1xNamoLnSD6DIpVHZC3jbYGceQKbZOA3adRacKDU/UEh4KxXjeEJjIdw==", + "license": "MIT", + "dependencies": { + "execa": "^5.0.0" + } + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/secure-keys": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/secure-keys/-/secure-keys-1.0.0.tgz", + "integrity": "sha512-nZi59hW3Sl5P3+wOO89eHBAAGwmCPd2aE1+dLZV5MO+ItQctIvAqihzaAXIQhvtH4KJPxM080HsnqltR2y8cWg==", + "license": "MIT" + }, + "node_modules/serialport": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/serialport/-/serialport-13.0.0.tgz", + "integrity": "sha512-PHpnTd8isMGPfFTZNCzOZp9m4mAJSNWle9Jxu6BPTcWq7YXl5qN7tp8Sgn0h+WIGcD6JFz5QDgixC2s4VW7vzg==", + "license": "MIT", + "dependencies": { + "@serialport/binding-mock": "10.2.2", + "@serialport/bindings-cpp": "13.0.0", + "@serialport/parser-byte-length": "13.0.0", + "@serialport/parser-cctalk": "13.0.0", + "@serialport/parser-delimiter": "13.0.0", + "@serialport/parser-inter-byte-timeout": "13.0.0", + "@serialport/parser-packet-length": "13.0.0", + "@serialport/parser-readline": "13.0.0", + "@serialport/parser-ready": "13.0.0", + "@serialport/parser-regex": "13.0.0", + "@serialport/parser-slip-encoder": "13.0.0", + "@serialport/parser-spacepacket": "13.0.0", + "@serialport/stream": "13.0.0", + "debug": "4.4.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz", + "integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.21.0" + } + }, + "node_modules/socket.io-adapter/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz", + "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/speedtest-net": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/speedtest-net/-/speedtest-net-1.6.2.tgz", + "integrity": "sha512-0rfBDPVLzvDbMUKrFWvF6kvtXxllXGZRkFL0aCWJ5y8uXlEfZDo6IEPACZwJbKt/fGqFjJLJRHnG62vJmLLpRA==", + "license": "MIT", + "dependencies": { + "chalk": "^2.4.1", + "draftlog": "^1.0.12", + "http-proxy-agent": "^2.0.0", + "https-proxy-agent": "^3.0.0", + "xml2js": "^0.4.4" + }, + "bin": { + "speedtest-net": "bin/index.js" + } + }, + "node_modules/speedtest-net/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/speedtest-net/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/speedtest-net/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/speedtest-net/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/speedtest-net/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/speedtest-net/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/suncalc": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/suncalc/-/suncalc-1.9.0.tgz", + "integrity": "sha512-vMJ8Byp1uIPoj+wb9c1AdK4jpkSKVAywgHX0lqY7zt6+EWRRC3Z+0Ucfjy/0yxTVO1hwwchZe4uoFNqrIC24+A==" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/underscore": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.4.4.tgz", + "integrity": "sha512-ZqGrAgaqqZM7LGRzNjLnw5elevWb5M8LEoDMadxIW3OWbcv72wMMgKdwOKpd5Fqxe8choLD8HN3iSj3TUh/giQ==" + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml2js": { + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", + "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", + "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==", + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "license": "ISC", + "engines": { + "node": ">=10" + } + } + } + }, + "invalidationHash": "4a56986d6d78137ab57bb57ba82e496280ec52d9ee02c674797339f3fbcd8051" +} \ No newline at end of file diff --git a/metrics/core.js b/metrics/core.js index b08934d..6f10bc6 100644 --- a/metrics/core.js +++ b/metrics/core.js @@ -11,11 +11,11 @@ // ******************************************************************************************** // (C) Felix Rusu, Low Power Lab LLC (2020), http://lowpowerlab.com/contact // ******************************************************************************************** -var config = require('nconf'); -var JSON5 = require('json5'); var suncalc = require('suncalc'); //https://github.com/mourner/suncalc -config.argv().file({ file: require('path').resolve(__dirname, '../settings.json5'), format: JSON5 }); -var settings = config.get('settings'); //these are local to avoid runtime errors but in events they will reference the global settings declared in gateway.js + +//these are local to avoid runtime errors but in events they will reference the +//global settings declared in gateway.js +var settings = require('../config').nconf.get('settings'); // ****************************************************************************************************************************************** // SAMPLE METRICS DEFINITIONS diff --git a/nix/checks.nix b/nix/checks.nix new file mode 100644 index 0000000..6a31fb0 --- /dev/null +++ b/nix/checks.nix @@ -0,0 +1,30 @@ +{self, ...}: { + perSystem = { + lib, + pkgs, + ... + }: { + checks = { + default = pkgs.testers.nixosTest { + name = "moteino-gateway"; + nodes.default = { + lib, + pkgs, + ... + }: { + imports = [ + self.nixosModules.default + ]; + + services.moteino-gateway.enable = true; + }; + testScript = '' + start_all() + default.wait_for_unit("moteino-gateway.service") + default.wait_for_open_port(8080) + default.wait_until_succeeds("${lib.getExe pkgs.curl} --resolve moteino-gateway:80:127.0.0.1 -fLI http://moteino-gateway/ 1>&2") + ''; + }; + }; + }; +} diff --git a/nix/devshells.nix b/nix/devshells.nix new file mode 100644 index 0000000..f83a037 --- /dev/null +++ b/nix/devshells.nix @@ -0,0 +1,52 @@ +{ + perSystem = { + config, + lib, + pkgs, + ... + }: { + devshells = { + default = { + commands = + [ + { + name = "dream2nix-lock"; + command = '' + cdup="$(git rev-parse --show-cdup)" || exit + if [[ -n "$cdup" ]]; then + cd "$cdup" + fi + exec -a "$0" nix run "$@" '.#default.lock' + ''; + help = "Lock dependencies in lock.json"; + } + + { + name = "mkoptdocs"; + command = '' + cd "$(git rev-parse --show-cdup)" || exit + while read -r out_path; do + install -Dm0644 "$out_path" ./doc/nixos-modules.md + done < <(nix build "$@" --no-link --print-out-paths '.#docs') + ''; + help = "Build NixOS module options documentation"; + } + + { + package = config.treefmt.build.wrapper; + } + + { + package = pkgs.act; + } + ] + ++ lib.pipe config.packages [ + (lib.flip builtins.removeAttrs ["default"]) + (builtins.attrValues) + (lib.filter (package: package ? meta.mainProgram)) + (map (package: {inherit package;})) + ]; + }; + }; + }; +} diff --git a/nix/modules/nixos/mightyhat.nix b/nix/modules/nixos/mightyhat.nix new file mode 100644 index 0000000..29ce938 --- /dev/null +++ b/nix/modules/nixos/mightyhat.nix @@ -0,0 +1,239 @@ +{ + self, + moduleWithSystem, + ... +}: +moduleWithSystem ( + {config} @ perSystem: { + config, + options, + lib, + pkgs, + utils, + ... + } @ nixos: let + inherit (lib) mkOption types; + cfg = config.profiles.mightyhat; + description = "LowPowerLab Moteino Gateway Raspberry Pi profile"; + mgCfg = config.services.moteino-gateway; + in { + imports = [ + # In order to access the configured serial device so that we can + # determine whether we should set pins 14 and 15 to their `ALT0` + # functions. + self.nixosModules.moteino-gateway + ]; + + options.profiles.mightyhat = { + enable = lib.mkEnableOption "the ${description}"; + + apply-overlays-dtmerge = { + enable = mkOption { + type = types.bool; + default = true; + description = '' + Merge device tree overlays with {command}`dtmerge` from + {package}`libraspberrypi`. + + Additionally, supports merging compiled overlays from + {package}`device-tree_rpi`, even if they specify a `compatible` + stanza that is different than that nominally required by the target + device tree. + ''; + }; + }; + + uboot = { + disableConsole = mkOption { + type = types.bool; + default = false; + description = '' + Apply an overlay that redefines the Raspberry-Pi-specific uboot + packages in order to disable the serial console. + + ::: {.important} + Enabling this option may prevent you from selecting a NixOS boot + generation at the uboot loader screen! + ::: + ''; + }; + }; + + dtboSources = mkOption { + type = types.nonEmptyListOf types.path; + defaultText = lib.literalExpression '' + ["/some/directory/or/other"] + ''; + description = '' + List of directories containing compiled device tree overlays + (`.dtbo`s) **in descending order of priority**. That is, this module + will prefer to use `.dtbo`s from earlier entries in this list over + homonymous `.dtbo`s from entries later in the list. + ''; + }; + + resolveDTBO = mkOption { + type = types.functionTo types.path; + readOnly = true; + default = let + sources = lib.unique cfg.dtboSources; + ext = ".dtbo"; + in + lib.flip lib.pipe [ + (name: "${name}${lib.optionalString (!(lib.hasSuffix ext name)) ext}") + (baseName: map (source: "${source}/${baseName}") sources) + (lib.filter builtins.pathExists) + lib.head + ]; + defaultText = lib.literalExpression '' + resolve "disable-bt" + => "/some/directory/or/other/disable-bt.dtbo" + ''; + description = '' + Function from a device tree overlay name to a compiled device tree + overlay (`.dtbo`). + ''; + }; + + overlays = mkOption { + type = types.path; + default = config.hardware.deviceTree.overlays; + description = '' + Path containing compiled device tree overlay files. + ''; + }; + + dtbo = mkOption { + type = types.attrsOf (types.submodule ({ + config, + name, + ... + }: { + options = { + name = mkOption { + type = types.str; + default = name; + description = '' + Name of this DTBO. Used as the default basename (minus + extension) of {option}`file`. + ''; + }; + + file = mkOption { + type = types.path; + default = cfg.resolveDTBO config.name; + defaultText = lib.literalExpression '' + config.profiles.mightyhat.resolveDTBO ${config.name} + ''; + description = '' + Path of the DTBO file. + ''; + }; + + enable = mkOption { + type = types.bool; + default = true; + description = '' + Apply this DTBO to the device tree. + ''; + }; + + filter = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Filter to pass through to the generated + {option}`hardware.deviceTree.overlays` entry. + ''; + }; + }; + })); + }; + + # Disable bluetooth per + # https://lowpowerlab.com/guide/mightyhat/pi3-compatibility/, or + # put it on the miniuart. + bluetooth = { + disable = mkOption { + type = types.bool; + default = true; + description = '' + Apply the `disable-bt` device tree overlay from + `raspberrypi/firmware`. + ''; + }; + + miniuart = mkOption { + type = types.bool; + default = false; + description = '' + Apply the `miniuart-bt` device tree overlay from + `raspberrypi/firmware`. + ''; + }; + }; + }; + + config = lib.mkIf cfg.enable (lib.mkMerge [ + { + environment.systemPackages = [ + perSystem.config.packages.avrdude-rpi + perSystem.config.packages.ubootToolsWithEnvtools + ]; + + boot.blacklistedKernelModules = lib.optionals cfg.bluetooth.disable [ + "bluetooth" + "btusb" + "btsdio" + ]; + + hardware.bluetooth.enable = !cfg.bluetooth.disable; + + hardware.deviceTree = { + enable = true; + + overlays = lib.pipe cfg.dtbo [ + builtins.attrValues + (lib.filter (dtbo: dtbo.enable)) + (map (dtbo: { + inherit (dtbo) name filter; + dtboFile = dtbo.file; + })) + ]; + }; + + hardware.enableRedistributableFirmware = lib.mkDefault true; + + profiles.mightyhat = { + dtboSources = let + inherit (config.hardware.deviceTree) dtbSource; + in + (lib.toList (dtbSource.overlays or [])) + ++ [ + "${dtbSource}/overlays" + pkgs.device-tree_rpi.overlays + "${pkgs.raspberrypifw}/share/raspberrypi/boot/overlays" + ]; + + dtbo = { + # FIXME no bluetooth on RPi Zero; add a filter to the `disable-bt` + # dtbo. + disable-bt.enable = cfg.bluetooth.disable; + miniuart-bt.enable = cfg.bluetooth.miniuart; + }; + }; + + nixpkgs.overlays = + (lib.optional cfg.apply-overlays-dtmerge.enable self.overlays.device-tree-apply-overlays-dtmerge) + ++ (lib.optional cfg.uboot.disableConsole self.overlays.uboot-disable-console); + + warnings = lib.optional (!cfg.apply-overlays-dtmerge.enable) (lib.concatStringsSep " " [ + "it appears that this configuration is using the default implementation of `deviceTree.applyOverlays`." + "This is problematic on Raspberry Pis." + "Please consider setting `profiles.mightyhat.apply-overlays-dtmerge = true` in your configuration." + "Without this, Bluetooth is likely to remain enabled on the non-mini UART, and `/dev/ttyAMA0` will be unavailable." + ]); + } + ]); + } +) diff --git a/nix/modules/nixos/moteino-gateway.nix b/nix/modules/nixos/moteino-gateway.nix new file mode 100644 index 0000000..f1a3e7a --- /dev/null +++ b/nix/modules/nixos/moteino-gateway.nix @@ -0,0 +1,408 @@ +{moduleWithSystem, ...}: +moduleWithSystem ( + {config} @ perSystem: { + config, + options, + lib, + pkgs, + utils, + ... + } @ nixos: let + inherit (lib) mkOption types; + + cfg = config.services.moteino-gateway; + opts = options.services.moteino-gateway; + + jsonFmt = pkgs.formats.json {}; + + description = "LowPowerLab Moteino Gateway"; + in { + options.services.moteino-gateway = { + enable = lib.mkEnableOption "the ${description}"; + + package = mkOption { + type = types.package; + default = perSystem.config.packages.default; + defaultText = "perSystem.config.packages.default"; + description = '' + The package providing the ${description}. + ''; + }; + + nodejs = lib.mkPackageOption pkgs "nodejs" { + default = "nodejs"; + pkgsText = "pkgs.nodejs"; + }; + + contentDir = mkOption { + type = types.path; + default = "/srv/www/moteino-gateway"; + description = '' + Directory for holding static content. At present, used only for + uploaded node icon images. + + ::: {.note} + Because the gateway systemd service uses `DynamicUser=yes`, this + directory has to be outside of `StateDirectory`, `CacheDirectory`, + and so on, so that the nginx webserver can read its contents. + ::: + ''; + }; + + persistLogs = mkOption { + type = types.bool; + default = false; + description = '' + Whether to persist gateway info and error logs to the systemd + service's configured `LogsDirectory` (by default, + `/var/log/moteino-gateway`). + ''; + }; + + metrics = mkOption { + default = {}; + description = '' + JavaScript scripts for exporting node metrics data. + + See https://github.com/LowPowerLab/RaspberryPi-Gateway/blob/master/metrics/examples/_example.js + for an example metrics script definition. + ''; + type = types.attrsOf (types.submodule ({ + name, + config, + options, + ... + }: { + options = { + name = mkOption { + # FIXME remove "/" characters and ensure that the name has + # the extension `.js`. + type = types.str; + default = name; + description = '' + Name of the metrics script. + ''; + }; + + basename = mkOption { + type = types.strMatching "^[^/]+\\.js$"; + internal = true; + readOnly = true; + defaultText = "${name}.js"; + }; + + text = mkOption { + type = types.nullOr types.lines; + default = null; + description = '' + Text of the metrics script. + ''; + }; + + source = mkOption { + type = types.path; + description = '' + Path of the source file containing the metrics script. + ''; + }; + }; + + config = { + basename = let + noSlashes = lib.replaceStrings ["/"] ["__"] config.name; + in + if lib.hasSuffix ".js" noSlashes + then noSlashes + else "${noSlashes}.js"; + + source = lib.mkIf (config.text != null) (lib.mkDerivedConfig options.text (pkgs.writeTextDir "metrics/${config.basename}")); + }; + })); + }; + + settings = mkOption { + default = {}; + description = '' + Settings for the ${description}. Defaults are loaded from + {option}`''${services.moteino-gateway.package}/lib/node_modules/RaspberryPi-Gateway/settings.json5`. + ''; + type = types.submodule { + freeformType = jsonFmt.type; + options = { + general = mkOption { + description = "General settings."; + default = {}; + type = types.submodule { + freeformType = jsonFmt.type; + options = { + port = mkOption { + type = types.port; + default = 8081; + description = '' + Listening port for the web application of the + ${description}. + ''; + }; + + socketPort = mkOption { + type = types.port; + default = 8080; + description = '' + Listening port for the websocket of the ${description}. + ''; + }; + }; + }; + }; + + serial = mkOption { + description = "Serial port settings."; + default = {}; + type = types.submodule { + freeformType = jsonFmt.type; + options = { + port = mkOption { + type = types.path; + default = + if (config.profiles.mightyhat.bluetooth.disable or false) + then "/dev/ttyAMA0" + else "/dev/ttyS0"; + defaultText = lib.literalExpression '' + if config.profiles.mightyhat.bluetooth.disable then "/dev/ttyAMA0" else "/dev/ttyS0" + ''; + description = '' + Serial port for the ${description}. + ''; + }; + + baud = mkOption { + type = types.ints.positive; + default = 115200; + description = '' + Needs to match the serial baud speed in the sketch + running on the Moteino or MightyHat, if any, that is + attached to this machine. + ''; + }; + }; + }; + }; + }; + }; + }; + }; + + config = lib.mkIf cfg.enable { + services.nginx = { + enable = lib.mkDefault true; + + virtualHosts.moteino-gateway = {name, ...}: let + unit = nixos.config.systemd.services.${name}; + root = "${cfg.package}/lib/node_modules/RaspberryPi-Gateway/www"; + in { + inherit root; + + extraConfig = '' + index index.html index.htm; + error_page 500 502 503 504 /50x.html; + ''; + + locations = { + "/images/" = { + root = cfg.contentDir; + extraConfig = '' + try_files $uri @fallback; + ''; + }; + + "/socket.io/" = { + proxyWebsockets = true; + recommendedProxySettings = true; + proxyPass = "http://localhost:${toString cfg.settings.general.socketPort}"; + }; + + "/httpendpoint/" = { + proxyWebsockets = true; + recommendedProxySettings = true; + proxyPass = "http://localhost:${toString cfg.settings.general.port}"; + }; + + "/50x.html" = { + extraConfig = '' + root html; + ''; + }; + + "@fallback" = { + inherit root; + }; + }; + }; + }; + + services.logrotate = lib.mkIf cfg.persistLogs { + enable = lib.mkDefault true; + settings = { + moteino-gateway = {name, ...}: let + unit = nixos.config.systemd.services.${name}; + logsDirectories = lib.toList (unit.serviceConfig.LogsDirectory or ["moteino-gateway"]); + logsDirectory = lib.head logsDirectories; + in { + files = [ + "/var/log/${logsDirectory}/*.log" + "/var/log/${logsDirectory}/*.err" + ]; + size = "20M"; + missingok = true; + rotate = 20; + dateext = true; + dateformat = "-%Y-%m-%d"; + compress = true; + notifempty = true; + nocreate = true; + copytruncate = true; + }; + }; + }; + + systemd.services.moteino-gateway = {name, ...}: let + app = "${cfg.package}/lib/node_modules/RaspberryPi-Gateway"; + metrics = pkgs.buildEnv { + name = "${name}-metrics"; + paths = lib.mapAttrsToList (_: metric: "${metric.source}") cfg.metrics; + pathsToLink = ["/metrics"]; + }; + in { + inherit description; + script = let + secretSnippet = lib.pipe cfg.settings [ + (lib.mapAttrsRecursive (_: value: {inherit value;})) + (settings: {inherit settings;}) + (lib.flip utils.genJqSecretsReplacementSnippet "settings.json") + ]; + in '' + export MOTEINO_GATEWAY_STATE_DIRECTORY="''${STATE_DIRECTORY%%:*}" + (cd "$MOTEINO_GATEWAY_STATE_DIRECTORY" && ${secretSnippet}) + ${lib.optionalString cfg.persistLogs '' + logsdir="''${LOGS_DIRECTORY%%:*}" + exec 1>>"''${logsdir}/info.log" + exec 2> >(exec ${pkgs.coreutils}/bin/tee -a "''${logsdir}/error.log" 1>&2) + ''} + exec ${cfg.nodejs}/bin/node ${app}/gateway.js + ''; + after = ["network.target"]; + wantedBy = ["multi-user.target"]; + environment = { + NODE_ENV = "production"; + MOTEINO_GATEWAY_CONTENT_DIRECTORY = cfg.contentDir; + + # For email and SMS integrations, among other things. + NODE_EXTRA_CA_CERTS = config.security.pki.caBundle; + }; + serviceConfig = { + DynamicUser = true; + + SupplementaryGroups = [ + "dialout" # for serial port access + config.services.nginx.group # for static files + ]; + + Restart = "always"; + RestartSec = 3; + + ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID"; + + CacheDirectory = name; + LogsDirectory = lib.mkIf cfg.persistLogs name; + RuntimeDirectory = name; + StateDirectory = name; + + StateDirectoryMode = "0750"; + + RootDirectory = "%t/${name}"; + WorkingDirectory = app; # to load files relative to `__dirname` + + MountAPIVFS = true; + + # This service uses its own mount namespace (via `RootDirectory`), + # and we need to bind-mount certain files in order to facilitate + # essential functionality. + BindPaths = [ + # So that the gateway can read the specified serial port. + "-${cfg.settings.serial.port}" + + # The icon upload path. + cfg.contentDir + ]; + + BindReadOnlyPaths = [ + # Stores the gateway application's runtime dependencies. + builtins.storeDir + + # Mount the metrics directory into the expected location. + "${metrics}/metrics:%S/${name}/metrics" + + # Permits loggint to the system journal. + "/dev/log" + "/run/systemd/journal/socket" + "/run/systemd/journal/stdout" + + # Helps ensure that name resolution works. + "-/etc/hosts" + "-/etc/nsswitch.conf" + "-/etc/resolv.conf" + + # So that the gateway knows the correct local time zone. + "-/etc/localtime" + ]; + + # Uploaded image files need to be readable to nginx, so + # no `0027` here. + UMask = "0022"; + + # Security settings. + CapabilityBoundingSet = [""]; + DeviceAllow = [cfg.settings.serial.port]; + DevicePolicy = "closed"; + LockPersonality = true; + #MemoryDenyWriteExecute = true; # evidently enabling this breaks NodeJS + NoNewPrivileges = true; + PrivateDevices = true; + PrivateIPC = true; + PrivateTmp = true; + ProcSubset = "pid"; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectProc = "invisible"; + ProtectSystem = "strict"; + RemoveIPC = true; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + "AF_NETLINK" + "AF_UNIX" + ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + # `@pkey` needed; NodeJS calls `pkey_alloc`. + SystemCallFilter = [ + "@system-service @resources @pkey" + "~@privileged" + ]; + }; + }; + + systemd.tmpfiles.rules = [ + "d ${cfg.contentDir} 0770 ${config.services.nginx.user} ${config.services.nginx.group} - -" + "d ${cfg.contentDir}/images 0770 ${config.services.nginx.user} ${config.services.nginx.group} - -" + ]; + }; + } +) diff --git a/nix/nixos-modules.nix b/nix/nixos-modules.nix new file mode 100644 index 0000000..4641c8d --- /dev/null +++ b/nix/nixos-modules.nix @@ -0,0 +1,24 @@ +{ + self, + flake-parts-lib, + lib, + moduleWithSystem, + ... +}: { + flake = { + nixosModules = let + importApply = lib.importApply or flake-parts-lib.importApply; + importModule = src: let + mod = importApply src {inherit self moduleWithSystem;}; + in + { + key = mod._file; + } + // mod; + in { + default = self.nixosModules.moteino-gateway; + mightyhat = importModule ./modules/nixos/mightyhat.nix; + moteino-gateway = importModule ./modules/nixos/moteino-gateway.nix; + }; + }; +} diff --git a/nix/overlays.nix b/nix/overlays.nix new file mode 100644 index 0000000..d4b7e7d --- /dev/null +++ b/nix/overlays.nix @@ -0,0 +1,28 @@ +{ + self, + inputs, + ... +}: { + imports = [ + inputs.flake-parts.flakeModules.easyOverlay + ]; + + flake = { + overlays = { + device-tree-apply-overlays-dtmerge = import ./overlays/device-tree-apply-overlays-dtmerge.nix; + uboot-disable-console = import ./overlays/uboot-disable-console.nix; + raspberrypi-utils = final: prev: let + applied = self.flake.overlays.default final prev; + in { + raspberrypi-userland = prev.libraspberrypi; + libraspberrypi = applied.raspberrypi-utils; + }; + }; + }; + + perSystem = {config, ...}: { + overlayAttrs = { + inherit (config.packages) avrdude-rpi avrdude-rpi-autoreset raspberrypi-utils; + }; + }; +} diff --git a/nix/overlays/device-tree-apply-overlays-dtmerge.nix b/nix/overlays/device-tree-apply-overlays-dtmerge.nix new file mode 100644 index 0000000..a216ba0 --- /dev/null +++ b/nix/overlays/device-tree-apply-overlays-dtmerge.nix @@ -0,0 +1,93 @@ +# Modification of nixpkgs deviceTree.applyOverlays to resolve https://github.com/NixOS/nixpkgs/issues/125354. +# +# Derived from https://github.com/NixOS/nixpkgs/blob/916ca8f2b0c208def051f8ea9760c534a40309db/pkgs/os-specific/linux/device-tree/default.nix. +# +# This replaces the default device tree overlay application process +# to use `dtmerge` from `libraspberrypi`. The default overlay +# application process can be problematic on Raspberry Pis due to +# (among other things) issues like this: +# +# https://github.com/raspberrypi/firmware/issues/1718 +# +# and `fdtget`-based compatibility checks, which yield false +# negatives (overlay is compatible, but the check says it is not): +# +# $ dtbCompat="$(fdtget -t s ./boot/bcm2710-rpi-3-b-plus.dtb / compatible | tee /dev/stderr)" +# raspberrypi,3-model-b-plus brcm,bcm2837 +# $ overlayCompat="$(fdtget -t s ./boot/overlays/disable-bt.dtbo / compatible | tee /dev/stderr)" +# brcm,bcm2835 +# $ [[ "$dtbCompat" =~ "$overlayCompat" ]] ; echo "$?" +# 1 +# +# The upstream/default merging logic is here: +# +# https://github.com/NixOS/nixpkgs/blob/c999f2e369508fca13646ca5a0fb926b3ce0063e/pkgs/os-specific/linux/device-tree/default.nix#L27-L65 +# +# The `nixos-hardware` override is here: +# +# https://github.com/NixOS/nixos-hardware/blob/33a97b5814d36ddd65ad678ad07ce43b1a67f159/raspberry-pi/4/pkgs-overlays.nix#L5 +final: prev: { + deviceTree = + prev.deviceTree + // { + applyOverlays = final.callPackage ({ + lib, + stdenvNoCC, + dtc, + libraspberrypi, + ... + } @ ctx: base: overlays': + stdenvNoCC.mkDerivation { + name = "device-tree-overlays"; + nativeBuildInputs = [dtc libraspberrypi]; + buildCommand = let + overlays = lib.toList overlays'; + in '' + mkdir -p "$out" + cd ${lib.escapeShellArg base} + find . -type f -name '*.dtb' -exec cp -v --no-preserve=mode --target-directory "$out" --parents '{}' '+' + + while read -d $'\0' dtb; do + dtbName="$(basename "$dtb")" + dtbCompat=$(fdtget -t s "$dtb" / compatible 2>/dev/null || true) + + # skip files without `compatible` string + if [[ -z "$dtbCompat" ]]; then + echo "Cannot apply overlays to ''${dtbName} since it lacks a `compatible` string" + continue + fi + + ${lib.concatMapStringsSep "\n" (o: '' + name=${lib.escapeShellArg o.name} + dtboFile=${lib.escapeShellArg o.dtboFile} + filter=${lib.escapeShellArg o.filter} + + overlayCompat="$(fdtget -t s "$dtboFile" / compatible)" + + # skip incompatible and non-matching overlays + if ! { [[ "$dtbCompat" =~ "$overlayCompat" ]] || [[ "$overlayCompat" = brcm,bcm2835 ]]; }; then + echo "Skipping overlay ''${name}: incompatible with ''${dtbName}: wanted ''${dtbCompat} or generic brcm,bcm2835, got ''${overlayCompat}" + elif [[ -n "$filter" ]] && [[ "''${dtb//''${filter}/}" = "$dtb" ]]; then + echo "Skipping overlay ''${name}: filter does not match ''${dtbName}" + else + echo -n "Applying overlay ''${name} to ''${dtbName}... " + dtbIn="''${dtb}.in" + mv -f "$dtb" "$dtbIn" + + # dtmerge requires a .dtbo ext for dtbo files, otherwise it adds it to the given file implicitly + dtboWithExt="''${TMPDIR}/$(basename "$dtboFile")" + dtboWithExt="''${dtboWithExt%.dtbo}.dtbo" + cp -r "$dtboFile" "$dtboWithExt" + + dtmerge "$dtbIn" "$dtb" "$dtboWithExt" + + echo "ok" + rm -f "$dtbIn" "$dtboWithExt" + fi + '') + overlays} + done < <(find "$out" -type f -name '*.dtb' -print0) + ''; + }) {}; + }; +} diff --git a/nix/overlays/uboot-disable-console.nix b/nix/overlays/uboot-disable-console.nix new file mode 100644 index 0000000..89c8112 --- /dev/null +++ b/nix/overlays/uboot-disable-console.nix @@ -0,0 +1,56 @@ +let + # https://raspberrypi.stackexchange.com/questions/116074/how-can-i-disable-the-serial-console-on-distributions-that-use-u-boot + # https://github.com/u-boot/u-boot/blob/eac52e4be4e234d563d6911737ee7ccdc0ada1f1/doc/README.autoboot + # https://github.com/u-boot/u-boot/blob/eac52e4be4e234d563d6911737ee7ccdc0ada1f1/doc/README.silent + # > The config option CONFIG_SILENT_CONSOLE can be used to quiet messages + # > on the console. If the option has been enabled, the output can be + # > silenced by setting the environment variable "silent". + # > + # > - CONFIG_SILENT_CONSOLE_UPDATE_ON_SET + # > When the "silent" variable is changed with env set, the change + # > will take effect immediately. + # > + # > - CONFIG_SILENT_CONSOLE_UPDATE_ON_RELOC + # > Some environments are not available until relocation (e.g. NAND) + # > so this will make the value in the flash env take effect at + # > relocation. + # > + # > The following actions are taken if "silent" is set at boot time: + # > + # > - Until the console devices have been initialized, output has to be + # > suppressed by testing for the flag "GD_FLG_SILENT" in "gd->flags". + # > + # > - When the console devices have been initialized, "stdout" and + # > "stderr" are set to "nulldev", so subsequent messages are + # > suppressed automatically. Make sure to enable "nulldev" by + # > enabling CONFIG_SYS_DEVICE_NULLDEV in your board defconfig file. + # > + # > - When booting a linux kernel, the "bootargs" are fixed up so that + # > the argument "console=" will be in the command line, no matter how + # > it was set in "bootargs" before. If you don't want the linux command + # > line to be affected, define CONFIG_SILENT_U_BOOT_ONLY in your board + # > config file as well, and this part of the feature will be disabled. + enableConfigSilentConsole = p: + p.overrideAttrs (oldAttrs: { + extraConfig = + (p.extraConfig or "") + + '' + CONFIG_BOOTDELAY=-2 + CONFIG_SILENT_CONSOLE=y + CONFIG_SILENT_U_BOOT_ONLY=y + CONFIG_SYS_DEVICE_NULLDEV=y + CONFIG_SILENT_CONSOLE_UPDATE_ON_SET=y + ''; + }); +in + final: prev: + prev.lib.mapAttrs (prev.lib.const enableConfigSilentConsole) (prev.lib.getAttrs [ + "ubootRaspberryPi" + "ubootRaspberryPi2" + "ubootRaspberryPi3_32bit" + "ubootRaspberryPi3_64bit" + "ubootRaspberryPi4_32bit" + "ubootRaspberryPi4_64bit" + "ubootRaspberryPiZero" + ] + prev) diff --git a/nix/packages.nix b/nix/packages.nix new file mode 100644 index 0000000..16710f2 --- /dev/null +++ b/nix/packages.nix @@ -0,0 +1,108 @@ +{ + self, + inputs, + ... +}: { + perSystem = { + config, + pkgs, + system, + ... + }: { + packages = { + default = config.packages.raspberrypi-gateway; + + docs = pkgs.callPackage ({ + nixosOptionsDoc, + lib, + eval, + }: + (nixosOptionsDoc { + options = { + inherit (eval.options.profiles) mightyhat; + inherit (eval.options.services) moteino-gateway; + }; + + # Default is currently "appendix". + documentType = "none"; + + warningsAreErrors = true; + + transformOptions = let + ourPrefix = "${toString self}/"; + moduleSource = "flake.nix"; + link = { + url = "/${moduleSource}"; + name = moduleSource; + }; + in + opt: + opt + // { + visible = opt.visible && (lib.any (lib.hasPrefix ourPrefix) opt.declarations); + declarations = map (decl: + if lib.hasPrefix ourPrefix decl + then link + else decl) + opt.declarations; + }; + }) + .optionsCommonMark) { + eval = self.nixosConfigurations.default.extendModules { + modules = [ + { + nixpkgs.hostPlatform = system; + } + ]; + }; + }; + + raspberrypi-gateway = inputs.dream2nix.lib.evalModules { + packageSets.nixpkgs = pkgs; + modules = let + projectRoot = self; + app = self + "/app.nix"; + in [ + app + { + paths = { + inherit projectRoot; + projectRootFile = "flake.nix"; + package = self; + }; + } + ]; + }; + + avrdude-rpi-autoreset = pkgs.callPackage ({writers}: + writers.makeScriptWriter { + inherit (pkgs.python3.withPackages (p: [p.rpi-gpio])) interpreter; + } "/bin/avrdude-rpi-autoreset" (builtins.readFile "${inputs.avrdude-rpi}/autoreset")) {}; + + # Likely has to be run with `sudo` or another privilege-escalation + # tool. + avrdude-rpi = pkgs.callPackage ({ + avrdude, + strace, + avrdude-rpi-autoreset, + }: + pkgs.writers.writeDashBin "avrdude-rpi" '' + exec ${strace}/bin/strace -o '| ${avrdude-rpi-autoreset}/bin/avrdude-rpi-autoreset' -eioctl ${avrdude}/bin/avrdude "$@" + '') { + inherit (config.packages) avrdude-rpi-autoreset; + }; + + ubootToolsWithEnvtools = pkgs.callPackage ({ubootTools}: + ubootTools.override { + extraMakeFlags = (ubootTools.extraMakeFlags or []) ++ ["envtools"]; + filesToInstall = (ubootTools.filesToInstall or []) ++ ["tools/env/fw_printenv"]; + postInstall = + (ubootTools.postInstall or "") + + '' + ln -sfT $out/bin/fw_printenv $out/bin/fw_setenv + install -D tools/env/fw_env.config $out/share/doc/uboot/tools/env/fw_env.config + ''; + }) {}; + }; + }; +} diff --git a/package.json b/package.json index 7dfe2c8..695e967 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "json5": "~2.2.0", "merge": "^2.1.1", "nconf": "~0.11.2", - "nedb": "https://github.com/LowPowerLab/nedb.git", + "nedb": "git+https://github.com/LowPowerLab/nedb.git", "nodemailer": "^6.6.0", "serialport": "~13.0.0", "socket.io": "~4.8.0", diff --git a/settings.json5 b/settings.json5 index fd75d18..bd5ef36 100644 --- a/settings.json5 +++ b/settings.json5 @@ -76,6 +76,12 @@ editable: false, description: 'the port at which the gateway.js socket app is listening', }, + port: { + value: 8081, + type: 'number', + editable: false, + description: 'the port at which the web app is listening', + }, genNodeIfNoMatch: { value: 'false', description: 'generate a new node even if the data received does not match any metric definition, default = false', diff --git a/www/images/user/.gitignore b/www/images/user/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/www/images/user/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/www/index.html b/www/index.html index e091927..8780c13 100644 --- a/www/index.html +++ b/www/index.html @@ -2285,35 +2285,33 @@

' + (nodeResolveString(node.label||'', node.metrics) || node._id) + ' ' + re $("#changeNodeIcon_OK").click("tap", function(event) { if ($("#nodeIconUpload").val()) { event.preventDefault(); - var file_data = $('#nodeIconUpload').prop('files')[0]; - var form_data = new FormData(); - form_data.append('file', file_data); - $.ajax({ - type: "POST", - url: "upload.php", - enctype: 'multipart/form-data', - dataType: 'json', - cache: false, - contentType: false, - processData: false, - data: form_data, - success: function (data, textStatus, jqXHR) { - data['status']; - if (data['status']=='success') + + var file = $('#nodeIconUpload').prop('files')[0]; + + var types = ['image/jpeg', 'image/png', 'image/gif']; + if (!types.includes(file.type)) { + alert('Invalid file type; only JPG/JPEG/PNG/GIF are allowed'); + return; + } + + if ((file.size || 0) > 500000) { + alert('File too large (>500K)'); + return; + } + + socket.emit('UPLOADIMAGE', file.name, file, function(filePath, err) { + if (err) { + alert('Upload failed: ' + err.message); + } + else { + if (filePath) { - if (data['filePath']) - { - $('#nodeDetailImage').attr('src', 'images/'+data['filePath']); - nodes[selectedNodeId].icon = data['filePath']; - socket.emit('REFRESHICONS'); - $.mobile.navigate('#nodedetails'); - } - else alert('Expected filePath back, got nothing'); + $('#nodeDetailImage').attr('src', 'images/'+filePath); + nodes[selectedNodeId].icon = filePath; + socket.emit('REFRESHICONS'); + $.mobile.navigate('#nodedetails'); } - else alert(data['message']||'Upload failed'); - }, - error: function (data, textStatus, msg) { - alert(msg||'Upload failed'); + else alert('Expected filePath back, got nothing'); } }); } diff --git a/www/upload.php b/www/upload.php deleted file mode 100644 index b29b92b..0000000 --- a/www/upload.php +++ /dev/null @@ -1,77 +0,0 @@ - 500000) { - $response['message'] .= "File too large (>500K) "; - $uploadOk = 0; - } - - // Allow certain file formats - if($imageFileType != "jpg" && $imageFileType != "png" && $imageFileType != "jpeg" && $imageFileType != "gif" ) { - $response['message'] .= "Only JPG/JPEG/PNG/GIF are allowed "; - $uploadOk = 0; - } - - // Check if $uploadOk is set to 0 by an error - if ($uploadOk == 0) { - $response['status'] = 'error'; - $response['message'] .= "Upload FAILED"; - } else { //all ok, try to upload file - if (move_uploaded_file($_FILES[$postFileId]["tmp_name"], $target_file)) { - $response['message'] = "Upload OK"; - $response['filePath'] = $uploadsDir . basename($target_file); - } else { - $response['status'] = 'error'; - $response['message'] .= "Upload FAILED"; - } - } - - echo json_encode($response); -?> \ No newline at end of file