diff --git a/0PLAN.org b/0PLAN.org new file mode 100644 index 0000000..e3c583d --- /dev/null +++ b/0PLAN.org @@ -0,0 +1,90 @@ +#+title: Frontend + +* Plan + +** 0.3 + +- [ ] versioned schema support + - [ ] barschema table + - [ ] function to check + - [ ] function to set + - [ ] initial creation of tables at version 1 assuming OK if tables exist +- [ ] users import from hackerbase + +** 0.4 + +- [ ] add inventory check table +- [ ] add inventory item check table +- [ ] create new check +- [ ] check item +- [ ] close check +- [ ] inventory check component + +** Later + +- [ ] add static token support + - [ ] hardwired token for the React.JS app? +- [ ] authentication + - [ ] login/logout + - [ ] allow API calls only with login token + +* Finished + +** 0.1 + +- [X] statically compiled binary HTTP server +- [X] include directory tree contents +- [X] handle frontend with mime type +- [X] move app to / again +- [X] load certificates +- [X] deployment preparations + - [X] crosscompilation - no, compile in qemu or on separate host + - [X] connect to postgres + - [X] use qr-scanner - no, qr-barcode-scanner is needed + - [X] rsync + build in qemu or host without react (filters or something) +- [X] API infrastructure + - [X] handle API calls + - [X] API registry syntax + - [X] lookup barcode in DB + - [X] separate module brmbar-data for queries + - [X] separate module api-servlets + +** 0.2 + +- [X] use localStorage to store user + - [X] add barcode scanner to get the user + - [X] allow editing + - [X] add UserSelect component +- [X] integrate script for starting build qemu system +- [X] add org file to the repository after cleanup + +* Qemu + +#+BEGIN_SRC +wget img zip # 2019-04-08-raspbian-stretch.zip +unzip 2019-04-08-raspbian-stretch.zip +sudo mkdir /mnt/image +sudo mount -o loop,offset=4194304 2019-04-08-raspbian-stretch.img /mnt/image/ +# In qemu-system-armhf terminal! +cp kernel, DT +umount /mnt/image +resize img +#+END_SRC + +sources.list - archive debian (remove rpi), archive raspberry +https://archive.raspberrypi.org/debian/ +https://archive.debian.org/debian/ +apt-get --no + +* React - Vite + +#+BEGIN_SRC sh + npm create vite@latest + # "frontend" + # React + # Javascript + cd frontend + npm install + npm run dev + npm run build +#+END_SRC diff --git a/backend/brminv.scm b/backend/brminv.scm index 808bf70..48c5c28 100644 --- a/backend/brminv.scm +++ b/backend/brminv.scm @@ -47,6 +47,7 @@ (define -db-user- (make-parameter #f)) (define -db-name- (make-parameter #f)) (define -db-pass- (make-parameter #f)) +(define -db-enabled- (make-parameter #t)) (command-line print-help @@ -82,6 +83,8 @@ (-db-user- dbuser)) (-dp (dbpass) "Database password" (-db-pass- dbpass)) + (-dd () "Disable database" + (-db-enabled- #f)) ) (define ssl? (and (-certificate-) (-key-) #t)) @@ -120,7 +123,8 @@ (print "current user id: " (current-user-id)) (print "current effective user id: " (current-effective-user-id)) -(bar-db-init! (-db-name-) (-db-host-) (-db-user-) (-db-pass-)) +(when (-db-enabled-) + (bar-db-init! (-db-name-) (-db-host-) (-db-user-) (-db-pass-))) (define (handle-api-calls) (define plst (cdr (uri-path (request-uri (current-request))))) diff --git a/backend/texts.scm b/backend/texts.scm index 643e50e..de6ac11 100644 --- a/backend/texts.scm +++ b/backend/texts.scm @@ -38,7 +38,7 @@ (chicken format)) ;; Short banner - (define banner-line "BrmInv 0.1 (c) 2023-2025 Brmlab, z.s.") + (define banner-line "BrmInv 0.2 (c) 2023-2025 Brmlab, z.s.") ;; The license of this file and of the whole suite. (define license "ISC License diff --git a/frontend/src/App.css b/frontend/src/App.css index 467c548..6638b9a 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,6 +1,6 @@ .App { background-color: var(--background-color); color: var(--primary-text-color) !important; - min-height: 100vh; - width: 100%; + height: 100vh; + width: 10vh; } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 2f4a741..0f3b48f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,13 +1,88 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import BarcodeScannerComponent from 'react-qr-barcode-scanner'; -import { Container, Row, Col } from 'react-bootstrap'; +import Container from 'react-bootstrap/Container'; +import Row from 'react-bootstrap/Row'; +import Col from 'react-bootstrap/Col'; import Table from 'react-bootstrap/Table'; import Alert from 'react-bootstrap/Alert'; +import Form from 'react-bootstrap/Form'; +import Button from 'react-bootstrap/Button'; +import Card from 'react-bootstrap/Card'; import './App.css'; import 'bootstrap/dist/css/bootstrap.min.css'; function App() { + // Ensure we have persistent (informal) user information + const [user, setUser] = useState(""); + useEffect(() => { + const user = localStorage.getItem('user'); + if (user) { + setUser(user); + } + }, []); + useEffect(() => { + localStorage.setItem('user', user); + }, [user]); + + // If no user, must be scanned/set otherwise + return ( + <> + {user === "" ? + + : + } + + ); +} + +function NoUserView({setUser}) { + const [preUser, setPreUser] = useState(""); + return ( + + + BrmInv Anonymous + + User name: + setPreUser(v.target.value)} /> + Enter a user name or scan your barcode. + + + { + if (result) { + setPreUser(result.text); + }}} + delay={1500} + /> + + + + + + + ); +} + +function UserView({user, setUser}) { + return ( + + + {user} + + + + + + + ); +} + +function BarItemScanner() { const [reqAccount, setReqAccount] = useState(''); const [actAccount, setActAccount] = useState(''); const [balance, setBalance] = useState(-1); @@ -37,7 +112,7 @@ function App() { }; return ( - + <> {statusMsg} - + ); } diff --git a/install-eggs-arm.sh b/install-eggs-arm.sh deleted file mode 100644 index fbd5da6..0000000 --- a/install-eggs-arm.sh +++ /dev/null @@ -1,64 +0,0 @@ -#!/bin/sh -# -# install-eggs.sh -# -# Local installer of CHICKEN eggs required for building. -# -# ISC License -# -# Copyright 2023 Brmlab, z.s. -# Dominik Pantůček -# -# Permission to use, copy, modify, and/or distribute this software -# for any purpose with or without fee is hereby granted, provided -# that the above copyright notice and this permission notice appear -# in all copies. -# -# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL -# WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED -# WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE -# AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR -# CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS -# OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, -# NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN -# CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -# - -# Source root directory -owd=$(pwd) -cd $(dirname "$0") -SRCDIR=$(pwd) -cd "$owd" - -# Make temporary prefix directory (eggs shared throwaway files) -TMPDIR=$(mktemp -d) - -# Installs given egg locally -chicken_install() { - echo "Installing $1 ..." - # CHICKEN_INSTALL_PREFIX="$TMPDIR" \ - # CHICKEN_REPOSITORY_PATH="$SRCDIR/eggs-arm":`./cross-chicken-arm/bin/arm-chicken-install -repository` \ - # CHICKEN_INSTALL_REPOSITORY="$SRCDIR/eggs-arm" \ - # ./cross-chicken-arm/bin/arm-chicken-install "$1" 2>&1 | \ - # sed -u 's/^/ /' -# CHICKEN_INSTALL_PREFIX="$TMPDIR" \ - ./cross-chicken-arm/bin/arm-chicken-install "$1" 2>&1 | \ - sed -u 's/^/ /' -} - -# Removes throwaway files -chicken_cleanup() { - echo "Cleaning up ..." - rm -fr ${TMPDIR} -} - -# Always cleanup -trap chicken_cleanup INT QUIT - -# Install required eggs -chicken_install spiffy -chicken_install openssl -chicken_install postgresql - -# Normal termination cleanup -chicken_cleanup diff --git a/install-eggs.sh b/install-eggs.sh index 2bcb95c..08c098c 100644 --- a/install-eggs.sh +++ b/install-eggs.sh @@ -57,6 +57,7 @@ chicken_install openssl chicken_install spiffy chicken_install postgresql chicken_install json +chicken_install posix-groups # Normal termination cleanup chicken_cleanup diff --git a/schema/0000-init.sql b/schema/0000-init.sql new file mode 100644 index 0000000..52afd7e --- /dev/null +++ b/schema/0000-init.sql @@ -0,0 +1,313 @@ +-- +-- 0000-init.sql +-- +-- Initial SQL schema construction as of 2025-04-20 (or so) +-- +-- ISC License +-- +-- Copyright 2023-2025 Brmlab, z.s. +-- Dominik Pantůček +-- +-- Permission to use, copy, modify, and/or distribute this software +-- for any purpose with or without fee is hereby granted, provided +-- that the above copyright notice and this permission notice appear +-- in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +-- WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED +-- WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE +-- AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +-- CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +-- OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, +-- NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +-- + +-- To require fully-qualified names +SELECT pg_catalog.set_config('search_path', '', false); + +-- Privileged schema with protected data +CREATE SCHEMA IF NOT EXISTS brmbar_privileged; + +-- Initial versioning +CREATE TABLE IF NOT EXISTS brmbar_privileged.brmbar_schema( + ver INTEGER NOT NULL +); + +-- ---------------------------------------------------------------- +-- Legacy Schema Initialization +-- ---------------------------------------------------------------- + +DO $$ +DECLARE v INTEGER; +BEGIN + SELECT ver FROM brmbar_privileged.brmbar_schema INTO v; + IF v IS NULL THEN + -- -------------------------------- + -- Legacy Types + + SELECT COUNT(*) INTO v + FROM pg_catalog.pg_type typ + INNER JOIN pg_catalog.pg_namespace nsp + ON nsp.oid = typ.typnamespace + WHERE nsp.nspname = 'public' + AND typ.typname='exchange_rate_direction'; + IF v=0 THEN + RAISE NOTICE 'Creating type exchange_rate_direction'; + CREATE TYPE public.exchange_rate_direction + AS ENUM ('source_to_target', 'target_to_source'); + ELSE + RAISE NOTICE 'Type exchange_rate_direction already exists'; + END IF; + + SELECT COUNT(*) INTO v + FROM pg_catalog.pg_type typ + INNER JOIN pg_catalog.pg_namespace nsp + ON nsp.oid = typ.typnamespace + WHERE nsp.nspname = 'public' + AND typ.typname='account_type'; + IF v=0 THEN + RAISE NOTICE 'Creating type account_type'; + CREATE TYPE public.account_type + AS ENUM ('cash', 'debt', 'inventory', 'income', 'expense', + 'starting_balance', 'ending_balance'); + ELSE + RAISE NOTICE 'Type account_type already exists'; + END IF; + + SELECT COUNT(*) INTO v + FROM pg_catalog.pg_type typ + INNER JOIN pg_catalog.pg_namespace nsp + ON nsp.oid = typ.typnamespace + WHERE nsp.nspname = 'public' + AND typ.typname='transaction_split_side'; + IF v=0 THEN + RAISE NOTICE 'Creating type transaction_split_side'; + CREATE TYPE public.transaction_split_side + AS ENUM ('credit', 'debit'); + ELSE + RAISE NOTICE 'Type transaction_split_side already exists'; + END IF; + + -- -------------------------------- + -- Currencies sequence, table and potential initial data + + CREATE SEQUENCE IF NOT EXISTS public.currencies_id_seq + START WITH 2 INCREMENT BY 1; + CREATE TABLE IF NOT EXISTS public.currencies ( + id INTEGER PRIMARY KEY NOT NULL DEFAULT NEXTVAL('public.currencies_id_seq'::regclass), + name VARCHAR(128) NOT NULL, + UNIQUE(name) + ); + INSERT INTO public.currencies (id, name) VALUES (1, 'Kč') + ON CONFLICT DO NOTHING; + + -- -------------------------------- + -- Exchange rates table - no initial data required + + CREATE TABLE IF NOT EXISTS public.exchange_rates ( + valid_since TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW() NOT NULL, + + target INTEGER NOT NULL, + FOREIGN KEY (target) REFERENCES public.currencies (id), + + source INTEGER NOT NULL, + FOREIGN KEY (source) REFERENCES public.currencies (id), + + rate DECIMAL(12,2) NOT NULL, + rate_dir public.exchange_rate_direction NOT NULL + ); + + -- -------------------------------- + -- Accounts sequence and table and 4 initial accounts + + CREATE SEQUENCE IF NOT EXISTS public.accounts_id_seq + START WITH 2 INCREMENT BY 1; + CREATE TABLE IF NOT EXISTS public.accounts ( + id INTEGER PRIMARY KEY NOT NULL DEFAULT NEXTVAL('public.accounts_id_seq'::regclass), + + name VARCHAR(128) NOT NULL, + UNIQUE (name), + + currency INTEGER NOT NULL, + FOREIGN KEY (currency) REFERENCES public.currencies (id), + + acctype public.account_type NOT NULL, + + active BOOLEAN NOT NULL DEFAULT TRUE + ); + INSERT INTO public.accounts (id, name, currency, acctype) + VALUES (1, 'BrmBar Cash', (SELECT id FROM public.currencies WHERE name='Kč'), 'cash') + ON CONFLICT DO NOTHING; + INSERT INTO public.accounts (name, currency, acctype) + VALUES ('BrmBar Profits', (SELECT id FROM public.currencies WHERE name='Kč'), 'income') + ON CONFLICT DO NOTHING; + INSERT INTO public.accounts (name, currency, acctype) + VALUES ('BrmBar Excess', (SELECT id FROM public.currencies WHERE name='Kč'), 'income') + ON CONFLICT DO NOTHING; + INSERT INTO public.accounts (name, currency, acctype) + VALUES ('BrmBar Deficit', (SELECT id FROM public.currencies WHERE name='Kč'), 'expense') + ON CONFLICT DO NOTHING; + + -- -------------------------------- + -- Barcodes + + CREATE TABLE IF NOT EXISTS public.barcodes ( + barcode VARCHAR(128) PRIMARY KEY NOT NULL, + + account INTEGER NOT NULL, + FOREIGN KEY (account) REFERENCES public.accounts (id) + ); + INSERT INTO public.barcodes (barcode, account) + VALUES ('_cash_', (SELECT id FROM public.accounts WHERE acctype = 'cash')) + ON CONFLICT DO NOTHING; + + -- -------------------------------- + -- Transactions + + CREATE SEQUENCE IF NOT EXISTS public.transactions_id_seq + START WITH 1 INCREMENT BY 1; + CREATE TABLE IF NOT EXISTS public.transactions ( + id INTEGER PRIMARY KEY NOT NULL DEFAULT NEXTVAL('public.transactions_id_seq'::regclass), + time TIMESTAMP DEFAULT NOW() NOT NULL, + + responsible INTEGER, + FOREIGN KEY (responsible) REFERENCES public.accounts (id), + + description TEXT + ); + + -- -------------------------------- + -- Transaction splits + + CREATE SEQUENCE IF NOT EXISTS public.transaction_splits_id_seq + START WITH 1 INCREMENT BY 1; + CREATE TABLE IF NOT EXISTS public.transaction_splits ( + id INTEGER PRIMARY KEY NOT NULL DEFAULT NEXTVAL('public.transaction_splits_id_seq'::regclass), + + transaction INTEGER NOT NULL, + FOREIGN KEY (transaction) REFERENCES public.transactions (id), + + side public.transaction_split_side NOT NULL, + + account INTEGER NOT NULL, + FOREIGN KEY (account) REFERENCES public.accounts (id), + amount DECIMAL(12,2) NOT NULL, + + memo TEXT + ); + + -- -------------------------------- + -- Account balances view + + CREATE OR REPLACE VIEW public.account_balances AS + SELECT ts.account AS id, + accounts.name, + accounts.acctype, + - sum( + CASE + WHEN ts.side = 'credit'::public.transaction_split_side THEN - ts.amount + ELSE ts.amount + END) AS crbalance + FROM public.transaction_splits ts + LEFT JOIN public.accounts ON accounts.id = ts.account + GROUP BY ts.account, accounts.name, accounts.acctype + ORDER BY (- sum( + CASE + WHEN ts.side = 'credit'::public.transaction_split_side THEN - ts.amount + ELSE ts.amount + END)); + + -- -------------------------------- + -- Transaction nice splits view + + CREATE OR REPLACE VIEW public.transaction_nicesplits AS + SELECT ts.id, + ts.transaction, + ts.account, + CASE + WHEN ts.side = 'credit'::public.transaction_split_side THEN - ts.amount + ELSE ts.amount + END AS amount, + a.currency, + ts.memo + FROM public.transaction_splits ts + LEFT JOIN public.accounts a ON a.id = ts.account + ORDER BY ts.id; + + -- -------------------------------- + -- Transaction cash sums view + + CREATE OR REPLACE VIEW public.transaction_cashsums AS + SELECT t.id, + t."time", + sum(credit.credit_cash) AS cash_credit, + sum(debit.debit_cash) AS cash_debit, + a.name AS responsible, + t.description + FROM public.transactions t + LEFT JOIN ( SELECT cts.amount AS credit_cash, + cts.transaction AS cts_t + FROM public.transaction_nicesplits cts + LEFT JOIN public.accounts a_1 ON a_1.id = cts.account OR a_1.id = cts.account + WHERE a_1.currency = (( SELECT accounts.currency + FROM public.accounts + WHERE accounts.name::text = 'BrmBar Cash'::text)) + AND (a_1.acctype = ANY (ARRAY['cash'::public.account_type, 'debt'::public.account_type])) + AND cts.amount < 0::numeric) credit ON credit.cts_t = t.id + LEFT JOIN ( SELECT dts.amount AS debit_cash, + dts.transaction AS dts_t + FROM public.transaction_nicesplits dts + LEFT JOIN public.accounts a_1 ON a_1.id = dts.account OR a_1.id = dts.account + WHERE a_1.currency = (( SELECT accounts.currency + FROM public.accounts + WHERE accounts.name::text = 'BrmBar Cash'::text)) + AND (a_1.acctype = ANY (ARRAY['cash'::public.account_type, 'debt'::public.account_type])) + AND dts.amount > 0::numeric) debit ON debit.dts_t = t.id + LEFT JOIN public.accounts a ON a.id = t.responsible + GROUP BY t.id, a.name + ORDER BY t.id DESC; + + -- -------------------------------- + -- Function to check schema version (used in migrations) + + CREATE OR REPLACE FUNCTION brmbar_privileged.has_exact_schema_version( + IN i_ver INTEGER + ) RETURNS INTEGER + VOLATILE NOT LEAKPROOF LANGUAGE plpgsql AS $x$ + DECLARE + v_ver INTEGER; + BEGIN + SELECT ver INTO v_ver FROM brmbar_privileged.brmbar_schema; + IF v_ver is NULL THEN + RETURN false; + ELSE + RETURN v_ver = i_ver; + END IF; + END; + $x$; + + -- -------------------------------- + -- + + CREATE OR REPLACE FUNCTION brmbar_privileged.upgrade_schema_version_to( + IN i_ver INTEGER + ) RETURNS INTEGER + VOLATILE NOT LEAKPROOF LANGUAGE plpgsql AS $x$ + DECLARE + v_ver INTEGER; + BEGIN + SELECT ver FROM brmbar_privileged.brmbar_schema INTO v_ver; + IF v_ver=(i_ver-1) THEN + UPDATE brmbar_privileged.brmbar_schema SET ver = i_ver; + ELSE + RAISE EXCEPTION 'Invalid brmbar schema version transition (% -> %)', v_ver, i_ver; + END IF; + END; + $x$; + + -- Initialize version 1 + INSERT INTO brmbar_privileged.brmbar_schema(ver) VALUES(1); + END IF; +END; +$$; diff --git a/tools/build-in-qemu.sh b/tools/build-in-qemu.sh index a14913f..ebd2093 100644 --- a/tools/build-in-qemu.sh +++ b/tools/build-in-qemu.sh @@ -1,4 +1,28 @@ #!/bin/sh +# +# build-in-qemu.sh +# +# Expects running armhf qemu system, builds the binary inside. +# +# ISC License +# +# Copyright 2025 Brmlab, z.s. +# Dominik Pantůček +# +# Permission to use, copy, modify, and/or distribute this software +# for any purpose with or without fee is hereby granted, provided +# that the above copyright notice and this permission notice appear +# in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +# WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE +# AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +# CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +# OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, +# NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +# CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# if [ -z "$1" ] ; then echo "Usage: $0 password" diff --git a/tools/run-build-qemu-system.sh b/tools/run-build-qemu-system.sh new file mode 100644 index 0000000..c76d8b8 --- /dev/null +++ b/tools/run-build-qemu-system.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# +# run-build-qemu-system.sh +# +# Runs the emulated armhf system for building the application. +# +# ISC License +# +# Copyright 2025 Brmlab, z.s. +# Dominik Pantůček +# +# Permission to use, copy, modify, and/or distribute this software +# for any purpose with or without fee is hereby granted, provided +# that the above copyright notice and this permission notice appear +# in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +# WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE +# AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR +# CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +# OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, +# NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +# CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +# + +qemu-system-armhf \ + -machine raspi2b \ + -nographic \ + -dtb bcm2710-rpi-3-b-plus.dtb \ + -m 1G \ + -smp 4 \ + -kernel kernel7.img \ + -sd 2019-04-08-raspbian-stretch.img \ + -append "rw earlyprintk loglevel=8 console=ttyAMA0,115200 root=/dev/mmcblk0p2 rootdelay=1 dwc_otg.fiq_fsm_enable=0" \ + -usb \ + -device usb-net,netdev=net0 \ + -netdev user,id=net0,hostfwd=tcp::2222-:22