} PartialState
5 | */
6 |
7 | const PERSIST_STATE_KEY_PREFIX = "state_";
8 | /** @type {(keyof State)[]} */
9 | const PERSIST_STATE_STR_KEYS = [
10 | "bgColor",
11 | "textColor",
12 | "fontFamily",
13 | "parity",
14 | "encoding",
15 | ];
16 | /** @type {(keyof State)[]} */
17 | const PERSIST_STATE_NUM_KEYS = ["fontSize", "baudRate", "dataBits", "stopBits"];
18 | /** @type {(keyof State)[]} */
19 | const PERSIST_STATE_OBJ_KEYS = ["lastPortInfo"];
20 | /** @type {(keyof State)[]} */
21 | const PERSIST_STATE_BOOL_KEYS = ["dtrSignal", "rtsSignal", "breakSignal"];
22 |
23 | /** @type {(keyof State)[]} */
24 | const PERSIST_STATE_KEYS = PERSIST_STATE_STR_KEYS.concat(PERSIST_STATE_NUM_KEYS)
25 | .concat(PERSIST_STATE_BOOL_KEYS)
26 | .concat(PERSIST_STATE_OBJ_KEYS);
27 |
28 | /**
29 | * Save State subset to Local Storage
30 | * @type {StateListener}
31 | */
32 | const saveState = (state) => {
33 | for (const [k, v] of Object.entries(state)) {
34 | if (!PERSIST_STATE_KEYS.includes(k)) continue;
35 | const lsKey = `${PERSIST_STATE_KEY_PREFIX}${k}`;
36 | if (PERSIST_STATE_OBJ_KEYS.includes(k)) {
37 | localStorage.setItem(lsKey, JSON.stringify(v));
38 | continue;
39 | }
40 | localStorage.setItem(lsKey, v);
41 | }
42 | };
43 |
44 | /**
45 | * Load state subset from Local Storage
46 | * @returns {PartialState}
47 | */
48 | const loadState = () => {
49 | /** @type {PartialState} */
50 | const state = {};
51 | for (const k of PERSIST_STATE_KEYS) {
52 | const lsKey = `${PERSIST_STATE_KEY_PREFIX}${k}`;
53 | const value = localStorage.getItem(lsKey);
54 | if (value === null) continue;
55 | if (PERSIST_STATE_STR_KEYS.includes(k)) {
56 | state[k] = value;
57 | }
58 | if (PERSIST_STATE_NUM_KEYS.includes(k)) {
59 | state[k] = +value;
60 | }
61 | if (PERSIST_STATE_BOOL_KEYS.includes(k)) {
62 | state[k] = value === "true" ? true : value === "false" ? false : null;
63 | }
64 | if (PERSIST_STATE_OBJ_KEYS.includes(k)) {
65 | try {
66 | state[k] = JSON.parse(value || "{}");
67 | } catch {
68 | state[k] = {};
69 | }
70 | }
71 | }
72 | return state;
73 | };
74 |
75 | export { loadState, saveState };
76 |
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | // vite.config.js
2 | /* eslint-env node */
3 | import { fileURLToPath } from "node:url";
4 | import { resolve, dirname } from "node:path";
5 | import { defineConfig } from "vite";
6 | import eslint from "vite-plugin-eslint";
7 | import legacy from "@vitejs/plugin-legacy"; // eslint-disable-line import/no-unresolved
8 | import { VitePWA } from "vite-plugin-pwa";
9 |
10 | const __filename = fileURLToPath(import.meta.url);
11 | const __dirname = dirname(__filename);
12 |
13 | export default defineConfig({
14 | publicDir: "public",
15 | root: "./",
16 | build: {
17 | outDir: "dist",
18 | sourcemap: true,
19 | rollupOptions: {
20 | input: {
21 | main: resolve(__dirname, "index.html"),
22 | },
23 | },
24 | },
25 | plugins: [
26 | eslint({
27 | cache: false,
28 | fix: true,
29 | }),
30 | legacy({}),
31 | VitePWA({
32 | injectRegister: "inline",
33 | workbox: {
34 | globPatterns: ["**/*.{js,css,html,png,svg}"],
35 | cleanupOutdatedCaches: false,
36 | },
37 | includeAssets: ["icons/*"],
38 | manifest: {
39 | name: "Serial Projector",
40 | short_name: "SerialProjector",
41 | description:
42 | "A simple web application that shows last line of text got from serial port with a big font.",
43 | categories: ["developer", "developer tools", "eductaion", "utilities"],
44 | theme_color: "#f6871f",
45 | icons: [
46 | {
47 | src: "icons/icon-192.png",
48 | sizes: "192x192",
49 | type: "image/png",
50 | },
51 | {
52 | src: "icons/icon-512.png",
53 | sizes: "512x512",
54 | type: "image/png",
55 | },
56 | {
57 | src: "icons/maskable-icon-512.png",
58 | sizes: "512x512",
59 | type: "image/png",
60 | purpose: "maskable",
61 | },
62 | ],
63 | screenshots: [
64 | {
65 | src: "screenshots/home.png",
66 | sizes: "1920x1080",
67 | type: "image/png",
68 | label: "Home screen",
69 | },
70 | {
71 | src: "screenshots/home-wide.webp",
72 | sizes: "1280x720",
73 | type: "image/webp",
74 | form_factor: "wide",
75 | label: "Wide home screen",
76 | },
77 | ],
78 | },
79 | }),
80 | ],
81 | });
82 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Serial Projector
2 |
3 | A simple web application that shows last line of text got from serial port
4 | with a big font.
5 |
6 | Use Serial Projector with Arduino and other development boards to show coming
7 | data with style and formatting of your choice. Go full screen to make your
8 | 42″ TV a blazing output peripheral.
9 |
10 | ## Installation
11 |
12 | Open [website](https://projector.amperka.ru) in Chromium-based browser and use.
13 | Optionally, install as application.
14 |
15 | ## Usage
16 |
17 | Just send text to serial. Once Serial Projector will see a carriage return
18 | character (ASCII 13, or `\r`) and a newline character (ASCII 10, or `\n`) the
19 | text on sceen will be updated.
20 |
21 | You can send UTF-8 unicode and HTML.
22 |
23 | ```cpp
24 | void setup()
25 | {
26 | Serial.begin(9600);
27 | }
28 |
29 | void loop()
30 | {
31 | int t = analogRead(A0) / 100;
32 | Serial.print("Температура / Temperature
");
33 | Serial.print(t);
34 | Serial.println(" ℃");
35 | }
36 | ```
37 |
38 | Use buttons at the bottom right corner to adjust application settings.
39 |
40 | ## Authors and License
41 |
42 | Written by Victor Nakoryakov, Sergei Korolev © Amperka LLC.
43 |
44 | This software may be modified and distributed under the terms
45 | of the MIT license. See the LICENSE file for details.
46 |
47 | [](https://deepwiki.com/amperka/serial-projector) [](https://zread.ai/amperka/serial-projector)
48 |
--------------------------------------------------------------------------------
/src/state.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @typedef {object} State
3 | * @property {string} bgColor - Background color
4 | * @property {string} textColor - Text color
5 | * @property {string} fontFamily - Font family
6 | * @property {number} fontSize - Font size
7 | * @property {number} baudRate - Baud rate
8 | * @property {number} dataBits - Data bits
9 | * @property {string} parity - Parity
10 | * @property {number} stopBits - Stop bits
11 | * @property {string} encoding - Text encoding
12 | * @property {boolean | null} dtrSignal - Send DTR signal
13 | * @property {boolean | null} rtsSignal - Send RTS signal
14 | * @property {boolean | null} breakSignal - Send break signal
15 | * @property {boolean} isFullscreen - Fullscreen mode state
16 | * @property {boolean} isSettingsModalOpened - Settings modal state
17 | * @property {boolean} isStyleModalOpened - Style modal state
18 | * @property {boolean} isAboutModalOpened - About modal state
19 | * @property {string} message - Message text
20 | * @property {string} status - Status text
21 | * @property {SerialPortInfo} lastPortInfo - Last connected port's info
22 | */
23 |
24 | /**
25 | * @typedef {(state: State) => Promise<*> | *} StateListener
26 | */
27 |
28 | /**
29 | * State container
30 | */
31 | export class StateContainer {
32 | #state;
33 | #listeners;
34 |
35 | /**
36 | * @param {State} initialState
37 | * @param {Set} [listeners]
38 | */
39 | constructor(initialState, listeners) {
40 | this.#state = initialState;
41 | this.#listeners = listeners || new Set();
42 | }
43 |
44 | /**
45 | * Returns a shallow copy of state
46 | * @returns {State}
47 | */
48 | getState() {
49 | return { ...this.#state };
50 | }
51 |
52 | /**
53 | * Update state and notify all subscribed listeners
54 | * @param {Partial} partialState
55 | */
56 | setState(partialState) {
57 | /** @type {State} */
58 | const updatedState = { ...this.#state };
59 | Object.keys(partialState).forEach((key) => {
60 | if (typeof partialState[key] === "object" && partialState[key] !== null) {
61 | updatedState[key] = { ...this.#state[key], ...partialState[key] };
62 | } else {
63 | updatedState[key] = partialState[key];
64 | }
65 | });
66 | this.#state = updatedState;
67 | this.#notify();
68 | console.debug("[StateContainer] setState() - new state:", this.#state);
69 | return this;
70 | }
71 |
72 | /**
73 | * Subscribe to the state changes
74 | * @param {StateListener} listener
75 | * @returns {() => bool}
76 | */
77 | subscribe(listener) {
78 | this.#listeners.add(listener);
79 | return () => this.unsubscribe(listener);
80 | }
81 |
82 | /**
83 | * Unsubscribe listener
84 | * @param {StateListener} listener
85 | * @returns {bool}
86 | */
87 | unsubscribe(listener) {
88 | return this.#listeners.delete(listener);
89 | }
90 |
91 | /**
92 | * Notify all listeners
93 | */
94 | #notify() {
95 | this.#listeners.forEach((listener) => listener(this.getState()));
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/AGENTS.md:
--------------------------------------------------------------------------------
1 | # AGENTS.md
2 |
3 | Guidelines for AI agents working with this project.
4 |
5 | ---
6 |
7 | ## 1. General Principles
8 |
9 | - **Minimalism first**: keep the codebase small, clean, and understandable.
10 | - **No external dependencies in production**: all production code must be plain HTML, CSS, and JavaScript.
11 | - **Static only**: project runs as a static website (with PWA support). No backend code.
12 | - **Modern standards**: use modern JavaScript (ES6+), semantic HTML, and clean CSS.
13 | - **Performance**: target initial load time ≤1 second on average hardware.
14 |
15 | ---
16 |
17 | ## 2. Project Structure
18 |
19 | ```
20 | index.html
21 | src/style.css
22 | src/main.js
23 | src/*.js
24 | public/icons/
25 | public/screenshots/
26 | public/robots.txt
27 | tests/
28 | package.json (dev only)
29 | ```
30 |
31 | ---
32 |
33 | ## 3. Development Rules
34 |
35 | - Do not add new production dependencies.
36 | - For development, only use tools declared in `package.json` (`eslint`, `prettier`, `vitest`).
37 | - Build via `vite`
38 | - Code must run directly in the browser.
39 |
40 | ---
41 |
42 | ## 4. Coding Standards
43 |
44 | - Follow ESLint rules defined in the project.
45 | - Format code with Prettier.
46 | - Use clear, descriptive names for functions, variables, and classes.
47 | - Avoid over-engineering: minimal code to achieve requirements.
48 | - Keep functions small and focused.
49 |
50 | ---
51 |
52 | ## 5. Testing
53 |
54 | - All JavaScript code must be unit tested with Vitest.
55 | - Target **≥90% coverage**.
56 | - Write tests alongside features (do not postpone).
57 | - Keep tests readable and focused on behavior.
58 |
59 | ---
60 |
61 | ## 6. CI/CD
62 |
63 | - GitHub Actions workflow must run:
64 | - ESLint
65 | - Prettier format check
66 | - Vitest unit tests with coverage
67 | - CI must fail if linting, formatting, or tests fail.
68 |
69 | ---
70 |
71 | ## 7. PWA Requirements
72 |
73 | - Must include manifest.json with icons (`192x192`, `512x512`, etc).
74 | - Must include service worker with:
75 | - Offline support.
76 | - Update prompt: _“Update available — click to refresh.”_
77 | - Ensure small static footprint (fast cache load).
78 |
79 | ---
80 |
81 | ## 8. Security
82 |
83 | - All incoming HTML from Serial must be sanitized:
84 | - Strip `
241 |